/** * Convert Bulgakov's "Настольная книга для священно-церковнослужителей" * EPUB to date-keyed JSON for enrichment of ru/lives bundles. * * Only processes the Menaion section (months 01-12). * Keeps original pre-revolutionary orthography. * Resolves footnotes inline. * Excludes hymn texts (Troparia/Kontakia). * * Usage: * npx tsx scripts/convert-bulgakov.ts [output.json] */ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs'; import { join, dirname } from 'path'; import { execSync } from 'child_process'; const MONTH_NAMES: Record = { 'январь': '01', 'января': '01', 'февраль': '02', 'февраля': '02', 'март': '03', 'марта': '03', 'апрель': '04', 'апреля': '04', 'май': '05', 'мая': '05', 'июнь': '06', 'июня': '06', 'июль': '07', 'июля': '07', 'август': '08', 'августа': '08', 'сентябрь': '09', 'сентября': '09', 'октябрь': '10', 'октября': '10', 'ноябрь': '11', 'ноября': '11', 'декабрь': '12', 'декабря': '12', }; interface BulgakovEntry { title: string; html: string; } interface BulgakovDay { [dateKey: string]: BulgakovEntry[]; } /** Strip HTML tags, collapse whitespace. */ function stripTags(s: string): string { return s.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim(); } /** Clean a paragraph's HTML, resolving footnotes inline. */ function cleanParagraph(raw: string, notes: string[]): string { let s = raw.trim(); // Resolve footnotes: [N] s = s.replace( /]*title="([^"]*)"[^>]*>(]*>\[(\d+)\]<\/sup>)<\/a>/g, (_m: string, note: string, sup: string) => { notes.push(note.trim()); return sup; } ); // Remove remaining tags (keep inner content) s = s.replace(/]*>(.*?)<\/a>/gs, (_m: string, inner: string) => inner); // Strip calibre-specific classes s = s.replace(/<(\w+)\s[^>]*class="[^"]*calibre[^"]*"[^>]*>/g, '<$1>'); s = s.replace(/]*class="ponomar"[^>]*>(.*?)<\/span>/g, '$1'); s = s.replace(/]*class="calibre\d+"\s*\/?>/g, '
'); s = s.replace(/\u00a0/g, ' ').replace(/\s+/g, ' '); return `

${s}

`; } /** Check if a paragraph is a hymn (Troparion/Kontakion) or liturgical rubric. */ function isHymn(text: string): boolean { const clean = stripTags(text).replace(/^\d+\s*\.?\s*/, '').trim(); const hymnPatterns = [ /^Тропарь,/i, /^Кондак,/i, /^Парем\./i, /^Утр\.\s*Ев\./i, /^Ап\./i, /^Ев\./i, /^Совершается собор/i, /^В этот день поется/i, /^Совершается/i, /^Служба/i, /^См\.\s/i, ]; return hymnPatterns.some(p => p.test(clean)); } /** Check if a paragraph is a cross-reference (starts with "См." or "см."). */ function isCrossRef(text: string): boolean { const clean = stripTags(text).replace(/^\d+\s*\.?\s*/, '').trim(); return /^см\.\s/i.test(clean); } /** Extract the day number from the start of a paragraph. */ function extractDayNum(text: string): number | null { const m = text.match(/]*>(\d+)<\/strong>\.?\s*/i); return m ? parseInt(m[1], 10) : null; } /** Extract the title from a paragraph (first significant text after day number and ponomar span). */ function extractTitle(text: string): string { let s = text; // Remove any leading anchor tags s = s.replace(/^]*><\/a>\s*/i, ''); // Remove day number prefix s = s.replace(/^]*>\d+<\/strong>\.?\s*/i, ''); // Handle ponomar span: may contain a month znamenanie (single letter) or saint name const ponomarMatch = s.match(/^<[^>]*>([^<]+)<\/[^>]+>\s*/); if (ponomarMatch) { let ponomarContent = ponomarMatch[1].trim(); // If ponomar is just a single letter (with optional period), it's a znamenanie — skip entire span if (/^\p{L}\.?\s*$/u.test(ponomarContent)) { s = s.replace(/^<[^>]*>[^<]+<\/[^>]+>\s*/, ''); } else { // Ponomar may contain znamenanie + title (e.g. "Л. 🕀 Срѣтенїе...") // Check if it starts with a single letter + period const znamenanieMatch = ponomarContent.match(/^(\p{L}\.)\s*(.*)$/su); if (znamenanieMatch) { // Replace the span with just the title part (remove znamenanie) s = s.replace(/^<[^>]*>[^<]+<\/[^>]+>\s*/, znamenanieMatch[2] + ' '); } } } // Strip all HTML tags s = stripTags(s); // Remove leading non-letter symbols (like †, 🕀, 🕂, 🕃) s = s.replace(/^[\s\p{P}\p{S}]+/u, ''); // Take first 100 chars, breaking at a period where possible const MAX_TITLE = 100; if (s.length <= MAX_TITLE) return s; // Try to break at a period within the first 100 chars const periodIdx = s.lastIndexOf('.', MAX_TITLE); if (periodIdx > 10) return s.substring(0, periodIdx + 1); return s.substring(0, MAX_TITLE) + '...'; } /** Check if text contains a month name (for heading detection). */ function findMonthName(text: string): string | null { const lower = text.toLowerCase(); for (const [name, _code] of Object.entries(MONTH_NAMES)) { if (lower.includes(name)) return name; } return null; } export async function convertBulgakov(epubPath: string, outputPath: string): Promise { const tmpDir = join(dirname(outputPath), 'tmp_bulgakov'); if (existsSync(tmpDir)) { execSync(`rm -rf "${tmpDir}"`); } mkdirSync(tmpDir, { recursive: true }); console.log(`Extracting EPUB: ${epubPath}`); execSync(`unzip -o "${epubPath}" -d "${tmpDir}" > /dev/null 2>&1`); const out: BulgakovDay = {}; let currentMonth: string | null = null; let currentDay: number | null = null; let inMenaion = false; // Parse all files in order for (let i = 0; ; i++) { const fileNum = String(i).padStart(3, '0'); const filePath = join(tmpDir, `index_split_${fileNum}.xhtml`); if (!existsSync(filePath)) break; if (i % 10 === 0) console.log(` Processing file ${fileNum}...`); const html = readFileSync(filePath, 'utf-8'); // Check for h2 headings containing month names // h2 looks like:

Январь

const h2Regex = /]*>([\s\S]*?)<\/h2>/gi; let h2Match: RegExpExecArray | null; while ((h2Match = h2Regex.exec(html)) !== null) { const h2Content = stripTags(h2Match[1]); const monthName = findMonthName(h2Content); if (monthName) { currentMonth = MONTH_NAMES[monthName]; currentDay = null; inMenaion = true; console.log(` → Month ${currentMonth}: ${monthName} (file ${fileNum})`); } } // Check for section headers (Месяцеслов / Триодион) if (/]*>[\s\S]*?Месяцеслов[\s\S]*?<\/h1>/i.test(html)) { inMenaion = true; console.log(` → Entered Месяцеслов section (file ${fileNum})`); } if (/]*>[\s\S]*?Триодион[\s\S]*?<\/h1>/i.test(html)) { inMenaion = false; console.log(` → Left Месяцеслов, entered Триодион (file ${fileNum})`); } if (!inMenaion || !currentMonth) continue; // Extract all paragraph divs const paraRegex = /
([\s\S]*?)<\/div>/g; let paraMatch: RegExpExecArray | null; // We process paragraphs in order. Each paragraph may be: // - A new day entry (strong with day number) // - Continuation of current day // - A hymn (skipped) while ((paraMatch = paraRegex.exec(html)) !== null) { const raw = paraMatch[1]; // Check for day number const dayNum = extractDayNum(raw); if (dayNum !== null) { currentDay = dayNum; // Skip hymns and cross-refs if (isHymn(raw) || isCrossRef(raw)) { continue; } // New entry for this day const title = extractTitle(raw); const notes: string[] = []; const cleanHtml = cleanParagraph(raw, notes); const dateKey = `${currentMonth}-${String(dayNum).padStart(2, '0')}`; if (!out[dateKey]) out[dateKey] = []; const entry: BulgakovEntry = { title, html: cleanHtml }; if (notes.length > 0) { entry.html += `

Примечания:

${notes.map((n, i) => `

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

`).join('')}`; } out[dateKey].push(entry); } else if (currentDay !== null) { // Skip hymn continuations if (isHymn(raw) || isCrossRef(raw)) continue; // Continuation paragraph for current day - append to last entry const dateKey = `${currentMonth}-${String(currentDay).padStart(2, '0')}`; const lives = out[dateKey]; if (lives && lives.length > 0) { const last = lives[lives.length - 1]; const notes: string[] = []; const cleanHtml = cleanParagraph(raw, notes); last.html += cleanHtml; if (notes.length > 0) { const noteBlock = `

Примечания:

${notes.map((n, i) => `

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

`).join('')}`; last.html += noteBlock; } } } } } // Cleanup temp directory execSync(`rm -rf "${tmpDir}"`); // Write output mkdirSync(dirname(outputPath), { recursive: true }); writeFileSync(outputPath, JSON.stringify(out, null, 0)); let totalEntries = 0; for (const lives of Object.values(out)) totalEntries += lives.length; console.log(`Converted ${Object.keys(out).length} days, ${totalEntries} entries → ${outputPath}`); return out; } if (import.meta.url === `file://${process.argv[1]}`) { const epubPath = process.argv[2]; if (!epubPath) { console.error('Usage: npx tsx scripts/convert-bulgakov.ts [output.json]'); process.exit(1); } const outputPath = process.argv[3] || join(process.cwd(), 'scripts', 'output', 'bulgakov.json'); convertBulgakov(epubPath, outputPath).catch(err => { console.error('Conversion failed:', err); process.exit(1); }); }