// LinkdingSync Popup JavaScript // Handles popup UI, state management, and bookmark operations 'use strict'; // Direct browser.storage.local API for popup (no browser API prefix needed) 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 for popup 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 (accessible from other scripts) 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 (uses storage object) 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 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 ''; } }, setBookmarkPath(bookmark, path, userNotes) { const notes = { path: path, userNotes: userNotes || '', autoTags: [] }; bookmark.notes = JSON.stringify(notes); }, extractTagsFromPath(path) { if (!path) return []; const folders = path.split('/').filter(f => f); return folders.map(folder => ({ name: folder.trim() })); }, showMessage(message, type = 'info') { 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 4 seconds setTimeout(() => { if (notification.parentNode) { notification.remove(); } }, 4000); } }; // 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 Sync Manager (uses storage directly for popup) 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 getBundle() { const config = await this.store.get(); return config.bundleTag || this.bundleTag || null; } 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 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; } } }; // Export for console access window.LinkdingSync = { ApiClient: ApiClient, Store: Store, Utils: Utils, NotesManager: NotesManager, Manager: SyncManager }; document.addEventListener('DOMContentLoaded', async () => { // Load configuration try { const config = await new Store().get(); // Auto-generate bundle tag if not set if (!config.bundleTag && config.serverUrl) { const bundleTag = NotesManager.generateBundleTag(config.serverUrl); await new Store().set({ bundleTag: bundleTag }); config.bundleTag = bundleTag; } // Initialize sync manager await new SyncManager().init(config); // Populate status updateStatus(); // Reset input fields const urlInput = document.getElementById('bookmark-url'); if (urlInput) urlInput.value = ''; const notesInput = document.getElementById('bookmark-notes'); if (notesInput) notesInput.value = ''; const folderSelect = document.getElementById('bookmark-folder'); if (folderSelect) folderSelect.value = ''; } catch (error) { console.error('Popup initialization error:', error); Utils.showMessage('Error loading configuration', 'error'); } // Event handlers const btnAddBookmark = document.getElementById('btn-add-bookmark'); if (btnAddBookmark) { btnAddBookmark.addEventListener('click', async () => { const url = document.getElementById('bookmark-url').value.trim(); const notes = document.getElementById('bookmark-notes').value.trim(); const folder = document.getElementById('bookmark-folder').value; if (!url) { Utils.showMessage('Please enter a URL', 'warning'); return; } try { const syncManager = new SyncManager(); await syncManager.init(await new Store().get()); const currentUrl = window.location.href; const existingBookmark = await syncManager.checkBookmark(currentUrl); if (existingBookmark) { await syncManager.syncBookmark({ title: document.title, description: '', url: currentUrl, notes: notes }, folder); Utils.showMessage('Bookmark updated', 'success'); } else { await syncManager.syncBookmark({ title: document.title, description: '', url: currentUrl, notes: notes }, folder); Utils.showMessage('Bookmark added', 'success'); } // Reset form document.getElementById('bookmark-url').value = ''; document.getElementById('bookmark-notes').value = ''; document.getElementById('bookmark-folder').value = ''; } catch (error) { console.error('Sync error:', error); Utils.showMessage('Failed to sync bookmark: ' + error.message, 'error'); } }); } const btnSettings = document.getElementById('btn-settings'); if (btnSettings) { btnSettings.addEventListener('click', async () => { await browser.runtime.openOptionsPage(); }); } }); function updateStatus() { const statusIcon = document.getElementById('status-icon'); const statusTitle = document.getElementById('status-title'); const statusMessage = document.getElementById('status-message'); const syncInfo = document.getElementById('sync-info'); const bundleNameEl = document.getElementById('bundle-name'); const syncModeEl = document.getElementById('sync-mode-display'); const lastSyncEl = document.getElementById('last-sync'); if (!syncInfo) { return; } checkConnection().then(canConnect => { if (!canConnect) { statusIcon.textContent = '✕'; statusTitle.textContent = 'Connection Error'; statusMessage.textContent = 'Unable to connect to Linkding'; syncInfo.style.display = 'none'; return; } syncInfo.style.display = 'block'; const config = new Store().get(); config.then(conf => { new SyncManager().getBundleInfo().then(bundleInfo => { if (bundleInfo) { bundleNameEl.textContent = bundleInfo.bundleTag || '-'; syncModeEl.textContent = conf.syncMode || 'bi-directional'; const lastSync = bundleInfo.lastSync; lastSyncEl.textContent = Utils.formatTimestamp(lastSync); statusIcon.textContent = '✓'; statusTitle.textContent = 'Connected'; statusMessage.textContent = 'Syncing with Linkding'; } }); }); // Default status for now statusIcon.textContent = '⏳'; statusTitle.textContent = 'Loading...'; statusMessage.textContent = 'Connecting to Linkding...'; }).catch(error => { console.error('Status update error:', error); statusIcon.textContent = '✕'; statusTitle.textContent = 'Connection Error'; statusMessage.textContent = error.message || 'Unable to connect'; syncInfo.style.display = 'none'; }); } function checkConnection() { const config = new Store().get(); return config.then(conf => { if (!conf.serverUrl) { return false; } return fetch(`${conf.serverUrl}api/bookmarks/?limit=1`, { headers: { 'Authorization': `Token ${conf.apiKey}` } }) .then(response => { if (response.ok) { return true; } return false; }) .catch(error => { console.error('Connection check error:', error); return false; }); }); }