// Append newly published (and newly free) chapters to EPUBs built by fetch-novel.js. // Usage: node scripts/update-novels.js // Only EPUBs whose identifier is urn:lnreader:: are touched. Chapters are // appended at the end, so existing positions (Kavita pages, reading progress, // illustration anchors) never move. Prints one line per updated novel. import JSZip from 'jszip'; import { load } from 'cheerio'; import fs from 'fs'; import path from 'path'; import { loadPlugin, print } from './load-plugins.js'; import { chapterPage, esc, toXhtml } from './epub.js'; const CONCURRENCY = 3; const RETRIES = 4; const [library] = process.argv.slice(2); if (!library) { console.error('Usage: node scripts/update-novels.js '); process.exit(2); } const retry = async (label, fn) => { for (let attempt = 1; ; attempt++) { try { return await fn(); } catch (e) { if (attempt >= RETRIES) throw new Error(`${label}: ${e.message}`); await new Promise(r => setTimeout(r, 1000 * 2 ** attempt)); } } }; async function update(file) { const zip = await JSZip.loadAsync(fs.readFileSync(file)); const opfPath = 'OEBPS/content.opf'; if (!zip.file(opfPath)) return; const opf = load(await zip.file(opfPath).async('string'), { xml: true }); const uid = opf('dc\\:identifier').first().text(); const m = uid.match(/^urn:lnreader:([^:]+):(.+)$/); if (!m) return; const [, pluginId, novelPath] = m; const have = opf('spine itemref').length; const plugin = await loadPlugin(pluginId); const novel = await retry('novel', () => plugin.parseNovel(novelPath)); const all = novel.chapters || []; const firstLocked = all.findIndex(c => c.name.includes('🔒')); const free = firstLocked < 0 ? all : all.slice(0, firstLocked); if (free.length <= have) return; const added = free.slice(have); const bodies = new Array(added.length); const queue = added.map((_, i) => i); await Promise.all( Array.from({ length: CONCURRENCY }, async () => { for (let i; (i = queue.shift()) !== undefined;) { bodies[i] = toXhtml( await retry(added[i].name, () => plugin.parseChapter(added[i].path)), ); } }), ); const nav = load(await zip.file('OEBPS/nav.xhtml').async('string'), { xml: true, }); added.forEach((c, i) => { const id = `ch${String(have + i + 1).padStart(4, '0')}`; zip.file(`OEBPS/${id}.xhtml`, chapterPage(c.name, bodies[i])); opf('manifest').append( ``, ); opf('spine').append(``); nav('nav ol') .first() .append(`
  • ${esc(c.name)}
  • `); }); opf('meta[property="dcterms:modified"]').text( new Date().toISOString().replace(/\.\d+Z$/, 'Z'), ); zip.file(opfPath, opf.xml()); zip.file('OEBPS/nav.xhtml', nav.xml()); // EPUB requires mimetype stored uncompressed; loaded entries lose that option. zip.file('mimetype', 'application/epub+zip', { compression: 'STORE' }); const tmp = `${file}.tmp`; fs.writeFileSync( tmp, await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE', mimeType: 'application/epub+zip', }), ); fs.renameSync(tmp, file); print( `${path.basename(file)}: +${added.length} chapters (${have} -> ${free.length})`, ); } const epubs = fs .readdirSync(library, { recursive: true }) .filter(f => f.endsWith('.epub')) .map(f => path.join(library, f)); let failed = 0; for (const file of epubs) { try { await update(file); } catch (e) { failed++; process.stderr.write(`${path.basename(file)}: ${e.message}\n`); } } process.exit(failed ? 1 : 0);