mirror of
https://github.com/Ar9av/obsidian-wiki.git
synced 2026-09-14 20:36:34 +08:00
d3e545b012
The brain-capture extension only went one way: browser -> vault. This adds the return path — read the form on the current page, answer what the vault supports, and leave the rest blank — and folds both directions into a single two-tab extension renamed from "Brain Vault Capture" to "Brain". Retrieval never calls a model. `obsidian-wiki context-pack --json` ranks the vault deterministically and the model does one narrow job: map retrieved facts onto fields. One `claude -p` / `codex exec` invocation per fill, billed to the subscription the user already has. Nothing is ever submitted. A native messaging host bridges the two, staged into ~/.obsidian-wiki/ rather than run from the checkout. Chrome's children do not inherit your shell, and all three consequences bite: - macOS TCC: ~/Documents is mode 700 with an ACL, and a native host inherits Chrome's TCC context, so a checkout there cannot be exec'd at all. Chrome reports this as "Native host has exited", identical to a crash. - PATH: Chrome passes a minimal PATH, hiding claude/codex in ~/.local/bin. - USER/LOGNAME: Claude Code reads OAuth credentials from the macOS login Keychain and needs these to find them, else "Not logged in" despite a valid session. The host bookends every run in its log so those failures stay distinguishable, re-validates every returned fill against the real form schema (an injected value cannot reach a field the page never advertised), and forwards the pack's instruction_policy so vault excerpts stay data rather than instruction. Form-schema extraction resolves labels via aria-labelledby before aria-label: Google Forms puts aria-label="Your answer" on every paragraph field while the real question sits behind aria-labelledby, so checking aria-label first silently mislabelled exactly the textarea fields. Values are written through the native setter with input/change dispatched, so React-controlled forms see a genuine edit. Docs: docs/browser-extension.md, linked from both READMEs and the docs index. Also backfills the untranslated tagline from #159 into README_TW.md.
215 lines
6.8 KiB
JavaScript
215 lines
6.8 KiB
JavaScript
// Unchanged across the rename to Brain: this keys the saved _raw directory
|
|
// handle, so renaming it would orphan every existing install's folder pick.
|
|
const DB_NAME = "brain-vault-capture";
|
|
const STORE_NAME = "handles";
|
|
const RAW_HANDLE_KEY = "raw-folder";
|
|
|
|
const MENU_PAGE = "brain-capture-page";
|
|
const MENU_SELECTION = "brain-capture-selection";
|
|
|
|
chrome.runtime.onInstalled.addListener(createContextMenus);
|
|
chrome.runtime.onStartup.addListener(createContextMenus);
|
|
|
|
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
|
|
try {
|
|
if (!tab?.id || isBlockedUrl(tab.url)) {
|
|
await showNeedsPopup("This page cannot be captured from the context menu.");
|
|
return;
|
|
}
|
|
|
|
const rawDirectoryHandle = await loadRawHandle();
|
|
if (!rawDirectoryHandle || !(await hasWritePermission(rawDirectoryHandle))) {
|
|
await showNeedsPopup("Open Brain and choose vault/_raw first.");
|
|
return;
|
|
}
|
|
|
|
const page = info.menuItemId === MENU_SELECTION
|
|
? buildSelectionPage(info, tab)
|
|
: await extractPageFromTab(tab.id);
|
|
|
|
const filename = buildFilename(page.title || tab.title || "web-capture");
|
|
const markdown = buildMarkdown(page, "");
|
|
const savedFilename = await writeMarkdown(rawDirectoryHandle, filename, markdown);
|
|
await flashBadge("ok", "#9be2b5", savedFilename);
|
|
} catch (error) {
|
|
await flashBadge("!", "#e58f85", error.message);
|
|
}
|
|
});
|
|
|
|
function createContextMenus() {
|
|
chrome.contextMenus.removeAll(() => {
|
|
chrome.contextMenus.create({
|
|
id: MENU_PAGE,
|
|
title: "Capture page to brain raw",
|
|
contexts: ["page"]
|
|
});
|
|
|
|
chrome.contextMenus.create({
|
|
id: MENU_SELECTION,
|
|
title: "Capture selection to brain raw",
|
|
contexts: ["selection"]
|
|
});
|
|
});
|
|
}
|
|
|
|
function isBlockedUrl(url = "") {
|
|
return url.startsWith("chrome://") || url.startsWith("chrome-extension://");
|
|
}
|
|
|
|
async function showNeedsPopup(title) {
|
|
await chrome.action.setTitle({ title });
|
|
await flashBadge("set", "#8ee7d2", title);
|
|
|
|
if (chrome.action.openPopup) {
|
|
await chrome.action.openPopup();
|
|
}
|
|
}
|
|
|
|
async function flashBadge(text, color, title) {
|
|
await chrome.action.setBadgeText({ text });
|
|
await chrome.action.setBadgeBackgroundColor({ color });
|
|
if (title) {
|
|
await chrome.action.setTitle({ title });
|
|
}
|
|
|
|
setTimeout(() => {
|
|
chrome.action.setBadgeText({ text: "" });
|
|
}, 2400);
|
|
}
|
|
|
|
async function hasWritePermission(directoryHandle) {
|
|
const options = { mode: "readwrite" };
|
|
return directoryHandle.queryPermission
|
|
&& await directoryHandle.queryPermission(options) === "granted";
|
|
}
|
|
|
|
async function extractPageFromTab(tabId) {
|
|
const [{ result }] = await chrome.scripting.executeScript({
|
|
target: { tabId },
|
|
func: extractPage
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
function buildSelectionPage(info, tab) {
|
|
const selectedText = (info.selectionText || "").trim();
|
|
|
|
return {
|
|
title: tab.title || "Selected web text",
|
|
url: tab.url || info.pageUrl || info.frameUrl || "",
|
|
description: "",
|
|
selection: selectedText,
|
|
text: ""
|
|
};
|
|
}
|
|
|
|
async function writeMarkdown(directoryHandle, filename, markdown) {
|
|
const fileHandle = await getUniqueFileHandle(directoryHandle, filename);
|
|
const writable = await fileHandle.createWritable();
|
|
await writable.write(markdown);
|
|
await writable.close();
|
|
return fileHandle.name;
|
|
}
|
|
|
|
async function getUniqueFileHandle(directoryHandle, filename) {
|
|
const extension = ".md";
|
|
const basename = filename.endsWith(extension) ? filename.slice(0, -extension.length) : filename;
|
|
|
|
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
const candidate = attempt === 0 ? filename : `${basename}-${attempt + 1}${extension}`;
|
|
try {
|
|
await directoryHandle.getFileHandle(candidate);
|
|
} catch (error) {
|
|
if (error.name === "NotFoundError") {
|
|
return directoryHandle.getFileHandle(candidate, { create: true });
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
return directoryHandle.getFileHandle(`${basename}-${Date.now()}${extension}`, { create: true });
|
|
}
|
|
|
|
function buildFilename(title) {
|
|
const date = new Date().toISOString().slice(0, 10);
|
|
const slug = title
|
|
.toLowerCase()
|
|
.normalize("NFKD")
|
|
.replace(/[\u0300-\u036f]/g, "")
|
|
.replace(/[^a-z0-9]+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.slice(0, 72) || "web-capture";
|
|
|
|
return `${date}-${slug}.md`;
|
|
}
|
|
|
|
function buildMarkdown(page, note) {
|
|
const now = new Date().toISOString();
|
|
const safeTitle = escapeYaml(page.title || "Untitled page");
|
|
const cleanNote = note.trim();
|
|
const selectedText = page.selection ? `\n## Selection\n\n${page.selection}\n` : "";
|
|
const noteText = cleanNote ? `\n## Capture Note\n\n${cleanNote}\n` : "";
|
|
const description = page.description ? `\n> ${page.description}\n` : "";
|
|
const pageContent = page.text ? `\n## Page Content\n\n${page.text}\n` : "";
|
|
|
|
return `---\ntitle: ${safeTitle}\ntags: [web-capture, raw-ingest]\nsources:\n - ${page.url}\ncreated: ${now}\ncaptured: chrome-extension\n---\n\n# ${page.title || "Untitled page"}\n\n${description}\n- Source: ${page.url}\n- Captured: ${now}\n${noteText}${selectedText}${pageContent}`;
|
|
}
|
|
|
|
function escapeYaml(value) {
|
|
return JSON.stringify(String(value));
|
|
}
|
|
|
|
function openDb() {
|
|
return new Promise((resolve, reject) => {
|
|
const request = indexedDB.open(DB_NAME, 1);
|
|
request.onupgradeneeded = () => request.result.createObjectStore(STORE_NAME);
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
async function loadRawHandle() {
|
|
const db = await openDb();
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = db.transaction(STORE_NAME, "readonly");
|
|
const request = transaction.objectStore(STORE_NAME).get(RAW_HANDLE_KEY);
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
transaction.oncomplete = () => db.close();
|
|
});
|
|
}
|
|
|
|
function extractPage() {
|
|
const selectorsToRemove = "script, style, noscript, svg, canvas, iframe, nav, footer, aside, form";
|
|
const clone = document.body ? document.body.cloneNode(true) : document.documentElement.cloneNode(true);
|
|
clone.querySelectorAll(selectorsToRemove).forEach((node) => node.remove());
|
|
|
|
const article = clone.querySelector("article, main, [role='main']") || clone;
|
|
const rawText = article.innerText || clone.innerText || document.body?.innerText || "";
|
|
const text = rawText
|
|
.replace(/\u00a0/g, " ")
|
|
.replace(/[ \t]+\n/g, "\n")
|
|
.replace(/\n{3,}/g, "\n\n")
|
|
.trim()
|
|
.slice(0, 140000);
|
|
|
|
const selection = String(window.getSelection?.() || "")
|
|
.replace(/\n{3,}/g, "\n\n")
|
|
.trim()
|
|
.slice(0, 30000);
|
|
|
|
const description = document.querySelector("meta[name='description'], meta[property='og:description']")
|
|
?.getAttribute("content")
|
|
?.trim() || "";
|
|
|
|
return {
|
|
title: document.title,
|
|
url: location.href,
|
|
description,
|
|
selection,
|
|
text
|
|
};
|
|
}
|