Initial commit: LinkSyncServer and LinkSyncExtension projects with complete documentation, models, API endpoints, tests, and extension implementation
This commit is contained in:
287
LinkSyncExtension/README.md
Normal file
287
LinkSyncExtension/README.md
Normal 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
103
LinkSyncExtension/TODOs.txt
Normal 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
|
||||
20
LinkSyncExtension/background.html
Normal file
20
LinkSyncExtension/background.html
Normal 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>
|
||||
312
LinkSyncExtension/background.js
Normal file
312
LinkSyncExtension/background.js
Normal 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
449
LinkSyncExtension/design.md
Normal 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
|
||||
1
LinkSyncExtension/icons/icon-48.png
Normal file
1
LinkSyncExtension/icons/icon-48.png
Normal file
@@ -0,0 +1 @@
|
||||
placeholder-icon-48
|
||||
27
LinkSyncExtension/manifest.json
Normal file
27
LinkSyncExtension/manifest.json
Normal 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
241
LinkSyncExtension/popup.css
Normal 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%;
|
||||
}
|
||||
78
LinkSyncExtension/popup.html
Normal file
78
LinkSyncExtension/popup.html
Normal 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
285
LinkSyncExtension/popup.js
Normal 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
257
LinkSyncExtension/tasks.md
Normal 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
|
||||
79
LinkSyncExtension/tests/README.md
Normal file
79
LinkSyncExtension/tests/README.md
Normal 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
|
||||
138
LinkSyncExtension/utils/bookmark.js
Normal file
138
LinkSyncExtension/utils/bookmark.js
Normal 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;
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user