refactor: extract batch generation helpers

Change-Id: I16ad657b783b65cc5b3e36a6ea163cc19740b651
This commit is contained in:
wujunchen 2026-04-26 17:54:35 +08:00
parent b199f56a35
commit 2d4bef9539
4 changed files with 120 additions and 29 deletions

40
main.js

File diff suppressed because one or more lines are too long

29
main.ts
View file

@ -1,6 +1,14 @@
'use strict';
import { MarkdownView, Modal, Notice, Plugin, TFile } from 'obsidian';
import { findLineForAnchor } from './src/anchor';
import {
batchProgressVars,
createBatchStats,
normalizeBatchFolderInput,
recordBatchSkip,
selectBatchFiles,
shouldSkipBatchFile,
} from './src/batch';
import { serializeCacheFile, shouldConfirmRegenerate, touchCacheEntry } from './src/cache';
import { CacheManager } from './src/cache-manager';
import { activeIndexAfterCardDelete, removeCardAt, updateCardAt } from './src/cards';
@ -560,27 +568,24 @@ class ParallelReaderPlugin extends Plugin {
new FolderPromptModal(plugin.app).open();
});
if (folderPath === null) return;
const allFiles = this.app.vault.getMarkdownFiles().filter((f) => {
if (folderPath === '') return !f.path.includes('/');
return f.parent?.path === folderPath;
});
const allFiles = selectBatchFiles(this.app.vault.getMarkdownFiles(), folderPath);
if (allFiles.length === 0) {
new Notice(this.t('batchNoMarkdown'));
return;
}
let skipped = 0;
let stats = createBatchStats(allFiles.length);
for (let i = 0; i < allFiles.length; i++) {
const file = allFiles[i];
new Notice(this.t('batchProgress', { current: i + 1, total: allFiles.length }));
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 (entry && cacheEntryMatches(entry, content, this.settings)) {
skipped++;
if (shouldSkipBatchFile(entry, content, this.settings)) {
stats = recordBatchSkip(stats);
continue;
}
await this.runForFile(file, false);
}
new Notice(this.t('batchDone', { total: allFiles.length, skipped }));
new Notice(this.t('batchDone', { total: stats.total, skipped: stats.skipped }));
}
resolveCardAnchors(content: string, rawCards: RawCard[]) {
@ -718,6 +723,7 @@ export const __test = {
buildGeminiBody,
buildOpenAiChatBody,
buildOpenAiResponsesBody,
batchProgressVars,
buildPrompts,
cardsToMarkdown,
cacheEntryMatches,
@ -728,16 +734,21 @@ export const __test = {
extractJson,
findLineForAnchor,
folderPathsForTarget,
createBatchStats,
generationFingerprint,
getApiBaseUrl,
modelForApi,
normalizeBatchFolderInput,
normalizeCardsPayload,
nextCardIndex,
pruneCacheEntries,
recordBatchSkip,
removeCardAt,
resolveCliPath,
serializeCacheFile,
shouldConfirmRegenerate,
shouldSkipBatchFile,
selectBatchFiles,
summarizeViaApi,
touchCacheEntry,
translate,

49
src/batch.ts Normal file
View file

@ -0,0 +1,49 @@
'use strict';
import { cacheEntryMatches } from './settings';
import type { CacheEntry, PluginSettings } from './types';
export interface BatchFileLike {
path: string;
parent?: { path: string } | null;
}
export interface BatchStats {
total: number;
skipped: number;
}
export function normalizeBatchFolderInput(input: string): string {
return (input || '')
.trim()
.split('/')
.map((part) => part.trim())
.filter((part) => part && part !== '.' && part !== '..')
.join('/');
}
export function isFileInBatchFolder(file: BatchFileLike, folderPath: string): boolean {
if (!folderPath) return !file.path.includes('/');
return file.parent?.path === folderPath;
}
export function selectBatchFiles<T extends BatchFileLike>(files: T[], folderPath: string): T[] {
const normalized = normalizeBatchFolderInput(folderPath);
return files.filter((file) => isFileInBatchFolder(file, normalized));
}
export function batchProgressVars(index: number, total: number): { current: number; total: number } {
return { current: index + 1, total };
}
export function createBatchStats(total: number): BatchStats {
return { total, skipped: 0 };
}
export function recordBatchSkip(stats: BatchStats): BatchStats {
return { ...stats, skipped: stats.skipped + 1 };
}
export function shouldSkipBatchFile(entry: CacheEntry | null, content: string, settings: PluginSettings): boolean {
return !!entry && cacheEntryMatches(entry, content, settings);
}

View file

@ -178,6 +178,27 @@ assert.deepStrictEqual(t.folderPathsForTarget('../../etc/passwd'), ['etc', 'etc/
assert.deepStrictEqual(t.folderPathsForTarget('./a/../b'), ['a', 'a/b'], 'strips . and .. segments');
assert.deepStrictEqual(t.folderPathsForTarget('a/../../b'), ['a', 'a/b'], 'strips mid-path ..');
// ── batch.ts ──
assert.strictEqual(t.normalizeBatchFolderInput('/Reading//Articles/'), 'Reading/Articles', 'normalizes folder input');
assert.strictEqual(t.normalizeBatchFolderInput('../Inbox/./Daily'), 'Inbox/Daily', 'strips unsafe folder segments');
const batchFiles = [
{ path: 'root.md', parent: null },
{ path: 'Reading/note.md', parent: { path: 'Reading' } },
{ path: 'Reading/Deep/nested.md', parent: { path: 'Reading/Deep' } },
];
assert.deepStrictEqual(t.selectBatchFiles(batchFiles, '').map((file) => file.path), ['root.md'], 'root folder only');
assert.deepStrictEqual(
t.selectBatchFiles(batchFiles, '/Reading/').map((file) => file.path),
['Reading/note.md'],
'selected folder is non-recursive',
);
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');
// ── i18n.ts ──
assert.strictEqual(t.translate({ uiLanguage: 'zh' }, 'appTitle'), '对照阅读笔记', 'zh translation');
@ -266,6 +287,16 @@ assert.strictEqual(
true, 'matching entry returns true'
);
assert.strictEqual(t.cacheEntryMatches(null, 'test', baseSettings), false, 'null entry returns false');
assert.strictEqual(
t.shouldSkipBatchFile({
schemaVersion: t.CACHE_SCHEMA_VERSION,
contentHash: hash,
settingsHash: t.generationFingerprint(baseSettings),
}, 'test', baseSettings),
true,
'fresh cache entry is skipped during batch',
);
assert.strictEqual(t.shouldSkipBatchFile(null, 'test', baseSettings), false, 'missing cache entry is not skipped');
// pruneCacheEntries
const cache = {