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