diff --git a/docs/IPC_API.md b/docs/IPC_API.md index da3274d..0d9ee15 100644 --- a/docs/IPC_API.md +++ b/docs/IPC_API.md @@ -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 | diff --git a/src/core/domain/repositories/TaskRepository.ts b/src/core/domain/repositories/TaskRepository.ts index f650dc7..47d2838 100644 --- a/src/core/domain/repositories/TaskRepository.ts +++ b/src/core/domain/repositories/TaskRepository.ts @@ -28,7 +28,7 @@ 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 || '', @@ -36,8 +36,8 @@ const create = async (task: Task) => { provider: task?.provider || 0, model: task?.model || '', model_name: task?.model_name || '', - progress: 0, - status: 0, + progress: task?.progress ?? 0, + status: task?.status ?? 0, } }); }; diff --git a/src/core/infrastructure/adapters/split/FileWaitUtil.ts b/src/core/infrastructure/adapters/split/FileWaitUtil.ts new file mode 100644 index 0000000..56e71e8 --- /dev/null +++ b/src/core/infrastructure/adapters/split/FileWaitUtil.ts @@ -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 { + 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 { + 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); + } + } +} diff --git a/src/core/infrastructure/adapters/split/ImageSplitter.ts b/src/core/infrastructure/adapters/split/ImageSplitter.ts index a50dc5f..da58818 100644 --- a/src/core/infrastructure/adapters/split/ImageSplitter.ts +++ b/src/core/infrastructure/adapters/split/ImageSplitter.ts @@ -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. @@ -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) { diff --git a/src/core/infrastructure/adapters/split/PDFSplitter.ts b/src/core/infrastructure/adapters/split/PDFSplitter.ts index 6b15398..6653039 100644 --- a/src/core/infrastructure/adapters/split/PDFSplitter.ts +++ b/src/core/infrastructure/adapters/split/PDFSplitter.ts @@ -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'; /** @@ -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); diff --git a/src/core/infrastructure/adapters/split/__tests__/ImageSplitter.test.ts b/src/core/infrastructure/adapters/split/__tests__/ImageSplitter.test.ts index af48ebe..e5c9cb8 100644 --- a/src/core/infrastructure/adapters/split/__tests__/ImageSplitter.test.ts +++ b/src/core/infrastructure/adapters/split/__tests__/ImageSplitter.test.ts @@ -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(), @@ -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(() => { @@ -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/); }); @@ -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/); }); diff --git a/src/core/infrastructure/adapters/split/__tests__/PDFSplitter.test.ts b/src/core/infrastructure/adapters/split/__tests__/PDFSplitter.test.ts index a3d54ef..fcec091 100644 --- a/src/core/infrastructure/adapters/split/__tests__/PDFSplitter.test.ts +++ b/src/core/infrastructure/adapters/split/__tests__/PDFSplitter.test.ts @@ -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: { @@ -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'; @@ -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({ @@ -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/); }); diff --git a/src/core/infrastructure/adapters/split/index.ts b/src/core/infrastructure/adapters/split/index.ts index 6ebce57..4a01d65 100644 --- a/src/core/infrastructure/adapters/split/index.ts +++ b/src/core/infrastructure/adapters/split/index.ts @@ -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'; diff --git a/src/main/ipc/__tests__/handlers.test.ts b/src/main/ipc/__tests__/handlers.test.ts index f749673..e6dc558 100644 --- a/src/main/ipc/__tests__/handlers.test.ts +++ b/src/main/ipc/__tests__/handlers.test.ts @@ -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: { @@ -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 } } })) diff --git a/src/main/ipc/handlers/__tests__/file.handler.test.ts b/src/main/ipc/handlers/__tests__/file.handler.test.ts index 2a465aa..757ca48 100644 --- a/src/main/ipc/handlers/__tests__/file.handler.test.ts +++ b/src/main/ipc/handlers/__tests__/file.handler.test.ts @@ -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 = { @@ -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' } } @@ -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) diff --git a/src/main/ipc/handlers/file.handler.ts b/src/main/ipc/handlers/file.handler.ts index 9d19bf8..7f54305 100644 --- a/src/main/ipc/handlers/file.handler.ts +++ b/src/main/ipc/handlers/file.handler.ts @@ -135,8 +135,9 @@ export function registerFileHandlers() { return { success: false, error: "Task ID and file path are required" }; } - // Check if file exists + // Check if source file exists if (!fs.existsSync(filePath)) { + console.error(`[IPC] file:upload - Source file does not exist: ${filePath}`); return { success: false, error: "File does not exist" }; } @@ -148,16 +149,38 @@ export function registerFileHandlers() { fs.mkdirSync(uploadDir, { recursive: true }); } + // Verify directory was created and is writable + try { + fs.accessSync(uploadDir, fs.constants.W_OK); + } catch { + console.error(`[IPC] file:upload - Upload directory is not writable: ${uploadDir}`); + return { success: false, error: `Upload directory is not writable: ${uploadDir}` }; + } + // Get file info const fileName = path.basename(filePath); const destPath = path.join(uploadDir, fileName); // Copy file + console.log(`[IPC] file:upload - Copying file: ${filePath} -> ${destPath}`); fs.copyFileSync(filePath, destPath); + // Verify copied file exists and has content + if (!fs.existsSync(destPath)) { + console.error(`[IPC] file:upload - File copy verification failed, destination not found: ${destPath}`); + return { success: false, error: "File copy failed: destination file not found after copy" }; + } + // Get file stats const stats = fs.statSync(destPath); + if (stats.size === 0) { + console.error(`[IPC] file:upload - Copied file is empty: ${destPath}`); + return { success: false, error: "File copy failed: destination file is empty" }; + } + + console.log(`[IPC] file:upload - File copied successfully: ${destPath} (${stats.size} bytes)`); + const fileInfo = { originalName: fileName, savedName: fileName, @@ -174,63 +197,6 @@ export function registerFileHandlers() { } ); - /** - * Multiple file upload - */ - ipcMain.handle( - IPC_CHANNELS.FILE.UPLOAD_MULTIPLE, - async (_, taskId: string, filePaths: string[]): Promise => { - try { - if (!taskId || !Array.isArray(filePaths) || filePaths.length === 0) { - return { success: false, error: "Task ID and file path list are required" }; - } - - const uploadResults = []; - - for (const filePath of filePaths) { - // Check if file exists - if (!fs.existsSync(filePath)) { - continue; - } - - const baseUploadDir = fileLogic.getUploadDir(); - const uploadDir = path.join(baseUploadDir, taskId); - - // Ensure directory exists - if (!fs.existsSync(uploadDir)) { - fs.mkdirSync(uploadDir, { recursive: true }); - } - - // Get file info - const fileName = path.basename(filePath); - const destPath = path.join(uploadDir, fileName); - - // Copy file - fs.copyFileSync(filePath, destPath); - - // Get file stats - const stats = fs.statSync(destPath); - - uploadResults.push({ - originalName: fileName, - savedName: fileName, - path: destPath, - size: stats.size, - taskId: taskId, - }); - } - - return { - success: true, - data: { message: "Files uploaded successfully", files: uploadResults }, - }; - } catch (error: any) { - console.error("[IPC] file:uploadMultiple error:", error); - return { success: false, error: error.message }; - } - } - ); - /** * File content upload (for drag and drop) */ @@ -250,19 +216,42 @@ export function registerFileHandlers() { fs.mkdirSync(uploadDir, { recursive: true }); } - // Build destination path - const destPath = path.join(uploadDir, fileName); + // Verify directory was created and is writable + try { + fs.accessSync(uploadDir, fs.constants.W_OK); + } catch { + console.error(`[IPC] file:uploadFileContent - Upload directory is not writable: ${uploadDir}`); + return { success: false, error: `Upload directory is not writable: ${uploadDir}` }; + } + + // Sanitize filename to prevent path traversal + const safeName = path.basename(fileName); + const destPath = path.join(uploadDir, safeName); // Convert ArrayBuffer to Buffer and write to file const buffer = Buffer.from(fileBuffer); + console.log(`[IPC] file:uploadFileContent - Writing file: ${destPath} (${buffer.length} bytes)`); fs.writeFileSync(destPath, buffer); + // Verify written file exists and has content + if (!fs.existsSync(destPath)) { + console.error(`[IPC] file:uploadFileContent - File write verification failed, not found: ${destPath}`); + return { success: false, error: "File write failed: file not found after write" }; + } + // Get file stats const stats = fs.statSync(destPath); + if (stats.size === 0) { + console.error(`[IPC] file:uploadFileContent - Written file is empty: ${destPath}`); + return { success: false, error: "File write failed: file is empty" }; + } + + console.log(`[IPC] file:uploadFileContent - File written successfully: ${destPath} (${stats.size} bytes)`); + const fileInfo = { - originalName: fileName, - savedName: fileName, + originalName: safeName, + savedName: safeName, path: destPath, size: stats.size, taskId: taskId, diff --git a/src/preload/electron.d.ts b/src/preload/electron.d.ts index 6744aca..4e60f12 100644 --- a/src/preload/electron.d.ts +++ b/src/preload/electron.d.ts @@ -72,7 +72,6 @@ interface WindowAPI { file: { selectDialog: () => Promise; upload: (taskId: string, filePath: string) => Promise; - uploadMultiple: (taskId: string, filePaths: string[]) => Promise; uploadFileContent: (taskId: string, fileName: string, fileBuffer: ArrayBuffer) => Promise; getImagePath: (taskId: string, page: number) => Promise; downloadMarkdown: (taskId: string) => Promise; diff --git a/src/preload/index.ts b/src/preload/index.ts index 0067fb4..5714d95 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -58,8 +58,6 @@ contextBridge.exposeInMainWorld("api", { selectDialog: () => ipcRenderer.invoke("file:selectDialog"), upload: (taskId: string, filePath: string) => ipcRenderer.invoke("file:upload", taskId, filePath), - uploadMultiple: (taskId: string, filePaths: string[]) => - ipcRenderer.invoke("file:uploadMultiple", taskId, filePaths), uploadFileContent: (taskId: string, fileName: string, fileBuffer: ArrayBuffer) => ipcRenderer.invoke("file:uploadFileContent", taskId, fileName, fileBuffer), getImagePath: (taskId: string, page: number) => diff --git a/src/renderer/electron.d.ts b/src/renderer/electron.d.ts index 4037933..a7b917d 100644 --- a/src/renderer/electron.d.ts +++ b/src/renderer/electron.d.ts @@ -202,10 +202,6 @@ interface ElectronAPI { taskId: string, filePath: string, ) => Promise>; - uploadMultiple: ( - taskId: string, - filePaths: string[], - ) => Promise>; getImagePath: ( taskId: string, page: number, diff --git a/src/shared/ipc/channels.ts b/src/shared/ipc/channels.ts index ed4f0eb..f889cbf 100644 --- a/src/shared/ipc/channels.ts +++ b/src/shared/ipc/channels.ts @@ -48,7 +48,6 @@ export const IPC_CHANNELS = { DOWNLOAD_MARKDOWN: 'file:downloadMarkdown', SELECT_DIALOG: 'file:selectDialog', UPLOAD: 'file:upload', - UPLOAD_MULTIPLE: 'file:uploadMultiple', UPLOAD_FILE_CONTENT: 'file:uploadFileContent', }, diff --git a/tests/helpers/window-api-mock.ts b/tests/helpers/window-api-mock.ts index 571bf56..b86a831 100644 --- a/tests/helpers/window-api-mock.ts +++ b/tests/helpers/window-api-mock.ts @@ -23,8 +23,7 @@ export const createMockWindowApi = () => ({ }, file: { selectDialog: vi.fn().mockResolvedValue({ success: true, data: ['/mock/file.pdf'] }), - upload: vi.fn().mockResolvedValue({ success: true, data: { path: '/mock/upload.pdf' } }), - uploadMultiple: vi.fn().mockResolvedValue({ success: true, data: [] }) + upload: vi.fn().mockResolvedValue({ success: true, data: { path: '/mock/upload.pdf' } }) }, completion: { markImagedown: vi.fn().mockResolvedValue({ success: true }), diff --git a/tests/setup.renderer.ts b/tests/setup.renderer.ts index 0329fff..8809d10 100644 --- a/tests/setup.renderer.ts +++ b/tests/setup.renderer.ts @@ -33,7 +33,6 @@ const mockWindowApi = { file: { selectDialog: vi.fn(), upload: vi.fn(), - uploadMultiple: vi.fn(), downloadMarkdown: vi.fn() }, completion: {