/** * Container Bookmarks - Background Script * Manages bookmark-container mappings and context menu integration */ // Storage key for bookmark mappings const STORAGE_KEY = 'bookmark_container_mappings'; // Fragment prefix used to tag bookmarks for container identification // Format: #cb- (e.g., #cb-abc123) const FRAGMENT_PREFIX = '#cb-'; // Cache for containers let containersCache = []; // ============================================================================ // URL Fragment Helper Functions // ============================================================================ /** * Generate a unique fragment for a bookmark */ function generateFragment(bookmarkId) { return `${FRAGMENT_PREFIX}${bookmarkId}`; } /** * Check if a URL contains a container bookmark fragment */ function hasContainerFragment(url) { return url.includes(FRAGMENT_PREFIX); } /** * Extract the bookmark ID from a URL with a container fragment */ function extractBookmarkIdFromUrl(url) { const fragmentIndex = url.indexOf(FRAGMENT_PREFIX); if (fragmentIndex === -1) return null; // Extract everything after the prefix until the end or next # or ? const afterPrefix = url.substring(fragmentIndex + FRAGMENT_PREFIX.length); // The bookmark ID continues until end of string (fragment is always at the end) return afterPrefix.split(/[#?]/)[0] || afterPrefix; } /** * Get the clean URL (without container fragment) */ function getCleanUrl(url) { const fragmentIndex = url.indexOf(FRAGMENT_PREFIX); if (fragmentIndex === -1) return url; // Return everything before the fragment prefix return url.substring(0, fragmentIndex); } /** * Add container fragment to a URL */ function addFragmentToUrl(url, bookmarkId) { // First, remove any existing container fragment const cleanUrl = getCleanUrl(url); return `${cleanUrl}${generateFragment(bookmarkId)}`; } // ============================================================================ // Storage Functions // ============================================================================ /** * Get all bookmark-container mappings from storage */ async function getMappings() { const result = await browser.storage.local.get(STORAGE_KEY); return result[STORAGE_KEY] || {}; } /** * Save a bookmark-container mapping and add unique fragment to bookmark URL */ async function saveMapping(bookmarkId, containerId, containerName) { // Get the bookmark const bookmarks = await browser.bookmarks.get(bookmarkId); if (!bookmarks || bookmarks.length === 0) { console.error('Bookmark not found:', bookmarkId); return; } const bookmark = bookmarks[0]; if (!bookmark.url) { console.error('Bookmark has no URL:', bookmarkId); return; } // Add unique fragment to bookmark URL (if not already present) const newUrl = addFragmentToUrl(bookmark.url, bookmarkId); if (newUrl !== bookmark.url) { await browser.bookmarks.update(bookmarkId, { url: newUrl }); console.log(`Updated bookmark URL: ${bookmark.url} -> ${newUrl}`); } // Save the mapping const mappings = await getMappings(); mappings[bookmarkId] = { containerId, containerName, createdAt: Date.now() }; await browser.storage.local.set({ [STORAGE_KEY]: mappings }); console.log(`Saved mapping: bookmark ${bookmarkId} -> container ${containerName}`); } /** * Remove a bookmark-container mapping and strip fragment from bookmark URL */ async function removeMapping(bookmarkId) { const mappings = await getMappings(); if (mappings[bookmarkId]) { // Try to restore the original bookmark URL (remove fragment) try { const bookmarks = await browser.bookmarks.get(bookmarkId); if (bookmarks && bookmarks.length > 0 && bookmarks[0].url) { const cleanUrl = getCleanUrl(bookmarks[0].url); if (cleanUrl !== bookmarks[0].url) { await browser.bookmarks.update(bookmarkId, { url: cleanUrl }); console.log(`Restored bookmark URL: ${bookmarks[0].url} -> ${cleanUrl}`); } } } catch (error) { // Bookmark might have been deleted console.log('Could not restore bookmark URL (bookmark may be deleted)'); } delete mappings[bookmarkId]; await browser.storage.local.set({ [STORAGE_KEY]: mappings }); console.log(`Removed mapping for bookmark ${bookmarkId}`); } } /** * Get the container mapping for a specific bookmark */ async function getMapping(bookmarkId) { const mappings = await getMappings(); return mappings[bookmarkId] || null; } // ============================================================================ // Container Functions // ============================================================================ /** * Refresh the containers cache */ async function refreshContainersCache() { try { containersCache = await browser.contextualIdentities.query({}); console.log(`Loaded ${containersCache.length} containers`); } catch (error) { console.error('Failed to load containers:', error); containersCache = []; } } /** * Get container by cookieStoreId */ function getContainerById(cookieStoreId) { return containersCache.find(c => c.cookieStoreId === cookieStoreId); } /** * Get hex color value for container color */ function getColorHex(color) { const colorMap = { 'blue': '#37adff', 'turquoise': '#00c79a', 'green': '#51cd00', 'yellow': '#ffcb00', 'orange': '#ff9f00', 'red': '#ff613d', 'pink': '#ff4bda', 'purple': '#af51f5', 'toolbar': '#7c7c7d' }; return colorMap[color] || '#7c7c7d'; } // ============================================================================ // Context Menu Functions // ============================================================================ /** * Create the context menu structure */ async function createContextMenus() { // Remove existing menus first await browser.menus.removeAll(); // Refresh containers await refreshContainersCache(); // Parent menu for "Set Container" browser.menus.create({ id: 'set-container-parent', title: 'Set Container', contexts: ['bookmark'] }); // Add container options for (const container of containersCache) { const iconName = container.icon || 'circle'; const colorName = container.color || 'toolbar'; browser.menus.create({ id: `set-container-${container.cookieStoreId}`, parentId: 'set-container-parent', title: container.name, icons: { 16: `icons/${colorName}-${iconName}.svg` }, contexts: ['bookmark'] }); } // Separator browser.menus.create({ id: 'set-container-separator', parentId: 'set-container-parent', type: 'separator', contexts: ['bookmark'] }); // Remove container option browser.menus.create({ id: 'remove-container', parentId: 'set-container-parent', title: '✗ Remove Container Assignment', contexts: ['bookmark'] }); // Open in Container menu browser.menus.create({ id: 'open-in-container-parent', title: 'Open in Container', contexts: ['bookmark'] }); for (const container of containersCache) { const iconName = container.icon || 'circle'; const colorName = container.color || 'toolbar'; browser.menus.create({ id: `open-in-container-${container.cookieStoreId}`, parentId: 'open-in-container-parent', title: container.name, icons: { 16: `icons/${colorName}-${iconName}.svg` }, contexts: ['bookmark'] }); } console.log('Context menus created'); } /** * Handle context menu clicks */ async function handleMenuClick(info, tab) { const menuItemId = info.menuItemId; const bookmarkId = info.bookmarkId; if (!bookmarkId) { console.error('No bookmark ID in menu click'); return; } // Get bookmark info const bookmarks = await browser.bookmarks.get(bookmarkId); if (!bookmarks || bookmarks.length === 0) { console.error('Bookmark not found:', bookmarkId); return; } const bookmark = bookmarks[0]; // Handle "Set Container" clicks if (menuItemId.startsWith('set-container-')) { const containerId = menuItemId.replace('set-container-', ''); const container = getContainerById(containerId); if (container) { await saveMapping(bookmarkId, containerId, container.name); // Show notification browser.notifications.create({ type: 'basic', title: 'Container Bookmarks', message: `"${bookmark.title}" will now open in ${container.name}` }); } return; } // Handle "Remove Container" click if (menuItemId === 'remove-container') { await removeMapping(bookmarkId); browser.notifications.create({ type: 'basic', title: 'Container Bookmarks', message: `Container removed from "${bookmark.title}"` }); return; } // Handle "Open in Container" clicks if (menuItemId.startsWith('open-in-container-')) { const containerId = menuItemId.replace('open-in-container-', ''); if (bookmark.url) { // Use clean URL (without container fragment if present) const cleanUrl = getCleanUrl(bookmark.url); await browser.tabs.create({ url: cleanUrl, cookieStoreId: containerId }); } return; } } // ============================================================================ // Bookmark Click Interception // ============================================================================ // Cache of URL -> containerId for faster matching let urlToContainerCache = {}; /** * Build URL to container cache from mappings */ async function buildUrlCache() { const mappings = await getMappings(); urlToContainerCache = {}; for (const [bookmarkId, mapping] of Object.entries(mappings)) { try { const bookmarks = await browser.bookmarks.get(bookmarkId); if (bookmarks && bookmarks.length > 0 && bookmarks[0].url) { urlToContainerCache[bookmarks[0].url] = { containerId: mapping.containerId, containerName: mapping.containerName, bookmarkId: bookmarkId }; } } catch (error) { // Bookmark was deleted, clean up await removeMapping(bookmarkId); } } console.log(`URL cache built with ${Object.keys(urlToContainerCache).length} entries`); } // Track tabs that we're currently redirecting to prevent loops const redirectingTabs = new Set(); /** * Handle navigation commits - intercept bookmark clicks with container fragments * * When a bookmark with a container fragment is clicked, we: * 1. Detect the fragment (e.g., #cb-abc123) * 2. Look up the container mapping for that bookmark ID * 3. Navigate to the clean URL (without fragment) in the correct container */ async function handleNavigation(details) { const { tabId, url, transitionType, frameId } = details; // Only handle main frame navigations if (frameId !== 0) { return; } // Skip special URLs if (url.startsWith('about:') || url.startsWith('moz-extension:')) { return; } // Skip if this tab is one we're redirecting (prevent infinite loop) if (redirectingTabs.has(tabId)) { return; } // Check if this URL has a container bookmark fragment if (!hasContainerFragment(url)) { return; } // Extract bookmark ID from the URL fragment const bookmarkId = extractBookmarkIdFromUrl(url); if (!bookmarkId) { console.log(`Could not extract bookmark ID from URL: ${url}`); return; } // Look up the mapping for this bookmark const mapping = await getMapping(bookmarkId); if (!mapping) { console.log(`No container mapping found for bookmark ID: ${bookmarkId}`); return; } // Get the clean URL (without the fragment) const cleanUrl = getCleanUrl(url); // Get the current tab info let tab; try { tab = await browser.tabs.get(tabId); } catch (error) { console.error('Failed to get tab info:', error); return; } // Skip if already in the correct container if (tab.cookieStoreId === mapping.containerId) { // Still need to redirect to clean URL if already in correct container if (cleanUrl !== url) { console.log(`Already in correct container, redirecting to clean URL: ${cleanUrl}`); redirectingTabs.add(tabId); await browser.tabs.update(tabId, { url: cleanUrl }); setTimeout(() => redirectingTabs.delete(tabId), 1000); } return; } console.log(`Intercepting navigation to ${url} -> redirecting to ${cleanUrl} in container ${mapping.containerName}`); // Mark this tab as being redirected redirectingTabs.add(tabId); try { // Close the current tab await browser.tabs.remove(tabId); // Open the CLEAN URL in the correct container const newTab = await browser.tabs.create({ url: cleanUrl, cookieStoreId: mapping.containerId, index: tab.index, active: tab.active }); // Mark the new tab as redirecting so we don't intercept it again redirectingTabs.add(newTab.id); // Remove from redirecting set after a short delay setTimeout(() => { redirectingTabs.delete(tabId); redirectingTabs.delete(newTab.id); }, 1000); } catch (error) { console.error('Failed to redirect tab:', error); redirectingTabs.delete(tabId); } } // ============================================================================ // Event Listeners // ============================================================================ // Listen for menu clicks browser.menus.onClicked.addListener(handleMenuClick); // Listen for navigation commits - intercept all except typed (address bar) browser.webNavigation.onCommitted.addListener(handleNavigation); // Listen for bookmark deletions to clean up mappings browser.bookmarks.onRemoved.addListener(async (bookmarkId) => { await removeMapping(bookmarkId); await buildUrlCache(); }); // Listen for bookmark URL changes browser.bookmarks.onChanged.addListener(async (bookmarkId, changeInfo) => { if (changeInfo.url) { await buildUrlCache(); } }); // Listen for storage changes to rebuild cache browser.storage.onChanged.addListener((changes, areaName) => { if (areaName === 'local' && changes[STORAGE_KEY]) { buildUrlCache(); } }); // Listen for container changes to refresh menu browser.contextualIdentities.onCreated.addListener(createContextMenus); browser.contextualIdentities.onRemoved.addListener(async (changeInfo) => { // Clean up mappings for deleted container const mappings = await getMappings(); const deletedContainerId = changeInfo.contextualIdentity.cookieStoreId; for (const [bookmarkId, mapping] of Object.entries(mappings)) { if (mapping.containerId === deletedContainerId) { await removeMapping(bookmarkId); } } await createContextMenus(); await buildUrlCache(); }); browser.contextualIdentities.onUpdated.addListener(createContextMenus); // ============================================================================ // Initialization // ============================================================================ // Initialize on startup async function init() { await createContextMenus(); await buildUrlCache(); console.log('Container Bookmarks extension loaded'); } init();