fix: date false-positive, partial-word highlights, cross-browser sync

Bug fixes:
- Date (possible DOB) pattern now requires context words (born, birthday,
  dob, etc.) before firing — prevents spurious warnings on page-load API
  calls that happen to contain ISO dates in conversation history.
- Highlight regex now uses word boundaries (\b) so short substitute values
  (e.g. "aud") no longer match inside unrelated words like "Claude".
- Both TreeWalkers in content.js now skip the extension's own UI elements
  (.ss-autodetect-warning, .ss-presend-warning, .ss-reveal-badge) to
  prevent the highlight API from marking text in the extension's banners.

Settings sync:
- New src/lib/sync.js: exportSyncCode / importSyncCode (base64 JSON) for
  manual copy-paste across any browser combination. Newest lastModified
  timestamp wins; force flag available to override.
- browser.storage.sync support: when "Browser account sync" is enabled the
  extension automatically pushes/pulls via Firefox Sync or Chrome account,
  chunked to stay within per-item quota limits.
- storage.js now writes ss_lastModified on every save so conflict resolution
  has an accurate timestamp.
- service-worker.js listens for both local and sync storage changes to keep
  all copies in sync.
- New "Sync Between Browsers" section in options.html with Generate/Copy/
  Import Sync Code UI and the browser sync toggle.

https://claude.ai/code/session_01TKpSR9M8JgHLXCp5CeDsQP
This commit is contained in:
Claude
2026-03-26 14:13:22 +00:00
parent 2e79e02bd3
commit 84f6e675db
6 changed files with 340 additions and 12 deletions
+4 -2
View File
@@ -25,6 +25,7 @@ const DEFAULT_SETTINGS = {
maxLogEntries: 200,
customDomains: [],
categories: ['name', 'email', 'phone', 'address', 'ssn', 'dob', 'domain', 'general'],
browserSync: false,
};
const Storage = {
@@ -36,7 +37,7 @@ const Storage = {
},
async saveMappings(mappings) {
await api.storage.local.set({ [KEYS.MAPPINGS]: mappings });
await api.storage.local.set({ [KEYS.MAPPINGS]: mappings, ss_lastModified: Date.now() });
},
async addMapping(mapping) {
@@ -96,7 +97,7 @@ const Storage = {
},
async saveProfiles(profiles) {
await api.storage.local.set({ [KEYS.IDENTITY]: { profiles } });
await api.storage.local.set({ [KEYS.IDENTITY]: { profiles }, ss_lastModified: Date.now() });
},
async addProfile(name) {
@@ -210,6 +211,7 @@ const Storage = {
const current = await this.getSettings();
await api.storage.local.set({
[KEYS.SETTINGS]: { ...current, ...settings },
ss_lastModified: Date.now(),
});
},
};
+176
View File
@@ -0,0 +1,176 @@
/**
* Silent Send - Cross-Browser Settings Sync
*
* Two sync mechanisms:
*
* 1. Sync Code — base64-encoded JSON snapshot for manual copy-paste between
* browsers (works across any browser/device combination).
*
* 2. browser.storage.sync — automatic sync within the same browser family
* (Firefox ↔ Firefox via Firefox Sync, Chrome ↔ Chrome via Google account).
* Data is chunked to stay within per-item size limits.
*
* Conflict resolution: newest `lastModified` timestamp wins.
*/
import api from './browser-polyfill.js';
// browser.storage.sync per-item limit (leave generous headroom)
const SYNC_CHUNK_SIZE = 5000;
const SYNC_KEY_PREFIX = 'ss_sync_chunk_';
const SYNC_META_KEY = 'ss_sync_meta';
const SilentSendSync = {
// ----------------------------------------------------------------
// Sync Code — manual cross-browser copy-paste
// ----------------------------------------------------------------
/**
* Export all settings/mappings/identity as a compact base64 sync code.
* Share this code with another browser to import.
*/
async exportSyncCode() {
const data = await this._getAllData();
const json = JSON.stringify(data);
// btoa requires ASCII; use encodeURIComponent to handle Unicode
return btoa(unescape(encodeURIComponent(json)));
},
/**
* Import from a sync code string.
* Returns { success, importTime } or { success: false, skipped?, reason, localTime?, importTime? }
*
* Conflict resolution: newest timestamp wins.
* Pass force=true to override even if local is newer.
*/
async importSyncCode(code, { force = false } = {}) {
try {
const json = decodeURIComponent(escape(atob(code.trim())));
const data = JSON.parse(json);
if (!data.version || !data.lastModified) {
return { success: false, reason: 'Invalid sync code — missing version or timestamp.' };
}
if (!force) {
const local = await this._getAllData();
if (local.lastModified && local.lastModified >= data.lastModified) {
return {
success: false,
skipped: true,
reason: 'Local data is the same age or newer.',
localTime: new Date(local.lastModified).toLocaleString(),
importTime: new Date(data.lastModified).toLocaleString(),
};
}
}
await this._applyData(data);
return { success: true, importTime: new Date(data.lastModified).toLocaleString() };
} catch (e) {
return { success: false, reason: e.message };
}
},
// ----------------------------------------------------------------
// browser.storage.sync — automatic within-browser-family sync
// ----------------------------------------------------------------
/**
* Push current local data to browser.storage.sync (chunked).
* Called automatically whenever settings, mappings, or identity change
* and browserSync setting is enabled.
*/
async pushToSyncStorage() {
if (!api.storage?.sync) return;
try {
const data = await this._getAllData();
const json = JSON.stringify(data);
// Split into chunks to stay under per-item quota
const chunks = [];
for (let i = 0; i < json.length; i += SYNC_CHUNK_SIZE) {
chunks.push(json.slice(i, i + SYNC_CHUNK_SIZE));
}
// Remove stale chunks
const staleKeys = await this._getSyncChunkKeys();
if (staleKeys.length > 0) {
await api.storage.sync.remove(staleKeys);
}
// Write new chunks + metadata in one shot
const toWrite = {
[SYNC_META_KEY]: { chunks: chunks.length, lastModified: data.lastModified },
};
chunks.forEach((chunk, i) => { toWrite[SYNC_KEY_PREFIX + i] = chunk; });
await api.storage.sync.set(toWrite);
} catch (e) {
console.warn('[Silent Send] pushToSyncStorage failed:', e);
}
},
/**
* Pull from browser.storage.sync and apply if newer than local.
* Returns { imported: true, time } if data was applied, null otherwise.
*/
async pullFromSyncStorage() {
if (!api.storage?.sync) return null;
try {
const metaResult = await api.storage.sync.get(SYNC_META_KEY);
const syncMeta = metaResult[SYNC_META_KEY];
if (!syncMeta?.chunks) return null;
// Skip if local is already up to date
const local = await this._getAllData();
if (local.lastModified && local.lastModified >= syncMeta.lastModified) return null;
// Reassemble chunks
const chunkKeys = Array.from({ length: syncMeta.chunks }, (_, i) => SYNC_KEY_PREFIX + i);
const chunkResult = await api.storage.sync.get(chunkKeys);
const json = chunkKeys.map(k => chunkResult[k] || '').join('');
const data = JSON.parse(json);
await this._applyData(data);
return { imported: true, time: new Date(data.lastModified).toLocaleString() };
} catch (e) {
console.warn('[Silent Send] pullFromSyncStorage failed:', e);
return null;
}
},
// ----------------------------------------------------------------
// Internal helpers
// ----------------------------------------------------------------
async _getAllData() {
const result = await api.storage.local.get(null);
return {
version: '1',
lastModified: result.ss_lastModified || Date.now(),
identity: result.ss_identity || {},
mappings: result.ss_mappings || [],
settings: result.ss_settings || {},
};
},
async _applyData(data) {
const toSet = { ss_lastModified: data.lastModified };
if (data.identity !== undefined) toSet.ss_identity = data.identity;
if (data.mappings !== undefined) toSet.ss_mappings = data.mappings;
if (data.settings !== undefined) toSet.ss_settings = data.settings;
await api.storage.local.set(toSet);
},
async _getSyncChunkKeys() {
if (!api.storage?.sync) return [];
try {
const all = await api.storage.sync.get(null);
return Object.keys(all).filter(k => k.startsWith(SYNC_KEY_PREFIX));
} catch {
return [];
}
},
};
export default SilentSendSync;