feat: Implement parallel batch processing

This commit is contained in:
Jacobinwwey 2025-11-16 20:48:29 +08:00
parent 17e92c40ec
commit 52eade37db
16 changed files with 667 additions and 302 deletions

View file

@ -218,6 +218,12 @@ Access plugin settings via:
* **On**: Adds a "Linked From" section with a backlink to the source file.
#### Processing Parameters
- **Enable Batch Parallelism**:
* **Disabled (Default)**: Batch processing tasks (like "Process Folder" or "Batch Generate from Titles") process files one by one (serially).
* **Enabled**: Allows the plugin to process multiple files concurrently, which can significantly speed up large batch jobs.
- **Batch Concurrency**: (Visible only when parallelism is enabled) Sets the maximum number of files to process in parallel. A higher number can be faster but uses more resources and may hit API rate limits. (Default: 1, Range: 1-20)
- **Batch Size**: (Visible only when parallelism is enabled) The number of files to group into a single batch. (Default: 50, Range: 10-200)
- **Delay Between Batches (ms)**: (Visible only when parallelism is enabled) An optional delay in milliseconds between processing each batch, which can help manage API rate limits. (Default: 1000ms)
- **Chunk Word Count**: Maximum words per chunk sent to the LLM. Affects the number of API calls for large files. (Default: 3000)
- **Enable Duplicate Detection**: Toggles the basic check for duplicate words within processed content (results in console). (Default: Enabled)
- **Max Tokens**: Maximum tokens the LLM should generate per response chunk. Affects cost and detail. (Default: 4096)

View file

@ -264,6 +264,12 @@ Notemd 通过与各种大型语言模型 (LLM) 集成来增强您的 Obsidian
#### 处理参数 (Processing Parameters)
- **启用批处理并行化 (Enable Batch Parallelism)**:
- **禁用 (默认)**: 批处理任务(如“处理文件夹”或“从标题批量生成”)将逐个(串行)处理文件。
- **启用**: 允许插件同时处理多个文件,这可以显著加快大型批处理作业的速度。
- **批处理并发数 (Batch Concurrency)**: (仅在启用并行化时可见) 设置并行处理的最大文件数。较高的数字可以更快但会消耗更多资源并可能达到API速率限制。默认值1范围1-20
- **批处理大小 (Batch Size)**: (仅在启用并行化时可见) 分组到单个批次中的文件数。默认值50范围10-200
- **批处理间隔延迟 (毫秒) (Delay Between Batches (ms))**: (仅在启用并行化时可见) 处理每个批次之间的可选延迟以毫秒为单位这有助于管理API速率限制。默认值1000毫秒
- **分块字数 (Chunk Word Count)**: 发送给 LLM 的每个块的最大字数。影响大型文件的 API 调用次数。默认值3000
- **启用重复检测 (Enable Duplicate Detection)**: 切换对处理内容中重复单词的基本检查(结果在控制台中)。(默认值:启用)
- **最大令牌数 (Max Tokens)**: LLM 每个响应块应生成的最大令牌数。影响成本和细节。默认值4096

369
TODO.md
View file

@ -1,228 +1,201 @@
# Plan: Fix “Extract Concepts” Cancel Behavior (English Implementation Plan)
## Refined Plan: User-Configurable Parallel Batch Processing
Goal: Ensure that after recent code changes, the “Extract concepts” tasks fully support cancellation, consistent with “Process file (add links)”. Specifically:
- The “Cancel processing” button must be enabled during Extract Concepts runs.
- Clicking Cancel must reliably stop the current Extract Concepts operation.
### Key Enhancements Based on Feedback & Code Review
- **User Controls** (synced across settings/code):
| Setting | Type | Default | UI | Purpose |
|---------|------|---------|----|---------|
| `enableBatchParallelism` | boolean | `false` | Toggle | Enable/disable parallelism (backward compat: serial by default). |
| `batchConcurrency` | number | `1` | Slider (1-20) | Max parallel LLM calls (respects provider limits). |
| `batchSize` | number | `50` | Slider (10-200) | Files per batch (balances memory/rates). |
| `batchInterDelayMs` | number | `1000` | Slider (0-5000) | Delay between batches (ms, for rate limits). |
- **No External Deps**: Implement lightweight semaphore in `utils.ts` (no `p-limit` needed; use `queue` + `Promise`).
- **Backward Compat**: If `enableBatchParallelism=false` or `batchConcurrency=1`, behaves exactly as serial (no perf regression).
- **ProgressReporter Integration**: Extend interface for concurrency stats (e.g., `activeTasks: number`, `updateActiveTasks(+1/-1)` atomic).
- **Provider Awareness**: In `llmUtils.ts`, add per-provider `recommendedConcurrency` (e.g., OpenAI:5, Ollama:10, LMStudio:∞).
- **Error/Resilience**: Exponential backoff retry (3x), `AbortController` per task, aggregate errors per batch.
- **Code Locations** (minimal changes):
- `constants.ts`: Add to `DEFAULT_SETTINGS`.
- `types.ts`: Extend `NotemdSettings`.
- `ui/NotemdSettingTab.ts`: Add UI sliders/toggle.
- `utils.ts`: Semaphore + chunkArray + retry.
- `fileUtils.ts` / `main.ts`: Refactor batch fns to use new processor.
## 1. Root Cause Summary
### Step-by-Step Implementation (Prioritized)
1. **[Settings Sync]** Update `constants.ts` / `types.ts` / `NotemdSettingTab.ts`.
2. **[Utils]** Add semaphore, chunkArray, retry in `utils.ts`.
3. **[ProgressReporter]** Extend `types.ts` + impl in `ProgressModal.ts` / `NotemdSidebarView.ts`.
4. **[Core Refactor]** Parallelize `batchGenerateContentForTitles`, `processFolderWithNotemdCommand`, `batchExtractConceptsForFolderCommand`.
5. **[Provider Tweaks]** Optional `llmUtils.ts` caps.
6. **Tests**: Add to existing test suites.
Current components and behavior:
1) ProgressModal (src/ui/ProgressModal.ts)
- Always renders an enabled “Cancel” button when opened.
- `requestCancel()`:
- Sets `isCancelled = true`
- Calls `updateStatus('Cancelling...', -1)`
- Logs “User requested cancellation.”
- Aborts `currentAbortController` (if set)
- Disables the button
- `clearDisplay()`:
- Resets `isCancelled`
- Re-enables the Cancel button
- It does NOT depend on an `isProcessing` flag to enable Cancel.
- Conclusion: For tasks using ProgressModal directly, cancel support is implemented correctly.
2) NotemdSidebarView (src/ui/NotemdSidebarView.ts)
- Implements ProgressReporter for sidebar-driven operations.
- Tracks:
- `isProcessing`
- `isCancelled`
- `currentAbortController`
- `updateButtonStates()`:
- Disables all command buttons while `isProcessing` is true.
- Enables “Cancel processing” ONLY when:
- `isProcessing === true` AND `isCancelled === false`.
- Otherwise disables the Cancel button.
- For sidebar buttons (e.g. Process file, Process folder, Translate, Summarise, Extract Concepts), each `onclick`:
- Calls `this.clearDisplay()`
- Sets `this.currentAbortController = new AbortController()`
- Sets `this.isProcessing = true`
- Calls `this.updateButtonStates()` (Cancel becomes enabled)
- Awaits the plugin command with `this` as reporter
- In `finally`:
- Sets `this.isProcessing = false`
- Calls `this.updateButtonStates()` (Cancel disabled again)
- Conclusion: When Extract Concepts is started via the sidebar buttons, Cancel should already work.
3) Extract Concepts logic (src/fileUtils.ts)
- `extractConceptsFromFile`:
- Before each chunk:
- If `progressReporter.cancelled``throw new Error("Concept extraction cancelled by user.");`
- Uses `progressReporter.abortController?.signal` when calling LLM helpers.
- `batchExtractConceptsForFolderCommand` (in main.ts; not shown here but assumed):
- Typical pattern: checks `useReporter.cancelled` and breaks loops on cancel.
- Conclusion: The internal loops already respect `ProgressReporter.cancelled` and are wired for cancellation.
Observed issue (from user report and screenshot):
- When “Extract concepts” is triggered (in some usage paths), the UI shows:
- “Extracting concepts from chunk 1/1…”
- A disabled “Cancel processing” button.
- This indicates:
- The active reporter is the NotemdSidebarView (button label matches).
- But its `isProcessing` was never set to true for this run, so `updateButtonStates()` kept Cancel disabled.
- Likely scenario:
- Extract Concepts was run via a command (e.g. command palette), not via sidebar button.
- In that path, `main.ts` uses:
- `const useReporter = reporter || this.getReporter();`
- `getReporter()` returns the sidebar view if it exists.
- But `main.ts` does NOT set `isProcessing = true` on the sidebar when invoked this way.
- As a result:
- Sidebars Cancel button remains disabled (grey), even though the task is running and checking `cancelled`.
## 2. Design Principles for the Fix
1. Single source of truth:
- The component that owns the UI (NotemdSidebarView / ProgressModal) should manage:
- Whether a task is “processing”.
- Whether Cancel is enabled.
- Callers (commands in main.ts) must consistently signal “start” and “end” of processing.
2. Symmetry with Add Links:
- Extract Concepts should follow the same pattern as:
- Process current file
- Process folder
- Batch operations
- No special-case UX differences unless intentional.
3. Non-breaking:
- Do not break the existing working flows:
- Sidebar buttons for Process/Translate/etc.
- ProgressModal cancel behavior.
## 3. Concrete Changes
### 3.1 Introduce unified start/finish helpers on NotemdSidebarView (optional but recommended)
Add two methods to `NotemdSidebarView`:
### Detailed Code Changes
#### 1. Settings (`constants.ts`)
```ts
startProcessing(initialStatus: string) {
this.clearDisplay();
this.currentAbortController = new AbortController();
this.isProcessing = true;
this.isCancelled = false;
this.startTime = Date.now();
this.updateStatus(initialStatus, 0);
this.updateButtonStates();
export const DEFAULT_SETTINGS: NotemdSettings = {
// ... existing ...
enableBatchParallelism: false,
batchConcurrency: 1,
batchSize: 50,
batchInterDelayMs: 1000,
};
```
#### 2. Types (`types.ts`)
```ts
export interface NotemdSettings {
// ... existing ...
enableBatchParallelism: boolean;
batchConcurrency: number;
batchSize: number;
batchInterDelayMs: number;
}
finishProcessing() {
this.isProcessing = false;
this.updateButtonStates();
export interface ProgressReporter {
// ... existing ...
activeTasks: number; // NEW: For concurrency display
updateActiveTasks(delta: number): void; // NEW
}
```
Notes:
- `clearDisplay()` already disables Cancel, resets flags; `startProcessing` immediately re-enables Cancel by setting `isProcessing = true` before calling `updateButtonStates()`.
- These helpers encapsulate the pattern currently duplicated in each button handler.
### 3.2 Ensure Extract Concepts commands mark the sidebar as processing when using it
In `src/main.ts`, for:
- `extractConceptsCommand(reporter?: ProgressReporter)`
- `batchExtractConceptsForFolderCommand(reporter?: ProgressReporter)`
Adjust the logic as follows:
1) Determine reporter:
#### 3. UI (`ui/NotemdSettingTab.ts`)
```ts
const useReporter = reporter || this.getReporter();
if (!reporter) {
useReporter.clearDisplay();
}
// In display() or relevant section
new Setting(parent)
.setName('Enable Batch Parallelism')
.setDesc('Allow parallel LLM calls for faster batch processing.')
.addToggle(t => t
.setValue(this.plugin.settings.enableBatchParallelism)
.onChange(async (v) => {
this.plugin.settings.enableBatchParallelism = v;
await this.plugin.saveSettings();
}));
new Setting(parent)
.setName('Batch Concurrency')
.setDesc('Max parallel LLM calls (1=serial). Respect API limits!')
.addSlider(s => s
.setLimits(1, 20)
.setValue(this.plugin.settings.batchConcurrency)
.setDynamicTooltip()
.onChange(async (v) => {
this.plugin.settings.batchConcurrency = Math.floor(v);
await this.plugin.saveSettings();
}));
// Similarly for batchSize (10-200), batchInterDelayMs (0-5000)
```
2) If `useReporter` is the sidebar (NotemdSidebarView), mark processing:
#### 4. Utils (`utils.ts`) - Semaphore (no deps)
```ts
const maybeSidebar = useReporter as any;
if (maybeSidebar instanceof (NotemdSidebarView as any)) {
// If you added helpers:
maybeSidebar.startProcessing('Extracting concepts...');
} else {
// For ProgressModal or other reporters:
useReporter.updateStatus('Extracting concepts...', 0);
// Lightweight semaphore
export class Semaphore {
private queue: Array<() => void> = [];
private active = 0;
constructor(private concurrency: number) {}
async acquire(): Promise<() => void> {
return new Promise(resolve => {
const release = () => {
this.active--;
const next = this.queue.shift();
next?.();
};
if (this.active < this.concurrency) {
this.active++;
resolve(release);
} else {
this.queue.push(release);
}
});
}
}
```
3) Run the core logic with try/finally:
```ts
try {
await this.loadSettings();
const activeFile = this.app.workspace.getActiveFile();
// ... validations ...
const concepts = await extractConceptsFromFile(this.app, this, activeFile, useReporter);
// ... createConceptNotes, notices, etc ...
} catch (error) {
// existing error and cancellation handling
} finally {
// Ensure sidebar knows processing ended
if (maybeSidebar instanceof (NotemdSidebarView as any)) {
maybeSidebar.finishProcessing();
export function createConcurrentProcessor(concurrency: number) {
const semaphore = new Semaphore(concurrency);
return async <T>(tasks: Array<() => Promise<T>>): Promise<Array<{success: boolean; value?: T; error?: unknown}>> => {
const results: Array<PromiseSettledResult<{success: boolean; value?: T; error?: unknown}>> = [];
for (const task of tasks) {
const release = await semaphore.acquire();
const p = task().then(v => ({success: true, value: v})).catch(e => ({success: false, error: e})).finally(release);
results.push(p);
}
this.isBusy = false;
return Promise.all(results).then(r => r.map(rr => rr.status === 'fulfilled' ? rr.value! : {success: false, error: rr.reason}));
};
}
```
4) Apply the same pattern in `batchExtractConceptsForFolderCommand`:
- When `getReporter()` returns the sidebar, call `startProcessing('Batch extracting concepts...')` before the loop and `finishProcessing()` in `finally`.
export function chunkArray<T>(arr: T[], size: number): T[][] {
return arr.reduce((acc, _, i) => {
if (i % size === 0) acc.push(arr.slice(i, i + size));
return acc;
}, [] as T[][]);
}
Result:
- No matter whether Extract Concepts is started via:
- Sidebar buttons (which already set isProcessing), or
- Command palette / hotkey (via `getReporter()` returning sidebar),
- The sidebars `isProcessing` will become true while work is running, enabling the Cancel button.
### 3.3 Do not change ProgressModal cancel logic
- ProgressModal already:
- Always shows an enabled Cancel button.
- Sets `isCancelled` and aborts when clicked.
- No changes are required for Extract Concepts when using the modal:
- It passes `abortController?.signal` to LLM calls.
- `extractConceptsFromFile` checks `cancelled` and returns early.
### 3.4 Reconfirm loop checks (for completeness)
- Keep existing checks:
In `extractConceptsFromFile`:
```ts
for (...) {
if (progressReporter.cancelled) {
throw new Error("Concept extraction cancelled by user.");
export async function retry<T>(fn: () => Promise<T>, maxRetries = 3, delayMs = 1000): Promise<T> {
for (let i = 0; i < maxRetries; i++) {
try { return await fn(); } catch (e) {
if (i === maxRetries - 1) throw e;
await delay(delayMs * Math.pow(2, i)); // Exponential backoff
}
...
}
throw new Error('Retry exhausted');
}
```
In `batchExtractConceptsForFolderCommand`:
#### 5. ProgressReporter Impl (e.g., `ui/ProgressModal.ts`)
```ts
if (useReporter.cancelled) { ... break; }
// Add to class
activeTasks = 0;
updateActiveTasks(delta: number) { this.activeTasks += delta; this.updateStatus(`Active: ${this.activeTasks}`); }
// Usage in batch: reporter.updateActiveTasks(1); ... finally { reporter.updateActiveTasks(-1); }
```
Optionally:
- Before calling `createConceptNotes` in batch mode, check `cancelled` again to avoid extra work after cancel.
#### 6. Batch Refactor Example (`fileUtils.ts` - `batchGenerateContentForTitles`)
```ts
export async function batchGenerateContentForTitles(app: App, settings: NotemdSettings, folderPath: string, progressReporter: ProgressReporter) {
// ... existing file filtering/setup ...
if (!settings.enableBatchParallelism || settings.batchConcurrency <= 1) {
// Serial fallback (existing loop)
for (...) { await generateContentForTitle(...); }
return { errors: [] };
}
## 4. Expected Behavior After Fix
const concurrency = Math.min(settings.batchConcurrency, 20); // Cap
const processor = createConcurrentProcessor(concurrency);
const fileBatches = chunkArray(filesToProcess, settings.batchSize);
- When “Extract concepts (current file)” or “Extract concepts (folder)” is run:
- If using sidebar:
- `isProcessing` is set to true.
- “Cancel processing” button becomes enabled.
- Clicking it sets `cancelled = true` and aborts the LLM calls.
- The loop in `extractConceptsFromFile` sees `cancelled` and exits.
- UI updates to “Cancelled” without error modals.
- If using modal:
- “Cancel” is enabled and works as before.
- Behavior is now:
- Consistent with Add Links.
- Clear and reliable from a users perspective.
let allErrors: {file: string; message: string}[] = [];
for (let b = 0; b < fileBatches.length; b++) {
const batch = fileBatches[b];
progressReporter.log(`Processing batch ${b+1}/${fileBatches.length} (${batch.length} files)`);
if (progressReporter.cancelled) break;
This plan is fully captured in English and ready for implementation in TODO.md or direct code changes.
const tasks = batch.map(file => () => {
progressReporter.updateActiveTasks(1);
return retry(async () => {
const content = await generateContentForTitle(app, settings, file, progressReporter);
// Move/save immediately after LLM (still serial per result, but parallel LLM)
await moveProcessedFile(file, content); // Existing logic
return { file, success: true };
}, 3);
});
const results = await processor(tasks);
allErrors.push(...results.filter(r => !r.success).map(r => ({file: r.file.name, message: String(r.error)})));
// Cleanup active
batch.forEach(() => progressReporter.updateActiveTasks(-1));
await delay(settings.batchInterDelayMs);
}
return { errors: allErrors };
}
```
- **Apply similarly** to other batches: Serialize I/O post-LLM, parallel only network-bound parts.
- **Per-task Abort**: Pass `progressReporter.abortController.signal` to LLM calls.
### Validation & Testing
- **Edge**: concurrency=1 → identical to current.
- **Perf**: Local LLM → concurrency=10+.
- **Rates**: OpenAI → cap at 5, interDelay=2000ms.
- **Tests**: Add `tests/parallelBatch.test.ts` mocking vault/LLM.

View file

@ -203,6 +203,11 @@ export const DEFAULT_SETTINGS: NotemdSettings = {
enableFocusedLearning: false,
focusedLearningDomain: '',
disableAutoTranslation: false,
// Batch processing settings
enableBatchParallelism: false,
batchConcurrency: 1,
batchSize: 50,
batchInterDelayMs: 1000,
};
// Constants for the Sidebar View

View file

@ -3,7 +3,7 @@ import { App, TFile, TFolder, Notice, Vault } from 'obsidian';
import NotemdPlugin from './main';
import { NotemdSettings, ProgressReporter } from './types';
import { DEFAULT_SETTINGS } from './constants';
import { normalizeNameForFilePath, splitContent, getProviderForTask, getModelForTask, delay } from './utils'; // Added delay import
import { normalizeNameForFilePath, splitContent, getProviderForTask, getModelForTask, delay, createConcurrentProcessor, chunkArray, retry } from './utils'; // Added delay import
import { callDeepSeekAPI, callOpenAIApi, callAnthropicApi, callGoogleApi, callMistralApi, callAzureOpenAIApi, callLMStudioApi, callOllamaApi, callOpenRouterAPI } from './llmUtils';
import { refineMermaidBlocks, cleanupLatexDelimiters } from './mermaidProcessor'; // Assuming this will be moved or imported correctly later
import { _performResearch } from './searchUtils'; // Assuming this will be moved or imported correctly later
@ -761,58 +761,110 @@ export async function batchGenerateContentForTitles(app: App, settings: NotemdSe
throw folderError instanceof Error ? folderError : new Error(errorMessage); // Re-throw
}
for (let i = 0; i < filesToProcess.length; i++) {
const file = filesToProcess[i];
const progress = Math.floor(((i) / filesToProcess.length) * 100);
progressReporter.updateStatus(`Generating ${i + 1}/${filesToProcess.length}: ${file.name}`, progress);
if (!settings.enableBatchParallelism || settings.batchConcurrency <= 1) {
// Serial fallback (existing loop)
for (let i = 0; i < filesToProcess.length; i++) {
const file = filesToProcess[i];
const progress = Math.floor(((i) / filesToProcess.length) * 100);
progressReporter.updateStatus(`Generating ${i + 1}/${filesToProcess.length}: ${file.name}`, progress);
if (progressReporter.cancelled) { progressReporter.log('Cancellation requested, stopping batch processing.'); break; }
await delay(1); // Yield
try {
await generateContentForTitle(app, settings, file, progressReporter);
if (progressReporter.cancelled) { progressReporter.log('Cancellation requested, stopping batch processing.'); break; }
await delay(1); // Yield
// Move Successfully Processed File
try {
const normalizedCompletePathForMove = completeFolderPath ? (completeFolderPath.endsWith('/') ? completeFolderPath : completeFolderPath + '/') : '';
const destinationPath = `${normalizedCompletePathForMove}${file.name}`;
const destExists = await app.vault.adapter.exists(destinationPath);
if (destExists) { progressReporter.log(`⚠️ File already exists at destination, skipping move: ${destinationPath}`); }
else {
const sourceExists = await app.vault.adapter.exists(file.path);
if (sourceExists) {
if (progressReporter.cancelled) { progressReporter.log(`⚠️ Cancellation requested before moving ${file.name}. Skipping move.`); break; }
else { await app.vault.rename(file, destinationPath); progressReporter.log(`✅ Moved processed file to: ${destinationPath}`); }
} else { progressReporter.log(`⚠️ Source file ${file.path} not found, skipping move.`); }
}
} catch (moveError: unknown) { // Changed to unknown
const errorMessage = moveError instanceof Error ? moveError.message : String(moveError);
const moveErrorMsg = `Error moving processed file ${file.name} to ${completeFolderPath}: ${errorMessage}`;
console.error(moveErrorMsg, moveError); progressReporter.log(`${moveErrorMsg}`);
errors.push({ file: file.name, message: `Failed to move after generation: ${errorMessage}` });
}
} catch (fileError: unknown) { // Changed to unknown
const errorMessage = fileError instanceof Error ? fileError.message : String(fileError);
const errorMsg = `Error generating content for ${file.name}: ${errorMessage}`;
console.error(errorMsg, fileError); progressReporter.log(`${errorMsg}`);
errors.push({ file: file.name, message: errorMessage });
// Log error silently
const timestamp = new Date().toISOString();
const errorDetails = fileError instanceof Error ? fileError.stack || fileError.message : String(fileError);
const logEntry = `[${timestamp}] Error generating content for ${file.path}:\nMessage: ${errorMessage}\nStack Trace:\n${errorDetails}\n\n`;
try { await app.vault.adapter.append('error_processing_filename.log', logEntry); }
catch (logError: unknown) { // Changed to unknown
const logErrorMessage = logError instanceof Error ? logError.message : String(logError);
console.error("Failed to write to error_processing_filename.log:", logError);
progressReporter.log(`⚠️ Failed to write error details to log file: ${logErrorMessage}`);
}
await generateContentForTitle(app, settings, file, progressReporter);
await delay(1); // Yield
if (errorMessage.includes("cancelled by user")) { break; }
// Move Successfully Processed File
try {
const normalizedCompletePathForMove = completeFolderPath ? (completeFolderPath.endsWith('/') ? completeFolderPath : completeFolderPath + '/') : '';
const destinationPath = `${normalizedCompletePathForMove}${file.name}`;
const destExists = await app.vault.adapter.exists(destinationPath);
if (destExists) { progressReporter.log(`⚠️ File already exists at destination, skipping move: ${destinationPath}`); }
else {
const sourceExists = await app.vault.adapter.exists(file.path);
if (sourceExists) {
if (progressReporter.cancelled) { progressReporter.log(`⚠️ Cancellation requested before moving ${file.name}. Skipping move.`); break; }
else { await app.vault.rename(file, destinationPath); progressReporter.log(`✅ Moved processed file to: ${destinationPath}`); }
} else { progressReporter.log(`⚠️ Source file ${file.path} not found, skipping move.`); }
}
} catch (moveError: unknown) { // Changed to unknown
const errorMessage = moveError instanceof Error ? moveError.message : String(moveError);
const moveErrorMsg = `Error moving processed file ${file.name} to ${completeFolderPath}: ${errorMessage}`;
console.error(moveErrorMsg, moveError); progressReporter.log(`${moveErrorMsg}`);
errors.push({ file: file.name, message: `Failed to move after generation: ${errorMessage}` });
}
} catch (fileError: unknown) { // Changed to unknown
const errorMessage = fileError instanceof Error ? fileError.message : String(fileError);
const errorMsg = `Error generating content for ${file.name}: ${errorMessage}`;
console.error(errorMsg, fileError); progressReporter.log(`${errorMsg}`);
errors.push({ file: file.name, message: errorMessage });
// Log error silently
const timestamp = new Date().toISOString();
const errorDetails = fileError instanceof Error ? fileError.stack || fileError.message : String(fileError);
const logEntry = `[${timestamp}] Error generating content for ${file.path}:\nMessage: ${errorMessage}\nStack Trace:\n${errorDetails}\n\n`;
try { await app.vault.adapter.append('error_processing_filename.log', logEntry); }
catch (logError: unknown) { // Changed to unknown
const logErrorMessage = logError instanceof Error ? logError.message : String(logError);
console.error("Failed to write to error_processing_filename.log:", logError);
progressReporter.log(`⚠️ Failed to write error details to log file: ${logErrorMessage}`);
}
if (errorMessage.includes("cancelled by user")) { break; }
}
if (progressReporter.cancelled) { break; }
}
if (progressReporter.cancelled) { break; }
return { errors }; // Return collected errors
}
return { errors }; // Return collected errors
const concurrency = Math.min(settings.batchConcurrency, 20); // Cap
const processor = createConcurrentProcessor(concurrency);
const fileBatches = chunkArray(filesToProcess, settings.batchSize);
let allErrors: { file: string; message: string }[] = [];
for (let b = 0; b < fileBatches.length; b++) {
const batch = fileBatches[b];
progressReporter.log(`Processing batch ${b + 1}/${fileBatches.length} (${batch.length} files)`);
if (progressReporter.cancelled) break;
const tasks = batch.map(file => ({
task: () => {
progressReporter.updateActiveTasks(1);
return retry(async () => {
await generateContentForTitle(app, settings, file, progressReporter);
// Move/save immediately after LLM (still serial per result, but parallel LLM)
const normalizedCompletePathForMove = completeFolderPath ? (completeFolderPath.endsWith('/') ? completeFolderPath : completeFolderPath + '/') : '';
const destinationPath = `${normalizedCompletePathForMove}${file.name}`;
const destExists = await app.vault.adapter.exists(destinationPath);
if (destExists) {
progressReporter.log(`⚠️ File already exists at destination, skipping move: ${destinationPath}`);
} else {
const sourceExists = await app.vault.adapter.exists(file.path);
if (sourceExists) {
if (progressReporter.cancelled) {
progressReporter.log(`⚠️ Cancellation requested before moving ${file.name}. Skipping move.`);
} else {
await app.vault.rename(file, destinationPath);
progressReporter.log(`✅ Moved processed file to: ${destinationPath}`);
}
} else {
progressReporter.log(`⚠️ Source file ${file.path} not found, skipping move.`);
}
}
return { file, success: true };
}, 3);
},
file
}));
const results = await processor(tasks);
allErrors.push(...results.filter(r => !r.success).map(r => ({ file: r.file.name, message: String(r.error) })));
// Cleanup active
batch.forEach(() => progressReporter.updateActiveTasks(-1));
await delay(settings.batchInterDelayMs);
}
return { errors: allErrors };
}
/**

View file

@ -1,7 +1,7 @@
import { App, Editor, MarkdownView, Modal, Notice, Plugin, TFile, TFolder, PluginSettingTab, Setting, WorkspaceLeaf } from 'obsidian';
import { NotemdSettings, ProgressReporter, LLMProviderConfig, TaskKey } from './types';
import { DEFAULT_SETTINGS, NOTEMD_SIDEBAR_VIEW_TYPE, NOTEMD_SIDEBAR_DISPLAY_TEXT, NOTEMD_SIDEBAR_ICON } from './constants';
import { delay } from './utils';
import { delay, createConcurrentProcessor, chunkArray, retry } from './utils';
import { testAPI, callLLM } from './llmUtils';
import {
handleFileRename,
@ -559,31 +559,59 @@ export default class NotemdPlugin extends Plugin {
useReporter.log(`Starting batch processing for ${files.length} files in "${folderPath}"...`);
const errors: { file: string; message: string }[] = [];
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (useReporter.cancelled) { new Notice('Batch processing cancelled.'); this.updateStatusBar('Cancelled'); useReporter.updateStatus('Cancelled', -1); break; }
if (!this.settings.enableBatchParallelism || this.settings.batchConcurrency <= 1) {
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (useReporter.cancelled) { new Notice('Batch processing cancelled.'); this.updateStatusBar('Cancelled'); useReporter.updateStatus('Cancelled', -1); break; }
const progress = Math.floor(((i) / files.length) * 100);
useReporter.updateStatus(`Processing ${i + 1}/${files.length}: ${file.name}`, progress);
const progress = Math.floor(((i) / files.length) * 100);
useReporter.updateStatus(`Processing ${i + 1}/${files.length}: ${file.name}`, progress);
try {
// Pass the ref object
await processFile(this.app, this.settings, file, useReporter, this.currentProcessingFileBasename);
} catch (fileError: unknown) {
const message = fileError instanceof Error ? fileError.message : String(fileError);
const stack = fileError instanceof Error ? fileError.stack : undefined;
const errorMsg = `Error processing ${file.name}: ${message}`;
console.error(errorMsg, fileError);
useReporter.log(`${errorMsg}`);
errors.push({ file: file.name, message: message });
// Log silently
const timestamp = new Date().toISOString();
const logEntry = `[${timestamp}] Error processing ${file.path}:\nMessage: ${message}\nStack Trace:\n${stack || fileError}\n\n`;
try { await this.app.vault.adapter.append('error_processing_filename.log', logEntry); }
catch (logError) { console.error("Failed to write to error log:", logError); useReporter.log("⚠️ Failed to write error details to log file."); }
if (message.includes("cancelled by user")) break; // Exit loop on cancellation
try {
// Pass the ref object
await processFile(this.app, this.settings, file, useReporter, this.currentProcessingFileBasename);
} catch (fileError: unknown) {
const message = fileError instanceof Error ? fileError.message : String(fileError);
const stack = fileError instanceof Error ? fileError.stack : undefined;
const errorMsg = `Error processing ${file.name}: ${message}`;
console.error(errorMsg, fileError);
useReporter.log(`${errorMsg}`);
errors.push({ file: file.name, message: message });
// Log silently
const timestamp = new Date().toISOString();
const logEntry = `[${timestamp}] Error processing ${file.path}:\nMessage: ${message}\nStack Trace:\n${stack || fileError}\n\n`;
try { await this.app.vault.adapter.append('error_processing_filename.log', logEntry); }
catch (logError) { console.error("Failed to write to error log:", logError); useReporter.log("⚠️ Failed to write error details to log file."); }
if (message.includes("cancelled by user")) break; // Exit loop on cancellation
}
} // End loop
} else {
const concurrency = Math.min(this.settings.batchConcurrency, 20); // Cap
const processor = createConcurrentProcessor(concurrency);
const fileBatches = chunkArray(files, this.settings.batchSize);
for (let b = 0; b < fileBatches.length; b++) {
const batch = fileBatches[b];
useReporter.log(`Processing batch ${b + 1}/${fileBatches.length} (${batch.length} files)`);
if (useReporter.cancelled) break;
const tasks = batch.map(file => ({
task: () => {
useReporter.updateActiveTasks(1);
return retry(() => processFile(this.app, this.settings, file, useReporter, this.currentProcessingFileBasename), 3);
},
file
}));
const results = await processor(tasks);
errors.push(...results.filter(r => !r.success).map(r => ({ file: r.file.name, message: String(r.error) })));
// Cleanup active
batch.forEach(() => useReporter.updateActiveTasks(-1));
await delay(this.settings.batchInterDelayMs);
}
} // End loop
}
if (!useReporter.cancelled) {
if (errors.length > 0) {
@ -1161,36 +1189,79 @@ export default class NotemdPlugin extends Plugin {
const errors: { file: string; message: string }[] = [];
let totalConcepts = 0;
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (useReporter.cancelled) { new Notice('Batch extraction cancelled.'); break; }
if (!this.settings.enableBatchParallelism || this.settings.batchConcurrency <= 1) {
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (useReporter.cancelled) { new Notice('Batch extraction cancelled.'); break; }
const progress = Math.floor(((i) / files.length) * 100);
useReporter.updateStatus(`Processing ${i + 1}/${files.length}: ${file.name}`, progress);
const progress = Math.floor(((i) / files.length) * 100);
useReporter.updateStatus(`Processing ${i + 1}/${files.length}: ${file.name}`, progress);
try {
const concepts = await extractConceptsFromFile(this.app, this, file, useReporter);
if (concepts.size > 0) {
totalConcepts += concepts.size;
await createConceptNotes(
this.app,
this.settings,
concepts,
file.basename, // Pass the basename of the current file
{
disableBacklink: !this.settings.extractConceptsAddBacklink,
minimalTemplate: this.settings.extractConceptsMinimalTemplate
}
);
try {
const concepts = await extractConceptsFromFile(this.app, this, file, useReporter);
if (concepts.size > 0) {
totalConcepts += concepts.size;
await createConceptNotes(
this.app,
this.settings,
concepts,
file.basename, // Pass the basename of the current file
{
disableBacklink: !this.settings.extractConceptsAddBacklink,
minimalTemplate: this.settings.extractConceptsMinimalTemplate
}
);
}
} catch (fileError: unknown) {
const message = fileError instanceof Error ? fileError.message : String(fileError);
errors.push({ file: file.name, message: message });
useReporter.log(`❌ Error processing ${file.name}: ${message}`);
if (message.includes("cancelled by user")) break;
}
} catch (fileError: unknown) {
const message = fileError instanceof Error ? fileError.message : String(fileError);
errors.push({ file: file.name, message: message });
useReporter.log(`❌ Error processing ${file.name}: ${message}`);
if (message.includes("cancelled by user")) break;
}
} else {
const concurrency = Math.min(this.settings.batchConcurrency, 20); // Cap
const processor = createConcurrentProcessor(concurrency);
const fileBatches = chunkArray(files, this.settings.batchSize);
for (let b = 0; b < fileBatches.length; b++) {
const batch = fileBatches[b];
useReporter.log(`Processing batch ${b + 1}/${fileBatches.length} (${batch.length} files)`);
if (useReporter.cancelled) break;
const tasks = batch.map(file => ({
task: () => {
useReporter.updateActiveTasks(1);
return retry(async () => {
const concepts = await extractConceptsFromFile(this.app, this, file, useReporter);
if (concepts.size > 0) {
totalConcepts += concepts.size;
await createConceptNotes(
this.app,
this.settings,
concepts,
file.basename, // Pass the basename of the current file
{
disableBacklink: !this.settings.extractConceptsAddBacklink,
minimalTemplate: this.settings.extractConceptsMinimalTemplate
}
);
}
}, 3);
},
file
}));
const results = await processor(tasks);
errors.push(...results.filter(r => !r.success).map(r => ({ file: r.file.name, message: String(r.error) })));
// Cleanup active
batch.forEach(() => useReporter.updateActiveTasks(-1));
await delay(this.settings.batchInterDelayMs);
}
}
if (!useReporter.cancelled) {
if (errors.length > 0) {
const errorSummary = `Batch extraction finished with ${errors.length} error(s).`;

View file

@ -5,8 +5,8 @@ export const mockApp = {
create: jest.fn(),
createFolder: jest.fn(),
getAbstractFileByPath: jest.fn(),
read: jest.fn(),
modify: jest.fn(),
read: jest.fn().mockResolvedValue(''),
modify: jest.fn().mockResolvedValue(undefined),
rename: jest.fn(),
delete: jest.fn(),
trash: jest.fn(),
@ -15,11 +15,11 @@ export const mockApp = {
getName: jest.fn(() => 'MyVault'),
configDir: '.obsidian',
adapter: {
exists: jest.fn(),
write: jest.fn(),
read: jest.fn(),
mkdir: jest.fn(),
append: jest.fn(), // Added
exists: jest.fn().mockResolvedValue(false),
write: jest.fn().mockResolvedValue(undefined),
read: jest.fn().mockResolvedValue(''),
mkdir: jest.fn().mockResolvedValue(undefined),
append: jest.fn().mockResolvedValue(undefined), // Added
stat: jest.fn(), // Added
},
on: jest.fn(), // Added

View file

@ -77,4 +77,8 @@ export const mockSettings: NotemdSettings = {
extractConceptsMinimalTemplate: true,
extractConceptsAddBacklink: false,
disableAutoTranslation: false,
enableBatchParallelism: false,
batchConcurrency: 1,
batchSize: 50,
batchInterDelayMs: 1000,
};

View file

@ -0,0 +1,99 @@
import { App, TFile, TFolder } from 'obsidian';
import { NotemdSettings, ProgressReporter } from '../types';
import { batchGenerateContentForTitles } from '../fileUtils';
import { DEFAULT_SETTINGS } from '../constants';
// Mock dependencies
jest.mock('../llmUtils', () => ({
callDeepSeekAPI: jest.fn().mockResolvedValue('processed content'),
}));
jest.mock('../utils', () => ({
...jest.requireActual('../utils'),
delay: jest.fn().mockResolvedValue(undefined),
}));
const mockApp = {
vault: {
getAbstractFileByPath: jest.fn(),
getMarkdownFiles: jest.fn(),
createFolder: jest.fn(),
rename: jest.fn(),
adapter: {
exists: jest.fn(),
stat: jest.fn(),
}
},
} as unknown as App;
const mockProgressReporter: ProgressReporter = {
log: jest.fn(),
updateStatus: jest.fn(),
requestCancel: jest.fn(),
clearDisplay: jest.fn(),
get cancelled() { return false; },
abortController: new AbortController(),
activeTasks: 0,
updateActiveTasks: jest.fn(),
};
describe('batchGenerateContentForTitles', () => {
let settings: NotemdSettings;
beforeEach(() => {
settings = { ...DEFAULT_SETTINGS };
jest.clearAllMocks();
});
it('should process files in parallel when enabled', async () => {
settings.enableBatchParallelism = true;
settings.batchConcurrency = 2;
settings.batchSize = 2;
const files = [
{ path: 'folder/file1.md', name: 'file1.md' },
{ path: 'folder/file2.md', name: 'file2.md' },
] as TFile[];
(mockApp.vault.getAbstractFileByPath as jest.Mock).mockImplementation((path: string) => {
if (path === 'folder') {
const folder = new TFolder();
Object.assign(folder, { path: 'folder', children: [] });
return folder;
}
return null;
});
(mockApp.vault.getMarkdownFiles as jest.Mock).mockReturnValue(files);
(mockApp.vault.adapter.exists as jest.Mock).mockResolvedValue(false);
await batchGenerateContentForTitles(mockApp, settings, 'folder', mockProgressReporter);
expect(mockProgressReporter.log).toHaveBeenCalledWith('Processing batch 1/1 (2 files)');
expect(mockProgressReporter.updateActiveTasks).toHaveBeenCalledTimes(4); // 2 tasks, each call increments and decrements
});
it('should process files serially when disabled', async () => {
settings.enableBatchParallelism = false;
const files = [
{ path: 'folder/file1.md', name: 'file1.md' },
{ path: 'folder/file2.md', name: 'file2.md' },
] as TFile[];
(mockApp.vault.getAbstractFileByPath as jest.Mock).mockImplementation((path: string) => {
if (path === 'folder') {
const folder = new TFolder();
Object.assign(folder, { path: 'folder', children: [] });
return folder;
}
return null;
});
(mockApp.vault.getMarkdownFiles as jest.Mock).mockReturnValue(files);
(mockApp.vault.adapter.exists as jest.Mock).mockResolvedValue(false);
await batchGenerateContentForTitles(mockApp, settings, 'folder', mockProgressReporter);
expect(mockProgressReporter.updateStatus).toHaveBeenCalledWith('Generating 1/2: file1.md', 0);
expect(mockProgressReporter.updateStatus).toHaveBeenCalledWith('Generating 2/2: file2.md', 50);
});
});

View file

@ -11,7 +11,10 @@ describe('processContentWithLLM', () => {
updateStatus: jest.fn(),
requestCancel: jest.fn(),
clearDisplay: jest.fn(),
get cancelled() { return false; } // Default to not cancelled for tests
get cancelled() { return false; }, // Default to not cancelled for tests
activeTasks: 0,
updateActiveTasks: jest.fn(),
abortController: new AbortController(),
};
beforeEach(() => {

View file

@ -11,7 +11,10 @@ const mockReporter: ProgressReporter = {
updateStatus: jest.fn(),
requestCancel: jest.fn(),
clearDisplay: jest.fn(),
get cancelled() { return false; }
get cancelled() { return false; },
activeTasks: 0,
updateActiveTasks: jest.fn(),
abortController: new AbortController(),
};
describe('processFile', () => {
@ -59,7 +62,7 @@ describe('processFile', () => {
await processFile(mockApp, settings, mockFile, mockReporter, { value: null });
expect(mockApp.vault.create).toHaveBeenCalledWith('Concepts/AI.md', '# AI');
expect(mockApp.vault.create).toHaveBeenCalledWith('Concepts/machine learning.md', '# machine learning');
expect(mockApp.vault.create).toHaveBeenCalledWith('Concepts/AI.md', '# AI\n\n## Linked From\n- [[test]]');
expect(mockApp.vault.create).toHaveBeenCalledWith('Concepts/machine learning.md', '# machine learning\n\n## Linked From\n- [[test]]');
});
});

View file

@ -111,6 +111,12 @@ export interface NotemdSettings {
// Language / translation behavior
disableAutoTranslation: boolean; // true => only explicit "Translate" task performs translation
// Batch processing settings
enableBatchParallelism: boolean;
batchConcurrency: number;
batchSize: number;
batchInterDelayMs: number;
}
// Defines the keys for tasks that can have custom prompts
@ -197,4 +203,6 @@ export interface ProgressReporter {
get cancelled(): boolean;
// Property to hold the AbortController for the current fetch
abortController?: AbortController | null;
activeTasks: number; // NEW: For concurrency display
updateActiveTasks(delta: number): void; // NEW
}

View file

@ -474,6 +474,56 @@ export class NotemdSettingTab extends PluginSettingTab {
new Setting(containerEl).setName('Max research content tokens').setDesc('Approx. max tokens from web results for summarization prompt.').addText(text => text.setPlaceholder(String(DEFAULT_SETTINGS.maxResearchContentTokens)).setValue(String(this.plugin.settings.maxResearchContentTokens)).onChange(async (value) => { const num = parseInt(value, 10); if (!isNaN(num) && num > 100) { this.plugin.settings.maxResearchContentTokens = num; } else { this.plugin.settings.maxResearchContentTokens = DEFAULT_SETTINGS.maxResearchContentTokens; } await this.plugin.saveSettings(); this.display(); }));
new Setting(containerEl).setName('Processing parameters').setHeading();
new Setting(containerEl)
.setName('Enable Batch Parallelism')
.setDesc('Allow parallel LLM calls for faster batch processing.')
.addToggle(t => t
.setValue(this.plugin.settings.enableBatchParallelism)
.onChange(async (v) => {
this.plugin.settings.enableBatchParallelism = v;
await this.plugin.saveSettings();
this.display();
}));
if (this.plugin.settings.enableBatchParallelism) {
new Setting(containerEl)
.setName('Batch Concurrency')
.setDesc('Max parallel LLM calls (1=serial). Respect API limits!')
.addSlider(s => s
.setLimits(1, 20, 1)
.setValue(this.plugin.settings.batchConcurrency)
.setDynamicTooltip()
.onChange(async (v) => {
this.plugin.settings.batchConcurrency = Math.floor(v);
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Batch Size')
.setDesc('Files per batch (balances memory/rates).')
.addSlider(s => s
.setLimits(10, 200, 10)
.setValue(this.plugin.settings.batchSize)
.setDynamicTooltip()
.onChange(async (v) => {
this.plugin.settings.batchSize = Math.floor(v);
await this.plugin.saveSettings();
}));
new Setting(containerEl)
.setName('Delay Between Batches (ms)')
.setDesc('Delay between batches (ms, for rate limits).')
.addSlider(s => s
.setLimits(0, 5000, 100)
.setValue(this.plugin.settings.batchInterDelayMs)
.setDynamicTooltip()
.onChange(async (v) => {
this.plugin.settings.batchInterDelayMs = Math.floor(v);
await this.plugin.saveSettings();
}));
}
new Setting(containerEl).setName('Chunk word count').setDesc('Max words per chunk sent to LLM.').addText(text => text.setPlaceholder(String(DEFAULT_SETTINGS.chunkWordCount)).setValue(String(this.plugin.settings.chunkWordCount)).onChange(async (value) => { const num = parseInt(value, 10); if (!isNaN(num) && num > 50) { this.plugin.settings.chunkWordCount = num; } else { this.plugin.settings.chunkWordCount = DEFAULT_SETTINGS.chunkWordCount; } await this.plugin.saveSettings(); this.display(); }));
new Setting(containerEl).setName('Enable duplicate detection').setDesc('Enable checks for duplicate terms (results in console).').addToggle(toggle => toggle.setValue(this.plugin.settings.enableDuplicateDetection).onChange(async (value) => { this.plugin.settings.enableDuplicateDetection = value; await this.plugin.saveSettings(); }));
new Setting(containerEl).setName('Max tokens').setDesc('Max tokens LLM should generate per response.').addText(text => text.setPlaceholder(String(DEFAULT_SETTINGS.maxTokens)).setValue(String(this.plugin.settings.maxTokens)).onChange(async (value) => { const num = parseInt(value, 10); if (!isNaN(num) && num > 0) { this.plugin.settings.maxTokens = num; } else { this.plugin.settings.maxTokens = DEFAULT_SETTINGS.maxTokens; } await this.plugin.saveSettings(); this.display(); }));

View file

@ -21,6 +21,7 @@ export class NotemdSidebarView extends ItemView implements ProgressReporter {
// Store the AbortController for the current operation
private currentAbortController: AbortController | null = null;
private activeLeafChangeHandler: (() => void) | null = null; // Store handler reference
activeTasks = 0;
// --- Button References ---
private processCurrentButton: HTMLButtonElement | null = null;
@ -44,6 +45,11 @@ export class NotemdSidebarView extends ItemView implements ProgressReporter {
this.plugin = plugin;
}
updateActiveTasks(delta: number): void {
this.activeTasks += delta;
this.updateStatus(`Active: ${this.activeTasks}`);
}
getViewType() {
return NOTEMD_SIDEBAR_VIEW_TYPE;
}

View file

@ -13,11 +13,17 @@ export class ProgressModal extends Modal implements ProgressReporter {
private timeRemainingEl: HTMLElement;
// Store the AbortController for the current operation
private currentAbortController: AbortController | null = null;
activeTasks = 0;
constructor(app: App) {
super(app);
}
updateActiveTasks(delta: number) {
this.activeTasks += delta;
this.updateStatus(`Active: ${this.activeTasks}`);
}
onOpen() {
const { contentEl } = this;
contentEl.addClass('notemd-progress-modal');

View file

@ -161,3 +161,76 @@ export function getModelForTask(taskType: 'addLinks' | 'research' | 'generateTit
return modelName || provider.model; // Ensure valid string
}
// Lightweight semaphore
export class Semaphore {
private queue: Array<() => void> = [];
private active = 0;
constructor(private concurrency: number) { }
async acquire(): Promise<() => void> {
return new Promise(resolve => {
const release = () => {
this.active--;
const next = this.queue.shift();
if (next) {
next();
}
};
if (this.active < this.concurrency) {
this.active++;
resolve(release);
} else {
this.queue.push(() => {
this.active++;
resolve(release);
});
}
});
}
}
export function createConcurrentProcessor(concurrency: number) {
const semaphore = new Semaphore(concurrency);
return async <T>(tasks: Array<{ task: () => Promise<T>, file: any }>): Promise<Array<{ success: boolean; value?: T; error?: unknown, file: any }>> => {
const results: Array<Promise<{ success: boolean; value?: T; error?: unknown, file: any }>> = [];
for (const { task, file } of tasks) {
const p = new Promise<{ success: boolean; value?: T; error?: unknown, file: any }>(async (resolve) => {
const release = await semaphore.acquire();
try {
const value = await task();
resolve({ success: true, value, file });
} catch (error) {
resolve({ success: false, error, file });
} finally {
release();
}
});
results.push(p);
}
return Promise.all(results);
};
}
export function chunkArray<T>(arr: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < arr.length; i += size) {
chunks.push(arr.slice(i, i + size));
}
return chunks;
}
export async function retry<T>(fn: () => Promise<T>, maxRetries = 3, delayMs = 1000): Promise<T> {
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
} catch (e) {
if (i === maxRetries - 1) throw e;
await delay(delayMs * Math.pow(2, i)); // Exponential backoff
}
}
throw new Error('Retry exhausted');
}