#!/usr/bin/env node
// epub-optimize — batch EPUB optimizer for xteink e-ink readers.
//
// Sequencing mirrors the page's uploadFile (5406-5688): read metadata name
// first, reserve a collision-free name second, convert third.
import { parseArgs } from 'node:util';
import { readFileSync, writeFileSync, readdirSync, mkdirSync, statSync, existsSync } from 'node:fs';
import { resolve, join, basename, dirname } from 'node:path';
import JSZip from 'jszip';
import { DEVICE_PROFILES, DEFAULT_DEVICE, DEFAULT_JPEG_QUALITY } from './constants.js';
import { convertEpub } from './convert.js';
import { getMetadataFilename, reserveAvailableFilename } from './rename.js';
import { Reporter } from './report.js';
const USAGE = `
Usage: epub-optimize [options]
a directory of .epub files (top-level scan) or a single .epub file
Options:
--out DIR output directory (default: /optimized)
--device X4|X3 target screen: X4 = 480x800 (default), X3 = 528x792
--quality N JPEG quality, 1-100 (default: ${DEFAULT_JPEG_QUALITY})
--auto-crop trim uniform margins before resizing (off by default)
--rename name output files "Title - Author.epub" from EPUB metadata
-h, --help show this message
Images are always converted to grayscale baseline JPEG. Originals are never
modified; output always lands in a separate directory.
`.trim();
class UsageError extends Error {}
function parseCliArgs(argv) {
let parsed;
try {
parsed = parseArgs({
args: argv,
options: {
out: { type: 'string' },
device: { type: 'string' },
quality: { type: 'string' },
'auto-crop': { type: 'boolean', default: false },
rename: { type: 'boolean', default: false },
help: { type: 'boolean', short: 'h', default: false },
},
allowPositionals: true,
});
} catch (e) {
throw new UsageError(e.message);
}
const { values, positionals } = parsed;
if (values.help) return { help: true };
if (positionals.length === 0) throw new UsageError('missing ');
if (positionals.length > 1) throw new UsageError(`unexpected extra argument "${positionals[1]}"`);
const device = String(values.device ?? DEFAULT_DEVICE).toUpperCase();
if (!DEVICE_PROFILES[device]) {
throw new UsageError(`--device must be one of ${Object.keys(DEVICE_PROFILES).join(', ')} (got "${values.device}")`);
}
const quality = values.quality === undefined ? DEFAULT_JPEG_QUALITY : Number(values.quality);
if (!Number.isInteger(quality) || quality < 1 || quality > 100) {
throw new UsageError(`--quality must be an integer from 1 to 100 (got "${values.quality}")`);
}
return {
help: false,
input: positionals[0],
outArg: values.out,
device,
quality,
autoCrop: values['auto-crop'],
rename: values.rename,
};
}
/** Resolve to a list of .epub paths, plus the count of skipped entries. */
function discoverInputs(inputPath) {
if (!existsSync(inputPath)) throw new UsageError(`input not found: ${inputPath}`);
const st = statSync(inputPath);
if (st.isFile()) {
if (!inputPath.toLowerCase().endsWith('.epub')) {
throw new UsageError(`not an .epub file: ${inputPath}`);
}
return { epubs: [inputPath], skipped: [], baseDir: dirname(inputPath) };
}
const epubs = [];
const skipped = [];
for (const name of readdirSync(inputPath)) {
const full = join(inputPath, name);
let entry;
try { entry = statSync(full); } catch (e) { continue; }
if (!entry.isFile()) continue;
if (name.toLowerCase().endsWith('.epub')) epubs.push(full);
else skipped.push(name);
}
epubs.sort();
return { epubs, skipped, baseDir: inputPath };
}
async function main(argv) {
const args = parseCliArgs(argv);
if (args.help) {
console.log(USAGE);
return 0;
}
const inputPath = resolve(args.input);
const { epubs, skipped, baseDir } = discoverInputs(inputPath);
const outDir = resolve(args.outArg ?? join(baseDir, 'optimized'));
if (outDir === inputPath) throw new UsageError('--out must differ from ');
mkdirSync(outDir, { recursive: true });
const profile = DEVICE_PROFILES[args.device];
const opts = Object.freeze({
maxWidth: profile.width,
maxHeight: profile.height,
quality: args.quality,
autoCrop: args.autoCrop,
});
const reporter = new Reporter();
for (const name of skipped) reporter.skipFile(name);
console.log(
`Device ${profile.label} (${profile.width}x${profile.height}) | quality ${opts.quality} | ` +
`auto-crop ${opts.autoCrop ? 'on' : 'off'} | rename ${args.rename ? 'on' : 'off'}`
);
console.log(`Output: ${outDir}`);
console.log('');
// Seeded from what is already on disk, then carried across the whole batch so
// same-run collisions also pick up " (2)" suffixes.
const usedNames = new Set(readdirSync(outDir).map(n => n.toLowerCase()));
for (const epubPath of epubs) {
const sourceName = basename(epubPath);
try {
const inputBuffer = readFileSync(epubPath);
let desiredName = sourceName;
if (args.rename) {
const zip = await JSZip.loadAsync(inputBuffer);
const metadataName = await getMetadataFilename(zip);
if (metadataName) desiredName = metadataName;
}
const outName = reserveAvailableFilename(desiredName, usedNames);
const hooks = reporter.beginFile();
const { buffer, stats } = await convertEpub(inputBuffer, opts, hooks);
writeFileSync(join(outDir, outName), buffer);
reporter.endFile(outName === sourceName ? outName : `${sourceName} → ${outName}`, stats);
} catch (e) {
reporter.failFile(sourceName, e);
}
}
return reporter.finish() > 0 ? 1 : 0;
}
main(process.argv.slice(2))
.then(code => { process.exitCode = code; })
.catch(e => {
if (e instanceof UsageError) {
console.error(`error: ${e.message}`);
console.error('');
console.error(USAGE);
} else {
console.error(`error: ${e && e.message ? e.message : e}`);
}
process.exitCode = 1;
});