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:
+30
-1
@@ -1134,9 +1134,17 @@
|
|||||||
});
|
});
|
||||||
await setStorageData('ss_mappings', currentMappings);
|
await setStorageData('ss_mappings', currentMappings);
|
||||||
|
|
||||||
// Update local mappings
|
// Update local mappings so the fetch interceptor uses them immediately
|
||||||
mappings = currentMappings;
|
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
|
// Visual feedback
|
||||||
btn.textContent = '\u2714';
|
btn.textContent = '\u2714';
|
||||||
btn.style.color = '#4ade80';
|
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)
|
// Storage helpers for page world (uses postMessage to injector)
|
||||||
function getStorageData(key) {
|
function getStorageData(key) {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
|
|||||||
@@ -171,6 +171,61 @@ const SilentSendSync = {
|
|||||||
return [];
|
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;
|
export default SilentSendSync;
|
||||||
|
|||||||
@@ -140,6 +140,23 @@
|
|||||||
|
|
||||||
<div id="syncStatus" style="margin-top:8px;font-size:12px;min-height:16px"></div>
|
<div id="syncStatus" style="margin-top:8px;font-size:12px;min-height:16px"></div>
|
||||||
</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>
|
||||||
|
|
||||||
<section class="section">
|
<section class="section">
|
||||||
|
|||||||
@@ -95,6 +95,29 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||||||
setSyncStatus('', 'neutral');
|
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
|
// Transfer data
|
||||||
$('#btnExportAll').addEventListener('click', exportAllPlain);
|
$('#btnExportAll').addEventListener('click', exportAllPlain);
|
||||||
$('#btnExportEncrypted').addEventListener('click', exportAllEncrypted);
|
$('#btnExportEncrypted').addEventListener('click', exportAllEncrypted);
|
||||||
@@ -465,6 +488,119 @@ async function importAll(e) {
|
|||||||
e.target.value = '';
|
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) {
|
function setSyncStatus(msg, type) {
|
||||||
const el = $('#syncStatus');
|
const el = $('#syncStatus');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user