Browse Source

implement bookstr rendering

imwald
Silberengel 4 months ago
parent
commit
3a25278851
  1. 583
      src/components/Bookstr/BookstrContent.tsx
  2. 2
      src/components/Bookstr/index.ts
  3. 28
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  4. 32
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  5. 181
      src/lib/bookstr-parser.ts
  6. 162
      src/services/client.service.ts
  7. 13
      src/services/content-parser.service.ts
  8. 2
      src/services/indexed-db.service.ts

583
src/components/Bookstr/BookstrContent.tsx

@ -0,0 +1,583 @@ @@ -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>
)
}

2
src/components/Bookstr/index.ts

@ -0,0 +1,2 @@ @@ -0,0 +1,2 @@
export { BookstrContent } from './BookstrContent'

28
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -16,6 +16,7 @@ import Zoom from 'yet-another-react-lightbox/plugins/zoom' @@ -16,6 +16,7 @@ import Zoom from 'yet-another-react-lightbox/plugins/zoom'
import { EmbeddedNote, EmbeddedMention } from '@/components/Embedded'
import EmbeddedCitation from '@/components/EmbeddedCitation'
import Wikilink from '@/components/UniversalContent/Wikilink'
import { BookstrContent } from '@/components/Bookstr'
import { preprocessAsciidocMediaLinks } from '../MarkdownArticle/preprocessMarkup'
import logger from '@/lib/logger'
import katex from 'katex'
@ -945,21 +946,38 @@ export default function AsciidocArticle({ @@ -945,21 +946,38 @@ export default function AsciidocArticle({
reactRootsRef.current.set(container, root)
})
// Process bookstr wikilinks - replace placeholders with React components
const bookstrPlaceholders = contentRef.current.querySelectorAll('.bookstr-placeholder[data-bookstr]')
bookstrPlaceholders.forEach((element) => {
const bookstrContent = element.getAttribute('data-bookstr')
if (!bookstrContent) return
// Create a container for React component
const container = document.createElement('div')
container.className = 'bookstr-container'
element.parentNode?.replaceChild(container, element)
// Use React to render the component
const root = createRoot(container)
root.render(<BookstrContent wikilink={bookstrContent} />)
reactRootsRef.current.set(container, root)
})
// Process wikilinks - replace placeholders with React components
const wikilinks = contentRef.current.querySelectorAll('.wikilink-placeholder[data-wikilink]')
wikilinks.forEach((element) => {
const linkContent = element.getAttribute('data-wikilink')
if (!linkContent) return
// Skip if this is a bookstr wikilink (already processed)
if (linkContent.startsWith('book:')) {
return
}
// Parse wikilink: extract target and display text
let target = linkContent.includes('|') ? linkContent.split('|')[0].trim() : linkContent.trim()
let displayText = linkContent.includes('|') ? linkContent.split('|')[1].trim() : linkContent.trim()
// Handle book: prefix
if (linkContent.startsWith('book:')) {
target = linkContent.replace('book:', '').trim()
}
// Convert to d-tag format (same as MarkdownArticle)
const dtag = target.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')

32
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -2,6 +2,7 @@ import { useSecondaryPage, useSmartHashtagNavigation, useSmartRelayNavigation } @@ -2,6 +2,7 @@ import { useSecondaryPage, useSmartHashtagNavigation, useSmartRelayNavigation }
import Image from '@/components/Image'
import MediaPlayer from '@/components/MediaPlayer'
import Wikilink from '@/components/UniversalContent/Wikilink'
import { BookstrContent } from '@/components/Bookstr'
import WebPreview from '@/components/WebPreview'
import YoutubeEmbeddedPlayer from '@/components/YoutubeEmbeddedPlayer'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
@ -2024,12 +2025,34 @@ function parseMarkdownContent( @@ -2024,12 +2025,34 @@ function parseMarkdownContent(
}
} else if (pattern.type === 'wikilink') {
const linkContent = pattern.data
let target = linkContent.includes('|') ? linkContent.split('|')[0].trim() : linkContent.trim()
let displayText = linkContent.includes('|') ? linkContent.split('|')[1].trim() : linkContent.trim()
if (linkContent.startsWith('book:')) {
target = linkContent.replace('book:', '').trim()
// Check if this is a bookstr wikilink
// Formats: book:bible:..., bible:..., quran:..., etc.
const isBookstrLink = linkContent.startsWith('book:') ||
['bible', 'quran', 'catechism', 'torah'].some(type =>
linkContent.toLowerCase().startsWith(`${type}:`)
)
if (isBookstrLink) {
// Extract the bookstr content
let bookstrContent = linkContent.trim()
// If it doesn't start with "book:", add it for consistency
if (!bookstrContent.startsWith('book:')) {
// Format: "bible:Genesis 3:1" -> "book:bible:Genesis 3:1"
const firstColon = bookstrContent.indexOf(':')
if (firstColon > 0) {
const bookType = bookstrContent.substring(0, firstColon)
const rest = bookstrContent.substring(firstColon + 1)
bookstrContent = `book:${bookType}:${rest}`
}
}
parts.push(
<BookstrContent key={`bookstr-${patternIdx}`} wikilink={bookstrContent} />
)
} else {
// Regular wikilink
let target = linkContent.includes('|') ? linkContent.split('|')[0].trim() : linkContent.trim()
let displayText = linkContent.includes('|') ? linkContent.split('|')[1].trim() : linkContent.trim()
const dtag = target.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
@ -2037,6 +2060,7 @@ function parseMarkdownContent( @@ -2037,6 +2060,7 @@ function parseMarkdownContent(
<Wikilink key={`wikilink-${patternIdx}`} dTag={dtag} displayText={displayText} />
)
}
}
lastIndex = pattern.end
})

181
src/lib/bookstr-parser.ts

@ -0,0 +1,181 @@ @@ -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
}

162
src/services/client.service.ts

@ -2097,6 +2097,168 @@ class ClientService extends EventTarget { @@ -2097,6 +2097,168 @@ class ClientService extends EventTarget {
filter: { authors: Array.from(authors) }
}))
}
/**
* Fetch bookstr events by tag filters
* Note: Most relays only index single-letter tags, so we fetch all kind 30041 events
* and filter client-side based on the custom tags (type, book, chapter, verse, version)
*/
async fetchBookstrEvents(filters: {
type?: string
book?: string
chapter?: number
verse?: string
version?: string
}): Promise<NEvent[]> {
// Build filter for querying - only use indexed tags (single letters)
// We'll filter by the custom tags client-side
const filter: Filter = {
kinds: [ExtendedKind.PUBLICATION_CONTENT]
}
// Note: We can't use #type, #book, #chapter, #verse, #version filters
// because relays only index single-letter tags. We'll fetch and filter client-side.
// First, try to get from cache
// Note: For now, we'll query the relay directly. The cache will be populated
// when publications are loaded through normal channels. We can enhance this
// later to check the cache first if needed.
const cachedEvents: NEvent[] = []
// Query from relays - fetch all kind 30041 events (we'll filter client-side)
// Use BIG_RELAY_URLS which includes both thecitadel.nostr1.com and nostr.land
const relayUrls = BIG_RELAY_URLS
let relayEvents: NEvent[] = []
try {
relayEvents = await this.fetchEvents(relayUrls, filter, {
eoseTimeout: 5000,
globalTimeout: 10000
})
// Filter events client-side based on the custom tags
// Since relays don't index multi-letter tags, we need to check tags manually
relayEvents = relayEvents.filter(event => {
return this.eventMatchesBookstrFilters(event, filters)
})
} catch (error) {
logger.warn('Error querying bookstr events from relays', { error, filters, relayUrls })
}
// Combine cached and relay events, deduplicate by event ID
const eventMap = new Map<string, NEvent>()
cachedEvents.forEach(event => eventMap.set(event.id, event))
relayEvents.forEach(event => eventMap.set(event.id, event))
return Array.from(eventMap.values())
}
/**
* Check if an event matches bookstr filters
*/
private eventMatchesBookstrFilters(event: NEvent, filters: {
type?: string
book?: string
chapter?: number
verse?: string
version?: string
}): boolean {
if (event.kind !== ExtendedKind.PUBLICATION_CONTENT) {
return false
}
const getTagValue = (tagName: string): string | undefined => {
const tag = event.tags.find(t => t[0] === tagName)
return tag?.[1]
}
if (filters.type) {
const eventType = getTagValue('type')
if (!eventType || eventType.toLowerCase() !== filters.type.toLowerCase()) {
return false
}
}
if (filters.book) {
const eventBook = getTagValue('book')
const normalizedBook = filters.book.toLowerCase().replace(/\s+/g, '-')
if (!eventBook || eventBook.toLowerCase() !== normalizedBook) {
return false
}
}
if (filters.chapter !== undefined) {
const eventChapter = getTagValue('chapter')
if (!eventChapter || parseInt(eventChapter) !== filters.chapter) {
return false
}
}
if (filters.verse) {
const eventVerse = getTagValue('verse')
if (!eventVerse) {
return false
}
// Check if verse matches (handle ranges like "1-3", "1,3,5", etc.)
const verseMatches = this.verseMatches(eventVerse, filters.verse)
if (!verseMatches) {
return false
}
}
if (filters.version) {
const eventVersion = getTagValue('version')
if (!eventVersion || eventVersion.toLowerCase() !== filters.version.toLowerCase()) {
return false
}
}
return true
}
/**
* Check if a verse string matches a verse filter
* Handles ranges like "1-3", "1,3,5", etc.
*/
private verseMatches(eventVerse: string, filterVerse: string): boolean {
// Normalize both verses
const normalize = (v: string) => v.trim().toLowerCase()
const eventV = normalize(eventVerse)
const filterV = normalize(filterVerse)
// If exact match
if (eventV === filterV) {
return true
}
// Parse filter verse (could be "1", "1-3", "1,3,5", etc.)
const filterParts = filterV.split(/[,\s]+/)
for (const part of filterParts) {
if (part.includes('-')) {
// Range like "1-3"
const [start, end] = part.split('-').map(v => parseInt(v.trim()))
const eventNum = parseInt(eventV)
if (!isNaN(start) && !isNaN(end) && !isNaN(eventNum)) {
if (eventNum >= start && eventNum <= end) {
return true
}
}
} else {
// Single verse
const filterNum = parseInt(part)
const eventNum = parseInt(eventV)
if (!isNaN(filterNum) && !isNaN(eventNum) && filterNum === eventNum) {
return true
}
// Also check if event verse contains the filter verse
if (eventV.includes(part)) {
return true
}
}
}
return false
}
}
const instance = ClientService.getInstance()

13
src/services/content-parser.service.ts

@ -518,11 +518,11 @@ class ContentParserService { @@ -518,11 +518,11 @@ class ContentParserService {
let processed = content
// Process bookstr macro wikilinks: [[book:...]] where ... can be any book type and reference
// These should be converted to a special marker that will be processed in HTML
processed = processed.replace(/\[\[book:([^\]]+)\]\]/g, (_match, bookContent) => {
const cleanContent = bookContent.trim()
const dTag = this.normalizeDtag(cleanContent)
return `wikilink:${dTag}[${cleanContent}]`
// Use a passthrough marker that will be converted to HTML placeholder in processWikilinksInHtml
return `BOOKSTR:${cleanContent}`
})
// Process standard wikilinks: [[Target Page]] or [[target page|see this]]
@ -553,6 +553,13 @@ class ContentParserService { @@ -553,6 +553,13 @@ class ContentParserService {
private processWikilinksInHtml(html: string): string {
let processed = html
// Convert bookstr markers to HTML placeholders
processed = processed.replace(/BOOKSTR:([^<>\s]+)/g, (_match, bookContent) => {
// Escape special characters for HTML attributes
const escaped = bookContent.replace(/"/g, '&quot;').replace(/'/g, '&#39;')
return `<span data-bookstr="${escaped}" class="bookstr-placeholder"></span>`
})
// Convert hashtag links to HTML with green styling
processed = processed.replace(/hashtag:([^[]+)\[([^\]]+)\]/g, (_match, normalizedHashtag, displayText) => {
return `<a href="/notes?t=${normalizedHashtag}" class="hashtag-link text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline">${displayText}</a>`

2
src/services/indexed-db.service.ts

@ -12,7 +12,7 @@ type TValue<T = any> = { @@ -12,7 +12,7 @@ type TValue<T = any> = {
masterPublicationKey?: string // For nested publication events, link to master publication
}
const StoreNames = {
export const StoreNames = {
PROFILE_EVENTS: 'profileEvents',
RELAY_LIST_EVENTS: 'relayListEvents',
FOLLOW_LIST_EVENTS: 'followListEvents',

Loading…
Cancel
Save