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
262 lines
9.0 KiB
JavaScript
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;
|
|
},
|
|
};
|