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

106
LinkSyncExtension/AGENTS.md Normal file
View File

@@ -0,0 +1,106 @@
# AGENTS.md - LinkSyncExtension
## Project Overview
LinkSyncExtension is a Firefox browser extension that synchronizes bookmarks with LinkSyncServer. It provides a popup UI for managing bookmarks, collections, and queries, with a background script handling automatic synchronization.
## Architecture
```
LinkSyncExtension/
├── manifest.json # Firefox extension manifest v2
├── popup.html/js/css # Main popup UI (bookmarks, collections, queries)
├── background.html/js # Background page with sync engine
├── options.html/js # Dedicated settings page
├── content/content.js # Content script for page metadata extraction
├── utils/
│ ├── api.js # REST API client with retries, auth, error handling
│ ├── sync.js # Sync engine (3 modes + conflict detection)
│ ├── collection.js # Collection management wrapper
│ ├── query-engine.js # Client-side query parser
│ └── bookmark.js # Bookmark parsing/merging utilities
├── icons/
│ ├── icon-48.png
│ └── icon-96.png
└── tests/
└── README.md # Manual testing checklist
```
## Communication Pattern
```
popup.js/options.js ──browser.runtime.sendMessage──> background.js
background.js ────────────────────────────────────> API (LinkSyncServer)
background.js ──browser.bookmarks.*───────────────> Firefox bookmarks
content.js ─────browser.runtime.onMessage─────────> popup.js
```
All API calls go through `utils/api.js`. The popup sends messages to the background script, which handles the actual API communication. This keeps the API token in the background context.
## Key Modules
### utils/api.js
- `API.init()` - loads settings from storage
- `API.request(method, path, body)` - core request with retries (3x), timeout (10s), rate limit handling
- `API.login(username, password)` - authenticates and stores token
- `API.testConnection()` - hits /health endpoint
- CRUD methods for links, collections, queries, sync
### utils/sync.js
- `SyncEngine.runSync()` - main sync entry point
- Three modes: bi-directional, browser-authoritative, server-authoritative
- `SyncEngine.detectConflicts()` - finds title mismatches between browser and server
- Uses `browser.bookmarks.getTree()` for local bookmarks
### utils/query-engine.js
- `QueryEngine.tokenize(expression)` - lexer
- `QueryEngine.parse(expression)` - recursive descent parser
- `QueryEngine.validate(expression)` - returns {valid, ast} or {valid, error}
- Supports: TERM, TERM_SET, FIELD (url/tag/title/description/path/id), AND, OR, XOR, parentheses
### background.js
- Listens for `browser.runtime.onMessage` from popup/options
- Handles bookmark change events (`onCreated`, `onChanged`, `onRemoved`)
- Auto-sync timer (5 min interval when enabled)
- Message types: SYNC_NOW, GET_SETTINGS, SAVE_SETTINGS, TEST_CONNECTION, LOGIN, GET_BOOKMARKS, CREATE_BOOKMARK, UPDATE_BOOKMARK, DELETE_BOOKMARK, GET_COLLECTIONS, EXECUTE_QUERY, PARSE_QUERY
### popup.js
- Tabbed interface: Bookmarks, Collections, Query
- Settings modal (server URL, API key, sync mode, deletions, auto-sync)
- Toast notifications (success/error/info, auto-dismiss after 3s)
- Bookmark form with auto-fill from current tab
- Search filter for bookmarks
- Query builder with parse/execute buttons
## Storage Keys
| Key | Type | Description |
|-----|------|-------------|
| `linksync_server_url` | string | Server base URL |
| `linksync_api_key` | string | JWT bearer token |
| `linksync_sync_mode` | string | bi-directional / browser-authoritative / server-authoritative |
| `linksync_deletions` | boolean | Enable deletions during sync |
| `linksync_auto_sync` | boolean | Enable 5-minute auto-sync |
| `linksync_last_sync` | string | ISO timestamp of last sync |
| `linksync_syncing` | boolean | Currently syncing flag |
| `linksync_pending` | boolean | Has pending local changes |
## Testing
Browser extensions require manual testing in Firefox:
1. Open `about:debugging` in Firefox
2. Click "This Firefox" → "Load Temporary Add-on"
3. Select `manifest.json` from the project folder
4. Click the extension icon to open the popup
5. Right-click the icon → "Options" for settings page
See `tests/README.md` for the full manual testing checklist.
## Development Notes
- Manifest v2 for Firefox compatibility
- Background page (not service worker) for Firefox support
- All API calls use Bearer token auth (matching server's JWT implementation)
- Content script runs on all pages to extract metadata
- No external dependencies - pure browser APIs

View File

@@ -3,101 +3,130 @@
## Project Setup
- [x] Create project directory structure
- [x] Write README.md
- [ ] Write TODOs.txt (in progress)
- [ ] Write design.md
- [ ] Write tasks.md
- [ ] Write AGENTS.md
- [x] Write TODOs.txt
- [x] Write design.md
- [x] Write tasks.md
- [x] Write AGENTS.md
- [x] Create manifest.json (with all permissions, content scripts, options page)
- [x] Add icon files (48x48, 96x96)
## Core Development
### Extension Manifest
- [ ] Create manifest.json (MVP)
- [ ] Add icon files
- [ ] Configure permissions
- [ ] Set browser ID
- [x] Create manifest.json with Firefox-specific settings
- [x] Add icon files (48x48, 96x96)
- [x] Configure permissions (bookmarks, storage, activeTab, tabs, <all_urls>)
- [x] Set browser ID (linksync@example.com)
- [x] Add content scripts registration
- [x] Add options page registration
### Background Script
- [ ] Create background.js service worker
- [ ] Implement sync logic
- [ ] Handle sync mode switching
- [ ] Manage collection mapping
- [ ] Auto-sync timer
- [ ] Error handling
- [x] Create background.js service worker
- [x] Implement init() on install/update
- [x] Implement sync loop with interval (5 min)
- [x] Add event handlers (message, bookmark changes)
- [x] Implement sync mode switching
- [x] Manage collection mapping
- [x] Auto-sync timer
- [x] Error handling
### Popup Script
- [ ] Create popup.html
- [ ] Create popup.css
- [ ] Create popup.js
- [ ] Bookmark form UI
- [ ] Collection list UI
- [ ] Settings UI
- [ ] Search UI
- [x] Create popup.html with tabs (Bookmarks, Collections, Query)
- [x] Create popup.css with full styling
- [x] Create popup.js with all functionality
- [x] Bookmark form UI with auto-fill
- [x] Bookmark list view with search
- [x] Collections panel
- [x] Query builder with parse/execute
- [x] Settings modal
- [x] Sync button handler
- [x] Toast notifications
### Utility Modules
- [ ] utils/bookmark.js - Bookmark manipulation
- [ ] utils/collection.js - Collection management
- [ ] utils/query-engine.js - Query parsing/execution
- [ ] utils/sync.js - Sync logic
- [x] utils/bookmark.js - Bookmark manipulation (parse, merge, format)
- [x] utils/collection.js - Collection management (CRUD, query execution)
- [x] utils/query-engine.js - Query parsing (tokenizer, recursive descent parser)
- [x] utils/sync.js - Sync logic (3 modes, conflict detection)
- [x] utils/api.js - API client (auth, retries, error handling, all endpoints)
### Content Script (Optional)
- [ ] content/content.js - Read page data
- [ ] Extract title/description
- [ ] Handle URL detection
- [ ] Inject into popup
### Content Script
- [x] content/content.js - Extract page title, description, favicon
- [x] Handle browser.runtime.onMessage for getPageData
### API Integration
- [ ] /api/auth/login/ - Authentication
- [ ] /api/links/ - Bookmark CRUD
- [ ] /api/collections/ - Collection CRUD
- [ ] /api/queries/execute/ - Query execution
- [ ] /api/sync/ - Sync endpoint
- [x] /api/auth/login - Authentication
- [x] /api/links/ - Bookmark CRUD (GET, POST, PUT, DELETE)
- [x] /api/collections/ - Collection CRUD
- [x] /api/queries/parse/ - Query parsing
- [x] /api/queries/execute/ - Query execution
- [x] /api/sync/ - Sync endpoint
- [x] /api/admin/stats - Admin stats
- [x] /health - Connection test
### Sync Logic
- [ ] Implement bi-directional sync
- [ ] Implement browser-authoritative sync
- [ ] Implement server-authoritative sync
- [ ] Handle deletions checkbox
- [ ] Conflict detection
- [ ] Conflict resolution UI
- [x] Implement bi-directional sync
- [x] Implement browser-authoritative sync
- [x] Implement server-authoritative sync
- [x] Handle deletions checkbox
- [x] Conflict detection (title mismatches)
### UI Components
- [ ] Bookmark list view
- [ ] Collection builder UI
- [ ] Query editor
- [ ] Search interface
- [ ] Sync status indicator
- [ ] Conflict resolution modal
- [x] Tabbed interface (Bookmarks, Collections, Query)
- [x] Bookmark list view with search filter
- [x] Collection list
- [x] Query builder with syntax help
- [x] Sync status indicator (syncing/synced/error)
- [x] Settings modal
- [x] Toast notifications
- [x] Options page (dedicated settings)
### Storage Management
- [ ] Store API key securely
- [ ] Store collection mapping
- [ ] Store sync settings
- [ ] Sync timestamp tracking
- [ ] Pending changes tracking
- [x] Store API key in browser.storage.local
- [x] Store server URL
- [x] Store sync settings (mode, deletions, auto-sync)
- [x] Sync timestamp tracking
- [x] Pending changes tracking
- [x] Syncing state flag
## Options Page
- [x] Create options.html
- [x] Create options.js
- [x] Server URL configuration
- [x] API key input (password field)
- [x] Sync mode dropdown
- [x] Deletions checkbox
- [x] Auto-sync checkbox
- [x] Test connection button
- [x] Sync now button
- [x] Last sync display
## Security
- [ ] Encrypted storage
- [ ] API key validation
- [ ] HTTPS enforcement checks
- [ ] CORS validation
- [ ] Input sanitization
- [x] API key stored in browser.storage.local (not localStorage)
- [x] Bearer token authentication
- [x] Input sanitization (escapeHtml)
- [x] Request timeout handling
- [x] Rate limit handling (429 retry)
## Testing
- [ ] Test sync modes
- [ ] Test conflict resolution
- [ ] Test query execution
- [ ] Test offline handling
- [ ] Test error handling
- [x] Manual testing checklist (tests/README.md)
- [ ] Test sync modes (manual)
- [ ] Test conflict resolution (manual)
- [ ] Test query execution (manual)
- [ ] Test offline handling (manual)
- [ ] Test error handling (manual)
## Documentation
- [ ] API reference
- [ ] User guide
- [ ] Troubleshooting guide
- [ ] Query syntax guide
- [x] API reference (README.md)
- [x] User guide (README.md)
- [x] Troubleshooting guide (README.md)
- [x] Query syntax guide (README.md)
- [x] Architecture docs (AGENTS.md, design.md)
## Future Enhancements
- [ ] Background sync notifications
- [ ] Auto-sync scheduler
- [ ] Keyboard shortcuts
- [ ] Gesture controls
- [ ] Mobile companion app
- [ ] Dark theme toggle
- [ ] Bookmark edit/delete from popup
- [ ] Batch operations
- [ ] Conflict resolution UI
- [ ] Offline queue for pending changes

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());

View File

@@ -0,0 +1,32 @@
// LinkSync Content Script
// Extracts page metadata for bookmark auto-fill
(function () {
"use strict";
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === "getPageData") {
const data = {
url: window.location.href,
title: document.title,
description: getMetaDescription(),
favicon: getFavicon(),
};
sendResponse(data);
}
return true;
});
function getMetaDescription() {
const meta = document.querySelector('meta[name="description"]');
return meta ? meta.getAttribute("content") : "";
}
function getFavicon() {
const link = document.querySelector("link[rel='icon'], link[rel='shortcut icon']");
if (link) {
return link.getAttribute("href");
}
return new URL("/favicon.ico", window.location.origin).href;
}
})();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 B

After

Width:  |  Height:  |  Size: 258 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 B

View File

@@ -6,9 +6,12 @@
"permissions": [
"bookmarks",
"storage",
"activeTab"
"activeTab",
"tabs",
"<all_urls>"
],
"browser_action": {
"default_popup": "popup.html",
"default_icon": {
"48": "icons/icon-48.png",
"96": "icons/icon-96.png"
@@ -18,10 +21,21 @@
"background": {
"page": "background.html"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content/content.js"],
"run_at": "document_idle"
}
],
"options_ui": {
"page": "options.html",
"open_in_tab": true
},
"browser_specific_settings": {
"gecko": {
"id": "{linksync-browser-extension-id}",
"id": "linksync@example.com",
"strict_min_version": "109.0"
}
}
}
}

View File

@@ -0,0 +1,162 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LinkSync Settings</title>
<style>
:root {
--primary: #3b82f6;
--primary-hover: #2563eb;
--success: #10b981;
--error: #ef4444;
--background: #ffffff;
--surface: #f9fafb;
--border: #e5e7eb;
--text: #111827;
--text-secondary: #6b7280;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, sans-serif;
background: var(--surface);
color: var(--text);
line-height: 1.6;
padding: 2rem;
max-width: 600px;
margin: 0 auto;
}
h1 { font-size: 1.5rem; margin-bottom: 1.5rem; color: var(--primary); }
h2 { font-size: 1.1rem; margin: 1.5rem 0 0.75rem; }
.card {
background: var(--background);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1rem;
}
.form-group { margin-bottom: 1rem; }
.form-group label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 0.25rem;
}
.form-group input, .form-group select {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 4px;
font-size: 0.875rem;
}
.form-group input:focus, .form-group select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-group input { width: auto; }
button {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.btn-primary { background: var(--primary); color: white; }
.btn-primary:hover { background: var(--primary-hover); }
.btn-secondary { background: var(--surface); border: 1px solid var(--border); }
.actions { display: flex; gap: 0.5rem; margin-top: 1rem; }
#status {
padding: 0.75rem;
border-radius: 4px;
margin-top: 1rem;
display: none;
}
#status.success { display: block; background: #d1fae5; color: #065f46; }
#status.error { display: block; background: #fee2e2; color: #991b1b; }
.sync-info {
font-size: 0.8rem;
color: var(--text-secondary);
margin-top: 0.5rem;
}
</style>
</head>
<body>
<h1>LinkSync Settings</h1>
<div class="card">
<h2>Server Connection</h2>
<form id="connection-form">
<div class="form-group">
<label for="server-url">Server URL</label>
<input type="url" id="server-url" placeholder="http://localhost:5000">
</div>
<div class="form-group">
<label for="api-key">API Key</label>
<input type="password" id="api-key" placeholder="Your API key">
</div>
<div class="actions">
<button type="button" id="test-btn" class="btn-secondary">Test Connection</button>
</div>
</form>
</div>
<div class="card">
<h2>Sync Settings</h2>
<form id="sync-form">
<div class="form-group">
<label for="sync-mode">Sync Mode</label>
<select id="sync-mode">
<option value="bi-directional">Bi-directional</option>
<option value="browser-authoritative">Browser Authoritative</option>
<option value="server-authoritative">Server Authoritative</option>
</select>
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" id="deletions">
Enable deletions during sync
</label>
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" id="auto-sync">
Auto-sync every 5 minutes
</label>
</div>
<div class="actions">
<button type="submit" class="btn-primary">Save Settings</button>
<button type="button" id="sync-now-btn" class="btn-secondary">Sync Now</button>
</div>
<div class="sync-info" id="sync-info"></div>
</form>
</div>
<div id="status"></div>
<script src="utils/api.js"></script>
<script src="options.js"></script>
</body>
</html>

View File

@@ -0,0 +1,71 @@
// LinkSync Options Page Script
document.addEventListener("DOMContentLoaded", async () => {
const statusEl = document.getElementById("status");
const syncInfoEl = document.getElementById("sync-info");
const settings = await browser.storage.local.get([
"linksync_server_url",
"linksync_api_key",
"linksync_sync_mode",
"linksync_deletions",
"linksync_auto_sync",
"linksync_last_sync",
]);
document.getElementById("server-url").value = settings.linksync_server_url || "http://localhost:5000";
document.getElementById("api-key").value = settings.linksync_api_key || "";
document.getElementById("sync-mode").value = settings.linksync_sync_mode || "bi-directional";
document.getElementById("deletions").checked = settings.linksync_deletions === true;
document.getElementById("auto-sync").checked = settings.linksync_auto_sync === true;
if (settings.linksync_last_sync) {
syncInfoEl.textContent = `Last sync: ${new Date(settings.linksync_last_sync).toLocaleString()}`;
}
document.getElementById("test-btn").addEventListener("click", async () => {
try {
const url = document.getElementById("server-url").value.replace(/\/+$/, "");
const response = await fetch(`${url}/health`, { signal: AbortSignal.timeout(5000) });
if (response.ok) {
showStatus("Connection successful!", "success");
} else {
showStatus(`Server returned ${response.status}`, "error");
}
} catch (e) {
showStatus(`Connection failed: ${e.message}`, "error");
}
});
document.getElementById("sync-form").addEventListener("submit", async (e) => {
e.preventDefault();
await browser.storage.local.set({
linksync_server_url: document.getElementById("server-url").value.replace(/\/+$/, ""),
linksync_api_key: document.getElementById("api-key").value,
linksync_sync_mode: document.getElementById("sync-mode").value,
linksync_deletions: document.getElementById("deletions").checked,
linksync_auto_sync: document.getElementById("auto-sync").checked,
});
showStatus("Settings saved successfully", "success");
});
document.getElementById("sync-now-btn").addEventListener("click", async () => {
try {
const result = await browser.runtime.sendMessage({ type: "SYNC_NOW" });
if (result && result.success) {
syncInfoEl.textContent = `Last sync: ${new Date().toLocaleString()} (${result.actions?.length || 0} actions)`;
showStatus("Sync completed", "success");
} else {
showStatus(`Sync failed: ${result?.error}`, "error");
}
} catch (e) {
showStatus(`Sync error: ${e.message}`, "error");
}
});
function showStatus(message, type) {
statusEl.textContent = message;
statusEl.className = type;
setTimeout(() => { statusEl.className = ""; }, 3000);
}
});

View File

@@ -20,8 +20,8 @@
}
html, body {
width: 360px;
height: 500px;
width: 400px;
height: 550px;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.5;
@@ -31,162 +31,26 @@ html, body {
}
header {
padding: 12px;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
background: var(--surface);
display: flex;
justify-content: space-between;
align-items: center;
}
header h1 {
font-size: 18px;
font-size: 16px;
font-weight: 600;
color: var(--primary);
}
section {
padding: 12px;
border-bottom: 1px solid var(--border);
}
h2 {
font-size: 13px;
font-weight: 600;
color: var(--secondary);
margin-bottom: 8px;
}
.form-group {
margin-bottom: 12px;
}
.form-group label {
display: block;
font-size: 12px;
color: var(--text-secondary);
margin-bottom: 4px;
}
.form-group input,
.form-group textarea {
width: 100%;
padding: 8px;
border: 1px solid var(--border);
border-radius: 4px;
font-size: 13px;
background: var(--background);
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
}
button {
padding: 8px 16px;
border: none;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
button#submit {
width: 100%;
background: var(--primary);
color: white;
}
button#submit:hover {
background: var(--primary-hover);
}
button#sync-btn,
button#settings-btn {
width: 100%;
padding: 8px;
margin-bottom: 8px;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
}
button#sync-btn:hover,
button#settings-btn:hover {
background: var(--border);
}
#search-filter {
margin-bottom: 8px;
}
#search {
width: 100%;
padding: 8px;
border: 1px solid var(--border);
border-radius: 4px;
font-size: 13px;
}
.bookmark-item {
padding: 10px;
border: 1px solid var(--border);
border-radius: 4px;
margin-bottom: 8px;
background: var(--surface);
}
.bookmark-item a {
display: block;
color: var(--primary);
text-decoration: none;
word-break: break-all;
margin-bottom: 4px;
}
.bookmark-item a:hover {
text-decoration: underline;
}
.bookmark-item .title {
font-weight: 500;
margin-bottom: 4px;
}
.bookmark-item .description {
font-size: 12px;
color: var(--text-secondary);
}
.bookmark-item .tags {
margin-top: 4px;
#sync-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--secondary);
}
#collections-list {
max-height: 150px;
overflow-y: auto;
}
.collection-item {
padding: 8px;
border: 1px solid var(--border);
border-radius: 4px;
margin-bottom: 4px;
background: var(--surface);
font-size: 12px;
}
.collection-item h3 {
font-size: 13px;
margin-bottom: 4px;
}
.collection-item p {
color: var(--text-secondary);
font-size: 11px;
}
#sync-indicator {
@@ -194,19 +58,19 @@ button#settings-btn:hover {
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 4px;
background: var(--secondary);
}
.syncing {
#sync-indicator.syncing {
background: var(--warning);
animation: pulse 1.5s infinite;
}
.synced {
#sync-indicator.synced {
background: var(--success);
}
.error {
#sync-indicator.error {
background: var(--error);
}
@@ -215,27 +79,449 @@ button#settings-btn:hover {
50% { opacity: 0.5; }
}
#last-sync {
/* Tabs */
#tabs {
display: flex;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.tab {
flex: 1;
padding: 8px;
border: none;
background: none;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.tab:hover {
color: var(--text);
}
.tab.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.tab-content {
display: none;
overflow-y: auto;
height: calc(100% - 120px);
}
.tab-content.active {
display: block;
}
/* Sections */
section {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
}
h2 {
font-size: 12px;
font-weight: 600;
color: var(--secondary);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Forms */
.form-group {
margin-bottom: 10px;
}
.form-group label {
display: block;
font-size: 11px;
color: var(--text-secondary);
margin-bottom: 3px;
font-weight: 500;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 7px 8px;
border: 1px solid var(--border);
border-radius: 4px;
font-size: 12px;
background: var(--background);
font-family: inherit;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
.checkbox-group input[type="checkbox"] {
width: auto;
}
/* Buttons */
button {
padding: 7px 14px;
border: none;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
font-family: inherit;
}
#submit-btn {
width: 100%;
background: var(--primary);
color: white;
}
#submit-btn:hover {
background: var(--primary-hover);
}
.btn-primary {
background: var(--primary);
color: white;
}
.btn-primary:hover {
background: var(--primary-hover);
}
.btn-small {
padding: 5px 10px;
font-size: 11px;
background: var(--surface);
border: 1px solid var(--border);
}
.btn-small:hover {
background: var(--border);
}
/* Search */
#search-filter {
margin-bottom: 8px;
}
#search {
width: 100%;
padding: 7px 8px;
border: 1px solid var(--border);
border-radius: 4px;
font-size: 12px;
}
/* Bookmark items */
.bookmark-item {
padding: 8px;
border: 1px solid var(--border);
border-radius: 4px;
margin-bottom: 6px;
background: var(--surface);
cursor: pointer;
transition: border-color 0.2s;
}
.bookmark-item:hover {
border-color: var(--primary);
}
.bookmark-item a {
display: block;
color: var(--primary);
text-decoration: none;
font-size: 11px;
word-break: break-all;
margin-bottom: 2px;
}
.bookmark-item a:hover {
text-decoration: underline;
}
.bookmark-item .bm-title {
font-weight: 500;
font-size: 12px;
margin-bottom: 2px;
}
.bookmark-item .bm-desc {
font-size: 11px;
color: var(--text-secondary);
}
#bookmarks-container {
max-height: 150px;
.bookmark-item .bm-tags {
margin-top: 4px;
display: flex;
flex-wrap: wrap;
gap: 3px;
}
.bookmark-item .bm-tag {
font-size: 10px;
padding: 1px 5px;
background: var(--border);
border-radius: 3px;
color: var(--text-secondary);
}
/* Collection items */
.collection-item {
padding: 8px;
border: 1px solid var(--border);
border-radius: 4px;
margin-bottom: 6px;
background: var(--surface);
}
.collection-item h3 {
font-size: 12px;
margin-bottom: 2px;
}
.collection-item p {
font-size: 11px;
color: var(--text-secondary);
}
.collection-item .col-type {
font-size: 10px;
color: var(--primary);
font-weight: 500;
}
/* Query panel */
.query-actions {
display: flex;
gap: 6px;
margin-bottom: 8px;
}
#query-result {
padding: 8px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
font-size: 11px;
max-height: 120px;
overflow-y: auto;
margin-bottom: 8px;
}
#collections-panel,
#bookmark-list {
max-height: 180px;
.query-help {
font-size: 10px;
color: var(--text-secondary);
padding: 6px;
background: var(--surface);
border-radius: 4px;
}
.query-help code {
background: var(--border);
padding: 1px 4px;
border-radius: 2px;
font-size: 10px;
}
/* Footer */
footer {
padding: 12px;
padding: 8px 12px;
background: var(--surface);
border-top: 1px solid var(--border);
display: flex;
gap: 8px;
}
footer button {
width: 100%;
}
flex: 1;
padding: 8px;
background: var(--background);
border: 1px solid var(--border);
color: var(--text);
}
footer button:hover {
background: var(--border);
}
#sync-btn {
position: relative;
}
/* Notifications */
#notification-container {
position: fixed;
top: 50px;
left: 12px;
right: 12px;
z-index: 100;
pointer-events: none;
}
.notification {
padding: 8px 12px;
border-radius: 4px;
font-size: 12px;
margin-bottom: 4px;
animation: slideIn 0.3s ease;
pointer-events: auto;
}
.notification.success {
background: #d1fae5;
color: #065f46;
border: 1px solid #a7f3d0;
}
.notification.error {
background: #fee2e2;
color: #991b1b;
border: 1px solid #fecaca;
}
.notification.info {
background: #dbeafe;
color: #1e40af;
border: 1px solid #bfdbfe;
}
@keyframes slideIn {
from { transform: translateY(-10px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
/* Modal */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 200;
align-items: center;
justify-content: center;
}
.modal.open {
display: flex;
}
.modal-content {
background: var(--background);
border-radius: 8px;
width: 360px;
max-height: 500px;
overflow-y: auto;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.modal-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h2 {
font-size: 14px;
color: var(--text);
text-transform: none;
letter-spacing: normal;
margin: 0;
}
.close-btn {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: var(--text-secondary);
padding: 0;
line-height: 1;
}
.close-btn:hover {
color: var(--text);
}
#settings-form {
padding: 16px;
}
.input-with-toggle {
display: flex;
gap: 4px;
}
.input-with-toggle input {
flex: 1;
}
.toggle-btn {
padding: 7px 8px;
font-size: 11px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
white-space: nowrap;
}
.settings-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.settings-actions button {
flex: 1;
}
/* Empty state */
.empty-state {
text-align: center;
color: var(--text-secondary);
font-size: 12px;
padding: 20px;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: var(--surface);
}
::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--secondary);
}

View File

@@ -8,71 +8,151 @@
<body>
<header>
<h1>LinkSync</h1>
</header>
<section id="sync-status">
<span id="sync-indicator"></span>
<span id="last-sync"></span>
</section>
<!-- Add/Edit Form -->
<section id="bookmark-form">
<h2>Add Bookmark</h2>
<form id="bookmark-form">
<div class="form-group">
<label for="url">URL:</label>
<input type="url" id="url" placeholder="https://example.com" required>
</div>
<div class="form-group">
<label for="title">Title:</label>
<input type="text" id="title" placeholder="Page title">
</div>
<div class="form-group">
<label for="description">Description:</label>
<textarea id="description" rows="2"></textarea>
</div>
<div class="form-group">
<label for="notes">Notes:</label>
<textarea id="notes" rows="2"></textarea>
</div>
<div class="form-group">
<label for="tags">Tags:</label>
<input type="text" id="tags" placeholder="work, personal, dev (comma-separated)">
</div>
<div class="form-group">
<label for="folder">Folder:</label>
<input type="text" id="folder" placeholder="path/to/folder">
</div>
<button type="submit" id="submit">Add Bookmark</button>
</form>
</section>
<!-- Bookmark List -->
<section id="bookmark-list">
<h2>Bookmarks</h2>
<div id="search-filter">
<input type="text" id="search" placeholder="Search bookmarks...">
<div id="sync-status">
<span id="sync-indicator"></span>
<span id="last-sync">Not synced yet</span>
</div>
<div id="bookmarks-container"></div>
</header>
<div id="notification-container"></div>
<!-- Tabs -->
<nav id="tabs">
<button class="tab active" data-tab="bookmarks">Bookmarks</button>
<button class="tab" data-tab="collections">Collections</button>
<button class="tab" data-tab="query">Query</button>
</nav>
<!-- Bookmarks Tab -->
<section id="tab-bookmarks" class="tab-content active">
<section id="bookmark-form">
<h2>Add Bookmark</h2>
<form id="add-bookmark-form">
<div class="form-group">
<label for="url">URL</label>
<input type="url" id="url" placeholder="https://example.com" required>
</div>
<div class="form-group">
<label for="title">Title</label>
<input type="text" id="title" placeholder="Page title">
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" rows="2" placeholder="Page description"></textarea>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea id="notes" rows="2" placeholder="Your notes"></textarea>
</div>
<div class="form-group">
<label for="tags">Tags</label>
<input type="text" id="tags" placeholder="work, personal, dev">
</div>
<div class="form-group">
<label for="folder">Folder</label>
<input type="text" id="folder" placeholder="path/to/folder">
</div>
<button type="submit" id="submit-btn">Add Bookmark</button>
</form>
</section>
<section id="bookmark-list">
<div id="search-filter">
<input type="text" id="search" placeholder="Search bookmarks...">
</div>
<div id="bookmarks-container">
<p class="empty-state">Loading bookmarks...</p>
</div>
</section>
</section>
<!-- Collections Panel -->
<section id="collections-panel">
<h2>Collections</h2>
<div id="collections-list"></div>
<!-- Collections Tab -->
<section id="tab-collections" class="tab-content">
<section id="collections-panel">
<h2>Collections</h2>
<div id="collections-container">
<p class="empty-state">Loading collections...</p>
</div>
</section>
</section>
<!-- Query Tab -->
<section id="tab-query" class="tab-content">
<section id="query-panel">
<h2>Query Builder</h2>
<div class="form-group">
<label for="query-input">Expression</label>
<input type="text" id="query-input" placeholder="('work', 'dev') OR tag:work">
</div>
<div class="query-actions">
<button id="parse-btn" class="btn-small">Parse</button>
<button id="execute-btn" class="btn-small btn-primary">Execute</button>
</div>
<div id="query-result"></div>
<div class="query-help">
<p>Syntax: <code>('term1', 'term2') OR tag:work AND url:example.com</code></p>
<p>Precedence: <code>()</code> &gt; XOR &gt; AND &gt; OR</p>
</div>
</section>
</section>
<footer>
<button id="sync-btn">Sync Now</button>
<button id="settings-btn">Settings</button>
</footer>
<!-- Settings Modal -->
<div id="settings-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2>Settings</h2>
<button id="close-settings" class="close-btn">&times;</button>
</div>
<form id="settings-form">
<div class="form-group">
<label for="server-url">Server URL</label>
<input type="url" id="server-url" placeholder="http://localhost:5000" required>
</div>
<div class="form-group">
<label for="api-key">API Key</label>
<div class="input-with-toggle">
<input type="password" id="api-key" placeholder="Your API key">
<button type="button" id="toggle-key" class="toggle-btn">Show</button>
</div>
</div>
<div class="form-group">
<label for="sync-mode">Sync Mode</label>
<select id="sync-mode">
<option value="bi-directional">Bi-directional</option>
<option value="browser-authoritative">Browser Authoritative</option>
<option value="server-authoritative">Server Authoritative</option>
</select>
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" id="deletions">
Enable deletions
</label>
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" id="auto-sync">
Auto-sync every 5 minutes
</label>
</div>
<div class="settings-actions">
<button type="button" id="test-connection" class="btn-small">Test Connection</button>
<button type="submit" class="btn-primary">Save</button>
</div>
</form>
</div>
</div>
<script src="utils/api.js"></script>
<script src="utils/sync.js"></script>
<script src="utils/collection.js"></script>
<script src="utils/query-engine.js"></script>
<script src="utils/bookmark.js"></script>
<script src="popup.js"></script>
</body>
</html>
</html>

View File

@@ -1,285 +1,365 @@
// LinkSync Popup Script
// Handles bookmark management and sync operations
// Handles bookmark management, sync, collections, and queries
(function() {
'use strict';
const Popup = {
bookmarks: [],
collections: [],
const Popup = {
// API Configuration
API_BASE_URL: '',
API_KEY: '',
// Initialize popup
async init() {
console.log('LinkSync: Popup initialized');
// Load settings
await this.loadSettings();
// Setup event listeners
this.setupEventListeners();
// Load bookmarks
await this.loadBookmarks();
// Load collections
await this.loadCollections();
// Update sync status
this.updateSyncStatus();
},
// Load settings from storage
async loadSettings() {
this.API_BASE_URL = await this.getSetting('url') || 'http://localhost:5000';
this.API_KEY = await this.getSetting('apiKey') || '';
// Update form
this.updateFormState();
},
// Get setting from storage
async getSetting(key) {
return new Promise(resolve => {
browser.storage.local.get(key, result => resolve(result[key]));
});
},
// Setup event listeners
setupEventListeners() {
// Form submission
document.getElementById('bookmark-form').addEventListener('submit', async (e) => {
e.preventDefault();
await this.addBookmark();
});
// Search filter
document.getElementById('search').addEventListener('input', async (e) => {
await this.filterBookmarks(e.target.value);
});
// Sync button
document.getElementById('sync-btn').addEventListener('click', async () => {
await this.syncBookmarks();
});
// Settings button
document.getElementById('settings-btn').addEventListener('click', () => {
this.openSettings();
});
},
// Update form state (edit mode)
updateFormState(isEdit = false) {
const form = document.getElementById('bookmark-form');
if (isEdit) {
form.style.display = 'block';
} else {
form.style.display = 'none';
}
},
// Load bookmarks from server
async loadBookmarks() {
try {
const response = await fetch(`${this.API_BASE_URL}/api/links/`, {
headers: { 'Authorization': `Token ${this.API_KEY}` }
});
if (response.ok) {
const data = await response.json();
this.renderBookmarks(data.links || []);
}
} catch (error) {
console.error('LinkSync: Failed to load bookmarks:', error);
this.renderError('Unable to connect to server. Check your settings.');
}
},
// Render bookmarks to list
renderBookmarks(bookmarks) {
const container = document.getElementById('bookmarks-container');
container.innerHTML = '';
if (!bookmarks || bookmarks.length === 0) {
container.innerHTML = '<p style="color: var(--secondary); font-size: 12px;">No bookmarks</p>';
return;
}
bookmarks.forEach(bookmark => {
const item = document.createElement('div');
item.className = 'bookmark-item';
item.innerHTML = `
<a href="${bookmark.url}" target="_blank">${bookmark.url}</a>
<div class="title">${bookmark.title}</div>
${bookmark.description ? `<div class="description">${bookmark.description}</div>` : ''}
${bookmark.tags && bookmark.tags.length > 0 ? `<div class="tags">${bookmark.tags.join(', ')}</div>` : ''}
`;
container.appendChild(item);
});
},
// Filter bookmarks by search term
async filterBookmarks(query) {
const bookmarks = await this.loadBookmarks();
const filtered = bookmarks.filter(b =>
b.title.toLowerCase().includes(query.toLowerCase()) ||
b.url.toLowerCase().includes(query.toLowerCase()) ||
(b.description && b.description.toLowerCase().includes(query.toLowerCase()))
);
this.renderBookmarks(filtered);
},
// Add bookmark
async addBookmark() {
const form = document.getElementById('bookmark-form');
const data = {
url: document.getElementById('url').value,
title: document.getElementById('title').value,
description: document.getElementById('description').value,
notes: document.getElementById('notes').value,
tags: this.formatTags(document.getElementById('tags').value),
path: document.getElementById('folder').value
};
const response = await fetch(`${this.API_BASE_URL}/api/links/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Token ${this.API_KEY}`
},
body: JSON.stringify(data)
});
if (response.ok) {
form.reset();
await this.loadBookmarks();
this.showNotification('Bookmark added', 'success');
} else {
this.showNotification('Failed to add bookmark', 'error');
}
},
// Format tags
formatTags(tagString) {
if (!tagString) return [];
return tagString.split(',').map(t => t.trim()).filter(t => t.length > 0);
},
// Load collections
async loadCollections() {
try {
const response = await fetch(`${this.API_BASE_URL}/api/collections/`, {
headers: { 'Authorization': `Token ${this.API_KEY}` }
});
if (response.ok) {
const data = await response.json();
this.renderCollections(data.collections || []);
}
} catch (error) {
console.error('LinkSync: Failed to load collections:', error);
}
},
// Render collections
renderCollections(collections) {
const container = document.getElementById('collections-list');
container.innerHTML = '';
if (!collections || collections.length === 0) {
container.innerHTML = '<p style="color: var(--secondary); font-size: 12px;">No collections</p>';
return;
}
collections.forEach(collection => {
const item = document.createElement('div');
item.className = 'collection-item';
item.innerHTML = `
<h3>${collection.name}</h3>
<p>${collection.description || ''}</p>
<p style="font-size: 10px; color: var(--secondary);">Type: ${collection.query_type || 'dynamic'}</p>
`;
container.appendChild(item);
});
},
// Sync bookmarks
async syncBookmarks() {
const indicator = document.getElementById('sync-indicator');
indicator.className = 'syncing';
try {
const response = await fetch(`${this.API_BASE_URL}/api/sync/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Token ${this.API_KEY}`
},
body: JSON.stringify({
bookmarks: [],
mode: await this.getSetting('mode') || 'bi-directional',
deletions: await this.getSetting('deletions') || false
})
});
if (response.ok) {
const data = await response.json();
indicator.className = 'synced';
document.getElementById('last-sync').textContent = `Last sync: ${new Date().toLocaleTimeString()}`;
this.showNotification('Sync completed', 'success');
} else {
this.showNotification('Sync failed', 'error');
}
} catch (error) {
console.error('LinkSync: Sync error:', error);
this.showNotification('Sync error', 'error');
} finally {
setTimeout(() => indicator.className = '', 2000);
}
},
// Update sync status
updateSyncStatus() {
const indicator = document.getElementById('sync-indicator');
const lastSync = document.getElementById('last-sync');
const lastSyncTime = new Date(await this.getSetting('lastSync') || Date.now());
const minutesAgo = Math.floor((Date.now() - lastSyncTime.getTime()) / 60000);
if (minutesAgo < 5) {
indicator.className = 'synced';
lastSync.textContent = `Synced ${minutesAgo} min ago`;
} else {
indicator.className = 'error';
lastSync.textContent = `Last sync: ${lastSyncTime.toLocaleString()}`;
}
},
// Open settings modal
openSettings() {
// TODO: Open settings modal
console.log('Open settings');
},
// Show notification
showNotification(message, type) {
// TODO: Show toast notification
console.log(`[LinkSync] ${message}`);
},
// Get setting
async getSetting(key) {
return new Promise(resolve => {
browser.storage.local.get(key, result => resolve(result[key]));
async init() {
this.setupTabs();
this.setupEventListeners();
await this.loadSettings();
await this.loadBookmarks();
await this.loadCollections();
this.updateSyncStatus();
},
setupTabs() {
document.querySelectorAll(".tab").forEach((tab) => {
tab.addEventListener("click", () => {
document.querySelectorAll(".tab").forEach((t) => t.classList.remove("active"));
document.querySelectorAll(".tab-content").forEach((c) => c.classList.remove("active"));
tab.classList.add("active");
document.getElementById(`tab-${tab.dataset.tab}`).classList.add("active");
});
});
},
setupEventListeners() {
document.getElementById("add-bookmark-form").addEventListener("submit", (e) => {
e.preventDefault();
this.addBookmark();
});
document.getElementById("search").addEventListener("input", (e) => {
this.filterBookmarks(e.target.value);
});
document.getElementById("sync-btn").addEventListener("click", () => this.syncNow());
document.getElementById("settings-btn").addEventListener("click", () => this.openSettings());
document.getElementById("close-settings").addEventListener("click", () => this.closeSettings());
document.getElementById("settings-form").addEventListener("submit", (e) => {
e.preventDefault();
this.saveSettings();
});
document.getElementById("toggle-key").addEventListener("click", () => this.toggleApiKey());
document.getElementById("test-connection").addEventListener("click", () => this.testConnection());
document.getElementById("parse-btn").addEventListener("click", () => this.parseQuery());
document.getElementById("execute-btn").addEventListener("click", () => this.executeQuery());
document.getElementById("settings-modal").addEventListener("click", (e) => {
if (e.target.id === "settings-modal") this.closeSettings();
});
},
async loadSettings() {
const settings = await browser.storage.local.get([
"linksync_server_url",
"linksync_api_key",
"linksync_sync_mode",
"linksync_deletions",
"linksync_auto_sync",
]);
document.getElementById("server-url").value = settings.linksync_server_url || "http://localhost:5000";
document.getElementById("api-key").value = settings.linksync_api_key || "";
document.getElementById("sync-mode").value = settings.linksync_sync_mode || "bi-directional";
document.getElementById("deletions").checked = settings.linksync_deletions === true;
document.getElementById("auto-sync").checked = settings.linksync_auto_sync === true;
},
async openSettings() {
document.getElementById("settings-modal").classList.add("open");
},
closeSettings() {
document.getElementById("settings-modal").classList.remove("open");
},
toggleApiKey() {
const input = document.getElementById("api-key");
const btn = document.getElementById("toggle-key");
if (input.type === "password") {
input.type = "text";
btn.textContent = "Hide";
} else {
input.type = "password";
btn.textContent = "Show";
}
};
// Initialize when page loads
window.addEventListener('load', () => Popup.init());
// Expose to window
window.Popup = Popup;
})();
},
async saveSettings() {
const settings = {
linksync_server_url: document.getElementById("server-url").value.replace(/\/+$/, ""),
linksync_api_key: document.getElementById("api-key").value,
linksync_sync_mode: document.getElementById("sync-mode").value,
linksync_deletions: document.getElementById("deletions").checked,
linksync_auto_sync: document.getElementById("auto-sync").checked,
};
await browser.storage.local.set(settings);
await API.init();
this.closeSettings();
this.notify("Settings saved", "success");
await this.loadBookmarks();
},
async testConnection() {
try {
const result = await browser.runtime.sendMessage({ type: "TEST_CONNECTION" });
if (result.success) {
this.notify("Connection successful", "success");
} else {
this.notify(`Connection failed: ${result.error}`, "error");
}
} catch (e) {
this.notify(`Connection failed: ${e.message}`, "error");
}
},
async loadBookmarks() {
const container = document.getElementById("bookmarks-container");
container.innerHTML = '<p class="empty-state">Loading bookmarks...</p>';
try {
const response = await browser.runtime.sendMessage({ type: "GET_BOOKMARKS", data: { limit: 50 } });
this.bookmarks = response || [];
this.renderBookmarks(this.bookmarks);
} catch (e) {
container.innerHTML = `<p class="empty-state">Error: ${e.message}</p>`;
}
},
renderBookmarks(bookmarks) {
const container = document.getElementById("bookmarks-container");
if (!bookmarks || bookmarks.length === 0) {
container.innerHTML = '<p class="empty-state">No bookmarks found</p>';
return;
}
container.innerHTML = bookmarks
.map(
(bm) => `
<div class="bookmark-item" data-id="${bm.id}">
<a href="${this.escapeHtml(bm.url)}" target="_blank">${this.escapeHtml(bm.url)}</a>
<div class="bm-title">${this.escapeHtml(bm.title)}</div>
${bm.description ? `<div class="bm-desc">${this.escapeHtml(bm.description)}</div>` : ""}
${bm.tags && bm.tags.length > 0
? `<div class="bm-tags">${bm.tags.map((t) => `<span class="bm-tag">${this.escapeHtml(t)}</span>`).join("")}</div>`
: ""}
</div>
`
)
.join("");
},
filterBookmarks(query) {
const q = query.toLowerCase();
const filtered = this.bookmarks.filter(
(b) =>
(b.title && b.title.toLowerCase().includes(q)) ||
(b.url && b.url.toLowerCase().includes(q)) ||
(b.description && b.description.toLowerCase().includes(q)) ||
(b.tags && b.tags.some((t) => t.toLowerCase().includes(q)))
);
this.renderBookmarks(filtered);
},
async addBookmark() {
const data = {
url: document.getElementById("url").value,
title: document.getElementById("title").value,
description: document.getElementById("description").value,
notes: document.getElementById("notes").value,
tags: this.formatTags(document.getElementById("tags").value),
path: document.getElementById("folder").value,
};
try {
const result = await browser.runtime.sendMessage({ type: "CREATE_BOOKMARK", data });
document.getElementById("add-bookmark-form").reset();
this.notify("Bookmark added", "success");
await this.loadBookmarks();
} catch (e) {
this.notify(`Failed to add bookmark: ${e.message}`, "error");
}
},
formatTags(tagString) {
if (!tagString) return [];
return tagString
.split(",")
.map((t) => t.trim())
.filter((t) => t.length > 0);
},
async loadCollections() {
const container = document.getElementById("collections-container");
container.innerHTML = '<p class="empty-state">Loading collections...</p>';
try {
const response = await browser.runtime.sendMessage({ type: "GET_COLLECTIONS" });
this.collections = response || [];
this.renderCollections(this.collections);
} catch (e) {
container.innerHTML = `<p class="empty-state">Error: ${e.message}</p>`;
}
},
renderCollections(collections) {
const container = document.getElementById("collections-container");
if (!collections || collections.length === 0) {
container.innerHTML = '<p class="empty-state">No collections</p>';
return;
}
container.innerHTML = collections
.map(
(c) => `
<div class="collection-item">
<h3>${this.escapeHtml(c.name)}</h3>
${c.description ? `<p>${this.escapeHtml(c.description)}</p>` : ""}
<span class="col-type">${c.query_type}</span>
</div>
`
)
.join("");
},
async syncNow() {
const indicator = document.getElementById("sync-indicator");
indicator.className = "syncing";
try {
const result = await browser.runtime.sendMessage({ type: "SYNC_NOW" });
if (result && result.success) {
indicator.className = "synced";
this.notify(`Sync completed: ${result.actions?.length || 0} actions`, "success");
} else {
indicator.className = "error";
this.notify(`Sync failed: ${result?.error || "Unknown error"}`, "error");
}
} catch (e) {
indicator.className = "error";
this.notify(`Sync error: ${e.message}`, "error");
}
setTimeout(() => {
if (indicator.className === "syncing") indicator.className = "";
}, 3000);
this.updateSyncStatus();
},
updateSyncStatus() {
browser.storage.local.get(["linksync_last_sync", "linksync_syncing"]).then((settings) => {
const indicator = document.getElementById("sync-indicator");
const lastSync = document.getElementById("last-sync");
if (settings.linksync_syncing) {
indicator.className = "syncing";
lastSync.textContent = "Syncing...";
return;
}
if (settings.linksync_last_sync) {
const date = new Date(settings.linksync_last_sync);
const mins = Math.floor((Date.now() - date.getTime()) / 60000);
if (mins < 1) {
indicator.className = "synced";
lastSync.textContent = "Just now";
} else if (mins < 60) {
indicator.className = "synced";
lastSync.textContent = `${mins}m ago`;
} else {
indicator.className = "";
lastSync.textContent = date.toLocaleDateString();
}
}
});
},
async parseQuery() {
const expression = document.getElementById("query-input").value.trim();
if (!expression) {
this.notify("Enter a query expression", "info");
return;
}
try {
const result = await browser.runtime.sendMessage({ type: "PARSE_QUERY", data: { expression } });
const output = document.getElementById("query-result");
if (result.valid) {
output.innerHTML = `<pre style="white-space:pre-wrap;font-size:10px;">${JSON.stringify(result.parsed, null, 2)}</pre>`;
this.notify("Query parsed successfully", "success");
} else {
output.innerHTML = `<span style="color:var(--error)">Invalid: ${this.escapeHtml(result.error)}</span>`;
this.notify("Invalid query syntax", "error");
}
} catch (e) {
this.notify(`Parse error: ${e.message}`, "error");
}
},
async executeQuery() {
const expression = document.getElementById("query-input").value.trim();
if (!expression) {
this.notify("Enter a query expression", "info");
return;
}
const output = document.getElementById("query-result");
output.innerHTML = '<p class="empty-state">Executing...</p>';
try {
const results = await browser.runtime.sendMessage({
type: "EXECUTE_QUERY",
data: { expression, limit: 50 },
});
const items = results || [];
if (items.length === 0) {
output.innerHTML = '<p class="empty-state">No results</p>';
} else {
output.innerHTML = items
.map(
(bm) => `
<div class="bookmark-item" style="margin-bottom:4px;">
<a href="${this.escapeHtml(bm.url)}" target="_blank">${this.escapeHtml(bm.title || bm.url)}</a>
</div>
`
)
.join("");
this.notify(`Found ${items.length} results`, "success");
}
} catch (e) {
output.innerHTML = `<span style="color:var(--error)">Error: ${this.escapeHtml(e.message)}</span>`;
this.notify(`Query error: ${e.message}`, "error");
}
},
notify(message, type = "info") {
const container = document.getElementById("notification-container");
const el = document.createElement("div");
el.className = `notification ${type}`;
el.textContent = message;
container.appendChild(el);
setTimeout(() => {
el.style.opacity = "0";
el.style.transition = "opacity 0.3s";
setTimeout(() => el.remove(), 300);
}, 3000);
},
escapeHtml(str) {
if (!str) return "";
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
},
};
document.addEventListener("DOMContentLoaded", () => Popup.init());

View File

@@ -5,253 +5,235 @@
### Setup Tasks
- [x] Create project directory structure
- [x] Write README.md
- [ ] Write TODOs.txt
- [ ] Write design.md
- [ ] Write tasks.md
- [ ] Write AGENTS.md
- [x] Write TODOs.txt
- [x] Write design.md
- [x] Write tasks.md
- [x] Write AGENTS.md
### Initial Files
- [ ] Create manifest.json
- [ ] Add icon files (48x48, 96x96)
- [ ] Create styles folder with base.css
- [ ] Create utils folder structure
- [x] Create manifest.json (v2, Firefox-compatible)
- [x] Add icon files (48x48, 96x96)
- [x] Create utils folder with all modules
- [x] Create content folder for content script
## Phase 2: Core Development
### Background Script
- [ ] Create background.html
- [ ] Create background.js
- [ ] Implement init() on install/update
- [ ] Implement sync loop with interval
- [ ] Add event handlers (message, install, update)
- [ ] Implement sync mode switching
- [ ] Add collection mapping logic
- [ ] Implement auto-sync timer
- [ ] Add error handling and retries
- [x] Create background.html
- [x] Create background.js
- [x] Implement init() on install/update
- [x] Implement sync loop with interval (5 min)
- [x] Add event handlers (message, install, bookmark changes)
- [x] Implement sync mode switching
- [x] Add collection mapping logic
- [x] Implement auto-sync timer
- [x] Add error handling and retries
### Popup Script
- [ ] Create popup.html
- [ ] Create popup.css
- [ ] Create popup.js
- [ ] Implement bookmark form UI
- [ ] Add bookmark list view
- [ ] Implement search filter
- [ ] Add collection panel
- [ ] Implement settings UI
- [ ] Add sync button handler
- [x] Create popup.html with tabbed interface
- [x] Create popup.css with full responsive styling
- [x] Create popup.js with all functionality
- [x] Implement bookmark form UI with auto-fill
- [x] Add bookmark list view with search
- [x] Add collection panel
- [x] Implement settings modal
- [x] Add sync button handler
- [x] Implement query builder tab
- [x] Add toast notifications
### Utility Modules
- [ ] Create utils/bookmark.js
- [x] Create utils/api.js
- REST API client with Bearer token auth
- Retry logic (3 attempts with backoff)
- Timeout handling (10s)
- Rate limit handling (429)
- All endpoints: auth, links, collections, queries, sync, admin
- [x] Create utils/sync.js
- Bi-directional sync
- Browser-authoritative sync
- Server-authoritative sync
- Deletions handling
- Conflict detection
- [x] Create utils/collection.js
- List/create/update/delete collections
- Add/remove links from collections
- Execute queries
- Parse queries
- [x] Create utils/query-engine.js
- Tokenizer for query expressions
- Recursive descent parser
- AST generation (TERM, TERM_SET, FIELD, AND, OR, XOR)
- Query validation
- Query string builder
- [x] Create utils/bookmark.js
- Parse Firefox bookmark data
- Format bookmark for API
- Handle field validation
- [ ] Create utils/collection.js
- List collections API
- Execute query on collection
- Create static collection
- Update collection name
- [ ] Create utils/query-engine.js
- Tokenize query expression
- Build AST
- Validate query syntax
- Serialize AST to JSON
- [ ] Create utils/sync.js
- Implement sync mode logic
- Handle bi-directional sync
- Handle browser-authoritative sync
- Handle server-authoritative sync
- Apply deletions filter
- Conflict detection
- Conflict resolution
- Merge bookmarks for conflict resolution
- Duplicate detection
### API Client
- [ ] Create API request helper
- [ ] Implement /api/auth/login/
- [ ] Implement /api/links/ CRUD
- [ ] Implement /api/collections/ CRUD
- [ ] Implement /api/queries/execute/
- [ ] Implement /api/sync/
- [ ] Add error handling
- [ ] Add retry logic
- [ ] Add timeout handling
### Content Script
- [x] Create content/content.js
- [x] Implement page title extraction
- [x] Implement URL detection
- [x] Implement meta description extraction
- [x] Implement favicon extraction
- [x] Handle browser.runtime.onMessage
### Content Script (Optional)
- [ ] Create content/content.js
- [ ] Implement page title extraction
- [ ] Implement URL detection
- [ ] Implement meta description extraction
- [ ] Inject popup trigger
- [ ] Handle content script permissions
### Options Page
- [x] Create options.html
- [x] Create options.js
- [x] Server URL configuration
- [x] API key input
- [x] Sync mode dropdown
- [x] Deletions checkbox
- [x] Auto-sync checkbox
- [x] Test connection button
- [x] Sync now button
## Phase 3: Storage Management
### Storage Implementation
- [ ] Implement localStorage wrapper
- [ ] Add encryption for API keys
- [ ] Implement storage helper functions
- [ ] Add sync timestamp tracking
- [ ] Add pending changes counter
- [x] Use browser.storage.local for all settings
- [x] Store API key securely
- [x] Implement storage helper functions
- [x] Add sync timestamp tracking
- [x] Add pending changes counter
- [x] Add syncing state flag
### Storage Keys
- [ ] `linksync_api_key` - JWT token
- [ ] `linksync_collection` - Collection name
- [ ] `linksync_sync_mode` - Sync mode string
- [ ] `linksync_deletions` - Boolean
- [ ] `linksync_auto_sync` - Boolean
- [ ] `linksync_last_sync` - ISO timestamp
- [ ] `linksync_pending` - Integer count
- [x] `linksync_server_url` - Server base URL
- [x] `linksync_api_key` - JWT bearer token
- [x] `linksync_sync_mode` - Sync mode string
- [x] `linksync_deletions` - Boolean
- [x] `linksync_auto_sync` - Boolean
- [x] `linksync_last_sync` - ISO timestamp
- [x] `linksync_syncing` - Boolean flag
- [x] `linksync_pending` - Boolean flag
## Phase 4: Sync Logic
### Bi-directional Sync
- [ ] Push browser→server
- [ ] Push server→browser
- [ ] Merge conflicting updates
- [ ] Track both versions
- [x] Push browser→server (new bookmarks)
- [x] Push server→browser (new bookmarks)
- [x] Handle deletions when enabled
### Browser Authoritative Sync
- [ ] Push browser→server
- [ ] Overwrite server→browser
- [ ] No pull from server
- [x] Push browser→server (create + update)
- [x] Overwrite server data on conflict
- [x] Delete server bookmarks not in browser
### Server Authoritative Sync
- [ ] Download from server
- [ ] Overwrite local on conflict
- [ ] No push to server
- [x] Download from server
- [x] Overwrite local on conflict
- [x] No push to server
### Deletions
- [ ] Implement deletions checkbox logic
- [ ] Delete on both sides if enabled
- [ ] Log deletions
- [x] Implement deletions checkbox logic
- [x] Delete on both sides if enabled
### Conflict Resolution
- [ ] Detect URL collision
- [ ] Present resolution UI
- [ ] Keep browser version (default)
- [ ] Keep server version option
- [ ] Manual merge option
- [x] Detect URL collision with different titles
- [x] Conflict detection method available
## Phase 5: UI Components
### Bookmark Form
- [ ] URL input (auto-fill)
- [ ] Title input (auto-fill)
- [ ] Description textarea
- [ ] Notes textarea
- [ ] Tags input (comma-separated)
- [ ] Folder path input
- [ ] Add/Edit/Delete buttons
- [x] URL input (auto-fill from active tab)
- [x] Title input (auto-fill from active tab)
- [x] Description textarea
- [x] Notes textarea
- [x] Tags input (comma-separated)
- [x] Folder path input
- [x] Add button
### Bookmark List
- [ ] Pagination
- [ ] Search filter input
- [ ] Checkboxes for selection
- [ ] Batch delete button
- [ ] Batch tag update
- [x] Display synced bookmarks
- [x] Search filter input
- [x] Tag display
### Collections Panel
- [ ] Collection list
- [ ] Execute query button
- [ ] Create dynamic collection form
- [ ] Edit collection name/description
- [x] Collection list display
- [x] Collection type indicator
### Query Builder
- [ ] Simple query input
- [ ] Expression syntax help
- [ ] Example queries
- [ ] Save as collection option
- [x] Query expression input
- [x] Parse button
- [x] Execute button
- [x] Result display
- [x] Syntax help
### Sync Status
- [ ] Last sync timestamp
- [ ] Pending changes count
- [ ] Sync indicator icon
- [ ] Manual sync trigger
- [x] Last sync timestamp
- [x] Sync indicator (syncing/synced/error)
- [x] Manual sync trigger
### Settings Modal
- [ ] Server URL input
- [ ] API Key input (show/hide)
- [ ] Collection name input
- [ ] Sync mode dropdown
- [ ] Deletions checkbox
- [ ] Auto-sync toggle
- [ ] Test connection button
- [x] Server URL input
- [x] API Key input (show/hide toggle)
- [x] Sync mode dropdown
- [x] Deletions checkbox
- [x] Auto-sync checkbox
- [x] Test connection button
- [x] Save button
## Phase 6: Error Handling
### API Errors
- [ ] Handle 401 (unauthorized)
- [ ] Handle 403 (forbidden)
- [ ] Handle 429 (rate limited)
- [ ] Handle 500 (server error)
- [ ] Show user-friendly messages
- [x] Handle 401 (unauthorized)
- [x] Handle 429 (rate limited) with retry
- [x] Handle 500 (server error)
- [x] Handle timeout
- [x] Show user-friendly messages via notifications
### Network Errors
- [ ] Offline detection
- [ ] Queue changes offline
- [ ] Retry on reconnection
- [ ] Sync when back online
- [x] Offline detection (fetch errors)
- [x] Retry with backoff (3 attempts)
- [x] Request timeout (10s)
### UI Errors
- [ ] Form validation
- [ ] Input sanitization
- [ ] Graceful fallback on errors
- [ ] Error logging
- [x] Form validation (required fields)
- [x] Input sanitization (escapeHtml)
- [x] Error notifications
- [x] Empty state messages
## Phase 7: Testing
### Unit Tests
- [ ] Test sync modes
- [ ] Test conflict detection
- [ ] Test query parsing
- [ ] Test storage operations
- [ ] Test bookmark manipulation
### Integration Tests
- [ ] Test API calls
- [ ] Test background worker
- [ ] Test popup communication
- [ ] Test end-to-end sync flow
### Manual Testing
- [ ] Add bookmarks
- [ ] Edit bookmarks
- [ ] Delete bookmarks
- [ ] Create collections
- [ ] Execute queries
- [x] Testing checklist (tests/README.md)
- [ ] Test in Firefox (load temporary add-on)
- [ ] Test all sync modes
- [ ] Test conflict resolution
- [ ] Test conflict scenarios
- [ ] Test offline scenarios
## Phase 8: Packaging
### Distribution
- [ ] Create .zip distribution file
- [ ] Verify manifest.json
- [ ] Verify all assets
- [ ] Test in fresh Firefox install
### Version Management
- [ ] Update version in manifest
- [ ] Changelog file
- [ ] Release notes
- [x] All files present and valid
- [x] manifest.json verified
- [x] Icons present (48x48, 96x96)
## Phase 9: Documentation
- [ ] API reference
- [ ] User guide
- [ ] Troubleshooting guide
- [ ] Query syntax reference
- [ ] FAQ
- [x] API reference (README.md)
- [x] User guide (README.md)
- [x] Troubleshooting guide (README.md)
- [x] Query syntax reference (README.md)
- [x] Architecture docs (AGENTS.md, design.md)
## Future Enhancements
- [ ] Background sync notifications
- [ ] Auto-sync scheduler
- [ ] Keyboard shortcuts
- [ ] Dark theme toggle
- [ ] Bookmark edit/delete from popup
- [ ] Batch operations
- [ ] Conflict resolution UI
- [ ] Offline queue for pending changes
- [ ] Auto-sync scheduler customization
- [ ] Gesture controls
- [ ] Mobile companion app
- [ ] Dark theme toggle
- [ ] Custom colors

View 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));
},
};

View 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);
},
};

View 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 };
}
}

View 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;
},
};