// In MV2 background, aria2 helpers are exposed on globalThis by src/lib/aria2-bg.js const DYNAMIC_PREFIX = 'aolg-act-'; const A_DOWNLOAD_RE = /^https:\/\/archive\.org\/download\//; // Per-tab state: { url, isArchive, itemsCount, filteredCount } const tabState = new Map(); function getState(tabId) { let s = tabState.get(tabId); if (!s) { s = { url: '', isArchive: false, itemsCount: 0, filteredCount: null }; tabState.set(tabId, s); } return s; } async function setBadgeForTab(tabId, { text = '', color = '#3b82f6', title = '' } = {}) { try { await browser.browserAction.setBadgeBackgroundColor({ color, tabId }); } catch (_) {} try { await browser.browserAction.setBadgeText({ text, tabId }); } catch (_) {} if (title) { try { await browser.browserAction.setTitle({ title, tabId }); } catch (_) {} } } async function recomputeBadge(tabId) { try { const tab = await browser.tabs.get(tabId); const s = getState(tabId); s.url = tab.url || ''; s.isArchive = !!(tab.url && A_DOWNLOAD_RE.test(tab.url)); // Prefer filteredCount when present (user-adjusted in popup) if (s.filteredCount != null) { const count = s.filteredCount; await setBadgeForTab(tabId, { text: count ? String(count) : '', color: '#6366f1', title: count ? `Filtered ${count} link(s)` : 'No filtered links' }); return; } // Default for archive pages: show total items if (s.isArchive) { const count = s.itemsCount || 0; await setBadgeForTab(tabId, { text: count ? String(count) : '', color: '#3b82f6', title: count ? `Found ${count} link(s)` : 'No links found' }); } else { // Non-archive pages: clear badge unless popup provides a filtered count await setBadgeForTab(tabId, { text: '' }); } } catch (_) {} } let dynamicMenuIds = []; const dynamicActions = new Map(); // id -> { kind: 'all'|'type', type?: string } function parseIdentifierFromUrl(url) { try { const u = new URL(url); const parts = u.pathname.split('/').filter(Boolean); const idx = parts.indexOf('download'); if (idx >= 0 && parts[idx + 1]) return parts[idx + 1]; } catch (_) {} return ''; } function isMeta(item, identifier) { const name = String(item?.name || ''); if (!name) return false; if (name === '__ia_thumb.jpg') return true; if (identifier && name.startsWith(identifier + '_')) return true; return false; } async function getPrefs() { const { prefs } = await browser.storage.local.get('prefs'); return { defaultAction: prefs?.defaultAction || 'download', allIncludeMeta: !!prefs?.allIncludeMeta, perPageDirEnabled: !!prefs?.perPageDirEnabled, perPageDirBase: prefs?.perPageDirBase || '/aria2/data', }; } async function getAria2() { const { aria2 } = await browser.storage.local.get('aria2'); return { endpoint: aria2?.endpoint || '', secret: aria2?.secret || '' }; } function computeTopTypes(items, identifier, limit = 4) { const counts = new Map(); for (const it of items || []) { if (isMeta(it, identifier)) continue; const t = (it.type || '').toLowerCase(); if (!t) continue; counts.set(t, (counts.get(t) || 0) + 1); } return Array.from(counts.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, limit) .map(([type, count]) => ({ type, count })); } async function rebuildContextSubmenu(tab) { if (!tab?.id) return; // Clear previous dynamic children for (const id of dynamicMenuIds) { try { await browser.contextMenus.remove(id); } catch (_) {} dynamicActions.delete(id); } dynamicMenuIds = []; let items = []; try { items = await collectFromTab(tab.id); } catch (_) { items = []; } const identifier = parseIdentifierFromUrl(tab.url || ''); const { defaultAction, allIncludeMeta, perPageDirEnabled, perPageDirBase } = await getPrefs(); const verb = defaultAction === 'copy' ? 'Copy' : 'Download'; const top = computeTopTypes(items, identifier, 4); // All action const allId = DYNAMIC_PREFIX + 'all'; try { await browser.contextMenus.create({ id: allId, title: `${verb} All`, parentId: 'aolg-collect', contexts: ['page'] }); dynamicMenuIds.push(allId); dynamicActions.set(allId, { kind: 'all' }); } catch (_) {} // Type-specific actions for (const { type, count } of top) { const id = `${DYNAMIC_PREFIX}type:${type}`; try { await browser.contextMenus.create({ id, title: `${verb} All ${type} (${count})`, parentId: 'aolg-collect', contexts: ['page'] }); dynamicMenuIds.push(id); dynamicActions.set(id, { kind: 'type', type }); } catch (_) {} } try { await browser.contextMenus.refresh(); } catch (_) {} } browser.runtime.onMessage.addListener(async (msg, sender) => { if (!msg || !msg.type) return; if (msg.type === 'aria2.send') { const { endpoint, secret, uris, options } = msg.payload || {}; if (!endpoint || !Array.isArray(uris) || uris.length === 0) { return { ok: false, error: 'Missing endpoint or URIs' }; } try { const res = await globalThis.addUrisBatch({ endpoint, secret, uris, options }); return { ok: true, result: res }; } catch (err) { return { ok: false, error: String(err && err.message || err) }; } } if (msg.type === 'collect.fromTab') { const tabId = msg.tabId || sender?.tab?.id; if (!tabId) return { ok: false, error: 'No tabId' }; try { const items = await collectFromTab(tabId); const s = getState(tabId); s.itemsCount = (items || []).length; await recomputeBadge(tabId); return { ok: true, items }; } catch (err) { return { ok: false, error: String(err && err.message || err) }; } } if (msg.type === 'popup.filteredCount') { const tabId = msg.tabId || sender?.tab?.id; if (!tabId) return; const count = Number(msg.count || 0); const s = getState(tabId); s.filteredCount = count; await recomputeBadge(tabId); return { ok: true }; } }); async function collectFromTab(tabId) { try { // Try asking an existing content script first const items = await browser.tabs.sendMessage(tabId, { type: 'collectLinks' }); return items || []; } catch (err) { // If there is no receiver yet, inject the content script and retry const noReceiver = /Receiving end does not exist|Could not establish connection/i.test(String(err)); try { if (noReceiver) { try { await browser.tabs.executeScript(tabId, { file: 'src/content/collect.js' }); } catch (_) {} const items = await browser.tabs.sendMessage(tabId, { type: 'collectLinks' }); return items || []; } } catch (err2) { // Log and rethrow below console.warn('collectFromTab retry failed:', err2); } console.warn('collectFromTab failed:', err); throw err; } } // Context menu to collect links on archive.org /download/* pages browser.runtime.onInstalled.addListener(() => { try { browser.contextMenus.create({ id: 'aolg-collect', title: 'Collect links…', contexts: ['page'], documentUrlPatterns: ['https://archive.org/download/*'] }); } catch (e) { // ignore if already exists or not supported } }); browser.contextMenus.onShown.addListener(async (info, tab) => { if (!tab?.url || !/^https:\/\/archive\.org\/download\//.test(tab.url)) return; await rebuildContextSubmenu(tab); }); browser.contextMenus.onClicked.addListener(async (info, tab) => { if (info.menuItemId !== 'aolg-collect' || !tab?.id) return; try { const items = await collectFromTab(tab.id); const count = (items || []).length; const s = getState(tab.id); s.url = tab.url || ''; s.isArchive = !!(tab.url && A_DOWNLOAD_RE.test(tab.url)); s.itemsCount = count; s.filteredCount = null; // reset to default after explicit collect await browser.storage.local.set({ lastCollected: { tabId: tab.id, url: tab.url, time: Date.now(), count }, lastItems: items }); await setBadgeForTab(tab.id, { text: count ? String(count) : '', color: '#3b82f6', title: count ? `Collected ${count} link(s)` : 'No links collected' }); console.debug('Collected links from page:', { count }); } catch (e) { console.warn('Context menu collection failed:', e); } }); browser.contextMenus.onClicked.addListener(async (info, tab) => { const action = dynamicActions.get(String(info.menuItemId)); if (!action || !tab?.id) return; try { const { defaultAction, allIncludeMeta, perPageDirEnabled, perPageDirBase } = await getPrefs(); const identifier = parseIdentifierFromUrl(tab.url || ''); const items = await collectFromTab(tab.id); let selected = items || []; if (action.kind === 'type') { selected = selected.filter(it => (it.type || '').toLowerCase() === String(action.type || '').toLowerCase()); } else { if (!allIncludeMeta) selected = selected.filter(it => !isMeta(it, identifier)); } const uris = selected.map(i => i.url); const count = uris.length; if (count === 0) return; if (defaultAction === 'copy') { try { await navigator.clipboard.writeText(uris.join('\n')); } catch (e) { console.warn('clipboard write failed', e); } await setBadgeForTab(tab.id, { text: String(count), color: '#10b981', title: `Copied ${count} link(s)` }); } else { const { endpoint, secret } = await getAria2(); if (!endpoint) { console.warn('aria2 endpoint not set'); return; } // Build aria2 options (dir) based on prefs let options = {}; try { if (perPageDirEnabled && perPageDirBase) { const base = String(perPageDirBase).replace(/\/+$/, ''); const dir = identifier ? `${base}/${identifier}` : base; if (dir) options.dir = dir; } } catch (_) { /* noop */ } try { await globalThis.addUrisBatch({ endpoint, secret, uris, options }); } catch (e) { console.warn('aria2 send failed', e); } await setBadgeForTab(tab.id, { text: String(count), color: '#3b82f6', title: `Sent ${count} link(s) to aria2` }); } } catch (e) { console.warn('Quick action failed:', e); } }); // React to tab activation and URL changes to refresh per-tab badges browser.tabs.onActivated.addListener(async ({ tabId }) => { await recomputeBadge(tabId); try { const tab = await browser.tabs.get(tabId); if (tab?.url && A_DOWNLOAD_RE.test(tab.url)) { const items = await collectFromTab(tabId); const s = getState(tabId); s.itemsCount = (items || []).length; // Only update badge if no filtered count is set if (s.filteredCount == null) await recomputeBadge(tabId); } } catch (_) {} }); browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { if (changeInfo.status === 'complete' || changeInfo.url) { const s = getState(tabId); s.url = tab?.url || ''; s.isArchive = !!(tab?.url && A_DOWNLOAD_RE.test(tab.url)); // Reset filtered count on navigation; it reflects popup state for previous page s.filteredCount = null; if (s.isArchive) { try { const items = await collectFromTab(tabId); s.itemsCount = (items || []).length; } catch (_) { s.itemsCount = 0; } } else { s.itemsCount = 0; } await recomputeBadge(tabId); } }); browser.tabs.onRemoved.addListener((tabId) => { tabState.delete(tabId); });