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
216 lines
6.4 KiB
JavaScript
216 lines
6.4 KiB
JavaScript
// 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));
|
|
},
|
|
};
|