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
Binary file added docs/assets/retry-model-selection.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
55 changes: 52 additions & 3 deletions src/main/ipc/handlers/__tests__/file.handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ const mockTaskRepository = {
findById: vi.fn()
}

const mockTaskDetailRepository = {
findByTaskId: vi.fn()
}

const mockFileLogic = {
getUploadDir: vi.fn()
}
Expand Down Expand Up @@ -63,6 +67,10 @@ vi.mock('../../../../core/domain/repositories/TaskRepository.js', () => ({
default: mockTaskRepository
}))

vi.mock('../../../../core/domain/repositories/TaskDetailRepository.js', () => ({
default: mockTaskDetailRepository
}))

vi.mock('../../../../core/infrastructure/services/FileService.js', () => ({
default: mockFileLogic
}))
Expand Down Expand Up @@ -291,15 +299,56 @@ describe('File Handler', () => {
})
})

it('should return error when merged_path is missing', async () => {
mockTaskRepository.findById.mockResolvedValue({ id: 'task-1', merged_path: null })
it('should assemble completed pages when merged_path is missing', async () => {
mockTaskRepository.findById.mockResolvedValue({
id: 'task-1',
filename: 'document.pdf',
merged_path: null,
})
mockTaskDetailRepository.findByTaskId.mockResolvedValue([
{ page: 2, status: 2, content: '## Page 2' },
{ page: 1, status: 2, content: '# Page 1' },
{ page: 3, status: -1, content: '' },
])
mockDialog.showSaveDialog.mockResolvedValue({
canceled: false,
filePath: '/downloads/document.md',
})

const handler = handlers.get('file:downloadMarkdown')
const result = await handler!({}, 'task-1')

expect(result).toEqual({
success: true,
data: { savedPath: '/downloads/document.md', pages: 2 },
})
expect(mockFs.writeFileSync).toHaveBeenCalledWith(
'/downloads/document.md',
'<!-- Page 1 -->\n\n# Page 1\n\n---\n\n<!-- Page 2 -->\n\n## Page 2',
{ encoding: 'utf-8' }
)
})

it('should return error when no completed pages are available', async () => {
mockTaskRepository.findById.mockResolvedValue({
id: 'task-1',
filename: 'document.pdf',
merged_path: null,
})
mockTaskDetailRepository.findByTaskId.mockResolvedValue([
{ page: 1, status: -1, content: '' },
])
mockDialog.showSaveDialog.mockResolvedValue({
canceled: false,
filePath: '/downloads/document.md',
})

const handler = handlers.get('file:downloadMarkdown')
const result = await handler!({}, 'task-1')

expect(result).toEqual({
success: false,
error: 'Merged file does not exist, task may not be completed'
error: 'No completed pages available to download',
})
})

Expand Down
94 changes: 89 additions & 5 deletions src/main/ipc/handlers/__tests__/taskDetail.handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,10 @@ vi.mock('../../../../core/infrastructure/db/index.js', () => ({

vi.mock('../../../../shared/types/TaskStatus.js', () => ({
TaskStatus: {
FAILED: 0,
PROCESSING: 3,
CANCELLED: 7
CANCELLED: 7,
PARTIAL_FAILED: 8
}
}))

Expand Down Expand Up @@ -336,18 +338,19 @@ describe('TaskDetail Handler', () => {

describe('taskDetail:retryFailed', () => {
it('should retry all failed pages', async () => {
const mockTask = { id: 'task-1', status: 6, completed_count: 8, pages: 10 }
const mockTask = { id: 'task-1', status: 0, completed_count: 8, pages: 10 }
const updatedTask = { ...mockTask, status: 3, failed_count: 0 }
const updateMany = vi.fn().mockResolvedValue({ count: 2 })

mockPrisma.$transaction.mockImplementation(async (callback: any) => {
mockPrisma.$transaction.mockImplementation(async (callback: (tx: unknown) => Promise<unknown>) => {
const tx = {
task: {
findUnique: vi.fn().mockResolvedValue(mockTask),
update: vi.fn().mockResolvedValue(updatedTask)
},
taskDetail: {
count: vi.fn().mockResolvedValue(2),
updateMany: vi.fn().mockResolvedValue({ count: 2 })
updateMany
}
}
return callback(tx)
Expand All @@ -360,6 +363,87 @@ describe('TaskDetail Handler', () => {
success: true,
data: { retried: 2 }
})
expect(updateMany).toHaveBeenCalledWith({
where: {
task: 'task-1',
status: { in: [-1, 0] },
},
data: expect.objectContaining({
status: 0,
}),
})
})
it('should retry failed pages with model override', async () => {
const mockTask = { id: 'task-1', status: 8, completed_count: 8, pages: 10, provider: 1, model: 'old-model' }
const mockProvider = { id: 2, name: 'OpenAI', status: 0 }
const mockModel = { id: 'gpt-4o', name: 'GPT-4o', provider: 2 }
const updatedTask = { ...mockTask, status: 3, failed_count: 0, provider: 2, model: 'gpt-4o', model_name: 'GPT-4o | OpenAI' }

mockPrisma.$transaction.mockImplementation(async (callback: (tx: unknown) => Promise<unknown>) => {
const tx = {
task: {
findUnique: vi.fn().mockResolvedValue(mockTask),
update: vi.fn().mockResolvedValue(updatedTask)
},
provider: {
findUnique: vi.fn().mockResolvedValue(mockProvider)
},
model: {
findUnique: vi.fn().mockResolvedValue(mockModel)
},
taskDetail: {
count: vi.fn().mockResolvedValue(2),
updateMany: vi.fn().mockResolvedValue({ count: 2 })
}
}
return callback(tx)
})

const handler = handlers.get('taskDetail:retryFailed')
const result = await handler!({}, { taskId: 'task-1', providerId: 2, modelId: 'gpt-4o' })

expect(result).toEqual({
success: true,
data: { retried: 2 }
})
})

it.each([
['created', -1],
['pending', 1],
['splitting', 2],
['processing', 3],
['ready to merge', 4],
['merging', 5],
['completed', 6],
['cancelled', 7],
])('should reject %s tasks', async (_statusName, status) => {
mockPrisma.$transaction.mockImplementation(async (callback: (tx: unknown) => Promise<unknown>) => {
const tx = {
task: {
findUnique: vi.fn().mockResolvedValue({ id: 'task-1', status }),
},
}
return callback(tx)
})

const handler = handlers.get('taskDetail:retryFailed')
const result = await handler!({}, 'task-1')

expect(result).toEqual({
success: false,
error: 'Can only retry failed or partially failed tasks'
})
})

it('should return error when model override params are incomplete in retryFailed', async () => {
const handler = handlers.get('taskDetail:retryFailed')
const result = await handler!({}, { taskId: 'task-1', providerId: 2 })

expect(result).toEqual({
success: false,
error: 'providerId and modelId must be provided together'
})
})

it('should return error when taskId is missing', async () => {
Expand All @@ -373,7 +457,7 @@ describe('TaskDetail Handler', () => {
})

it('should return error when no failed pages exist', async () => {
const mockTask = { id: 'task-1', status: 6 }
const mockTask = { id: 'task-1', status: 8 }

mockPrisma.$transaction.mockImplementation(async (callback: any) => {
const tx = {
Expand Down
55 changes: 36 additions & 19 deletions src/main/ipc/handlers/file.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import path from "path";
import fs from "fs";
import { fileURLToPath } from "url";
import taskRepository from "../../../core/domain/repositories/TaskRepository.js";
import taskDetailRepository from "../../../core/domain/repositories/TaskDetailRepository.js";
import fileLogic from "../../../core/infrastructure/services/FileService.js";
import { ImagePathUtil } from "../../../core/infrastructure/adapters/split/index.js";
import { PageStatus } from "../../../shared/types/PageStatus.js";
import { IPC_CHANNELS } from "../../../shared/ipc/channels.js";
import type { IpcResponse } from "../../../shared/ipc/responses.js";

Expand Down Expand Up @@ -62,7 +64,9 @@ export function registerFileHandlers() {
);

/**
* Download merged Markdown file
* Download Markdown file.
* Prefers merged_path when present; otherwise assembles completed pages
* so partial/failed tasks can still export finished work.
*/
ipcMain.handle(
IPC_CHANNELS.FILE.DOWNLOAD_MARKDOWN,
Expand All @@ -72,43 +76,56 @@ export function registerFileHandlers() {
return { success: false, error: "Task ID is required" };
}

// Get task info
const task = await taskRepository.findById(taskId);

if (!task) {
return { success: false, error: "Task not found" };
}

if (!task.merged_path) {
return { success: false, error: "Merged file does not exist, task may not be completed" };
}

// Check if file exists
if (!fs.existsSync(task.merged_path)) {
return { success: false, error: "Merged file is missing" };
}

// Open save dialog
const result = await dialog.showSaveDialog({
const defaultPath = task.filename.replace(/\.[^/.]+$/, ".md");
const saveResult = await dialog.showSaveDialog({
title: "Save Markdown File",
defaultPath: task.filename.replace(/\.[^/.]+$/, ".md"),
defaultPath,
filters: [
{ name: "Markdown Files", extensions: ["md"] },
{ name: "All Files", extensions: ["*"] },
],
});

// User cancelled
if (result.canceled || !result.filePath) {
if (saveResult.canceled || !saveResult.filePath) {
return { success: false, error: "User cancelled save" };
}

// Copy file to destination
fs.copyFileSync(task.merged_path, result.filePath);
if (task.merged_path && fs.existsSync(task.merged_path)) {
fs.copyFileSync(task.merged_path, saveResult.filePath);
return {
success: true,
data: { savedPath: saveResult.filePath },
};
}

// Fallback: assemble completed pages for partial downloads
const details = await taskDetailRepository.findByTaskId(taskId);
const completedPages = details
.filter((detail) => detail.status === PageStatus.COMPLETED && detail.content)
.sort((a, b) => a.page - b.page);

if (completedPages.length === 0) {
return {
success: false,
error: "No completed pages available to download",
};
}

const markdown = completedPages
.map((page) => `<!-- Page ${page.page} -->\n\n${page.content}`)
.join("\n\n---\n\n");

fs.writeFileSync(saveResult.filePath, markdown, { encoding: "utf-8" });

return {
success: true,
data: { savedPath: result.filePath },
data: { savedPath: saveResult.filePath, pages: completedPages.length },
};
} catch (error: any) {
console.error("[IPC] file:downloadMarkdown error:", error);
Expand Down
Loading
Loading