#!/usr/bin/env node 'use strict'; // Reviewed, one-series migration for ambiguous pre-provenance metadata. Dry run // by default. Apply only during a stopped-writer cutover with a state snapshot. const fs = require('node:fs').promises; const path = require('node:path'); const { imageSize } = require('../library'); const { detectVerticalStrip, KEY } = require('../reading-direction'); const { writeJsonAtomic } = require('../scraper/lib/publisher'); const [folder, expectedId, ...flags] = process.argv.slice(2); const apply = flags.includes('--apply'); const mustInclude = flags.find(flag => flag.startsWith('--chapter='))?.slice('--chapter='.length); if (!folder || !/^[A-Z0-9]{20,40}$/.test(expectedId || '') || (mustInclude && !/^c\d{1,6}$/i.test(mustInclude)) || flags.some(flag => !['--apply', '--writers-stopped'].includes(flag) && !/^--chapter=c\d{1,6}$/i.test(flag)) || (apply && !flags.includes('--writers-stopped'))) { console.error('Usage: node tools/review-reading-direction.js [--chapter=c0056] [--apply --writers-stopped]'); process.exit(2); } async function main() { const libraryRoot = path.resolve(process.env.LIBRARY || path.join(__dirname, '..', 'library')); const mangaDir = path.resolve(folder); if (path.dirname(mangaDir) !== libraryRoot) throw new Error('Manga folder must be directly inside the configured library'); const file = path.join(mangaDir, 'manga.json'); const original = await fs.readFile(file, 'utf8'); const meta = JSON.parse(original); if (meta['x-scraper']?.ids?.weebcentral !== expectedId) throw new Error('Source identity did not match'); if (meta.direction !== 'rtl' || meta.directionOverride || meta[KEY]) throw new Error('Only an unmarked legacy RTL value can be reviewed'); const chapters = []; for (const book of await fs.readdir(mangaDir, { withFileTypes: true })) { if (!book.isDirectory() || !/^v\d/i.test(book.name)) continue; for (const chapter of await fs.readdir(path.join(mangaDir, book.name), { withFileTypes: true })) { if (chapter.isDirectory() && /^c\d/i.test(chapter.name)) chapters.push(path.join(mangaDir, book.name, chapter.name)); } } chapters.sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); if (chapters.length < 3) throw new Error('Too few chapters for reviewed migration'); const requiredChapter = mustInclude && chapters.find(item => path.basename(item).toLowerCase().startsWith(mustInclude.toLowerCase() + ' ')); if (mustInclude && !requiredChapter) throw new Error('Required chapter was not found'); const selected = [...new Set([chapters[0], chapters[Math.floor(chapters.length / 2)], chapters.at(-1), requiredChapter].filter(Boolean))]; const results = []; for (const dir of selected) { const pages = []; for (const entry of (await fs.readdir(dir)).sort()) { if (!/\.(?:jpe?g|png|webp|gif|avif|bmp)$/i.test(entry)) continue; const size = await imageSize(path.join(dir, entry)).catch(() => null); const rotation = /_rot(90|180|270)(?:_|\.)/i.exec(entry); pages.push({ w: size?.w || 0, h: size?.h || 0, r: rotation ? Number(rotation[1]) : 0 }); } const detected = detectVerticalStrip(pages); results.push({ chapter: path.basename(dir), detected: detected?.value || null, evidence: detected?.evidence || { pages: pages.length } }); } const supporting = results.filter(item => item.detected === 'ttb').length; const reviewed = supporting >= Math.max(3, Math.ceil(results.length * .75)) && (!requiredChapter || results.some(item => item.chapter === path.basename(requiredChapter) && item.detected === 'ttb')); if (apply) { if (!reviewed) throw new Error('Insufficient consistent strip evidence; metadata unchanged'); if (await fs.readFile(file, 'utf8') !== original) throw new Error('Metadata changed during review'); await writeJsonAtomic(file, { ...meta, direction: 'ttb', [KEY]: { version: 1, value: 'ttb', source: 'detected', evidence: { reviewedChapters: results.map(item => item.chapter), supportingChapters: supporting } } }); } console.log(JSON.stringify({ manga: path.basename(mangaDir), reviewed, applied: apply, samples: results }, null, 2)); } main().catch(error => { console.error(error.message); process.exitCode = 1; });