/** * Convert the Chetyi-Minei EPUB (St. Dimitri of Rostov, "Жития Святых (все месяцы)") * into a date-keyed JSON for enrichment of ru/lives bundles. * * Usage: * npx tsx scripts/convert-minei.ts [output.json] * * Input layout (extracted EPUB): * index_split_000.xhtml ... index_split_9999.xhtml (day files + continuations) * toc.ncx, content.opf * * Output: * { "01-01": [{ title, html, notes }], ... } * where html = concatenated

paragraphs (em/sup preserved), and notes * collected from footnote markers appended as a Примечания block. */ import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync } from 'fs'; import { join, dirname } from 'path'; const MONTH_GENITIVE_TO_NUM: Record = { января: '01', февраля: '02', марта: '03', апреля: '04', мая: '05', июня: '06', июля: '07', августа: '08', сентября: '09', октября: '10', ноября: '11', декабря: '12', }; interface MineiLife { title: string; html: string; notes: string[]; } interface MineiDay { [dateKey: string]: MineiLife[]; } /** * Corrections for EPUB heading errors. Key: h2 element id. Value: corrected date. * calibre_toc_10023 (index_split_041) is "Память 27 сентября" but its content is * the Translation of Relics of St. John Chrysostom, commemorated Jan 27. */ const HEADING_DATE_OVERRIDES: Record = { calibre_toc_10023: '01-27', }; /** Strip tags, collapse whitespace (for headings). */ function stripTags(s: string): string { return s.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); } /** Convert a paragraph's raw HTML to clean

...

HTML, collecting footnotes. */ function cleanParagraph(raw: string): { html: string; notes: string[] } { let s = raw.trim(); // Footnote anchors: [N] → collect note, keep [N] const notes: string[] = []; s = s.replace(/]*title="([^"]*)"[^>]*>(]*>\[(\d+)\]<\/sup>)<\/a>/g, (_m, note: string, sup: string) => { notes.push(note.trim()); return sup; }); // Any remaining wrappers: keep inner content, drop href s = s.replace(/]*>(.*?)<\/a>/gs, (_m, inner: string) => inner); // Drop classes from (keep italics semantic) s = s.replace(/]*>/g, ''); // Normalize non-breaking space and whitespace s = s.replace(/\u00a0/g, ' ').replace(/\s+/g, ' '); return { html: `

${s}

`, notes }; } /** Extract (type, body, id) for h2/h3 headings and paragraph bodies in document order. */ interface Token { kind: 'h2' | 'h3' | 'p'; body: string; id?: string; notes?: string[]; } function tokenize(html: string): Token[] { const tokens: Token[] = []; const re = /]*>([\s\S]*?)<\/h\1>|

([\s\S]*?)<\/p>/g; let m: RegExpExecArray | null; while ((m = re.exec(html)) !== null) { if (m[1] === '2' || m[1] === '3') { const idMatch = m[0].match(/id="([^"]+)"/); tokens.push({ kind: `h${m[1]}` as 'h2' | 'h3', body: m[2], id: idMatch?.[1] }); } else if (m[3] !== undefined) { const cleaned = cleanParagraph(m[3]); tokens.push({ kind: 'p', body: cleaned.html, notes: cleaned.notes }); } } return tokens; } export async function convertMinei(inputDir: string, outputPath: string): Promise { const files = readdirSync(inputDir) .filter(f => /^index_split_\d+\.xhtml$/.test(f)) .sort((a, b) => parseInt(a.replace(/\D/g, ''), 10) - parseInt(b.replace(/\D/g, ''), 10)); const out: MineiDay = {}; let currentDate: string | null = null; for (const file of files) { const html = readFileSync(join(inputDir, file), 'utf-8'); const tokens = tokenize(html); for (const tok of tokens) { if (tok.kind === 'h2') { const title = stripTags(tok.body); const overrideDate: string | null = tok.id ? HEADING_DATE_OVERRIDES[tok.id] ?? null : null; const m = title.match(/^Память (\d{1,2}) (\p{L}+)/u); if (overrideDate) { currentDate = overrideDate; if (!out[currentDate]) out[currentDate] = []; } else if (m && MONTH_GENITIVE_TO_NUM[m[2]]) { const dd = m[1].padStart(2, '0'); const mm = MONTH_GENITIVE_TO_NUM[m[2]]; currentDate = `${mm}-${dd}`; if (!out[currentDate]) out[currentDate] = []; } else { // Intro / appendix / movable-feast section — lives under it are not date-bound currentDate = null; } continue; } if (currentDate === null) continue; if (tok.kind === 'h3') { const title = stripTags(tok.body).replace(/\s*\[\d+\]\s*$/, '').trim(); out[currentDate].push({ title, html: '', notes: [] }); continue; } // tok.kind === 'p' const lives = out[currentDate]; if (lives.length === 0) continue; const last = lives[lives.length - 1]; last.html += tok.body; if (tok.notes && tok.notes.length > 0) last.notes.push(...tok.notes); } } // Append Примечания block to lives that have notes for (const lives of Object.values(out)) { for (const life of lives) { if (life.notes.length > 0) { const notesHtml = life.notes.map((n, i) => `

[${i + 1}] ${n}

`).join(''); life.html += `

Примечания:

${notesHtml}`; } delete (life as { notes?: string[] }).notes; } } mkdirSync(dirname(outputPath), { recursive: true }); writeFileSync(outputPath, JSON.stringify(out, null, 0)); return out; } if (import.meta.url === `file://${process.argv[1]}`) { (async () => { const [, , inputArg, outputArg] = process.argv; if (!inputArg) { console.error('Usage: npx tsx scripts/convert-minei.ts [output.json]'); process.exit(1); } const inputDir = inputArg; if (!existsSync(inputDir)) { console.error(`Input dir not found: ${inputDir}`); process.exit(1); } const outputPath = outputArg || join(process.cwd(), 'scripts', 'output', 'minei.json'); const minei = await convertMinei(inputDir, outputPath); let lives = 0; for (const ls of Object.values(minei)) lives += ls.length; console.log(`Converted ${Object.keys(minei).length} days, ${lives} lives -> ${outputPath}`); })(); }