@jager-ai/holy-editor
Version:
Rich text editor with Bible verse slash commands and PWA keyboard tracking, extracted from Holy Habit project
286 lines (247 loc) • 8.61 kB
text/typescript
/**
* Bible Verse Engine
*
* Core engine for Bible verse slash commands and API integration
* Extracted from Holy Habit holy-editor-pro.js
*/
import {
BibleVerse,
BibleVerseData,
BibleApiResponse,
BibleApiError,
BibleBookMapping,
VerseCache,
SlashCommandMatch
} from '../types/Editor';
export class BibleVerseEngine {
private static instance: BibleVerseEngine;
private cache: VerseCache = {};
private lastInsertedRef = '';
private lastInsertedTime = 0;
private debounceMs = 300;
private apiEndpoint = '/api/bible_verse_full.php';
// Bible book mappings (66 books total)
private static readonly OLD_TESTAMENT_BOOKS: BibleBookMapping = {
창: '창세기', 출: '출애굽기', 레: '레위기', 민: '민수기', 신: '신명기',
수: '여호수아', 삿: '사사기', 룻: '룻기', 삼상: '사무엘상', 삼하: '사무엘하',
왕상: '열왕기상', 왕하: '열왕기하', 대상: '역대상', 대하: '역대하',
스: '에스라', 느: '느헤미야', 에: '에스더', 욥: '욥기', 시: '시편',
잠: '잠언', 전: '전도서', 아: '아가', 사: '이사야', 렘: '예레미야',
애: '예레미야애가', 겔: '에스겔', 단: '다니엘', 호: '호세아', 욜: '요엘',
암: '아모스', 옵: '오바댜', 욘: '요나', 미: '미가', 나: '나훔',
합: '하박국', 습: '스바냐', 학: '학개', 슥: '스가랴', 말: '말라기'
};
private static readonly NEW_TESTAMENT_BOOKS: BibleBookMapping = {
마: '마태복음', 막: '마가복음', 눅: '누가복음', 요: '요한복음',
행: '사도행전', 롬: '로마서', 고전: '고린도전서', 고후: '고린도후서',
갈: '갈라디아서', 엡: '에베소서', 빌: '빌립보서', 골: '골로새서',
살전: '데살로니가전서', 살후: '데살로니가후서', 딤전: '디모데전서',
딤후: '디모데후서', 딛: '디도서', 몬: '빌레몬서', 히: '히브리서',
약: '야고보서', 벧전: '베드로전서', 벧후: '베드로후서',
요일: '요한일서', 요이: '요한이서', 요삼: '요한삼서', 유: '유다서', 계: '요한계시록'
};
private static readonly ALL_BOOKS: BibleBookMapping = {
...BibleVerseEngine.OLD_TESTAMENT_BOOKS,
...BibleVerseEngine.NEW_TESTAMENT_BOOKS
};
// Regular expressions
private static readonly CMD_RE = /\/([^\s/]{1,24})/ug;
private static readonly BIBLE_REF_RE = /^([가-힣A-Za-z]{1,6})(\d{0,3}):(\d{1,3})(?:-(\d{1,3}))?$/u;
private constructor(apiEndpoint?: string, debounceMs?: number) {
if (apiEndpoint) {
this.apiEndpoint = apiEndpoint;
}
if (debounceMs !== undefined) {
this.debounceMs = debounceMs;
}
}
/**
* Get singleton instance
*/
public static getInstance(apiEndpoint?: string, debounceMs?: number): BibleVerseEngine {
if (!BibleVerseEngine.instance) {
BibleVerseEngine.instance = new BibleVerseEngine(apiEndpoint, debounceMs);
}
return BibleVerseEngine.instance;
}
/**
* Parse slash commands from text
*/
public parseSlashCommands(text: string): SlashCommandMatch[] {
const matches: SlashCommandMatch[] = [];
for (const match of text.matchAll(BibleVerseEngine.CMD_RE)) {
const ref = match[1].replace(/\u00A0/g, ''); // Remove NBSP
const position = match.index || 0;
if (this.isValidBibleRef(ref)) {
matches.push({
ref,
position,
fullMatch: match[0]
});
}
}
return matches;
}
/**
* Validate Bible reference format (supports range verses)
*/
public isValidBibleRef(ref: string): boolean {
// Format check
const match = ref.match(BibleVerseEngine.BIBLE_REF_RE);
if (!match) return false;
const [, , , fromVerse, toVerse] = match;
// Range verse validation
if (toVerse) {
const from = parseInt(fromVerse);
const to = parseInt(toVerse);
// End verse must be greater than start verse
if (to < from) {
return false;
}
// Limit range to 10 verses maximum
if (to - from > 10) {
console.warn('📖 Range too large (max 10 verses):', ref);
return false;
}
}
// Book name validation
const bookAbbr = ref.replace(/\s*\d.*$/, '').trim();
if (!BibleVerseEngine.ALL_BOOKS[bookAbbr]) {
console.warn('📖 Unsupported Bible book:', bookAbbr);
return false;
}
// Duplicate prevention (debouncing)
const now = Date.now();
if (ref === this.lastInsertedRef && now - this.lastInsertedTime < this.debounceMs) {
console.log('📖 Duplicate insertion prevented:', ref);
return false;
}
console.log('📖 Valid Bible reference:', ref);
return true;
}
/**
* Load verse from API with caching and error handling
*/
public async loadVerse(ref: string): Promise<BibleVerseData | null> {
// Check memory cache first
const cachedVerse = this.cache[ref];
if (cachedVerse) {
console.log('📖 Loading verse from cache:', ref, cachedVerse);
return cachedVerse;
}
// API call if not cached
try {
const url = `${this.apiEndpoint}?query=${encodeURIComponent(ref)}`;
const response = await fetch(url, { cache: 'force-cache' });
if (!response.ok) {
// Handle different error status codes
await response.json().catch(() => ({}));
switch (response.status) {
case 404:
throw new BibleApiError('Verse not found', 404);
case 422:
throw new BibleApiError('Invalid verse format', 422);
case 500:
throw new BibleApiError('Server error', 500);
default:
throw new BibleApiError('Network error', response.status);
}
}
const data: BibleApiResponse = await response.json();
// Handle new API response format (supports range verses)
if (data.success && data.verses && data.verses.length > 0) {
const verseData: BibleVerseData = {
verses: data.verses,
isRange: data.verses.length > 1
};
// Cache the result
this.cache[ref] = verseData;
console.log('📖 Verse loaded successfully:', ref, verseData);
return verseData;
} else {
console.warn('📖 Verse not found:', ref);
return null;
}
} catch (error) {
if (error instanceof BibleApiError) {
throw error;
}
console.error('📖 Network connection error:', ref, error);
throw new BibleApiError('Network connection failed', undefined, error as Error);
}
}
/**
* Get book name from abbreviation
*/
public getBookName(abbr: string): string | undefined {
return BibleVerseEngine.ALL_BOOKS[abbr];
}
/**
* Get all supported book abbreviations
*/
public getSupportedBooks(): string[] {
return Object.keys(BibleVerseEngine.ALL_BOOKS);
}
/**
* Update duplicate prevention state
*/
public updateInsertionState(ref: string): void {
this.lastInsertedRef = ref;
this.lastInsertedTime = Date.now();
}
/**
* Clear cache
*/
public clearCache(): void {
this.cache = {};
console.log('📖 Bible verse cache cleared');
}
/**
* Get cache stats
*/
public getCacheStats(): { size: number; keys: string[] } {
const keys = Object.keys(this.cache);
return {
size: keys.length,
keys
};
}
/**
* Set API endpoint
*/
public setApiEndpoint(endpoint: string): void {
this.apiEndpoint = endpoint;
}
/**
* Set debounce time
*/
public setDebounceMs(ms: number): void {
this.debounceMs = ms;
}
/**
* Extract book abbreviation from reference
*/
public extractBookAbbr(ref: string): string {
return ref.replace(/\s*\d.*$/, '').trim();
}
/**
* Check if reference is a range verse
*/
public isRangeVerse(ref: string): boolean {
const match = ref.match(BibleVerseEngine.BIBLE_REF_RE);
return match ? !!match[4] : false;
}
/**
* Parse verse numbers from reference
*/
public parseVerseNumbers(ref: string): { chapter: number; startVerse: number; endVerse?: number } | null {
const match = ref.match(BibleVerseEngine.BIBLE_REF_RE);
if (!match) return null;
const [, , chapter, startVerse, endVerse] = match;
return {
chapter: parseInt(chapter) || 1,
startVerse: parseInt(startVerse),
endVerse: endVerse ? parseInt(endVerse) : undefined
};
}
}