// LinkdingSync Background Service Worker // Handles synchronization logic, bookmark management, and API calls 'use strict'; // Direct browser.storage.local API (no prefix in service worker) const storage = { get(keys) { return browser.storage.local.get(keys); }, set(items) { return browser.storage.local.set(items); }, clear() { return browser.storage.local.clear(); } }; // Direct browser.bookmarks API (no prefix in service worker) const bookmarks = { getTree() { return browser.bookmarks.getTree(); }, get(id) { return browser.bookmarks.get(id); }, onCreated(handler) { browser.bookmarks.onCreated.addListener(handler); }, onChanged(handler) { browser.bookmarks.onChanged.addListener(handler); }, onRemoved(handler) { browser.bookmarks.onRemoved.addListener(handler); } }; // Global API Client module class ApiClient { constructor(serverUrl, apiKey) { this.serverUrl = serverUrl.endsWith('/') ? serverUrl : serverUrl + '/'; this.apiToken = apiKey; this.baseURL = this.serverUrl + 'api'; } async getAuthHeaders() { return { 'Authorization': `Token ${this.apiToken}` }; } async getBundles() { try { const response = await fetch(`${this.baseURL}bundles/?limit=100`, { headers: await this.getAuthHeaders() }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } return await response.json(); } catch (error) { console.error('Get bundles error:', error); return { results: [], count: 0 }; } } async getBundle(bundleTag) { try { // Query bookmarks with this tag const response = await fetch(`${this.baseURL}bookmarks/?limit=1&any=${encodeURIComponent(bundleTag)}`, { headers: await this.getAuthHeaders() }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const data = await response.json(); return data; } catch (error) { return { results: [], count: 0 }; } } async getBundleDetails(bundleId) { try { const response = await fetch(`${this.baseURL}bundles/${bundleId}/`, { headers: await this.getAuthHeaders() }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } return await response.json(); } catch (error) { return null; } } async checkUrl(url) { try { const response = await fetch(`${this.baseURL}bookmarks/check/?url=${encodeURIComponent(url)}`, { headers: await this.getAuthHeaders() }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } return await response.json(); } catch (error) { return { bookmark: null }; } } async createBookmark(bookmarkData) { try { const response = await fetch(`${this.baseURL}bookmarks/`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...await this.getAuthHeaders() }, body: JSON.stringify(bookmarkData) }); if (!response.ok) { const error = await response.text(); throw new Error(`HTTP ${response.status}: ${response.statusText} - ${error}`); } return await response.json(); } catch (error) { console.error('Create bookmark error:', error); throw error; } } async updateBookmark(bookmarkId, bookmarkData) { try { const response = await fetch(`${this.baseURL}bookmarks/${bookmarkId}/`, { method: 'PUT', headers: { 'Content-Type': 'application/json', ...await this.getAuthHeaders() }, body: JSON.stringify(bookmarkData) }); if (!response.ok) { const error = await response.text(); throw new Error(`HTTP ${response.status}: ${response.statusText} - ${error}`); } return await response.json(); } catch (error) { console.error('Update bookmark error:', error); throw error; } } async deleteBookmark(bookmarkId) { try { const response = await fetch(`${this.baseURL}bookmarks/${bookmarkId}/`, { method: 'DELETE', headers: await this.getAuthHeaders() }); if (!response.ok && response.status !== 404) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } } catch (error) { console.error('Delete bookmark error:', error); throw error; } } async addBundle(bundleData) { try { const response = await fetch(`${this.baseURL}bundles/`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...await this.getAuthHeaders() }, body: JSON.stringify(bundleData) }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } return await response.json(); } catch (error) { console.error('Add bundle error:', error); throw error; } } }; // Global Store for state management class Store { async get(keys) { try { const data = await storage.get(keys); return data; } catch (error) { console.error('Storage load error:', error); return {}; } } async set(items) { try { await storage.set(items); return true; } catch (error) { console.error('Storage save error:', error); return false; } } } // Global Notes Manager for version handling class NotesManager { static getHostname() { try { return window.location.hostname || navigator.deviceMemory || 'unknown'; } catch (e) { return 'unknown'; } } static getProfileName() { try { return navigator.userAgent.toLowerCase().match(/(firefox|chrome|edge|safari|opera)/i)?.[1] || 'browser'; } catch (e) { return 'browser'; } } static generateBundleTag(serverUrl) { const hostname = this.getHostname(); const profile = this.getProfileName(); const normalizedHost = hostname.replace(/\./g, '_').toLowerCase(); // If bundleTag is configured, use it if (serverUrl && serverUrl.bundleTag) { return serverUrl.bundleTag; } // Otherwise auto-generate from hostname/profile return `bundle_${normalizedHost}_${profile}_${Date.now().toString().slice(-4)}`; } static parseVersion(version) { const [major, minor] = version.split('.').map(Number); return { major, minor }; } static parseNotes(noteString) { if (!noteString) { return null; } try { const parsed = JSON.parse(noteString); // Handle non-JSON notes (old bookmarks) if (typeof parsed === 'string') { return { version: '1.0', path: '', userNotes: parsed, autoTags: [], bundleTag: null, keyword: '' }; } // Handle notes without version field (old structured format) if (!parsed.version) { return { version: '1.0', path: parsed.path || '', userNotes: parsed.userNotes || parsed.notes || parsed.description || '', autoTags: parsed.autoTags || [], bundleTag: parsed.bundleTag || null, keyword: parsed.keyword || '' }; } // Handle version 1.x - basic fields only if (this.parseVersion(parsed.version).major < 2) { return { version: parsed.version, path: parsed.path || '', userNotes: parsed.userNotes || '', autoTags: parsed.autoTags || [], bundleTag: parsed.bundleTag || null, keyword: parsed.keyword || '' }; } // Handle version 2.x and above return { version: parsed.version, path: parsed.path || '', userNotes: parsed.userNotes || '', autoTags: parsed.autoTags || [], bundleTag: parsed.bundleTag || null, keyword: parsed.keyword || '', ...parsed }; } catch (e) { // Invalid JSON - treat as old notes return { version: '1.0', path: '', userNotes: noteString, autoTags: [], bundleTag: null, keyword: '' }; } } static formatNotes(notesObject) { if (!notesObject) { return ''; } return JSON.stringify(notesObject); } static createEmptyNotes(bundleTag) { return { version: '1.0', path: '', userNotes: '', autoTags: [], bundleTag: bundleTag, keyword: '' }; } static shouldMigrate(notes) { // Should migrate if: // - notes is not a string (not JSON) // - notes doesn't have version field // - version is old (< 1.0) if (!notes) { return true; } if (typeof notes !== 'object') { return true; } const version = notes.version || '0.0'; const current = this.parseVersion(version); const target = this.parseVersion('1.0'); return current.major < target.major; } static migrateNotes(oldNotes, bundleTag) { return { version: '1.0', path: '', userNotes: typeof oldNotes === 'string' ? oldNotes : '', autoTags: [], bundleTag: bundleTag, keyword: '' }; } static updateBundleTag(notes, bundleTag) { if (!notes) return notes; if (typeof notes !== 'object') return notes; const parsed = this.parseNotes(notes); // If this is our extension's notes (version 1.0 without bundleTag), update bundleTag if (!parsed.bundleTag) { return { ...parsed, bundleTag: bundleTag }; } return notes; } }; // Global utility functions const Utils = { formatTimestamp(timestamp) { if (!timestamp) return 'Never'; const date = new Date(timestamp); const options = { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZoneName: 'short' }; return date.toLocaleDateString('en-US', options); }, getBookmarkPath(bookmark) { if (!bookmark.notes || typeof bookmark.notes !== 'string') { return ''; } try { const notes = JSON.parse(bookmark.notes); return notes.path || ''; } catch (e) { return ''; } }, extractTagsFromPath(path) { if (!path) return []; const folders = path.split('/').filter(f => f); return folders.map(folder => ({ name: folder.trim() })); }, async showNotification(message, type = 'info', duration = 4000) { const types = { info: { color: '#2196f3', icon: 'ℹ️' }, success: { color: '#4caf50', icon: '✓' }, warning: { color: '#ff9800', icon: '⚠️' }, error: { color: '#f44336', icon: '✕' } }; const style = types[type] || types.info; // Remove existing notifications const existing = document.getElementById('notification-area'); if (existing) existing.remove(); // Create notification const notification = document.createElement('div'); notification.id = 'notification-area'; notification.style.cssText = ` position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); background: ${style.color}; color: white; padding: 12px 20px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.2); z-index: 9999; font-size: 13px; max-width: 90vw; text-align: center; `; notification.textContent = `${style.icon} ${message}`; document.body.appendChild(notification); // Auto-remove after duration if (duration > 0) { setTimeout(() => { if (notification.parentNode) { notification.remove(); } }, duration); } } }; // Global Sync Manager class SyncManager { constructor() { this.api = null; this.store = new Store(); this.config = null; this.lastSyncTime = null; this.bundleTag = null; this.bundleId = null; } async init(config) { this.config = config; this.api = new ApiClient(config.serverUrl, config.apiKey); // Auto-generate bundle tag if not set if (!config.bundleTag) { this.bundleTag = NotesManager.generateBundleTag(config.serverUrl); await this.store.set({ bundleTag: this.bundleTag }); } else { this.bundleTag = config.bundleTag; } this.lastSyncTime = Date.now(); await this.store.set({ lastSyncTime: this.lastSyncTime }); // Ensure bundle exists await this.ensureBundle(); } async ensureBundle() { try { // Get existing bundle let bundleData = await this.getBundleDetails(this.bundleId); if (!bundleData) { // Bundle doesn't exist, create it bundleData = await this.createBundle(this.bundleTag); } // Save bundle info await this.store.set({ bundleId: bundleData.id, bundleName: bundleData.name || this.bundleTag, bundleTags: bundleData.all_tags || '' }); return bundleData; } catch (error) { console.error('Ensure bundle error:', error); return null; } } async createBundle(bundleTag) { // Create new bundle with required_tags set to our bundle tag const bundleData = { name: bundleTag, search: '', any_tags: '', all_tags: bundleTag, excluded_tags: '' }; return await this.api.addBundle(bundleData); } async getBundle(bundleTag) { // First check if bundle exists let bundleData = await this.getBundleDetails(this.bundleId); if (bundleData) { // Bundle exists, get bookmarks with this tag // Use pagination to handle more than 1000 bookmarks const allBookmarks = []; let hasMore = true; let offset = 0; const pageSize = 100; while (hasMore) { const response = await fetch( `${this.config.serverUrl}api/bookmarks/?limit=${pageSize}&all=${encodeURIComponent(bundleData.all_tags || '')}&offset=${offset}`, { headers: await this.api.getAuthHeaders() } ); if (!response.ok) { break; } const data = await response.json(); allBookmarks.push(...(data.results || [])); if (data.results?.length < pageSize) { hasMore = false; } else { offset += pageSize; } } return { bundleId: bundleData.id, bundleTag: bundleData.name || bundleTag, count: allBookmarks.length, results: allBookmarks }; } // Bundle doesn't exist yet, create it bundleData = await this.createBundle(bundleTag); // Create empty bundle return { results: [], count: 0, bundleId: bundleData.id }; } async checkBookmark(url) { if (!url) return null; try { const result = await this.api.checkUrl(url); return result.bookmark; } catch (error) { console.error('Check bookmark error:', error); return null; } } async syncBookmark(bookmarkData, folder) { // Get bundle tag from config or auto-generated const config = await this.store.get(); const bundleTag = config.bundleTag || this.bundleTag; if (!bundleTag) { throw new Error('No bundle tag configured'); } // Check if bookmark exists const existing = await this.checkBookmark(bookmarkData.url); if (existing) { // Bookmark exists - update it const existingNotes = this.parseAndMigrateNotes(existing.notes, bundleTag); // Get autoGenerateTags setting const autoGenerateTags = config.autoGenerateTags || false; // Merge Firefox tags to Linkding tags (only if autoGenerateTags is enabled) const mergedNotes = this.mergeNotes(existingNotes, bookmarkData.notes, bookmarkData.tags, autoGenerateTags); // Update bookmark const updated = await this.api.updateBookmark(existing.id, { title: bookmarkData.title || existing.title, description: bookmarkData.description || existing.description, notes: mergedNotes, // Use browser's keyword for consistent cross-browser sync keyword: bookmarkData.keyword || existing.keyword || '' }); return updated; } else { // Create new bookmark with bundle tag const newNotes = NotesManager.createEmptyNotes(bundleTag); // Add folder path if provided if (folder) { newNotes.path = folder; } // Add user notes if provided if (bookmarkData.notes) { newNotes.userNotes = bookmarkData.notes; } // Add Firefox tags if provided AND autoGenerateTags is enabled if (bookmarkData.tags && autoGenerateTags) { newNotes.autoTags = bookmarkData.tags; } // Add keyword if provided if (bookmarkData.keyword) { newNotes.keyword = bookmarkData.keyword; } // Create new bookmark const newBookmark = { url: bookmarkData.url, title: bookmarkData.title, description: bookmarkData.description, notes: NotesManager.formatNotes(newNotes), unread: false, shared: false }; return await this.api.createBookmark(newBookmark); } } parseAndMigrateNotes(noteString, bundleTag) { if (!noteString) { return NotesManager.createEmptyNotes(bundleTag); } const parsed = NotesManager.parseNotes(noteString); // Migrate if needed if (NotesManager.shouldMigrate(parsed)) { parsed = NotesManager.migrateNotes(parsed, bundleTag); } // Update bundle tag if this is our notes parsed = NotesManager.updateBundleTag(parsed, bundleTag); return parsed; } mergeNotes(notes1, notes2, tags = [], autoGenerateTags = false) { // Parse both notes const p1 = NotesManager.parseNotes(notes1); const p2 = NotesManager.parseNotes(notes2); // Combine tags (use Firefox tags if provided and autoGenerateTags is enabled) const combinedTags = autoGenerateTags && tags.length > 0 ? tags : (p2.autoTags || []); // Merge notes with Firefox keyword support return { version: Math.max(p1.version, p2.version), path: p1.path || p2.path || '', userNotes: p1.userNotes || p2.userNotes || '', autoTags: combinedTags, bundleTag: p1.bundleTag || p2.bundleTag, // Store Firefox keyword in notes for cross-browser sync keyword: p1.keyword || p2.keyword || '' }; } async deleteBookmark(url) { const existing = await this.checkBookmark(url); if (!existing) { return { success: false, error: 'Bookmark not found' }; } try { await this.api.deleteBookmark(existing.id); return { success: true }; } catch (error) { console.error('Delete bookmark error:', error); return { success: false, error: error.message }; } } async getBundleInfo() { const config = await this.store.get(); if (!config.apiKey || !config.serverUrl) { return null; } try { const bundleInfo = await this.ensureBundle(); if (!bundleInfo) { return null; } // Get bookmarks with this tag using "all" query param const response = await fetch(`${config.serverUrl}api/bookmarks/?limit=1000&all=${encodeURIComponent(bundleInfo.all_tags || '')}`, { headers: await this.api.getAuthHeaders() }); if (response.ok) { const results = await response.json(); return { bundleId: bundleInfo.id, bundleTag: bundleInfo.name || this.bundleTag, count: results.count || results.results?.length || 0, results: results.results || [] }; } return { bundleId: bundleInfo.id, bundleTag: bundleInfo.name || this.bundleTag, count: 0, results: [] }; } catch (error) { console.error('Get bundle info error:', error); return null; } } async syncAllBookmarks() { const config = await this.store.get(); if (!config.apiKey || !config.serverUrl) { console.log('Not configured for sync'); return; } try { await this.init(config); const bundleInfo = await this.getBundleInfo(); if (!bundleInfo) { console.log('No bundle available'); return; } // Fetch bookmarks from Linkding with bundle filter const linkdingBookmarks = await this.getBundle(bundleInfo.bundleTag); // Get browser bookmarks const browserBookmarks = await bookmarks.getTree(); // Sync each bookmark for (const bookmark of browserBookmarks) { await this.syncBookmark(bookmark, '', this.config.syncMode); } this.lastSyncTime = Date.now(); await this.store.set({ lastSyncTime: this.lastSyncTime }); Utils.showNotification(`Synced ${linkdingBookmarks.count || 0} bookmarks`, 'success'); } catch (error) { console.error('Sync all error:', error); Utils.showNotification('Sync failed: ' + error.message, 'error'); } } }; // Export for console access window.LinkdingSync = { ApiClient: ApiClient, Store: Store, Utils: Utils, NotesManager: NotesManager, Manager: SyncManager }; // Initialize when storage changes browser.storage.onChanged.addListener((changes, area) => { if (area === 'local') { console.log('Configuration changed:', changes); } }); // Listen for bookmark changes browser.bookmarks.onCreated.addListener(async (bookmark) => { console.log('Bookmark created:', bookmark.id); const manager = new SyncManager(); const config = await manager.store.get(); if (config) { manager.syncBookmark(bookmark, '', config.syncMode); } }); browser.bookmarks.onChanged.addListener(async (change, bookmarkId) => { console.log('Bookmark changed:', bookmarkId); const manager = new SyncManager(); const bookmarkData = await browser.bookmarks.get(bookmarkId); if (bookmarkData && bookmarkData[0]) { const config = await manager.store.get(); if (config) { manager.syncBookmark(bookmarkData[0], '', config.syncMode); } } }); browser.bookmarks.onRemoved.addListener(async (removed, bookmarkId) => { console.log('Bookmark removed:', bookmarkId); const manager = new SyncManager(); const config = await manager.store.get(); if (config) { manager.deleteBookmark(removed.url); } }); // Initialize console.log('linkdingsync initialized');