Skip to content

Commit 0dc9bfb

Browse files
authored
Add browser raw capture extension (#112)
1 parent c4d27ec commit 0dc9bfb

11 files changed

Lines changed: 902 additions & 0 deletions

File tree

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,31 @@ The fastest way to feed `_raw/` during a live coding session is `/wiki-capture -
294294

295295
The directory is created automatically by `wiki-setup`. The path is configurable via `OBSIDIAN_RAW_DIR` in `.env` (defaults to `_raw`).
296296

297+
### Browser Capture Extension
298+
299+
This repo includes a zero-build Chrome extension at `extensions/brain-capture/` for saving web pages and selected text into your vault's `_raw/` folder.
300+
301+
To install it:
302+
303+
1. Open `chrome://extensions`
304+
2. Enable **Developer mode**
305+
3. Click **Load unpacked**
306+
4. Select `extensions/brain-capture`
307+
308+
To find the configured `_raw` folder from this repo:
309+
310+
```bash
311+
awk -F= '/^OBSIDIAN_VAULT_PATH=/{print $2 "/_raw"; exit}' "$(git rev-parse --show-toplevel)/.env"
312+
```
313+
314+
After capturing pages into `_raw/`, ask your agent to process them:
315+
316+
```text
317+
/wiki-ingest promote my raw pages
318+
```
319+
320+
`wiki-ingest` will read each `_raw/` capture, distill it into the right wiki pages, update the manifest/index/log, and remove the promoted raw files so they are not processed twice.
321+
297322
---
298323

299324
## Syncing your vault to GitHub

extensions/brain-capture/README.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Brain Vault Capture
2+
3+
A zero-build Chrome extension that captures the active page URL and readable text into an Obsidian Wiki `_raw` folder.
4+
5+
## Install
6+
7+
1. Open `chrome://extensions`.
8+
2. Enable **Developer mode**.
9+
3. Click **Load unpacked**.
10+
4. Select this folder: `extensions/brain-capture`.
11+
12+
## Use
13+
14+
Find this repo's configured vault path from the command line:
15+
16+
```bash
17+
awk -F= '/^OBSIDIAN_VAULT_PATH=/{print $2 "/_raw"; exit}' "$(git rev-parse --show-toplevel)/.env"
18+
```
19+
20+
1. Open the extension popup.
21+
2. Click **Choose _raw** and select the vault raw folder, usually `vault/_raw`.
22+
3. Open any normal web page and click **Capture current page**.
23+
4. Or right-click a page and choose **Capture page to brain raw**.
24+
5. Or select text, right-click, and choose **Capture selection to brain raw**.
25+
26+
The extension writes a markdown file named like `2026-06-17-page-title.md` into the selected folder.
27+
28+
## Promote Captures Into The Wiki
29+
30+
After captures land in `_raw/`, run the ingest skill from your AI agent:
31+
32+
```text
33+
/wiki-ingest promote my raw pages
34+
```
35+
36+
The ingest skill will distill the raw captures into proper wiki pages, update the vault bookkeeping files, and delete promoted `_raw/` files so they are not processed twice.
37+
38+
## What Gets Captured
39+
40+
- YAML frontmatter with title, source URL, creation timestamp, and capture metadata.
41+
- The page title, URL, optional user note, selected text, and readable page text.
42+
- Content is capped at 140,000 characters to keep captures usable for later wiki ingest.
24.8 KB
Loading
753 Bytes
Loading
3.95 KB
Loading
377 KB
Loading
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
const DB_NAME = "brain-vault-capture";
2+
const STORE_NAME = "handles";
3+
const RAW_HANDLE_KEY = "raw-folder";
4+
5+
const MENU_PAGE = "brain-capture-page";
6+
const MENU_SELECTION = "brain-capture-selection";
7+
8+
chrome.runtime.onInstalled.addListener(createContextMenus);
9+
chrome.runtime.onStartup.addListener(createContextMenus);
10+
11+
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
12+
try {
13+
if (!tab?.id || isBlockedUrl(tab.url)) {
14+
await showNeedsPopup("This page cannot be captured from the context menu.");
15+
return;
16+
}
17+
18+
const rawDirectoryHandle = await loadRawHandle();
19+
if (!rawDirectoryHandle || !(await hasWritePermission(rawDirectoryHandle))) {
20+
await showNeedsPopup("Open Brain Vault Capture and choose vault/_raw first.");
21+
return;
22+
}
23+
24+
const page = info.menuItemId === MENU_SELECTION
25+
? buildSelectionPage(info, tab)
26+
: await extractPageFromTab(tab.id);
27+
28+
const filename = buildFilename(page.title || tab.title || "web-capture");
29+
const markdown = buildMarkdown(page, "");
30+
const savedFilename = await writeMarkdown(rawDirectoryHandle, filename, markdown);
31+
await flashBadge("ok", "#9be2b5", savedFilename);
32+
} catch (error) {
33+
await flashBadge("!", "#e58f85", error.message);
34+
}
35+
});
36+
37+
function createContextMenus() {
38+
chrome.contextMenus.removeAll(() => {
39+
chrome.contextMenus.create({
40+
id: MENU_PAGE,
41+
title: "Capture page to brain raw",
42+
contexts: ["page"]
43+
});
44+
45+
chrome.contextMenus.create({
46+
id: MENU_SELECTION,
47+
title: "Capture selection to brain raw",
48+
contexts: ["selection"]
49+
});
50+
});
51+
}
52+
53+
function isBlockedUrl(url = "") {
54+
return url.startsWith("chrome://") || url.startsWith("chrome-extension://");
55+
}
56+
57+
async function showNeedsPopup(title) {
58+
await chrome.action.setTitle({ title });
59+
await flashBadge("set", "#8ee7d2", title);
60+
61+
if (chrome.action.openPopup) {
62+
await chrome.action.openPopup();
63+
}
64+
}
65+
66+
async function flashBadge(text, color, title) {
67+
await chrome.action.setBadgeText({ text });
68+
await chrome.action.setBadgeBackgroundColor({ color });
69+
if (title) {
70+
await chrome.action.setTitle({ title });
71+
}
72+
73+
setTimeout(() => {
74+
chrome.action.setBadgeText({ text: "" });
75+
}, 2400);
76+
}
77+
78+
async function hasWritePermission(directoryHandle) {
79+
const options = { mode: "readwrite" };
80+
return directoryHandle.queryPermission
81+
&& await directoryHandle.queryPermission(options) === "granted";
82+
}
83+
84+
async function extractPageFromTab(tabId) {
85+
const [{ result }] = await chrome.scripting.executeScript({
86+
target: { tabId },
87+
func: extractPage
88+
});
89+
90+
return result;
91+
}
92+
93+
function buildSelectionPage(info, tab) {
94+
const selectedText = (info.selectionText || "").trim();
95+
96+
return {
97+
title: tab.title || "Selected web text",
98+
url: tab.url || info.pageUrl || info.frameUrl || "",
99+
description: "",
100+
selection: selectedText,
101+
text: ""
102+
};
103+
}
104+
105+
async function writeMarkdown(directoryHandle, filename, markdown) {
106+
const fileHandle = await getUniqueFileHandle(directoryHandle, filename);
107+
const writable = await fileHandle.createWritable();
108+
await writable.write(markdown);
109+
await writable.close();
110+
return fileHandle.name;
111+
}
112+
113+
async function getUniqueFileHandle(directoryHandle, filename) {
114+
const extension = ".md";
115+
const basename = filename.endsWith(extension) ? filename.slice(0, -extension.length) : filename;
116+
117+
for (let attempt = 0; attempt < 100; attempt += 1) {
118+
const candidate = attempt === 0 ? filename : `${basename}-${attempt + 1}${extension}`;
119+
try {
120+
await directoryHandle.getFileHandle(candidate);
121+
} catch (error) {
122+
if (error.name === "NotFoundError") {
123+
return directoryHandle.getFileHandle(candidate, { create: true });
124+
}
125+
126+
throw error;
127+
}
128+
}
129+
130+
return directoryHandle.getFileHandle(`${basename}-${Date.now()}${extension}`, { create: true });
131+
}
132+
133+
function buildFilename(title) {
134+
const date = new Date().toISOString().slice(0, 10);
135+
const slug = title
136+
.toLowerCase()
137+
.normalize("NFKD")
138+
.replace(/[\u0300-\u036f]/g, "")
139+
.replace(/[^a-z0-9]+/g, "-")
140+
.replace(/^-+|-+$/g, "")
141+
.slice(0, 72) || "web-capture";
142+
143+
return `${date}-${slug}.md`;
144+
}
145+
146+
function buildMarkdown(page, note) {
147+
const now = new Date().toISOString();
148+
const safeTitle = escapeYaml(page.title || "Untitled page");
149+
const cleanNote = note.trim();
150+
const selectedText = page.selection ? `\n## Selection\n\n${page.selection}\n` : "";
151+
const noteText = cleanNote ? `\n## Capture Note\n\n${cleanNote}\n` : "";
152+
const description = page.description ? `\n> ${page.description}\n` : "";
153+
const pageContent = page.text ? `\n## Page Content\n\n${page.text}\n` : "";
154+
155+
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}`;
156+
}
157+
158+
function escapeYaml(value) {
159+
return JSON.stringify(String(value));
160+
}
161+
162+
function openDb() {
163+
return new Promise((resolve, reject) => {
164+
const request = indexedDB.open(DB_NAME, 1);
165+
request.onupgradeneeded = () => request.result.createObjectStore(STORE_NAME);
166+
request.onsuccess = () => resolve(request.result);
167+
request.onerror = () => reject(request.error);
168+
});
169+
}
170+
171+
async function loadRawHandle() {
172+
const db = await openDb();
173+
return new Promise((resolve, reject) => {
174+
const transaction = db.transaction(STORE_NAME, "readonly");
175+
const request = transaction.objectStore(STORE_NAME).get(RAW_HANDLE_KEY);
176+
request.onsuccess = () => resolve(request.result);
177+
request.onerror = () => reject(request.error);
178+
transaction.oncomplete = () => db.close();
179+
});
180+
}
181+
182+
function extractPage() {
183+
const selectorsToRemove = "script, style, noscript, svg, canvas, iframe, nav, footer, aside, form";
184+
const clone = document.body ? document.body.cloneNode(true) : document.documentElement.cloneNode(true);
185+
clone.querySelectorAll(selectorsToRemove).forEach((node) => node.remove());
186+
187+
const article = clone.querySelector("article, main, [role='main']") || clone;
188+
const rawText = article.innerText || clone.innerText || document.body?.innerText || "";
189+
const text = rawText
190+
.replace(/\u00a0/g, " ")
191+
.replace(/[ \t]+\n/g, "\n")
192+
.replace(/\n{3,}/g, "\n\n")
193+
.trim()
194+
.slice(0, 140000);
195+
196+
const selection = String(window.getSelection?.() || "")
197+
.replace(/\n{3,}/g, "\n\n")
198+
.trim()
199+
.slice(0, 30000);
200+
201+
const description = document.querySelector("meta[name='description'], meta[property='og:description']")
202+
?.getAttribute("content")
203+
?.trim() || "";
204+
205+
return {
206+
title: document.title,
207+
url: location.href,
208+
description,
209+
selection,
210+
text
211+
};
212+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"manifest_version": 3,
3+
"name": "Brain Vault Capture",
4+
"description": "Capture the current page URL and readable text into your Obsidian brain vault _raw folder.",
5+
"version": "0.1.0",
6+
"action": {
7+
"default_title": "Capture to Brain Vault",
8+
"default_popup": "popup.html",
9+
"default_icon": {
10+
"16": "assets/icon-16.png",
11+
"48": "assets/icon-48.png",
12+
"128": "assets/icon-128.png"
13+
}
14+
},
15+
"icons": {
16+
"16": "assets/icon-16.png",
17+
"48": "assets/icon-48.png",
18+
"128": "assets/icon-128.png"
19+
},
20+
"background": {
21+
"service_worker": "background.js"
22+
},
23+
"permissions": [
24+
"activeTab",
25+
"contextMenus",
26+
"scripting"
27+
]
28+
}

0 commit comments

Comments
 (0)