feat: improve reader cache and card UX

Change-Id: Ie1b5c5ba1a6538b701af2591effd1845dca2f670
This commit is contained in:
wujunchen 2026-04-25 19:44:39 +08:00
parent 08f510c65c
commit 5c2c97fdbe
16 changed files with 868 additions and 102 deletions

View file

@ -100,16 +100,21 @@ Then in the plugin settings, paste `/Users/you/bin/codex` (or `~/bin/claude`) in
| `取消当前对照笔记生成` | Mark the active generation job as cancelled |
| `清除当前笔记的缓存` | Drop the cache entry for the active note |
| `清除所有缓存` | Wipe all cached summaries |
| `聚焦上一张摘要卡片` / `Focus previous summary card` | Move the active summary card upward |
| `聚焦下一张摘要卡片` / `Focus next summary card` | Move the active summary card downward |
| `跳转到当前摘要卡片原文` / `Jump current summary card to source` | Jump the source editor to the active card |
## 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 |
| Right-click a card | Context menu: Copy Markdown / Copy plain text / Copy anchor / Jump / Edit / Delete card |
| Header icon buttons | Regenerate or cancel / copy all Markdown / export to Vault |
| File context menu | Generate / force regenerate / clear cache for a Markdown file |
| Ribbon icon | Open the comparison pane for the active note |
| `Alt+↑` / `Alt+↓` | Move between summary cards |
| `Enter` in the summary pane | Jump to the active card's source line |
| Drag to select text | Normal text selection (does not trigger jump) |
| Scroll source editor | Active card gets highlighted on the right |
@ -132,10 +137,15 @@ The plugin keeps `main.js` as the generated Obsidian runtime bundle. Edit `main.
|------|----------------|
| `main.ts` | Obsidian lifecycle, commands, right-pane view, settings tab orchestration |
| `src/anchor.ts` | Anchor-to-line matching and whitespace-normalized fallback |
| `src/cache.ts` | Cache entry touch semantics and compact cache-file serialization |
| `src/cards.ts` | Card list edit/delete helpers |
| `src/i18n.ts` | Chinese/English UI strings and translation helper |
| `src/navigation.ts` | Summary-card keyboard navigation helpers |
| `src/prompt.ts` | Prompt construction, language controls, and custom prompt templating |
| `src/settings.ts` | Defaults, provider presets, settings normalization, cache fingerprinting and pruning |
| `src/vault.ts` | Vault path normalization and recursive folder creation |
| `src/schema.ts` | JSON extraction, card payload normalization, structured-output schemas |
| `src/scroll.ts` | Scroll-sync requestAnimationFrame throttling helper |
| `src/providers.ts` | API provider request/response adapters |
| `src/generation-job-manager.ts` | Per-file generation state, cancellation, and error classification |
| `src/markdown.ts` | Card-to-Markdown/plain-text serialization |

89
main.js

File diff suppressed because one or more lines are too long

293
main.ts
View file

@ -1,18 +1,23 @@
'use strict';
import { Plugin, ItemView, PluginSettingTab, Setting, Notice, MarkdownView, TFile, Menu, MarkdownRenderer, requestUrl, setIcon } from 'obsidian';
import { Plugin, ItemView, PluginSettingTab, Setting, Notice, MarkdownView, TFile, Menu, Modal, MarkdownRenderer, requestUrl, setIcon } from 'obsidian';
import { spawn } from 'child_process';
import os from 'os';
import path from 'path';
import fs from 'fs';
import { findLineForAnchor } from './src/anchor';
import { serializeCacheFile, touchCacheEntry } from './src/cache';
import { activeIndexAfterCardDelete, removeCardAt, updateCardAt } from './src/cards';
import { translate } from './src/i18n';
import { cardToMarkdown, cardToPlain, cardsToMarkdown } from './src/markdown';
import { activeSectionLine, nextCardIndex } from './src/navigation';
import { buildPrompts } from './src/prompt';
import { ensureVaultFolder, folderPathsForTarget, normalizeVaultPath } from './src/vault';
import {
extractJson,
normalizeCardsPayload,
parseCardsJson,
} from './src/schema';
import { createRafThrottledHandler } from './src/scroll';
import {
buildAnthropicMessagesBody,
buildGeminiBody,
@ -40,10 +45,12 @@ import {
applyApiProviderPreset,
cacheEntryMatches,
generationFingerprint,
getApiBaseUrl,
getApiFormat,
getApiPreset,
hashContent,
isApiBackend,
modelForApi,
normalizeSettings,
pruneCacheEntries,
} from './src/settings';
@ -166,7 +173,7 @@ async function summarizeViaClaudeCode(system, user, settings, job) {
throw new Error('claude CLI returned a non-JSON envelope:\n' + stdout.slice(0, 500));
}
const resultText = envelope.result || envelope.content || '';
return parseCardsJson(resultText);
return parseCardsJson(resultText, settings);
}
async function summarizeViaCodex(system, user, settings, job) {
@ -174,7 +181,7 @@ async function summarizeViaCodex(system, user, settings, job) {
const combined = `<<SYSTEM>>\n${system}\n<<USER>>\n${user}\n\nOutput JSON directly with no explanation.`;
const args = ['exec', '--skip-git-repo-check', '-'];
const { stdout } = await runCli(cmd, args, combined, settings.cliTimeoutMs, job);
return parseCardsJson(stdout);
return parseCardsJson(stdout, settings);
}
async function testBackend(settings) {
@ -283,6 +290,64 @@ async function copyToClipboard(text, successMsg) {
}
}
function cancellationNoticeKey(settings, job) {
if (job?.phase === 'generating' && isApiBackend(settings?.backend)) {
return 'cancelRequestedApiInFlight';
}
return 'cancelRequested';
}
class CardEditModal extends Modal {
plugin: ParallelReaderPlugin;
card: any;
onSave: (patch: any) => void | Promise<void>;
constructor(app, plugin, card, onSave) {
super(app);
this.plugin = plugin;
this.card = card || {};
this.onSave = onSave;
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.createEl('h2', { text: this.plugin.t('editCardTitle') });
const titleInput = this.createLabeledInput(contentEl, this.plugin.t('editCardTitleField'), this.card.title || '');
const gistInput = this.createLabeledTextarea(contentEl, this.plugin.t('editCardGistField'), this.card.gist || '', 3);
const bulletsInput = this.createLabeledTextarea(contentEl, this.plugin.t('editCardBulletsField'), (this.card.bullets || []).join('\n'), 8);
const actions = contentEl.createDiv({ cls: 'parallel-reader-modal-actions' });
addTextButton(actions, null, this.plugin.t('editCardCancel'), () => this.close(), 'parallel-reader-text-button');
addTextButton(actions, null, this.plugin.t('editCardSave'), async () => {
await this.onSave({
title: titleInput.value.trim() || this.card.title || '',
gist: gistInput.value.trim(),
bullets: bulletsInput.value.split(/\r?\n/).map(line => line.trim()).filter(Boolean),
});
this.close();
}, 'parallel-reader-text-button');
}
createLabeledInput(parent, label, value) {
const wrapper = parent.createDiv({ cls: 'parallel-reader-modal-field' });
wrapper.createEl('label', { text: label });
const input = wrapper.createEl('input', { attr: { type: 'text' } });
input.value = value;
return input;
}
createLabeledTextarea(parent, label, value, rows) {
const wrapper = parent.createDiv({ cls: 'parallel-reader-modal-field' });
wrapper.createEl('label', { text: label });
const textarea = wrapper.createEl('textarea');
textarea.rows = rows;
textarea.value = value;
return textarea;
}
}
/* ---------- Right-pane view ---------- */
class ParallelReaderView extends ItemView {
@ -310,14 +375,20 @@ class ParallelReaderView extends ItemView {
getDisplayText() { return this.plugin.t('displayName'); }
getIcon() { return 'book-open'; }
async onOpen() {
onOpen() {
const container = this.containerEl.children[1];
container.empty();
container.addClass('parallel-reader-container');
container.setAttr('tabindex', '0');
container.addEventListener('keydown', e => this.handleKeydown(e as KeyboardEvent));
this.renderEmpty();
this.focusSummaryPane();
return Promise.resolve();
}
async onClose() {}
onClose() {
return Promise.resolve();
}
renderEmpty() {
this.sourceFile = null;
@ -333,6 +404,13 @@ class ParallelReaderView extends ItemView {
hint.createEl('code', { text: this.plugin.t('commandGenerate') });
}
focusSummaryPane() {
const container = this.containerEl.children[1] as HTMLElement;
if (!container || typeof container.focus !== 'function') return false;
container.focus({ preventScroll: true });
return true;
}
async loadFor(file, sections, stale) {
this.sourceFile = file;
this.sections = sections;
@ -489,11 +567,19 @@ class ParallelReaderView extends ItemView {
menu.addItem(it => it.setTitle(this.plugin.t('menuJumpSource')).setIcon('arrow-right')
.onClick(() => this.plugin.scrollEditorToLine(s.startLine, this.sourceFile)));
}
menu.addSeparator();
menu.addItem(it => it.setTitle(this.plugin.t('menuEditCard')).setIcon('pencil')
.onClick(() => this.openEditCardModal(i)));
menu.addItem(it => it.setTitle(this.plugin.t('menuDeleteCard')).setIcon('trash')
.onClick(() => this.deleteCard(i)));
menu.showAtMouseEvent(e);
});
this.cards.push(card);
});
if (this.activeIdx >= 0 && this.cards[this.activeIdx]) {
this.cards[this.activeIdx].addClass('is-active');
}
}
setActiveSection(idx) {
@ -508,9 +594,70 @@ class ParallelReaderView extends ItemView {
}
}
moveActiveSection(delta) {
const nextIdx = nextCardIndex(this.activeIdx, this.sections.length, delta);
this.setActiveSection(nextIdx);
this.focusSummaryPane();
return nextIdx;
}
jumpToActiveSection() {
const line = activeSectionLine(this.sections, this.activeIdx);
if (line < 0 || !this.sourceFile) return -1;
this.plugin.scrollEditorToLine(line, this.sourceFile);
return line;
}
handleKeydown(e: KeyboardEvent) {
if (e.altKey && e.key === 'ArrowUp') {
e.preventDefault();
this.moveActiveSection(-1);
return;
}
if (e.altKey && e.key === 'ArrowDown') {
e.preventDefault();
this.moveActiveSection(1);
return;
}
if (!e.altKey && !e.metaKey && !e.ctrlKey && !e.shiftKey && e.key === 'Enter') {
const line = this.jumpToActiveSection();
if (line >= 0) e.preventDefault();
}
}
async deleteCard(index) {
if (!this.sourceFile) return false;
const nextSections = removeCardAt(this.sections, index);
if (nextSections.length === this.sections.length) return false;
const previousLength = this.sections.length;
this.sections = nextSections;
this.activeIdx = activeIndexAfterCardDelete(index, previousLength, this.activeIdx);
await this.plugin.cacheReplaceCards(this.sourceFile.path, nextSections);
this.render();
new Notice(this.plugin.t('cardDeleted'));
return true;
}
openEditCardModal(index) {
if (!this.sourceFile || !this.sections[index]) return false;
new CardEditModal(this.app, this.plugin, this.sections[index], patch => this.updateCard(index, patch)).open();
return true;
}
async updateCard(index, patch) {
if (!this.sourceFile) return false;
const nextSections = updateCardAt(this.sections, index, patch);
if (nextSections.length !== this.sections.length) return false;
this.sections = nextSections;
await this.plugin.cacheReplaceCards(this.sourceFile.path, nextSections);
this.render();
new Notice(this.plugin.t('cardSaved'));
return true;
}
async exportToVault() {
if (!this.sourceFile) return;
const folder = this.plugin.settings.exportFolder.replace(/\/$/, '');
const folder = normalizeVaultPath(this.plugin.settings.exportFolder);
const name = `${this.sourceFile.basename} - ${this.plugin.t('displayName')}.md`;
const targetPath = `${folder}/${name}`;
@ -526,11 +673,7 @@ class ParallelReaderView extends ItemView {
].join('\n');
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 */ }
}
await ensureVaultFolder(app, folder);
const existing = app.vault.getAbstractFileByPath(targetPath);
if (existing instanceof TFile) {
@ -550,6 +693,8 @@ class ParallelReaderPlugin extends Plugin {
jobs: GenerationJobManager;
_scrollDispose: (() => void) | null;
_settingsSaveTimer: ReturnType<typeof setTimeout> | null;
_cacheSaveTimer: ReturnType<typeof setTimeout> | null;
_cacheDirty: boolean;
t(key, vars?) {
return translate(this.settings || DEFAULT_SETTINGS, key, vars);
@ -558,6 +703,8 @@ class ParallelReaderPlugin extends Plugin {
async onload() {
await this.loadSettings();
this.jobs = new GenerationJobManager();
this._cacheSaveTimer = null;
this._cacheDirty = false;
this.addRibbonIcon('book-open', this.t('ribbonOpen'), async () => {
const active = this.getActiveView();
@ -628,6 +775,26 @@ class ParallelReaderPlugin extends Plugin {
},
});
this.addCommand({
id: 'parallel-reader-card-prev',
name: this.t('cmdCardPrev'),
hotkeys: [{ modifiers: ['Alt'], key: 'ArrowUp' }],
callback: () => this.moveActiveCard(-1),
});
this.addCommand({
id: 'parallel-reader-card-next',
name: this.t('cmdCardNext'),
hotkeys: [{ modifiers: ['Alt'], key: 'ArrowDown' }],
callback: () => this.moveActiveCard(1),
});
this.addCommand({
id: 'parallel-reader-card-jump',
name: this.t('cmdCardJump'),
callback: () => this.jumpActiveCard(),
});
this.addSettingTab(new ParallelReaderSettingTab(this.app, this));
this.registerEvent(
@ -652,6 +819,7 @@ class ParallelReaderPlugin extends Plugin {
async onunload() {
await this.flushSettingsSave();
await this.flushCacheSave();
this.app.workspace.detachLeavesOfType(VIEW_TYPE_PARALLEL);
}
@ -686,8 +854,32 @@ class ParallelReaderPlugin extends Plugin {
}
async saveCache() {
if (this._cacheSaveTimer) {
clearTimeout(this._cacheSaveTimer);
this._cacheSaveTimer = null;
}
this.pruneCache();
await this.writeCacheFile();
this._cacheDirty = false;
}
scheduleCacheSave(delayMs = 5000) {
this._cacheDirty = true;
if (this._cacheSaveTimer) return;
this._cacheSaveTimer = setTimeout(() => {
this._cacheSaveTimer = null;
if (!this._cacheDirty) return;
this.saveCache().catch(e => console.error('[parallel-reader] failed to save cache', e));
}, delayMs);
}
async flushCacheSave() {
if (this._cacheSaveTimer) {
clearTimeout(this._cacheSaveTimer);
this._cacheSaveTimer = null;
}
if (!this._cacheDirty) return;
await this.saveCache();
}
cacheFilePath() {
@ -730,10 +922,7 @@ class ParallelReaderPlugin extends Plugin {
await this.ensurePluginDataDir();
await this.app.vault.adapter.write(
this.cacheFilePath(),
JSON.stringify({
version: 1,
entries: this.cache,
}, null, 2)
serializeCacheFile(this.cache)
);
}
@ -750,13 +939,18 @@ class ParallelReaderPlugin extends Plugin {
async pruneCacheIfNeeded() {
const removed = this.pruneCache();
if (removed.length > 0) await this.writeCacheFile();
if (removed.length > 0) await this.saveCache();
return removed;
}
cacheGet(filePath) {
const entry = this.cache[filePath] || null;
if (entry) entry.lastAccessedAt = new Date().toISOString();
return this.cache[filePath] || null;
}
async cacheTouch(filePath) {
const entry = touchCacheEntry(this.cache[filePath] || null);
if (!entry) return null;
this.scheduleCacheSave();
return entry;
}
@ -773,6 +967,22 @@ class ParallelReaderPlugin extends Plugin {
await this.saveCache();
}
async cacheReplaceCards(filePath, cards) {
const entry = this.cache[filePath];
if (!entry) return false;
const now = new Date().toISOString();
entry.cards = (cards || []).map(card => ({
title: card.title,
anchor: card.anchor,
gist: card.gist,
bullets: card.bullets || [],
}));
entry.updatedAt = now;
entry.lastAccessedAt = now;
await this.saveCache();
return true;
}
async cacheDelete(filePath) {
if (this.cache[filePath]) {
delete this.cache[filePath];
@ -800,6 +1010,28 @@ class ParallelReaderPlugin extends Plugin {
return this.app.workspace.getActiveViewOfType(MarkdownView);
}
getParallelView() {
return this.app.workspace.getLeavesOfType(VIEW_TYPE_PARALLEL)[0]?.view as ParallelReaderView | undefined;
}
moveActiveCard(delta) {
const view = this.getParallelView();
if (!view || !view.sections.length) {
new Notice(this.t('noActiveCard'));
return -1;
}
return view.moveActiveSection(delta);
}
jumpActiveCard() {
const view = this.getParallelView();
if (!view || view.jumpToActiveSection() < 0) {
new Notice(this.t('noActiveCard'));
return false;
}
return true;
}
isGeneratingFile(file) {
return !!file && !!file.path && this.jobs.isRunning(file.path);
}
@ -809,8 +1041,10 @@ class ParallelReaderPlugin extends Plugin {
new Notice(this.t('noCancelableJob'));
return false;
}
const job = this.jobs.get(file.path);
const noticeKey = cancellationNoticeKey(this.settings, job);
const cancelled = this.jobs.cancel(file.path);
new Notice(cancelled ? this.t('cancelRequested') : this.t('noCancelableJob'));
new Notice(cancelled ? this.t(noticeKey) : this.t('noCancelableJob'));
return cancelled;
}
@ -961,6 +1195,7 @@ class ParallelReaderPlugin extends Plugin {
if (!force) {
const entry = this.cacheGet(file.path);
if (cacheEntryMatches(entry, content, this.settings)) {
await this.cacheTouch(file.path);
if (this.activeFileStillMatches(file)) {
await view.loadFor(file, this.resolveCardAnchors(content, entry.cards), false);
}
@ -1059,6 +1294,7 @@ class ParallelReaderPlugin extends Plugin {
const content = await this.app.vault.read(file);
if (!this.activeFileStillMatches(file)) return;
const stale = !cacheEntryMatches(entry, content, this.settings);
await this.cacheTouch(file.path);
const resolved = this.resolveCardAnchors(content, entry.cards);
await view.loadFor(file, resolved, stale);
}
@ -1079,9 +1315,12 @@ class ParallelReaderPlugin extends Plugin {
const scrollDom = cm && cm.scrollDOM ? cm.scrollDOM : mdView.contentEl.querySelector('.cm-scroller');
if (!scrollDom) return;
const handler = () => this.handleEditorScroll(mdView);
const handler = createRafThrottledHandler(() => this.handleEditorScroll(mdView));
scrollDom.addEventListener('scroll', handler, { passive: true });
this._scrollDispose = () => scrollDom.removeEventListener('scroll', handler);
this._scrollDispose = () => {
handler.cancel();
scrollDom.removeEventListener('scroll', handler);
};
}
handleEditorScroll(mdView) {
@ -1508,6 +1747,8 @@ export const __test = {
GenerationJobAlreadyRunningError,
GenerationJobCancelledError,
GenerationJobManager,
activeIndexAfterCardDelete,
activeSectionLine,
buildAnthropicMessagesBody,
buildGeminiBody,
buildOpenAiChatBody,
@ -1515,13 +1756,23 @@ export const __test = {
buildPrompts,
cardsToMarkdown,
cacheEntryMatches,
cancellationNoticeKey,
classifyGenerationError,
createRafThrottledHandler,
extractJson,
findLineForAnchor,
folderPathsForTarget,
generationFingerprint,
getApiBaseUrl,
modelForApi,
normalizeCardsPayload,
nextCardIndex,
pruneCacheEntries,
removeCardAt,
serializeCacheFile,
summarizeViaApi,
touchCacheEntry,
translate,
tokenLimitFieldForOpenAiChat,
updateCardAt,
};

14
src/cache.ts Normal file
View file

@ -0,0 +1,14 @@
'use strict';
export function touchCacheEntry(entry, now?) {
if (!entry) return null;
entry.lastAccessedAt = now || new Date().toISOString();
return entry;
}
export function serializeCacheFile(entries) {
return JSON.stringify({
version: 1,
entries: entries || {},
});
}

26
src/cards.ts Normal file
View file

@ -0,0 +1,26 @@
'use strict';
export function removeCardAt(cards, index) {
const next = Array.isArray(cards) ? cards.slice() : [];
if (!Number.isInteger(index) || index < 0 || index >= next.length) return next;
next.splice(index, 1);
return next;
}
export function activeIndexAfterCardDelete(deleteIndex, previousLength, activeIdx) {
if (!Number.isInteger(deleteIndex) || !Number.isInteger(previousLength) || previousLength <= 0) return activeIdx;
if (!Number.isInteger(activeIdx) || activeIdx < 0) return activeIdx;
if (deleteIndex < 0 || deleteIndex >= previousLength) return activeIdx;
const nextLength = previousLength - 1;
if (nextLength <= 0) return -1;
if (deleteIndex < activeIdx) return Math.max(0, activeIdx - 1);
if (deleteIndex === activeIdx) return Math.min(activeIdx, nextLength - 1);
return activeIdx;
}
export function updateCardAt(cards, index, patch) {
const next = Array.isArray(cards) ? cards.slice() : [];
if (!Number.isInteger(index) || index < 0 || index >= next.length) return next;
next[index] = Object.assign({}, next[index], patch || {});
return next;
}

View file

@ -17,6 +17,9 @@ export const STRINGS = {
cmdCancel: '取消当前对照笔记生成',
cmdClearCurrent: '清除当前笔记的缓存',
cmdClearAll: '清除所有缓存',
cmdCardPrev: '聚焦上一张摘要卡片',
cmdCardNext: '聚焦下一张摘要卡片',
cmdCardJump: '跳转到当前摘要卡片原文',
actionCancel: '取消生成',
actionRegenerate: '重新生成',
actionCopyAll: '复制全部 Markdown',
@ -32,6 +35,8 @@ export const STRINGS = {
menuCopyPlain: '复制纯文本',
menuCopyAnchor: '复制 anchor 引用',
menuJumpSource: '跳转到原文',
menuEditCard: '编辑此卡片',
menuDeleteCard: '删除此卡片',
copiedMarkdown: '已复制 Markdown',
copiedPlain: '已复制纯文本',
copiedAnchor: '已复制引用原文',
@ -44,11 +49,21 @@ export const STRINGS = {
cacheClearedAll: '已清除 {count} 条缓存',
noCancelableJob: '当前没有可取消的生成任务',
cancelRequested: '已请求取消生成',
cancelRequestedApiInFlight: '已请求取消生成;当前 API 请求无法立即中断,返回后会丢弃结果。',
fileMenuGenerate: '生成对照笔记',
fileMenuRegen: '强制重新生成对照笔记',
fileMenuClear: '清除对照笔记缓存',
noExportContent: '当前没有可导出的对照笔记',
noCopyContent: '当前没有可复制的对照笔记',
noActiveCard: '当前没有可跳转的摘要卡片',
cardDeleted: '已删除此卡片',
cardSaved: '已保存此卡片',
editCardTitle: '编辑摘要卡片',
editCardTitleField: '标题',
editCardGistField: '领读',
editCardBulletsField: '要点(每行一条)',
editCardCancel: '取消',
editCardSave: '保存',
openNoteFirst: '先打开一篇笔记',
alreadyGenerating: '该笔记正在生成对照笔记',
emptyNote: '笔记为空',
@ -60,6 +75,18 @@ export const STRINGS = {
cancelled: '已取消生成',
cancelledError: '生成已取消',
generationFailed: '生成失败{kind}{error}',
errorCustomProviderBaseUrlRequired: '自定义 provider 需要填写 API Base URL。',
errorApiBaseUrlMissing: 'API Base URL 未设置。请在设置里选择 provider 或填写自定义 base URL。',
errorModelMissing: 'Model 未设置。请在设置里填写模型 ID。',
errorApiKeyMissing: 'API key 未设置。请在设置里填写 API Key{hint}。',
errorApiKeyEnvHint: ' 或环境变量 {envVar}',
errorLlmNonJson: 'LLM 返回非 JSON\n{excerpt}',
errorCustomHeadersJsonParse: '自定义 headers JSON 解析失败:{error}',
errorCustomHeadersJsonObject: '自定义 headers JSON 必须是对象',
errorCustomHeadersLineFormat: '自定义 headers 每行格式应为 `Header-Name: value`',
errorProviderNonJson: '{label} 返回非 JSON\n{excerpt}',
errorProviderRequestFailed: '{label} 请求失败:{error}',
errorProviderApiStatus: '{label} API 返回 HTTP {status}{excerpt}',
noEditor: '找不到源笔记对应的编辑器窗口',
settingUiLanguageName: '界面语言',
settingUiLanguageDesc: '控制插件界面、命令和提示文案Auto 跟随 Obsidian/系统语言',
@ -128,6 +155,9 @@ export const STRINGS = {
cmdCancel: 'Cancel current parallel-note generation',
cmdClearCurrent: 'Clear cache for current note',
cmdClearAll: 'Clear all caches',
cmdCardPrev: 'Focus previous summary card',
cmdCardNext: 'Focus next summary card',
cmdCardJump: 'Jump current summary card to source',
actionCancel: 'Cancel generation',
actionRegenerate: 'Regenerate',
actionCopyAll: 'Copy all Markdown',
@ -143,6 +173,8 @@ export const STRINGS = {
menuCopyPlain: 'Copy plain text',
menuCopyAnchor: 'Copy anchor quote',
menuJumpSource: 'Jump to source',
menuEditCard: 'Edit this card',
menuDeleteCard: 'Delete this card',
copiedMarkdown: 'Copied Markdown',
copiedPlain: 'Copied plain text',
copiedAnchor: 'Copied anchor quote',
@ -155,11 +187,21 @@ export const STRINGS = {
cacheClearedAll: 'Cleared {count} cache entries',
noCancelableJob: 'No cancellable generation job',
cancelRequested: 'Cancel requested',
cancelRequestedApiInFlight: 'Cancel requested. The in-flight API request cannot be aborted immediately; its result will be ignored.',
fileMenuGenerate: 'Generate parallel notes',
fileMenuRegen: 'Regenerate parallel notes',
fileMenuClear: 'Clear parallel-note cache',
noExportContent: 'No parallel notes to export',
noCopyContent: 'No parallel notes to copy',
noActiveCard: 'No active summary card to jump',
cardDeleted: 'Deleted this card',
cardSaved: 'Saved this card',
editCardTitle: 'Edit summary card',
editCardTitleField: 'Title',
editCardGistField: 'Gist',
editCardBulletsField: 'Bullets (one per line)',
editCardCancel: 'Cancel',
editCardSave: 'Save',
openNoteFirst: 'Open a note first',
alreadyGenerating: 'This note is already being generated',
emptyNote: 'The note is empty',
@ -171,6 +213,18 @@ export const STRINGS = {
cancelled: 'Generation cancelled',
cancelledError: 'Generation cancelled',
generationFailed: 'Generation failed{kind}: {error}',
errorCustomProviderBaseUrlRequired: 'Custom provider requires an API Base URL.',
errorApiBaseUrlMissing: 'API Base URL is not set. Choose a provider or enter a custom base URL in settings.',
errorModelMissing: 'Model is not set. Enter a model ID in settings.',
errorApiKeyMissing: 'API key is not set. Enter an API Key in settings{hint}.',
errorApiKeyEnvHint: ' or set environment variable {envVar}',
errorLlmNonJson: 'LLM returned non-JSON:\n{excerpt}',
errorCustomHeadersJsonParse: 'Custom headers JSON parse failed: {error}',
errorCustomHeadersJsonObject: 'Custom headers JSON must be an object',
errorCustomHeadersLineFormat: 'Custom headers lines must use `Header-Name: value`',
errorProviderNonJson: '{label} returned non-JSON:\n{excerpt}',
errorProviderRequestFailed: '{label} request failed: {error}',
errorProviderApiStatus: '{label} API returned HTTP {status}: {excerpt}',
noEditor: 'Could not find the source note editor',
settingUiLanguageName: 'UI language',
settingUiLanguageDesc: 'Controls plugin UI, commands, and notices. Auto follows Obsidian/system language.',

15
src/navigation.ts Normal file
View file

@ -0,0 +1,15 @@
'use strict';
export function nextCardIndex(activeIdx, cardCount, delta) {
if (!Number.isFinite(cardCount) || cardCount <= 0) return -1;
const count = Math.floor(cardCount);
const direction = delta < 0 ? -1 : 1;
if (activeIdx < 0 || activeIdx >= count) return direction < 0 ? count - 1 : 0;
return Math.min(count - 1, Math.max(0, activeIdx + direction));
}
export function activeSectionLine(sections, activeIdx) {
if (!Array.isArray(sections) || activeIdx < 0 || activeIdx >= sections.length) return -1;
const line = Number(sections[activeIdx]?.startLine);
return Number.isFinite(line) && line >= 0 ? line : -1;
}

View file

@ -29,22 +29,37 @@ export function renderPromptTemplate(template, vars) {
});
}
export function buildPrompts(content, settings) {
const maxDocChars = Number(settings.maxDocChars) || MAX_DOC_CHARS;
const promptLanguage = PROMPT_LANGUAGES[settings.promptLanguage] ? settings.promptLanguage : DEFAULT_SETTINGS.promptLanguage;
const minCards = Math.max(1, Number(settings.minCards) || DEFAULT_SETTINGS.minCards);
const maxCards = Math.max(minCards, Number(settings.maxCards) || DEFAULT_SETTINGS.maxCards);
const languageInstruction = promptLanguageInstruction(promptLanguage);
const doc = content.length > maxDocChars
? content.slice(0, maxDocChars) + (promptLanguage === 'en' ? '\n\n[Document truncated]' : '\n\n[文档过长,已截断]')
: content;
function defaultSystemPrompt(language, minCards, maxCards, languageInstruction, schema, example) {
if (language === 'en') {
return `You are a long-form reading summary assistant. After reading the full document, split it into ${minCards}-${maxCards} natural topic units. They do not need to match markdown headings; use a complete argument or topic as the unit, merging short sections and splitting long ones when needed.
const schema = '{"cards":[{"title":"...","anchor":"...","gist":"...","bullets":["...","..."]}]}';
const example = promptSchemaExample(promptLanguage);
const templateVars = { minCards, maxCards, languageInstruction, schema, example };
const customSystem = renderPromptTemplate(settings.customSystemPrompt, templateVars).trim();
Each card has one guiding sentence plus several bullets. Bullets carry details; gist is the lead-in.
const defaultSystem = `你是一个长文阅读摘要助手。阅读全文后,把文章切成 ${minCards}-${maxCards} 个"自然主题单元"——不必对应 markdown heading以"一个完整论点或话题"为单位自行判断粒度:短章节合并、长章节拆分。
Language:
- ${languageInstruction}
For each unit, output:
- title: a concise 3-10 word heading that clearly states what the section is about; avoid vague labels like "Background" or "Introduction"
- anchor: a verbatim quote from the start of this unit, copied 1:1 from the source, 40-80 characters where possible, preserving punctuation, spaces, and line breaks; it is only used internally for positioning
- gist: one guiding sentence, 20-40 words, stating the core claim or conclusion
- bullets: 3-6 supporting bullets, each 20-50 words, carrying data, comparisons, mechanisms, examples, or counterintuitive observations. Gist and bullets must not repeat each other.
Rules:
- anchor must exact-substring-match the source. Never paraphrase, translate, summarize, or alter it.
- Choose the earliest sufficiently distinctive quote for each unit; avoid generic phrases.
- Every card must include both gist and bullets.
- Each bullet must be a standalone assertion; avoid sequencing phrases such as "first" or "then".
- Output strict JSON only: no markdown fence, no explanation, no tool call.
Output shape:
${schema}
Example:
${example}`;
}
return `你是一个长文阅读摘要助手。阅读全文后,把文章切成 ${minCards}-${maxCards} 个"自然主题单元"——不必对应 markdown heading以"一个完整论点或话题"为单位自行判断粒度:短章节合并、长章节拆分。
** + bulletbullet gist **
@ -70,16 +85,46 @@ ${schema}
${example}`;
}
const system = customSystem
? `${customSystem}
function systemPromptContract(language, minCards, maxCards, languageInstruction, schema) {
if (language === 'en') {
return `Non-overridable output contract:
- Must output ${minCards}-${maxCards} cards.
- ${languageInstruction}
- anchor must be copied verbatim from the source and exact-substring-match the source.
- Output strict JSON only: no markdown fence, no explanation, no tool call.
- JSON shape: ${schema}`;
}
return `不可覆盖的输出契约:
- ${minCards}-${maxCards} cards
- ${languageInstruction}
- anchor exact substring match
- JSON markdown fence tool call
- JSON shape: ${schema}`
- JSON shape: ${schema}`;
}
export function buildPrompts(content, settings) {
const maxDocChars = Number(settings.maxDocChars) || MAX_DOC_CHARS;
const promptLanguage = PROMPT_LANGUAGES[settings.promptLanguage] ? settings.promptLanguage : DEFAULT_SETTINGS.promptLanguage;
const minCards = Math.max(1, Number(settings.minCards) || DEFAULT_SETTINGS.minCards);
const maxCards = Math.max(minCards, Number(settings.maxCards) || DEFAULT_SETTINGS.maxCards);
const languageInstruction = promptLanguageInstruction(promptLanguage);
const doc = content.length > maxDocChars
? content.slice(0, maxDocChars) + (promptLanguage === 'en' ? '\n\n[Document truncated]' : '\n\n[文档过长,已截断]')
: content;
const schema = '{"cards":[{"title":"...","anchor":"...","gist":"...","bullets":["...","..."]}]}';
const example = promptSchemaExample(promptLanguage);
const templateVars = { minCards, maxCards, languageInstruction, schema, example };
const customSystem = renderPromptTemplate(settings.customSystemPrompt, templateVars).trim();
const contract = systemPromptContract(promptLanguage, minCards, maxCards, languageInstruction, schema);
const defaultSystem = defaultSystemPrompt(promptLanguage, minCards, maxCards, languageInstruction, schema, example);
const system = customSystem
? `${customSystem}
${contract}`
: defaultSystem;
const user = promptLanguage === 'en'

View file

@ -9,6 +9,7 @@ import {
getApiPreset,
modelForApi,
} from './settings';
import { translate } from './i18n';
import {
ANTHROPIC_CARD_TOOL_NAME,
anthropicCardTool,
@ -27,7 +28,7 @@ function endpointUrl(baseUrl, suffixes) {
return base + suffixes[0];
}
function parseApiHeaders(raw) {
function parseApiHeaders(raw, settings?) {
const text = (raw || '').trim();
if (!text) return {};
if (text.startsWith('{')) {
@ -35,10 +36,10 @@ function parseApiHeaders(raw) {
try {
parsed = JSON.parse(text);
} catch (e) {
throw new Error('自定义 headers JSON 解析失败:' + e.message);
throw new Error(translate(settings, 'errorCustomHeadersJsonParse', { error: e.message }));
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('自定义 headers JSON 必须是对象');
throw new Error(translate(settings, 'errorCustomHeadersJsonObject'));
}
const headers = {};
for (const [k, v] of Object.entries(parsed)) {
@ -53,7 +54,7 @@ function parseApiHeaders(raw) {
if (!trimmed || trimmed.startsWith('#')) continue;
const idx = trimmed.indexOf(':');
if (idx <= 0) {
throw new Error('自定义 headers 每行格式应为 `Header-Name: value`');
throw new Error(translate(settings, 'errorCustomHeadersLineFormat'));
}
const key = trimmed.slice(0, idx).trim();
const value = trimmed.slice(idx + 1).trim();
@ -68,8 +69,8 @@ function authHeaders(settings) {
const key = getApiKey(settings);
if (!key) {
const envVar = (settings.apiKeyEnvVar || getApiPreset(settings).envVar || '').trim();
const hint = envVar ? ` 或环境变量 ${envVar}` : '';
throw new Error(`API key 未设置。请在设置里填写 API Key${hint}`);
const hint = envVar ? translate(settings, 'errorApiKeyEnvHint', { envVar }) : '';
throw new Error(translate(settings, 'errorApiKeyMissing', { hint }));
}
if (authType === 'bearer') return { authorization: `Bearer ${key}` };
if (authType === 'x-api-key') return { 'x-api-key': key };
@ -83,20 +84,23 @@ function buildApiHeaders(settings, extra?) {
'content-type': 'application/json',
...authHeaders(settings),
...(extra || {}),
...parseApiHeaders(settings.apiHeaders),
...parseApiHeaders(settings.apiHeaders, settings),
};
}
function responseJson(resp, label) {
function responseJson(resp, label, settings?) {
if (resp.json && typeof resp.json === 'object') return resp.json;
try {
return JSON.parse(resp.text || '{}');
} catch (_) {
throw new Error(`${label} 返回非 JSON\n${(resp.text || '').slice(0, 500)}`);
throw new Error(translate(settings, 'errorProviderNonJson', {
label,
excerpt: (resp.text || '').slice(0, 500),
}));
}
}
async function requestJsonBody(requestUrlImpl, label, url, headers, body) {
async function requestJsonBody(requestUrlImpl, label, url, headers, body, settings?) {
let resp;
try {
resp = await requestUrlImpl({
@ -107,28 +111,35 @@ async function requestJsonBody(requestUrlImpl, label, url, headers, body) {
throw: false,
});
} catch (e) {
throw new Error(`${label} 请求失败:` + (e.message || e));
throw new Error(translate(settings, 'errorProviderRequestFailed', {
label,
error: e.message || e,
}));
}
if (resp.status >= 400) {
throw new Error(`${label} API ${resp.status}: ${(resp.text || '').slice(0, 500)}`);
throw new Error(translate(settings, 'errorProviderApiStatus', {
label,
status: resp.status,
excerpt: (resp.text || '').slice(0, 500),
}));
}
return responseJson(resp, label);
return responseJson(resp, label, settings);
}
function shouldRetryWithoutStructuredOutput(error) {
const message = String(error && error.message ? error.message : error);
if (!/API (400|404|422):/.test(message)) return false;
if (!/(?:API (?:400|404|422):|API returned HTTP (?:400|404|422)|API 返回 HTTP (?:400|404|422))/.test(message)) return false;
return /response_format|json_schema|responseJsonSchema|responseMimeType|tools?|tool_choice|unsupported|unrecognized|unknown|schema/i.test(message);
}
async function requestJsonBodyWithStructuredFallback(requestUrlImpl, label, url, headers, structuredBody, fallbackBody) {
async function requestJsonBodyWithStructuredFallback(requestUrlImpl, label, url, headers, structuredBody, fallbackBody, settings?) {
try {
return await requestJsonBody(requestUrlImpl, label, url, headers, structuredBody);
return await requestJsonBody(requestUrlImpl, label, url, headers, structuredBody, settings);
} catch (e) {
if (!fallbackBody || !shouldRetryWithoutStructuredOutput(e)) throw e;
console.warn(`[parallel-reader] ${label} structured output rejected; retrying without structured output`, e);
return requestJsonBody(requestUrlImpl, label + ' fallback', url, headers, fallbackBody);
return requestJsonBody(requestUrlImpl, label + ' fallback', url, headers, fallbackBody, settings);
}
}
@ -237,11 +248,11 @@ export function buildGeminiBody(system, user, settings, options?) {
};
}
function cardsFromAnthropicToolUse(json) {
function cardsFromAnthropicToolUse(json, settings?) {
const content = Array.isArray(json && json.content) ? json.content : [];
const block = content.find(c => c && c.type === 'tool_use' && c.name === ANTHROPIC_CARD_TOOL_NAME);
if (!block) return null;
if (typeof block.input === 'string') return parseCardsJson(block.input);
if (typeof block.input === 'string') return parseCardsJson(block.input, settings);
if (block.input && typeof block.input === 'object') return normalizeCardsPayload(block.input);
return [];
}
@ -254,14 +265,15 @@ async function summarizeViaAnthropicMessages(requestUrlImpl, system, user, setti
url,
buildApiHeaders(settings, { 'anthropic-version': '2023-06-01' }),
buildAnthropicMessagesBody(system, user, settings),
buildAnthropicMessagesBody(system, user, settings, { structured: false })
buildAnthropicMessagesBody(system, user, settings, { structured: false }),
settings
);
const toolCards = cardsFromAnthropicToolUse(json);
const toolCards = cardsFromAnthropicToolUse(json, settings);
if (toolCards) return toolCards;
const text = (json.content || []).map(c => textFromContent(c)).join('').trim();
return parseCardsJson(text);
return parseCardsJson(text, settings);
}
async function summarizeViaOpenAiChat(requestUrlImpl, system, user, settings) {
@ -272,11 +284,12 @@ async function summarizeViaOpenAiChat(requestUrlImpl, system, user, settings) {
url,
buildApiHeaders(settings),
buildOpenAiChatBody(system, user, settings),
buildOpenAiChatBody(system, user, settings, { structured: false })
buildOpenAiChatBody(system, user, settings, { structured: false }),
settings
);
const choice = (json.choices || [])[0] || {};
const text = textFromContent(choice.message?.content || choice.text || '').trim();
return parseCardsJson(text);
return parseCardsJson(text, settings);
}
async function summarizeViaOpenAiResponses(requestUrlImpl, system, user, settings) {
@ -287,9 +300,10 @@ async function summarizeViaOpenAiResponses(requestUrlImpl, system, user, setting
url,
buildApiHeaders(settings),
buildOpenAiResponsesBody(system, user, settings),
buildOpenAiResponsesBody(system, user, settings, { structured: false })
buildOpenAiResponsesBody(system, user, settings, { structured: false }),
settings
);
return parseCardsJson(textFromOpenAiResponses(json).trim());
return parseCardsJson(textFromOpenAiResponses(json).trim(), settings);
}
async function summarizeViaGoogleGenerativeAi(requestUrlImpl, system, user, settings) {
@ -305,12 +319,13 @@ async function summarizeViaGoogleGenerativeAi(requestUrlImpl, system, user, sett
url,
headers,
buildGeminiBody(system, user, settings),
buildGeminiBody(system, user, settings, { structured: false })
buildGeminiBody(system, user, settings, { structured: false }),
settings
);
const candidate = (json.candidates || [])[0] || {};
const parts = candidate.content?.parts || [];
const text = parts.map(p => textFromContent(p)).join('').trim();
return parseCardsJson(text);
return parseCardsJson(text, settings);
}
export async function summarizeViaApi(requestUrlImpl, system, user, settings) {

View file

@ -1,5 +1,7 @@
'use strict';
import { translate } from './i18n';
export const ANTHROPIC_CARD_TOOL_NAME = 'record_parallel_reader_cards';
export function collectJsonObjectCandidates(raw) {
@ -58,13 +60,15 @@ export function extractJson(text) {
return raw;
}
export function parseCardsJson(text) {
export function parseCardsJson(text, settings?) {
const jsonText = extractJson(text);
let parsed;
try {
parsed = JSON.parse(jsonText);
} catch (e) {
throw new Error('LLM 返回非 JSON\n' + (text || '').slice(0, 500));
throw new Error(translate(settings, 'errorLlmNonJson', {
excerpt: (text || '').slice(0, 500),
}));
}
return normalizeCardsPayload(parsed);
}

40
src/scroll.ts Normal file
View file

@ -0,0 +1,40 @@
'use strict';
export type RafThrottledHandler = (() => void) & { cancel: () => void };
function defaultSchedule(callback: FrameRequestCallback): number {
if (typeof requestAnimationFrame === 'function') return requestAnimationFrame(callback);
return setTimeout(() => callback(Date.now()), 16) as unknown as number;
}
function defaultCancel(frameId: number) {
if (typeof cancelAnimationFrame === 'function') {
cancelAnimationFrame(frameId);
return;
}
clearTimeout(frameId as unknown as ReturnType<typeof setTimeout>);
}
export function createRafThrottledHandler(
callback: () => void,
schedule: (callback: FrameRequestCallback) => number = defaultSchedule,
cancel: (frameId: number) => void = defaultCancel,
): RafThrottledHandler {
let frameId: number | null = null;
const handler = (() => {
if (frameId !== null) return;
frameId = schedule(() => {
frameId = null;
callback();
});
}) as RafThrottledHandler;
handler.cancel = () => {
if (frameId === null) return;
cancel(frameId);
frameId = null;
};
return handler;
}

View file

@ -1,6 +1,7 @@
'use strict';
import crypto from 'crypto';
import { translate } from './i18n';
export const MAX_DOC_CHARS = 20000;
export const PROMPT_VERSION = 2;
@ -254,11 +255,11 @@ export function getApiBaseUrl(settings) {
const explicit = (settings.apiBaseUrl || '').trim();
if (explicit) return explicit.replace(/\/+$/, '');
if ((settings.apiProvider || '').startsWith('custom-')) {
throw new Error('自定义 provider 需要填写 API Base URL。');
throw new Error(translate(settings, 'errorCustomProviderBaseUrlRequired'));
}
const base = (preset.baseUrl || API_FORMATS[format].defaultBaseUrl || '').trim();
if (!base) {
throw new Error('API Base URL 未设置。请在设置里选择 provider 或填写自定义 base URL。');
throw new Error(translate(settings, 'errorApiBaseUrlMissing'));
}
return base.replace(/\/+$/, '');
}
@ -284,7 +285,7 @@ export function getApiKey(settings) {
export function modelForApi(settings) {
const raw = (settings.model || '').trim();
if (!raw) {
throw new Error('Model 未设置。请在设置里填写模型 ID。');
throw new Error(translate(settings, 'errorModelMissing'));
}
const preset = getApiPreset(settings);
const prefixes = [settings.apiProvider, preset.modelPrefix]

27
src/vault.ts Normal file
View file

@ -0,0 +1,27 @@
'use strict';
export function normalizeVaultPath(path) {
return String(path || '')
.split('/')
.map(part => part.trim())
.filter(Boolean)
.join('/');
}
export function folderPathsForTarget(folderPath) {
const normalized = normalizeVaultPath(folderPath);
if (!normalized) return [];
const parts = normalized.split('/');
return parts.map((_, idx) => parts.slice(0, idx + 1).join('/'));
}
export async function ensureVaultFolder(app, folderPath) {
for (const folder of folderPathsForTarget(folderPath)) {
if (app.vault.getAbstractFileByPath(folder)) continue;
try {
await app.vault.createFolder(folder);
} catch (e) {
if (!app.vault.getAbstractFileByPath(folder)) throw e;
}
}
}

View file

@ -310,6 +310,31 @@
to { transform: rotate(360deg); }
}
.parallel-reader-modal-field {
display: flex;
flex-direction: column;
gap: 6px;
margin: 12px 0;
}
.parallel-reader-modal-field label {
color: var(--text-muted);
font-size: 12px;
font-weight: 600;
}
.parallel-reader-modal-field input,
.parallel-reader-modal-field textarea {
width: 100%;
}
.parallel-reader-modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
.parallel-reader-empty {
padding: 20px;
text-align: center;

View file

@ -12,6 +12,7 @@ Module._load = function load(request, parent, isMain) {
class MarkdownView {}
class TFile {}
class Menu {}
class Modal {}
return {
Plugin,
ItemView,
@ -21,6 +22,7 @@ Module._load = function load(request, parent, isMain) {
MarkdownView,
TFile,
Menu,
Modal,
MarkdownRenderer: { render: async () => {} },
requestUrl: async () => ({ status: 200, json: {}, text: '{}' }),
setIcon: () => {},

View file

@ -1,5 +1,7 @@
const assert = require('assert');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const Module = require('module');
const originalLoad = Module._load;
@ -18,6 +20,7 @@ Module._load = function load(request, parent, isMain) {
class MarkdownView {}
class TFile {}
class Menu {}
class Modal {}
return {
Plugin,
ItemView,
@ -27,6 +30,7 @@ Module._load = function load(request, parent, isMain) {
MarkdownView,
TFile,
Menu,
Modal,
MarkdownRenderer: { render: async () => {} },
requestUrl: async () => ({ status: 200, json: {}, text: '{}' }),
setIcon: () => {},
@ -39,15 +43,38 @@ const plugin = require('../main.js');
const t = plugin.__test;
assert.ok(t, 'test helpers should be exported');
const mainSource = fs.readFileSync(path.join(__dirname, '..', 'main.ts'), 'utf8');
assert.ok(!/\basync\s+onOpen\s*\(/.test(mainSource), 'ParallelReaderView.onOpen should not be async without await');
assert.ok(!/\basync\s+onClose\s*\(\)\s*\{\s*\}/.test(mainSource), 'empty onClose should not be async');
assert.ok(/focusSummaryPane\s*\(\)/.test(mainSource), 'summary pane should expose a focus helper');
assert.ok(/\.focus\(\{\s*preventScroll:\s*true\s*\}\)/.test(mainSource), 'summary pane focus should not scroll the page');
assert.ok(/moveActiveSection[\s\S]*focusSummaryPane/.test(mainSource), 'card navigation should focus the summary pane');
assert.ok(/scheduleCacheSave\s*\(/.test(mainSource), 'cache touch should use a debounced cache save path');
assert.ok(/flushCacheSave\s*\(/.test(mainSource), 'pending cache touches should be flushable');
assert.ok(/onunload[\s\S]*flushCacheSave/.test(mainSource), 'plugin unload should flush pending cache touches');
assert.ok(/cacheTouch[\s\S]*scheduleCacheSave/.test(mainSource), 'cacheTouch should schedule a cache save');
assert.ok(!/cacheTouch[\s\S]{0,220}await this\.saveCache/.test(mainSource), 'cacheTouch should not synchronously write cache.json');
assert.strictEqual(typeof t.cardsToMarkdown, 'function');
assert.strictEqual(typeof t.cancellationNoticeKey, 'function');
assert.strictEqual(typeof t.buildPrompts, 'function');
assert.strictEqual(typeof t.buildOpenAiChatBody, 'function');
assert.strictEqual(typeof t.extractJson, 'function');
assert.strictEqual(typeof t.findLineForAnchor, 'function');
assert.strictEqual(typeof t.folderPathsForTarget, 'function');
assert.strictEqual(typeof t.getApiBaseUrl, 'function');
assert.strictEqual(typeof t.generationFingerprint, 'function');
assert.strictEqual(typeof t.GenerationJobManager, 'function');
assert.strictEqual(typeof t.modelForApi, 'function');
assert.strictEqual(typeof t.activeSectionLine, 'function');
assert.strictEqual(typeof t.touchCacheEntry, 'function');
assert.strictEqual(typeof t.nextCardIndex, 'function');
assert.strictEqual(typeof t.pruneCacheEntries, 'function');
assert.strictEqual(typeof t.removeCardAt, 'function');
assert.strictEqual(typeof t.activeIndexAfterCardDelete, 'function');
assert.strictEqual(typeof t.createRafThrottledHandler, 'function');
assert.strictEqual(typeof t.serializeCacheFile, 'function');
assert.strictEqual(typeof t.translate, 'function');
assert.strictEqual(typeof t.updateCardAt, 'function');
const baseSettings = {
backend: 'api',
@ -90,6 +117,36 @@ assert.strictEqual(
t.generationFingerprint({ ...baseSettings, uiLanguage: 'en' }),
'cache fingerprint should not change when UI language changes'
);
assert.strictEqual(
t.cancellationNoticeKey({ backend: 'api' }, { phase: 'generating' }),
'cancelRequestedApiInFlight',
'API cancellation should explain that the in-flight network request cannot be aborted'
);
assert.strictEqual(
t.cancellationNoticeKey({ backend: 'claude-code' }, { phase: 'generating' }),
'cancelRequested',
'CLI cancellation can use the generic cancellation notice'
);
assert.strictEqual(
t.cancellationNoticeKey({ backend: 'api' }, { phase: 'reading' }),
'cancelRequested',
'API cancellation outside the request phase can use the generic notice'
);
assert.throws(
() => t.modelForApi({ ...baseSettings, model: '', uiLanguage: 'en' }),
/Model is not set/,
'settings errors should respect English UI mode'
);
assert.throws(
() => t.getApiBaseUrl({
...baseSettings,
apiProvider: 'custom-openai-compatible',
apiBaseUrl: '',
uiLanguage: 'en',
}),
/Custom provider requires an API Base URL/,
'custom provider base URL errors should respect English UI mode'
);
const contentHash = crypto.createHash('sha1').update('hello', 'utf8').digest('hex');
assert.strictEqual(
@ -145,6 +202,8 @@ const englishPrompt = t.buildPrompts('Hello world', {
});
assert.ok(englishPrompt.system.includes('2-4'));
assert.ok(englishPrompt.system.includes('Write title, gist, and bullets in English.'));
assert.ok(englishPrompt.system.includes('You are a long-form reading summary assistant.'));
assert.ok(!englishPrompt.system.includes('你是一个长文阅读摘要助手'));
assert.ok(englishPrompt.user.startsWith('Source document:'));
const customPrompt = t.buildPrompts('Hello world', {
@ -161,6 +220,61 @@ assert.ok(customPrompt.system.includes('JSON shape'));
assert.strictEqual(t.translate({ uiLanguage: 'en' }, 'cmdOpenView'), 'Open Parallel Reader pane');
assert.strictEqual(t.translate({ uiLanguage: 'zh' }, 'cmdOpenView'), '打开对照笔记面板');
assert.strictEqual(t.translate({ uiLanguage: 'en' }, 'cacheClearedAll', { count: 3 }), 'Cleared 3 cache entries');
assert.strictEqual(t.translate({ uiLanguage: 'en' }, 'cmdCardNext'), 'Focus next summary card');
assert.strictEqual(t.nextCardIndex(-1, 3, 1), 0);
assert.strictEqual(t.nextCardIndex(-1, 3, -1), 2);
assert.strictEqual(t.nextCardIndex(0, 3, 1), 1);
assert.strictEqual(t.nextCardIndex(2, 3, 1), 2);
assert.strictEqual(t.nextCardIndex(0, 3, -1), 0);
assert.strictEqual(t.nextCardIndex(0, 0, 1), -1);
assert.strictEqual(t.activeSectionLine([{ startLine: 3 }], 0), 3);
assert.strictEqual(t.activeSectionLine([{ startLine: -1 }], 0), -1);
assert.strictEqual(t.activeSectionLine([{ startLine: 3 }], 1), -1);
const scheduledFrames = [];
let throttledCalls = 0;
const throttled = t.createRafThrottledHandler(() => { throttledCalls += 1; }, cb => {
scheduledFrames.push(cb);
return scheduledFrames.length;
});
throttled();
throttled();
throttled();
assert.strictEqual(scheduledFrames.length, 1, 'scroll handler should be scheduled at most once per frame');
assert.strictEqual(throttledCalls, 0, 'throttled handler should not run synchronously');
scheduledFrames.shift()(0);
assert.strictEqual(throttledCalls, 1);
throttled();
assert.strictEqual(scheduledFrames.length, 1, 'scroll handler should be schedulable after the frame runs');
const cardList = [{ title: 'A' }, { title: 'B' }, { title: 'C' }];
assert.deepStrictEqual(t.removeCardAt(cardList, 1), [{ title: 'A' }, { title: 'C' }]);
assert.deepStrictEqual(t.removeCardAt(cardList, -1), cardList);
assert.deepStrictEqual(t.removeCardAt(cardList, 3), cardList);
assert.notStrictEqual(t.removeCardAt(cardList, 3), cardList);
assert.strictEqual(t.activeIndexAfterCardDelete(1, 3, 1), 1, 'deleting active card should select the next card');
assert.strictEqual(t.activeIndexAfterCardDelete(2, 3, 2), 1, 'deleting the last active card should select the previous card');
assert.strictEqual(t.activeIndexAfterCardDelete(1, 3, 2), 1, 'deleting before active card should shift active index left');
assert.strictEqual(t.activeIndexAfterCardDelete(2, 3, 0), 0, 'deleting after active card should keep active index');
assert.deepStrictEqual(
t.updateCardAt(cardList, 1, { title: 'B2', gist: 'G', bullets: ['x'] }),
[{ title: 'A' }, { title: 'B2', gist: 'G', bullets: ['x'] }, { title: 'C' }]
);
assert.deepStrictEqual(t.updateCardAt(cardList, -1, { title: 'X' }), cardList);
assert.deepStrictEqual(t.updateCardAt(cardList, 3, { title: 'X' }), cardList);
assert.notStrictEqual(t.updateCardAt(cardList, 3, { title: 'X' }), cardList);
assert.deepStrictEqual(t.folderPathsForTarget('Reading/Articles'), ['Reading', 'Reading/Articles']);
assert.deepStrictEqual(t.folderPathsForTarget('/Reading//Articles/'), ['Reading', 'Reading/Articles']);
assert.deepStrictEqual(t.folderPathsForTarget('Reading'), ['Reading']);
assert.deepStrictEqual(t.folderPathsForTarget(''), []);
const untouchedCacheEntry = { generatedAt: '2024-01-01T00:00:00.000Z' };
assert.strictEqual(t.touchCacheEntry(null), null);
assert.strictEqual(t.touchCacheEntry(untouchedCacheEntry, '2024-01-05T00:00:00.000Z'), untouchedCacheEntry);
assert.strictEqual(untouchedCacheEntry.lastAccessedAt, '2024-01-05T00:00:00.000Z');
const serializedCache = t.serializeCacheFile({
'note.md': { generatedAt: '2024-01-01T00:00:00.000Z', cards: [{ title: 'A' }] },
});
assert.strictEqual(serializedCache.includes('\n'), false, 'cache.json should be compact');
assert.deepStrictEqual(JSON.parse(serializedCache).entries['note.md'].cards, [{ title: 'A' }]);
const noisyJson = '说明文字 {"cards":[{"title":"A","anchor":"保留 { 花括号 } 字符","gist":"G","bullets":["B"]}]} trailing';
const extracted = t.extractJson(noisyJson);
@ -248,6 +362,90 @@ async function testOpenAiStructuredFallback() {
assert.deepStrictEqual(cards, [{ title: 'T', anchor: 'A', gist: 'G', bullets: ['B'] }]);
}
async function testProviderMissingApiKeyUsesEnglishUi() {
await assert.rejects(
() => t.summarizeViaApi(async () => {
throw new Error('request should not be sent');
}, 'system JSON', 'user', {
...baseSettings,
apiKey: '',
apiKeyEnvVar: '',
uiLanguage: 'en',
}),
/API key is not set/
);
}
async function testProviderHeaderJsonErrorUsesEnglishUi() {
await assert.rejects(
() => t.summarizeViaApi(async () => {
throw new Error('request should not be sent');
}, 'system JSON', 'user', {
...baseSettings,
apiHeaders: '{"x":',
uiLanguage: 'en',
}),
/Custom headers JSON parse failed/
);
}
async function testProviderResponseNonJsonUsesEnglishUi() {
await assert.rejects(
() => t.summarizeViaApi(async () => ({
status: 200,
text: '<html>not json</html>',
}), 'system JSON', 'user', {
...baseSettings,
uiLanguage: 'en',
}),
/OpenAI-compatible Chat returned non-JSON/
);
}
async function testProviderRequestFailureUsesEnglishUi() {
await assert.rejects(
() => t.summarizeViaApi(async () => {
throw new Error('network down');
}, 'system JSON', 'user', {
...baseSettings,
uiLanguage: 'en',
}),
/OpenAI-compatible Chat request failed: network down/
);
}
async function testProviderApiStatusErrorUsesEnglishUi() {
await assert.rejects(
() => t.summarizeViaApi(async () => ({
status: 500,
text: 'upstream exploded',
}), 'system JSON', 'user', {
...baseSettings,
uiLanguage: 'en',
}),
/OpenAI-compatible Chat API returned HTTP 500: upstream exploded/
);
}
async function testSchemaNonJsonErrorUsesEnglishUi() {
await assert.rejects(
() => t.summarizeViaApi(async () => ({
status: 200,
json: {
choices: [{
message: {
content: 'not json',
},
}],
},
}), 'system JSON', 'user', {
...baseSettings,
uiLanguage: 'en',
}),
/LLM returned non-JSON/
);
}
async function testAnthropicToolUseParsing() {
const requestUrlImpl = async req => {
const body = JSON.parse(req.body);
@ -280,6 +478,12 @@ async function testAnthropicToolUseParsing() {
(async () => {
await testOpenAiStructuredFallback();
await testProviderMissingApiKeyUsesEnglishUi();
await testProviderHeaderJsonErrorUsesEnglishUi();
await testProviderResponseNonJsonUsesEnglishUi();
await testProviderRequestFailureUsesEnglishUi();
await testProviderApiStatusErrorUsesEnglishUi();
await testSchemaNonJsonErrorUsesEnglishUi();
await testAnthropicToolUseParsing();
console.log('tests passed');
})().catch(e => {