import AdmZip from 'adm-zip' import { execFile } from 'node:child_process' import { createHash } from 'node:crypto' import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises' import path from 'node:path' import { promisify } from 'node:util' import { classifySwordModule, fetchSwordConfCatalog, loadLicenseApproval, loadSwordPilotModules, swordPackageUrl, } from './sword-lib.js' import { cleanUsfmText, exists, sha256File, writeJson } from './lib.js' const execFileAsync = promisify(execFile) const root = process.cwd() const swordCacheDir = path.join(root, 'cache', 'sword') const swordLibraryDir = path.join(swordCacheDir, 'library') const swordExportDir = path.join(swordCacheDir, 'exports') const swordPackageDir = path.join(swordCacheDir, 'packages') const pilot = await loadSwordPilotModules() const licenseRegistry = await loadLicenseApproval() const catalog = await fetchSwordConfCatalog(pilot.repository.mods_tarball_url) await mkdir(swordLibraryDir, { recursive: true }) await mkdir(swordExportDir, { recursive: true }) await mkdir(swordPackageDir, { recursive: true }) const imported = [] const skipped = [] for (const item of pilot.modules) { try { if (item.import_enabled === false) { skipped.push(`${item.id}: import disabled - ${item.import_status ?? 'deferred'}`) continue } const catalogEntry = catalog.modules.get(item.id.toLowerCase()) if (!catalogEntry) { skipped.push(`${item.id}: not found in CrossWire catalog`) continue } if (item.intended_resource_type === 'translation') { skipped.push(`${item.id}: skipped duplicate/alternate Bible text pending edition comparison`) continue } const license = classifySwordModule(catalogEntry.conf, licenseRegistry) if (!license.status.startsWith('approved')) { skipped.push(`${item.id}: license status ${license.status}`) continue } const rawzip = await downloadSwordPackage(catalogEntry.module_id) extractSwordPackage(rawzip.package_path) const exportPath = await exportSwordModule(catalogEntry.module_id) const records = await parseImpFile(exportPath, item.intended_resource_type, catalogEntry.conf) const resource = await writeSwordResource(item, catalogEntry, license, rawzip, records) imported.push(resource.id) } catch (error) { skipped.push(`${item.id}: import failed - ${error.message}`) } } console.log(`SWORD import complete: ${imported.length} imported, ${skipped.length} skipped.`) for (const id of imported) console.log(` imported: ${id}`) for (const reason of skipped) console.log(` skipped: ${reason}`) async function downloadSwordPackage(moduleId) { const url = swordPackageUrl(moduleId, pilot.repository.package_base_url) const packagePath = path.join(swordPackageDir, `${moduleId}.zip`) if (!(await exists(packagePath))) { const response = await fetch(url) if (!response.ok) { throw new Error(`Unable to download SWORD package ${url}: ${response.status} ${response.statusText}`) } await writeFile(packagePath, Buffer.from(await response.arrayBuffer())) } const checksum = await sha256File(packagePath) const fileStat = await stat(packagePath) return { package_url: url, package_path: packagePath, bytes: fileStat.size, sha256: checksum, } } function extractSwordPackage(packagePath) { const archive = new AdmZip(packagePath) archive.extractAllTo(swordLibraryDir, true) } async function exportSwordModule(moduleId) { const outputPath = path.join(swordExportDir, `${moduleId}.imp`) if (await exists(outputPath)) { const outputStat = await stat(outputPath) if (outputStat.size > 0) { return outputPath } } const shell = [ 'set -e', 'export DEBIAN_FRONTEND=noninteractive', 'apt-get update >/dev/null', 'apt-get install -y libsword-utils >/dev/null', `SWORD_PATH=/workspace/cache/sword/library mod2imp ${shellQuote(moduleId)} > /workspace/cache/sword/exports/${shellQuote(moduleId)}.imp`, ].join('\n') await execFileAsync('docker', [ 'run', '--rm', '-v', `${root}:/workspace`, '-w', '/workspace', 'ubuntu:26.04', 'bash', '-lc', shell, ], { maxBuffer: 20 * 1024 * 1024, timeout: 120_000, }) if (!(await exists(outputPath))) { throw new Error(`SWORD export did not create ${outputPath}`) } return outputPath } async function parseImpFile(filePath, resourceType, conf) { const text = await readFile(filePath, 'utf8') const records = [] let currentKey = null let body = [] function flush() { if (!currentKey) return const raw = body.join('\n').trim() if (!raw) { currentKey = null body = [] return } if (resourceType === 'commentary') { const record = commentaryRecord(currentKey, raw, conf) if (record) records.push(record) } else { const record = dictionaryRecord(currentKey, raw, conf) if (record) records.push(record) } currentKey = null body = [] } for (const line of text.split(/\r?\n/)) { if (line.startsWith('$$$')) { flush() currentKey = line.slice(3).trim() } else { body.push(line) } } flush() return records } async function writeSwordResource(item, catalogEntry, license, rawzip, records) { const conf = catalogEntry.conf const id = resourceIdFor(catalogEntry.module_id) const outputDir = path.join(root, 'packages', 'json', id) const fileName = item.intended_resource_type === 'commentary' ? 'commentary.jsonl' : 'entries.jsonl' const filePath = path.join(outputDir, fileName) await rm(outputDir, { recursive: true, force: true }) await mkdir(outputDir, { recursive: true }) await writeFile(filePath, `${records.map((record) => JSON.stringify(record)).join('\n')}\n`, 'utf8') const packageFile = await describePackageFile(outputDir, fileName) const manifest = sourceManifestFor(item, catalogEntry, license, rawzip, id, fileName, records) const catalog = packageCatalogFor(manifest, catalogEntry, rawzip, records, packageFile) await writeJson(path.join(root, 'sources', `${id}.json`), manifest) await writeJson(path.join(outputDir, 'catalog.json'), catalog) return { id, records: records.length } } function sourceManifestFor(item, catalogEntry, license, rawzip, id, fileName, records) { const conf = catalogEntry.conf const resourceType = item.intended_resource_type const title = conf.Description ?? catalogEntry.module_id const licenseName = conf.DistributionLicense ?? license.rule ?? 'Unknown' return { id, resource_type: resourceType, project_name: 'LibreBible', title, short_title: title, abbreviation: conf.Abbreviation ?? catalogEntry.module_id, alternate_ids: [catalogEntry.module_id], language: { code: conf.Lang ?? 'en', name: conf.Lang === 'en' || !conf.Lang ? 'English' : conf.Lang, }, script: 'Latn', canon: { scope: conf.Versification ?? 'n/a', notes: resourceType === 'commentary' ? 'Commentary entries are keyed to SWORD Bible references.' : 'Dictionary entries are keyed by SWORD lexical keys.', }, contributors: contributorsFor(conf, resourceType), features: featuresFor(resourceType, id, fileName, catalogEntry), attachments: attachmentsFor(resourceType, id, fileName), source: { provider: 'CrossWire Bible Society', url: `https://crosswire.org/sword/modules/ModInfo.jsp?modName=${encodeURIComponent(catalogEntry.module_id)}`, download_url: rawzip.package_url, format: 'sword-rawzip', upstream_id: catalogEntry.module_id, upstream_last_updated: conf.SwordVersionDate, sword: { module_id: catalogEntry.module_id, conf_file: catalogEntry.conf_file, mod_drv: conf.ModDrv, source_type: conf.SourceType, versification: conf.Versification, version: conf.Version, data_path: conf.DataPath, text_source: conf.TextSource, }, }, license: { name: licenseName, redistribution: license.redistribution === true, commercial_use: license.commercial_use, share_alike: license.share_alike === true, source_license_url: `https://crosswire.org/ftpmirror/pub/sword/raw/mods.d/${catalogEntry.module_id.toLowerCase()}.conf`, attribution: attributionFor(conf, catalogEntry.module_id), jurisdiction_notes: conf.Copyright ?? 'No jurisdiction-specific restriction was found in the SWORD module metadata.', restricted_notes: license.notes, }, checks: { expected_sha256: rawzip.sha256, last_checked_at: new Date().toISOString(), }, importer: { name: 'scripts/import-sword.js', version: '0.1.0', engine: 'CrossWire SWORD mod2imp via Docker/Ubuntu libsword-utils', }, packages: { json_catalog: `packages/json/${id}/catalog.json`, entries_jsonl: `packages/json/${id}/${fileName}`, }, catalog_display: { primary_features: resourceType === 'commentary' ? ['Commentary entries', 'Verse/range anchors', 'SWORD source'] : ['Dictionary entries', 'Lexical anchors', 'SWORD source'], summary: summaryFor(resourceType, title, records.length, catalogEntry.module_id), }, } } function packageCatalogFor(manifest, catalogEntry, rawzip, records, packageFile) { const fileKey = manifest.resource_type === 'commentary' ? 'commentary_jsonl' : 'entries_jsonl' return { schema_version: 'librebible.resource-catalog.v1', project: 'LibreBible', id: manifest.id, source_manifest_file: `${manifest.id}.json`, 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, 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: rawzip.sha256, checks: manifest.checks, counts: { entries: records.length, commentary_entries: manifest.resource_type === 'commentary' ? records.length : undefined, dictionary_entries: manifest.resource_type === 'dictionary' ? records.length : undefined, }, files: { [fileKey]: packageFile, source_rawzip: { path: path.basename(rawzip.package_path), bytes: rawzip.bytes, sha256: rawzip.sha256, }, }, sword: manifest.source.sword, raw_conf_sha256: createHash('sha256').update(catalogEntry.conf_text).digest('hex'), } } async function describePackageFile(outputDir, fileName) { const filePath = path.join(outputDir, fileName) const fileStat = await stat(filePath) return { path: fileName, bytes: fileStat.size, sha256: await sha256File(filePath), } } function commentaryRecord(key, raw, conf) { const parsed = parseBibleKey(key) const text = stripMarkup(raw) if (!text) return null return { id: stableId(`${conf.module_id}:${key}`), resource_id: resourceIdFor(conf.module_id), module_id: conf.module_id, key, anchor_type: parsed ? 'verse' : 'module_key', reference: parsed?.reference ?? key, book: parsed?.book, chapter: parsed?.chapter, verse: parsed?.verse, raw_markup: raw, text, } } function dictionaryRecord(key, raw, conf) { const text = stripMarkup(raw) if (!text) return null const prefix = conf.module_id === 'StrongsGreek' ? 'G' : conf.module_id === 'StrongsHebrew' ? 'H' : '' const number = /^\d+$/.test(key) ? Number(key) : null const lexicalKey = prefix && number !== null ? `${prefix}${number}` : key return { id: stableId(`${conf.module_id}:${key}`), resource_id: resourceIdFor(conf.module_id), module_id: conf.module_id, key, lexical_key: lexicalKey, anchor_type: prefix ? 'strongs_number' : 'dictionary_key', raw_markup: raw, text, } } function parseBibleKey(key) { const match = /^(.+?)\s+(\d+):(\d+)$/.exec(key) if (!match) return null return { book: match[1], chapter: Number(match[2]), verse: Number(match[3]), reference: key, } } function stripMarkup(raw) { return cleanUsfmText(raw .replace(/]*>(.*?)<\/reference>/gis, '$1') .replace(/]*>(.*?)<\/ref>/gis, '$1') .replace(/<[^>]+>/g, ' ') .replace(/ /g, ' ') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>')) } function resourceIdFor(moduleId) { return `sword-${moduleId.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()}` } function stableId(value) { return createHash('sha1').update(value).digest('hex') } function featuresFor(resourceType, id, fileName, catalogEntry) { if (resourceType === 'commentary') { return [ { id: 'commentary-entries', type: 'commentary', label: 'Commentary entries', languages: ['eng'], embedded: true, package: `packages/json/${id}/${fileName}`, }, { id: 'sword-source', type: 'source_format', label: 'SWORD source', format: catalogEntry.conf.SourceType ?? catalogEntry.conf.ModDrv, embedded: false, }, ] } return [ { id: 'dictionary-entries', type: 'dictionary', label: 'Dictionary entries', languages: ['eng'], systems: catalogEntry.module_id.startsWith('Strongs') ? ['strongs'] : undefined, embedded: true, package: `packages/json/${id}/${fileName}`, }, { id: 'sword-source', type: 'source_format', label: 'SWORD source', format: catalogEntry.conf.SourceType ?? catalogEntry.conf.ModDrv, embedded: false, }, ] } function attachmentsFor(resourceType, id, fileName) { if (resourceType === 'commentary') { return { included: [ { id: `${id}-commentary`, resource_type: 'commentary', label: 'Commentary entries', relationship: 'range-to-commentary', anchor_types: ['book', 'chapter', 'verse', 'verse_range'], languages: ['eng'], source: 'CrossWire SWORD module export.', package: `packages/json/${id}/${fileName}`, }, ], } } return { included: [ { id: `${id}-dictionary`, resource_type: 'dictionary', label: 'Dictionary entries', relationship: 'key-to-definition', anchor_types: ['strongs_number', 'topic', 'dictionary_key'], languages: ['eng'], systems: id.includes('strongs') ? ['strongs'] : undefined, source: 'CrossWire SWORD module export.', package: `packages/json/${id}/${fileName}`, }, ], } } function contributorsFor(conf, resourceType) { const contributors = [ { name: 'CrossWire Bible Society', role: 'module provider', }, ] if (resourceType === 'commentary' && /Matthew Henry/i.test(conf.Description ?? '')) { contributors.unshift({ name: 'Matthew Henry', role: 'commentary author' }) } else if (resourceType === 'commentary' && /Jamieson/i.test(conf.Description ?? '')) { contributors.unshift({ name: 'Robert Jamieson, A. R. Fausset, and David Brown', role: 'commentary authors' }) } else if (/Strong/i.test(conf.Description ?? '')) { contributors.unshift({ name: 'James Strong', role: 'dictionary author' }) } else if (/Nave/i.test(conf.Description ?? '')) { contributors.unshift({ name: 'Orville J. Nave', role: 'topical dictionary author' }) } return contributors } function attributionFor(conf, moduleId) { const parts = [ conf.Description, conf.TextSource ? `Text source: ${conf.TextSource}` : null, `SWORD module: ${moduleId}`, 'Provided by CrossWire Bible Society.', ] return parts.filter(Boolean).join(' ') } function summaryFor(resourceType, title, count, moduleId) { return resourceType === 'commentary' ? `${title}, imported from the CrossWire ${moduleId} SWORD module with ${count} commentary entries.` : `${title}, imported from the CrossWire ${moduleId} SWORD module with ${count} dictionary entries.` } function shellQuote(value) { return `'${String(value).replace(/'/g, `'\\''`)}'` }