From b95e37198920430c88d5e94025459ce6fcd31b76 Mon Sep 17 00:00:00 2001 From: Anthony GAEREMYNCK <1@anthony.sh> Date: Fri, 31 Jul 2026 13:23:01 +0200 Subject: [PATCH] feat: add ebook converter wrapping Calibre's ebook-convert CLI --- src/converters/ebook.js | 24 +++++++++++ test/converters/ebook.test.js | 81 +++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 src/converters/ebook.js create mode 100644 test/converters/ebook.test.js diff --git a/src/converters/ebook.js b/src/converters/ebook.js new file mode 100644 index 0000000..abe9568 --- /dev/null +++ b/src/converters/ebook.js @@ -0,0 +1,24 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { register } from './registry.js'; + +const execFileAsync = promisify(execFile); + +export const EBOOK_FORMATS = ['epub', 'fb2', 'lrf', 'mobi', 'pdb', 'rb', 'snb', 'tcr', 'azw3', 'pdf']; + +export function registerEbookConverter() { + for (const sourceFormat of EBOOK_FORMATS) { + for (const targetFormat of EBOOK_FORMATS) { + if (sourceFormat === targetFormat) continue; + register({ + family: 'ebook', + sourceFormat, + targetFormat, + convert: async (inputPath, outputPath, options = {}) => { + const calibrePath = process.env.CALIBRE_PATH || 'ebook-convert'; + await execFileAsync(calibrePath, [inputPath, outputPath], { timeout: options.timeoutMs }); + }, + }); + } + } +} diff --git a/test/converters/ebook.test.js b/test/converters/ebook.test.js new file mode 100644 index 0000000..91f1500 --- /dev/null +++ b/test/converters/ebook.test.js @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { registerEbookConverter, EBOOK_FORMATS } from '../../src/converters/ebook.js'; +import { resolve, _resetForTests } from '../../src/converters/registry.js'; + +const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() })); +vi.mock('node:child_process', () => ({ execFile: execFileMock })); + +beforeEach(() => { + _resetForTests(); + execFileMock.mockReset(); + execFileMock.mockImplementation((file, args, options, callback) => callback(null, '', '')); +}); + +describe('registerEbookConverter — registration', () => { + it('registers every pair among the 10 ebook formats, and nothing for source === target', () => { + registerEbookConverter(); + + for (const sourceFormat of EBOOK_FORMATS) { + for (const targetFormat of EBOOK_FORMATS) { + const entry = resolve(sourceFormat, targetFormat); + if (sourceFormat === targetFormat) { + expect(entry).toBeNull(); + } else { + expect(entry).not.toBeNull(); + expect(entry.family).toBe('ebook'); + } + } + } + }); + + it('exposes exactly the 10 requested formats', () => { + expect(EBOOK_FORMATS.sort()).toEqual( + ['azw3', 'epub', 'fb2', 'lrf', 'mobi', 'pdb', 'pdf', 'rb', 'snb', 'tcr'].sort() + ); + }); +}); + +describe('registerEbookConverter — subprocess invocation', () => { + beforeEach(() => { + registerEbookConverter(); + }); + + it('calls ebook-convert with the input and output paths, and no timeout when none is given', async () => { + await resolve('epub', 'pdf').convert('/tmp/in.epub', '/tmp/out.pdf'); + + expect(execFileMock).toHaveBeenCalledTimes(1); + const [file, args, options] = execFileMock.mock.calls[0]; + expect(file).toBe('ebook-convert'); + expect(args).toEqual(['/tmp/in.epub', '/tmp/out.pdf']); + expect(options).toEqual({ timeout: undefined }); + }); + + it('forwards options.timeoutMs to execFile as its timeout', async () => { + await resolve('mobi', 'epub').convert('/tmp/in.mobi', '/tmp/out.epub', { timeoutMs: 180000 }); + + const [, , options] = execFileMock.mock.calls[0]; + expect(options).toEqual({ timeout: 180000 }); + }); + + it('uses CALIBRE_PATH from the environment when set', async () => { + const previous = process.env.CALIBRE_PATH; + process.env.CALIBRE_PATH = '/opt/calibre/ebook-convert'; + + await resolve('fb2', 'pdf').convert('/tmp/in.fb2', '/tmp/out.pdf'); + + expect(execFileMock.mock.calls[0][0]).toBe('/opt/calibre/ebook-convert'); + + if (previous === undefined) delete process.env.CALIBRE_PATH; + else process.env.CALIBRE_PATH = previous; + }); + + it('propagates a rejection from execFile as a rejected promise', async () => { + execFileMock.mockImplementation((file, args, options, callback) => + callback(new Error('ebook-convert exited with code 1'), '', 'error: unknown format') + ); + + await expect(resolve('epub', 'pdf').convert('/tmp/in.epub', '/tmp/out.pdf')).rejects.toThrow( + /exited with code 1/ + ); + }); +});