You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1243 lines
46 KiB
1243 lines
46 KiB
import React, { useState, useEffect, useMemo, useRef } from 'react' |
|
import { Event } from 'nostr-tools' |
|
import { parseBookWikilink, extractBookMetadata, BookReference } from '@/lib/bookstr-parser' |
|
import client from '@/services/client.service' |
|
import { macroService } from '@/services/client.service' |
|
import { ExtendedKind } from '@/constants' |
|
import { Loader2, AlertCircle, ExternalLink } from 'lucide-react' |
|
import { |
|
Select, |
|
SelectContent, |
|
SelectItem, |
|
SelectTrigger, |
|
SelectValue, |
|
} from '@/components/ui/select' |
|
import { cn } from '@/lib/utils' |
|
import logger from '@/lib/logger' |
|
import { contentParserService } from '@/services/content-parser.service' |
|
import WebPreview from '@/components/WebPreview' |
|
|
|
interface BookstrContentProps { |
|
wikilink: string |
|
sourceUrl?: string |
|
className?: string |
|
skipWebPreview?: boolean // If true, show simple button instead of WebPreview |
|
} |
|
|
|
interface BookSection { |
|
reference: BookReference |
|
events: Event[] |
|
versions: string[] |
|
originalVerses?: string |
|
originalChapter?: number |
|
} |
|
|
|
/** |
|
* Get the first verse number from a verse string (handles ranges and lists) |
|
*/ |
|
function getFirstVerse(verse: string): number | null { |
|
if (!verse) return null |
|
|
|
// Split by comma to handle lists like "6,8,10" |
|
const firstPart = verse.split(',')[0].trim() |
|
|
|
// Handle ranges like "6-8" - take the first number |
|
if (firstPart.includes('-')) { |
|
const start = parseInt(firstPart.split('-')[0].trim(), 10) |
|
return isNaN(start) ? null : start |
|
} |
|
|
|
// Single verse number |
|
const verseNum = parseInt(firstPart, 10) |
|
return isNaN(verseNum) ? null : verseNum |
|
} |
|
|
|
/** |
|
* Normalize book name to Sefaria format (capitalize first letter of each word) |
|
*/ |
|
function normalizeSefariaBookName(bookName: string): string { |
|
return bookName |
|
.split(' ') |
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) |
|
.join(' ') |
|
} |
|
|
|
/** |
|
* Build Sefaria URL for a torah reference |
|
*/ |
|
function buildSefariaUrl(reference: BookReference): string | null { |
|
if (!reference.book) return null |
|
|
|
// Sefaria uses exact book names: Genesis, Exodus, Leviticus, Numbers, Deuteronomy |
|
const bookName = normalizeSefariaBookName(reference.book) |
|
|
|
if (!reference.chapter) { |
|
// Book only |
|
return `https://www.sefaria.org/${bookName}?tab=contents` |
|
} |
|
|
|
if (!reference.verse) { |
|
// Chapter only |
|
return `https://www.sefaria.org/${bookName}.${reference.chapter}?lang=bi` |
|
} |
|
|
|
// Verse - get first verse from range/list |
|
const firstVerse = getFirstVerse(reference.verse) |
|
if (firstVerse === null) { |
|
// Invalid verse, fall back to chapter |
|
return `https://www.sefaria.org/${bookName}.${reference.chapter}?lang=bi` |
|
} |
|
|
|
// Verse with chapter |
|
return `https://www.sefaria.org/${bookName}.${reference.chapter}.${firstVerse}?lang=bi&with=all&lang2=en` |
|
} |
|
|
|
/** |
|
* Mapping from Quran surah names to surah numbers (1-114) |
|
*/ |
|
const surahNameToNumber: Record<string, number> = { |
|
'Al-Fatiha': 1, 'Al-Baqarah': 2, 'Ali Imran': 3, 'An-Nisa': 4, 'Al-Maidah': 5, |
|
'Al-Anam': 6, 'Al-Araf': 7, 'Al-Anfal': 8, 'At-Tawbah': 9, 'Yunus': 10, |
|
'Hud': 11, 'Yusuf': 12, 'Ar-Rad': 13, 'Ibrahim': 14, 'Al-Hijr': 15, |
|
'An-Nahl': 16, 'Al-Isra': 17, 'Al-Kahf': 18, 'Maryam': 19, 'Taha': 20, |
|
'Al-Anbiya': 21, 'Al-Hajj': 22, 'Al-Muminun': 23, 'An-Nur': 24, 'Al-Furqan': 25, |
|
'Ash-Shuara': 26, 'An-Naml': 27, 'Al-Qasas': 28, 'Al-Ankabut': 29, 'Ar-Rum': 30, |
|
'Luqman': 31, 'As-Sajdah': 32, 'Al-Ahzab': 33, 'Saba': 34, 'Fatir': 35, |
|
'Ya-Sin': 36, 'As-Saffat': 37, 'Sad': 38, 'Az-Zumar': 39, 'Ghafir': 40, |
|
'Fussilat': 41, 'Ash-Shura': 42, 'Az-Zukhruf': 43, 'Ad-Dukhan': 44, 'Al-Jathiyah': 45, |
|
'Al-Ahqaf': 46, 'Muhammad': 47, 'Al-Fath': 48, 'Al-Hujurat': 49, 'Qaf': 50, |
|
'Adh-Dhariyat': 51, 'At-Tur': 52, 'An-Najm': 53, 'Al-Qamar': 54, 'Ar-Rahman': 55, |
|
'Al-Waqiah': 56, 'Al-Hadid': 57, 'Al-Mujadilah': 58, 'Al-Hashr': 59, 'Al-Mumtahanah': 60, |
|
'As-Saff': 61, 'Al-Jumuah': 62, 'Al-Munafiqun': 63, 'At-Taghabun': 64, 'At-Talaq': 65, |
|
'At-Tahrim': 66, 'Al-Mulk': 67, 'Al-Qalam': 68, 'Al-Haqqah': 69, 'Al-Maarij': 70, |
|
'Nuh': 71, 'Al-Jinn': 72, 'Al-Muzzammil': 73, 'Al-Muddaththir': 74, 'Al-Qiyamah': 75, |
|
'Al-Insan': 76, 'Al-Mursalat': 77, 'An-Naba': 78, 'An-Naziat': 79, 'Abasa': 80, |
|
'At-Takwir': 81, 'Al-Infitar': 82, 'Al-Mutaffifin': 83, 'Al-Inshiqaq': 84, 'Al-Buruj': 85, |
|
'At-Tariq': 86, 'Al-Ala': 87, 'Al-Ghashiyah': 88, 'Al-Fajr': 89, 'Al-Balad': 90, |
|
'Ash-Shams': 91, 'Al-Layl': 92, 'Ad-Duha': 93, 'Ash-Sharh': 94, 'At-Tin': 95, |
|
'Al-Alaq': 96, 'Al-Qadr': 97, 'Al-Bayyinah': 98, 'Az-Zalzalah': 99, 'Al-Adiyat': 100, |
|
'Al-Qariah': 101, 'At-Takathur': 102, 'Al-Asr': 103, 'Al-Humazah': 104, 'Al-Fil': 105, |
|
'Quraysh': 106, 'Al-Maun': 107, 'Al-Kawthar': 108, 'Al-Kafirun': 109, 'An-Nasr': 110, |
|
'Al-Masad': 111, 'Al-Ikhlas': 112, 'Al-Falaq': 113, 'An-Nas': 114 |
|
} |
|
|
|
/** |
|
* Build quran.com URL for a quran reference |
|
*/ |
|
function buildQuranComUrl(reference: BookReference): string | null { |
|
if (!reference.book) return null |
|
|
|
// For Quran, "chapter" is actually the surah number |
|
let surahNumber: number | undefined |
|
if (reference.chapter && typeof reference.chapter === 'number' && reference.chapter >= 1 && reference.chapter <= 114) { |
|
surahNumber = reference.chapter |
|
} else { |
|
// Try book name lookup |
|
const bookAsNumber = parseInt(reference.book.trim(), 10) |
|
if (!isNaN(bookAsNumber) && bookAsNumber >= 1 && bookAsNumber <= 114) { |
|
surahNumber = bookAsNumber |
|
} else { |
|
// Try case-insensitive lookup |
|
const normalizedBook = reference.book.trim() |
|
const matchingKey = Object.keys(surahNameToNumber).find( |
|
key => key.toLowerCase() === normalizedBook.toLowerCase() |
|
) |
|
if (matchingKey) { |
|
surahNumber = surahNameToNumber[matchingKey] |
|
} else { |
|
// Try normalized matching (remove hyphens, spaces, etc.) |
|
const normalizedBookClean = normalizedBook.toLowerCase().replace(/[^a-z0-9]/g, '') |
|
const matchingKey2 = Object.keys(surahNameToNumber).find(key => { |
|
const normalizedKey = key.toLowerCase().replace(/[^a-z0-9]/g, '') |
|
return normalizedKey === normalizedBookClean |
|
}) |
|
if (matchingKey2) { |
|
surahNumber = surahNameToNumber[matchingKey2] |
|
} |
|
} |
|
} |
|
} |
|
|
|
if (!surahNumber) { |
|
return null |
|
} |
|
|
|
// In Quran, "verse" is the ayah |
|
if (reference.verse) { |
|
const firstAyah = getFirstVerse(reference.verse) |
|
if (firstAyah === null) { |
|
return `https://quran.com/${surahNumber}` |
|
} |
|
return `https://quran.com/${surahNumber}?startingVerse=${firstAyah}` |
|
} |
|
|
|
return `https://quran.com/${surahNumber}` |
|
} |
|
|
|
/** |
|
* Build Bible Gateway URL for a passage |
|
*/ |
|
function buildBibleGatewayUrl(reference: BookReference, version?: string): string { |
|
// Format passage: "Psalm 23:4-7" or "Genesis 1:4" or "1 John 3:16" |
|
let passage = reference.book |
|
if (reference.chapter !== undefined) { |
|
passage += ` ${reference.chapter}` |
|
} |
|
if (reference.verse) { |
|
passage += `:${reference.verse}` |
|
} |
|
|
|
// Map version codes to Bible Gateway codes |
|
const versionMap: Record<string, string> = { |
|
'DRB': 'DRA', // Douay-Rheims Bible -> Douay-Rheims 1899 American Edition |
|
'DRA': 'DRA', // Already correct |
|
} |
|
|
|
const bgVersion = version ? (versionMap[version.toUpperCase()] || version.toUpperCase()) : 'DRA' |
|
|
|
// URL encode the passage |
|
const encodedPassage = encodeURIComponent(passage) |
|
|
|
return `https://www.biblegateway.com/passage/?search=${encodedPassage}&version=${bgVersion}` |
|
} |
|
|
|
/** |
|
* Build external URL for a book reference based on bookType |
|
*/ |
|
function buildExternalUrl(reference: BookReference, bookType: string, version?: string): string | null { |
|
if (bookType === 'torah') { |
|
return buildSefariaUrl(reference) |
|
} else if (bookType === 'quran') { |
|
return buildQuranComUrl(reference) |
|
} else if (bookType === 'bible') { |
|
// Only build Bible Gateway URL for bible type |
|
return buildBibleGatewayUrl(reference, version) |
|
} else { |
|
// For other types (like 'book'), return null - no external link |
|
return null |
|
} |
|
} |
|
|
|
export function BookstrContent({ wikilink, sourceUrl, className, skipWebPreview = false }: BookstrContentProps) { |
|
const [sections, setSections] = useState<BookSection[]>([]) |
|
const [isLoading, setIsLoading] = useState(false) // Start as false, only set to true when actually fetching |
|
const [error, setError] = useState<string | null>(null) |
|
const [selectedVersions, setSelectedVersions] = useState<Map<number, string>>(new Map()) |
|
// Track which sections are still loading (by reference key) |
|
const [loadingSections, setLoadingSections] = useState<Set<string>>(new Set()) |
|
|
|
// Parse the wikilink - use a ref to store the last parsed result for comparison |
|
const parsedRef = useRef<ReturnType<typeof parseBookWikilink> & { bookType: string } | null>(null) |
|
const parsed = useMemo(() => { |
|
try { |
|
// NKBIP-08 format: book::... (must have double colon) |
|
let wikilinkToParse = wikilink |
|
|
|
if (wikilink.startsWith('book::')) { |
|
// Already in correct format, add brackets if needed |
|
if (!wikilink.startsWith('[[')) { |
|
wikilinkToParse = `[[${wikilink}]]` |
|
} else { |
|
wikilinkToParse = wikilink |
|
} |
|
} else { |
|
// Invalid format - must start with book:: |
|
parsedRef.current = null |
|
return null |
|
} |
|
|
|
const result = parseBookWikilink(wikilinkToParse) |
|
if (result) { |
|
const inferredBookType = result.bookType || 'book' |
|
const parsedResult = { ...result, bookType: inferredBookType } |
|
|
|
// Only log if this is a new parse (not a re-render with same wikilink) |
|
if (parsedRef.current === null || JSON.stringify(parsedRef.current.references) !== JSON.stringify(parsedResult.references)) { |
|
logger.debug('BookstrContent: Parsed wikilink', { |
|
wikilink, |
|
wikilinkToParse, |
|
bookType: inferredBookType, |
|
referenceCount: result.references.length, |
|
references: result.references.map(r => ({ |
|
book: r.book, |
|
chapter: r.chapter, |
|
verse: r.verse, |
|
version: r.version |
|
})), |
|
versions: result.versions |
|
}) |
|
} |
|
|
|
parsedRef.current = parsedResult |
|
return parsedResult |
|
} |
|
parsedRef.current = null |
|
return null |
|
} catch (err) { |
|
logger.error('Error parsing bookstr wikilink', { error: err, wikilink }) |
|
parsedRef.current = null |
|
return null |
|
} |
|
}, [wikilink]) |
|
|
|
// Track if we've already fetched to prevent infinite loops |
|
const hasFetchedRef = useRef<string | null>(null) |
|
const isFetchingRef = useRef<boolean>(false) |
|
const lastWikilinkRef = useRef<string | null>(null) |
|
const effectRunCountRef = useRef<number>(0) |
|
|
|
// Fetch events for each reference |
|
useEffect(() => { |
|
effectRunCountRef.current += 1 |
|
const runCount = effectRunCountRef.current |
|
|
|
// Early return if parsed is not ready |
|
if (!parsed) { |
|
setIsLoading(false) |
|
setError('Failed to parse bookstr wikilink') |
|
return |
|
} |
|
|
|
if (!parsed.references.length) { |
|
setIsLoading(false) |
|
setError('Invalid bookstr reference') |
|
return |
|
} |
|
|
|
// Create a unique key for this fetch based on the parsed references |
|
const fetchKey = JSON.stringify(parsed.references.map(r => ({ |
|
book: r.book, |
|
chapter: r.chapter, |
|
verse: r.verse, |
|
version: r.version |
|
}))) |
|
|
|
// Reset fetch state if wikilink changed |
|
if (lastWikilinkRef.current !== wikilink) { |
|
hasFetchedRef.current = null |
|
lastWikilinkRef.current = wikilink |
|
isFetchingRef.current = false |
|
effectRunCountRef.current = 1 |
|
} |
|
|
|
// AGGRESSIVE: If we've already fetched for this exact key, STOP IMMEDIATELY |
|
if (hasFetchedRef.current === fetchKey) { |
|
return |
|
} |
|
|
|
// AGGRESSIVE: If we're already fetching, STOP IMMEDIATELY |
|
if (isFetchingRef.current) { |
|
return |
|
} |
|
|
|
// AGGRESSIVE: If effect has run more than once for the same wikilink, something is wrong |
|
if (runCount > 2 && lastWikilinkRef.current === wikilink) { |
|
logger.warn('BookstrContent: Effect running too many times, blocking', { |
|
wikilink, |
|
runCount, |
|
fetchKey, |
|
hasFetched: hasFetchedRef.current |
|
}) |
|
return |
|
} |
|
|
|
// Mark that we're starting a fetch for this wikilink |
|
logger.debug('BookstrContent: Starting fetch', { wikilink, fetchKey, runCount }) |
|
hasFetchedRef.current = fetchKey |
|
isFetchingRef.current = true |
|
|
|
// Create placeholder sections IMMEDIATELY - before any checks or async operations |
|
// This ensures something is always displayed |
|
const placeholderSections: BookSection[] = parsed.references.map(ref => ({ |
|
reference: ref, |
|
events: [], |
|
versions: [], |
|
originalVerses: ref.verse, |
|
originalChapter: ref.chapter |
|
})) |
|
setSections(placeholderSections) |
|
setIsLoading(false) |
|
|
|
let isCancelled = false |
|
let loadingTimeout: NodeJS.Timeout | null = null |
|
|
|
const fetchEvents = async () => { |
|
setError(null) |
|
|
|
// Create placeholder sections IMMEDIATELY before any async operations |
|
// This ensures something is always displayed, even if the fetch fails or is slow |
|
const placeholderSections: BookSection[] = parsed.references.map(ref => ({ |
|
reference: ref, |
|
events: [], |
|
versions: [], |
|
originalVerses: ref.verse, |
|
originalChapter: ref.chapter |
|
})) |
|
setSections(placeholderSections) |
|
setIsLoading(false) // Ensure loading is false - we have placeholders to show |
|
|
|
// Mark all sections as loading initially (will be removed when fetch completes) |
|
const initialLoadingKeys = new Set(parsed.references.map(ref => |
|
`${ref.book}-${ref.chapter}-${ref.verse}` |
|
)) |
|
setLoadingSections(initialLoadingKeys) |
|
|
|
// Set a timeout to clear loading state if fetch takes too long (30 seconds) |
|
loadingTimeout = setTimeout(() => { |
|
if (!isCancelled) { |
|
logger.warn('BookstrContent: Fetch timeout - clearing loading state', { wikilink }) |
|
setLoadingSections(new Set()) |
|
} |
|
}, 30000) |
|
|
|
try { |
|
logger.debug('BookstrContent: Processing references', { |
|
totalReferences: parsed.references.length, |
|
references: parsed.references.map(r => ({ |
|
book: r.book, |
|
chapter: r.chapter, |
|
verse: r.verse |
|
})) |
|
}) |
|
|
|
const newSections: BookSection[] = [] |
|
|
|
// Step 1: Check cache for ALL references first (in parallel) |
|
const bookType = (parsed as any).bookType || 'book' |
|
const cacheChecks = parsed.references.map(async (ref) => { |
|
const normalizedBook = ref.book.toLowerCase().replace(/\s+/g, '-') |
|
const versionsToFetch = parsed.versions || (ref.version ? [ref.version] : []) |
|
|
|
// Check cache for each version (or without version if none specified) |
|
const cachePromises = versionsToFetch.length > 0 |
|
? versionsToFetch.map(version => |
|
client.getCachedBookstrEvents({ |
|
type: bookType, |
|
book: normalizedBook, |
|
chapter: ref.chapter, |
|
verse: ref.verse, |
|
version: version.toLowerCase() |
|
}) |
|
) |
|
: [ |
|
client.getCachedBookstrEvents({ |
|
type: bookType, |
|
book: normalizedBook, |
|
chapter: ref.chapter, |
|
verse: ref.verse |
|
}) |
|
] |
|
|
|
const cachedResults = await Promise.all(cachePromises) |
|
const allCachedEvents = cachedResults.flat() |
|
|
|
return { ref, cachedEvents: allCachedEvents, versionsToFetch } |
|
}) |
|
|
|
const cacheResults = await Promise.all(cacheChecks) |
|
|
|
// Step 2: Display cached results IMMEDIATELY |
|
for (const { ref, cachedEvents } of cacheResults) { |
|
const refKey = `${ref.book}-${ref.chapter}-${ref.verse}` |
|
|
|
if (cachedEvents.length > 0) { |
|
// Mark this section as loaded (has cached data) |
|
setLoadingSections(prev => { |
|
const updated = new Set(prev) |
|
updated.delete(refKey) |
|
return updated |
|
}) |
|
|
|
const allVersions = new Set<string>() |
|
cachedEvents.forEach(event => { |
|
const metadata = extractBookMetadata(event) |
|
if (metadata.version) { |
|
allVersions.add(metadata.version.toUpperCase()) |
|
} |
|
}) |
|
|
|
// Filter events based on what was requested |
|
let filteredEvents = cachedEvents |
|
|
|
// Filter by chapter if specified |
|
if (ref.chapter !== undefined) { |
|
filteredEvents = filteredEvents.filter(event => { |
|
const metadata = extractBookMetadata(event) |
|
const eventChapter = parseInt(metadata.chapter || '0') |
|
return eventChapter === ref.chapter |
|
}) |
|
} |
|
|
|
// Filter by verse if specified |
|
if (ref.verse) { |
|
const verseNumbers = new Set<number>() |
|
const verseSpecs = ref.verse.split(',').map(v => v.trim()).filter(v => v) |
|
|
|
for (const spec of verseSpecs) { |
|
if (spec.includes('-')) { |
|
const [startStr, endStr] = spec.split('-').map(v => v.trim()) |
|
const start = parseInt(startStr) |
|
const end = parseInt(endStr) |
|
if (!isNaN(start) && !isNaN(end) && start <= end) { |
|
for (let v = start; v <= end; v++) { |
|
verseNumbers.add(v) |
|
} |
|
} |
|
} else { |
|
const verseNum = parseInt(spec) |
|
if (!isNaN(verseNum)) { |
|
verseNumbers.add(verseNum) |
|
} |
|
} |
|
} |
|
|
|
filteredEvents = filteredEvents.filter(event => { |
|
const metadata = extractBookMetadata(event) |
|
const eventVerse = metadata.verse |
|
if (!eventVerse) return false |
|
const eventVerseNum = parseInt(eventVerse) |
|
return !isNaN(eventVerseNum) && verseNumbers.has(eventVerseNum) |
|
}) |
|
} |
|
|
|
// Sort events by verse number |
|
filteredEvents.sort((a, b) => { |
|
const aMeta = extractBookMetadata(a) |
|
const bMeta = extractBookMetadata(b) |
|
const aVerse = parseInt(aMeta.verse || '0') |
|
const bVerse = parseInt(bMeta.verse || '0') |
|
return aVerse - bVerse |
|
}) |
|
|
|
newSections.push({ |
|
reference: ref, |
|
events: filteredEvents, |
|
versions: Array.from(allVersions), |
|
originalVerses: ref.verse, |
|
originalChapter: ref.chapter |
|
}) |
|
} |
|
} |
|
|
|
// Display cached results immediately (merge with placeholders) |
|
if (!isCancelled) { |
|
// Create a map of sections by reference key for easy lookup |
|
const sectionsByRef = new Map<string, BookSection>() |
|
newSections.forEach(section => { |
|
const key = `${section.reference.book}-${section.reference.chapter}-${section.reference.verse}` |
|
sectionsByRef.set(key, section) |
|
}) |
|
|
|
// Update placeholders with cached results, keep placeholders for missing ones |
|
const updatedSections = placeholderSections.map(placeholder => { |
|
const key = `${placeholder.reference.book}-${placeholder.reference.chapter}-${placeholder.reference.verse}` |
|
const cachedSection = sectionsByRef.get(key) |
|
return cachedSection || placeholder |
|
}) |
|
|
|
setSections(updatedSections) |
|
|
|
// Set initial selected versions - prioritize version from parsed wikilink |
|
const initialVersions = new Map<number, string>() |
|
updatedSections.forEach((section, index) => { |
|
// Priority: 1) version from reference (parsed wikilink), 2) parsed.versions[0], 3) section.versions[0] |
|
const versionFromRef = section.reference.version?.toUpperCase() |
|
const versionFromParsed = parsed?.versions?.[0]?.toUpperCase() |
|
const versionFromSection = section.versions.length > 0 ? section.versions[0] : '' |
|
|
|
const initialVersion = versionFromRef || versionFromParsed || versionFromSection |
|
if (initialVersion) { |
|
initialVersions.set(index, initialVersion) |
|
} |
|
}) |
|
setSelectedVersions(initialVersions) |
|
} |
|
|
|
// Step 3: Fetch missing events from network in the background |
|
for (const { ref, cachedEvents, versionsToFetch } of cacheResults) { |
|
if (isCancelled) break |
|
|
|
const refKey = `${ref.book}-${ref.chapter}-${ref.verse}` |
|
|
|
// If we already have cached events for this reference, skip or do background refresh |
|
if (cachedEvents.length > 0) { |
|
// Still fetch in background to get updates |
|
const normalizedBook = ref.book.toLowerCase().replace(/\s+/g, '-') |
|
const fetchPromises = versionsToFetch.length > 0 |
|
? versionsToFetch.map(version => |
|
macroService.fetchMacroEvents({ |
|
type: bookType, |
|
book: normalizedBook, |
|
chapter: ref.chapter, |
|
verse: ref.verse, |
|
version: version.toLowerCase() |
|
}) |
|
) |
|
: [ |
|
macroService.fetchMacroEvents({ |
|
type: bookType, |
|
book: normalizedBook, |
|
chapter: ref.chapter, |
|
verse: ref.verse |
|
}) |
|
] |
|
|
|
Promise.all(fetchPromises).then(fetchedResults => { |
|
if (isCancelled) return |
|
|
|
// Mark this section as loaded (background fetch complete) |
|
setLoadingSections(prev => { |
|
const updated = new Set(prev) |
|
updated.delete(refKey) |
|
return updated |
|
}) |
|
|
|
const allFetchedEvents = fetchedResults.flat() |
|
if (allFetchedEvents.length > 0) { |
|
// Update the section with fresh data |
|
setSections(prevSections => { |
|
const updated = [...prevSections] |
|
const sectionIndex = updated.findIndex(s => |
|
s.reference.book === ref.book && |
|
s.reference.chapter === ref.chapter && |
|
s.reference.verse === ref.verse |
|
) |
|
|
|
if (sectionIndex >= 0) { |
|
// Merge with existing events (deduplicate by event id) |
|
const existingIds = new Set(updated[sectionIndex].events.map(e => e.id)) |
|
const newEvents = allFetchedEvents.filter(e => !existingIds.has(e.id)) |
|
updated[sectionIndex] = { |
|
...updated[sectionIndex], |
|
events: [...updated[sectionIndex].events, ...newEvents] |
|
} |
|
} |
|
|
|
return updated |
|
}) |
|
} |
|
}).catch(err => { |
|
logger.warn('BookstrContent: Background fetch failed', { error: err, ref }) |
|
// Mark as loaded even on error to stop spinner |
|
setLoadingSections(prev => { |
|
const updated = new Set(prev) |
|
updated.delete(refKey) |
|
return updated |
|
}) |
|
}) |
|
continue |
|
} |
|
|
|
// No cached events, mark as loading and fetch from network |
|
setLoadingSections(prev => { |
|
const updated = new Set(prev) |
|
updated.add(refKey) |
|
return updated |
|
}) |
|
|
|
const normalizedBook = ref.book.toLowerCase().replace(/\s+/g, '-') |
|
|
|
// Determine which versions to fetch |
|
let versionsToFetchFinal = versionsToFetch |
|
if (versionsToFetchFinal.length === 0) { |
|
// First, try to find any version for this book/chapter/verse |
|
const allEvents = await macroService.fetchMacroEvents({ |
|
type: bookType, |
|
book: normalizedBook, |
|
chapter: ref.chapter, |
|
verse: ref.verse |
|
}) |
|
|
|
// Extract unique versions |
|
const availableVersions = new Set<string>() |
|
allEvents.forEach(event => { |
|
const metadata = extractBookMetadata(event) |
|
if (metadata.version) { |
|
availableVersions.add(metadata.version.toUpperCase()) |
|
} |
|
}) |
|
|
|
if (availableVersions.size > 0) { |
|
versionsToFetchFinal = [Array.from(availableVersions)[0]] // Use first available |
|
} else { |
|
if (allEvents.length > 0) { |
|
// Use events without version filter |
|
const allVersions = new Set<string>() |
|
allEvents.forEach(event => { |
|
const metadata = extractBookMetadata(event) |
|
if (metadata.version) { |
|
allVersions.add(metadata.version.toUpperCase()) |
|
} |
|
}) |
|
|
|
// Mark this section as loaded (found events) |
|
setLoadingSections(prev => { |
|
const updated = new Set(prev) |
|
updated.delete(refKey) |
|
return updated |
|
}) |
|
|
|
newSections.push({ |
|
reference: ref, |
|
events: allEvents, |
|
versions: Array.from(allVersions), |
|
originalVerses: ref.verse, |
|
originalChapter: ref.chapter |
|
}) |
|
continue |
|
} else { |
|
// No events found, mark as loaded to stop spinner |
|
setLoadingSections(prev => { |
|
const updated = new Set(prev) |
|
updated.delete(refKey) |
|
return updated |
|
}) |
|
} |
|
} |
|
} |
|
|
|
// Fetch events for each version |
|
const allEvents: Event[] = [] |
|
const allVersions = new Set<string>() |
|
|
|
for (const version of versionsToFetchFinal) { |
|
const events = await macroService.fetchMacroEvents({ |
|
type: bookType, |
|
book: normalizedBook, |
|
chapter: ref.chapter, |
|
verse: ref.verse, |
|
version: version.toLowerCase() |
|
}) |
|
|
|
events.forEach(event => { |
|
allEvents.push(event) |
|
const metadata = extractBookMetadata(event) |
|
if (metadata.version) { |
|
allVersions.add(metadata.version.toUpperCase()) |
|
} |
|
}) |
|
} |
|
|
|
// Filter events based on what was requested |
|
let filteredEvents = allEvents |
|
|
|
// Filter by chapter if specified |
|
if (ref.chapter !== undefined) { |
|
filteredEvents = filteredEvents.filter(event => { |
|
const metadata = extractBookMetadata(event) |
|
const eventChapter = parseInt(metadata.chapter || '0') |
|
return eventChapter === ref.chapter |
|
}) |
|
} |
|
|
|
// Filter by verse if specified |
|
if (ref.verse) { |
|
const verseNumbers = new Set<number>() |
|
const verseSpecs = ref.verse.split(',').map(v => v.trim()).filter(v => v) |
|
|
|
for (const spec of verseSpecs) { |
|
if (spec.includes('-')) { |
|
const [startStr, endStr] = spec.split('-').map(v => v.trim()) |
|
const start = parseInt(startStr) |
|
const end = parseInt(endStr) |
|
if (!isNaN(start) && !isNaN(end) && start <= end) { |
|
for (let v = start; v <= end; v++) { |
|
verseNumbers.add(v) |
|
} |
|
} |
|
} else { |
|
const verseNum = parseInt(spec) |
|
if (!isNaN(verseNum)) { |
|
verseNumbers.add(verseNum) |
|
} |
|
} |
|
} |
|
|
|
filteredEvents = filteredEvents.filter(event => { |
|
const metadata = extractBookMetadata(event) |
|
const eventVerse = metadata.verse |
|
if (!eventVerse) return false |
|
const eventVerseNum = parseInt(eventVerse) |
|
return !isNaN(eventVerseNum) && verseNumbers.has(eventVerseNum) |
|
}) |
|
} |
|
|
|
// Sort events by verse number |
|
filteredEvents.sort((a, b) => { |
|
const aMeta = extractBookMetadata(a) |
|
const bMeta = extractBookMetadata(b) |
|
const aVerse = parseInt(aMeta.verse || '0') |
|
const bVerse = parseInt(bMeta.verse || '0') |
|
return aVerse - bVerse |
|
}) |
|
|
|
// Mark this section as loaded (network fetch complete) |
|
setLoadingSections(prev => { |
|
const updated = new Set(prev) |
|
updated.delete(refKey) |
|
return updated |
|
}) |
|
|
|
newSections.push({ |
|
reference: ref, |
|
events: filteredEvents, |
|
versions: Array.from(allVersions), |
|
originalVerses: ref.verse, |
|
originalChapter: ref.chapter |
|
}) |
|
} |
|
|
|
if (isCancelled) return |
|
|
|
// Merge network results with existing sections (replace placeholders or update with new data) |
|
setSections(prevSections => { |
|
const sectionsByRef = new Map<string, BookSection>() |
|
newSections.forEach(section => { |
|
const key = `${section.reference.book}-${section.reference.chapter}-${section.reference.verse}` |
|
sectionsByRef.set(key, section) |
|
}) |
|
|
|
// Update existing sections with network results, or add new ones |
|
const updated = prevSections.map(section => { |
|
const key = `${section.reference.book}-${section.reference.chapter}-${section.reference.verse}` |
|
const networkSection = sectionsByRef.get(key) |
|
if (networkSection) { |
|
// Merge events (deduplicate by event id) |
|
const existingIds = new Set(section.events.map(e => e.id)) |
|
const newEvents = networkSection.events.filter(e => !existingIds.has(e.id)) |
|
return { |
|
...networkSection, |
|
events: [...section.events, ...newEvents] |
|
} |
|
} |
|
return section |
|
}) |
|
|
|
// Add any new sections that weren't in placeholders |
|
newSections.forEach(section => { |
|
const key = `${section.reference.book}-${section.reference.chapter}-${section.reference.verse}` |
|
if (!prevSections.some(s => |
|
`${s.reference.book}-${s.reference.chapter}-${s.reference.verse}` === key |
|
)) { |
|
updated.push(section) |
|
} |
|
}) |
|
|
|
return updated |
|
}) |
|
|
|
// Update selected versions - prioritize version from parsed wikilink |
|
setSelectedVersions(prevVersions => { |
|
const updated = new Map(prevVersions) |
|
newSections.forEach((section, index) => { |
|
// Only set if not already set (preserve user selection) |
|
if (!updated.has(index)) { |
|
// Priority: 1) version from reference (parsed wikilink), 2) parsed.versions[0], 3) section.versions[0] |
|
const versionFromRef = section.reference.version?.toUpperCase() |
|
const versionFromParsed = parsed?.versions?.[0]?.toUpperCase() |
|
const versionFromSection = section.versions.length > 0 ? section.versions[0] : '' |
|
|
|
const initialVersion = versionFromRef || versionFromParsed || versionFromSection |
|
if (initialVersion) { |
|
updated.set(index, initialVersion) |
|
} |
|
} |
|
}) |
|
return updated |
|
}) |
|
} catch (err) { |
|
if (isCancelled) return |
|
logger.error('Error fetching bookstr events', { error: err, wikilink }) |
|
setError(err instanceof Error ? err.message : 'Failed to fetch book content') |
|
// Mark all sections as loaded on error to stop spinners |
|
setLoadingSections(new Set()) |
|
} finally { |
|
if (!isCancelled) { |
|
setIsLoading(false) |
|
} |
|
isFetchingRef.current = false |
|
if (loadingTimeout) { |
|
clearTimeout(loadingTimeout) |
|
} |
|
} |
|
} |
|
|
|
fetchEvents() |
|
|
|
return () => { |
|
isCancelled = true |
|
isFetchingRef.current = false |
|
if (loadingTimeout) { |
|
clearTimeout(loadingTimeout) |
|
} |
|
} |
|
}, [wikilink]) // Depend on wikilink directly - it's a stable string, parsed is derived from it |
|
|
|
|
|
// Show loading spinner only if we're actively loading AND have no sections |
|
// Once we have sections (even empty placeholders), show them instead |
|
if (isLoading && sections.length === 0) { |
|
return ( |
|
<span className={cn('inline-flex items-center gap-1', className)}> |
|
<span>{wikilink}</span> |
|
<Loader2 className="h-3 w-3 animate-spin" /> |
|
</span> |
|
) |
|
} |
|
|
|
// If we have no sections and no error, show the wikilink as plain text |
|
// This handles the case where parsing failed or no data is available |
|
if (sections.length === 0 && !error && !isLoading) { |
|
return ( |
|
<span className={cn('inline-flex items-center gap-1', className)}> |
|
<span>{wikilink}</span> |
|
</span> |
|
) |
|
} |
|
|
|
if (error) { |
|
return ( |
|
<span className={cn('inline-flex items-center gap-1', className)} title={error}> |
|
<span>{wikilink}</span> |
|
<AlertCircle className="h-3 w-3 text-red-500" /> |
|
</span> |
|
) |
|
} |
|
|
|
if (sections.length === 0) { |
|
return ( |
|
<span className={cn('inline-flex items-center gap-1', className)} title="No content found"> |
|
<span>{wikilink}</span> |
|
<AlertCircle className="h-3 w-3 text-yellow-500" /> |
|
</span> |
|
) |
|
} |
|
|
|
return ( |
|
<div className={cn('my-2', className)}> |
|
<div className="border rounded-lg bg-muted/30 overflow-hidden"> |
|
{sections.map((section, sectionIndex) => { |
|
// Priority for selected version: 1) user-selected, 2) version from reference, 3) parsed.versions[0], 4) section.versions[0] |
|
const versionFromRef = section.reference.version?.toUpperCase() |
|
const versionFromParsed = parsed?.versions?.[0]?.toUpperCase() |
|
const selectedVersion = selectedVersions.get(sectionIndex) || versionFromRef || versionFromParsed || section.versions[0] || '' |
|
const filteredEvents = selectedVersion |
|
? section.events.filter(event => { |
|
const metadata = extractBookMetadata(event) |
|
return metadata.version?.toUpperCase() === selectedVersion |
|
}) |
|
: section.events |
|
|
|
const isLast = sectionIndex === sections.length - 1 |
|
|
|
// Check if this section is still loading |
|
const refKey = `${section.reference.book}-${section.reference.chapter}-${section.reference.verse}` |
|
const isSectionLoading = loadingSections.has(refKey) |
|
|
|
return ( |
|
<React.Fragment key={sectionIndex}> |
|
<div |
|
className={cn( |
|
'p-3', |
|
!isLast && 'border-b' |
|
)} |
|
> |
|
{/* Header */} |
|
<div className="flex items-center justify-between gap-2 mb-2"> |
|
<div className="flex items-center gap-2 flex-1 min-w-0"> |
|
<h4 className="font-semibold text-sm"> |
|
{section.reference.book} |
|
{section.reference.chapter && ` ${section.reference.chapter}`} |
|
{section.reference.verse && `:${section.reference.verse}`} |
|
{selectedVersion && ` (${selectedVersion})`} |
|
</h4> |
|
{/* Only show spinner if section is still loading AND has no events */} |
|
{isSectionLoading && filteredEvents.length === 0 && ( |
|
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" /> |
|
)} |
|
<VersionSelector |
|
section={section} |
|
sectionIndex={sectionIndex} |
|
selectedVersion={selectedVersion} |
|
onVersionChange={(version: string) => { |
|
const newVersions = new Map(selectedVersions) |
|
newVersions.set(sectionIndex, version) |
|
setSelectedVersions(newVersions) |
|
}} |
|
/> |
|
</div> |
|
{/* Source URL link button */} |
|
{sourceUrl && ( |
|
<a |
|
href={sourceUrl} |
|
target="_blank" |
|
rel="noopener noreferrer" |
|
className="inline-flex items-center gap-1.5 px-2 py-1 text-xs text-muted-foreground hover:text-foreground hover:bg-muted rounded-md transition-colors shrink-0" |
|
title="View original source" |
|
> |
|
<ExternalLink className="h-3 w-3" /> |
|
<span className="hidden sm:inline">Source</span> |
|
</a> |
|
)} |
|
</div> |
|
|
|
{/* External URL preview/button for bible/torah/quran */} |
|
{(() => { |
|
// Get bookType from parsed wikilink (defaults to 'book') |
|
const bookType = parsed?.bookType || 'book' |
|
|
|
// Only show external link for bible, torah, or quran collections |
|
// Other collections (secular books) don't have external links |
|
if (!['bible', 'torah', 'quran'].includes(bookType)) { |
|
return null |
|
} |
|
|
|
// Priority for Bible Gateway version: 1) version from reference, 2) selectedVersion, 3) parsed.versions[0], 4) DRA (default) |
|
const versionForUrl = versionFromRef || selectedVersion || versionFromParsed || undefined |
|
const externalUrl = buildExternalUrl(section.reference, bookType, versionForUrl) |
|
|
|
if (!externalUrl) return null |
|
|
|
// If skipWebPreview is true (e.g., in AsciiDoc), show simple button |
|
if (skipWebPreview) { |
|
return ( |
|
<div className="mb-3"> |
|
<a |
|
href={externalUrl} |
|
target="_blank" |
|
rel="noopener noreferrer" |
|
className="inline-flex items-center gap-2 px-3 py-2 text-sm bg-muted hover:bg-muted/80 rounded-md transition-colors" |
|
> |
|
<ExternalLink className="h-4 w-4" /> |
|
<span>View on external site</span> |
|
</a> |
|
</div> |
|
) |
|
} |
|
|
|
// Otherwise, use WebPreview (for markdown articles) |
|
return ( |
|
<div className="mb-3"> |
|
<WebPreview url={externalUrl} className="w-full" /> |
|
</div> |
|
) |
|
})()} |
|
|
|
{/* Verses - render all verses together, including ranges */} |
|
{filteredEvents.length > 0 && ( |
|
<VerseContent |
|
events={filteredEvents} |
|
originalVerses={section.originalVerses} |
|
/> |
|
)} |
|
</div> |
|
</React.Fragment> |
|
) |
|
})} |
|
</div> |
|
</div> |
|
) |
|
} |
|
|
|
interface VerseContentProps { |
|
events: Event[] |
|
originalVerses?: string |
|
} |
|
|
|
function VerseContent({ events, originalVerses }: VerseContentProps) { |
|
const [parsedContents, setParsedContents] = useState<Map<string, string>>(new Map()) |
|
|
|
// Parse original verses to determine which ones should have a border |
|
const originalVerseNumbers = new Set<number>() |
|
if (originalVerses) { |
|
const verseSpecs = originalVerses.split(',').map(v => v.trim()).filter(v => v) |
|
for (const spec of verseSpecs) { |
|
if (spec.includes('-')) { |
|
// Expand range like "16-18" into 16, 17, 18 |
|
const [startStr, endStr] = spec.split('-').map(v => v.trim()) |
|
const start = parseInt(startStr) |
|
const end = parseInt(endStr) |
|
if (!isNaN(start) && !isNaN(end) && start <= end) { |
|
for (let v = start; v <= end; v++) { |
|
originalVerseNumbers.add(v) |
|
} |
|
} |
|
} else { |
|
const verseNum = parseInt(spec) |
|
if (!isNaN(verseNum)) { |
|
originalVerseNumbers.add(verseNum) |
|
} |
|
} |
|
} |
|
} |
|
|
|
useEffect(() => { |
|
const parseAll = async () => { |
|
const newParsed = new Map<string, string>() |
|
for (const event of events) { |
|
if (!parsedContents.has(event.id)) { |
|
try { |
|
const result = await contentParserService.parseContent(event.content, { |
|
eventKind: ExtendedKind.PUBLICATION_CONTENT |
|
}) |
|
newParsed.set(event.id, result.html) |
|
} catch (err) { |
|
logger.warn('Error parsing verse content', { error: err, eventId: event.id.substring(0, 8) }) |
|
newParsed.set(event.id, event.content) |
|
} |
|
} else { |
|
// Already parsed, copy it |
|
newParsed.set(event.id, parsedContents.get(event.id)!) |
|
} |
|
} |
|
if (newParsed.size > 0) { |
|
setParsedContents(newParsed) |
|
} |
|
} |
|
parseAll() |
|
}, [events]) |
|
|
|
return ( |
|
<div className="space-y-1"> |
|
{events.map((event) => { |
|
const metadata = extractBookMetadata(event) |
|
const verseNum = metadata.verse |
|
const verseNumInt = verseNum ? parseInt(verseNum) : null |
|
const isOriginalVerse = originalVerseNumbers.size > 0 && verseNumInt !== null && originalVerseNumbers.has(verseNumInt) |
|
const content = parsedContents.get(event.id) || event.content |
|
|
|
return ( |
|
<div |
|
key={event.id} |
|
className={cn( |
|
"flex gap-2 text-sm leading-relaxed items-baseline", |
|
isOriginalVerse && "border-l-2 border-muted-foreground/30 pl-2 py-1" |
|
)} |
|
> |
|
{/* Verse number on the left - only show verse number, not chapter:verse */} |
|
<span className="font-semibold text-muted-foreground shrink-0 min-w-[2.5rem] text-right"> |
|
{verseNum || null} |
|
</span> |
|
{/* Content on the right */} |
|
<span className="flex-1" dangerouslySetInnerHTML={{ __html: content }} /> |
|
</div> |
|
) |
|
})} |
|
</div> |
|
) |
|
} |
|
|
|
interface VersionSelectorProps { |
|
section: BookSection |
|
sectionIndex: number |
|
selectedVersion: string |
|
onVersionChange: (version: string) => void |
|
} |
|
|
|
function VersionSelector({ section, selectedVersion, onVersionChange }: VersionSelectorProps) { |
|
// Sync availableVersions with section.versions when section updates |
|
const [availableVersions, setAvailableVersions] = useState<string[]>(section.versions) |
|
|
|
// Update availableVersions when section.versions changes (from parent fetches) |
|
// Use a ref to track the last versions to avoid unnecessary updates |
|
const lastVersionsRef = useRef<string>('') |
|
useEffect(() => { |
|
const versionsKey = JSON.stringify([...section.versions].sort()) |
|
if (versionsKey !== lastVersionsRef.current && section.versions.length > availableVersions.length) { |
|
lastVersionsRef.current = versionsKey |
|
setAvailableVersions(section.versions) |
|
} |
|
}, [section.versions, availableVersions.length]) |
|
|
|
// DISABLED: Version fetching is causing loops. Use versions from parent only. |
|
// Just sync with parent versions |
|
useEffect(() => { |
|
// COMPLETELY DISABLE VERSION FETCHING TO PREVENT LOOPS |
|
// Just use the versions we already have from the parent |
|
if (availableVersions.length === 0 && section.versions.length > 0) { |
|
setAvailableVersions(section.versions) |
|
} |
|
|
|
/* DISABLED CODE - was causing infinite loops |
|
// Reset fetch state if section reference changed |
|
if (lastFetchKeyRef.current !== fetchKey) { |
|
hasFetchedRef.current = false |
|
} |
|
|
|
// Skip if we've already fetched for this exact section |
|
if (hasFetchedRef.current && lastFetchKeyRef.current === fetchKey) { |
|
return |
|
} |
|
|
|
// Skip if we already have multiple versions |
|
if (availableVersions.length > 1) { |
|
hasFetchedRef.current = true |
|
lastFetchKeyRef.current = fetchKey |
|
return |
|
} |
|
|
|
const fetchAvailableVersions = async () => { |
|
setIsLoadingVersions(true) |
|
try { |
|
// Query for all versions of this book/chapter/verse |
|
const normalizedBook = section.reference.book.toLowerCase().replace(/\s+/g, '-') |
|
const allEvents = await macroService.fetchMacroEvents({ |
|
type: 'bible', |
|
book: normalizedBook, |
|
chapter: section.reference.chapter, |
|
verse: section.reference.verse |
|
}) |
|
|
|
const versions = new Set<string>() |
|
allEvents.forEach(event => { |
|
const metadata = extractBookMetadata(event) |
|
if (metadata.version) { |
|
versions.add(metadata.version.toUpperCase()) |
|
} |
|
}) |
|
|
|
if (versions.size > availableVersions.length) { |
|
setAvailableVersions(Array.from(versions).sort()) |
|
} |
|
|
|
// Mark as fetched for this section |
|
hasFetchedRef.current = true |
|
lastFetchKeyRef.current = fetchKey |
|
} catch (err) { |
|
logger.warn('Error fetching available versions', { error: err }) |
|
// Mark as fetched even on error to prevent retry loops |
|
hasFetchedRef.current = true |
|
lastFetchKeyRef.current = fetchKey |
|
} finally { |
|
setIsLoadingVersions(false) |
|
} |
|
} |
|
|
|
fetchAvailableVersions() |
|
*/ |
|
}, [section.reference.book, section.reference.chapter, section.reference.verse, section.versions, availableVersions.length]) |
|
|
|
// Don't show selector if only one version available |
|
if (availableVersions.length <= 1) { |
|
return null |
|
} |
|
|
|
return ( |
|
<Select |
|
value={selectedVersion} |
|
onValueChange={onVersionChange} |
|
> |
|
<SelectTrigger className="h-6 w-auto px-2 text-xs"> |
|
<SelectValue /> |
|
</SelectTrigger> |
|
<SelectContent> |
|
{availableVersions.map((version) => ( |
|
<SelectItem key={version} value={version}> |
|
{version} |
|
</SelectItem> |
|
))} |
|
</SelectContent> |
|
</Select> |
|
) |
|
} |
|
|
|
|