Browse Source

prepare bookstr rendering

imwald
Silberengel 4 months ago
parent
commit
5dd48cd3b3
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 9
      src/PageManager.tsx
  4. 78
      src/components/Bookstr/BookstrContent.tsx
  5. 94
      src/components/Embedded/EmbeddedNote.tsx
  6. 48
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  7. 27
      src/components/Note/PublicationCard.tsx
  8. 53
      src/components/Note/PublicationIndex/PublicationIndex.tsx
  9. 21
      src/components/Note/UnknownNote.tsx
  10. 23
      src/components/WebPreview/index.tsx
  11. 6
      src/pages/secondary/HomePage/index.tsx
  12. 577
      src/services/client.service.ts

4
package-lock.json generated

@ -1,12 +1,12 @@ @@ -1,12 +1,12 @@
{
"name": "jumble-imwald",
"version": "14.2",
"version": "14.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "jumble-imwald",
"version": "14.2",
"version": "14.3",
"license": "MIT",
"dependencies": {
"@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
{
"name": "jumble-imwald",
"version": "14.2",
"version": "14.3",
"description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble",
"private": true,
"type": "module",

9
src/PageManager.tsx

@ -63,6 +63,7 @@ type TSecondaryPageContext = { @@ -63,6 +63,7 @@ type TSecondaryPageContext = {
push: (url: string) => void
pop: () => void
currentIndex: number
navigateToPrimaryPage: (page: TPrimaryPageName, props?: object) => void
}
type TStackItem = {
@ -115,7 +116,7 @@ export function usePrimaryPage() { @@ -115,7 +116,7 @@ export function usePrimaryPage() {
export function useSecondaryPage() {
const context = useContext(SecondaryPageContext)
if (!context) {
throw new Error('usePrimaryPage must be used within a SecondaryPageContext.Provider')
throw new Error('useSecondaryPage must be used within a SecondaryPageContext.Provider')
}
return context
}
@ -1666,7 +1667,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1666,7 +1667,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
pop: popSecondaryPage,
currentIndex: secondaryStack.length
? secondaryStack[secondaryStack.length - 1].index
: 0
: 0,
navigateToPrimaryPage: navigatePrimaryPage
}}
>
<CurrentRelaysProvider>
@ -1763,7 +1765,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { @@ -1763,7 +1765,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
value={{
push: pushSecondaryPage,
pop: popSecondaryPage,
currentIndex: secondaryStack.length ? secondaryStack[secondaryStack.length - 1].index : 0
currentIndex: secondaryStack.length ? secondaryStack[secondaryStack.length - 1].index : 0,
navigateToPrimaryPage: navigatePrimaryPage
}}
>
<CurrentRelaysProvider>

78
src/components/Bookstr/BookstrContent.tsx

@ -139,14 +139,23 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { @@ -139,14 +139,23 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
const allVersions = new Set<string>()
for (const version of versionsToFetch) {
// Fetch entire chapter if verse is specified, entire book if only chapter is specified
const events = await client.fetchBookstrEvents({
type: bookType,
book: normalizedBook,
chapter: ref.chapter,
verse: ref.verse,
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
})
events.forEach(event => {
allEvents.push(event)
const metadata = extractBookMetadata(event)
@ -156,8 +165,32 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { @@ -156,8 +165,32 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
})
}
// Filter events to only show requested verses (if verse is specified)
// We fetched the entire chapter/book, but only display the requested verses
let filteredEvents = allEvents
if (ref.verse) {
const verseParts = ref.verse.split(/[,\s-]+/).map(v => v.trim()).filter(v => v)
filteredEvents = allEvents.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
}
})
})
}
// Sort events by verse number
allEvents.sort((a, b) => {
filteredEvents.sort((a, b) => {
const aMeta = extractBookMetadata(a)
const bMeta = extractBookMetadata(b)
const aVerse = parseInt(aMeta.verse || '0')
@ -165,15 +198,34 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { @@ -165,15 +198,34 @@ 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: allEvents,
events: filteredEvents,
versions: Array.from(allVersions),
originalVerses: ref.verse,
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
}))
})
setSections(newSections)
// Set initial selected versions
@ -267,8 +319,8 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { @@ -267,8 +319,8 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
isExpanded={isExpanded}
/>
{/* Expand/Collapse buttons */}
{hasVerses && (
{/* Expand/Collapse buttons - only show if events were found */}
{hasVerses && filteredEvents.length > 0 && (
<Button
variant="ghost"
size="sm"
@ -296,7 +348,7 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { @@ -296,7 +348,7 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
)}
</Button>
)}
{hasChapter && !hasVerses && (
{hasChapter && !hasVerses && filteredEvents.length > 0 && (
<Button
variant="ghost"
size="sm"
@ -491,16 +543,16 @@ function VerseContent({ events, hasVerses, originalVerses, isExpanded, originalC @@ -491,16 +543,16 @@ function VerseContent({ events, hasVerses, originalVerses, isExpanded, originalC
<div
key={event.id}
className={cn(
'text-sm',
'flex gap-2 text-sm leading-relaxed items-baseline',
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 }} />
{/* Verse number on the left - only show verse number, not chapter:verse */}
<span className="font-semibold text-muted-foreground shrink-0 min-w-[2.5rem] text-right">
{verseNum || null}
</span>
{/* Content on the right */}
<span className="flex-1" dangerouslySetInnerHTML={{ __html: content }} />
</div>
)
})}

94
src/components/Embedded/EmbeddedNote.tsx

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
import { Skeleton } from '@/components/ui/skeleton'
import { BIG_RELAY_URLS, FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
import { BIG_RELAY_URLS, FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS, ExtendedKind } from '@/constants'
import { useFetchEvent } from '@/hooks'
import { normalizeUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
@ -12,6 +12,10 @@ import MainNoteCard from '../NoteCard/MainNoteCard' @@ -12,6 +12,10 @@ import MainNoteCard from '../NoteCard/MainNoteCard'
import { Button } from '../ui/button'
import { Search } from 'lucide-react'
import logger from '@/lib/logger'
import { extractBookMetadata } from '@/lib/bookstr-parser'
import { contentParserService } from '@/services/content-parser.service'
import { useSmartNoteNavigation } from '@/PageManager'
import { toNote } from '@/lib/link'
export function EmbeddedNote({ noteId, className }: { noteId: string; className?: string }) {
const { event, isFetching } = useFetchEvent(noteId)
@ -57,6 +61,20 @@ export function EmbeddedNote({ noteId, className }: { noteId: string; className? @@ -57,6 +61,20 @@ export function EmbeddedNote({ noteId, className }: { noteId: string; className?
return <EmbeddedNoteNotFound className={className} noteId={noteId} onEventFound={setRetryEvent} />
}
// Check if this event has bookstr tags (at least "book" tag)
const bookMetadata = extractBookMetadata(finalEvent)
const hasBookstrTags = !!bookMetadata.book
// If it has bookstr tags, render directly as bookstr content (no need to search)
if (hasBookstrTags) {
return (
<div data-embedded-note data-bookstr onClick={(e) => e.stopPropagation()}>
<EmbeddedBookstrEvent event={finalEvent} originalNoteId={noteId} className={className} />
</div>
)
}
// Otherwise, render as regular embedded note
return (
<div data-embedded-note onClick={(e) => e.stopPropagation()}>
<MainNoteCard
@ -280,3 +298,77 @@ function EmbeddedNoteNotFound({ @@ -280,3 +298,77 @@ function EmbeddedNoteNotFound({
</div>
)
}
/**
* Render a single bookstr event directly (no searching needed)
*/
function EmbeddedBookstrEvent({ event, originalNoteId, className }: { event: Event; originalNoteId?: string; className?: string }) {
const [parsedContent, setParsedContent] = useState<string | null>(null)
const bookMetadata = extractBookMetadata(event)
const { navigateToNote } = useSmartNoteNavigation()
useEffect(() => {
const parseContent = async () => {
try {
const result = await contentParserService.parseContent(event.content, {
eventKind: ExtendedKind.PUBLICATION_CONTENT
})
setParsedContent(result.html)
} catch (err) {
logger.warn('Error parsing bookstr event content', { error: err, eventId: event.id.substring(0, 8) })
setParsedContent(event.content)
}
}
parseContent()
}, [event])
const chapterNum = bookMetadata.chapter
const verseNum = bookMetadata.verse
const version = bookMetadata.version
const bookName = bookMetadata.book
? bookMetadata.book
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
: ''
const content = parsedContent || event.content
return (
<div
className={cn('border rounded-lg p-3 bg-muted/30 clickable', className)}
data-event-id={event.id}
onClick={(e) => {
// Don't navigate if clicking on interactive elements
const target = e.target as HTMLElement
if (target.closest('button') || target.closest('[role="button"]') || target.closest('a')) {
return
}
e.stopPropagation()
// Navigate to the note view
const noteUrl = toNote(originalNoteId ?? event)
navigateToNote(noteUrl)
}}
>
{/* Header */}
<div className="flex items-center gap-2 mb-2">
<h4 className="font-semibold text-sm">
{bookName}
{chapterNum && ` ${chapterNum}`}
{verseNum && `:${verseNum}`}
{version && ` (${version.toUpperCase()})`}
</h4>
</div>
{/* Content */}
<div className="flex gap-2 text-sm leading-relaxed items-baseline">
{/* Verse number on the left - only show verse number, not chapter:verse */}
<span className="font-semibold text-muted-foreground shrink-0 min-w-[2.5rem] text-right">
{verseNum || null}
</span>
{/* Content on the right */}
<span className="flex-1" dangerouslySetInnerHTML={{ __html: content }} />
</div>
</div>
)
}

48
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -19,6 +19,8 @@ import Wikilink from '@/components/UniversalContent/Wikilink' @@ -19,6 +19,8 @@ import Wikilink from '@/components/UniversalContent/Wikilink'
import { BookstrContent } from '@/components/Bookstr'
import { preprocessAsciidocMediaLinks } from '../MarkdownArticle/preprocessMarkup'
import logger from '@/lib/logger'
import { extractBookMetadata } from '@/lib/bookstr-parser'
import { ExtendedKind } from '@/constants'
import katex from 'katex'
import 'katex/dist/katex.min.css'
import { WS_URL_REGEX, YOUTUBE_URL_REGEX } from '@/constants'
@ -331,6 +333,8 @@ export default function AsciidocArticle({ @@ -331,6 +333,8 @@ export default function AsciidocArticle({
const { navigateToHashtag } = useSmartHashtagNavigation()
const { navigateToRelay } = useSmartRelayNavigation()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const bookMetadata = useMemo(() => extractBookMetadata(event), [event])
const isBookstrEvent = (event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT) && !!bookMetadata.book
const contentRef = useRef<HTMLDivElement>(null)
// Preprocess content: convert all markdown to AsciiDoc syntax
@ -1219,6 +1223,28 @@ export default function AsciidocArticle({ @@ -1219,6 +1223,28 @@ export default function AsciidocArticle({
<div className={`prose prose-zinc max-w-none dark:prose-invert break-words overflow-wrap-anywhere ${className || ''}`}>
{/* Metadata */}
{!hideImagesAndInfo && metadata.title && <h1 className="break-words">{metadata.title}</h1>}
{!hideImagesAndInfo && !metadata.title && isBookstrEvent && (
<h1 className="break-words">
{bookMetadata.book
? bookMetadata.book
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
: 'Bookstr Publication'}
</h1>
)}
{!hideImagesAndInfo && isBookstrEvent && (
<div className="text-xs text-muted-foreground space-x-2 mb-2">
{bookMetadata.type && <span>Type: {bookMetadata.type}</span>}
{bookMetadata.book && <span>Book: {bookMetadata.book
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')}</span>}
{bookMetadata.chapter && <span>Chapter: {bookMetadata.chapter}</span>}
{bookMetadata.verse && <span>Verse: {bookMetadata.verse}</span>}
{bookMetadata.version && <span>Version: {bookMetadata.version.toUpperCase()}</span>}
</div>
)}
{!hideImagesAndInfo && metadata.summary && (
<blockquote>
<p className="break-words">{metadata.summary}</p>
@ -1227,6 +1253,28 @@ export default function AsciidocArticle({ @@ -1227,6 +1253,28 @@ export default function AsciidocArticle({
{hideImagesAndInfo && metadata.title && (
<h2 className="text-2xl font-bold mb-4 leading-tight break-words">{metadata.title}</h2>
)}
{hideImagesAndInfo && !metadata.title && isBookstrEvent && (
<h2 className="text-2xl font-bold mb-4 leading-tight break-words">
{bookMetadata.book
? bookMetadata.book
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
: 'Bookstr Publication'}
</h2>
)}
{hideImagesAndInfo && isBookstrEvent && (
<div className="text-xs text-muted-foreground space-x-2 mb-2">
{bookMetadata.type && <span>Type: {bookMetadata.type}</span>}
{bookMetadata.book && <span>Book: {bookMetadata.book
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')}</span>}
{bookMetadata.chapter && <span>Chapter: {bookMetadata.chapter}</span>}
{bookMetadata.verse && <span>Verse: {bookMetadata.verse}</span>}
{bookMetadata.version && <span>Version: {bookMetadata.version.toUpperCase()}</span>}
</div>
)}
{/* Metadata image */}
{!hideImagesAndInfo && metadata.image && (() => {

27
src/components/Note/PublicationCard.tsx

@ -6,6 +6,8 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider' @@ -6,6 +6,8 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react'
import Image from '../Image'
import { extractBookMetadata } from '@/lib/bookstr-parser'
import { ExtendedKind } from '@/constants'
export default function PublicationCard({
event,
@ -18,13 +20,32 @@ export default function PublicationCard({ @@ -18,13 +20,32 @@ export default function PublicationCard({
const { push } = useSecondaryPage()
const { autoLoadMedia } = useContentPolicy()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const bookMetadata = useMemo(() => extractBookMetadata(event), [event])
const isBookstrEvent = (event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT) && !!bookMetadata.book
const handleCardClick = (e: React.MouseEvent) => {
e.stopPropagation()
push(toNote(event.id))
}
const titleComponent = <div className="text-xl font-semibold line-clamp-2">{metadata.title}</div>
const titleComponent = metadata.title ? <div className="text-xl font-semibold line-clamp-2">{metadata.title}</div> : null
const formatBookName = (book: string) => {
return book
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
}
const bookstrMetadataComponent = isBookstrEvent && (
<div className="text-xs text-muted-foreground space-x-2">
{bookMetadata.type && <span>Type: {bookMetadata.type}</span>}
{bookMetadata.book && <span>Book: {formatBookName(bookMetadata.book)}</span>}
{bookMetadata.chapter && <span>Chapter: {bookMetadata.chapter}</span>}
{bookMetadata.verse && <span>Verse: {bookMetadata.verse}</span>}
{bookMetadata.version && <span>Version: {bookMetadata.version.toUpperCase()}</span>}
</div>
)
const tagsComponent = metadata.tags.length > 0 && (
<div className="flex gap-1 flex-wrap">
@ -63,6 +84,8 @@ export default function PublicationCard({ @@ -63,6 +84,8 @@ export default function PublicationCard({
)}
<div className="space-y-2">
{titleComponent}
{bookstrMetadataComponent}
{!titleComponent && bookstrMetadataComponent && <div className="h-0" />}
{summaryComponent}
{tagsComponent}
</div>
@ -87,6 +110,8 @@ export default function PublicationCard({ @@ -87,6 +110,8 @@ export default function PublicationCard({
)}
<div className="flex-1 w-0 space-y-2">
{titleComponent}
{bookstrMetadataComponent}
{!titleComponent && bookstrMetadataComponent && <div className="h-0" />}
{summaryComponent}
{tagsComponent}
</div>

53
src/components/Note/PublicationIndex/PublicationIndex.tsx

@ -13,6 +13,7 @@ import { MoreVertical, RefreshCw, ArrowUp } from 'lucide-react' @@ -13,6 +13,7 @@ import { MoreVertical, RefreshCw, ArrowUp } from 'lucide-react'
import indexedDb from '@/services/indexed-db.service'
import { isReplaceableEvent } from '@/lib/event'
import { useSecondaryPage } from '@/PageManager'
import { extractBookMetadata } from '@/lib/bookstr-parser'
interface PublicationReference {
coordinate?: string
@ -83,6 +84,8 @@ export default function PublicationIndex({ @@ -83,6 +84,8 @@ export default function PublicationIndex({
return meta
}, [event])
const bookMetadata = useMemo(() => extractBookMetadata(event), [event])
const isBookstrEvent = (event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT) && !!bookMetadata.book
const [references, setReferences] = useState<PublicationReference[]>([])
const [visitedIndices, setVisitedIndices] = useState<Set<string>>(new Set())
const [isLoading, setIsLoading] = useState(true)
@ -992,7 +995,19 @@ export default function PublicationIndex({ @@ -992,7 +995,19 @@ export default function PublicationIndex({
<div className="prose prose-zinc max-w-none dark:prose-invert">
<header className="mb-8 border-b pb-6">
<div className="flex items-start justify-between gap-4 mb-4">
<h1 className="text-4xl font-bold leading-tight break-words flex-1">{metadata.title}</h1>
{metadata.title && <h1 className="text-4xl font-bold leading-tight break-words flex-1">{metadata.title}</h1>}
{!metadata.title && isBookstrEvent && (
<div className="flex-1">
<h1 className="text-4xl font-bold leading-tight break-words">
{bookMetadata.book
? bookMetadata.book
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
: 'Bookstr Publication'}
</h1>
</div>
)}
<Button
variant="ghost"
size="icon"
@ -1014,16 +1029,48 @@ export default function PublicationIndex({ @@ -1014,16 +1029,48 @@ export default function PublicationIndex({
<span className="font-semibold">Author:</span> {metadata.author}
</div>
)}
{metadata.version && (
{metadata.version && !isBookstrEvent && (
<div>
<span className="font-semibold">Version:</span> {metadata.version}
</div>
)}
{metadata.type && (
{metadata.type && !isBookstrEvent && (
<div>
<span className="font-semibold">Type:</span> {metadata.type}
</div>
)}
{isBookstrEvent && (
<>
{bookMetadata.type && (
<div>
<span className="font-semibold">Type:</span> {bookMetadata.type}
</div>
)}
{bookMetadata.book && (
<div>
<span className="font-semibold">Book:</span> {bookMetadata.book
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')}
</div>
)}
{bookMetadata.chapter && (
<div>
<span className="font-semibold">Chapter:</span> {bookMetadata.chapter}
</div>
)}
{bookMetadata.verse && (
<div>
<span className="font-semibold">Verse:</span> {bookMetadata.verse}
</div>
)}
{bookMetadata.version && (
<div>
<span className="font-semibold">Version:</span> {bookMetadata.version.toUpperCase()}
</div>
)}
</>
)}
</div>
</header>
</div>

21
src/components/Note/UnknownNote.tsx

@ -2,9 +2,21 @@ import { cn } from '@/lib/utils' @@ -2,9 +2,21 @@ import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next'
import ClientSelect from '../ClientSelect'
import { extractBookMetadata } from '@/lib/bookstr-parser'
import { ExtendedKind } from '@/constants'
import { useMemo } from 'react'
export default function UnknownNote({ event, className }: { event: Event; className?: string }) {
const { t } = useTranslation()
const bookMetadata = useMemo(() => extractBookMetadata(event), [event])
const isBookstrEvent = (event.kind === ExtendedKind.PUBLICATION || event.kind === ExtendedKind.PUBLICATION_CONTENT) && !!bookMetadata.book
const formatBookName = (book: string) => {
return book
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
}
return (
<div
@ -14,6 +26,15 @@ export default function UnknownNote({ event, className }: { event: Event; classN @@ -14,6 +26,15 @@ export default function UnknownNote({ event, className }: { event: Event; classN
)}
>
<div>{t('Cannot handle event of kind k', { k: event.kind })}</div>
{isBookstrEvent && (
<div className="text-xs text-muted-foreground space-x-2">
{bookMetadata.type && <span>Type: {bookMetadata.type}</span>}
{bookMetadata.book && <span>Book: {formatBookName(bookMetadata.book)}</span>}
{bookMetadata.chapter && <span>Chapter: {bookMetadata.chapter}</span>}
{bookMetadata.verse && <span>Verse: {bookMetadata.verse}</span>}
{bookMetadata.version && <span>Version: {bookMetadata.version.toUpperCase()}</span>}
</div>
)}
<ClientSelect event={event} />
</div>
)

23
src/components/WebPreview/index.tsx

@ -3,6 +3,7 @@ import { useFetchEvent } from '@/hooks/useFetchEvent' @@ -3,6 +3,7 @@ import { useFetchEvent } from '@/hooks/useFetchEvent'
import { useFetchProfile } from '@/hooks/useFetchProfile'
import { ExtendedKind } from '@/constants'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { extractBookMetadata } from '@/lib/bookstr-parser'
import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
@ -148,6 +149,17 @@ export default function WebPreview({ url, className }: { url: string; className? @@ -148,6 +149,17 @@ export default function WebPreview({ url, className }: { url: string; className?
const eventSummary = eventMetadata?.summary || description
const eventImage = eventMetadata?.image
// Extract bookstr metadata if applicable
const bookMetadata = fetchedEvent ? extractBookMetadata(fetchedEvent) : null
const isBookstrEvent = fetchedEvent && (fetchedEvent.kind === ExtendedKind.PUBLICATION || fetchedEvent.kind === ExtendedKind.PUBLICATION_CONTENT) && !!bookMetadata?.book
const formatBookName = (book: string) => {
return book
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
}
return (
<div
className={cn('p-3 clickable flex w-full border rounded-lg overflow-hidden gap-3', className)}
@ -183,11 +195,20 @@ export default function WebPreview({ url, className }: { url: string; className? @@ -183,11 +195,20 @@ export default function WebPreview({ url, className }: { url: string; className?
{eventTitle && (
<div className="font-semibold text-sm line-clamp-2 mb-1">{eventTitle}</div>
)}
{isBookstrEvent && bookMetadata && (
<div className="text-xs text-muted-foreground space-x-2 mb-1">
{bookMetadata.type && <span>Type: {bookMetadata.type}</span>}
{bookMetadata.book && <span>Book: {formatBookName(bookMetadata.book)}</span>}
{bookMetadata.chapter && <span>Chapter: {bookMetadata.chapter}</span>}
{bookMetadata.verse && <span>Verse: {bookMetadata.verse}</span>}
{bookMetadata.version && <span>Version: {bookMetadata.version.toUpperCase()}</span>}
</div>
)}
{eventSummary && (
<div className="text-xs text-muted-foreground line-clamp-2 mb-1">{eventSummary}</div>
)}
{contentPreview && (
<div className="text-xs text-muted-foreground line-clamp-3 whitespace-pre-wrap break-words">
<div className="my-2 text-sm whitespace-pre-wrap break-words line-clamp-6">
{contentPreview}
</div>
)}

6
src/pages/secondary/HomePage/index.tsx

@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
import { usePrimaryPage, useSmartRelayNavigation } from '@/PageManager'
import { useSecondaryPage, useSmartRelayNavigation } from '@/PageManager'
import RelaySimpleInfo from '@/components/RelaySimpleInfo'
import { Button } from '@/components/ui/button'
import { RECOMMENDED_RELAYS } from '@/constants'
@ -13,7 +13,7 @@ import logger from '@/lib/logger' @@ -13,7 +13,7 @@ import logger from '@/lib/logger'
const HomePage = forwardRef(({ index }: { index?: number }, ref) => {
const { t } = useTranslation()
const { navigate } = usePrimaryPage()
const { navigateToPrimaryPage } = useSecondaryPage()
const { navigateToRelay } = useSmartRelayNavigation()
// DEPRECATED: updateShowRecommendedRelaysPanel removed - double-panel functionality disabled
const [recommendedRelayInfos, setRecommendedRelayInfos] = useState<TRelayInfo[]>([])
@ -72,7 +72,7 @@ const HomePage = forwardRef(({ index }: { index?: number }, ref) => { @@ -72,7 +72,7 @@ const HomePage = forwardRef(({ index }: { index?: number }, ref) => {
))}
</div>
<div className="flex mt-2 justify-center">
<Button variant="ghost" onClick={() => navigate('explore')}>
<Button variant="ghost" onClick={() => navigateToPrimaryPage('explore')}>
<div>{t('Explore more')}</div>
<ArrowRight />
</Button>

577
src/services/client.service.ts

@ -2100,8 +2100,15 @@ class ClientService extends EventTarget { @@ -2100,8 +2100,15 @@ class ClientService extends EventTarget {
/**
* 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)
* Strategy:
* 1. Find the appropriate publication (kind 30040) level:
* - If verse requested find chapter-level 30040
* - If chapter requested find chapter-level 30040
* - If only book requested find book-level 30040
* 2. Fetch ALL a-tags from that publication (we always pull more than needed for expansion)
* 3. Filter from cached results to show only what was requested
*
* This is efficient because there are far fewer 30040s than 30041s
*/
async fetchBookstrEvents(filters: {
type?: string
@ -2110,60 +2117,479 @@ class ClientService extends EventTarget { @@ -2110,60 +2117,479 @@ class ClientService extends EventTarget {
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[] = []
logger.info('fetchBookstrEvents: Called', { filters })
try {
relayEvents = await this.fetchEvents(relayUrls, filter, {
eoseTimeout: 5000,
globalTimeout: 10000
// Step 1: Determine what level of publication we need
// - If verse is specified → we need chapter-level publication
// - If chapter is specified (but no verse) → we need chapter-level publication
// - If only book is specified → we need book-level publication
const needsChapterLevel = filters.chapter !== undefined || filters.verse !== undefined
const publicationFilter: Filter = {
kinds: [ExtendedKind.PUBLICATION]
}
// Build search terms for finding the publication
const searchTerms: string[] = []
if (filters.type) {
searchTerms.push(filters.type)
}
if (filters.book) {
const normalizedBook = filters.book.toLowerCase().replace(/\s+/g, '-')
const originalBook = filters.book.toLowerCase()
searchTerms.push(normalizedBook)
if (normalizedBook !== originalBook) {
searchTerms.push(originalBook)
}
}
// Only include chapter in search if we need chapter-level publication
if (needsChapterLevel && filters.chapter !== undefined) {
searchTerms.push(filters.chapter.toString())
}
if (filters.version) {
searchTerms.push(filters.version)
}
const relayUrls = FAST_READ_RELAY_URLS
logger.info('fetchBookstrEvents: Searching for publication', {
filters,
needsChapterLevel,
searchTerms,
relayUrls: relayUrls.length
})
// Fetch publications
logger.info('fetchBookstrEvents: About to fetch publications', {
relayUrls: relayUrls.length,
filter: publicationFilter
})
let publications: NEvent[] = []
try {
publications = await this.fetchEvents(relayUrls, publicationFilter, {
eoseTimeout: 10000,
globalTimeout: 15000
})
logger.info('fetchBookstrEvents: Fetched publications', {
count: publications.length
})
} catch (fetchError) {
logger.error('fetchBookstrEvents: Error fetching publications', {
error: fetchError,
filters,
relayUrls: relayUrls.length
})
throw fetchError
}
// Filter publications by tags
// For chapter-level: must have matching chapter tag
// For book-level: must NOT have chapter tag
const filtersForMatching = { ...filters }
delete filtersForMatching.verse // Never filter by verse for publication search
// Log sample publications before filtering to debug
if (publications.length > 0) {
const samplePub = publications[0]
const getTagValue = (name: string) => samplePub.tags.find(t => t[0] === name)?.[1]
logger.info('fetchBookstrEvents: Sample publication before filtering', {
id: samplePub.id.substring(0, 8),
kind: samplePub.kind,
tags: samplePub.tags.map(t => `${t[0]}:${t[1]}`).slice(0, 10),
type: getTagValue('type'),
book: getTagValue('book'),
chapter: getTagValue('chapter'),
version: getTagValue('version'),
allTagNames: samplePub.tags.map(t => t[0])
})
}
const beforeFilterCount = publications.length
// Step 1: Filter by chapter-level requirement
publications = publications.filter(event => {
const getTagValue = (name: string) => event.tags.find(t => t[0] === name)?.[1]
const hasChapter = getTagValue('chapter') !== undefined
// If we need chapter-level, the publication must have a chapter tag
// If we need book-level, the publication must NOT have a chapter tag
if (needsChapterLevel && !hasChapter) {
return false
}
if (!needsChapterLevel && hasChapter) {
return false
}
return true
})
logger.info('fetchBookstrEvents: After chapter-level filter', {
beforeFilter: beforeFilterCount,
afterChapterFilter: publications.length,
needsChapterLevel
})
// Step 2: Do fulltext search first (more lenient)
// For book names, we'll rely on tag matching, so we only do fulltext for type, chapter, and version
if (searchTerms.length > 0) {
const beforeFulltext = publications.length
const sampleBeforeFilter = beforeFulltext > 0 ? publications[0] : null
// Separate book-related terms from other terms
// Book terms will be handled by tag matching, so we only require non-book terms in fulltext
const normalizedBook = filters.book ? filters.book.toLowerCase().replace(/\s+/g, '-') : null
const bookTerms: string[] = []
if (normalizedBook) {
bookTerms.push(normalizedBook)
if (filters.book) {
bookTerms.push(filters.book.toLowerCase())
}
}
publications = publications.filter(event => {
const contentLower = event.content.toLowerCase()
const allTags = event.tags.map(t => t.join(' ')).join(' ').toLowerCase()
const searchableText = `${contentLower} ${allTags}`
// For each search term, check if it matches
// For book terms, we'll skip fulltext matching (handled by tag matching)
// For other terms (type, chapter, version), require exact or partial match
const matches = searchTerms.every(term => {
const termLower = term.toLowerCase()
// Skip fulltext matching for book terms - they'll be handled by tag matching
if (bookTerms.some(bookTerm => termLower === bookTerm || termLower.includes(bookTerm) || bookTerm.includes(termLower))) {
return true // Always pass for book terms in fulltext search
}
// For other terms, check if they're in the searchable text
// Also try word-boundary matching for better results
if (searchableText.includes(termLower)) {
return true
}
// Try partial word matching (e.g., "psalm" matches "psalms")
const termWords = termLower.split(/[-\s]+/).filter(w => w.length > 2)
if (termWords.length > 0) {
const hasPartialMatch = termWords.some(word => {
// Check if the word or its plural/singular form appears
const wordPlural = word + 's'
const wordSingular = word.endsWith('s') ? word.slice(0, -1) : word
return searchableText.includes(word) ||
searchableText.includes(wordPlural) ||
searchableText.includes(wordSingular)
})
if (hasPartialMatch) {
return true
}
}
return false
})
return matches
})
// Log a sample of what didn't match if we filtered everything out
if (publications.length === 0 && sampleBeforeFilter) {
const contentLower = sampleBeforeFilter.content.toLowerCase()
const allTags = sampleBeforeFilter.tags.map(t => t.join(' ')).join(' ').toLowerCase()
const searchableText = `${contentLower} ${allTags}`
const missingTerms = searchTerms.filter(term => {
const termLower = term.toLowerCase()
if (bookTerms.some(bookTerm => termLower === bookTerm || termLower.includes(bookTerm) || bookTerm.includes(termLower))) {
return false // Book terms are handled by tag matching
}
return !searchableText.includes(termLower)
})
logger.info('fetchBookstrEvents: Fulltext search filtered all out', {
searchTerms,
missingTerms,
bookTerms,
sampleBook: sampleBeforeFilter.tags.find(t => t[0] === 'book')?.[1],
sampleChapter: sampleBeforeFilter.tags.find(t => t[0] === 'chapter')?.[1],
sampleSearchableText: searchableText.substring(0, 200)
})
}
logger.info('fetchBookstrEvents: After fulltext filter', {
beforeFulltext,
afterFulltext: publications.length,
searchTerms
})
}
// Step 3: Do lenient tag matching (only require matches if tags exist)
publications = publications.filter(event => {
return this.eventMatchesBookstrFiltersLenient(event, filtersForMatching)
})
logger.info('fetchBookstrEvents: Filtering results', {
beforeFilter: beforeFilterCount,
afterTagFilter: publications.length,
needsChapterLevel,
filtersForMatching
})
// 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)
logger.info('fetchBookstrEvents: Found publications after filtering', {
filters,
needsChapterLevel,
publicationCount: publications.length
})
if (publications.length === 0) {
logger.info('fetchBookstrEvents: No matching publications found', { filters })
return []
}
// Step 2: Find the best matching publication
// Score publications by how well they match (exact matches score higher)
const scoredPublications = publications.map(pub => {
let score = 0
const getTagValue = (name: string) => pub.tags.find(t => t[0] === name)?.[1]
if (filters.type && getTagValue('type')?.toLowerCase() === filters.type.toLowerCase()) {
score += 10
}
if (filters.book) {
const normalizedBook = filters.book.toLowerCase().replace(/\s+/g, '-')
const eventBook = getTagValue('book')?.toLowerCase()
if (eventBook === normalizedBook) {
score += 10
} else if (eventBook?.includes(normalizedBook) || normalizedBook.includes(eventBook || '')) {
score += 5
}
}
if (needsChapterLevel && filters.chapter !== undefined) {
const eventChapter = parseInt(getTagValue('chapter') || '0')
if (eventChapter === filters.chapter) {
score += 10
}
}
if (filters.version) {
const eventVersion = getTagValue('version')?.toLowerCase()
if (eventVersion === filters.version.toLowerCase()) {
score += 10
}
}
return { pub, score }
})
// Sort by score (highest first) and take the best match
scoredPublications.sort((a, b) => b.score - a.score)
const bestPublication = scoredPublications[0].pub
logger.info('fetchBookstrEvents: Best matching publication', {
filters,
publicationId: bestPublication.id.substring(0, 8),
score: scoredPublications[0].score,
aTagCount: bestPublication.tags.filter(t => t[0] === 'a').length,
level: needsChapterLevel ? 'chapter' : 'book'
})
// Step 3: Recursively fetch ALL content events from nested publications
// Publications can be nested (book → chapters → verses), so we need to traverse
// all the way down to the leaves (30041 content events)
const allContentEvents: NEvent[] = []
const visitedPublications = new Set<string>() // Prevent infinite loops
const fetchFromPublication = async (publication: NEvent): Promise<void> => {
const pubId = publication.id
if (visitedPublications.has(pubId)) {
return // Already processed this publication
}
visitedPublications.add(pubId)
const aTags = publication.tags
.filter(tag => tag[0] === 'a' && tag[1])
.map(tag => tag[1])
if (aTags.length === 0) {
return
}
logger.info('fetchBookstrEvents: Processing publication a-tags', {
publicationId: pubId.substring(0, 8),
aTagCount: aTags.length
})
// Process all a-tags in parallel
const promises = aTags.map(async (aTag) => {
// aTag format: "kind:pubkey:d"
const parts = aTag.split(':')
if (parts.length < 2) return null
const kind = parseInt(parts[0])
const pubkey = parts[1]
const d = parts[2] || ''
const filter: any = {
authors: [pubkey],
kinds: [kind],
limit: 1
}
if (d) {
filter['#d'] = [d]
}
const events = await this.fetchEvents(relayUrls, filter, {
eoseTimeout: 5000,
globalTimeout: 10000
})
const event = events[0] || null
if (!event) return null
// If it's a nested publication (30040), recursively fetch from it
if (event.kind === ExtendedKind.PUBLICATION) {
await fetchFromPublication(event)
return null // Don't add publications to content events
}
// If it's a content event (30041), add it to our collection
if (event.kind === ExtendedKind.PUBLICATION_CONTENT) {
return event
}
return null
})
const results = await Promise.all(promises)
results.forEach(event => {
if (event) {
allContentEvents.push(event)
}
})
}
logger.info('fetchBookstrEvents: Starting recursive fetch from publication', {
publicationId: bestPublication.id.substring(0, 8),
note: 'Will traverse nested publications to find all content events'
})
await fetchFromPublication(bestPublication)
logger.info('fetchBookstrEvents: Completed recursive fetch', {
filters,
totalFetched: allContentEvents.length,
publicationsVisited: visitedPublications.size
})
// Step 4: Filter from cached results to show only what was requested
// We have all the data, now filter to what they want to display
let finalEvents = allContentEvents
// Filter by book (if we fetched book-level, this ensures we only show the right book)
if (filters.book) {
const normalizedBook = filters.book.toLowerCase().replace(/\s+/g, '-')
finalEvents = finalEvents.filter(event => {
const metadata = this.extractBookMetadataFromEvent(event)
return metadata.book?.toLowerCase() === normalizedBook
})
}
// Filter by chapter (if we fetched book-level but they want a specific chapter)
if (filters.chapter !== undefined && !needsChapterLevel) {
// We fetched book-level, but they want a specific chapter
finalEvents = finalEvents.filter(event => {
const metadata = this.extractBookMetadataFromEvent(event)
return parseInt(metadata.chapter || '0') === filters.chapter
})
}
// Filter by verse if specified
if (filters.verse) {
finalEvents = finalEvents.filter(event => {
const metadata = this.extractBookMetadataFromEvent(event)
const eventVerse = metadata.verse
if (!eventVerse) return false
const verseParts = filters.verse!.split(/[,\s-]+/).map(v => v.trim()).filter(v => v)
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
}
})
})
}
// Filter by version if specified
if (filters.version) {
finalEvents = finalEvents.filter(event => {
const metadata = this.extractBookMetadataFromEvent(event)
return metadata.version?.toLowerCase() === filters.version!.toLowerCase()
})
}
logger.info('fetchBookstrEvents: Final filtered results', {
filters,
totalFetched: allContentEvents.length,
finalCount: finalEvents.length,
note: 'All events cached for expansion support'
})
return finalEvents
} catch (error) {
logger.warn('Error querying bookstr events from relays', { error, filters, relayUrls })
logger.warn('Error querying bookstr events', { error, filters })
return []
}
// 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())
}
/**
* Extract book metadata from event tags (helper method)
*/
private extractBookMetadataFromEvent(event: NEvent): {
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
}
/**
* Check if an event matches bookstr filters
* Lenient version of eventMatchesBookstrFilters
* Only requires exact matches if the tag exists in the event.
* If a filter is provided but the tag doesn't exist, it still passes
* (since fulltext search already filtered it).
*/
private eventMatchesBookstrFilters(event: NEvent, filters: {
private eventMatchesBookstrFiltersLenient(event: NEvent, filters: {
type?: string
book?: string
chapter?: number
verse?: string
version?: string
}): boolean {
if (event.kind !== ExtendedKind.PUBLICATION_CONTENT) {
// Accept both publication and publication content events
if (event.kind !== ExtendedKind.PUBLICATION && event.kind !== ExtendedKind.PUBLICATION_CONTENT) {
return false
}
@ -2172,43 +2598,39 @@ class ClientService extends EventTarget { @@ -2172,43 +2598,39 @@ class ClientService extends EventTarget {
return tag?.[1]
}
// Type: if filter provided, check if tag exists and matches
if (filters.type) {
const eventType = getTagValue('type')
if (!eventType || eventType.toLowerCase() !== filters.type.toLowerCase()) {
// If tag exists, it must match. If it doesn't exist, we already did fulltext search
if (eventType && eventType.toLowerCase() !== filters.type.toLowerCase()) {
return false
}
}
// Book: if filter provided, check if tag exists and matches (exact match only)
if (filters.book) {
const eventBook = getTagValue('book')
const normalizedBook = filters.book.toLowerCase().replace(/\s+/g, '-')
if (!eventBook || eventBook.toLowerCase() !== normalizedBook) {
// If tag exists, it must match exactly. If it doesn't exist, we already did fulltext search
if (eventBook && eventBook.toLowerCase() !== normalizedBook) {
return false
}
}
// Chapter: if filter provided, check if tag exists and matches
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) {
// If tag exists, it must match. If it doesn't exist, we already did fulltext search
if (eventChapter && parseInt(eventChapter) !== filters.chapter) {
return false
}
}
// Version: if filter provided, check if tag exists and matches
if (filters.version) {
const eventVersion = getTagValue('version')
if (!eventVersion || eventVersion.toLowerCase() !== filters.version.toLowerCase()) {
// If tag exists, it must match. If it doesn't exist, we already did fulltext search
if (eventVersion && eventVersion.toLowerCase() !== filters.version.toLowerCase()) {
return false
}
}
@ -2216,49 +2638,6 @@ class ClientService extends EventTarget { @@ -2216,49 +2638,6 @@ class ClientService extends EventTarget {
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()

Loading…
Cancel
Save