Add map and KJV audio resource packages

This commit is contained in:
2026-07-15 11:33:32 -05:00
parent b376b07c38
commit 7759c0de6a
210 changed files with 47310 additions and 7 deletions
+26
View File
@@ -132,6 +132,15 @@ function resourceDetailMarkdown(manifest, packageCatalog) {
`- Footnotes JSONL: ${manifest.packages?.footnotes_jsonl ?? 'n/a'}`,
`- Study notes JSONL: ${manifest.packages?.study_notes_jsonl ?? 'n/a'}`,
`- Entries JSONL: ${manifest.packages?.entries_jsonl ?? 'n/a'}`,
`- Places JSONL: ${manifest.packages?.places_jsonl ?? 'n/a'}`,
`- Modern locations JSONL: ${manifest.packages?.modern_locations_jsonl ?? 'n/a'}`,
`- Verse places JSONL: ${manifest.packages?.verse_places_jsonl ?? 'n/a'}`,
`- Place images JSONL: ${manifest.packages?.place_images_jsonl ?? 'n/a'}`,
`- Routes JSONL: ${manifest.packages?.routes_jsonl ?? 'n/a'}`,
`- GeoJSON directory: ${manifest.packages?.geojson_dir ?? 'n/a'}`,
`- Audio segments JSONL: ${manifest.packages?.audio_segments_jsonl ?? 'n/a'}`,
`- Chapter playlists JSONL: ${manifest.packages?.chapter_playlists_jsonl ?? 'n/a'}`,
`- Book packages JSONL: ${manifest.packages?.book_packages_jsonl ?? 'n/a'}`,
`- Source SHA-256: ${packageCatalog?.source_sha256 ?? manifest.checks?.expected_sha256 ?? 'n/a'}`,
`- Last checked: ${manifest.checks?.last_checked_at ?? 'n/a'}`,
'',
@@ -145,6 +154,16 @@ function resourceDetailMarkdown(manifest, packageCatalog) {
`- Entries: ${packageCatalog?.counts?.entries ?? 'n/a'}`,
`- Commentary entries: ${packageCatalog?.counts?.commentary_entries ?? 'n/a'}`,
`- Dictionary entries: ${packageCatalog?.counts?.dictionary_entries ?? 'n/a'}`,
`- Places: ${packageCatalog?.counts?.places ?? 'n/a'}`,
`- Modern locations: ${packageCatalog?.counts?.modern_locations ?? 'n/a'}`,
`- Verse-place links: ${packageCatalog?.counts?.verse_place_links ?? 'n/a'}`,
`- Verses with places: ${packageCatalog?.counts?.verses_with_places ?? 'n/a'}`,
`- Place images: ${packageCatalog?.counts?.place_images ?? 'n/a'}`,
`- Routes: ${packageCatalog?.counts?.routes ?? 'n/a'}`,
`- GeoJSON files: ${packageCatalog?.counts?.geojson_files ?? 'n/a'}`,
`- Audio segments: ${packageCatalog?.counts?.audio_segments ?? 'n/a'}`,
`- Chapter playlists: ${packageCatalog?.counts?.chapter_playlists ?? 'n/a'}`,
`- Book packages: ${packageCatalog?.counts?.book_packages ?? 'n/a'}`,
'',
'## Package Checksums',
'',
@@ -206,6 +225,13 @@ function countsLabel(packageCatalog) {
counts.study_notes != null ? `${counts.study_notes} study notes` : null,
counts.commentary_entries != null ? `${counts.commentary_entries} commentary entries` : null,
counts.dictionary_entries != null ? `${counts.dictionary_entries} dictionary entries` : null,
counts.places != null ? `${counts.places} places` : null,
counts.modern_locations != null ? `${counts.modern_locations} modern locations` : null,
counts.verse_place_links != null ? `${counts.verse_place_links} verse-place links` : null,
counts.routes != null ? `${counts.routes} routes` : null,
counts.audio_segments != null ? `${counts.audio_segments} audio segments` : null,
counts.chapter_playlists != null ? `${counts.chapter_playlists} chapter playlists` : null,
counts.book_packages != null ? `${counts.book_packages} book packages` : null,
counts.entries != null && counts.commentary_entries == null && counts.dictionary_entries == null ? `${counts.entries} entries` : null,
]
return parts.filter(Boolean).join(', ')
+272
View File
@@ -0,0 +1,272 @@
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { readJson, sha256File, writeJson } from './lib.js'
const manifestPath = process.argv[2]
if (!manifestPath) {
console.error('Usage: node scripts/import-kjv-audio-eliranwong.js sources/kjv-audio-eliranwong.json')
process.exit(1)
}
const root = process.cwd()
const manifest = await readJson(path.resolve(root, manifestPath))
const packageDir = path.join(root, 'packages', 'json', manifest.id)
const kjvVersesPath = path.join(root, 'packages', 'json', 'kjv-eng-kjv2006', 'verses.jsonl')
const bookOrder = [
['GEN', 'Genesis'],
['EXO', 'Exodus'],
['LEV', 'Leviticus'],
['NUM', 'Numbers'],
['DEU', 'Deuteronomy'],
['JOS', 'Joshua'],
['JDG', 'Judges'],
['RUT', 'Ruth'],
['1SA', '1Samuel'],
['2SA', '2Samuel'],
['1KI', '1Kings'],
['2KI', '2Kings'],
['1CH', '1Chronicles'],
['2CH', '2Chronicles'],
['EZR', 'Ezra'],
['NEH', 'Nehemiah'],
['EST', 'Esther'],
['JOB', 'Job'],
['PSA', 'Psalms'],
['PRO', 'Proverbs'],
['ECC', 'Ecclesiastes'],
['SNG', 'SongOfSolomon'],
['ISA', 'Isaiah'],
['JER', 'Jeremiah'],
['LAM', 'Lamentations'],
['EZK', 'Ezekiel'],
['DAN', 'Daniel'],
['HOS', 'Hosea'],
['JOL', 'Joel'],
['AMO', 'Amos'],
['OBA', 'Obadiah'],
['JON', 'Jonah'],
['MIC', 'Micah'],
['NAM', 'Nahum'],
['HAB', 'Habakkuk'],
['ZEP', 'Zephaniah'],
['HAG', 'Haggai'],
['ZEC', 'Zechariah'],
['MAL', 'Malachi'],
['MAT', 'Matthew'],
['MRK', 'Mark'],
['LUK', 'Luke'],
['JHN', 'John'],
['ACT', 'Acts'],
['ROM', 'Romans'],
['1CO', '1Corinthians'],
['2CO', '2Corinthians'],
['GAL', 'Galatians'],
['EPH', 'Ephesians'],
['PHP', 'Philippians'],
['COL', 'Colossians'],
['1TH', '1Thessalonians'],
['2TH', '2Thessalonians'],
['1TI', '1Timothy'],
['2TI', '2Timothy'],
['TIT', 'Titus'],
['PHM', 'Philemon'],
['HEB', 'Hebrews'],
['JAS', 'James'],
['1PE', '1Peter'],
['2PE', '2Peter'],
['1JN', '1John'],
['2JN', '2John'],
['3JN', '3John'],
['JUD', 'Jude'],
['REV', 'Revelation'],
]
const bookById = new Map(
bookOrder.map(([bookId, audioName], index) => {
const bookNumber = index + 1
return [bookId, { bookId, bookNumber, audioName, zipPrefix: String(bookNumber).padStart(2, '0') }]
}),
)
const verses = (await readFile(kjvVersesPath, 'utf8'))
.trim()
.split(/\r?\n/)
.filter(Boolean)
.map((line) => JSON.parse(line))
const audioPackageIndex = await fetchJson(manifest.source.audio_package_index_url)
const zipEntries = audioPackageIndex
.filter((entry) => entry.type === 'file' && entry.name.endsWith('.zip'))
.sort((a, b) => a.name.localeCompare(b.name))
const zipByPrefix = new Map(zipEntries.map((entry) => [entry.name.slice(0, 2), entry]))
const segments = []
const chapterPlaylists = new Map()
const bookStats = new Map()
for (const verse of verses) {
const bookInfo = bookById.get(verse.book_id)
if (!bookInfo) throw new Error(`Unsupported KJV book id: ${verse.book_id}`)
const zipEntry = zipByPrefix.get(bookInfo.zipPrefix)
if (!zipEntry) throw new Error(`No audio ZIP found for ${bookInfo.zipPrefix} ${verse.book}`)
const entryPath = `${bookInfo.bookNumber}_${verse.chapter}/KJV_${bookInfo.bookNumber}_${verse.chapter}_${verse.verse}.mp3`
const segment = {
id: `${manifest.id}:${verse.id}`,
resource_id: manifest.id,
translation_id: 'kjv-eng-kjv2006',
verse_id: verse.id,
book_id: verse.book_id,
book: verse.book,
book_number: bookInfo.bookNumber,
chapter: verse.chapter,
verse: verse.verse,
reference: verse.reference,
granularity: 'verse',
format: 'mp3',
reader: 'text-to-speech',
accent: 'British',
audio_zip_file: zipEntry.name,
audio_zip_url: zipEntry.download_url,
audio_zip_bytes: zipEntry.size,
audio_zip_sha: zipEntry.sha,
audio_entry_path: entryPath,
license: manifest.license.name,
attribution: manifest.license.attribution,
}
segments.push(segment)
const chapterKey = `${verse.book_id}.${verse.chapter}`
if (!chapterPlaylists.has(chapterKey)) {
chapterPlaylists.set(chapterKey, {
id: `${manifest.id}:${chapterKey}`,
resource_id: manifest.id,
translation_id: 'kjv-eng-kjv2006',
book_id: verse.book_id,
book: verse.book,
book_number: bookInfo.bookNumber,
chapter: verse.chapter,
reference: `${verse.book} ${verse.chapter}`,
granularity: 'chapter',
format: 'mp3-playlist',
audio_zip_file: zipEntry.name,
audio_zip_url: zipEntry.download_url,
audio_zip_bytes: zipEntry.size,
audio_zip_sha: zipEntry.sha,
segment_ids: [],
audio_entry_paths: [],
})
}
const playlist = chapterPlaylists.get(chapterKey)
playlist.segment_ids.push(segment.id)
playlist.audio_entry_paths.push(entryPath)
if (!bookStats.has(verse.book_id)) {
bookStats.set(verse.book_id, {
book_id: verse.book_id,
book: verse.book,
book_number: bookInfo.bookNumber,
chapters: new Set(),
verses: 0,
zipEntry,
})
}
const stats = bookStats.get(verse.book_id)
stats.chapters.add(verse.chapter)
stats.verses += 1
}
const playlists = Array.from(chapterPlaylists.values()).map((playlist) => ({
...playlist,
segment_count: playlist.segment_ids.length,
}))
const bookPackages = Array.from(bookStats.values())
.sort((a, b) => a.book_number - b.book_number)
.map((stats) => ({
id: `${manifest.id}:${String(stats.book_number).padStart(2, '0')}`,
resource_id: manifest.id,
translation_id: 'kjv-eng-kjv2006',
book_id: stats.book_id,
book: stats.book,
book_number: stats.book_number,
chapters: stats.chapters.size,
verses: stats.verses,
audio_zip_file: stats.zipEntry.name,
audio_zip_url: stats.zipEntry.download_url,
audio_zip_bytes: stats.zipEntry.size,
audio_zip_sha: stats.zipEntry.sha,
}))
await mkdir(packageDir, { recursive: true })
await writeJsonl(path.join(packageDir, 'audio-segments.jsonl'), segments)
await writeJsonl(path.join(packageDir, 'chapter-playlists.jsonl'), playlists)
await writeJsonl(path.join(packageDir, 'book-packages.jsonl'), bookPackages)
const files = {
audio_segments_jsonl: await fileInfo(path.join(packageDir, 'audio-segments.jsonl'), 'audio-segments.jsonl'),
chapter_playlists_jsonl: await fileInfo(path.join(packageDir, 'chapter-playlists.jsonl'), 'chapter-playlists.jsonl'),
book_packages_jsonl: await fileInfo(path.join(packageDir, 'book-packages.jsonl'), 'book-packages.jsonl'),
}
await writeJson(path.join(packageDir, 'catalog.json'), {
schema_version: 'librebible.resource-catalog.v1',
project: manifest.project_name,
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,
contributors: manifest.contributors ?? [],
features: manifest.features ?? [],
attachments: manifest.attachments ?? {},
source: manifest.source,
license: manifest.license,
catalog_display: manifest.catalog_display,
importer: manifest.importer,
source_manifest_file: path.basename(manifestPath),
generated_at: new Date().toISOString(),
source_sha256: manifest.checks.expected_sha256,
checks: manifest.checks,
counts: {
audio_segments: segments.length,
chapter_playlists: playlists.length,
book_packages: bookPackages.length,
books: bookPackages.length,
verses: segments.length,
},
files,
})
console.log(`Imported ${segments.length} KJV audio segment(s), ${playlists.length} chapter playlist(s), and ${bookPackages.length} book package(s).`)
async function fetchJson(url) {
const response = await fetch(url, {
headers: {
Accept: 'application/vnd.github+json',
'User-Agent': 'LibreBibleDataImporter',
},
})
if (!response.ok) throw new Error(`GitHub API request failed ${response.status} ${response.statusText}: ${url}`)
return response.json()
}
async function writeJsonl(filePath, rows) {
await mkdir(path.dirname(filePath), { recursive: true })
await writeFile(filePath, `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8')
}
async function fileInfo(filePath, relativePath) {
const stats = await stat(filePath)
return {
path: relativePath,
bytes: stats.size,
sha256: await sha256File(filePath),
}
}
+358
View File
@@ -0,0 +1,358 @@
import AdmZip from 'adm-zip'
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { downloadFile, readJson, sha256File, writeJson } from './lib.js'
const manifestPath = process.argv[2]
if (!manifestPath) {
console.error('Usage: node scripts/import-openbible-places.js sources/<manifest>.json')
process.exit(1)
}
const root = process.cwd()
const manifest = await readJson(path.resolve(root, manifestPath))
const zipPath = path.join(root, 'cache', manifest.id, `${manifest.id}.zip`)
const extractDir = path.join(root, 'cache', manifest.id, 'source')
const packageDir = path.join(root, 'packages', 'json', manifest.id)
await downloadFile(manifest.source.download_url, zipPath)
const sourceSha = await sha256File(zipPath)
if (sourceSha !== manifest.checks.expected_sha256.toLowerCase()) {
throw new Error(`${manifest.id}: source checksum mismatch. Expected ${manifest.checks.expected_sha256}, got ${sourceSha}`)
}
await rm(extractDir, { recursive: true, force: true })
await mkdir(extractDir, { recursive: true })
new AdmZip(zipPath).extractAllTo(extractDir, true)
const sourceRoot = await findExtractedRoot(extractDir, 'data/ancient.jsonl')
const ancientRows = await readJsonl(path.join(sourceRoot, 'data', 'ancient.jsonl'))
const modernRows = await readJsonl(path.join(sourceRoot, 'data', 'modern.jsonl'))
const modernLocations = modernRows.map((row) => ({
id: row.id,
name: row.friendly_id,
type: row.type ?? row.class ?? '',
class: row.class ?? '',
url_slug: row.url_slug ?? '',
geometry: row.geometry ?? '',
land_or_water: row.land_or_water ?? '',
lon: parseLonLat(row.lonlat).lon,
lat: parseLonLat(row.lonlat).lat,
lonlat_type: row.lonlat_type ?? row.precision?.description ?? '',
precision: row.precision ?? null,
names: row.names ?? [],
media: normalizeMedia(row.media),
source: {
geometry_credit: row.geometry_credit ?? '',
coordinates_source: row.coordinates_source ?? null,
secondary_sources: row.secondary_sources ?? [],
},
}))
const modernById = new Map(modernLocations.map((location) => [location.id, location]))
const places = []
const versePlaces = []
const placeImages = []
for (const row of ancientRows) {
const best = bestIdentification(row, modernById)
const place = {
id: row.id,
name: row.friendly_id,
url_slug: row.url_slug ?? '',
types: row.types ?? [],
preceding_article: row.preceding_article ?? '',
comment: row.comment ?? '',
translation_name_counts: row.translation_name_counts ?? {},
linked_data: row.linked_data ?? {},
geometry_credit: row.geometry_credit ?? '',
geojson_file: row.geojson_file ?? '',
best_modern_location_id: best?.id ?? '',
best_modern_location_name: best?.name ?? '',
lon: best?.lon ?? null,
lat: best?.lat ?? null,
confidence: best?.confidence ?? null,
confidence_label: confidenceLabel(best?.confidence),
media: normalizeMedia(row.media),
identifications: (row.identifications ?? []).map((identification) => ({
id: identification.id,
description: stripTags(identification.description ?? ''),
class: identification.class ?? '',
types: identification.resolutions?.flatMap((resolution) => resolution.types ?? []) ?? [],
score: identification.score?.time_total ?? identification.score?.vote_total ?? null,
})),
}
places.push(place)
const thumbnail = place.media?.thumbnail
if (thumbnail?.file || thumbnail?.credit_url) {
placeImages.push({
id: `${place.id}:thumbnail`,
place_id: place.id,
modern_location_id: place.best_modern_location_id,
kind: 'thumbnail',
file: thumbnail.file ?? '',
image_id: thumbnail.image_id ?? '',
description: stripTags(thumbnail.description ?? ''),
credit: thumbnail.credit ?? '',
credit_url: thumbnail.credit_url ?? '',
placeholder: thumbnail.placeholder ?? '',
})
}
for (const verse of row.verses ?? []) {
versePlaces.push({
id: `${osisToLibreVerseId(verse.osis)}:${row.id}`,
verse_id: osisToLibreVerseId(verse.osis),
osis: verse.osis,
usx: verse.usx ?? '',
readable: verse.readable ?? '',
translations: verse.translations ?? [],
place_id: row.id,
place_name: row.friendly_id,
place_types: row.types ?? [],
modern_location_id: place.best_modern_location_id,
modern_location_name: place.best_modern_location_name,
lon: place.lon,
lat: place.lat,
confidence: place.confidence,
confidence_label: place.confidence_label,
instance_types: verse.instance_types ?? {},
})
}
}
places.sort((a, b) => a.name.localeCompare(b.name))
modernLocations.sort((a, b) => a.name.localeCompare(b.name))
versePlaces.sort((a, b) => a.verse_id.localeCompare(b.verse_id) || a.place_name.localeCompare(b.place_name))
placeImages.sort((a, b) => a.place_id.localeCompare(b.place_id))
await mkdir(packageDir, { recursive: true })
await writeJsonl(path.join(packageDir, 'places.jsonl'), places)
await writeJsonl(path.join(packageDir, 'modern-locations.jsonl'), modernLocations)
await writeJsonl(path.join(packageDir, 'verse-places.jsonl'), versePlaces)
await writeJsonl(path.join(packageDir, 'place-images.jsonl'), placeImages)
const files = {
places_jsonl: await fileInfo(path.join(packageDir, 'places.jsonl')),
modern_locations_jsonl: await fileInfo(path.join(packageDir, 'modern-locations.jsonl')),
verse_places_jsonl: await fileInfo(path.join(packageDir, 'verse-places.jsonl')),
place_images_jsonl: await fileInfo(path.join(packageDir, 'place-images.jsonl')),
}
await writeJson(path.join(packageDir, 'catalog.json'), {
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,
contributors: manifest.contributors ?? [],
features: manifest.features ?? [],
attachments: manifest.attachments ?? {},
source: manifest.source,
license: manifest.license,
catalog_display: manifest.catalog_display,
importer: manifest.importer,
source_manifest_file: path.basename(manifestPath),
generated_at: new Date().toISOString(),
source_sha256: sourceSha,
checks: manifest.checks,
counts: {
entries: places.length,
places: places.length,
modern_locations: modernLocations.length,
verse_place_links: versePlaces.length,
verses_with_places: new Set(versePlaces.map((entry) => entry.verse_id)).size,
place_images: placeImages.length,
},
files,
})
console.log(`${manifest.id}: imported ${places.length} places, ${modernLocations.length} modern locations, ${versePlaces.length} verse-place links`)
async function findExtractedRoot(baseDir, requiredRelativePath) {
const entries = await import('node:fs/promises').then((fs) => fs.readdir(baseDir, { withFileTypes: true }))
for (const entry of entries) {
if (!entry.isDirectory()) continue
const candidate = path.join(baseDir, entry.name)
try {
await readFile(path.join(candidate, requiredRelativePath), 'utf8')
return candidate
} catch {
// Keep looking.
}
}
throw new Error(`Unable to find extracted source containing ${requiredRelativePath}`)
}
async function readJsonl(filePath) {
const text = await readFile(filePath, 'utf8')
return text
.split('\n')
.filter((line) => line.trim().length > 0)
.map((line) => JSON.parse(line))
}
function bestIdentification(row, modernById) {
const associations = Object.entries(row.modern_associations ?? {})
.map(([id, value]) => ({
id,
name: value.name,
confidence: Number(value.score ?? 0),
}))
.sort((left, right) => right.confidence - left.confidence)
for (const association of associations) {
const modern = modernById.get(association.id)
if (!modern) continue
return {
...modern,
confidence: association.confidence,
}
}
for (const identification of row.identifications ?? []) {
const modern = modernById.get(identification.id)
if (!modern) continue
return {
...modern,
confidence: identification.score?.time_total ?? identification.score?.vote_total ?? null,
}
}
return null
}
function confidenceLabel(score) {
if (score == null) return 'unknown'
if (score >= 800) return 'high'
if (score >= 400) return 'medium'
if (score > 0) return 'low'
return 'unknown'
}
function normalizeMedia(media) {
if (!media) return null
return {
thumbnail: media.thumbnail
? {
file: media.thumbnail.file ?? '',
image_id: media.thumbnail.image_id ?? '',
description: stripTags(media.thumbnail.description ?? ''),
credit: media.thumbnail.credit ?? '',
credit_url: media.thumbnail.credit_url ?? '',
placeholder: media.thumbnail.placeholder ?? '',
}
: null,
}
}
function parseLonLat(value) {
const [lon, lat] = String(value ?? '').split(',').map((part) => Number(part.trim()))
return {
lon: Number.isFinite(lon) ? lon : null,
lat: Number.isFinite(lat) ? lat : null,
}
}
function stripTags(value) {
return String(value).replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()
}
function osisToLibreVerseId(osis) {
const match = /^([1-3]?[A-Za-z]+)\.(\d+)\.(\d+)$/.exec(osis)
if (!match) return osis
return `${osisBookToUsx(match[1])}.${Number(match[2])}.${Number(match[3])}`
}
function osisBookToUsx(book) {
const map = {
Gen: 'GEN',
Exod: 'EXO',
Lev: 'LEV',
Num: 'NUM',
Deut: 'DEU',
Josh: 'JOS',
Judg: 'JDG',
Ruth: 'RUT',
'1Sam': '1SA',
'2Sam': '2SA',
'1Kgs': '1KI',
'2Kgs': '2KI',
'1Chr': '1CH',
'2Chr': '2CH',
Ezra: 'EZR',
Neh: 'NEH',
Esth: 'EST',
Job: 'JOB',
Ps: 'PSA',
Prov: 'PRO',
Eccl: 'ECC',
Song: 'SNG',
Isa: 'ISA',
Jer: 'JER',
Lam: 'LAM',
Ezek: 'EZK',
Dan: 'DAN',
Hos: 'HOS',
Joel: 'JOL',
Amos: 'AMO',
Obad: 'OBA',
Jonah: 'JON',
Mic: 'MIC',
Nah: 'NAM',
Hab: 'HAB',
Zeph: 'ZEP',
Hag: 'HAG',
Zech: 'ZEC',
Mal: 'MAL',
Matt: 'MAT',
Mark: 'MRK',
Luke: 'LUK',
John: 'JHN',
Acts: 'ACT',
Rom: 'ROM',
'1Cor': '1CO',
'2Cor': '2CO',
Gal: 'GAL',
Eph: 'EPH',
Phil: 'PHP',
Col: 'COL',
'1Thess': '1TH',
'2Thess': '2TH',
'1Tim': '1TI',
'2Tim': '2TI',
Titus: 'TIT',
Phlm: 'PHM',
Heb: 'HEB',
Jas: 'JAS',
'1Pet': '1PE',
'2Pet': '2PE',
'1John': '1JN',
'2John': '2JN',
'3John': '3JN',
Jude: 'JUD',
Rev: 'REV',
}
return map[book] ?? book.toUpperCase()
}
async function writeJsonl(filePath, rows) {
await writeFile(filePath, `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8')
}
async function fileInfo(filePath) {
const data = await readFile(filePath)
return {
path: path.basename(filePath),
bytes: data.length,
sha256: await sha256File(filePath),
}
}
+228
View File
@@ -0,0 +1,228 @@
import AdmZip from 'adm-zip'
import { copyFile, mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { downloadFile, readJson, sha256File, writeJson } from './lib.js'
const manifestPath = process.argv[2]
if (!manifestPath) {
console.error('Usage: node scripts/import-ubs-bible-routes.js sources/<manifest>.json')
process.exit(1)
}
const root = process.cwd()
const manifest = await readJson(path.resolve(root, manifestPath))
const zipPath = path.join(root, 'cache', manifest.id, `${manifest.id}.zip`)
const extractDir = path.join(root, 'cache', manifest.id, 'source')
const packageDir = path.join(root, 'packages', 'json', manifest.id)
const geojsonOutDir = path.join(packageDir, 'geojson')
await downloadFile(manifest.source.download_url, zipPath)
const sourceSha = await sha256File(zipPath)
if (sourceSha !== manifest.checks.expected_sha256.toLowerCase()) {
throw new Error(`${manifest.id}: source checksum mismatch. Expected ${manifest.checks.expected_sha256}, got ${sourceSha}`)
}
await rm(extractDir, { recursive: true, force: true })
await rm(packageDir, { recursive: true, force: true })
await mkdir(extractDir, { recursive: true })
await mkdir(geojsonOutDir, { recursive: true })
new AdmZip(zipPath).extractAllTo(extractDir, true)
const sourceRoot = await findExtractedRoot(extractDir, 'ubs-bible-routes/metadata.csv')
const routeRoot = path.join(sourceRoot, 'ubs-bible-routes')
const geojsonSourceDir = path.join(routeRoot, 'GeoJsonRoutes')
const metadata = parseMetadata(await readFile(path.join(routeRoot, 'metadata.csv'), 'utf8'))
const metadataByTitle = new Map(metadata.map((row) => [normalizeTitle(row.image_file), row]))
const geojsonFiles = (await readdir(geojsonSourceDir))
.filter((file) => file.toLowerCase().endsWith('.geojson'))
.sort((a, b) => a.localeCompare(b))
const routes = []
for (const file of geojsonFiles) {
const idMatch = /^(\d+)/.exec(file)
const routeNumber = idMatch ? Number(idMatch[1]) : routes.length + 1
const title = routeTitleFromFilename(file)
const chapter = metadataByTitle.get(normalizeTitle(file))
const outputName = `${String(routeNumber).padStart(3, '0')}-${slug(title)}.geojson`
const sourceFile = path.join(geojsonSourceDir, file)
const outputFile = path.join(geojsonOutDir, outputName)
await copyFile(sourceFile, outputFile)
const geojson = JSON.parse(await readFile(sourceFile, 'utf8'))
routes.push({
id: `ubs-route-${String(routeNumber).padStart(3, '0')}`,
route_number: routeNumber,
title,
section_number: chapter?.section_number ?? '',
section_title: chapter?.section_title ?? '',
map_number: chapter?.map_number ?? '',
source_file: `GeoJsonRoutes/${file}`,
geojson_file: `geojson/${outputName}`,
geometry_types: geometryTypes(geojson),
bounds: boundsForGeojson(geojson),
})
}
routes.sort((a, b) => a.route_number - b.route_number)
await writeJsonl(path.join(packageDir, 'routes.jsonl'), routes)
const files = {
routes_jsonl: await fileInfo(path.join(packageDir, 'routes.jsonl')),
geojson_dir: {
path: 'geojson',
files: geojsonFiles.length,
},
}
await writeJson(path.join(packageDir, 'catalog.json'), {
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,
contributors: manifest.contributors ?? [],
features: manifest.features ?? [],
attachments: manifest.attachments ?? {},
source: manifest.source,
license: manifest.license,
catalog_display: manifest.catalog_display,
importer: manifest.importer,
source_manifest_file: path.basename(manifestPath),
generated_at: new Date().toISOString(),
source_sha256: sourceSha,
checks: manifest.checks,
counts: {
entries: routes.length,
routes: routes.length,
geojson_files: geojsonFiles.length,
sections: new Set(routes.map((route) => route.section_number).filter(Boolean)).size,
},
files,
})
console.log(`${manifest.id}: imported ${routes.length} routes`)
async function findExtractedRoot(baseDir, requiredRelativePath) {
const entries = await readdir(baseDir, { withFileTypes: true })
for (const entry of entries) {
if (!entry.isDirectory()) continue
const candidate = path.join(baseDir, entry.name)
try {
await readFile(path.join(candidate, requiredRelativePath), 'utf8')
return candidate
} catch {
// Keep looking.
}
}
throw new Error(`Unable to find extracted source containing ${requiredRelativePath}`)
}
function parseMetadata(text) {
return text
.replace(/\r\n/g, '\n')
.split('\n')
.filter((line) => line.trim().length > 0)
.map((line) => {
const columns = line.split('\t')
return {
section_number: columns[0]?.trim() ?? '',
section_title: columns[1]?.trim() ?? '',
map_number: columns[2]?.trim() ?? '',
image_file: columns[3]?.trim() ?? '',
}
})
}
function routeTitleFromFilename(file) {
return file.replace(/^\d+\.\s*/, '').replace(/\.geojson$/i, '').trim()
}
function normalizeTitle(file) {
return String(file)
.replace(/^\d+\.\s*/, '')
.replace(/\.(geojson|jpg|jpeg|png|svg)$/i, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase()
}
function geometryTypes(geojson) {
const types = new Set()
visitCoordinates(geojson, (_lon, _lat, type) => {
if (type) types.add(type)
})
return [...types].sort()
}
function boundsForGeojson(geojson) {
const bounds = {
min_lon: Infinity,
min_lat: Infinity,
max_lon: -Infinity,
max_lat: -Infinity,
}
visitCoordinates(geojson, (lon, lat) => {
if (!Number.isFinite(lon) || !Number.isFinite(lat)) return
bounds.min_lon = Math.min(bounds.min_lon, lon)
bounds.min_lat = Math.min(bounds.min_lat, lat)
bounds.max_lon = Math.max(bounds.max_lon, lon)
bounds.max_lat = Math.max(bounds.max_lat, lat)
})
if (!Number.isFinite(bounds.min_lon)) return null
return bounds
}
function visitCoordinates(geojson, callback) {
if (geojson?.type === 'FeatureCollection') {
for (const feature of geojson.features ?? []) visitCoordinates(feature, callback)
return
}
if (geojson?.type === 'Feature') {
visitGeometry(geojson.geometry, callback)
return
}
visitGeometry(geojson, callback)
}
function visitGeometry(geometry, callback) {
if (!geometry) return
const type = geometry.type
if (type === 'GeometryCollection') {
for (const child of geometry.geometries ?? []) visitGeometry(child, callback)
return
}
walkCoordinates(geometry.coordinates, type, callback)
}
function walkCoordinates(value, type, callback) {
if (!Array.isArray(value)) return
if (typeof value[0] === 'number' && typeof value[1] === 'number') {
callback(value[0], value[1], type)
return
}
for (const child of value) walkCoordinates(child, type, callback)
}
function slug(value) {
return String(value).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
}
async function writeJsonl(filePath, rows) {
await writeFile(filePath, `${rows.map((row) => JSON.stringify(row)).join('\n')}\n`, 'utf8')
}
async function fileInfo(filePath) {
const data = await readFile(filePath)
return {
path: path.basename(filePath),
bytes: data.length,
sha256: await sha256File(filePath),
}
}