feat: support cancellable batch generation

Change-Id: I89302f58995e4e15fbf77c20c87a94832e572d20
This commit is contained in:
wujunchen 2026-04-26 18:10:17 +08:00
parent ebc882c2f1
commit d8dc82362b
6 changed files with 122 additions and 38 deletions

44
main.js

File diff suppressed because one or more lines are too long

61
main.ts
View file

@ -2,10 +2,15 @@
import { MarkdownView, Modal, Notice, Plugin, TFile } from 'obsidian';
import { findLineForAnchor } from './src/anchor';
import {
type BatchRunState,
batchProgressVars,
createBatchRunState,
createBatchStats,
markBatchFileRunning,
normalizeBatchFolderInput,
recordBatchProcessed,
recordBatchSkip,
requestBatchCancel,
selectBatchFiles,
shouldSkipBatchFile,
} from './src/batch';
@ -66,6 +71,7 @@ class ParallelReaderPlugin extends Plugin {
settings!: PluginSettings;
cacheManager!: CacheManager;
jobs!: GenerationJobManager;
activeBatch: BatchRunState | null = null;
_scrollDispose: (() => void) | null = null;
_settingsSaveTimer: ReturnType<typeof setTimeout> | null = null;
@ -306,10 +312,15 @@ class ParallelReaderPlugin extends Plugin {
const job = this.jobs.get(file.path);
const noticeKey = cancellationNoticeKey(this.settings, job);
const cancelled = this.jobs.cancel(file.path);
if (cancelled && this.activeBatch?.currentPath === file.path) requestBatchCancel(this.activeBatch);
new Notice(cancelled ? this.t(noticeKey) : this.t('noCancelableJob'));
return cancelled;
}
requestBatchCancellation(): boolean {
return requestBatchCancel(this.activeBatch);
}
viewIsShowingFile(view: ParallelReaderView | null, file: TFile) {
return !!view && !!file && view.sourceFile?.path === file.path;
}
@ -319,6 +330,12 @@ class ParallelReaderPlugin extends Plugin {
}
cancelActiveGeneration() {
if (this.requestBatchCancellation()) {
const currentPath = this.activeBatch?.currentPath;
if (currentPath) this.jobs.cancel(currentPath);
new Notice(this.t('batchCancelRequested'));
return;
}
const active = this.getActiveView();
if (active?.file && this.cancelGenerationForFile(active.file)) return;
const view = this.getParallelView();
@ -537,6 +554,10 @@ class ParallelReaderPlugin extends Plugin {
}
async runBatchForFolder() {
if (this.activeBatch) {
new Notice(this.t('batchAlreadyRunning'));
return;
}
const plugin = this;
const folderPath = await new Promise<string | null>((resolve) => {
class FolderPromptModal extends Modal {
@ -569,19 +590,35 @@ class ParallelReaderPlugin extends Plugin {
new Notice(this.t('batchNoMarkdown'));
return;
}
const batch = createBatchRunState();
this.activeBatch = batch;
let stats = createBatchStats(allFiles.length);
for (let i = 0; i < allFiles.length; i++) {
const file = allFiles[i];
new Notice(this.t('batchProgress', batchProgressVars(i, allFiles.length)));
const content = await this.app.vault.read(file);
const entry = this.cacheManager.get(file.path);
if (shouldSkipBatchFile(entry, content, this.settings)) {
stats = recordBatchSkip(stats);
continue;
try {
for (let i = 0; i < allFiles.length; i++) {
if (batch.cancelled) break;
const file = allFiles[i];
markBatchFileRunning(batch, file.path);
new Notice(this.t('batchProgress', batchProgressVars(i, allFiles.length)));
const content = await this.app.vault.read(file);
if (batch.cancelled) break;
const entry = this.cacheManager.get(file.path);
if (shouldSkipBatchFile(entry, content, this.settings)) {
stats = recordBatchSkip(stats);
continue;
}
await this.runForFile(file, false);
stats = recordBatchProcessed(stats);
}
await this.runForFile(file, false);
} finally {
if (this.activeBatch === batch) this.activeBatch = null;
}
new Notice(this.t('batchDone', { total: stats.total, skipped: stats.skipped }));
const batchVars: Record<string, string | number> = {
processed: stats.processed,
skipped: stats.skipped,
total: stats.total,
};
if (batch.cancelled) new Notice(this.t('batchCancelled', batchVars));
else new Notice(this.t('batchDone', batchVars));
}
resolveCardAnchors(content: string, rawCards: RawCard[]) {
@ -728,18 +765,22 @@ export const __test = {
classifyGenerationError,
copyToClipboard,
createRafThrottledHandler,
createBatchRunState,
extractJson,
findLineForAnchor,
folderPathsForTarget,
createBatchStats,
generationFingerprint,
getApiBaseUrl,
markBatchFileRunning,
modelForApi,
normalizeBatchFolderInput,
normalizeCardsPayload,
nextCardIndex,
pruneCacheEntries,
recordBatchProcessed,
recordBatchSkip,
requestBatchCancel,
removeCardAt,
resolveCliPath,
serializeCacheFile,

View file

@ -10,9 +10,15 @@ export interface BatchFileLike {
export interface BatchStats {
total: number;
processed: number;
skipped: number;
}
export interface BatchRunState {
cancelled: boolean;
currentPath: string;
}
export function normalizeBatchFolderInput(input: string): string {
return (input || '')
.trim()
@ -37,13 +43,32 @@ export function batchProgressVars(index: number, total: number): { current: numb
}
export function createBatchStats(total: number): BatchStats {
return { total, skipped: 0 };
return { total, processed: 0, skipped: 0 };
}
export function recordBatchProcessed(stats: BatchStats): BatchStats {
return { ...stats, processed: stats.processed + 1 };
}
export function recordBatchSkip(stats: BatchStats): BatchStats {
return { ...stats, skipped: stats.skipped + 1 };
return { ...stats, processed: stats.processed + 1, skipped: stats.skipped + 1 };
}
export function shouldSkipBatchFile(entry: CacheEntry | null, content: string, settings: PluginSettings): boolean {
return !!entry && cacheEntryMatches(entry, content, settings);
}
export function createBatchRunState(): BatchRunState {
return { cancelled: false, currentPath: '' };
}
export function markBatchFileRunning(state: BatchRunState, filePath: string): BatchRunState {
state.currentPath = filePath;
return state;
}
export function requestBatchCancel(state: BatchRunState | null): boolean {
if (!state || state.cancelled) return false;
state.cancelled = true;
return true;
}

View file

@ -149,9 +149,12 @@ export const STRINGS: Record<string, Record<string, string>> = {
cmdBatchGenerate: '批量生成对照笔记(当前文件夹)',
batchSelectFolder: '请输入要批量处理的文件夹路径(留空为 Vault 根目录)',
batchFolderPrompt: '文件夹路径',
batchAlreadyRunning: '批量生成正在运行',
batchCancelRequested: '已请求取消批量生成;当前文件结束后停止',
batchNoMarkdown: '该文件夹下没有 Markdown 文件',
batchProgress: '批量生成:{current}/{total}',
batchDone: '批量生成完成:共处理 {total} 篇,跳过缓存 {skipped} 篇',
batchDone: '批量生成完成:共处理 {processed}/{total} 篇,跳过缓存 {skipped} 篇',
batchCancelled: '批量生成已取消:已处理 {processed}/{total} 篇,跳过缓存 {skipped} 篇',
},
en: {
appTitle: 'Parallel Reader',
@ -306,9 +309,12 @@ export const STRINGS: Record<string, Record<string, string>> = {
cmdBatchGenerate: 'Batch generate parallel notes (current folder)',
batchSelectFolder: 'Enter the folder path to batch-process (leave blank for Vault root)',
batchFolderPrompt: 'Folder path',
batchAlreadyRunning: 'Batch generation is already running',
batchCancelRequested: 'Batch cancellation requested; it will stop after the current note',
batchNoMarkdown: 'No Markdown files found in that folder',
batchProgress: 'Batch generating: {current}/{total}',
batchDone: 'Batch complete: processed {total}, skipped {skipped} (cached)',
batchDone: 'Batch complete: processed {processed}/{total}, skipped {skipped} (cached)',
batchCancelled: 'Batch cancelled: processed {processed}/{total}, skipped {skipped} (cached)',
},
};

View file

@ -76,6 +76,7 @@ assert.strictEqual(typeof t.getApiBaseUrl, 'function');
assert.strictEqual(typeof t.generationFingerprint, 'function');
assert.strictEqual(typeof t.CacheManager, 'function');
assert.strictEqual(typeof t.GenerationJobManager, 'function');
assert.strictEqual(typeof t.createBatchRunState, 'function');
assert.strictEqual(typeof t.modelForApi, 'function');
assert.strictEqual(typeof t.activeSectionLine, 'function');
assert.strictEqual(typeof t.touchCacheEntry, 'function');

View file

@ -240,8 +240,19 @@ assert.deepStrictEqual(
assert.deepStrictEqual(t.batchProgressVars(1, 3), { current: 2, total: 3 }, 'progress vars are one-based');
const batchStats = t.createBatchStats(3);
const skippedStats = t.recordBatchSkip(batchStats);
assert.deepStrictEqual(batchStats, { total: 3, skipped: 0 }, 'batch stats are immutable');
assert.deepStrictEqual(skippedStats, { total: 3, skipped: 1 }, 'batch stats accumulate');
const processedStats = t.recordBatchProcessed(skippedStats);
assert.deepStrictEqual(batchStats, { total: 3, processed: 0, skipped: 0 }, 'batch stats are immutable');
assert.deepStrictEqual(skippedStats, { total: 3, processed: 1, skipped: 1 }, 'batch skip updates progress');
assert.deepStrictEqual(processedStats, { total: 3, processed: 2, skipped: 1 }, 'batch processed count accumulates');
const batchState = t.createBatchRunState();
assert.deepStrictEqual(batchState, { cancelled: false, currentPath: '' }, 'batch state starts idle');
assert.strictEqual(t.markBatchFileRunning(batchState, 'Reading/a.md'), batchState, 'batch state updates in place');
assert.strictEqual(batchState.currentPath, 'Reading/a.md', 'batch state records current file');
assert.strictEqual(t.requestBatchCancel(batchState), true, 'first batch cancellation request succeeds');
assert.strictEqual(batchState.cancelled, true, 'batch cancellation flag is set');
assert.strictEqual(t.requestBatchCancel(batchState), false, 'duplicate batch cancellation request is ignored');
assert.strictEqual(t.requestBatchCancel(null), false, 'missing batch state cannot be cancelled');
// ── i18n.ts ──