commit 4576597e64ba5bd9b467a28a2ff3058dbb1b26ea Author: Claude Date: Wed Mar 25 23:26:13 2026 +0000 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9f8a1ef --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.DS_Store +*.crx +*.pem +*.zip +dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..b22a213 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +# Silent Send + +A Chrome extension that intercepts personal information and substitutes it with user-defined replacements before sending to Claude.ai. + +## How it works + +1. **You define mappings** — e.g. "John Smith" → "Alex Demo", "john@gmail.com" → "alex@example.com" +2. **You type normally** — you see your real text while composing +3. **On send, it swaps** — the extension intercepts the API request and replaces real values with substitutes +4. **Badge shows count** — the extension icon shows how many substitutions were made +5. **Reveal mode** — optionally translates Claude's responses back to your real data + +## How to verify it's working + +- **Badge count** on the extension icon shows substitutions per page +- **Activity tab** in the popup shows a timestamped log of every substitution +- **Test tab** in the popup lets you type text and see the before/after diff +- **Reveal mode** (eye icon) toggles showing real vs substitute data in responses +- **Browser DevTools** → Console shows `[Silent Send] Substituted N value(s)` messages + +## Installation (Developer Mode) + +1. Clone this repo +2. Open `chrome://extensions/` in Chrome +3. Enable **Developer mode** (top right) +4. Click **Load unpacked** +5. Select this directory +6. Navigate to claude.ai — the extension is active + +No build step needed. No Chrome Web Store required. + +## Architecture + +``` +manifest.json — Extension manifest (Manifest V3) +src/ + background/ + service-worker.js — Badge management, logging coordination + content/ + injector.js — Content script (isolated world) — loads config, bridges messaging + content.js — Page script (main world) — hooks fetch(), does substitution + content.css — Visual indicators (highlights, reveals) + popup/ + popup.html/css/js — Quick access: add mappings, view activity, test mode + options/ + options.html/css/js — Full mapping management, import/export, settings + lib/ + substitution-engine.js — Core find/replace logic + storage.js — Chrome storage wrapper +``` + +## Privacy + +- All data stays local in Chrome storage +- No external servers, no telemetry, no analytics +- The extension only activates on claude.ai diff --git a/icons/generate-icons.html b/icons/generate-icons.html new file mode 100644 index 0000000..e48fa4c --- /dev/null +++ b/icons/generate-icons.html @@ -0,0 +1,2 @@ + + diff --git a/icons/icon128.svg b/icons/icon128.svg new file mode 100644 index 0000000..8472f30 --- /dev/null +++ b/icons/icon128.svg @@ -0,0 +1,4 @@ + + + SS + diff --git a/icons/icon16.svg b/icons/icon16.svg new file mode 100644 index 0000000..1e40b5b --- /dev/null +++ b/icons/icon16.svg @@ -0,0 +1,4 @@ + + + SS + diff --git a/icons/icon48.svg b/icons/icon48.svg new file mode 100644 index 0000000..290eabc --- /dev/null +++ b/icons/icon48.svg @@ -0,0 +1,4 @@ + + + SS + diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..183fb80 --- /dev/null +++ b/manifest.json @@ -0,0 +1,47 @@ +{ + "manifest_version": 3, + "name": "Silent Send", + "version": "0.1.0", + "description": "Intercepts personal info and substitutes it with user-defined replacements before sending to AI services.", + "permissions": [ + "storage", + "activeTab", + "scripting" + ], + "host_permissions": [ + "https://claude.ai/*" + ], + "background": { + "service_worker": "src/background/service-worker.js", + "type": "module" + }, + "content_scripts": [ + { + "matches": ["https://claude.ai/*"], + "js": ["src/content/injector.js"], + "css": ["src/content/content.css"], + "run_at": "document_start", + "all_frames": true + } + ], + "action": { + "default_popup": "src/popup/popup.html", + "default_icon": { + "16": "icons/icon16.svg", + "48": "icons/icon48.svg", + "128": "icons/icon128.svg" + } + }, + "options_page": "src/options/options.html", + "icons": { + "16": "icons/icon16.svg", + "48": "icons/icon48.svg", + "128": "icons/icon128.svg" + }, + "web_accessible_resources": [ + { + "resources": ["src/content/content.js"], + "matches": ["https://claude.ai/*"] + } + ] +} diff --git a/src/background/service-worker.js b/src/background/service-worker.js new file mode 100644 index 0000000..18f559a --- /dev/null +++ b/src/background/service-worker.js @@ -0,0 +1,103 @@ +/** + * Silent Send - Background Service Worker + * + * Manages badge count, coordinates between popup and content scripts. + */ + +import Storage from '../lib/storage.js'; + +// Track substitution counts per tab +const tabCounts = new Map(); + +// --- Badge Management --- + +function updateBadge(tabId) { + const count = tabCounts.get(tabId) || 0; + const text = count > 0 ? String(count) : ''; + + chrome.action.setBadgeText({ text, tabId }); + chrome.action.setBadgeBackgroundColor({ color: count > 0 ? '#10b981' : '#6b7280', tabId }); +} + +// Reset count when tab navigates +chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { + if (changeInfo.status === 'loading') { + tabCounts.set(tabId, 0); + updateBadge(tabId); + } +}); + +// Cleanup when tab closes +chrome.tabs.onRemoved.addListener((tabId) => { + tabCounts.delete(tabId); +}); + +// --- Message Handling --- + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + const handler = messageHandlers[message.type]; + if (handler) { + handler(message, sender, sendResponse); + return true; // async response + } +}); + +const messageHandlers = { + async 'substitution:performed'(message, sender) { + const tabId = sender.tab?.id; + if (tabId == null) return; + + const current = tabCounts.get(tabId) || 0; + tabCounts.set(tabId, current + message.count); + updateBadge(tabId); + + // Log each replacement + for (const replacement of message.replacements) { + await Storage.addLogEntry({ + type: 'substitution', + direction: 'outbound', + original: replacement.original, + replaced: replacement.replaced, + category: replacement.category, + url: sender.tab?.url || '', + }); + } + }, + + async 'get:mappings'(_message, _sender, sendResponse) { + const mappings = await Storage.getMappings(); + sendResponse({ mappings }); + }, + + async 'get:settings'(_message, _sender, sendResponse) { + const settings = await Storage.getSettings(); + sendResponse({ settings }); + }, + + async 'get:log'(_message, _sender, sendResponse) { + const log = await Storage.getLog(); + sendResponse({ log }); + }, + + async 'get:tab-count'(message, sender, sendResponse) { + const tabId = message.tabId || sender.tab?.id; + sendResponse({ count: tabCounts.get(tabId) || 0 }); + }, + + async 'update:settings'(message) { + await Storage.saveSettings(message.settings); + // Broadcast to content scripts + const tabs = await chrome.tabs.query({ url: 'https://claude.ai/*' }); + for (const tab of tabs) { + chrome.tabs.sendMessage(tab.id, { + type: 'settings:updated', + settings: message.settings, + }).catch(() => {}); + } + }, +}; + +// --- Set initial badge state --- +chrome.runtime.onInstalled.addListener(() => { + chrome.action.setBadgeBackgroundColor({ color: '#6b7280' }); +}); diff --git a/src/content/content.css b/src/content/content.css new file mode 100644 index 0000000..c07ebfc --- /dev/null +++ b/src/content/content.css @@ -0,0 +1,22 @@ +/* Silent Send - Content Script Styles */ + +/* Subtle indicator when input contains sensitive data that will be substituted */ +.ss-has-sensitive { + box-shadow: inset 0 -2px 0 0 rgba(16, 185, 129, 0.3) !important; +} + +/* Highlight class for substituted text (when highlights enabled) */ +.ss-highlight { + background: rgba(16, 185, 129, 0.15); + border-bottom: 1.5px solid rgba(16, 185, 129, 0.5); + border-radius: 2px; + padding: 0 1px; +} + +/* Revealed text styling (when reveal mode shows real values in responses) */ +.ss-revealed { + background: rgba(59, 130, 246, 0.1); + border-bottom: 1.5px dashed rgba(59, 130, 246, 0.5); + border-radius: 2px; + padding: 0 1px; +} diff --git a/src/content/content.js b/src/content/content.js new file mode 100644 index 0000000..749c935 --- /dev/null +++ b/src/content/content.js @@ -0,0 +1,313 @@ +/** + * Silent Send - Page World Script + * + * Runs in the MAIN page world (injected by injector.js) so it can + * hook the real fetch() and XMLHttpRequest used by Claude.ai. + * + * Communicates back to the content script via window.postMessage. + */ + +(function () { + 'use strict'; + + // ============================================================ + // Load config from the injector script's data attribute + // ============================================================ + let mappings = []; + let settings = { enabled: true, revealMode: false, showHighlights: false }; + + try { + const configEl = document.querySelector('script[data-ss-config]'); + if (configEl) { + const config = JSON.parse(configEl.getAttribute('data-ss-config')); + mappings = config.mappings || []; + settings = { ...settings, ...(config.settings || {}) }; + } + } catch (e) { + console.warn('[Silent Send] Failed to parse initial config:', e); + } + + // Listen for config updates from the content script + window.addEventListener('message', (event) => { + if (event.source !== window) return; + if (event.data?.type === 'ss:config-updated') { + if (event.data.mappings) mappings = event.data.mappings; + if (event.data.settings) settings = { ...settings, ...event.data.settings }; + } + }); + + // ============================================================ + // Substitution Engine (inline — no module imports in page world) + // ============================================================ + function substitute(text, maps) { + const replacements = []; + let result = text; + const sorted = [...maps].sort((a, b) => b.real.length - a.real.length); + + for (const m of sorted) { + if (!m.enabled || !m.real || !m.substitute) continue; + const escaped = m.real.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(escaped, m.caseSensitive ? 'g' : 'gi'); + let match; + while ((match = regex.exec(result)) !== null) { + replacements.push({ + original: match[0], + replaced: m.substitute, + category: m.category || 'general', + }); + } + result = result.replace(regex, m.substitute); + } + return { text: result, replacements }; + } + + function reveal(text, maps) { + let result = text; + const sorted = [...maps].sort((a, b) => b.substitute.length - a.substitute.length); + for (const m of sorted) { + if (!m.enabled || !m.real || !m.substitute) continue; + const escaped = m.substitute.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(escaped, m.caseSensitive ? 'g' : 'gi'); + result = result.replace(regex, m.real); + } + return result; + } + + // ============================================================ + // Notify content script of substitutions (for badge + logging) + // ============================================================ + function notifySubstitutions(replacements) { + window.postMessage({ + type: 'ss:substitution-performed', + count: replacements.length, + replacements, + }, '*'); + } + + // ============================================================ + // Process a JSON body — handles all known Claude API shapes + // ============================================================ + function processBody(body) { + let modified = false; + const allReplacements = []; + + // Shape 1: { prompt: "..." } + if (typeof body.prompt === 'string') { + const r = substitute(body.prompt, mappings); + if (r.replacements.length > 0) { + body.prompt = r.text; + allReplacements.push(...r.replacements); + modified = true; + } + } + + // Shape 2: { content: [{ type: "text", text: "..." }] } + if (Array.isArray(body.content)) { + for (let i = 0; i < body.content.length; i++) { + const item = body.content[i]; + if (item.type === 'text' && typeof item.text === 'string') { + const r = substitute(item.text, mappings); + if (r.replacements.length > 0) { + body.content[i] = { ...item, text: r.text }; + allReplacements.push(...r.replacements); + modified = true; + } + } + } + } + + // Shape 3: { messages: [{ role: "user", content: "..." }] } + if (Array.isArray(body.messages)) { + for (const msg of body.messages) { + if (msg.role !== 'user' && msg.role !== 'human') continue; + + if (typeof msg.content === 'string') { + const r = substitute(msg.content, mappings); + if (r.replacements.length > 0) { + msg.content = r.text; + allReplacements.push(...r.replacements); + modified = true; + } + } + + if (Array.isArray(msg.content)) { + for (let j = 0; j < msg.content.length; j++) { + if (msg.content[j].type === 'text') { + const r = substitute(msg.content[j].text, mappings); + if (r.replacements.length > 0) { + msg.content[j] = { ...msg.content[j], text: r.text }; + allReplacements.push(...r.replacements); + modified = true; + } + } + } + } + } + } + + return { modified, replacements: allReplacements }; + } + + // ============================================================ + // Fetch Interception + // ============================================================ + const originalFetch = window.fetch; + + window.fetch = async function (url, options) { + if (!settings.enabled || mappings.length === 0) { + return originalFetch.call(this, url, options); + } + + const urlStr = typeof url === 'string' ? url : url?.url || ''; + const isTargetApi = + urlStr.includes('/api/organizations/') && + (urlStr.includes('/chat_conversations/') || + urlStr.includes('/completion') || + urlStr.includes('/messages')); + + if (isTargetApi && options?.body && typeof options.body === 'string') { + try { + const body = JSON.parse(options.body); + const { modified, replacements } = processBody(body); + + if (modified) { + options = { ...options, body: JSON.stringify(body) }; + notifySubstitutions(replacements); + console.log( + `[Silent Send] Substituted ${replacements.length} value(s) in fetch request` + ); + } + } catch (e) { + // Not JSON — pass through + } + } + + return originalFetch.call(this, url, options); + }; + + // ============================================================ + // XMLHttpRequest Interception (fallback) + // ============================================================ + const origOpen = XMLHttpRequest.prototype.open; + const origSend = XMLHttpRequest.prototype.send; + + XMLHttpRequest.prototype.open = function (method, url, ...rest) { + this._ssUrl = url; + return origOpen.call(this, method, url, ...rest); + }; + + XMLHttpRequest.prototype.send = function (body) { + if ( + settings.enabled && mappings.length > 0 && + typeof body === 'string' && this._ssUrl && + (this._ssUrl.includes('/chat_conversations/') || + this._ssUrl.includes('/completion') || + this._ssUrl.includes('/messages')) + ) { + try { + const parsed = JSON.parse(body); + const { modified, replacements } = processBody(parsed); + if (modified) { + body = JSON.stringify(parsed); + notifySubstitutions(replacements); + } + } catch (e) { /* pass through */ } + } + return origSend.call(this, body); + }; + + // ============================================================ + // Response Observer (reveal mode) + // ============================================================ + function observeResponses() { + const observer = new MutationObserver((mutations) => { + if (!settings.revealMode || mappings.length === 0) return; + + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (node.nodeType !== Node.ELEMENT_NODE) continue; + + // Claude response selectors + const responseEls = node.querySelectorAll + ? node.querySelectorAll('[data-is-streaming], .font-claude-message, .prose, [class*="Message"]') + : []; + + for (const el of responseEls) revealInElement(el); + + if (node.matches?.('[data-is-streaming], .font-claude-message, .prose, [class*="Message"]')) { + revealInElement(node); + } + } + } + }); + + observer.observe(document.body, { childList: true, subtree: true }); + } + + function revealInElement(el) { + const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); + let textNode; + while ((textNode = walker.nextNode())) { + const original = textNode.textContent; + const revealed = reveal(original, mappings); + if (revealed !== original) { + textNode.textContent = revealed; + } + } + } + + // ============================================================ + // Shadow DOM Traversal + // ============================================================ + function traverseShadowRoots() { + const visited = new WeakSet(); + + function walk(root) { + const allElements = root.querySelectorAll('*'); + for (const el of allElements) { + if (el.shadowRoot && !visited.has(el.shadowRoot)) { + visited.add(el.shadowRoot); + walk(el.shadowRoot); + } + } + } + + // Initial + periodic scan + walk(document); + setInterval(() => walk(document), 3000); + } + + // ============================================================ + // Input Highlighting + // ============================================================ + document.addEventListener('input', (e) => { + if (!settings.showHighlights || mappings.length === 0) return; + const target = e.target; + if (target.matches?.('[contenteditable], textarea, input[type="text"]')) { + const text = target.textContent || target.value || ''; + let hasMatches = false; + for (const m of mappings) { + if (!m.enabled || !m.real) continue; + if (text.toLowerCase().includes(m.real.toLowerCase())) { + hasMatches = true; + break; + } + } + target.classList.toggle('ss-has-sensitive', hasMatches); + } + }, true); + + // ============================================================ + // Boot + // ============================================================ + if (document.body) { + observeResponses(); + } else { + document.addEventListener('DOMContentLoaded', observeResponses); + } + traverseShadowRoots(); + + console.log( + `[Silent Send] Active on ${location.hostname} with ${mappings.length} mapping(s)` + ); +})(); diff --git a/src/content/injector.js b/src/content/injector.js new file mode 100644 index 0000000..bad9492 --- /dev/null +++ b/src/content/injector.js @@ -0,0 +1,60 @@ +/** + * Silent Send - Content Script Injector + * + * This runs in Chrome's ISOLATED content script world, where it has + * access to chrome.storage. It then injects the fetch-hooking code + * into the MAIN page world so it can intercept the actual fetch() calls. + * + * Communication: page script <-> content script via window.postMessage + */ + +'use strict'; + +// Load mappings and settings, then inject into page +async function init() { + const result = await chrome.storage.local.get(['ss_mappings', 'ss_settings']); + const mappings = result.ss_mappings || []; + const settings = result.ss_settings || { enabled: true }; + + // Inject the main interception script into the page's world + const script = document.createElement('script'); + script.setAttribute('data-ss-config', JSON.stringify({ mappings, settings })); + script.src = chrome.runtime.getURL('src/content/content.js'); + (document.head || document.documentElement).appendChild(script); + script.onload = () => script.remove(); + + // Listen for substitution events from the page script + window.addEventListener('message', (event) => { + if (event.source !== window) return; + if (event.data?.type === 'ss:substitution-performed') { + chrome.runtime.sendMessage({ + type: 'substitution:performed', + count: event.data.count, + replacements: event.data.replacements, + }).catch(() => {}); + } + }); + + // Forward storage changes to the page script + chrome.storage.onChanged.addListener((changes) => { + if (changes.ss_mappings || changes.ss_settings) { + window.postMessage({ + type: 'ss:config-updated', + mappings: changes.ss_mappings?.newValue, + settings: changes.ss_settings?.newValue, + }, '*'); + } + }); + + // Listen for settings updates from popup via runtime messages + chrome.runtime.onMessage.addListener((message) => { + if (message.type === 'settings:updated') { + window.postMessage({ + type: 'ss:config-updated', + settings: message.settings, + }, '*'); + } + }); +} + +init(); diff --git a/src/lib/storage.js b/src/lib/storage.js new file mode 100644 index 0000000..94f813b --- /dev/null +++ b/src/lib/storage.js @@ -0,0 +1,113 @@ +/** + * Silent Send - Storage Manager + * + * Wraps chrome.storage.local with typed helpers for mappings, + * activity log, and settings. + */ + +const KEYS = { + MAPPINGS: 'ss_mappings', + LOG: 'ss_activity_log', + SETTINGS: 'ss_settings', +}; + +const DEFAULT_SETTINGS = { + enabled: true, + showHighlights: false, + revealMode: false, + maxLogEntries: 200, + categories: ['name', 'email', 'phone', 'address', 'ssn', 'dob', 'general'], +}; + +const Storage = { + // --- Mappings --- + + async getMappings() { + const result = await chrome.storage.local.get(KEYS.MAPPINGS); + return result[KEYS.MAPPINGS] || []; + }, + + async saveMappings(mappings) { + await chrome.storage.local.set({ [KEYS.MAPPINGS]: mappings }); + }, + + async addMapping(mapping) { + const mappings = await this.getMappings(); + const newMapping = { + id: crypto.randomUUID(), + real: mapping.real || '', + substitute: mapping.substitute || '', + category: mapping.category || 'general', + caseSensitive: mapping.caseSensitive ?? false, + enabled: mapping.enabled ?? true, + createdAt: Date.now(), + }; + mappings.push(newMapping); + await this.saveMappings(mappings); + return newMapping; + }, + + async updateMapping(id, updates) { + const mappings = await this.getMappings(); + const idx = mappings.findIndex((m) => m.id === id); + if (idx === -1) return null; + mappings[idx] = { ...mappings[idx], ...updates }; + await this.saveMappings(mappings); + return mappings[idx]; + }, + + async deleteMapping(id) { + const mappings = await this.getMappings(); + const filtered = mappings.filter((m) => m.id !== id); + await this.saveMappings(filtered); + }, + + // --- Activity Log --- + + async getLog() { + const result = await chrome.storage.local.get(KEYS.LOG); + return result[KEYS.LOG] || []; + }, + + async addLogEntry(entry) { + const settings = await this.getSettings(); + const log = await this.getLog(); + + log.unshift({ + ...entry, + id: crypto.randomUUID(), + timestamp: Date.now(), + }); + + // Trim to max entries + if (log.length > settings.maxLogEntries) { + log.length = settings.maxLogEntries; + } + + await chrome.storage.local.set({ [KEYS.LOG]: log }); + }, + + async clearLog() { + await chrome.storage.local.set({ [KEYS.LOG]: [] }); + }, + + // --- Settings --- + + async getSettings() { + const result = await chrome.storage.local.get(KEYS.SETTINGS); + return { ...DEFAULT_SETTINGS, ...(result[KEYS.SETTINGS] || {}) }; + }, + + async saveSettings(settings) { + const current = await this.getSettings(); + await chrome.storage.local.set({ + [KEYS.SETTINGS]: { ...current, ...settings }, + }); + }, +}; + +if (typeof globalThis !== 'undefined') { + globalThis.SilentSendStorage = Storage; +} + +export default Storage; diff --git a/src/lib/substitution-engine.js b/src/lib/substitution-engine.js new file mode 100644 index 0000000..f7e5fd9 --- /dev/null +++ b/src/lib/substitution-engine.js @@ -0,0 +1,155 @@ +/** + * Silent Send - Substitution Engine + * + * Handles bidirectional substitution of personal data. + * real → fake on outbound messages, fake → real on inbound display. + */ + +const SubstitutionEngine = { + /** + * Apply all mappings to text (real → substitute). + * Returns { text, replacements[] } so we can log what happened. + */ + substitute(text, mappings) { + const replacements = []; + let result = text; + + // Sort by length descending so longer matches take priority + // e.g. "John Smith" matches before "John" + const sorted = [...mappings].sort( + (a, b) => b.real.length - a.real.length + ); + + for (const mapping of sorted) { + if (!mapping.enabled || !mapping.real || !mapping.substitute) continue; + + const escaped = this._escapeRegex(mapping.real); + const regex = new RegExp(escaped, mapping.caseSensitive ? 'g' : 'gi'); + let match; + + while ((match = regex.exec(result)) !== null) { + replacements.push({ + original: match[0], + replaced: mapping.substitute, + index: match.index, + category: mapping.category || 'general', + timestamp: Date.now(), + }); + } + + result = result.replace(regex, mapping.substitute); + } + + return { text: result, replacements }; + }, + + /** + * Reverse substitution (substitute → real) for inbound display. + */ + reveal(text, mappings) { + let result = text; + + const sorted = [...mappings].sort( + (a, b) => b.substitute.length - a.substitute.length + ); + + for (const mapping of sorted) { + if (!mapping.enabled || !mapping.real || !mapping.substitute) continue; + + const escaped = this._escapeRegex(mapping.substitute); + const regex = new RegExp(escaped, mapping.caseSensitive ? 'g' : 'gi'); + result = result.replace(regex, mapping.real); + } + + return result; + }, + + /** + * Check if text contains any real values that should be substituted. + */ + scan(text, mappings) { + const found = []; + + for (const mapping of mappings) { + if (!mapping.enabled || !mapping.real) continue; + + const escaped = this._escapeRegex(mapping.real); + const regex = new RegExp(escaped, mapping.caseSensitive ? 'g' : 'gi'); + + if (regex.test(text)) { + found.push({ + real: mapping.real, + substitute: mapping.substitute, + category: mapping.category || 'general', + }); + } + } + + return found; + }, + + /** + * Generate a diff-style comparison between original and substituted text. + */ + diff(original, substituted, mappings) { + const chunks = []; + let i = 0; + + // Simple character-level diff by finding substituted regions + const sorted = [...mappings].sort( + (a, b) => b.real.length - a.real.length + ); + + // Collect all match positions in the original text + const matches = []; + for (const mapping of sorted) { + if (!mapping.enabled || !mapping.real || !mapping.substitute) continue; + + const escaped = this._escapeRegex(mapping.real); + const regex = new RegExp(escaped, mapping.caseSensitive ? 'g' : 'gi'); + let match; + + while ((match = regex.exec(original)) !== null) { + matches.push({ + start: match.index, + end: match.index + match[0].length, + original: match[0], + substitute: mapping.substitute, + }); + } + } + + // Sort by position + matches.sort((a, b) => a.start - b.start); + + // Build chunks + for (const m of matches) { + if (m.start > i) { + chunks.push({ type: 'unchanged', text: original.slice(i, m.start) }); + } + chunks.push({ + type: 'substituted', + original: m.original, + replacement: m.substitute, + }); + i = m.end; + } + + if (i < original.length) { + chunks.push({ type: 'unchanged', text: original.slice(i) }); + } + + return chunks; + }, + + _escapeRegex(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + }, +}; + +// Support both module and content-script contexts +if (typeof globalThis !== 'undefined') { + globalThis.SubstitutionEngine = SubstitutionEngine; +} + +export default SubstitutionEngine; diff --git a/src/options/options.css b/src/options/options.css new file mode 100644 index 0000000..dabfe1e --- /dev/null +++ b/src/options/options.css @@ -0,0 +1,142 @@ +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-size: 14px; + color: #1a1a1a; + background: #f5f5f5; + line-height: 1.5; +} + +.container { + max-width: 800px; + margin: 0 auto; + padding: 32px 24px; +} + +header { margin-bottom: 32px; } +header h1 { font-size: 24px; font-weight: 600; } +.subtitle { color: #6b7280; margin-top: 4px; } + +.section { + background: #fff; + border-radius: 8px; + padding: 24px; + margin-bottom: 24px; + border: 1px solid #e5e7eb; +} + +.section h2 { + font-size: 16px; + font-weight: 600; + margin-bottom: 16px; + padding-bottom: 8px; + border-bottom: 1px solid #f3f4f6; +} + +.section-desc { color: #6b7280; font-size: 13px; margin-bottom: 16px; } + +/* Settings */ +.setting-row { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 0; + border-bottom: 1px solid #f3f4f6; +} + +.setting-row label { font-weight: 500; } +.setting-desc { font-size: 12px; color: #9ca3af; margin-top: 2px; } + +.toggle { position: relative; display: inline-block; width: 40px; height: 22px; } +.toggle input { display: none; } +.toggle-slider { + position: absolute; inset: 0; background: #d1d5db; + border-radius: 22px; cursor: pointer; transition: 0.2s; +} +.toggle-slider::before { + content: ''; position: absolute; width: 18px; height: 18px; + 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(18px); } + +.input-small { + width: 80px; padding: 6px 8px; border: 1px solid #d1d5db; + border-radius: 6px; font-size: 13px; text-align: center; +} + +/* Table */ +.mapping-table { width: 100%; border-collapse: collapse; margin-bottom: 16px; } +.mapping-table th { + text-align: left; font-size: 11px; font-weight: 600; + text-transform: uppercase; letter-spacing: 0.5px; + color: #6b7280; padding: 8px 10px; border-bottom: 1px solid #e5e7eb; +} +.mapping-table td { + padding: 8px 10px; border-bottom: 1px solid #f3f4f6; font-size: 13px; +} +.mapping-table tr:hover { background: #f9fafb; } +.mapping-table .real { color: #dc2626; } +.mapping-table .sub { color: #059669; font-weight: 500; } +.mapping-table .cat { + font-size: 11px; background: #f3f4f6; padding: 2px 6px; + border-radius: 4px; text-transform: uppercase; +} + +/* Add row */ +.add-row { + display: flex; gap: 8px; align-items: center; + padding-top: 12px; border-top: 1px solid #e5e7eb; +} + +.input { + flex: 1; padding: 7px 10px; border: 1px solid #d1d5db; + border-radius: 6px; font-size: 13px; outline: none; +} +.input:focus { border-color: #111; } + +.select { + padding: 7px 8px; border: 1px solid #d1d5db; + border-radius: 6px; font-size: 13px; background: #fff; +} + +.checkbox-label { + display: flex; align-items: center; gap: 4px; + font-size: 12px; color: #6b7280; +} + +/* Buttons */ +.btn { + padding: 7px 14px; border: 1px solid #d1d5db; border-radius: 6px; + font-size: 12px; font-weight: 500; cursor: pointer; + background: #fff; transition: all 0.15s; +} +.btn:hover { background: #f3f4f6; } +.btn-primary { background: #111; color: #fff; border-color: #111; } +.btn-primary:hover { background: #333; } +.btn-danger { color: #dc2626; border-color: #fecaca; } +.btn-danger:hover { background: #fef2f2; } + +.btn-sm { + padding: 4px 8px; font-size: 11px; +} + +.bulk-actions { display: flex; gap: 8px; margin-bottom: 16px; } + +/* Log */ +.log-actions { + display: flex; justify-content: space-between; align-items: center; + margin-bottom: 12px; +} +.log-list { max-height: 300px; overflow-y: auto; } +.log-item { + display: flex; gap: 12px; padding: 6px 0; + border-bottom: 1px solid #f3f4f6; font-size: 13px; +} +.log-time { color: #9ca3af; font-size: 12px; white-space: nowrap; } +.log-original { color: #dc2626; text-decoration: line-through; } +.log-replaced { color: #059669; } + +footer { text-align: center; padding: 16px 0; color: #9ca3af; font-size: 12px; } diff --git a/src/options/options.html b/src/options/options.html new file mode 100644 index 0000000..7e3beff --- /dev/null +++ b/src/options/options.html @@ -0,0 +1,98 @@ + + + + + Silent Send - Options + + + +
+
+

Silent Send Options

+

Manage your privacy substitution mappings

+
+ +
+

Settings

+
+
+ +

Show a subtle underline on text that will be substituted

+
+ +
+
+
+ +

Number of activity log entries to keep

+
+ +
+
+ +
+

Mappings

+

Manage all your substitution rules. Longer matches take priority.

+ +
+ + + + +
+ + + + + + + + + + + + + + +
Real ValueSubstituteCategoryCaseEnabled
+ +
+ + + + + +
+
+ +
+

Activity Log

+
+ 0 entries + +
+
+
+ +
+

Silent Send v0.1.0

+
+
+ + + + diff --git a/src/options/options.js b/src/options/options.js new file mode 100644 index 0000000..651fad4 --- /dev/null +++ b/src/options/options.js @@ -0,0 +1,183 @@ +import Storage from '../lib/storage.js'; + +let mappings = []; +let settings = {}; + +const $ = (sel) => document.querySelector(sel); + +document.addEventListener('DOMContentLoaded', async () => { + mappings = await Storage.getMappings(); + settings = await Storage.getSettings(); + + // Apply settings to UI + $('#showHighlights').checked = settings.showHighlights || false; + $('#maxLogEntries').value = settings.maxLogEntries || 200; + + renderMappings(); + renderLog(); + + // Settings listeners + $('#showHighlights').addEventListener('change', async (e) => { + await Storage.saveSettings({ showHighlights: e.target.checked }); + }); + + $('#maxLogEntries').addEventListener('change', async (e) => { + await Storage.saveSettings({ maxLogEntries: parseInt(e.target.value, 10) || 200 }); + }); + + // Add mapping + $('#btnAddMapping').addEventListener('click', addMapping); + $('#newSub').addEventListener('keydown', (e) => { + if (e.key === 'Enter') addMapping(); + }); + + // Export + $('#btnExport').addEventListener('click', () => { + const data = JSON.stringify(mappings, null, 2); + const blob = new Blob([data], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'silent-send-mappings.json'; + a.click(); + URL.revokeObjectURL(url); + }); + + // Import + $('#btnImport').addEventListener('click', () => $('#fileImport').click()); + $('#fileImport').addEventListener('change', async (e) => { + const file = e.target.files[0]; + if (!file) return; + try { + const text = await file.text(); + const imported = JSON.parse(text); + if (!Array.isArray(imported)) throw new Error('Expected array'); + // Merge: add IDs if missing + for (const item of imported) { + if (!item.id) item.id = crypto.randomUUID(); + if (!item.createdAt) item.createdAt = Date.now(); + if (item.enabled === undefined) item.enabled = true; + } + mappings = [...mappings, ...imported]; + await Storage.saveMappings(mappings); + renderMappings(); + } catch (err) { + alert('Failed to import: ' + err.message); + } + }); + + // Clear all + $('#btnClearAll').addEventListener('click', async () => { + if (!confirm('Delete all mappings? This cannot be undone.')) return; + mappings = []; + await Storage.saveMappings([]); + renderMappings(); + }); + + // Clear log + $('#btnClearLog').addEventListener('click', async () => { + await Storage.clearLog(); + renderLog(); + }); +}); + +async function addMapping() { + const real = $('#newReal').value.trim(); + const sub = $('#newSub').value.trim(); + if (!real || !sub) return; + + const mapping = await Storage.addMapping({ + real, + substitute: sub, + category: $('#newCategory').value, + caseSensitive: $('#newCaseSensitive').checked, + }); + + mappings.push(mapping); + renderMappings(); + + $('#newReal').value = ''; + $('#newSub').value = ''; + $('#newCaseSensitive').checked = false; + $('#newReal').focus(); +} + +function renderMappings() { + const tbody = $('#mappingTableBody'); + + if (mappings.length === 0) { + tbody.innerHTML = 'No mappings configured'; + return; + } + + tbody.innerHTML = mappings + .map( + (m) => ` + + ${escapeHtml(m.real)} + ${escapeHtml(m.substitute)} + ${m.category || 'general'} + ${m.caseSensitive ? 'Yes' : 'No'} + + + + + + ` + ) + .join(''); + + // Bind + tbody.querySelectorAll('.btn-delete').forEach((btn) => { + btn.addEventListener('click', async () => { + const id = btn.closest('tr').dataset.id; + await Storage.deleteMapping(id); + mappings = mappings.filter((m) => m.id !== id); + renderMappings(); + }); + }); + + tbody.querySelectorAll('.toggle-enabled').forEach((cb) => { + cb.addEventListener('change', async () => { + const id = cb.closest('tr').dataset.id; + await Storage.updateMapping(id, { enabled: cb.checked }); + const m = mappings.find((m) => m.id === id); + if (m) m.enabled = cb.checked; + }); + }); +} + +async function renderLog() { + const log = await Storage.getLog(); + $('#logCount').textContent = `${log.length} entries`; + + const list = $('#logList'); + if (log.length === 0) { + list.innerHTML = '
No activity logged
'; + return; + } + + list.innerHTML = log + .slice(0, 100) + .map((entry) => { + const time = new Date(entry.timestamp).toLocaleString(); + return ` +
+ ${time} + ${escapeHtml(entry.original || '')} + + ${escapeHtml(entry.replaced || '')} +
+ `; + }) + .join(''); +} + +function escapeHtml(str) { + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} diff --git a/src/popup/popup.css b/src/popup/popup.css new file mode 100644 index 0000000..f1208f1 --- /dev/null +++ b/src/popup/popup.css @@ -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; +} diff --git a/src/popup/popup.html b/src/popup/popup.html new file mode 100644 index 0000000..31972a0 --- /dev/null +++ b/src/popup/popup.html @@ -0,0 +1,100 @@ + + + + + + + + +
+ +
+
+

Silent Send

+ +
+
+ + +
+
+ + + + + +
+
+
+ + + +
+
+ + + +
+
+ +
+
+ No mappings yet. Add your first one above. +
+
+
+ + +
+
+ 0 substitutions this session + +
+
+
No activity yet.
+
+
+ + +
+

Type text containing your real values to see what would be sent.

+ +
+
What gets sent:
+
+
+
+
+ + + +
+ + + + diff --git a/src/popup/popup.js b/src/popup/popup.js new file mode 100644 index 0000000..23ec2fa --- /dev/null +++ b/src/popup/popup.js @@ -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 = '
No mappings yet. Add your first one above.
'; + return; + } + + list.innerHTML = mappings + .map( + (m) => ` +
+
+ ${escapeHtml(m.real)} + → + ${escapeHtml(m.substitute)} +
+ ${m.category} +
+ + +
+
+ ` + ) + .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 = '
No activity yet.
'; + return; + } + + list.innerHTML = log + .slice(0, 50) + .map((entry) => { + const time = new Date(entry.timestamp).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); + return ` +
+ ${time} +
+ ${escapeHtml(entry.original)} + → + ${escapeHtml(entry.replaced)} +
+
+ `; + }) + .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 `${escapeHtml(chunk.replacement)}`; + } + 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; +}