Files
firefox-containerbookmarks/background.js

531 lines
15 KiB
JavaScript

/**
* 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-<bookmarkId> (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();