Something went wrong. Try again.
Two-way sync Firefox bookmarks with an AT Protocol PDS using network.cosmik.* lexicons.
Something went wrong. Try again.
35 kB · 970 lines
JavaScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971const logSync = createLogger("sync");
const CHANGE_TYPES = { CREATE_LOCAL_BOOKMARK: "CREATE_LOCAL_BOOKMARK", CREATE_REMOTE_CARD: "CREATE_REMOTE_CARD", UPDATE_LOCAL_BOOKMARK: "UPDATE_LOCAL_BOOKMARK", UPDATE_REMOTE_CARD: "UPDATE_REMOTE_CARD", DELETE_LOCAL_BOOKMARK: "DELETE_LOCAL_BOOKMARK", DELETE_REMOTE_CARD: "DELETE_REMOTE_CARD", CREATE_LOCAL_FOLDER: "CREATE_LOCAL_FOLDER", RENAME_LOCAL_FOLDER: "RENAME_LOCAL_FOLDER", DELETE_LOCAL_FOLDER: "DELETE_LOCAL_FOLDER", CREATE_REMOTE_COLLECTION: "CREATE_REMOTE_COLLECTION", LINK_FOLDER_TO_COLLECTION: "LINK_FOLDER_TO_COLLECTION", LINK_BOOKMARK_TO_CARD: "LINK_BOOKMARK_TO_CARD",};
/** * Extracts the record key (rkey) from an AT Protocol URI by taking the last path segment. * @param {string} uri - AT URI (e.g. "at://did:plc:abc/network.cosmik.collection/tid123") * @returns {string} The rkey */function rkeyFromUri(uri) { return uri.split("/").pop();}
/** * Builds an AT Protocol URI from its components. * @param {string} did - The repository DID * @param {string} collection - The collection NSID * @param {string} rkey - The record key * @returns {string} Full AT URI (e.g. "at://did:plc:abc/net.cosmik.collection/tid") */function uriFor(did, collection, rkey) { return `at://${did}/${collection}/${rkey}`;}
/** * Builds a local tree snapshot from the Firefox bookmarks API. * Returns a map of folderId → { id, title, index, bookmarks[] }. * Only includes direct child folders of the sync root and their bookmark children. * @param {string} syncRootId - The ID of the sync root folder * @returns {Promise<Object.<string, {id: string, title: string, index: number, bookmarks: Array}>} Local tree snapshot */function buildLocalTree(syncRootId) { const done = logSync.timer("buildLocalTree"); return browser.bookmarks.getSubTree(syncRootId).then((nodes) => { const root = nodes[0]; if (!root || !root.children) { logSync.debug("buildLocalTree: empty tree", { syncRootId }); return {}; } const tree = {}; for (const folder of root.children) { if (folder.type !== "folder") continue; const bookmarks = (folder.children || []) .filter((c) => c.type === "bookmark") .map((c) => ({ id: c.id, title: c.title, url: c.url, dateAdded: c.dateAdded })); tree[folder.id] = { id: folder.id, title: folder.title, index: folder.index, bookmarks, }; } const counts = Object.values(tree) .map((f) => `${f.title}:${f.bookmarks.length}`) .join(", "); done(`${Object.keys(tree).length} folders [${counts}]`); return tree; });}
/** * Builds a remote tree snapshot from the PDS by fetching all collections, links, and cards. * Returns a map of collection rkey → { rkey, uri, cid, name, createdAt, updatedAt, cards[] }. * @param {PdsClient} pdsClient - The PDS client instance * @param {string} did - The user's DID * @returns {Promise<Object.<string, Object>>} Remote tree snapshot */async function buildRemoteTree(pdsClient, did) { const done = logSync.timer("buildRemoteTree"); logSync.info("Fetching remote records", { did }); const collections = await pdsClient.getAllRecords(COLLECTION, did); const links = await pdsClient.getAllRecords(COLLECTION_LINK, did); const cards = await pdsClient.getAllRecords(CARD, did); logSync.info("Remote records fetched", { collections: collections.length, links: links.length, cards: cards.length, });
const cardsByRkey = {}; for (const c of cards) { const rkey = rkeyFromUri(c.uri); cardsByRkey[rkey] = c; }
const linksByCollection = {}; for (const link of links) { const colRkey = rkeyFromUri(link.value.collection.uri); if (!linksByCollection[colRkey]) linksByCollection[colRkey] = []; linksByCollection[colRkey].push(link); }
const tree = {}; for (const col of collections) { const colRkey = rkeyFromUri(col.uri); const colLinks = linksByCollection[colRkey] || []; const cardsInCol = []; for (const link of colLinks) { const cardRkey = rkeyFromUri(link.value.card.uri); const card = cardsByRkey[cardRkey]; if (card && card.value.type === "URL") { cardsInCol.push({ linkRkey: rkeyFromUri(link.uri), cardRkey: cardRkey, linkUri: link.uri, linkCid: link.cid, cardUri: card.uri, cardCid: card.cid, cardValue: card.value, url: card.value.content && (card.value.content.url || (card.value.content.metadata && card.value.content.metadata.url)), title: (card.value.content && card.value.content.metadata && card.value.content.metadata.title) || "", }); } } tree[colRkey] = { rkey: colRkey, uri: col.uri, cid: col.cid, name: col.value.name, createdAt: col.value.createdAt, updatedAt: col.value.updatedAt, cards: cardsInCol, }; } done(`${Object.keys(tree).length} collections, ${cards.length} cards`); return tree;}
/** * Diffs the local and remote trees against the sync index to produce an ordered list of changes. * Detects: missing folders/bookmarks (create), title mismatches (update), * orphaned index entries (delete), and new remote records (create local). * @param {Object.<string, Object>} localTree - Snapshot from buildLocalTree * @param {Object.<string, Object>} remoteTree - Snapshot from buildRemoteTree * @param {Object.<string, Object>} syncIndex - Current sync index from storage * @param {Object} config - Extension config (used for syncOnDelete) * @returns {Array<{type: string, target?: string, entityType?: string, payload: Object}>} Ordered change list */function computeChangeset(localTree, remoteTree, syncIndex, config) { logSync.debug("computeChangeset"); const changes = [];
/* Phase 1 — Remote → Local: mirror remote collections/collections as local folders/bookmarks */ for (const [colRkey, remoteCol] of Object.entries(remoteTree)) { let localFolderId = findLocalFolderForCollection(colRkey, syncIndex, localTree);
if (!localFolderId) { localFolderId = findLocalFolderByName(remoteCol.name, localTree); if (localFolderId && !syncIndex[localFolderId]) { syncIndex[localFolderId] = { recordUri: remoteCol.uri, recordCid: remoteCol.cid, recordType: "collection", }; changes.push({ type: CHANGE_TYPES.LINK_FOLDER_TO_COLLECTION, payload: { localFolderId, collectionUri: remoteCol.uri, collectionCid: remoteCol.cid, }, }); } else { changes.push({ type: CHANGE_TYPES.CREATE_LOCAL_FOLDER, payload: { title: remoteCol.name, collectionUri: remoteCol.uri }, }); } }
const localFolder = localTree[localFolderId]; if (localFolder && localFolder.title !== remoteCol.name) { changes.push({ type: CHANGE_TYPES.RENAME_LOCAL_FOLDER, target: "local", entityType: "folder", payload: { localId: localFolderId, title: remoteCol.name, }, }); }
if (localFolder) { for (const remoteCard of remoteCol.cards) { const localBookmark = findLocalBookmarkForCard(remoteCard.cardRkey, syncIndex, localFolder); if (!localBookmark) { const urlMatch = findLocalBookmarkByUrl(remoteCard.url, localFolder); if (urlMatch && !syncIndex[urlMatch.id]) { syncIndex[urlMatch.id] = { recordUri: remoteCard.cardUri, recordCid: remoteCard.cardCid, recordType: "card", linkUri: remoteCard.linkUri, }; changes.push({ type: CHANGE_TYPES.LINK_BOOKMARK_TO_CARD, payload: { localBookmarkId: urlMatch.id, cardUri: remoteCard.cardUri, cardCid: remoteCard.cardCid, linkUri: remoteCard.linkUri, localTitle: urlMatch.title || "", remoteTitle: remoteCard.title, }, }); } else { changes.push({ type: CHANGE_TYPES.CREATE_LOCAL_BOOKMARK, target: "local", entityType: "bookmark", payload: { parentId: localFolderId, title: remoteCard.title || remoteCard.url, url: remoteCard.url, index: 0, recordUri: remoteCard.cardUri, recordCid: remoteCard.cardCid, linkUri: remoteCard.linkUri, }, }); } } else { const entry = syncIndex[localBookmark.id]; const baseTitle = entry && entry.localTitle !== undefined ? entry.localTitle : (localBookmark.title || ""); const localTitle = localBookmark.title || ""; const remoteTitle = remoteCard.title; const localChanged = (localTitle !== baseTitle); const remoteChanged = (remoteTitle !== baseTitle);
if (localChanged && remoteChanged) { const localModTime = entry && entry.localModifiedAt ? entry.localModifiedAt : 0; const remoteModTime = remoteCard.cardValue && remoteCard.cardValue.updatedAt ? new Date(remoteCard.cardValue.updatedAt).getTime() : (remoteCard.cardValue && remoteCard.cardValue.createdAt ? new Date(remoteCard.cardValue.createdAt).getTime() : 0); const localWins = localModTime >= remoteModTime; logSync.debug("Title conflict", { localId: localBookmark.id, baseTitle, localTitle, remoteTitle, localModTime, remoteModTime, winner: localWins ? "local" : "remote", }); if (localWins) { changes.push({ type: CHANGE_TYPES.UPDATE_REMOTE_CARD, target: "remote", entityType: "bookmark", payload: { localId: localBookmark.id, title: localTitle, cardUri: remoteCard.cardUri, cardCid: remoteCard.cardCid, }, }); } else { changes.push({ type: CHANGE_TYPES.UPDATE_LOCAL_BOOKMARK, target: "local", entityType: "bookmark", payload: { localId: localBookmark.id, title: remoteTitle, }, }); } } else if (remoteChanged) { logSync.debug("Remote title changed, pulling to local", { localId: localBookmark.id, baseTitle, localTitle, remoteTitle, }); changes.push({ type: CHANGE_TYPES.UPDATE_LOCAL_BOOKMARK, target: "local", entityType: "bookmark", payload: { localId: localBookmark.id, title: remoteTitle, }, }); } else if (localChanged) { logSync.debug("Local title changed, pushing to remote", { localId: localBookmark.id, baseTitle, localTitle, remoteTitle, }); changes.push({ type: CHANGE_TYPES.UPDATE_REMOTE_CARD, target: "remote", entityType: "bookmark", payload: { localId: localBookmark.id, title: localTitle, cardUri: remoteCard.cardUri, cardCid: remoteCard.cardCid, }, }); } } } } }
/* Phase 2 — Remote deletions → Local: delete local folders whose remote collection was removed */ const remoteColRkeys = new Set(Object.keys(remoteTree)); for (const [bookmarkId, entry] of Object.entries(syncIndex)) { if (entry.recordType === "collection" && bookmarkId in localTree) { const colRkey = rkeyFromUri(entry.recordUri); if (!remoteColRkeys.has(colRkey)) { changes.push({ type: CHANGE_TYPES.DELETE_LOCAL_FOLDER, target: "local", entityType: "folder", payload: { localId: bookmarkId }, }); } } }
/* Phase 3 — Local → Remote: create/mirror local folders and bookmarks as remote collections/cards */ for (const [localFolderId, localFolder] of Object.entries(localTree)) { let existingEntry = syncIndex[localFolderId]; if (!existingEntry) { const matchRkey = findRemoteCollectionByName(localFolder.title, remoteTree); if (matchRkey) { const remoteCol = remoteTree[matchRkey]; syncIndex[localFolderId] = { recordUri: remoteCol.uri, recordCid: remoteCol.cid, recordType: "collection", }; existingEntry = syncIndex[localFolderId]; changes.push({ type: CHANGE_TYPES.LINK_FOLDER_TO_COLLECTION, payload: { localFolderId, collectionUri: remoteCol.uri, collectionCid: remoteCol.cid, }, }); } else { changes.push({ type: CHANGE_TYPES.CREATE_REMOTE_COLLECTION, target: "remote", entityType: "folder", payload: { localId: localFolderId, name: localFolder.title, }, }); } }
const remoteCardRkeys = new Set( ( remoteTree[existingEntry ? rkeyFromUri(existingEntry.recordUri) : ""] || { cards: [] } ).cards.map((c) => c.cardRkey), );
for (const bookmark of localFolder.bookmarks) { const bmEntry = syncIndex[bookmark.id]; if (!bmEntry) { changes.push({ type: CHANGE_TYPES.CREATE_REMOTE_CARD, target: "remote", entityType: "bookmark", payload: { localId: bookmark.id, parentLocalFolderId: localFolderId, url: bookmark.url, title: bookmark.title, collectionRkey: existingEntry ? rkeyFromUri(existingEntry.recordUri) : null, }, }); } else { const cardRkey = rkeyFromUri(bmEntry.recordUri); if (!remoteCardRkeys.has(cardRkey)) { changes.push({ type: CHANGE_TYPES.DELETE_LOCAL_BOOKMARK, target: "local", entityType: "bookmark", payload: { localId: bookmark.id, recordUri: bmEntry.recordUri }, }); } } }
if (existingEntry) { const colRkey = rkeyFromUri(existingEntry.recordUri); const remoteCol = remoteTree[colRkey]; if (remoteCol) { const remoteCardRkeySet = new Set(remoteCol.cards.map((c) => c.cardRkey)); for (const bookmark of localFolder.bookmarks) { const bmEntry = syncIndex[bookmark.id]; if (bmEntry) { const cardRkey = rkeyFromUri(bmEntry.recordUri); if (!remoteCardRkeySet.has(cardRkey)) { changes.push({ type: CHANGE_TYPES.DELETE_REMOTE_CARD, target: "remote", entityType: "bookmark", payload: { localId: bookmark.id, recordUri: bmEntry.recordUri }, }); } } } } } }
/* Phase 4 — Orphan index entries: remove stale local bookmarks whose record vanished from both sides */ const remoteCardRkeysAll = new Set(); for (const col of Object.values(remoteTree)) { for (const card of col.cards) remoteCardRkeysAll.add(card.cardRkey); } for (const [bookmarkId, entry] of Object.entries(syncIndex)) { if (entry.recordType === "card") { if (!(bookmarkId in findBookmarkIdMap(localTree))) { if (!remoteCardRkeysAll.has(rkeyFromUri(entry.recordUri))) { changes.push({ type: CHANGE_TYPES.DELETE_LOCAL_BOOKMARK, target: "local", entityType: "bookmark", payload: { localId: bookmarkId, recordUri: entry.recordUri }, }); } } } }
const summary = {}; for (const c of changes) { summary[c.type] = (summary[c.type] || 0) + 1; } logSync.info(`computeChangeset: ${changes.length} total changes`, summary);
return changes;}
/** * Finds the local folder ID that corresponds to a remote collection rkey via the sync index. * @param {string} colRkey - The collection record key * @param {Object.<string, Object>} syncIndex - The sync index * @param {Object.<string, Object>} localTree - The local tree snapshot * @returns {string|null} The local folder ID, or null if not found */function findLocalFolderForCollection(colRkey, syncIndex, localTree) { for (const [bookmarkId, entry] of Object.entries(syncIndex)) { if (entry.recordType === "collection" && entry.recordUri.endsWith(`/${colRkey}`)) { if (bookmarkId in localTree) return bookmarkId; } } return null;}
/** * Finds a local bookmark within a folder that corresponds to a remote card rkey. * @param {string} cardRkey - The card record key * @param {Object.<string, Object>} syncIndex - The sync index * @param {Object} localFolder - A local folder entry from the tree snapshot * @returns {Object|null} The matching bookmark object, or null */function findLocalBookmarkForCard(cardRkey, syncIndex, localFolder) { for (const bm of (localFolder && localFolder.bookmarks) || []) { const entry = syncIndex[bm.id]; if (entry && entry.recordUri && entry.recordUri.endsWith(`/${cardRkey}`)) { return bm; } } return null;}
/** * Builds a set-like object of all bookmark and folder IDs present in the local tree. * @param {Object.<string, Object>} localTree - The local tree snapshot * @returns {Object.<string, boolean>} Map of id → true */function findBookmarkIdMap(localTree) { const map = {}; for (const folder of Object.values(localTree)) { map[folder.id] = true; for (const bm of folder.bookmarks) map[bm.id] = true; } return map;}
/** * Finds a local folder by name in the local tree (fallback when sync index has no entry). * Returns null if ambiguous (multiple folders with same name). * @param {string} folderName - The folder name to search for * @param {Object.<string, Object>} localTree - The local tree snapshot * @returns {string|null} The folder ID, or null */function findLocalFolderByName(folderName, localTree) { let match = null; for (const folder of Object.values(localTree)) { if (folder.title === folderName) { if (match) return null; match = folder.id; } } return match;}
/** * Finds a remote collection by name in the remote tree (fallback when sync index has no entry). * Returns null if ambiguous (multiple collections with same name). * @param {string} collectionName - The collection name to search for * @param {Object.<string, Object>} remoteTree - The remote tree snapshot * @returns {string|null} The collection rkey, or null */function findRemoteCollectionByName(collectionName, remoteTree) { let match = null; for (const [colRkey, col] of Object.entries(remoteTree)) { if (col.name === collectionName) { if (match) return null; match = colRkey; } } return match;}
/** * Finds a local bookmark by URL within a folder (fallback when sync index has no entry). * Returns null if ambiguous (multiple bookmarks with same URL). * @param {string} url - The bookmark URL to search for * @param {Object} localFolder - A local folder entry from the tree snapshot * @returns {Object|null} The matching bookmark object, or null */function findLocalBookmarkByUrl(url, localFolder) { let match = null; for (const bm of (localFolder && localFolder.bookmarks) || []) { if (bm.url === url) { if (match) return null; match = bm; } } return match;}
/** * Applies an ordered list of changes (deletes → updates → link bridges → folder creates → bookmark creates). * Each change is applied individually; errors are logged and collected but don't abort the batch. * @param {Array} changes - Ordered change list from computeChangeset * @param {Object} config - Extension config * @param {PdsClient} pdsClient - The PDS client * @param {string} did - The user's DID * @returns {Promise<Array<Object>>} Log of each change with status and result/error */async function applyChanges(changes, config, pdsClient, did) { const resultLog = []; const deletes = changes.filter( (c) => c.type === CHANGE_TYPES.DELETE_LOCAL_BOOKMARK || c.type === CHANGE_TYPES.DELETE_LOCAL_FOLDER, ); const updates = changes.filter( (c) => c.type === CHANGE_TYPES.UPDATE_LOCAL_BOOKMARK || c.type === CHANGE_TYPES.UPDATE_REMOTE_CARD || c.type === CHANGE_TYPES.RENAME_LOCAL_FOLDER, ); const creates = changes.filter( (c) => c.type === CHANGE_TYPES.CREATE_LOCAL_BOOKMARK || c.type === CHANGE_TYPES.CREATE_LOCAL_FOLDER || c.type === CHANGE_TYPES.CREATE_REMOTE_CARD || c.type === CHANGE_TYPES.CREATE_REMOTE_COLLECTION, ); const links = changes.filter( (c) => c.type === CHANGE_TYPES.LINK_FOLDER_TO_COLLECTION || c.type === CHANGE_TYPES.LINK_BOOKMARK_TO_CARD, );
const folderCreates = creates.filter((c) => c.entityType === "folder"); const bookmarkCreates = creates.filter((c) => c.entityType !== "folder"); /* Order: deletes first (free up names), then updates, then link bridges, then folder creates (parent before children), then bookmark creates */ const ordered = [...deletes, ...updates, ...links, ...folderCreates, ...bookmarkCreates];
logSync.info("applyChanges", { deletes: deletes.length, updates: updates.length, links: links.length, folderCreates: folderCreates.length, bookmarkCreates: bookmarkCreates.length, });
for (const change of ordered) { try { const result = await applyChange(change, config, pdsClient, did); resultLog.push({ ...change, status: "ok", result }); logSync.debug("applyChange ok", { type: change.type, target: change.target }); } catch (err) { resultLog.push({ ...change, status: "error", error: err.message }); logSync.error("applyChange failed", { type: change.type, error: err.message }); } }
const errors = resultLog.filter((l) => l.status === "error"); logSync.info("applyChanges completed", { ok: resultLog.length - errors.length, errors: errors.length, });
return resultLog;}
/** * Applies a single change by its type, creating/updating/deleting local bookmarks or remote records. * @param {Object} change - A single change object from computeChangeset * @param {Object} config - Extension config * @param {PdsClient} pdsClient - The PDS client * @param {string} did - The user's DID * @returns {Promise<Object|boolean|null>} Result of the operation */async function applyChange(change, config, pdsClient, did) { logSync.trace("applyChange", { type: change.type, target: change.target }); switch (change.type) { case CHANGE_TYPES.CREATE_LOCAL_FOLDER: { if (!config.syncRootId) { logSync.error("CREATE_LOCAL_FOLDER: no sync root"); throw new Error("No sync root configured"); } const node = await browser.bookmarks.create({ parentId: config.syncRootId, title: change.payload.title, type: "folder", }); await updateSyncIndexEntry(node.id, { recordUri: change.payload.collectionUri, recordCid: "", recordType: "collection", lastSyncAt: new Date().toISOString(), }); logSync.info("Created local folder", { id: node.id, title: node.title }); return node; }
case CHANGE_TYPES.CREATE_LOCAL_BOOKMARK: { if (!change.payload.parentId) { logSync.error("CREATE_LOCAL_BOOKMARK: no parent folder ID"); throw new Error("No parent folder ID"); } const node = await browser.bookmarks.create({ parentId: change.payload.parentId, title: change.payload.title || "", url: change.payload.url, }); const bookmarkTitle = node.title || ""; const now = new Date().toISOString(); const indexUpdate = { recordType: "card", localTitle: bookmarkTitle, remoteTitle: bookmarkTitle, localModifiedAt: Date.now(), lastSyncAt: now, }; if (change.payload.recordUri) indexUpdate.recordUri = change.payload.recordUri; if (change.payload.linkUri) indexUpdate.linkUri = change.payload.linkUri; await updateSyncIndexEntry(node.id, indexUpdate); logSync.debug("Created local bookmark", { id: node.id, title: node.title }); return node; }
case CHANGE_TYPES.UPDATE_LOCAL_BOOKMARK: { const pulledTitle = change.payload.title || ""; const result = await browser.bookmarks.update(change.payload.localId, { title: pulledTitle, }); await updateSyncIndexEntry(change.payload.localId, { localTitle: pulledTitle, remoteTitle: pulledTitle, localModifiedAt: Date.now(), lastSyncAt: new Date().toISOString(), }); logSync.debug("Updated local bookmark", { id: change.payload.localId, title: pulledTitle }); return result; }
case CHANGE_TYPES.UPDATE_REMOTE_CARD: { logSync.debug("Updating remote card title", { localId: change.payload.localId, title: change.payload.title, }); const entry = await getSyncIndex().then((idx) => idx[change.payload.localId]); if (!entry || !entry.recordUri) { throw new Error(`No sync index entry for bookmark ${change.payload.localId}`); } const cardUri = change.payload.cardUri || entry.recordUri; const rkey = rkeyFromUri(cardUri); const updatedTitle = change.payload.title || ""; const updatedAt = new Date().toISOString();
const existingRecord = await pdsClient.getRecord(CARD, rkey, did); if (!existingRecord) { logSync.error("UPDATE_REMOTE_CARD: card not found on PDS", { rkey }); throw new Error(`Card ${rkey} not found on PDS`); } const oldTitle = (existingRecord.value.content && existingRecord.value.content.metadata && existingRecord.value.content.metadata.title) || ""; logSync.debug("Putting updated card record", { rkey, oldTitle, newTitle: updatedTitle, swapCid: existingRecord.cid, }); const updatedRecord = { ...existingRecord.value, content: { ...(existingRecord.value.content || {}), metadata: { ...((existingRecord.value.content && existingRecord.value.content.metadata) || {}), title: updatedTitle, retrievedAt: updatedAt, }, }, updatedAt, }; const result = await pdsClient.putRecord(CARD, rkey, updatedRecord, did, existingRecord.cid); logSync.debug("putRecord result", { newCid: result.cid, updatedAt }); await updateSyncIndexEntry(change.payload.localId, { recordType: "card", recordUri: result.uri, recordCid: result.cid, linkUri: entry.linkUri || "", localTitle: updatedTitle, remoteTitle: updatedTitle, localModifiedAt: Date.now(), lastSyncAt: updatedAt, }); logSync.info("Updated remote card title", { rkey, oldTitle, newTitle: updatedTitle }); return result; }
case CHANGE_TYPES.RENAME_LOCAL_FOLDER: { const result = await browser.bookmarks.update(change.payload.localId, { title: change.payload.title, }); logSync.debug("Renamed local folder", { id: change.payload.localId }); return result; }
case CHANGE_TYPES.DELETE_LOCAL_BOOKMARK: { await browser.bookmarks.remove(change.payload.localId).catch(() => {}); await removeSyncIndexEntry(change.payload.localId); logSync.debug("Deleted local bookmark", { id: change.payload.localId }); return true; }
case CHANGE_TYPES.DELETE_LOCAL_FOLDER: { await browser.bookmarks.removeTree(change.payload.localId).catch(() => {}); await removeSyncIndexEntry(change.payload.localId); logSync.debug("Deleted local folder", { id: change.payload.localId }); return true; }
case CHANGE_TYPES.CREATE_REMOTE_CARD: { logSync.debug("Creating remote card", { title: change.payload.title, url: change.payload.url, }); const cardRecord = { type: "URL", content: { url: change.payload.url, metadata: { title: change.payload.title, retrievedAt: new Date().toISOString(), }, }, createdAt: new Date().toISOString(), };
const cardRkey = generateTid(); const cardResult = await pdsClient.createRecordWithKey(CARD, cardRkey, cardRecord, did);
let collectionRkey = change.payload.collectionRkey; if (!collectionRkey) { const folderEntry = await getSyncIndex(); const colEntry = folderEntry[change.payload.parentLocalFolderId]; if (colEntry) collectionRkey = rkeyFromUri(colEntry.recordUri); }
const now = new Date().toISOString(); const pushedTitle = change.payload.title || ""; if (collectionRkey) { const linkRecord = { card: { uri: cardResult.uri, cid: cardResult.cid }, collection: { uri: uriFor(did, COLLECTION, collectionRkey), cid: "" }, addedBy: did, addedAt: now, createdAt: now, }; const linkResult = await pdsClient.createRecord(COLLECTION_LINK, linkRecord, did);
await updateSyncIndexEntry(change.payload.localId, { recordUri: cardResult.uri, recordCid: cardResult.cid, recordType: "card", linkUri: linkResult.uri, localTitle: pushedTitle, remoteTitle: pushedTitle, localModifiedAt: Date.now(), lastSyncAt: now, }); } else { await updateSyncIndexEntry(change.payload.localId, { recordUri: cardResult.uri, recordCid: cardResult.cid, recordType: "card", localTitle: pushedTitle, remoteTitle: pushedTitle, localModifiedAt: Date.now(), lastSyncAt: now, }); } return cardResult; }
case CHANGE_TYPES.CREATE_REMOTE_COLLECTION: { logSync.debug("Creating remote collection", { name: change.payload.name }); const colRkey = generateTid(); const colRecord = { name: change.payload.name, accessType: "OPEN", createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; const result = await pdsClient.createRecordWithKey(COLLECTION, colRkey, colRecord, did); await updateSyncIndexEntry(change.payload.localId, { recordUri: result.uri, recordCid: result.cid, recordType: "collection", lastSyncAt: new Date().toISOString(), }); return result; }
case CHANGE_TYPES.DELETE_REMOTE_CARD: { if (!config.syncOnDelete) { logSync.debug("DELETE_REMOTE_CARD skipped (syncOnDelete disabled)", { localId: change.payload.localId, }); return { skipped: true, reason: "syncOnDelete disabled" }; } const rkey = rkeyFromUri(change.payload.recordUri); await pdsClient.deleteRecord(CARD, rkey, did); await removeSyncIndexEntry(change.payload.localId); logSync.debug("Deleted remote card", { localId: change.payload.localId, rkey }); return true; }
case CHANGE_TYPES.LINK_FOLDER_TO_COLLECTION: { await updateSyncIndexEntry(change.payload.localFolderId, { recordUri: change.payload.collectionUri, recordCid: change.payload.collectionCid || "", recordType: "collection", lastSyncAt: new Date().toISOString(), }); logSync.debug("Linked folder to collection", { localFolderId: change.payload.localFolderId, collectionUri: change.payload.collectionUri, }); return true; }
case CHANGE_TYPES.LINK_BOOKMARK_TO_CARD: { const remoteTitle = change.payload.remoteTitle || ""; const localTitle = change.payload.localTitle || ""; await updateSyncIndexEntry(change.payload.localBookmarkId, { recordUri: change.payload.cardUri, recordCid: change.payload.cardCid, recordType: "card", linkUri: change.payload.linkUri, localTitle, remoteTitle, localModifiedAt: Date.now(), lastSyncAt: new Date().toISOString(), }); logSync.debug("Linked bookmark to card", { localBookmarkId: change.payload.localBookmarkId, cardUri: change.payload.cardUri, localTitle, remoteTitle, }); return true; }
default: logSync.warn("Unknown change type", { type: change.type }); return null; }}
/** * Orchestrates a full sync cycle: builds local + remote trees, computes changeset, applies changes. * @param {Object} config - Extension config (needs syncRootId, at minimum) * @param {PdsClient} pdsClient - The PDS client instance * @param {string} [did] - The user's DID (falls back to config.did) * @returns {Promise<{status: string, changes?: number, log?: Array, errors?: Array, message?: string}>} */async function runSync(config, pdsClient, did) { const done = logSync.timer("runSync"); logSync.info("Starting sync cycle");
if (!config.syncRootId) { logSync.warn("No sync root configured, skipping sync"); return { status: "error", message: "No sync root configured" }; }
if (!did) { const cfg = await getConfig(); did = cfg.did; }
if (!pdsClient) { logSync.error("PDS client not available"); return { status: "error", message: "PDS client not available" }; }
try { const remoteTree = await buildRemoteTree(pdsClient, did); const localTree = await buildLocalTree(config.syncRootId); const syncIndex = await getSyncIndex();
const changes = computeChangeset(localTree, remoteTree, syncIndex, config);
if (changes.length === 0) { logSync.info("No changes to sync"); await setConfig({ lastFullSync: new Date().toISOString() }); done("no changes"); return { status: "ok", changes: 0, log: [] }; }
const applyLog = await applyChanges(changes, config, pdsClient, did);
await setConfig({ lastFullSync: new Date().toISOString() });
const errors = applyLog.filter((e) => e.status === "error"); if (errors.length > 0) { logSync.warn("Sync completed with errors", { errors: errors.length }); } else { logSync.info("Sync completed successfully"); }
done(`${changes.length} changes, ${errors.length} errors`); return { status: errors.length > 0 ? "partial" : "ok", changes: changes.length, log: applyLog, errors, }; } catch (err) { logSync.error("Sync failed", { error: err.message }); done(`failed: ${err.message}`); return { status: "error", message: err.message }; }}