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:
106
LinkSyncExtension/AGENTS.md
Normal file
106
LinkSyncExtension/AGENTS.md
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
|
||||
32
LinkSyncExtension/content/content.js
Normal file
32
LinkSyncExtension/content/content.js
Normal 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 |
BIN
LinkSyncExtension/icons/icon-96.png
Normal file
BIN
LinkSyncExtension/icons/icon-96.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 536 B |
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
162
LinkSyncExtension/options.html
Normal file
162
LinkSyncExtension/options.html
Normal 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>
|
||||
71
LinkSyncExtension/options.js
Normal file
71
LinkSyncExtension/options.js
Normal 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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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> > XOR > AND > 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">×</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>
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
215
LinkSyncExtension/utils/api.js
Normal file
215
LinkSyncExtension/utils/api.js
Normal file
@@ -0,0 +1,215 @@
|
||||
// LinkSync API Client
|
||||
// Handles all communication with LinkSyncServer
|
||||
|
||||
const API = {
|
||||
baseUrl: "",
|
||||
token: "",
|
||||
maxRetries: 3,
|
||||
retryDelay: 1000,
|
||||
timeout: 10000,
|
||||
|
||||
async init() {
|
||||
const settings = await this.getSettings();
|
||||
this.baseUrl = (settings.serverUrl || "http://localhost:5000").replace(/\/+$/, "");
|
||||
this.token = settings.apiKey || "";
|
||||
},
|
||||
|
||||
async getSettings() {
|
||||
return new Promise((resolve) => {
|
||||
browser.storage.local.get(
|
||||
["linksync_server_url", "linksync_api_key"],
|
||||
(result) => resolve(result)
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
async request(method, path, body = null) {
|
||||
await this.init();
|
||||
|
||||
if (!this.token) {
|
||||
throw new Error("Not authenticated. Set your API key in settings.");
|
||||
}
|
||||
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||
|
||||
const options = {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
},
|
||||
signal: controller.signal,
|
||||
};
|
||||
|
||||
if (body && method !== "GET") {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}${path}`, options);
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new Error("Authentication failed. Check your API key.");
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
const retryAfter = parseInt(response.headers.get("Retry-After"), 10) || this.retryDelay * attempt;
|
||||
await this.delay(retryAfter);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(
|
||||
errorData?.detail || `Server error: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (contentType && contentType.includes("application/json")) {
|
||||
return await response.json();
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (error.name === "AbortError") {
|
||||
lastError = new Error("Request timed out");
|
||||
}
|
||||
if (attempt < this.maxRetries) {
|
||||
await this.delay(this.retryDelay * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error("Request failed after retries");
|
||||
},
|
||||
|
||||
async get(path) {
|
||||
return this.request("GET", path);
|
||||
},
|
||||
|
||||
async post(path, body) {
|
||||
return this.request("POST", path, body);
|
||||
},
|
||||
|
||||
async put(path, body) {
|
||||
return this.request("PUT", path, body);
|
||||
},
|
||||
|
||||
async delete(path) {
|
||||
return this.request("DELETE", path);
|
||||
},
|
||||
|
||||
async login(username, password) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||
|
||||
const formData = new URLSearchParams();
|
||||
formData.append("username", username);
|
||||
formData.append("password", password);
|
||||
|
||||
const settings = await this.getSettings();
|
||||
this.baseUrl = (settings.serverUrl || "http://localhost:5000").replace(/\/+$/, "");
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: formData.toString(),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => null);
|
||||
throw new Error(error?.detail || "Login failed");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.token = data.access_token;
|
||||
|
||||
await browser.storage.local.set({ linksync_api_key: data.access_token });
|
||||
return data;
|
||||
},
|
||||
|
||||
async testConnection() {
|
||||
await this.init();
|
||||
const response = await fetch(`${this.baseUrl}/health`, {
|
||||
method: "GET",
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Server returned ${response.status}`);
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
// Links
|
||||
async getLinks(params = {}) {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
return this.get(`/api/links/${qs ? "?" + qs : ""}`);
|
||||
},
|
||||
|
||||
async createLink(data) {
|
||||
return this.post("/api/links/", data);
|
||||
},
|
||||
|
||||
async updateLink(id, data) {
|
||||
return this.put(`/api/links/${id}/`, data);
|
||||
},
|
||||
|
||||
async deleteLink(id) {
|
||||
return this.delete(`/api/links/${id}/`);
|
||||
},
|
||||
|
||||
// Collections
|
||||
async getCollections() {
|
||||
return this.get("/api/collections/");
|
||||
},
|
||||
|
||||
async createCollection(data) {
|
||||
return this.post("/api/collections/", data);
|
||||
},
|
||||
|
||||
async updateCollection(id, data) {
|
||||
return this.put(`/api/collections/${id}/`, data);
|
||||
},
|
||||
|
||||
async deleteCollection(id) {
|
||||
return this.delete(`/api/collections/${id}/`);
|
||||
},
|
||||
|
||||
async refreshCollection(id) {
|
||||
return this.post(`/api/collections/${id}/refresh`, {});
|
||||
},
|
||||
|
||||
// Queries
|
||||
async parseQuery(expression) {
|
||||
return this.post(`/api/queries/parse?expression=${encodeURIComponent(expression)}`);
|
||||
},
|
||||
|
||||
async executeQuery(expression, limit = 50) {
|
||||
return this.post(
|
||||
`/api/queries/execute?expression=${encodeURIComponent(expression)}&limit=${limit}`
|
||||
);
|
||||
},
|
||||
|
||||
// Sync
|
||||
async sync(config, browserBookmarks) {
|
||||
return this.post("/api/sync/", {
|
||||
mode: config.mode,
|
||||
deletions_enabled: config.deletions,
|
||||
browser_bookmarks: browserBookmarks,
|
||||
});
|
||||
},
|
||||
|
||||
// Admin
|
||||
async getAdminStats() {
|
||||
return this.get("/api/admin/stats");
|
||||
},
|
||||
|
||||
delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
},
|
||||
};
|
||||
51
LinkSyncExtension/utils/collection.js
Normal file
51
LinkSyncExtension/utils/collection.js
Normal file
@@ -0,0 +1,51 @@
|
||||
// LinkSync Collection Management
|
||||
// Handles collection CRUD and query execution
|
||||
|
||||
const CollectionManager = {
|
||||
async listCollections() {
|
||||
return API.getCollections();
|
||||
},
|
||||
|
||||
async createCollection(name, description, queryType, queryExpression, isPublic) {
|
||||
return API.createCollection({
|
||||
name,
|
||||
description: description || "",
|
||||
query_type: queryType || "static",
|
||||
query_expression: queryExpression || null,
|
||||
is_public: isPublic || false,
|
||||
link_ids: [],
|
||||
});
|
||||
},
|
||||
|
||||
async updateCollection(id, data) {
|
||||
return API.updateCollection(id, data);
|
||||
},
|
||||
|
||||
async deleteCollection(id) {
|
||||
return API.deleteCollection(id);
|
||||
},
|
||||
|
||||
async refreshCollection(id) {
|
||||
return API.refreshCollection(id);
|
||||
},
|
||||
|
||||
async addLinksToCollection(collectionId, linkIds) {
|
||||
return API.post(`/api/collections/${collectionId}/add-links`, linkIds);
|
||||
},
|
||||
|
||||
async removeLinksFromCollection(collectionId, linkIds) {
|
||||
return API.delete(`/api/collections/${collectionId}/remove-links`, { body: linkIds });
|
||||
},
|
||||
|
||||
async executeQuery(expression, limit = 50) {
|
||||
return API.executeQuery(expression, limit);
|
||||
},
|
||||
|
||||
async parseQuery(expression) {
|
||||
return API.parseQuery(expression);
|
||||
},
|
||||
|
||||
async createDynamicCollection(name, description, queryExpression) {
|
||||
return this.createCollection(name, description, "dynamic", queryExpression, false);
|
||||
},
|
||||
};
|
||||
233
LinkSyncExtension/utils/query-engine.js
Normal file
233
LinkSyncExtension/utils/query-engine.js
Normal file
@@ -0,0 +1,233 @@
|
||||
// LinkSync Query Engine
|
||||
// Client-side query parser and executor for building queries
|
||||
|
||||
const QueryEngine = {
|
||||
tokenize(expression) {
|
||||
const tokens = [];
|
||||
let pos = 0;
|
||||
const len = expression.length;
|
||||
|
||||
while (pos < len) {
|
||||
const ch = expression[pos];
|
||||
|
||||
if (ch === " " || ch === "\t") {
|
||||
pos++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "(") {
|
||||
tokens.push({ type: "LPAREN", value: "(" });
|
||||
pos++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === ")") {
|
||||
tokens.push({ type: "RPAREN", value: ")" });
|
||||
pos++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === ",") {
|
||||
tokens.push({ type: "COMMA", value: "," });
|
||||
pos++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (expression.substring(pos, pos + 3) === "AND") {
|
||||
tokens.push({ type: "OPERATOR", value: "AND" });
|
||||
pos += 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (expression.substring(pos, pos + 2) === "OR") {
|
||||
tokens.push({ type: "OPERATOR", value: "OR" });
|
||||
pos += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (expression.substring(pos, pos + 3) === "XOR") {
|
||||
tokens.push({ type: "OPERATOR", value: "XOR" });
|
||||
pos += 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "'" || ch === '"') {
|
||||
const quote = ch;
|
||||
pos++;
|
||||
let value = "";
|
||||
while (pos < len && expression[pos] !== quote) {
|
||||
value += expression[pos];
|
||||
pos++;
|
||||
}
|
||||
pos++;
|
||||
tokens.push({ type: "TERM", value });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/[a-zA-Z0-9_.\-]/.test(ch)) {
|
||||
let value = "";
|
||||
while (pos < len && /[a-zA-Z0-9_.\-:\/?&=%]/.test(expression[pos])) {
|
||||
value += expression[pos];
|
||||
pos++;
|
||||
}
|
||||
|
||||
if (value.includes(":")) {
|
||||
const [field, ...rest] = value.split(":");
|
||||
const fieldValue = rest.join(":");
|
||||
const knownFields = ["url", "tag", "title", "description", "path", "id"];
|
||||
if (knownFields.includes(field.toLowerCase())) {
|
||||
tokens.push({ type: "FIELD", value: field.toUpperCase() });
|
||||
tokens.push({ type: "TERM", value: fieldValue });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
tokens.push({ type: "TERM", value });
|
||||
continue;
|
||||
}
|
||||
|
||||
pos++;
|
||||
}
|
||||
|
||||
return tokens;
|
||||
},
|
||||
|
||||
parse(expression) {
|
||||
if (!expression || !expression.trim()) return null;
|
||||
|
||||
const tokens = this.tokenize(expression);
|
||||
if (tokens.length === 0) return null;
|
||||
|
||||
const parser = new QueryParserState(tokens);
|
||||
return parser.parseOr();
|
||||
},
|
||||
|
||||
buildQueryString(ast) {
|
||||
if (!ast) return "";
|
||||
|
||||
switch (ast.type) {
|
||||
case "TERM":
|
||||
return ast.value;
|
||||
case "TERM_SET":
|
||||
return `(${ast.values.join(", ")})`;
|
||||
case "FIELD":
|
||||
return `${ast.field.toLowerCase()}:${ast.value}`;
|
||||
case "AND":
|
||||
case "OR":
|
||||
case "XOR":
|
||||
return `(${this.buildQueryString(ast.left)} ${ast.type} ${this.buildQueryString(ast.right)})`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
},
|
||||
|
||||
validate(expression) {
|
||||
try {
|
||||
const ast = this.parse(expression);
|
||||
return { valid: true, ast };
|
||||
} catch (e) {
|
||||
return { valid: false, error: e.message };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
class QueryParserState {
|
||||
constructor(tokens) {
|
||||
this.tokens = tokens;
|
||||
this.pos = 0;
|
||||
}
|
||||
|
||||
current() {
|
||||
return this.pos < this.tokens.length ? this.tokens[this.pos] : null;
|
||||
}
|
||||
|
||||
advance() {
|
||||
const token = this.current();
|
||||
this.pos++;
|
||||
return token;
|
||||
}
|
||||
|
||||
expect(type) {
|
||||
const token = this.current();
|
||||
if (!token) throw new Error(`Expected ${type}, got end of input`);
|
||||
if (token.type !== type) throw new Error(`Expected ${type}, got ${token.type}`);
|
||||
return this.advance();
|
||||
}
|
||||
|
||||
parseOr() {
|
||||
let left = this.parseAnd();
|
||||
while (this.current() && this.current().type === "OPERATOR" && this.current().value === "OR") {
|
||||
this.advance();
|
||||
const right = this.parseAnd();
|
||||
left = { type: "OR", left, right };
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
parseAnd() {
|
||||
let left = this.parseXor();
|
||||
while (this.current() && this.current().type === "OPERATOR" && this.current().value === "AND") {
|
||||
this.advance();
|
||||
const right = this.parseXor();
|
||||
left = { type: "AND", left, right };
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
parseXor() {
|
||||
let left = this.parsePrimary();
|
||||
while (this.current() && this.current().type === "OPERATOR" && this.current().value === "XOR") {
|
||||
this.advance();
|
||||
const right = this.parsePrimary();
|
||||
left = { type: "XOR", left, right };
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
parsePrimary() {
|
||||
const token = this.current();
|
||||
if (!token) throw new Error("Unexpected end of input");
|
||||
|
||||
if (token.type === "LPAREN") {
|
||||
this.advance();
|
||||
const node = this.parseOr();
|
||||
this.expect("RPAREN");
|
||||
return node;
|
||||
}
|
||||
|
||||
if (token.type === "FIELD") {
|
||||
const field = this.advance().value;
|
||||
const valueToken = this.current();
|
||||
if (valueToken && valueToken.type === "TERM") {
|
||||
this.advance();
|
||||
return { type: "FIELD", field, value: valueToken.value };
|
||||
}
|
||||
return { type: "FIELD", field, value: "" };
|
||||
}
|
||||
|
||||
if (token.type === "TERM") {
|
||||
return this.parseTerm();
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected token: ${token.value}`);
|
||||
}
|
||||
|
||||
parseTerm() {
|
||||
const token = this.advance();
|
||||
const next = this.current();
|
||||
|
||||
if (next && next.type === "COMMA") {
|
||||
const values = [token.value];
|
||||
while (this.current() && this.current().type === "COMMA") {
|
||||
this.advance();
|
||||
const termToken = this.current();
|
||||
if (termToken && termToken.type === "TERM") {
|
||||
values.push(this.advance().value);
|
||||
}
|
||||
}
|
||||
return { type: "TERM_SET", values };
|
||||
}
|
||||
|
||||
return { type: "TERM", value: token.value };
|
||||
}
|
||||
}
|
||||
261
LinkSyncExtension/utils/sync.js
Normal file
261
LinkSyncExtension/utils/sync.js
Normal file
@@ -0,0 +1,261 @@
|
||||
// LinkSync Sync Engine
|
||||
// Handles all three sync modes with conflict resolution
|
||||
|
||||
const SyncEngine = {
|
||||
MODES: {
|
||||
BIDIRECTIONAL: "bi-directional",
|
||||
BROWSER_AUTHORITY: "browser-authoritative",
|
||||
SERVER_AUTHORITY: "server-authoritative",
|
||||
},
|
||||
|
||||
async getConfig() {
|
||||
const result = await browser.storage.local.get([
|
||||
"linksync_sync_mode",
|
||||
"linksync_deletions",
|
||||
"linksync_collection",
|
||||
]);
|
||||
return {
|
||||
mode: result.linksync_sync_mode || this.MODES.BIDIRECTIONAL,
|
||||
deletions: result.linksync_deletions === true,
|
||||
collection: result.linksync_collection || null,
|
||||
};
|
||||
},
|
||||
|
||||
async runSync() {
|
||||
const config = await this.getConfig();
|
||||
const browserBookmarks = await this.getBrowserBookmarks();
|
||||
const serverBookmarks = await API.getLinks({ limit: 1000 });
|
||||
|
||||
let actions = [];
|
||||
|
||||
switch (config.mode) {
|
||||
case this.MODES.BIDIRECTIONAL:
|
||||
actions = await this.bidirectionalSync(browserBookmarks, serverBookmarks, config);
|
||||
break;
|
||||
case this.MODES.BROWSER_AUTHORITY:
|
||||
actions = await this.browserAuthoritativeSync(browserBookmarks, serverBookmarks, config);
|
||||
break;
|
||||
case this.MODES.SERVER_AUTHORITY:
|
||||
actions = await this.serverAuthoritativeSync(browserBookmarks, serverBookmarks, config);
|
||||
break;
|
||||
}
|
||||
|
||||
await browser.storage.local.set({
|
||||
linksync_last_sync: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return actions;
|
||||
},
|
||||
|
||||
async getBrowserBookmarks() {
|
||||
const tree = await browser.bookmarks.getTree();
|
||||
const flat = [];
|
||||
this.flattenBookmarks(tree, flat);
|
||||
return flat;
|
||||
},
|
||||
|
||||
flattenBookmarks(nodes, result, path = "") {
|
||||
for (const node of nodes) {
|
||||
if (node.url) {
|
||||
result.push({
|
||||
id: node.id,
|
||||
url: node.url,
|
||||
title: node.title || "",
|
||||
dateAdded: node.dateAdded ? new Date(node.dateAdded).toISOString() : null,
|
||||
path: path,
|
||||
});
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
const newPath = node.title ? `${path}/${node.title}` : path;
|
||||
this.flattenBookmarks(node.children, result, newPath);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async bidirectionalSync(browserBookmarks, serverBookmarks, config) {
|
||||
const actions = [];
|
||||
const serverByUrl = {};
|
||||
const browserByUrl = {};
|
||||
|
||||
for (const bm of serverBookmarks || []) {
|
||||
serverByUrl[bm.url.toLowerCase()] = bm;
|
||||
}
|
||||
for (const bm of browserBookmarks) {
|
||||
browserByUrl[bm.url.toLowerCase()] = bm;
|
||||
}
|
||||
|
||||
// Push new/updated browser bookmarks to server
|
||||
for (const bm of browserBookmarks) {
|
||||
const key = bm.url.toLowerCase();
|
||||
const serverBm = serverByUrl[key];
|
||||
|
||||
if (!serverBm) {
|
||||
try {
|
||||
const created = await API.createLink({
|
||||
url: bm.url,
|
||||
title: bm.title,
|
||||
tags: [],
|
||||
path: bm.path,
|
||||
});
|
||||
actions.push({ type: "create", url: bm.url, target: "server", id: created?.id });
|
||||
} catch (e) {
|
||||
actions.push({ type: "error", url: bm.url, target: "server", error: e.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Push new server bookmarks to browser
|
||||
for (const bm of serverBookmarks || []) {
|
||||
const key = bm.url.toLowerCase();
|
||||
const browserBm = browserByUrl[key];
|
||||
|
||||
if (!browserBm) {
|
||||
try {
|
||||
const created = await browser.bookmarks.create({
|
||||
url: bm.url,
|
||||
title: bm.title,
|
||||
});
|
||||
actions.push({ type: "create", url: bm.url, target: "browser", id: created?.id });
|
||||
} catch (e) {
|
||||
actions.push({ type: "error", url: bm.url, target: "browser", error: e.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle deletions
|
||||
if (config.deletions) {
|
||||
for (const key in serverByUrl) {
|
||||
if (!browserByUrl[key]) {
|
||||
try {
|
||||
await API.deleteLink(serverByUrl[key].id);
|
||||
actions.push({ type: "delete", url: key, target: "server" });
|
||||
} catch (e) {
|
||||
actions.push({ type: "error", url: key, target: "server", error: e.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return actions;
|
||||
},
|
||||
|
||||
async browserAuthoritativeSync(browserBookmarks, serverBookmarks, config) {
|
||||
const actions = [];
|
||||
const serverByUrl = {};
|
||||
|
||||
for (const bm of serverBookmarks || []) {
|
||||
serverByUrl[bm.url.toLowerCase()] = bm;
|
||||
}
|
||||
|
||||
// Push all browser bookmarks to server (overwriting if exists)
|
||||
for (const bm of browserBookmarks) {
|
||||
const key = bm.url.toLowerCase();
|
||||
const serverBm = serverByUrl[key];
|
||||
|
||||
if (!serverBm) {
|
||||
try {
|
||||
const created = await API.createLink({
|
||||
url: bm.url,
|
||||
title: bm.title,
|
||||
tags: [],
|
||||
path: bm.path,
|
||||
});
|
||||
actions.push({ type: "create", url: bm.url, target: "server", id: created?.id });
|
||||
} catch (e) {
|
||||
actions.push({ type: "error", url: bm.url, target: "server", error: e.message });
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await API.updateLink(serverBm.id, {
|
||||
url: bm.url,
|
||||
title: bm.title,
|
||||
});
|
||||
actions.push({ type: "update", url: bm.url, target: "server" });
|
||||
} catch (e) {
|
||||
actions.push({ type: "error", url: bm.url, target: "server", error: e.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete server bookmarks not in browser
|
||||
if (config.deletions) {
|
||||
const browserUrls = new Set(browserBookmarks.map((b) => b.url.toLowerCase()));
|
||||
for (const key in serverByUrl) {
|
||||
if (!browserUrls.has(key)) {
|
||||
try {
|
||||
await API.deleteLink(serverByUrl[key].id);
|
||||
actions.push({ type: "delete", url: key, target: "server" });
|
||||
} catch (e) {
|
||||
actions.push({ type: "error", url: key, target: "server", error: e.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return actions;
|
||||
},
|
||||
|
||||
async serverAuthoritativeSync(browserBookmarks, serverBookmarks, config) {
|
||||
const actions = [];
|
||||
const browserByUrl = {};
|
||||
|
||||
for (const bm of browserBookmarks) {
|
||||
browserByUrl[bm.url.toLowerCase()] = bm;
|
||||
}
|
||||
|
||||
// Download all server bookmarks to browser
|
||||
for (const bm of serverBookmarks || []) {
|
||||
const key = bm.url.toLowerCase();
|
||||
const browserBm = browserByUrl[key];
|
||||
|
||||
if (!browserBm) {
|
||||
try {
|
||||
const created = await browser.bookmarks.create({
|
||||
url: bm.url,
|
||||
title: bm.title,
|
||||
});
|
||||
actions.push({ type: "create", url: bm.url, target: "browser", id: created?.id });
|
||||
} catch (e) {
|
||||
actions.push({ type: "error", url: bm.url, target: "browser", error: e.message });
|
||||
}
|
||||
} else {
|
||||
// Overwrite local on conflict
|
||||
try {
|
||||
await browser.bookmarks.update(browserBm.id, {
|
||||
title: bm.title,
|
||||
});
|
||||
actions.push({ type: "update", url: bm.url, target: "browser" });
|
||||
} catch (e) {
|
||||
actions.push({ type: "error", url: bm.url, target: "browser", error: e.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return actions;
|
||||
},
|
||||
|
||||
detectConflicts(browserBookmarks, serverBookmarks) {
|
||||
const conflicts = [];
|
||||
const serverByUrl = {};
|
||||
|
||||
for (const bm of serverBookmarks || []) {
|
||||
serverByUrl[bm.url.toLowerCase()] = bm;
|
||||
}
|
||||
|
||||
for (const bm of browserBookmarks) {
|
||||
const key = bm.url.toLowerCase();
|
||||
const serverBm = serverByUrl[key];
|
||||
if (serverBm && serverBm.title !== bm.title) {
|
||||
conflicts.push({
|
||||
url: bm.url,
|
||||
browserTitle: bm.title,
|
||||
serverTitle: serverBm.title,
|
||||
browserId: bm.id,
|
||||
serverId: serverBm.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return conflicts;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user