feat(context-menu): add dynamic quick actions and options

- Parent menu now has submenu with "All" and top 4 file-type actions
- Actions honor new Options: default action (download/copy) and include metadata in All
- Build submenu on open by collecting links; exclude meta from top-types
- Handle actions: copy to clipboard or send to aria2 using saved settings
This commit is contained in:
Jordan Wages 2025-08-22 01:18:04 -05:00
commit 9d5a3e4224
3 changed files with 168 additions and 4 deletions

View file

@ -1,5 +1,102 @@
// In MV2 background, aria2 helpers are exposed on globalThis by src/lib/aria2-bg.js
const DYNAMIC_PREFIX = 'aolg-act-';
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,
};
}
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 } = 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;
@ -56,7 +153,7 @@ browser.runtime.onInstalled.addListener(() => {
try {
browser.contextMenus.create({
id: 'aolg-collect',
title: 'Collect archive.org links',
title: 'Collect links',
contexts: ['page'],
documentUrlPatterns: ['https://archive.org/download/*']
});
@ -65,6 +162,11 @@ browser.runtime.onInstalled.addListener(() => {
}
});
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 {
@ -82,3 +184,38 @@ browser.contextMenus.onClicked.addListener(async (info, tab) => {
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 } = 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); }
try { await browser.browserAction.setBadgeBackgroundColor({ color: '#10b981' }); } catch (e) {}
try { await browser.browserAction.setBadgeText({ text: String(count) }); } catch (e) {}
try { await browser.browserAction.setTitle({ title: `Copied ${count} link(s)` }); } catch (e) {}
} else {
const { endpoint, secret } = await getAria2();
if (!endpoint) { console.warn('aria2 endpoint not set'); return; }
try { await globalThis.addUrisBatch({ endpoint, secret, uris }); } catch (e) { console.warn('aria2 send failed', e); }
try { await browser.browserAction.setBadgeBackgroundColor({ color: '#3b82f6' }); } catch (e) {}
try { await browser.browserAction.setBadgeText({ text: String(count) }); } catch (e) {}
try { await browser.browserAction.setTitle({ title: `Sent ${count} link(s) to aria2` }); } catch (e) {}
}
} catch (e) {
console.warn('Quick action failed:', e);
}
});