/** * browser-cli Extension — Background Service Worker * * Connects to the native host (com.browsercli.host) via Native Messaging. * The native host relays commands from the CLI Unix socket to this extension, * and relays responses back. */ const NATIVE_HOST = "com.browsercli.host"; let port = null; // ── Connection management ───────────────────────────────────────────────────── async function getProfileAlias() { const { profileAlias } = await chrome.storage.local.get("profileAlias"); return profileAlias || "default"; } async function connect() { if (port) return; try { port = chrome.runtime.connectNative(NATIVE_HOST); port.onMessage.addListener(onMessage); port.onDisconnect.addListener(() => { port = null; const err = chrome.runtime.lastError; if (err) console.warn("[browser-cli] Native host disconnected:", err.message); }); // Send hello so native host knows which profile/alias this is const alias = await getProfileAlias(); port.postMessage({ type: "hello", alias }); console.log("[browser-cli] Connected to native host as profile:", alias); } catch (e) { port = null; console.error("[browser-cli] Failed to connect:", e); } } chrome.runtime.onInstalled.addListener(connect); chrome.runtime.onStartup.addListener(connect); // Keepalive alarm — prevents service worker suspension and reconnects if needed chrome.alarms.create("keepalive", { periodInMinutes: 0.4 }); chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === "keepalive") { if (!port) connect(); } }); // ── Message dispatcher ──────────────────────────────────────────────────────── async function onMessage(msg) { const { id, command, args } = msg; if (!id || !command) return; console.log("[browser-cli] ←", command, args); let data, error; try { data = await dispatch(command, args || {}); } catch (e) { error = e.message || String(e); } if (error !== undefined) { console.log("[browser-cli] → ERROR", command, error); port.postMessage({ id, success: false, error }); } else { console.log("[browser-cli] →", command, data); port.postMessage({ id, success: true, data }); } } async function dispatch(command, args) { switch (command) { // ── Navigation ──────────────────────────────────────────────────────── case "navigate.open": return navOpen(args); case "navigate.reload": return navReload(args, false); case "navigate.hard_reload": return navReload(args, true); case "navigate.back": return navBack(args); case "navigate.forward": return navForward(args); case "navigate.focus": return navFocus(args); // ── Tabs ────────────────────────────────────────────────────────────── case "tabs.list": return tabsList(); case "tabs.close": return tabsClose(args); case "tabs.move": return tabsMove(args); case "tabs.active": return tabsActive(args); case "tabs.filter": return tabsFilter(args); case "tabs.count": return tabsCount(args); case "tabs.query": return tabsQuery(args); case "tabs.html": return tabsHtml(args); case "tabs.dedupe": return tabsDedupe(); case "tabs.sort": return tabsSort(args); case "tabs.merge_windows": return tabsMergeWindows(); // ── Groups ──────────────────────────────────────────────────────────── case "group.list": return groupList(); case "group.tabs": return groupTabs(args); case "group.count": return groupCount(); case "group.query": return groupQuery(args); case "group.close": return groupClose(args); case "group.open": return groupOpen(args); case "group.add_tab": return groupAddTab(args); case "group.move": return groupMove(args); // ── Windows ─────────────────────────────────────────────────────────── case "windows.list": return windowsList(); case "windows.rename": return windowsRename(args); case "windows.close": return windowsClose(args); case "windows.open": return windowsOpen(args); // ── DOM ─────────────────────────────────────────────────────────────── case "dom.query": return domOp("domQuery", args); case "dom.click": return domOp("domClick", args); case "dom.type": return domOp("domType", args); case "dom.attr": return domOp("domAttr", args); case "dom.text": return domOp("domText", args); case "dom.exists": return domOp("domExists", args); // ── Extract ─────────────────────────────────────────────────────────── case "extract.links": return domOp("extractLinks", args); case "extract.images": return domOp("extractImages", args); case "extract.text": return domOp("extractText", args); case "extract.json": return domOp("extractJson", args); case "extract.markdown": return domOp("extractMarkdown", args); case "extract.html": return tabsHtml({}); // ── Session ─────────────────────────────────────────────────────────── case "session.save": return sessionSave(args); case "session.load": return sessionLoad(args); case "session.list": return sessionList(); case "session.remove": return sessionRemove(args); case "session.diff": return sessionDiff(args); case "session.auto_save": return sessionAutoSave(args); // ── Misc ────────────────────────────────────────────────────────────── case "clients.list": return clientsList(); case "clients.rename_profile": return clientsRenameProfile(args); default: throw new Error(`Unknown command: ${command}`); } } // ── Navigation ──────────────────────────────────────────────────────────────── async function navOpen({ url, background, window: windowName, group: groupNameOrId }) { let windowId; if (windowName) { const aliases = await getAliases(); const entry = Object.entries(aliases).find(([, v]) => v === windowName); if (entry) windowId = parseInt(entry[0]); } const tab = await chrome.tabs.create({ url, active: !background, windowId }); if (groupNameOrId != null) { let groupId; try { groupId = await resolveGroupId(groupNameOrId); // Close any blank placeholder tabs that were created when the group was made const groupTabs = await chrome.tabs.query({ groupId }); const placeholders = groupTabs.filter(t => t.id !== tab.id && (t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/") ); await chrome.tabs.group({ tabIds: [tab.id], groupId }); if (placeholders.length) await chrome.tabs.remove(placeholders.map(t => t.id)); } catch (e) { if (!e.message.startsWith("No tab group found")) throw e; // Group doesn't exist — create it with the tab already in it groupId = await chrome.tabs.group({ tabIds: [tab.id] }); await chrome.tabGroups.update(groupId, { title: String(groupNameOrId) }); } } return { id: tab.id, url: tab.url }; } async function navReload({ tabId }, bypassCache) { const tab = tabId ? { id: tabId } : await getActiveTab(); await chrome.tabs.reload(tab.id, { bypassCache }); return { tabId: tab.id }; } async function navBack({ tabId }) { const tab = tabId ? { id: tabId } : await getActiveTab(); await chrome.tabs.goBack(tab.id); return { tabId: tab.id }; } async function navForward({ tabId }) { const tab = tabId ? { id: tabId } : await getActiveTab(); await chrome.tabs.goForward(tab.id); return { tabId: tab.id }; } async function navFocus({ pattern }) { // If pattern is a plain integer, treat it as a tab ID const asInt = parseInt(pattern); let match; if (!isNaN(asInt) && String(asInt) === String(pattern)) { match = await chrome.tabs.get(asInt); } else { const all = await chrome.tabs.query({}); match = all.find(t => (t.url && t.url.includes(pattern)) || (t.pendingUrl && t.pendingUrl.includes(pattern))); } if (!match) return null; await chrome.windows.update(match.windowId, { focused: true }); await chrome.tabs.update(match.id, { active: true }); return { id: match.id, url: match.url || match.pendingUrl, title: match.title }; } // ── Tabs ────────────────────────────────────────────────────────────────────── async function tabsList() { const windows = await chrome.windows.getAll({ populate: true }); const aliases = await getAliases(); const tabs = []; for (const w of windows) { for (const t of w.tabs) { tabs.push({ id: t.id, windowId: t.windowId, windowAlias: aliases[t.windowId] || null, active: t.active, pinned: t.pinned, title: t.title, url: t.url, favIconUrl: t.favIconUrl, groupId: t.groupId >= 0 ? t.groupId : null, }); } } return tabs; } async function tabsClose({ tabId, inactive, duplicates }) { let toClose = []; if (duplicates) { const all = await chrome.tabs.query({}); const seen = new Set(); for (const t of all) { if (seen.has(t.url)) toClose.push(t.id); else seen.add(t.url); } } else if (inactive) { const all = await chrome.tabs.query({}); toClose = all.filter(t => !t.active).map(t => t.id); } else if (tabId) { toClose = [tabId]; } if (toClose.length) await chrome.tabs.remove(toClose); return { closed: toClose.length }; } async function tabsMove({ tabId, groupId, windowId, index, forward, backward }) { const moveProps = {}; if (windowId != null) moveProps.windowId = windowId; if (forward || backward) { const tab = await chrome.tabs.get(tabId); if (forward) moveProps.index = tab.index + 2; // +2 because Chrome shifts after removal else moveProps.index = Math.max(0, tab.index - 1); } else if (index != null) { moveProps.index = index; } else { moveProps.index = -1; } await chrome.tabs.move(tabId, moveProps); if (groupId != null) { await chrome.tabs.group({ tabIds: [tabId], groupId }); } return { tabId }; } async function tabsActive({ tabId }) { const tab = await chrome.tabs.get(tabId); await chrome.windows.update(tab.windowId, { focused: true }); await chrome.tabs.update(tabId, { active: true }); return { tabId }; } async function tabsFilter({ pattern }) { const all = await chrome.tabs.query({}); return all.filter(t => t.url && t.url.includes(pattern)).map(tabInfo); } async function tabsCount({ pattern }) { const all = await chrome.tabs.query({}); if (pattern) return all.filter(t => t.url && t.url.includes(pattern)).length; return all.length; } async function tabsQuery({ search }) { const q = search.toLowerCase(); const all = await chrome.tabs.query({}); return all.filter(t => (t.url && t.url.toLowerCase().includes(q)) || (t.title && t.title.toLowerCase().includes(q)) ).map(tabInfo); } async function executeScript(options, retries = 3) { for (let i = 0; i < retries; i++) { try { return await chrome.scripting.executeScript(options); } catch (e) { if (i < retries - 1 && e.message && (e.message.includes("Frame with ID") || e.message.includes("No tab with id")) && !e.message.includes("error page")) { await new Promise(r => setTimeout(r, 300)); continue; } throw e; } } } async function tabsHtml({ tabId }) { for (let i = 0; i < 3; i++) { const tab = tabId ? await chrome.tabs.get(tabId) : await getActiveTab(); if (!isScriptableUrl(tab.url || tab.pendingUrl || "")) { throw new Error(`Cannot get HTML of ${tab.url || tab.pendingUrl} — navigate to a regular web page first`); } try { const results = await executeScript({ target: { tabId: tab.id }, func: () => document.documentElement.outerHTML, }); return results[0]?.result || ""; } catch (e) { const transient = e.message && (e.message.includes("Frame with ID") || e.message.includes("No tab with id")) && !e.message.includes("error page"); if (i < 2 && transient) { await new Promise(r => setTimeout(r, 300)); continue; } throw e; } } } async function tabsDedupe() { return tabsClose({ duplicates: true }); } async function tabsSort({ by }) { const windows = await chrome.windows.getAll({ populate: true }); let moved = 0; for (const w of windows) { const sorted = [...w.tabs].sort((a, b) => { if (by === "title") return (a.title || "").localeCompare(b.title || ""); if (by === "time") return a.id - b.id; // lower id = opened earlier // domain (default) const da = new URL(a.url || "about:blank").hostname; const db = new URL(b.url || "about:blank").hostname; return da.localeCompare(db); }); for (let i = 0; i < sorted.length; i++) { await chrome.tabs.move(sorted[i].id, { index: i }); moved++; } } return { moved }; } async function tabsMergeWindows() { const [focused] = await chrome.windows.getAll({ populate: false }); const current = await chrome.windows.getCurrent(); const all = await chrome.windows.getAll({ populate: true }); let moved = 0; for (const w of all) { if (w.id === current.id) continue; const ids = w.tabs.map(t => t.id); await chrome.tabs.move(ids, { windowId: current.id, index: -1 }); moved += ids.length; } return { moved }; } function tabInfo(t) { return { id: t.id, windowId: t.windowId, active: t.active, title: t.title, url: t.url }; } // ── Groups ──────────────────────────────────────────────────────────────────── async function groupList() { const groups = await chrome.tabGroups.query({}); const all = await chrome.tabs.query({}); return groups.map(g => ({ id: g.id, title: g.title, color: g.color, collapsed: g.collapsed, windowId: g.windowId, tabCount: all.filter(t => t.groupId === g.id).length, })); } async function groupTabs({ groupId }) { const all = await chrome.tabs.query({}); return all.filter(t => t.groupId === groupId).map(tabInfo); } async function groupCount() { const groups = await chrome.tabGroups.query({}); return groups.length; } async function groupQuery({ search }) { const q = search.toLowerCase(); const groups = await chrome.tabGroups.query({}); return groups.filter(g => g.title && g.title.toLowerCase().includes(q)); } async function groupClose({ groupId }) { const tabs = await chrome.tabs.query({}); const groupTabs = tabs.filter(t => t.groupId === groupId); await chrome.tabs.ungroup(groupTabs.map(t => t.id)); return { groupId }; } async function groupOpen({ name }) { const tab = await chrome.tabs.create({ active: true }); const groupId = await chrome.tabs.group({ tabIds: [tab.id] }); await chrome.tabGroups.update(groupId, { title: name }); return { id: groupId, name }; } async function groupAddTab({ group, url }) { const groupId = await resolveGroupId(group); const existingTabs = await chrome.tabs.query({ groupId }); const tab = await chrome.tabs.create({ url: url || "chrome://newtab/", active: true }); await chrome.tabs.group({ tabIds: [tab.id], groupId }); // If a URL was provided, close any blank placeholder tabs left from group creation if (url) { const placeholders = existingTabs.filter(t => t.url === "chrome://newtab/" || t.url === "about:blank" || t.pendingUrl === "chrome://newtab/" ); if (placeholders.length) await chrome.tabs.remove(placeholders.map(t => t.id)); } return { tabId: tab.id, groupId }; } async function groupMove({ group, forward, backward }) { const groupId = await resolveGroupId(group); const groupInfo = await chrome.tabGroups.get(groupId); const allTabs = await chrome.tabs.query({ windowId: groupInfo.windowId }); allTabs.sort((a, b) => a.index - b.index); const blocks = buildTabBlocks(allTabs); const currentIdx = blocks.findIndex(block => block.groupId === groupId); if (currentIdx === -1) throw new Error(`No tabs found in group '${group}'`); const currentBlock = blocks[currentIdx]; const currentLength = currentBlock.tabIds.length; if (forward) { const nextBlock = blocks[currentIdx + 1]; if (!nextBlock) return { groupId, moved: false }; const targetIndex = nextBlock.groupId === null ? currentBlock.startIndex + 1 : nextBlock.endIndex - currentLength + 1; await chrome.tabGroups.move(groupId, { index: targetIndex }); } else if (backward) { const previousBlock = blocks[currentIdx - 1]; if (!previousBlock) return { groupId, moved: false }; const targetIndex = previousBlock.groupId === null ? currentBlock.startIndex - 1 : previousBlock.startIndex; await chrome.tabGroups.move(groupId, { index: targetIndex }); } return { groupId, moved: true }; } // ── Windows ─────────────────────────────────────────────────────────────────── async function windowsList() { const windows = await chrome.windows.getAll({ populate: true }); const aliases = await getAliases(); return windows.map(w => ({ id: w.id, alias: aliases[w.id] || null, focused: w.focused, state: w.state, tabCount: (w.tabs || []).length, })); } async function windowsRename({ windowId, name }) { const aliases = await getAliases(); aliases[windowId] = name; await chrome.storage.local.set({ windowAliases: aliases }); return { windowId, name }; } async function windowsClose({ windowId }) { await chrome.windows.remove(windowId); return { windowId }; } async function windowsOpen({ profile }) { // profile support requires launching Chrome with --profile-directory which // isn't possible from within an extension — we open a plain new window. const w = await chrome.windows.create({ focused: true }); return { id: w.id }; } // ── DOM / Extract ───────────────────────────────────────────────────────────── function isScriptableUrl(url) { if (!url) return false; return !url.startsWith("chrome://") && !url.startsWith("brave://") && !url.startsWith("about:") && !url.startsWith("edge://") && !url.startsWith("chrome-extension://"); } async function domOp(funcName, args) { const tab = await getActiveTab(); if (!isScriptableUrl(tab.url)) { throw new Error(`Cannot run DOM commands on ${tab.url} — navigate to a regular web page first`); } const results = await executeScript({ target: { tabId: tab.id }, func: contentDispatch, args: [funcName, args], }); return results[0]?.result; } // This function is serialized and injected into the page by chrome.scripting function contentDispatch(funcName, args) { function domQuery({ selector }) { return Array.from(document.querySelectorAll(selector)).map(el => ({ tag: el.tagName.toLowerCase(), text: el.textContent.trim().slice(0, 200), attrs: Object.fromEntries(Array.from(el.attributes).map(a => [a.name, a.value])), })); } function domClick({ selector }) { const el = document.querySelector(selector); if (!el) throw new Error(`No element: ${selector}`); el.click(); return true; } function domType({ selector, text }) { const el = document.querySelector(selector); if (!el) throw new Error(`No element: ${selector}`); el.focus(); el.value = text; el.dispatchEvent(new Event("input", { bubbles: true })); el.dispatchEvent(new Event("change", { bubbles: true })); return true; } function domAttr({ selector, attr }) { return Array.from(document.querySelectorAll(selector)) .map(el => el.getAttribute(attr)) .filter(v => v !== null); } function domText({ selector }) { return Array.from(document.querySelectorAll(selector)) .map(el => el.textContent.trim()) .filter(Boolean); } function domExists({ selector }) { return document.querySelector(selector) !== null; } function extractLinks() { const seen = new Set(); return Array.from(document.querySelectorAll("a[href]")).reduce((links, a) => { const href = a.href; if (!href || seen.has(href)) return links; seen.add(href); links.push({ text: a.textContent.trim().slice(0, 100), href, }); return links; }, []); } function extractImages() { const seen = new Set(); return Array.from(document.querySelectorAll("img")).reduce((images, img) => { const src = img.src || img.getAttribute("data-src") || img.getAttribute("data-lazy-src") || img.getAttribute("data-original") || (img.srcset ? img.srcset.split(",")[0].trim().split(" ")[0] : "") || ""; if (!src || seen.has(src)) return images; seen.add(src); const FAKE_ALT = new Set(["true", "false", "null", "undefined", "image", "img"]); const alt = img.alt && !FAKE_ALT.has(img.alt.trim().toLowerCase()) ? img.alt.trim() : ""; images.push({ alt, src }); return images; }, []); } function extractText() { return document.body.innerText; } function extractJson({ selector }) { const el = document.querySelector(selector); if (!el) throw new Error(`No element: ${selector}`); return JSON.parse(el.textContent); } function extractMarkdown({ selector }) { const BLOCKS = new Set([ "article", "aside", "blockquote", "body", "div", "dl", "fieldset", "figcaption", "figure", "footer", "form", "h1", "h2", "h3", "h4", "h5", "h6", "header", "hr", "li", "main", "nav", "ol", "p", "pre", "section", "table", "tbody", "td", "tfoot", "th", "thead", "tr", "ul" ]); function normalizeText(value) { return value.replace(/\s+/g, " ").trim(); } function normalizeInline(value) { return value .replace(/[ \t]+\n/g, "\n") .replace(/\n[ \t]+/g, "\n") .replace(/\n{3,}/g, "\n\n") .replace(/[ \t]{2,}/g, " ") .trim(); } function collapseBlankLines(value) { return value .replace(/[ \t]+\n/g, "\n") .replace(/\n{3,}/g, "\n\n") .trim(); } function escapeMarkdown(text) { return text.replace(/([\\`*_{}\[\]()#+\-!|>])/g, "\\$1"); } function escapeTableCell(text) { return text.replace(/\|/g, "\\|").replace(/\n+/g, " ").trim(); } function absoluteUrl(attr, fallback) { return attr || fallback || ""; } function stripNoise(root) { const clone = root.cloneNode(true); clone.querySelectorAll("script, style, noscript, template").forEach(node => node.remove()); return clone; } function pickRoot() { if (selector) { const matched = document.querySelector(selector); if (!matched) throw new Error(`No element: ${selector}`); return matched; } const candidates = Array.from(document.querySelectorAll("main, article, [role='main']")) .filter(node => normalizeText(node.innerText || "").length > 0); if (!candidates.length) return document.body; candidates.sort((a, b) => (b.innerText || "").length - (a.innerText || "").length); return candidates[0]; } function inlineText(node) { if (node.nodeType === Node.TEXT_NODE) { return escapeMarkdown(node.textContent || ""); } if (node.nodeType !== Node.ELEMENT_NODE) return ""; const tag = node.tagName.toLowerCase(); if (tag === "script" || tag === "style" || tag === "noscript" || tag === "template") return ""; if (tag === "br") return "\n"; if (tag === "img") { const src = absoluteUrl(node.getAttribute("src"), node.src); if (!src) return ""; const alt = normalizeText(node.getAttribute("alt") || ""); return alt ? `![${escapeMarkdown(alt)}](${src})` : `![](${src})`; } if (tag === "a") { const text = normalizeInline(Array.from(node.childNodes).map(inlineText).join("")); const href = absoluteUrl(node.getAttribute("href"), node.href); if (!href) return text; return `[${text || href}](${href})`; } if (tag === "code") { const text = normalizeInline(Array.from(node.childNodes).map(inlineText).join("")); return text ? `\`${text.replace(/`/g, "\\`")}\`` : ""; } if (tag === "strong" || tag === "b") { const text = normalizeInline(Array.from(node.childNodes).map(inlineText).join("")); return text ? `**${text}**` : ""; } if (tag === "em" || tag === "i") { const text = normalizeInline(Array.from(node.childNodes).map(inlineText).join("")); return text ? `*${text}*` : ""; } const chunks = []; for (const child of node.childNodes) { const rendered = inlineText(child); if (!rendered) continue; chunks.push(rendered); if (child.nodeType === Node.ELEMENT_NODE && BLOCKS.has(child.tagName.toLowerCase())) { chunks.push("\n"); } } return chunks.join(""); } function textBlock(node) { return collapseBlankLines(normalizeInline(Array.from(node.childNodes).map(inlineText).join(""))); } function tableToMarkdown(table) { const rows = Array.from(table.querySelectorAll("tr")) .map(row => Array.from(row.children) .filter(cell => cell.tagName === "TD" || cell.tagName === "TH") .map(cell => escapeTableCell(textBlock(cell))) ) .filter(cells => cells.length > 0); if (!rows.length) return ""; const widths = rows.reduce((max, row) => Math.max(max, row.length), 0); const normalizedRows = rows.map(row => { const next = row.slice(); while (next.length < widths) next.push(""); return next; }); let headers = normalizedRows[0]; let bodyRows = normalizedRows.slice(1); const firstRow = table.querySelector("tr"); const thead = table.querySelector("thead"); const firstRowHasTh = firstRow && Array.from(firstRow.children).some(cell => cell.tagName === "TH"); if (!(thead || firstRowHasTh)) { headers = new Array(widths).fill(""); bodyRows = normalizedRows; } const separator = new Array(widths).fill("---"); const lines = [ `| ${headers.join(" | ")} |`, `| ${separator.join(" | ")} |`, ]; for (const row of bodyRows) { lines.push(`| ${row.join(" | ")} |`); } return lines.join("\n"); } function listToMarkdown(list, depth = 0) { const ordered = list.tagName.toLowerCase() === "ol"; const items = []; const children = Array.from(list.children).filter(child => child.tagName === "LI"); children.forEach((item, index) => { const marker = ordered ? `${index + 1}. ` : "- "; const indent = " ".repeat(depth); const nested = []; const content = []; for (const child of item.childNodes) { if (child.nodeType === Node.ELEMENT_NODE && (child.tagName === "UL" || child.tagName === "OL")) { nested.push(listToMarkdown(child, depth + 1)); } else { content.push(inlineText(child)); } } const line = collapseBlankLines(normalizeInline(content.join(""))); if (line) items.push(`${indent}${marker}${line}`); nested.filter(Boolean).forEach(block => items.push(block)); }); return items.join("\n"); } function blockToMarkdown(node) { if (node.nodeType === Node.TEXT_NODE) { return normalizeText(node.textContent || ""); } if (node.nodeType !== Node.ELEMENT_NODE) return ""; const tag = node.tagName.toLowerCase(); if (tag === "script" || tag === "style" || tag === "noscript" || tag === "template") return ""; if (tag === "table") return tableToMarkdown(node); if (tag === "ul" || tag === "ol") return listToMarkdown(node); if (tag === "pre") { const code = node.innerText.replace(/\n$/, ""); return code ? `\`\`\`\n${code}\n\`\`\`` : ""; } if (tag === "blockquote") { const content = collapseBlankLines(Array.from(node.childNodes).map(blockToMarkdown).join("\n\n")); return content .split("\n") .map(line => line ? `> ${line}` : ">") .join("\n"); } if (/^h[1-6]$/.test(tag)) { const level = Number(tag.slice(1)); const text = textBlock(node); return text ? `${"#".repeat(level)} ${text}` : ""; } if (tag === "p" || tag === "figcaption") { return textBlock(node); } if (tag === "hr") { return "---"; } if (tag === "img") { return inlineText(node); } const childBlocks = Array.from(node.childNodes) .map(child => blockToMarkdown(child)) .filter(Boolean); if (childBlocks.length) return collapseBlankLines(childBlocks.join("\n\n")); return textBlock(node); } const root = stripNoise(pickRoot()); const markdown = blockToMarkdown(root); return collapseBlankLines(markdown); } const fns = { domQuery, domClick, domType, domAttr, domText, domExists, extractLinks, extractImages, extractText, extractJson, extractMarkdown }; const fn = fns[funcName]; if (!fn) throw new Error(`Unknown content function: ${funcName}`); return fn(args); } // ── Session ─────────────────────────────────────────────────────────────────── async function sessionSave({ name }) { const tabs = await chrome.tabs.query({}); const groups = await chrome.tabGroups.query({}); const groupById = new Map(groups.map(group => [group.id, group])); const sessionTabs = tabs .filter(tab => Boolean(tab.url)) .sort((a, b) => (a.windowId - b.windowId) || (a.index - b.index)) .map(tab => { const entry = { url: tab.url }; if (tab.groupId >= 0) { const group = groupById.get(tab.groupId); entry.group = { key: `${tab.windowId}:${tab.groupId}`, title: group?.title || "", color: normalizeGroupColor(group?.color), collapsed: Boolean(group?.collapsed), }; } return entry; }); const sessions = await getSessions(); sessions[name] = { tabs: sessionTabs, urls: sessionTabs.map(tab => tab.url), savedAt: Date.now(), }; await chrome.storage.local.set({ sessions }); return { name, tabs: sessionTabs.length }; } async function sessionLoad({ name }) { const sessions = await getSessions(); const session = sessions[name]; if (!session) throw new Error(`Session '${name}' not found`); const sessionTabs = getSessionTabs(session); const createdTabs = []; for (const entry of sessionTabs) { const tab = await chrome.tabs.create({ url: entry.url, active: false }); createdTabs.push({ tabId: tab.id, entry }); } const groups = new Map(); for (const { tabId, entry } of createdTabs) { if (!entry.group) continue; const key = entry.group.key || `${entry.group.title || "group"}:${groups.size}`; if (!groups.has(key)) { groups.set(key, { meta: entry.group, tabIds: [] }); } groups.get(key).tabIds.push(tabId); } for (const { meta, tabIds } of groups.values()) { const restoredGroupId = await chrome.tabs.group({ tabIds }); await chrome.tabGroups.update(restoredGroupId, { title: meta.title || "", color: normalizeGroupColor(meta.color), collapsed: Boolean(meta.collapsed), }); } return { name, tabs: sessionTabs.length }; } async function sessionList() { const sessions = await getSessions(); return Object.entries(sessions).map(([name, s]) => ({ name, tabs: getSessionTabs(s).length, savedAt: s.savedAt || null, })); } async function sessionRemove({ name }) { const sessions = await getSessions(); if (!(name in sessions)) throw new Error(`Session '${name}' not found`); delete sessions[name]; await chrome.storage.local.set({ sessions }); return { name }; } async function sessionDiff({ nameA, nameB }) { const sessions = await getSessions(); const a = new Set(getSessionTabs(sessions[nameA]).map(tab => tab.url)); const b = new Set(getSessionTabs(sessions[nameB]).map(tab => tab.url)); return { added: [...b].filter(u => !a.has(u)), removed: [...a].filter(u => !b.has(u)), }; } async function sessionAutoSave({ enabled }) { await chrome.storage.local.set({ autoSave: enabled }); if (enabled) { chrome.tabs.onUpdated.addListener(autoSaveHandler); chrome.tabs.onRemoved.addListener(autoSaveHandler); } return { enabled }; } async function autoSaveHandler() { const { autoSave } = await chrome.storage.local.get("autoSave"); if (!autoSave) return; await sessionSave({ name: "__auto__" }); } // ── Misc ────────────────────────────────────────────────────────────────────── async function clientsList() { const manifest = chrome.runtime.getManifest(); const alias = await getProfileAlias(); return [{ name: "Chrome", version: navigator.userAgent.match(/Chrome\/([\d.]+)/)?.[1] || "unknown", platform: navigator.platform, extensionVersion: manifest.version, profile: alias, }]; } async function clientsRenameProfile({ alias }) { await chrome.storage.local.set({ profileAlias: alias }); return { alias }; } // ── Helpers ─────────────────────────────────────────────────────────────────── async function getActiveTab() { const [tab] = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); if (!tab) throw new Error("No active tab found"); return tab; } async function resolveGroupId(nameOrId) { const asInt = parseInt(nameOrId); if (!isNaN(asInt)) return asInt; const groups = await chrome.tabGroups.query({}); const match = groups.find(g => g.title && g.title.toLowerCase() === String(nameOrId).toLowerCase()); if (!match) throw new Error(`No tab group found with name '${nameOrId}'`); return match.id; } function buildTabBlocks(tabs) { const blocks = []; for (const tab of tabs) { const normalizedGroupId = tab.groupId >= 0 ? tab.groupId : null; const lastBlock = blocks[blocks.length - 1]; if (lastBlock?.groupId === normalizedGroupId) { lastBlock.tabIds.push(tab.id); lastBlock.endIndex = tab.index; continue; } blocks.push({ groupId: normalizedGroupId, startIndex: tab.index, endIndex: tab.index, tabIds: [tab.id], }); } return blocks; } function getSessionTabs(session) { if (!session) return []; if (Array.isArray(session.tabs)) { return session.tabs .map(entry => typeof entry === "string" ? { url: entry } : entry) .filter(entry => entry?.url); } if (Array.isArray(session.urls)) { return session.urls.filter(Boolean).map(url => ({ url })); } return []; } function normalizeGroupColor(color) { const allowed = new Set(["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]); return allowed.has(color) ? color : "grey"; } async function getAliases() { const { windowAliases } = await chrome.storage.local.get("windowAliases"); return windowAliases || {}; } async function getSessions() { const { sessions } = await chrome.storage.local.get("sessions"); return sessions || {}; }