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:
		
					parent
					
						
							
								92ec0a9a74
							
						
					
				
			
			
				commit
				
					
						9d5a3e4224
					
				
			
		
					 3 changed files with 168 additions and 4 deletions
				
			
		| 
						 | 
					@ -1,5 +1,102 @@
 | 
				
			||||||
// In MV2 background, aria2 helpers are exposed on globalThis by src/lib/aria2-bg.js
 | 
					// 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) => {
 | 
					browser.runtime.onMessage.addListener(async (msg, sender) => {
 | 
				
			||||||
  if (!msg || !msg.type) return;
 | 
					  if (!msg || !msg.type) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -56,7 +153,7 @@ browser.runtime.onInstalled.addListener(() => {
 | 
				
			||||||
  try {
 | 
					  try {
 | 
				
			||||||
    browser.contextMenus.create({
 | 
					    browser.contextMenus.create({
 | 
				
			||||||
      id: 'aolg-collect',
 | 
					      id: 'aolg-collect',
 | 
				
			||||||
      title: 'Collect archive.org links',
 | 
					      title: 'Collect links…',
 | 
				
			||||||
      contexts: ['page'],
 | 
					      contexts: ['page'],
 | 
				
			||||||
      documentUrlPatterns: ['https://archive.org/download/*']
 | 
					      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) => {
 | 
					browser.contextMenus.onClicked.addListener(async (info, tab) => {
 | 
				
			||||||
  if (info.menuItemId !== 'aolg-collect' || !tab?.id) return;
 | 
					  if (info.menuItemId !== 'aolg-collect' || !tab?.id) return;
 | 
				
			||||||
  try {
 | 
					  try {
 | 
				
			||||||
| 
						 | 
					@ -82,3 +184,38 @@ browser.contextMenus.onClicked.addListener(async (info, tab) => {
 | 
				
			||||||
    console.warn('Context menu collection failed:', 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 } = 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);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -27,8 +27,22 @@
 | 
				
			||||||
          <output id="status"></output>
 | 
					          <output id="status"></output>
 | 
				
			||||||
        </div>
 | 
					        </div>
 | 
				
			||||||
      </section>
 | 
					      </section>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <section>
 | 
				
			||||||
 | 
					        <h2>Quick Actions</h2>
 | 
				
			||||||
 | 
					        <div class="row">
 | 
				
			||||||
 | 
					          <label>Default Action
 | 
				
			||||||
 | 
					            <select id="default-action">
 | 
				
			||||||
 | 
					              <option value="download">Download via aria2 RPC</option>
 | 
				
			||||||
 | 
					              <option value="copy">Copy to clipboard</option>
 | 
				
			||||||
 | 
					            </select>
 | 
				
			||||||
 | 
					          </label>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					        <div class="row">
 | 
				
			||||||
 | 
					          <label class="chk"><input type="checkbox" id="include-meta" /> Include metadata in “All”</label>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      </section>
 | 
				
			||||||
    </main>
 | 
					    </main>
 | 
				
			||||||
    <script type="module" src="index.js"></script>
 | 
					    <script type="module" src="index.js"></script>
 | 
				
			||||||
  </body>
 | 
					  </body>
 | 
				
			||||||
  </html>
 | 
					  </html>
 | 
				
			||||||
 | 
					 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -3,6 +3,8 @@ import { getVersion } from "../lib/aria2.js";
 | 
				
			||||||
const els = {
 | 
					const els = {
 | 
				
			||||||
  endpoint: document.getElementById('rpc-endpoint'),
 | 
					  endpoint: document.getElementById('rpc-endpoint'),
 | 
				
			||||||
  secret: document.getElementById('rpc-secret'),
 | 
					  secret: document.getElementById('rpc-secret'),
 | 
				
			||||||
 | 
					  defaultAction: document.getElementById('default-action'),
 | 
				
			||||||
 | 
					  includeMeta: document.getElementById('include-meta'),
 | 
				
			||||||
  btnSave: document.getElementById('btn-save'),
 | 
					  btnSave: document.getElementById('btn-save'),
 | 
				
			||||||
  btnReset: document.getElementById('btn-reset'),
 | 
					  btnReset: document.getElementById('btn-reset'),
 | 
				
			||||||
  btnTest: document.getElementById('btn-test'),
 | 
					  btnTest: document.getElementById('btn-test'),
 | 
				
			||||||
| 
						 | 
					@ -13,17 +15,28 @@ async function restore() {
 | 
				
			||||||
  const { aria2 } = await browser.storage.local.get('aria2');
 | 
					  const { aria2 } = await browser.storage.local.get('aria2');
 | 
				
			||||||
  els.endpoint.value = aria2?.endpoint || 'http://localhost:6800/jsonrpc';
 | 
					  els.endpoint.value = aria2?.endpoint || 'http://localhost:6800/jsonrpc';
 | 
				
			||||||
  els.secret.value = aria2?.secret || '';
 | 
					  els.secret.value = aria2?.secret || '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const { prefs } = await browser.storage.local.get('prefs');
 | 
				
			||||||
 | 
					  const defaultAction = prefs?.defaultAction || 'download';
 | 
				
			||||||
 | 
					  const includeMeta = !!prefs?.allIncludeMeta;
 | 
				
			||||||
 | 
					  els.defaultAction.value = defaultAction;
 | 
				
			||||||
 | 
					  els.includeMeta.checked = includeMeta;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async function save() {
 | 
					async function save() {
 | 
				
			||||||
  const endpoint = els.endpoint.value.trim();
 | 
					  const endpoint = els.endpoint.value.trim();
 | 
				
			||||||
  const secret = els.secret.value.trim();
 | 
					  const secret = els.secret.value.trim();
 | 
				
			||||||
  await browser.storage.local.set({ aria2: { endpoint, secret } });
 | 
					  const defaultAction = els.defaultAction.value;
 | 
				
			||||||
 | 
					  const allIncludeMeta = !!els.includeMeta.checked;
 | 
				
			||||||
 | 
					  await browser.storage.local.set({
 | 
				
			||||||
 | 
					    aria2: { endpoint, secret },
 | 
				
			||||||
 | 
					    prefs: { defaultAction, allIncludeMeta }
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
  els.status.textContent = 'Saved.';
 | 
					  els.status.textContent = 'Saved.';
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
async function reset() {
 | 
					async function reset() {
 | 
				
			||||||
  await browser.storage.local.remove('aria2');
 | 
					  await browser.storage.local.remove(['aria2', 'prefs']);
 | 
				
			||||||
  await restore();
 | 
					  await restore();
 | 
				
			||||||
  els.status.textContent = 'Reset to defaults.';
 | 
					  els.status.textContent = 'Reset to defaults.';
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue