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
This commit is contained in:
215
LinkSyncExtension/utils/api.js
Normal file
215
LinkSyncExtension/utils/api.js
Normal file
@@ -0,0 +1,215 @@
|
||||
// LinkSync API Client
|
||||
// Handles all communication with LinkSyncServer
|
||||
|
||||
const API = {
|
||||
baseUrl: "",
|
||||
token: "",
|
||||
maxRetries: 3,
|
||||
retryDelay: 1000,
|
||||
timeout: 10000,
|
||||
|
||||
async init() {
|
||||
const settings = await this.getSettings();
|
||||
this.baseUrl = (settings.serverUrl || "http://localhost:5000").replace(/\/+$/, "");
|
||||
this.token = settings.apiKey || "";
|
||||
},
|
||||
|
||||
async getSettings() {
|
||||
return new Promise((resolve) => {
|
||||
browser.storage.local.get(
|
||||
["linksync_server_url", "linksync_api_key"],
|
||||
(result) => resolve(result)
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
async request(method, path, body = null) {
|
||||
await this.init();
|
||||
|
||||
if (!this.token) {
|
||||
throw new Error("Not authenticated. Set your API key in settings.");
|
||||
}
|
||||
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||
|
||||
const options = {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
},
|
||||
signal: controller.signal,
|
||||
};
|
||||
|
||||
if (body && method !== "GET") {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}${path}`, options);
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new Error("Authentication failed. Check your API key.");
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
const retryAfter = parseInt(response.headers.get("Retry-After"), 10) || this.retryDelay * attempt;
|
||||
await this.delay(retryAfter);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(
|
||||
errorData?.detail || `Server error: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (contentType && contentType.includes("application/json")) {
|
||||
return await response.json();
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (error.name === "AbortError") {
|
||||
lastError = new Error("Request timed out");
|
||||
}
|
||||
if (attempt < this.maxRetries) {
|
||||
await this.delay(this.retryDelay * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error("Request failed after retries");
|
||||
},
|
||||
|
||||
async get(path) {
|
||||
return this.request("GET", path);
|
||||
},
|
||||
|
||||
async post(path, body) {
|
||||
return this.request("POST", path, body);
|
||||
},
|
||||
|
||||
async put(path, body) {
|
||||
return this.request("PUT", path, body);
|
||||
},
|
||||
|
||||
async delete(path) {
|
||||
return this.request("DELETE", path);
|
||||
},
|
||||
|
||||
async login(username, password) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||
|
||||
const formData = new URLSearchParams();
|
||||
formData.append("username", username);
|
||||
formData.append("password", password);
|
||||
|
||||
const settings = await this.getSettings();
|
||||
this.baseUrl = (settings.serverUrl || "http://localhost:5000").replace(/\/+$/, "");
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: formData.toString(),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => null);
|
||||
throw new Error(error?.detail || "Login failed");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.token = data.access_token;
|
||||
|
||||
await browser.storage.local.set({ linksync_api_key: data.access_token });
|
||||
return data;
|
||||
},
|
||||
|
||||
async testConnection() {
|
||||
await this.init();
|
||||
const response = await fetch(`${this.baseUrl}/health`, {
|
||||
method: "GET",
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Server returned ${response.status}`);
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
// Links
|
||||
async getLinks(params = {}) {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
return this.get(`/api/links/${qs ? "?" + qs : ""}`);
|
||||
},
|
||||
|
||||
async createLink(data) {
|
||||
return this.post("/api/links/", data);
|
||||
},
|
||||
|
||||
async updateLink(id, data) {
|
||||
return this.put(`/api/links/${id}/`, data);
|
||||
},
|
||||
|
||||
async deleteLink(id) {
|
||||
return this.delete(`/api/links/${id}/`);
|
||||
},
|
||||
|
||||
// Collections
|
||||
async getCollections() {
|
||||
return this.get("/api/collections/");
|
||||
},
|
||||
|
||||
async createCollection(data) {
|
||||
return this.post("/api/collections/", data);
|
||||
},
|
||||
|
||||
async updateCollection(id, data) {
|
||||
return this.put(`/api/collections/${id}/`, data);
|
||||
},
|
||||
|
||||
async deleteCollection(id) {
|
||||
return this.delete(`/api/collections/${id}/`);
|
||||
},
|
||||
|
||||
async refreshCollection(id) {
|
||||
return this.post(`/api/collections/${id}/refresh`, {});
|
||||
},
|
||||
|
||||
// Queries
|
||||
async parseQuery(expression) {
|
||||
return this.post(`/api/queries/parse?expression=${encodeURIComponent(expression)}`);
|
||||
},
|
||||
|
||||
async executeQuery(expression, limit = 50) {
|
||||
return this.post(
|
||||
`/api/queries/execute?expression=${encodeURIComponent(expression)}&limit=${limit}`
|
||||
);
|
||||
},
|
||||
|
||||
// Sync
|
||||
async sync(config, browserBookmarks) {
|
||||
return this.post("/api/sync/", {
|
||||
mode: config.mode,
|
||||
deletions_enabled: config.deletions,
|
||||
browser_bookmarks: browserBookmarks,
|
||||
});
|
||||
},
|
||||
|
||||
// Admin
|
||||
async getAdminStats() {
|
||||
return this.get("/api/admin/stats");
|
||||
},
|
||||
|
||||
delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
},
|
||||
};
|
||||
51
LinkSyncExtension/utils/collection.js
Normal file
51
LinkSyncExtension/utils/collection.js
Normal file
@@ -0,0 +1,51 @@
|
||||
// LinkSync Collection Management
|
||||
// Handles collection CRUD and query execution
|
||||
|
||||
const CollectionManager = {
|
||||
async listCollections() {
|
||||
return API.getCollections();
|
||||
},
|
||||
|
||||
async createCollection(name, description, queryType, queryExpression, isPublic) {
|
||||
return API.createCollection({
|
||||
name,
|
||||
description: description || "",
|
||||
query_type: queryType || "static",
|
||||
query_expression: queryExpression || null,
|
||||
is_public: isPublic || false,
|
||||
link_ids: [],
|
||||
});
|
||||
},
|
||||
|
||||
async updateCollection(id, data) {
|
||||
return API.updateCollection(id, data);
|
||||
},
|
||||
|
||||
async deleteCollection(id) {
|
||||
return API.deleteCollection(id);
|
||||
},
|
||||
|
||||
async refreshCollection(id) {
|
||||
return API.refreshCollection(id);
|
||||
},
|
||||
|
||||
async addLinksToCollection(collectionId, linkIds) {
|
||||
return API.post(`/api/collections/${collectionId}/add-links`, linkIds);
|
||||
},
|
||||
|
||||
async removeLinksFromCollection(collectionId, linkIds) {
|
||||
return API.delete(`/api/collections/${collectionId}/remove-links`, { body: linkIds });
|
||||
},
|
||||
|
||||
async executeQuery(expression, limit = 50) {
|
||||
return API.executeQuery(expression, limit);
|
||||
},
|
||||
|
||||
async parseQuery(expression) {
|
||||
return API.parseQuery(expression);
|
||||
},
|
||||
|
||||
async createDynamicCollection(name, description, queryExpression) {
|
||||
return this.createCollection(name, description, "dynamic", queryExpression, false);
|
||||
},
|
||||
};
|
||||
233
LinkSyncExtension/utils/query-engine.js
Normal file
233
LinkSyncExtension/utils/query-engine.js
Normal file
@@ -0,0 +1,233 @@
|
||||
// LinkSync Query Engine
|
||||
// Client-side query parser and executor for building queries
|
||||
|
||||
const QueryEngine = {
|
||||
tokenize(expression) {
|
||||
const tokens = [];
|
||||
let pos = 0;
|
||||
const len = expression.length;
|
||||
|
||||
while (pos < len) {
|
||||
const ch = expression[pos];
|
||||
|
||||
if (ch === " " || ch === "\t") {
|
||||
pos++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "(") {
|
||||
tokens.push({ type: "LPAREN", value: "(" });
|
||||
pos++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === ")") {
|
||||
tokens.push({ type: "RPAREN", value: ")" });
|
||||
pos++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === ",") {
|
||||
tokens.push({ type: "COMMA", value: "," });
|
||||
pos++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (expression.substring(pos, pos + 3) === "AND") {
|
||||
tokens.push({ type: "OPERATOR", value: "AND" });
|
||||
pos += 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (expression.substring(pos, pos + 2) === "OR") {
|
||||
tokens.push({ type: "OPERATOR", value: "OR" });
|
||||
pos += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (expression.substring(pos, pos + 3) === "XOR") {
|
||||
tokens.push({ type: "OPERATOR", value: "XOR" });
|
||||
pos += 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "'" || ch === '"') {
|
||||
const quote = ch;
|
||||
pos++;
|
||||
let value = "";
|
||||
while (pos < len && expression[pos] !== quote) {
|
||||
value += expression[pos];
|
||||
pos++;
|
||||
}
|
||||
pos++;
|
||||
tokens.push({ type: "TERM", value });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/[a-zA-Z0-9_.\-]/.test(ch)) {
|
||||
let value = "";
|
||||
while (pos < len && /[a-zA-Z0-9_.\-:\/?&=%]/.test(expression[pos])) {
|
||||
value += expression[pos];
|
||||
pos++;
|
||||
}
|
||||
|
||||
if (value.includes(":")) {
|
||||
const [field, ...rest] = value.split(":");
|
||||
const fieldValue = rest.join(":");
|
||||
const knownFields = ["url", "tag", "title", "description", "path", "id"];
|
||||
if (knownFields.includes(field.toLowerCase())) {
|
||||
tokens.push({ type: "FIELD", value: field.toUpperCase() });
|
||||
tokens.push({ type: "TERM", value: fieldValue });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
tokens.push({ type: "TERM", value });
|
||||
continue;
|
||||
}
|
||||
|
||||
pos++;
|
||||
}
|
||||
|
||||
return tokens;
|
||||
},
|
||||
|
||||
parse(expression) {
|
||||
if (!expression || !expression.trim()) return null;
|
||||
|
||||
const tokens = this.tokenize(expression);
|
||||
if (tokens.length === 0) return null;
|
||||
|
||||
const parser = new QueryParserState(tokens);
|
||||
return parser.parseOr();
|
||||
},
|
||||
|
||||
buildQueryString(ast) {
|
||||
if (!ast) return "";
|
||||
|
||||
switch (ast.type) {
|
||||
case "TERM":
|
||||
return ast.value;
|
||||
case "TERM_SET":
|
||||
return `(${ast.values.join(", ")})`;
|
||||
case "FIELD":
|
||||
return `${ast.field.toLowerCase()}:${ast.value}`;
|
||||
case "AND":
|
||||
case "OR":
|
||||
case "XOR":
|
||||
return `(${this.buildQueryString(ast.left)} ${ast.type} ${this.buildQueryString(ast.right)})`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
},
|
||||
|
||||
validate(expression) {
|
||||
try {
|
||||
const ast = this.parse(expression);
|
||||
return { valid: true, ast };
|
||||
} catch (e) {
|
||||
return { valid: false, error: e.message };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
class QueryParserState {
|
||||
constructor(tokens) {
|
||||
this.tokens = tokens;
|
||||
this.pos = 0;
|
||||
}
|
||||
|
||||
current() {
|
||||
return this.pos < this.tokens.length ? this.tokens[this.pos] : null;
|
||||
}
|
||||
|
||||
advance() {
|
||||
const token = this.current();
|
||||
this.pos++;
|
||||
return token;
|
||||
}
|
||||
|
||||
expect(type) {
|
||||
const token = this.current();
|
||||
if (!token) throw new Error(`Expected ${type}, got end of input`);
|
||||
if (token.type !== type) throw new Error(`Expected ${type}, got ${token.type}`);
|
||||
return this.advance();
|
||||
}
|
||||
|
||||
parseOr() {
|
||||
let left = this.parseAnd();
|
||||
while (this.current() && this.current().type === "OPERATOR" && this.current().value === "OR") {
|
||||
this.advance();
|
||||
const right = this.parseAnd();
|
||||
left = { type: "OR", left, right };
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
parseAnd() {
|
||||
let left = this.parseXor();
|
||||
while (this.current() && this.current().type === "OPERATOR" && this.current().value === "AND") {
|
||||
this.advance();
|
||||
const right = this.parseXor();
|
||||
left = { type: "AND", left, right };
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
parseXor() {
|
||||
let left = this.parsePrimary();
|
||||
while (this.current() && this.current().type === "OPERATOR" && this.current().value === "XOR") {
|
||||
this.advance();
|
||||
const right = this.parsePrimary();
|
||||
left = { type: "XOR", left, right };
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
parsePrimary() {
|
||||
const token = this.current();
|
||||
if (!token) throw new Error("Unexpected end of input");
|
||||
|
||||
if (token.type === "LPAREN") {
|
||||
this.advance();
|
||||
const node = this.parseOr();
|
||||
this.expect("RPAREN");
|
||||
return node;
|
||||
}
|
||||
|
||||
if (token.type === "FIELD") {
|
||||
const field = this.advance().value;
|
||||
const valueToken = this.current();
|
||||
if (valueToken && valueToken.type === "TERM") {
|
||||
this.advance();
|
||||
return { type: "FIELD", field, value: valueToken.value };
|
||||
}
|
||||
return { type: "FIELD", field, value: "" };
|
||||
}
|
||||
|
||||
if (token.type === "TERM") {
|
||||
return this.parseTerm();
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected token: ${token.value}`);
|
||||
}
|
||||
|
||||
parseTerm() {
|
||||
const token = this.advance();
|
||||
const next = this.current();
|
||||
|
||||
if (next && next.type === "COMMA") {
|
||||
const values = [token.value];
|
||||
while (this.current() && this.current().type === "COMMA") {
|
||||
this.advance();
|
||||
const termToken = this.current();
|
||||
if (termToken && termToken.type === "TERM") {
|
||||
values.push(this.advance().value);
|
||||
}
|
||||
}
|
||||
return { type: "TERM_SET", values };
|
||||
}
|
||||
|
||||
return { type: "TERM", value: token.value };
|
||||
}
|
||||
}
|
||||
261
LinkSyncExtension/utils/sync.js
Normal file
261
LinkSyncExtension/utils/sync.js
Normal file
@@ -0,0 +1,261 @@
|
||||
// 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;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user