feat: add ebook converter wrapping Calibre's ebook-convert CLI
This commit is contained in:
@@ -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 });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user