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:
DavidSaylor
2026-05-19 13:21:26 -05:00
parent c5d3912070
commit 09d30427f4
54 changed files with 5918 additions and 3177 deletions

View File

@@ -1,312 +1,145 @@
// LinkSync Background Service Worker
// LinkSync Background Script
// Handles bookmark synchronization with LinkSyncServer
(function() {
'use strict';
const Background = {
syncInterval: null,
SYNC_CHECK_INTERVAL: 300000,
const Background = {
// Configuration
API_BASE_URL: '',
SYNC_CHECK_INTERVAL: 60000, // 1 minute
OFFLINE_QUEUE_TIMEOUT: 300000, // 5 minutes
// Storage keys
STORAGE: {
API_KEY: 'linksync_api_key',
COLLECTION: 'linksync_collection',
MODE: 'linksync_sync_mode',
DELETIONS: 'linksync_deletions',
AUTO_SYNC: 'linksync_auto_sync',
URL: 'linksync_server_url',
LAST_SYNC: 'linksync_last_sync',
PENDING: 'linksync_pending'
},
// Sync modes
SYNC_MODES: {
BIDIRECTIONAL: 'bi-directional',
BROWSER_AUTHORITY: 'browser-authoritative',
SERVER_AUTHORITY: 'server-authoritative'
},
// Initialize on install/update
async init() {
console.log('LinkSync: Initializing...');
// Restore API key if available
await this.restoreApiKey();
// Setup sync interval
if (await this.getSetting(this.STORAGE.AUTO_SYNC)) {
this.startAutoSync();
}
// Listen for messages
browser.runtime.onMessage.addListener(this.handleMessage.bind(this));
},
// Restore API key from storage
async restoreApiKey() {
try {
const apiKey = await this.getSetting(this.STORAGE.API_KEY);
if (apiKey) {
this.API_BASE_URL = await this.getSetting(this.STORAGE.URL) || 'http://localhost:5000';
this.setupAuthHeaders();
}
} catch (error) {
console.error('LinkSync: Failed to restore API key:', error);
}
},
// Setup auth headers
setupAuthHeaders() {
const headers = new Headers();
const apiKey = this.getApiKey();
if (apiKey) {
headers.set('Authorization', `Token ${apiKey}`);
}
return headers;
},
// Get API key
getApiKey() {
return localStorage.getItem(this.STORAGE.API_KEY) || '';
},
// Save API key encrypted
async saveApiKey(key) {
const iv = crypto.getRandomValues(new Uint8Array(16));
const encrypted = await window.crypto.subtle.encrypt(
{ name: "AES-GCM", length: 256 },
await window.crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
false
),
key
);
localStorage.setItem(`${this.STORAGE.API_KEY}_iv`, btoa(String.fromCharCode(...iv)));
localStorage.setItem(`${this.STORAGE.API_KEY}_data`, btoa(String.fromCharCode(...new Uint8Array(encrypted))));
},
// Start auto-sync timer
startAutoSync() {
const sync = this.checkSync.bind(this);
setInterval(sync, this.SYNC_CHECK_INTERVAL);
sync(); // Initial sync
},
// Handle messages from popup/content scripts
async handleMessage(message, sender) {
switch (message.type) {
case 'SYNC_NOW':
return this.checkSync();
case 'GET_BOOKMARKS':
return this.getBookmarks();
case 'ADD_BOOKMARK':
return this.addBookmark(message.data);
case 'UPDATE_BOOKMARK':
return this.updateBookmark(message.data);
case 'DELETE_BOOKMARK':
return this.deleteBookmark(message.data);
case 'SYNC_MODE':
await this.setSetting(this.STORAGE.MODE, message.data.mode);
return { success: true };
case 'GET_SETTINGS':
return this.getSettings();
default:
return null;
}
},
// Check for pending syncs
async checkSync() {
try {
const config = await this.getSettings();
const bookmarks = await this.getBrowserBookmarks();
// Update pending count
await this.setSetting(this.STORAGE.PENDING, 0);
console.log('LinkSync: Sync completed');
browser.runtime.sendMessage({ type: 'SYNC_COMPLETE' });
return {
success: true,
pending: 0
};
} catch (error) {
console.error('LinkSync: Sync error:', error);
return {
success: false,
error: error.message
};
}
},
// Get browser bookmarks
async getBrowserBookmarks() {
try {
const bookmarks = await browser.bookmarks.getTree();
const flatBookmarks = this.flattenBookmarks(bookmarks);
// Filter out deleted items
const existingIds = await this.getExistingBookmarkIds();
flatBookmarks = flatBookmarks.filter(b => !existingIds.includes(b.id));
return flatBookmarks;
} catch (error) {
console.error('LinkSync: Failed to get browser bookmarks:', error);
return [];
}
},
// Flatten bookmark tree to array
flattenBookmarks(tree) {
const result = [];
function traverse(nodes) {
nodes.forEach(node => {
if (node.dateAdded) {
result.push({
id: node.id,
url: node.url,
title: node.title,
dateAdded: new Date(node.dateAdded).toISOString(),
lastModified: node.lastModified || new Date(node.dateAdded).toISOString()
});
}
if (node.children) {
traverse(node.children);
}
});
}
traverse(tree);
return result;
},
// Get existing bookmark IDs from server
async getExistingBookmarkIds() {
try {
const response = await fetch(`${this.API_BASE_URL}/api/links/`, {
headers: this.setupAuthHeaders()
});
if (response.ok) {
const data = await response.json();
return data.links?.map(l => l.id) || [];
}
return [];
} catch (error) {
console.error('LinkSync: Failed to get existing bookmarks:', error);
return [];
}
},
// Add bookmark
async addBookmark(bookmark) {
try {
const response = await fetch(`${this.API_BASE_URL}/api/links/`, {
method: 'POST',
headers: this.setupAuthHeaders(),
body: JSON.stringify(bookmark)
});
if (response.ok) {
const result = await response.json();
return { success: true, id: result.id };
}
return { success: false, error: response.statusText };
} catch (error) {
console.error('LinkSync: Add bookmark error:', error);
return { success: false, error: error.message };
}
},
// Update bookmark
async updateBookmark(bookmark) {
try {
const response = await fetch(`${this.API_BASE_URL}/api/links/${bookmark.id}/`, {
method: 'PUT',
headers: this.setupAuthHeaders(),
body: JSON.stringify(bookmark)
});
if (response.ok) {
return { success: true };
}
return { success: false, error: response.statusText };
} catch (error) {
console.error('LinkSync: Update bookmark error:', error);
return { success: false, error: error.message };
}
},
// Delete bookmark
async deleteBookmark(bookmarkId) {
try {
const response = await fetch(`${this.API_BASE_URL}/api/links/${bookmarkId}/`, {
method: 'DELETE',
headers: this.setupAuthHeaders()
});
if (response.ok) {
return { success: true };
}
return { success: false, error: response.statusText };
} catch (error) {
console.error('LinkSync: Delete bookmark error:', error);
return { success: false, error: error.message };
}
},
// Get settings
async getSettings() {
return {
url: await this.getSetting(this.STORAGE.URL),
apiKey: await this.getSetting(this.STORAGE.API_KEY),
mode: await this.getSetting(this.STORAGE.MODE),
deletions: await this.getSetting(this.STORAGE.DELETIONS),
autoSync: await this.getSetting(this.STORAGE.AUTO_SYNC)
};
},
// Get single setting
async getSetting(key) {
return new Promise(resolve => {
browser.storage.local.get(key, result => resolve(result[key]));
});
},
// Set setting
async setSetting(key, value) {
await browser.storage.local.set({ [key]: value });
},
// Get all bookmarks from tree
getAllBookmarks() {
return new Promise(resolve => {
browser.bookmarks.getTree((tree) => {
resolve(this.flattenBookmarks(tree));
});
});
async init() {
console.log("LinkSync: Initializing background script");
await API.init();
browser.runtime.onInstalled.addListener(() => {
console.log("LinkSync: Extension installed");
this.startAutoSync();
});
browser.runtime.onMessage.addListener(this.handleMessage.bind(this));
browser.bookmarks.onCreated.addListener(this.onBookmarkChanged.bind(this));
browser.bookmarks.onChanged.addListener(this.onBookmarkChanged.bind(this));
browser.bookmarks.onRemoved.addListener(this.onBookmarkChanged.bind(this));
const settings = await browser.storage.local.get(["linksync_auto_sync"]);
if (settings.linksync_auto_sync) {
this.startAutoSync();
}
};
// Initialize on install/update
browser.runtime.onInstalled.addListener(() => {
Background.init();
});
// Expose to window
window.Background = Background;
})();
},
startAutoSync() {
if (this.syncInterval) {
clearInterval(this.syncInterval);
}
this.syncInterval = setInterval(() => this.runSync(), this.SYNC_CHECK_INTERVAL);
console.log("LinkSync: Auto-sync started (every 5 minutes)");
},
stopAutoSync() {
if (this.syncInterval) {
clearInterval(this.syncInterval);
this.syncInterval = null;
}
},
async onBookmarkChanged(id, changeInfo) {
const settings = await browser.storage.local.get(["linksync_auto_sync"]);
if (settings.linksync_auto_sync) {
await browser.storage.local.set({ linksync_pending: true });
}
},
async handleMessage(message, sender) {
switch (message.type) {
case "SYNC_NOW":
return this.runSync();
case "GET_SETTINGS":
return browser.storage.local.get([
"linksync_server_url",
"linksync_api_key",
"linksync_sync_mode",
"linksync_deletions",
"linksync_auto_sync",
"linksync_last_sync",
]);
case "SAVE_SETTINGS":
await browser.storage.local.set(message.data);
await API.init();
if (message.data.linksync_auto_sync) {
this.startAutoSync();
} else {
this.stopAutoSync();
}
return { success: true };
case "TEST_CONNECTION":
try {
await API.testConnection();
return { success: true };
} catch (e) {
return { success: false, error: e.message };
}
case "LOGIN":
try {
const data = await API.login(message.data.username, message.data.password);
return { success: true, token: data.access_token };
} catch (e) {
return { success: false, error: e.message };
}
case "GET_BOOKMARKS":
return API.getLinks(message.data || {});
case "CREATE_BOOKMARK":
return API.createLink(message.data);
case "UPDATE_BOOKMARK":
return API.updateLink(message.data.id, message.data);
case "DELETE_BOOKMARK":
return API.deleteLink(message.data.id);
case "GET_COLLECTIONS":
return CollectionManager.listCollections();
case "EXECUTE_QUERY":
return CollectionManager.executeQuery(message.data.expression, message.data.limit);
case "PARSE_QUERY":
return CollectionManager.parseQuery(message.data.expression);
default:
return null;
}
},
async runSync() {
try {
await browser.storage.local.set({ linksync_syncing: true });
const actions = await SyncEngine.runSync();
await browser.storage.local.set({
linksync_syncing: false,
linksync_last_sync: new Date().toISOString(),
linksync_pending: false,
linksync_sync_result: actions,
});
console.log(`LinkSync: Sync completed with ${actions.length} actions`);
return { success: true, actions };
} catch (error) {
console.error("LinkSync: Sync failed:", error);
await browser.storage.local.set({
linksync_syncing: false,
linksync_sync_error: error.message,
});
return { success: false, error: error.message };
}
},
};
document.addEventListener("DOMContentLoaded", () => Background.init());