Merge pull request #6 from outis1one/claude/check-repo-access-VmcDX

Claude/check repo access vmc dx
This commit is contained in:
Outis
2026-03-26 10:50:38 -04:00
committed by GitHub
7 changed files with 595 additions and 14 deletions
+2 -1
View File
@@ -12,7 +12,8 @@
"permissions": [
"storage",
"activeTab",
"scripting"
"scripting",
"notifications"
],
"host_permissions": [
"https://claude.ai/*",
+2 -1
View File
@@ -6,7 +6,8 @@
"permissions": [
"storage",
"activeTab",
"scripting"
"scripting",
"notifications"
],
"host_permissions": [
"https://claude.ai/*",
+50
View File
@@ -146,6 +146,13 @@ const messageHandlers = {
sendResponse({ count: tabCounts.get(tabId) || 0 });
},
async 'sync:notification-seen'() {
// Options page opened — clear the sync badge and pending notification flag
await api.storage.local.remove('ss_sync_notification');
api.action.setBadgeText({ text: '' });
api.action.setBadgeBackgroundColor({ color: '#6b7280' });
},
async 'update:settings'(message) {
await Storage.saveSettings(message.settings);
@@ -309,6 +316,33 @@ api.storage.onChanged.addListener(async (changes, areaName) => {
}
}
// When a sync operation applied new data, show badge + notification
if (areaName === 'local' && changes.ss_sync_notification?.newValue) {
const notif = changes.ss_sync_notification.newValue;
const sourceLabel = {
'file': 'sync folder',
'browser-sync': 'browser account sync',
'code': 'sync code import',
}[notif.source] || 'sync';
// Purple badge — persists until Options is opened
api.action.setBadgeText({ text: 'SYN' });
api.action.setBadgeBackgroundColor({ color: '#7c3aed' });
// Desktop notification
try {
api.notifications.create('ss-sync-applied', {
type: 'basic',
iconUrl: 'icons/icon48.svg',
title: 'Silent Send — Settings Synced',
message: `Settings updated via ${sourceLabel}. Open Options to review.`,
priority: 1,
});
} catch (e) {
// Notifications permission not granted — badge is still visible
}
}
// When sync storage changes (another device pushed new data), pull it into local
if (areaName === 'sync' && (changes.ss_sync_meta || Object.keys(changes).some(k => k.startsWith('ss_sync_chunk_')))) {
const settings = await Storage.getSettings();
@@ -318,6 +352,14 @@ api.storage.onChanged.addListener(async (changes, areaName) => {
}
});
// Clicking a sync notification opens the Options page
api.notifications.onClicked.addListener((notificationId) => {
if (notificationId === 'ss-sync-applied') {
api.runtime.openOptionsPage();
api.notifications.clear(notificationId);
}
});
// --- Set initial state ---
api.runtime.onInstalled.addListener(async () => {
api.action.setBadgeBackgroundColor({ color: '#6b7280' });
@@ -329,6 +371,14 @@ api.runtime.onInstalled.addListener(async () => {
(async () => {
const settings = await Storage.getSettings();
await updateIcon(settings);
// Restore the SYN badge if the user hasn't opened Options since the last sync
const stored = await api.storage.local.get('ss_sync_notification');
if (stored.ss_sync_notification) {
api.action.setBadgeText({ text: 'SYN' });
api.action.setBadgeBackgroundColor({ color: '#7c3aed' });
}
if (settings.browserSync) {
await SilentSendSync.pullFromSyncStorage();
}
+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 => {
+216 -11
View File
@@ -1,14 +1,16 @@
/**
* Silent Send - Cross-Browser Settings Sync
*
* Two sync mechanisms:
* 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.
* 1. Sync Code — base64-encoded JSON snapshot for manual copy-paste.
* 2. browser.storage.sync — automatic within the same browser family.
* 3. Folder sync (File System Access API) — any locally-mounted folder
* including Dropbox, OneDrive, Google Drive, iCloud, Nextcloud, etc.
* 4. GitHub Gist — serverless cloud sync using a personal access token;
* works across any browser/device without a local desktop client.
* 5. Custom HTTP endpoint — any URL supporting GET + PUT (WebDAV,
* self-hosted server, cloud function, etc.).
*
* Conflict resolution: newest `lastModified` timestamp wins.
*/
@@ -65,7 +67,7 @@ const SilentSendSync = {
}
}
await this._applyData(data);
await this._applyData(data, 'code');
return { success: true, importTime: new Date(data.lastModified).toLocaleString() };
} catch (e) {
return { success: false, reason: e.message };
@@ -131,7 +133,7 @@ const SilentSendSync = {
const json = chunkKeys.map(k => chunkResult[k] || '').join('');
const data = JSON.parse(json);
await this._applyData(data);
await this._applyData(data, 'browser-sync');
return { imported: true, time: new Date(data.lastModified).toLocaleString() };
} catch (e) {
console.warn('[Silent Send] pullFromSyncStorage failed:', e);
@@ -154,8 +156,12 @@ const SilentSendSync = {
};
},
async _applyData(data) {
const toSet = { ss_lastModified: data.lastModified };
async _applyData(data, source = 'unknown') {
const toSet = {
ss_lastModified: data.lastModified,
// Signal the service worker to show a badge/notification
ss_sync_notification: { source, time: Date.now() },
};
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;
@@ -171,6 +177,205 @@ const SilentSendSync = {
return [];
}
},
// ----------------------------------------------------------------
// GitHub Gist sync
// Requires a personal access token with the `gist` scope.
// On first push a new secret Gist is created; the Gist ID is stored
// in local storage so all subsequent reads/writes use the same Gist.
// ----------------------------------------------------------------
/**
* Push current data to a GitHub Gist (creates one if no gist ID stored).
* token: GitHub PAT with `gist` scope.
* Returns { success, gistId } or { success: false, reason }.
*/
async pushToGist(token) {
if (!token) return { success: false, reason: 'No GitHub token provided.' };
try {
const data = await this._getAllData();
const content = JSON.stringify(data, null, 2);
const stored = await api.storage.local.get('ss_gist_id');
const gistId = stored.ss_gist_id;
let resp;
if (gistId) {
// Update existing Gist
resp = await fetch(`https://api.github.com/gists/${gistId}`, {
method: 'PATCH',
headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ files: { 'silent-send-sync.json': { content } } }),
});
} else {
// Create new secret Gist
resp = await fetch('https://api.github.com/gists', {
method: 'POST',
headers: { Authorization: `token ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
description: 'Silent Send settings sync',
public: false,
files: { 'silent-send-sync.json': { content } },
}),
});
}
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
return { success: false, reason: err.message || `HTTP ${resp.status}` };
}
const json = await resp.json();
await api.storage.local.set({ ss_gist_id: json.id });
return { success: true, gistId: json.id };
} catch (e) {
return { success: false, reason: e.message };
}
},
/**
* Pull data from a GitHub Gist and apply if newer.
* token: GitHub PAT with `gist` scope.
* Returns { success, imported?, reason? }.
*/
async pullFromGist(token) {
if (!token) return { success: false, reason: 'No GitHub token provided.' };
try {
const stored = await api.storage.local.get('ss_gist_id');
const gistId = stored.ss_gist_id;
if (!gistId) return { success: false, reason: 'No Gist ID stored. Push first.' };
const resp = await fetch(`https://api.github.com/gists/${gistId}`, {
headers: { Authorization: `token ${token}` },
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
return { success: false, reason: err.message || `HTTP ${resp.status}` };
}
const gist = await resp.json();
const file = gist.files?.['silent-send-sync.json'];
if (!file) return { success: false, reason: 'Sync file not found in Gist.' };
// Fetch raw content (may be truncated in the API response)
const rawResp = await fetch(file.raw_url);
const data = JSON.parse(await rawResp.text());
const local = await this._getAllData();
if (data.lastModified <= (local.lastModified || 0)) {
return { success: true, imported: false };
}
await this._applyData(data, 'gist');
return { success: true, imported: true, time: new Date(data.lastModified).toLocaleString() };
} catch (e) {
return { success: false, reason: e.message };
}
},
// ----------------------------------------------------------------
// Custom HTTP endpoint sync
// GET fetches the JSON, PUT/PATCH writes it.
// Works with WebDAV (Nextcloud, ownCloud), any REST endpoint, or a
// simple static file server that supports PUT.
// ----------------------------------------------------------------
/**
* Push to a custom URL via HTTP PUT.
* opts: { url, method = 'PUT', headers = {} }
*/
async pushToUrl({ url, method = 'PUT', headers = {} } = {}) {
if (!url) return { success: false, reason: 'No URL provided.' };
try {
const data = await this._getAllData();
const resp = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(data, null, 2),
});
if (!resp.ok) return { success: false, reason: `HTTP ${resp.status}` };
return { success: true };
} catch (e) {
return { success: false, reason: e.message };
}
},
/**
* Pull from a custom URL via HTTP GET and apply if newer.
* opts: { url, headers = {} }
*/
async pullFromUrl({ url, headers = {} } = {}) {
if (!url) return { success: false, reason: 'No URL provided.' };
try {
const resp = await fetch(url, { headers });
if (!resp.ok) return { success: false, reason: `HTTP ${resp.status}` };
const data = await resp.json();
const local = await this._getAllData();
if (data.lastModified <= (local.lastModified || 0)) {
return { success: true, imported: false };
}
await this._applyData(data, 'url');
return { success: true, imported: true, time: new Date(data.lastModified).toLocaleString() };
} catch (e) {
return { success: false, reason: e.message };
}
},
// ----------------------------------------------------------------
// 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;
+58
View File
@@ -140,6 +140,64 @@
<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.
Works with <strong>any cloud storage that has a desktop sync client</strong>
Dropbox, OneDrive, Google Drive, iCloud Drive, Box, pCloud, Nextcloud,
Synology Drive, or any network share. Just pick the cloud-synced folder.
</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>
<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">GitHub Gist Sync — no desktop client needed</h3>
<p class="section-desc" style="margin-bottom:8px">
Store settings in a private GitHub Gist. Works across any browser or device
with just a GitHub account. Create a
<a href="https://github.com/settings/tokens/new?scopes=gist&description=SilentSend" target="_blank" rel="noopener">
Personal Access Token
</a>
with the <code>gist</code> scope, paste it below, and click Push.
The Gist ID is remembered — future pushes update the same Gist.
</p>
<div style="display:flex;gap:8px;margin-bottom:6px;flex-wrap:wrap">
<input type="password" id="gistToken" placeholder="GitHub PAT (ghp_…)" autocomplete="off"
style="flex:1;min-width:180px;font-family:monospace;font-size:12px;padding:5px 8px;border:1px solid #d1d5db;border-radius:6px">
<button class="btn btn-primary" id="btnGistPush">Push</button>
<button class="btn" id="btnGistPull">Pull</button>
</div>
<div id="gistSyncStatus" style="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">Custom URL Sync</h3>
<p class="section-desc" style="margin-bottom:8px">
Any URL that supports HTTP GET (read) and PUT (write). Works with
Nextcloud/ownCloud WebDAV, a self-hosted server, or a cloud function.
Add custom headers (e.g. Authorization) as JSON if needed.
</p>
<div style="display:flex;gap:8px;margin-bottom:6px;flex-wrap:wrap">
<input type="url" id="customSyncUrl" placeholder="https://…/silent-send-sync.json"
style="flex:2;min-width:200px;font-size:12px;padding:5px 8px;border:1px solid #d1d5db;border-radius:6px">
<input type="text" id="customSyncHeaders" placeholder='{"Authorization":"Bearer …"}' autocomplete="off"
style="flex:1;min-width:160px;font-family:monospace;font-size:11px;padding:5px 8px;border:1px solid #d1d5db;border-radius:6px">
</div>
<div class="bulk-actions">
<button class="btn btn-primary" id="btnUrlPush">Push</button>
<button class="btn" id="btnUrlPull">Pull</button>
</div>
<div id="urlSyncStatus" style="margin-top:6px;font-size:12px;min-height:16px"></div>
</div>
</section>
<section class="section">
+237
View File
@@ -95,6 +95,107 @@ document.addEventListener('DOMContentLoaded', async () => {
setSyncStatus('', 'neutral');
});
// --- File-based auto-sync ---
// Tell the service worker the user has seen any pending sync notification
api.runtime.sendMessage({ type: 'sync:notification-seen' }).catch(() => {});
await api.storage.local.remove('ss_sync_notification');
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();
});
// --- GitHub Gist sync ---
// Restore saved token (session only — never persisted to storage)
{
const stored = await api.storage.local.get('ss_gist_id');
if (stored.ss_gist_id) {
setGistSyncStatus(`Gist ID: ${stored.ss_gist_id.slice(0, 12)}`, 'ok');
}
}
$('#btnGistPush').addEventListener('click', async () => {
const token = $('#gistToken').value.trim();
if (!token) { setGistSyncStatus('Enter your GitHub PAT first.', 'warn'); return; }
setGistSyncStatus('Pushing…', 'neutral');
const r = await SilentSendSync.pushToGist(token);
if (r.success) {
setGistSyncStatus(`Pushed. Gist ID: ${r.gistId.slice(0, 12)}`, 'ok');
} else {
setGistSyncStatus('Push failed: ' + r.reason, 'error');
}
});
$('#btnGistPull').addEventListener('click', async () => {
const token = $('#gistToken').value.trim();
if (!token) { setGistSyncStatus('Enter your GitHub PAT first.', 'warn'); return; }
setGistSyncStatus('Pulling…', 'neutral');
const r = await SilentSendSync.pullFromGist(token);
if (!r.success) {
setGistSyncStatus('Pull failed: ' + r.reason, 'error');
} else if (r.imported) {
setGistSyncStatus(`Pulled (${r.time}). Refreshing…`, 'ok');
mappings = await Storage.getMappings();
settings = await Storage.getSettings();
renderMappings();
renderDomains();
renderLog();
} else {
setGistSyncStatus('Already up to date.', 'ok');
}
});
// --- Custom URL sync ---
$('#btnUrlPush').addEventListener('click', async () => {
const url = $('#customSyncUrl').value.trim();
if (!url) { setUrlSyncStatus('Enter a URL first.', 'warn'); return; }
const headers = parseHeadersField($('#customSyncHeaders').value);
setUrlSyncStatus('Pushing…', 'neutral');
const r = await SilentSendSync.pushToUrl({ url, headers });
if (r.success) {
setUrlSyncStatus('Pushed successfully.', 'ok');
} else {
setUrlSyncStatus('Push failed: ' + r.reason, 'error');
}
});
$('#btnUrlPull').addEventListener('click', async () => {
const url = $('#customSyncUrl').value.trim();
if (!url) { setUrlSyncStatus('Enter a URL first.', 'warn'); return; }
const headers = parseHeadersField($('#customSyncHeaders').value);
setUrlSyncStatus('Pulling…', 'neutral');
const r = await SilentSendSync.pullFromUrl({ url, headers });
if (!r.success) {
setUrlSyncStatus('Pull failed: ' + r.reason, 'error');
} else if (r.imported) {
setUrlSyncStatus(`Pulled (${r.time}). Refreshing…`, 'ok');
mappings = await Storage.getMappings();
settings = await Storage.getSettings();
renderMappings();
renderDomains();
renderLog();
} else {
setUrlSyncStatus('Already up to date.', 'ok');
}
});
// Transfer data
$('#btnExportAll').addEventListener('click', exportAllPlain);
$('#btnExportEncrypted').addEventListener('click', exportAllEncrypted);
@@ -465,6 +566,142 @@ 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, 'file');
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 setGistSyncStatus(msg, type) {
const el = $('#gistSyncStatus');
if (!el) return;
el.textContent = msg;
el.style.color = type === 'ok' ? '#10b981' : type === 'warn' ? '#f59e0b' : type === 'error' ? '#dc2626' : '#6b7280';
}
function setUrlSyncStatus(msg, type) {
const el = $('#urlSyncStatus');
if (!el) return;
el.textContent = msg;
el.style.color = type === 'ok' ? '#10b981' : type === 'warn' ? '#f59e0b' : type === 'error' ? '#dc2626' : '#6b7280';
}
function parseHeadersField(val) {
if (!val || !val.trim()) return {};
try {
return JSON.parse(val.trim());
} catch {
return {};
}
}
function setSyncStatus(msg, type) {
const el = $('#syncStatus');
if (!el) return;