mirror of
https://github.com/fancive/obsidian-parallel-reader.git
synced 2026-07-22 06:53:43 +00:00
feat: support cancellable batch generation
Change-Id: I89302f58995e4e15fbf77c20c87a94832e572d20
This commit is contained in:
parent
ebc882c2f1
commit
d8dc82362b
6 changed files with 122 additions and 38 deletions
44
main.js
44
main.js
File diff suppressed because one or more lines are too long
61
main.ts
61
main.ts
|
|
@ -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,
|
||||
|
|
|
|||
29
src/batch.ts
29
src/batch.ts
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
10
src/i18n.ts
10
src/i18n.ts
|
|
@ -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)',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 ──
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue