feat: initial Silent Send browser extension
Chrome Manifest V3 extension that intercepts personal data and substitutes it with user-defined replacements before sending to Claude.ai. Hooks fetch() in the page's main world to catch API requests, with bidirectional substitution (real→fake on send, fake→real on display via reveal mode). Includes popup UI with mapping management, live test/diff view, activity log with badge count, options page with import/export, and Shadow DOM traversal for Claude.ai compatibility. https://claude.ai/code/session_01Dvgwe7XMoSxnWXkih8p1Cw
This commit is contained in:
@@ -0,0 +1,455 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 400px;
|
||||
min-height: 300px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 13px;
|
||||
color: #1a1a1a;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #10b981;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.status-dot.disabled {
|
||||
background: #6b7280;
|
||||
}
|
||||
|
||||
.status-dot.off-site {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
/* Toggle */
|
||||
.toggle {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.toggle input { display: none; }
|
||||
|
||||
.toggle-slider {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #d1d5db;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.toggle-slider::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
left: 2px;
|
||||
bottom: 2px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
transition: 0.2s;
|
||||
}
|
||||
|
||||
.toggle input:checked + .toggle-slider {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.toggle input:checked + .toggle-slider::before {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
/* Button styles */
|
||||
.btn-icon {
|
||||
background: none;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
background: #f3f4f6;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
.btn-icon.active {
|
||||
background: #eff6ff;
|
||||
border-color: #3b82f6;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #111;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #333;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn-text:hover {
|
||||
color: #111;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.tab {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #6b7280;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.tab:hover { color: #111; }
|
||||
|
||||
.tab.active {
|
||||
color: #111;
|
||||
border-bottom-color: #111;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
padding: 12px 16px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 350px;
|
||||
}
|
||||
|
||||
.tab-content.active { display: block; }
|
||||
|
||||
/* Inputs */
|
||||
.input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.input-row.secondary {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: #111;
|
||||
}
|
||||
|
||||
.select {
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
color: #9ca3af;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Mapping list */
|
||||
.add-mapping {
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.mapping-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mapping-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
background: #f9fafb;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.mapping-item:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.mapping-values {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mapping-real {
|
||||
font-weight: 500;
|
||||
color: #dc2626;
|
||||
font-size: 12px;
|
||||
text-decoration: line-through;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.mapping-sub {
|
||||
font-weight: 500;
|
||||
color: #059669;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mapping-category {
|
||||
font-size: 10px;
|
||||
color: #9ca3af;
|
||||
background: #f3f4f6;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.mapping-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.mapping-actions button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.mapping-actions button:hover {
|
||||
background: #e5e7eb;
|
||||
color: #111;
|
||||
}
|
||||
|
||||
/* Activity */
|
||||
.activity-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
background: #f3f4f6;
|
||||
padding: 3px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.activity-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 6px;
|
||||
background: #f9fafb;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.activity-time {
|
||||
color: #9ca3af;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.activity-detail {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.activity-original {
|
||||
color: #dc2626;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.activity-replaced {
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
/* Test tab */
|
||||
.help-text {
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.textarea:focus {
|
||||
border-color: #111;
|
||||
}
|
||||
|
||||
.diff-view {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background: #f9fafb;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.diff-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
margin-bottom: 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.diff-output {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
min-height: 20px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.diff-output .sub-highlight {
|
||||
background: #dcfce7;
|
||||
color: #059669;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.diff-stats {
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
padding: 24px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.footer {
|
||||
padding: 8px 16px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
font-size: 11px;
|
||||
color: #6b7280;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer a:hover {
|
||||
color: #111;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=400">
|
||||
<link rel="stylesheet" href="popup.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
<h1 class="title">Silent Send</h1>
|
||||
<span class="status-dot" id="statusDot"></span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<button class="btn-icon" id="btnReveal" title="Reveal Mode - show real data">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M8 3C4.5 3 1.7 5.1 0.5 8c1.2 2.9 4 5 7.5 5s6.3-2.1 7.5-5c-1.2-2.9-4-5-7.5-5z" stroke="currentColor" stroke-width="1.5" fill="none"/>
|
||||
<circle cx="8" cy="8" r="2.5" stroke="currentColor" stroke-width="1.5" fill="none"/>
|
||||
</svg>
|
||||
</button>
|
||||
<label class="toggle" title="Enable/disable Silent Send">
|
||||
<input type="checkbox" id="enableToggle" checked>
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Tab Bar -->
|
||||
<nav class="tabs">
|
||||
<button class="tab active" data-tab="mappings">Mappings</button>
|
||||
<button class="tab" data-tab="activity">Activity</button>
|
||||
<button class="tab" data-tab="test">Test</button>
|
||||
</nav>
|
||||
|
||||
<!-- Mappings Tab -->
|
||||
<section class="tab-content active" id="tab-mappings">
|
||||
<div class="add-mapping">
|
||||
<div class="input-row">
|
||||
<input type="text" id="inputReal" placeholder="Real value (e.g. John Smith)" class="input">
|
||||
<span class="arrow">→</span>
|
||||
<input type="text" id="inputSub" placeholder="Substitute (e.g. Alex Demo)" class="input">
|
||||
</div>
|
||||
<div class="input-row secondary">
|
||||
<select id="inputCategory" class="select">
|
||||
<option value="name">Name</option>
|
||||
<option value="email">Email</option>
|
||||
<option value="phone">Phone</option>
|
||||
<option value="address">Address</option>
|
||||
<option value="ssn">SSN</option>
|
||||
<option value="dob">DOB</option>
|
||||
<option value="general">General</option>
|
||||
</select>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="inputCaseSensitive">
|
||||
Case sensitive
|
||||
</label>
|
||||
<button class="btn btn-primary" id="btnAdd">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mapping-list" id="mappingList">
|
||||
<div class="empty-state">
|
||||
No mappings yet. Add your first one above.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Activity Tab -->
|
||||
<section class="tab-content" id="tab-activity">
|
||||
<div class="activity-header">
|
||||
<span class="badge" id="sessionCount">0 substitutions this session</span>
|
||||
<button class="btn-text" id="btnClearLog">Clear</button>
|
||||
</div>
|
||||
<div class="activity-list" id="activityList">
|
||||
<div class="empty-state">No activity yet.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Test Tab -->
|
||||
<section class="tab-content" id="tab-test">
|
||||
<p class="help-text">Type text containing your real values to see what would be sent.</p>
|
||||
<textarea id="testInput" class="textarea" placeholder="Try typing: My name is John Smith and my email is john@example.com" rows="4"></textarea>
|
||||
<div class="diff-view" id="diffView">
|
||||
<div class="diff-label">What gets sent:</div>
|
||||
<div class="diff-output" id="diffOutput"></div>
|
||||
</div>
|
||||
<div class="diff-stats" id="diffStats"></div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer">
|
||||
<a href="#" id="btnOptions">Options</a>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script src="popup.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,233 @@
|
||||
import SubstitutionEngine from '../lib/substitution-engine.js';
|
||||
import Storage from '../lib/storage.js';
|
||||
|
||||
// --- State ---
|
||||
let mappings = [];
|
||||
let settings = {};
|
||||
|
||||
// --- DOM refs ---
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const $$ = (sel) => document.querySelectorAll(sel);
|
||||
|
||||
// --- Init ---
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
mappings = await Storage.getMappings();
|
||||
settings = await Storage.getSettings();
|
||||
|
||||
renderMappings();
|
||||
renderActivity();
|
||||
updateStatusDot();
|
||||
|
||||
$('#enableToggle').checked = settings.enabled;
|
||||
|
||||
// Tab switching
|
||||
$$('.tab').forEach((tab) => {
|
||||
tab.addEventListener('click', () => {
|
||||
$$('.tab').forEach((t) => t.classList.remove('active'));
|
||||
$$('.tab-content').forEach((c) => c.classList.remove('active'));
|
||||
tab.classList.add('active');
|
||||
$(`#tab-${tab.dataset.tab}`).classList.add('active');
|
||||
|
||||
if (tab.dataset.tab === 'activity') renderActivity();
|
||||
});
|
||||
});
|
||||
|
||||
// Enable toggle
|
||||
$('#enableToggle').addEventListener('change', async (e) => {
|
||||
settings.enabled = e.target.checked;
|
||||
await Storage.saveSettings(settings);
|
||||
updateStatusDot();
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'update:settings',
|
||||
settings,
|
||||
});
|
||||
});
|
||||
|
||||
// Reveal mode toggle
|
||||
$('#btnReveal').addEventListener('click', async () => {
|
||||
settings.revealMode = !settings.revealMode;
|
||||
await Storage.saveSettings(settings);
|
||||
$('#btnReveal').classList.toggle('active', settings.revealMode);
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'update:settings',
|
||||
settings,
|
||||
});
|
||||
});
|
||||
|
||||
$('#btnReveal').classList.toggle('active', settings.revealMode);
|
||||
|
||||
// Add mapping
|
||||
$('#btnAdd').addEventListener('click', addMapping);
|
||||
$('#inputSub').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') addMapping();
|
||||
});
|
||||
|
||||
// Clear log
|
||||
$('#btnClearLog').addEventListener('click', async () => {
|
||||
await Storage.clearLog();
|
||||
renderActivity();
|
||||
});
|
||||
|
||||
// Test tab - live diff
|
||||
$('#testInput').addEventListener('input', renderTestDiff);
|
||||
|
||||
// Options link
|
||||
$('#btnOptions').addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
chrome.runtime.openOptionsPage();
|
||||
});
|
||||
});
|
||||
|
||||
// --- Add Mapping ---
|
||||
async function addMapping() {
|
||||
const real = $('#inputReal').value.trim();
|
||||
const sub = $('#inputSub').value.trim();
|
||||
const category = $('#inputCategory').value;
|
||||
const caseSensitive = $('#inputCaseSensitive').checked;
|
||||
|
||||
if (!real || !sub) return;
|
||||
|
||||
const mapping = await Storage.addMapping({
|
||||
real,
|
||||
substitute: sub,
|
||||
category,
|
||||
caseSensitive,
|
||||
});
|
||||
|
||||
mappings.push(mapping);
|
||||
renderMappings();
|
||||
|
||||
// Clear inputs
|
||||
$('#inputReal').value = '';
|
||||
$('#inputSub').value = '';
|
||||
$('#inputCaseSensitive').checked = false;
|
||||
$('#inputReal').focus();
|
||||
}
|
||||
|
||||
// --- Render Mappings ---
|
||||
function renderMappings() {
|
||||
const list = $('#mappingList');
|
||||
|
||||
if (mappings.length === 0) {
|
||||
list.innerHTML = '<div class="empty-state">No mappings yet. Add your first one above.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = mappings
|
||||
.map(
|
||||
(m) => `
|
||||
<div class="mapping-item" data-id="${m.id}">
|
||||
<div class="mapping-values">
|
||||
<span class="mapping-real">${escapeHtml(m.real)}</span>
|
||||
→
|
||||
<span class="mapping-sub">${escapeHtml(m.substitute)}</span>
|
||||
</div>
|
||||
<span class="mapping-category">${m.category}</span>
|
||||
<div class="mapping-actions">
|
||||
<button class="btn-toggle" title="${m.enabled ? 'Disable' : 'Enable'}">${m.enabled ? '✔' : '○'}</button>
|
||||
<button class="btn-delete" title="Delete">×</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join('');
|
||||
|
||||
// Bind actions
|
||||
list.querySelectorAll('.btn-delete').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const id = btn.closest('.mapping-item').dataset.id;
|
||||
await Storage.deleteMapping(id);
|
||||
mappings = mappings.filter((m) => m.id !== id);
|
||||
renderMappings();
|
||||
});
|
||||
});
|
||||
|
||||
list.querySelectorAll('.btn-toggle').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const item = btn.closest('.mapping-item');
|
||||
const id = item.dataset.id;
|
||||
const mapping = mappings.find((m) => m.id === id);
|
||||
if (!mapping) return;
|
||||
mapping.enabled = !mapping.enabled;
|
||||
await Storage.updateMapping(id, { enabled: mapping.enabled });
|
||||
renderMappings();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Render Activity Log ---
|
||||
async function renderActivity() {
|
||||
const log = await Storage.getLog();
|
||||
const list = $('#activityList');
|
||||
const countEl = $('#sessionCount');
|
||||
|
||||
countEl.textContent = `${log.length} substitution${log.length !== 1 ? 's' : ''} logged`;
|
||||
|
||||
if (log.length === 0) {
|
||||
list.innerHTML = '<div class="empty-state">No activity yet.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = log
|
||||
.slice(0, 50)
|
||||
.map((entry) => {
|
||||
const time = new Date(entry.timestamp).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
return `
|
||||
<div class="activity-item">
|
||||
<span class="activity-time">${time}</span>
|
||||
<div class="activity-detail">
|
||||
<span class="activity-original">${escapeHtml(entry.original)}</span>
|
||||
→
|
||||
<span class="activity-replaced">${escapeHtml(entry.replaced)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
// --- Test Diff ---
|
||||
function renderTestDiff() {
|
||||
const input = $('#testInput').value;
|
||||
const output = $('#diffOutput');
|
||||
const stats = $('#diffStats');
|
||||
|
||||
if (!input) {
|
||||
output.innerHTML = '';
|
||||
stats.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const { text, replacements } = SubstitutionEngine.substitute(input, mappings);
|
||||
const chunks = SubstitutionEngine.diff(input, text, mappings);
|
||||
|
||||
output.innerHTML = chunks
|
||||
.map((chunk) => {
|
||||
if (chunk.type === 'substituted') {
|
||||
return `<span class="sub-highlight" title="Was: ${escapeHtml(chunk.original)}">${escapeHtml(chunk.replacement)}</span>`;
|
||||
}
|
||||
return escapeHtml(chunk.text);
|
||||
})
|
||||
.join('');
|
||||
|
||||
stats.textContent =
|
||||
replacements.length > 0
|
||||
? `${replacements.length} substitution${replacements.length !== 1 ? 's' : ''} would be made`
|
||||
: 'No substitutions detected';
|
||||
}
|
||||
|
||||
// --- Status Dot ---
|
||||
function updateStatusDot() {
|
||||
const dot = $('#statusDot');
|
||||
dot.classList.toggle('disabled', !settings.enabled);
|
||||
}
|
||||
|
||||
// --- Util ---
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
Reference in New Issue
Block a user