Browse Source

increase bookstr performance

imwald
Silberengel 4 months ago
parent
commit
06c09bd55b
  1. 787
      src/components/Bookstr/BookstrContent.tsx
  2. 64
      src/lib/bookstr-parser.ts
  3. 806
      src/services/client.service.ts

787
src/components/Bookstr/BookstrContent.tsx

@ -1,9 +1,9 @@ @@ -1,9 +1,9 @@
import { useState, useEffect, useMemo } from 'react'
import { 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 { ExtendedKind } from '@/constants'
import { Loader2, AlertCircle, ChevronDown, ChevronUp } from 'lucide-react'
import { Loader2, AlertCircle, ChevronDown, ChevronUp, ExternalLink } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Select,
@ -29,12 +29,43 @@ interface BookSection { @@ -29,12 +29,43 @@ interface BookSection {
originalChapter?: number
}
/**
* 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
// Common mappings: DRB -> DRA (Douay-Rheims), etc.
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}`
}
export function BookstrContent({ wikilink, className }: BookstrContentProps) {
const [sections, setSections] = useState<BookSection[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [expandedSections, setExpandedSections] = useState<Set<number>>(new Set())
const [selectedVersions, setSelectedVersions] = useState<Map<number, string>>(new Map())
const [collapsedCards, setCollapsedCards] = useState<Set<number>>(new Set())
const [cardHeights, setCardHeights] = useState<Map<number, number>>(new Map())
const cardRefs = useRef<Map<number, HTMLDivElement>>(new Map())
// Parse the wikilink
const parsed = useMemo(() => {
@ -60,6 +91,21 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { @@ -60,6 +91,21 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
}
const result = parseBookWikilink(`[[book:${bookType}:${content}]]`, bookType)
if (result) {
logger.debug('BookstrContent: Parsed wikilink', {
wikilink,
content,
bookType,
referenceCount: result.references.length,
references: result.references.map(r => ({
book: r.book,
chapter: r.chapter,
verse: r.verse,
version: r.version
})),
versions: result.versions
})
}
return result ? { ...result, bookType } : null
} catch (err) {
logger.error('Error parsing bookstr wikilink', { error: err, wikilink })
@ -87,18 +133,230 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { @@ -87,18 +133,230 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
setError(null)
try {
logger.debug('BookstrContent: Processing references', {
totalReferences: parsed.references.length,
references: parsed.references.map(r => ({
book: r.book,
chapter: r.chapter,
verse: r.verse
}))
})
// Step 0: Create placeholder sections immediately so links don't disappear
const placeholderSections: BookSection[] = parsed.references.map(ref => ({
reference: ref,
events: [],
versions: [],
originalVerses: ref.verse,
originalChapter: ref.chapter
}))
setSections(placeholderSections)
setIsLoading(false) // Show placeholders immediately
const newSections: BookSection[] = []
for (const ref of parsed.references) {
// Normalize book name (lowercase, hyphenated)
// Step 1: Check cache for ALL references first (in parallel)
const bookType = (parsed as any).bookType || 'bible'
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) {
if (cachedEvents.length > 0) {
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
const initialVersions = new Map<number, string>()
updatedSections.forEach((section, index) => {
if (section.versions.length > 0) {
initialVersions.set(index, section.versions[0])
}
})
setSelectedVersions(initialVersions)
}
// Step 3: Fetch missing events from network in the background
for (const { ref, cachedEvents, versionsToFetch } of cacheResults) {
if (isCancelled) break
// 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 =>
client.fetchBookstrEvents({
type: bookType,
book: normalizedBook,
chapter: ref.chapter,
verse: ref.verse,
version: version.toLowerCase()
})
)
: [
client.fetchBookstrEvents({
type: bookType,
book: normalizedBook,
chapter: ref.chapter,
verse: ref.verse
})
]
Promise.all(fetchPromises).then(fetchedResults => {
if (isCancelled) return
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 })
})
continue
}
// No cached events, fetch from network
const normalizedBook = ref.book.toLowerCase().replace(/\s+/g, '-')
const bookType = (parsed as any).bookType || 'bible'
// Determine which versions to fetch
const versionsToFetch = parsed.versions || (ref.version ? [ref.version] : [])
// If no versions specified, try to find available versions
if (versionsToFetch.length === 0) {
let versionsToFetchFinal = versionsToFetch
if (versionsToFetchFinal.length === 0) {
// First, try to find any version for this book/chapter/verse
const allEvents = await client.fetchBookstrEvents({
type: bookType,
@ -117,22 +375,22 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { @@ -117,22 +375,22 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
})
if (availableVersions.size > 0) {
versionsToFetch.push(Array.from(availableVersions)[0]) // Use first available
versionsToFetchFinal = [Array.from(availableVersions)[0]] // Use first available
} else {
// No versions found, try without version filter
const eventsWithoutVersion = await client.fetchBookstrEvents({
type: bookType,
book: normalizedBook,
chapter: ref.chapter,
verse: ref.verse
})
if (eventsWithoutVersion.length > 0) {
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())
}
})
newSections.push({
reference: ref,
events: eventsWithoutVersion,
versions: [],
events: allEvents,
versions: Array.from(allVersions),
originalVerses: ref.verse,
originalChapter: ref.chapter
})
@ -145,22 +403,13 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { @@ -145,22 +403,13 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
const allEvents: Event[] = []
const allVersions = new Set<string>()
for (const version of versionsToFetch) {
// Fetch entire chapter if verse is specified, entire book if only chapter is specified
for (const version of versionsToFetchFinal) {
const events = await client.fetchBookstrEvents({
type: bookType,
book: normalizedBook,
chapter: ref.chapter,
verse: ref.verse, // Pass verse for context, but we'll fetch entire chapter
version: version.toLowerCase()
})
logger.debug('BookstrContent: Fetched events', {
book: normalizedBook,
chapter: ref.chapter,
verse: ref.verse,
version,
eventCount: events.length
version: version.toLowerCase()
})
events.forEach(event => {
@ -172,10 +421,7 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { @@ -172,10 +421,7 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
})
}
// Filter events based on what was requested:
// - Book only: Show all events (all chapters)
// - Chapter only: Show all events for that chapter (all verses)
// - Verses: Show only the requested verses (but we have all verses cached for expansion)
// Filter events based on what was requested
let filteredEvents = allEvents
// Filter by chapter if specified
@ -187,25 +433,35 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { @@ -187,25 +433,35 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
})
}
// Filter by verse if specified (for verse-level queries)
// Filter by verse if specified
if (ref.verse) {
const verseParts = ref.verse.split(/[,\s-]+/).map(v => v.trim()).filter(v => v)
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
// Check if this verse matches any of the requested verses
const verseNum = parseInt(eventVerse)
return verseParts.some(part => {
if (part.includes('-')) {
const [start, end] = part.split('-').map(v => parseInt(v.trim()))
return !isNaN(start) && !isNaN(end) && verseNum >= start && verseNum <= end
} else {
const partNum = parseInt(part)
return !isNaN(partNum) && partNum === verseNum
}
})
const eventVerseNum = parseInt(eventVerse)
return !isNaN(eventVerseNum) && verseNumbers.has(eventVerseNum)
})
}
@ -218,14 +474,6 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { @@ -218,14 +474,6 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
return aVerse - bVerse
})
logger.debug('BookstrContent: Filtered events', {
book: normalizedBook,
chapter: ref.chapter,
verse: ref.verse,
totalFetched: allEvents.length,
filteredCount: filteredEvents.length
})
newSections.push({
reference: ref,
events: filteredEvents,
@ -234,30 +482,56 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { @@ -234,30 +482,56 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
originalChapter: ref.chapter
})
}
logger.debug('BookstrContent: Setting sections', {
sectionCount: newSections.length,
sections: newSections.map(s => ({
book: s.reference.book,
chapter: s.reference.chapter,
verse: s.reference.verse,
eventCount: s.events.length,
versions: s.versions
}))
})
if (isCancelled) return
setSections(newSections)
// 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
})
// Set initial selected versions
const initialVersions = new Map<number, string>()
newSections.forEach((section, index) => {
if (section.versions.length > 0) {
initialVersions.set(index, section.versions[0])
}
// Update selected versions
setSelectedVersions(prevVersions => {
const updated = new Map(prevVersions)
newSections.forEach((section, index) => {
if (section.versions.length > 0 && !updated.has(index)) {
updated.set(index, section.versions[0])
}
})
return updated
})
setSelectedVersions(initialVersions)
} catch (err) {
if (isCancelled) return
logger.error('Error fetching bookstr events', { error: err, wikilink })
@ -277,6 +551,69 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { @@ -277,6 +551,69 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wikilink]) // Only depend on wikilink - parsed is derived from it via useMemo
// Measure card heights - measure BEFORE applying collapse
useEffect(() => {
const timeoutId = setTimeout(() => {
cardRefs.current.forEach((element, index) => {
if (element) {
// IMPORTANT: Temporarily remove ALL constraints to get true height
// This must happen BEFORE any collapse is applied
const originalMaxHeight = element.style.maxHeight
const originalOverflow = element.style.overflow
const originalHeight = element.style.height
// Remove all constraints
element.style.maxHeight = 'none'
element.style.overflow = 'visible'
element.style.height = 'auto'
// Force a reflow to ensure we get the true height
void element.offsetHeight
const height = element.scrollHeight
// Restore original styles
element.style.maxHeight = originalMaxHeight
element.style.overflow = originalOverflow
element.style.height = originalHeight
// Store the TRUE height (before collapse)
setCardHeights(prev => {
const currentHeight = prev.get(index)
if (currentHeight !== height && height > 0) {
const newMap = new Map(prev)
newMap.set(index, height)
logger.debug('BookstrContent: Measured card height', {
sectionIndex: index,
height,
needsCollapse: height > 500,
wasCollapsed: collapsedCards.has(index)
})
// Only auto-collapse if height > 500px and not already manually toggled
if (height > 500) {
setCollapsedCards(prevCollapsed => {
// Only auto-collapse if user hasn't manually expanded it
if (!prevCollapsed.has(index)) {
logger.debug('BookstrContent: Auto-collapsing card', { sectionIndex: index, height })
return new Set(prevCollapsed).add(index)
}
return prevCollapsed
})
}
return newMap
}
return prev
})
}
})
}, 500) // Wait longer for content to fully render
return () => clearTimeout(timeoutId)
}, [sections, collapsedCards])
if (isLoading) {
return (
<span className={cn('inline-flex items-center gap-1', className)}>
@ -318,116 +655,175 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { @@ -318,116 +655,175 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
const isExpanded = expandedSections.has(sectionIndex)
const hasVerses = section.originalVerses !== undefined && section.originalVerses.length > 0
const hasChapter = section.originalChapter !== undefined && !hasVerses
const isLast = sectionIndex === sections.length - 1
const cardHeight = cardHeights.get(sectionIndex) || 0
const isCardCollapsed = collapsedCards.has(sectionIndex)
const needsCollapse = cardHeight > 500
// Only show button if card is actually tall (needs collapse) or is currently collapsed
const shouldShowButton = filteredEvents.length > 0 && (needsCollapse || isCardCollapsed)
// Debug logging
if (filteredEvents.length > 0) {
logger.debug('BookstrContent: Card collapse check', {
sectionIndex,
eventCount: filteredEvents.length,
cardHeight,
isCardCollapsed,
needsCollapse,
shouldShowButton
})
}
return (
<>
<div
key={sectionIndex}
ref={(el) => {
if (el) {
cardRefs.current.set(sectionIndex, el)
} else {
cardRefs.current.delete(sectionIndex)
}
}}
className={cn(
'p-3',
!isLast && 'border-b'
!isLast && 'border-b',
needsCollapse && isCardCollapsed && 'overflow-hidden'
)}
style={needsCollapse && isCardCollapsed ? {
maxHeight: '500px',
transition: 'max-height 0.3s ease-out'
} : undefined}
>
{/* Header */}
<div className="flex items-center gap-2 mb-2">
<h4 className="font-semibold text-sm">
{section.reference.book}
{section.reference.chapter && ` ${section.reference.chapter}`}
{section.reference.verse && `:${section.reference.verse}`}
{selectedVersion && ` (${selectedVersion})`}
</h4>
<VersionSelector
section={section}
sectionIndex={sectionIndex}
selectedVersion={selectedVersion}
onVersionChange={(version: string) => {
const newVersions = new Map(selectedVersions)
newVersions.set(sectionIndex, version)
setSelectedVersions(newVersions)
}}
/>
</div>
{/* Verses */}
<VerseContent
events={filteredEvents}
hasVerses={hasVerses}
originalVerses={section.originalVerses}
isExpanded={isExpanded}
/>
{/* Expand/Collapse buttons - only show if events were found */}
{hasVerses && filteredEvents.length > 0 && (
<Button
variant="ghost"
size="sm"
className="mt-2 h-6 text-xs"
onClick={() => {
const newExpanded = new Set(expandedSections)
if (newExpanded.has(sectionIndex)) {
newExpanded.delete(sectionIndex)
} else {
newExpanded.add(sectionIndex)
}
setExpandedSections(newExpanded)
}}
>
{isExpanded ? (
<>
<ChevronUp className="h-3 w-3 mr-1" />
Collapse chapter
</>
) : (
<>
<ChevronDown className="h-3 w-3 mr-1" />
Read full chapter
</>
<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>
{filteredEvents.length === 0 && (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground" />
)}
</Button>
)}
{hasChapter && !hasVerses && filteredEvents.length > 0 && (
<VersionSelector
section={section}
sectionIndex={sectionIndex}
selectedVersion={selectedVersion}
onVersionChange={(version: string) => {
const newVersions = new Map(selectedVersions)
newVersions.set(sectionIndex, version)
setSelectedVersions(newVersions)
}}
/>
</div>
<Button
variant="ghost"
size="sm"
className="mt-2 h-6 text-xs"
onClick={() => {
const newExpanded = new Set(expandedSections)
if (newExpanded.has(sectionIndex)) {
newExpanded.delete(sectionIndex)
} else {
newExpanded.add(sectionIndex)
}
setExpandedSections(newExpanded)
}}
className="h-6 w-6 p-0 shrink-0"
asChild
>
{isExpanded ? (
<>
<ChevronUp className="h-3 w-3 mr-1" />
Collapse book
</>
) : (
<>
<ChevronDown className="h-3 w-3 mr-1" />
Read full book
</>
)}
<a
href={buildBibleGatewayUrl(section.reference, selectedVersion)}
target="_blank"
rel="noopener noreferrer"
title="View on Bible Gateway"
>
<ExternalLink className="h-3 w-3" />
</a>
</Button>
</div>
{/* Verses */}
{filteredEvents.length > 0 && (
<VerseContent
events={filteredEvents}
/>
)}
</div>
{/* Show more/less button for tall cards - OUTSIDE collapsed div so it's always visible */}
{shouldShowButton ? (
<div className="px-3 pb-3 border-t pt-2">
<Button
variant="ghost"
size="sm"
className="h-6 text-xs w-full"
onClick={() => {
setCollapsedCards(prev => {
const newSet = new Set(prev)
if (newSet.has(sectionIndex)) {
newSet.delete(sectionIndex)
} else {
newSet.add(sectionIndex)
}
return newSet
})
}}
>
{isCardCollapsed ? (
<>
<ChevronDown className="h-3 w-3 mr-1" />
Show more
</>
) : (
<>
<ChevronUp className="h-3 w-3 mr-1" />
Show less
</>
)}
</Button>
</div>
) : null}
{/* Expand/Collapse buttons - only show if events were found */}
{hasVerses && filteredEvents.length > 0 && (
<div className="px-3 pb-3">
<Button
variant="ghost"
size="sm"
className="mt-2 h-6 text-xs"
onClick={() => {
const newExpanded = new Set(expandedSections)
if (newExpanded.has(sectionIndex)) {
newExpanded.delete(sectionIndex)
} else {
newExpanded.add(sectionIndex)
}
setExpandedSections(newExpanded)
}}
>
{isExpanded ? (
<>
<ChevronUp className="h-3 w-3 mr-1" />
Collapse chapter
</>
) : (
<>
<ChevronDown className="h-3 w-3 mr-1" />
Read full chapter
</>
)}
</Button>
</div>
)}
{/* Expanded content */}
{isExpanded && (
<div className="mt-3 pt-3 border-t">
<div className="px-3 pb-3 mt-3 pt-3 border-t">
{/* Fetch and display full chapter/book */}
<ExpandedContent
section={section}
selectedVersion={selectedVersion}
originalVerses={section.originalVerses}
originalChapter={section.originalChapter}
originalVerses={section.originalVerses}
/>
</div>
)}
</div>
</>
)
})}
</div>
@ -438,11 +834,11 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { @@ -438,11 +834,11 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
interface ExpandedContentProps {
section: BookSection
selectedVersion: string
originalVerses?: string
originalChapter?: number
originalVerses?: string
}
function ExpandedContent({ section, selectedVersion, originalVerses, originalChapter }: ExpandedContentProps) {
function ExpandedContent({ section, selectedVersion, originalChapter, originalVerses }: ExpandedContentProps) {
const [expandedEvents, setExpandedEvents] = useState<Event[]>([])
const [isLoading, setIsLoading] = useState(true)
@ -499,26 +895,43 @@ function ExpandedContent({ section, selectedVersion, originalVerses, originalCha @@ -499,26 +895,43 @@ function ExpandedContent({ section, selectedVersion, originalVerses, originalCha
return <div className="text-xs text-muted-foreground">Loading...</div>
}
// 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('-')) {
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)
}
}
}
}
return (
<VerseContent
events={expandedEvents}
hasVerses={!!originalVerses}
originalVerses={originalVerses}
isExpanded={true}
originalChapter={originalChapter}
originalVerseNumbers={originalVerseNumbers}
/>
)
}
interface VerseContentProps {
events: Event[]
hasVerses: boolean
originalVerses?: string
isExpanded: boolean
originalChapter?: number
originalVerseNumbers?: Set<number>
}
function VerseContent({ events, hasVerses, originalVerses, isExpanded, originalChapter }: VerseContentProps) {
function VerseContent({ events, originalVerseNumbers }: VerseContentProps) {
const [parsedContents, setParsedContents] = useState<Map<string, string>>(new Map())
useEffect(() => {
@ -552,38 +965,16 @@ function VerseContent({ events, hasVerses, originalVerses, isExpanded, originalC @@ -552,38 +965,16 @@ function VerseContent({ events, hasVerses, originalVerses, isExpanded, originalC
{events.map((event) => {
const metadata = extractBookMetadata(event)
const verseNum = metadata.verse
const chapterNum = metadata.chapter
// Check if this verse is in the original verses list
const isOriginalVerse = hasVerses && originalVerses && verseNum && (() => {
const verseParts = originalVerses.split(/[,\s-]+/).map(v => v.trim())
const verseNumInt = parseInt(verseNum)
// Check exact match or range
for (const part of verseParts) {
if (part.includes('-')) {
const [start, end] = part.split('-').map(v => parseInt(v.trim()))
if (!isNaN(start) && !isNaN(end) && verseNumInt >= start && verseNumInt <= end) {
return true
}
} else {
const partNum = parseInt(part)
if (!isNaN(partNum) && partNum === verseNumInt) {
return true
}
}
}
return false
})()
const isOriginalChapter = originalChapter !== undefined &&
chapterNum && parseInt(chapterNum) === originalChapter
const verseNumInt = verseNum ? parseInt(verseNum) : null
const isOriginalVerse = originalVerseNumbers && 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',
isExpanded && (isOriginalVerse || isOriginalChapter) && 'border-l-2 border-gray-400 pl-2'
"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 */}

64
src/lib/bookstr-parser.ts

@ -33,42 +33,38 @@ export function parseBookNotation(notation: string, bookType: string = 'bible'): @@ -33,42 +33,38 @@ export function parseBookNotation(notation: string, bookType: string = 'bible'):
const references: BookReference[] = []
// Split by comma or semicolon to handle multiple references
// Use a regex to split on commas/semicolons, but be careful with verse ranges like "1-3"
// We'll split on commas/semicolons that are followed by a space and a capital letter (new book name)
// or split on commas/semicolons that are not part of a verse range
// Strategy:
// 1. First, try to intelligently split on commas/semicolons that are followed by a capital letter (new book)
// 2. If that doesn't work, check if all parts start with capital letters (multiple references)
// 3. Otherwise, treat as a single reference with verse lists
// Step 1: Try intelligent splitting
const parts: string[] = []
let currentPart = ''
let inVerseRange = false
for (let i = 0; i < notation.length; i++) {
const char = notation[i]
const nextChar = notation[i + 1]
if (char === '-' && /^\d/.test(currentPart.slice(-1))) {
// This is part of a verse range (e.g., "1-3")
inVerseRange = true
currentPart += char
} else if (char === ',' || char === ';') {
// Check if this comma/semicolon is separating references
// If the next non-whitespace character is a capital letter, it's likely a new book
const rest = notation.substring(i + 1).trim()
if (rest.length > 0 && /^[A-Z]/.test(rest)) {
// This is separating references - save current part and start new one
if (char === ',' || char === ';') {
// Look ahead to see if this is separating references
// Check if there's whitespace followed by a capital letter or number after this comma/semicolon
// (Numbers handle cases like "1 John", "2 Corinthians")
const afterComma = notation.substring(i + 1)
const trimmedAfter = afterComma.trim()
// If the next non-whitespace character is a capital letter or number, it's likely a new book reference
if (trimmedAfter.length > 0 && /^[A-Z0-9]/.test(trimmedAfter)) {
// This comma/semicolon is separating references
if (currentPart.trim()) {
parts.push(currentPart.trim())
}
currentPart = ''
inVerseRange = false
} else {
// This is part of the current reference (e.g., verse list "1,3,5")
// This comma/semicolon is part of the current reference (e.g., verse list "1,3,5")
currentPart += char
inVerseRange = false
}
} else {
currentPart += char
if (char === ' ' && inVerseRange) {
inVerseRange = false
}
}
}
@ -77,26 +73,28 @@ export function parseBookNotation(notation: string, bookType: string = 'bible'): @@ -77,26 +73,28 @@ export function parseBookNotation(notation: string, bookType: string = 'bible'):
parts.push(currentPart.trim())
}
// If no splitting occurred, try simple split as fallback
if (parts.length === 0) {
parts.push(notation.trim())
} else if (parts.length === 1 && (notation.includes(',') || notation.includes(';'))) {
// Fallback: if we didn't split but there are commas/semicolons, try simple split
// This handles cases like "Genesis 1:1,2,3" (verse list, not multiple references)
const simpleParts = notation.split(/[,;]/).map(p => p.trim())
// Step 2: If we only got one part but there are commas/semicolons, try simple split
if (parts.length === 1 && (notation.includes(',') || notation.includes(';'))) {
const simpleParts = notation.split(/[,;]/).map(p => p.trim()).filter(p => p.length > 0)
if (simpleParts.length > 1) {
// Check if these look like separate references (each has a book name)
const looksLikeMultipleRefs = simpleParts.every(part => {
// Check if part starts with a capital letter (likely a book name)
return /^[A-Z]/.test(part.trim())
// Check if these look like separate references (each starts with a capital letter or number)
// Numbers handle cases like "1 John", "2 Corinthians"
const allStartWithCapitalOrNumber = simpleParts.every(part => {
const trimmed = part.trim()
return trimmed.length > 0 && /^[A-Z0-9]/.test(trimmed)
})
if (looksLikeMultipleRefs) {
if (allStartWithCapitalOrNumber) {
// These are multiple references
parts.length = 0
parts.push(...simpleParts)
}
// Otherwise, treat as a single reference with verse lists (e.g., "Genesis 1:1,2,3")
}
}
// Step 3: Parse each part
for (const part of parts) {
const normalizedPart = normalizeBookReferenceWhitespace(part)
const ref = parseSingleBookReference(normalizedPart, bookType)

806
src/services/client.service.ts

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save