Add map and KJV audio resource packages
This commit is contained in:
@@ -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(', ')
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user