import AdmZip from 'adm-zip' import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises' import path from 'node:path' import { cleanUsfmText, downloadFile, exists, normalizeStrong, readJson, sha256File, writeJson, } from './lib.js' const manifestPath = process.argv[2] if (!manifestPath) { throw new Error('Usage: node scripts/import-usfm.js sources/.json') } const root = process.cwd() const manifest = await readJson(path.resolve(root, manifestPath)) const archivePath = path.join(root, 'cache', manifest.id, path.basename(manifest.source.download_url)) const extractDir = path.join(root, 'cache', manifest.id, 'usfm') const outputDir = path.join(root, 'packages', 'json', manifest.id) if (!(await exists(archivePath))) { await downloadFile(manifest.source.download_url, archivePath) } const archiveSha = await sha256File(archivePath) if (archiveSha !== manifest.checks.expected_sha256.toLowerCase()) { throw new Error(`Checksum mismatch for ${manifest.id}: ${archiveSha}`) } await rm(extractDir, { recursive: true, force: true }) await mkdir(extractDir, { recursive: true }) new AdmZip(archivePath).extractAllTo(extractDir, true) const zip = new AdmZip(archivePath) const usfmEntries = zip .getEntries() .filter((entry) => entry.entryName.toLowerCase().endsWith('.usfm')) .sort((a, b) => a.entryName.localeCompare(b.entryName)) const verses = [] const strongsLinks = [] const footnotes = [] let tokenId = 1 let footnoteId = 1 for (const entry of usfmEntries) { const filePath = path.join(extractDir, entry.entryName) const usfm = await readFile(filePath, 'utf8') const bookId = /\\id\s+([A-Z0-9]+)/.exec(usfm)?.[1] const bookTitle = /\\toc2\s+(.+)/.exec(usfm)?.[1]?.trim() ?? bookId let chapter = null for (const line of usfm.split(/\r?\n/)) { const chapterMatch = /^\\c\s+(\d+)/.exec(line) if (chapterMatch) { chapter = Number(chapterMatch[1]) continue } const verseMatch = /^\\v\s+(\d+)\s+([\s\S]+)/.exec(line) if (!verseMatch || chapter === null || !bookId) { continue } const verseNumber = Number(verseMatch[1]) const raw = verseMatch[2] const verseId = `${bookId}.${chapter}.${verseNumber}` const links = [...raw.matchAll(/\\\+?w\s+([^|\\]+)\|strong="([^"]+)"\\\+?w\*/g)] const notes = extractFootnotes(raw) const text = cleanUsfmText(raw.replace(/\\\+?w\s+([^|\\]+)\|strong="([^"]+)"\\\+?w\*/g, '$1')) verses.push({ id: verseId, translation_id: manifest.id, book_id: bookId, book: bookTitle, chapter, verse: verseNumber, reference: `${bookTitle} ${chapter}:${verseNumber}`, text, }) for (const [index, link] of links.entries()) { strongsLinks.push({ id: tokenId++, verse_id: verseId, translation_id: manifest.id, book_id: bookId, chapter, verse: verseNumber, position: index + 1, surface: cleanUsfmText(link[1]), strong_raw: link[2], strong: normalizeStrong(link[2]), }) } for (const [index, note] of notes.entries()) { footnotes.push({ id: footnoteId++, verse_id: verseId, translation_id: manifest.id, book_id: bookId, chapter, verse: verseNumber, note_index: index + 1, caller: note.caller, reference: note.reference || `${chapter}.${verseNumber}`, text: note.text, }) } } } await mkdir(outputDir, { recursive: true }) await writeFile( path.join(outputDir, 'verses.jsonl'), `${verses.map((verse) => JSON.stringify(verse)).join('\n')}\n`, 'utf8', ) await writeFile( path.join(outputDir, 'strongs-links.jsonl'), `${strongsLinks.map((link) => JSON.stringify(link)).join('\n')}\n`, 'utf8', ) await writeFile( path.join(outputDir, 'footnotes.jsonl'), `${footnotes.map((note) => JSON.stringify(note)).join('\n')}\n`, 'utf8', ) const versesPackage = await packageFile(outputDir, 'verses.jsonl') const strongsPackage = await packageFile(outputDir, 'strongs-links.jsonl') const footnotesPackage = await packageFile(outputDir, 'footnotes.jsonl') const catalog = { schema_version: 'librebible.resource-catalog.v1', project: manifest.project_name ?? 'LibreBible', id: manifest.id, resource_type: manifest.resource_type, title: manifest.title, short_title: manifest.short_title, abbreviation: manifest.abbreviation, alternate_ids: manifest.alternate_ids ?? [], language: manifest.language, script: manifest.script, canon: manifest.canon, translation: manifest.translation, contributors: manifest.contributors ?? [], features: manifest.features, attachments: manifest.attachments, source: manifest.source, license: manifest.license, catalog_display: manifest.catalog_display, importer: manifest.importer, generated_at: new Date().toISOString(), source_sha256: archiveSha, checks: manifest.checks, counts: { books: new Set(verses.map((verse) => verse.book_id)).size, verses: verses.length, strongs_links: strongsLinks.length, footnotes: footnotes.length, }, files: { verses_jsonl: versesPackage, strongs_jsonl: strongsPackage, footnotes_jsonl: footnotesPackage, }, } await writeJson(path.join(outputDir, 'catalog.json'), catalog) await writeJson(path.join(root, 'packages', 'json', 'catalog.json'), { schema_version: 'librebible.index-catalog.v1', project: manifest.project_name ?? 'LibreBible', generated_at: catalog.generated_at, resources: [ { id: catalog.id, resource_type: catalog.resource_type, title: catalog.title, short_title: catalog.short_title, abbreviation: catalog.abbreviation, alternate_ids: catalog.alternate_ids, language: catalog.language, script: catalog.script, canon: catalog.canon, translation: catalog.translation, contributors: catalog.contributors, features: catalog.features, attachments: catalog.attachments, source: catalog.source, license: catalog.license, catalog_display: catalog.catalog_display, package_path: `packages/json/${catalog.id}/catalog.json`, source_manifest_path: `sources/${path.basename(manifestPath)}`, detail_doc_path: `docs/resources/${catalog.id}.md`, counts: catalog.counts, files: catalog.files, checks: catalog.checks, }, ], }) console.log(`${manifest.id}: imported ${catalog.counts.verses} verses`) console.log(`${manifest.id}: imported ${catalog.counts.strongs_links} Strong's links`) console.log(`${manifest.id}: imported ${catalog.counts.footnotes} footnotes`) function extractFootnotes(raw) { return [...raw.matchAll(/\\f\s+([\s\S]*?)\\f\*/g)] .map((match) => { const content = match[1] const caller = /^([+\w-]+)/.exec(content.trim())?.[1] ?? '+' const reference = /\\fr\s+([^\\]+)/.exec(content)?.[1]?.trim() ?? '' const textSource = content .replace(/^([+\w-]+)/, '') .replace(/\\fr\s+[^\\]+/, '') return { caller, reference, text: cleanUsfmText(textSource), } }) .filter((note) => note.text.length > 0) } async function packageFile(outputDir, fileName) { const filePath = path.join(outputDir, fileName) const fileStat = await stat(filePath) return { path: fileName, bytes: fileStat.size, sha256: await sha256File(filePath), } }