531 lines
15 KiB
JavaScript
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();
|
|
|