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
This commit is contained in:
Claude
2026-03-25 23:52:37 +00:00
parent 7fe110d8fc
commit 387b22530b
7 changed files with 879 additions and 60 deletions
+40
View File
@@ -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;
+58 -2
View File
@@ -29,13 +29,69 @@
<!-- Tab Bar -->
<nav class="tabs">
<button class="tab active" data-tab="mappings">Mappings</button>
<button class="tab active" data-tab="identity">Identity</button>
<button class="tab" data-tab="mappings">Mappings</button>
<button class="tab" data-tab="activity">Activity</button>
<button class="tab" data-tab="test">Test</button>
</nav>
<!-- Identity Tab (Smart Patterns) -->
<section class="tab-content active" id="tab-identity">
<p class="help-text">Tell Silent Send who you are. It auto-catches all variations.</p>
<div class="id-section">
<div class="id-label">Names</div>
<div class="id-row">
<input type="text" id="idFirstReal" placeholder="First name" class="input input-sm">
<span class="arrow">&rarr;</span>
<input type="text" id="idFirstSub" placeholder="Fake first" class="input input-sm">
</div>
<div class="id-row">
<input type="text" id="idLastReal" placeholder="Last name" class="input input-sm">
<span class="arrow">&rarr;</span>
<input type="text" id="idLastSub" placeholder="Fake last" class="input input-sm">
</div>
<div class="id-hint">Catches: first last, last first, possessives, case variants</div>
</div>
<div class="id-section">
<div class="id-label">Email</div>
<div class="id-row">
<input type="email" id="idEmailReal" placeholder="you@gmail.com" class="input input-sm">
<span class="arrow">&rarr;</span>
<input type="email" id="idEmailSub" placeholder="fake@example.com" class="input input-sm">
</div>
<div class="id-row">
<input type="email" id="idCatchAllEmail" placeholder="Catch-all email (for unknown addresses)" class="input" style="flex:1">
</div>
<div class="id-hint">Catches: any *@gmail.com, *@yahoo.com, *@outlook.com, etc.</div>
</div>
<div class="id-section">
<div class="id-label">Username / Computer</div>
<div class="id-row">
<input type="text" id="idUserReal" placeholder="jsmith" class="input input-sm">
<span class="arrow">&rarr;</span>
<input type="text" id="idUserSub" placeholder="ademo" class="input input-sm">
</div>
<div class="id-hint">Catches: jsmith@hostname, /home/jsmith, ~jsmith, C:\Users\jsmith</div>
</div>
<div class="id-section">
<div class="id-label">Phone</div>
<div class="id-row">
<input type="text" id="idPhoneReal" placeholder="(555) 123-4567" class="input input-sm">
<span class="arrow">&rarr;</span>
<input type="text" id="idPhoneSub" placeholder="(555) 000-0000" class="input input-sm">
</div>
<div class="id-hint">Catches: all formats — (555) 123-4567, 555-123-4567, 555.123.4567</div>
</div>
<button class="btn btn-primary" id="btnSaveIdentity" style="width:100%;margin-top:8px">Save Identity</button>
</section>
<!-- Mappings Tab -->
<section class="tab-content active" id="tab-mappings">
<section class="tab-content" 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">
+142 -14
View File
@@ -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 `<span class="sub-highlight" title="Was: ${escapeHtml(chunk.original)}">${escapeHtml(chunk.replacement)}</span>`;
}
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,
`<span class="sub-highlight" title="Was: ${escapeHtml(r.original)} [${r.pattern || r.category}]">${escapedReplaced}</span>`
);
}
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 ---