From 387b22530bec07f067ea915edac69c1ced7077cc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 25 Mar 2026 23:52:37 +0000 Subject: [PATCH] feat: add smart pattern detection for emails, names, usernames, phones Instead of requiring explicit mappings for every variation, users now configure their identity once (Identity tab) and Silent Send auto-catches: - Emails: any address @gmail, @yahoo, @outlook, etc. - Names: first/last, full name, reversed, possessives, case variants - Usernames: user@host, ~user, /home/user, C:\Users\user - Phones: all common formats ((555) 123-4567, 555.123.4567, etc.) Smart patterns run before explicit mappings, so explicit rules can override smart catches when needed. https://claude.ai/code/session_01Dvgwe7XMoSxnWXkih8p1Cw --- src/content/content.js | 263 +++++++++++++++++++++---- src/content/injector.js | 8 +- src/lib/smart-patterns.js | 392 ++++++++++++++++++++++++++++++++++++++ src/lib/storage.js | 20 ++ src/popup/popup.css | 40 ++++ src/popup/popup.html | 60 +++++- src/popup/popup.js | 156 +++++++++++++-- 7 files changed, 879 insertions(+), 60 deletions(-) create mode 100644 src/lib/smart-patterns.js diff --git a/src/content/content.js b/src/content/content.js index 749c935..4d6988a 100644 --- a/src/content/content.js +++ b/src/content/content.js @@ -14,6 +14,7 @@ // Load config from the injector script's data attribute // ============================================================ let mappings = []; + let identity = {}; let settings = { enabled: true, revealMode: false, showHighlights: false }; try { @@ -21,6 +22,7 @@ if (configEl) { const config = JSON.parse(configEl.getAttribute('data-ss-config')); mappings = config.mappings || []; + identity = config.identity || {}; settings = { ...settings, ...(config.settings || {}) }; } } catch (e) { @@ -32,6 +34,7 @@ if (event.source !== window) return; if (event.data?.type === 'ss:config-updated') { if (event.data.mappings) mappings = event.data.mappings; + if (event.data.identity) identity = event.data.identity; if (event.data.settings) settings = { ...settings, ...event.data.settings }; } }); @@ -46,7 +49,7 @@ for (const m of sorted) { if (!m.enabled || !m.real || !m.substitute) continue; - const escaped = m.real.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const escaped = esc(m.real); const regex = new RegExp(escaped, m.caseSensitive ? 'g' : 'gi'); let match; while ((match = regex.exec(result)) !== null) { @@ -66,13 +69,191 @@ 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 escaped = esc(m.substitute); const regex = new RegExp(escaped, m.caseSensitive ? 'g' : 'gi'); result = result.replace(regex, m.real); } return result; } + function esc(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + // ============================================================ + // Smart Pattern Engine (inline for page world) + // ============================================================ + const COMMON_EMAIL_DOMAINS = new Set([ + 'gmail.com', 'googlemail.com', 'yahoo.com', 'yahoo.co.uk', + 'hotmail.com', 'outlook.com', 'live.com', 'msn.com', + 'icloud.com', 'me.com', 'mac.com', + 'aol.com', 'proton.me', 'protonmail.com', + 'mail.com', 'zoho.com', 'fastmail.com', + 'yandex.com', 'gmx.com', 'gmx.net', + 'comcast.net', 'verizon.net', 'att.net', 'cox.net', + 'sbcglobal.net', 'charter.net', 'bellsouth.net', + ]); + + function smartSubstitute(text, id) { + if (!id || !id.enabled) return { text, replacements: [] }; + const replacements = []; + let result = text; + + // Emails + if (id.enabled.emails !== false) { + const emailMap = new Map(); + for (const e of (id.emails || [])) { + emailMap.set(e.real.toLowerCase(), e.substitute); + } + const myDomains = new Set((id.emailDomains || []).map(d => d.toLowerCase())); + const emailRegex = /\b[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\b/g; + const matches = []; + let m; + while ((m = emailRegex.exec(result)) !== null) { + matches.push({ index: m.index, value: m[0] }); + } + for (let i = matches.length - 1; i >= 0; i--) { + const em = matches[i]; + const lower = em.value.toLowerCase(); + const domain = lower.split('@')[1]; + let replacement = null; + if (emailMap.has(lower)) { + replacement = emailMap.get(lower); + } else if (COMMON_EMAIL_DOMAINS.has(domain) || myDomains.has(domain)) { + replacement = id.catchAllEmail || 'user@example.com'; + } + if (replacement) { + replacements.push({ original: em.value, replaced: replacement, category: 'email', pattern: 'smart' }); + result = result.slice(0, em.index) + replacement + result.slice(em.index + em.value.length); + } + } + } + + // Phones + if (id.enabled.phones !== false) { + for (const p of (id.phones || [])) { + if (!p.real || !p.substitute) continue; + const digits = p.real.replace(/\D/g, ''); + if (digits.length < 7) continue; + const d = digits.startsWith('1') && digits.length === 11 ? digits.slice(1) : digits; + if (d.length !== 10 && d.length !== 7) continue; + let pattern; + if (d.length === 10) { + const a = d.slice(0, 3), b = d.slice(3, 6), c = d.slice(6); + pattern = '(?:\\+?1[\\s.-]?)?(?:' + esc(a) + '|\\(' + esc(a) + '\\))[\\s.\\-]?' + esc(b) + '[\\s.\\-]?' + esc(c); + } else { + pattern = esc(d.slice(0, 3)) + '[\\s.\\-]?' + esc(d.slice(3)); + } + const phoneRegex = new RegExp(pattern, 'g'); + result = result.replace(phoneRegex, (matched) => { + replacements.push({ original: matched, replaced: p.substitute, category: 'phone', pattern: 'smart' }); + return p.substitute; + }); + } + } + + // Names (full names first, then individual) + if (id.enabled.names !== false) { + const names = id.names || []; + const firsts = names.filter(n => n.type === 'first'); + const lasts = names.filter(n => n.type === 'last'); + + for (const first of firsts) { + for (const last of lasts) { + // "First Last" + result = result.replace(new RegExp(esc(first.real) + '\\s+' + esc(last.real), 'gi'), (matched) => { + const sub = `${first.substitute} ${last.substitute}`; + replacements.push({ original: matched, replaced: sub, category: 'name', pattern: 'smart' }); + return sub; + }); + // "Last, First" + result = result.replace(new RegExp(esc(last.real) + ',\\s*' + esc(first.real), 'gi'), (matched) => { + const sub = `${last.substitute}, ${first.substitute}`; + replacements.push({ original: matched, replaced: sub, category: 'name', pattern: 'smart' }); + return sub; + }); + } + } + + for (const name of names) { + if (!name.real || !name.substitute) continue; + result = result.replace(new RegExp('\\b' + esc(name.real) + "(?:'s)?\\b", 'gi'), (matched) => { + const isPossessive = matched.endsWith("'s"); + const sub = isPossessive ? name.substitute + "'s" : name.substitute; + replacements.push({ original: matched, replaced: sub, category: 'name', pattern: 'smart' }); + return sub; + }); + } + } + + // Usernames + paths + if (id.enabled.usernames !== false) { + for (const u of (id.usernames || [])) { + if (!u.real || !u.substitute) continue; + + // user@hostname + result = result.replace(new RegExp(esc(u.real) + '@[a-zA-Z0-9._\\-]+', 'g'), (matched) => { + const host = matched.slice(u.real.length + 1); + const sub = u.substitute + '@' + host; + replacements.push({ original: matched, replaced: sub, category: 'username', pattern: 'smart' }); + return sub; + }); + + // ~username + result = result.replace(new RegExp('~' + esc(u.real) + '\\b', 'g'), (matched) => { + const sub = '~' + u.substitute; + replacements.push({ original: matched, replaced: sub, category: 'username', pattern: 'smart' }); + return sub; + }); + + // /home/username, /Users/username + result = result.replace(new RegExp('(/(?:home|Users)/)' + esc(u.real) + '(?=/|\\s|$|"|\')', 'g'), (matched, prefix) => { + const sub = prefix + u.substitute; + replacements.push({ original: matched, replaced: sub, category: 'path', pattern: 'smart' }); + return sub; + }); + + // C:\Users\username + result = result.replace(new RegExp('([A-Z]:\\\\Users\\\\)' + esc(u.real) + '(?=\\\\|\\s|$|"|\')', 'gi'), (matched, prefix) => { + const sub = prefix + u.substitute; + replacements.push({ original: matched, replaced: sub, category: 'path', pattern: 'smart' }); + return sub; + }); + + // plain username (3+ chars to avoid false positives) + if (u.real.length >= 3) { + result = result.replace(new RegExp('\\b' + esc(u.real) + '\\b', 'g'), (matched) => { + replacements.push({ original: matched, replaced: u.substitute, category: 'username', pattern: 'smart' }); + return u.substitute; + }); + } + } + } + + return { text: result, replacements }; + } + + // ============================================================ + // Combined substitution: smart patterns first, then explicit + // ============================================================ + function substituteAll(text) { + const allReplacements = []; + + // Smart patterns (broad catches) + const smart = smartSubstitute(text, identity); + allReplacements.push(...smart.replacements); + + // Explicit mappings (specific overrides) + const explicit = substitute(smart.text, mappings); + allReplacements.push(...explicit.replacements); + + return { + text: explicit.text, + replacements: allReplacements, + modified: allReplacements.length > 0, + }; + } + // ============================================================ // Notify content script of substitutions (for badge + logging) // ============================================================ @@ -91,14 +272,19 @@ 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; + function processText(text) { + const r = substituteAll(text); + if (r.modified) { allReplacements.push(...r.replacements); modified = true; } + return r; + } + + // Shape 1: { prompt: "..." } + if (typeof body.prompt === 'string') { + const r = processText(body.prompt); + if (r.modified) body.prompt = r.text; } // Shape 2: { content: [{ type: "text", text: "..." }] } @@ -106,12 +292,8 @@ 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; - } + const r = processText(item.text); + if (r.modified) body.content[i] = { ...item, text: r.text }; } } } @@ -122,23 +304,15 @@ 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; - } + const r = processText(msg.content); + if (r.modified) msg.content = r.text; } 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; - } + const r = processText(msg.content[j].text); + if (r.modified) msg.content[j] = { ...msg.content[j], text: r.text }; } } } @@ -148,13 +322,24 @@ return { modified, replacements: allReplacements }; } + // ============================================================ + // Check if we have anything to substitute + // ============================================================ + function hasSubstitutions() { + return mappings.length > 0 || + (identity.emails || []).length > 0 || + (identity.names || []).length > 0 || + (identity.usernames || []).length > 0 || + (identity.phones || []).length > 0; + } + // ============================================================ // Fetch Interception // ============================================================ const originalFetch = window.fetch; window.fetch = async function (url, options) { - if (!settings.enabled || mappings.length === 0) { + if (!settings.enabled || !hasSubstitutions()) { return originalFetch.call(this, url, options); } @@ -198,7 +383,7 @@ XMLHttpRequest.prototype.send = function (body) { if ( - settings.enabled && mappings.length > 0 && + settings.enabled && hasSubstitutions() && typeof body === 'string' && this._ssUrl && (this._ssUrl.includes('/chat_conversations/') || this._ssUrl.includes('/completion') || @@ -221,13 +406,12 @@ // ============================================================ function observeResponses() { const observer = new MutationObserver((mutations) => { - if (!settings.revealMode || mappings.length === 0) return; + if (!settings.revealMode || !hasSubstitutions()) 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"]') : []; @@ -272,7 +456,6 @@ } } - // Initial + periodic scan walk(document); setInterval(() => walk(document), 3000); } @@ -281,19 +464,12 @@ // Input Highlighting // ============================================================ document.addEventListener('input', (e) => { - if (!settings.showHighlights || mappings.length === 0) return; + if (!settings.showHighlights || !hasSubstitutions()) 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); + const r = substituteAll(text); + target.classList.toggle('ss-has-sensitive', r.modified); } }, true); @@ -307,7 +483,12 @@ } traverseShadowRoots(); + const smartCount = (identity.emails || []).length + + (identity.names || []).length + + (identity.usernames || []).length + + (identity.phones || []).length; + console.log( - `[Silent Send] Active on ${location.hostname} with ${mappings.length} mapping(s)` + `[Silent Send] Active on ${location.hostname} — ${mappings.length} explicit mapping(s), ${smartCount} smart pattern(s)` ); })(); diff --git a/src/content/injector.js b/src/content/injector.js index 500b5ac..4ea3515 100644 --- a/src/content/injector.js +++ b/src/content/injector.js @@ -20,13 +20,14 @@ const api = // Load mappings and settings, then inject into page async function init() { - const result = await api.storage.local.get(['ss_mappings', 'ss_settings']); + const result = await api.storage.local.get(['ss_mappings', 'ss_identity', 'ss_settings']); const mappings = result.ss_mappings || []; + const identity = result.ss_identity || {}; 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.setAttribute('data-ss-config', JSON.stringify({ mappings, identity, settings })); script.src = api.runtime.getURL('src/content/content.js'); (document.head || document.documentElement).appendChild(script); script.onload = () => script.remove(); @@ -45,10 +46,11 @@ async function init() { // Forward storage changes to the page script api.storage.onChanged.addListener((changes) => { - if (changes.ss_mappings || changes.ss_settings) { + if (changes.ss_mappings || changes.ss_identity || changes.ss_settings) { window.postMessage({ type: 'ss:config-updated', mappings: changes.ss_mappings?.newValue, + identity: changes.ss_identity?.newValue, settings: changes.ss_settings?.newValue, }, '*'); } diff --git a/src/lib/smart-patterns.js b/src/lib/smart-patterns.js new file mode 100644 index 0000000..6af22c4 --- /dev/null +++ b/src/lib/smart-patterns.js @@ -0,0 +1,392 @@ +/** + * Silent Send - Smart Pattern Detector + * + * Automatically detects and substitutes personal data patterns + * without requiring explicit mappings for every variation. + * + * Supported patterns: + * - Emails: anything@recognized-domain → substitute-email + * - Names: First, Last, First Last, LAST (case variants) + * - User@Host: username patterns from system/shell contexts + * - Phones: common formats (xxx) xxx-xxxx, xxx-xxx-xxxx, etc. + * - Paths: /home/username, /Users/username, C:\Users\username + */ + +const COMMON_EMAIL_DOMAINS = new Set([ + 'gmail.com', 'googlemail.com', 'yahoo.com', 'yahoo.co.uk', + 'hotmail.com', 'outlook.com', 'live.com', 'msn.com', + 'icloud.com', 'me.com', 'mac.com', + 'aol.com', 'proton.me', 'protonmail.com', + 'mail.com', 'zoho.com', 'fastmail.com', + 'yandex.com', 'gmx.com', 'gmx.net', + 'comcast.net', 'verizon.net', 'att.net', 'cox.net', + 'sbcglobal.net', 'charter.net', 'bellsouth.net', +]); + +const SmartPatterns = { + /** + * Process text using smart patterns + identity config. + * Returns { text, replacements[] } same shape as SubstitutionEngine. + * + * @param {string} text - Input text + * @param {object} identity - User's identity config: + * { + * emails: [{ real: "john@gmail.com", substitute: "alex@example.com" }], + * names: [{ real: "John", substitute: "Alex", type: "first" }, + * { real: "Smith", substitute: "Demo", type: "last" }], + * usernames: [{ real: "jsmith", substitute: "ademo" }], + * phones: [{ real: "555-123-4567", substitute: "555-000-0000" }], + * catchAllEmail: "anon@example.com", // fallback for unknown emails with your domain + * emailDomains: ["mycompany.com"], // additional domains to catch + * enabled: { emails: true, names: true, usernames: true, phones: true, paths: true } + * } + */ + substitute(text, identity) { + if (!identity) return { text, replacements: [] }; + + const replacements = []; + let result = text; + + // Order matters: do emails first (most specific), then names, then usernames, then paths + if (identity.enabled?.emails !== false) { + const r = this._substituteEmails(result, identity); + result = r.text; + replacements.push(...r.replacements); + } + + if (identity.enabled?.phones !== false) { + const r = this._substitutePhones(result, identity); + result = r.text; + replacements.push(...r.replacements); + } + + if (identity.enabled?.names !== false) { + const r = this._substituteNames(result, identity); + result = r.text; + replacements.push(...r.replacements); + } + + if (identity.enabled?.usernames !== false) { + const r = this._substituteUsernames(result, identity); + result = r.text; + replacements.push(...r.replacements); + } + + if (identity.enabled?.paths !== false) { + const r = this._substitutePaths(result, identity); + result = r.text; + replacements.push(...r.replacements); + } + + return { text: result, replacements }; + }, + + // ----- Emails ----- + // Catches: exact matches, AND any something@known-domain + _substituteEmails(text, identity) { + const replacements = []; + let result = text; + + // Build set of known real emails for exact matching + const emailMap = new Map(); + for (const e of (identity.emails || [])) { + emailMap.set(e.real.toLowerCase(), e.substitute); + } + + // Additional domains to treat as "yours" + const myDomains = new Set( + (identity.emailDomains || []).map(d => d.toLowerCase()) + ); + + // Match all email-like patterns + const emailRegex = /\b[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\b/g; + let match; + + // Collect all matches first, then replace from end to preserve indices + const matches = []; + while ((match = emailRegex.exec(result)) !== null) { + matches.push({ index: match.index, value: match[0] }); + } + + // Process from end to start so indices stay valid + for (let i = matches.length - 1; i >= 0; i--) { + const m = matches[i]; + const lower = m.value.toLowerCase(); + const domain = lower.split('@')[1]; + + let replacement = null; + + // Exact match? + if (emailMap.has(lower)) { + replacement = emailMap.get(lower); + } + // Known personal domain (gmail, etc.) or custom domain? + else if ( + COMMON_EMAIL_DOMAINS.has(domain) || + myDomains.has(domain) + ) { + replacement = identity.catchAllEmail || 'user@example.com'; + } + + if (replacement) { + replacements.push({ + original: m.value, + replaced: replacement, + category: 'email', + pattern: 'smart', + }); + result = + result.slice(0, m.index) + + replacement + + result.slice(m.index + m.value.length); + } + } + + return { text: result, replacements }; + }, + + // ----- Names ----- + // Catches: "John Smith", "Smith, John", "John", "Smith", "SMITH", "john" + // Also catches possessives: "John's", "Smith's" + _substituteNames(text, identity) { + const replacements = []; + let result = text; + const names = identity.names || []; + + if (names.length === 0) return { text: result, replacements }; + + // First pass: full name combinations (first + last) + const firsts = names.filter(n => n.type === 'first'); + const lasts = names.filter(n => n.type === 'last'); + + for (const first of firsts) { + for (const last of lasts) { + // "First Last" + const fullRegex = new RegExp( + esc(first.real) + "\\s+" + esc(last.real), + 'gi' + ); + result = result.replace(fullRegex, (matched) => { + replacements.push({ + original: matched, + replaced: `${first.substitute} ${last.substitute}`, + category: 'name', + pattern: 'smart-fullname', + }); + return `${first.substitute} ${last.substitute}`; + }); + + // "Last, First" + const reverseRegex = new RegExp( + esc(last.real) + ",\\s*" + esc(first.real), + 'gi' + ); + result = result.replace(reverseRegex, (matched) => { + replacements.push({ + original: matched, + replaced: `${last.substitute}, ${first.substitute}`, + category: 'name', + pattern: 'smart-fullname-reverse', + }); + return `${last.substitute}, ${first.substitute}`; + }); + } + } + + // Second pass: individual names (with word boundaries) + for (const name of names) { + if (!name.real || !name.substitute) continue; + + // Match the name with word boundaries, including possessives + const nameRegex = new RegExp( + '\\b' + esc(name.real) + "(?:'s)?\\b", + 'gi' + ); + + result = result.replace(nameRegex, (matched) => { + const isPossessive = matched.endsWith("'s"); + const sub = isPossessive + ? name.substitute + "'s" + : name.substitute; + + replacements.push({ + original: matched, + replaced: sub, + category: 'name', + pattern: 'smart-name', + }); + return sub; + }); + } + + return { text: result, replacements }; + }, + + // ----- Usernames ----- + // Catches: user@hostname, ~username, /home/username, mentions of username + // in shell/code contexts + _substituteUsernames(text, identity) { + const replacements = []; + let result = text; + const usernames = identity.usernames || []; + + for (const u of usernames) { + if (!u.real || !u.substitute) continue; + + // user@hostname patterns (SSH, terminal prompts) + const userHostRegex = new RegExp( + esc(u.real) + '@[a-zA-Z0-9._\\-]+', + 'g' + ); + result = result.replace(userHostRegex, (matched) => { + const host = matched.slice(u.real.length + 1); + const sub = u.substitute + '@' + host; + replacements.push({ + original: matched, + replaced: sub, + category: 'username', + pattern: 'smart-userhost', + }); + return sub; + }); + + // ~username (shell shorthand) + const tildeRegex = new RegExp('~' + esc(u.real) + '\\b', 'g'); + result = result.replace(tildeRegex, (matched) => { + const sub = '~' + u.substitute; + replacements.push({ + original: matched, + replaced: sub, + category: 'username', + pattern: 'smart-tilde', + }); + return sub; + }); + + // Plain username with word boundaries (careful - short names can over-match) + // Only match if username is 3+ chars to avoid false positives + if (u.real.length >= 3) { + const plainRegex = new RegExp('\\b' + esc(u.real) + '\\b', 'g'); + result = result.replace(plainRegex, (matched) => { + replacements.push({ + original: matched, + replaced: u.substitute, + category: 'username', + pattern: 'smart-username', + }); + return u.substitute; + }); + } + } + + return { text: result, replacements }; + }, + + // ----- Phones ----- + // Catches common formats: (555) 123-4567, 555-123-4567, 555.123.4567, + // +1 555 123 4567, 5551234567 + _substitutePhones(text, identity) { + const replacements = []; + let result = text; + const phones = identity.phones || []; + + for (const p of phones) { + if (!p.real || !p.substitute) continue; + + // Normalize the real phone to just digits + const digits = p.real.replace(/\D/g, ''); + if (digits.length < 7) continue; + + // Build a regex that matches the digits in any common format + // For a number like 5551234567, match: + // 555-123-4567, (555) 123-4567, 555.123.4567, +1-555-123-4567, etc. + const d = digits.startsWith('1') && digits.length === 11 + ? digits.slice(1) + : digits; + + if (d.length !== 10 && d.length !== 7) continue; + + let pattern; + if (d.length === 10) { + const a = d.slice(0, 3), b = d.slice(3, 6), c = d.slice(6); + pattern = + '(?:\\+?1[\\s.-]?)?' + + '(?:' + esc(a) + '|\\(' + esc(a) + '\\))' + + '[\\s.\\-]?' + + esc(b) + '[\\s.\\-]?' + esc(c); + } else { + const b = d.slice(0, 3), c = d.slice(3); + pattern = esc(b) + '[\\s.\\-]?' + esc(c); + } + + const phoneRegex = new RegExp(pattern, 'g'); + result = result.replace(phoneRegex, (matched) => { + replacements.push({ + original: matched, + replaced: p.substitute, + category: 'phone', + pattern: 'smart-phone', + }); + return p.substitute; + }); + } + + return { text: result, replacements }; + }, + + // ----- File Paths ----- + // Catches: /home/username, /Users/username, C:\Users\username + _substitutePaths(text, identity) { + const replacements = []; + let result = text; + const usernames = identity.usernames || []; + + for (const u of usernames) { + if (!u.real || !u.substitute) continue; + + // Unix paths: /home/username or /Users/username + const unixRegex = new RegExp( + '(/(?:home|Users)/)' + esc(u.real) + '(?=/|\\s|$|"|\')', + 'g' + ); + result = result.replace(unixRegex, (matched, prefix) => { + const sub = prefix + u.substitute; + replacements.push({ + original: matched, + replaced: sub, + category: 'path', + pattern: 'smart-path', + }); + return sub; + }); + + // Windows paths: C:\Users\username + const winRegex = new RegExp( + '([A-Z]:\\\\Users\\\\)' + esc(u.real) + '(?=\\\\|\\s|$|"|\')', + 'gi' + ); + result = result.replace(winRegex, (matched, prefix) => { + const sub = prefix + u.substitute; + replacements.push({ + original: matched, + replaced: sub, + category: 'path', + pattern: 'smart-path-win', + }); + return sub; + }); + } + + return { text: result, replacements }; + }, +}; + +function esc(str) { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +if (typeof globalThis !== 'undefined') { + globalThis.SmartPatterns = SmartPatterns; +} + +export default SmartPatterns; diff --git a/src/lib/storage.js b/src/lib/storage.js index 7974510..64923b4 100644 --- a/src/lib/storage.js +++ b/src/lib/storage.js @@ -9,6 +9,7 @@ import api from './browser-polyfill.js'; const KEYS = { MAPPINGS: 'ss_mappings', + IDENTITY: 'ss_identity', LOG: 'ss_activity_log', SETTINGS: 'ss_settings', }; @@ -64,6 +65,25 @@ const Storage = { await this.saveMappings(filtered); }, + // --- Identity (Smart Patterns) --- + + async getIdentity() { + const result = await api.storage.local.get(KEYS.IDENTITY); + return result[KEYS.IDENTITY] || { + emails: [], + names: [], + usernames: [], + phones: [], + catchAllEmail: '', + emailDomains: [], + enabled: { emails: true, names: true, usernames: true, phones: true, paths: true }, + }; + }, + + async saveIdentity(identity) { + await api.storage.local.set({ [KEYS.IDENTITY]: identity }); + }, + // --- Activity Log --- async getLog() { diff --git a/src/popup/popup.css b/src/popup/popup.css index f1208f1..e2f2ede 100644 --- a/src/popup/popup.css +++ b/src/popup/popup.css @@ -242,6 +242,46 @@ body { white-space: nowrap; } +/* Identity tab */ +.id-section { + margin-bottom: 12px; + padding-bottom: 10px; + border-bottom: 1px solid #f3f4f6; +} + +.id-section:last-of-type { + border-bottom: none; + margin-bottom: 4px; +} + +.id-label { + font-size: 11px; + font-weight: 600; + color: #374151; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 6px; +} + +.id-row { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 4px; +} + +.input-sm { + padding: 5px 8px; + font-size: 12px; +} + +.id-hint { + font-size: 10px; + color: #9ca3af; + margin-top: 2px; + font-style: italic; +} + /* Mapping list */ .add-mapping { padding-bottom: 12px; diff --git a/src/popup/popup.html b/src/popup/popup.html index 31972a0..7680ef9 100644 --- a/src/popup/popup.html +++ b/src/popup/popup.html @@ -29,13 +29,69 @@ + +
+

Tell Silent Send who you are. It auto-catches all variations.

+ +
+
Names
+
+ + + +
+
+ + + +
+
Catches: first last, last first, possessives, case variants
+
+ +
+
Email
+
+ + + +
+
+ +
+
Catches: any *@gmail.com, *@yahoo.com, *@outlook.com, etc.
+
+ +
+
Username / Computer
+
+ + + +
+
Catches: jsmith@hostname, /home/jsmith, ~jsmith, C:\Users\jsmith
+
+ +
+
Phone
+
+ + + +
+
Catches: all formats — (555) 123-4567, 555-123-4567, 555.123.4567
+
+ + +
+ -
+
diff --git a/src/popup/popup.js b/src/popup/popup.js index 2e488d0..6d94a53 100644 --- a/src/popup/popup.js +++ b/src/popup/popup.js @@ -1,9 +1,11 @@ import SubstitutionEngine from '../lib/substitution-engine.js'; +import SmartPatterns from '../lib/smart-patterns.js'; import Storage from '../lib/storage.js'; import api from '../lib/browser-polyfill.js'; // --- State --- let mappings = []; +let identity = {}; let settings = {}; // --- DOM refs --- @@ -13,10 +15,12 @@ const $$ = (sel) => document.querySelectorAll(sel); // --- Init --- document.addEventListener('DOMContentLoaded', async () => { mappings = await Storage.getMappings(); + identity = await Storage.getIdentity(); settings = await Storage.getSettings(); renderMappings(); renderActivity(); + loadIdentityForm(); updateStatusDot(); $('#enableToggle').checked = settings.enabled; @@ -57,6 +61,9 @@ document.addEventListener('DOMContentLoaded', async () => { $('#btnReveal').classList.toggle('active', settings.revealMode); + // Save identity + $('#btnSaveIdentity').addEventListener('click', saveIdentity); + // Add mapping $('#btnAdd').addEventListener('click', addMapping); $('#inputSub').addEventListener('keydown', (e) => { @@ -79,6 +86,93 @@ document.addEventListener('DOMContentLoaded', async () => { }); }); +// --- Identity --- +function loadIdentityForm() { + const first = (identity.names || []).find(n => n.type === 'first'); + const last = (identity.names || []).find(n => n.type === 'last'); + const email = (identity.emails || [])[0]; + const user = (identity.usernames || [])[0]; + const phone = (identity.phones || [])[0]; + + if (first) { + $('#idFirstReal').value = first.real || ''; + $('#idFirstSub').value = first.substitute || ''; + } + if (last) { + $('#idLastReal').value = last.real || ''; + $('#idLastSub').value = last.substitute || ''; + } + if (email) { + $('#idEmailReal').value = email.real || ''; + $('#idEmailSub').value = email.substitute || ''; + } + $('#idCatchAllEmail').value = identity.catchAllEmail || ''; + if (user) { + $('#idUserReal').value = user.real || ''; + $('#idUserSub').value = user.substitute || ''; + } + if (phone) { + $('#idPhoneReal').value = phone.real || ''; + $('#idPhoneSub').value = phone.substitute || ''; + } +} + +async function saveIdentity() { + const names = []; + const firstReal = $('#idFirstReal').value.trim(); + const firstSub = $('#idFirstSub').value.trim(); + if (firstReal && firstSub) { + names.push({ real: firstReal, substitute: firstSub, type: 'first' }); + } + const lastReal = $('#idLastReal').value.trim(); + const lastSub = $('#idLastSub').value.trim(); + if (lastReal && lastSub) { + names.push({ real: lastReal, substitute: lastSub, type: 'last' }); + } + + const emails = []; + const emailReal = $('#idEmailReal').value.trim(); + const emailSub = $('#idEmailSub').value.trim(); + if (emailReal && emailSub) { + emails.push({ real: emailReal, substitute: emailSub }); + } + + const usernames = []; + const userReal = $('#idUserReal').value.trim(); + const userSub = $('#idUserSub').value.trim(); + if (userReal && userSub) { + usernames.push({ real: userReal, substitute: userSub }); + } + + const phones = []; + const phoneReal = $('#idPhoneReal').value.trim(); + const phoneSub = $('#idPhoneSub').value.trim(); + if (phoneReal && phoneSub) { + phones.push({ real: phoneReal, substitute: phoneSub }); + } + + identity = { + names, + emails, + usernames, + phones, + catchAllEmail: $('#idCatchAllEmail').value.trim(), + emailDomains: identity.emailDomains || [], + enabled: identity.enabled || { emails: true, names: true, usernames: true, phones: true, paths: true }, + }; + + await Storage.saveIdentity(identity); + + // Flash save button + const btn = $('#btnSaveIdentity'); + btn.textContent = 'Saved!'; + btn.style.background = '#059669'; + setTimeout(() => { + btn.textContent = 'Save Identity'; + btn.style.background = ''; + }, 1500); +} + // --- Add Mapping --- async function addMapping() { const real = $('#inputReal').value.trim(); @@ -191,6 +285,7 @@ async function renderActivity() { } // --- Test Diff --- +// Runs both smart patterns AND explicit mappings, shows combined result function renderTestDiff() { const input = $('#testInput').value; const output = $('#diffOutput'); @@ -202,22 +297,55 @@ function renderTestDiff() { return; } - const { text, replacements } = SubstitutionEngine.substitute(input, mappings); - const chunks = SubstitutionEngine.diff(input, text, mappings); + // Smart patterns first (broader catches), then explicit mappings (specific overrides) + const smartResult = SmartPatterns.substitute(input, identity); + const explicitResult = SubstitutionEngine.substitute(smartResult.text, mappings); - output.innerHTML = chunks - .map((chunk) => { - if (chunk.type === 'substituted') { - return `${escapeHtml(chunk.replacement)}`; - } - return escapeHtml(chunk.text); - }) - .join(''); + const allReplacements = [...smartResult.replacements, ...explicitResult.replacements]; + const finalText = explicitResult.text; - stats.textContent = - replacements.length > 0 - ? `${replacements.length} substitution${replacements.length !== 1 ? 's' : ''} would be made` - : 'No substitutions detected'; + // Simple diff: highlight differences + if (finalText === input) { + output.textContent = input; + stats.textContent = 'No substitutions detected'; + return; + } + + // Build a visual diff by running smart patterns on original to find positions + // For display, we re-run on the original to get positions + const smartPositions = findReplacementPositions(input, identity, mappings); + + if (smartPositions.length === 0) { + output.textContent = finalText; + } else { + // Build highlighted output from the final text + // Simpler approach: show the final text with replaced values highlighted + let html = escapeHtml(finalText); + for (const r of allReplacements) { + const escapedReplaced = escapeHtml(r.replaced); + html = html.replace( + escapedReplaced, + `${escapedReplaced}` + ); + } + output.innerHTML = html; + } + + const smartCount = smartResult.replacements.length; + const explicitCount = explicitResult.replacements.length; + const parts = []; + if (smartCount > 0) parts.push(`${smartCount} smart`); + if (explicitCount > 0) parts.push(`${explicitCount} explicit`); + stats.textContent = `${allReplacements.length} substitution${allReplacements.length !== 1 ? 's' : ''} (${parts.join(', ')})`; +} + +function findReplacementPositions(text, ident, maps) { + const positions = []; + const r1 = SmartPatterns.substitute(text, ident); + positions.push(...r1.replacements); + const r2 = SubstitutionEngine.substitute(r1.text, maps); + positions.push(...r2.replacements); + return positions; } // --- Status Dot ---