Complete LinkSyncServer and LinkSyncExtension implementation
LinkSyncServer: - Fix app.py imports, add CORS middleware, lifespan events - Create api/routes.py router aggregator - Create config/settings.py for centralized configuration - Rewrite models/base.py with proper relationships and serialization - Rewrite all API endpoints with real DB integration (auth, links, collections, sync, queries, tags) - Add admin endpoints (user management, stats, audit log) - Complete query parser with recursive descent and proper precedence - Complete query executor with set operations and field filters - Set up Alembic migrations with initial schema - Create web interface (templates, CSS, JS) - Add 42 passing tests (auth, links, collections, queries) - Add deploy.ps1 and deploy.sh scripts - Update README with deployment workflow LinkSyncExtension: - Create utils/api.js (REST client with retries, auth, error handling) - Create utils/sync.js (3 sync modes + conflict detection) - Create utils/collection.js (collection management) - Create utils/query-engine.js (client-side query parser) - Rewrite background.js (sync loop, bookmark events, message routing) - Rewrite popup.js (tabs, settings modal, notifications, CRUD) - Update popup.html (tabbed interface, query builder, modal) - Update popup.css (full redesign) - Create content/content.js (page metadata extraction) - Create options.html/js (dedicated settings page) - Generate icons (48x48, 96x96) - Update manifest.json (host permissions, content scripts, options) - Create AGENTS.md
This commit is contained in:
106
LinkSyncExtension/AGENTS.md
Normal file
106
LinkSyncExtension/AGENTS.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# AGENTS.md - LinkSyncExtension
|
||||
|
||||
## Project Overview
|
||||
|
||||
LinkSyncExtension is a Firefox browser extension that synchronizes bookmarks with LinkSyncServer. It provides a popup UI for managing bookmarks, collections, and queries, with a background script handling automatic synchronization.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
LinkSyncExtension/
|
||||
├── manifest.json # Firefox extension manifest v2
|
||||
├── popup.html/js/css # Main popup UI (bookmarks, collections, queries)
|
||||
├── background.html/js # Background page with sync engine
|
||||
├── options.html/js # Dedicated settings page
|
||||
├── content/content.js # Content script for page metadata extraction
|
||||
├── utils/
|
||||
│ ├── api.js # REST API client with retries, auth, error handling
|
||||
│ ├── sync.js # Sync engine (3 modes + conflict detection)
|
||||
│ ├── collection.js # Collection management wrapper
|
||||
│ ├── query-engine.js # Client-side query parser
|
||||
│ └── bookmark.js # Bookmark parsing/merging utilities
|
||||
├── icons/
|
||||
│ ├── icon-48.png
|
||||
│ └── icon-96.png
|
||||
└── tests/
|
||||
└── README.md # Manual testing checklist
|
||||
```
|
||||
|
||||
## Communication Pattern
|
||||
|
||||
```
|
||||
popup.js/options.js ──browser.runtime.sendMessage──> background.js
|
||||
background.js ────────────────────────────────────> API (LinkSyncServer)
|
||||
background.js ──browser.bookmarks.*───────────────> Firefox bookmarks
|
||||
content.js ─────browser.runtime.onMessage─────────> popup.js
|
||||
```
|
||||
|
||||
All API calls go through `utils/api.js`. The popup sends messages to the background script, which handles the actual API communication. This keeps the API token in the background context.
|
||||
|
||||
## Key Modules
|
||||
|
||||
### utils/api.js
|
||||
- `API.init()` - loads settings from storage
|
||||
- `API.request(method, path, body)` - core request with retries (3x), timeout (10s), rate limit handling
|
||||
- `API.login(username, password)` - authenticates and stores token
|
||||
- `API.testConnection()` - hits /health endpoint
|
||||
- CRUD methods for links, collections, queries, sync
|
||||
|
||||
### utils/sync.js
|
||||
- `SyncEngine.runSync()` - main sync entry point
|
||||
- Three modes: bi-directional, browser-authoritative, server-authoritative
|
||||
- `SyncEngine.detectConflicts()` - finds title mismatches between browser and server
|
||||
- Uses `browser.bookmarks.getTree()` for local bookmarks
|
||||
|
||||
### utils/query-engine.js
|
||||
- `QueryEngine.tokenize(expression)` - lexer
|
||||
- `QueryEngine.parse(expression)` - recursive descent parser
|
||||
- `QueryEngine.validate(expression)` - returns {valid, ast} or {valid, error}
|
||||
- Supports: TERM, TERM_SET, FIELD (url/tag/title/description/path/id), AND, OR, XOR, parentheses
|
||||
|
||||
### background.js
|
||||
- Listens for `browser.runtime.onMessage` from popup/options
|
||||
- Handles bookmark change events (`onCreated`, `onChanged`, `onRemoved`)
|
||||
- Auto-sync timer (5 min interval when enabled)
|
||||
- Message types: SYNC_NOW, GET_SETTINGS, SAVE_SETTINGS, TEST_CONNECTION, LOGIN, GET_BOOKMARKS, CREATE_BOOKMARK, UPDATE_BOOKMARK, DELETE_BOOKMARK, GET_COLLECTIONS, EXECUTE_QUERY, PARSE_QUERY
|
||||
|
||||
### popup.js
|
||||
- Tabbed interface: Bookmarks, Collections, Query
|
||||
- Settings modal (server URL, API key, sync mode, deletions, auto-sync)
|
||||
- Toast notifications (success/error/info, auto-dismiss after 3s)
|
||||
- Bookmark form with auto-fill from current tab
|
||||
- Search filter for bookmarks
|
||||
- Query builder with parse/execute buttons
|
||||
|
||||
## Storage Keys
|
||||
|
||||
| Key | Type | Description |
|
||||
|-----|------|-------------|
|
||||
| `linksync_server_url` | string | Server base URL |
|
||||
| `linksync_api_key` | string | JWT bearer token |
|
||||
| `linksync_sync_mode` | string | bi-directional / browser-authoritative / server-authoritative |
|
||||
| `linksync_deletions` | boolean | Enable deletions during sync |
|
||||
| `linksync_auto_sync` | boolean | Enable 5-minute auto-sync |
|
||||
| `linksync_last_sync` | string | ISO timestamp of last sync |
|
||||
| `linksync_syncing` | boolean | Currently syncing flag |
|
||||
| `linksync_pending` | boolean | Has pending local changes |
|
||||
|
||||
## Testing
|
||||
|
||||
Browser extensions require manual testing in Firefox:
|
||||
|
||||
1. Open `about:debugging` in Firefox
|
||||
2. Click "This Firefox" → "Load Temporary Add-on"
|
||||
3. Select `manifest.json` from the project folder
|
||||
4. Click the extension icon to open the popup
|
||||
5. Right-click the icon → "Options" for settings page
|
||||
|
||||
See `tests/README.md` for the full manual testing checklist.
|
||||
|
||||
## Development Notes
|
||||
|
||||
- Manifest v2 for Firefox compatibility
|
||||
- Background page (not service worker) for Firefox support
|
||||
- All API calls use Bearer token auth (matching server's JWT implementation)
|
||||
- Content script runs on all pages to extract metadata
|
||||
- No external dependencies - pure browser APIs
|
||||
@@ -3,101 +3,130 @@
|
||||
## Project Setup
|
||||
- [x] Create project directory structure
|
||||
- [x] Write README.md
|
||||
- [ ] Write TODOs.txt (in progress)
|
||||
- [ ] Write design.md
|
||||
- [ ] Write tasks.md
|
||||
- [ ] Write AGENTS.md
|
||||
- [x] Write TODOs.txt
|
||||
- [x] Write design.md
|
||||
- [x] Write tasks.md
|
||||
- [x] Write AGENTS.md
|
||||
- [x] Create manifest.json (with all permissions, content scripts, options page)
|
||||
- [x] Add icon files (48x48, 96x96)
|
||||
|
||||
## Core Development
|
||||
|
||||
### Extension Manifest
|
||||
- [ ] Create manifest.json (MVP)
|
||||
- [ ] Add icon files
|
||||
- [ ] Configure permissions
|
||||
- [ ] Set browser ID
|
||||
- [x] Create manifest.json with Firefox-specific settings
|
||||
- [x] Add icon files (48x48, 96x96)
|
||||
- [x] Configure permissions (bookmarks, storage, activeTab, tabs, <all_urls>)
|
||||
- [x] Set browser ID (linksync@example.com)
|
||||
- [x] Add content scripts registration
|
||||
- [x] Add options page registration
|
||||
|
||||
### Background Script
|
||||
- [ ] Create background.js service worker
|
||||
- [ ] Implement sync logic
|
||||
- [ ] Handle sync mode switching
|
||||
- [ ] Manage collection mapping
|
||||
- [ ] Auto-sync timer
|
||||
- [ ] Error handling
|
||||
- [x] Create background.js service worker
|
||||
- [x] Implement init() on install/update
|
||||
- [x] Implement sync loop with interval (5 min)
|
||||
- [x] Add event handlers (message, bookmark changes)
|
||||
- [x] Implement sync mode switching
|
||||
- [x] Manage collection mapping
|
||||
- [x] Auto-sync timer
|
||||
- [x] Error handling
|
||||
|
||||
### Popup Script
|
||||
- [ ] Create popup.html
|
||||
- [ ] Create popup.css
|
||||
- [ ] Create popup.js
|
||||
- [ ] Bookmark form UI
|
||||
- [ ] Collection list UI
|
||||
- [ ] Settings UI
|
||||
- [ ] Search UI
|
||||
- [x] Create popup.html with tabs (Bookmarks, Collections, Query)
|
||||
- [x] Create popup.css with full styling
|
||||
- [x] Create popup.js with all functionality
|
||||
- [x] Bookmark form UI with auto-fill
|
||||
- [x] Bookmark list view with search
|
||||
- [x] Collections panel
|
||||
- [x] Query builder with parse/execute
|
||||
- [x] Settings modal
|
||||
- [x] Sync button handler
|
||||
- [x] Toast notifications
|
||||
|
||||
### Utility Modules
|
||||
- [ ] utils/bookmark.js - Bookmark manipulation
|
||||
- [ ] utils/collection.js - Collection management
|
||||
- [ ] utils/query-engine.js - Query parsing/execution
|
||||
- [ ] utils/sync.js - Sync logic
|
||||
- [x] utils/bookmark.js - Bookmark manipulation (parse, merge, format)
|
||||
- [x] utils/collection.js - Collection management (CRUD, query execution)
|
||||
- [x] utils/query-engine.js - Query parsing (tokenizer, recursive descent parser)
|
||||
- [x] utils/sync.js - Sync logic (3 modes, conflict detection)
|
||||
- [x] utils/api.js - API client (auth, retries, error handling, all endpoints)
|
||||
|
||||
### Content Script (Optional)
|
||||
- [ ] content/content.js - Read page data
|
||||
- [ ] Extract title/description
|
||||
- [ ] Handle URL detection
|
||||
- [ ] Inject into popup
|
||||
### Content Script
|
||||
- [x] content/content.js - Extract page title, description, favicon
|
||||
- [x] Handle browser.runtime.onMessage for getPageData
|
||||
|
||||
### API Integration
|
||||
- [ ] /api/auth/login/ - Authentication
|
||||
- [ ] /api/links/ - Bookmark CRUD
|
||||
- [ ] /api/collections/ - Collection CRUD
|
||||
- [ ] /api/queries/execute/ - Query execution
|
||||
- [ ] /api/sync/ - Sync endpoint
|
||||
- [x] /api/auth/login - Authentication
|
||||
- [x] /api/links/ - Bookmark CRUD (GET, POST, PUT, DELETE)
|
||||
- [x] /api/collections/ - Collection CRUD
|
||||
- [x] /api/queries/parse/ - Query parsing
|
||||
- [x] /api/queries/execute/ - Query execution
|
||||
- [x] /api/sync/ - Sync endpoint
|
||||
- [x] /api/admin/stats - Admin stats
|
||||
- [x] /health - Connection test
|
||||
|
||||
### Sync Logic
|
||||
- [ ] Implement bi-directional sync
|
||||
- [ ] Implement browser-authoritative sync
|
||||
- [ ] Implement server-authoritative sync
|
||||
- [ ] Handle deletions checkbox
|
||||
- [ ] Conflict detection
|
||||
- [ ] Conflict resolution UI
|
||||
- [x] Implement bi-directional sync
|
||||
- [x] Implement browser-authoritative sync
|
||||
- [x] Implement server-authoritative sync
|
||||
- [x] Handle deletions checkbox
|
||||
- [x] Conflict detection (title mismatches)
|
||||
|
||||
### UI Components
|
||||
- [ ] Bookmark list view
|
||||
- [ ] Collection builder UI
|
||||
- [ ] Query editor
|
||||
- [ ] Search interface
|
||||
- [ ] Sync status indicator
|
||||
- [ ] Conflict resolution modal
|
||||
- [x] Tabbed interface (Bookmarks, Collections, Query)
|
||||
- [x] Bookmark list view with search filter
|
||||
- [x] Collection list
|
||||
- [x] Query builder with syntax help
|
||||
- [x] Sync status indicator (syncing/synced/error)
|
||||
- [x] Settings modal
|
||||
- [x] Toast notifications
|
||||
- [x] Options page (dedicated settings)
|
||||
|
||||
### Storage Management
|
||||
- [ ] Store API key securely
|
||||
- [ ] Store collection mapping
|
||||
- [ ] Store sync settings
|
||||
- [ ] Sync timestamp tracking
|
||||
- [ ] Pending changes tracking
|
||||
- [x] Store API key in browser.storage.local
|
||||
- [x] Store server URL
|
||||
- [x] Store sync settings (mode, deletions, auto-sync)
|
||||
- [x] Sync timestamp tracking
|
||||
- [x] Pending changes tracking
|
||||
- [x] Syncing state flag
|
||||
|
||||
## Options Page
|
||||
- [x] Create options.html
|
||||
- [x] Create options.js
|
||||
- [x] Server URL configuration
|
||||
- [x] API key input (password field)
|
||||
- [x] Sync mode dropdown
|
||||
- [x] Deletions checkbox
|
||||
- [x] Auto-sync checkbox
|
||||
- [x] Test connection button
|
||||
- [x] Sync now button
|
||||
- [x] Last sync display
|
||||
|
||||
## Security
|
||||
- [ ] Encrypted storage
|
||||
- [ ] API key validation
|
||||
- [ ] HTTPS enforcement checks
|
||||
- [ ] CORS validation
|
||||
- [ ] Input sanitization
|
||||
- [x] API key stored in browser.storage.local (not localStorage)
|
||||
- [x] Bearer token authentication
|
||||
- [x] Input sanitization (escapeHtml)
|
||||
- [x] Request timeout handling
|
||||
- [x] Rate limit handling (429 retry)
|
||||
|
||||
## Testing
|
||||
- [ ] Test sync modes
|
||||
- [ ] Test conflict resolution
|
||||
- [ ] Test query execution
|
||||
- [ ] Test offline handling
|
||||
- [ ] Test error handling
|
||||
- [x] Manual testing checklist (tests/README.md)
|
||||
- [ ] Test sync modes (manual)
|
||||
- [ ] Test conflict resolution (manual)
|
||||
- [ ] Test query execution (manual)
|
||||
- [ ] Test offline handling (manual)
|
||||
- [ ] Test error handling (manual)
|
||||
|
||||
## Documentation
|
||||
- [ ] API reference
|
||||
- [ ] User guide
|
||||
- [ ] Troubleshooting guide
|
||||
- [ ] Query syntax guide
|
||||
- [x] API reference (README.md)
|
||||
- [x] User guide (README.md)
|
||||
- [x] Troubleshooting guide (README.md)
|
||||
- [x] Query syntax guide (README.md)
|
||||
- [x] Architecture docs (AGENTS.md, design.md)
|
||||
|
||||
## Future Enhancements
|
||||
- [ ] Background sync notifications
|
||||
- [ ] Auto-sync scheduler
|
||||
- [ ] Keyboard shortcuts
|
||||
- [ ] Gesture controls
|
||||
- [ ] Mobile companion app
|
||||
- [ ] Dark theme toggle
|
||||
- [ ] Bookmark edit/delete from popup
|
||||
- [ ] Batch operations
|
||||
- [ ] Conflict resolution UI
|
||||
- [ ] Offline queue for pending changes
|
||||
|
||||
@@ -1,312 +1,145 @@
|
||||
// LinkSync Background Service Worker
|
||||
// LinkSync Background Script
|
||||
// Handles bookmark synchronization with LinkSyncServer
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
const Background = {
|
||||
syncInterval: null,
|
||||
SYNC_CHECK_INTERVAL: 300000,
|
||||
|
||||
const Background = {
|
||||
// Configuration
|
||||
API_BASE_URL: '',
|
||||
SYNC_CHECK_INTERVAL: 60000, // 1 minute
|
||||
OFFLINE_QUEUE_TIMEOUT: 300000, // 5 minutes
|
||||
|
||||
// Storage keys
|
||||
STORAGE: {
|
||||
API_KEY: 'linksync_api_key',
|
||||
COLLECTION: 'linksync_collection',
|
||||
MODE: 'linksync_sync_mode',
|
||||
DELETIONS: 'linksync_deletions',
|
||||
AUTO_SYNC: 'linksync_auto_sync',
|
||||
URL: 'linksync_server_url',
|
||||
LAST_SYNC: 'linksync_last_sync',
|
||||
PENDING: 'linksync_pending'
|
||||
},
|
||||
|
||||
// Sync modes
|
||||
SYNC_MODES: {
|
||||
BIDIRECTIONAL: 'bi-directional',
|
||||
BROWSER_AUTHORITY: 'browser-authoritative',
|
||||
SERVER_AUTHORITY: 'server-authoritative'
|
||||
},
|
||||
|
||||
// Initialize on install/update
|
||||
async init() {
|
||||
console.log('LinkSync: Initializing...');
|
||||
|
||||
// Restore API key if available
|
||||
await this.restoreApiKey();
|
||||
|
||||
// Setup sync interval
|
||||
if (await this.getSetting(this.STORAGE.AUTO_SYNC)) {
|
||||
this.startAutoSync();
|
||||
}
|
||||
|
||||
// Listen for messages
|
||||
browser.runtime.onMessage.addListener(this.handleMessage.bind(this));
|
||||
},
|
||||
|
||||
// Restore API key from storage
|
||||
async restoreApiKey() {
|
||||
try {
|
||||
const apiKey = await this.getSetting(this.STORAGE.API_KEY);
|
||||
if (apiKey) {
|
||||
this.API_BASE_URL = await this.getSetting(this.STORAGE.URL) || 'http://localhost:5000';
|
||||
this.setupAuthHeaders();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('LinkSync: Failed to restore API key:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Setup auth headers
|
||||
setupAuthHeaders() {
|
||||
const headers = new Headers();
|
||||
const apiKey = this.getApiKey();
|
||||
if (apiKey) {
|
||||
headers.set('Authorization', `Token ${apiKey}`);
|
||||
}
|
||||
return headers;
|
||||
},
|
||||
|
||||
// Get API key
|
||||
getApiKey() {
|
||||
return localStorage.getItem(this.STORAGE.API_KEY) || '';
|
||||
},
|
||||
|
||||
// Save API key encrypted
|
||||
async saveApiKey(key) {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(16));
|
||||
const encrypted = await window.crypto.subtle.encrypt(
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
await window.crypto.subtle.generateKey(
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false
|
||||
),
|
||||
key
|
||||
);
|
||||
localStorage.setItem(`${this.STORAGE.API_KEY}_iv`, btoa(String.fromCharCode(...iv)));
|
||||
localStorage.setItem(`${this.STORAGE.API_KEY}_data`, btoa(String.fromCharCode(...new Uint8Array(encrypted))));
|
||||
},
|
||||
|
||||
// Start auto-sync timer
|
||||
startAutoSync() {
|
||||
const sync = this.checkSync.bind(this);
|
||||
setInterval(sync, this.SYNC_CHECK_INTERVAL);
|
||||
sync(); // Initial sync
|
||||
},
|
||||
|
||||
// Handle messages from popup/content scripts
|
||||
async handleMessage(message, sender) {
|
||||
switch (message.type) {
|
||||
case 'SYNC_NOW':
|
||||
return this.checkSync();
|
||||
|
||||
case 'GET_BOOKMARKS':
|
||||
return this.getBookmarks();
|
||||
|
||||
case 'ADD_BOOKMARK':
|
||||
return this.addBookmark(message.data);
|
||||
|
||||
case 'UPDATE_BOOKMARK':
|
||||
return this.updateBookmark(message.data);
|
||||
|
||||
case 'DELETE_BOOKMARK':
|
||||
return this.deleteBookmark(message.data);
|
||||
|
||||
case 'SYNC_MODE':
|
||||
await this.setSetting(this.STORAGE.MODE, message.data.mode);
|
||||
return { success: true };
|
||||
|
||||
case 'GET_SETTINGS':
|
||||
return this.getSettings();
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
// Check for pending syncs
|
||||
async checkSync() {
|
||||
try {
|
||||
const config = await this.getSettings();
|
||||
const bookmarks = await this.getBrowserBookmarks();
|
||||
|
||||
// Update pending count
|
||||
await this.setSetting(this.STORAGE.PENDING, 0);
|
||||
|
||||
console.log('LinkSync: Sync completed');
|
||||
browser.runtime.sendMessage({ type: 'SYNC_COMPLETE' });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
pending: 0
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('LinkSync: Sync error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
// Get browser bookmarks
|
||||
async getBrowserBookmarks() {
|
||||
try {
|
||||
const bookmarks = await browser.bookmarks.getTree();
|
||||
const flatBookmarks = this.flattenBookmarks(bookmarks);
|
||||
|
||||
// Filter out deleted items
|
||||
const existingIds = await this.getExistingBookmarkIds();
|
||||
flatBookmarks = flatBookmarks.filter(b => !existingIds.includes(b.id));
|
||||
|
||||
return flatBookmarks;
|
||||
} catch (error) {
|
||||
console.error('LinkSync: Failed to get browser bookmarks:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
// Flatten bookmark tree to array
|
||||
flattenBookmarks(tree) {
|
||||
const result = [];
|
||||
function traverse(nodes) {
|
||||
nodes.forEach(node => {
|
||||
if (node.dateAdded) {
|
||||
result.push({
|
||||
id: node.id,
|
||||
url: node.url,
|
||||
title: node.title,
|
||||
dateAdded: new Date(node.dateAdded).toISOString(),
|
||||
lastModified: node.lastModified || new Date(node.dateAdded).toISOString()
|
||||
});
|
||||
}
|
||||
if (node.children) {
|
||||
traverse(node.children);
|
||||
}
|
||||
});
|
||||
}
|
||||
traverse(tree);
|
||||
return result;
|
||||
},
|
||||
|
||||
// Get existing bookmark IDs from server
|
||||
async getExistingBookmarkIds() {
|
||||
try {
|
||||
const response = await fetch(`${this.API_BASE_URL}/api/links/`, {
|
||||
headers: this.setupAuthHeaders()
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return data.links?.map(l => l.id) || [];
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('LinkSync: Failed to get existing bookmarks:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
// Add bookmark
|
||||
async addBookmark(bookmark) {
|
||||
try {
|
||||
const response = await fetch(`${this.API_BASE_URL}/api/links/`, {
|
||||
method: 'POST',
|
||||
headers: this.setupAuthHeaders(),
|
||||
body: JSON.stringify(bookmark)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
return { success: true, id: result.id };
|
||||
}
|
||||
|
||||
return { success: false, error: response.statusText };
|
||||
} catch (error) {
|
||||
console.error('LinkSync: Add bookmark error:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// Update bookmark
|
||||
async updateBookmark(bookmark) {
|
||||
try {
|
||||
const response = await fetch(`${this.API_BASE_URL}/api/links/${bookmark.id}/`, {
|
||||
method: 'PUT',
|
||||
headers: this.setupAuthHeaders(),
|
||||
body: JSON.stringify(bookmark)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
return { success: false, error: response.statusText };
|
||||
} catch (error) {
|
||||
console.error('LinkSync: Update bookmark error:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// Delete bookmark
|
||||
async deleteBookmark(bookmarkId) {
|
||||
try {
|
||||
const response = await fetch(`${this.API_BASE_URL}/api/links/${bookmarkId}/`, {
|
||||
method: 'DELETE',
|
||||
headers: this.setupAuthHeaders()
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
return { success: false, error: response.statusText };
|
||||
} catch (error) {
|
||||
console.error('LinkSync: Delete bookmark error:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
|
||||
// Get settings
|
||||
async getSettings() {
|
||||
return {
|
||||
url: await this.getSetting(this.STORAGE.URL),
|
||||
apiKey: await this.getSetting(this.STORAGE.API_KEY),
|
||||
mode: await this.getSetting(this.STORAGE.MODE),
|
||||
deletions: await this.getSetting(this.STORAGE.DELETIONS),
|
||||
autoSync: await this.getSetting(this.STORAGE.AUTO_SYNC)
|
||||
};
|
||||
},
|
||||
|
||||
// Get single setting
|
||||
async getSetting(key) {
|
||||
return new Promise(resolve => {
|
||||
browser.storage.local.get(key, result => resolve(result[key]));
|
||||
});
|
||||
},
|
||||
|
||||
// Set setting
|
||||
async setSetting(key, value) {
|
||||
await browser.storage.local.set({ [key]: value });
|
||||
},
|
||||
|
||||
// Get all bookmarks from tree
|
||||
getAllBookmarks() {
|
||||
return new Promise(resolve => {
|
||||
browser.bookmarks.getTree((tree) => {
|
||||
resolve(this.flattenBookmarks(tree));
|
||||
});
|
||||
});
|
||||
async init() {
|
||||
console.log("LinkSync: Initializing background script");
|
||||
|
||||
await API.init();
|
||||
|
||||
browser.runtime.onInstalled.addListener(() => {
|
||||
console.log("LinkSync: Extension installed");
|
||||
this.startAutoSync();
|
||||
});
|
||||
|
||||
browser.runtime.onMessage.addListener(this.handleMessage.bind(this));
|
||||
|
||||
browser.bookmarks.onCreated.addListener(this.onBookmarkChanged.bind(this));
|
||||
browser.bookmarks.onChanged.addListener(this.onBookmarkChanged.bind(this));
|
||||
browser.bookmarks.onRemoved.addListener(this.onBookmarkChanged.bind(this));
|
||||
|
||||
const settings = await browser.storage.local.get(["linksync_auto_sync"]);
|
||||
if (settings.linksync_auto_sync) {
|
||||
this.startAutoSync();
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize on install/update
|
||||
browser.runtime.onInstalled.addListener(() => {
|
||||
Background.init();
|
||||
});
|
||||
|
||||
// Expose to window
|
||||
window.Background = Background;
|
||||
|
||||
})();
|
||||
},
|
||||
|
||||
startAutoSync() {
|
||||
if (this.syncInterval) {
|
||||
clearInterval(this.syncInterval);
|
||||
}
|
||||
this.syncInterval = setInterval(() => this.runSync(), this.SYNC_CHECK_INTERVAL);
|
||||
console.log("LinkSync: Auto-sync started (every 5 minutes)");
|
||||
},
|
||||
|
||||
stopAutoSync() {
|
||||
if (this.syncInterval) {
|
||||
clearInterval(this.syncInterval);
|
||||
this.syncInterval = null;
|
||||
}
|
||||
},
|
||||
|
||||
async onBookmarkChanged(id, changeInfo) {
|
||||
const settings = await browser.storage.local.get(["linksync_auto_sync"]);
|
||||
if (settings.linksync_auto_sync) {
|
||||
await browser.storage.local.set({ linksync_pending: true });
|
||||
}
|
||||
},
|
||||
|
||||
async handleMessage(message, sender) {
|
||||
switch (message.type) {
|
||||
case "SYNC_NOW":
|
||||
return this.runSync();
|
||||
|
||||
case "GET_SETTINGS":
|
||||
return browser.storage.local.get([
|
||||
"linksync_server_url",
|
||||
"linksync_api_key",
|
||||
"linksync_sync_mode",
|
||||
"linksync_deletions",
|
||||
"linksync_auto_sync",
|
||||
"linksync_last_sync",
|
||||
]);
|
||||
|
||||
case "SAVE_SETTINGS":
|
||||
await browser.storage.local.set(message.data);
|
||||
await API.init();
|
||||
if (message.data.linksync_auto_sync) {
|
||||
this.startAutoSync();
|
||||
} else {
|
||||
this.stopAutoSync();
|
||||
}
|
||||
return { success: true };
|
||||
|
||||
case "TEST_CONNECTION":
|
||||
try {
|
||||
await API.testConnection();
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
|
||||
case "LOGIN":
|
||||
try {
|
||||
const data = await API.login(message.data.username, message.data.password);
|
||||
return { success: true, token: data.access_token };
|
||||
} catch (e) {
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
|
||||
case "GET_BOOKMARKS":
|
||||
return API.getLinks(message.data || {});
|
||||
|
||||
case "CREATE_BOOKMARK":
|
||||
return API.createLink(message.data);
|
||||
|
||||
case "UPDATE_BOOKMARK":
|
||||
return API.updateLink(message.data.id, message.data);
|
||||
|
||||
case "DELETE_BOOKMARK":
|
||||
return API.deleteLink(message.data.id);
|
||||
|
||||
case "GET_COLLECTIONS":
|
||||
return CollectionManager.listCollections();
|
||||
|
||||
case "EXECUTE_QUERY":
|
||||
return CollectionManager.executeQuery(message.data.expression, message.data.limit);
|
||||
|
||||
case "PARSE_QUERY":
|
||||
return CollectionManager.parseQuery(message.data.expression);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async runSync() {
|
||||
try {
|
||||
await browser.storage.local.set({ linksync_syncing: true });
|
||||
|
||||
const actions = await SyncEngine.runSync();
|
||||
|
||||
await browser.storage.local.set({
|
||||
linksync_syncing: false,
|
||||
linksync_last_sync: new Date().toISOString(),
|
||||
linksync_pending: false,
|
||||
linksync_sync_result: actions,
|
||||
});
|
||||
|
||||
console.log(`LinkSync: Sync completed with ${actions.length} actions`);
|
||||
return { success: true, actions };
|
||||
} catch (error) {
|
||||
console.error("LinkSync: Sync failed:", error);
|
||||
await browser.storage.local.set({
|
||||
linksync_syncing: false,
|
||||
linksync_sync_error: error.message,
|
||||
});
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => Background.init());
|
||||
|
||||
32
LinkSyncExtension/content/content.js
Normal file
32
LinkSyncExtension/content/content.js
Normal file
@@ -0,0 +1,32 @@
|
||||
// LinkSync Content Script
|
||||
// Extracts page metadata for bookmark auto-fill
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.action === "getPageData") {
|
||||
const data = {
|
||||
url: window.location.href,
|
||||
title: document.title,
|
||||
description: getMetaDescription(),
|
||||
favicon: getFavicon(),
|
||||
};
|
||||
sendResponse(data);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
function getMetaDescription() {
|
||||
const meta = document.querySelector('meta[name="description"]');
|
||||
return meta ? meta.getAttribute("content") : "";
|
||||
}
|
||||
|
||||
function getFavicon() {
|
||||
const link = document.querySelector("link[rel='icon'], link[rel='shortcut icon']");
|
||||
if (link) {
|
||||
return link.getAttribute("href");
|
||||
}
|
||||
return new URL("/favicon.ico", window.location.origin).href;
|
||||
}
|
||||
})();
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 19 B After Width: | Height: | Size: 258 B |
BIN
LinkSyncExtension/icons/icon-96.png
Normal file
BIN
LinkSyncExtension/icons/icon-96.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 536 B |
@@ -6,9 +6,12 @@
|
||||
"permissions": [
|
||||
"bookmarks",
|
||||
"storage",
|
||||
"activeTab"
|
||||
"activeTab",
|
||||
"tabs",
|
||||
"<all_urls>"
|
||||
],
|
||||
"browser_action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"48": "icons/icon-48.png",
|
||||
"96": "icons/icon-96.png"
|
||||
@@ -18,10 +21,21 @@
|
||||
"background": {
|
||||
"page": "background.html"
|
||||
},
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["<all_urls>"],
|
||||
"js": ["content/content.js"],
|
||||
"run_at": "document_idle"
|
||||
}
|
||||
],
|
||||
"options_ui": {
|
||||
"page": "options.html",
|
||||
"open_in_tab": true
|
||||
},
|
||||
"browser_specific_settings": {
|
||||
"gecko": {
|
||||
"id": "{linksync-browser-extension-id}",
|
||||
"id": "linksync@example.com",
|
||||
"strict_min_version": "109.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
162
LinkSyncExtension/options.html
Normal file
162
LinkSyncExtension/options.html
Normal file
@@ -0,0 +1,162 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>LinkSync Settings</title>
|
||||
<style>
|
||||
:root {
|
||||
--primary: #3b82f6;
|
||||
--primary-hover: #2563eb;
|
||||
--success: #10b981;
|
||||
--error: #ef4444;
|
||||
--background: #ffffff;
|
||||
--surface: #f9fafb;
|
||||
--border: #e5e7eb;
|
||||
--text: #111827;
|
||||
--text-secondary: #6b7280;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
padding: 2rem;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 { font-size: 1.5rem; margin-bottom: 1.5rem; color: var(--primary); }
|
||||
h2 { font-size: 1.1rem; margin: 1.5rem 0 0.75rem; }
|
||||
|
||||
.card {
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group { margin-bottom: 1rem; }
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.form-group input, .form-group select {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-group input:focus, .form-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.checkbox-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-group input { width: auto; }
|
||||
|
||||
button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary { background: var(--primary); color: white; }
|
||||
.btn-primary:hover { background: var(--primary-hover); }
|
||||
.btn-secondary { background: var(--surface); border: 1px solid var(--border); }
|
||||
|
||||
.actions { display: flex; gap: 0.5rem; margin-top: 1rem; }
|
||||
|
||||
#status {
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
margin-top: 1rem;
|
||||
display: none;
|
||||
}
|
||||
#status.success { display: block; background: #d1fae5; color: #065f46; }
|
||||
#status.error { display: block; background: #fee2e2; color: #991b1b; }
|
||||
|
||||
.sync-info {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>LinkSync Settings</h1>
|
||||
|
||||
<div class="card">
|
||||
<h2>Server Connection</h2>
|
||||
<form id="connection-form">
|
||||
<div class="form-group">
|
||||
<label for="server-url">Server URL</label>
|
||||
<input type="url" id="server-url" placeholder="http://localhost:5000">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="api-key">API Key</label>
|
||||
<input type="password" id="api-key" placeholder="Your API key">
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" id="test-btn" class="btn-secondary">Test Connection</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Sync Settings</h2>
|
||||
<form id="sync-form">
|
||||
<div class="form-group">
|
||||
<label for="sync-mode">Sync Mode</label>
|
||||
<select id="sync-mode">
|
||||
<option value="bi-directional">Bi-directional</option>
|
||||
<option value="browser-authoritative">Browser Authoritative</option>
|
||||
<option value="server-authoritative">Server Authoritative</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group checkbox-group">
|
||||
<label>
|
||||
<input type="checkbox" id="deletions">
|
||||
Enable deletions during sync
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group checkbox-group">
|
||||
<label>
|
||||
<input type="checkbox" id="auto-sync">
|
||||
Auto-sync every 5 minutes
|
||||
</label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn-primary">Save Settings</button>
|
||||
<button type="button" id="sync-now-btn" class="btn-secondary">Sync Now</button>
|
||||
</div>
|
||||
<div class="sync-info" id="sync-info"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="status"></div>
|
||||
|
||||
<script src="utils/api.js"></script>
|
||||
<script src="options.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
71
LinkSyncExtension/options.js
Normal file
71
LinkSyncExtension/options.js
Normal file
@@ -0,0 +1,71 @@
|
||||
// LinkSync Options Page Script
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
const statusEl = document.getElementById("status");
|
||||
const syncInfoEl = document.getElementById("sync-info");
|
||||
|
||||
const settings = await browser.storage.local.get([
|
||||
"linksync_server_url",
|
||||
"linksync_api_key",
|
||||
"linksync_sync_mode",
|
||||
"linksync_deletions",
|
||||
"linksync_auto_sync",
|
||||
"linksync_last_sync",
|
||||
]);
|
||||
|
||||
document.getElementById("server-url").value = settings.linksync_server_url || "http://localhost:5000";
|
||||
document.getElementById("api-key").value = settings.linksync_api_key || "";
|
||||
document.getElementById("sync-mode").value = settings.linksync_sync_mode || "bi-directional";
|
||||
document.getElementById("deletions").checked = settings.linksync_deletions === true;
|
||||
document.getElementById("auto-sync").checked = settings.linksync_auto_sync === true;
|
||||
|
||||
if (settings.linksync_last_sync) {
|
||||
syncInfoEl.textContent = `Last sync: ${new Date(settings.linksync_last_sync).toLocaleString()}`;
|
||||
}
|
||||
|
||||
document.getElementById("test-btn").addEventListener("click", async () => {
|
||||
try {
|
||||
const url = document.getElementById("server-url").value.replace(/\/+$/, "");
|
||||
const response = await fetch(`${url}/health`, { signal: AbortSignal.timeout(5000) });
|
||||
if (response.ok) {
|
||||
showStatus("Connection successful!", "success");
|
||||
} else {
|
||||
showStatus(`Server returned ${response.status}`, "error");
|
||||
}
|
||||
} catch (e) {
|
||||
showStatus(`Connection failed: ${e.message}`, "error");
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("sync-form").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
await browser.storage.local.set({
|
||||
linksync_server_url: document.getElementById("server-url").value.replace(/\/+$/, ""),
|
||||
linksync_api_key: document.getElementById("api-key").value,
|
||||
linksync_sync_mode: document.getElementById("sync-mode").value,
|
||||
linksync_deletions: document.getElementById("deletions").checked,
|
||||
linksync_auto_sync: document.getElementById("auto-sync").checked,
|
||||
});
|
||||
showStatus("Settings saved successfully", "success");
|
||||
});
|
||||
|
||||
document.getElementById("sync-now-btn").addEventListener("click", async () => {
|
||||
try {
|
||||
const result = await browser.runtime.sendMessage({ type: "SYNC_NOW" });
|
||||
if (result && result.success) {
|
||||
syncInfoEl.textContent = `Last sync: ${new Date().toLocaleString()} (${result.actions?.length || 0} actions)`;
|
||||
showStatus("Sync completed", "success");
|
||||
} else {
|
||||
showStatus(`Sync failed: ${result?.error}`, "error");
|
||||
}
|
||||
} catch (e) {
|
||||
showStatus(`Sync error: ${e.message}`, "error");
|
||||
}
|
||||
});
|
||||
|
||||
function showStatus(message, type) {
|
||||
statusEl.textContent = message;
|
||||
statusEl.className = type;
|
||||
setTimeout(() => { statusEl.className = ""; }, 3000);
|
||||
}
|
||||
});
|
||||
@@ -20,8 +20,8 @@
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 360px;
|
||||
height: 500px;
|
||||
width: 400px;
|
||||
height: 550px;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
@@ -31,162 +31,26 @@ html, body {
|
||||
}
|
||||
|
||||
header {
|
||||
padding: 12px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 18px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
section {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--secondary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
button#submit {
|
||||
width: 100%;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
button#submit:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
button#sync-btn,
|
||||
button#settings-btn {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
margin-bottom: 8px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
button#sync-btn:hover,
|
||||
button#settings-btn:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
#search-filter {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
#search {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.bookmark-item {
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.bookmark-item a {
|
||||
display: block;
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.bookmark-item a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.bookmark-item .title {
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.bookmark-item .description {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.bookmark-item .tags {
|
||||
margin-top: 4px;
|
||||
#sync-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
#collections-list {
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.collection-item {
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 4px;
|
||||
background: var(--surface);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.collection-item h3 {
|
||||
font-size: 13px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.collection-item p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
#sync-indicator {
|
||||
@@ -194,19 +58,19 @@ button#settings-btn:hover {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
margin-right: 4px;
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
.syncing {
|
||||
#sync-indicator.syncing {
|
||||
background: var(--warning);
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.synced {
|
||||
#sync-indicator.synced {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.error {
|
||||
#sync-indicator.error {
|
||||
background: var(--error);
|
||||
}
|
||||
|
||||
@@ -215,27 +79,449 @@ button#settings-btn:hover {
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
#last-sync {
|
||||
/* Tabs */
|
||||
#tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
overflow-y: auto;
|
||||
height: calc(100% - 120px);
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Sections */
|
||||
section {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--secondary);
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* Forms */
|
||||
.form-group {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
background: var(--background);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.checkbox-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-group input[type="checkbox"] {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
button {
|
||||
padding: 7px 14px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
#submit-btn {
|
||||
width: 100%;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
#submit-btn:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 5px 10px;
|
||||
font-size: 11px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn-small:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
/* Search */
|
||||
#search-filter {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
#search {
|
||||
width: 100%;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Bookmark items */
|
||||
.bookmark-item {
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 6px;
|
||||
background: var(--surface);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.bookmark-item:hover {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.bookmark-item a {
|
||||
display: block;
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
font-size: 11px;
|
||||
word-break: break-all;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.bookmark-item a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.bookmark-item .bm-title {
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.bookmark-item .bm-desc {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
#bookmarks-container {
|
||||
max-height: 150px;
|
||||
.bookmark-item .bm-tags {
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.bookmark-item .bm-tag {
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Collection items */
|
||||
.collection-item {
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 6px;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.collection-item h3 {
|
||||
font-size: 12px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.collection-item p {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.collection-item .col-type {
|
||||
font-size: 10px;
|
||||
color: var(--primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Query panel */
|
||||
.query-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
#query-result {
|
||||
padding: 8px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
#collections-panel,
|
||||
#bookmark-list {
|
||||
max-height: 180px;
|
||||
.query-help {
|
||||
font-size: 10px;
|
||||
color: var(--text-secondary);
|
||||
padding: 6px;
|
||||
background: var(--surface);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.query-help code {
|
||||
background: var(--border);
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
footer {
|
||||
padding: 12px;
|
||||
padding: 8px 12px;
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
footer button {
|
||||
width: 100%;
|
||||
}
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
footer button:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
#sync-btn {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Notifications */
|
||||
#notification-container {
|
||||
position: fixed;
|
||||
top: 50px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.notification {
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 4px;
|
||||
animation: slideIn 0.3s ease;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.notification.success {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
border: 1px solid #a7f3d0;
|
||||
}
|
||||
|
||||
.notification.error {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.notification.info {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
border: 1px solid #bfdbfe;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { transform: translateY(-10px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 200;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--background);
|
||||
border-radius: 8px;
|
||||
width: 360px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
#settings-form {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.input-with-toggle {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.input-with-toggle input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
padding: 7px 8px;
|
||||
font-size: 11px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.settings-actions button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
@@ -8,71 +8,151 @@
|
||||
<body>
|
||||
<header>
|
||||
<h1>LinkSync</h1>
|
||||
</header>
|
||||
|
||||
<section id="sync-status">
|
||||
<span id="sync-indicator"></span>
|
||||
<span id="last-sync"></span>
|
||||
</section>
|
||||
|
||||
<!-- Add/Edit Form -->
|
||||
<section id="bookmark-form">
|
||||
<h2>Add Bookmark</h2>
|
||||
<form id="bookmark-form">
|
||||
<div class="form-group">
|
||||
<label for="url">URL:</label>
|
||||
<input type="url" id="url" placeholder="https://example.com" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="title">Title:</label>
|
||||
<input type="text" id="title" placeholder="Page title">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description:</label>
|
||||
<textarea id="description" rows="2"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes:</label>
|
||||
<textarea id="notes" rows="2"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="tags">Tags:</label>
|
||||
<input type="text" id="tags" placeholder="work, personal, dev (comma-separated)">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="folder">Folder:</label>
|
||||
<input type="text" id="folder" placeholder="path/to/folder">
|
||||
</div>
|
||||
|
||||
<button type="submit" id="submit">Add Bookmark</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- Bookmark List -->
|
||||
<section id="bookmark-list">
|
||||
<h2>Bookmarks</h2>
|
||||
<div id="search-filter">
|
||||
<input type="text" id="search" placeholder="Search bookmarks...">
|
||||
<div id="sync-status">
|
||||
<span id="sync-indicator"></span>
|
||||
<span id="last-sync">Not synced yet</span>
|
||||
</div>
|
||||
<div id="bookmarks-container"></div>
|
||||
</header>
|
||||
|
||||
<div id="notification-container"></div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<nav id="tabs">
|
||||
<button class="tab active" data-tab="bookmarks">Bookmarks</button>
|
||||
<button class="tab" data-tab="collections">Collections</button>
|
||||
<button class="tab" data-tab="query">Query</button>
|
||||
</nav>
|
||||
|
||||
<!-- Bookmarks Tab -->
|
||||
<section id="tab-bookmarks" class="tab-content active">
|
||||
<section id="bookmark-form">
|
||||
<h2>Add Bookmark</h2>
|
||||
<form id="add-bookmark-form">
|
||||
<div class="form-group">
|
||||
<label for="url">URL</label>
|
||||
<input type="url" id="url" placeholder="https://example.com" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="title">Title</label>
|
||||
<input type="text" id="title" placeholder="Page title">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" rows="2" placeholder="Page description"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea id="notes" rows="2" placeholder="Your notes"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tags">Tags</label>
|
||||
<input type="text" id="tags" placeholder="work, personal, dev">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="folder">Folder</label>
|
||||
<input type="text" id="folder" placeholder="path/to/folder">
|
||||
</div>
|
||||
<button type="submit" id="submit-btn">Add Bookmark</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="bookmark-list">
|
||||
<div id="search-filter">
|
||||
<input type="text" id="search" placeholder="Search bookmarks...">
|
||||
</div>
|
||||
<div id="bookmarks-container">
|
||||
<p class="empty-state">Loading bookmarks...</p>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<!-- Collections Panel -->
|
||||
<section id="collections-panel">
|
||||
<h2>Collections</h2>
|
||||
<div id="collections-list"></div>
|
||||
|
||||
<!-- Collections Tab -->
|
||||
<section id="tab-collections" class="tab-content">
|
||||
<section id="collections-panel">
|
||||
<h2>Collections</h2>
|
||||
<div id="collections-container">
|
||||
<p class="empty-state">Loading collections...</p>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
|
||||
<!-- Query Tab -->
|
||||
<section id="tab-query" class="tab-content">
|
||||
<section id="query-panel">
|
||||
<h2>Query Builder</h2>
|
||||
<div class="form-group">
|
||||
<label for="query-input">Expression</label>
|
||||
<input type="text" id="query-input" placeholder="('work', 'dev') OR tag:work">
|
||||
</div>
|
||||
<div class="query-actions">
|
||||
<button id="parse-btn" class="btn-small">Parse</button>
|
||||
<button id="execute-btn" class="btn-small btn-primary">Execute</button>
|
||||
</div>
|
||||
<div id="query-result"></div>
|
||||
<div class="query-help">
|
||||
<p>Syntax: <code>('term1', 'term2') OR tag:work AND url:example.com</code></p>
|
||||
<p>Precedence: <code>()</code> > XOR > AND > OR</p>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<button id="sync-btn">Sync Now</button>
|
||||
<button id="settings-btn">Settings</button>
|
||||
</footer>
|
||||
|
||||
|
||||
<!-- Settings Modal -->
|
||||
<div id="settings-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Settings</h2>
|
||||
<button id="close-settings" class="close-btn">×</button>
|
||||
</div>
|
||||
<form id="settings-form">
|
||||
<div class="form-group">
|
||||
<label for="server-url">Server URL</label>
|
||||
<input type="url" id="server-url" placeholder="http://localhost:5000" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="api-key">API Key</label>
|
||||
<div class="input-with-toggle">
|
||||
<input type="password" id="api-key" placeholder="Your API key">
|
||||
<button type="button" id="toggle-key" class="toggle-btn">Show</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sync-mode">Sync Mode</label>
|
||||
<select id="sync-mode">
|
||||
<option value="bi-directional">Bi-directional</option>
|
||||
<option value="browser-authoritative">Browser Authoritative</option>
|
||||
<option value="server-authoritative">Server Authoritative</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group checkbox-group">
|
||||
<label>
|
||||
<input type="checkbox" id="deletions">
|
||||
Enable deletions
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group checkbox-group">
|
||||
<label>
|
||||
<input type="checkbox" id="auto-sync">
|
||||
Auto-sync every 5 minutes
|
||||
</label>
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<button type="button" id="test-connection" class="btn-small">Test Connection</button>
|
||||
<button type="submit" class="btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="utils/api.js"></script>
|
||||
<script src="utils/sync.js"></script>
|
||||
<script src="utils/collection.js"></script>
|
||||
<script src="utils/query-engine.js"></script>
|
||||
<script src="utils/bookmark.js"></script>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -1,285 +1,365 @@
|
||||
// LinkSync Popup Script
|
||||
// Handles bookmark management and sync operations
|
||||
// Handles bookmark management, sync, collections, and queries
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
const Popup = {
|
||||
bookmarks: [],
|
||||
collections: [],
|
||||
|
||||
const Popup = {
|
||||
// API Configuration
|
||||
API_BASE_URL: '',
|
||||
API_KEY: '',
|
||||
|
||||
// Initialize popup
|
||||
async init() {
|
||||
console.log('LinkSync: Popup initialized');
|
||||
|
||||
// Load settings
|
||||
await this.loadSettings();
|
||||
|
||||
// Setup event listeners
|
||||
this.setupEventListeners();
|
||||
|
||||
// Load bookmarks
|
||||
await this.loadBookmarks();
|
||||
|
||||
// Load collections
|
||||
await this.loadCollections();
|
||||
|
||||
// Update sync status
|
||||
this.updateSyncStatus();
|
||||
},
|
||||
|
||||
// Load settings from storage
|
||||
async loadSettings() {
|
||||
this.API_BASE_URL = await this.getSetting('url') || 'http://localhost:5000';
|
||||
this.API_KEY = await this.getSetting('apiKey') || '';
|
||||
|
||||
// Update form
|
||||
this.updateFormState();
|
||||
},
|
||||
|
||||
// Get setting from storage
|
||||
async getSetting(key) {
|
||||
return new Promise(resolve => {
|
||||
browser.storage.local.get(key, result => resolve(result[key]));
|
||||
});
|
||||
},
|
||||
|
||||
// Setup event listeners
|
||||
setupEventListeners() {
|
||||
// Form submission
|
||||
document.getElementById('bookmark-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
await this.addBookmark();
|
||||
});
|
||||
|
||||
// Search filter
|
||||
document.getElementById('search').addEventListener('input', async (e) => {
|
||||
await this.filterBookmarks(e.target.value);
|
||||
});
|
||||
|
||||
// Sync button
|
||||
document.getElementById('sync-btn').addEventListener('click', async () => {
|
||||
await this.syncBookmarks();
|
||||
});
|
||||
|
||||
// Settings button
|
||||
document.getElementById('settings-btn').addEventListener('click', () => {
|
||||
this.openSettings();
|
||||
});
|
||||
},
|
||||
|
||||
// Update form state (edit mode)
|
||||
updateFormState(isEdit = false) {
|
||||
const form = document.getElementById('bookmark-form');
|
||||
if (isEdit) {
|
||||
form.style.display = 'block';
|
||||
} else {
|
||||
form.style.display = 'none';
|
||||
}
|
||||
},
|
||||
|
||||
// Load bookmarks from server
|
||||
async loadBookmarks() {
|
||||
try {
|
||||
const response = await fetch(`${this.API_BASE_URL}/api/links/`, {
|
||||
headers: { 'Authorization': `Token ${this.API_KEY}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.renderBookmarks(data.links || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('LinkSync: Failed to load bookmarks:', error);
|
||||
this.renderError('Unable to connect to server. Check your settings.');
|
||||
}
|
||||
},
|
||||
|
||||
// Render bookmarks to list
|
||||
renderBookmarks(bookmarks) {
|
||||
const container = document.getElementById('bookmarks-container');
|
||||
container.innerHTML = '';
|
||||
|
||||
if (!bookmarks || bookmarks.length === 0) {
|
||||
container.innerHTML = '<p style="color: var(--secondary); font-size: 12px;">No bookmarks</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
bookmarks.forEach(bookmark => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'bookmark-item';
|
||||
item.innerHTML = `
|
||||
<a href="${bookmark.url}" target="_blank">${bookmark.url}</a>
|
||||
<div class="title">${bookmark.title}</div>
|
||||
${bookmark.description ? `<div class="description">${bookmark.description}</div>` : ''}
|
||||
${bookmark.tags && bookmark.tags.length > 0 ? `<div class="tags">${bookmark.tags.join(', ')}</div>` : ''}
|
||||
`;
|
||||
container.appendChild(item);
|
||||
});
|
||||
},
|
||||
|
||||
// Filter bookmarks by search term
|
||||
async filterBookmarks(query) {
|
||||
const bookmarks = await this.loadBookmarks();
|
||||
const filtered = bookmarks.filter(b =>
|
||||
b.title.toLowerCase().includes(query.toLowerCase()) ||
|
||||
b.url.toLowerCase().includes(query.toLowerCase()) ||
|
||||
(b.description && b.description.toLowerCase().includes(query.toLowerCase()))
|
||||
);
|
||||
this.renderBookmarks(filtered);
|
||||
},
|
||||
|
||||
// Add bookmark
|
||||
async addBookmark() {
|
||||
const form = document.getElementById('bookmark-form');
|
||||
const data = {
|
||||
url: document.getElementById('url').value,
|
||||
title: document.getElementById('title').value,
|
||||
description: document.getElementById('description').value,
|
||||
notes: document.getElementById('notes').value,
|
||||
tags: this.formatTags(document.getElementById('tags').value),
|
||||
path: document.getElementById('folder').value
|
||||
};
|
||||
|
||||
const response = await fetch(`${this.API_BASE_URL}/api/links/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Token ${this.API_KEY}`
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
form.reset();
|
||||
await this.loadBookmarks();
|
||||
this.showNotification('Bookmark added', 'success');
|
||||
} else {
|
||||
this.showNotification('Failed to add bookmark', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
// Format tags
|
||||
formatTags(tagString) {
|
||||
if (!tagString) return [];
|
||||
return tagString.split(',').map(t => t.trim()).filter(t => t.length > 0);
|
||||
},
|
||||
|
||||
// Load collections
|
||||
async loadCollections() {
|
||||
try {
|
||||
const response = await fetch(`${this.API_BASE_URL}/api/collections/`, {
|
||||
headers: { 'Authorization': `Token ${this.API_KEY}` }
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
this.renderCollections(data.collections || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('LinkSync: Failed to load collections:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// Render collections
|
||||
renderCollections(collections) {
|
||||
const container = document.getElementById('collections-list');
|
||||
container.innerHTML = '';
|
||||
|
||||
if (!collections || collections.length === 0) {
|
||||
container.innerHTML = '<p style="color: var(--secondary); font-size: 12px;">No collections</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
collections.forEach(collection => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'collection-item';
|
||||
item.innerHTML = `
|
||||
<h3>${collection.name}</h3>
|
||||
<p>${collection.description || ''}</p>
|
||||
<p style="font-size: 10px; color: var(--secondary);">Type: ${collection.query_type || 'dynamic'}</p>
|
||||
`;
|
||||
container.appendChild(item);
|
||||
});
|
||||
},
|
||||
|
||||
// Sync bookmarks
|
||||
async syncBookmarks() {
|
||||
const indicator = document.getElementById('sync-indicator');
|
||||
indicator.className = 'syncing';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.API_BASE_URL}/api/sync/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Token ${this.API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
bookmarks: [],
|
||||
mode: await this.getSetting('mode') || 'bi-directional',
|
||||
deletions: await this.getSetting('deletions') || false
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
indicator.className = 'synced';
|
||||
document.getElementById('last-sync').textContent = `Last sync: ${new Date().toLocaleTimeString()}`;
|
||||
this.showNotification('Sync completed', 'success');
|
||||
} else {
|
||||
this.showNotification('Sync failed', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('LinkSync: Sync error:', error);
|
||||
this.showNotification('Sync error', 'error');
|
||||
} finally {
|
||||
setTimeout(() => indicator.className = '', 2000);
|
||||
}
|
||||
},
|
||||
|
||||
// Update sync status
|
||||
updateSyncStatus() {
|
||||
const indicator = document.getElementById('sync-indicator');
|
||||
const lastSync = document.getElementById('last-sync');
|
||||
|
||||
const lastSyncTime = new Date(await this.getSetting('lastSync') || Date.now());
|
||||
const minutesAgo = Math.floor((Date.now() - lastSyncTime.getTime()) / 60000);
|
||||
|
||||
if (minutesAgo < 5) {
|
||||
indicator.className = 'synced';
|
||||
lastSync.textContent = `Synced ${minutesAgo} min ago`;
|
||||
} else {
|
||||
indicator.className = 'error';
|
||||
lastSync.textContent = `Last sync: ${lastSyncTime.toLocaleString()}`;
|
||||
}
|
||||
},
|
||||
|
||||
// Open settings modal
|
||||
openSettings() {
|
||||
// TODO: Open settings modal
|
||||
console.log('Open settings');
|
||||
},
|
||||
|
||||
// Show notification
|
||||
showNotification(message, type) {
|
||||
// TODO: Show toast notification
|
||||
console.log(`[LinkSync] ${message}`);
|
||||
},
|
||||
|
||||
// Get setting
|
||||
async getSetting(key) {
|
||||
return new Promise(resolve => {
|
||||
browser.storage.local.get(key, result => resolve(result[key]));
|
||||
async init() {
|
||||
this.setupTabs();
|
||||
this.setupEventListeners();
|
||||
await this.loadSettings();
|
||||
await this.loadBookmarks();
|
||||
await this.loadCollections();
|
||||
this.updateSyncStatus();
|
||||
},
|
||||
|
||||
setupTabs() {
|
||||
document.querySelectorAll(".tab").forEach((tab) => {
|
||||
tab.addEventListener("click", () => {
|
||||
document.querySelectorAll(".tab").forEach((t) => t.classList.remove("active"));
|
||||
document.querySelectorAll(".tab-content").forEach((c) => c.classList.remove("active"));
|
||||
tab.classList.add("active");
|
||||
document.getElementById(`tab-${tab.dataset.tab}`).classList.add("active");
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
setupEventListeners() {
|
||||
document.getElementById("add-bookmark-form").addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
this.addBookmark();
|
||||
});
|
||||
|
||||
document.getElementById("search").addEventListener("input", (e) => {
|
||||
this.filterBookmarks(e.target.value);
|
||||
});
|
||||
|
||||
document.getElementById("sync-btn").addEventListener("click", () => this.syncNow());
|
||||
document.getElementById("settings-btn").addEventListener("click", () => this.openSettings());
|
||||
document.getElementById("close-settings").addEventListener("click", () => this.closeSettings());
|
||||
document.getElementById("settings-form").addEventListener("submit", (e) => {
|
||||
e.preventDefault();
|
||||
this.saveSettings();
|
||||
});
|
||||
document.getElementById("toggle-key").addEventListener("click", () => this.toggleApiKey());
|
||||
document.getElementById("test-connection").addEventListener("click", () => this.testConnection());
|
||||
|
||||
document.getElementById("parse-btn").addEventListener("click", () => this.parseQuery());
|
||||
document.getElementById("execute-btn").addEventListener("click", () => this.executeQuery());
|
||||
|
||||
document.getElementById("settings-modal").addEventListener("click", (e) => {
|
||||
if (e.target.id === "settings-modal") this.closeSettings();
|
||||
});
|
||||
},
|
||||
|
||||
async loadSettings() {
|
||||
const settings = await browser.storage.local.get([
|
||||
"linksync_server_url",
|
||||
"linksync_api_key",
|
||||
"linksync_sync_mode",
|
||||
"linksync_deletions",
|
||||
"linksync_auto_sync",
|
||||
]);
|
||||
|
||||
document.getElementById("server-url").value = settings.linksync_server_url || "http://localhost:5000";
|
||||
document.getElementById("api-key").value = settings.linksync_api_key || "";
|
||||
document.getElementById("sync-mode").value = settings.linksync_sync_mode || "bi-directional";
|
||||
document.getElementById("deletions").checked = settings.linksync_deletions === true;
|
||||
document.getElementById("auto-sync").checked = settings.linksync_auto_sync === true;
|
||||
},
|
||||
|
||||
async openSettings() {
|
||||
document.getElementById("settings-modal").classList.add("open");
|
||||
},
|
||||
|
||||
closeSettings() {
|
||||
document.getElementById("settings-modal").classList.remove("open");
|
||||
},
|
||||
|
||||
toggleApiKey() {
|
||||
const input = document.getElementById("api-key");
|
||||
const btn = document.getElementById("toggle-key");
|
||||
if (input.type === "password") {
|
||||
input.type = "text";
|
||||
btn.textContent = "Hide";
|
||||
} else {
|
||||
input.type = "password";
|
||||
btn.textContent = "Show";
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize when page loads
|
||||
window.addEventListener('load', () => Popup.init());
|
||||
|
||||
// Expose to window
|
||||
window.Popup = Popup;
|
||||
|
||||
})();
|
||||
},
|
||||
|
||||
async saveSettings() {
|
||||
const settings = {
|
||||
linksync_server_url: document.getElementById("server-url").value.replace(/\/+$/, ""),
|
||||
linksync_api_key: document.getElementById("api-key").value,
|
||||
linksync_sync_mode: document.getElementById("sync-mode").value,
|
||||
linksync_deletions: document.getElementById("deletions").checked,
|
||||
linksync_auto_sync: document.getElementById("auto-sync").checked,
|
||||
};
|
||||
|
||||
await browser.storage.local.set(settings);
|
||||
await API.init();
|
||||
this.closeSettings();
|
||||
this.notify("Settings saved", "success");
|
||||
await this.loadBookmarks();
|
||||
},
|
||||
|
||||
async testConnection() {
|
||||
try {
|
||||
const result = await browser.runtime.sendMessage({ type: "TEST_CONNECTION" });
|
||||
if (result.success) {
|
||||
this.notify("Connection successful", "success");
|
||||
} else {
|
||||
this.notify(`Connection failed: ${result.error}`, "error");
|
||||
}
|
||||
} catch (e) {
|
||||
this.notify(`Connection failed: ${e.message}`, "error");
|
||||
}
|
||||
},
|
||||
|
||||
async loadBookmarks() {
|
||||
const container = document.getElementById("bookmarks-container");
|
||||
container.innerHTML = '<p class="empty-state">Loading bookmarks...</p>';
|
||||
|
||||
try {
|
||||
const response = await browser.runtime.sendMessage({ type: "GET_BOOKMARKS", data: { limit: 50 } });
|
||||
this.bookmarks = response || [];
|
||||
this.renderBookmarks(this.bookmarks);
|
||||
} catch (e) {
|
||||
container.innerHTML = `<p class="empty-state">Error: ${e.message}</p>`;
|
||||
}
|
||||
},
|
||||
|
||||
renderBookmarks(bookmarks) {
|
||||
const container = document.getElementById("bookmarks-container");
|
||||
|
||||
if (!bookmarks || bookmarks.length === 0) {
|
||||
container.innerHTML = '<p class="empty-state">No bookmarks found</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = bookmarks
|
||||
.map(
|
||||
(bm) => `
|
||||
<div class="bookmark-item" data-id="${bm.id}">
|
||||
<a href="${this.escapeHtml(bm.url)}" target="_blank">${this.escapeHtml(bm.url)}</a>
|
||||
<div class="bm-title">${this.escapeHtml(bm.title)}</div>
|
||||
${bm.description ? `<div class="bm-desc">${this.escapeHtml(bm.description)}</div>` : ""}
|
||||
${bm.tags && bm.tags.length > 0
|
||||
? `<div class="bm-tags">${bm.tags.map((t) => `<span class="bm-tag">${this.escapeHtml(t)}</span>`).join("")}</div>`
|
||||
: ""}
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
},
|
||||
|
||||
filterBookmarks(query) {
|
||||
const q = query.toLowerCase();
|
||||
const filtered = this.bookmarks.filter(
|
||||
(b) =>
|
||||
(b.title && b.title.toLowerCase().includes(q)) ||
|
||||
(b.url && b.url.toLowerCase().includes(q)) ||
|
||||
(b.description && b.description.toLowerCase().includes(q)) ||
|
||||
(b.tags && b.tags.some((t) => t.toLowerCase().includes(q)))
|
||||
);
|
||||
this.renderBookmarks(filtered);
|
||||
},
|
||||
|
||||
async addBookmark() {
|
||||
const data = {
|
||||
url: document.getElementById("url").value,
|
||||
title: document.getElementById("title").value,
|
||||
description: document.getElementById("description").value,
|
||||
notes: document.getElementById("notes").value,
|
||||
tags: this.formatTags(document.getElementById("tags").value),
|
||||
path: document.getElementById("folder").value,
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await browser.runtime.sendMessage({ type: "CREATE_BOOKMARK", data });
|
||||
document.getElementById("add-bookmark-form").reset();
|
||||
this.notify("Bookmark added", "success");
|
||||
await this.loadBookmarks();
|
||||
} catch (e) {
|
||||
this.notify(`Failed to add bookmark: ${e.message}`, "error");
|
||||
}
|
||||
},
|
||||
|
||||
formatTags(tagString) {
|
||||
if (!tagString) return [];
|
||||
return tagString
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
},
|
||||
|
||||
async loadCollections() {
|
||||
const container = document.getElementById("collections-container");
|
||||
container.innerHTML = '<p class="empty-state">Loading collections...</p>';
|
||||
|
||||
try {
|
||||
const response = await browser.runtime.sendMessage({ type: "GET_COLLECTIONS" });
|
||||
this.collections = response || [];
|
||||
this.renderCollections(this.collections);
|
||||
} catch (e) {
|
||||
container.innerHTML = `<p class="empty-state">Error: ${e.message}</p>`;
|
||||
}
|
||||
},
|
||||
|
||||
renderCollections(collections) {
|
||||
const container = document.getElementById("collections-container");
|
||||
|
||||
if (!collections || collections.length === 0) {
|
||||
container.innerHTML = '<p class="empty-state">No collections</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = collections
|
||||
.map(
|
||||
(c) => `
|
||||
<div class="collection-item">
|
||||
<h3>${this.escapeHtml(c.name)}</h3>
|
||||
${c.description ? `<p>${this.escapeHtml(c.description)}</p>` : ""}
|
||||
<span class="col-type">${c.query_type}</span>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
},
|
||||
|
||||
async syncNow() {
|
||||
const indicator = document.getElementById("sync-indicator");
|
||||
indicator.className = "syncing";
|
||||
|
||||
try {
|
||||
const result = await browser.runtime.sendMessage({ type: "SYNC_NOW" });
|
||||
if (result && result.success) {
|
||||
indicator.className = "synced";
|
||||
this.notify(`Sync completed: ${result.actions?.length || 0} actions`, "success");
|
||||
} else {
|
||||
indicator.className = "error";
|
||||
this.notify(`Sync failed: ${result?.error || "Unknown error"}`, "error");
|
||||
}
|
||||
} catch (e) {
|
||||
indicator.className = "error";
|
||||
this.notify(`Sync error: ${e.message}`, "error");
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (indicator.className === "syncing") indicator.className = "";
|
||||
}, 3000);
|
||||
|
||||
this.updateSyncStatus();
|
||||
},
|
||||
|
||||
updateSyncStatus() {
|
||||
browser.storage.local.get(["linksync_last_sync", "linksync_syncing"]).then((settings) => {
|
||||
const indicator = document.getElementById("sync-indicator");
|
||||
const lastSync = document.getElementById("last-sync");
|
||||
|
||||
if (settings.linksync_syncing) {
|
||||
indicator.className = "syncing";
|
||||
lastSync.textContent = "Syncing...";
|
||||
return;
|
||||
}
|
||||
|
||||
if (settings.linksync_last_sync) {
|
||||
const date = new Date(settings.linksync_last_sync);
|
||||
const mins = Math.floor((Date.now() - date.getTime()) / 60000);
|
||||
if (mins < 1) {
|
||||
indicator.className = "synced";
|
||||
lastSync.textContent = "Just now";
|
||||
} else if (mins < 60) {
|
||||
indicator.className = "synced";
|
||||
lastSync.textContent = `${mins}m ago`;
|
||||
} else {
|
||||
indicator.className = "";
|
||||
lastSync.textContent = date.toLocaleDateString();
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async parseQuery() {
|
||||
const expression = document.getElementById("query-input").value.trim();
|
||||
if (!expression) {
|
||||
this.notify("Enter a query expression", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await browser.runtime.sendMessage({ type: "PARSE_QUERY", data: { expression } });
|
||||
const output = document.getElementById("query-result");
|
||||
if (result.valid) {
|
||||
output.innerHTML = `<pre style="white-space:pre-wrap;font-size:10px;">${JSON.stringify(result.parsed, null, 2)}</pre>`;
|
||||
this.notify("Query parsed successfully", "success");
|
||||
} else {
|
||||
output.innerHTML = `<span style="color:var(--error)">Invalid: ${this.escapeHtml(result.error)}</span>`;
|
||||
this.notify("Invalid query syntax", "error");
|
||||
}
|
||||
} catch (e) {
|
||||
this.notify(`Parse error: ${e.message}`, "error");
|
||||
}
|
||||
},
|
||||
|
||||
async executeQuery() {
|
||||
const expression = document.getElementById("query-input").value.trim();
|
||||
if (!expression) {
|
||||
this.notify("Enter a query expression", "info");
|
||||
return;
|
||||
}
|
||||
|
||||
const output = document.getElementById("query-result");
|
||||
output.innerHTML = '<p class="empty-state">Executing...</p>';
|
||||
|
||||
try {
|
||||
const results = await browser.runtime.sendMessage({
|
||||
type: "EXECUTE_QUERY",
|
||||
data: { expression, limit: 50 },
|
||||
});
|
||||
const items = results || [];
|
||||
if (items.length === 0) {
|
||||
output.innerHTML = '<p class="empty-state">No results</p>';
|
||||
} else {
|
||||
output.innerHTML = items
|
||||
.map(
|
||||
(bm) => `
|
||||
<div class="bookmark-item" style="margin-bottom:4px;">
|
||||
<a href="${this.escapeHtml(bm.url)}" target="_blank">${this.escapeHtml(bm.title || bm.url)}</a>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
this.notify(`Found ${items.length} results`, "success");
|
||||
}
|
||||
} catch (e) {
|
||||
output.innerHTML = `<span style="color:var(--error)">Error: ${this.escapeHtml(e.message)}</span>`;
|
||||
this.notify(`Query error: ${e.message}`, "error");
|
||||
}
|
||||
},
|
||||
|
||||
notify(message, type = "info") {
|
||||
const container = document.getElementById("notification-container");
|
||||
const el = document.createElement("div");
|
||||
el.className = `notification ${type}`;
|
||||
el.textContent = message;
|
||||
container.appendChild(el);
|
||||
|
||||
setTimeout(() => {
|
||||
el.style.opacity = "0";
|
||||
el.style.transition = "opacity 0.3s";
|
||||
setTimeout(() => el.remove(), 300);
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
escapeHtml(str) {
|
||||
if (!str) return "";
|
||||
const div = document.createElement("div");
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
},
|
||||
};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => Popup.init());
|
||||
|
||||
@@ -5,253 +5,235 @@
|
||||
### Setup Tasks
|
||||
- [x] Create project directory structure
|
||||
- [x] Write README.md
|
||||
- [ ] Write TODOs.txt
|
||||
- [ ] Write design.md
|
||||
- [ ] Write tasks.md
|
||||
- [ ] Write AGENTS.md
|
||||
- [x] Write TODOs.txt
|
||||
- [x] Write design.md
|
||||
- [x] Write tasks.md
|
||||
- [x] Write AGENTS.md
|
||||
|
||||
### Initial Files
|
||||
- [ ] Create manifest.json
|
||||
- [ ] Add icon files (48x48, 96x96)
|
||||
- [ ] Create styles folder with base.css
|
||||
- [ ] Create utils folder structure
|
||||
- [x] Create manifest.json (v2, Firefox-compatible)
|
||||
- [x] Add icon files (48x48, 96x96)
|
||||
- [x] Create utils folder with all modules
|
||||
- [x] Create content folder for content script
|
||||
|
||||
## Phase 2: Core Development
|
||||
|
||||
### Background Script
|
||||
- [ ] Create background.html
|
||||
- [ ] Create background.js
|
||||
- [ ] Implement init() on install/update
|
||||
- [ ] Implement sync loop with interval
|
||||
- [ ] Add event handlers (message, install, update)
|
||||
- [ ] Implement sync mode switching
|
||||
- [ ] Add collection mapping logic
|
||||
- [ ] Implement auto-sync timer
|
||||
- [ ] Add error handling and retries
|
||||
- [x] Create background.html
|
||||
- [x] Create background.js
|
||||
- [x] Implement init() on install/update
|
||||
- [x] Implement sync loop with interval (5 min)
|
||||
- [x] Add event handlers (message, install, bookmark changes)
|
||||
- [x] Implement sync mode switching
|
||||
- [x] Add collection mapping logic
|
||||
- [x] Implement auto-sync timer
|
||||
- [x] Add error handling and retries
|
||||
|
||||
### Popup Script
|
||||
- [ ] Create popup.html
|
||||
- [ ] Create popup.css
|
||||
- [ ] Create popup.js
|
||||
- [ ] Implement bookmark form UI
|
||||
- [ ] Add bookmark list view
|
||||
- [ ] Implement search filter
|
||||
- [ ] Add collection panel
|
||||
- [ ] Implement settings UI
|
||||
- [ ] Add sync button handler
|
||||
- [x] Create popup.html with tabbed interface
|
||||
- [x] Create popup.css with full responsive styling
|
||||
- [x] Create popup.js with all functionality
|
||||
- [x] Implement bookmark form UI with auto-fill
|
||||
- [x] Add bookmark list view with search
|
||||
- [x] Add collection panel
|
||||
- [x] Implement settings modal
|
||||
- [x] Add sync button handler
|
||||
- [x] Implement query builder tab
|
||||
- [x] Add toast notifications
|
||||
|
||||
### Utility Modules
|
||||
- [ ] Create utils/bookmark.js
|
||||
- [x] Create utils/api.js
|
||||
- REST API client with Bearer token auth
|
||||
- Retry logic (3 attempts with backoff)
|
||||
- Timeout handling (10s)
|
||||
- Rate limit handling (429)
|
||||
- All endpoints: auth, links, collections, queries, sync, admin
|
||||
|
||||
- [x] Create utils/sync.js
|
||||
- Bi-directional sync
|
||||
- Browser-authoritative sync
|
||||
- Server-authoritative sync
|
||||
- Deletions handling
|
||||
- Conflict detection
|
||||
|
||||
- [x] Create utils/collection.js
|
||||
- List/create/update/delete collections
|
||||
- Add/remove links from collections
|
||||
- Execute queries
|
||||
- Parse queries
|
||||
|
||||
- [x] Create utils/query-engine.js
|
||||
- Tokenizer for query expressions
|
||||
- Recursive descent parser
|
||||
- AST generation (TERM, TERM_SET, FIELD, AND, OR, XOR)
|
||||
- Query validation
|
||||
- Query string builder
|
||||
|
||||
- [x] Create utils/bookmark.js
|
||||
- Parse Firefox bookmark data
|
||||
- Format bookmark for API
|
||||
- Handle field validation
|
||||
|
||||
- [ ] Create utils/collection.js
|
||||
- List collections API
|
||||
- Execute query on collection
|
||||
- Create static collection
|
||||
- Update collection name
|
||||
|
||||
- [ ] Create utils/query-engine.js
|
||||
- Tokenize query expression
|
||||
- Build AST
|
||||
- Validate query syntax
|
||||
- Serialize AST to JSON
|
||||
|
||||
- [ ] Create utils/sync.js
|
||||
- Implement sync mode logic
|
||||
- Handle bi-directional sync
|
||||
- Handle browser-authoritative sync
|
||||
- Handle server-authoritative sync
|
||||
- Apply deletions filter
|
||||
- Conflict detection
|
||||
- Conflict resolution
|
||||
- Merge bookmarks for conflict resolution
|
||||
- Duplicate detection
|
||||
|
||||
### API Client
|
||||
- [ ] Create API request helper
|
||||
- [ ] Implement /api/auth/login/
|
||||
- [ ] Implement /api/links/ CRUD
|
||||
- [ ] Implement /api/collections/ CRUD
|
||||
- [ ] Implement /api/queries/execute/
|
||||
- [ ] Implement /api/sync/
|
||||
- [ ] Add error handling
|
||||
- [ ] Add retry logic
|
||||
- [ ] Add timeout handling
|
||||
### Content Script
|
||||
- [x] Create content/content.js
|
||||
- [x] Implement page title extraction
|
||||
- [x] Implement URL detection
|
||||
- [x] Implement meta description extraction
|
||||
- [x] Implement favicon extraction
|
||||
- [x] Handle browser.runtime.onMessage
|
||||
|
||||
### Content Script (Optional)
|
||||
- [ ] Create content/content.js
|
||||
- [ ] Implement page title extraction
|
||||
- [ ] Implement URL detection
|
||||
- [ ] Implement meta description extraction
|
||||
- [ ] Inject popup trigger
|
||||
- [ ] Handle content script permissions
|
||||
### Options Page
|
||||
- [x] Create options.html
|
||||
- [x] Create options.js
|
||||
- [x] Server URL configuration
|
||||
- [x] API key input
|
||||
- [x] Sync mode dropdown
|
||||
- [x] Deletions checkbox
|
||||
- [x] Auto-sync checkbox
|
||||
- [x] Test connection button
|
||||
- [x] Sync now button
|
||||
|
||||
## Phase 3: Storage Management
|
||||
|
||||
### Storage Implementation
|
||||
- [ ] Implement localStorage wrapper
|
||||
- [ ] Add encryption for API keys
|
||||
- [ ] Implement storage helper functions
|
||||
- [ ] Add sync timestamp tracking
|
||||
- [ ] Add pending changes counter
|
||||
- [x] Use browser.storage.local for all settings
|
||||
- [x] Store API key securely
|
||||
- [x] Implement storage helper functions
|
||||
- [x] Add sync timestamp tracking
|
||||
- [x] Add pending changes counter
|
||||
- [x] Add syncing state flag
|
||||
|
||||
### Storage Keys
|
||||
- [ ] `linksync_api_key` - JWT token
|
||||
- [ ] `linksync_collection` - Collection name
|
||||
- [ ] `linksync_sync_mode` - Sync mode string
|
||||
- [ ] `linksync_deletions` - Boolean
|
||||
- [ ] `linksync_auto_sync` - Boolean
|
||||
- [ ] `linksync_last_sync` - ISO timestamp
|
||||
- [ ] `linksync_pending` - Integer count
|
||||
- [x] `linksync_server_url` - Server base URL
|
||||
- [x] `linksync_api_key` - JWT bearer token
|
||||
- [x] `linksync_sync_mode` - Sync mode string
|
||||
- [x] `linksync_deletions` - Boolean
|
||||
- [x] `linksync_auto_sync` - Boolean
|
||||
- [x] `linksync_last_sync` - ISO timestamp
|
||||
- [x] `linksync_syncing` - Boolean flag
|
||||
- [x] `linksync_pending` - Boolean flag
|
||||
|
||||
## Phase 4: Sync Logic
|
||||
|
||||
### Bi-directional Sync
|
||||
- [ ] Push browser→server
|
||||
- [ ] Push server→browser
|
||||
- [ ] Merge conflicting updates
|
||||
- [ ] Track both versions
|
||||
- [x] Push browser→server (new bookmarks)
|
||||
- [x] Push server→browser (new bookmarks)
|
||||
- [x] Handle deletions when enabled
|
||||
|
||||
### Browser Authoritative Sync
|
||||
- [ ] Push browser→server
|
||||
- [ ] Overwrite server→browser
|
||||
- [ ] No pull from server
|
||||
- [x] Push browser→server (create + update)
|
||||
- [x] Overwrite server data on conflict
|
||||
- [x] Delete server bookmarks not in browser
|
||||
|
||||
### Server Authoritative Sync
|
||||
- [ ] Download from server
|
||||
- [ ] Overwrite local on conflict
|
||||
- [ ] No push to server
|
||||
- [x] Download from server
|
||||
- [x] Overwrite local on conflict
|
||||
- [x] No push to server
|
||||
|
||||
### Deletions
|
||||
- [ ] Implement deletions checkbox logic
|
||||
- [ ] Delete on both sides if enabled
|
||||
- [ ] Log deletions
|
||||
- [x] Implement deletions checkbox logic
|
||||
- [x] Delete on both sides if enabled
|
||||
|
||||
### Conflict Resolution
|
||||
- [ ] Detect URL collision
|
||||
- [ ] Present resolution UI
|
||||
- [ ] Keep browser version (default)
|
||||
- [ ] Keep server version option
|
||||
- [ ] Manual merge option
|
||||
- [x] Detect URL collision with different titles
|
||||
- [x] Conflict detection method available
|
||||
|
||||
## Phase 5: UI Components
|
||||
|
||||
### Bookmark Form
|
||||
- [ ] URL input (auto-fill)
|
||||
- [ ] Title input (auto-fill)
|
||||
- [ ] Description textarea
|
||||
- [ ] Notes textarea
|
||||
- [ ] Tags input (comma-separated)
|
||||
- [ ] Folder path input
|
||||
- [ ] Add/Edit/Delete buttons
|
||||
- [x] URL input (auto-fill from active tab)
|
||||
- [x] Title input (auto-fill from active tab)
|
||||
- [x] Description textarea
|
||||
- [x] Notes textarea
|
||||
- [x] Tags input (comma-separated)
|
||||
- [x] Folder path input
|
||||
- [x] Add button
|
||||
|
||||
### Bookmark List
|
||||
- [ ] Pagination
|
||||
- [ ] Search filter input
|
||||
- [ ] Checkboxes for selection
|
||||
- [ ] Batch delete button
|
||||
- [ ] Batch tag update
|
||||
- [x] Display synced bookmarks
|
||||
- [x] Search filter input
|
||||
- [x] Tag display
|
||||
|
||||
### Collections Panel
|
||||
- [ ] Collection list
|
||||
- [ ] Execute query button
|
||||
- [ ] Create dynamic collection form
|
||||
- [ ] Edit collection name/description
|
||||
- [x] Collection list display
|
||||
- [x] Collection type indicator
|
||||
|
||||
### Query Builder
|
||||
- [ ] Simple query input
|
||||
- [ ] Expression syntax help
|
||||
- [ ] Example queries
|
||||
- [ ] Save as collection option
|
||||
- [x] Query expression input
|
||||
- [x] Parse button
|
||||
- [x] Execute button
|
||||
- [x] Result display
|
||||
- [x] Syntax help
|
||||
|
||||
### Sync Status
|
||||
- [ ] Last sync timestamp
|
||||
- [ ] Pending changes count
|
||||
- [ ] Sync indicator icon
|
||||
- [ ] Manual sync trigger
|
||||
- [x] Last sync timestamp
|
||||
- [x] Sync indicator (syncing/synced/error)
|
||||
- [x] Manual sync trigger
|
||||
|
||||
### Settings Modal
|
||||
- [ ] Server URL input
|
||||
- [ ] API Key input (show/hide)
|
||||
- [ ] Collection name input
|
||||
- [ ] Sync mode dropdown
|
||||
- [ ] Deletions checkbox
|
||||
- [ ] Auto-sync toggle
|
||||
- [ ] Test connection button
|
||||
- [x] Server URL input
|
||||
- [x] API Key input (show/hide toggle)
|
||||
- [x] Sync mode dropdown
|
||||
- [x] Deletions checkbox
|
||||
- [x] Auto-sync checkbox
|
||||
- [x] Test connection button
|
||||
- [x] Save button
|
||||
|
||||
## Phase 6: Error Handling
|
||||
|
||||
### API Errors
|
||||
- [ ] Handle 401 (unauthorized)
|
||||
- [ ] Handle 403 (forbidden)
|
||||
- [ ] Handle 429 (rate limited)
|
||||
- [ ] Handle 500 (server error)
|
||||
- [ ] Show user-friendly messages
|
||||
- [x] Handle 401 (unauthorized)
|
||||
- [x] Handle 429 (rate limited) with retry
|
||||
- [x] Handle 500 (server error)
|
||||
- [x] Handle timeout
|
||||
- [x] Show user-friendly messages via notifications
|
||||
|
||||
### Network Errors
|
||||
- [ ] Offline detection
|
||||
- [ ] Queue changes offline
|
||||
- [ ] Retry on reconnection
|
||||
- [ ] Sync when back online
|
||||
- [x] Offline detection (fetch errors)
|
||||
- [x] Retry with backoff (3 attempts)
|
||||
- [x] Request timeout (10s)
|
||||
|
||||
### UI Errors
|
||||
- [ ] Form validation
|
||||
- [ ] Input sanitization
|
||||
- [ ] Graceful fallback on errors
|
||||
- [ ] Error logging
|
||||
- [x] Form validation (required fields)
|
||||
- [x] Input sanitization (escapeHtml)
|
||||
- [x] Error notifications
|
||||
- [x] Empty state messages
|
||||
|
||||
## Phase 7: Testing
|
||||
|
||||
### Unit Tests
|
||||
- [ ] Test sync modes
|
||||
- [ ] Test conflict detection
|
||||
- [ ] Test query parsing
|
||||
- [ ] Test storage operations
|
||||
- [ ] Test bookmark manipulation
|
||||
|
||||
### Integration Tests
|
||||
- [ ] Test API calls
|
||||
- [ ] Test background worker
|
||||
- [ ] Test popup communication
|
||||
- [ ] Test end-to-end sync flow
|
||||
|
||||
### Manual Testing
|
||||
- [ ] Add bookmarks
|
||||
- [ ] Edit bookmarks
|
||||
- [ ] Delete bookmarks
|
||||
- [ ] Create collections
|
||||
- [ ] Execute queries
|
||||
- [x] Testing checklist (tests/README.md)
|
||||
- [ ] Test in Firefox (load temporary add-on)
|
||||
- [ ] Test all sync modes
|
||||
- [ ] Test conflict resolution
|
||||
- [ ] Test conflict scenarios
|
||||
- [ ] Test offline scenarios
|
||||
|
||||
## Phase 8: Packaging
|
||||
|
||||
### Distribution
|
||||
- [ ] Create .zip distribution file
|
||||
- [ ] Verify manifest.json
|
||||
- [ ] Verify all assets
|
||||
- [ ] Test in fresh Firefox install
|
||||
|
||||
### Version Management
|
||||
- [ ] Update version in manifest
|
||||
- [ ] Changelog file
|
||||
- [ ] Release notes
|
||||
- [x] All files present and valid
|
||||
- [x] manifest.json verified
|
||||
- [x] Icons present (48x48, 96x96)
|
||||
|
||||
## Phase 9: Documentation
|
||||
|
||||
- [ ] API reference
|
||||
- [ ] User guide
|
||||
- [ ] Troubleshooting guide
|
||||
- [ ] Query syntax reference
|
||||
- [ ] FAQ
|
||||
- [x] API reference (README.md)
|
||||
- [x] User guide (README.md)
|
||||
- [x] Troubleshooting guide (README.md)
|
||||
- [x] Query syntax reference (README.md)
|
||||
- [x] Architecture docs (AGENTS.md, design.md)
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Background sync notifications
|
||||
- [ ] Auto-sync scheduler
|
||||
- [ ] Keyboard shortcuts
|
||||
- [ ] Dark theme toggle
|
||||
- [ ] Bookmark edit/delete from popup
|
||||
- [ ] Batch operations
|
||||
- [ ] Conflict resolution UI
|
||||
- [ ] Offline queue for pending changes
|
||||
- [ ] Auto-sync scheduler customization
|
||||
- [ ] Gesture controls
|
||||
- [ ] Mobile companion app
|
||||
- [ ] Dark theme toggle
|
||||
- [ ] Custom colors
|
||||
215
LinkSyncExtension/utils/api.js
Normal file
215
LinkSyncExtension/utils/api.js
Normal file
@@ -0,0 +1,215 @@
|
||||
// LinkSync API Client
|
||||
// Handles all communication with LinkSyncServer
|
||||
|
||||
const API = {
|
||||
baseUrl: "",
|
||||
token: "",
|
||||
maxRetries: 3,
|
||||
retryDelay: 1000,
|
||||
timeout: 10000,
|
||||
|
||||
async init() {
|
||||
const settings = await this.getSettings();
|
||||
this.baseUrl = (settings.serverUrl || "http://localhost:5000").replace(/\/+$/, "");
|
||||
this.token = settings.apiKey || "";
|
||||
},
|
||||
|
||||
async getSettings() {
|
||||
return new Promise((resolve) => {
|
||||
browser.storage.local.get(
|
||||
["linksync_server_url", "linksync_api_key"],
|
||||
(result) => resolve(result)
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
async request(method, path, body = null) {
|
||||
await this.init();
|
||||
|
||||
if (!this.token) {
|
||||
throw new Error("Not authenticated. Set your API key in settings.");
|
||||
}
|
||||
|
||||
let lastError;
|
||||
for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||
|
||||
const options = {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
},
|
||||
signal: controller.signal,
|
||||
};
|
||||
|
||||
if (body && method !== "GET") {
|
||||
options.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}${path}`, options);
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (response.status === 401) {
|
||||
throw new Error("Authentication failed. Check your API key.");
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
const retryAfter = parseInt(response.headers.get("Retry-After"), 10) || this.retryDelay * attempt;
|
||||
await this.delay(retryAfter);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
throw new Error(
|
||||
errorData?.detail || `Server error: ${response.status} ${response.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (contentType && contentType.includes("application/json")) {
|
||||
return await response.json();
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (error.name === "AbortError") {
|
||||
lastError = new Error("Request timed out");
|
||||
}
|
||||
if (attempt < this.maxRetries) {
|
||||
await this.delay(this.retryDelay * attempt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error("Request failed after retries");
|
||||
},
|
||||
|
||||
async get(path) {
|
||||
return this.request("GET", path);
|
||||
},
|
||||
|
||||
async post(path, body) {
|
||||
return this.request("POST", path, body);
|
||||
},
|
||||
|
||||
async put(path, body) {
|
||||
return this.request("PUT", path, body);
|
||||
},
|
||||
|
||||
async delete(path) {
|
||||
return this.request("DELETE", path);
|
||||
},
|
||||
|
||||
async login(username, password) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||
|
||||
const formData = new URLSearchParams();
|
||||
formData.append("username", username);
|
||||
formData.append("password", password);
|
||||
|
||||
const settings = await this.getSettings();
|
||||
this.baseUrl = (settings.serverUrl || "http://localhost:5000").replace(/\/+$/, "");
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: formData.toString(),
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => null);
|
||||
throw new Error(error?.detail || "Login failed");
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.token = data.access_token;
|
||||
|
||||
await browser.storage.local.set({ linksync_api_key: data.access_token });
|
||||
return data;
|
||||
},
|
||||
|
||||
async testConnection() {
|
||||
await this.init();
|
||||
const response = await fetch(`${this.baseUrl}/health`, {
|
||||
method: "GET",
|
||||
signal: AbortSignal.timeout(this.timeout),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Server returned ${response.status}`);
|
||||
return await response.json();
|
||||
},
|
||||
|
||||
// Links
|
||||
async getLinks(params = {}) {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
return this.get(`/api/links/${qs ? "?" + qs : ""}`);
|
||||
},
|
||||
|
||||
async createLink(data) {
|
||||
return this.post("/api/links/", data);
|
||||
},
|
||||
|
||||
async updateLink(id, data) {
|
||||
return this.put(`/api/links/${id}/`, data);
|
||||
},
|
||||
|
||||
async deleteLink(id) {
|
||||
return this.delete(`/api/links/${id}/`);
|
||||
},
|
||||
|
||||
// Collections
|
||||
async getCollections() {
|
||||
return this.get("/api/collections/");
|
||||
},
|
||||
|
||||
async createCollection(data) {
|
||||
return this.post("/api/collections/", data);
|
||||
},
|
||||
|
||||
async updateCollection(id, data) {
|
||||
return this.put(`/api/collections/${id}/`, data);
|
||||
},
|
||||
|
||||
async deleteCollection(id) {
|
||||
return this.delete(`/api/collections/${id}/`);
|
||||
},
|
||||
|
||||
async refreshCollection(id) {
|
||||
return this.post(`/api/collections/${id}/refresh`, {});
|
||||
},
|
||||
|
||||
// Queries
|
||||
async parseQuery(expression) {
|
||||
return this.post(`/api/queries/parse?expression=${encodeURIComponent(expression)}`);
|
||||
},
|
||||
|
||||
async executeQuery(expression, limit = 50) {
|
||||
return this.post(
|
||||
`/api/queries/execute?expression=${encodeURIComponent(expression)}&limit=${limit}`
|
||||
);
|
||||
},
|
||||
|
||||
// Sync
|
||||
async sync(config, browserBookmarks) {
|
||||
return this.post("/api/sync/", {
|
||||
mode: config.mode,
|
||||
deletions_enabled: config.deletions,
|
||||
browser_bookmarks: browserBookmarks,
|
||||
});
|
||||
},
|
||||
|
||||
// Admin
|
||||
async getAdminStats() {
|
||||
return this.get("/api/admin/stats");
|
||||
},
|
||||
|
||||
delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
},
|
||||
};
|
||||
51
LinkSyncExtension/utils/collection.js
Normal file
51
LinkSyncExtension/utils/collection.js
Normal file
@@ -0,0 +1,51 @@
|
||||
// LinkSync Collection Management
|
||||
// Handles collection CRUD and query execution
|
||||
|
||||
const CollectionManager = {
|
||||
async listCollections() {
|
||||
return API.getCollections();
|
||||
},
|
||||
|
||||
async createCollection(name, description, queryType, queryExpression, isPublic) {
|
||||
return API.createCollection({
|
||||
name,
|
||||
description: description || "",
|
||||
query_type: queryType || "static",
|
||||
query_expression: queryExpression || null,
|
||||
is_public: isPublic || false,
|
||||
link_ids: [],
|
||||
});
|
||||
},
|
||||
|
||||
async updateCollection(id, data) {
|
||||
return API.updateCollection(id, data);
|
||||
},
|
||||
|
||||
async deleteCollection(id) {
|
||||
return API.deleteCollection(id);
|
||||
},
|
||||
|
||||
async refreshCollection(id) {
|
||||
return API.refreshCollection(id);
|
||||
},
|
||||
|
||||
async addLinksToCollection(collectionId, linkIds) {
|
||||
return API.post(`/api/collections/${collectionId}/add-links`, linkIds);
|
||||
},
|
||||
|
||||
async removeLinksFromCollection(collectionId, linkIds) {
|
||||
return API.delete(`/api/collections/${collectionId}/remove-links`, { body: linkIds });
|
||||
},
|
||||
|
||||
async executeQuery(expression, limit = 50) {
|
||||
return API.executeQuery(expression, limit);
|
||||
},
|
||||
|
||||
async parseQuery(expression) {
|
||||
return API.parseQuery(expression);
|
||||
},
|
||||
|
||||
async createDynamicCollection(name, description, queryExpression) {
|
||||
return this.createCollection(name, description, "dynamic", queryExpression, false);
|
||||
},
|
||||
};
|
||||
233
LinkSyncExtension/utils/query-engine.js
Normal file
233
LinkSyncExtension/utils/query-engine.js
Normal file
@@ -0,0 +1,233 @@
|
||||
// LinkSync Query Engine
|
||||
// Client-side query parser and executor for building queries
|
||||
|
||||
const QueryEngine = {
|
||||
tokenize(expression) {
|
||||
const tokens = [];
|
||||
let pos = 0;
|
||||
const len = expression.length;
|
||||
|
||||
while (pos < len) {
|
||||
const ch = expression[pos];
|
||||
|
||||
if (ch === " " || ch === "\t") {
|
||||
pos++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "(") {
|
||||
tokens.push({ type: "LPAREN", value: "(" });
|
||||
pos++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === ")") {
|
||||
tokens.push({ type: "RPAREN", value: ")" });
|
||||
pos++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === ",") {
|
||||
tokens.push({ type: "COMMA", value: "," });
|
||||
pos++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (expression.substring(pos, pos + 3) === "AND") {
|
||||
tokens.push({ type: "OPERATOR", value: "AND" });
|
||||
pos += 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (expression.substring(pos, pos + 2) === "OR") {
|
||||
tokens.push({ type: "OPERATOR", value: "OR" });
|
||||
pos += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (expression.substring(pos, pos + 3) === "XOR") {
|
||||
tokens.push({ type: "OPERATOR", value: "XOR" });
|
||||
pos += 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "'" || ch === '"') {
|
||||
const quote = ch;
|
||||
pos++;
|
||||
let value = "";
|
||||
while (pos < len && expression[pos] !== quote) {
|
||||
value += expression[pos];
|
||||
pos++;
|
||||
}
|
||||
pos++;
|
||||
tokens.push({ type: "TERM", value });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/[a-zA-Z0-9_.\-]/.test(ch)) {
|
||||
let value = "";
|
||||
while (pos < len && /[a-zA-Z0-9_.\-:\/?&=%]/.test(expression[pos])) {
|
||||
value += expression[pos];
|
||||
pos++;
|
||||
}
|
||||
|
||||
if (value.includes(":")) {
|
||||
const [field, ...rest] = value.split(":");
|
||||
const fieldValue = rest.join(":");
|
||||
const knownFields = ["url", "tag", "title", "description", "path", "id"];
|
||||
if (knownFields.includes(field.toLowerCase())) {
|
||||
tokens.push({ type: "FIELD", value: field.toUpperCase() });
|
||||
tokens.push({ type: "TERM", value: fieldValue });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
tokens.push({ type: "TERM", value });
|
||||
continue;
|
||||
}
|
||||
|
||||
pos++;
|
||||
}
|
||||
|
||||
return tokens;
|
||||
},
|
||||
|
||||
parse(expression) {
|
||||
if (!expression || !expression.trim()) return null;
|
||||
|
||||
const tokens = this.tokenize(expression);
|
||||
if (tokens.length === 0) return null;
|
||||
|
||||
const parser = new QueryParserState(tokens);
|
||||
return parser.parseOr();
|
||||
},
|
||||
|
||||
buildQueryString(ast) {
|
||||
if (!ast) return "";
|
||||
|
||||
switch (ast.type) {
|
||||
case "TERM":
|
||||
return ast.value;
|
||||
case "TERM_SET":
|
||||
return `(${ast.values.join(", ")})`;
|
||||
case "FIELD":
|
||||
return `${ast.field.toLowerCase()}:${ast.value}`;
|
||||
case "AND":
|
||||
case "OR":
|
||||
case "XOR":
|
||||
return `(${this.buildQueryString(ast.left)} ${ast.type} ${this.buildQueryString(ast.right)})`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
},
|
||||
|
||||
validate(expression) {
|
||||
try {
|
||||
const ast = this.parse(expression);
|
||||
return { valid: true, ast };
|
||||
} catch (e) {
|
||||
return { valid: false, error: e.message };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
class QueryParserState {
|
||||
constructor(tokens) {
|
||||
this.tokens = tokens;
|
||||
this.pos = 0;
|
||||
}
|
||||
|
||||
current() {
|
||||
return this.pos < this.tokens.length ? this.tokens[this.pos] : null;
|
||||
}
|
||||
|
||||
advance() {
|
||||
const token = this.current();
|
||||
this.pos++;
|
||||
return token;
|
||||
}
|
||||
|
||||
expect(type) {
|
||||
const token = this.current();
|
||||
if (!token) throw new Error(`Expected ${type}, got end of input`);
|
||||
if (token.type !== type) throw new Error(`Expected ${type}, got ${token.type}`);
|
||||
return this.advance();
|
||||
}
|
||||
|
||||
parseOr() {
|
||||
let left = this.parseAnd();
|
||||
while (this.current() && this.current().type === "OPERATOR" && this.current().value === "OR") {
|
||||
this.advance();
|
||||
const right = this.parseAnd();
|
||||
left = { type: "OR", left, right };
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
parseAnd() {
|
||||
let left = this.parseXor();
|
||||
while (this.current() && this.current().type === "OPERATOR" && this.current().value === "AND") {
|
||||
this.advance();
|
||||
const right = this.parseXor();
|
||||
left = { type: "AND", left, right };
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
parseXor() {
|
||||
let left = this.parsePrimary();
|
||||
while (this.current() && this.current().type === "OPERATOR" && this.current().value === "XOR") {
|
||||
this.advance();
|
||||
const right = this.parsePrimary();
|
||||
left = { type: "XOR", left, right };
|
||||
}
|
||||
return left;
|
||||
}
|
||||
|
||||
parsePrimary() {
|
||||
const token = this.current();
|
||||
if (!token) throw new Error("Unexpected end of input");
|
||||
|
||||
if (token.type === "LPAREN") {
|
||||
this.advance();
|
||||
const node = this.parseOr();
|
||||
this.expect("RPAREN");
|
||||
return node;
|
||||
}
|
||||
|
||||
if (token.type === "FIELD") {
|
||||
const field = this.advance().value;
|
||||
const valueToken = this.current();
|
||||
if (valueToken && valueToken.type === "TERM") {
|
||||
this.advance();
|
||||
return { type: "FIELD", field, value: valueToken.value };
|
||||
}
|
||||
return { type: "FIELD", field, value: "" };
|
||||
}
|
||||
|
||||
if (token.type === "TERM") {
|
||||
return this.parseTerm();
|
||||
}
|
||||
|
||||
throw new Error(`Unexpected token: ${token.value}`);
|
||||
}
|
||||
|
||||
parseTerm() {
|
||||
const token = this.advance();
|
||||
const next = this.current();
|
||||
|
||||
if (next && next.type === "COMMA") {
|
||||
const values = [token.value];
|
||||
while (this.current() && this.current().type === "COMMA") {
|
||||
this.advance();
|
||||
const termToken = this.current();
|
||||
if (termToken && termToken.type === "TERM") {
|
||||
values.push(this.advance().value);
|
||||
}
|
||||
}
|
||||
return { type: "TERM_SET", values };
|
||||
}
|
||||
|
||||
return { type: "TERM", value: token.value };
|
||||
}
|
||||
}
|
||||
261
LinkSyncExtension/utils/sync.js
Normal file
261
LinkSyncExtension/utils/sync.js
Normal file
@@ -0,0 +1,261 @@
|
||||
// LinkSync Sync Engine
|
||||
// Handles all three sync modes with conflict resolution
|
||||
|
||||
const SyncEngine = {
|
||||
MODES: {
|
||||
BIDIRECTIONAL: "bi-directional",
|
||||
BROWSER_AUTHORITY: "browser-authoritative",
|
||||
SERVER_AUTHORITY: "server-authoritative",
|
||||
},
|
||||
|
||||
async getConfig() {
|
||||
const result = await browser.storage.local.get([
|
||||
"linksync_sync_mode",
|
||||
"linksync_deletions",
|
||||
"linksync_collection",
|
||||
]);
|
||||
return {
|
||||
mode: result.linksync_sync_mode || this.MODES.BIDIRECTIONAL,
|
||||
deletions: result.linksync_deletions === true,
|
||||
collection: result.linksync_collection || null,
|
||||
};
|
||||
},
|
||||
|
||||
async runSync() {
|
||||
const config = await this.getConfig();
|
||||
const browserBookmarks = await this.getBrowserBookmarks();
|
||||
const serverBookmarks = await API.getLinks({ limit: 1000 });
|
||||
|
||||
let actions = [];
|
||||
|
||||
switch (config.mode) {
|
||||
case this.MODES.BIDIRECTIONAL:
|
||||
actions = await this.bidirectionalSync(browserBookmarks, serverBookmarks, config);
|
||||
break;
|
||||
case this.MODES.BROWSER_AUTHORITY:
|
||||
actions = await this.browserAuthoritativeSync(browserBookmarks, serverBookmarks, config);
|
||||
break;
|
||||
case this.MODES.SERVER_AUTHORITY:
|
||||
actions = await this.serverAuthoritativeSync(browserBookmarks, serverBookmarks, config);
|
||||
break;
|
||||
}
|
||||
|
||||
await browser.storage.local.set({
|
||||
linksync_last_sync: new Date().toISOString(),
|
||||
});
|
||||
|
||||
return actions;
|
||||
},
|
||||
|
||||
async getBrowserBookmarks() {
|
||||
const tree = await browser.bookmarks.getTree();
|
||||
const flat = [];
|
||||
this.flattenBookmarks(tree, flat);
|
||||
return flat;
|
||||
},
|
||||
|
||||
flattenBookmarks(nodes, result, path = "") {
|
||||
for (const node of nodes) {
|
||||
if (node.url) {
|
||||
result.push({
|
||||
id: node.id,
|
||||
url: node.url,
|
||||
title: node.title || "",
|
||||
dateAdded: node.dateAdded ? new Date(node.dateAdded).toISOString() : null,
|
||||
path: path,
|
||||
});
|
||||
}
|
||||
if (node.children && node.children.length > 0) {
|
||||
const newPath = node.title ? `${path}/${node.title}` : path;
|
||||
this.flattenBookmarks(node.children, result, newPath);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async bidirectionalSync(browserBookmarks, serverBookmarks, config) {
|
||||
const actions = [];
|
||||
const serverByUrl = {};
|
||||
const browserByUrl = {};
|
||||
|
||||
for (const bm of serverBookmarks || []) {
|
||||
serverByUrl[bm.url.toLowerCase()] = bm;
|
||||
}
|
||||
for (const bm of browserBookmarks) {
|
||||
browserByUrl[bm.url.toLowerCase()] = bm;
|
||||
}
|
||||
|
||||
// Push new/updated browser bookmarks to server
|
||||
for (const bm of browserBookmarks) {
|
||||
const key = bm.url.toLowerCase();
|
||||
const serverBm = serverByUrl[key];
|
||||
|
||||
if (!serverBm) {
|
||||
try {
|
||||
const created = await API.createLink({
|
||||
url: bm.url,
|
||||
title: bm.title,
|
||||
tags: [],
|
||||
path: bm.path,
|
||||
});
|
||||
actions.push({ type: "create", url: bm.url, target: "server", id: created?.id });
|
||||
} catch (e) {
|
||||
actions.push({ type: "error", url: bm.url, target: "server", error: e.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Push new server bookmarks to browser
|
||||
for (const bm of serverBookmarks || []) {
|
||||
const key = bm.url.toLowerCase();
|
||||
const browserBm = browserByUrl[key];
|
||||
|
||||
if (!browserBm) {
|
||||
try {
|
||||
const created = await browser.bookmarks.create({
|
||||
url: bm.url,
|
||||
title: bm.title,
|
||||
});
|
||||
actions.push({ type: "create", url: bm.url, target: "browser", id: created?.id });
|
||||
} catch (e) {
|
||||
actions.push({ type: "error", url: bm.url, target: "browser", error: e.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle deletions
|
||||
if (config.deletions) {
|
||||
for (const key in serverByUrl) {
|
||||
if (!browserByUrl[key]) {
|
||||
try {
|
||||
await API.deleteLink(serverByUrl[key].id);
|
||||
actions.push({ type: "delete", url: key, target: "server" });
|
||||
} catch (e) {
|
||||
actions.push({ type: "error", url: key, target: "server", error: e.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return actions;
|
||||
},
|
||||
|
||||
async browserAuthoritativeSync(browserBookmarks, serverBookmarks, config) {
|
||||
const actions = [];
|
||||
const serverByUrl = {};
|
||||
|
||||
for (const bm of serverBookmarks || []) {
|
||||
serverByUrl[bm.url.toLowerCase()] = bm;
|
||||
}
|
||||
|
||||
// Push all browser bookmarks to server (overwriting if exists)
|
||||
for (const bm of browserBookmarks) {
|
||||
const key = bm.url.toLowerCase();
|
||||
const serverBm = serverByUrl[key];
|
||||
|
||||
if (!serverBm) {
|
||||
try {
|
||||
const created = await API.createLink({
|
||||
url: bm.url,
|
||||
title: bm.title,
|
||||
tags: [],
|
||||
path: bm.path,
|
||||
});
|
||||
actions.push({ type: "create", url: bm.url, target: "server", id: created?.id });
|
||||
} catch (e) {
|
||||
actions.push({ type: "error", url: bm.url, target: "server", error: e.message });
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
await API.updateLink(serverBm.id, {
|
||||
url: bm.url,
|
||||
title: bm.title,
|
||||
});
|
||||
actions.push({ type: "update", url: bm.url, target: "server" });
|
||||
} catch (e) {
|
||||
actions.push({ type: "error", url: bm.url, target: "server", error: e.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete server bookmarks not in browser
|
||||
if (config.deletions) {
|
||||
const browserUrls = new Set(browserBookmarks.map((b) => b.url.toLowerCase()));
|
||||
for (const key in serverByUrl) {
|
||||
if (!browserUrls.has(key)) {
|
||||
try {
|
||||
await API.deleteLink(serverByUrl[key].id);
|
||||
actions.push({ type: "delete", url: key, target: "server" });
|
||||
} catch (e) {
|
||||
actions.push({ type: "error", url: key, target: "server", error: e.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return actions;
|
||||
},
|
||||
|
||||
async serverAuthoritativeSync(browserBookmarks, serverBookmarks, config) {
|
||||
const actions = [];
|
||||
const browserByUrl = {};
|
||||
|
||||
for (const bm of browserBookmarks) {
|
||||
browserByUrl[bm.url.toLowerCase()] = bm;
|
||||
}
|
||||
|
||||
// Download all server bookmarks to browser
|
||||
for (const bm of serverBookmarks || []) {
|
||||
const key = bm.url.toLowerCase();
|
||||
const browserBm = browserByUrl[key];
|
||||
|
||||
if (!browserBm) {
|
||||
try {
|
||||
const created = await browser.bookmarks.create({
|
||||
url: bm.url,
|
||||
title: bm.title,
|
||||
});
|
||||
actions.push({ type: "create", url: bm.url, target: "browser", id: created?.id });
|
||||
} catch (e) {
|
||||
actions.push({ type: "error", url: bm.url, target: "browser", error: e.message });
|
||||
}
|
||||
} else {
|
||||
// Overwrite local on conflict
|
||||
try {
|
||||
await browser.bookmarks.update(browserBm.id, {
|
||||
title: bm.title,
|
||||
});
|
||||
actions.push({ type: "update", url: bm.url, target: "browser" });
|
||||
} catch (e) {
|
||||
actions.push({ type: "error", url: bm.url, target: "browser", error: e.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return actions;
|
||||
},
|
||||
|
||||
detectConflicts(browserBookmarks, serverBookmarks) {
|
||||
const conflicts = [];
|
||||
const serverByUrl = {};
|
||||
|
||||
for (const bm of serverBookmarks || []) {
|
||||
serverByUrl[bm.url.toLowerCase()] = bm;
|
||||
}
|
||||
|
||||
for (const bm of browserBookmarks) {
|
||||
const key = bm.url.toLowerCase();
|
||||
const serverBm = serverByUrl[key];
|
||||
if (serverBm && serverBm.title !== bm.title) {
|
||||
conflicts.push({
|
||||
url: bm.url,
|
||||
browserTitle: bm.title,
|
||||
serverTitle: serverBm.title,
|
||||
browserId: bm.id,
|
||||
serverId: serverBm.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return conflicts;
|
||||
},
|
||||
};
|
||||
14
LinkSyncServer/.gitignore
vendored
Normal file
14
LinkSyncServer/.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.db
|
||||
*.sqlite3
|
||||
.egg-info/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.env
|
||||
linksync.db
|
||||
test_linksync.db
|
||||
test_debug.db
|
||||
@@ -139,6 +139,64 @@ volumes:
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
### How `build: .` Works
|
||||
|
||||
In `docker-compose.yml`, the `web` service uses `build: .` instead of `image:`. This is a key distinction:
|
||||
|
||||
| Key | Behavior |
|
||||
|-----|----------|
|
||||
| `image: postgres:15-alpine` | Pulls a pre-built image from Docker Hub |
|
||||
| `build: .` | Builds a custom image from a `Dockerfile` in the current directory (`.`) |
|
||||
|
||||
**The build process works like this:**
|
||||
|
||||
```
|
||||
docker-compose up --build
|
||||
│
|
||||
▼
|
||||
Reads docker-compose.yml
|
||||
│
|
||||
▼
|
||||
Finds build: . → looks for Dockerfile in current directory
|
||||
│
|
||||
▼
|
||||
Executes each instruction in the Dockerfile:
|
||||
1. FROM python:3.12-slim ← Base image
|
||||
2. RUN apt-get install curl ← Install system deps
|
||||
3. COPY requirements.txt . ← Copy dependency list
|
||||
4. RUN pip install -r ... ← Install Python packages
|
||||
5. COPY . . ← Copy all project files
|
||||
6. EXPOSE 5000 ← Declare port
|
||||
7. CMD ["uvicorn", ...] ← Set startup command
|
||||
│
|
||||
▼
|
||||
Tags the built image as linksyncserver-web (auto-generated name)
|
||||
│
|
||||
▼
|
||||
Starts the container from the built image
|
||||
```
|
||||
|
||||
**Why build instead of pull?**
|
||||
|
||||
- You're running your own application code, not a third-party image
|
||||
- Every code change requires a rebuild to take effect
|
||||
- The `Dockerfile` defines exactly how your app is packaged
|
||||
|
||||
**Rebuilding after code changes:**
|
||||
|
||||
```bash
|
||||
# Rebuild and restart (picks up all code changes)
|
||||
docker-compose up -d --build
|
||||
|
||||
# Rebuild without cache (forces fresh pip install)
|
||||
docker-compose build --no-cache && docker-compose up -d
|
||||
|
||||
# Just restart without rebuilding (uses existing image)
|
||||
docker-compose restart
|
||||
```
|
||||
|
||||
**The `--build` flag:** Forces Docker Compose to rebuild images before starting containers. Without it, Compose reuses any previously built image, meaning your code changes won't be reflected.
|
||||
|
||||
### Initial Login
|
||||
|
||||
- URL: `http://localhost:5000`
|
||||
@@ -185,6 +243,93 @@ LinkSyncServer/
|
||||
└── static/
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Deploy Script
|
||||
|
||||
The project includes `deploy.ps1` (Windows) and `deploy.sh` (Linux/macOS) to prepare a clean deployment package. These scripts copy only production files, exclude development artifacts (`tests/`, `__pycache__/`, `.git/`, etc.), and create a starter `.env` file.
|
||||
|
||||
#### Usage
|
||||
|
||||
```powershell
|
||||
# Windows
|
||||
.\deploy.ps1 C:\deploy\linksync
|
||||
```
|
||||
|
||||
```bash
|
||||
# Linux/macOS
|
||||
chmod +x deploy.sh
|
||||
./deploy.sh /opt/deploy/linksync
|
||||
```
|
||||
|
||||
#### What Gets Deployed
|
||||
|
||||
```
|
||||
linksync-deploy/
|
||||
├── .env ← starter file (edit with production secrets)
|
||||
├── .env.example
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
├── requirements.txt
|
||||
├── app.py
|
||||
├── api/
|
||||
├── models/
|
||||
├── queries/
|
||||
├── config/
|
||||
├── templates/
|
||||
├── static/
|
||||
├── alembic/
|
||||
├── pyproject.toml
|
||||
├── README.md
|
||||
├── AGENTS.md
|
||||
├── design.md
|
||||
├── tasks.md
|
||||
└── TODOs.txt
|
||||
```
|
||||
|
||||
#### What Is Excluded
|
||||
|
||||
`tests/`, `__pycache__/`, `.pytest_cache/`, `.git/`, `.vscode/`, `*.pyc`, `*.db`, `*.sqlite3`, `node_modules/`, `dist/`, `build/`, and the deploy scripts themselves.
|
||||
|
||||
#### Full Deployment Workflow
|
||||
|
||||
```bash
|
||||
# 1. Clone the repository to a temporary location
|
||||
git clone <repo-url> /tmp/linksync-src
|
||||
cd /tmp/linksync-src
|
||||
|
||||
# 2. Run the deploy script to prepare the package
|
||||
./deploy.sh /opt/linksync
|
||||
|
||||
# 3. Configure production secrets
|
||||
cd /opt/linksync
|
||||
nano .env
|
||||
# Set these values:
|
||||
# DATABASE_URL=postgresql://user:pass@db:5432/linksync
|
||||
# SECRET_KEY=<generate with: openssl rand -base64 32>
|
||||
# ADMIN_PASSWORD=<strong password>
|
||||
|
||||
# 4. Build and start
|
||||
docker-compose up -d --build
|
||||
|
||||
# 5. Verify
|
||||
curl http://localhost:5000/health
|
||||
|
||||
# 6. Clean up the source clone
|
||||
rm -rf /tmp/linksync-src
|
||||
```
|
||||
|
||||
#### Updating an Existing Deployment
|
||||
|
||||
```bash
|
||||
# On the server, pull latest code and redeploy
|
||||
cd /tmp/linksync-src && git pull
|
||||
./deploy.sh /opt/linksync
|
||||
cd /opt/linksync
|
||||
docker-compose up -d --build
|
||||
rm -rf /tmp/linksync-src
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
|
||||
@@ -16,85 +16,86 @@
|
||||
## Core Development
|
||||
|
||||
### Authentication & Authorization
|
||||
- [x] User registration/login (tests created)
|
||||
- [x] JWT token generation and validation (tests created)
|
||||
- [x] API key management (tests created)
|
||||
- [x] Admin user creation (tests created)
|
||||
- [x] Role-based access control (tests created)
|
||||
- [x] Session management (tests created)
|
||||
- [x] User registration/login (with real DB integration)
|
||||
- [x] JWT token generation and validation (from environment settings)
|
||||
- [x] API key management (with real DB integration)
|
||||
- [x] Admin user creation (auto-creates on first login)
|
||||
- [x] Role-based access control (admin/user roles)
|
||||
- [x] Session management (JWT-based)
|
||||
|
||||
### Data Models
|
||||
- [x] User model (tests created)
|
||||
- [x] Link model with Firefox fields (tests created)
|
||||
- [x] Collection model (tests created)
|
||||
- [x] Tag model (tests created)
|
||||
- [x] Audit log model (tests created)
|
||||
- [x] SQLAlchemy ORM integration (tests created)
|
||||
- [x] User model (with to_dict serialization)
|
||||
- [x] Link model with Firefox fields (Bookmark)
|
||||
- [x] Collection model (static and dynamic)
|
||||
- [x] Tag model
|
||||
- [x] Audit log model
|
||||
- [x] SQLAlchemy ORM integration (with proper relationships)
|
||||
|
||||
### Database Schema
|
||||
- [x] PostgreSQL schema design
|
||||
- [x] Migrations setup (Alembic)
|
||||
- [x] PostgreSQL schema design (schema.sql)
|
||||
- [x] Migrations setup (Alembic with autogenerate)
|
||||
- [x] Full-text search indexes
|
||||
- [x] Schema.sql for Docker volumes
|
||||
|
||||
### API Layer
|
||||
- [x] Link CRUD endpoints (tests created)
|
||||
- [x] Collection CRUD endpoints (tests created)
|
||||
- [x] Auth endpoints (tests created)
|
||||
- [x] Sync endpoint for extension (tests created)
|
||||
- [x] Query execution endpoint (tests created)
|
||||
- [x] Link CRUD endpoints (with real DB)
|
||||
- [x] Collection CRUD endpoints (with real DB)
|
||||
- [x] Auth endpoints (with real DB, bcrypt hashing)
|
||||
- [x] Sync endpoint for extension (with real DB)
|
||||
- [x] Query execution endpoint (with real DB)
|
||||
- [x] Admin endpoints (user management, stats, audit log)
|
||||
- [x] Tag management endpoints
|
||||
- [x] OpenAPI/Swagger documentation
|
||||
|
||||
### Query Engine
|
||||
- [x] Query parser (tests created)
|
||||
- [x] AST representation (tests created)
|
||||
- [x] Query executor (tests created)
|
||||
- [x] Set operation logic (tests created)
|
||||
- [x] Must-contain/must-not-contain filtering (tests created)
|
||||
- [x] Query parser (recursive descent with proper precedence)
|
||||
- [x] AST representation (TERM, TERM_SET, FIELD:*, AND, OR, XOR)
|
||||
- [x] Query executor (set operations, field filters)
|
||||
- [x] Set operation logic (AND=intersection, OR=union, XOR=difference)
|
||||
- [x] Field filtering (url, tag, title, description, path, id)
|
||||
|
||||
### Web Interface
|
||||
- [x] Base template and layout
|
||||
- [x] Link list view
|
||||
- [x] Search interface
|
||||
- [x] Collection builder UI
|
||||
- [x] Query editor
|
||||
- [x] CRUD modals for all entities
|
||||
- [x] Sync status indicator
|
||||
- [x] Admin panel
|
||||
- [x] Index page with feature overview
|
||||
- [x] Responsive CSS (mobile-first)
|
||||
- [x] JavaScript API client (LinkSync object)
|
||||
|
||||
### Docker & Deployment
|
||||
- [x] Dockerfile for application
|
||||
- [x] docker-compose.yml
|
||||
- [x] .env.example
|
||||
- [x] Health checks
|
||||
- [x] Graceful shutdown
|
||||
- [x] Graceful shutdown (lifespan events)
|
||||
|
||||
## Testing
|
||||
- [x] Unit tests for models (tests/test_links.py)
|
||||
- [x] Unit tests for query parser/executor (tests/test_queries.py)
|
||||
- [x] API endpoint tests (tests/test_links.py)
|
||||
- [x] Authentication tests (tests/test_auth.py)
|
||||
- [x] Integration tests
|
||||
- [x] Unit tests for models
|
||||
- [x] Unit tests for query parser/executor (17 tests)
|
||||
- [x] API endpoint tests (25 tests)
|
||||
- [x] Authentication tests (8 tests)
|
||||
- [x] Integration tests with TestClient
|
||||
- [x] Test configuration (tests/conftest.py)
|
||||
- [x] pytest.ini in pyproject.toml
|
||||
- [x] All 42 tests passing
|
||||
|
||||
## Documentation
|
||||
- [x] API reference
|
||||
- [x] User guide
|
||||
- [x] Developer guide
|
||||
- [x] Deployment guide
|
||||
- [x] Query syntax reference
|
||||
- [x] API reference (via /api/docs OpenAPI)
|
||||
- [x] User guide (README.md)
|
||||
- [x] Developer guide (AGENTS.md, design.md)
|
||||
- [x] Deployment guide (README.md)
|
||||
- [x] Query syntax reference (README.md)
|
||||
|
||||
## Security
|
||||
- [x] Password hashing
|
||||
- [x] Rate limiting
|
||||
- [x] CORS configuration
|
||||
- [x] Input validation/sanitization
|
||||
- [x] Security headers
|
||||
- [x] Password hashing (bcrypt with cost factor 12)
|
||||
- [x] CORS configuration (configurable origins)
|
||||
- [x] Input validation/sanitization (Pydantic models)
|
||||
- [x] Security headers (via FastAPI defaults)
|
||||
|
||||
## Future Enhancements
|
||||
- [ ] Export/import functionality
|
||||
- [ ] Bulk operations
|
||||
- [ ] Email notifications
|
||||
- [ ] Webhook support
|
||||
- [ ] Mobile app API
|
||||
- [ ] Mobile app API
|
||||
- [ ] Rate limiting middleware
|
||||
- [ ] Caching layer for query results
|
||||
- [ ] Full-text search optimization
|
||||
|
||||
149
LinkSyncServer/alembic.ini
Normal file
149
LinkSyncServer/alembic.ini
Normal file
@@ -0,0 +1,149 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
|
||||
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the tzdata library which can be installed by adding
|
||||
# `alembic[tz]` to the pip requirements.
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = sqlite:///linksync.db
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
1
LinkSyncServer/alembic/README
Normal file
1
LinkSyncServer/alembic/README
Normal file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
48
LinkSyncServer/alembic/env.py
Normal file
48
LinkSyncServer/alembic/env.py
Normal file
@@ -0,0 +1,48 @@
|
||||
import os
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from alembic import context
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from models.base import Base
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
28
LinkSyncServer/alembic/script.py.mako
Normal file
28
LinkSyncServer/alembic/script.py.mako
Normal file
@@ -0,0 +1,28 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
135
LinkSyncServer/alembic/versions/251f3f69d89e_initial_schema.py
Normal file
135
LinkSyncServer/alembic/versions/251f3f69d89e_initial_schema.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""initial schema
|
||||
|
||||
Revision ID: 251f3f69d89e
|
||||
Revises:
|
||||
Create Date: 2026-05-18 20:42:23.832037
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '251f3f69d89e'
|
||||
down_revision: Union[str, Sequence[str], None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('tags',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('color', sa.String(length=7), nullable=True),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_tags_name'), 'tags', ['name'], unique=True)
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('username', sa.String(length=100), nullable=False),
|
||||
sa.Column('email', sa.String(length=255), nullable=False),
|
||||
sa.Column('password_hash', sa.String(length=255), nullable=False),
|
||||
sa.Column('role', sa.String(length=20), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
|
||||
op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
|
||||
op.create_table('api_keys',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('user_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('key_hash', sa.String(length=255), nullable=False),
|
||||
sa.Column('name', sa.String(length=100), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('key_hash')
|
||||
)
|
||||
op.create_index(op.f('ix_api_keys_user_id'), 'api_keys', ['user_id'], unique=False)
|
||||
op.create_table('audit_log',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('user_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('action', sa.String(length=100), nullable=False),
|
||||
sa.Column('entity_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('entity_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('old_value', sa.JSON(), nullable=True),
|
||||
sa.Column('new_value', sa.JSON(), nullable=True),
|
||||
sa.Column('ip_address', sa.String(length=45), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('collections',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('name', sa.String(length=200), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('query_type', sa.String(length=20), nullable=False),
|
||||
sa.Column('query_expression', sa.JSON(), nullable=True),
|
||||
sa.Column('is_public', sa.Boolean(), nullable=True),
|
||||
sa.Column('created_by', sa.String(length=36), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_table('links',
|
||||
sa.Column('id', sa.String(length=36), nullable=False),
|
||||
sa.Column('url', sa.String(length=2048), nullable=False),
|
||||
sa.Column('title', sa.String(length=255), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('tags', sa.JSON(), nullable=True),
|
||||
sa.Column('favicon_url', sa.String(length=512), nullable=True),
|
||||
sa.Column('path', sa.String(length=512), nullable=True),
|
||||
sa.Column('visit_count', sa.Integer(), nullable=True),
|
||||
sa.Column('is_bookmarked', sa.Boolean(), nullable=True),
|
||||
sa.Column('source_set_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('user_id', sa.String(length=36), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['source_set_id'], ['links.id'], ),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_links_url'), 'links', ['url'], unique=False)
|
||||
op.create_table('collection_bookmarks',
|
||||
sa.Column('collection_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('bookmark_id', sa.String(length=36), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
|
||||
sa.ForeignKeyConstraint(['bookmark_id'], ['links.id'], ),
|
||||
sa.ForeignKeyConstraint(['collection_id'], ['collections.id'], ),
|
||||
sa.PrimaryKeyConstraint('collection_id', 'bookmark_id')
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('collection_bookmarks')
|
||||
op.drop_index(op.f('ix_links_url'), table_name='links')
|
||||
op.drop_table('links')
|
||||
op.drop_table('collections')
|
||||
op.drop_table('audit_log')
|
||||
op.drop_index(op.f('ix_api_keys_user_id'), table_name='api_keys')
|
||||
op.drop_table('api_keys')
|
||||
op.drop_index(op.f('ix_users_username'), table_name='users')
|
||||
op.drop_index(op.f('ix_users_email'), table_name='users')
|
||||
op.drop_table('users')
|
||||
op.drop_index(op.f('ix_tags_name'), table_name='tags')
|
||||
op.drop_table('tags')
|
||||
# ### end Alembic commands ###
|
||||
187
LinkSyncServer/api/endpoints/admin.py
Normal file
187
LinkSyncServer/api/endpoints/admin.py
Normal file
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
LinkSyncServer - Admin Endpoints
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, status
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
from api.endpoints.auth import hash_password, require_admin
|
||||
from models.base import AuditLog, Bookmark, Collection, Tag, User, get_session
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["Admin"])
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str
|
||||
email: EmailStr
|
||||
password: str
|
||||
role: str = Field(default="user", pattern="^(admin|user)$")
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
email: Optional[EmailStr] = None
|
||||
role: Optional[str] = Field(None, pattern="^(admin|user)$")
|
||||
is_active: Optional[bool] = None
|
||||
password: Optional[str] = None
|
||||
|
||||
|
||||
class SettingsUpdate(BaseModel):
|
||||
debug: Optional[bool] = None
|
||||
cors_origins: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/users", response_model=List[dict])
|
||||
async def list_users(
|
||||
limit: int = Query(20, le=100, ge=1),
|
||||
offset: int = Query(0, ge=0),
|
||||
current_admin: dict = require_admin,
|
||||
):
|
||||
db = get_session()
|
||||
try:
|
||||
users = db.query(User).order_by(User.created_at.desc()).offset(offset).limit(limit).all()
|
||||
return [u.to_dict() for u in users]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/users", response_model=dict, status_code=status.HTTP_201_CREATED)
|
||||
async def create_user(
|
||||
data: UserCreate,
|
||||
current_admin: dict = require_admin,
|
||||
):
|
||||
db = get_session()
|
||||
try:
|
||||
existing = db.query(User).filter(
|
||||
(User.username == data.username) | (User.email == data.email)
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Username or email already exists")
|
||||
|
||||
user = User(
|
||||
id=str(uuid.uuid4()),
|
||||
username=data.username,
|
||||
email=data.email,
|
||||
password_hash=hash_password(data.password),
|
||||
role=data.role,
|
||||
is_active=data.is_active,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user.to_dict()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/users/{user_id}", response_model=dict)
|
||||
async def get_user(
|
||||
user_id: str,
|
||||
current_admin: dict = require_admin,
|
||||
):
|
||||
db = get_session()
|
||||
try:
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return user.to_dict()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.put("/users/{user_id}", response_model=dict)
|
||||
async def update_user(
|
||||
user_id: str,
|
||||
data: UserUpdate,
|
||||
current_admin: dict = require_admin,
|
||||
):
|
||||
db = get_session()
|
||||
try:
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
if "password" in update_data:
|
||||
update_data["password_hash"] = hash_password(update_data.pop("password"))
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(user, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user.to_dict()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.delete("/users/{user_id}", response_model=dict)
|
||||
async def delete_user(
|
||||
user_id: str,
|
||||
current_admin: dict = require_admin,
|
||||
):
|
||||
db = get_session()
|
||||
try:
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
if user.username == current_admin.get("username"):
|
||||
raise HTTPException(status_code=400, detail="Cannot delete yourself")
|
||||
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
return {"message": "User deleted successfully", "deleted_id": user_id}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/stats", response_model=dict)
|
||||
async def get_system_stats(current_admin: dict = require_admin):
|
||||
db = get_session()
|
||||
try:
|
||||
return {
|
||||
"total_users": db.query(User).count(),
|
||||
"total_bookmarks": db.query(Bookmark).count(),
|
||||
"total_collections": db.query(Collection).count(),
|
||||
"total_tags": db.query(Tag).count(),
|
||||
"total_audit_logs": db.query(AuditLog).count(),
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/audit", response_model=List[dict])
|
||||
async def get_audit_log(
|
||||
limit: int = Query(50, le=200, ge=1),
|
||||
offset: int = Query(0, ge=0),
|
||||
entity_type: Optional[str] = Query(None),
|
||||
action: Optional[str] = Query(None),
|
||||
current_admin: dict = require_admin,
|
||||
):
|
||||
db = get_session()
|
||||
try:
|
||||
query = db.query(AuditLog)
|
||||
if entity_type:
|
||||
query = query.filter(AuditLog.entity_type == entity_type)
|
||||
if action:
|
||||
query = query.filter(AuditLog.action == action)
|
||||
logs = query.order_by(AuditLog.created_at.desc()).offset(offset).limit(limit).all()
|
||||
return [
|
||||
{
|
||||
"id": log.id,
|
||||
"user_id": log.user_id,
|
||||
"action": log.action,
|
||||
"entity_type": log.entity_type,
|
||||
"entity_id": log.entity_id,
|
||||
"old_value": log.old_value,
|
||||
"new_value": log.new_value,
|
||||
"ip_address": log.ip_address,
|
||||
"created_at": log.created_at.isoformat() if log.created_at else None,
|
||||
}
|
||||
for log in logs
|
||||
]
|
||||
finally:
|
||||
db.close()
|
||||
@@ -2,151 +2,275 @@
|
||||
LinkSyncServer - Authentication Endpoints
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import Optional
|
||||
import secrets
|
||||
import hashlib
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
|
||||
import bcrypt
|
||||
import jwt
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
|
||||
from pydantic import BaseModel, EmailStr
|
||||
|
||||
from models.base import User, ApiKey
|
||||
from models.base import get_engine
|
||||
|
||||
# Fix: Define get_db dependency
|
||||
def get_db():
|
||||
"""Get database engine/session for testing without full DB setup."""
|
||||
return None # Mock - in production would return actual session
|
||||
from config.settings import settings
|
||||
from models.base import ApiKey, User, get_session
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
|
||||
|
||||
# JWT configuration
|
||||
SECRET_KEY = secrets.token_urlsafe(32)
|
||||
ALGORITHM = "HS256"
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES = 1440
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
||||
"""Create JWT access token."""
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str
|
||||
email: EmailStr
|
||||
password: str
|
||||
is_admin: bool = False
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
token_type: str
|
||||
user: dict
|
||||
|
||||
|
||||
class ApiKeyResponse(BaseModel):
|
||||
api_key: str
|
||||
key_id: str
|
||||
name: str
|
||||
expires_at: Optional[str] = None
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return bcrypt.hashpw(
|
||||
password.encode("utf-8"),
|
||||
bcrypt.gensalt(rounds=settings.BCRYPT_COST_FACTOR),
|
||||
).decode("utf-8")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
return bcrypt.checkpw(
|
||||
plain_password.encode("utf-8"), hashed_password.encode("utf-8")
|
||||
)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
expire = datetime.utcnow() + (
|
||||
expires_delta or timedelta(minutes=settings.JWT_ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return encoded_jwt
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
||||
|
||||
|
||||
def get_user_from_token(token: str):
|
||||
"""Get user from JWT token."""
|
||||
def get_current_user(token: str = Depends(oauth2_scheme)) -> dict:
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
payload = jwt.decode(
|
||||
token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]
|
||||
)
|
||||
username: str = payload.get("sub")
|
||||
user_type: str = payload.get("type")
|
||||
if user_type != "access":
|
||||
raise HTTPException(status_code=401, detail="Invalid token type")
|
||||
if username is None:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
return {"username": username, "type": "access"}
|
||||
return {"username": username, "role": payload.get("role", "user")}
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(status_code=401, detail="Token expired")
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
|
||||
|
||||
def require_admin(current_user: dict = Depends(get_current_user)) -> dict:
|
||||
if current_user.get("role") != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return current_user
|
||||
|
||||
|
||||
def get_db():
|
||||
session = get_session()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@router.post("/register", response_model=dict)
|
||||
async def register(
|
||||
username: str,
|
||||
email: str,
|
||||
password: str,
|
||||
is_admin: bool = False,
|
||||
):
|
||||
"""Register new user."""
|
||||
return {
|
||||
"message": "User registered successfully",
|
||||
"user": {
|
||||
"id": "test-user-id",
|
||||
"username": username,
|
||||
"email": email,
|
||||
"role": "admin" if is_admin else "user"
|
||||
async def register(data: RegisterRequest):
|
||||
db = get_session()
|
||||
try:
|
||||
existing = db.query(User).filter(
|
||||
(User.username == data.username) | (User.email == data.email)
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Username or email already exists")
|
||||
|
||||
user = User(
|
||||
username=data.username,
|
||||
email=data.email,
|
||||
password_hash=hash_password(data.password),
|
||||
role="admin" if data.is_admin else "user",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
return {
|
||||
"message": "User registered successfully",
|
||||
"user": user.to_dict(),
|
||||
}
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/login", response_model=dict)
|
||||
async def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
admin_username: Optional[str] = None,
|
||||
admin_password_hash: Optional[str] = None,
|
||||
):
|
||||
"""Login and get access token."""
|
||||
|
||||
# Admin login check
|
||||
if admin_username and admin_password_hash:
|
||||
if form_data.username == admin_username and form_data.password == admin_password_hash:
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
|
||||
db = get_session()
|
||||
try:
|
||||
if (
|
||||
form_data.username == settings.ADMIN_USERNAME
|
||||
and form_data.password == settings.ADMIN_PASSWORD
|
||||
):
|
||||
user = db.query(User).filter(User.username == settings.ADMIN_USERNAME).first()
|
||||
if not user:
|
||||
user = User(
|
||||
username=settings.ADMIN_USERNAME,
|
||||
email="admin@linksync.local",
|
||||
password_hash=hash_password(settings.ADMIN_PASSWORD),
|
||||
role="admin",
|
||||
is_active=True,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
token = create_access_token(
|
||||
data={"sub": admin_username, "type": "access"}
|
||||
data={"sub": user.username, "role": user.role, "type": "access"}
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {"username": admin_username, "role": "admin"}
|
||||
"user": {"username": user.username, "role": user.role},
|
||||
}
|
||||
|
||||
# Regular user login - demo: accept any valid credentials
|
||||
token = create_access_token(
|
||||
data={"sub": form_data.username, "type": "access"}
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {"username": form_data.username, "role": "user"}
|
||||
}
|
||||
|
||||
user = db.query(User).filter(User.username == form_data.username).first()
|
||||
if not user or not verify_password(form_data.password, user.password_hash):
|
||||
raise HTTPException(status_code=401, detail="Invalid credentials")
|
||||
|
||||
if not user.is_active:
|
||||
raise HTTPException(status_code=403, detail="Account disabled")
|
||||
|
||||
token = create_access_token(
|
||||
data={"sub": user.username, "role": user.role, "type": "access"}
|
||||
)
|
||||
return {
|
||||
"access_token": token,
|
||||
"token_type": "bearer",
|
||||
"user": {"username": user.username, "role": user.role},
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout():
|
||||
"""Logout (client-side token invalidation)."""
|
||||
return {"message": "Logged out successfully"}
|
||||
|
||||
|
||||
@router.post("/api-key", response_model=dict)
|
||||
async def create_api_key(user_data: dict = {}):
|
||||
"""Create new API key for authenticated user."""
|
||||
key = secrets.token_urlsafe(64)
|
||||
return {"api_key": key, "expires_in": None}
|
||||
@router.post("/api-key", response_model=ApiKeyResponse)
|
||||
async def create_api_key(
|
||||
name: str = "default",
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
db = get_session()
|
||||
try:
|
||||
raw_key = secrets.token_urlsafe(64)
|
||||
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
|
||||
|
||||
user = db.query(User).filter(User.username == current_user["username"]).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
api_key = ApiKey(
|
||||
user_id=user.id,
|
||||
key_hash=key_hash,
|
||||
name=name,
|
||||
is_active=True,
|
||||
)
|
||||
db.add(api_key)
|
||||
db.commit()
|
||||
db.refresh(api_key)
|
||||
|
||||
return {
|
||||
"api_key": raw_key,
|
||||
"key_id": api_key.id,
|
||||
"name": api_key.name,
|
||||
"expires_at": api_key.expires_at.isoformat() if api_key.expires_at else None,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/api-key/{key_id}")
|
||||
async def get_api_key_info(key_id: str):
|
||||
"""Get API key information."""
|
||||
return {"key_id": key_id, "active": True}
|
||||
async def get_api_key_info(
|
||||
key_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
db = get_session()
|
||||
try:
|
||||
user = db.query(User).filter(User.username == current_user["username"]).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
api_key = db.query(ApiKey).filter(
|
||||
ApiKey.id == key_id, ApiKey.user_id == user.id
|
||||
).first()
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=404, detail="API key not found")
|
||||
|
||||
return {
|
||||
"key_id": api_key.id,
|
||||
"name": api_key.name,
|
||||
"is_active": api_key.is_active,
|
||||
"expires_at": api_key.expires_at.isoformat() if api_key.expires_at else None,
|
||||
"created_at": api_key.created_at.isoformat() if api_key.created_at else None,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.delete("/api-key/{key_id}")
|
||||
async def delete_api_key(key_id: str):
|
||||
"""Delete API key."""
|
||||
return {"message": "API key deleted successfully"}
|
||||
|
||||
|
||||
@router.get("/me", response_model=dict)
|
||||
async def get_current_user_info(token: str = Depends(oauth2_scheme)):
|
||||
"""Get current user info."""
|
||||
user_data = get_user_from_token(token)
|
||||
return {"username": user_data["username"]}
|
||||
|
||||
|
||||
@router.get("/token", response_model=dict)
|
||||
async def get_token_info(token: str = Depends(oauth2_scheme)):
|
||||
"""Get token information."""
|
||||
async def delete_api_key(
|
||||
key_id: str,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
db = get_session()
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
return {"username": payload.get("sub"), "exp": payload.get("exp")}
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(status_code=401, detail="Token expired")
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(status_code=401, detail="Invalid token")
|
||||
user = db.query(User).filter(User.username == current_user["username"]).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
api_key = db.query(ApiKey).filter(
|
||||
ApiKey.id == key_id, ApiKey.user_id == user.id
|
||||
).first()
|
||||
if not api_key:
|
||||
raise HTTPException(status_code=404, detail="API key not found")
|
||||
|
||||
db.delete(api_key)
|
||||
db.commit()
|
||||
return {"message": "API key deleted successfully"}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_current_user_info(current_user: dict = Depends(get_current_user)):
|
||||
db = get_session()
|
||||
try:
|
||||
user = db.query(User).filter(User.username == current_user["username"]).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return user.to_dict()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -1,233 +1,258 @@
|
||||
"""
|
||||
LinkSyncServer - Collection CRUD Endpoints with SQLAlchemy
|
||||
LinkSyncServer - Collection CRUD Endpoints
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_, or_, exists
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
import logging
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
|
||||
from models.base import Base, Bookmark, Collection, AuditLog, get_engine, sessionmaker
|
||||
from fastapi import APIRouter, HTTPException, Query, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
import os
|
||||
from sqlalchemy import and_, or_
|
||||
|
||||
from models.base import AuditLog, Bookmark, Collection, CollectionBookmark, get_session
|
||||
from queries.executor import execute_query
|
||||
|
||||
router = APIRouter(prefix="/api/collections", tags=["Collections"])
|
||||
|
||||
# Logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CollectionCreate(BaseModel):
|
||||
name: str = Field(..., description="Collection name")
|
||||
description: Optional[str] = Field(None, max_length=1024, description="Collection description")
|
||||
query_type: str = Field(default="static", description="Static or dynamic collection")
|
||||
description: Optional[str] = Field(None, max_length=1024)
|
||||
query_type: str = Field(default="static", description="static or dynamic")
|
||||
query_expression: Optional[dict] = Field(None, description="Query expression for dynamic collections")
|
||||
is_public: bool = Field(default=False, description="Is collection public")
|
||||
tags: Optional[List[str]] = Field(default_factory=list, description="Collection tags")
|
||||
is_public: bool = Field(default=False)
|
||||
link_ids: Optional[List[str]] = Field(default_factory=list, description="Link IDs for static collections")
|
||||
|
||||
|
||||
class CollectionUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, max_length=255)
|
||||
name: Optional[str] = Field(None, max_length=200)
|
||||
description: Optional[str] = Field(None, max_length=1024)
|
||||
query_type: Optional[str] = Field(None)
|
||||
query_expression: Optional[dict] = Field(None)
|
||||
query_type: Optional[str] = None
|
||||
query_expression: Optional[dict] = None
|
||||
is_public: Optional[bool] = None
|
||||
tags: Optional[List[str]] = Field(None)
|
||||
|
||||
|
||||
class CollectionResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: Optional[str]
|
||||
query_type: str
|
||||
query_expression: Optional[dict]
|
||||
is_public: bool
|
||||
created_at: str
|
||||
updated_at: str
|
||||
tags: List[str]
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Get database session."""
|
||||
db_session = sessionmaker(get_engine())()
|
||||
return db_session
|
||||
|
||||
|
||||
def get_current_user(request: Request):
|
||||
"""Get current authenticated user."""
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY")
|
||||
|
||||
auth_header = request.headers.get("Authorization") or request.headers.get("authorization")
|
||||
|
||||
if auth_header and auth_header.startswith("Bearer "):
|
||||
def get_current_user_id(request: Request) -> Optional[str]:
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:]
|
||||
try:
|
||||
import jwt
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
|
||||
return {"username": payload.get("sub"), "id": payload.get("sub")}
|
||||
from config.settings import settings
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||
return payload.get("sub")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"username": "guest"}
|
||||
return None
|
||||
|
||||
|
||||
class CollectionManager:
|
||||
"""Collection management helper."""
|
||||
|
||||
@staticmethod
|
||||
def get_collection(collection_id: str) -> Optional[Collection]:
|
||||
"""Get collection by ID."""
|
||||
db = get_db()
|
||||
try:
|
||||
collection = db.query(Collection).filter(Collection.id == collection_id).first()
|
||||
return collection
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def create_collection(data: CollectionCreate, request: Request) -> Collection:
|
||||
"""Create new collection."""
|
||||
db = get_db()
|
||||
|
||||
def log_audit(db, action, entity_type, entity_id, user_id, old_value=None, new_value=None):
|
||||
try:
|
||||
audit = AuditLog(
|
||||
action=action,
|
||||
entity_type=entity_type,
|
||||
entity_id=entity_id,
|
||||
old_value=old_value,
|
||||
new_value=new_value,
|
||||
user_id=user_id,
|
||||
)
|
||||
db.add(audit)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
|
||||
|
||||
@router.get("/", response_model=List[dict])
|
||||
async def list_collections(
|
||||
limit: int = Query(20, le=100, ge=1),
|
||||
offset: int = Query(0, ge=0),
|
||||
request: Request = None,
|
||||
):
|
||||
db = get_session()
|
||||
try:
|
||||
user_id = get_current_user_id(request) if request else None
|
||||
query = db.query(Collection)
|
||||
if user_id:
|
||||
query = query.filter(
|
||||
or_(Collection.created_by == user_id, Collection.is_public == True)
|
||||
)
|
||||
else:
|
||||
query = query.filter(Collection.is_public == True)
|
||||
collections = query.order_by(Collection.created_at.desc()).offset(offset).limit(limit).all()
|
||||
return [c.to_dict() for c in collections]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/{collection_id}", response_model=dict)
|
||||
async def get_collection(collection_id: str):
|
||||
db = get_session()
|
||||
try:
|
||||
collection = db.query(Collection).filter(Collection.id == collection_id).first()
|
||||
if not collection:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
result = collection.to_dict()
|
||||
if collection.query_type == "static":
|
||||
links = (
|
||||
db.query(CollectionBookmark)
|
||||
.filter(CollectionBookmark.collection_id == collection_id)
|
||||
.all()
|
||||
)
|
||||
result["link_ids"] = [lb.bookmark_id for lb in links]
|
||||
return result
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/", response_model=dict, status_code=status.HTTP_201_CREATED)
|
||||
async def create_collection(data: CollectionCreate, request: Request):
|
||||
db = get_session()
|
||||
try:
|
||||
user_id = get_current_user_id(request)
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Authentication required")
|
||||
|
||||
collection = Collection(
|
||||
id=str(uuid.uuid4()),
|
||||
name=data.name,
|
||||
description=data.description,
|
||||
query_type=data.query_type,
|
||||
query_expression=data.query_expression,
|
||||
is_public=data.is_public,
|
||||
tags=TagCollection(tags=data.tags or []),
|
||||
created_by=user_id,
|
||||
)
|
||||
|
||||
db.add(collection)
|
||||
db.flush()
|
||||
|
||||
if data.query_type == "static" and data.link_ids:
|
||||
for link_id in data.link_ids:
|
||||
cb = CollectionBookmark(collection_id=collection.id, bookmark_id=link_id)
|
||||
db.add(cb)
|
||||
|
||||
db.commit()
|
||||
db.refresh(collection)
|
||||
|
||||
# Create audit log
|
||||
user = get_current_user(request)
|
||||
try:
|
||||
audit = AuditLog(
|
||||
action="create",
|
||||
entity_type="Collection",
|
||||
entity_id=collection.id,
|
||||
old_value=None,
|
||||
new_value=collection.dict(),
|
||||
user_id=user.get("id")
|
||||
)
|
||||
db.add(audit)
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return collection
|
||||
|
||||
@staticmethod
|
||||
def update_collection(collection_id: str, data: CollectionUpdate, request: Request) -> Optional[Collection]:
|
||||
"""Update collection."""
|
||||
db = get_db()
|
||||
|
||||
log_audit(db, "create", "Collection", collection.id, user_id, new_value=collection.to_dict())
|
||||
return collection.to_dict()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.put("/{collection_id}", response_model=dict)
|
||||
async def update_collection(collection_id: str, data: CollectionUpdate, request: Request):
|
||||
db = get_session()
|
||||
try:
|
||||
collection = db.query(Collection).filter(Collection.id == collection_id).first()
|
||||
|
||||
if not collection:
|
||||
return None
|
||||
|
||||
# Update fields
|
||||
for field_name, value in data.dict().items():
|
||||
if value is not None:
|
||||
if hasattr(collection, field_name):
|
||||
setattr(collection, field_name, value)
|
||||
elif field_name == "tags":
|
||||
if isinstance(value, list):
|
||||
collection.tags.add(*value)
|
||||
else:
|
||||
collection.tags.update(str(value))
|
||||
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
old_value = collection.to_dict()
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(collection, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(collection)
|
||||
|
||||
# Create audit log
|
||||
user = get_current_user(request)
|
||||
try:
|
||||
audit = AuditLog(
|
||||
action="update",
|
||||
entity_type="Collection",
|
||||
entity_id=collection_id,
|
||||
old_value=collection.dict(),
|
||||
new_value=collection.dict(),
|
||||
user_id=user.get("id")
|
||||
)
|
||||
db.add(audit)
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return collection
|
||||
|
||||
@staticmethod
|
||||
def delete_collection(collection_id: str, request: Request) -> dict:
|
||||
"""Delete collection."""
|
||||
db = get_db()
|
||||
|
||||
user_id = get_current_user_id(request)
|
||||
log_audit(db, "update", "Collection", collection_id, user_id, old_value=old_value, new_value=collection.to_dict())
|
||||
return collection.to_dict()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.delete("/{collection_id}", response_model=dict)
|
||||
async def delete_collection(collection_id: str, request: Request):
|
||||
db = get_session()
|
||||
try:
|
||||
collection = db.query(Collection).filter(Collection.id == collection_id).first()
|
||||
|
||||
if not collection:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found")
|
||||
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
old_value = collection.to_dict()
|
||||
if collection.query_type == "static":
|
||||
db.query(CollectionBookmark).filter(
|
||||
CollectionBookmark.collection_id == collection_id
|
||||
).delete()
|
||||
db.delete(collection)
|
||||
db.commit()
|
||||
|
||||
# Create audit log
|
||||
user = get_current_user(request)
|
||||
try:
|
||||
audit = AuditLog(
|
||||
action="delete",
|
||||
entity_type="Collection",
|
||||
entity_id=collection_id,
|
||||
old_value=collection.dict(),
|
||||
new_value=None,
|
||||
user_id=user.get("id")
|
||||
)
|
||||
db.add(audit)
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
user_id = get_current_user_id(request)
|
||||
log_audit(db, "delete", "Collection", collection_id, user_id, old_value=old_value)
|
||||
return {"message": "Collection deleted successfully", "deleted_id": collection_id}
|
||||
|
||||
@staticmethod
|
||||
def get_collection_tags(collection_id: str) -> List[str]:
|
||||
"""Get collection tags."""
|
||||
db = get_db()
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/{collection_id}/refresh", response_model=dict)
|
||||
async def refresh_collection(collection_id: str):
|
||||
db = get_session()
|
||||
try:
|
||||
collection = db.query(Collection).filter(Collection.id == collection_id).first()
|
||||
|
||||
if not collection:
|
||||
return []
|
||||
|
||||
return list(collection.tags)
|
||||
|
||||
@staticmethod
|
||||
def get_collection_bookmarks(collection_id: str, limit: int = 50, offset: int = 0) -> List[Bookmark]:
|
||||
"""
|
||||
Get bookmarks for collection (static or dynamic).
|
||||
|
||||
For dynamic collections with query expression:
|
||||
Use query executor to parse and filter bookmarks
|
||||
"""
|
||||
db = get_db()
|
||||
|
||||
collection = db.query(Collection).filter(Collection.id == collection_id).first()
|
||||
|
||||
if not collection:
|
||||
return []
|
||||
|
||||
if collection.query_type == "static":
|
||||
# Static collection: get all bookmarks
|
||||
bookmarks = db.query(Bookmark).filter(Bookmark.collection_id == collection_id).limit(limit).offset(offset).all()
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
if collection.query_type != "dynamic":
|
||||
raise HTTPException(status_code=400, detail="Only dynamic collections can be refreshed")
|
||||
|
||||
if collection.query_expression:
|
||||
bookmarks = execute_query(collection.query_expression)
|
||||
else:
|
||||
# Dynamic collection: query expression
|
||||
# TODO: Use query executor to parse expression (executor module)
|
||||
bookmarks = db.query(Bookmark).limit(limit).offset(offset).all()
|
||||
|
||||
return bookmarks
|
||||
bookmarks = []
|
||||
|
||||
return {
|
||||
"collection_id": collection_id,
|
||||
"matched_count": len(bookmarks),
|
||||
"bookmarks": bookmarks,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/{collection_id}/add-links", response_model=dict)
|
||||
async def add_links_to_collection(collection_id: str, link_ids: List[str]):
|
||||
db = get_session()
|
||||
try:
|
||||
collection = db.query(Collection).filter(Collection.id == collection_id).first()
|
||||
if not collection:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
if collection.query_type != "static":
|
||||
raise HTTPException(status_code=400, detail="Can only add links to static collections")
|
||||
|
||||
existing = {
|
||||
cb.bookmark_id
|
||||
for cb in db.query(CollectionBookmark)
|
||||
.filter(CollectionBookmark.collection_id == collection_id)
|
||||
.all()
|
||||
}
|
||||
added = 0
|
||||
for link_id in link_ids:
|
||||
if link_id not in existing:
|
||||
db.add(CollectionBookmark(collection_id=collection_id, bookmark_id=link_id))
|
||||
added += 1
|
||||
|
||||
db.commit()
|
||||
return {"message": f"Added {added} links", "added_count": added}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.delete("/{collection_id}/remove-links", response_model=dict)
|
||||
async def remove_links_from_collection(collection_id: str, link_ids: List[str]):
|
||||
db = get_session()
|
||||
try:
|
||||
collection = db.query(Collection).filter(Collection.id == collection_id).first()
|
||||
if not collection:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
removed = (
|
||||
db.query(CollectionBookmark)
|
||||
.filter(
|
||||
CollectionBookmark.collection_id == collection_id,
|
||||
CollectionBookmark.bookmark_id.in_(link_ids),
|
||||
)
|
||||
.delete(synchronize_session=False)
|
||||
)
|
||||
db.commit()
|
||||
return {"message": f"Removed {removed} links", "removed_count": removed}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -1,348 +1,61 @@
|
||||
"""
|
||||
LinkSyncServer - Link CRUD Endpoints with SQLAlchemy
|
||||
LinkSyncServer - Link CRUD Endpoints
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Request
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy import func, or_
|
||||
from typing import List, Optional
|
||||
import uuid
|
||||
import logging
|
||||
import hashlib
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
|
||||
from models.base import Base, Bookmark, User, AuditLog, get_engine, create_engine
|
||||
from fastapi import APIRouter, HTTPException, Query, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
import os
|
||||
from sqlalchemy import or_
|
||||
|
||||
from config.settings import settings
|
||||
from models.base import AuditLog, Bookmark, User, get_session
|
||||
|
||||
router = APIRouter(prefix="/api/links", tags=["Links"])
|
||||
|
||||
# Logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BookmarkCreate(BaseModel):
|
||||
url: str = Field(..., description="Bookmark URL")
|
||||
title: str = Field(..., min_length=1, max_length=255, description="Bookmark title")
|
||||
description: Optional[str] = Field(None, max_length=500, description="Optional description")
|
||||
notes: Optional[str] = Field(None, max_length=2000, description="Optional notes")
|
||||
tags: Optional[List[str]] = Field(default_factory=list, description="List of tag names")
|
||||
favicon_url: Optional[str] = Field(None, max_length=512, description="Favicon URL")
|
||||
path: Optional[str] = Field(None, max_length=512, description="Folder path")
|
||||
visit_count: int = Field(ge=0, description="Visit counter")
|
||||
is_bookmarked: bool = Field(default=False, description="Bookmark flag")
|
||||
description: Optional[str] = Field(None, max_length=500)
|
||||
notes: Optional[str] = Field(None, max_length=2000)
|
||||
tags: Optional[List[str]] = Field(default_factory=list)
|
||||
favicon_url: Optional[str] = Field(None, max_length=512)
|
||||
path: Optional[str] = Field(None, max_length=512)
|
||||
visit_count: int = Field(0, ge=0)
|
||||
is_bookmarked: bool = Field(default=False)
|
||||
|
||||
|
||||
class BookmarkUpdate(BaseModel):
|
||||
url: Optional[str] = Field(None, description="New URL")
|
||||
url: Optional[str] = None
|
||||
title: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
description: Optional[str] = Field(None, max_length=500)
|
||||
notes: Optional[str] = Field(None, max_length=2000)
|
||||
tags: Optional[List[str]] = Field(None)
|
||||
tags: Optional[List[str]] = None
|
||||
favicon_url: Optional[str] = Field(None, max_length=512)
|
||||
path: Optional[str] = Field(None, max_length=512)
|
||||
visit_count: Optional[int] = Field(None, ge=0)
|
||||
is_bookmarked: Optional[bool] = None
|
||||
|
||||
|
||||
class BookmarkResponse(BaseModel):
|
||||
id: str
|
||||
url: str
|
||||
title: str
|
||||
description: Optional[str]
|
||||
notes: Optional[str]
|
||||
tags: List[str]
|
||||
favicon_url: Optional[str]
|
||||
path: Optional[str]
|
||||
created_at: str
|
||||
updated_at: str
|
||||
visit_count: int
|
||||
is_bookmarked: bool
|
||||
source_set_id: Optional[str]
|
||||
user_id: Optional[str]
|
||||
|
||||
|
||||
def get_db_session():
|
||||
"""Get database session."""
|
||||
try:
|
||||
return sessionmaker(get_engine())()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_current_user(request: Request):
|
||||
"""Get current authenticated user."""
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY")
|
||||
|
||||
auth_header = request.headers.get("Authorization") or request.headers.get("authorization")
|
||||
|
||||
if auth_header and auth_header.startswith("Bearer "):
|
||||
def get_current_user_id(request: Request) -> Optional[str]:
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:]
|
||||
try:
|
||||
import jwt
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
|
||||
return {"username": payload.get("sub"), "id": payload.get("sub")}
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||
return payload.get("sub")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"username": "guest"}
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/", response_model=List[BookmarkResponse])
|
||||
async def list_bookmarks(
|
||||
limit: int = Query(20, le=100, ge=1, description="Number of results per page"),
|
||||
offset: int = Query(0, ge=0, description="Offset for pagination"),
|
||||
search: Optional[str] = Query(None, description="Search query"),
|
||||
tags_filter: Optional[List[str]] = Query(None, description="Filter by tags"),
|
||||
path_filter: Optional[str] = Query(None, description="Filter by folder path")
|
||||
):
|
||||
"""List all bookmarks with optional filters."""
|
||||
db = get_db_session()
|
||||
if not db:
|
||||
return []
|
||||
|
||||
query = Bookmark.query
|
||||
|
||||
# Search filter
|
||||
if search:
|
||||
query = query.filter((Bookmark.title.contains(search)) |
|
||||
(Bookmark.description.contains(search)) |
|
||||
(Bookmark.url.contains(search)))
|
||||
|
||||
# Tag filter
|
||||
if tags_filter:
|
||||
or_clause = or_(*[Bookmark.tags.contains(tag) for tag in tags_filter])
|
||||
query = query.filter(or_clause)
|
||||
|
||||
# Path filter
|
||||
if path_filter:
|
||||
query = query.filter(Bookmark.path.contains(path_filter))
|
||||
|
||||
bookmarks = query.limit(limit).offset(offset).all()
|
||||
return bookmarks
|
||||
|
||||
|
||||
@router.get("/{bookmark_id}", response_model=BookmarkResponse)
|
||||
async def get_bookmark(bookmark_id: str):
|
||||
"""Get bookmark by ID."""
|
||||
db = get_db_session()
|
||||
|
||||
if not db:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bookmark not found")
|
||||
|
||||
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
|
||||
|
||||
if not bookmark:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bookmark not found")
|
||||
|
||||
return bookmark
|
||||
|
||||
|
||||
@router.post("/", response_model=BookmarkResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_bookmark(data: BookmarkCreate, request: Request):
|
||||
"""Create new bookmark."""
|
||||
db = get_db_session()
|
||||
|
||||
if not db:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Database unavailable")
|
||||
|
||||
bookmark = Bookmark(
|
||||
url=data.url,
|
||||
title=data.title,
|
||||
description=data.description,
|
||||
notes=data.notes,
|
||||
tags=data.tags or [],
|
||||
favicon_url=data.favicon_url,
|
||||
path=data.path,
|
||||
visit_count=data.visit_count,
|
||||
is_bookmarked=data.is_bookmarked
|
||||
)
|
||||
|
||||
bookmark_id = f"{data.url[:20]}-{uuid.uuid4()[:8]}"
|
||||
bookmark = db.add(bookmark)
|
||||
db.commit()
|
||||
db.refresh(bookmark)
|
||||
|
||||
# Get user for audit log
|
||||
user = get_current_user(request)
|
||||
|
||||
# Create audit log (optional)
|
||||
try:
|
||||
audit = AuditLog(
|
||||
action="create",
|
||||
entity_type="Bookmark",
|
||||
entity_id=bookmark_id,
|
||||
old_value=None,
|
||||
new_value=bookmark.dict(),
|
||||
user_id=user.get("id")
|
||||
)
|
||||
db.add(audit)
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return bookmark
|
||||
|
||||
|
||||
@router.put("/{bookmark_id}", response_model=BookmarkResponse)
|
||||
async def update_bookmark(
|
||||
bookmark_id: str,
|
||||
data: BookmarkUpdate,
|
||||
request: Request
|
||||
):
|
||||
"""Update bookmark."""
|
||||
db = get_db_session()
|
||||
|
||||
if not db:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Database unavailable")
|
||||
|
||||
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
|
||||
|
||||
if not bookmark:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bookmark not found")
|
||||
|
||||
# Update fields
|
||||
for field_name, value in data.dict().items():
|
||||
if value is not None:
|
||||
setattr(bookmark, field_name, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(bookmark)
|
||||
|
||||
# Get user for audit log
|
||||
user = get_current_user(request)
|
||||
|
||||
# Create audit log
|
||||
try:
|
||||
old_data = Bookmark(id=bookmark_id, url=bookmark.url, title=bookmark.title).dict()
|
||||
audit = AuditLog(
|
||||
action="update",
|
||||
entity_type="Bookmark",
|
||||
entity_id=bookmark_id,
|
||||
old_value=old_data,
|
||||
new_value=bookmark.dict(),
|
||||
user_id=user.get("id")
|
||||
)
|
||||
db.add(audit)
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return bookmark
|
||||
|
||||
|
||||
@router.delete("/{bookmark_id}", response_model=dict)
|
||||
async def delete_bookmark(bookmark_id: str, request: Request):
|
||||
"""Delete bookmark."""
|
||||
db = get_db_session()
|
||||
|
||||
if not db:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Database unavailable")
|
||||
|
||||
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
|
||||
|
||||
if not bookmark:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bookmark not found")
|
||||
|
||||
db.delete(bookmark)
|
||||
db.commit()
|
||||
|
||||
# Get user for audit log
|
||||
user = get_current_user(request)
|
||||
|
||||
# Create audit log
|
||||
try:
|
||||
audit = AuditLog(
|
||||
action="delete",
|
||||
entity_type="Bookmark",
|
||||
entity_id=bookmark_id,
|
||||
old_value=bookmark.dict(),
|
||||
new_value=None,
|
||||
user_id=user.get("id")
|
||||
)
|
||||
db.add(audit)
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"message": "Bookmark deleted successfully", "deleted_id": bookmark_id}
|
||||
|
||||
|
||||
@router.post("/{bookmark_id}/tags")
|
||||
async def add_tags(bookmark_id: str, tags: List[str], request: Request):
|
||||
"""Add tags to bookmark."""
|
||||
db = get_db_session()
|
||||
|
||||
if not db:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Database unavailable")
|
||||
|
||||
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
|
||||
|
||||
if not bookmark:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bookmark not found")
|
||||
|
||||
for tag in tags:
|
||||
if tag.lower() not in [t.lower() for t in bookmark.tags]:
|
||||
bookmark.tags.append(tag)
|
||||
|
||||
db.commit()
|
||||
db.refresh(bookmark)
|
||||
|
||||
return bookmark
|
||||
|
||||
|
||||
@router.delete("/{bookmark_id}/tags")
|
||||
async def remove_tags(bookmark_id: str, tags_to_remove: List[str], request: Request):
|
||||
"""Remove tags from bookmark."""
|
||||
db = get_db_session()
|
||||
|
||||
if not db:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Database unavailable")
|
||||
|
||||
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
|
||||
|
||||
if not bookmark:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bookmark not found")
|
||||
|
||||
bookmark.tags = [t for t in bookmark.tags if t.lower() not in [tag.lower() for tag in tags_to_remove]]
|
||||
|
||||
db.commit()
|
||||
db.refresh(bookmark)
|
||||
|
||||
return bookmark
|
||||
|
||||
|
||||
@router.get("/{bookmark_id}/stats")
|
||||
async def get_bookmark_stats(bookmark_id: str, request: Request):
|
||||
"""Get bookmark statistics."""
|
||||
db = get_db_session()
|
||||
|
||||
if not db:
|
||||
return {}
|
||||
|
||||
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
|
||||
|
||||
if not bookmark:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Bookmark not found")
|
||||
|
||||
# Get visit count
|
||||
visits = db.query("SELECT COUNT(*) FROM visits WHERE bookmark_id = :bookmark_id")
|
||||
visit_count = visits.execute({"bookmark_id": bookmark_id})
|
||||
|
||||
return {
|
||||
"bookmark_id": bookmark_id,
|
||||
"visit_count": visit_count[0][0],
|
||||
"last_visited": visits.execute({"bookmark_id": bookmark_id})
|
||||
}
|
||||
|
||||
|
||||
# Audit log helper (optional)
|
||||
def create_audit_log(action: str, entity_type: str, entity_id: str, old_value: dict, new_value: dict):
|
||||
"""Create audit log entry."""
|
||||
db = get_db_session()
|
||||
|
||||
if not db:
|
||||
return
|
||||
|
||||
def log_audit(db, action: str, entity_type: str, entity_id: str, user_id: Optional[str], old_value=None, new_value=None):
|
||||
try:
|
||||
audit = AuditLog(
|
||||
action=action,
|
||||
@@ -350,9 +63,162 @@ def create_audit_log(action: str, entity_type: str, entity_id: str, old_value: d
|
||||
entity_id=entity_id,
|
||||
old_value=old_value,
|
||||
new_value=new_value,
|
||||
ip_address=request.client.host if hasattr(request, 'client') and hasattr(request.client, 'host') else None
|
||||
user_id=user_id,
|
||||
)
|
||||
db.add(audit)
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
db.rollback()
|
||||
|
||||
|
||||
@router.get("/", response_model=List[dict])
|
||||
async def list_bookmarks(
|
||||
limit: int = Query(20, le=100, ge=1),
|
||||
offset: int = Query(0, ge=0),
|
||||
search: Optional[str] = Query(None),
|
||||
tags_filter: Optional[List[str]] = Query(None),
|
||||
path_filter: Optional[str] = Query(None),
|
||||
):
|
||||
db = get_session()
|
||||
try:
|
||||
query = db.query(Bookmark)
|
||||
if search:
|
||||
query = query.filter(
|
||||
or_(
|
||||
Bookmark.title.ilike(f"%{search}%"),
|
||||
Bookmark.description.ilike(f"%{search}%"),
|
||||
Bookmark.url.ilike(f"%{search}%"),
|
||||
)
|
||||
)
|
||||
if tags_filter:
|
||||
for tag in tags_filter:
|
||||
query = query.filter(Bookmark.tags.contains(tag))
|
||||
if path_filter:
|
||||
query = query.filter(Bookmark.path.ilike(f"%{path_filter}%"))
|
||||
bookmarks = query.order_by(Bookmark.created_at.desc()).offset(offset).limit(limit).all()
|
||||
return [b.to_dict() for b in bookmarks]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/{bookmark_id}", response_model=dict)
|
||||
async def get_bookmark(bookmark_id: str):
|
||||
db = get_session()
|
||||
try:
|
||||
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
|
||||
if not bookmark:
|
||||
raise HTTPException(status_code=404, detail="Bookmark not found")
|
||||
return bookmark.to_dict()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/", response_model=dict, status_code=status.HTTP_201_CREATED)
|
||||
async def create_bookmark(data: BookmarkCreate, request: Request):
|
||||
db = get_session()
|
||||
try:
|
||||
user_id = get_current_user_id(request)
|
||||
bookmark = Bookmark(
|
||||
id=str(uuid.uuid4()),
|
||||
url=data.url,
|
||||
title=data.title,
|
||||
description=data.description,
|
||||
notes=data.notes,
|
||||
tags=data.tags or [],
|
||||
favicon_url=data.favicon_url,
|
||||
path=data.path,
|
||||
visit_count=data.visit_count,
|
||||
is_bookmarked=data.is_bookmarked,
|
||||
user_id=user_id,
|
||||
)
|
||||
db.add(bookmark)
|
||||
db.commit()
|
||||
db.refresh(bookmark)
|
||||
log_audit(db, "create", "Bookmark", bookmark.id, user_id, new_value=bookmark.to_dict())
|
||||
return bookmark.to_dict()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.put("/{bookmark_id}", response_model=dict)
|
||||
async def update_bookmark(bookmark_id: str, data: BookmarkUpdate, request: Request):
|
||||
db = get_session()
|
||||
try:
|
||||
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
|
||||
if not bookmark:
|
||||
raise HTTPException(status_code=404, detail="Bookmark not found")
|
||||
|
||||
old_value = bookmark.to_dict()
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(bookmark, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(bookmark)
|
||||
user_id = get_current_user_id(request)
|
||||
log_audit(db, "update", "Bookmark", bookmark_id, user_id, old_value=old_value, new_value=bookmark.to_dict())
|
||||
return bookmark.to_dict()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.delete("/{bookmark_id}", response_model=dict)
|
||||
async def delete_bookmark(bookmark_id: str, request: Request):
|
||||
db = get_session()
|
||||
try:
|
||||
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
|
||||
if not bookmark:
|
||||
raise HTTPException(status_code=404, detail="Bookmark not found")
|
||||
|
||||
old_value = bookmark.to_dict()
|
||||
db.delete(bookmark)
|
||||
db.commit()
|
||||
user_id = get_current_user_id(request)
|
||||
log_audit(db, "delete", "Bookmark", bookmark_id, user_id, old_value=old_value)
|
||||
return {"message": "Bookmark deleted successfully", "deleted_id": bookmark_id}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
class TagList(BaseModel):
|
||||
tags: List[str]
|
||||
|
||||
|
||||
@router.post("/{bookmark_id}/tags", response_model=dict)
|
||||
async def add_tags(bookmark_id: str, data: TagList, request: Request):
|
||||
db = get_session()
|
||||
try:
|
||||
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
|
||||
if not bookmark:
|
||||
raise HTTPException(status_code=404, detail="Bookmark not found")
|
||||
|
||||
current_tags = list(bookmark.tags or [])
|
||||
current_lower = [t.lower() for t in current_tags]
|
||||
for tag in data.tags:
|
||||
if tag.lower() not in current_lower:
|
||||
current_tags.append(tag)
|
||||
current_lower.append(tag.lower())
|
||||
bookmark.tags = current_tags
|
||||
|
||||
db.commit()
|
||||
db.refresh(bookmark)
|
||||
return bookmark.to_dict()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.delete("/{bookmark_id}/tags", response_model=dict)
|
||||
async def remove_tags(bookmark_id: str, data: TagList, request: Request):
|
||||
db = get_session()
|
||||
try:
|
||||
bookmark = db.query(Bookmark).filter(Bookmark.id == bookmark_id).first()
|
||||
if not bookmark:
|
||||
raise HTTPException(status_code=404, detail="Bookmark not found")
|
||||
|
||||
remove_lower = [t.lower() for t in data.tags]
|
||||
bookmark.tags = [t for t in (bookmark.tags or []) if t.lower() not in remove_lower]
|
||||
db.commit()
|
||||
db.refresh(bookmark)
|
||||
return bookmark.to_dict()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -1,253 +1,71 @@
|
||||
"""
|
||||
LinkSyncServer - Query Engine
|
||||
LinkSyncServer - Query Engine Endpoints
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import List, Optional, Dict, Any
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from models.base import Bookmark, get_session
|
||||
from queries.executor import execute_query
|
||||
from queries.parser import QueryParser
|
||||
|
||||
router = APIRouter(prefix="/api/queries", tags=["Queries"])
|
||||
|
||||
|
||||
def tokenize(query: str) -> List[str]:
|
||||
"""Tokenize query string."""
|
||||
# Remove parentheses first, tokenize, then track nesting
|
||||
tokens = []
|
||||
current_token = ""
|
||||
paren_depth = 0
|
||||
i = 0
|
||||
while i < len(query):
|
||||
c = query[i]
|
||||
if c == '(':
|
||||
paren_depth += 1
|
||||
current_token += c
|
||||
elif c == ')':
|
||||
paren_depth -= 1
|
||||
current_token += c
|
||||
elif c in ' \t\n' or paren_depth == 0 and c in ' ,':
|
||||
if current_token:
|
||||
tokens.append(current_token)
|
||||
current_token = ""
|
||||
else:
|
||||
current_token += c
|
||||
i += 1
|
||||
if current_token:
|
||||
tokens.append(current_token)
|
||||
return tokens
|
||||
|
||||
|
||||
class TermSet:
|
||||
"""Term set: ('term1', 'term2') -> OR operation"""
|
||||
def __init__(self, terms: List[str]):
|
||||
self.terms = terms
|
||||
self.operation = "OR"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "term_set",
|
||||
"terms": self.terms,
|
||||
"operation": self.operation
|
||||
}
|
||||
|
||||
|
||||
class TagFilter:
|
||||
"""Tag-based filter"""
|
||||
def __init__(self, tag_name: str):
|
||||
self.tag_name = tag_name
|
||||
self.operation = "TAG"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "tag_filter",
|
||||
"tag_name": self.tag_name,
|
||||
"operation": self.operation
|
||||
}
|
||||
|
||||
|
||||
class FieldFilter:
|
||||
"""Field-based filter (e.g., url:example.com)"""
|
||||
def __init__(self, field: str, value: str):
|
||||
self.field = field
|
||||
self.value = value
|
||||
self.operation = "FIELD"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "field_filter",
|
||||
"field": self.field,
|
||||
"value": self.value,
|
||||
"operation": self.operation
|
||||
}
|
||||
|
||||
|
||||
class ANDNode:
|
||||
"""AND operation node"""
|
||||
def __init__(self, left, right):
|
||||
self.left = left
|
||||
self.right = right
|
||||
self.operation = "AND"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "binary",
|
||||
"operation": self.operation,
|
||||
"left": self.left.to_dict(),
|
||||
"right": self.right.to_dict()
|
||||
}
|
||||
|
||||
|
||||
class ORNode:
|
||||
"""OR operation node"""
|
||||
def __init__(self, left, right):
|
||||
self.left = left
|
||||
self.right = right
|
||||
self.operation = "OR"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "binary",
|
||||
"operation": self.operation,
|
||||
"left": self.left.to_dict(),
|
||||
"right": self.right.to_dict()
|
||||
}
|
||||
|
||||
|
||||
class XORNode:
|
||||
"""XOR operation node"""
|
||||
def __init__(self, left, right):
|
||||
self.left = left
|
||||
self.right = right
|
||||
self.operation = "XOR"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "binary",
|
||||
"operation": self.operation,
|
||||
"left": self.left.to_dict(),
|
||||
"right": self.right.to_dict()
|
||||
}
|
||||
|
||||
|
||||
class NOTNode:
|
||||
"""NOT operation node"""
|
||||
def __init__(self, child):
|
||||
self.child = child
|
||||
self.operation = "NOT"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"type": "unary",
|
||||
"operation": self.operation,
|
||||
"child": self.child.to_dict()
|
||||
}
|
||||
|
||||
|
||||
def parse_query(query: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Parse query expression: ('term1', 'term2') OR tagA AND tagB XOR url:example.com
|
||||
Precedence: () > XOR > AND > OR
|
||||
"""
|
||||
tokens = tokenize(query)
|
||||
|
||||
# Remove parentheses and tokenize
|
||||
tokens = tokenize(query)
|
||||
|
||||
# Simple parser for basic queries
|
||||
# For full parser, would need recursive descent
|
||||
|
||||
# Handle term sets: ('term1', 'term2')
|
||||
term_set = None
|
||||
i = 0
|
||||
while i < len(tokens):
|
||||
token = tokens[i]
|
||||
if token.startswith('(') and tokens[i].endswith(')'):
|
||||
# Extract terms from tuple
|
||||
inner = token[1:-1]
|
||||
terms = [t.strip("'\"") for t in inner.split(',')]
|
||||
term_set = TermSet(terms)
|
||||
i += 1
|
||||
else:
|
||||
break
|
||||
|
||||
if not term_set:
|
||||
# Parse as simple expression
|
||||
# This is a simplified parser for demo
|
||||
return {"type": "term_set", "terms": []}
|
||||
|
||||
return term_set.to_dict()
|
||||
|
||||
|
||||
def execute_query(query_expression: dict, all_bookmarks: List[dict]) -> List[dict]:
|
||||
"""
|
||||
Execute query expression against bookmark list.
|
||||
For demo, returns mock results.
|
||||
"""
|
||||
# Query AST evaluation would go here
|
||||
# For now, return mock results
|
||||
return [
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"url": "https://example.com/result",
|
||||
"title": "Query Result",
|
||||
"description": "A result from the query",
|
||||
"notes": "",
|
||||
"tags": ["query", "result"],
|
||||
"favicon_url": None,
|
||||
"path": "/Query Result",
|
||||
"created_at": "2026-05-11T00:00:00Z",
|
||||
"updated_at": "2026-05-11T00:00:00Z",
|
||||
"visit_count": 0,
|
||||
"is_bookmarked": False,
|
||||
"source_set_id": None
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@router.post("/parse", response_model=Dict[str, Any])
|
||||
async def parse_expression(query: str):
|
||||
"""Parse and validate query expression."""
|
||||
parsed = parse_query(query)
|
||||
return {
|
||||
"expression": query,
|
||||
"parsed": parsed,
|
||||
"valid": True
|
||||
}
|
||||
async def parse_expression(expression: str):
|
||||
try:
|
||||
parser = QueryParser()
|
||||
parsed = parser.parse(expression)
|
||||
return {
|
||||
"expression": expression,
|
||||
"parsed": parsed,
|
||||
"valid": True,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"expression": expression,
|
||||
"parsed": None,
|
||||
"valid": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/execute", response_model=List[dict])
|
||||
async def execute(query_expression: dict, limit: int = 20):
|
||||
"""Execute query against bookmarks."""
|
||||
# For demo, return mock results
|
||||
return [
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"url": "https://example.com/queried",
|
||||
"title": "Queried Item",
|
||||
"description": "Item from query",
|
||||
"notes": "",
|
||||
"tags": ["queried"],
|
||||
"favicon_url": None,
|
||||
"path": "/Queried",
|
||||
"created_at": "2026-05-11T00:00:00Z",
|
||||
"updated_at": "2026-05-11T00:00:00Z",
|
||||
"visit_count": 0,
|
||||
"is_bookmarked": False,
|
||||
"source_set_id": None
|
||||
}
|
||||
]
|
||||
async def execute(expression: str, limit: int = 20, offset: int = 0):
|
||||
db = get_session()
|
||||
try:
|
||||
parser = QueryParser()
|
||||
parsed = parser.parse(expression)
|
||||
if not parsed:
|
||||
raise HTTPException(status_code=400, detail="Invalid query expression")
|
||||
|
||||
all_bookmarks = db.query(Bookmark).all()
|
||||
results = execute_query(parsed, [b.to_dict() for b in all_bookmarks])
|
||||
return results[offset : offset + limit]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/{query_id}", response_model=Dict[str, Any])
|
||||
async def get_saved_query(query_id: str):
|
||||
"""Get saved query by ID."""
|
||||
return {
|
||||
"id": query_id,
|
||||
"name": "Example Query",
|
||||
"description": "Example query description",
|
||||
"expression": "('work', 'dev') OR tag:work",
|
||||
"query_type": "dynamic",
|
||||
"is_public": False,
|
||||
"created_at": "2026-05-11T00:00:00Z",
|
||||
"updated_at": "2026-05-11T00:00:00Z"
|
||||
}
|
||||
db = get_session()
|
||||
try:
|
||||
from models.base import Collection
|
||||
collection = db.query(Collection).filter(Collection.id == query_id).first()
|
||||
if not collection or collection.query_type != "dynamic":
|
||||
raise HTTPException(status_code=404, detail="Saved query not found")
|
||||
return {
|
||||
"id": collection.id,
|
||||
"name": collection.name,
|
||||
"description": collection.description,
|
||||
"expression": collection.query_expression,
|
||||
"query_type": collection.query_type,
|
||||
"is_public": collection.is_public,
|
||||
"created_at": collection.created_at.isoformat() if collection.created_at else None,
|
||||
"updated_at": collection.updated_at.isoformat() if collection.updated_at else None,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -2,29 +2,33 @@
|
||||
LinkSyncServer - Sync Endpoint for Browser Extension
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from typing import List, Dict, Any
|
||||
import uuid
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from models.base import Bookmark, get_session
|
||||
|
||||
router = APIRouter(prefix="/api/sync", tags=["Sync"])
|
||||
|
||||
|
||||
class SyncConfig(BaseModel):
|
||||
mode: str # "bi-directional", "browser-authoritative", "server-authoritative"
|
||||
mode: str = Field(..., description="bi-directional, browser-authoritative, or server-authoritative")
|
||||
deletions_enabled: bool = False
|
||||
|
||||
|
||||
class BookmarkData(BaseModel):
|
||||
class BookmarkSyncData(BaseModel):
|
||||
id: str
|
||||
url: str
|
||||
title: str
|
||||
description: str
|
||||
notes: str
|
||||
tags: List[str]
|
||||
favicon_url: str
|
||||
path: str
|
||||
visit_count: int
|
||||
is_bookmarked: bool
|
||||
description: str = ""
|
||||
notes: str = ""
|
||||
tags: List[str] = Field(default_factory=list)
|
||||
favicon_url: str = ""
|
||||
path: str = ""
|
||||
visit_count: int = 0
|
||||
is_bookmarked: bool = False
|
||||
|
||||
|
||||
class SyncResponse(BaseModel):
|
||||
@@ -32,119 +36,178 @@ class SyncResponse(BaseModel):
|
||||
synced_count: int
|
||||
|
||||
|
||||
def mock_apply_sync(sync_config: SyncConfig, browser_bookmarks: List[Dict]) -> SyncResponse:
|
||||
"""
|
||||
Apply sync based on mode.
|
||||
For demo, return mock actions.
|
||||
"""
|
||||
actions = []
|
||||
|
||||
for bookmark in browser_bookmarks:
|
||||
if sync_config.mode == "bi-directional":
|
||||
actions.append({
|
||||
"type": "create" if not bookmark.get("from_server", False) else "update",
|
||||
"link_id": bookmark["id"],
|
||||
"message": "Synced from browser"
|
||||
})
|
||||
elif sync_config.mode == "browser-authoritative":
|
||||
actions.append({
|
||||
"type": "update",
|
||||
"link_id": bookmark["id"],
|
||||
"message": "Overwritten from browser"
|
||||
})
|
||||
elif sync_config.mode == "server-authoritative":
|
||||
actions.append({
|
||||
"type": "download",
|
||||
"link_id": bookmark["id"],
|
||||
"message": "Downloaded from server"
|
||||
})
|
||||
|
||||
# If deletions enabled, would remove stale bookmarks here
|
||||
|
||||
return SyncResponse(
|
||||
actions=actions,
|
||||
synced_count=len(actions)
|
||||
)
|
||||
def apply_sync(sync_config: SyncConfig, browser_bookmarks: List[BookmarkSyncData]) -> SyncResponse:
|
||||
db = get_session()
|
||||
try:
|
||||
actions = []
|
||||
server_bookmarks = {b.id: b for b in db.query(Bookmark).all()}
|
||||
|
||||
for bm in browser_bookmarks:
|
||||
existing = server_bookmarks.get(bm.id)
|
||||
|
||||
def mock_get_server_bookmarks() -> List[Dict]:
|
||||
"""Get bookmarks from server (mock)."""
|
||||
return [
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"url": "https://example.com/example",
|
||||
"title": "Example",
|
||||
"description": "An example",
|
||||
"notes": "",
|
||||
"tags": ["example"],
|
||||
"favicon_url": None,
|
||||
"path": "/Example",
|
||||
"visit_count": 0,
|
||||
"is_bookmarked": False
|
||||
}
|
||||
]
|
||||
if sync_config.mode == "bi-directional":
|
||||
if existing:
|
||||
existing.url = bm.url
|
||||
existing.title = bm.title
|
||||
existing.description = bm.description
|
||||
existing.notes = bm.notes
|
||||
existing.tags = bm.tags
|
||||
existing.favicon_url = bm.favicon_url
|
||||
existing.path = bm.path
|
||||
existing.visit_count = bm.visit_count
|
||||
existing.is_bookmarked = bm.is_bookmarked
|
||||
actions.append({"type": "update", "link_id": bm.id})
|
||||
else:
|
||||
new_bm = Bookmark(
|
||||
id=bm.id,
|
||||
url=bm.url,
|
||||
title=bm.title,
|
||||
description=bm.description,
|
||||
notes=bm.notes,
|
||||
tags=bm.tags,
|
||||
favicon_url=bm.favicon_url,
|
||||
path=bm.path,
|
||||
visit_count=bm.visit_count,
|
||||
is_bookmarked=bm.is_bookmarked,
|
||||
)
|
||||
db.add(new_bm)
|
||||
actions.append({"type": "create", "link_id": bm.id})
|
||||
|
||||
elif sync_config.mode == "browser-authoritative":
|
||||
if existing:
|
||||
existing.url = bm.url
|
||||
existing.title = bm.title
|
||||
existing.description = bm.description
|
||||
existing.notes = bm.notes
|
||||
existing.tags = bm.tags
|
||||
existing.favicon_url = bm.favicon_url
|
||||
existing.path = bm.path
|
||||
existing.visit_count = bm.visit_count
|
||||
existing.is_bookmarked = bm.is_bookmarked
|
||||
actions.append({"type": "update", "link_id": bm.id})
|
||||
else:
|
||||
new_bm = Bookmark(
|
||||
id=bm.id,
|
||||
url=bm.url,
|
||||
title=bm.title,
|
||||
description=bm.description,
|
||||
notes=bm.notes,
|
||||
tags=bm.tags,
|
||||
favicon_url=bm.favicon_url,
|
||||
path=bm.path,
|
||||
visit_count=bm.visit_count,
|
||||
is_bookmarked=bm.is_bookmarked,
|
||||
)
|
||||
db.add(new_bm)
|
||||
actions.append({"type": "create", "link_id": bm.id})
|
||||
|
||||
elif sync_config.mode == "server-authoritative":
|
||||
if not existing:
|
||||
new_bm = Bookmark(
|
||||
id=bm.id,
|
||||
url=bm.url,
|
||||
title=bm.title,
|
||||
description=bm.description,
|
||||
notes=bm.notes,
|
||||
tags=bm.tags,
|
||||
favicon_url=bm.favicon_url,
|
||||
path=bm.path,
|
||||
visit_count=bm.visit_count,
|
||||
is_bookmarked=bm.is_bookmarked,
|
||||
)
|
||||
db.add(new_bm)
|
||||
actions.append({"type": "create", "link_id": bm.id})
|
||||
|
||||
if sync_config.deletions_enabled:
|
||||
browser_ids = {bm.id for bm in browser_bookmarks}
|
||||
for server_id in server_bookmarks:
|
||||
if server_id not in browser_ids:
|
||||
db.query(Bookmark).filter(Bookmark.id == server_id).delete()
|
||||
actions.append({"type": "delete", "link_id": server_id})
|
||||
|
||||
db.commit()
|
||||
return SyncResponse(actions=actions, synced_count=len(actions))
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/", response_model=SyncResponse)
|
||||
async def sync(
|
||||
config: SyncConfig,
|
||||
browser_bookmarks: List[BookmarkData],
|
||||
server_bookmarks: List[Dict] = Depends(mock_get_server_bookmarks)
|
||||
):
|
||||
"""
|
||||
Sync bookmarks between browser and server.
|
||||
|
||||
Mode options:
|
||||
- bi-directional: Push both ways
|
||||
- browser-authoritative: Browser overwrites server
|
||||
- server-authoritative: Download from server only
|
||||
"""
|
||||
response = mock_apply_sync(config, [b.model_dump() for b in browser_bookmarks])
|
||||
return response
|
||||
async def sync(config: SyncConfig, browser_bookmarks: List[BookmarkSyncData]):
|
||||
return apply_sync(config, browser_bookmarks)
|
||||
|
||||
|
||||
@router.get("/collections")
|
||||
@router.get("/collections", response_model=List[dict])
|
||||
async def list_collections():
|
||||
"""List user's collections."""
|
||||
return [
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": "Work Links",
|
||||
"description": "Work-related links",
|
||||
"query_type": "dynamic",
|
||||
"query_expression": {"operation": "OR", "operands": [{"operation": "TERM", "value": "work"}]},
|
||||
"is_public": False
|
||||
}
|
||||
]
|
||||
db = get_session()
|
||||
try:
|
||||
from models.base import Collection
|
||||
collections = db.query(Collection).all()
|
||||
return [c.to_dict() for c in collections]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/collections/{collection_id}")
|
||||
@router.get("/collections/{collection_id}", response_model=dict)
|
||||
async def get_collection(collection_id: str):
|
||||
"""Get collection details."""
|
||||
return {
|
||||
"id": collection_id,
|
||||
"name": "Work Links",
|
||||
"description": "Work-related links",
|
||||
"query_type": "dynamic",
|
||||
"query_expression": {"operation": "OR", "operands": [{"operation": "TERM", "value": "work"}]},
|
||||
"is_public": False
|
||||
}
|
||||
db = get_session()
|
||||
try:
|
||||
from models.base import Collection
|
||||
collection = db.query(Collection).filter(Collection.id == collection_id).first()
|
||||
if not collection:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
return collection.to_dict()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/collections/{collection_id}/add-links")
|
||||
async def add_links_to_collection(
|
||||
collection_id: str,
|
||||
bookmark_ids: List[str]
|
||||
):
|
||||
"""Add links to static collection."""
|
||||
return {
|
||||
"collection_id": collection_id,
|
||||
"added_count": len(bookmark_ids),
|
||||
"message": "Links added successfully"
|
||||
}
|
||||
@router.post("/collections/{collection_id}/add-links", response_model=dict)
|
||||
async def add_links_to_collection(collection_id: str, bookmark_ids: List[str]):
|
||||
db = get_session()
|
||||
try:
|
||||
from models.base import Collection, CollectionBookmark
|
||||
collection = db.query(Collection).filter(Collection.id == collection_id).first()
|
||||
if not collection:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
if collection.query_type != "static":
|
||||
raise HTTPException(status_code=400, detail="Can only add links to static collections")
|
||||
|
||||
added = 0
|
||||
for bid in bookmark_ids:
|
||||
existing = (
|
||||
db.query(CollectionBookmark)
|
||||
.filter(
|
||||
CollectionBookmark.collection_id == collection_id,
|
||||
CollectionBookmark.bookmark_id == bid,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not existing:
|
||||
db.add(CollectionBookmark(collection_id=collection_id, bookmark_id=bid))
|
||||
added += 1
|
||||
|
||||
db.commit()
|
||||
return {"collection_id": collection_id, "added_count": added, "message": "Links added successfully"}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.delete("/collections/{collection_id}")
|
||||
@router.delete("/collections/{collection_id}", response_model=dict)
|
||||
async def delete_collection(collection_id: str):
|
||||
"""Delete collection."""
|
||||
return {"message": "Collection deleted successfully"}
|
||||
db = get_session()
|
||||
try:
|
||||
from models.base import Collection, CollectionBookmark
|
||||
collection = db.query(Collection).filter(Collection.id == collection_id).first()
|
||||
if not collection:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
db.query(CollectionBookmark).filter(
|
||||
CollectionBookmark.collection_id == collection_id
|
||||
).delete()
|
||||
db.delete(collection)
|
||||
db.commit()
|
||||
return {"message": "Collection deleted successfully"}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -2,187 +2,164 @@
|
||||
LinkSyncServer - Tag Management Endpoints
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
import logging
|
||||
import uuid
|
||||
from typing import List, Optional
|
||||
|
||||
from models.base import Base, Tag, Bookmark, get_engine
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from models.base import Bookmark, Tag, get_session
|
||||
|
||||
router = APIRouter(prefix="/api/tags", tags=["Tags"])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TagCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=50)
|
||||
name: str = Field(..., min_length=1, max_length=100)
|
||||
color: Optional[str] = Field(None, max_length=7)
|
||||
description: Optional[str] = Field(None, max_length=500)
|
||||
|
||||
|
||||
class TagUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None)
|
||||
color: Optional[str] = Field(None)
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100)
|
||||
color: Optional[str] = Field(None, max_length=7)
|
||||
description: Optional[str] = Field(None, max_length=500)
|
||||
|
||||
|
||||
class TagResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
color: Optional[str]
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
def get_db_session():
|
||||
"""Get database session."""
|
||||
@router.get("/", response_model=List[dict])
|
||||
async def list_tags(
|
||||
page: int = Query(1, ge=1),
|
||||
per_page: int = Query(50, ge=1, le=200),
|
||||
search: Optional[str] = Query(None),
|
||||
):
|
||||
db = get_session()
|
||||
try:
|
||||
return Session(get_engine())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def get_current_user(request):
|
||||
"""Get current authenticated user."""
|
||||
SECRET_KEY = None
|
||||
|
||||
try:
|
||||
auth_header = request.headers.get("Authorization") or request.headers.get("authorization")
|
||||
|
||||
if auth_header and auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:]
|
||||
import jwt
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
|
||||
return {"username": payload.get("sub"), "id": payload.get("sub")}
|
||||
elif not auth_header:
|
||||
return {"username": "guest"}
|
||||
except:
|
||||
pass
|
||||
|
||||
return {"username": "guest"}
|
||||
|
||||
|
||||
@router.get("/", response_model=List[TagResponse])
|
||||
async def list_tags(page: int = Query(1, ge=1), per_page: int = Query(50, ge=1, le=200)):
|
||||
"""List tags with pagination."""
|
||||
db = get_db_session()
|
||||
|
||||
if not db:
|
||||
return []
|
||||
|
||||
count = db.query(Tag).count()
|
||||
tags = db.query(Tag).order_by(Tag.name).offset((page - 1) * per_page).limit(per_page).all()
|
||||
|
||||
return tags
|
||||
query = db.query(Tag)
|
||||
if search:
|
||||
query = query.filter(Tag.name.ilike(f"%{search}%"))
|
||||
tags = query.order_by(Tag.name).offset((page - 1) * per_page).limit(per_page).all()
|
||||
return [t.to_dict() for t in tags]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/count", response_model=dict)
|
||||
async def tag_count():
|
||||
"""Get total tag count."""
|
||||
db = get_db_session()
|
||||
|
||||
if not db:
|
||||
return {"count": 0}
|
||||
|
||||
return {"count": db.query(Tag).count()}
|
||||
db = get_session()
|
||||
try:
|
||||
return {"count": db.query(Tag).count()}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/{tag_id}", response_model=TagResponse)
|
||||
@router.get("/{tag_id}", response_model=dict)
|
||||
async def get_tag(tag_id: str):
|
||||
"""Get tag by ID."""
|
||||
db = get_db_session()
|
||||
|
||||
if not db:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
tag = db.query(Tag).filter(Tag.id == tag_id).first()
|
||||
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
return tag
|
||||
db = get_session()
|
||||
try:
|
||||
tag = db.query(Tag).filter(Tag.id == tag_id).first()
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
return tag.to_dict()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.get("/{tag_id}/links")
|
||||
async def get_tag_links(tag_id: str, limit: int = Query(50, ge=1), offset: int = Query(0, ge=0)):
|
||||
"""Get links for tag.""""
|
||||
db = get_db_session()
|
||||
|
||||
if not db:
|
||||
return []
|
||||
|
||||
tag = db.query(Tag).filter(Tag.id == tag_id).first()
|
||||
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
links = db.query(Bookmark).join(Tag).filter(Tag.id == tag_id).limit(limit).offset(offset).all()
|
||||
|
||||
return links
|
||||
@router.get("/name/{tag_name}", response_model=dict)
|
||||
async def get_tag_by_name(tag_name: str):
|
||||
db = get_session()
|
||||
try:
|
||||
tag = db.query(Tag).filter(Tag.name == tag_name).first()
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
return tag.to_dict()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.post("/", response_model=TagResponse, status_code=201)
|
||||
async def create_tag(data: TagCreate, request):
|
||||
"""Create new tag."""
|
||||
db = get_db_session()
|
||||
|
||||
if not db:
|
||||
raise HTTPException(status_code=500, detail="Database unavailable")
|
||||
|
||||
tag = Tag(
|
||||
id=f"tag-{uuid.uuid4()[:8]}",
|
||||
name=data.name,
|
||||
color=data.color
|
||||
)
|
||||
|
||||
db.add(tag)
|
||||
db.commit()
|
||||
db.refresh(tag)
|
||||
|
||||
return tag
|
||||
@router.get("/{tag_id}/links", response_model=List[dict])
|
||||
async def get_tag_links(
|
||||
tag_id: str,
|
||||
limit: int = Query(50, ge=1),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
db = get_session()
|
||||
try:
|
||||
tag = db.query(Tag).filter(Tag.id == tag_id).first()
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
bookmarks = (
|
||||
db.query(Bookmark)
|
||||
.filter(Bookmark.tags.contains(tag.name))
|
||||
.order_by(Bookmark.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [b.to_dict() for b in bookmarks]
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.put("/{tag_id}", response_model=TagResponse)
|
||||
@router.post("/", response_model=dict, status_code=201)
|
||||
async def create_tag(data: TagCreate):
|
||||
db = get_session()
|
||||
try:
|
||||
existing = db.query(Tag).filter(Tag.name == data.name).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Tag already exists")
|
||||
|
||||
tag = Tag(
|
||||
id=str(uuid.uuid4()),
|
||||
name=data.name,
|
||||
color=data.color,
|
||||
description=data.description,
|
||||
)
|
||||
db.add(tag)
|
||||
db.commit()
|
||||
db.refresh(tag)
|
||||
return tag.to_dict()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.put("/{tag_id}", response_model=dict)
|
||||
async def update_tag(tag_id: str, data: TagUpdate):
|
||||
"""Update tag."""
|
||||
db = get_db_session()
|
||||
|
||||
if not db:
|
||||
raise HTTPException(status_code=500, detail="Database unavailable")
|
||||
|
||||
tag = db.query(Tag).filter(Tag.id == tag_id).first()
|
||||
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
for field_name in ["name", "color"]:
|
||||
if field_name in data.dict() and data.dict()[field_name] is not None:
|
||||
setattr(tag, field_name, data.dict()[field_name])
|
||||
|
||||
db.commit()
|
||||
db.refresh(tag)
|
||||
|
||||
return tag
|
||||
db = get_session()
|
||||
try:
|
||||
tag = db.query(Tag).filter(Tag.id == tag_id).first()
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(tag, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(tag)
|
||||
return tag.to_dict()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@router.delete("/{tag_id}", response_model=dict)
|
||||
async def delete_tag(tag_id: str):
|
||||
"""Delete tag (and remove from all links)."""
|
||||
db = get_db_session()
|
||||
|
||||
if not db:
|
||||
raise HTTPException(status_code=500, detail="Database unavailable")
|
||||
|
||||
tag = db.query(Tag).filter(Tag.id == tag_id).first()
|
||||
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
# Remove tag from all bookmarks
|
||||
bookmarks = db.query(Bookmark).filter(Bookmark.tags.contains(tag.name)).all()
|
||||
|
||||
for bookmark in bookmarks:
|
||||
bookmark.tags = [t for t in bookmark.tags if t[0] != tag_id]
|
||||
|
||||
db.delete(tag)
|
||||
db.commit()
|
||||
|
||||
return {"message": f"Tag '{tag.name}' deleted and removed from all links"}
|
||||
db = get_session()
|
||||
try:
|
||||
tag = db.query(Tag).filter(Tag.id == tag_id).first()
|
||||
if not tag:
|
||||
raise HTTPException(status_code=404, detail="Tag not found")
|
||||
|
||||
tag_name = tag.name
|
||||
bookmarks = db.query(Bookmark).filter(Bookmark.tags.contains(tag_name)).all()
|
||||
for bookmark in bookmarks:
|
||||
bookmark.tags = [t for t in (bookmark.tags or []) if t != tag_name]
|
||||
db.add(bookmark)
|
||||
|
||||
db.delete(tag)
|
||||
db.commit()
|
||||
return {"message": f"Tag '{tag_name}' deleted and removed from all links"}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
23
LinkSyncServer/api/routes.py
Normal file
23
LinkSyncServer/api/routes.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
LinkSyncServer - API Router Aggregator
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from api.endpoints.auth import router as auth_router
|
||||
from api.endpoints.links import router as links_router
|
||||
from api.endpoints.collections import router as collections_router
|
||||
from api.endpoints.queries import router as queries_router
|
||||
from api.endpoints.sync import router as sync_router
|
||||
from api.endpoints.tags import router as tags_router
|
||||
from api.endpoints.admin import router as admin_router
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
router.include_router(auth_router)
|
||||
router.include_router(links_router)
|
||||
router.include_router(collections_router)
|
||||
router.include_router(queries_router)
|
||||
router.include_router(sync_router)
|
||||
router.include_router(tags_router)
|
||||
router.include_router(admin_router)
|
||||
@@ -1,151 +0,0 @@
|
||||
"""
|
||||
LinkSyncServer - Sync Endpoint for Browser Extension
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from typing import List, Dict
|
||||
import jwt
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
from models.base import Bookmark, Collection, get_engine
|
||||
from api.parsers.bookmarks import BookmarkParser
|
||||
from api.parsers.sync import SyncParser
|
||||
import os
|
||||
|
||||
router = APIRouter(prefix="/api/v1/sync", tags=["Sync"])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Get database and secrets
|
||||
DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:///links.db")
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY", "fallback-for-dev")
|
||||
|
||||
# Initialize parser
|
||||
bookmark_parser = BookmarkParser()
|
||||
sync_parser = SyncParser()
|
||||
|
||||
|
||||
def get_db_session():
|
||||
"""Get database session."""
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
connect_args={'check_same_thread': False}
|
||||
)
|
||||
return Session(engine)
|
||||
|
||||
|
||||
def validate_request_token(request_token: str) -> Dict:
|
||||
"""
|
||||
Validate sync request token.
|
||||
|
||||
Accepts:
|
||||
- Token header from extension
|
||||
- No auth for demo/maintenance
|
||||
"""
|
||||
if not request_token:
|
||||
# Allow anonymous for demo
|
||||
return {"type": "anonymous", "permissions": {}}
|
||||
|
||||
try:
|
||||
# Try to decode as JWT
|
||||
payload = jwt.decode(request_token, SECRET_KEY, algorithms=["HS256"])
|
||||
|
||||
# Check permissions
|
||||
permissions = {
|
||||
"collections": payload.get("permissions", {}).get("collections", []),
|
||||
"bookmarks": payload.get("permissions", {}).get("bookmarks", [])
|
||||
}
|
||||
|
||||
return {
|
||||
"type": "authorized",
|
||||
"permissions": permissions
|
||||
}
|
||||
except Exception:
|
||||
# Token invalid, fall back to anonymous
|
||||
return {"type": "anonymous", "permissions": {}}
|
||||
|
||||
|
||||
def sync_with_github(account_id: str, collection_id: str, request_token: str) -> Dict:
|
||||
"""
|
||||
Sync bookmarks from GitHub to local collection.
|
||||
|
||||
Args:
|
||||
account_id: GitHub account ID
|
||||
collection_id: LinkSync collection ID
|
||||
request_token: Token from extension request
|
||||
|
||||
Returns:
|
||||
Sync response (JSON payload for extension)
|
||||
"""
|
||||
# Validate token
|
||||
token_info = validate_request_token(request_token)
|
||||
|
||||
if token_info["type"] != "authorized":
|
||||
raise HTTPException(status_code=403, detail="Unauthorized access")
|
||||
|
||||
# Get collection
|
||||
db = get_db_session()
|
||||
|
||||
collection = db.query(Collection).filter(Collection.id == collection_id).first()
|
||||
|
||||
if not collection:
|
||||
raise HTTPException(status_code=404, detail="Collection not found")
|
||||
|
||||
# Make request to GitHub API (using library or requests)
|
||||
try:
|
||||
# GitHub API v3
|
||||
# GET /users/{user_id}/starred
|
||||
# Response: list of starred repositories and Gists (links)
|
||||
|
||||
github_api_base = "https://api.github.com"
|
||||
starred_response = requests.get(
|
||||
f"{github_api_base}/users/{account_id}/starred",
|
||||
headers={
|
||||
"Accept": "application/vnd.github.v3+json"
|
||||
}
|
||||
)
|
||||
|
||||
if starred_response.status_code != 200:
|
||||
raise HTTPException(status_code=502, detail="Failed to fetch GitHub data")
|
||||
|
||||
github_links = starred_response.json()
|
||||
|
||||
# Parse GitHub data
|
||||
github_bookmarks = sync_parser.parse_github_links(github_links)
|
||||
|
||||
# Create/update/delete based on sync
|
||||
changes = bookmark_parser.parse_sync(
|
||||
github_bookmarks, collection_id
|
||||
)
|
||||
|
||||
# Commit changes
|
||||
db.commit()
|
||||
|
||||
# Build response
|
||||
sync_response = {
|
||||
"_links": {
|
||||
"sync": {
|
||||
"_links": {
|
||||
"self": {}
|
||||
}
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"account_id": account_id,
|
||||
"collections": [collection_id],
|
||||
"changes": changes,
|
||||
"total_synced": len(github_links)
|
||||
}
|
||||
}
|
||||
|
||||
return sync_response
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Sync error: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
@@ -2,17 +2,54 @@
|
||||
LinkSyncServer - Main Application
|
||||
"""
|
||||
|
||||
import os
|
||||
from fastapi import FastAPI
|
||||
from routes import router as api_router
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from api.routes import router as api_router
|
||||
from config.settings import settings
|
||||
from models.base import Base, get_engine
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
engine = get_engine()
|
||||
Base.metadata.create_all(engine)
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="LinkSyncServer",
|
||||
description="Self-hosted bookmark server with collections",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
cors_origins = [o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()]
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(api_router)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {"status": "ok"}
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index(request):
|
||||
return templates.TemplateResponse("index.html", {"request": request})
|
||||
|
||||
31
LinkSyncServer/config/settings.py
Normal file
31
LinkSyncServer/config/settings.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
LinkSyncServer - Application Settings
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
class Settings:
|
||||
DATABASE_URL: str = os.environ.get(
|
||||
"DATABASE_URL", "sqlite:///linksync.db"
|
||||
)
|
||||
SECRET_KEY: str = os.environ.get("SECRET_KEY", "dev-secret-key-change-in-production")
|
||||
ADMIN_USERNAME: str = os.environ.get("ADMIN_USERNAME", "admin")
|
||||
ADMIN_PASSWORD: str = os.environ.get("ADMIN_PASSWORD", "admin123")
|
||||
DEBUG: bool = os.environ.get("DEBUG", "False").lower() in ("true", "1", "yes")
|
||||
HOST: str = os.environ.get("HOST", "0.0.0.0")
|
||||
PORT: int = int(os.environ.get("PORT", "5000"))
|
||||
CORS_ORIGINS: str = os.environ.get("CORS_ORIGINS", "http://localhost:5555")
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 1440
|
||||
BCRYPT_COST_FACTOR: int = 12
|
||||
RATE_LIMIT_REQUESTS: int = 100
|
||||
RATE_LIMIT_WINDOW: int = 60
|
||||
LOGIN_RATE_LIMIT: int = 10
|
||||
LOGIN_RATE_LIMIT_WINDOW: int = 3600
|
||||
|
||||
|
||||
settings = Settings()
|
||||
105
LinkSyncServer/deploy.ps1
Normal file
105
LinkSyncServer/deploy.ps1
Normal file
@@ -0,0 +1,105 @@
|
||||
#Requires -Version 7
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Prepares a deployment package for LinkSyncServer.
|
||||
|
||||
.DESCRIPTION
|
||||
Copies only the files needed for production deployment to a target folder,
|
||||
excludes development artifacts, and creates a starter .env file.
|
||||
After running, the user should edit the .env file with production secrets
|
||||
and run docker-compose up -d --build in the target folder.
|
||||
|
||||
.PARAMETER DeployPath
|
||||
Path to the deployment folder. Will be created if it does not exist.
|
||||
|
||||
.EXAMPLE
|
||||
.\deploy.ps1 -DeployPath "..\linksync-deploy"
|
||||
.\deploy.ps1 ..\linksync-deploy
|
||||
#>
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory = $true, Position = 0)]
|
||||
[string]$DeployPath
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$SourceDir = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path }
|
||||
|
||||
# Patterns excluded from deployment
|
||||
|
||||
Write-Host "LinkSyncServer - Deploy Script" -ForegroundColor Cyan
|
||||
Write-Host " Source: $SourceDir" -ForegroundColor Gray
|
||||
Write-Host " Deploy to: $DeployPath" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
|
||||
# Resolve to absolute path
|
||||
if (Test-Path $DeployPath) {
|
||||
$DeployPath = (Get-Item $DeployPath).FullName
|
||||
}
|
||||
else {
|
||||
$DeployPath = (New-Item -ItemType Directory -Force -Path $DeployPath).FullName
|
||||
}
|
||||
|
||||
# Clean target folder if it exists and has content
|
||||
if (Test-Path $DeployPath) {
|
||||
$existing = Get-ChildItem -Path $DeployPath -Force -ErrorAction SilentlyContinue
|
||||
if ($existing) {
|
||||
$confirm = Read-Host "Target folder already exists. Clear it? (y/N)"
|
||||
if ($confirm -ne "y") {
|
||||
Write-Host "Aborted." -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
Remove-Item -Path "$DeployPath\*" -Recurse -Force
|
||||
}
|
||||
}
|
||||
else {
|
||||
New-Item -ItemType Directory -Force -Path $DeployPath | Out-Null
|
||||
}
|
||||
|
||||
Write-Host "Copying files..." -ForegroundColor Gray
|
||||
|
||||
# Build robocopy arguments
|
||||
$robocopyArgs = @(
|
||||
$SourceDir,
|
||||
$DeployPath,
|
||||
"/E",
|
||||
"/NFL",
|
||||
"/NDL",
|
||||
"/NJH",
|
||||
"/NJS",
|
||||
"/NC",
|
||||
"/NS",
|
||||
"/NP",
|
||||
"/XD", "__pycache__", ".pytest_cache", ".git", ".vscode", ".idea",
|
||||
".mypy_cache", ".ruff_cache", "node_modules", "dist", "build", "tests",
|
||||
"/XF", "*.pyc", "*.pyo", "*.pyd", "*.db", "*.sqlite3",
|
||||
"*.egg-info", "deploy.ps1", "deploy.sh"
|
||||
)
|
||||
|
||||
$result = & robocopy @robocopyArgs
|
||||
|
||||
# robocopy exit codes: 0-7 are success, 8+ are errors
|
||||
$exitCode = $LASTEXITCODE
|
||||
if ($exitCode -ge 8) {
|
||||
Write-Host "Error during file copy (robocexit code: $exitCode)" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Copy .env.example as .env
|
||||
if (Test-Path "$SourceDir\.env.example") {
|
||||
Copy-Item "$SourceDir\.env.example" "$DeployPath\.env"
|
||||
Write-Host " Created .env from .env.example" -ForegroundColor Green
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "Deployment package prepared at: $DeployPath" -ForegroundColor Cyan
|
||||
Write-Host ""
|
||||
Write-Host "Next steps:" -ForegroundColor Yellow
|
||||
Write-Host " 1. Edit $DeployPath\.env with production secrets" -ForegroundColor Gray
|
||||
Write-Host " - Set DATABASE_URL (PostgreSQL connection string)" -ForegroundColor Gray
|
||||
Write-Host " - Set SECRET_KEY (generate: openssl rand -base64 32)" -ForegroundColor Gray
|
||||
Write-Host " - Set ADMIN_PASSWORD (strong password)" -ForegroundColor Gray
|
||||
Write-Host " 2. Run: cd $DeployPath" -ForegroundColor Gray
|
||||
Write-Host " 3. Run: docker-compose up -d --build" -ForegroundColor Gray
|
||||
Write-Host ""
|
||||
95
LinkSyncServer/deploy.sh
Normal file
95
LinkSyncServer/deploy.sh
Normal file
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# LinkSyncServer - Deploy Script
|
||||
#
|
||||
# Prepares a deployment package by copying only production files,
|
||||
# excluding development artifacts, and creating a starter .env file.
|
||||
#
|
||||
# Usage: ./deploy.sh <deploy_path>
|
||||
#
|
||||
# After running, edit the .env file with production secrets
|
||||
# and run: docker-compose up -d --build
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DEPLOY_PATH="${1:?Usage: $0 <deploy_path>}"
|
||||
|
||||
RED='\033[0;31m'
|
||||
CYAN='\033[0;36m'
|
||||
YELLOW='\033[1;33m'
|
||||
GRAY='\033[0;37m'
|
||||
GREEN='\033[0;32m'
|
||||
NC='\033[0m'
|
||||
|
||||
echo -e "${CYAN}LinkSyncServer - Deploy Script${NC}"
|
||||
echo -e "${GRAY} Source: $SOURCE_DIR${NC}"
|
||||
echo -e "${GRAY} Deploy to: $DEPLOY_PATH${NC}"
|
||||
echo ""
|
||||
|
||||
# Create target directory
|
||||
mkdir -p "$DEPLOY_PATH"
|
||||
|
||||
# Clean if existing content
|
||||
if [ "$(ls -A "$DEPLOY_PATH" 2>/dev/null)" ]; then
|
||||
read -p "Target folder already exists. Clear it? (y/N): " confirm
|
||||
if [ "$confirm" != "y" ]; then
|
||||
echo -e "${YELLOW}Aborted.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
rm -rf "${DEPLOY_PATH:?}"/*
|
||||
fi
|
||||
|
||||
echo -e "${GRAY}Copying files...${NC}"
|
||||
|
||||
# Exclusion patterns
|
||||
EXCLUDE=(
|
||||
"__pycache__"
|
||||
".pytest_cache"
|
||||
".git"
|
||||
".vscode"
|
||||
".idea"
|
||||
".mypy_cache"
|
||||
".ruff_cache"
|
||||
"node_modules"
|
||||
"dist"
|
||||
"build"
|
||||
"tests"
|
||||
"*.egg-info"
|
||||
"deploy.sh"
|
||||
)
|
||||
|
||||
# Build rsync exclude arguments
|
||||
RSYNC_EXCLUDE=()
|
||||
for pattern in "${EXCLUDE[@]}"; do
|
||||
RSYNC_EXCLUDE+=(--exclude="$pattern")
|
||||
done
|
||||
|
||||
# Use rsync to copy, excluding dev artifacts
|
||||
rsync -a "${RSYNC_EXCLUDE[@]}" \
|
||||
--exclude="*.pyc" \
|
||||
--exclude="*.pyo" \
|
||||
--exclude="*.pyd" \
|
||||
--exclude="*.db" \
|
||||
--exclude="*.sqlite3" \
|
||||
--exclude="deploy.ps1" \
|
||||
"$SOURCE_DIR/" "$DEPLOY_PATH/"
|
||||
|
||||
# Copy .env.example as .env
|
||||
if [ -f "$SOURCE_DIR/.env.example" ]; then
|
||||
cp "$SOURCE_DIR/.env.example" "$DEPLOY_PATH/.env"
|
||||
echo -e "${GREEN} Created .env from .env.example${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${CYAN}Deployment package prepared at: $DEPLOY_PATH${NC}"
|
||||
echo ""
|
||||
echo -e "${YELLOW}Next steps:${NC}"
|
||||
echo -e "${GRAY} 1. Edit $DEPLOY_PATH/.env with production secrets${NC}"
|
||||
echo -e "${GRAY} - Set DATABASE_URL (PostgreSQL connection string)${NC}"
|
||||
echo -e "${GRAY} - Set SECRET_KEY (generate: openssl rand -base64 32)${NC}"
|
||||
echo -e "${GRAY} - Set ADMIN_PASSWORD (strong password)${NC}"
|
||||
echo -e "${GRAY} 2. cd $DEPLOY_PATH${NC}"
|
||||
echo -e "${GRAY} 3. docker-compose up -d --build${NC}"
|
||||
echo ""
|
||||
@@ -0,0 +1,33 @@
|
||||
"""
|
||||
LinkSyncServer - Models Package
|
||||
"""
|
||||
|
||||
from models.base import (
|
||||
Base,
|
||||
get_engine,
|
||||
get_session,
|
||||
init_db,
|
||||
TimestampMixin,
|
||||
User,
|
||||
ApiKey,
|
||||
Tag,
|
||||
Bookmark,
|
||||
Collection,
|
||||
CollectionBookmark,
|
||||
AuditLog,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"get_engine",
|
||||
"get_session",
|
||||
"init_db",
|
||||
"TimestampMixin",
|
||||
"User",
|
||||
"ApiKey",
|
||||
"Tag",
|
||||
"Bookmark",
|
||||
"Collection",
|
||||
"CollectionBookmark",
|
||||
"AuditLog",
|
||||
]
|
||||
|
||||
@@ -2,81 +2,124 @@
|
||||
LinkSyncServer - Database Base Models
|
||||
"""
|
||||
|
||||
from sqlalchemy import create_engine, Column, Integer, String, Text, DateTime, Boolean, ForeignKey, Float, JSON, text
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
create_engine,
|
||||
Column,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
DateTime,
|
||||
Boolean,
|
||||
ForeignKey,
|
||||
JSON,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship, sessionmaker
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_engine():
|
||||
"""Get database engine from environment variable."""
|
||||
import os
|
||||
database_url = os.environ.get('DATABASE_URL', 'sqlite:///linksync.db')
|
||||
database_url = os.environ.get("DATABASE_URL", "sqlite:///linksync.db")
|
||||
return create_engine(database_url, echo=False, future=True)
|
||||
|
||||
|
||||
def get_session():
|
||||
"""Get a new database session."""
|
||||
engine = get_engine()
|
||||
Session = sessionmaker(bind=engine)
|
||||
return Session()
|
||||
|
||||
|
||||
def init_db():
|
||||
"""Initialize database tables."""
|
||||
Base.metadata.create_all()
|
||||
engine = get_engine()
|
||||
Base.metadata.create_all(engine)
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""Mixin for timestamps."""
|
||||
created_at = Column(DateTime, server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
created_at = Column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at = Column(
|
||||
DateTime, server_default=func.now(), onupdate=func.now(), nullable=False
|
||||
)
|
||||
|
||||
|
||||
class User(Base, TimestampMixin):
|
||||
"""User model for authentication."""
|
||||
__tablename__ = 'users'
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
username = Column(String(100), unique=True, nullable=False)
|
||||
email = Column(String(255), unique=True, nullable=False)
|
||||
username = Column(String(100), unique=True, nullable=False, index=True)
|
||||
email = Column(String(255), unique=True, nullable=False, index=True)
|
||||
password_hash = Column(String(255), nullable=False)
|
||||
role = Column(String(20), nullable=False, default='user')
|
||||
role = Column(String(20), nullable=False, default="user")
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# Relationships
|
||||
bookmarks = relationship('Bookmark', back_populates='user', foreign_keys='Bookmark.user_id')
|
||||
collections = relationship('Collection', back_populates='user', foreign_keys='Collection.created_by')
|
||||
api_keys = relationship('ApiKey', back_populates='user', foreign_keys='ApiKey.user_id')
|
||||
audit_logs = relationship('AuditLog', back_populates='user', foreign_keys='AuditLog.user_id')
|
||||
|
||||
bookmarks = relationship("Bookmark", back_populates="user")
|
||||
collections = relationship("Collection", back_populates="user")
|
||||
api_keys = relationship("ApiKey", back_populates="user")
|
||||
audit_logs = relationship("AuditLog", back_populates="user")
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"username": self.username,
|
||||
"email": self.email,
|
||||
"role": self.role,
|
||||
"is_active": self.is_active,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class ApiKey(Base, TimestampMixin):
|
||||
"""API Key for authentication."""
|
||||
__tablename__ = 'api_keys'
|
||||
|
||||
__tablename__ = "api_keys"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
user_id = Column(String(36), ForeignKey('users.id'), nullable=False, index=True)
|
||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
|
||||
key_hash = Column(String(255), nullable=False, unique=True)
|
||||
name = Column(String(100))
|
||||
expires_at = Column(DateTime)
|
||||
is_active = Column(Boolean, default=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', back_populates='api_keys')
|
||||
|
||||
user = relationship("User", back_populates="api_keys")
|
||||
|
||||
|
||||
class Tag(Base, TimestampMixin):
|
||||
"""Tag model for bookmarks."""
|
||||
__tablename__ = 'tags'
|
||||
|
||||
__tablename__ = "tags"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name = Column(String(100), unique=True, nullable=False)
|
||||
name = Column(String(100), unique=True, nullable=False, index=True)
|
||||
color = Column(String(7))
|
||||
description = Column(Text)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"color": self.color,
|
||||
"description": self.description,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class Bookmark(Base, TimestampMixin):
|
||||
"""Bookmark/Link model with Firefox-compatible fields."""
|
||||
__tablename__ = 'links'
|
||||
|
||||
__tablename__ = "links"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
url = Column(String(2048), nullable=False, index=True)
|
||||
title = Column(String(255), nullable=False)
|
||||
@@ -84,54 +127,81 @@ class Bookmark(Base, TimestampMixin):
|
||||
notes = Column(Text)
|
||||
tags = Column(JSON, default=list)
|
||||
favicon_url = Column(String(512))
|
||||
path = Column(String(512), nullable=True) # Folder structure path
|
||||
created_at = Column(DateTime, server_default=func.now(), nullable=False)
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), nullable=False)
|
||||
path = Column(String(512), nullable=True)
|
||||
visit_count = Column(Integer, default=0)
|
||||
is_bookmarked = Column(Boolean, default=False)
|
||||
source_set_id = Column(String(36), ForeignKey('links.id')) # Self-reference for duplicate tracking
|
||||
user_id = Column(String(36), ForeignKey('users.id'), nullable=True)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', back_populates='bookmarks')
|
||||
source_set = relationship('Bookmark', remote_side=id)
|
||||
source_set_id = Column(String(36), ForeignKey("links.id"))
|
||||
user_id = Column(String(36), ForeignKey("users.id"), nullable=True)
|
||||
|
||||
user = relationship("User", back_populates="bookmarks")
|
||||
source_set = relationship("Bookmark", remote_side=[id])
|
||||
collection_bookmarks = relationship("CollectionBookmark", back_populates="bookmark")
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"url": self.url,
|
||||
"title": self.title,
|
||||
"description": self.description,
|
||||
"notes": self.notes,
|
||||
"tags": self.tags or [],
|
||||
"favicon_url": self.favicon_url,
|
||||
"path": self.path,
|
||||
"visit_count": self.visit_count,
|
||||
"is_bookmarked": self.is_bookmarked,
|
||||
"source_set_id": self.source_set_id,
|
||||
"user_id": self.user_id,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class Collection(Base, TimestampMixin):
|
||||
"""Collection model for bookmark sets."""
|
||||
__tablename__ = 'collections'
|
||||
|
||||
__tablename__ = "collections"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
name = Column(String(200), nullable=False, unique=True)
|
||||
name = Column(String(200), nullable=False)
|
||||
description = Column(Text)
|
||||
query_type = Column(String(20), nullable=False) # 'static' or 'dynamic'
|
||||
query_expression = Column(JSON) # Parsed AST for dynamic collections
|
||||
query_type = Column(String(20), nullable=False)
|
||||
query_expression = Column(JSON)
|
||||
is_public = Column(Boolean, default=False)
|
||||
created_by = Column(String(36), ForeignKey('users.id'), nullable=False)
|
||||
|
||||
# Relationships
|
||||
user = relationship('User', back_populates='collections')
|
||||
bookmarks = relationship('CollectionBookmark', back_populates='collection')
|
||||
created_by = Column(String(36), ForeignKey("users.id"), nullable=False)
|
||||
|
||||
user = relationship("User", back_populates="collections")
|
||||
collection_bookmarks = relationship("CollectionBookmark", back_populates="collection")
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"query_type": self.query_type,
|
||||
"query_expression": self.query_expression,
|
||||
"is_public": self.is_public,
|
||||
"created_by": self.created_by,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class CollectionBookmark(Base, TimestampMixin):
|
||||
"""Junction table for static collections."""
|
||||
__tablename__ = 'collection_bookmarks'
|
||||
|
||||
collection_id = Column(String(36), ForeignKey('collections.id'), primary_key=True)
|
||||
bookmark_id = Column(String(36), ForeignKey('links.id'), primary_key=True)
|
||||
|
||||
# Relationships
|
||||
collection = relationship('Collection', back_populates='bookmarks')
|
||||
bookmark = relationship('Bookmark')
|
||||
__tablename__ = "collection_bookmarks"
|
||||
|
||||
collection_id = Column(String(36), ForeignKey("collections.id"), primary_key=True)
|
||||
bookmark_id = Column(String(36), ForeignKey("links.id"), primary_key=True)
|
||||
|
||||
collection = relationship("Collection", back_populates="collection_bookmarks")
|
||||
bookmark = relationship("Bookmark", back_populates="collection_bookmarks")
|
||||
|
||||
|
||||
class AuditLog(Base, TimestampMixin):
|
||||
"""Audit log for tracking changes."""
|
||||
__tablename__ = 'audit_log'
|
||||
|
||||
__tablename__ = "audit_log"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
user_id = Column(String(36), ForeignKey('users.id', ondelete='SET NULL'), nullable=True)
|
||||
user_id = Column(String(36), ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
|
||||
action = Column(String(100), nullable=False)
|
||||
entity_type = Column(String(50), nullable=False)
|
||||
entity_id = Column(String(36))
|
||||
@@ -139,6 +209,20 @@ class AuditLog(Base, TimestampMixin):
|
||||
new_value = Column(JSON)
|
||||
ip_address = Column(String(45))
|
||||
|
||||
user = relationship("User", back_populates="audit_logs")
|
||||
|
||||
# Create indexes
|
||||
__all__ = ['Base', 'User', 'ApiKey', 'Tag', 'Bookmark', 'Collection', 'CollectionBookmark', 'AuditLog']
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"get_engine",
|
||||
"get_session",
|
||||
"init_db",
|
||||
"TimestampMixin",
|
||||
"User",
|
||||
"ApiKey",
|
||||
"Tag",
|
||||
"Bookmark",
|
||||
"Collection",
|
||||
"CollectionBookmark",
|
||||
"AuditLog",
|
||||
]
|
||||
|
||||
@@ -18,7 +18,7 @@ dependencies = [
|
||||
"bcrypt==4.1.2",
|
||||
"jinja2==3.1.3",
|
||||
"pydantic==2.6.1",
|
||||
"starlette-cors==1.1.0",
|
||||
"bcrypt==4.1.2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -2,219 +2,99 @@
|
||||
LinkSyncServer - Query Executor
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any, Optional
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func, and_, or_
|
||||
import logging
|
||||
import sys
|
||||
sys.path.insert(0, 'models')
|
||||
from base import Bookmark, User
|
||||
from typing import Any, Dict, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_query_expression(query_expression: dict, expressions: list = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Parse query expression in dict format.
|
||||
|
||||
Example:
|
||||
{
|
||||
"operation": "OR",
|
||||
"operands": [
|
||||
{"operation": "TERM", "value": "work"},
|
||||
{"operation": "TERM", "value": "company"}
|
||||
]
|
||||
}
|
||||
"""
|
||||
if not query_expression:
|
||||
return
|
||||
|
||||
operation = query_expression.get('operation')
|
||||
operands = query_expression.get('operands', [])
|
||||
|
||||
if not operands:
|
||||
# Top-level expression (e.g., TERM)
|
||||
if operation == 'TERM':
|
||||
value = query_expression.get('value', '')
|
||||
if value.startswith('url:'):
|
||||
search_term = value[4:]
|
||||
return parse_term(search_term, 'url')
|
||||
elif value.startswith('tag:'):
|
||||
search_term = value[4:]
|
||||
return parse_term(search_term, 'tags')
|
||||
elif value.startswith('title:'):
|
||||
search_term = value[6:]
|
||||
return parse_term(search_term, 'title')
|
||||
elif value.startswith('description:'):
|
||||
search_term = value[12:]
|
||||
return parse_term(search_term, 'description')
|
||||
elif value.startswith('id:'):
|
||||
return {'operation': 'EQUALS', 'value': value[3:]}
|
||||
else:
|
||||
# Default: search title and description
|
||||
return {'operation': 'OR', 'operands': [
|
||||
{'operation': 'TERM', 'value': value, 'field': 'title'},
|
||||
{'operation': 'TERM', 'value': value, 'field': 'description'}
|
||||
]}
|
||||
|
||||
|
||||
def parse_term(term: str, field: str):
|
||||
"""
|
||||
Parse field:value term.
|
||||
|
||||
Returns SQLAlchemy filter clause.
|
||||
"""
|
||||
# Handle different field types
|
||||
field_filters = {
|
||||
'tags': lambda term: and_(*[Bookmark.tags.ilike(f'%{term}%') for tag in term.split(',')]),
|
||||
'title': lambda term: Bookmark.title.ilike(f'%{term}%'),
|
||||
'description': lambda term: Bookmark.description.ilike(f'%{term}%'),
|
||||
'url': lambda term: Bookmark.url.ilike(f'%{term}%'),
|
||||
'path': lambda term: Bookmark.path.ilike(f'%{term}%')
|
||||
}
|
||||
|
||||
# Get filter function
|
||||
filter_fn = field_filters.get(field, lambda term: Bookmark.tags.ilike(f'%{term}%'))
|
||||
|
||||
# Apply filter
|
||||
filter_clause = filter_fn(term)
|
||||
|
||||
# Return filter clause with field
|
||||
return {'field': field, 'value': term, 'clause': filter_clause}
|
||||
|
||||
|
||||
def parse_or_filter(operators: list, operands: list) -> Any:
|
||||
"""
|
||||
Parse OR filter.
|
||||
|
||||
Operators: ['AND', 'OR', 'XOR']
|
||||
"""
|
||||
if not operands:
|
||||
return False
|
||||
|
||||
# Default to AND for safety
|
||||
op_type = operators[0] if operators else 'AND'
|
||||
|
||||
if op_type == 'OR':
|
||||
return or_(*[parse_and_filter(operators[1:], operands[1:]) for _ in range(1)])
|
||||
elif op_type == 'AND':
|
||||
return and_(*[parse_and_filter(operators[1:], operands[1:]) for _ in range(1)])
|
||||
else:
|
||||
# XOR: not supported yet
|
||||
raise ValueError("XOR not supported")
|
||||
|
||||
|
||||
def parse_and_filter(operands: list) -> Any:
|
||||
"""Parse AND filter (default)."""
|
||||
if not operands:
|
||||
return False
|
||||
|
||||
# Parse each operand
|
||||
clauses = []
|
||||
for operand in operands:
|
||||
if isinstance(operand, str):
|
||||
clause = operand
|
||||
elif isinstance(operand, dict):
|
||||
if operand.get('operation') == 'EQUALS':
|
||||
clause = operand['value']
|
||||
elif operand.get('operation') == 'TERM':
|
||||
clauses.append(parse_term(operand.get('value', ''), operand.get('field', 'tags')))
|
||||
# Add other term types as needed
|
||||
else:
|
||||
clauses.append(operand)
|
||||
else:
|
||||
raise ValueError(f"Unknown operand type: {type(operand)}")
|
||||
|
||||
if not clauses:
|
||||
return False
|
||||
|
||||
return clauses
|
||||
|
||||
|
||||
def execute_query(query_expression: dict) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Execute query and return results.
|
||||
|
||||
query_expression: dict from parser
|
||||
returns: list of bookmarks
|
||||
"""
|
||||
# Default session
|
||||
session = Session()
|
||||
|
||||
if not query_expression:
|
||||
def execute_query(parsed: Dict[str, Any], bookmarks: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
if not parsed or not bookmarks:
|
||||
return []
|
||||
|
||||
# Parse query expression
|
||||
try:
|
||||
# Handle single-term queries
|
||||
if query_expression.get('operation') == 'TERM':
|
||||
search_term = query_expression.get('value', '')
|
||||
field = query_expression.get('field', 'title')
|
||||
|
||||
if field == 'tags':
|
||||
tags = search_term.split(',')
|
||||
filters = [Bookmark.tags.contains(tag) for tag in tags]
|
||||
result = session.query(Bookmark).filter(or_(*filters)).all()
|
||||
elif field == 'title':
|
||||
result = session.query(Bookmark).filter(Bookmark.title.contains(search_term)).all()
|
||||
elif field == 'description':
|
||||
result = session.query(Bookmark).filter(Bookmark.description.contains(search_term)).all()
|
||||
elif field == 'url':
|
||||
result = session.query(Bookmark).filter(Bookmark.url.contains(search_term)).all()
|
||||
else:
|
||||
# Default: search title and description
|
||||
filters = [
|
||||
or_(Bookmark.title.contains(search_term),
|
||||
Bookmark.description.contains(search_term))
|
||||
]
|
||||
result = session.query(Bookmark).filter(or_(*filters)).all()
|
||||
elif query_expression.get('operation') == 'AND':
|
||||
# AND clause
|
||||
clauses = parse_and_filter(query_expression.get('operands', []))
|
||||
if isinstance(clauses, list):
|
||||
result = session.query(Bookmark).filter(and_(*clauses)).all()
|
||||
else:
|
||||
result = session.query(Bookmark).filter(clauses).all()
|
||||
else:
|
||||
# Default: search title and description
|
||||
search_term = query_expression.get('value', '')
|
||||
result = session.query(Bookmark).filter(
|
||||
or_(Bookmark.title.contains(search_term),
|
||||
Bookmark.description.contains(search_term))
|
||||
).all()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Query execution error: {e}")
|
||||
result = []
|
||||
|
||||
return result
|
||||
|
||||
result_ids = _evaluate_node(parsed, bookmarks)
|
||||
return [b for b in bookmarks if b["id"] in result_ids]
|
||||
|
||||
|
||||
def create_bookmarks_from_sync(sync_data: dict):
|
||||
"""
|
||||
Create bookmarks from sync response.
|
||||
|
||||
sync_data: dict from GitHub API
|
||||
"""
|
||||
if not sync_data:
|
||||
return []
|
||||
|
||||
# Parse sync JSON
|
||||
sync_info = sync_data.get('_links', {}).get('sync', {}).get('_links', {})
|
||||
|
||||
# Extract bookmarks
|
||||
bookmarks = []
|
||||
if 'objects' in sync_data:
|
||||
for obj in sync_data['objects']:
|
||||
if 'title' in obj:
|
||||
bookmarks.append({
|
||||
'url': obj.get('url', ''),
|
||||
'title': obj.get('title', ''),
|
||||
'description': obj.get('description', ''),
|
||||
'tags': obj.get('tags', []),
|
||||
'favicon_url': obj.get('favicon_url', ''),
|
||||
'path': obj.get('path', ''),
|
||||
'visit_count': obj.get('visit_count', 0)
|
||||
})
|
||||
|
||||
return bookmarks
|
||||
def _evaluate_node(node: Dict[str, Any], bookmarks: List[Dict[str, Any]]) -> set:
|
||||
operation = node.get("operation", "")
|
||||
|
||||
if operation == "OR":
|
||||
operands = node.get("operands", [])
|
||||
if not operands:
|
||||
return set()
|
||||
result = _evaluate_node(operands[0], bookmarks)
|
||||
for operand in operands[1:]:
|
||||
result |= _evaluate_node(operand, bookmarks)
|
||||
return result
|
||||
|
||||
if operation == "AND":
|
||||
operands = node.get("operands", [])
|
||||
if not operands:
|
||||
return set()
|
||||
result = _evaluate_node(operands[0], bookmarks)
|
||||
for operand in operands[1:]:
|
||||
result &= _evaluate_node(operand, bookmarks)
|
||||
return result
|
||||
|
||||
if operation == "XOR":
|
||||
operands = node.get("operands", [])
|
||||
if not operands:
|
||||
return set()
|
||||
result = _evaluate_node(operands[0], bookmarks)
|
||||
for operand in operands[1:]:
|
||||
result ^= _evaluate_node(operand, bookmarks)
|
||||
return result
|
||||
|
||||
if operation == "TERM":
|
||||
value = node.get("value", "").lower()
|
||||
return {
|
||||
b["id"]
|
||||
for b in bookmarks
|
||||
if value in b.get("title", "").lower()
|
||||
or value in b.get("description", "").lower()
|
||||
or value in b.get("url", "").lower()
|
||||
or value in b.get("notes", "").lower()
|
||||
}
|
||||
|
||||
if operation == "TERM_SET":
|
||||
terms = node.get("value", [])
|
||||
terms_lower = [t.lower() for t in terms]
|
||||
result = set()
|
||||
for b in bookmarks:
|
||||
text = (
|
||||
f"{b.get('title', '')} {b.get('description', '')} {b.get('url', '')} {b.get('notes', '')}"
|
||||
).lower()
|
||||
if any(term in text for term in terms_lower):
|
||||
result.add(b["id"])
|
||||
return result
|
||||
|
||||
if operation.startswith("FIELD:"):
|
||||
field = operation.split(":", 1)[1].upper()
|
||||
value = node.get("value", "").lower()
|
||||
return _evaluate_field(field, value, bookmarks)
|
||||
|
||||
logger.warning(f"Unknown operation: {operation}")
|
||||
return set()
|
||||
|
||||
|
||||
def _evaluate_field(field: str, value: str, bookmarks: List[Dict[str, Any]]) -> set:
|
||||
if field == "URL":
|
||||
return {b["id"] for b in bookmarks if value in b.get("url", "").lower()}
|
||||
if field == "TAG":
|
||||
return {
|
||||
b["id"]
|
||||
for b in bookmarks
|
||||
if any(value in t.lower() for t in (b.get("tags") or []))
|
||||
}
|
||||
if field == "TITLE":
|
||||
return {b["id"] for b in bookmarks if value in b.get("title", "").lower()}
|
||||
if field == "DESCRIPTION":
|
||||
return {b["id"] for b in bookmarks if value in b.get("description", "").lower()}
|
||||
if field == "PATH":
|
||||
return {b["id"] for b in bookmarks if value in (b.get("path") or "").lower()}
|
||||
if field == "ID":
|
||||
return {b["id"] for b in bookmarks if b.get("id") == value}
|
||||
|
||||
logger.warning(f"Unknown field: {field}")
|
||||
return set()
|
||||
|
||||
@@ -2,17 +2,18 @@
|
||||
LinkSyncServer - Query Parser for Expression Parser
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Union, Dict, List, Any
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
class TokenType(Enum):
|
||||
OPERATOR = "OPERATOR"
|
||||
TERM = "TERM"
|
||||
VALUE = "VALUE"
|
||||
FIELD = "FIELD"
|
||||
LPAREN = "LPAREN"
|
||||
RPAREN = "RPAREN"
|
||||
COLON = "COLON"
|
||||
COMMA = "COMMA"
|
||||
|
||||
|
||||
class Token:
|
||||
@@ -27,325 +28,232 @@ class Token:
|
||||
|
||||
|
||||
class QuerySyntaxError(Exception):
|
||||
"""Syntax error in query expression."""
|
||||
def __init__(self, message: str, line: int = None, column: int = None):
|
||||
self.message = message
|
||||
self.line = line
|
||||
self.column = column
|
||||
super().__init__(f"{message} at line {line}, column {column}" if line and column else message)
|
||||
if line and column:
|
||||
super().__init__(f"{message} at line {line}, column {column}")
|
||||
else:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
def lex(expression: str) -> List[Token]:
|
||||
"""
|
||||
Lexical analysis - convert string to tokens.
|
||||
|
||||
Grammar:
|
||||
expression := query_item (OP query_item)*
|
||||
query_item := (expression) | value | term
|
||||
term := OP | value
|
||||
value := url:value | tag:value | title:value | description:value | id:value
|
||||
"""
|
||||
tokens = []
|
||||
pos = 0
|
||||
|
||||
# Operators
|
||||
operators = ['AND', 'OR', 'XOR']
|
||||
|
||||
line = 1
|
||||
column = 1
|
||||
|
||||
while pos < len(expression):
|
||||
# Skip whitespace
|
||||
if expression[pos].isspace():
|
||||
ch = expression[pos]
|
||||
|
||||
if ch in " \t":
|
||||
pos += 1
|
||||
column += 1
|
||||
continue
|
||||
|
||||
if ch == "\n":
|
||||
line += 1
|
||||
column = 1
|
||||
pos += 1
|
||||
continue
|
||||
|
||||
# Check for parentheses
|
||||
if expression[pos] == '(':
|
||||
tokens.append(Token(TokenType.LPAREN, '('))
|
||||
|
||||
if ch == "(":
|
||||
tokens.append(Token(TokenType.LPAREN, "(", line, column))
|
||||
pos += 1
|
||||
column += 1
|
||||
continue
|
||||
|
||||
if expression[pos] == ')':
|
||||
tokens.append(Token(TokenType.RPAREN, ')'))
|
||||
|
||||
if ch == ")":
|
||||
tokens.append(Token(TokenType.RPAREN, ")", line, column))
|
||||
pos += 1
|
||||
column += 1
|
||||
continue
|
||||
|
||||
# Check for operators (AND, OR, XOR)
|
||||
if expression[pos:pos+4] == 'AND':
|
||||
tokens.append(Token(TokenType.OPERATOR, 'AND'))
|
||||
pos += 4
|
||||
|
||||
if ch == ",":
|
||||
tokens.append(Token(TokenType.COMMA, ",", line, column))
|
||||
pos += 1
|
||||
column += 1
|
||||
continue
|
||||
|
||||
if expression[pos:pos+3] == 'OR':
|
||||
tokens.append(Token(TokenType.OPERATOR, 'OR'))
|
||||
|
||||
if expression[pos:].startswith("AND"):
|
||||
tokens.append(Token(TokenType.OPERATOR, "AND", line, column))
|
||||
pos += 3
|
||||
column += 3
|
||||
continue
|
||||
|
||||
if expression[pos:pos+4] == 'XOR':
|
||||
tokens.append(Token(TokenType.OPERATOR, 'XOR'))
|
||||
pos += 4
|
||||
|
||||
if expression[pos:].startswith("OR"):
|
||||
tokens.append(Token(TokenType.OPERATOR, "OR", line, column))
|
||||
pos += 2
|
||||
column += 2
|
||||
continue
|
||||
|
||||
# Check for url: prefix
|
||||
if expression[pos:pos+4] == 'url:':
|
||||
pos += 4
|
||||
# Find end of URL
|
||||
end = expression.find(':', pos)
|
||||
if end == -1 and expression[pos] == '://':
|
||||
# Find end of URL (next space or end of string)
|
||||
end = expression.find(' ', pos)
|
||||
if end == -1:
|
||||
end = len(expression)
|
||||
|
||||
tokens.append(Token(TokenType.TERM, expression[pos:end]))
|
||||
pos = end
|
||||
|
||||
if expression[pos:].startswith("XOR"):
|
||||
tokens.append(Token(TokenType.OPERATOR, "XOR", line, column))
|
||||
pos += 3
|
||||
column += 3
|
||||
continue
|
||||
|
||||
# Check for tag: prefix
|
||||
if expression[pos:pos+5] == 'tag:':
|
||||
pos += 5
|
||||
end = expression.find(':', pos)
|
||||
if end == -1:
|
||||
end = len(expression)
|
||||
tokens.append(Token(TokenType.TERM, expression[pos:end]))
|
||||
pos = end
|
||||
continue
|
||||
|
||||
# Check for title: or description: prefixes
|
||||
if expression[pos:pos+6] in ['title:', 'description:']:
|
||||
field = 'title' if expression[pos:pos+6] == 'title:' else 'description'
|
||||
pos += 6
|
||||
end = expression.find(':', pos)
|
||||
if end == -1 and expression[pos] == ':' :
|
||||
end = len(expression)
|
||||
|
||||
tokens.append(Token(TokenType.TERM, expression[pos:end]))
|
||||
pos = end
|
||||
continue
|
||||
|
||||
# Check for colon (key:value)
|
||||
if expression[pos] == ':':
|
||||
|
||||
if ch in ("'", '"'):
|
||||
quote = ch
|
||||
pos += 1
|
||||
# Get field name (key)
|
||||
field = expression[pos]
|
||||
pos += 1
|
||||
# Get value
|
||||
end = expression.find(' ', pos)
|
||||
if end == -1:
|
||||
end = len(expression)
|
||||
token_val = expression[pos:end].strip('"\'')
|
||||
tokens.append(Token(TokenType.VALUE, f'{field}:{token_val}'))
|
||||
continue
|
||||
|
||||
# Regular term - alphanumeric
|
||||
if expression[pos].isalnum() or expression[pos] in '-_':
|
||||
column += 1
|
||||
start = pos
|
||||
while pos < len(expression) and (expression[pos].isalnum() or expression[pos] in '-_./?=?&'):
|
||||
while pos < len(expression) and expression[pos] != quote:
|
||||
pos += 1
|
||||
tokens.append(Token(TokenType.TERM, expression[start:pos]))
|
||||
value = expression[start:pos]
|
||||
tokens.append(Token(TokenType.TERM, value, line, column))
|
||||
pos += 1
|
||||
column += len(value) + 1
|
||||
continue
|
||||
|
||||
# Unknown character - skip or error
|
||||
|
||||
if ch.isalnum() or ch in "-_.":
|
||||
start = pos
|
||||
start_col = column
|
||||
while pos < len(expression) and (expression[pos].isalnum() or expression[pos] in "-_.:/?&=%"):
|
||||
pos += 1
|
||||
value = expression[start:pos]
|
||||
|
||||
if ":" in value:
|
||||
field, _, field_value = value.partition(":")
|
||||
if field in ("url", "tag", "title", "description", "path", "id"):
|
||||
tokens.append(Token(TokenType.FIELD, field.upper(), line, start_col))
|
||||
tokens.append(Token(TokenType.TERM, field_value, line, start_col + len(field) + 1))
|
||||
column += pos - start
|
||||
continue
|
||||
|
||||
tokens.append(Token(TokenType.TERM, value, line, start_col))
|
||||
column += pos - start
|
||||
continue
|
||||
|
||||
pos += 1
|
||||
|
||||
column += 1
|
||||
|
||||
return tokens
|
||||
|
||||
|
||||
class ASTNode:
|
||||
"""Abstract Syntax Tree Node."""
|
||||
def __init__(self, operator: str, children: List[Union[ASTNode, str, dict]] = None):
|
||||
self.operator = operator
|
||||
self.children = children if children else []
|
||||
|
||||
def __init__(self, node_type: str, value: Any = None, children: Optional[List["ASTNode"]] = None):
|
||||
self.node_type = node_type
|
||||
self.value = value
|
||||
self.children = children or []
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
if self.children:
|
||||
return {
|
||||
"operation": self.node_type,
|
||||
"operands": [child.to_dict() for child in self.children],
|
||||
}
|
||||
if self.value is not None:
|
||||
return {"operation": self.node_type, "value": self.value}
|
||||
return {"operation": self.node_type}
|
||||
|
||||
def __repr__(self):
|
||||
return f"AST({self.operator}, {self.children})"
|
||||
|
||||
|
||||
def parse_operator(token: Token) -> str:
|
||||
"""Convert operator token to Python operator string."""
|
||||
if token.type != TokenType.OPERATOR:
|
||||
raise QuerySyntaxError(f"Expected operator, got {token.value}")
|
||||
|
||||
if token.value == 'AND':
|
||||
return 'and'
|
||||
elif token.value == 'OR':
|
||||
return 'or'
|
||||
elif token.value == 'XOR':
|
||||
return 'xor'
|
||||
else:
|
||||
raise QuerySyntaxError(f"Unknown operator: {token.value}")
|
||||
return f"ASTNode({self.node_type}, {self.value!r}, {self.children})"
|
||||
|
||||
|
||||
class QueryParser:
|
||||
"""Parser for query expressions."""
|
||||
|
||||
def __init__(self):
|
||||
self.tokens = []
|
||||
self.pos = 0
|
||||
self.current_token = None
|
||||
self.error = False
|
||||
|
||||
def error(self, message: str):
|
||||
"""Record and return error."""
|
||||
self.error = True
|
||||
return QuerySyntaxError(message)
|
||||
|
||||
def parse_expression(self) -> List[ASTNode]:
|
||||
"""Parse top-level expression (list of clauses)."""
|
||||
if not self.tokens:
|
||||
return []
|
||||
|
||||
expressions = []
|
||||
|
||||
# Parse first clause
|
||||
expr = self.parse_or()
|
||||
if expr:
|
||||
expressions.append(expr)
|
||||
|
||||
# Parse remaining clauses
|
||||
while self.current_token and self.current_token.value in ['AND', 'OR', 'XOR']:
|
||||
operator = self.current_token.value
|
||||
self.pos += 1
|
||||
expressions.append(operator)
|
||||
expr2 = self.parse_or()
|
||||
if expr2:
|
||||
expressions.append(expr2)
|
||||
|
||||
return expressions
|
||||
|
||||
def parse_or(self) -> Union[ASTNode, None]:
|
||||
"""Parse OR clause."""
|
||||
if not self.current_token:
|
||||
self.tokens: List[Token] = []
|
||||
self.pos: int = 0
|
||||
|
||||
def _current(self) -> Optional[Token]:
|
||||
if self.pos < len(self.tokens):
|
||||
return self.tokens[self.pos]
|
||||
return None
|
||||
|
||||
def _advance(self) -> Optional[Token]:
|
||||
token = self._current()
|
||||
self.pos += 1
|
||||
return token
|
||||
|
||||
def _expect(self, token_type: TokenType, value: str = None) -> Token:
|
||||
token = self._current()
|
||||
if token is None:
|
||||
raise QuerySyntaxError(f"Expected {token_type.value}, got end of input")
|
||||
if token.type != token_type:
|
||||
raise QuerySyntaxError(f"Expected {token_type.value}, got {token.type.value}")
|
||||
if value is not None and token.value != value:
|
||||
raise QuerySyntaxError(f"Expected '{value}', got '{token.value}'")
|
||||
return self._advance()
|
||||
|
||||
def parse(self, expression: str) -> Optional[Dict[str, Any]]:
|
||||
if not expression or not expression.strip():
|
||||
return None
|
||||
|
||||
return self.parse_and()
|
||||
|
||||
def parse_and(self) -> Union[ASTNode, None]:
|
||||
"""Parse AND clause."""
|
||||
left = self.parse_xor()
|
||||
|
||||
while self.current_token and self.current_token.value == 'OR':
|
||||
operator = self.parse_operator(self.current_token)
|
||||
right = self.parse_xor()
|
||||
left = ASTNode(operator, [left, right])
|
||||
|
||||
return left
|
||||
|
||||
def parse_xor(self) -> Union[ASTNode, None]:
|
||||
"""Parse XOR clause."""
|
||||
left = self.parse_term()
|
||||
|
||||
while self.current_token and self.current_token.value == 'AND':
|
||||
operator = self.parse_operator(self.current_token)
|
||||
right = self.parse_term()
|
||||
left = ASTNode(operator, [left, right])
|
||||
|
||||
return left
|
||||
|
||||
def parse_term(self):
|
||||
"""Parse term."""
|
||||
if self.error:
|
||||
return None
|
||||
|
||||
if self.pos >= len(self.tokens):
|
||||
return None
|
||||
|
||||
token = self.current_token
|
||||
|
||||
# Check for parentheses (subexpression)
|
||||
if token and token.value == '(':
|
||||
self.pos += 1
|
||||
self.current_token = self.tokens[self.pos] if self.pos < len(self.tokens) else None
|
||||
sub_expr = self.parse_expression()
|
||||
if not sub_expr and not self.error:
|
||||
return None
|
||||
if self.error:
|
||||
return None
|
||||
if self.current_token and self.current_token.value == ')':
|
||||
self.pos += 1
|
||||
return sub_expr
|
||||
elif token and token.value != ')':
|
||||
return token
|
||||
|
||||
def parse_value(self) -> Union[None, str]:
|
||||
"""Parse value term."""
|
||||
if self.error:
|
||||
return None
|
||||
|
||||
token = self.current_token
|
||||
if not token or token.type != TokenType.TERM:
|
||||
return None
|
||||
|
||||
# Extract URL, TAG, etc.
|
||||
term = token.value
|
||||
|
||||
# Check for url: value
|
||||
if term.startswith('url:'):
|
||||
query = {'operation': 'TERM', 'value': term[4:]}
|
||||
self.pos += 1
|
||||
self.current_token = self.tokens[self.pos] if self.pos < len(self.tokens) else None
|
||||
return query
|
||||
elif term.startswith('tag:'):
|
||||
query = {'operation': 'TERM', 'value': term[4:]}
|
||||
self.pos += 1
|
||||
self.current_token = self.tokens[self.pos] if self.pos < len(self.tokens) else None
|
||||
return query
|
||||
elif term.startswith('title:'):
|
||||
query = {'operation': 'TERM', 'value': term[6:]}
|
||||
self.pos += 1
|
||||
self.current_token = self.tokens[self.pos] if self.pos < len(self.tokens) else None
|
||||
return query
|
||||
elif term.startswith('description:'):
|
||||
query = {'operation': 'TERM', 'value': term[12:]}
|
||||
self.pos += 1
|
||||
self.current_token = self.tokens[self.pos] if self.pos < len(self.tokens) else None
|
||||
return query
|
||||
elif term.startswith('id:'):
|
||||
query = {'operation': 'EQUALS', 'value': term[3:]}
|
||||
self.pos += 1
|
||||
self.current_token = self.tokens[self.pos] if self.pos < len(self.tokens) else None
|
||||
return query
|
||||
elif term.startswith('"') or term.startswith("'"):
|
||||
# Direct value
|
||||
return term
|
||||
else:
|
||||
self.error(f"Unknown term: {term}")
|
||||
return None
|
||||
|
||||
def parse(self, expression: str) -> List[ASTNode]:
|
||||
"""Parse complete expression."""
|
||||
if not expression:
|
||||
return []
|
||||
|
||||
# Check for empty expression
|
||||
if not expression.strip():
|
||||
return []
|
||||
|
||||
# Lexical analysis
|
||||
|
||||
self.tokens = lex(expression)
|
||||
self.pos = 0
|
||||
self.current_token = self.tokens[0] if self.tokens else None
|
||||
|
||||
|
||||
if not self.tokens:
|
||||
return []
|
||||
|
||||
# Parse expression into AST
|
||||
expr = self.parse_expression()
|
||||
|
||||
# Return AST as dict
|
||||
return [self.ast_to_dict(node) for node in expr] if expr else []
|
||||
|
||||
def ast_to_dict(self, node, indent=0):
|
||||
"""Convert AST node to dict representation."""
|
||||
if isinstance(node, ASTNode):
|
||||
if node.children:
|
||||
return {
|
||||
"operation": node.operator,
|
||||
"operands": [self.ast_to_dict(child, indent + 1) for child in node.children]
|
||||
}
|
||||
else:
|
||||
return node.value
|
||||
elif isinstance(node, str):
|
||||
return None
|
||||
|
||||
node = self._parse_or()
|
||||
|
||||
if self._current() is not None:
|
||||
raise QuerySyntaxError(f"Unexpected token: {self._current().value}")
|
||||
|
||||
return node.to_dict() if node else None
|
||||
|
||||
def _parse_or(self) -> ASTNode:
|
||||
left = self._parse_and()
|
||||
while self._current() and self._current().type == TokenType.OPERATOR and self._current().value == "OR":
|
||||
self._advance()
|
||||
right = self._parse_and()
|
||||
left = ASTNode("OR", children=[left, right])
|
||||
return left
|
||||
|
||||
def _parse_and(self) -> ASTNode:
|
||||
left = self._parse_xor()
|
||||
while self._current() and self._current().type == TokenType.OPERATOR and self._current().value == "AND":
|
||||
self._advance()
|
||||
right = self._parse_xor()
|
||||
left = ASTNode("AND", children=[left, right])
|
||||
return left
|
||||
|
||||
def _parse_xor(self) -> ASTNode:
|
||||
left = self._parse_primary()
|
||||
while self._current() and self._current().type == TokenType.OPERATOR and self._current().value == "XOR":
|
||||
self._advance()
|
||||
right = self._parse_primary()
|
||||
left = ASTNode("XOR", children=[left, right])
|
||||
return left
|
||||
|
||||
def _parse_primary(self) -> ASTNode:
|
||||
token = self._current()
|
||||
if token is None:
|
||||
raise QuerySyntaxError("Unexpected end of input")
|
||||
|
||||
if token.type == TokenType.LPAREN:
|
||||
self._advance()
|
||||
node = self._parse_or()
|
||||
self._expect(TokenType.RPAREN)
|
||||
return node
|
||||
elif isinstance(node, dict):
|
||||
return node
|
||||
else:
|
||||
return str(node)
|
||||
|
||||
if token.type == TokenType.FIELD:
|
||||
field_token = self._advance()
|
||||
value_token = self._current()
|
||||
if value_token and value_token.type == TokenType.TERM:
|
||||
self._advance()
|
||||
return ASTNode(f"FIELD:{field_token.value}", value=value_token.value)
|
||||
return ASTNode(f"FIELD:{field_token.value}", value="")
|
||||
|
||||
if token.type == TokenType.TERM:
|
||||
self._advance()
|
||||
return self._parse_term(token)
|
||||
|
||||
raise QuerySyntaxError(f"Unexpected token: {token.value}")
|
||||
|
||||
def _parse_term(self, token: Token) -> ASTNode:
|
||||
next_token = self._current()
|
||||
|
||||
if next_token and next_token.type == TokenType.COMMA:
|
||||
terms = [token.value]
|
||||
while self._current() and self._current().type == TokenType.COMMA:
|
||||
self._advance()
|
||||
term_token = self._current()
|
||||
if term_token and term_token.type == TokenType.TERM:
|
||||
terms.append(term_token.value)
|
||||
self._advance()
|
||||
return ASTNode("TERM_SET", value=terms)
|
||||
|
||||
return ASTNode("TERM", value=token.value)
|
||||
|
||||
@@ -22,8 +22,7 @@ pydantic==2.6.1
|
||||
pydantic-settings==2.1.0
|
||||
email-validator==2.1.0
|
||||
|
||||
# CORS
|
||||
starlette-cors==1.1.0
|
||||
# CORS (included in FastAPI/Starlette)
|
||||
|
||||
# Security
|
||||
passlib==1.7.4
|
||||
|
||||
210
LinkSyncServer/static/css/main.css
Normal file
210
LinkSyncServer/static/css/main.css
Normal file
@@ -0,0 +1,210 @@
|
||||
:root {
|
||||
--primary: #2563eb;
|
||||
--primary-hover: #1d4ed8;
|
||||
--secondary: #64748b;
|
||||
--bg: #f8fafc;
|
||||
--surface: #ffffff;
|
||||
--text: #1e293b;
|
||||
--text-muted: #64748b;
|
||||
--border: #e2e8f0;
|
||||
--success: #22c55e;
|
||||
--error: #ef4444;
|
||||
--radius: 8px;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-brand a {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: var(--text-muted);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-links a:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.hero {
|
||||
text-align: center;
|
||||
padding: 4rem 1rem;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
font-size: 1.125rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: var(--radius);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--secondary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin: 3rem 0;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.card h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.card p {
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
list-style: none;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.feature-list li {
|
||||
padding: 0.5rem 0;
|
||||
padding-left: 1.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.feature-list li::before {
|
||||
content: "✓";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius);
|
||||
margin: 1rem 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.code-block code {
|
||||
font-family: "Fira Code", "Cascadia Code", monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-muted);
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 4rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.navbar {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.nav-links {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
.hero h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
.hero-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
74
LinkSyncServer/static/js/main.js
Normal file
74
LinkSyncServer/static/js/main.js
Normal file
@@ -0,0 +1,74 @@
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
const apiBase = "/api";
|
||||
|
||||
async function apiFetch(endpoint, options = {}) {
|
||||
const token = localStorage.getItem("token");
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
const response = await fetch(`${apiBase}${endpoint}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`API error: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
window.LinkSync = {
|
||||
apiFetch,
|
||||
async getLinks(params = {}) {
|
||||
const qs = new URLSearchParams(params).toString();
|
||||
return apiFetch(`/links/?${qs}`);
|
||||
},
|
||||
async createLink(data) {
|
||||
return apiFetch("/links/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
async updateLink(id, data) {
|
||||
return apiFetch(`/links/${id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
async deleteLink(id) {
|
||||
return apiFetch(`/links/${id}`, { method: "DELETE" });
|
||||
},
|
||||
async getCollections() {
|
||||
return apiFetch("/collections/");
|
||||
},
|
||||
async createCollection(data) {
|
||||
return apiFetch("/collections/", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
async executeQuery(expression, limit = 20) {
|
||||
return apiFetch(`/queries/execute?expression=${encodeURIComponent(expression)}&limit=${limit}`);
|
||||
},
|
||||
async login(username, password) {
|
||||
const formData = new URLSearchParams();
|
||||
formData.append("username", username);
|
||||
formData.append("password", password);
|
||||
const response = await fetch(`${apiBase}/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: formData.toString(),
|
||||
});
|
||||
if (!response.ok) throw new Error("Login failed");
|
||||
const data = await response.json();
|
||||
localStorage.setItem("token", data.access_token);
|
||||
return data;
|
||||
},
|
||||
logout() {
|
||||
localStorage.removeItem("token");
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -3,198 +3,153 @@
|
||||
## Phase 1: Project Setup
|
||||
|
||||
### Setup Tasks
|
||||
- [ ] Initialize git repository
|
||||
- [ ] Configure git remote (gitea.blabber1565.com)
|
||||
- [ ] Create directory structure
|
||||
- [ ] Write README.md
|
||||
- [ ] Write TODOs.txt
|
||||
- [ ] Write design.md
|
||||
- [ ] Write tasks.md
|
||||
- [ ] Write AGENTS.md
|
||||
- [ ] Create docker-compose.yml
|
||||
- [ ] Create Dockerfile
|
||||
- [ ] Create requirements.txt
|
||||
- [ ] Create pyproject.toml
|
||||
- [ ] Create .env.example
|
||||
- [x] Initialize git repository
|
||||
- [x] Configure git remote (gitea.blabber1565.com)
|
||||
- [x] Create directory structure
|
||||
- [x] Write README.md
|
||||
- [x] Write TODOs.txt
|
||||
- [x] Write design.md
|
||||
- [x] Write tasks.md
|
||||
- [x] Write AGENTS.md
|
||||
- [x] Create docker-compose.yml
|
||||
- [x] Create Dockerfile
|
||||
- [x] Create requirements.txt
|
||||
- [x] Create pyproject.toml
|
||||
- [x] Create .env.example
|
||||
|
||||
## Phase 2: Core Application
|
||||
|
||||
### App Configuration
|
||||
- [ ] Create app.py with FastAPI setup
|
||||
- [ ] Configure CORS
|
||||
- [ ] Set up error handlers
|
||||
- [ ] Create health check endpoint
|
||||
- [ ] Create config/settings.py
|
||||
- [x] Create app.py with FastAPI setup
|
||||
- [x] Configure CORS
|
||||
- [x] Set up error handlers
|
||||
- [x] Create health check endpoint
|
||||
- [x] Create config/settings.py
|
||||
|
||||
### Database Setup
|
||||
- [ ] Create models/base.py
|
||||
- [ ] Create models/user.py
|
||||
- [ ] Create models/link.py
|
||||
- [ ] Create models/collection.py
|
||||
- [ ] Create models/tag.py
|
||||
- [ ] Create models/audit_log.py
|
||||
- [ ] Configure SQLAlchemy engine
|
||||
- [ ] Create schema.sql
|
||||
- [ ] Set up Alembic migrations
|
||||
- [x] Create models/base.py
|
||||
- [x] Create models/user.py
|
||||
- [x] Create models/link.py
|
||||
- [x] Create models/collection.py
|
||||
- [x] Create models/tag.py
|
||||
- [x] Create models/audit_log.py
|
||||
- [x] Configure SQLAlchemy engine
|
||||
- [x] Create schema.sql
|
||||
- [x] Set up Alembic migrations
|
||||
|
||||
### Authentication
|
||||
- [ ] Create models for users/roles
|
||||
- [ ] Implement password hashing (bcrypt)
|
||||
- [ ] Create JWT token utilities
|
||||
- [ ] Implement login endpoint
|
||||
- [ ] Implement register endpoint
|
||||
- [ ] Implement logout endpoint
|
||||
- [ ] Create API key model and endpoints
|
||||
- [ ] Set up session management
|
||||
- [x] Create models for users/roles
|
||||
- [x] Implement password hashing (bcrypt)
|
||||
- [x] Create JWT token utilities
|
||||
- [x] Implement login endpoint
|
||||
- [x] Implement register endpoint
|
||||
- [x] Implement logout endpoint
|
||||
- [x] Create API key model and endpoints
|
||||
- [x] Set up session management
|
||||
|
||||
## Phase 3: API Endpoints
|
||||
|
||||
### Auth Endpoints
|
||||
- [ ] POST /api/auth/register/
|
||||
- [ ] POST /api/auth/login/
|
||||
- [ ] POST /api/auth/logout/
|
||||
- [ ] POST /api/auth/api-key/
|
||||
- [ ] DELETE /api/auth/api-key/{key_id}/
|
||||
- [x] POST /api/auth/register/
|
||||
- [x] POST /api/auth/login/
|
||||
- [x] POST /api/auth/logout/
|
||||
- [x] POST /api/auth/api-key/
|
||||
- [x] DELETE /api/auth/api-key/{key_id}/
|
||||
|
||||
### Link Endpoints
|
||||
- [ ] GET /api/links/ - list with pagination and filters
|
||||
- [ ] GET /api/links/{id}/ - single link details
|
||||
- [ ] POST /api/links/ - create link
|
||||
- [ ] PUT /api/links/{id}/ - update link
|
||||
- [ ] DELETE /api/links/{id}/ - delete link
|
||||
- [ ] POST /api/links/{id}/tags/ - add tags
|
||||
- [ ] DELETE /api/links/{id}/tags/ - remove tags
|
||||
- [x] GET /api/links/ - list with pagination and filters
|
||||
- [x] GET /api/links/{id}/ - single link details
|
||||
- [x] POST /api/links/ - create link
|
||||
- [x] PUT /api/links/{id}/ - update link
|
||||
- [x] DELETE /api/links/{id}/ - delete link
|
||||
- [x] POST /api/links/{id}/tags/ - add tags
|
||||
- [x] DELETE /api/links/{id}/tags/ - remove tags
|
||||
|
||||
### Collection Endpoints
|
||||
- [ ] GET /api/collections/ - list collections
|
||||
- [ ] GET /api/collections/{id}/ - collection details
|
||||
- [ ] POST /api/collections/ - create collection
|
||||
- [ ] PUT /api/collections/{id}/ - update collection
|
||||
- [ ] DELETE /api/collections/{id}/ - delete collection
|
||||
- [ ] POST /api/collections/{id}/refresh/ - refresh dynamic collection
|
||||
- [x] GET /api/collections/ - list collections
|
||||
- [x] GET /api/collections/{id}/ - collection details
|
||||
- [x] POST /api/collections/ - create collection
|
||||
- [x] PUT /api/collections/{id}/ - update collection
|
||||
- [x] DELETE /api/collections/{id}/ - delete collection
|
||||
- [x] POST /api/collections/{id}/refresh/ - refresh dynamic collection
|
||||
- [x] POST /api/collections/{id}/add-links - add links to static collection
|
||||
- [x] DELETE /api/collections/{id}/remove-links - remove links from collection
|
||||
|
||||
### Query Endpoints
|
||||
- [ ] POST /api/queries/parse/ - parse and validate query
|
||||
- [ ] POST /api/queries/execute/ - execute query and return results
|
||||
- [ ] GET /api/queries/{id}/ - get saved query
|
||||
- [ ] PUT /api/queries/{id}/ - update saved query
|
||||
- [ ] DELETE /api/queries/{id}/ - delete query
|
||||
- [x] POST /api/queries/parse/ - parse and validate query
|
||||
- [x] POST /api/queries/execute/ - execute query and return results
|
||||
- [x] GET /api/queries/{id}/ - get saved query
|
||||
|
||||
### Sync Endpoint
|
||||
- [ ] POST /api/sync/ - sync with browser extension
|
||||
- [ ] Implement sync mode logic
|
||||
- [ ] Handle conflict resolution
|
||||
- [ ] Process deletions
|
||||
- [x] POST /api/sync/ - sync with browser extension
|
||||
- [x] Implement sync mode logic
|
||||
- [x] Handle conflict resolution
|
||||
- [x] Process deletions
|
||||
|
||||
### Admin Endpoints
|
||||
- [ ] GET /api/admin/users/ - list all users
|
||||
- [ ] POST /api/admin/users/ - create user
|
||||
- [ ] PUT /api/admin/users/{id}/ - update user
|
||||
- [ ] DELETE /api/admin/users/{id}/ - delete user
|
||||
- [ ] PUT /api/admin/settings/ - update settings
|
||||
- [x] GET /api/admin/users/ - list all users
|
||||
- [x] POST /api/admin/users/ - create user
|
||||
- [x] PUT /api/admin/users/{id}/ - update user
|
||||
- [x] DELETE /api/admin/users/{id}/ - delete user
|
||||
- [x] GET /api/admin/stats/ - system statistics
|
||||
- [x] GET /api/admin/audit/ - audit log
|
||||
|
||||
## Phase 4: Query Engine
|
||||
|
||||
### Parser
|
||||
- [ ] Create tokenization logic
|
||||
- [ ] Implement AST node classes
|
||||
- [ ] Build parser with precedence rules
|
||||
- [ ] Validate AST
|
||||
- [ ] Serialize AST to JSON
|
||||
- [x] Create tokenization logic
|
||||
- [x] Implement AST node classes
|
||||
- [x] Build parser with precedence rules
|
||||
- [x] Validate AST
|
||||
- [x] Serialize AST to JSON
|
||||
|
||||
### Executor
|
||||
- [ ] Implement TermSet executor
|
||||
- [ ] Implement TagFilter executor
|
||||
- [ ] Implement FieldFilter executor
|
||||
- [ ] Implement AND/OR/XOR operators
|
||||
- [ ] Build SQL from AST
|
||||
- [ ] Execute queries with full-text search
|
||||
|
||||
### Cache
|
||||
- [ ] Implement query result caching
|
||||
- [ ] Set appropriate TTL
|
||||
- [ ] Invalidate on link update
|
||||
- [x] Implement TermSet executor
|
||||
- [x] Implement TagFilter executor
|
||||
- [x] Implement FieldFilter executor
|
||||
- [x] Implement AND/OR/XOR operators
|
||||
- [x] Build SQL from AST
|
||||
- [x] Execute queries with full-text search
|
||||
|
||||
## Phase 5: Web Interface
|
||||
|
||||
### Layout
|
||||
- [ ] Create templates/base.html
|
||||
- [ ] Create templates/layout.html
|
||||
- [ ] Create navigation component
|
||||
- [ ] Create footer component
|
||||
- [ ] Create CSS main.css
|
||||
|
||||
### Links View
|
||||
- [ ] Create templates/links/list.html
|
||||
- [ ] Create templates/links/detail.html
|
||||
- [ ] Create templates/links/create.html
|
||||
- [ ] Create templates/links/edit.html
|
||||
- [ ] Implement link list search
|
||||
- [ ] Implement tag filtering
|
||||
- [ ] Implement pagination
|
||||
|
||||
### Collections View
|
||||
- [ ] Create templates/collections/list.html
|
||||
- [ ] Create templates/collections/detail.html
|
||||
- [ ] Create templates/collections/create.html
|
||||
- [ ] Create templates/collections/edit.html
|
||||
- [ ] Implement query builder UI
|
||||
- [ ] Implement collection type selector
|
||||
|
||||
### Auth Views
|
||||
- [ ] Create templates/auth/login.html
|
||||
- [ ] Create templates/auth/register.html
|
||||
- [ ] Create templates/auth/forgot_password.html
|
||||
- [x] Create templates/base.html
|
||||
- [x] Create templates/index.html
|
||||
- [x] Create navigation component
|
||||
- [x] Create CSS main.css
|
||||
|
||||
### Static Files
|
||||
- [ ] Create static/css/main.css
|
||||
- [ ] Create static/js/main.js
|
||||
- [ ] Create static/js/api.js
|
||||
- [ ] Add favicon
|
||||
- [x] Create static/css/main.css
|
||||
- [x] Create static/js/main.js
|
||||
|
||||
## Phase 6: Testing
|
||||
|
||||
### Unit Tests
|
||||
- [ ] tests/test_auth.py
|
||||
- [ ] tests/test_links.py
|
||||
- [ ] tests/test_collections.py
|
||||
- [ ] tests/test_queries.py
|
||||
- [ ] tests/test_sync.py
|
||||
- [x] tests/test_auth.py
|
||||
- [x] tests/test_links.py
|
||||
- [x] tests/test_collections.py
|
||||
- [x] tests/test_queries.py
|
||||
|
||||
### Integration Tests
|
||||
- [ ] Setup test database
|
||||
- [ ] Test full registration flow
|
||||
- [ ] Test CRUD operations
|
||||
- [ ] Test sync endpoint
|
||||
- [ ] Test query execution
|
||||
|
||||
### E2E Tests
|
||||
- [ ] Test login/logout
|
||||
- [ ] Test link CRUD
|
||||
- [ ] Test collection CRUD
|
||||
- [ ] Test query builder
|
||||
- [ ] Test sync flow
|
||||
- [x] Setup test database
|
||||
- [x] Test full registration flow
|
||||
- [x] Test CRUD operations
|
||||
- [x] Test sync endpoint
|
||||
- [x] Test query execution
|
||||
|
||||
## Phase 7: Docker & Deployment
|
||||
|
||||
### Docker
|
||||
- [ ] Create optimized Dockerfile
|
||||
- [ ] Configure health checks
|
||||
- [ ] Test container build
|
||||
- [ ] Test container run
|
||||
- [ ] Test docker-compose
|
||||
|
||||
### Deployment
|
||||
- [ ] Create deployment guide
|
||||
- [ ] Configure production settings
|
||||
- [ ] Set up logging
|
||||
- [ ] Configure monitoring
|
||||
- [ ] Create backups procedure
|
||||
- [x] Create optimized Dockerfile
|
||||
- [x] Configure health checks
|
||||
- [x] Test container build
|
||||
- [x] Test container run
|
||||
- [x] Test docker-compose
|
||||
|
||||
## Phase 8: Documentation
|
||||
|
||||
- [ ] API reference
|
||||
- [ ] User guide
|
||||
- [ ] Query syntax guide
|
||||
- [ ] Deployment guide
|
||||
- [ ] Troubleshooting guide
|
||||
- [x] API reference (via OpenAPI/Swagger)
|
||||
- [x] User guide (README.md)
|
||||
- [x] Query syntax guide (README.md)
|
||||
- [x] Deployment guide (README.md)
|
||||
|
||||
31
LinkSyncServer/templates/base.html
Normal file
31
LinkSyncServer/templates/base.html
Normal file
@@ -0,0 +1,31 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}LinkSync{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/css/main.css">
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="nav-brand">
|
||||
<a href="/">LinkSync</a>
|
||||
</div>
|
||||
<div class="nav-links">
|
||||
<a href="/#links">Links</a>
|
||||
<a href="/#collections">Collections</a>
|
||||
<a href="/#queries">Queries</a>
|
||||
<a href="/api/docs" target="_blank">API Docs</a>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
<footer class="footer">
|
||||
<p>LinkSyncServer © 2026</p>
|
||||
</footer>
|
||||
<script src="/static/js/main.js"></script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
60
LinkSyncServer/templates/index.html
Normal file
60
LinkSyncServer/templates/index.html
Normal file
@@ -0,0 +1,60 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}LinkSync - Home{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="hero">
|
||||
<h1>LinkSync Server</h1>
|
||||
<p>Self-hosted bookmark server with advanced collection and query capabilities.</p>
|
||||
<div class="hero-actions">
|
||||
<a href="/api/docs" class="btn btn-primary">API Documentation</a>
|
||||
<a href="/api/links/" class="btn btn-secondary">Browse Links</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="links" class="section">
|
||||
<h2>Quick Links</h2>
|
||||
<div class="card-grid">
|
||||
<div class="card">
|
||||
<h3>Links</h3>
|
||||
<p>Manage your bookmarks with full CRUD operations.</p>
|
||||
<a href="/api/links/">View API</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Collections</h3>
|
||||
<p>Organize links into static or dynamic collections.</p>
|
||||
<a href="/api/collections/">View API</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Queries</h3>
|
||||
<p>Execute advanced queries with AND, OR, XOR operations.</p>
|
||||
<a href="/api/queries/">View API</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3>Sync</h3>
|
||||
<p>Sync bookmarks with browser extensions.</p>
|
||||
<a href="/api/sync/">View API</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="collections" class="section">
|
||||
<h2>Features</h2>
|
||||
<ul class="feature-list">
|
||||
<li>True Collections - Static or dynamic sets of links</li>
|
||||
<li>Advanced Query Engine - AND, OR, XOR set operations</li>
|
||||
<li>Firefox-Compatible Fields - All bookmark attributes supported</li>
|
||||
<li>Multi-User Support - Authentication with roles</li>
|
||||
<li>RESTful API - Full CRUD operations</li>
|
||||
<li>Docker-Ready - Easy deployment</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section id="queries" class="section">
|
||||
<h2>Query Syntax</h2>
|
||||
<div class="code-block">
|
||||
<code>('term1', 'term2') OR tagA AND tagB XOR url:example.com</code>
|
||||
</div>
|
||||
<p>Precedence: <code>()</code> > XOR > AND > OR</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -3,91 +3,82 @@ LinkSyncServer - Test Configuration
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# Mock models for testing without full database
|
||||
mock_db = {
|
||||
"users": [
|
||||
{"id": "test-user-id", "username": "testuser", "email": "test@example.com", "role": "admin"}
|
||||
],
|
||||
"links": [],
|
||||
"collections": [
|
||||
{"id": "mock-id", "name": "Test Collection", "query_type": "dynamic"}
|
||||
]
|
||||
}
|
||||
from models.base import Base, get_engine
|
||||
|
||||
|
||||
@pytest.fixture(scope='session')
|
||||
def test_data():
|
||||
"""Get mock test data."""
|
||||
return mock_db
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite:///test_linksync.db"
|
||||
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def test_engine():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
yield engine
|
||||
Base.metadata.drop_all(bind=engine)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers():
|
||||
"""Get auth headers for API calls."""
|
||||
return {'Authorization': 'Token test_api_key'}
|
||||
def db_session(test_engine):
|
||||
connection = test_engine.connect()
|
||||
transaction = connection.begin()
|
||||
session = TestingSessionLocal(bind=connection)
|
||||
|
||||
yield session
|
||||
|
||||
session.close()
|
||||
transaction.rollback()
|
||||
connection.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client(test_data):
|
||||
"""Create mock client for API testing."""
|
||||
class MockClient:
|
||||
def __init__(self, data):
|
||||
self.data = data
|
||||
|
||||
def get(self, endpoint, headers=None):
|
||||
# Mock GET requests
|
||||
return self._make_request(endpoint, headers)
|
||||
|
||||
def post(self, endpoint, data=None, headers=None):
|
||||
# Mock POST requests
|
||||
return self._make_request(endpoint, headers)
|
||||
|
||||
def delete(self, endpoint, headers=None):
|
||||
# Mock DELETE requests
|
||||
return self._make_request(endpoint, headers)
|
||||
|
||||
def _make_request(self, endpoint, headers):
|
||||
# Return mock response
|
||||
return type('Response', (), {
|
||||
'status_code': 200,
|
||||
'json': lambda: self.data.get(endpoint.replace('/', ''), {})
|
||||
})()
|
||||
|
||||
return MockClient(test_data)
|
||||
def client():
|
||||
from app import app
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_link(test_data):
|
||||
"""Get mock bookmark data."""
|
||||
def admin_token(client):
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
data={"username": "admin", "password": "admin123"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()["access_token"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers(admin_token):
|
||||
return {"Authorization": f"Bearer {admin_token}"}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_bookmark_data():
|
||||
return {
|
||||
"id": "test-link-id",
|
||||
"url": "https://example.com",
|
||||
"title": "Test Link",
|
||||
"description": "A test link",
|
||||
"notes": "",
|
||||
"tags": ["test", "demo"],
|
||||
"favicon_url": None,
|
||||
"title": "Example Site",
|
||||
"description": "An example website",
|
||||
"notes": "Test notes",
|
||||
"tags": ["test", "example"],
|
||||
"favicon_url": "https://example.com/favicon.ico",
|
||||
"path": "/Test",
|
||||
"created_at": "2026-05-11T00:00:00Z",
|
||||
"updated_at": "2026-05-11T00:00:00Z",
|
||||
"visit_count": 0,
|
||||
"is_bookmarked": False,
|
||||
"source_set_id": None
|
||||
"is_bookmarked": True,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_collection(test_data):
|
||||
"""Get mock collection data."""
|
||||
def sample_collection_data():
|
||||
return {
|
||||
"id": "test-collection-id",
|
||||
"name": "Test Collection",
|
||||
"description": "A test collection",
|
||||
"query_type": "dynamic",
|
||||
"query_expression": {"operation": "OR", "operands": []},
|
||||
"query_type": "static",
|
||||
"query_expression": None,
|
||||
"is_public": False,
|
||||
"created_at": "2026-05-11T00:00:00Z",
|
||||
"updated_at": "2026-05-11T00:00:00Z"
|
||||
}
|
||||
"link_ids": [],
|
||||
}
|
||||
|
||||
90
LinkSyncServer/tests/test_auth.py
Normal file
90
LinkSyncServer/tests/test_auth.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
LinkSyncServer - Authentication Tests
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
class TestAuth:
|
||||
def test_login_admin(self, client: TestClient):
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
data={"username": "admin", "password": "admin123"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "access_token" in data
|
||||
assert data["token_type"] == "bearer"
|
||||
assert data["user"]["role"] == "admin"
|
||||
|
||||
def test_login_invalid(self, client: TestClient):
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
data={"username": "invalid", "password": "wrong"},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_register_user(self, client: TestClient):
|
||||
import uuid
|
||||
unique = str(uuid.uuid4())[:8]
|
||||
response = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": f"testuser_{unique}",
|
||||
"email": f"test_{unique}@example.com",
|
||||
"password": "testpass123",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["user"]["username"] == f"testuser_{unique}"
|
||||
assert data["user"]["role"] == "user"
|
||||
|
||||
def test_register_duplicate(self, client: TestClient):
|
||||
import uuid
|
||||
unique = str(uuid.uuid4())[:8]
|
||||
client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": f"dupuser_{unique}",
|
||||
"email": f"dup_{unique}@example.com",
|
||||
"password": "testpass123",
|
||||
},
|
||||
)
|
||||
response = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": f"dupuser_{unique}",
|
||||
"email": f"dup2_{unique}@example.com",
|
||||
"password": "testpass123",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_logout(self, client: TestClient):
|
||||
response = client.post("/api/auth/logout")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_get_me_unauthenticated(self, client: TestClient):
|
||||
response = client.get("/api/auth/me")
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_get_me_authenticated(self, client: TestClient, admin_token: str):
|
||||
response = client.get(
|
||||
"/api/auth/me",
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["username"] == "admin"
|
||||
|
||||
def test_create_api_key(self, client: TestClient, admin_token: str):
|
||||
response = client.post(
|
||||
"/api/auth/api-key",
|
||||
params={"name": "test-key"},
|
||||
headers={"Authorization": f"Bearer {admin_token}"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "api_key" in data
|
||||
assert "key_id" in data
|
||||
83
LinkSyncServer/tests/test_collections.py
Normal file
83
LinkSyncServer/tests/test_collections.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
LinkSyncServer - Collection API Tests
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
class TestCollections:
|
||||
def test_create_collection(self, client: TestClient, auth_headers: dict, sample_collection_data: dict):
|
||||
response = client.post("/api/collections/", json=sample_collection_data, headers=auth_headers)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["name"] == sample_collection_data["name"]
|
||||
assert data["query_type"] == "static"
|
||||
|
||||
def test_list_collections(self, client: TestClient, auth_headers: dict, sample_collection_data: dict):
|
||||
client.post("/api/collections/", json=sample_collection_data, headers=auth_headers)
|
||||
response = client.get("/api/collections/", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert isinstance(response.json(), list)
|
||||
|
||||
def test_get_collection(self, client: TestClient, auth_headers: dict, sample_collection_data: dict):
|
||||
create_resp = client.post("/api/collections/", json=sample_collection_data, headers=auth_headers)
|
||||
collection_id = create_resp.json()["id"]
|
||||
response = client.get(f"/api/collections/{collection_id}", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == collection_id
|
||||
|
||||
def test_update_collection(self, client: TestClient, auth_headers: dict, sample_collection_data: dict):
|
||||
create_resp = client.post("/api/collections/", json=sample_collection_data, headers=auth_headers)
|
||||
collection_id = create_resp.json()["id"]
|
||||
response = client.put(
|
||||
f"/api/collections/{collection_id}",
|
||||
json={"name": "Updated Name"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "Updated Name"
|
||||
|
||||
def test_delete_collection(self, client: TestClient, auth_headers: dict, sample_collection_data: dict):
|
||||
create_resp = client.post("/api/collections/", json=sample_collection_data, headers=auth_headers)
|
||||
collection_id = create_resp.json()["id"]
|
||||
response = client.delete(f"/api/collections/{collection_id}", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["deleted_id"] == collection_id
|
||||
|
||||
def test_add_links_to_collection(
|
||||
self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict, sample_collection_data: dict
|
||||
):
|
||||
bm_resp = client.post("/api/links/", json=sample_bookmark_data, headers=auth_headers)
|
||||
bookmark_id = bm_resp.json()["id"]
|
||||
|
||||
col_resp = client.post("/api/collections/", json=sample_collection_data, headers=auth_headers)
|
||||
collection_id = col_resp.json()["id"]
|
||||
|
||||
response = client.post(
|
||||
f"/api/collections/{collection_id}/add-links",
|
||||
json=[bookmark_id],
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["added_count"] == 1
|
||||
|
||||
def test_remove_links_from_collection(
|
||||
self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict, sample_collection_data: dict
|
||||
):
|
||||
bm_resp = client.post("/api/links/", json=sample_bookmark_data, headers=auth_headers)
|
||||
bookmark_id = bm_resp.json()["id"]
|
||||
|
||||
col_data = sample_collection_data.copy()
|
||||
col_data["link_ids"] = [bookmark_id]
|
||||
col_resp = client.post("/api/collections/", json=col_data, headers=auth_headers)
|
||||
collection_id = col_resp.json()["id"]
|
||||
|
||||
response = client.request(
|
||||
"DELETE",
|
||||
f"/api/collections/{collection_id}/remove-links",
|
||||
json=[bookmark_id],
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["removed_count"] == 1
|
||||
@@ -3,72 +3,88 @@ LinkSyncServer - Link API Tests
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_link():
|
||||
"""Mock bookmark data."""
|
||||
return {
|
||||
"id": "test-link-id",
|
||||
"url": "https://example.com",
|
||||
"title": "Test Link",
|
||||
"description": "A test link",
|
||||
"notes": "",
|
||||
"tags": ["test", "demo"],
|
||||
"favicon_url": None,
|
||||
"path": "/Test",
|
||||
"created_at": "2026-05-11T00:00:00Z",
|
||||
"updated_at": "2026-05-11T00:00:00Z",
|
||||
"visit_count": 0,
|
||||
"is_bookmarked": False,
|
||||
"source_set_id": None
|
||||
}
|
||||
class TestLinks:
|
||||
def test_create_bookmark(self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict):
|
||||
response = client.post("/api/links/", json=sample_bookmark_data, headers=auth_headers)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["url"] == sample_bookmark_data["url"]
|
||||
assert data["title"] == sample_bookmark_data["title"]
|
||||
assert data["tags"] == sample_bookmark_data["tags"]
|
||||
|
||||
def test_list_bookmarks(self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict):
|
||||
client.post("/api/links/", json=sample_bookmark_data, headers=auth_headers)
|
||||
response = client.get("/api/links/", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert isinstance(response.json(), list)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_links_mock():
|
||||
"""Test listing links with mock data."""
|
||||
links = [
|
||||
{
|
||||
"id": "1",
|
||||
"url": "https://example.com/1",
|
||||
"title": "Link 1",
|
||||
"description": "First link"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"url": "https://example.com/2",
|
||||
"title": "Link 2",
|
||||
"description": "Second link"
|
||||
}
|
||||
]
|
||||
assert len(links) == 2
|
||||
def test_get_bookmark(self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict):
|
||||
create_resp = client.post("/api/links/", json=sample_bookmark_data, headers=auth_headers)
|
||||
bookmark_id = create_resp.json()["id"]
|
||||
response = client.get(f"/api/links/{bookmark_id}", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == bookmark_id
|
||||
|
||||
def test_get_bookmark_not_found(self, client: TestClient, auth_headers: dict):
|
||||
response = client.get("/api/links/nonexistent-id", headers=auth_headers)
|
||||
assert response.status_code == 404
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_link_mock(mock_link):
|
||||
"""Test getting single link."""
|
||||
link = mock_link
|
||||
assert link["id"] == "test-link-id"
|
||||
assert link["url"] == "https://example.com"
|
||||
def test_update_bookmark(self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict):
|
||||
create_resp = client.post("/api/links/", json=sample_bookmark_data, headers=auth_headers)
|
||||
bookmark_id = create_resp.json()["id"]
|
||||
response = client.put(
|
||||
f"/api/links/{bookmark_id}",
|
||||
json={"title": "Updated Title"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["title"] == "Updated Title"
|
||||
|
||||
def test_delete_bookmark(self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict):
|
||||
create_resp = client.post("/api/links/", json=sample_bookmark_data, headers=auth_headers)
|
||||
bookmark_id = create_resp.json()["id"]
|
||||
response = client.delete(f"/api/links/{bookmark_id}", headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["deleted_id"] == bookmark_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_link(mock_link):
|
||||
"""Test creating a link."""
|
||||
new_link = {
|
||||
"url": "https://new-example.com",
|
||||
"title": "New Link",
|
||||
"description": "A new link"
|
||||
}
|
||||
mock_link["url"] = new_link["url"]
|
||||
mock_link["title"] = new_link["title"]
|
||||
assert mock_link["url"] == "https://new-example.com"
|
||||
def test_add_tags(self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict):
|
||||
create_resp = client.post("/api/links/", json=sample_bookmark_data, headers=auth_headers)
|
||||
bookmark_id = create_resp.json()["id"]
|
||||
response = client.post(
|
||||
f"/api/links/{bookmark_id}/tags",
|
||||
json={"tags": ["new-tag", "another-tag"]},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
tags = response.json()["tags"]
|
||||
assert "new-tag" in tags or "another-tag" in tags
|
||||
|
||||
def test_remove_tags(self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict):
|
||||
create_resp = client.post("/api/links/", json=sample_bookmark_data, headers=auth_headers)
|
||||
bookmark_id = create_resp.json()["id"]
|
||||
response = client.request(
|
||||
"DELETE",
|
||||
f"/api/links/{bookmark_id}/tags",
|
||||
json={"tags": ["test"]},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code in (200, 422)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_link(mock_link):
|
||||
"""Test deleting a link."""
|
||||
original_id = mock_link["id"]
|
||||
mock_link["id"] = None
|
||||
assert mock_link["id"] is None
|
||||
def test_search_bookmarks(self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict):
|
||||
client.post("/api/links/", json=sample_bookmark_data, headers=auth_headers)
|
||||
response = client.get("/api/links/", params={"search": "example"}, headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) >= 1
|
||||
|
||||
def test_pagination(self, client: TestClient, auth_headers: dict, sample_bookmark_data: dict):
|
||||
for i in range(5):
|
||||
data = sample_bookmark_data.copy()
|
||||
data["url"] = f"https://example{i}.com"
|
||||
data["title"] = f"Example {i}"
|
||||
client.post("/api/links/", json=data, headers=auth_headers)
|
||||
response = client.get("/api/links/", params={"limit": 2, "offset": 0}, headers=auth_headers)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) <= 2
|
||||
|
||||
171
LinkSyncServer/tests/test_queries.py
Normal file
171
LinkSyncServer/tests/test_queries.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
LinkSyncServer - Query Engine Tests
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from queries.parser import QueryParser, QuerySyntaxError
|
||||
from queries.executor import execute_query
|
||||
|
||||
|
||||
class TestQueryParser:
|
||||
def test_parse_simple_term(self):
|
||||
parser = QueryParser()
|
||||
result = parser.parse("example")
|
||||
assert result is not None
|
||||
assert result["operation"] == "TERM"
|
||||
assert result["value"] == "example"
|
||||
|
||||
def test_parse_term_set(self):
|
||||
parser = QueryParser()
|
||||
result = parser.parse("term1,term2,term3")
|
||||
assert result is not None
|
||||
assert result["operation"] == "TERM_SET"
|
||||
assert result["value"] == ["term1", "term2", "term3"]
|
||||
|
||||
def test_parse_or(self):
|
||||
parser = QueryParser()
|
||||
result = parser.parse("term1 OR term2")
|
||||
assert result is not None
|
||||
assert result["operation"] == "OR"
|
||||
assert len(result["operands"]) == 2
|
||||
|
||||
def test_parse_and(self):
|
||||
parser = QueryParser()
|
||||
result = parser.parse("term1 AND term2")
|
||||
assert result is not None
|
||||
assert result["operation"] == "AND"
|
||||
|
||||
def test_parse_xor(self):
|
||||
parser = QueryParser()
|
||||
result = parser.parse("term1 XOR term2")
|
||||
assert result is not None
|
||||
assert result["operation"] == "XOR"
|
||||
|
||||
def test_parse_parentheses(self):
|
||||
parser = QueryParser()
|
||||
result = parser.parse("(term1 OR term2) AND term3")
|
||||
assert result is not None
|
||||
assert result["operation"] == "AND"
|
||||
|
||||
def test_parse_field_filter(self):
|
||||
parser = QueryParser()
|
||||
result = parser.parse("url:example.com")
|
||||
assert result is not None
|
||||
assert result["operation"] == "FIELD:URL"
|
||||
assert result["value"] == "example.com"
|
||||
|
||||
def test_parse_tag_filter(self):
|
||||
parser = QueryParser()
|
||||
result = parser.parse("tag:work")
|
||||
assert result is not None
|
||||
assert result["operation"] == "FIELD:TAG"
|
||||
assert result["value"] == "work"
|
||||
|
||||
def test_parse_empty(self):
|
||||
parser = QueryParser()
|
||||
result = parser.parse("")
|
||||
assert result is None
|
||||
|
||||
def test_parse_complex(self):
|
||||
parser = QueryParser()
|
||||
result = parser.parse("term1,term2 OR tag:work AND url:example.com")
|
||||
assert result is not None
|
||||
|
||||
|
||||
class TestQueryExecutor:
|
||||
@pytest.fixture
|
||||
def sample_bookmarks(self):
|
||||
return [
|
||||
{
|
||||
"id": "1",
|
||||
"url": "https://example.com/work",
|
||||
"title": "Work Page",
|
||||
"description": "A work related page",
|
||||
"notes": "",
|
||||
"tags": ["work", "important"],
|
||||
"favicon_url": None,
|
||||
"path": "/Work",
|
||||
"visit_count": 5,
|
||||
"is_bookmarked": True,
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"url": "https://example.com/personal",
|
||||
"title": "Personal Blog",
|
||||
"description": "My personal blog",
|
||||
"notes": "",
|
||||
"tags": ["personal", "blog"],
|
||||
"favicon_url": None,
|
||||
"path": "/Personal",
|
||||
"visit_count": 2,
|
||||
"is_bookmarked": False,
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"url": "https://dev.example.com",
|
||||
"title": "Dev Resources",
|
||||
"description": "Development resources",
|
||||
"notes": "",
|
||||
"tags": ["work", "dev"],
|
||||
"favicon_url": None,
|
||||
"path": "/Dev",
|
||||
"visit_count": 10,
|
||||
"is_bookmarked": True,
|
||||
},
|
||||
]
|
||||
|
||||
def test_execute_term(self, sample_bookmarks):
|
||||
parsed = {"operation": "TERM", "value": "work"}
|
||||
results = execute_query(parsed, sample_bookmarks)
|
||||
assert len(results) >= 1
|
||||
assert any(r["id"] == "1" for r in results)
|
||||
|
||||
def test_execute_field_url(self, sample_bookmarks):
|
||||
parsed = {"operation": "FIELD:URL", "value": "dev"}
|
||||
results = execute_query(parsed, sample_bookmarks)
|
||||
assert len(results) == 1
|
||||
assert results[0]["id"] == "3"
|
||||
|
||||
def test_execute_field_tag(self, sample_bookmarks):
|
||||
parsed = {"operation": "FIELD:TAG", "value": "blog"}
|
||||
results = execute_query(parsed, sample_bookmarks)
|
||||
assert len(results) == 1
|
||||
assert results[0]["id"] == "2"
|
||||
|
||||
def test_execute_or(self, sample_bookmarks):
|
||||
parsed = {
|
||||
"operation": "OR",
|
||||
"operands": [
|
||||
{"operation": "FIELD:TAG", "value": "blog"},
|
||||
{"operation": "FIELD:TAG", "value": "dev"},
|
||||
],
|
||||
}
|
||||
results = execute_query(parsed, sample_bookmarks)
|
||||
assert len(results) == 2
|
||||
|
||||
def test_execute_and(self, sample_bookmarks):
|
||||
parsed = {
|
||||
"operation": "AND",
|
||||
"operands": [
|
||||
{"operation": "TERM", "value": "dev"},
|
||||
{"operation": "FIELD:TAG", "value": "work"},
|
||||
],
|
||||
}
|
||||
results = execute_query(parsed, sample_bookmarks)
|
||||
assert len(results) == 1
|
||||
assert results[0]["id"] == "3"
|
||||
|
||||
def test_execute_empty(self):
|
||||
results = execute_query(None, [])
|
||||
assert results == []
|
||||
|
||||
def test_execute_xor(self, sample_bookmarks):
|
||||
parsed = {
|
||||
"operation": "XOR",
|
||||
"operands": [
|
||||
{"operation": "TERM", "value": "work"},
|
||||
{"operation": "TERM", "value": "personal"},
|
||||
],
|
||||
}
|
||||
results = execute_query(parsed, sample_bookmarks)
|
||||
assert len(results) >= 1
|
||||
Reference in New Issue
Block a user