/** * 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; let keepaliveEnabled = true; // ── Connection management ───────────────────────────────────────────────────── function sendControlMessage(targetPort, message) { if (!targetPort) return; try { targetPort.postMessage(message); } catch (e) { console.warn("[browser-cli] Failed to send control message:", e); } } function disconnectPort({ sendBye = false } = {}) { const currentPort = port; if (!currentPort) return; if (sendBye) sendControlMessage(currentPort, { type: "bye" }); if (port === currentPort) port = null; try { currentPort.disconnect(); } catch (e) { console.warn("[browser-cli] Failed to disconnect native port:", e); } } async function getProfileAlias() { const { profileAlias } = await chrome.storage.local.get("profileAlias"); return profileAlias || "default"; } async function connect() { if (port || !keepaliveEnabled) return; try { const nativePort = chrome.runtime.connectNative(NATIVE_HOST); port = nativePort; nativePort.onMessage.addListener(onMessage); nativePort.onDisconnect.addListener(() => { if (port === nativePort) 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(); nativePort.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); chrome.runtime.onSuspend.addListener(() => { disconnectPort({ sendBye: true }); }); chrome.windows.onCreated.addListener(() => { keepaliveEnabled = true; if (!port) connect(); }); chrome.windows.onRemoved.addListener(async () => { const windows = await chrome.windows.getAll({}); if (windows.length > 0) return; keepaliveEnabled = false; disconnectPort({ sendBye: true }); }); // 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 && keepaliveEnabled) 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 }); } if (command === "clients.rename_profile" && error === undefined) { disconnectPort({ sendBye: true }); keepaliveEnabled = true; await connect(); } } async function dispatch(command, args) { switch (command) { // ── Navigation ──────────────────────────────────────────────────────── case "navigate.open": return navOpen(args); case "navigate.to": return navTo(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.active_in_window": return tabsActiveInWindow(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, windowId: explicitWindowId, group: groupNameOrId }) { let windowId; if (explicitWindowId != null) { windowId = explicitWindowId; } else 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 navTo({ tabId, url }) { const tab = await chrome.tabs.update(tabId, { url }); return { id: tab.id, url: tab.url || 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 tabsActiveInWindow({ windowId }) { const activeTabs = await chrome.tabs.query({ windowId, active: true }); const tab = activeTabs[0]; if (!tab) { throw new Error(`No active tab found for window ${windowId}`); } return tabInfo(tab); } 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({ url }) { const createData = { focused: true }; if (url) createData.url = url; const w = await chrome.windows.create(createData); 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" ]); const NOISE_SELECTOR = [ "script", "style", "noscript", "template", "svg", "canvas", "iframe", "dialog", "button", "input", "textarea", "select", "option", "form", "[hidden]", "[aria-hidden='true']", ".sr-only", "[class*='sr-only']", "[class*='file-tile']", "form[data-type='unified-composer']", ".composer-btn", "[data-composer-surface='true']", "#thread-bottom-container", "[data-testid*='action-button']", ].join(", "); 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 isNoiseElement(node) { if (!node || node.nodeType !== Node.ELEMENT_NODE) return false; const tag = node.tagName.toLowerCase(); if (["script", "style", "noscript", "template", "svg", "canvas", "iframe", "dialog"].includes(tag)) return true; if (["button", "input", "textarea", "select", "option", "form"].includes(tag)) return true; if (node.hasAttribute("hidden")) return true; if ((node.getAttribute("aria-hidden") || "").toLowerCase() === "true") return true; if (node.matches(".sr-only, [class*='sr-only']")) return true; if (node.matches("[class*='file-tile'], form[data-type='unified-composer'], .composer-btn, [data-composer-surface='true'], #thread-bottom-container")) return true; if (node.matches("[data-testid*='action-button']")) return true; return false; } function stripNoise(root) { const clone = root.cloneNode(true); clone.querySelectorAll(NOISE_SELECTOR).forEach(node => node.remove()); return clone; } function candidateScore(node) { const text = normalizeText(node.innerText || ""); if (!text) return -Infinity; const headings = node.querySelectorAll("h1, h2, h3, h4, h5, h6").length; const paragraphs = node.querySelectorAll("p").length; const listItems = node.querySelectorAll("li").length; const tables = node.querySelectorAll("table").length; const codeBlocks = node.querySelectorAll("pre, code").length; const images = node.querySelectorAll("img, figure").length; const mainLike = node.matches("main, article, [role='main']") ? 1 : 0; const proseBlocks = node.matches(".markdown, .prose, [data-message-author-role='assistant']") ? 1 : 0; const buttons = node.querySelectorAll("button, input, textarea, select").length; const forms = node.querySelectorAll("form").length; const svgs = node.querySelectorAll("svg, canvas").length; return text.length + (mainLike * 4000) + (proseBlocks * 5000) + (headings * 250) + (paragraphs * 60) + (listItems * 35) + (tables * 80) + (codeBlocks * 60) + (images * 25) - (buttons * 120) - (forms * 200) - (svgs * 40); } 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'], section, .markdown, .prose, [data-message-author-role]" )) .filter(node => normalizeText(node.innerText || "").length > 0); if (!candidates.length) return document.body; candidates.sort((a, b) => candidateScore(b) - candidateScore(a)); return candidates[0]; } function inlineText(node) { if (node.nodeType === Node.TEXT_NODE) { return escapeMarkdown(node.textContent || ""); } if (node.nodeType !== Node.ELEMENT_NODE) return ""; if (isNoiseElement(node)) return ""; const tag = node.tagName.toLowerCase(); 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 preserveNodeText(node) { if (node.nodeType === Node.TEXT_NODE) { return node.textContent || ""; } if (node.nodeType !== Node.ELEMENT_NODE) return ""; const tag = node.tagName.toLowerCase(); if (tag === "br") return "\n"; const parts = []; for (const child of node.childNodes) { const rendered = preserveNodeText(child); if (!rendered) continue; parts.push(rendered); } if (["div", "p", "li"].includes(tag)) { return `${parts.join("")}\n`; } return parts.join(""); } function repairFlattenedDiagram(text) { if (text.includes("\n")) return text; const markerCount = (text.match(/[│▼├└]/g) || []).length; if (markerCount < 2) return text; let repaired = text; repaired = repaired.replace(/\s{2,}([│▼])/g, "\n $1"); repaired = repaired.replace(/([│▼])\s{2,}/g, "$1\n"); repaired = repaired.replace(/([│▼])(?=[^\s\n│▼├└])/g, "$1\n"); repaired = repaired.replace(/(?<=[^\s\n])([├└])/g, "\n$1"); repaired = repaired.replace(/([^\s\n])(\()/g, "$1\n$2"); return repaired .split("\n") .map(line => line.replace(/\s+$/, "")) .filter(line => line.trim()) .join("\n"); } function convertDashListsToBranches(lines) { const converted = []; let index = 0; while (index < lines.length) { const match = lines[index].match(/^(\s*)-\s+(.*)$/); if (!match) { converted.push(lines[index]); index += 1; continue; } const indent = match[1]; const items = []; while (index < lines.length) { const nextMatch = lines[index].match(new RegExp(`^${indent.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}-\\s+(.*)$`)); if (!nextMatch) break; items.push(nextMatch[1]); index += 1; } items.forEach((item, itemIndex) => { const branch = itemIndex === items.length - 1 ? "└" : "├"; converted.push(`${indent}${branch} ${item}`); }); } return converted; } function normalizeCodeBlock(text) { let lines = text.replace(/\r\n?/g, "\n").split("\n").map(line => line.replace(/\s+$/, "")); while (lines.length && !lines[0].trim()) lines.shift(); while (lines.length && !lines[lines.length - 1].trim()) lines.pop(); const flattened = repairFlattenedDiagram(lines.join("\n")); lines = flattened ? flattened.split("\n") : []; lines = lines.map(line => { const trimmed = line.trim(); if ((trimmed === "│" || trimmed === "▼") && !/^\s+[│▼]\s*$/.test(line)) { return ` ${trimmed}`; } return line; }); lines = convertDashListsToBranches(lines); return lines.join("\n"); } 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 firstRowIsBlank = headers.every(cell => !cell.trim()); if (firstRowIsBlank && normalizedRows.length > 1) { headers = normalizedRows[1]; bodyRows = normalizedRows.slice(2); } 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 || firstRowIsBlank)) { 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) { const lineParts = line.split("\n"); items.push(`${indent}${marker}${lineParts[0]}`); const continuationIndent = `${indent}${" ".repeat(marker.length)}`; lineParts.slice(1).forEach(part => items.push(`${continuationIndent}${part}`)); } 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 ""; if (isNoiseElement(node)) return ""; const tag = node.tagName.toLowerCase(); if (tag === "table") return tableToMarkdown(node); if (tag === "ul" || tag === "ol") return listToMarkdown(node); if (node.matches(".cm-editor[data-is-code-block-view='true']")) { const lines = Array.from(node.querySelectorAll(".cm-line")).map(line => { const text = preserveNodeText(line); return text === "\n" ? "" : text.replace(/\n$/, ""); }); const code = normalizeCodeBlock(lines.join("\n")); return code ? `\`\`\`\n${code}\n\`\`\`` : ""; } if (tag === "pre") { const code = normalizeCodeBlock(preserveNodeText(node)); 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 activeTabs = await chrome.tabs.query({ active: true }); if (!activeTabs.length) throw new Error("No active tab found"); const windows = await chrome.windows.getAll({ populate: false }); const focusedWindowIds = new Set(windows.filter(window => window.focused).map(window => window.id)); const chooseTab = (predicate) => activeTabs.find(predicate); const byFocusAndScriptable = tab => focusedWindowIds.has(tab.windowId) && isScriptableUrl(tab.url || tab.pendingUrl || ""); const byScriptable = tab => isScriptableUrl(tab.url || tab.pendingUrl || ""); const byFocus = tab => focusedWindowIds.has(tab.windowId); return chooseTab(byFocusAndScriptable) || chooseTab(byScriptable) || chooseTab(byFocus) || activeTabs[0]; } 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 || {}; }