First commit on the local Gitea server, made after moving the tree off the Windows machine. Build outputs, node_modules and the AI models stay out (see .gitignore); everything the installer build needs is tracked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
268 lines
10 KiB
JavaScript
268 lines
10 KiB
JavaScript
// convert-message-txt-to-json.js
|
||
// Purpose: Convert a plain text export of The Message (MSG) into a local JSON file
|
||
// compatible with this app's loaders (Variant B: { Book: { Chapter: { Verse: "text" }}} like ESV/NIV/NLT JSON in this repo).
|
||
//
|
||
// Notes:
|
||
// - The input text groups content in verse ranges (e.g., "1–4 34 text...") rather
|
||
// than strict per-verse lines. This converter maps each range to all covered verse
|
||
// numbers by duplicating the range's text to each verse. While this isn't as precise
|
||
// as official per-verse segmentation, it produces a structurally valid local JSON
|
||
// that the app can render offline.
|
||
// - The converter validates chapter verse counts against the project's bible-structure.js
|
||
// and will pad missing verses with empty strings or truncate excess if necessary.
|
||
// - If you later want exact per-verse MSG text, prefer building from an official API
|
||
// and writing the same JSON shape.
|
||
//
|
||
// Usage (Windows CMD or PowerShell):
|
||
// node "C:\\Users\\media\\Downloads\\bible-app\\tools\\convert-message-txt-to-json.js" \
|
||
// "C:\\Users\\media\\Downloads\\bible-app\\message translation.txt" \
|
||
// "C:\\Users\\media\\Downloads\\bible-app\\MSG_bible.json"
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
function readFileUtf8(filePath) {
|
||
return fs.readFileSync(filePath, { encoding: 'utf8' });
|
||
}
|
||
|
||
function writeFileUtf8(filePath, data) {
|
||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||
fs.writeFileSync(filePath, data, { encoding: 'utf8' });
|
||
}
|
||
|
||
function loadBibleStructure(structureFilePath) {
|
||
const raw = readFileUtf8(structureFilePath);
|
||
// Extract the JS object literal assigned to `export const bibleStructure = { ... }`.
|
||
const startToken = 'export const bibleStructure =';
|
||
const startIdx = raw.indexOf(startToken);
|
||
if (startIdx === -1) {
|
||
throw new Error('Could not find bibleStructure export in bible-structure.js');
|
||
}
|
||
const afterStart = raw.slice(startIdx + startToken.length);
|
||
// Find the terminating semicolon after the object literal
|
||
// by tracking braces.
|
||
let brace = 0;
|
||
let objStart = -1;
|
||
let objEnd = -1;
|
||
for (let i = 0; i < afterStart.length; i++) {
|
||
const ch = afterStart[i];
|
||
if (objStart === -1) {
|
||
if (ch === '{') { objStart = i; brace = 1; }
|
||
continue;
|
||
} else {
|
||
if (ch === '{') brace++;
|
||
if (ch === '}') {
|
||
brace--;
|
||
if (brace === 0) { objEnd = i; break; }
|
||
}
|
||
}
|
||
}
|
||
if (objStart === -1 || objEnd === -1) {
|
||
throw new Error('Failed to extract bibleStructure object literal');
|
||
}
|
||
const objectLiteral = afterStart.slice(objStart, objEnd + 1);
|
||
// Evaluate in a sandboxed Function to obtain the object.
|
||
// The literal is plain JS with numeric arrays; no functions.
|
||
// eslint-disable-next-line no-new-func
|
||
const bibleStructure = new Function(`return (${objectLiteral});`)();
|
||
if (!bibleStructure || typeof bibleStructure !== 'object') {
|
||
throw new Error('bibleStructure parsed as invalid object');
|
||
}
|
||
return bibleStructure;
|
||
}
|
||
|
||
function normalizeSpace(s) {
|
||
return String(s || '')
|
||
.replace(/\u00A0/g, ' ') // non-breaking spaces -> space
|
||
.replace(/[\t ]+/g, ' ') // collapse whitespace
|
||
.trim();
|
||
}
|
||
|
||
function isBookHeader(line, knownBooksSet) {
|
||
const t = normalizeSpace(line);
|
||
if (!t) return null;
|
||
// Lines like "Exodus" / "Leviticus" / "Introduction".
|
||
if (t.toLowerCase() === 'introduction') return { type: 'intro' };
|
||
// Case-insensitive match to tolerate capitalization variants in source
|
||
for (const b of knownBooksSet) {
|
||
if (String(b).toLowerCase() === t.toLowerCase()) return { type: 'book', name: b };
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function isNoiseLine(line) {
|
||
const t = normalizeSpace(line).toLowerCase();
|
||
if (!t) return true;
|
||
// Skip site chrome and non-content lines common in scraped exports
|
||
const noiseSnippets = [
|
||
'link panels', 'go to table of contents', 'single column', 'stretch across both columns',
|
||
'show reading view', 'learn more', 'home', 'topic or passage', 'reading plans', 'about biblia',
|
||
'a service of logos', 'restore columns', 'share', 'the message: the bible in contemporary language',
|
||
'aa', 'christian bridges', 'the future of bible study is here.'
|
||
];
|
||
return noiseSnippets.some(sn => t.includes(sn));
|
||
}
|
||
|
||
// Match lines that begin a new (verse-range, chapter) chunk, e.g.:
|
||
// "1–4 34 One day Dinah, ..."
|
||
// "5–7 Jacob heard ..." (sometimes chapter implied by prior? Not in this export)
|
||
// "1 36 This is the family tree of Esau, ..."
|
||
// We capture: verseStart, verseEnd (optional), chapterNumber, and the first text segment.
|
||
const RANGE_DASH = '[\u2013\u2014\-]'; // en dash/em dash/hyphen
|
||
const CHUNK_RE = new RegExp(`^(\\d+)(?:\\s*${RANGE_DASH}\\s*(\\d+))?\\s+(\\d+)\\s+(.*)$`);
|
||
|
||
function appendTextToVerses(versesArray, indices, extraText) {
|
||
const add = normalizeSpace(extraText);
|
||
if (!add) return;
|
||
for (const idx of indices) {
|
||
if (!versesArray[idx]) {
|
||
versesArray[idx] = { verse: idx + 1, text: add };
|
||
} else {
|
||
const prev = String(versesArray[idx].text || '').trim();
|
||
versesArray[idx].text = prev ? (prev + ' ' + add) : add;
|
||
}
|
||
}
|
||
}
|
||
|
||
function convertTextToJson(inputText, bibleStructure) {
|
||
const knownBooks = Object.keys(bibleStructure);
|
||
const knownBooksSet = new Set(knownBooks);
|
||
// Heuristic: If the input starts mid-book without a header, assume Genesis until a header tells us otherwise.
|
||
let currentBook = 'Genesis';
|
||
let currentChapter = null; // number
|
||
let currentVerses = []; // array of { verse, text }
|
||
let lastTargetIndices = []; // verse indices (0-based) most recently written to
|
||
const booksOut = new Map(); // bookName -> Map(chapterNumber -> versesArray)
|
||
|
||
function ensureBook(book) {
|
||
if (!booksOut.has(book)) booksOut.set(book, new Map());
|
||
}
|
||
function flushChapter() {
|
||
if (currentBook && currentChapter != null) {
|
||
ensureBook(currentBook);
|
||
const chapters = booksOut.get(currentBook);
|
||
// Validate/pad according to structure counts where available
|
||
const chapterIdx = Number(currentChapter) - 1;
|
||
const expectedCount = Array.isArray(bibleStructure[currentBook]) ? (bibleStructure[currentBook][chapterIdx] || 0) : 0;
|
||
if (expectedCount > 0) {
|
||
if (currentVerses.length > expectedCount) {
|
||
currentVerses = currentVerses.slice(0, expectedCount);
|
||
} else if (currentVerses.length < expectedCount) {
|
||
for (let v = currentVerses.length; v < expectedCount; v++) {
|
||
currentVerses[v] = { verse: v + 1, text: '' };
|
||
}
|
||
}
|
||
}
|
||
chapters.set(Number(currentChapter), currentVerses);
|
||
}
|
||
}
|
||
|
||
const lines = inputText.split(/\r?\n/);
|
||
for (let rawLine of lines) {
|
||
const line = normalizeSpace(rawLine);
|
||
if (!line) continue;
|
||
const header = isBookHeader(line, knownBooksSet);
|
||
if (header) {
|
||
if (header.type === 'book') {
|
||
// Switching books: flush any prior chapter
|
||
flushChapter();
|
||
currentBook = header.name;
|
||
currentChapter = null;
|
||
currentVerses = [];
|
||
lastTargetIndices = [];
|
||
}
|
||
// Skip intros explicitly
|
||
continue;
|
||
}
|
||
|
||
if (isNoiseLine(line)) continue;
|
||
|
||
const m = CHUNK_RE.exec(line);
|
||
if (m) {
|
||
const verseStart = Number(m[1]);
|
||
const verseEnd = m[2] ? Number(m[2]) : verseStart;
|
||
const chapterNum = Number(m[3]);
|
||
const firstText = normalizeSpace(m[4] || '');
|
||
|
||
if (Number.isNaN(verseStart) || Number.isNaN(verseEnd) || Number.isNaN(chapterNum)) {
|
||
continue; // malformed
|
||
}
|
||
|
||
// New chapter encountered
|
||
if (currentChapter === null || Number(chapterNum) !== Number(currentChapter)) {
|
||
// flush previous chapter
|
||
flushChapter();
|
||
currentChapter = chapterNum;
|
||
currentVerses = [];
|
||
lastTargetIndices = [];
|
||
}
|
||
|
||
const startIdx = Math.max(1, verseStart) - 1;
|
||
const endIdx = Math.max(startIdx, verseEnd - 1);
|
||
const targetIndices = [];
|
||
for (let idx = startIdx; idx <= endIdx; idx++) targetIndices.push(idx);
|
||
|
||
appendTextToVerses(currentVerses, targetIndices, firstText);
|
||
lastTargetIndices = targetIndices;
|
||
continue;
|
||
}
|
||
|
||
// Continuation line: append to last target verses
|
||
if (lastTargetIndices.length) {
|
||
appendTextToVerses(currentVerses, lastTargetIndices, line);
|
||
}
|
||
}
|
||
|
||
// Flush trailing chapter
|
||
flushChapter();
|
||
|
||
// Build final JSON structure matching ESV/NIV/NLT shape: { Book: { Chapter: { Verse: text } } }
|
||
const out = {};
|
||
for (const book of knownBooks) {
|
||
const chaptersMap = booksOut.get(book);
|
||
if (!chaptersMap || chaptersMap.size === 0) continue; // only include books present in the input
|
||
const chapterObj = {};
|
||
const chapterNumbers = Array.from(chaptersMap.keys()).sort((a, b) => a - b);
|
||
for (const chNum of chapterNumbers) {
|
||
const verses = chaptersMap.get(chNum) || [];
|
||
const verseObj = {};
|
||
for (const v of verses) {
|
||
if (!v) continue;
|
||
verseObj[String(v.verse)] = String(v.text == null ? '' : v.text);
|
||
}
|
||
chapterObj[String(chNum)] = verseObj;
|
||
}
|
||
out[book] = chapterObj;
|
||
}
|
||
|
||
return out;
|
||
}
|
||
|
||
function main() {
|
||
const [, , inputPathArg, outputPathArg] = process.argv;
|
||
if (!inputPathArg || !outputPathArg) {
|
||
console.error('Usage:\n node \'tools/convert-message-txt-to-json.js\' "<INPUT_TXT_ABSOLUTE_PATH>" "<OUTPUT_JSON_ABSOLUTE_PATH>"');
|
||
process.exit(1);
|
||
}
|
||
|
||
const inputPath = path.resolve(inputPathArg);
|
||
const outputPath = path.resolve(outputPathArg);
|
||
const structurePath = path.resolve(__dirname, '..', 'bible-structure.js');
|
||
|
||
console.log('[MSG Convert] Reading input:', inputPath);
|
||
const inputText = readFileUtf8(inputPath);
|
||
console.log('[MSG Convert] Loading verse structure:', structurePath);
|
||
const bibleStructure = loadBibleStructure(structurePath);
|
||
console.log('[MSG Convert] Converting...');
|
||
const json = convertTextToJson(inputText, bibleStructure);
|
||
console.log('[MSG Convert] Writing output:', outputPath);
|
||
writeFileUtf8(outputPath, JSON.stringify(json, null, 2));
|
||
console.log('[MSG Convert] Done.');
|
||
}
|
||
|
||
if (require.main === module) {
|
||
try { main(); } catch (err) { console.error(err); process.exit(1); }
|
||
}
|
||
|
||
|