mirror of
https://github.com/fancive/obsidian-parallel-reader.git
synced 2026-07-22 06:53:43 +00:00
1778 lines
58 KiB
TypeScript
1778 lines
58 KiB
TypeScript
'use strict';
|
|
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,
|
|
buildOpenAiChatBody,
|
|
buildOpenAiResponsesBody,
|
|
summarizeViaApi,
|
|
testApiBackend,
|
|
tokenLimitFieldForOpenAiChat,
|
|
} from './src/providers';
|
|
import {
|
|
GenerationJobAlreadyRunningError,
|
|
GenerationJobCancelledError,
|
|
GenerationJobManager,
|
|
classifyGenerationError,
|
|
} from './src/generation-job-manager';
|
|
import {
|
|
API_AUTH_TYPES,
|
|
API_FORMATS,
|
|
API_PROVIDER_PRESETS,
|
|
CACHE_SCHEMA_VERSION,
|
|
DEFAULT_SETTINGS,
|
|
DEFAULT_MAX_CACHE_ENTRIES,
|
|
PROMPT_LANGUAGES,
|
|
UI_LANGUAGES,
|
|
applyApiProviderPreset,
|
|
cacheEntryMatches,
|
|
generationFingerprint,
|
|
getApiBaseUrl,
|
|
getApiFormat,
|
|
getApiPreset,
|
|
hashContent,
|
|
isApiBackend,
|
|
modelForApi,
|
|
normalizeSettings,
|
|
pruneCacheEntries,
|
|
} from './src/settings';
|
|
|
|
const VIEW_TYPE_PARALLEL = 'parallel-reader-view';
|
|
|
|
/* 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, job?) {
|
|
return new Promise<{ stdout: string; stderr: string }>((resolve, reject) => {
|
|
let child;
|
|
let settled = false;
|
|
let timer;
|
|
const fail = err => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (timer) clearTimeout(timer);
|
|
reject(err);
|
|
};
|
|
const succeed = value => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (timer) clearTimeout(timer);
|
|
resolve(value);
|
|
};
|
|
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(`Failed to start ${cmd}: ${e.message}`));
|
|
}
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
timer = setTimeout(() => {
|
|
try { child.kill('SIGKILL'); } catch (_) {}
|
|
fail(new Error(`CLI timed out (${timeoutMs}ms)`));
|
|
}, timeoutMs);
|
|
if (job) {
|
|
job.onCancel(() => {
|
|
try { child.kill('SIGKILL'); } catch (_) {}
|
|
fail(new GenerationJobCancelledError(job.key));
|
|
});
|
|
}
|
|
|
|
child.stdout.on('data', d => { stdout += d.toString('utf8'); });
|
|
child.stderr.on('data', d => { stderr += d.toString('utf8'); });
|
|
child.on('error', e => {
|
|
fail(new Error(`CLI startup error: ${e.message}. Try setting an absolute CLI path.`));
|
|
});
|
|
child.on('close', code => {
|
|
if (settled) return;
|
|
if (code !== 0) {
|
|
return fail(new Error(`CLI exited with code ${code}\nstderr:\n${stderr.slice(0, 1000)}`));
|
|
}
|
|
succeed({ 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, job) {
|
|
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, job);
|
|
|
|
// claude -p --output-format json returns {"type":"result", ..., "result":"..."}
|
|
let envelope;
|
|
try { envelope = JSON.parse(stdout); } catch (e) {
|
|
throw new Error('claude CLI returned a non-JSON envelope:\n' + stdout.slice(0, 500));
|
|
}
|
|
const resultText = envelope.result || envelope.content || '';
|
|
return parseCardsJson(resultText, settings);
|
|
}
|
|
|
|
async function summarizeViaCodex(system, user, settings, job) {
|
|
const cmd = resolveCliPath('codex', settings.cliPath);
|
|
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, settings);
|
|
}
|
|
|
|
async function testBackend(settings) {
|
|
if (settings.backend === 'codex') {
|
|
const cmd = resolveCliPath('codex', settings.cliPath);
|
|
const { stdout } = await runCli(cmd, ['--version'], '', 10000);
|
|
return `codex @ ${cmd}\n${stdout.trim()}`;
|
|
}
|
|
if (settings.backend === 'claude-code') {
|
|
const cmd = resolveCliPath('claude', settings.cliPath);
|
|
const { stdout } = await runCli(cmd, ['--version'], '', 10000);
|
|
return `claude @ ${cmd}\n${stdout.trim()}`;
|
|
}
|
|
if (isApiBackend(settings.backend)) {
|
|
return testApiBackend(requestUrl, settings);
|
|
}
|
|
throw new Error('Unknown backend: ' + settings.backend);
|
|
}
|
|
|
|
async function summarizeDocument(content, settings, job) {
|
|
const { system, user } = buildPrompts(content, settings);
|
|
let cards;
|
|
switch (settings.backend) {
|
|
case 'codex':
|
|
cards = await summarizeViaCodex(system, user, settings, job);
|
|
break;
|
|
case 'api':
|
|
cards = await summarizeViaApi(requestUrl, system, user, settings);
|
|
break;
|
|
case 'anthropic-api':
|
|
cards = await summarizeViaApi(requestUrl, system, user, settings);
|
|
break;
|
|
case 'claude-code':
|
|
default:
|
|
cards = await summarizeViaClaudeCode(system, user, settings, job);
|
|
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;
|
|
}
|
|
|
|
function addIconButton(parent, icon, title, onClick) {
|
|
const button = parent.createEl('button', {
|
|
cls: 'parallel-reader-icon-button',
|
|
attr: { type: 'button', 'aria-label': title },
|
|
});
|
|
button.title = title;
|
|
if (typeof setIcon === 'function') {
|
|
setIcon(button, icon);
|
|
} else {
|
|
button.textContent = title;
|
|
}
|
|
button.addEventListener('click', async (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
try {
|
|
await onClick();
|
|
} catch (err) {
|
|
console.error(err);
|
|
new Notice(`${title} failed: ` + (err.message || err));
|
|
}
|
|
});
|
|
return button;
|
|
}
|
|
|
|
function addTextButton(parent, icon, label, onClick, cls) {
|
|
const button = parent.createEl('button', {
|
|
cls: cls || 'parallel-reader-text-button',
|
|
attr: { type: 'button' },
|
|
});
|
|
if (icon && typeof setIcon === 'function') setIcon(button, icon);
|
|
button.createSpan({ text: label });
|
|
button.addEventListener('click', async (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
try {
|
|
await onClick();
|
|
} catch (err) {
|
|
console.error(err);
|
|
new Notice(`${label} failed: ` + (err.message || err));
|
|
}
|
|
});
|
|
return button;
|
|
}
|
|
|
|
async function copyToClipboard(text, successMsg) {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
new Notice(successMsg);
|
|
} catch (e) {
|
|
new Notice('Copy failed: ' + (e.message || e));
|
|
}
|
|
}
|
|
|
|
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 {
|
|
plugin: ParallelReaderPlugin;
|
|
sections: any[];
|
|
sourceFile: TFile | null;
|
|
cards: HTMLElement[];
|
|
activeIdx: number;
|
|
stale: boolean;
|
|
loadingMessage: string;
|
|
errorMessage: string;
|
|
|
|
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;
|
|
this.loadingMessage = '';
|
|
this.errorMessage = '';
|
|
}
|
|
|
|
getViewType() { return VIEW_TYPE_PARALLEL; }
|
|
getDisplayText() { return this.plugin.t('displayName'); }
|
|
getIcon() { return 'book-open'; }
|
|
|
|
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();
|
|
}
|
|
|
|
onClose() {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
renderEmpty() {
|
|
this.sourceFile = null;
|
|
this.sections = [];
|
|
this.stale = false;
|
|
this.loadingMessage = '';
|
|
this.errorMessage = '';
|
|
const container = this.containerEl.children[1];
|
|
container.empty();
|
|
const hint = container.createDiv({ cls: 'parallel-reader-empty' });
|
|
hint.createEl('h3', { text: this.plugin.t('appTitle') });
|
|
hint.createEl('p', { text: this.plugin.t('emptyOpenNote') });
|
|
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;
|
|
this.stale = !!stale;
|
|
this.loadingMessage = '';
|
|
this.errorMessage = '';
|
|
this.render();
|
|
}
|
|
|
|
async renderLoading(file, message) {
|
|
this.sourceFile = file;
|
|
this.sections = [];
|
|
this.stale = false;
|
|
this.loadingMessage = message || this.plugin.t('loadingDefault');
|
|
this.errorMessage = '';
|
|
this.render();
|
|
}
|
|
|
|
async renderError(file, message) {
|
|
this.sourceFile = file;
|
|
this.sections = [];
|
|
this.stale = false;
|
|
this.loadingMessage = '';
|
|
this.errorMessage = message || this.plugin.t('errorTitle');
|
|
this.render();
|
|
}
|
|
|
|
renderEmptyWithHint(file) {
|
|
this.sourceFile = file;
|
|
this.sections = [];
|
|
this.stale = false;
|
|
this.loadingMessage = '';
|
|
this.errorMessage = '';
|
|
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: this.plugin.t('emptyNoCache') });
|
|
hint.createEl('code', { text: this.plugin.t('commandGenerate') });
|
|
}
|
|
|
|
render() {
|
|
const container = this.containerEl.children[1];
|
|
container.empty();
|
|
|
|
const header = container.createDiv({ cls: 'parallel-reader-header' });
|
|
const headerRow = header.createDiv({ cls: 'parallel-reader-header-row' });
|
|
headerRow.createEl('div', { text: this.sourceFile?.basename || '', cls: 'parallel-reader-title' });
|
|
const actions = headerRow.createDiv({ cls: 'parallel-reader-actions' });
|
|
if (this.sourceFile) {
|
|
if (this.plugin.isGeneratingFile(this.sourceFile)) {
|
|
addIconButton(actions, 'square', this.plugin.t('actionCancel'), () => this.plugin.cancelGenerationForFile(this.sourceFile));
|
|
} else {
|
|
addIconButton(actions, 'refresh-cw', this.plugin.t('actionRegenerate'), () => this.plugin.runForFile(this.sourceFile, true));
|
|
}
|
|
addIconButton(actions, 'copy', this.plugin.t('actionCopyAll'), () => this.plugin.copyCurrentViewMarkdown());
|
|
addIconButton(actions, 'download', this.plugin.t('actionExport'), () => this.exportToVault());
|
|
}
|
|
|
|
if (this.stale) {
|
|
const banner = container.createDiv({ cls: 'parallel-reader-stale-banner' });
|
|
banner.createSpan({ text: this.plugin.t('staleBanner') });
|
|
addTextButton(
|
|
banner,
|
|
'refresh-cw',
|
|
this.plugin.t('actionRegenerate'),
|
|
() => this.plugin.runForFile(this.sourceFile, true),
|
|
'parallel-reader-stale-button'
|
|
);
|
|
}
|
|
|
|
if (this.loadingMessage) {
|
|
const state = container.createDiv({ cls: 'parallel-reader-state parallel-reader-loading' });
|
|
state.createDiv({ cls: 'parallel-reader-spinner' });
|
|
state.createEl('div', { text: this.loadingMessage, cls: 'parallel-reader-state-title' });
|
|
state.createEl('div', { text: this.plugin.t('loadingSubtitle'), cls: 'parallel-reader-state-subtitle' });
|
|
return;
|
|
}
|
|
|
|
if (this.errorMessage) {
|
|
const state = container.createDiv({ cls: 'parallel-reader-state parallel-reader-error' });
|
|
state.createEl('div', { text: this.plugin.t('errorTitle'), cls: 'parallel-reader-state-title' });
|
|
state.createEl('div', { text: this.errorMessage, cls: 'parallel-reader-state-subtitle' });
|
|
addTextButton(
|
|
state,
|
|
'refresh-cw',
|
|
this.plugin.t('actionRegenerate'),
|
|
() => this.plugin.runForFile(this.sourceFile, true),
|
|
'parallel-reader-text-button'
|
|
);
|
|
return;
|
|
}
|
|
|
|
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: this.plugin.t('anchorMismatch') });
|
|
}
|
|
|
|
// --- 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: this.plugin.t('emptyCard') });
|
|
}
|
|
|
|
// 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 as HTMLElement | null;
|
|
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(this.plugin.t('menuCopyMarkdown')).setIcon('copy')
|
|
.onClick(() => copyToClipboard(cardToMarkdown(s), this.plugin.t('copiedMarkdown'))));
|
|
menu.addItem(it => it.setTitle(this.plugin.t('menuCopyPlain')).setIcon('clipboard-copy')
|
|
.onClick(() => copyToClipboard(cardToPlain(s), this.plugin.t('copiedPlain'))));
|
|
if (s.anchor) {
|
|
menu.addItem(it => it.setTitle(this.plugin.t('menuCopyAnchor')).setIcon('quote-glyph')
|
|
.onClick(() => copyToClipboard(s.anchor, this.plugin.t('copiedAnchor'))));
|
|
}
|
|
menu.addSeparator();
|
|
if (s.startLine >= 0) {
|
|
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) {
|
|
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' });
|
|
}
|
|
}
|
|
|
|
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 = normalizeVaultPath(this.plugin.settings.exportFolder);
|
|
const name = `${this.sourceFile.basename} - ${this.plugin.t('displayName')}.md`;
|
|
const targetPath = `${folder}/${name}`;
|
|
|
|
const markdown = [
|
|
'---',
|
|
`source: [[${this.sourceFile.basename}]]`,
|
|
`generated: ${new Date().toISOString().slice(0, 10)}`,
|
|
'tool: parallel-reader',
|
|
'---',
|
|
'',
|
|
cardsToMarkdown(`${this.sourceFile.basename} · ${this.plugin.t('displayName')}`, this.sections),
|
|
'',
|
|
].join('\n');
|
|
|
|
const app = this.plugin.app;
|
|
await ensureVaultFolder(app, folder);
|
|
|
|
const existing = app.vault.getAbstractFileByPath(targetPath);
|
|
if (existing instanceof TFile) {
|
|
await app.vault.modify(existing, markdown);
|
|
} else {
|
|
await app.vault.create(targetPath, markdown);
|
|
}
|
|
new Notice(this.plugin.t('exported', { path: targetPath }));
|
|
}
|
|
}
|
|
|
|
/* ---------- Plugin ---------- */
|
|
|
|
class ParallelReaderPlugin extends Plugin {
|
|
settings: any;
|
|
cache: Record<string, any>;
|
|
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);
|
|
}
|
|
|
|
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();
|
|
await this.ensureView();
|
|
if (active?.file) await this.syncViewToFile(active.file);
|
|
});
|
|
|
|
this.registerView(VIEW_TYPE_PARALLEL, (leaf) => new ParallelReaderView(leaf, this));
|
|
|
|
this.addCommand({
|
|
id: 'parallel-reader-run',
|
|
name: this.t('cmdRun'),
|
|
callback: () => this.runForActiveFile(false),
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'parallel-reader-regen',
|
|
name: this.t('cmdRegen'),
|
|
callback: () => this.runForActiveFile(true),
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'parallel-reader-open-view',
|
|
name: this.t('cmdOpenView'),
|
|
callback: async () => {
|
|
const active = this.getActiveView();
|
|
await this.ensureView();
|
|
if (active?.file) this.syncViewToFile(active.file);
|
|
},
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'parallel-reader-export-current',
|
|
name: this.t('cmdExport'),
|
|
callback: () => this.exportCurrentView(),
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'parallel-reader-copy-current-markdown',
|
|
name: this.t('cmdCopyMarkdown'),
|
|
callback: () => this.copyCurrentViewMarkdown(),
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'parallel-reader-cancel-current',
|
|
name: this.t('cmdCancel'),
|
|
callback: () => this.cancelActiveGeneration(),
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'parallel-reader-clear-current',
|
|
name: this.t('cmdClearCurrent'),
|
|
callback: async () => {
|
|
const active = this.getActiveView();
|
|
if (!active?.file) return new Notice(this.t('noCurrentNote'));
|
|
await this.cacheDelete(active.file.path);
|
|
new Notice(this.t('cacheClearedFile', { name: active.file.basename }));
|
|
},
|
|
});
|
|
|
|
this.addCommand({
|
|
id: 'parallel-reader-clear-all',
|
|
name: this.t('cmdClearAll'),
|
|
callback: async () => {
|
|
const n = Object.keys(this.cache).length;
|
|
await this.cacheClear();
|
|
new Notice(this.t('cacheClearedAll', { count: n }));
|
|
},
|
|
});
|
|
|
|
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(
|
|
this.app.workspace.on('active-leaf-change', () => this.bindScrollSync())
|
|
);
|
|
this.registerEvent(
|
|
this.app.workspace.on('file-open', (file) => {
|
|
if (file) this.syncViewToFile(file);
|
|
})
|
|
);
|
|
this.registerEvent(
|
|
this.app.workspace.on('file-menu', (menu, file) => this.addFileMenuItems(menu, file))
|
|
);
|
|
this.registerEvent(
|
|
this.app.vault.on('rename', (file, oldPath) => this.handleFileRename(file, oldPath))
|
|
);
|
|
this.registerEvent(
|
|
this.app.vault.on('delete', (file) => this.handleFileDelete(file))
|
|
);
|
|
this.bindScrollSync();
|
|
}
|
|
|
|
async onunload() {
|
|
await this.flushSettingsSave();
|
|
await this.flushCacheSave();
|
|
this.app.workspace.detachLeavesOfType(VIEW_TYPE_PARALLEL);
|
|
}
|
|
|
|
async loadSettings() {
|
|
const data = (await this.loadData()) || {};
|
|
const settingsBlob = data.settings || {};
|
|
this.settings = normalizeSettings(Object.assign({}, DEFAULT_SETTINGS, settingsBlob));
|
|
await this.loadCache();
|
|
}
|
|
|
|
async saveSettings() {
|
|
if (this._settingsSaveTimer) {
|
|
clearTimeout(this._settingsSaveTimer);
|
|
this._settingsSaveTimer = null;
|
|
}
|
|
await this.saveData({ settings: this.settings });
|
|
}
|
|
|
|
saveSettingsDebounced(delayMs = 400) {
|
|
if (this._settingsSaveTimer) clearTimeout(this._settingsSaveTimer);
|
|
this._settingsSaveTimer = setTimeout(() => {
|
|
this._settingsSaveTimer = null;
|
|
this.saveSettings().catch(e => console.error('[parallel-reader] failed to save settings', e));
|
|
}, delayMs);
|
|
}
|
|
|
|
async flushSettingsSave() {
|
|
if (!this._settingsSaveTimer) return;
|
|
clearTimeout(this._settingsSaveTimer);
|
|
this._settingsSaveTimer = null;
|
|
await this.saveSettings();
|
|
}
|
|
|
|
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() {
|
|
const configDir = this.app.vault.configDir || '.obsidian';
|
|
const pluginId = this.manifest?.id || 'parallel-reader';
|
|
return `${configDir}/plugins/${pluginId}/cache.json`;
|
|
}
|
|
|
|
async ensurePluginDataDir() {
|
|
const adapter = this.app.vault.adapter;
|
|
const configDir = this.app.vault.configDir || '.obsidian';
|
|
const pluginId = this.manifest?.id || 'parallel-reader';
|
|
const dir = `${configDir}/plugins/${pluginId}`;
|
|
try {
|
|
if (typeof adapter.exists === 'function' && await adapter.exists(dir)) return;
|
|
await adapter.mkdir(dir);
|
|
} catch (_) {
|
|
// The plugin directory normally already exists; ignore create races.
|
|
}
|
|
}
|
|
|
|
async readCacheFile() {
|
|
const adapter = this.app.vault.adapter;
|
|
try {
|
|
const raw = await adapter.read(this.cacheFilePath());
|
|
const parsed = JSON.parse(raw);
|
|
if (parsed && typeof parsed === 'object' && parsed.entries && typeof parsed.entries === 'object') {
|
|
return parsed.entries;
|
|
}
|
|
} catch (e) {
|
|
const message = String(e?.message || e || '');
|
|
if (!/not found|does not exist|ENOENT/i.test(message)) {
|
|
console.warn('[parallel-reader] failed to read cache.json', e);
|
|
}
|
|
}
|
|
return {};
|
|
}
|
|
|
|
async writeCacheFile() {
|
|
await this.ensurePluginDataDir();
|
|
await this.app.vault.adapter.write(
|
|
this.cacheFilePath(),
|
|
serializeCacheFile(this.cache)
|
|
);
|
|
}
|
|
|
|
async loadCache() {
|
|
const fileCache = await this.readCacheFile();
|
|
this.cache = fileCache;
|
|
const pruned = this.pruneCache();
|
|
if (pruned.length > 0) await this.writeCacheFile();
|
|
}
|
|
|
|
pruneCache() {
|
|
return pruneCacheEntries(this.cache, this.settings?.maxCacheEntries || DEFAULT_MAX_CACHE_ENTRIES);
|
|
}
|
|
|
|
async pruneCacheIfNeeded() {
|
|
const removed = this.pruneCache();
|
|
if (removed.length > 0) await this.saveCache();
|
|
return removed;
|
|
}
|
|
|
|
cacheGet(filePath) {
|
|
return this.cache[filePath] || null;
|
|
}
|
|
|
|
async cacheTouch(filePath) {
|
|
const entry = touchCacheEntry(this.cache[filePath] || null);
|
|
if (!entry) return null;
|
|
this.scheduleCacheSave();
|
|
return entry;
|
|
}
|
|
|
|
async cachePut(filePath, content, cards, settings) {
|
|
const now = new Date().toISOString();
|
|
this.cache[filePath] = {
|
|
schemaVersion: CACHE_SCHEMA_VERSION,
|
|
contentHash: hashContent(content),
|
|
settingsHash: generationFingerprint(settings || this.settings),
|
|
cards,
|
|
generatedAt: now,
|
|
lastAccessedAt: now,
|
|
};
|
|
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];
|
|
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 as ParallelReaderView;
|
|
}
|
|
|
|
getActiveView() {
|
|
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);
|
|
}
|
|
|
|
cancelGenerationForFile(file) {
|
|
if (!file || !file.path) {
|
|
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(noticeKey) : this.t('noCancelableJob'));
|
|
return cancelled;
|
|
}
|
|
|
|
viewIsShowingFile(view, file) {
|
|
return !!view && !!file && view.sourceFile?.path === file.path;
|
|
}
|
|
|
|
activeFileStillMatches(file) {
|
|
const active = this.getActiveView();
|
|
return !active?.file || active.file.path === file.path;
|
|
}
|
|
|
|
cancelActiveGeneration() {
|
|
const active = this.getActiveView();
|
|
if (active?.file && this.cancelGenerationForFile(active.file)) return;
|
|
const view = this.app.workspace.getLeavesOfType(VIEW_TYPE_PARALLEL)[0]?.view as ParallelReaderView | undefined;
|
|
if (view?.sourceFile) this.cancelGenerationForFile(view.sourceFile);
|
|
else new Notice(this.t('noCancelableJob'));
|
|
}
|
|
|
|
addFileMenuItems(menu, file) {
|
|
if (!(file instanceof TFile) || !file.path.endsWith('.md')) return;
|
|
menu.addSeparator();
|
|
menu.addItem(it => it
|
|
.setTitle(this.t('fileMenuGenerate'))
|
|
.setIcon('book-open')
|
|
.onClick(() => this.runForFile(file, false)));
|
|
menu.addItem(it => it
|
|
.setTitle(this.t('fileMenuRegen'))
|
|
.setIcon('refresh-cw')
|
|
.onClick(() => this.runForFile(file, true)));
|
|
if (this.cacheGet(file.path)) {
|
|
menu.addItem(it => it
|
|
.setTitle(this.t('fileMenuClear'))
|
|
.setIcon('trash')
|
|
.onClick(async () => {
|
|
await this.cacheDelete(file.path);
|
|
new Notice(this.t('cacheClearedFile', { name: file.basename }));
|
|
}));
|
|
}
|
|
}
|
|
|
|
async handleFileRename(file, oldPath) {
|
|
if (!(file instanceof TFile) || !oldPath) return;
|
|
const wasMarkdown = oldPath.endsWith('.md');
|
|
const isMarkdown = file.path.endsWith('.md');
|
|
if (!wasMarkdown && !isMarkdown) return;
|
|
if (wasMarkdown && !isMarkdown) {
|
|
if (this.cache[oldPath]) {
|
|
delete this.cache[oldPath];
|
|
await this.saveCache();
|
|
}
|
|
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_PARALLEL);
|
|
const view = leaves[0]?.view as ParallelReaderView | undefined;
|
|
if (view?.sourceFile?.path === oldPath) view.renderEmpty();
|
|
return;
|
|
}
|
|
if (!wasMarkdown) return;
|
|
if (this.cache[oldPath]) {
|
|
this.cache[file.path] = this.cache[oldPath];
|
|
delete this.cache[oldPath];
|
|
await this.saveCache();
|
|
}
|
|
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_PARALLEL);
|
|
const view = leaves[0]?.view as ParallelReaderView | undefined;
|
|
if (view?.sourceFile && (view.sourceFile.path === oldPath || view.sourceFile.path === file.path)) {
|
|
view.sourceFile = file;
|
|
await this.syncViewToFile(file);
|
|
}
|
|
}
|
|
|
|
async handleFileDelete(file) {
|
|
if (!(file instanceof TFile)) return;
|
|
const hadCache = !!this.cache[file.path];
|
|
if (hadCache) {
|
|
delete this.cache[file.path];
|
|
await this.saveCache();
|
|
}
|
|
const leaves = this.app.workspace.getLeavesOfType(VIEW_TYPE_PARALLEL);
|
|
const view = leaves[0]?.view as ParallelReaderView | undefined;
|
|
if (view?.sourceFile?.path === file.path) view.renderEmpty();
|
|
}
|
|
|
|
async exportCurrentView() {
|
|
const active = this.getActiveView();
|
|
const view = await this.ensureView();
|
|
if (!view.sourceFile || !view.sections.length) {
|
|
if (active?.file) await this.syncViewToFile(active.file);
|
|
}
|
|
if (!view.sourceFile || !view.sections.length) {
|
|
new Notice(this.t('noExportContent'));
|
|
return;
|
|
}
|
|
await view.exportToVault();
|
|
}
|
|
|
|
async copyCurrentViewMarkdown() {
|
|
const active = this.getActiveView();
|
|
const view = await this.ensureView();
|
|
if (!view.sourceFile || !view.sections.length) {
|
|
if (active?.file) await this.syncViewToFile(active.file);
|
|
}
|
|
if (!view.sourceFile || !view.sections.length) {
|
|
new Notice(this.t('noCopyContent'));
|
|
return;
|
|
}
|
|
await copyToClipboard(
|
|
cardsToMarkdown(`${view.sourceFile.basename} · ${this.t('displayName')}`, view.sections),
|
|
this.t('copiedAllMarkdown')
|
|
);
|
|
}
|
|
|
|
async runForActiveFile(force) {
|
|
const mdView = this.getActiveView();
|
|
if (!mdView || !mdView.file) {
|
|
new Notice(this.t('openNoteFirst'));
|
|
return;
|
|
}
|
|
return this.runForFile(mdView.file, force);
|
|
}
|
|
|
|
async runForFile(file, force) {
|
|
if (!file) {
|
|
new Notice(this.t('openNoteFirst'));
|
|
return;
|
|
}
|
|
const runningKey = file.path;
|
|
if (this.jobs.isRunning(runningKey)) {
|
|
new Notice(this.t('alreadyGenerating'));
|
|
return;
|
|
}
|
|
|
|
let view = null;
|
|
return this.jobs.start(runningKey, async job => {
|
|
job.setPhase('reading');
|
|
const content = await this.app.vault.read(file);
|
|
job.throwIfCancelled();
|
|
if (!content.trim()) {
|
|
new Notice(this.t('emptyNote'));
|
|
return;
|
|
}
|
|
|
|
view = await this.ensureView();
|
|
job.throwIfCancelled();
|
|
|
|
// Try cache unless user explicitly forced regeneration
|
|
job.setPhase('cache-check');
|
|
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);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
await view.renderLoading(file, this.t('loadingGenerating'));
|
|
const maxDocChars = Number(this.settings.maxDocChars) || DEFAULT_SETTINGS.maxDocChars;
|
|
if (content.length > maxDocChars) {
|
|
new Notice(this.t('longNoteTruncated', { count: maxDocChars }));
|
|
}
|
|
new Notice(this.t('generatingNotice'));
|
|
|
|
job.setPhase('generating');
|
|
const sections = await summarizeDocument(content, this.settings, job);
|
|
job.throwIfCancelled();
|
|
if (sections.length === 0) {
|
|
new Notice(this.t('noCardsReturned'));
|
|
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,
|
|
}));
|
|
job.setPhase('saving');
|
|
await this.cachePut(file.path, content, rawCards, this.settings);
|
|
job.throwIfCancelled();
|
|
|
|
if (this.viewIsShowingFile(view, file)) {
|
|
await view.loadFor(file, sections, false);
|
|
}
|
|
const unanchored = sections.filter(s => s.startLine < 0).length;
|
|
new Notice(this.t('generationDone', {
|
|
count: sections.length,
|
|
suffix: unanchored ? this.t('unanchoredSuffix', { count: unanchored }) : '',
|
|
}));
|
|
}).catch(async e => {
|
|
if (e instanceof GenerationJobAlreadyRunningError) {
|
|
new Notice(e.message);
|
|
return;
|
|
}
|
|
if (e instanceof GenerationJobCancelledError) {
|
|
if (this.viewIsShowingFile(view, file)) await view.renderError(file, this.t('cancelledError'));
|
|
new Notice(this.t('cancelled'));
|
|
return;
|
|
}
|
|
const kind = classifyGenerationError(e);
|
|
console.error(e);
|
|
if (this.viewIsShowingFile(view, file)) await view.renderError(file, e.message || String(e));
|
|
new Notice(this.t('generationFailed', {
|
|
kind: kind === 'unknown' ? '' : ` (${kind})`,
|
|
error: e.message || e,
|
|
}));
|
|
});
|
|
}
|
|
|
|
// 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 as ParallelReaderView;
|
|
if (!view) return;
|
|
if (!this.activeFileStillMatches(file)) 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);
|
|
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);
|
|
}
|
|
|
|
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 as any).cm;
|
|
const scrollDom = cm && cm.scrollDOM ? cm.scrollDOM : mdView.contentEl.querySelector('.cm-scroller');
|
|
if (!scrollDom) return;
|
|
|
|
const handler = createRafThrottledHandler(() => this.handleEditorScroll(mdView));
|
|
scrollDom.addEventListener('scroll', handler, { passive: true });
|
|
this._scrollDispose = () => {
|
|
handler.cancel();
|
|
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 as ParallelReaderView;
|
|
if (!view || !mdView.file || view.sourceFile?.path !== mdView.file.path) return;
|
|
|
|
const editor = mdView.editor;
|
|
const cm = editor && (editor as any).cm;
|
|
if (!cm) return;
|
|
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 as any;
|
|
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(this.t('noEditor'));
|
|
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 {
|
|
plugin: ParallelReaderPlugin;
|
|
|
|
constructor(app, plugin) {
|
|
super(app, plugin);
|
|
this.plugin = plugin;
|
|
}
|
|
|
|
display() {
|
|
const { containerEl } = this;
|
|
const tr = (key, vars?) => this.plugin.t(key, vars);
|
|
containerEl.empty();
|
|
containerEl.createEl('h2', { text: tr('settingsTitle') });
|
|
|
|
new Setting(containerEl)
|
|
.setName(tr('settingUiLanguageName'))
|
|
.setDesc(tr('settingUiLanguageDesc'))
|
|
.addDropdown(d => {
|
|
for (const [id, label] of Object.entries(UI_LANGUAGES)) {
|
|
d.addOption(id, label);
|
|
}
|
|
return d
|
|
.setValue(this.plugin.settings.uiLanguage || DEFAULT_SETTINGS.uiLanguage)
|
|
.onChange(async v => {
|
|
this.plugin.settings.uiLanguage = v;
|
|
await this.plugin.saveSettings();
|
|
this.display();
|
|
});
|
|
});
|
|
|
|
new Setting(containerEl)
|
|
.setName(tr('settingBackendName'))
|
|
.setDesc(tr('settingBackendDesc'))
|
|
.addDropdown(d => d
|
|
.addOption('claude-code', 'Claude Code CLI')
|
|
.addOption('codex', 'Codex CLI')
|
|
.addOption('api', 'API / Provider')
|
|
.addOption('anthropic-api', 'Anthropic API (legacy)')
|
|
.setValue(this.plugin.settings.backend)
|
|
.onChange(async v => {
|
|
this.plugin.settings.backend = v;
|
|
if (v === 'api' && !this.plugin.settings.apiBaseUrl) {
|
|
applyApiProviderPreset(this.plugin.settings, this.plugin.settings.apiProvider || 'anthropic');
|
|
}
|
|
await this.plugin.saveSettings();
|
|
this.display();
|
|
}));
|
|
|
|
const apiBackend = isApiBackend(this.plugin.settings.backend);
|
|
|
|
if (!apiBackend) {
|
|
new Setting(containerEl)
|
|
.setName(tr('settingCliPathName'))
|
|
.setDesc(tr('settingCliPathDesc'))
|
|
.addText(t => t
|
|
.setPlaceholder(tr('settingCliPathPlaceholder'))
|
|
.setValue(this.plugin.settings.cliPath)
|
|
.onChange(async v => {
|
|
this.plugin.settings.cliPath = v.trim();
|
|
this.plugin.saveSettingsDebounced();
|
|
}));
|
|
|
|
new Setting(containerEl)
|
|
.setName(tr('settingCliTimeoutName'))
|
|
.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;
|
|
this.plugin.saveSettingsDebounced();
|
|
}
|
|
}));
|
|
} else {
|
|
containerEl.createEl('h3', { text: tr('apiProviderHeader') });
|
|
|
|
const preset = getApiPreset(this.plugin.settings);
|
|
new Setting(containerEl)
|
|
.setName(tr('settingProviderPresetName'))
|
|
.setDesc(tr('settingProviderPresetDesc'))
|
|
.addDropdown(d => {
|
|
for (const [id, entry] of Object.entries(API_PROVIDER_PRESETS)) {
|
|
d.addOption(id, (entry as any).label);
|
|
}
|
|
return d
|
|
.setValue(this.plugin.settings.apiProvider)
|
|
.onChange(async v => {
|
|
applyApiProviderPreset(this.plugin.settings, v);
|
|
await this.plugin.saveSettings();
|
|
this.display();
|
|
});
|
|
});
|
|
|
|
new Setting(containerEl)
|
|
.setName(tr('settingApiFormatName'))
|
|
.setDesc(tr('settingApiFormatDesc'))
|
|
.addDropdown(d => {
|
|
for (const [id, entry] of Object.entries(API_FORMATS)) {
|
|
d.addOption(id, (entry as any).label);
|
|
}
|
|
return d
|
|
.setValue(getApiFormat(this.plugin.settings))
|
|
.onChange(async v => {
|
|
this.plugin.settings.apiFormat = v;
|
|
await this.plugin.saveSettings();
|
|
this.display();
|
|
});
|
|
});
|
|
|
|
new Setting(containerEl)
|
|
.setName(tr('settingBaseUrlName'))
|
|
.setDesc(tr('settingBaseUrlDesc'))
|
|
.addText(t => t
|
|
.setPlaceholder(
|
|
(this.plugin.settings.apiProvider || '').startsWith('custom-')
|
|
? 'https://your-provider.example/v1'
|
|
: (preset.baseUrl || API_FORMATS[getApiFormat(this.plugin.settings)].defaultBaseUrl)
|
|
)
|
|
.setValue(this.plugin.settings.apiBaseUrl)
|
|
.onChange(async v => {
|
|
this.plugin.settings.apiBaseUrl = v.trim();
|
|
this.plugin.saveSettingsDebounced();
|
|
}));
|
|
|
|
new Setting(containerEl)
|
|
.setName(tr('settingApiKeyName'))
|
|
.setDesc(tr('settingApiKeyDesc'))
|
|
.addText(t => {
|
|
t.inputEl.type = 'password';
|
|
return t
|
|
.setPlaceholder('sk-...')
|
|
.setValue(this.plugin.settings.apiKey)
|
|
.onChange(async v => {
|
|
this.plugin.settings.apiKey = v.trim();
|
|
this.plugin.saveSettingsDebounced();
|
|
});
|
|
});
|
|
|
|
new Setting(containerEl)
|
|
.setName(tr('settingApiKeyEnvName'))
|
|
.setDesc(tr('settingApiKeyEnvDesc'))
|
|
.addText(t => t
|
|
.setPlaceholder(preset.envVar || 'OPENAI_API_KEY')
|
|
.setValue(this.plugin.settings.apiKeyEnvVar)
|
|
.onChange(async v => {
|
|
this.plugin.settings.apiKeyEnvVar = v.trim();
|
|
this.plugin.saveSettingsDebounced();
|
|
}));
|
|
|
|
new Setting(containerEl)
|
|
.setName(tr('settingAuthTypeName'))
|
|
.setDesc(tr('settingAuthTypeDesc'))
|
|
.addDropdown(d => {
|
|
for (const [id, label] of Object.entries(API_AUTH_TYPES)) {
|
|
d.addOption(id, label);
|
|
}
|
|
return d
|
|
.setValue(this.plugin.settings.apiAuthType || 'auto')
|
|
.onChange(async v => {
|
|
this.plugin.settings.apiAuthType = v;
|
|
await this.plugin.saveSettings();
|
|
});
|
|
});
|
|
|
|
new Setting(containerEl)
|
|
.setName(tr('settingHeadersName'))
|
|
.setDesc(tr('settingHeadersDesc'))
|
|
.addTextArea(t => t
|
|
.setPlaceholder('cf-aig-authorization: Bearer ...')
|
|
.setValue(this.plugin.settings.apiHeaders)
|
|
.onChange(async v => {
|
|
this.plugin.settings.apiHeaders = v;
|
|
this.plugin.saveSettingsDebounced();
|
|
}));
|
|
|
|
new Setting(containerEl)
|
|
.setName(tr('settingMaxTokensName'))
|
|
.addText(t => t
|
|
.setValue(String(this.plugin.settings.apiMaxTokens))
|
|
.onChange(async v => {
|
|
const n = parseInt(v, 10);
|
|
if (!isNaN(n) && n > 0) {
|
|
this.plugin.settings.apiMaxTokens = n;
|
|
this.plugin.saveSettingsDebounced();
|
|
}
|
|
}));
|
|
}
|
|
|
|
new Setting(containerEl)
|
|
.setName(tr('settingModelName'))
|
|
.setDesc(apiBackend
|
|
? tr('settingModelDescApi')
|
|
: tr('settingModelDescCli'))
|
|
.addText(t => t
|
|
.setPlaceholder(apiBackend ? (getApiPreset(this.plugin.settings).model || 'model-id') : DEFAULT_SETTINGS.model)
|
|
.setValue(this.plugin.settings.model)
|
|
.onChange(async v => {
|
|
this.plugin.settings.model = v.trim() || (apiBackend ? '' : DEFAULT_SETTINGS.model);
|
|
this.plugin.saveSettingsDebounced();
|
|
}));
|
|
|
|
new Setting(containerEl)
|
|
.setName(tr('settingMaxInputName'))
|
|
.setDesc(tr('settingMaxInputDesc'))
|
|
.addText(t => t
|
|
.setValue(String(this.plugin.settings.maxDocChars || DEFAULT_SETTINGS.maxDocChars))
|
|
.onChange(async v => {
|
|
const n = parseInt(v, 10);
|
|
if (!isNaN(n) && n >= 1000) {
|
|
this.plugin.settings.maxDocChars = n;
|
|
this.plugin.saveSettingsDebounced();
|
|
}
|
|
}));
|
|
|
|
containerEl.createEl('h3', { text: tr('promptHeader') });
|
|
|
|
new Setting(containerEl)
|
|
.setName(tr('settingPromptLanguageName'))
|
|
.setDesc(tr('settingPromptLanguageDesc'))
|
|
.addDropdown(d => {
|
|
for (const [id, label] of Object.entries(PROMPT_LANGUAGES)) {
|
|
d.addOption(id, label);
|
|
}
|
|
return d
|
|
.setValue(this.plugin.settings.promptLanguage || DEFAULT_SETTINGS.promptLanguage)
|
|
.onChange(async v => {
|
|
this.plugin.settings.promptLanguage = v;
|
|
await this.plugin.saveSettings();
|
|
});
|
|
});
|
|
|
|
new Setting(containerEl)
|
|
.setName(tr('settingCardRangeName'))
|
|
.setDesc(tr('settingCardRangeDesc'))
|
|
.addText(t => t
|
|
.setPlaceholder('min')
|
|
.setValue(String(this.plugin.settings.minCards || DEFAULT_SETTINGS.minCards))
|
|
.onChange(async v => {
|
|
const n = parseInt(v, 10);
|
|
if (!isNaN(n) && n > 0) {
|
|
this.plugin.settings.minCards = n;
|
|
if (this.plugin.settings.maxCards < n) this.plugin.settings.maxCards = n;
|
|
this.plugin.saveSettingsDebounced();
|
|
}
|
|
}))
|
|
.addText(t => t
|
|
.setPlaceholder('max')
|
|
.setValue(String(this.plugin.settings.maxCards || DEFAULT_SETTINGS.maxCards))
|
|
.onChange(async v => {
|
|
const n = parseInt(v, 10);
|
|
if (!isNaN(n) && n > 0) {
|
|
this.plugin.settings.maxCards = Math.max(n, this.plugin.settings.minCards || DEFAULT_SETTINGS.minCards);
|
|
this.plugin.saveSettingsDebounced();
|
|
}
|
|
}));
|
|
|
|
new Setting(containerEl)
|
|
.setName(tr('settingCustomPromptName'))
|
|
.setDesc(tr('settingCustomPromptDesc'))
|
|
.addTextArea(t => {
|
|
t.inputEl.rows = 8;
|
|
return t
|
|
.setPlaceholder(tr('settingCustomPromptPlaceholder'))
|
|
.setValue(this.plugin.settings.customSystemPrompt || '')
|
|
.onChange(async v => {
|
|
this.plugin.settings.customSystemPrompt = v;
|
|
this.plugin.saveSettingsDebounced();
|
|
});
|
|
});
|
|
|
|
new Setting(containerEl)
|
|
.setName(tr('settingTestBackendName'))
|
|
.setDesc(apiBackend ? tr('settingTestBackendDescApi') : tr('settingTestBackendDescCli'))
|
|
.addButton(b => b
|
|
.setButtonText('Test')
|
|
.onClick(async () => {
|
|
try {
|
|
const result = await testBackend(this.plugin.settings);
|
|
new Notice(`✓ ${result.slice(0, 180)}`, 8000);
|
|
} catch (e) {
|
|
new Notice(tr('backendTestFailed', { error: e.message }), 10000);
|
|
}
|
|
}));
|
|
|
|
new Setting(containerEl)
|
|
.setName(tr('settingExportFolderName'))
|
|
.setDesc(tr('settingExportFolderDesc'))
|
|
.addText(t => t
|
|
.setValue(this.plugin.settings.exportFolder)
|
|
.onChange(async v => {
|
|
this.plugin.settings.exportFolder = v.trim() || DEFAULT_SETTINGS.exportFolder;
|
|
this.plugin.saveSettingsDebounced();
|
|
}));
|
|
|
|
containerEl.createEl('h3', { text: tr('cacheHeader') });
|
|
|
|
new Setting(containerEl)
|
|
.setName(tr('settingMaxCacheName'))
|
|
.setDesc(tr('settingMaxCacheDesc'))
|
|
.addText(t => {
|
|
t.setValue(String(this.plugin.settings.maxCacheEntries || DEFAULT_MAX_CACHE_ENTRIES));
|
|
const commit = async () => {
|
|
const n = parseInt(t.getValue(), 10);
|
|
if (Number.isFinite(n) && n > 0) {
|
|
this.plugin.settings.maxCacheEntries = n;
|
|
await this.plugin.saveSettings();
|
|
const removed = await this.plugin.pruneCacheIfNeeded();
|
|
if (removed.length > 0) new Notice(tr('cachePruned', { count: removed.length }));
|
|
this.display();
|
|
}
|
|
};
|
|
t.inputEl.addEventListener('change', commit);
|
|
t.inputEl.addEventListener('keydown', e => {
|
|
if (e.key === 'Enter') t.inputEl.blur();
|
|
});
|
|
return t;
|
|
});
|
|
|
|
const cacheCount = Object.keys(this.plugin.cache).length;
|
|
new Setting(containerEl)
|
|
.setName(tr('cachedNotesName', { count: cacheCount }))
|
|
.setDesc(tr('cachedNotesDesc'))
|
|
.addButton(b => b
|
|
.setButtonText(tr('clearAllCacheButton'))
|
|
.setWarning()
|
|
.onClick(async () => {
|
|
const n = Object.keys(this.plugin.cache).length;
|
|
await this.plugin.cacheClear();
|
|
new Notice(tr('cacheClearedAll', { count: n }));
|
|
this.display();
|
|
}));
|
|
}
|
|
}
|
|
|
|
export default ParallelReaderPlugin;
|
|
export const __test = {
|
|
CACHE_SCHEMA_VERSION,
|
|
GenerationJobAlreadyRunningError,
|
|
GenerationJobCancelledError,
|
|
GenerationJobManager,
|
|
activeIndexAfterCardDelete,
|
|
activeSectionLine,
|
|
buildAnthropicMessagesBody,
|
|
buildGeminiBody,
|
|
buildOpenAiChatBody,
|
|
buildOpenAiResponsesBody,
|
|
buildPrompts,
|
|
cardsToMarkdown,
|
|
cacheEntryMatches,
|
|
cancellationNoticeKey,
|
|
classifyGenerationError,
|
|
createRafThrottledHandler,
|
|
extractJson,
|
|
findLineForAnchor,
|
|
folderPathsForTarget,
|
|
generationFingerprint,
|
|
getApiBaseUrl,
|
|
modelForApi,
|
|
normalizeCardsPayload,
|
|
nextCardIndex,
|
|
pruneCacheEntries,
|
|
removeCardAt,
|
|
serializeCacheFile,
|
|
summarizeViaApi,
|
|
touchCacheEntry,
|
|
translate,
|
|
tokenLimitFieldForOpenAiChat,
|
|
updateCardAt,
|
|
};
|