diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30b4f96..c7971a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,11 +12,11 @@ jobs: - uses: pnpm/action-setup@v4 with: - version: 9 + version: 11 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: 'pnpm' - run: pnpm install --frozen-lockfile diff --git a/README.md b/README.md index 6feeb29..db29575 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,8 @@ Ollama server, no network — so the numbers are reproducible in CI. _Measured over 23 curated fixtures (21 Traditional-Chinese targets). Regenerate with `pnpm eval`; full report in [`eval/RESULTS.md`](eval/RESULTS.md)._ -The pure core carries **100% function coverage and ~92% line coverage** across -57 unit tests (`pnpm test:cov`). +The pure core carries **100% function coverage and ~94% line coverage** across +85 unit tests (`pnpm test:cov`). ## How it works @@ -95,6 +95,40 @@ The pure core carries **100% function coverage and ~92% line coverage** across See [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the module map and the streaming sequence diagram. +## Capture to Obsidian + +Reading is only half the loop — OpenRead also turns any translated selection +into a note in your [Obsidian](https://obsidian.md) vault. Once a translation +streams in, a **+ 儲存到 Obsidian** button drops a Markdown note — original, +translation, and a machine-readable YAML header — straight into your vault via +an `obsidian://new` URI. No extra permissions, no server; notes too large for a +protocol-handler URL fall back to the clipboard. + +That header is a deliberate **handoff contract**. Every note is written +`status: raw`, so a stronger downstream model (a "second brain") can query the +unprocessed captures, synthesize them, and flip the flag. OpenRead does the +cheap, reliable part on-device and defers the expensive part — rather than +re-implementing a knowledge base it has no business owning. + +Optionally, a small local model can pre-label a capture with a title, summary, +and tags. Small models are unreliable at structured output, so this is strictly +best-effort — and _measured_: `parseEnrichResponse` salvages usable metadata +from fenced, preamble-wrapped, and trailing-prose replies that a naive +`JSON.parse` drops. + +| Metric | Rate | +| --------------------------------------------------- | ---------- | +| Naive `JSON.parse` yields an object | 42.9% | +| Robust `parseEnrichResponse` yields usable metadata | **71.4%** | + +_Offline, deterministic run over 14 real small-model reply shapes; regenerate +with `pnpm eval:capture`, full report in +[`eval/CAPTURE-RESULTS.md`](eval/CAPTURE-RESULTS.md). This is why enrichment is +off by default — the raw capture is always the source of truth._ + +Set your vault, capture folder, and the enrichment toggle in the popup; leave +the vault blank to use whichever vault is currently open. + ## Install (from source) ```bash @@ -137,6 +171,7 @@ pnpm dev # HMR dev build (Chrome); pnpm dev:firefox for Firefox pnpm test # Vitest unit suite pnpm test:cov # …with coverage pnpm eval # reliability eval -> eval/RESULTS.md +pnpm eval:capture # capture-enrichment eval -> eval/CAPTURE-RESULTS.md pnpm compile # tsc --noEmit (strict) pnpm lint # ESLint pnpm build # production build -> .output/chrome-mv3 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5769a25..8acff33 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -14,24 +14,29 @@ src/ stream.ts StreamAssembler — reluctant-buffer streaming logic zh-convert.ts OpenCC s2twp Simplified→Traditional wrapper prompt.ts system prompt + adaptive anti-echo few-shot + capture.ts capture → Markdown + obsidian:// URI builders + enrich.ts enrich prompt + tolerant small-model JSON salvager types.ts shared domain types api/ - ollama.ts typed streaming + single-shot client, pure extractDelta + ollama.ts typed streaming + single-shot + enrich client messaging.ts typed content⇄background port + one-shot protocol settings.ts typed chrome.storage.sync wrapper ui/ selection.ts shared selection icon + panel + stream client + capture.ts capture orchestrator: enrich round-trip + write entrypoints/ - background.ts service worker: stream broker + PDF router + background.ts service worker: stream broker + PDF router + enrich content.ts mounts the selection translator on web pages pdf-viewer.ts mounts the same translator inside the PDF.js viewer popup/ settings UI (index.html + main.ts) public/ pdfjs/ vendored PDF.js viewer (worker path fixed to .mjs) eval/ - dataset/fixtures.json curated failure-mode fixtures - detectors.ts preamble / Simplified / echo detectors (reuse core) - run.ts before→after runner → eval/RESULTS.md + dataset/fixtures.json curated translation failure-mode fixtures + dataset/capture-fixtures.json curated small-model enrichment reply shapes + detectors.ts preamble / Simplified / echo detectors (reuse core) + run.ts translation before→after runner → eval/RESULTS.md + capture-run.ts enrichment-parser runner → eval/CAPTURE-RESULTS.md ``` ### Why this split @@ -90,3 +95,5 @@ sequenceDiagram | OpenCC `s2twp` over a hand-rolled map | v1's unconditional character map corrupted common words (`界面→界麵`). Phrase-level conversion is correct and maintained. | | Ollama base URL read in background from storage; request goes to a local server | No secret rides the message bus — and since the request never leaves the machine, there's nothing to leak either way. | | Offline, deterministic eval | Before/after numbers are reproducible in CI without a running Ollama server, so they are honest to cite. | +| Capture writes via `obsidian://new` from the content script, not a new API | Keeps least-privilege intact (no `downloads`/native-host permission) and reuses the user gesture; oversized notes fall back to the clipboard. | +| Captures are `status: raw`; heavy synthesis deferred to a downstream model | On-device small models fail at structured output (measured), so OpenRead ships a reliable raw note and lets a stronger "second brain" process it. | diff --git a/eval/CAPTURE-RESULTS.md b/eval/CAPTURE-RESULTS.md new file mode 100644 index 0000000..3e5542b --- /dev/null +++ b/eval/CAPTURE-RESULTS.md @@ -0,0 +1,14 @@ +# OpenRead — Capture Enrichment Eval + +Offline, deterministic run over **14** small-model reply fixtures. No network, no Ollama server needed. + +| Metric | Count | Rate | +| --- | --- | --- | +| Naive `JSON.parse` yields an object | 6 | 42.9% | +| Robust `parseEnrichResponse` yields usable metadata | 10 | 71.4% | +| Usable title recovered | 9 | 64.3% | +| Usable summary recovered | 9 | 64.3% | +| ≥1 tag recovered | 9 | 64.3% | + +_The robust parser recovers usable metadata from **5** replies that a naive `JSON.parse` drops (fenced, preamble-wrapped, or trailing-prose output), while rejecting empty/garbage replies that naive parsing would wave through. Enrichment is best-effort — every capture writes a raw note regardless._ + diff --git a/eval/capture-run.ts b/eval/capture-run.ts new file mode 100644 index 0000000..f9a3a98 --- /dev/null +++ b/eval/capture-run.ts @@ -0,0 +1,93 @@ +/** + * Capture-enrichment eval — deterministic and offline. + * + * Small local models return metadata unreliably: wrapped in code fences, buried + * in preamble, tags as a comma string, or nothing usable at all. This measures + * how much the pure `parseEnrichResponse` salvager improves on a naive + * `JSON.parse` over a fixture set of real small-model reply shapes. No network, + * no Ollama server — reproducible in CI and honest to cite. Run with + * `pnpm eval:capture`. + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseEnrichResponse } from '../src/core/enrich'; + +interface Fixture { + id: string; + category: string; + raw: string; +} + +const here = dirname(fileURLToPath(import.meta.url)); +const fixtures = JSON.parse( + readFileSync(join(here, 'dataset', 'capture-fixtures.json'), 'utf8'), +) as Fixture[]; + +function naiveParses(raw: string): boolean { + try { + const value: unknown = JSON.parse(raw); + return typeof value === 'object' && value !== null && !Array.isArray(value); + } catch { + return false; + } +} + +const rows = fixtures.map((fixture) => { + const parsed = parseEnrichResponse(fixture.raw); + return { + naive: naiveParses(fixture.raw), + robust: parsed !== null, + title: Boolean(parsed?.title), + summary: Boolean(parsed?.summary), + tags: (parsed?.tags?.length ?? 0) > 0, + }; +}); + +const total = fixtures.length; +const count = (predicate: (r: (typeof rows)[number]) => boolean): number => + rows.filter(predicate).length; + +const naiveOk = count((r) => r.naive); +const robustOk = count((r) => r.robust); +const robustOnly = count((r) => r.robust && !r.naive); +const withTitle = count((r) => r.title); +const withSummary = count((r) => r.summary); +const withTags = count((r) => r.tags); + +const pct = (n: number): string => `${((n / total) * 100).toFixed(1)}%`; + +const lines: string[] = []; +lines.push('# OpenRead — Capture Enrichment Eval'); +lines.push(''); +lines.push( + `Offline, deterministic run over **${total}** small-model reply fixtures. ` + + 'No network, no Ollama server needed.', +); +lines.push(''); +lines.push('| Metric | Count | Rate |'); +lines.push('| --- | --- | --- |'); +lines.push( + `| Naive \`JSON.parse\` yields an object | ${naiveOk} | ${pct(naiveOk)} |`, +); +lines.push( + `| Robust \`parseEnrichResponse\` yields usable metadata | ${robustOk} | ${pct(robustOk)} |`, +); +lines.push(`| Usable title recovered | ${withTitle} | ${pct(withTitle)} |`); +lines.push( + `| Usable summary recovered | ${withSummary} | ${pct(withSummary)} |`, +); +lines.push(`| ≥1 tag recovered | ${withTags} | ${pct(withTags)} |`); +lines.push(''); +lines.push( + `_The robust parser recovers usable metadata from **${robustOnly}** replies ` + + 'that a naive `JSON.parse` drops (fenced, preamble-wrapped, or trailing-prose ' + + 'output), while rejecting empty/garbage replies that naive parsing would wave ' + + 'through. Enrichment is best-effort — every capture writes a raw note ' + + 'regardless._', +); +lines.push(''); + +const report = lines.join('\n'); +console.log(report); +writeFileSync(join(here, 'CAPTURE-RESULTS.md'), report + '\n', 'utf8'); diff --git a/eval/dataset/capture-fixtures.json b/eval/dataset/capture-fixtures.json new file mode 100644 index 0000000..d275b28 --- /dev/null +++ b/eval/dataset/capture-fixtures.json @@ -0,0 +1,72 @@ +[ + { + "id": "clean-01", + "category": "clean-json", + "raw": "{\"title\":\"線性時間演算法\",\"summary\":\"這段說明演算法以線性時間執行。\",\"tags\":[\"演算法\",\"複雜度\",\"效能\"]}" + }, + { + "id": "clean-02", + "category": "clean-json", + "raw": "{\"title\":\"Transformer 架構\",\"summary\":\"介紹自注意力機制。\",\"tags\":[\"nlp\",\"transformer\",\"attention\"]}" + }, + { + "id": "fenced-01", + "category": "fenced-json", + "raw": "```json\n{\"title\":\"向量資料庫\",\"summary\":\"說明向量檢索的原理。\",\"tags\":[\"database\",\"embeddings\"]}\n```" + }, + { + "id": "fenced-02", + "category": "fenced-json", + "raw": "```\n{\"title\":\"梯度下降\",\"tags\":[\"optimization\",\"training\"]}\n```" + }, + { + "id": "preamble-01", + "category": "preamble-json", + "raw": "Sure! Here is the metadata you asked for:\n\n{\"title\":\"知識圖譜\",\"summary\":\"描述實體與關係的建模。\",\"tags\":[\"graph\",\"knowledge\"]}" + }, + { + "id": "preamble-02", + "category": "preamble-json", + "raw": "好的,這是整理後的結果:{\"title\":\"檢索增強生成\",\"summary\":\"結合檢索與生成的方法。\",\"tags\":[\"rag\",\"llm\",\"retrieval\"]}" + }, + { + "id": "trailing-01", + "category": "trailing-prose", + "raw": "{\"title\":\"擴散模型\",\"summary\":\"逐步去雜訊生成影像。\",\"tags\":[\"diffusion\",\"generative\"]} 希望對你有幫助!" + }, + { + "id": "comma-tags-01", + "category": "comma-tags", + "raw": "{\"title\":\"提示工程\",\"summary\":\"設計提示以引導模型。\",\"tags\":\"prompt, engineering, llm\"}" + }, + { + "id": "hash-tags-01", + "category": "hash-tags", + "raw": "{\"title\":\"量化\",\"summary\":\"降低模型精度以節省記憶體。\",\"tags\":[\"#Quantization\",\"#Model Size\"]}" + }, + { + "id": "partial-01", + "category": "partial", + "raw": "{\"summary\":\"一段關於微調的說明。\"}" + }, + { + "id": "refusal-01", + "category": "refusal", + "raw": "I'm sorry, I can't produce that." + }, + { + "id": "garbage-01", + "category": "garbage", + "raw": "title: 混亂輸出\ntags - a, b, c" + }, + { + "id": "empty-fields-01", + "category": "empty-fields", + "raw": "{\"title\":\"\",\"summary\":\"\",\"tags\":[]}" + }, + { + "id": "malformed-01", + "category": "malformed", + "raw": "{\"title\": \"未閉合" + } +] diff --git a/package.json b/package.json index 7eeb480..d22eb10 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "license": "MIT", "engines": { - "node": ">=20" + "node": ">=22" }, "scripts": { "dev": "wxt", @@ -20,6 +20,7 @@ "lint": "eslint .", "format": "prettier --write .", "eval": "tsx eval/run.ts", + "eval:capture": "tsx eval/capture-run.ts", "postinstall": "wxt prepare" }, "devDependencies": { diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0b3d7bb..5364b42 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,8 +1,7 @@ -# Build-script allowlist. esbuild ships a platform binary via a postinstall -# script; WXT/Vite/Vitest all need it, so allow it. spawn-sync is an unused -# prebuilt fallback — explicitly denied so the tooling stops prompting. +# Build-script approval (pnpm 11 `allowBuilds`; replaced v10's +# onlyBuiltDependencies). esbuild ships a platform binary via a postinstall +# script that WXT / Vite / Vitest need, so approve it. spawn-sync is an unused +# prebuilt fallback — explicitly reviewed-and-denied so pnpm stops flagging it. allowBuilds: esbuild: true spawn-sync: false -onlyBuiltDependencies: - - esbuild diff --git a/src/api/ollama.ts b/src/api/ollama.ts index cda41bf..c06559c 100644 --- a/src/api/ollama.ts +++ b/src/api/ollama.ts @@ -16,7 +16,12 @@ import { generateSystemPrompt, getFewShotMessages } from '../core/prompt'; import { cleanTranslationOutput } from '../core/sanitize'; import { toTraditionalTW } from '../core/zh-convert'; import { StreamAssembler } from '../core/stream'; -import type { ChatMessage, TranslationContext } from '../core/types'; +import { buildEnrichMessages, parseEnrichResponse } from '../core/enrich'; +import type { + ChatMessage, + TranslationContext, + EnrichResult, +} from '../core/types'; /** Build the OpenAI-compatible chat endpoint for an Ollama server base URL. */ function endpoint(baseUrl: string): string { @@ -179,3 +184,43 @@ export async function translateText(params: TranslateParams): Promise { const cleaned = cleanTranslationOutput(text, content); return wantsTraditional(targetLang) ? toTraditionalTW(cleaned) : cleaned; } + +export interface EnrichParams { + text: string; + baseUrl: string; + model: string; + targetLang: string; + signal?: AbortSignal; +} + +/** + * Single non-streamed enrichment call: ask a small model to label a capture + * with a title, summary, and tags. Best-effort by design — returns null on any + * HTTP or parse failure so a weak local model can never block a capture. + * Temperature 0 for determinism. + */ +export async function enrichText( + params: EnrichParams, +): Promise { + const { text, baseUrl, model, targetLang, signal } = params; + if (!text || !baseUrl) return null; + + const response = await fetch(endpoint(baseUrl), { + method: 'POST', + signal, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model, + messages: buildEnrichMessages(text, targetLang), + temperature: 0, + stream: false, + }), + }); + if (!response.ok) return null; + + const data = (await response.json()) as { + choices?: Array<{ message?: { content?: string } }>; + }; + const content = data.choices?.[0]?.message?.content ?? ''; + return parseEnrichResponse(content); +} diff --git a/src/core/capture.test.ts b/src/core/capture.test.ts new file mode 100644 index 0000000..8cecb2f --- /dev/null +++ b/src/core/capture.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest'; +import { + buildMarkdown, + captureFilename, + buildObsidianUri, + resolveSourceUrl, + applyEnrichment, +} from './capture'; +import type { CaptureNote } from './types'; + +const base: CaptureNote = { + title: 'The Title', + source: 'Example Page', + url: 'https://example.com/a', + capturedAt: '2026-07-09T13:05:22.123Z', + from: 'auto', + to: 'Traditional Chinese', + original: 'Hello world', + translation: '你好世界', + tags: ['openread'], + status: 'raw', +}; + +describe('buildMarkdown', () => { + it('emits YAML frontmatter with the handoff fields', () => { + const md = buildMarkdown(base); + expect(md).toContain('title: "The Title"'); + expect(md).toContain('url: https://example.com/a'); + expect(md).toContain('captured: 2026-07-09T13:05:22.123Z'); + expect(md).toContain('status: raw'); + expect(md).toContain('tags: [openread]'); + }); + + it('escapes double quotes in quoted scalars', () => { + const md = buildMarkdown({ ...base, source: 'A "quoted" title' }); + expect(md).toContain('source: "A \\"quoted\\" title"'); + }); + + it('blockquotes a multi-line original', () => { + const md = buildMarkdown({ ...base, original: 'line one\nline two' }); + expect(md).toContain('> line one\n> line two'); + }); + + it('includes a Summary section only when present', () => { + expect(buildMarkdown(base)).not.toContain('## Summary'); + expect(buildMarkdown({ ...base, summary: 'A recap.' })).toContain( + '## Summary\n\nA recap.', + ); + }); +}); + +describe('captureFilename', () => { + it('appends a compact timestamp', () => { + expect(captureFilename(base)).toBe('The Title 20260709-1305'); + }); + + it('strips characters illegal in a vault filename', () => { + const name = captureFilename({ ...base, title: 'a/b:c*d?"e"' }); + expect(name).not.toMatch(/[\\/:*?"<>|]/); + }); + + it('falls back to "capture" when the title is empty', () => { + const name = captureFilename({ ...base, title: '', source: '' }); + expect(name.startsWith('capture ')).toBe(true); + }); +}); + +describe('buildObsidianUri', () => { + it('encodes spaces as %20, not +', () => { + const uri = buildObsidianUri({ + vault: 'My Vault', + folder: 'OpenRead', + filename: 'note one', + content: 'a b', + }); + expect(uri).toContain('vault=My%20Vault'); + expect(uri).toContain('file=OpenRead%2Fnote%20one'); + expect(uri).toContain('content=a%20b'); + expect(uri).not.toContain('+'); + }); + + it('omits the vault param when no vault is given', () => { + const uri = buildObsidianUri({ filename: 'x', content: 'y' }); + expect(uri).not.toContain('vault='); + expect(uri.startsWith('obsidian://new?')).toBe(true); + }); +}); + +describe('resolveSourceUrl', () => { + it('unwraps the PDF.js viewer URL to the real file', () => { + const href = + 'chrome-extension://abc/pdfjs/web/viewer.html?file=https%3A%2F%2Farxiv.org%2Fp.pdf'; + expect(resolveSourceUrl(href)).toBe('https://arxiv.org/p.pdf'); + }); + + it('passes a normal page URL through unchanged', () => { + expect(resolveSourceUrl('https://example.com/a')).toBe( + 'https://example.com/a', + ); + }); +}); + +describe('applyEnrichment', () => { + it('overrides the title and merges new tags without duplicates', () => { + const out = applyEnrichment(base, { + title: 'Better Title', + tags: ['openread', 'ml'], + }); + expect(out.title).toBe('Better Title'); + expect(out.tags).toEqual(['openread', 'ml']); + }); + + it('keeps the original title when enrichment has none', () => { + const out = applyEnrichment(base, { summary: 'x' }); + expect(out.title).toBe('The Title'); + expect(out.summary).toBe('x'); + }); + + it('does not mutate the input note', () => { + applyEnrichment(base, { tags: ['ml'] }); + expect(base.tags).toEqual(['openread']); + }); +}); diff --git a/src/core/capture.ts b/src/core/capture.ts new file mode 100644 index 0000000..a4cdd15 --- /dev/null +++ b/src/core/capture.ts @@ -0,0 +1,165 @@ +/** + * Capture note builder — turns a `CaptureNote` into the Markdown document and + * the `obsidian://new` URI used to drop it into a vault. Pure and + * side-effect-free: the actual navigation / clipboard write lives in the UI + * shell, so every byte of the emitted note and URL is unit-testable and + * diffable. + * + * The YAML frontmatter is the handoff contract to a downstream "second brain": + * a stable, machine-readable header (source, url, captured, from/to, status, + * tags) that a stronger model can query and rewrite later. OpenRead only ever + * writes `status: raw` — heavy synthesis is deliberately deferred. + */ +import type { CaptureNote, EnrichResult } from './types'; + +/** Characters illegal in a vault filename (Windows-safe) plus Obsidian-special. */ +const ILLEGAL_FILENAME = /[\\/:*?"<>|#^[\]]/g; + +/** Escape a string for a double-quoted YAML scalar (single line). */ +function yamlQuote(value: string): string { + const escaped = value + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/[\r\n]+/g, ' ') + .trim(); + return `"${escaped}"`; +} + +/** Render a tag list as a YAML flow sequence: `[a, b, c]`. */ +function yamlTags(tags: string[]): string { + const safe = tags.map((t) => t.replace(/[[\],]/g, '').trim()).filter(Boolean); + return `[${safe.join(', ')}]`; +} + +/** Prefix every line of `text` with `> ` to form a Markdown blockquote. */ +function blockquote(text: string): string { + return text + .split(/\r?\n/) + .map((line) => (line.length > 0 ? `> ${line}` : '>')) + .join('\n'); +} + +/** + * Compact a timestamp for a filename: an ISO string becomes `YYYYMMDD-HHMM`. + * Non-ISO input is reduced to its alphanumeric characters as a fallback. + */ +function compactStamp(iso: string): string { + if (iso.length < 16) return iso.replace(/[^0-9A-Za-z]/g, ''); + return ( + iso.slice(0, 4) + + iso.slice(5, 7) + + iso.slice(8, 10) + + '-' + + iso.slice(11, 13) + + iso.slice(14, 16) + ); +} + +/** Build the full Markdown document (frontmatter + body) for a capture. */ +export function buildMarkdown(note: CaptureNote): string { + const front = [ + '---', + `title: ${yamlQuote(note.title)}`, + `source: ${yamlQuote(note.source)}`, + `url: ${note.url}`, + `captured: ${note.capturedAt}`, + `from: ${note.from}`, + `to: ${yamlQuote(note.to)}`, + `status: ${note.status}`, + `tags: ${yamlTags(note.tags)}`, + '---', + ].join('\n'); + + const parts: string[] = [ + front, + '', + '## Original', + '', + blockquote(note.original.trim()), + '', + '## Translation', + '', + note.translation.trim(), + ]; + if (note.summary && note.summary.trim()) { + parts.push('', '## Summary', '', note.summary.trim()); + } + return parts.join('\n') + '\n'; +} + +/** Vault filename (no extension): a sanitised title plus a compact timestamp. */ +export function captureFilename(note: CaptureNote): string { + const base = + (note.title || note.source || 'capture') + .replace(ILLEGAL_FILENAME, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 60) || 'capture'; + return `${base} ${compactStamp(note.capturedAt)}`.trim(); +} + +export interface ObsidianUriOptions { + /** Target vault; omit to let Obsidian use the current/last vault. */ + vault?: string; + /** Vault-relative folder; omit to write at the vault root. */ + folder?: string; + /** Filename without extension. */ + filename: string; + /** Full Markdown body. */ + content: string; +} + +/** + * Build an `obsidian://new` URI. Every value is `encodeURIComponent`-escaped + * (so spaces become `%20`, which Obsidian's `decodeURIComponent` restores — a + * form-encoded `+` would not). + */ +export function buildObsidianUri(opts: ObsidianUriOptions): string { + const path = opts.folder ? `${opts.folder}/${opts.filename}` : opts.filename; + const params = [ + opts.vault ? `vault=${encodeURIComponent(opts.vault)}` : null, + `file=${encodeURIComponent(path)}`, + `content=${encodeURIComponent(opts.content)}`, + ].filter((p): p is string => p !== null); + return `obsidian://new?${params.join('&')}`; +} + +/** + * Unwrap the bundled PDF.js viewer URL back to the real document URL, so a + * capture from a PDF records the paper's address, not the extension viewer's. + */ +export function resolveSourceUrl(href: string): string { + const marker = '/pdfjs/web/viewer.html'; + const idx = href.indexOf(marker); + if (idx < 0) return href; + const q = href.indexOf('?', idx); + if (q < 0) return href; + try { + const params = new URLSearchParams(href.slice(q + 1)); + const file = params.get('file'); + return file ? decodeURIComponent(file) : href; + } catch { + return href; + } +} + +/** + * Merge best-effort enrichment into a capture: an enriched title/summary wins + * when present, and new tags are appended without duplicating existing ones. + * Pure — returns a new note, never mutates the input. + */ +export function applyEnrichment( + note: CaptureNote, + enrich: EnrichResult, +): CaptureNote { + const tags = [...note.tags]; + for (const tag of enrich.tags ?? []) { + if (!tags.includes(tag)) tags.push(tag); + } + return { + ...note, + title: enrich.title?.trim() || note.title, + summary: enrich.summary?.trim() || note.summary, + tags, + }; +} diff --git a/src/core/enrich.test.ts b/src/core/enrich.test.ts new file mode 100644 index 0000000..f09b729 --- /dev/null +++ b/src/core/enrich.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect } from 'vitest'; +import { + buildEnrichMessages, + parseEnrichResponse, + ENRICH_INPUT_LIMIT, +} from './enrich'; + +describe('buildEnrichMessages', () => { + it('names the target language in the system prompt', () => { + const [system] = buildEnrichMessages('hello', 'Traditional Chinese'); + expect(system?.role).toBe('system'); + expect(system?.content).toContain('Traditional Chinese'); + }); + + it('truncates the source text to the input limit', () => { + const long = 'x'.repeat(ENRICH_INPUT_LIMIT + 500); + const messages = buildEnrichMessages(long, 'English'); + const user = messages[messages.length - 1]; + expect(user?.content.length).toBe(ENRICH_INPUT_LIMIT); + }); +}); + +describe('parseEnrichResponse', () => { + it('parses a clean JSON object', () => { + const out = parseEnrichResponse( + '{"title":"Linear time","summary":"It runs in O(n).","tags":["algorithms","performance"]}', + ); + expect(out).toEqual({ + title: 'Linear time', + summary: 'It runs in O(n).', + tags: ['algorithms', 'performance'], + }); + }); + + it('recovers JSON wrapped in a code fence', () => { + const out = parseEnrichResponse( + '```json\n{"title":"Fenced","tags":["a"]}\n```', + ); + expect(out?.title).toBe('Fenced'); + expect(out?.tags).toEqual(['a']); + }); + + it('recovers JSON buried in preamble and trailing prose', () => { + const out = parseEnrichResponse( + 'Sure, here is the metadata: {"summary":"A note."} Hope this helps!', + ); + expect(out?.summary).toBe('A note.'); + }); + + it('accepts tags given as a comma-separated string', () => { + const out = parseEnrichResponse('{"tags":"ml, nlp, models"}'); + expect(out?.tags).toEqual(['ml', 'nlp', 'models']); + }); + + it('normalises hashes, spaces, and case in tags and dedupes', () => { + const out = parseEnrichResponse( + '{"tags":["#Machine Learning","machine-learning","AI"]}', + ); + expect(out?.tags).toEqual(['machine-learning', 'ai']); + }); + + it('strips wrapping quotes from scalar fields', () => { + const out = parseEnrichResponse('{"title":"「引號」"}'); + expect(out?.title).toBe('引號'); + }); + + it('caps tags at six', () => { + const out = parseEnrichResponse( + '{"tags":["a","b","c","d","e","f","g","h"]}', + ); + expect(out?.tags?.length).toBe(6); + }); + + it('returns null for an empty string', () => { + expect(parseEnrichResponse('')).toBeNull(); + }); + + it('returns null when there is no JSON object', () => { + expect(parseEnrichResponse('I cannot help with that.')).toBeNull(); + }); + + it('returns null for a JSON array (not an object)', () => { + expect(parseEnrichResponse('["a","b"]')).toBeNull(); + }); + + it('returns null when no field is usable', () => { + expect(parseEnrichResponse('{"title":"","tags":[]}')).toBeNull(); + }); + + it('returns null on malformed JSON', () => { + expect(parseEnrichResponse('{"title": unterminated')).toBeNull(); + }); +}); diff --git a/src/core/enrich.ts b/src/core/enrich.ts new file mode 100644 index 0000000..835fb55 --- /dev/null +++ b/src/core/enrich.ts @@ -0,0 +1,137 @@ +/** + * Optional local enrichment — the light, best-effort structuring pass. + * + * When enabled, a small Ollama model is asked to label a capture with a title, + * a one-sentence summary, and a few tags. Small models are unreliable at this + * (Obsidian's own Web Clipper warns they "silently fail" once a page exceeds a + * ~2048-token context), so this module is built around that reality: the prompt + * truncates input to stay well inside context, and `parseEnrichResponse` + * salvages whatever is usable from noisy output — fenced code blocks, preamble, + * trailing prose, tags as a comma string — returning null rather than throwing + * when nothing can be recovered. Enrichment is garnish; the raw capture is + * always the source of truth. + * + * Pure and dependency-free, so the unit tests and the eval harness score the + * exact parser the extension ships. + */ +import type { ChatMessage, EnrichResult } from './types'; + +/** + * Max characters of source text sent to the model. Deliberately small: it keeps + * the request well inside a weak model's context window so it does not silently + * truncate and return irrelevant metadata. + */ +export const ENRICH_INPUT_LIMIT = 1200; + +const MAX_TAGS = 6; +const MAX_TAG_LEN = 40; +const MAX_TITLE_LEN = 120; +const MAX_SUMMARY_LEN = 400; + +/** + * Build the chat messages for an enrichment request. Title and summary are + * requested in `targetLang` so the downstream metadata matches the reader's + * language. Input is trimmed and truncated to `ENRICH_INPUT_LIMIT`. + */ +export function buildEnrichMessages( + text: string, + targetLang: string, +): ChatMessage[] { + const clipped = text.trim().slice(0, ENRICH_INPUT_LIMIT); + return [ + { + role: 'system', + content: [ + 'You label a reading excerpt with metadata for a note-taking system.', + `Reply with ONLY a compact JSON object, written in ${targetLang}:`, + '{"title": string, "summary": string, "tags": string[]}', + 'Rules:', + '1. title: at most 8 words, no surrounding quotes.', + '2. summary: exactly one sentence.', + '3. tags: 3 to 5 short lowercase keywords.', + '4. No markdown, no code fences, no commentary — output JSON only.', + ].join('\n'), + }, + { role: 'user', content: clipped }, + ]; +} + +/** Slice out the first `{`…last `}` span — the JSON object, ignoring any prose + * or code fence around it. Returns null when there is no object-shaped span. */ +function extractJsonObject(text: string): string | null { + const start = text.indexOf('{'); + const end = text.lastIndexOf('}'); + if (start < 0 || end <= start) return null; + return text.slice(start, end + 1); +} + +/** Trim, strip wrapping quotes, collapse newlines, and cap length. */ +function cleanScalar(value: unknown, max: number): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value + .replace(/[\r\n]+/g, ' ') + .trim() + .replace(/^["'“”「」]+|["'“”「」]+$/g, '') + .trim(); + if (!trimmed) return undefined; + return trimmed.slice(0, max); +} + +/** Normalise tags from an array (or a comma/、-separated string) into a deduped, + * lowercased, length-capped list. Returns undefined when none survive. */ +function cleanTags(value: unknown): string[] | undefined { + let raw: unknown[]; + if (Array.isArray(value)) raw = value; + else if (typeof value === 'string') raw = value.split(/[,,、]/); + else return undefined; + + const seen = new Set(); + const tags: string[] = []; + for (const item of raw) { + if (typeof item !== 'string') continue; + const tag = item + .trim() + .replace(/^#/, '') + .replace(/\s+/g, '-') + .toLowerCase() + .slice(0, MAX_TAG_LEN); + if (!tag || seen.has(tag)) continue; + seen.add(tag); + tags.push(tag); + if (tags.length >= MAX_TAGS) break; + } + return tags.length ? tags : undefined; +} + +/** + * Parse a model's enrichment reply into whatever structured fields are usable. + * Handles clean JSON, JSON wrapped in ```code fences```, and JSON buried in + * preamble or trailing prose. Returns null when nothing usable can be + * recovered, so callers fall back cleanly to a raw capture. + */ +export function parseEnrichResponse(content: string): EnrichResult | null { + if (!content) return null; + const candidate = extractJsonObject(content); + if (!candidate) return null; + + let parsed: unknown; + try { + parsed = JSON.parse(candidate); + } catch { + return null; + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return null; + } + + const record = parsed as Record; + const result: EnrichResult = {}; + const title = cleanScalar(record.title, MAX_TITLE_LEN); + const summary = cleanScalar(record.summary, MAX_SUMMARY_LEN); + const tags = cleanTags(record.tags); + if (title) result.title = title; + if (summary) result.summary = summary; + if (tags) result.tags = tags; + + return Object.keys(result).length > 0 ? result : null; +} diff --git a/src/core/types.ts b/src/core/types.ts index d7a6732..280e10d 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -14,3 +14,45 @@ export interface TranslationContext { contextBefore?: string; contextAfter?: string; } + +/** + * A single reading capture — the raw unit OpenRead hands off to an external + * knowledge base (e.g. an Obsidian vault). Deliberately unprocessed: heavy + * synthesis is deferred to a stronger downstream model, so `status` starts at + * 'raw' and the frontmatter is a stable contract, not a finished note. + */ +export interface CaptureNote { + /** Note title — an enrichment title when available, else the page title. */ + title: string; + /** Originating document title (`document.title`). */ + source: string; + /** Canonical page URL (PDF viewer URLs are unwrapped to the real file). */ + url: string; + /** ISO 8601 capture timestamp. */ + capturedAt: string; + /** Source language, or 'auto' when undetected. */ + from: string; + /** Target language the translation is in. */ + to: string; + /** The text the user selected, verbatim. */ + original: string; + /** The streamed translation of `original`. */ + translation: string; + /** One-sentence summary from optional local enrichment. */ + summary?: string; + /** Tags for downstream querying; always includes 'openread'. */ + tags: string[]; + /** Handoff flag: 'raw' until a downstream system processes the note. */ + status: 'raw' | 'processed'; +} + +/** + * Structured metadata a small local model may produce for a capture. Every + * field is optional because weak models fail partially — the parser keeps + * whatever is usable and the capture proceeds regardless. + */ +export interface EnrichResult { + title?: string; + summary?: string; + tags?: string[]; +} diff --git a/src/entrypoints/background.ts b/src/entrypoints/background.ts index dae2d19..f9cd815 100644 --- a/src/entrypoints/background.ts +++ b/src/entrypoints/background.ts @@ -7,13 +7,14 @@ * URL is loaded from storage here; inference runs on the user's machine. * - PDF routing: `.pdf` navigations are redirected into the bundled viewer. */ -import { translateStream } from '../api/ollama'; +import { translateStream, enrichText } from '../api/ollama'; import { loadSettings } from '../settings'; import { STREAM_PORT_NAME, type PortRequest, type RuntimeRequest, type OpenPdfViewerResponse, + type EnrichCaptureResponse, type StreamResponse, } from '../messaging'; @@ -90,23 +91,49 @@ export default defineBackground(() => { port.onDisconnect.addListener(() => controller?.abort()); }); - // One-shot: a content script asks to open a local PDF in the viewer. + // One-shot handlers: open a local PDF in the viewer, or run a best-effort + // enrichment pass for a capture. Both keep the channel open for an async + // response (`return true`). chrome.runtime.onMessage.addListener( (request: RuntimeRequest, sender, sendResponse) => { - if (request.type !== 'OPEN_PDF_VIEWER') return undefined; - void (async () => { - const allowed = await chrome.extension.isAllowedFileSchemeAccess(); - const response: OpenPdfViewerResponse = allowed - ? { success: true } - : { error: 'PERMISSION_DENIED' }; - if (allowed && sender.tab?.id !== undefined) { - await chrome.tabs.update(sender.tab.id, { - url: `${viewerUrl}?file=${encodeURIComponent(request.url)}`, - }); - } - sendResponse(response); - })(); - return true; // keep the channel open for the async response + if (request.type === 'OPEN_PDF_VIEWER') { + void (async () => { + const allowed = await chrome.extension.isAllowedFileSchemeAccess(); + const response: OpenPdfViewerResponse = allowed + ? { success: true } + : { error: 'PERMISSION_DENIED' }; + if (allowed && sender.tab?.id !== undefined) { + await chrome.tabs.update(sender.tab.id, { + url: `${viewerUrl}?file=${encodeURIComponent(request.url)}`, + }); + } + sendResponse(response); + })(); + return true; + } + + if (request.type === 'ENRICH_CAPTURE') { + void (async () => { + const { baseUrl } = await loadSettings(); + let result = null; + try { + result = await enrichText({ + text: request.text, + baseUrl, + model: request.model, + targetLang: request.targetLang, + }); + } catch { + // Best-effort: any failure falls back to a raw capture. + result = null; + } + const response: EnrichCaptureResponse = { result }; + sendResponse(response); + })(); + return true; + } + + return undefined; }, ); }); diff --git a/src/entrypoints/content.ts b/src/entrypoints/content.ts index cafd5a9..c575734 100644 --- a/src/entrypoints/content.ts +++ b/src/entrypoints/content.ts @@ -14,8 +14,14 @@ export default defineContentScript({ main() { mountSelectionTranslator({ getSettings: async () => { - const { modelId, targetLang } = await loadSettings(); - return { modelId, targetLang }; + const s = await loadSettings(); + return { + modelId: s.modelId, + targetLang: s.targetLang, + obsidianVault: s.obsidianVault, + obsidianFolder: s.obsidianFolder, + enrichOnCapture: s.enrichOnCapture, + }; }, }); }, diff --git a/src/entrypoints/pdf-viewer.ts b/src/entrypoints/pdf-viewer.ts index 2b378db..1f43e94 100644 --- a/src/entrypoints/pdf-viewer.ts +++ b/src/entrypoints/pdf-viewer.ts @@ -11,8 +11,14 @@ import { loadSettings } from '../settings'; export default defineUnlistedScript(() => { mountSelectionTranslator({ getSettings: async () => { - const { modelId, targetLang } = await loadSettings(); - return { modelId, targetLang }; + const s = await loadSettings(); + return { + modelId: s.modelId, + targetLang: s.targetLang, + obsidianVault: s.obsidianVault, + obsidianFolder: s.obsidianFolder, + enrichOnCapture: s.enrichOnCapture, + }; }, // The PDF viewer's overlay CSS targets this class to keep the close button // above the rendered page. diff --git a/src/entrypoints/popup/index.html b/src/entrypoints/popup/index.html index 8b2a52a..b19d022 100644 --- a/src/entrypoints/popup/index.html +++ b/src/entrypoints/popup/index.html @@ -72,6 +72,27 @@ color: #16a34a; text-align: center; } + hr { + margin: 16px 0 4px; + border: 0; + border-top: 1px solid #ddd; + } + @media (prefers-color-scheme: dark) { + hr { + border-top-color: #444; + } + } + label.checkbox { + display: flex; + align-items: center; + gap: 8px; + margin-top: 12px; + font-weight: 400; + cursor: pointer; + } + label.checkbox input { + width: auto; + } @@ -86,6 +107,19 @@

OpenRead

+
+ + + + + + + + +
diff --git a/src/entrypoints/popup/main.ts b/src/entrypoints/popup/main.ts index 915c7f5..a9f760c 100644 --- a/src/entrypoints/popup/main.ts +++ b/src/entrypoints/popup/main.ts @@ -10,9 +10,22 @@ const form = document.querySelector('#settingsForm'); const baseUrlInput = document.querySelector('#baseUrl'); const modelInput = document.querySelector('#modelId'); const langSelect = document.querySelector('#targetLang'); +const vaultInput = document.querySelector('#obsidianVault'); +const folderInput = document.querySelector('#obsidianFolder'); +const enrichInput = + document.querySelector('#enrichOnCapture'); const status = document.querySelector('#status'); -if (form && baseUrlInput && modelInput && langSelect && status) { +if ( + form && + baseUrlInput && + modelInput && + langSelect && + vaultInput && + folderInput && + enrichInput && + status +) { for (const lang of TARGET_LANGUAGES) { const option = document.createElement('option'); option.value = lang; @@ -24,6 +37,9 @@ if (form && baseUrlInput && modelInput && langSelect && status) { baseUrlInput.value = settings.baseUrl; modelInput.value = settings.modelId; langSelect.value = settings.targetLang; + vaultInput.value = settings.obsidianVault; + folderInput.value = settings.obsidianFolder; + enrichInput.checked = settings.enrichOnCapture; }); form.addEventListener('submit', (event) => { @@ -32,6 +48,10 @@ if (form && baseUrlInput && modelInput && langSelect && status) { baseUrl: baseUrlInput.value.trim() || DEFAULT_SETTINGS.baseUrl, modelId: modelInput.value.trim() || DEFAULT_SETTINGS.modelId, targetLang: langSelect.value, + obsidianVault: vaultInput.value.trim(), + obsidianFolder: + folderInput.value.trim() || DEFAULT_SETTINGS.obsidianFolder, + enrichOnCapture: enrichInput.checked, }; void saveSettings(settings).then(() => { status.textContent = 'Saved ✓'; diff --git a/src/messaging.ts b/src/messaging.ts index 12532f5..34dd243 100644 --- a/src/messaging.ts +++ b/src/messaging.ts @@ -7,7 +7,7 @@ * only the text, target language, and model; the background broker reads the * Ollama server URL from `chrome.storage` itself. */ -import type { TranslationContext } from './core/types'; +import type { TranslationContext, EnrichResult } from './core/types'; /** Long-lived port used for streaming translations. */ export const STREAM_PORT_NAME = 'stream-translate'; @@ -37,7 +37,24 @@ export interface OpenPdfViewerMessage { url: string; } -export type RuntimeRequest = OpenPdfViewerMessage; +/** + * content -> background one-shot: run an optional local-model enrichment pass + * for a capture. The broker reads the Ollama base URL from storage itself, so + * (like translation) it never rides the message bus. + */ +export interface EnrichCaptureMessage { + type: 'ENRICH_CAPTURE'; + text: string; + targetLang: string; + model: string; +} + +export type RuntimeRequest = OpenPdfViewerMessage | EnrichCaptureMessage; export type OpenPdfViewerResponse = { success: true } | { error: 'PERMISSION_DENIED' }; + +/** background -> content: the parsed enrichment, or null on any failure. */ +export interface EnrichCaptureResponse { + result: EnrichResult | null; +} diff --git a/src/settings.ts b/src/settings.ts index 3e540cc..863b32d 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -10,12 +10,22 @@ export interface Settings { baseUrl: string; modelId: string; targetLang: string; + /** Obsidian vault to capture into; empty = the user's current/last vault. */ + obsidianVault: string; + /** Vault-relative folder for captures; empty = the vault root. */ + obsidianFolder: string; + /** Run a local-model enrichment pass when capturing (best-effort, off by + * default — small models are unreliable at structured extraction). */ + enrichOnCapture: boolean; } export const DEFAULT_SETTINGS: Settings = { baseUrl: 'http://localhost:11434', modelId: 'qwen2.5', targetLang: 'Traditional Chinese', + obsidianVault: '', + obsidianFolder: 'OpenRead', + enrichOnCapture: false, }; /** Languages offered in the popup, in display order (first = default). */ @@ -36,6 +46,9 @@ export async function loadSettings(): Promise { 'baseUrl', 'modelId', 'targetLang', + 'obsidianVault', + 'obsidianFolder', + 'enrichOnCapture', ])) as Partial; return { ...DEFAULT_SETTINGS, ...stored }; } diff --git a/src/ui/capture.ts b/src/ui/capture.ts new file mode 100644 index 0000000..8a2e2f9 --- /dev/null +++ b/src/ui/capture.ts @@ -0,0 +1,117 @@ +/** + * Capture orchestrator (UI shell). Runs in the content-script world where the + * user gesture lives, so it can open an `obsidian://` URI directly and fall + * back to the clipboard for notes too large for a URL. Optional enrichment is + * delegated to the background worker — the only context allowed to reach the + * local Ollama server without tripping mixed-content / CORS. + * + * All formatting is pure and lives in `core/capture`; this file only owns the + * side effects (message round-trip, navigation, clipboard). + */ +import { + buildMarkdown, + buildObsidianUri, + captureFilename, + applyEnrichment, +} from '../core/capture'; +import type { CaptureNote } from '../core/types'; +import type { EnrichCaptureMessage, EnrichCaptureResponse } from '../messaging'; + +export interface CaptureConfig { + vault: string; + folder: string; + enrich: boolean; + model: string; + targetLang: string; +} + +export type CaptureOutcome = + | { ok: true; method: 'obsidian' | 'clipboard' } + | { ok: false; reason: string }; + +/** + * Conservative URL length cap. OS protocol handlers truncate very long URLs + * (Windows' `ShellExecute` in particular), so above this we copy the note to + * the clipboard instead of silently losing content. + */ +const URI_LIMIT = 8000; + +async function requestEnrichment(text: string, config: CaptureConfig) { + try { + const message: EnrichCaptureMessage = { + type: 'ENRICH_CAPTURE', + text: text.slice(0, 4000), + targetLang: config.targetLang, + model: config.model, + }; + const res = (await chrome.runtime.sendMessage(message)) as + EnrichCaptureResponse | undefined; + return res?.result ?? null; + } catch { + return null; + } +} + +function openUri(uri: string): void { + const a = document.createElement('a'); + a.href = uri; + a.style.display = 'none'; + document.body.appendChild(a); + a.click(); + a.remove(); +} + +async function copyToClipboard(text: string): Promise { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + // Fall through to the execCommand path (non-secure contexts / no gesture). + } + try { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.focus(); + ta.select(); + const ok = document.execCommand('copy'); + ta.remove(); + return ok; + } catch { + return false; + } +} + +/** + * Enrich (optional) → build the note → write it to Obsidian, or copy to the + * clipboard when the note is too large for a protocol-handler URL. + */ +export async function captureNote( + base: CaptureNote, + config: CaptureConfig, +): Promise { + let note = base; + if (config.enrich) { + const enrichment = await requestEnrichment(base.original, config); + if (enrichment) note = applyEnrichment(base, enrichment); + } + + const content = buildMarkdown(note); + const uri = buildObsidianUri({ + vault: config.vault || undefined, + folder: config.folder || undefined, + filename: captureFilename(note), + content, + }); + + if (uri.length <= URI_LIMIT) { + openUri(uri); + return { ok: true, method: 'obsidian' }; + } + const copied = await copyToClipboard(content); + return copied + ? { ok: true, method: 'clipboard' } + : { ok: false, reason: 'copy-failed' }; +} diff --git a/src/ui/selection.ts b/src/ui/selection.ts index 9821bd6..8fb15fb 100644 --- a/src/ui/selection.ts +++ b/src/ui/selection.ts @@ -10,6 +10,9 @@ * network calls via the pure `shouldBypassAI` check. */ import { shouldBypassAI } from '../core/language'; +import { resolveSourceUrl } from '../core/capture'; +import { captureNote, type CaptureConfig } from './capture'; +import type { CaptureNote } from '../core/types'; import { STREAM_PORT_NAME, type StartStreamMessage, @@ -24,6 +27,12 @@ const Z = '2147483647'; export interface SelectionSettings { modelId: string; targetLang: string; + /** Obsidian vault to capture into; empty = the user's current/last vault. */ + obsidianVault: string; + /** Vault-relative folder for captures. */ + obsidianFolder: string; + /** Run a local-model enrichment pass when capturing. */ + enrichOnCapture: boolean; } export interface SelectionUIOptions { @@ -158,17 +167,19 @@ export function mountSelectionTranslator( async function translate(text: string, rect: DOMRect): Promise { removeIcon(); - const { modelId, targetLang } = await options.getSettings(); + const settings = await options.getSettings(); const content = showPanel(rect); // Same-language selection: show it verbatim, no API round-trip. - if (shouldBypassAI(text, targetLang)) { + if (shouldBypassAI(text, settings.targetLang)) { setPanelText(content, text); + mountCaptureButton(text, settings); return; } setPanelText(content, 'Translating…'); let firstChunk = true; + let full = ''; activePort?.disconnect(); const port = chrome.runtime.connect({ name: STREAM_PORT_NAME }); @@ -180,6 +191,7 @@ export function mountSelectionTranslator( setPanelText(content, ''); firstChunk = false; } + full += res.chunk; appendChunk(content, res.chunk); } else if (res.status === 'error') { setPanelText(content, `⚠️ ${res.message}`); @@ -188,17 +200,98 @@ export function mountSelectionTranslator( } else { port.disconnect(); if (activePort === port) activePort = null; + if (full.trim()) mountCaptureButton(full, settings); } }); const message: StartStreamMessage = { type: 'START_STREAM', text, - targetLang, - model: modelId, + targetLang: settings.targetLang, + model: settings.modelId, retryCount: 0, }; port.postMessage(message); + + // Append a one-tap "save to Obsidian" control once a translation is ready. + // Hoisted, so the same-language branch above can call it too. + function mountCaptureButton( + finalText: string, + config: SelectionSettings, + ): void { + if (!panel || panel.querySelector('.oit-capture-bar')) return; + const host = panel; + + const bar = document.createElement('div'); + bar.className = 'oit-capture-bar'; + bar.style.cssText = + 'margin-top:12px;padding-top:10px;border-top:1px solid #eee;' + + 'display:flex;align-items:center;gap:8px'; + + const btn = document.createElement('button'); + btn.textContent = '+ 儲存到 Obsidian'; + btn.style.cssText = [ + 'appearance:none', + 'border:0', + 'cursor:pointer', + 'font-size:13px', + 'padding:6px 10px', + 'border-radius:6px', + 'background:#3b82f6', + 'color:#fff', + 'font-family:inherit', + ].join(';'); + + const hint = document.createElement('span'); + hint.style.cssText = 'font-size:12px;color:#888'; + + bar.append(btn, hint); + host.appendChild(bar); + + // Guard our own presses from the document-level mousedown teardown. + btn.addEventListener('mousedown', (e) => { + e.preventDefault(); + e.stopPropagation(); + }); + btn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + btn.disabled = true; + hint.textContent = config.enrichOnCapture ? '整理中…' : '儲存中…'; + + const note: CaptureNote = { + title: document.title, + source: document.title, + url: resolveSourceUrl(location.href), + capturedAt: new Date().toISOString(), + from: 'auto', + to: config.targetLang, + original: text, + translation: finalText, + tags: ['openread'], + status: 'raw', + }; + const captureConfig: CaptureConfig = { + vault: config.obsidianVault, + folder: config.obsidianFolder, + enrich: config.enrichOnCapture, + model: config.modelId, + targetLang: config.targetLang, + }; + + void captureNote(note, captureConfig).then((outcome) => { + if (outcome.ok) { + hint.textContent = + outcome.method === 'clipboard' + ? '已複製,貼到 Obsidian' + : '已儲存 ✓'; + } else { + hint.textContent = '儲存失敗'; + btn.disabled = false; + } + }); + }); + } } function onMouseUp(event: MouseEvent): void {