Initial commit: LinkSyncServer and LinkSyncExtension projects with complete documentation, models, API endpoints, tests, and extension implementation

This commit is contained in:
DavidSaylor
2026-05-11 17:37:10 -05:00
parent ad0b12b452
commit aed69afdfd
691 changed files with 181874 additions and 28 deletions

287
LinkSyncExtension/README.md Normal file
View File

@@ -0,0 +1,287 @@
# LinkSyncExtension
A Firefox browser extension for bookmark synchronization with LinkSyncServer.
## Overview
LinkSyncExtension syncs bookmarks between Firefox and LinkSyncServer, supporting:
- **Firefox-Compatible Fields** - All bookmark attributes natively
- **Multiple Sync Modes** - Bi-directional, browser authoritative, server authoritative
- **Collection Management** - View and manage collections
- **Query Builder** - Build and execute complex queries
- **Conflict Resolution** - Handle sync conflicts gracefully
## Features
### Synchronization
| Mode | Description |
|------|-------------|
| **Bi-directional** | Add/update bookmarks both ways; optional deletions |
| **Browser Authoritative** | Browser is source of truth; overwrites server |
| **Server Authoritative** | Download from server only; overwrite on conflict |
### Bookmarks (Links)
All Firefox bookmark attributes:
- `url` - Bookmark URL
- `title` - Display title
- `description` - Optional description
- `notes` - User notes
- `tags` - Array of tag names
- `favicon_url` - Icon URL
- `path` - Folder structure
- `created_at`, `updated_at` - Timestamps
- `visit_count`, `is_bookmarked` - Status fields
### Collections
Two types:
1. **Static Collections** - Explicit set of bookmark IDs
2. **Dynamic Collections** - Query expression evaluated on access
### Query Builder
Build queries with:
- Term lists: `('term1', 'term2', 'term3')` → OR
- Tag filters: `tagA`, `tagB`
- Field filters: `url:example.com`
- Set operations: `AND`, `OR`, `XOR`
Example:
```
('work', 'dev') OR tag:work XOR url:internal.com
```
### Sync Status
Monitor sync state:
- Last sync time
- Pending changes count
- Conflict indicators
- Manual sync trigger
## Installation
### Step 1: Load Extension
1. Open Firefox and navigate to `about:addons`
2. Click the "Gear" icon → "Debug Add-ons"
3. Click "Load Temporary Add-on..."
4. Navigate to `LinkSyncExtension` folder
5. Select `manifest.json`
Or upload a `.zip` file via "Install Temporary Add-on from File..."
### Step 2: Configure
1. Click the extension icon
2. Click "Settings" button
3. Configure:
- **Server URL** - LinkSyncServer address (e.g., `https://links.example.com`)
- **API Key** - Generate from server admin panel
- **Collection Name** - Name to map this browser
4. Click "Save Settings"
### Step 3: Start Syncing
- Extension runs in background
- Click icon to view status
- Add bookmarks via popup
- Sync automatically or manually
## Usage
### Adding Bookmarks
Click the extension icon to open the popup:
- **URL** - Auto-filled with current page (or manual entry)
- **Title** - Auto-filled from page title
- **Description** - Auto-filled from meta description
- **Notes** - Your notes
- **Tags** - Comma-separated tag names
- **Folder** - Folder structure path
- Click "Add Bookmark"
### Viewing Collections
Click the extension icon:
- **All Links** - View all synced bookmarks
- **Collections** - View your collections
- **Search** - Search across all links
- **Query Builder** - Build custom queries
### Syncing
Click the extension icon → "Sync Now"
- Manual sync triggers
- Automatic sync on page load (optional)
### Managing Settings
Click the extension icon → "Settings"
Change:
- Server URL
- API Key
- Collection mapping
- Sync mode
- Auto-sync settings
## File Structure
```
LinkSyncExtension/
├── manifest.json # Extension manifest v2
├── popup.html # Bookmark add/edit UI
├── popup.css # Popup styling
├── popup.js # Popup logic
├── background.html # Settings page
├── background.js # Service worker
├── content/
│ └── content.js # Content script (optional)
└── utils/
├── bookmark.js # Bookmark manipulation
├── collection.js # Collection management
├── query-engine.js # Query parsing/execution
└── sync.js # Sync logic
```
## API
The extension communicates with LinkSyncServer via REST API:
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/auth/login/` | POST | Authenticate, get API token |
| `/api/links/` | GET | List bookmarks |
| `/api/links/` | POST | Create bookmark |
| `/api/links/{id}/` | PUT | Update bookmark |
| `/api/links/{id}/` | DELETE | Delete bookmark |
| `/api/collections/` | GET | List collections |
| `/api/collections/{id}/` | GET | Get collection |
| `/api/collections/{id}/query/` | POST | Execute query |
| `/api/sync/` | POST | Sync bookmarks |
Headers:
```
Authorization: Token <your-api-key>
Content-Type: application/json
```
## Configuration
### manifest.json
```json
{
"manifest_version": 2,
"name": "LinkSync",
"version": "1.0.0",
"description": "Sync bookmarks with LinkSyncServer",
"permissions": [
"bookmarks",
"storage",
"activeTab"
],
"browser_action": {
"default_icon": {
"48": "icons/icon-48.png",
"96": "icons/icon-96.png"
},
"default_title": "LinkSync"
},
"background": {
"page": "background.html"
},
"browser_specific_settings": {
"gecko": {
"id": "linksync@example.com",
"strict_min_version": "109.0"
}
}
}
```
## Sync Modes
### Bi-directional
- Bookmarks added/updated in browser → pushed to server
- Bookmarks added/updated on server → pushed to browser
- Optional: Enable deletions
### Browser Authoritative
- Browser is source of truth
- Server data overwritten on conflict
- Only additions/updates pushed
### Server Authoritative
- Download bookmarks from server
- Overwrite local data on conflict
- No push to server
## Collection Mapping
Map browser profile to collection:
- Each collection has a unique name
- Extension stores collection name in settings
- Server uses name to identify collection
- Multiple extensions per profile supported
## Security
- API keys stored in browser storage (encrypted)
- Never expose keys in code
- Validate all server responses
- HTTPS-only connections preferred
## Troubleshooting
### Extension not loading
- Check browser console for errors
- Verify manifest.json is valid
- Ensure all files present
### Cannot connect to server
- Verify server URL is correct
- Check API key is valid
- Ensure HTTPS or server allows HTTP
### Sync not working
- Check sync mode settings
- Verify collection exists on server
- Check browser console for errors
### Conflicts
- Conflict detected when same URL exists in different locations
- Review conflict in popup
- Choose which version to keep
## License
MIT License
## Support
For issues and questions, open an issue on the LinkSyncServer repository.
---
**Version:** 1.0.0
**Last Updated:** 2026-05-11

103
LinkSyncExtension/TODOs.txt Normal file
View File

@@ -0,0 +1,103 @@
# LinkSyncExtension - Task List
## 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
## Core Development
### Extension Manifest
- [ ] Create manifest.json (MVP)
- [ ] Add icon files
- [ ] Configure permissions
- [ ] Set browser ID
### Background Script
- [ ] Create background.js service worker
- [ ] Implement sync logic
- [ ] Handle sync mode switching
- [ ] Manage collection mapping
- [ ] Auto-sync timer
- [ ] Error handling
### Popup Script
- [ ] Create popup.html
- [ ] Create popup.css
- [ ] Create popup.js
- [ ] Bookmark form UI
- [ ] Collection list UI
- [ ] Settings UI
- [ ] Search UI
### Utility Modules
- [ ] utils/bookmark.js - Bookmark manipulation
- [ ] utils/collection.js - Collection management
- [ ] utils/query-engine.js - Query parsing/execution
- [ ] utils/sync.js - Sync logic
### Content Script (Optional)
- [ ] content/content.js - Read page data
- [ ] Extract title/description
- [ ] Handle URL detection
- [ ] Inject into popup
### API Integration
- [ ] /api/auth/login/ - Authentication
- [ ] /api/links/ - Bookmark CRUD
- [ ] /api/collections/ - Collection CRUD
- [ ] /api/queries/execute/ - Query execution
- [ ] /api/sync/ - Sync endpoint
### Sync Logic
- [ ] Implement bi-directional sync
- [ ] Implement browser-authoritative sync
- [ ] Implement server-authoritative sync
- [ ] Handle deletions checkbox
- [ ] Conflict detection
- [ ] Conflict resolution UI
### UI Components
- [ ] Bookmark list view
- [ ] Collection builder UI
- [ ] Query editor
- [ ] Search interface
- [ ] Sync status indicator
- [ ] Conflict resolution modal
### Storage Management
- [ ] Store API key securely
- [ ] Store collection mapping
- [ ] Store sync settings
- [ ] Sync timestamp tracking
- [ ] Pending changes tracking
## Security
- [ ] Encrypted storage
- [ ] API key validation
- [ ] HTTPS enforcement checks
- [ ] CORS validation
- [ ] Input sanitization
## Testing
- [ ] Test sync modes
- [ ] Test conflict resolution
- [ ] Test query execution
- [ ] Test offline handling
- [ ] Test error handling
## Documentation
- [ ] API reference
- [ ] User guide
- [ ] Troubleshooting guide
- [ ] Query syntax guide
## Future Enhancements
- [ ] Background sync notifications
- [ ] Auto-sync scheduler
- [ ] Keyboard shortcuts
- [ ] Gesture controls
- [ ] Mobile companion app

View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'">
<title>LinkSync Background</title>
<style>
body { margin: 0; padding: 10px; font-family: system-ui, sans-serif; }
#status { margin-top: 10px; padding: 8px; border-radius: 4px; }
.connected { background: #d1fae5; color: #065f46; }
.disconnected { background: #fee2e2; color: #991b1b; }
</style>
</head>
<body>
<h3>LinkSync Extension</h3>
<p>Status: <span id="status-text">Connecting...</span></p>
<div id="status"></div>
<script src="background.js"></script>
</body>
</html>

View File

@@ -0,0 +1,312 @@
// LinkSync Background Service Worker
// Handles bookmark synchronization with LinkSyncServer
(function() {
'use strict';
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));
});
});
}
};
// Initialize on install/update
browser.runtime.onInstalled.addListener(() => {
Background.init();
});
// Expose to window
window.Background = Background;
})();

449
LinkSyncExtension/design.md Normal file
View File

@@ -0,0 +1,449 @@
# LinkSyncExtension - Design Documentation
## Architecture Overview
LinkSyncExtension is a Firefox browser extension that synchronizes bookmarks with LinkSyncServer. It runs as a background service worker with popup and settings interfaces.
### Component Architecture
```
┌─────────────────────────────────────────────────────┐
│ LinkSyncExtension │
│ │
│ ┌─────────────────┐ ┌─────────────────────────┐ │
│ │ Background │ │ Popup UI │ │
│ │ Service │ │ (Add/Edit Bookmarks) │ │
│ │ Worker │ │ │ │
│ └─────────────────┘ └─────────────────────────┘ │
│ ┌─────────────────┐ ┌─────────────────────────┐ │
│ │ Storage │ │ Settings UI │ │
│ │ Manager │ │ (Configuration) │ │
│ └─────────────────┘ └─────────────────────────┘ │
│ ┌─────────────────┐ ┌─────────────────────────┐ │
│ │ Sync Engine │ │ Query Engine │ │
│ │ (3 modes) │ │ (Parser + Executor) │ │
│ └─────────────────┘ └─────────────────────────┘ │
│ ┌─────────────────┐ ┌─────────────────────────┐ │
│ │ Bookmark │ │ API Client │ │
│ │ Manipulator │ │ (REST calls) │ │
│ └─────────────────┘ └─────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────┐ │
│ │ Browser Bookmarks API │ │
│ └───────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
```
## File Structure
```
LinkSyncExtension/
├── manifest.json # Extension manifest v2
├── popup.html # Bookmark add/edit UI
├── popup.css # Popup styling
├── popup.js # Popup logic
├── background.html # Settings page
├── background.js # Service worker
├── content/
│ └── content.js # Content script (optional)
├── utils/
│ ├── bookmark.js # Bookmark manipulation
│ ├── collection.js # Collection management
│ ├── query-engine.js # Query parsing/execution
│ └── sync.js # Sync logic
├── icons/
│ ├── icon-48.png # 48x48 icon
│ └── icon-96.png # 96x96 icon
└── styles/
├── base.css # Common styles
└── theme.css # Theme variables
```
## Manifest Design
### manifest.json
```json
{
"manifest_version": 2,
"name": "LinkSync",
"version": "1.0.0",
"description": "Sync bookmarks with LinkSyncServer",
"permissions": [
"bookmarks",
"storage",
"activeTab",
"http://*/*",
"https://*/*"
],
"browser_action": {
"default_icon": {
"48": "icons/icon-48.png",
"96": "icons/icon-96.png"
},
"default_title": "LinkSync"
},
"background": {
"page": "background.html"
},
"browser_specific_settings": {
"gecko": {
"id": "{linksync-id}",
"strict_min_version": "109.0"
}
}
}
```
### Permissions
- `bookmarks` - Read/write Firefox bookmarks
- `storage` - Store settings, API keys, state
- `activeTab` - Get current page data
- HTTP/HTTPS - API communication
## Background Worker Design
### Responsibilities
1. **Sync Loop**
- Check for pending syncs
- Compare browser vs server bookmarks
- Apply sync mode rules
- Handle conflicts
2. **Event Handlers**
- `onMessage` - UI requests
- `onInstall` - Initialization
- `onUpdate` - Handle version changes
3. **State Management**
- Store collection mapping
- Track sync timestamps
- Monitor pending changes
### Code Structure
```javascript
// background.js
const Background = {
// Constants
SYNC_CHECK_INTERVAL: 60000, // 1 minute
// Storage keys
STORAGE: {
API_KEY: 'linksync_api_key',
COLLECTION: 'linksync_collection',
MODE: 'linksync_sync_mode',
DELETIONS: 'linksync_deletions',
AUTO_SYNC: 'linksync_auto_sync'
},
// Methods
init(), // Initialize on install/update
checkSync(), // Run sync loop
handleSyncAction(), // Process sync actions
handleEvent(), // Event handlers
sendMessage(), // UI communication
authenticate() // Handle auth
};
```
### Sync Logic
```javascript
async function handleSync() {
const config = await loadConfig();
// Get browser bookmarks
const browserBookmarks = await getBrowserBookmarks();
// Get server bookmarks via API
const serverBookmarks = await fetchServerBookmarks();
// Apply sync mode
const actions = applySyncMode(config.mode, browserBookmarks, serverBookmarks);
// Process deletions if enabled
if (config.deletions) {
actions = applyDeletions(actions);
}
// Apply actions
await applyActions(actions);
// Update sync timestamp
await saveSyncTimestamp();
}
```
### Sync Modes
| Mode | Browser→Server | Server→Browser |
|------|---------------|---------------|
| **Bi-directional** | Push | Push |
| **Browser Authoritative** | Push | Overwrite |
| **Server Authoritative** | Download | Overwrite |
## Popup Design
### Components
1. **Add/Edit Form**
- URL (auto-filled)
- Title (auto-filled)
- Description (auto-filled)
- Notes
- Tags input
- Folder path
- Actions (Add, Edit, Delete)
2. **Bookmark List**
- Paginated list of synced bookmarks
- Search filter
- Select for batch operations
3. **Collections Panel**
- View all collections
- Execute query
- Create dynamic collection
4. **Settings Modal**
- Server URL
- API Key
- Collection name
- Sync mode
- Auto-sync toggle
### HTML Structure
```html
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="popup.css">
</head>
<body>
<!-- Header -->
<header>
<h1>LinkSync</h1>
</header>
<!-- Add/Edit Form -->
<section id="bookmark-form">
<form id="bookmark-form">
<input id="url" type="url">
<input id="title" type="text">
<textarea id="description"></textarea>
<textarea id="notes"></textarea>
<input id="tags" placeholder="comma-separated">
<input id="folder" placeholder="path">
<button id="submit">Add Bookmark</button>
</form>
</section>
<!-- Bookmark List -->
<section id="bookmark-list">
<!-- Bookmarks -->
</section>
<!-- Footer with actions -->
<footer>
<button id="sync-btn">Sync Now</button>
<button id="settings-btn">Settings</button>
</footer>
</body>
</html>
```
## Storage Design
### localStorage Keys
| Key | Type | Description |
|-----|------|-------------|
| `linksync_api_key` | string | JWT API token |
| `linksync_collection` | string | Collection name |
| `linksync_sync_mode` | string | Sync mode |
| `linksync_deletions` | boolean | Enable deletions |
| `linksync_auto_sync` | boolean | Auto-sync toggle |
| `linksync_last_sync` | timestamp | Last sync time |
| `linksync_pending` | number | Pending changes count |
### Encrypted Storage
API keys should be encrypted before storage:
```javascript
async function saveEncryptedKey(key) {
const iv = crypto.getRandomValues(new Uint8Array(16));
const encrypted = await window.crypto.subtle.encrypt(
{ name: "AES-GCM", length: 256 },
await window.crypto.subtle.importKey(
"raw",
encryptionKey,
{ name: "AES-GCM" },
false
),
key
);
// Store iv + encrypted data
}
```
## Query Engine Design
### Query Syntax
```
('term1', 'term2') OR tagA AND tagB XOR url:example.com
```
### Parser
```javascript
class QueryParser {
parse(expression) {
// Tokenize
const tokens = this.tokenize(expression);
// Build AST
const ast = this.buildAST(tokens);
// Validate
this.validate(ast);
return this.serialize(ast);
}
}
```
### Executor
```javascript
class QueryExecutor {
async execute(ast) {
// Build SQL
const sql = this.buildSQL(ast);
// Execute
const result = await fetch(`/api/links/?sql=${sql}`);
return await result.json();
}
}
```
## API Client Design
### REST API Integration
```javascript
const API = {
baseUrl: '',
headers: {
'Authorization': 'Token {key}',
'Content-Type': 'application/json'
},
async login() {
const response = await fetch(`${this.baseUrl}/api/auth/login/`, {
method: 'POST',
body: JSON.stringify({ username, password })
});
const data = await response.json();
this.storeApiKey(data.token);
return data;
},
async listLinks() {
return await this.request('/api/links/');
},
async createLink(link) {
return await this.post('/api/links/', link);
},
async executeQuery(expression) {
return await this.post('/api/queries/execute/', { expression });
}
};
```
## UI/UX Design
### Color Scheme
```css
:root {
--primary: #3b82f6;
--secondary: #6b7280;
--success: #10b981;
--warning: #f59e0b;
--error: #ef4444;
--background: #ffffff;
--surface: #f9fafb;
--border: #e5e7eb;
--text: #111827;
--text-secondary: #6b7280;
}
```
### Typography
- Font family: System UI
- Base size: 14px
- Heading: 18px
- Form labels: 12px
### Responsive Design
- Mobile-first approach
- Breakpoint: 480px for landscape
- Touch-friendly tap targets (44px minimum)
## Security Design
### API Key Handling
1. **Storage**
- Encrypted in localStorage
- Never logged or exposed
2. **Transmission**
- HTTPS preferred
- Token in Authorization header
- No tokens in URL params
3. **Validation**
- Verify response signatures
- Check rate limits
- Handle 401/403 gracefully
### Data Privacy
- No bookmarks stored locally after sync
- API keys user-managed
- No telemetry or analytics
## Testing Strategy
### Unit Tests
- Sync logic modes
- Conflict detection
- Query parsing
- Storage operations
### Integration Tests
- API endpoint calls
- Background worker events
- Popup communication
### Manual Testing
- Add/edit/delete bookmarks
- Collection creation
- Query execution
- Conflict scenarios

View File

@@ -0,0 +1 @@
placeholder-icon-48

View File

@@ -0,0 +1,27 @@
{
"manifest_version": 2,
"name": "LinkSync",
"version": "1.0.0",
"description": "Sync bookmarks with LinkSyncServer",
"permissions": [
"bookmarks",
"storage",
"activeTab"
],
"browser_action": {
"default_icon": {
"48": "icons/icon-48.png",
"96": "icons/icon-96.png"
},
"default_title": "LinkSync"
},
"background": {
"page": "background.html"
},
"browser_specific_settings": {
"gecko": {
"id": "{linksync-browser-extension-id}",
"strict_min_version": "109.0"
}
}
}

241
LinkSyncExtension/popup.css Normal file
View File

@@ -0,0 +1,241 @@
:root {
--primary: #3b82f6;
--primary-hover: #2563eb;
--secondary: #6b7280;
--success: #10b981;
--warning: #f59e0b;
--error: #ef4444;
--background: #ffffff;
--surface: #f9fafb;
--border: #e5e7eb;
--text: #111827;
--text-secondary: #6b7280;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
width: 360px;
height: 500px;
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--text);
background: var(--background);
overflow: hidden;
}
header {
padding: 12px;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
header h1 {
font-size: 18px;
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;
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 {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 4px;
}
.syncing {
background: var(--warning);
animation: pulse 1.5s infinite;
}
.synced {
background: var(--success);
}
.error {
background: var(--error);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
#last-sync {
font-size: 11px;
color: var(--text-secondary);
}
#bookmarks-container {
max-height: 150px;
overflow-y: auto;
}
#collections-panel,
#bookmark-list {
max-height: 180px;
}
footer {
padding: 12px;
background: var(--surface);
border-top: 1px solid var(--border);
}
footer button {
width: 100%;
}

View File

@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>LinkSync</title>
<link rel="stylesheet" href="popup.css">
</head>
<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>
<div id="bookmarks-container"></div>
</section>
<!-- Collections Panel -->
<section id="collections-panel">
<h2>Collections</h2>
<div id="collections-list"></div>
</section>
<footer>
<button id="sync-btn">Sync Now</button>
<button id="settings-btn">Settings</button>
</footer>
<script src="popup.js"></script>
</body>
</html>

285
LinkSyncExtension/popup.js Normal file
View File

@@ -0,0 +1,285 @@
// LinkSync Popup Script
// Handles bookmark management and sync operations
(function() {
'use strict';
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]));
});
}
};
// Initialize when page loads
window.addEventListener('load', () => Popup.init());
// Expose to window
window.Popup = Popup;
})();

257
LinkSyncExtension/tasks.md Normal file
View File

@@ -0,0 +1,257 @@
# LinkSyncExtension - Implementation Tasks
## Phase 1: Project Setup
### Setup Tasks
- [x] Create project directory structure
- [x] Write README.md
- [ ] Write TODOs.txt
- [ ] Write design.md
- [ ] Write tasks.md
- [ ] Write AGENTS.md
### Initial Files
- [ ] Create manifest.json
- [ ] Add icon files (48x48, 96x96)
- [ ] Create styles folder with base.css
- [ ] Create utils folder structure
## 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
### 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
### Utility Modules
- [ ] 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
### 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 (Optional)
- [ ] Create content/content.js
- [ ] Implement page title extraction
- [ ] Implement URL detection
- [ ] Implement meta description extraction
- [ ] Inject popup trigger
- [ ] Handle content script permissions
## 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
### 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
## Phase 4: Sync Logic
### Bi-directional Sync
- [ ] Push browser→server
- [ ] Push server→browser
- [ ] Merge conflicting updates
- [ ] Track both versions
### Browser Authoritative Sync
- [ ] Push browser→server
- [ ] Overwrite server→browser
- [ ] No pull from server
### Server Authoritative Sync
- [ ] Download from server
- [ ] Overwrite local on conflict
- [ ] No push to server
### Deletions
- [ ] Implement deletions checkbox logic
- [ ] Delete on both sides if enabled
- [ ] Log deletions
### Conflict Resolution
- [ ] Detect URL collision
- [ ] Present resolution UI
- [ ] Keep browser version (default)
- [ ] Keep server version option
- [ ] Manual merge option
## 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
### Bookmark List
- [ ] Pagination
- [ ] Search filter input
- [ ] Checkboxes for selection
- [ ] Batch delete button
- [ ] Batch tag update
### Collections Panel
- [ ] Collection list
- [ ] Execute query button
- [ ] Create dynamic collection form
- [ ] Edit collection name/description
### Query Builder
- [ ] Simple query input
- [ ] Expression syntax help
- [ ] Example queries
- [ ] Save as collection option
### Sync Status
- [ ] Last sync timestamp
- [ ] Pending changes count
- [ ] Sync indicator icon
- [ ] 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
## Phase 6: Error Handling
### API Errors
- [ ] Handle 401 (unauthorized)
- [ ] Handle 403 (forbidden)
- [ ] Handle 429 (rate limited)
- [ ] Handle 500 (server error)
- [ ] Show user-friendly messages
### Network Errors
- [ ] Offline detection
- [ ] Queue changes offline
- [ ] Retry on reconnection
- [ ] Sync when back online
### UI Errors
- [ ] Form validation
- [ ] Input sanitization
- [ ] Graceful fallback on errors
- [ ] Error logging
## 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
- [ ] Test all sync modes
- [ ] Test conflict resolution
- [ ] 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
## Phase 9: Documentation
- [ ] API reference
- [ ] User guide
- [ ] Troubleshooting guide
- [ ] Query syntax reference
- [ ] FAQ
## Future Enhancements
- [ ] Background sync notifications
- [ ] Auto-sync scheduler
- [ ] Keyboard shortcuts
- [ ] Gesture controls
- [ ] Mobile companion app
- [ ] Dark theme toggle
- [ ] Custom colors

View File

@@ -0,0 +1,79 @@
# LinkSyncExtension Tests
## Testing Strategy
Browser extensions are tested differently than server applications:
1. **Manual Testing** - Primary testing method
2. **Firefox Nightly Testing** - Test in development mode
3. **Puppeteer Playwright** - Automated E2E tests (optional)
## Manual Testing Checklist
### Installation
- [ ] Load extension in Firefox
- [ ] Verify icon appears in toolbar
- [ ] Click icon opens popup
### Settings
- [ ] Enter server URL
- [ ] Enter API key
- [ ] Select sync mode
- [ ] Save settings
- [ ] Verify settings persist after reload
### Bookmark Management
- [ ] Add bookmark with form
- [ ] Verify bookmark appears in list
- [ ] Edit bookmark
- [ ] Delete bookmark
- [ ] Search filter works
### Collections
- [ ] Load collections list
- [ ] Execute query
- [ ] Create dynamic collection
### Sync
- [ ] Click "Sync Now"
- [ ] Verify sync indicator
- [ ] Check last sync timestamp
### Offline Mode
- [ ] Disconnect network
- [ ] Add bookmark
- [ ] Reconnect network
- [ ] Verify bookmark syncs
## Puppeteer Test Setup
```javascript
// tests/puppeteer.config.js
module.exports = {
browsers: ['chromium'],
config: {
launch: {
args: ['--no-sandbox', '--disable-setuid-sandbox']
}
},
// Test scripts in tests/
}
```
## Run Tests
```bash
# Manual testing - run in Firefox
# Install Firefox Nightly and load extension
# Automated testing (if Puppeteer installed)
npm install puppeteer
npx puppeteer tests/puppeteer.test.js
```
## Notes
- Browser extensions cannot be fully automated
- Manual testing is primary verification method
- Use Firefox Nightly for development testing
- Test in different browsers for compatibility

View File

@@ -0,0 +1,138 @@
// Bookmark Manipulation Utilities
// Handles bookmark operations for synchronization with LinkSyncServer
(function() {
'use strict';
const BookmarkUtils = {
// Parse Firefox bookmark data
parseBookmark(bookmark) {
return {
id: bookmark.id,
url: bookmark.url,
title: bookmark.title,
description: bookmark.description,
notes: bookmark.description || bookmark.notes || '',
tags: bookmark.tags || [],
favicon_url: bookmark.faviconUrl || bookmark.favicon_url || null,
path: bookmark.folder || bookmark.path || '',
created_at: bookmark.dateAdded,
updated_at: bookmark.lastModified,
visit_count: bookmark.count || 0,
is_bookmarked: bookmark.isBookmarked || false
};
},
// Build bookmark for API
buildBookmarkData(data) {
return {
url: data.url || '',
title: data.title || '',
description: data.description || '',
notes: data.notes || '',
tags: Array.isArray(data.tags) ? data.tags : data.tags.split(',').map(t => t.trim()),
favicon_url: data.favicon_url || null,
path: data.folder || data.path || '',
visit_count: data.visit_count || 0,
is_bookmarked: data.is_bookmarked || false
};
},
// Get page data for auto-fill
async getPageData() {
try {
// Extract URL from active tab
const activeTab = await browser.tabs.query({active: true, current: true});
const url = activeTab[0]?.url || '';
// Extract title from active tab
const title = activeTab[0]?.title || '';
// Extract description from meta tags
const description = await this.getMetaDescription(url);
return {
url,
title,
description
};
} catch (error) {
console.error('Failed to get page data:', error);
return {};
}
},
// Get meta description from page
async getMetaDescription(url) {
try {
const tabId = await browser.tabs.query({active: true, current: true});
const tabIdValue = tabId[0]?.id;
if (!tabIdValue) {
return '';
}
const content = await browser.tabs.sendMessage(tabIdValue, {
action: 'getMetaDescription'
});
return content?.description || '';
} catch (error) {
// Content script not injected or error
return '';
}
},
// Format tags as JSON array
formatTags(tagString) {
if (!tagString) {
return [];
}
return tagString.split(',').map(t => t.trim()).filter(t => t.length > 0);
},
// Parse JSON string to bookmark data
parseJsonData(jsonString) {
try {
return JSON.parse(jsonString);
} catch (e) {
console.error('Failed to parse bookmark JSON:', e);
return null;
}
},
// Get bookmark URL from bookmark object
getBookmarkUrl(bookmark) {
return bookmark?.url || bookmark?.id || '';
},
// Check if bookmark is a duplicate
isDuplicate(existingBookmark, newBookmark) {
// Two bookmarks are duplicates if same URL
return existingBookmark.url.toLowerCase() === newBookmark.url.toLowerCase();
},
// Merge two bookmarks (for conflict resolution)
mergeBookmarks(existing, incoming) {
return {
id: existing.id || incoming.id,
url: incoming.url,
title: incoming.title || existing.title,
description: incoming.description || existing.description,
notes: incoming.notes || existing.notes,
tags: Array.from(new Set([...(existing.tags || []), ...(incoming.tags || [])])),
favicon_url: incoming.favicon_url || existing.favicon_url,
path: incoming.path || existing.path,
created_at: existing.created_at,
updated_at: new Date().toISOString(),
visit_count: incoming.visit_count || existing.visit_count || 0,
is_bookmarked: incoming.is_bookmarked || existing.is_bookmarked
};
}
};
// Export for use in other scripts
window.BookmarkUtils = BookmarkUtils;
})();