feat: GitHub Gist and custom HTTP URL cloud sync

GitHub Gist sync:
- pushToGist(token): pushes all settings to a private Gist; creates a
  new Gist on first use and stores the Gist ID in local storage so
  subsequent pushes update the same Gist.
- pullFromGist(token): fetches the Gist, compares lastModified, applies
  if newer (raw_url used to avoid API truncation).
- No desktop client needed; works across any browser/OS with a GitHub PAT.
- Options page shows a token field + Push/Pull buttons; Gist ID shown
  once linked.

Custom URL sync:
- pushToUrl({ url, method, headers }): HTTP PUT to any endpoint.
- pullFromUrl({ url, headers }): HTTP GET, applies if newer.
- Works with Nextcloud/ownCloud WebDAV, self-hosted servers, cloud
  functions, or any static-file host that allows PUT.
- Options page shows URL + optional JSON headers field + Push/Pull.

Both methods write ss_sync_notification on apply, triggering the purple
'SYN' badge and desktop notification added in the previous commit.

https://claude.ai/code/session_01TKpSR9M8JgHLXCp5CeDsQP
This commit is contained in:
Claude
2026-03-26 14:44:18 +00:00
parent 1e475196d6
commit cbe5b8584d
3 changed files with 290 additions and 7 deletions
+40
View File
@@ -158,6 +158,46 @@
</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">
+97
View File
@@ -122,6 +122,80 @@ document.addEventListener('DOMContentLoaded', 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);
@@ -605,6 +679,29 @@ function setFileSyncStatus(msg, type) {
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;