Initial commit of MyWorkspace - contains multiple projects and global workspace configuration

This commit is contained in:
DavidSaylor
2026-05-06 22:59:37 -05:00
commit 019e35b488
2520 changed files with 13634 additions and 0 deletions

View File

@@ -0,0 +1,929 @@
// 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;
});
});
}