const 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} 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>} 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.} localTree - Snapshot from buildLocalTree * @param {Object.} remoteTree - Snapshot from buildRemoteTree * @param {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.} syncIndex - The sync index * @param {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.} 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.} localTree - The local tree snapshot * @returns {Object.} 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.} 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.} 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>} 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} 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 }; } }