fix: PPI + button replaces text immediately; add auto-sync folder

PPI + button fix:
- After adding a mapping, the real PPI value is now immediately replaced
  with the fake value in the input/contenteditable element via the new
  replaceInInput() helper (handles both <textarea>/<input> and
  contenteditable divs by walking text nodes).
- Triggers a re-scan 150ms later so the pre-send warning updates or
  dismisses itself if no more PPI remains.

Auto-sync folder (File System Access API):
- User picks a folder once per browser via "Choose Sync Folder".
  Any settings/mappings/identity change writes silent-send-sync.json
  to that folder automatically (via api.storage.onChanged listener).
- On options page open and every time the page regains focus, the file
  is read back; if its lastModified is newer than local data, settings
  are imported immediately and the UI refreshes.
- File handle is stored in IndexedDB (ss_sync_handles) so it persists
  across browser sessions without repeated permission prompts.
- Pick the SAME folder in each browser (or a synced cloud folder for
  cross-computer sync) — fully automatic after that, no copy-paste.
- sync.js gains saveSyncDirHandle / loadSyncDirHandle / clearSyncDirHandle
  helpers backed by IndexedDB.

https://claude.ai/code/session_01TKpSR9M8JgHLXCp5CeDsQP
This commit is contained in:
Claude
2026-03-26 14:32:53 +00:00
parent 84f6e675db
commit f05375c2ec
4 changed files with 238 additions and 1 deletions
+30 -1
View File
@@ -1134,9 +1134,17 @@
});
await setStorageData('ss_mappings', currentMappings);
// Update local mappings
// Update local mappings so the fetch interceptor uses them immediately
mappings = currentMappings;
// Replace the PPI value in the current input right now
if (inputEl) {
replaceInInput(inputEl, real, fake);
// Re-scan — will dismiss warning if no more PPI remains
if (inputScanTimer) clearTimeout(inputScanTimer);
inputScanTimer = setTimeout(() => scanInputForPPI(inputEl), 150);
}
// Visual feedback
btn.textContent = '\u2714';
btn.style.color = '#4ade80';
@@ -1145,6 +1153,27 @@
});
}
// Replace all occurrences of `real` with `fake` in an input or contenteditable element
function replaceInInput(el, real, fake) {
if (!el) return;
const re = new RegExp(real.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
if (el.value !== undefined) {
// <textarea> or <input>
el.value = el.value.replace(re, fake);
} else if (el.isContentEditable) {
// contenteditable div — walk text nodes to avoid breaking inner HTML
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
let node;
while ((node = walker.nextNode())) {
re.lastIndex = 0;
if (re.test(node.textContent)) {
re.lastIndex = 0;
node.textContent = node.textContent.replace(re, fake);
}
}
}
}
// Storage helpers for page world (uses postMessage to injector)
function getStorageData(key) {
return new Promise(resolve => {
+55
View File
@@ -171,6 +171,61 @@ const SilentSendSync = {
return [];
}
},
// ----------------------------------------------------------------
// File System Access API helpers — folder-based sync
// The directory handle is stored in IndexedDB so the user only
// needs to grant access once per browser session.
// ----------------------------------------------------------------
_dbPromise: null,
_openDB() {
if (this._dbPromise) return this._dbPromise;
this._dbPromise = new Promise((resolve, reject) => {
const req = indexedDB.open('ss_sync_handles', 1);
req.onupgradeneeded = () => req.result.createObjectStore('handles');
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
return this._dbPromise;
},
async saveSyncDirHandle(handle) {
const db = await this._openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction('handles', 'readwrite');
tx.objectStore('handles').put(handle, 'syncDir');
tx.oncomplete = resolve;
tx.onerror = () => reject(tx.error);
});
},
async loadSyncDirHandle() {
try {
const db = await this._openDB();
return new Promise((resolve) => {
const tx = db.transaction('handles', 'readonly');
const req = tx.objectStore('handles').get('syncDir');
req.onsuccess = () => resolve(req.result || null);
req.onerror = () => resolve(null);
});
} catch {
return null;
}
},
async clearSyncDirHandle() {
try {
const db = await this._openDB();
return new Promise((resolve) => {
const tx = db.transaction('handles', 'readwrite');
tx.objectStore('handles').delete('syncDir');
tx.oncomplete = resolve;
tx.onerror = resolve;
});
} catch { /* ignore */ }
},
};
export default SilentSendSync;
+17
View File
@@ -140,6 +140,23 @@
<div id="syncStatus" style="margin-top:8px;font-size:12px;min-height:16px"></div>
</div>
<div style="margin-top:20px;padding-top:16px;border-top:1px solid #e5e7eb">
<h3 style="font-size:13px;font-weight:600;margin:0 0 4px">Auto-Sync Folder — fully automatic, no copy-paste</h3>
<p class="section-desc" style="margin-bottom:8px">
Pick the same folder in each browser once. Changes are written to
<code>silent-send-sync.json</code> automatically whenever settings change,
and read back whenever this page is opened or regains focus.
Use a shared folder (Dropbox, OneDrive, iCloud Drive, or any network share)
for cross-computer sync too.
</p>
<div class="bulk-actions" style="align-items:center">
<button class="btn btn-primary" id="btnPickSyncFolder">Choose Sync Folder</button>
<span id="syncFolderName" style="font-size:12px;color:#6b7280;font-family:monospace"></span>
<button class="btn btn-danger btn-sm" id="btnClearSyncFolder" style="display:none">Clear</button>
</div>
<div id="fileSyncStatus" style="margin-top:6px;font-size:12px;min-height:16px"></div>
</div>
</section>
<section class="section">
+136
View File
@@ -95,6 +95,29 @@ document.addEventListener('DOMContentLoaded', async () => {
setSyncStatus('', 'neutral');
});
// --- File-based auto-sync ---
await initFileSync();
$('#btnPickSyncFolder').addEventListener('click', pickSyncFolder);
$('#btnClearSyncFolder').addEventListener('click', async () => {
await SilentSendSync.clearSyncDirHandle();
syncDirHandle = null;
updateFileSyncUI();
setFileSyncStatus('Sync folder cleared.', 'neutral');
});
// Auto-write when local storage changes (catches popup edits, page-world adds, etc.)
api.storage.onChanged.addListener(async (changes, area) => {
if (area === 'local' && (changes.ss_settings || changes.ss_mappings || changes.ss_identity)) {
await writeToSyncFile();
}
});
// Re-check sync file whenever the options page regains focus
window.addEventListener('focus', async () => {
await checkFileSyncUpdate();
});
// Transfer data
$('#btnExportAll').addEventListener('click', exportAllPlain);
$('#btnExportEncrypted').addEventListener('click', exportAllEncrypted);
@@ -465,6 +488,119 @@ async function importAll(e) {
e.target.value = '';
}
// ----------------------------------------------------------------
// File-based auto-sync (File System Access API)
// ----------------------------------------------------------------
let syncDirHandle = null;
const SYNC_FILE_NAME = 'silent-send-sync.json';
async function initFileSync() {
syncDirHandle = await SilentSendSync.loadSyncDirHandle();
updateFileSyncUI();
if (syncDirHandle) {
await checkFileSyncUpdate();
}
}
async function pickSyncFolder() {
if (!window.showDirectoryPicker) {
setFileSyncStatus('Your browser does not support the File System Access API.', 'error');
return;
}
try {
const handle = await window.showDirectoryPicker({ mode: 'readwrite', id: 'ss-sync' });
syncDirHandle = handle;
await SilentSendSync.saveSyncDirHandle(handle);
updateFileSyncUI();
// Write current settings immediately so the file exists for the other browser
await writeToSyncFile();
setFileSyncStatus('Sync folder set. Settings will sync automatically.', 'ok');
} catch (e) {
if (e.name !== 'AbortError') {
setFileSyncStatus('Could not set sync folder: ' + e.message, 'error');
}
}
}
async function writeToSyncFile() {
if (!syncDirHandle) return;
try {
// Re-verify permission is still granted (required after browser restart)
const perm = await syncDirHandle.requestPermission({ mode: 'readwrite' });
if (perm !== 'granted') return;
const data = await SilentSendSync._getAllData();
const fileHandle = await syncDirHandle.getFileHandle(SYNC_FILE_NAME, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(JSON.stringify(data, null, 2));
await writable.close();
} catch (e) {
// Permission denied or folder removed — don't spam errors
console.warn('[Silent Send] writeToSyncFile failed:', e.message);
}
}
async function checkFileSyncUpdate() {
if (!syncDirHandle) return;
try {
const perm = await syncDirHandle.queryPermission({ mode: 'readwrite' });
if (perm === 'prompt') {
// Need a user gesture to re-request — skip silently
return;
}
if (perm !== 'granted') return;
const fileHandle = await syncDirHandle.getFileHandle(SYNC_FILE_NAME);
const file = await fileHandle.getFile();
const data = JSON.parse(await file.text());
if (!data.version || !data.lastModified) return;
const local = await SilentSendSync._getAllData();
if (data.lastModified > (local.lastModified || 0)) {
await SilentSendSync._applyData(data);
mappings = await Storage.getMappings();
settings = await Storage.getSettings();
$('#browserSync').checked = settings.browserSync === true;
renderMappings();
renderDomains();
renderLog();
setFileSyncStatus(
'Auto-synced from folder (' + new Date(data.lastModified).toLocaleString() + ').',
'ok'
);
}
} catch (e) {
if (e.name !== 'NotFoundError') {
console.warn('[Silent Send] checkFileSyncUpdate failed:', e.message);
}
}
}
function updateFileSyncUI() {
const nameEl = $('#syncFolderName');
const clearBtn = $('#btnClearSyncFolder');
const pickBtn = $('#btnPickSyncFolder');
if (!nameEl) return;
if (syncDirHandle) {
nameEl.textContent = syncDirHandle.name + '/';
clearBtn.style.display = '';
pickBtn.textContent = 'Change Folder';
} else {
nameEl.textContent = '';
clearBtn.style.display = 'none';
pickBtn.textContent = 'Choose Sync Folder';
}
}
function setFileSyncStatus(msg, type) {
const el = $('#fileSyncStatus');
if (!el) return;
el.textContent = msg;
el.style.color = type === 'ok' ? '#10b981' : type === 'warn' ? '#f59e0b' : type === 'error' ? '#dc2626' : '#6b7280';
}
function setSyncStatus(msg, type) {
const el = $('#syncStatus');
if (!el) return;