8 changed files with 996 additions and 19 deletions
@ -0,0 +1,583 @@ |
|||||||
|
import { useState, useEffect, useMemo } 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 { Button } from '@/components/ui/button' |
||||||
|
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' |
||||||
|
|
||||||
|
interface BookstrContentProps { |
||||||
|
wikilink: string |
||||||
|
className?: string |
||||||
|
} |
||||||
|
|
||||||
|
interface BookSection { |
||||||
|
reference: BookReference |
||||||
|
events: Event[] |
||||||
|
versions: string[] |
||||||
|
originalVerses?: string |
||||||
|
originalChapter?: number |
||||||
|
} |
||||||
|
|
||||||
|
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()) |
||||||
|
|
||||||
|
// Parse the wikilink
|
||||||
|
const parsed = useMemo(() => { |
||||||
|
try { |
||||||
|
// Extract book type from wikilink (e.g., "book:bible:Genesis 3:1")
|
||||||
|
let bookType = 'bible' |
||||||
|
let content = wikilink |
||||||
|
|
||||||
|
if (wikilink.startsWith('book:')) { |
||||||
|
const parts = wikilink.substring(5).split(':') |
||||||
|
if (parts.length >= 2) { |
||||||
|
bookType = parts[0] |
||||||
|
content = parts.slice(1).join(':') |
||||||
|
} |
||||||
|
} else if (wikilink.includes(':')) { |
||||||
|
// Might be "bible:Genesis 3:1" format
|
||||||
|
const firstColon = wikilink.indexOf(':') |
||||||
|
const potentialType = wikilink.substring(0, firstColon) |
||||||
|
if (['bible', 'quran', 'catechism', 'torah'].includes(potentialType.toLowerCase())) { |
||||||
|
bookType = potentialType.toLowerCase() |
||||||
|
content = wikilink.substring(firstColon + 1) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const result = parseBookWikilink(`[[book:${bookType}:${content}]]`, bookType) |
||||||
|
return result ? { ...result, bookType } : null |
||||||
|
} catch (err) { |
||||||
|
logger.error('Error parsing bookstr wikilink', { error: err, wikilink }) |
||||||
|
return null |
||||||
|
} |
||||||
|
}, [wikilink]) |
||||||
|
|
||||||
|
// Fetch events for each reference
|
||||||
|
useEffect(() => { |
||||||
|
if (!parsed || !parsed.references.length) { |
||||||
|
setIsLoading(false) |
||||||
|
setError('Invalid bookstr reference') |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
const fetchEvents = async () => { |
||||||
|
setIsLoading(true) |
||||||
|
setError(null) |
||||||
|
|
||||||
|
try { |
||||||
|
const newSections: BookSection[] = [] |
||||||
|
|
||||||
|
for (const ref of parsed.references) { |
||||||
|
// Normalize book name (lowercase, hyphenated)
|
||||||
|
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) { |
||||||
|
// First, try to find any version for this book/chapter/verse
|
||||||
|
const allEvents = await client.fetchBookstrEvents({ |
||||||
|
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) { |
||||||
|
versionsToFetch.push(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) { |
||||||
|
// Use events without version filter
|
||||||
|
newSections.push({ |
||||||
|
reference: ref, |
||||||
|
events: eventsWithoutVersion, |
||||||
|
versions: [], |
||||||
|
originalVerses: ref.verse, |
||||||
|
originalChapter: ref.chapter |
||||||
|
}) |
||||||
|
continue |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Fetch events for each version
|
||||||
|
const allEvents: Event[] = [] |
||||||
|
const allVersions = new Set<string>() |
||||||
|
|
||||||
|
for (const version of versionsToFetch) { |
||||||
|
const events = await client.fetchBookstrEvents({ |
||||||
|
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()) |
||||||
|
} |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
// Sort events by verse number
|
||||||
|
allEvents.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: allEvents, |
||||||
|
versions: Array.from(allVersions), |
||||||
|
originalVerses: ref.verse, |
||||||
|
originalChapter: ref.chapter |
||||||
|
}) |
||||||
|
} |
||||||
|
|
||||||
|
setSections(newSections) |
||||||
|
|
||||||
|
// 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]) |
||||||
|
} |
||||||
|
}) |
||||||
|
setSelectedVersions(initialVersions) |
||||||
|
} catch (err) { |
||||||
|
logger.error('Error fetching bookstr events', { error: err, wikilink }) |
||||||
|
setError(err instanceof Error ? err.message : 'Failed to fetch book content') |
||||||
|
} finally { |
||||||
|
setIsLoading(false) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fetchEvents() |
||||||
|
}, [parsed, wikilink]) |
||||||
|
|
||||||
|
if (isLoading) { |
||||||
|
return ( |
||||||
|
<span className={cn('inline-flex items-center gap-1', className)}> |
||||||
|
<span>{wikilink}</span> |
||||||
|
<Loader2 className="h-3 w-3 animate-spin" /> |
||||||
|
</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 space-y-4', className)}> |
||||||
|
{sections.map((section, sectionIndex) => { |
||||||
|
const selectedVersion = selectedVersions.get(sectionIndex) || section.versions[0] || '' |
||||||
|
const filteredEvents = selectedVersion |
||||||
|
? section.events.filter(event => { |
||||||
|
const metadata = extractBookMetadata(event) |
||||||
|
return metadata.version?.toUpperCase() === selectedVersion |
||||||
|
}) |
||||||
|
: section.events |
||||||
|
|
||||||
|
const isExpanded = expandedSections.has(sectionIndex) |
||||||
|
const hasVerses = section.originalVerses !== undefined && section.originalVerses.length > 0 |
||||||
|
const hasChapter = section.originalChapter !== undefined && !hasVerses |
||||||
|
|
||||||
|
return ( |
||||||
|
<div key={sectionIndex} className="border rounded-lg p-3 bg-muted/30"> |
||||||
|
{/* 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 */} |
||||||
|
{hasVerses && ( |
||||||
|
<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> |
||||||
|
)} |
||||||
|
{hasChapter && !hasVerses && ( |
||||||
|
<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 book |
||||||
|
</> |
||||||
|
) : ( |
||||||
|
<> |
||||||
|
<ChevronDown className="h-3 w-3 mr-1" /> |
||||||
|
Read full book |
||||||
|
</> |
||||||
|
)} |
||||||
|
</Button> |
||||||
|
)} |
||||||
|
|
||||||
|
{/* Expanded content */} |
||||||
|
{isExpanded && ( |
||||||
|
<div className="mt-3 pt-3 border-t"> |
||||||
|
{/* Fetch and display full chapter/book */} |
||||||
|
<ExpandedContent |
||||||
|
section={section} |
||||||
|
selectedVersion={selectedVersion} |
||||||
|
originalVerses={section.originalVerses} |
||||||
|
originalChapter={section.originalChapter} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
) |
||||||
|
})} |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
interface ExpandedContentProps { |
||||||
|
section: BookSection |
||||||
|
selectedVersion: string |
||||||
|
originalVerses?: string |
||||||
|
originalChapter?: number |
||||||
|
} |
||||||
|
|
||||||
|
function ExpandedContent({ section, selectedVersion, originalVerses, originalChapter }: ExpandedContentProps) { |
||||||
|
const [expandedEvents, setExpandedEvents] = useState<Event[]>([]) |
||||||
|
const [isLoading, setIsLoading] = useState(true) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
const fetchExpanded = async () => { |
||||||
|
setIsLoading(true) |
||||||
|
try { |
||||||
|
// Determine book type (default to bible)
|
||||||
|
const bookType = 'bible' // Could be extracted from section if we store it
|
||||||
|
const normalizedBook = section.reference.book.toLowerCase().replace(/\s+/g, '-') |
||||||
|
|
||||||
|
// Fetch full chapter or book
|
||||||
|
const filters: any = { |
||||||
|
type: bookType, |
||||||
|
book: normalizedBook |
||||||
|
} |
||||||
|
|
||||||
|
if (originalChapter !== undefined) { |
||||||
|
// Fetch full chapter
|
||||||
|
filters.chapter = originalChapter |
||||||
|
} |
||||||
|
// If no chapter specified, fetch entire book
|
||||||
|
|
||||||
|
if (selectedVersion) { |
||||||
|
filters.version = selectedVersion.toLowerCase() |
||||||
|
} |
||||||
|
|
||||||
|
const events = await client.fetchBookstrEvents(filters) |
||||||
|
|
||||||
|
// Sort by chapter and verse
|
||||||
|
events.sort((a, b) => { |
||||||
|
const aMeta = extractBookMetadata(a) |
||||||
|
const bMeta = extractBookMetadata(b) |
||||||
|
const aChapter = parseInt(aMeta.chapter || '0') |
||||||
|
const bChapter = parseInt(bMeta.chapter || '0') |
||||||
|
if (aChapter !== bChapter) return aChapter - bChapter |
||||||
|
const aVerse = parseInt(aMeta.verse || '0') |
||||||
|
const bVerse = parseInt(bMeta.verse || '0') |
||||||
|
return aVerse - bVerse |
||||||
|
}) |
||||||
|
|
||||||
|
setExpandedEvents(events) |
||||||
|
} catch (err) { |
||||||
|
logger.error('Error fetching expanded content', { error: err }) |
||||||
|
} finally { |
||||||
|
setIsLoading(false) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fetchExpanded() |
||||||
|
}, [section, selectedVersion, originalChapter]) |
||||||
|
|
||||||
|
if (isLoading) { |
||||||
|
return <div className="text-xs text-muted-foreground">Loading...</div> |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<VerseContent |
||||||
|
events={expandedEvents} |
||||||
|
hasVerses={!!originalVerses} |
||||||
|
originalVerses={originalVerses} |
||||||
|
isExpanded={true} |
||||||
|
originalChapter={originalChapter} |
||||||
|
/> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
interface VerseContentProps { |
||||||
|
events: Event[] |
||||||
|
hasVerses: boolean |
||||||
|
originalVerses?: string |
||||||
|
isExpanded: boolean |
||||||
|
originalChapter?: number |
||||||
|
} |
||||||
|
|
||||||
|
function VerseContent({ events, hasVerses, originalVerses, isExpanded, originalChapter }: VerseContentProps) { |
||||||
|
const [parsedContents, setParsedContents] = useState<Map<string, string>>(new Map()) |
||||||
|
|
||||||
|
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 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 content = parsedContents.get(event.id) || event.content |
||||||
|
|
||||||
|
return ( |
||||||
|
<div |
||||||
|
key={event.id} |
||||||
|
className={cn( |
||||||
|
'text-sm', |
||||||
|
isExpanded && (isOriginalVerse || isOriginalChapter) && 'border-l-2 border-gray-400 pl-2' |
||||||
|
)} |
||||||
|
> |
||||||
|
{chapterNum && verseNum ? ( |
||||||
|
<span className="font-semibold mr-1">{chapterNum}:{verseNum}</span> |
||||||
|
) : verseNum && ( |
||||||
|
<span className="font-semibold mr-1">{verseNum}</span> |
||||||
|
)} |
||||||
|
<span dangerouslySetInnerHTML={{ __html: content }} /> |
||||||
|
</div> |
||||||
|
) |
||||||
|
})} |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
interface VersionSelectorProps { |
||||||
|
section: BookSection |
||||||
|
sectionIndex: number |
||||||
|
selectedVersion: string |
||||||
|
onVersionChange: (version: string) => void |
||||||
|
} |
||||||
|
|
||||||
|
function VersionSelector({ section, selectedVersion, onVersionChange }: VersionSelectorProps) { |
||||||
|
const [availableVersions, setAvailableVersions] = useState<string[]>(section.versions) |
||||||
|
const [isLoadingVersions, setIsLoadingVersions] = useState(false) |
||||||
|
|
||||||
|
// When component mounts or section changes, try to fetch more versions if needed
|
||||||
|
useEffect(() => { |
||||||
|
const fetchAvailableVersions = async () => { |
||||||
|
if (availableVersions.length > 1) return // Already have multiple versions
|
||||||
|
|
||||||
|
setIsLoadingVersions(true) |
||||||
|
try { |
||||||
|
// Query for all versions of this book/chapter/verse
|
||||||
|
const normalizedBook = section.reference.book.toLowerCase().replace(/\s+/g, '-') |
||||||
|
const allEvents = await client.fetchBookstrEvents({ |
||||||
|
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()) |
||||||
|
} |
||||||
|
} catch (err) { |
||||||
|
logger.warn('Error fetching available versions', { error: err }) |
||||||
|
} finally { |
||||||
|
setIsLoadingVersions(false) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
fetchAvailableVersions() |
||||||
|
}, [section.reference.book, section.reference.chapter, section.reference.verse, availableVersions.length]) |
||||||
|
|
||||||
|
// Don't show selector if only one version available
|
||||||
|
if (availableVersions.length <= 1) { |
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<Select |
||||||
|
value={selectedVersion} |
||||||
|
onValueChange={onVersionChange} |
||||||
|
disabled={isLoadingVersions} |
||||||
|
> |
||||||
|
<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> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,2 @@ |
|||||||
|
export { BookstrContent } from './BookstrContent' |
||||||
|
|
||||||
@ -0,0 +1,181 @@ |
|||||||
|
/** |
||||||
|
* Bookstr parsing utilities |
||||||
|
* Ported from wikistr/src/lib/books.ts for use in jumble |
||||||
|
*/ |
||||||
|
|
||||||
|
export interface BookReference { |
||||||
|
book: string |
||||||
|
chapter?: number |
||||||
|
verse?: string // Can be "1", "1-3", "1,3,5", etc.
|
||||||
|
version?: string |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Normalize whitespace and case in book reference strings |
||||||
|
*/ |
||||||
|
function normalizeBookReferenceWhitespace(ref: string): string { |
||||||
|
let normalized = ref.trim() |
||||||
|
|
||||||
|
// Handle cases where there's no space between book name and chapter/verse
|
||||||
|
normalized = normalized.replace(/^([A-Za-z]+)(\d+)/, '$1 $2') |
||||||
|
|
||||||
|
// Normalize multiple spaces to single spaces
|
||||||
|
normalized = normalized.replace(/\s+/g, ' ') |
||||||
|
|
||||||
|
return normalized.trim() |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse book notation like "John 1–3; 3:16; 6:14, 44" for any book type |
||||||
|
* Returns an array of BookReference objects |
||||||
|
*/ |
||||||
|
export function parseBookNotation(notation: string, bookType: string = 'bible'): BookReference[] { |
||||||
|
const references: BookReference[] = [] |
||||||
|
|
||||||
|
// Split by semicolon to handle multiple references
|
||||||
|
const parts = notation.split(';').map(p => p.trim()) |
||||||
|
|
||||||
|
for (const part of parts) { |
||||||
|
const normalizedPart = normalizeBookReferenceWhitespace(part) |
||||||
|
const ref = parseSingleBookReference(normalizedPart, bookType) |
||||||
|
if (ref) { |
||||||
|
references.push(ref) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return references |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse a single book reference like "John 3:16" or "John 1-3" or "John 3:16 KJV" |
||||||
|
*/ |
||||||
|
function parseSingleBookReference(ref: string, _bookType: string = 'bible'): BookReference | null { |
||||||
|
// Remove extra whitespace
|
||||||
|
ref = ref.trim() |
||||||
|
|
||||||
|
// First, try to extract version from the end
|
||||||
|
let version: string | undefined |
||||||
|
let refWithoutVersion = ref |
||||||
|
|
||||||
|
// Common version abbreviations (can be extended)
|
||||||
|
const versionPattern = /\s+(KJV|NKJV|NIV|ESV|NASB|NLT|MSG|CEV|NRSV|RSV|ASV|YLT|WEB|GNV|DRB|SAHIH|PICKTHALL|YUSUFALI|SHAKIR|CCC|YOUCAT|COMPENDIUM)$/i |
||||||
|
const versionMatch = ref.match(versionPattern) |
||||||
|
if (versionMatch) { |
||||||
|
version = versionMatch[1].toUpperCase() |
||||||
|
refWithoutVersion = ref.replace(versionPattern, '').trim() |
||||||
|
} |
||||||
|
|
||||||
|
// Match patterns
|
||||||
|
const patterns = [ |
||||||
|
// Book Chapter:Verses (e.g., "John 3:16", "John 3:16,18")
|
||||||
|
/^(.+?)\s+(\d+):(.+)$/, |
||||||
|
// Book Chapter-Verses (e.g., "John 1-3", "John 1-3,5")
|
||||||
|
/^(.+?)\s+(\d+)-(.+)$/, |
||||||
|
// Book Chapter (e.g., "John 3")
|
||||||
|
/^(.+?)\s+(\d+)$/, |
||||||
|
// Just Book (e.g., "John")
|
||||||
|
/^(.+)$/ |
||||||
|
] |
||||||
|
|
||||||
|
for (const pattern of patterns) { |
||||||
|
const match = refWithoutVersion.match(pattern) |
||||||
|
if (match) { |
||||||
|
const bookName = match[1].trim() |
||||||
|
|
||||||
|
const reference: BookReference = { |
||||||
|
book: bookName |
||||||
|
} |
||||||
|
|
||||||
|
if (match[2]) { |
||||||
|
reference.chapter = parseInt(match[2]) |
||||||
|
} |
||||||
|
|
||||||
|
if (match[3]) { |
||||||
|
reference.verse = match[3] |
||||||
|
} |
||||||
|
|
||||||
|
if (version) { |
||||||
|
reference.version = version |
||||||
|
} |
||||||
|
|
||||||
|
return reference |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return null |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse book wikilink notation like "[[book:bible:John 3:16 | KJV]]" or "[[book:bible:John 3:16 | KJV DRB]]" |
||||||
|
*/ |
||||||
|
export function parseBookWikilink(wikilink: string, bookType: string = 'bible'): { references: BookReference[], versions?: string[] } | null { |
||||||
|
// Remove the [[ and ]] brackets
|
||||||
|
const content = wikilink.replace(/^\[\[|\]\]$/g, '') |
||||||
|
|
||||||
|
// Handle book: prefix (e.g., "book:bible:John 3:16")
|
||||||
|
let referenceContent = content |
||||||
|
if (content.startsWith('book:')) { |
||||||
|
const parts = content.substring(5).split(':') |
||||||
|
if (parts.length >= 2) { |
||||||
|
bookType = parts[0] |
||||||
|
referenceContent = parts.slice(1).join(':') |
||||||
|
} |
||||||
|
} else if (content.startsWith('bible:')) { |
||||||
|
// Legacy Bible prefix support
|
||||||
|
bookType = 'bible' |
||||||
|
referenceContent = content.substring(6).trim() |
||||||
|
} |
||||||
|
|
||||||
|
// Split by | to separate references from versions
|
||||||
|
const parts = referenceContent.split('|').map(p => p.trim()) |
||||||
|
|
||||||
|
if (parts.length === 0) return null |
||||||
|
|
||||||
|
// Normalize whitespace in the reference part
|
||||||
|
const normalizedReference = normalizeBookReferenceWhitespace(parts[0]) |
||||||
|
const references = parseBookNotation(normalizedReference, bookType) |
||||||
|
|
||||||
|
// Parse multiple versions if provided
|
||||||
|
let versions: string[] | undefined |
||||||
|
if (parts[1]) { |
||||||
|
versions = parts[1].split(/\s+/).map(v => v.trim().toUpperCase()).filter(v => v.length > 0) |
||||||
|
} |
||||||
|
|
||||||
|
return { references, versions } |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Extract book metadata from event tags |
||||||
|
*/ |
||||||
|
export function extractBookMetadata(event: { tags: string[][] }): { |
||||||
|
type?: string |
||||||
|
book?: string |
||||||
|
chapter?: string |
||||||
|
verse?: string |
||||||
|
version?: string |
||||||
|
} { |
||||||
|
const metadata: any = {} |
||||||
|
|
||||||
|
for (const [tag, value] of event.tags) { |
||||||
|
switch (tag) { |
||||||
|
case 'type': |
||||||
|
metadata.type = value |
||||||
|
break |
||||||
|
case 'book': |
||||||
|
metadata.book = value |
||||||
|
break |
||||||
|
case 'chapter': |
||||||
|
metadata.chapter = value |
||||||
|
break |
||||||
|
case 'verse': |
||||||
|
metadata.verse = value |
||||||
|
break |
||||||
|
case 'version': |
||||||
|
metadata.version = value |
||||||
|
break |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return metadata |
||||||
|
} |
||||||
|
|
||||||
Loading…
Reference in new issue