Something went wrong. Try again.
Two-way sync Firefox bookmarks with an AT Protocol PDS using network.cosmik.* lexicons.
Something went wrong. Try again.
orbitmarks options.js
15 kB · 506 lines
JavaScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507if (window.__orbitmarksOptionsLoaded) { throw new Error("options.js loaded twice");}window.__orbitmarksOptionsLoaded = true;
let config = {};let status = {};let pendingPermissionOrigin = null;let pendingGrantCallback = null;
function showPermissionPrompt(origin, message, onGrant) { pendingPermissionOrigin = origin; pendingGrantCallback = onGrant; $("permissionMessage").textContent = message; $("permissionPrompt").style.display = "block";}
function hidePermissionPrompt() { $("permissionPrompt").style.display = "none"; pendingPermissionOrigin = null; pendingGrantCallback = null;}
$("grantPermissionBtn").addEventListener("click", async () => { if (!pendingPermissionOrigin) return; const granted = await browser.permissions.request({ origins: [pendingPermissionOrigin].concat( // For Bluesky's mushroom PDSes let's avoid asking permissions twice: // - The OAuth token retrieval must be done against bsky.social // - Requests for xrpc are done against the PDS itself pendingPermissionOrigin.endsWith(".bsky.network") ? "https://bsky.social/oauth/*" : [], ), }); if (granted) { const cb = pendingGrantCallback; hidePermissionPrompt(); if (cb) cb(); } else { showStatus("Permission denied. Grant it and try again.", "err"); }});
/** * Initializes the options page: loads config and status from background, fills form fields. * @returns {Promise<void>} */async function init() { config = await browser.runtime.sendMessage({ action: "get-config" }); status = await browser.runtime.sendMessage({ action: "get-status" });
const redirectUri = (() => { const url = new URL(browser.identity.getRedirectURL()); const subdomain = url.hostname.split(".")[0]; return `http://127.0.0.1/mozoauth2/${subdomain}`; })(); $("redirectUriPlaceholder").textContent = redirectUri;
const handleInput = $("handle"); if (config.handle) { handleInput.value = config.handle; } else { handleInput.focus(); }
$("customClientId").addEventListener("click", () => { $("clientIdField").hidden = !$("clientIdField").hidden; });
$("clientId").value = config.clientId || ""; $("syncInterval").value = String(config.syncInterval || 5); $("syncOnDelete").checked = !!config.syncOnDelete;
if (config.did && config.pdsUrl) { await updateAvatar(config.handle); showResolved(config.did, config.pdsUrl); }
updateAuthStatus(); updateRootStatus(config.syncRootId);}
/** * Shorthand for document.getElementById. * @param {string} id - Element ID * @returns {HTMLElement} */function $(id) { return document.getElementById(id);}
/** * Displays a status message in the options page. * @param {string} msg - The message text * @param {string} type - CSS class: "ok", "err", or "info" * @returns {void} */function showStatus(msg, type) { const el = $("status"); el.textContent = msg; el.className = `status ${type}`; if (type !== "info") { setTimeout(() => { el.className = "status"; }, 6000); }}
/** * Shows the resolved DID and PDS URL in the UI. * @param {string} did - Resolved DID string * @param {string} pdsUrl - Resolved PDS URL * @returns {void} */function showResolved(did, pdsUrl) { $("resolveResult").style.display = "block"; $("resolvedDid").textContent = did; $("resolvedPdsUrl").textContent = pdsUrl;}
/** * Hides the resolved DID/PDS URL display. * @returns {void} */function hideResolved() { $("resolveResult").style.display = "none";}
/** * Updates the avatar image with current user's. * @param {string} handle - User handle */async function updateAvatar(handle) { const url = new URL(`https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${handle}`); const response = await fetch(url, { headers: { Authorization: "Bearer " } }); const root = response.ok ? await response.json() : {}; const img = $("avatar"); if (root.avatar) { img.alt = `Avatar for user ${handle}`; img.src = root.avatar; } else { img.alt = "Atmosphere Account logo"; img.src = "/icons/atmosphere.svg"; }}
/** * Updates the authentication status indicator (dot + label) and button visibility. * @returns {void} */function updateAuthStatus() { const dot = $("authDot"); const label = $("authLabel");
if (status.authenticated) { dot.className = "dot ok"; label.textContent = "Connected"; $("connectBtn").style.display = "none"; $("disconnectBtn").style.display = ""; } else if (config.handle && config.clientId) { dot.className = "dot off"; label.textContent = "Not authenticated"; $("connectBtn").style.display = ""; $("disconnectBtn").style.display = "none"; } else { dot.className = "dot off"; label.textContent = "Enter your handle and Client ID first"; $("connectBtn").style.display = ""; $("disconnectBtn").style.display = "none"; }}
/** * Updates the sync root status display by fetching the folder title from bookmarks API. * @param {string|null} rootId - The sync root folder ID, or null * @returns {Promise<void>} */async function updateRootStatus(rootId) { if (!rootId) { $("rootStatus").value = "⚠️ No sync root configured. Synced folders will appear under this root."; return; } try { const node = await browser.bookmarks.get(rootId); $("rootStatus").value = `📂 ${node[0].title} (ID: ${rootId})`; } catch { $("rootStatus").value = `Sync root ID ${rootId} not found (may have been deleted).`; }}
/** * Resolves the entered handle via background script and persists the result. * If a host permission is needed (handle domain, did:web, or PDS URL), * shows a Grant Access button — the click handler calls permissions.request() * in a synchronous user-gesture context, avoiding Firefox's restriction on * async Promise chains. * @returns {Promise<void>} */async function doResolve() { const handle = $("handle").value.trim(); if (!handle) { showStatus("Enter a handle first.", "err"); return; }
const btn = $("resolveBtn"); btn.disabled = true; btn.textContent = "Resolving..."; showStatus(""); hideResolved(); hidePermissionPrompt();
try { const result = await browser.runtime.sendMessage({ action: "resolve-handle", handle }); if (result.status === "needs_permission") { btn.disabled = false; btn.textContent = "Resolve"; showPermissionPrompt( result.origin, `Grant access to ${result.origin.replace("/*", "")} to resolve this handle.`, () => doResolve(), ); return; }
if (result.status === "ok") { const pdsOrigin = new URL(result.pdsUrl).origin + "/*"; const hasPermission = await browser.permissions.contains({ origins: [pdsOrigin] }); if (!hasPermission) { btn.disabled = false; btn.textContent = "Resolve"; showPermissionPrompt( pdsOrigin, `Grant access to ${result.pdsUrl} to sync bookmarks with this PDS.`, () => finalizeResolve(handle, result.did, result.pdsUrl), ); return; } await finalizeResolve(handle, result.did, result.pdsUrl); } else { showStatus(`Resolution failed: ${result.message}`, "err"); } } catch (err) { showStatus(`Resolution error: ${err.message}`, "err"); } finally { btn.disabled = false; btn.textContent = "Resolve"; }}
/** * Saves the resolved config and updates the UI. * @param {string} handle * @param {string} did * @param {string} pdsUrl */async function finalizeResolve(handle, did, pdsUrl) { showResolved(did, pdsUrl); await updateAvatar(handle); await browser.runtime.sendMessage({ action: "save-config", config: { handle, did, pdsUrl }, }); config.handle = handle; config.did = did; config.pdsUrl = pdsUrl; showStatus(`Resolved: ${did}`, "ok");}
/** * Initiates OAuth authentication flow via background script. * @returns {Promise<void>} */async function doConnect() { const clientId = $("clientId").value.trim();
if (!config.pdsUrl || !config.did) { showStatus("Resolve your handle first.", "err"); return; } if (!clientId) { showStatus("Enter your OAuth Client ID.", "err"); return; }
await browser.runtime.sendMessage({ action: "save-config", config: { clientId }, }); config.clientId = clientId;
const btn = $("connectBtn"); btn.disabled = true; btn.textContent = "Connecting..."; showStatus("");
try { const result = await browser.runtime.sendMessage({ action: "authenticate" }); if (result.status === "needs_permission") { btn.disabled = false; btn.textContent = "Connect to PDS"; showPermissionPrompt( result.origin, result.message || `Grant access to ${result.origin.replace("/*", "")} to authenticate.`, () => doConnect(), ); return; } if (result.status === "ok") { status = await browser.runtime.sendMessage({ action: "get-status" }); updateAuthStatus(); showStatus("Connected successfully!", "ok"); } else { showStatus(`Authentication failed: ${result.message}`, "err"); } } catch (err) { showStatus(`Authentication error: ${err.message}`, "err"); } finally { btn.disabled = false; btn.textContent = "Connect to PDS"; }}
/** * Disconnects from the PDS by clearing tokens via background script. * @returns {Promise<void>} */async function doDisconnect() { if (!confirm("Disconnect from PDS? This will clear your authentication tokens.")) return; const result = await browser.runtime.sendMessage({ action: "disconnect" }); if (result.status === "ok") { config.accessToken = ""; config.refreshToken = ""; status.authenticated = false; updateAuthStatus(); showStatus("Disconnected.", "info"); }}
/** * Creates a new sync root folder titled "OrbitMarks" at the bookmarks root. * @returns {Promise<void>} */async function createRoot() { const result = await browser.runtime.sendMessage({ action: "set-sync-root", title: "OrbitMarks", }); if (result.status === "ok") { showStatus(`Created sync root folder: "${result.folderTitle}"`, "ok"); updateRootStatus(result.folderId); } else { showStatus(`Error: ${result.message}`, "err"); }}
/** * Opens an inline folder picker showing the full bookmarks tree for selecting an existing folder as sync root. * @returns {Promise<void>} */async function chooseRoot() { const tree = await browser.bookmarks.getTree(); const folders = []; function walk(nodes, depth, prefix) { for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if (node.type === "folder" || node.type === "menu") { const isLast = i === nodes.length - 1; const displayPrefix = depth === 1 ? "" : prefix + (isLast ? "└── " : "├── "); folders.push({ id: node.id, title: node.title, prefix: displayPrefix }); if (node.children) { const childPrefix = depth === 1 ? (isLast ? " " : "│ ") : prefix + (isLast ? " " : "│ "); walk(node.children, depth + 1, childPrefix); } } } } walk(tree, 0, "");
$("rootStatus").hidden = true;
const picker = document.createElement("select"); picker.style.width = "100%"; picker.innerHTML = '<option value="">— Select a folder —</option>'; for (const f of folders) { const opt = document.createElement("option"); opt.value = f.id; opt.textContent = f.prefix + (f.title || "(unnamed)"); picker.appendChild(opt); }
const confirmBtn = document.createElement("button"); confirmBtn.textContent = "Confirm"; confirmBtn.className = "primary";
const container = document.createElement("div"); container.className = "input-group"; container.appendChild(picker); container.appendChild(confirmBtn);
$("rootStatus").after(container);
confirmBtn.addEventListener("click", async () => { if (!picker.value) { showStatus("Please select a folder.", "err"); return; } const result = await browser.runtime.sendMessage({ action: "set-sync-root", folderId: picker.value, }); if (result.status === "ok") { showStatus(`Sync root set to: "${result.folderTitle}"`, "ok"); updateRootStatus(result.folderId); } else { showStatus(`Error: ${result.message}`, "err"); } $("rootStatus").hidden = false; container.remove(); });}
/** * Clears the sync root reference (does not delete the actual bookmarks folder). * @returns {Promise<void>} */async function clearRoot() { if (!confirm("Remove the sync root reference? The bookmarks folder will remain in your tree.")) return; const result = await browser.runtime.sendMessage({ action: "clear-sync-root" }); if (result.status === "ok") { showStatus("Sync root cleared.", "ok"); updateRootStatus(null); }}
/** * Triggers a manual sync and displays the result. * @returns {Promise<void>} */async function syncNow() { const result = await browser.runtime.sendMessage({ action: "sync-now" }); if (result.status === "ok") { showStatus(`Sync complete. ${result.changes} changes applied.`, "ok"); } else if (result.status === "partial") { showStatus(`Sync completed with ${result.errors.length} error(s). Check console.`, "err"); } else { showStatus(`Sync failed: ${result.message}`, "err"); }}
/** * Exports session logs to a downloadable text file. * @returns {Promise<void>} */async function exportLogs() { $("exportLogsBtn").disabled = true; try { const result = await browser.runtime.sendMessage({ action: "get-logs" }); if (result.logs) { const blob = new Blob([result.logs], { type: "text/plain" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `orbitmarks-logs-${new Date().toISOString().replace(/[:.]/g, "-")}.txt`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); showStatus("Logs exported successfully.", "ok"); } else { showStatus("No logs available.", "info"); } } catch (err) { showStatus(`Export failed: ${err.message}`, "err"); } finally { $("exportLogsBtn").disabled = false; }}
/** * Clears all session logs from the background script. * @returns {Promise<void>} */async function clearLogs() { if (!confirm("Clear all session logs? This cannot be undone.")) return; try { await browser.runtime.sendMessage({ action: "clear-logs" }); showStatus("Session logs cleared.", "info"); } catch (err) { showStatus(`Failed to clear logs: ${err.message}`, "err"); }}
$("resolveBtn").addEventListener("click", doResolve);$("connectBtn").addEventListener("click", doConnect);$("disconnectBtn").addEventListener("click", doDisconnect);$("createRoot").addEventListener("click", createRoot);$("chooseRoot").addEventListener("click", chooseRoot);$("clearRoot").addEventListener("click", clearRoot);$("syncNow").addEventListener("click", syncNow);$("exportLogsBtn").addEventListener("click", exportLogs);$("clearLogsBtn").addEventListener("click", clearLogs);
init();