Files
myworkspace/LinkSyncExtension/utils/sync.js
DavidSaylor 09d30427f4 Complete LinkSyncServer and LinkSyncExtension implementation
LinkSyncServer:
- Fix app.py imports, add CORS middleware, lifespan events
- Create api/routes.py router aggregator
- Create config/settings.py for centralized configuration
- Rewrite models/base.py with proper relationships and serialization
- Rewrite all API endpoints with real DB integration (auth, links, collections, sync, queries, tags)
- Add admin endpoints (user management, stats, audit log)
- Complete query parser with recursive descent and proper precedence
- Complete query executor with set operations and field filters
- Set up Alembic migrations with initial schema
- Create web interface (templates, CSS, JS)
- Add 42 passing tests (auth, links, collections, queries)
- Add deploy.ps1 and deploy.sh scripts
- Update README with deployment workflow

LinkSyncExtension:
- Create utils/api.js (REST client with retries, auth, error handling)
- Create utils/sync.js (3 sync modes + conflict detection)
- Create utils/collection.js (collection management)
- Create utils/query-engine.js (client-side query parser)
- Rewrite background.js (sync loop, bookmark events, message routing)
- Rewrite popup.js (tabs, settings modal, notifications, CRUD)
- Update popup.html (tabbed interface, query builder, modal)
- Update popup.css (full redesign)
- Create content/content.js (page metadata extraction)
- Create options.html/js (dedicated settings page)
- Generate icons (48x48, 96x96)
- Update manifest.json (host permissions, content scripts, options)
- Create AGENTS.md
2026-05-19 13:21:26 -05:00

262 lines
9.0 KiB
JavaScript

// LinkSync Sync Engine
// Handles all three sync modes with conflict resolution
const SyncEngine = {
MODES: {
BIDIRECTIONAL: "bi-directional",
BROWSER_AUTHORITY: "browser-authoritative",
SERVER_AUTHORITY: "server-authoritative",
},
async getConfig() {
const result = await browser.storage.local.get([
"linksync_sync_mode",
"linksync_deletions",
"linksync_collection",
]);
return {
mode: result.linksync_sync_mode || this.MODES.BIDIRECTIONAL,
deletions: result.linksync_deletions === true,
collection: result.linksync_collection || null,
};
},
async runSync() {
const config = await this.getConfig();
const browserBookmarks = await this.getBrowserBookmarks();
const serverBookmarks = await API.getLinks({ limit: 1000 });
let actions = [];
switch (config.mode) {
case this.MODES.BIDIRECTIONAL:
actions = await this.bidirectionalSync(browserBookmarks, serverBookmarks, config);
break;
case this.MODES.BROWSER_AUTHORITY:
actions = await this.browserAuthoritativeSync(browserBookmarks, serverBookmarks, config);
break;
case this.MODES.SERVER_AUTHORITY:
actions = await this.serverAuthoritativeSync(browserBookmarks, serverBookmarks, config);
break;
}
await browser.storage.local.set({
linksync_last_sync: new Date().toISOString(),
});
return actions;
},
async getBrowserBookmarks() {
const tree = await browser.bookmarks.getTree();
const flat = [];
this.flattenBookmarks(tree, flat);
return flat;
},
flattenBookmarks(nodes, result, path = "") {
for (const node of nodes) {
if (node.url) {
result.push({
id: node.id,
url: node.url,
title: node.title || "",
dateAdded: node.dateAdded ? new Date(node.dateAdded).toISOString() : null,
path: path,
});
}
if (node.children && node.children.length > 0) {
const newPath = node.title ? `${path}/${node.title}` : path;
this.flattenBookmarks(node.children, result, newPath);
}
}
},
async bidirectionalSync(browserBookmarks, serverBookmarks, config) {
const actions = [];
const serverByUrl = {};
const browserByUrl = {};
for (const bm of serverBookmarks || []) {
serverByUrl[bm.url.toLowerCase()] = bm;
}
for (const bm of browserBookmarks) {
browserByUrl[bm.url.toLowerCase()] = bm;
}
// Push new/updated browser bookmarks to server
for (const bm of browserBookmarks) {
const key = bm.url.toLowerCase();
const serverBm = serverByUrl[key];
if (!serverBm) {
try {
const created = await API.createLink({
url: bm.url,
title: bm.title,
tags: [],
path: bm.path,
});
actions.push({ type: "create", url: bm.url, target: "server", id: created?.id });
} catch (e) {
actions.push({ type: "error", url: bm.url, target: "server", error: e.message });
}
}
}
// Push new server bookmarks to browser
for (const bm of serverBookmarks || []) {
const key = bm.url.toLowerCase();
const browserBm = browserByUrl[key];
if (!browserBm) {
try {
const created = await browser.bookmarks.create({
url: bm.url,
title: bm.title,
});
actions.push({ type: "create", url: bm.url, target: "browser", id: created?.id });
} catch (e) {
actions.push({ type: "error", url: bm.url, target: "browser", error: e.message });
}
}
}
// Handle deletions
if (config.deletions) {
for (const key in serverByUrl) {
if (!browserByUrl[key]) {
try {
await API.deleteLink(serverByUrl[key].id);
actions.push({ type: "delete", url: key, target: "server" });
} catch (e) {
actions.push({ type: "error", url: key, target: "server", error: e.message });
}
}
}
}
return actions;
},
async browserAuthoritativeSync(browserBookmarks, serverBookmarks, config) {
const actions = [];
const serverByUrl = {};
for (const bm of serverBookmarks || []) {
serverByUrl[bm.url.toLowerCase()] = bm;
}
// Push all browser bookmarks to server (overwriting if exists)
for (const bm of browserBookmarks) {
const key = bm.url.toLowerCase();
const serverBm = serverByUrl[key];
if (!serverBm) {
try {
const created = await API.createLink({
url: bm.url,
title: bm.title,
tags: [],
path: bm.path,
});
actions.push({ type: "create", url: bm.url, target: "server", id: created?.id });
} catch (e) {
actions.push({ type: "error", url: bm.url, target: "server", error: e.message });
}
} else {
try {
await API.updateLink(serverBm.id, {
url: bm.url,
title: bm.title,
});
actions.push({ type: "update", url: bm.url, target: "server" });
} catch (e) {
actions.push({ type: "error", url: bm.url, target: "server", error: e.message });
}
}
}
// Delete server bookmarks not in browser
if (config.deletions) {
const browserUrls = new Set(browserBookmarks.map((b) => b.url.toLowerCase()));
for (const key in serverByUrl) {
if (!browserUrls.has(key)) {
try {
await API.deleteLink(serverByUrl[key].id);
actions.push({ type: "delete", url: key, target: "server" });
} catch (e) {
actions.push({ type: "error", url: key, target: "server", error: e.message });
}
}
}
}
return actions;
},
async serverAuthoritativeSync(browserBookmarks, serverBookmarks, config) {
const actions = [];
const browserByUrl = {};
for (const bm of browserBookmarks) {
browserByUrl[bm.url.toLowerCase()] = bm;
}
// Download all server bookmarks to browser
for (const bm of serverBookmarks || []) {
const key = bm.url.toLowerCase();
const browserBm = browserByUrl[key];
if (!browserBm) {
try {
const created = await browser.bookmarks.create({
url: bm.url,
title: bm.title,
});
actions.push({ type: "create", url: bm.url, target: "browser", id: created?.id });
} catch (e) {
actions.push({ type: "error", url: bm.url, target: "browser", error: e.message });
}
} else {
// Overwrite local on conflict
try {
await browser.bookmarks.update(browserBm.id, {
title: bm.title,
});
actions.push({ type: "update", url: bm.url, target: "browser" });
} catch (e) {
actions.push({ type: "error", url: bm.url, target: "browser", error: e.message });
}
}
}
return actions;
},
detectConflicts(browserBookmarks, serverBookmarks) {
const conflicts = [];
const serverByUrl = {};
for (const bm of serverBookmarks || []) {
serverByUrl[bm.url.toLowerCase()] = bm;
}
for (const bm of browserBookmarks) {
const key = bm.url.toLowerCase();
const serverBm = serverByUrl[key];
if (serverBm && serverBm.title !== bm.title) {
conflicts.push({
url: bm.url,
browserTitle: bm.title,
serverTitle: serverBm.title,
browserId: bm.id,
serverId: serverBm.id,
});
}
}
return conflicts;
},
};