Files

873 lines
22 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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');