feat: Add initial Firefox container tab extension with popup UI, background script, manifest, and a comprehensive set of icons.
This commit is contained in:
530
background.js
Normal file
530
background.js
Normal file
@@ -0,0 +1,530 @@
|
||||
/**
|
||||
* 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();
|
||||
|
||||
Reference in New Issue
Block a user