Files
myworkspace/Linkding Browser Extension/LinkdingSync/popup.js

929 lines
24 KiB
JavaScript
Raw 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 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;
});
});
}