Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion docs/IPC_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ Handle file operations (upload, download, dialog).
|--------|-------------|
| `window.api.file.selectDialog()` | Open file selection dialog |
| `window.api.file.upload(taskId, filePath)` | Upload file |
| `window.api.file.uploadMultiple(taskId, filePaths[])` | Upload multiple files |
| `window.api.file.uploadFileContent(taskId, fileName, fileBuffer)` | Upload file content as ArrayBuffer |
| `window.api.file.getImagePath(taskId, page)` | Get image path for a page |
| `window.api.file.downloadMarkdown(taskId)` | Download merged markdown file |
Expand Down
6 changes: 3 additions & 3 deletions src/core/domain/repositories/TaskRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,16 @@ const findById = async (id: string) => {
const create = async (task: Task) => {
return await prisma.task.create({
data: {
id: uuidv4(),
id: task?.id || uuidv4(),
filename: task?.filename || '',
type: task?.type || '',
page_range: task?.page_range || '',
pages: task?.pages || 0,
provider: task?.provider || 0,
model: task?.model || '',
model_name: task?.model_name || '',
progress: 0,
status: 0,
progress: task?.progress ?? 0,
status: task?.status ?? 0,
}
});
};
Expand Down
91 changes: 91 additions & 0 deletions src/core/infrastructure/adapters/split/FileWaitUtil.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { promises as fs } from 'fs';
import path from 'path';

/**
* Utility for waiting on file availability.
*
* On Windows, antivirus software may temporarily lock newly copied files for scanning,
* or filesystem operations may have slight delays. This utility retries file access
* checks before proceeding with actual file processing.
*/
export class FileWaitUtil {
private static readonly MAX_ATTEMPTS = 5;
private static readonly DELAY_MS = 1000;

/**
* Wait for a file to become available and non-empty.
*
* @param filePath - Full path to the file
* @param uploadsDir - Base uploads directory for diagnostic logging
* @param taskId - Task ID for diagnostic logging
* @param filename - Original filename for error messages
* @param label - Log label (e.g., 'PDFSplitter', 'ImageSplitter')
* @throws Error if the file is not found after all retries
*/
static async waitForFile(
filePath: string,
uploadsDir: string,
taskId: string,
filename: string,
label: string
): Promise<void> {
for (let attempt = 1; attempt <= this.MAX_ATTEMPTS; attempt++) {
try {
await fs.access(filePath);
const stats = await fs.stat(filePath);
if (stats.size > 0) {
if (attempt > 1) {
console.log(
`[${label}] File became available on attempt ${attempt}: ${filePath}`
);
}
return;
}
console.warn(
`[${label}] File exists but is empty (attempt ${attempt}/${this.MAX_ATTEMPTS}): ${filePath}`
);
} catch {
console.warn(
`[${label}] File not accessible (attempt ${attempt}/${this.MAX_ATTEMPTS}): ${filePath}`
);
}

if (attempt < this.MAX_ATTEMPTS) {
await new Promise((resolve) => setTimeout(resolve, this.DELAY_MS));
}
}

// Log diagnostic info before throwing
await this.logDiagnostics(uploadsDir, taskId, filename, label);

throw new Error(
`${label === 'PDFSplitter' ? 'PDF' : 'Image'} file not found: ${filename}. The file may have been moved or deleted.`
);
}

/**
* Log diagnostic information about the task directory contents.
*/
private static async logDiagnostics(
uploadsDir: string,
taskId: string,
filename: string,
label: string
): Promise<void> {
const taskDir = path.join(uploadsDir, taskId);
try {
const dirExists = await fs.stat(taskDir).then(() => true).catch(() => false);
if (dirExists) {
const files = await fs.readdir(taskDir);
console.error(
`[${label}] Task directory exists but target file not found. ` +
`Dir: ${taskDir}, Files in dir: [${files.join(', ')}], Expected: ${filename}`
);
} else {
console.error(`[${label}] Task directory does not exist: ${taskDir}`);
}
} catch (diagError) {
console.error(`[${label}] Diagnostic check failed:`, diagError);
}
}
}
7 changes: 4 additions & 3 deletions src/core/infrastructure/adapters/split/ImageSplitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from 'path';
import { ISplitter, SplitResult, PageInfo } from '../../../domain/split/ISplitter.js';
import { Task } from '../../../../shared/types/index.js';
import { ImagePathUtil } from './ImagePathUtil.js';
import { FileWaitUtil } from './FileWaitUtil.js';

/**
* Image splitter implementation for single-page image files.
Expand Down Expand Up @@ -41,10 +42,10 @@ export class ImageSplitter implements ISplitter {
const filename = task.filename;
const sourcePath = path.join(this.uploadsDir, taskId, filename);

try {
// Validate source file exists
await fs.access(sourcePath);
// Pre-check: wait for file to become available (handles antivirus scanning delays on Windows)
await FileWaitUtil.waitForFile(sourcePath, this.uploadsDir, taskId, filename, 'ImageSplitter');

try {
// Get file extension (preserve original format)
const ext = path.extname(filename).toLowerCase();
if (!ext) {
Expand Down
5 changes: 5 additions & 0 deletions src/core/infrastructure/adapters/split/PDFSplitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ISplitter, SplitResult, PageInfo } from '../../../domain/split/ISplitte
import { Task } from '../../../../shared/types/index.js';
import { PageRangeParser } from '../../../domain/split/PageRangeParser.js';
import { ImagePathUtil } from './ImagePathUtil.js';
import { FileWaitUtil } from './FileWaitUtil.js';
import { WORKER_CONFIG } from '../../config/worker.config.js';

/**
Expand Down Expand Up @@ -43,6 +44,10 @@ export class PDFSplitter implements ISplitter {
const filename = task.filename;
const sourcePath = path.join(this.uploadsDir, taskId, filename);

// Pre-check: verify source file exists before processing
// Retry with short delays to handle antivirus scanning or filesystem sync delays
await FileWaitUtil.waitForFile(sourcePath, this.uploadsDir, taskId, filename, 'PDFSplitter');

try {
// Step 1: Get total page count with retry
const totalPages = await this.getPDFPageCountWithRetry(sourcePath);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { ImageSplitter } from '../ImageSplitter.js';
import { ImagePathUtil } from '../ImagePathUtil.js';
import { FileWaitUtil } from '../FileWaitUtil.js';
import { promises as fs } from 'fs';
import path from 'path';

// Mock FileWaitUtil
vi.mock('../FileWaitUtil.js', () => ({
FileWaitUtil: {
waitForFile: vi.fn(),
},
}));

// Mock fs promises
vi.mock('fs', () => ({
promises: {
access: vi.fn(),
stat: vi.fn(),
readdir: vi.fn(),
mkdir: vi.fn(),
copyFile: vi.fn(),
rm: vi.fn(),
Expand All @@ -27,6 +37,9 @@ describe('ImageSplitter', () => {

// Reset mocks
vi.clearAllMocks();

// Setup default mock for FileWaitUtil (file available immediately)
vi.mocked(FileWaitUtil.waitForFile).mockResolvedValue(undefined);
});

afterEach(() => {
Expand Down Expand Up @@ -189,7 +202,10 @@ describe('ImageSplitter', () => {
filename: 'missing.jpg',
};

vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT: no such file'));
// Mock FileWaitUtil to reject (file not found)
vi.mocked(FileWaitUtil.waitForFile).mockRejectedValue(
new Error('Image file not found: missing.jpg. The file may have been moved or deleted.')
);

await expect(splitter.split(task)).rejects.toThrow(/Image file not found/);
});
Expand All @@ -200,7 +216,9 @@ describe('ImageSplitter', () => {
filename: 'photo.jpg',
};

vi.mocked(fs.access).mockRejectedValue(new Error('EACCES: permission denied'));
// FileWaitUtil passes, but file copy fails with permission denied
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.copyFile).mockRejectedValue(new Error('EACCES: permission denied'));

await expect(splitter.split(task)).rejects.toThrow(/Permission denied/);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ vi.mock('pdf-lib', () => ({
},
}));

// Mock FileWaitUtil
vi.mock('../FileWaitUtil.js', () => ({
FileWaitUtil: {
waitForFile: vi.fn(),
},
}));

// Mock fs promises
vi.mock('fs', () => ({
promises: {
Expand All @@ -24,11 +31,15 @@ vi.mock('fs', () => ({
rm: vi.fn(),
unlink: vi.fn(),
readFile: vi.fn(),
access: vi.fn(),
stat: vi.fn(),
readdir: vi.fn(),
},
}));

import { pdfToPng } from 'pdf-to-png-converter';
import { PDFDocument } from 'pdf-lib';
import { FileWaitUtil } from '../FileWaitUtil.js';

describe('PDFSplitter', () => {
const uploadsDir = '/mock/uploads';
Expand All @@ -44,6 +55,9 @@ describe('PDFSplitter', () => {
// Reset mocks
vi.clearAllMocks();

// Setup default mock for FileWaitUtil (file available immediately)
vi.mocked(FileWaitUtil.waitForFile).mockResolvedValue(undefined);

// Setup default mocks for PDFDocument
vi.mocked(fs.readFile).mockResolvedValue(Buffer.from('mock-pdf-bytes'));
vi.mocked(PDFDocument.load).mockResolvedValue({
Expand Down Expand Up @@ -191,8 +205,10 @@ describe('PDFSplitter', () => {
page_range: '',
};

// Mock fs.readFile to throw file not found error
vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT: no such file'));
// Mock FileWaitUtil to reject (file not found)
vi.mocked(FileWaitUtil.waitForFile).mockRejectedValue(
new Error('PDF file not found: missing.pdf. The file may have been moved or deleted.')
);

await expect(splitter.split(task)).rejects.toThrow(/PDF file not found/);
});
Expand Down
1 change: 1 addition & 0 deletions src/core/infrastructure/adapters/split/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Infrastructure layer exports - implementations with external dependencies
export { ImagePathUtil } from './ImagePathUtil.js';
export { FileWaitUtil } from './FileWaitUtil.js';
export { PDFSplitter } from './PDFSplitter.js';
export { ImageSplitter } from './ImageSplitter.js';
export { SplitterFactory } from './SplitterFactory.js';
Expand Down
6 changes: 4 additions & 2 deletions src/main/ipc/__tests__/handlers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,6 @@ vi.mock('../../../shared/ipc/channels.js', () => ({
DOWNLOAD_MARKDOWN: 'file:downloadMarkdown',
SELECT_DIALOG: 'file:selectDialog',
UPLOAD: 'file:upload',
UPLOAD_MULTIPLE: 'file:uploadMultiple',
UPLOAD_FILE_CONTENT: 'file:uploadFileContent',
},
COMPLETION: {
Expand All @@ -153,7 +152,10 @@ vi.mock('fs', () => ({
existsSync: vi.fn(() => true),
mkdirSync: vi.fn(),
copyFileSync: vi.fn(),
statSync: vi.fn(() => ({ size: 1024 }))
statSync: vi.fn(() => ({ size: 1024 })),
writeFileSync: vi.fn(),
accessSync: vi.fn(),
constants: { W_OK: 2 }
}
}))

Expand Down
51 changes: 3 additions & 48 deletions src/main/ipc/handlers/__tests__/file.handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ const mockFs = {
mkdirSync: vi.fn(),
copyFileSync: vi.fn(),
statSync: vi.fn(),
writeFileSync: vi.fn()
writeFileSync: vi.fn(),
accessSync: vi.fn(),
constants: { W_OK: 2 }
}

const mockPath = {
Expand Down Expand Up @@ -66,7 +68,6 @@ vi.mock('../../../../shared/ipc/channels.js', () => ({
DOWNLOAD_MARKDOWN: 'file:downloadMarkdown',
SELECT_DIALOG: 'file:selectDialog',
UPLOAD: 'file:upload',
UPLOAD_MULTIPLE: 'file:uploadMultiple',
UPLOAD_FILE_CONTENT: 'file:uploadFileContent'
}
}
Expand Down Expand Up @@ -327,52 +328,6 @@ describe('File Handler', () => {
})
})

describe('file:uploadMultiple', () => {
it('should upload multiple files successfully', async () => {
mockFs.existsSync.mockReturnValue(true)

const handler = handlers.get('file:uploadMultiple')
const result = await handler!({}, 'task-123', ['/file1.pdf', '/file2.pdf'])

expect(result.success).toBe(true)
expect(result.data.files).toHaveLength(2)
expect(mockFs.copyFileSync).toHaveBeenCalledTimes(2)
})

it('should return error when taskId is missing', async () => {
const handler = handlers.get('file:uploadMultiple')
const result = await handler!({}, '', ['/file.pdf'])

expect(result).toEqual({
success: false,
error: 'Task ID and file path list are required'
})
})

it('should return error when filePaths is empty', async () => {
const handler = handlers.get('file:uploadMultiple')
const result = await handler!({}, 'task-1', [])

expect(result).toEqual({
success: false,
error: 'Task ID and file path list are required'
})
})

it('should skip non-existent files', async () => {
mockFs.existsSync
.mockReturnValueOnce(true) // file1 exists
.mockReturnValueOnce(true) // upload dir check
.mockReturnValueOnce(false) // file2 doesn't exist

const handler = handlers.get('file:uploadMultiple')
const result = await handler!({}, 'task-1', ['/file1.pdf', '/non-existent.pdf'])

expect(result.success).toBe(true)
expect(result.data.files).toHaveLength(1)
})
})

describe('file:uploadFileContent', () => {
it('should save file content successfully', async () => {
const fileBuffer = new ArrayBuffer(8)
Expand Down
Loading