Features added: - Database schema for pages and media files with content types (Rich Text, HTML, Markdown) - Admin API routes for full page CRUD operations - Image upload functionality with file management - Rich text editor using TinyMCE with image insertion - Admin interface for creating/editing pages with SEO options - Dynamic navigation and footer integration - Public page display routes with proper SEO metadata - Support for featured images and content excerpts Admin features: - Create/edit/delete pages with rich content editor - Upload and manage images through media library - Configure pages to appear in navigation or footer - Set page status (Draft, Published, Archived) - SEO title and description management - Real-time preview of content changes 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
176 lines
7.1 KiB
TypeScript
176 lines
7.1 KiB
TypeScript
#!/usr/bin/env tsx
|
|
/*
|
|
Fetch the full English Bible from api.bible with careful rate limiting.
|
|
- Requires env:
|
|
- API_BIBLE_KEY: your api.scripture.api.bible key
|
|
- API_BIBLE_BASE (optional): default https://api.scripture.api.bible
|
|
- API_BIBLE_ID (optional): specific Bible ID to fetch (overrides abbr search)
|
|
- API_BIBLE_ABBR (optional): preferred abbreviation to locate (e.g., BSB, NIV, KJV)
|
|
- OUTPUT_DIR (optional): default data/en_bible
|
|
|
|
Output: Creates OT/NT JSON in format compatible with scripts/import-romanian-versioned.ts
|
|
data/en_bible/<ABBR>/old_testament.json
|
|
data/en_bible/<ABBR>/new_testament.json
|
|
|
|
Notes on rate limits:
|
|
- Serializes requests (concurrency 1) with a base delay between calls (baseDelayMs).
|
|
- Handles 429 with Retry-After header or exponential backoff.
|
|
- Supports resume by writing per-chapter JSON; if a chapter file exists, it skips re-fetching it.
|
|
*/
|
|
|
|
import 'dotenv/config'
|
|
import fs from 'fs'
|
|
import path from 'path'
|
|
|
|
const API_KEY = process.env.API_BIBLE_KEY || ''
|
|
const API_BASE = (process.env.API_BIBLE_BASE || 'https://api.scripture.api.bible').replace(/\/$/, '')
|
|
const PREF_ABBR = process.env.API_BIBLE_ABBR || 'BSB'
|
|
const FORCE_BIBLE_ID = process.env.API_BIBLE_ID || ''
|
|
const OUTPUT_ROOT = process.env.OUTPUT_DIR || path.join('data', 'en_bible')
|
|
|
|
if (!API_KEY) {
|
|
console.error('Missing API_BIBLE_KEY in environment')
|
|
process.exit(1)
|
|
}
|
|
|
|
// Simple throttling and backoff
|
|
const baseDelayMs = 350 // ~3 requests/second baseline
|
|
function sleep(ms: number) { return new Promise(res => setTimeout(res, ms)) }
|
|
|
|
async function requestJson(url: string, init: RequestInit = {}, attempt = 0): Promise<any> {
|
|
await sleep(baseDelayMs)
|
|
const res = await fetch(url, {
|
|
...init,
|
|
headers: {
|
|
'accept': 'application/json',
|
|
'api-key': API_KEY,
|
|
...(init.headers || {})
|
|
}
|
|
})
|
|
|
|
if (res.status === 429) {
|
|
const retry = parseInt(res.headers.get('retry-after') || '0', 10)
|
|
const wait = Math.max(1000, (retry || 1) * 1000)
|
|
if (attempt < 6) {
|
|
console.warn(`429 rate limited. Waiting ${wait}ms and retrying...`)
|
|
await sleep(wait)
|
|
return requestJson(url, init, attempt + 1)
|
|
}
|
|
}
|
|
|
|
if (!res.ok) {
|
|
const body = await res.text()
|
|
throw new Error(`HTTP ${res.status} for ${url}: ${body}`)
|
|
}
|
|
return res.json()
|
|
}
|
|
|
|
type BibleMeta = { id: string; name: string; abbreviation: string; language: { id: string; name: string } }
|
|
|
|
async function resolveBible(): Promise<BibleMeta> {
|
|
if (FORCE_BIBLE_ID) {
|
|
const data = await requestJson(`${API_BASE}/v1/bibles/${FORCE_BIBLE_ID}`)
|
|
return data.data as BibleMeta
|
|
}
|
|
const list = await requestJson(`${API_BASE}/v1/bibles?language=eng`) // English
|
|
const bibles: BibleMeta[] = list.data || []
|
|
let chosen = bibles.find(b => (b.abbreviation || '').toUpperCase() === PREF_ABBR.toUpperCase())
|
|
if (!chosen) chosen = bibles[0]
|
|
if (!chosen) throw new Error('No English bibles found via API')
|
|
return chosen
|
|
}
|
|
|
|
type Book = { id: string; name: string; abbreviation: string; ord: number }
|
|
type Chapter = { id: string; number: string }
|
|
|
|
async function fetchBooks(bibleId: string): Promise<Book[]> {
|
|
const resp = await requestJson(`${API_BASE}/v1/bibles/${bibleId}/books`)
|
|
const data = resp.data || []
|
|
return data.map((b: any, i: number) => ({ id: b.id, name: b.name, abbreviation: b.abbreviation, ord: b.order || (i + 1) }))
|
|
}
|
|
|
|
async function fetchChapters(bibleId: string, bookId: string): Promise<Chapter[]> {
|
|
const resp = await requestJson(`${API_BASE}/v1/bibles/${bibleId}/books/${bookId}/chapters`)
|
|
const data = resp.data || []
|
|
return data.map((c: any) => ({ id: c.id, number: c.number }))
|
|
}
|
|
|
|
// Fetch all verse IDs for a chapter, then fetch each verse text (contentType=text)
|
|
async function fetchChapterVerses(bibleId: string, chapterId: string): Promise<{ verseNum: number; text: string }[]> {
|
|
const resp = await requestJson(`${API_BASE}/v1/bibles/${bibleId}/chapters/${chapterId}/verses`)
|
|
const verses: any[] = resp.data || []
|
|
const results: { verseNum: number; text: string }[] = []
|
|
for (const v of verses) {
|
|
const vId = v.id
|
|
// Respect rate limits while fetching verse content
|
|
const vResp = await requestJson(`${API_BASE}/v1/bibles/${bibleId}/verses/${vId}?content-type=text&include-notes=false&include-titles=false&include-chapter-numbers=false&include-verse-numbers=false&include-verse-spans=false`)
|
|
const text: string = vResp?.data?.content?.trim?.() || vResp?.data?.content || ''
|
|
// Extract verse number from reference when available, fallback to sequence
|
|
const num = parseInt((v.reference || '').split(':')[1] || v.verseCount || v.position || results.length + 1, 10)
|
|
results.push({ verseNum: Number.isFinite(num) ? num : (results.length + 1), text })
|
|
}
|
|
// Sort by verse number just in case
|
|
results.sort((a, b) => a.verseNum - b.verseNum)
|
|
return results
|
|
}
|
|
|
|
function ensureDir(p: string) { fs.mkdirSync(p, { recursive: true }) }
|
|
function writeJson(file: string, obj: any) { ensureDir(path.dirname(file)); fs.writeFileSync(file, JSON.stringify(obj, null, 2), 'utf-8') }
|
|
function exists(p: string) { try { fs.accessSync(p); return true } catch { return false } }
|
|
|
|
async function main() {
|
|
const bible = await resolveBible()
|
|
console.log(`Using Bible: ${bible.name} (${bible.abbreviation}) [${bible.id}]`)
|
|
const outDir = path.join(OUTPUT_ROOT, bible.abbreviation.toUpperCase())
|
|
ensureDir(outDir)
|
|
|
|
const books = await fetchBooks(bible.id)
|
|
// Partition into OT/NT by order threshold (first 39 = OT)
|
|
const otBooks = books.filter(b => b.ord <= 39)
|
|
const ntBooks = books.filter(b => b.ord > 39)
|
|
|
|
const buildTestament = async (subset: Book[], label: 'Old Testament' | 'New Testament') => {
|
|
const result: any = { testament: label, books: [] as any[] }
|
|
for (const b of subset) {
|
|
console.log(`Book: ${b.name}`)
|
|
const bookOutDir = path.join(outDir, b.abbreviation || b.name)
|
|
ensureDir(bookOutDir)
|
|
const chs = await fetchChapters(bible.id, b.id)
|
|
const chaptersArr: any[] = []
|
|
for (const ch of chs) {
|
|
const cacheFile = path.join(bookOutDir, `chapter-${ch.number}.json`)
|
|
if (exists(cacheFile)) {
|
|
const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf-8'))
|
|
chaptersArr.push(cached)
|
|
continue
|
|
}
|
|
console.log(` Chapter ${ch.number}`)
|
|
const verses = await fetchChapterVerses(bible.id, ch.id)
|
|
const chapterObj = { chapterNum: parseInt(ch.number, 10), verses }
|
|
writeJson(cacheFile, chapterObj)
|
|
chaptersArr.push(chapterObj)
|
|
}
|
|
result.books.push({ name: b.name, chapters: chaptersArr })
|
|
}
|
|
return result
|
|
}
|
|
|
|
const ot = await buildTestament(otBooks, 'Old Testament')
|
|
const nt = await buildTestament(ntBooks, 'New Testament')
|
|
|
|
const otFile = path.join(outDir, 'old_testament.json')
|
|
const ntFile = path.join(outDir, 'new_testament.json')
|
|
writeJson(otFile, ot)
|
|
writeJson(ntFile, nt)
|
|
console.log('Wrote:', otFile)
|
|
console.log('Wrote:', ntFile)
|
|
|
|
console.log('\nNext: import into the versioned schema using scripts/import-romanian-versioned.ts with LANG_CODE=en and TRANSLATION_CODE matching', bible.abbreviation)
|
|
}
|
|
|
|
main().catch(err => {
|
|
console.error('Fetch failed:', err)
|
|
process.exit(1)
|
|
})
|
|
|