fancive_obsidian-parallel-r.../src/batch.ts
fancivez 01ec934d28 fix: address 13 review findings (security, UX, i18n)
P1:
- cli: pin codex --sandbox read-only (defeat prompt injection)
- settings: clear apiKey/apiHeaders on provider preset switch
- main/view: render error state when LLM returns empty cards
- schema/cli: do not log raw model output; UI errors expose length only

P2:
- cli: pass --model to Claude Code when settings.model set
- settings-tab: add CLI timeout input + normalizeCliTimeoutMs (min 1s)
- view: export source link uses [[path|basename]]
- view: export failures surface a localized Notice

P3:
- schema: cardUntitled fallback now respects uiLanguage
- generation-job-manager: error codes localized via main.ts
- batch: settled guard on promptForBatchFolder modal
- README: drop stale e2e validator install note

Codex review pass:
- provider-parsers: forward settings to normalizeCardsPayload
- generation-job-manager: classifyGenerationError regex covers new EN/zh
  schema-error messages
- cli: do NOT pass --model to codex (DEFAULT_SETTINGS.model is a Claude
  name and would break codex; codex relies on its own config)

Tests: cli (codex sandbox + claude --model + classify zh/en), settings
(applyApiProviderPreset isolation + normalizeCliTimeoutMs), schema
(language-aware fallback title), test-exports (new symbols).

Change-Id: I9f4c21f9299e1a9bf166f69f712859854482fe92
2026-05-02 12:50:35 +08:00

146 lines
4.4 KiB
TypeScript

'use strict';
import { type App, Modal } from 'obsidian';
import { cacheEntryMatches } from './settings';
import type { CacheEntry, PluginSettings } from './types';
export interface BatchFileLike {
path: string;
parent?: { path: string } | null;
}
export interface BatchStats {
total: number;
processed: number;
skipped: number;
errors: number;
}
export interface BatchRunState {
cancelled: boolean;
currentPath: string;
}
export type BatchFolderValidationReason = 'ok' | 'unsafe' | 'missing';
export interface BatchFolderValidation {
valid: boolean;
reason: BatchFolderValidationReason;
folderPath: string;
}
export function normalizeBatchFolderInput(input: string): string {
return (input || '')
.trim()
.split('/')
.map((part) => part.trim())
.filter((part) => part && part !== '.' && part !== '..')
.join('/');
}
export function hasUnsafeBatchFolderSegments(input: string): boolean {
return (input || '')
.split('/')
.map((part) => part.trim())
.some((part) => part === '.' || part === '..');
}
export function validateBatchFolderInput(
input: string,
folderExists: (folderPath: string) => boolean,
): BatchFolderValidation {
const folderPath = normalizeBatchFolderInput(input);
if (hasUnsafeBatchFolderSegments(input)) return { valid: false, reason: 'unsafe', folderPath };
if (folderPath && !folderExists(folderPath)) return { valid: false, reason: 'missing', folderPath };
return { valid: true, reason: 'ok', folderPath };
}
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, processed: 0, skipped: 0, errors: 0 };
}
export function recordBatchProcessed(stats: BatchStats): BatchStats {
return { ...stats, processed: stats.processed + 1 };
}
export function recordBatchSkip(stats: BatchStats): BatchStats {
return { ...stats, processed: stats.processed + 1, skipped: stats.skipped + 1 };
}
export function recordBatchError(stats: BatchStats): BatchStats {
return { ...stats, processed: stats.processed + 1, errors: stats.errors + 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;
}
export function promptForBatchFolder(
app: App,
selectText: string,
promptText: string,
confirmText: string,
cancelText: string,
): Promise<string | null> {
return new Promise<string | null>((resolve) => {
let settled = false;
const settle = (value: string | null) => {
if (settled) return;
settled = true;
resolve(value);
};
const modal = new Modal(app);
modal.onOpen = () => {
modal.contentEl.createEl('p', { text: selectText });
const field = modal.contentEl.createDiv({ cls: 'parallel-reader-modal-field' });
const input = field.createEl('input', { type: 'text' });
input.placeholder = promptText;
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
settle(input.value.trim());
modal.close();
}
});
const actions = modal.contentEl.createDiv({ cls: 'modal-button-container' });
actions.createEl('button', { text: cancelText }).addEventListener('click', () => {
settle(null);
modal.close();
});
actions.createEl('button', { text: confirmText, cls: 'mod-cta' }).addEventListener('click', () => {
settle(input.value.trim());
modal.close();
});
};
modal.onClose = () => settle(null);
modal.open();
});
}