function findMinimumScope(text) { if (!text) return null; const normalized = normalizeWhitespace(text); if (!normalized) return null; const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, { acceptNode: (node) => { const nodeText = normalizeWhitespace(node.innerText); if (nodeText.includes(normalized)) { return NodeFilter.FILTER_ACCEPT; } return NodeFilter.FILTER_REJECT; } }); let deepest = null; let node = walker.nextNode(); while (node) { deepest = node; node = walker.nextNode(); } return deepest; } function normalizeWhitespace(value) { return String(value || "") .replace(/\r?\n/g, " ") .replace(/\s+/g, " ") .trim() .toLowerCase(); } function isPlainObject(value) { return Boolean(value && typeof value === "object" && !Array.isArray(value)); } function escapeSelector(value) { if (window.CSS && typeof CSS.escape === "function") { return CSS.escape(value); } return String(value).replace(/[^a-zA-Z0-9_-]/g, "\\$&"); } function buildClassSelector(className) { const parts = String(className || "") .trim() .split(/\s+/) .filter(Boolean); if (!parts.length) return ""; return parts.map((name) => `.${escapeSelector(name)}`).join(""); } function inferCssAllTarget(node) { if (!node || node.nodeType !== 1) return null; const classList = node.classList ? Array.from(node.classList) : []; let best = null; for (const className of classList) { if (!className) continue; const matches = Array.from(document.getElementsByClassName(className)); const index = matches.indexOf(node); if (index < 0) continue; if (!best || matches.length < best.matches.length) { best = { className, index, matches }; } } if (best) { return { kind: "cssAll", selector: `.${escapeSelector(best.className)}`, index: best.index }; } const className = typeof node.className === "string" ? node.className.trim() : ""; if (!className) return null; const selector = buildClassSelector(className); if (!selector) return null; const matches = Array.from(document.getElementsByClassName(className)); const index = matches.indexOf(node); if (index < 0) return null; return { kind: "cssAll", selector, index }; } function inferCssTarget(node) { if (!node || node.nodeType !== 1) return null; const selector = buildSelector(node); if (!selector) return null; return { kind: "css", selector }; } function inferAnchoredCssTarget(text) { const trimmed = String(text || "").trim(); if (!trimmed) return null; return { kind: "anchoredCss", anchor: { kind: "textScope", text: trimmed }, selector: ":scope" }; } function inferScopeTargets(text, node) { const candidates = []; const cssAll = inferCssAllTarget(node); if (cssAll) candidates.push(cssAll); const css = inferCssTarget(node); if (css) candidates.push(css); const anchoredCss = inferAnchoredCssTarget(text); if (anchoredCss) candidates.push(anchoredCss); const trimmed = String(text || "").trim(); if (trimmed) { candidates.push({ kind: "textScope", text: trimmed }); } return candidates; } function selectInferredTarget(text, node) { const candidates = inferScopeTargets(text, node); for (const candidate of candidates) { const resolved = resolveExtractionTarget(candidate); if (!resolved.error && resolved.node === node) { return candidate; } } return null; } function findBestScopeCandidate(text) { const normalized = String(text || "").trim(); if (!normalized) return null; const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, { acceptNode: (node) => { if (node.innerText.includes(normalized)) { return NodeFilter.FILTER_ACCEPT; } return NodeFilter.FILTER_REJECT; } }); let best = null; let node = walker.nextNode(); while (node) { if (node !== document.body) { const cssAll = inferCssAllTarget(node); if (cssAll) { const resolved = resolveExtractionTarget(cssAll); if (!resolved.error && resolved.node === node) { const matchCount = document.querySelectorAll(cssAll.selector).length; if (!best || matchCount < best.matchCount) { best = { node, target: cssAll, matchCount }; } } } } node = walker.nextNode(); } return best; } function parseLegacySelectorString(value) { const trimmed = String(value || "").trim(); if (!trimmed) { return { error: "Missing extraction target." }; } const classMatch = trimmed.match( /^(?:document\.)?getElementsByClassName\(\s*(['"])(.+?)\1\s*\)\s*\[\s*(\d+)\s*\]\s*(?:\.innerText\s*)?;?$/i ); if (classMatch) { const selector = buildClassSelector(classMatch[2]); if (!selector) { return { error: "Missing extraction target." }; } const index = Number.parseInt(classMatch[3], 10); if (!Number.isInteger(index) || index < 0) { return { error: "Invalid index." }; } return { target: { kind: "cssAll", selector, index } }; } if (trimmed.includes("getElementsByClassName")) { return { error: "Unsupported extraction target." }; } return null; } function normalizeExtractionTarget(input) { if (!input) { return { error: "Missing extraction target." }; } if (typeof input === "string") { const parsed = parseLegacySelectorString(input); if (parsed) { if (parsed.error) return { error: parsed.error }; return { target: parsed.target }; } const selector = input.trim(); if (!selector) { return { error: "Missing extraction target." }; } return { target: { kind: "css", selector } }; } if (!isPlainObject(input) || typeof input.kind !== "string") { return { error: "Missing extraction target." }; } return { target: input }; } function resolveExtractionTarget(target) { if (!target || typeof target !== "object") { return { error: "Missing extraction target." }; } if (target.kind === "xpath") { return { error: "XPath not supported." }; } if (target.kind === "textScope") { if (typeof target.text !== "string" || !target.text.trim()) { return { error: "Missing extraction target." }; } const node = findMinimumScope(target.text); if (!node) { return { error: "Scope not found." }; } return { node }; } if (target.kind === "anchoredCss") { const anchor = target.anchor; if ( !anchor || anchor.kind !== "textScope" || typeof anchor.text !== "string" || !anchor.text.trim() ) { return { error: "Missing extraction target." }; } const anchorNode = findMinimumScope(anchor.text); if (!anchorNode) { return { error: "Anchor scope not found." }; } const selector = target.selector || ""; if (!selector.trim()) { return { error: "Missing extraction target." }; } let node = null; try { node = anchorNode.querySelector(selector); } catch { return { error: "Invalid selector." }; } if (!node) { return { error: "Selector matched no elements." }; } return { node }; } if (target.kind === "css" || target.kind === "cssAll") { const selector = target.selector || ""; if (!selector) { return { error: "Missing extraction target." }; } if (target.kind === "css") { let node = null; try { node = document.querySelector(selector); } catch { return { error: "Invalid selector." }; } if (!node) { return { error: "Selector matched no elements." }; } return { node }; } const index = target.index; if (!Number.isInteger(index) || index < 0) { return { error: "Invalid index." }; } let nodes = []; try { nodes = Array.from(document.querySelectorAll(selector)); } catch { return { error: "Invalid selector." }; } if (!nodes.length) { return { error: "Selector matched no elements." }; } if (index >= nodes.length) { return { error: "Index out of bounds." }; } return { node: nodes[index] }; } return { error: "Unsupported extraction target." }; } function buildSelector(node) { if (!node || node.nodeType !== 1) return "body"; if (node === document.body) return "body"; const parts = []; let current = node; while (current && current.nodeType === 1 && current !== document.body) { const tag = current.tagName.toLowerCase(); if (current.id) { parts.unshift(`${tag}#${escapeSelector(current.id)}`); break; } let selector = tag; const parent = current.parentElement; if (parent) { const siblings = Array.from(parent.children).filter( (child) => child.tagName === current.tagName ); if (siblings.length > 1) { const index = siblings.indexOf(current) + 1; selector += `:nth-of-type(${index})`; } } parts.unshift(selector); current = parent; } if (current === document.body) { parts.unshift("body"); } const selector = parts.join(" > "); return selector || "body"; } function normalizeName(value) { return (value || "").trim().toLowerCase(); } function resolveScopedItems(parentItems, localItems, disabledNames) { const parent = Array.isArray(parentItems) ? parentItems : []; const local = Array.isArray(localItems) ? localItems : []; const disabledSet = new Set( (disabledNames || []).map((name) => normalizeName(name)).filter(Boolean) ); const localNameSet = new Set( local.map((item) => normalizeName(item.name)).filter(Boolean) ); const inherited = parent.filter((item) => { if (item?.enabled === false) return false; const key = normalizeName(item?.name); if (!key) return false; if (localNameSet.has(key)) return false; if (disabledSet.has(key)) return false; return true; }); const localEnabled = local.filter((item) => item?.enabled !== false); return [...inherited, ...localEnabled]; } function resolveThemeValue(globalTheme, workspace, site) { const siteTheme = site?.theme; if (siteTheme && siteTheme !== "inherit") return siteTheme; const workspaceTheme = workspace?.theme; if (workspaceTheme && workspaceTheme !== "inherit") return workspaceTheme; return globalTheme || "system"; } function normalizeEmptyToolbarBehavior(value, allowInherit = true) { if (value === "hide" || value === "open") return value; if (allowInherit && value === "inherit") return "inherit"; return allowInherit ? "inherit" : "open"; } function resolveEmptyToolbarBehavior(globalValue, workspace, site) { const base = normalizeEmptyToolbarBehavior(globalValue, false); const workspaceValue = normalizeEmptyToolbarBehavior( workspace?.emptyToolbarBehavior ); const workspaceResolved = workspaceValue === "inherit" ? base : workspaceValue; const siteValue = normalizeEmptyToolbarBehavior(site?.emptyToolbarBehavior); return siteValue === "inherit" ? workspaceResolved : siteValue; } function resolveThemeMode(theme) { if (theme === "dark" || theme === "light") return theme; if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches) { return "dark"; } return "light"; } function getToolbarThemeTokens(mode) { if (mode === "dark") { return { ink: "#abb2bf", muted: "#8b93a5", accent: "#61afef", accentDeep: "#56b6c2", panel: "#2f343f", border: "#3e4451", glow: "rgba(97, 175, 239, 0.2)", shadow: "rgba(0, 0, 0, 0.35)" }; } return { ink: "#1f1a17", muted: "#6b5f55", accent: "#b14d2b", accentDeep: "#7d321b", panel: "#fff7ec", border: "#e4d6c5", glow: "rgba(177, 77, 43, 0.18)", shadow: "rgba(122, 80, 47, 0.12)" }; } function createToolbar( shortcuts, position = "bottom-right", themeMode = "light", options = {} ) { let toolbar = document.getElementById("sitecompanion-toolbar"); if (toolbar) toolbar.remove(); const hasShortcuts = Array.isArray(shortcuts) && shortcuts.length > 0; const showOpenButton = options?.unknown || (!hasShortcuts && options?.emptyBehavior === "open"); if (!hasShortcuts && !showOpenButton) return; toolbar = document.createElement("div"); toolbar.id = "sitecompanion-toolbar"; const tokens = getToolbarThemeTokens(themeMode); let posStyle = ""; switch (position) { case "top-left": posStyle = "top: 20px; left: 20px;"; break; case "top-right": posStyle = "top: 20px; right: 20px;"; break; case "bottom-left": posStyle = "bottom: 20px; left: 20px;"; break; case "bottom-center": posStyle = "bottom: 20px; left: 50%; transform: translateX(-50%);"; break; case "bottom-right": default: posStyle = "bottom: 20px; right: 20px;"; break; } toolbar.style.cssText = ` position: fixed; ${posStyle} background: ${tokens.panel}; border: 1px solid ${tokens.border}; border-radius: 12px; padding: 8px; box-shadow: 0 12px 30px ${tokens.shadow}; z-index: 2147483647; display: flex; gap: 8px; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; color: ${tokens.ink}; `; if (showOpenButton) { const btn = document.createElement("button"); btn.type = "button"; btn.textContent = "Open SiteCompanion"; btn.style.cssText = ` padding: 6px 12px; background: ${tokens.accent}; color: #fff9f3; border: 1px solid ${tokens.accent}; border-radius: 10px; cursor: pointer; font-size: 12px; box-shadow: 0 8px 20px ${tokens.glow}; `; btn.addEventListener("click", () => { chrome.runtime.sendMessage({ type: "OPEN_POPUP" }, () => { void chrome.runtime.lastError; }); }); toolbar.appendChild(btn); } else { for (const shortcut of shortcuts) { const btn = document.createElement("button"); btn.type = "button"; btn.textContent = shortcut.name; btn.style.cssText = ` padding: 6px 12px; background: ${tokens.accent}; color: #fff9f3; border: 1px solid ${tokens.accent}; border-radius: 10px; cursor: pointer; font-size: 12px; box-shadow: 0 8px 20px ${tokens.glow}; `; btn.addEventListener("click", () => { chrome.runtime.sendMessage( { type: "RUN_SHORTCUT", shortcutId: shortcut.id }, () => { void chrome.runtime.lastError; } ); }); toolbar.appendChild(btn); } } document.body.appendChild(toolbar); } function matchUrl(url, pattern) { if (!pattern) return false; let regex = null; try { regex = new RegExp("^" + pattern.split("*").join(".*") + "$"); } catch { return false; } try { const urlObj = new URL(url); const target = urlObj.hostname + urlObj.pathname; return regex.test(target); } catch { return false; } } let suppressObserver = false; async function refreshToolbar() { suppressObserver = true; try { if (!chrome?.runtime?.id) return; let { sites = [], workspaces = [], shortcuts = [], presets = [], toolbarPosition = "bottom-right", theme = "system", toolbarAutoHide = true, emptyToolbarBehavior = "open" } = await chrome.storage.local.get([ "sites", "workspaces", "shortcuts", "presets", "toolbarPosition", "theme", "toolbarAutoHide", "emptyToolbarBehavior" ]); const currentUrl = window.location.href; const site = sites.find(s => matchUrl(currentUrl, s.urlPattern)); if (!site) { if (toolbarAutoHide) { const toolbar = document.getElementById("sitecompanion-toolbar"); if (toolbar) toolbar.remove(); return; } const resolvedTheme = resolveThemeValue(theme, null, null); const themeMode = resolveThemeMode(resolvedTheme); createToolbar([], toolbarPosition, themeMode, { unknown: true }); return; } if (!shortcuts.length && Array.isArray(presets) && presets.length) { shortcuts = presets; await chrome.storage.local.set({ shortcuts }); await chrome.storage.local.remove("presets"); } const workspace = workspaces.find((ws) => ws.id === site.workspaceId) || null; const workspaceDisabled = workspace?.disabledInherited?.shortcuts || []; const siteDisabled = site?.disabledInherited?.shortcuts || []; const workspaceShortcuts = resolveScopedItems( shortcuts, workspace?.shortcuts || [], workspaceDisabled ); const siteShortcuts = resolveScopedItems( workspaceShortcuts, site.shortcuts || [], siteDisabled ); const resolvedPosition = site.toolbarPosition && site.toolbarPosition !== "inherit" ? site.toolbarPosition : workspace?.toolbarPosition && workspace.toolbarPosition !== "inherit" ? workspace.toolbarPosition : toolbarPosition; const resolvedTheme = resolveThemeValue(theme, workspace, site); const themeMode = resolveThemeMode(resolvedTheme); const resolvedEmptyToolbarBehavior = resolveEmptyToolbarBehavior( emptyToolbarBehavior, workspace, site ); if (!siteShortcuts.length && resolvedEmptyToolbarBehavior === "hide") { const toolbar = document.getElementById("sitecompanion-toolbar"); if (toolbar) toolbar.remove(); return; } createToolbar(siteShortcuts, resolvedPosition, themeMode, { emptyBehavior: resolvedEmptyToolbarBehavior }); } catch (error) { const message = String(error?.message || ""); if (message.includes("Extension context invalidated")) { return; } console.warn("SiteCompanion toolbar refresh failed:", error); } finally { window.setTimeout(() => { suppressObserver = false; }, 0); } } let refreshTimer = null; let contentChangeTimer = null; function scheduleToolbarRefresh() { if (refreshTimer) return; refreshTimer = window.setTimeout(() => { refreshTimer = null; refreshToolbar().catch((error) => { const message = String(error?.message || ""); if (message.includes("Extension context invalidated")) { return; } console.warn("SiteCompanion toolbar refresh failed:", error); }); }, 200); } function scheduleContentChangeNotice() { if (contentChangeTimer) return; contentChangeTimer = window.setTimeout(() => { contentChangeTimer = null; chrome.runtime.sendMessage({ type: "SITE_CONTENT_CHANGED" }, () => { if (chrome.runtime.lastError) { return; } }); }, 250); } const observer = new MutationObserver(() => { if (suppressObserver) return; scheduleToolbarRefresh(); scheduleContentChangeNotice(); }); observer.observe(document.documentElement, { childList: true, subtree: true }); chrome.storage.onChanged.addListener(() => { scheduleToolbarRefresh(); }); chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { if (!message || typeof message !== "object") return; if (message.type === "FIND_SCOPE") { const rawText = message.text || ""; const baseTarget = { kind: "textScope", text: rawText }; const resolved = resolveExtractionTarget(baseTarget); if (resolved.error) { sendResponse({ ok: false, error: resolved.error }); return; } let effectiveNode = resolved.node; let responseTarget = selectInferredTarget(rawText, resolved.node) || baseTarget; if (resolved.node === document.body) { const scoped = findBestScopeCandidate(rawText); if (scoped) { effectiveNode = scoped.node; responseTarget = scoped.target; } else if ( responseTarget.kind === "css" && responseTarget.selector === "body" ) { responseTarget = baseTarget; } } sendResponse({ ok: true, extracted: effectiveNode.innerText || "", target: responseTarget }); return; } if (message.type === "EXTRACT_BY_SELECTOR") { const { target, error } = normalizeExtractionTarget( message.target ?? message.selector ); if (error) { sendResponse({ ok: false, error }); return; } const resolved = resolveExtractionTarget(target); if (resolved.error) { sendResponse({ ok: false, error: resolved.error }); return; } sendResponse({ ok: true, extracted: resolved.node.innerText || "", target }); return; } if (message.type === "EXTRACT_FULL") { const target = { kind: "css", selector: "body" }; const resolved = resolveExtractionTarget(target); if (resolved.error) { const extracted = document.body?.innerText || ""; sendResponse({ ok: true, extracted, target }); return; } sendResponse({ ok: true, extracted: resolved.node.innerText || "", target }); } }); try { refreshToolbar(); } catch (error) { console.warn("SiteCompanion toolbar failed:", error); }