From ff856f719b3ae503d235f528f716535eed8b2641 Mon Sep 17 00:00:00 2001 From: wujunchen Date: Fri, 24 Apr 2026 19:31:23 +0800 Subject: [PATCH] init: Obsidian Parallel Reader v0.1.0 Split-view reading plugin for long-form notes: - LLM self-adaptive segmentation (no heading dependency) - Anchor-based line resolution with fallbacks - Scroll-sync highlight + click-to-jump - Right-click context menu for copy actions - MarkdownRenderer for native table/bold/code rendering - SHA-1 content-hash cache with stale-detection banner - Three backends: Claude Code CLI / Codex CLI / Anthropic API Change-Id: I105c57e3df0b2d8d1570c31e3e3a689ecf52ff71 --- .gitignore | 14 + LICENSE | 21 + README.md | 104 +++++ main.js | 1011 +++++++++++++++++++++++++++++++++++++++++++++++++ manifest.json | 9 + styles.css | 198 ++++++++++ 6 files changed, 1357 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 main.js create mode 100644 manifest.json create mode 100644 styles.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c5ff4c1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Plugin runtime data (user cache + settings, not source) +data.json + +# Editor / OS +.DS_Store +.vscode/ +.idea/ +*.swp +*.swo + +# Node (if build tooling is added later) +node_modules/ +dist/ +*.log diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..86761af --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 wujunchen + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7b20f7c --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# Obsidian Parallel Reader + +An Obsidian plugin that splits any long note into a left/right parallel-reading layout: the original content stays on the left; LLM-generated section summaries appear on the right. Scrolling the source highlights the matching summary card; clicking a summary jumps the source editor. + +Inspired by the reading workflow demo in [this Bilibili video](https://www.bilibili.com/video/BV1FxoGBVETm/). + +## Features + +- **Self-adaptive segmentation** — the LLM decides natural topic boundaries, no need to match markdown headings. Short adjacent sections merge; long ones split. +- **Anchor-based line resolution** — summaries carry a verbatim quote from the source; the plugin finds the line number by `indexOf` (with graceful fallbacks). +- **Scroll-sync highlighting** — scrolling the source editor updates the active card in the summary pane. +- **Right-click → copy** — Markdown / plain text / anchor quote / jump to source, as a native Obsidian context menu. +- **Markdown rendering** — summaries render through Obsidian's official `MarkdownRenderer`, so tables, bold, code, wikilinks all work. +- **Persistent cache** — generated summaries are cached by SHA-1 of source content. Reopening a note shows the cached view instantly; edits invalidate the cache (shows a stale banner). +- **Three backends** — Claude Code CLI, Codex CLI, or Anthropic API. CLI backends reuse your existing subscription and don't need a separate API key. + +## Installation + +### Manual install (until this lands in Community Plugins) + +1. Clone this repo into `.obsidian/plugins/parallel-reader/` in your vault, or symlink it there: + ```bash + ln -s /path/to/obsidian-parallel-reader /path/to/YourVault/.obsidian/plugins/parallel-reader + ``` +2. In Obsidian: **Settings → Community plugins → Installed plugins** → enable **Parallel Reader**. + +### Pick a backend + +| Backend | Auth | Cost | Notes | +|---------|------|------|-------| +| **Claude Code CLI** | OAuth in macOS Keychain | Included in Claude subscription | ⚠️ Keychain ACL may block Obsidian subprocess reads — may not work from GUI | +| **Codex CLI** | File-based token (`~/.codex/auth.json`) | Included in ChatGPT Plus | ✅ Works from Obsidian GUI; recommended | +| **Anthropic API (direct)** | API key in plugin settings | Separate billing | Fallback when CLI auth fails | + +### Configure the CLI path + +Obsidian launched from Finder does **not** inherit your shell `PATH`. Create a stable symlink in `~/bin/` and point the plugin setting at it: + +```bash +mkdir -p ~/bin +# Claude Code (Mach-O binary, no Node needed) +ln -sf "$(readlink -f "$(which claude)")" ~/bin/claude +# Codex (Node script — use the wrapper pattern below instead) +cat > ~/bin/codex <<'EOF' +#!/bin/bash +NODE="/path/to/your/node" +CODEX_JS="/path/to/@openai/codex/bin/codex.js" +exec "$NODE" "$CODEX_JS" "$@" +EOF +chmod +x ~/bin/codex +``` + +Then in the plugin settings, paste `/Users/you/bin/codex` (or `~/bin/claude`) into **CLI 路径**, and click **Test** to verify. + +## Commands + +| Command | What it does | +|---------|--------------| +| `为当前笔记生成对照笔记(缓存优先)` | Show cached summary if present, otherwise call LLM | +| `强制重新生成(绕过缓存)` | Re-run the LLM even if cache matches | +| `打开对照笔记面板` | Open right-pane view (loads cache if present) | +| `清除当前笔记的缓存` | Drop the cache entry for the active note | +| `清除所有缓存` | Wipe all cached summaries | + +## Interaction + +| Action | Effect | +|--------|--------| +| Left-click a card body | Scroll source editor to that section | +| Right-click a card | Context menu: Copy Markdown / Copy plain text / Copy anchor / Jump | +| Drag to select text | Normal text selection (does not trigger jump) | +| Scroll source editor | Active card gets highlighted on the right | + +## Design notes + +The LLM is asked to return JSON with this shape: + +```json +{ + "cards": [ + { + "title": "3-10 character short heading", + "anchor": "40-80 characters, verbatim from source, used for line resolution", + "gist": "20-40 character one-line summary serving as a lead-in", + "bullets": ["3-6 supporting details, 20-50 chars each"] + } + ] +} +``` + +- The **anchor** is the key mechanism that keeps scroll-sync working without relying on markdown headings. It's copied verbatim so `content.indexOf(anchor)` finds the origin line; graceful fallbacks (prefix trimming, whitespace normalization) handle minor LLM drift. +- The **gist + bullets** dual structure was chosen after several iterations — pure prose felt wall-of-text, pure bullets felt fragmented. A one-line lead-in before bullets gives both overview and scannable detail. +- Output is rendered via `MarkdownRenderer.render()` so any tables / bold / code / wikilinks in the LLM response render natively. + +## Known limitations + +- **Document size cap**: 20,000 characters. Longer notes are truncated. +- **Claude Code CLI auth**: Obsidian subprocess reads of the macOS Keychain `Claude Code-credentials` service may fail due to code-signing ACL. Codex backend doesn't have this issue. +- **No streaming**: generation is a single batch call; expect ~5-15s wait depending on document length and backend. +- **Preview mode**: scroll-sync uses Obsidian's `setEphemeralState` which works in reading mode too, but accuracy depends on how CodeMirror resolves line positions. + +## License + +MIT diff --git a/main.js b/main.js new file mode 100644 index 0000000..6ea2552 --- /dev/null +++ b/main.js @@ -0,0 +1,1011 @@ +'use strict'; + +const { Plugin, ItemView, PluginSettingTab, Setting, Notice, MarkdownView, TFile, Menu, MarkdownRenderer, requestUrl } = require('obsidian'); +const { spawn } = require('child_process'); +const os = require('os'); +const path = require('path'); +const fs = require('fs'); +const crypto = require('crypto'); + +function hashContent(text) { + return crypto.createHash('sha1').update(text, 'utf8').digest('hex'); +} + +const VIEW_TYPE_PARALLEL = 'parallel-reader-view'; + +const DEFAULT_SETTINGS = { + backend: 'claude-code', // 'claude-code' | 'codex' | 'anthropic-api' + cliPath: '', // optional override; empty = auto-discover + apiKey: '', // only used when backend === 'anthropic-api' + model: 'claude-sonnet-4-6', + exportFolder: 'Reading/Articles', + cliTimeoutMs: 120000, +}; + +/* ---------- Document preparation & anchor resolution ---------- */ + +const MAX_DOC_CHARS = 20000; + +function buildPrompts(content, settings) { + const doc = content.length > MAX_DOC_CHARS + ? content.slice(0, MAX_DOC_CHARS) + '\n\n[文档过长,已截断]' + : content; + + const system = `你是一个长文阅读摘要助手。阅读全文后,把文章切成 5-15 个"自然主题单元"——不必对应 markdown heading,以"一个完整论点或话题"为单位自行判断粒度:短章节合并、长章节拆分。 + +**每张卡片的结构:一句话领读 + 若干条 bullet。bullet 承载细节,gist 是一句话导读。** + +对每个单元输出: + +- title: 3-10 字的短标题(中文),要能独立说明这段讲什么,避免"背景""介绍"这类空泛词 +- anchor: 该单元开头的**逐字引用**,从原文 1:1 复制 40-80 字,保留原始标点/空格/换行;仅供插件内部定位,用户不可见 +- gist: **一句话领读**(20-40 字),点出该单元的核心立场或结论,作为 bullets 的导读 +- bullets: **3-6 条**支撑 bullet,每条 20-50 字。承载数据、对比、机制、例子、反直觉观察。gist 是立场,bullets 是具体内容,两者不允许重复。 + +规则: +- anchor 必须能在原文里 exact substring match 找到。绝对不要改动、总结、翻译,必须原样复制 +- anchor 选用该单元最靠前且足够独特的一段(避免通用套话如"综上所述") +- 每张卡都必须同时有 gist 和 bullets——不要只有 gist,也不要只有 bullets +- bullet 每条是一个完整独立的断言,不要用"首先""其次"这种顺序词 +- 严格只输出 JSON,无 markdown fence、无解释、无 tool call + +输出格式: +{"cards":[{"title":"...","anchor":"...","gist":"一句领读","bullets":["...","..."]}]} + +示例: +{"cards":[ + {"title":"U 型收益曲线","anchor":"那谁又会被 AI 所受益?整体来看,它把整个分数变成了一分到七分","gist":"AI 生产力收益呈 U 型,两端受益最大、中间层塌陷","bullets":["最高薪岗位(软件管理)通过加速既有工作受益最大","最低薪岗位(外卖员、园艺工)用 AI 开副业创造新收入","中间层科学家、律师收益最少,部分因对 prompt 精度信任不足","全体均分 5.1/7,42% 报告收益模糊"]} +]}`; + + const user = `以下是需要处理的文档全文:\n\n${doc}`; + return { system, user }; +} + +function findLineForAnchor(content, anchor) { + if (!anchor) return -1; + const normalize = s => s.replace(/\s+/g, ' ').trim(); + const tryAt = needle => { + if (!needle) return -1; + const idx = content.indexOf(needle); + if (idx === -1) return -1; + let line = 0; + for (let i = 0; i < idx; i++) if (content[i] === '\n') line++; + return line; + }; + + let line = tryAt(anchor); + if (line >= 0) return line; + + // Fallback 1: trim trailing whitespace variants + line = tryAt(anchor.trim()); + if (line >= 0) return line; + + // Fallback 2: progressively shorter prefix (LLM may paraphrase the tail) + for (const len of [60, 40, 25, 15]) { + const prefix = anchor.trim().slice(0, len); + line = tryAt(prefix); + if (line >= 0) return line; + } + + // Fallback 3: whitespace-normalized search (costlier, rarely needed) + const normDoc = normalize(content); + const normAnchor = normalize(anchor).slice(0, 30); + const normIdx = normDoc.indexOf(normAnchor); + if (normIdx === -1) return -1; + // Walk original content tracking normalized offset + let normSeen = 0; + let prevWs = true; + for (let i = 0; i < content.length; i++) { + const c = content[i]; + const isWs = /\s/.test(c); + if (isWs) { + if (!prevWs) normSeen++; + prevWs = true; + } else { + if (normSeen === normIdx) { + let l = 0; + for (let j = 0; j < i; j++) if (content[j] === '\n') l++; + return l; + } + normSeen++; + prevWs = false; + } + } + return -1; +} + +/* ---------- LLM call ---------- */ + +function extractJson(text) { + const raw = (text || '').trim(); + // 1. Pure JSON already? + if (raw.startsWith('{')) return raw; + // 2. Inside a ```json fence + const fence = raw.match(/```(?:json)?\s*([\s\S]*?)```/); + if (fence) return fence[1].trim(); + // 3. Find all balanced {...} candidates and pick the longest one that parses. + const candidates = []; + const stack = []; + let start = -1; + for (let i = 0; i < raw.length; i++) { + const c = raw[i]; + if (c === '{') { + if (stack.length === 0) start = i; + stack.push('{'); + } else if (c === '}') { + if (stack.length > 0) stack.pop(); + if (stack.length === 0 && start >= 0) { + candidates.push(raw.slice(start, i + 1)); + start = -1; + } + } + } + // Try candidates longest-first (the "sections" envelope is usually the biggest) + candidates.sort((a, b) => b.length - a.length); + for (const c of candidates) { + try { JSON.parse(c); return c; } catch (_) { /* skip */ } + } + return raw; // let caller's JSON.parse throw the real error +} + +function parseCardsJson(text) { + const jsonText = extractJson(text); + let parsed; + try { + parsed = JSON.parse(jsonText); + } catch (e) { + throw new Error('LLM 返回非 JSON:\n' + (text || '').slice(0, 500)); + } + const raw = Array.isArray(parsed.cards) ? parsed.cards : []; + return raw + .filter(c => c && typeof c === 'object') + .map(c => ({ + title: typeof c.title === 'string' ? c.title : '(无标题)', + anchor: typeof c.anchor === 'string' ? c.anchor : '', + gist: typeof c.gist === 'string' ? c.gist : '', + bullets: Array.isArray(c.bullets) ? c.bullets.filter(b => typeof b === 'string') : [], + })); +} + +/* CLI discovery: Obsidian launched from GUI doesn't inherit shell PATH */ +function resolveCliPath(name, override) { + if (override && override.trim()) return override.trim(); + const home = os.homedir(); + const candidates = [ + path.join(home, 'bin', name), // user-maintained (take precedence) + path.join(home, '.local/bin', name), + path.join(home, '.claude/local', name), + path.join(home, '.codex/bin', name), + path.join(home, '.bun/bin', name), + path.join(home, '.npm-global/bin', name), + path.join(home, '.cargo/bin', name), + '/opt/homebrew/bin/' + name, // homebrew (apple silicon) + '/usr/local/bin/' + name, // may be stale on mac — last resort + ]; + for (const p of candidates) { + try { if (fs.existsSync(p)) return p; } catch (_) {} + } + return name; // fall back to PATH lookup +} + +function runCli(cmd, args, stdinText, timeoutMs) { + return new Promise((resolve, reject) => { + let child; + try { + child = spawn(cmd, args, { + stdio: ['pipe', 'pipe', 'pipe'], + env: { + ...process.env, + // ensure common install paths are in PATH for sub-spawns + PATH: [ + process.env.PATH || '', + '/usr/local/bin', + '/opt/homebrew/bin', + path.join(os.homedir(), '.local/bin'), + path.join(os.homedir(), '.claude/local'), + ].filter(Boolean).join(':'), + }, + }); + } catch (e) { + return reject(new Error(`无法启动 ${cmd}: ${e.message}`)); + } + + let stdout = ''; + let stderr = ''; + const timer = setTimeout(() => { + try { child.kill('SIGKILL'); } catch (_) {} + reject(new Error(`CLI 超时 (${timeoutMs}ms)`)); + }, timeoutMs); + + child.stdout.on('data', d => { stdout += d.toString('utf8'); }); + child.stderr.on('data', d => { stderr += d.toString('utf8'); }); + child.on('error', e => { + clearTimeout(timer); + reject(new Error(`CLI 启动错误: ${e.message}(尝试在设置里填绝对路径)`)); + }); + child.on('close', code => { + clearTimeout(timer); + if (code !== 0) { + return reject(new Error(`CLI 退出码 ${code}\nstderr:\n${stderr.slice(0, 1000)}`)); + } + resolve({ stdout, stderr }); + }); + + if (stdinText) { + try { + child.stdin.write(stdinText); + child.stdin.end(); + } catch (e) { + // swallow — child may have exited + } + } else { + try { child.stdin.end(); } catch (_) {} + } + }); +} + +async function summarizeViaClaudeCode(system, user, settings) { + const cmd = resolveCliPath('claude', settings.cliPath); + // -p = print mode; disallow all tools so it returns plain text only; request JSON output format + const args = [ + '-p', + '--output-format', 'json', + '--append-system-prompt', system, + '--disallowed-tools', 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch,TodoWrite,Task', + ]; + if (settings.model) { + args.push('--model', settings.model); + } + const { stdout } = await runCli(cmd, args, user, settings.cliTimeoutMs); + + // claude -p --output-format json returns {"type":"result", ..., "result":"..."} + let envelope; + try { envelope = JSON.parse(stdout); } catch (e) { + throw new Error('claude CLI 输出非 JSON envelope:\n' + stdout.slice(0, 500)); + } + const resultText = envelope.result || envelope.content || ''; + return parseCardsJson(resultText); +} + +async function summarizeViaCodex(system, user, settings) { + const cmd = resolveCliPath('codex', settings.cliPath); + const combined = `<>\n${system}\n<>\n${user}\n\n直接输出 JSON,不要任何解释。`; + const args = ['exec', '--skip-git-repo-check', '-']; + const { stdout } = await runCli(cmd, args, combined, settings.cliTimeoutMs); + return parseCardsJson(stdout); +} + +async function summarizeViaAnthropicApi(system, user, settings) { + if (!settings.apiKey) { + throw new Error('Anthropic API key not set. Open Settings → Parallel Reader.'); + } + const body = { + model: settings.model, + max_tokens: 4096, + system, + messages: [{ role: 'user', content: user }], + }; + + let resp; + try { + resp = await requestUrl({ + url: 'https://api.anthropic.com/v1/messages', + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-api-key': settings.apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify(body), + throw: false, + }); + } catch (e) { + throw new Error('Anthropic 请求失败:' + (e.message || e)); + } + + if (resp.status >= 400) { + throw new Error(`Anthropic API ${resp.status}: ${(resp.text || '').slice(0, 300)}`); + } + + const text = (resp.json.content || []).map(c => c.text || '').join('').trim(); + return parseCardsJson(text); +} + +async function summarizeDocument(content, settings) { + const { system, user } = buildPrompts(content, settings); + let cards; + switch (settings.backend) { + case 'codex': + cards = await summarizeViaCodex(system, user, settings); + break; + case 'anthropic-api': + cards = await summarizeViaAnthropicApi(system, user, settings); + break; + case 'claude-code': + default: + cards = await summarizeViaClaudeCode(system, user, settings); + break; + } + // Resolve each card's anchor → startLine, then sort by doc order (unanchored to tail) + const resolved = cards.map(c => ({ + title: c.title, + level: 2, + anchor: c.anchor, + gist: c.gist, + startLine: findLineForAnchor(content, c.anchor), + bullets: c.bullets, + })); + resolved.sort((a, b) => { + if (a.startLine < 0 && b.startLine < 0) return 0; + if (a.startLine < 0) return 1; + if (b.startLine < 0) return -1; + return a.startLine - b.startLine; + }); + return resolved; +} + +/* ---------- Card → markdown / plain text ---------- */ + +function cardToMarkdown(card) { + const parts = [`## ${card.title}`]; + if (card.anchor) { + const q = card.anchor.replace(/\s+/g, ' ').trim(); + parts.push(`> ${q}`); + } + if (card.gist) parts.push(card.gist); + if (card.bullets && card.bullets.length > 0) { + parts.push(card.bullets.map(b => `- ${b}`).join('\n')); + } + return parts.join('\n\n'); +} + +function cardToPlain(card) { + return [ + card.title, + card.gist || '', + ...(card.bullets || []).map(b => '• ' + b), + ].filter(Boolean).join('\n'); +} + +async function copyToClipboard(text, successMsg) { + try { + await navigator.clipboard.writeText(text); + new Notice(successMsg); + } catch (e) { + new Notice('复制失败:' + (e.message || e)); + } +} + +/* ---------- Right-pane view ---------- */ + +class ParallelReaderView extends ItemView { + constructor(leaf, plugin) { + super(leaf); + this.plugin = plugin; + this.sections = []; // [{title, level, anchor, startLine, bullets}] + this.sourceFile = null; + this.cards = []; // DOM refs + this.activeIdx = -1; + } + + getViewType() { return VIEW_TYPE_PARALLEL; } + getDisplayText() { return '对照阅读笔记'; } + getIcon() { return 'book-open'; } + + async onOpen() { + const container = this.containerEl.children[1]; + container.empty(); + container.addClass('parallel-reader-container'); + this.renderEmpty(); + } + + async onClose() {} + + renderEmpty() { + const container = this.containerEl.children[1]; + container.empty(); + const hint = container.createDiv({ cls: 'parallel-reader-empty' }); + hint.createEl('h3', { text: '对照阅读笔记' }); + hint.createEl('p', { text: '打开一篇笔记,然后运行命令:' }); + hint.createEl('code', { text: 'Parallel Reader: 为当前笔记生成对照笔记' }); + } + + async loadFor(file, sections, stale) { + this.sourceFile = file; + this.sections = sections; + this.stale = !!stale; + this.render(); + } + + renderEmptyWithHint(file) { + const container = this.containerEl.children[1]; + container.empty(); + const hint = container.createDiv({ cls: 'parallel-reader-empty' }); + hint.createEl('h3', { text: file.basename }); + hint.createEl('p', { text: '该笔记尚无对照笔记缓存。运行命令:' }); + hint.createEl('code', { text: 'Parallel Reader: 为当前笔记生成对照笔记' }); + } + + render() { + const container = this.containerEl.children[1]; + container.empty(); + + const header = container.createDiv({ cls: 'parallel-reader-header' }); + header.createEl('div', { text: this.sourceFile?.basename || '', cls: 'parallel-reader-title' }); + + if (this.stale) { + const banner = container.createDiv({ cls: 'parallel-reader-stale-banner' }); + banner.setText('⚠ 源笔记已修改,当前是旧缓存。点击"重新生成"刷新。'); + } + + const list = container.createDiv({ cls: 'parallel-reader-cards' }); + this.cards = []; + const sourcePath = this.sourceFile?.path || ''; + this.sections.forEach((s, i) => { + const card = list.createDiv({ cls: 'parallel-reader-card' }); + card.dataset.idx = String(i); + if (s.startLine < 0) card.addClass('parallel-reader-card-unanchored'); + + // --- Title row --- + const title = card.createEl('div', { cls: 'parallel-reader-card-title' }); + title.createSpan({ text: s.title }); + if (s.startLine < 0) { + title.createEl('span', { text: ' ⚠', cls: 'parallel-reader-warn', title: 'anchor 匹配失败,无法滚动联动' }); + } + + // --- Gist (rendered as markdown so inline bold/code/links work) --- + if (s.gist) { + const gistEl = card.createEl('div', { cls: 'parallel-reader-gist' }); + MarkdownRenderer.render(this.app, s.gist, gistEl, sourcePath, this).catch(() => { + gistEl.setText(s.gist); + }); + } + + // --- Bullets (rendered as markdown list — handles tables, bold, etc.) --- + const bs = s.bullets || []; + if (bs.length > 0) { + const bulletsEl = card.createEl('div', { cls: 'parallel-reader-bullets-md' }); + const md = bs.map(b => `- ${b}`).join('\n'); + MarkdownRenderer.render(this.app, md, bulletsEl, sourcePath, this).catch(() => { + bulletsEl.setText(md); + }); + } else if (!s.gist) { + card.createEl('div', { cls: 'parallel-reader-empty-li', text: '(未生成)' }); + } + + // Left click → jump to source line + card.addEventListener('click', (e) => { + // Don't hijack when user is selecting text + const sel = window.getSelection(); + if (sel && sel.toString().length > 0) return; + // Let clicks on anchor links pass through (e.g. wikilinks inside rendered markdown) + const target = e.target; + if (target && target.tagName === 'A') return; + if (s.startLine >= 0) this.plugin.scrollEditorToLine(s.startLine, this.sourceFile); + }); + + // Right click → context menu with copy actions + card.addEventListener('contextmenu', (e) => { + e.preventDefault(); + const menu = new Menu(); + menu.addItem(it => it.setTitle('复制 Markdown').setIcon('copy') + .onClick(() => copyToClipboard(cardToMarkdown(s), '已复制 Markdown'))); + menu.addItem(it => it.setTitle('复制纯文本').setIcon('clipboard-copy') + .onClick(() => copyToClipboard(cardToPlain(s), '已复制纯文本'))); + if (s.anchor) { + menu.addItem(it => it.setTitle('复制 anchor 引用').setIcon('quote-glyph') + .onClick(() => copyToClipboard(s.anchor, '已复制引用原文'))); + } + menu.addSeparator(); + if (s.startLine >= 0) { + menu.addItem(it => it.setTitle('跳转到原文').setIcon('arrow-right') + .onClick(() => this.plugin.scrollEditorToLine(s.startLine, this.sourceFile))); + } + menu.showAtMouseEvent(e); + }); + + this.cards.push(card); + }); + } + + setActiveSection(idx) { + if (idx === this.activeIdx) return; + if (this.activeIdx >= 0 && this.cards[this.activeIdx]) { + this.cards[this.activeIdx].removeClass('is-active'); + } + this.activeIdx = idx; + if (idx >= 0 && this.cards[idx]) { + this.cards[idx].addClass('is-active'); + this.cards[idx].scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + } + } + + async exportToVault() { + if (!this.sourceFile) return; + const folder = this.plugin.settings.exportFolder.replace(/\/$/, ''); + const name = `${this.sourceFile.basename} - 对照笔记.md`; + const targetPath = `${folder}/${name}`; + + const lines = []; + lines.push(`---`); + lines.push(`source: [[${this.sourceFile.basename}]]`); + lines.push(`generated: ${new Date().toISOString().slice(0, 10)}`); + lines.push(`tool: parallel-reader`); + lines.push(`---`); + lines.push(''); + lines.push(`# ${this.sourceFile.basename} · 对照笔记`); + lines.push(''); + this.sections.forEach(s => { + lines.push(`## ${s.title}`); + if (s.anchor) { + const q = s.anchor.replace(/\s+/g, ' ').trim(); + lines.push(`> ${q.length > 120 ? q.slice(0, 120) + '…' : q}`); + } + if (s.gist) { + lines.push(''); + lines.push(s.gist); + } + const bs = s.bullets || []; + if (bs.length > 0) { + lines.push(''); + bs.forEach(b => lines.push(`- ${b}`)); + } + lines.push(''); + }); + + const app = this.plugin.app; + // Ensure folder exists + const folderTF = app.vault.getAbstractFileByPath(folder); + if (!folderTF) { + try { await app.vault.createFolder(folder); } catch (e) { /* exists race */ } + } + + const existing = app.vault.getAbstractFileByPath(targetPath); + if (existing instanceof TFile) { + await app.vault.modify(existing, lines.join('\n')); + } else { + await app.vault.create(targetPath, lines.join('\n')); + } + new Notice(`已导出 → ${targetPath}`); + } +} + +/* ---------- Plugin ---------- */ + +class ParallelReaderPlugin extends Plugin { + async onload() { + await this.loadSettings(); + + this.registerView(VIEW_TYPE_PARALLEL, (leaf) => new ParallelReaderView(leaf, this)); + + this.addCommand({ + id: 'parallel-reader-run', + name: '为当前笔记生成对照笔记(缓存优先)', + callback: () => this.runForActiveFile(false), + }); + + this.addCommand({ + id: 'parallel-reader-regen', + name: '强制重新生成(绕过缓存)', + callback: () => this.runForActiveFile(true), + }); + + this.addCommand({ + id: 'parallel-reader-open-view', + name: '打开对照笔记面板', + callback: async () => { + await this.ensureView(); + const active = this.getActiveView(); + if (active?.file) this.syncViewToFile(active.file); + }, + }); + + this.addCommand({ + id: 'parallel-reader-clear-current', + name: '清除当前笔记的缓存', + callback: async () => { + const active = this.getActiveView(); + if (!active?.file) return new Notice('没有当前笔记'); + await this.cacheDelete(active.file.path); + new Notice('已清除缓存:' + active.file.basename); + }, + }); + + this.addCommand({ + id: 'parallel-reader-clear-all', + name: '清除所有缓存', + callback: async () => { + const n = Object.keys(this.cache).length; + await this.cacheClear(); + new Notice(`已清除 ${n} 条缓存`); + }, + }); + + this.addSettingTab(new ParallelReaderSettingTab(this.app, this)); + + this.registerEvent( + this.app.workspace.on('active-leaf-change', () => this.bindScrollSync()) + ); + this.registerEvent( + this.app.workspace.on('file-open', (file) => { + if (file) this.syncViewToFile(file); + }) + ); + this.bindScrollSync(); + } + + onunload() { + this.app.workspace.detachLeavesOfType(VIEW_TYPE_PARALLEL); + } + + async loadSettings() { + const data = (await this.loadData()) || {}; + // Backward compat: old plugin versions stored settings flat at root + const settingsBlob = data.settings || data; + this.settings = Object.assign({}, DEFAULT_SETTINGS, settingsBlob); + // Strip cache key out of the flat structure if it accidentally leaked + delete this.settings.cache; + this.cache = (data.cache && typeof data.cache === 'object') ? data.cache : {}; + } + + async saveSettings() { + await this.saveData({ settings: this.settings, cache: this.cache }); + } + + async saveCache() { + await this.saveData({ settings: this.settings, cache: this.cache }); + } + + cacheGet(filePath) { + return this.cache[filePath] || null; + } + + async cachePut(filePath, content, cards) { + this.cache[filePath] = { + contentHash: hashContent(content), + cards, + generatedAt: new Date().toISOString(), + }; + await this.saveCache(); + } + + async cacheDelete(filePath) { + if (this.cache[filePath]) { + delete this.cache[filePath]; + await this.saveCache(); + } + } + + async cacheClear() { + this.cache = {}; + await this.saveCache(); + } + + async ensureView() { + const { workspace } = this.app; + let leaf = workspace.getLeavesOfType(VIEW_TYPE_PARALLEL)[0]; + if (!leaf) { + leaf = workspace.getRightLeaf(false); + await leaf.setViewState({ type: VIEW_TYPE_PARALLEL, active: true }); + } + workspace.revealLeaf(leaf); + return leaf.view; + } + + getActiveView() { + return this.app.workspace.getActiveViewOfType(MarkdownView); + } + + async runForActiveFile(force) { + const mdView = this.getActiveView(); + if (!mdView || !mdView.file) { + new Notice('先打开一篇笔记'); + return; + } + const file = mdView.file; + const content = await this.app.vault.read(file); + if (!content.trim()) { + new Notice('笔记为空'); + return; + } + + const view = await this.ensureView(); + + // Try cache unless user explicitly forced regeneration + if (!force) { + const entry = this.cacheGet(file.path); + if (entry && entry.contentHash === hashContent(content)) { + await view.loadFor(file, this.resolveCardAnchors(content, entry.cards), false); + return; + } + } + + await view.loadFor(file, [], false); + new Notice(`对照阅读:让 LLM 读全文并自适应切段…`); + + try { + const sections = await summarizeDocument(content, this.settings); + if (sections.length === 0) { + new Notice('LLM 未返回任何 card'); + return; + } + // Persist raw cards (without computed startLine — re-resolve on load, in case source was renamed/edited) + const rawCards = sections.map(s => ({ + title: s.title, + anchor: s.anchor, + gist: s.gist, + bullets: s.bullets, + })); + await this.cachePut(file.path, content, rawCards); + + await view.loadFor(file, sections, false); + const unanchored = sections.filter(s => s.startLine < 0).length; + new Notice(`对照笔记生成完成:${sections.length} 段${unanchored ? `(⚠ ${unanchored} 段 anchor 未匹配)` : ''}`); + } catch (e) { + console.error(e); + new Notice('生成失败:' + e.message); + } + } + + // Rehydrate cached cards: compute startLine from anchor against current content + resolveCardAnchors(content, rawCards) { + const resolved = (rawCards || []).map(c => ({ + title: c.title, + level: 2, + anchor: c.anchor, + gist: c.gist, + startLine: findLineForAnchor(content, c.anchor), + bullets: c.bullets || [], + })); + resolved.sort((a, b) => { + if (a.startLine < 0 && b.startLine < 0) return 0; + if (a.startLine < 0) return 1; + if (b.startLine < 0) return -1; + return a.startLine - b.startLine; + }); + return resolved; + } + + async syncViewToFile(file) { + if (!file || !file.path || !file.path.endsWith('.md')) return; + const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_PARALLEL); + if (leaves.length === 0) return; // view not open, nothing to sync + const view = leaves[0].view; + if (!view) return; + + const entry = this.cacheGet(file.path); + if (!entry) { + // No cache for this file → clear panel + await view.loadFor(file, [], false); + view.renderEmptyWithHint(file); + return; + } + + const content = await this.app.vault.read(file); + const stale = entry.contentHash !== hashContent(content); + const resolved = this.resolveCardAnchors(content, entry.cards); + await view.loadFor(file, resolved, stale); + } + + bindScrollSync() { + // Remove previous listener reference + if (this._scrollDispose) { + this._scrollDispose(); + this._scrollDispose = null; + } + + const mdView = this.getActiveView(); + if (!mdView) return; + + // Prefer CM6 scrollDOM when available + const editor = mdView.editor; + const cm = editor && editor.cm; + const scrollDom = cm && cm.scrollDOM ? cm.scrollDOM : mdView.contentEl.querySelector('.cm-scroller'); + if (!scrollDom) return; + + const handler = () => this.handleEditorScroll(mdView); + scrollDom.addEventListener('scroll', handler, { passive: true }); + this._scrollDispose = () => scrollDom.removeEventListener('scroll', handler); + } + + handleEditorScroll(mdView) { + const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_PARALLEL); + if (leaves.length === 0) return; + const view = leaves[0].view; + if (!view || !mdView.file || view.sourceFile?.path !== mdView.file.path) return; + + const editor = mdView.editor; + if (!editor || !editor.cm) return; + const cm = editor.cm; + const scrollDom = cm.scrollDOM; + if (!scrollDom) return; + + // Find visible top line + const rect = scrollDom.getBoundingClientRect(); + const topY = rect.top + 80; // offset to pick line just under the header + let topLine = 0; + try { + const pos = cm.posAtCoords({ x: rect.left + 20, y: topY }); + if (pos != null) { + topLine = cm.state.doc.lineAt(pos).number - 1; // 0-indexed + } + } catch (e) { + return; + } + + // Find the closest anchored section whose startLine <= topLine + let activeIdx = -1; + for (let i = 0; i < view.sections.length; i++) { + const s = view.sections[i]; + if (s.startLine < 0) continue; + if (s.startLine <= topLine) activeIdx = i; + else break; + } + view.setActiveSection(activeIdx); + } + + findLeafForFile(file) { + if (!file) return null; + const leaves = this.app.workspace.getLeavesOfType('markdown'); + for (const leaf of leaves) { + const v = leaf.view; + if (v && v.file && v.file.path === file.path) return leaf; + } + return null; + } + + async scrollEditorToLine(line, file) { + let leaf = file ? this.findLeafForFile(file) : null; + + // Fallback: file not open anywhere → open it in a new tab in the main area + if (!leaf && file) { + leaf = this.app.workspace.getLeaf('tab'); + await leaf.openFile(file, { active: false }); + } + + // Last resort: currently active markdown view + if (!leaf) { + const active = this.getActiveView(); + if (active) leaf = active.leaf; + } + if (!leaf) { + new Notice('找不到源笔记对应的编辑器窗口'); + return; + } + + this.app.workspace.setActiveLeaf(leaf, { focus: true }); + const mdView = leaf.view; + if (!(mdView instanceof MarkdownView)) return; + + // Use ephemeral state — works in both source/live-preview AND reading mode. + mdView.setEphemeralState({ line }); + + // Belt-and-suspenders: also scroll the editor if in source/live-preview mode. + if (mdView.editor) { + try { + mdView.editor.setCursor({ line, ch: 0 }); + mdView.editor.scrollIntoView({ from: { line, ch: 0 }, to: { line, ch: 0 } }, true); + } catch (_) { /* ignore */ } + } + } +} + +/* ---------- Settings tab ---------- */ + +class ParallelReaderSettingTab extends PluginSettingTab { + constructor(app, plugin) { + super(app, plugin); + this.plugin = plugin; + } + + display() { + const { containerEl } = this; + containerEl.empty(); + containerEl.createEl('h2', { text: 'Parallel Reader 设置' }); + + new Setting(containerEl) + .setName('Backend') + .setDesc('生成 bullet 的后端:Claude Code / Codex CLI 复用你的订阅;Anthropic API 直连需要 key') + .addDropdown(d => d + .addOption('claude-code', 'Claude Code CLI') + .addOption('codex', 'Codex CLI') + .addOption('anthropic-api', 'Anthropic API (直连)') + .setValue(this.plugin.settings.backend) + .onChange(async v => { + this.plugin.settings.backend = v; + await this.plugin.saveSettings(); + this.display(); + })); + + new Setting(containerEl) + .setName('CLI 路径(可选)') + .setDesc('留空则自动探测常见位置;Obsidian GUI 启动时不继承 shell PATH,建议在终端 `readlink -f $(which claude)` 取真实路径填入。Claude Code 是 Mach-O 可直接跑;Codex 是 node 脚本,需 node 在 PATH') + .addText(t => t + .setPlaceholder('例:/Users/you/.local/share/fnm/.../bin/claude.exe') + .setValue(this.plugin.settings.cliPath) + .onChange(async v => { + this.plugin.settings.cliPath = v.trim(); + await this.plugin.saveSettings(); + })); + + new Setting(containerEl) + .setName('测试 CLI 连通') + .setDesc('调用 ` --version` 验证 spawn 能找到二进制') + .addButton(b => b + .setButtonText('Test') + .onClick(async () => { + const backend = this.plugin.settings.backend; + const name = backend === 'codex' ? 'codex' : 'claude'; + const cmd = resolveCliPath(name, this.plugin.settings.cliPath); + try { + const { stdout } = await runCli(cmd, ['--version'], '', 10000); + new Notice(`✓ ${name} @ ${cmd}\n${stdout.trim().slice(0, 100)}`, 8000); + } catch (e) { + new Notice(`✗ ${name} 调用失败:${e.message}`, 10000); + } + })); + + if (this.plugin.settings.backend === 'anthropic-api') { + new Setting(containerEl) + .setName('Anthropic API Key') + .setDesc('从 https://console.anthropic.com/ 获取') + .addText(t => t + .setPlaceholder('sk-ant-...') + .setValue(this.plugin.settings.apiKey) + .onChange(async v => { + this.plugin.settings.apiKey = v.trim(); + await this.plugin.saveSettings(); + })); + } + + new Setting(containerEl) + .setName('Model') + .setDesc('Claude Code 下会传 --model;Codex 下通常忽略(用 Codex 默认配置);API 下决定调用模型。推荐 claude-sonnet-4-6 或 claude-haiku-4-5-20251001') + .addText(t => t + .setValue(this.plugin.settings.model) + .onChange(async v => { + this.plugin.settings.model = v.trim() || DEFAULT_SETTINGS.model; + await this.plugin.saveSettings(); + })); + + new Setting(containerEl) + .setName('CLI 超时 (ms)') + .addText(t => t + .setValue(String(this.plugin.settings.cliTimeoutMs)) + .onChange(async v => { + const n = parseInt(v, 10); + if (!isNaN(n) && n > 0) { + this.plugin.settings.cliTimeoutMs = n; + await this.plugin.saveSettings(); + } + })); + + new Setting(containerEl) + .setName('导出文件夹') + .setDesc('对照笔记生成位置(相对 Vault 根)') + .addText(t => t + .setValue(this.plugin.settings.exportFolder) + .onChange(async v => { + this.plugin.settings.exportFolder = v.trim() || DEFAULT_SETTINGS.exportFolder; + await this.plugin.saveSettings(); + })); + + containerEl.createEl('h3', { text: '缓存' }); + + const cacheCount = Object.keys(this.plugin.cache).length; + new Setting(containerEl) + .setName(`已缓存笔记:${cacheCount} 篇`) + .setDesc('缓存以源笔记 SHA1 哈希作为失效键,源笔记修改后下次打开会显示 stale 提示') + .addButton(b => b + .setButtonText('清除所有缓存') + .setWarning() + .onClick(async () => { + const n = Object.keys(this.plugin.cache).length; + await this.plugin.cacheClear(); + new Notice(`已清除 ${n} 条缓存`); + this.display(); + })); + } +} + +module.exports = ParallelReaderPlugin; diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..a98c61c --- /dev/null +++ b/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "parallel-reader", + "name": "Parallel Reader", + "version": "0.1.0", + "minAppVersion": "1.4.0", + "description": "Split-view reading: left = original note, right = LLM-generated section bullets with scroll-sync highlight.", + "author": "lancivez", + "isDesktopOnly": true +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..49be354 --- /dev/null +++ b/styles.css @@ -0,0 +1,198 @@ +.parallel-reader-container { + padding: 0; + overflow: hidden; + display: flex; + flex-direction: column; + height: 100%; +} + +.parallel-reader-header { + padding: 12px 14px 8px; + border-bottom: 1px solid var(--background-modifier-border); + background: var(--background-secondary); + flex-shrink: 0; +} + +.parallel-reader-title { + font-weight: 600; + font-size: 13px; + color: var(--text-normal); + margin-bottom: 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.parallel-reader-cards { + flex: 1; + overflow-y: auto; + padding: 12px; +} + +.parallel-reader-card { + padding: 16px 18px; + margin-bottom: 14px; + background: var(--background-primary); + border: 1px solid var(--background-modifier-border); + border-left: 3px solid var(--background-modifier-border); + border-radius: 6px; + cursor: pointer; + transition: border-left-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease; + user-select: text; + -webkit-user-select: text; +} + +.parallel-reader-card * { + user-select: text; + -webkit-user-select: text; +} + +.parallel-reader-card:hover { + background: var(--background-secondary); + border-left-color: var(--interactive-accent); +} + +.parallel-reader-card.is-active { + border-left-color: var(--interactive-accent); + border-left-width: 4px; + background: var(--background-secondary); + box-shadow: 0 0 0 1px var(--interactive-accent-hover, var(--interactive-accent)); +} + +.parallel-reader-card-title { + font-weight: 700; + font-size: 15px; + color: var(--text-accent, var(--interactive-accent)); + margin-bottom: 10px; + line-height: 1.45; +} + +.parallel-reader-gist { + font-size: 14px; + color: var(--text-normal); + line-height: 1.75; + margin-bottom: 12px; + font-weight: 500; +} + +.parallel-reader-gist p:first-child { margin-top: 0; } +.parallel-reader-gist p:last-child { margin-bottom: 0; } + +.parallel-reader-bullets-md { + font-size: 13px; + color: var(--text-muted); + line-height: 1.75; +} + +.parallel-reader-bullets-md ul { + margin: 0; + padding-left: 22px; +} + +.parallel-reader-bullets-md li { + margin-bottom: 8px; +} + +.parallel-reader-bullets-md li:last-child { + margin-bottom: 0; +} + +.parallel-reader-bullets-md li::marker { + color: var(--interactive-accent); +} + +.parallel-reader-bullets-md strong { + color: var(--text-normal); +} + +.parallel-reader-bullets-md code { + font-size: 12px; + background: var(--code-background, var(--background-secondary)); + padding: 1px 4px; + border-radius: 3px; +} + +.parallel-reader-bullets-md table { + font-size: 12px; + border-collapse: collapse; + margin: 6px 0; + width: 100%; +} + +.parallel-reader-bullets-md th { + background: var(--background-secondary); + font-weight: 600; + color: var(--text-normal); +} + +.parallel-reader-bullets-md th, +.parallel-reader-bullets-md td { + border: 1px solid var(--background-modifier-border); + padding: 4px 8px; + text-align: left; +} + + +.parallel-reader-lvl { + color: var(--text-muted); + font-family: var(--font-monospace); + font-size: 11px; + margin-right: 2px; +} + +.parallel-reader-card-unanchored { + opacity: 0.75; +} + +.parallel-reader-card-unanchored:hover { + opacity: 1; +} + +.parallel-reader-warn { + color: var(--text-warning, #e0af68); + font-size: 11px; + margin-left: 4px; + cursor: help; +} + +.parallel-reader-bullets { + margin: 0; + padding-left: 18px; + color: var(--text-muted); + font-size: 12px; + line-height: 1.5; +} + +.parallel-reader-bullets li { + margin-bottom: 3px; +} + +.parallel-reader-empty-li { + font-style: italic; + color: var(--text-faint); +} + +.parallel-reader-stale-banner { + margin: 10px 12px 0; + padding: 8px 12px; + background: var(--background-modifier-error, rgba(224, 175, 104, 0.1)); + color: var(--text-warning, #e0af68); + border-radius: 4px; + font-size: 12px; + border: 1px solid var(--text-warning, #e0af68); +} + +.parallel-reader-empty { + padding: 20px; + text-align: center; + color: var(--text-muted); +} + +.parallel-reader-empty code { + display: inline-block; + margin-top: 8px; + padding: 4px 8px; + background: var(--background-secondary); + border-radius: 3px; + font-size: 11px; +}