diff --git a/src/components/Bookstr/BookstrContent.tsx b/src/components/Bookstr/BookstrContent.tsx index 8b1eeef..d488b67 100644 --- a/src/components/Bookstr/BookstrContent.tsx +++ b/src/components/Bookstr/BookstrContent.tsx @@ -3,7 +3,7 @@ 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, ExternalLink } from 'lucide-react' +import { Loader2, AlertCircle, ExternalLink } from 'lucide-react' import { Button } from '@/components/ui/button' import { Select, @@ -61,13 +61,9 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { const [sections, setSections] = useState([]) const [isLoading, setIsLoading] = useState(false) // Start as false, only set to true when actually fetching const [error, setError] = useState(null) - const [expandedSections, setExpandedSections] = useState>(new Set()) const [selectedVersions, setSelectedVersions] = useState>(new Map()) - const [collapsedCards, setCollapsedCards] = useState>(new Set()) - const [cardHeights, setCardHeights] = useState>(new Map()) // Track which sections are still loading (by reference key) const [loadingSections, setLoadingSections] = useState>(new Set()) - const cardRefs = useRef>(new Map()) // Parse the wikilink - use a ref to store the last parsed result for comparison const parsedRef = useRef & { bookType: string } | null>(null) @@ -701,68 +697,6 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { } }, [wikilink]) // Depend on wikilink directly - it's a stable string, parsed is derived from it - // Measure card heights - measure BEFORE applying collapse - useEffect(() => { - const timeoutId = setTimeout(() => { - cardRefs.current.forEach((element, index) => { - if (element) { - // IMPORTANT: Temporarily remove ALL constraints to get true height - // This must happen BEFORE any collapse is applied - const originalMaxHeight = element.style.maxHeight - const originalOverflow = element.style.overflow - const originalHeight = element.style.height - - // Remove all constraints - element.style.maxHeight = 'none' - element.style.overflow = 'visible' - element.style.height = 'auto' - - // Force a reflow to ensure we get the true height - void element.offsetHeight - - const height = element.scrollHeight - - // Restore original styles - element.style.maxHeight = originalMaxHeight - element.style.overflow = originalOverflow - element.style.height = originalHeight - - // Store the TRUE height (before collapse) - setCardHeights(prev => { - const currentHeight = prev.get(index) - if (currentHeight !== height && height > 0) { - const newMap = new Map(prev) - newMap.set(index, height) - - logger.debug('BookstrContent: Measured card height', { - sectionIndex: index, - height, - needsCollapse: height > 500, - wasCollapsed: collapsedCards.has(index) - }) - - // Only auto-collapse if height > 500px and not already manually toggled - if (height > 500) { - setCollapsedCards(prevCollapsed => { - // Only auto-collapse if user hasn't manually expanded it - if (!prevCollapsed.has(index)) { - logger.debug('BookstrContent: Auto-collapsing card', { sectionIndex: index, height }) - return new Set(prevCollapsed).add(index) - } - return prevCollapsed - }) - } - - return newMap - } - return prev - }) - } - }) - }, 500) // Wait longer for content to fully render - - return () => clearTimeout(timeoutId) - }, [sections, collapsedCards]) // Show loading spinner only if we're actively loading AND have no sections // Once we have sections (even empty placeholders), show them instead @@ -815,52 +749,19 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { }) : section.events - const isExpanded = expandedSections.has(sectionIndex) - const hasVerses = section.originalVerses !== undefined && section.originalVerses.length > 0 const isLast = sectionIndex === sections.length - 1 - - const cardHeight = cardHeights.get(sectionIndex) || 0 - const isCardCollapsed = collapsedCards.has(sectionIndex) - const needsCollapse = cardHeight > 500 - - // Only show button if card is actually tall (needs collapse) or is currently collapsed - const shouldShowButton = filteredEvents.length > 0 && (needsCollapse || isCardCollapsed) // Check if this section is still loading const refKey = `${section.reference.book}-${section.reference.chapter}-${section.reference.verse}` const isSectionLoading = loadingSections.has(refKey) - // Debug logging - if (filteredEvents.length > 0) { - logger.debug('BookstrContent: Card collapse check', { - sectionIndex, - eventCount: filteredEvents.length, - cardHeight, - isCardCollapsed, - needsCollapse, - shouldShowButton - }) - } - return (
{ - if (el) { - cardRefs.current.set(sectionIndex, el) - } else { - cardRefs.current.delete(sectionIndex) - } - }} className={cn( 'p-3', - !isLast && 'border-b', - needsCollapse && isCardCollapsed && 'overflow-hidden' + !isLast && 'border-b' )} - style={needsCollapse && isCardCollapsed ? { - maxHeight: '500px', - transition: 'max-height 0.3s ease-out' - } : undefined} > {/* Header */}
@@ -903,92 +804,14 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
- {/* Verses */} + {/* Verses - render all verses together, including ranges */} {filteredEvents.length > 0 && ( )}
- - {/* Show more/less button for tall cards - OUTSIDE collapsed div so it's always visible */} - {shouldShowButton ? ( -
- -
- ) : null} - - {/* Expand/Collapse buttons - only show if events were found */} - {hasVerses && filteredEvents.length > 0 && ( -
- -
- )} - - {/* Expanded content */} - {isExpanded && ( -
- {/* Fetch and display full chapter/book */} - -
- )}
) })} @@ -997,69 +820,13 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { ) } -interface ExpandedContentProps { - section: BookSection - selectedVersion: string - originalChapter?: number +interface VerseContentProps { + events: Event[] originalVerses?: string } -function ExpandedContent({ section, selectedVersion, originalChapter, originalVerses }: ExpandedContentProps) { - const [expandedEvents, setExpandedEvents] = useState([]) - 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
Loading...
- } +function VerseContent({ events, originalVerses }: VerseContentProps) { + const [parsedContents, setParsedContents] = useState>(new Map()) // Parse original verses to determine which ones should have a border const originalVerseNumbers = new Set() @@ -1067,6 +834,7 @@ function ExpandedContent({ section, selectedVersion, originalChapter, originalVe const verseSpecs = originalVerses.split(',').map(v => v.trim()).filter(v => v) for (const spec of verseSpecs) { if (spec.includes('-')) { + // Expand range like "16-18" into 16, 17, 18 const [startStr, endStr] = spec.split('-').map(v => v.trim()) const start = parseInt(startStr) const end = parseInt(endStr) @@ -1084,22 +852,6 @@ function ExpandedContent({ section, selectedVersion, originalChapter, originalVe } } - return ( - - ) -} - -interface VerseContentProps { - events: Event[] - originalVerseNumbers?: Set -} - -function VerseContent({ events, originalVerseNumbers }: VerseContentProps) { - const [parsedContents, setParsedContents] = useState>(new Map()) - useEffect(() => { const parseAll = async () => { const newParsed = new Map() @@ -1132,7 +884,7 @@ function VerseContent({ events, originalVerseNumbers }: VerseContentProps) { const metadata = extractBookMetadata(event) const verseNum = metadata.verse const verseNumInt = verseNum ? parseInt(verseNum) : null - const isOriginalVerse = originalVerseNumbers && verseNumInt !== null && originalVerseNumbers.has(verseNumInt) + const isOriginalVerse = originalVerseNumbers.size > 0 && verseNumInt !== null && originalVerseNumbers.has(verseNumInt) const content = parsedContents.get(event.id) || event.content return ( diff --git a/src/lib/bookstr-parser.ts b/src/lib/bookstr-parser.ts index 8668661..28fe4a2 100644 --- a/src/lib/bookstr-parser.ts +++ b/src/lib/bookstr-parser.ts @@ -80,50 +80,60 @@ export function parseBookWikilink(wikilink: string): { references: BookReference versionPart = pipeParts[pipeParts.length - 1].trim() } - // Parse title, chapter, section from titlePart - const chapterSectionMatch = titlePart.match(/^(.+?)\s+(\d+|[a-zA-Z0-9_-]+)(?::(.+))?$/) + // Parse versions first (needed for references) + const versions = versionPart ? versionPart.split(/\s+/).map(v => normalizeNip54(v).toUpperCase()).filter(v => v) : undefined - let title = '' - let chapter: number | undefined - let verse: string | undefined + // Parse multiple references from titlePart (e.g., "romans 1:16-17, psalms 23:1") + // Split by comma to handle multiple book references + const referenceStrings = titlePart.split(',').map(s => s.trim()).filter(s => s) + const references: BookReference[] = [] - if (chapterSectionMatch) { - title = normalizeNip54(chapterSectionMatch[1].trim()) - const chapterStr = chapterSectionMatch[2] - chapter = /^\d+$/.test(chapterStr) ? parseInt(chapterStr, 10) : undefined - if (chapterSectionMatch[3]) { - verse = chapterSectionMatch[3].trim() + for (const refString of referenceStrings) { + // Parse each reference: "book chapter:verse" or "book chapter" or "book" + const chapterSectionMatch = refString.match(/^(.+?)\s+(\d+|[a-zA-Z0-9_-]+)(?::(.+))?$/) + + let title = '' + let chapter: number | undefined + let verse: string | undefined + + if (chapterSectionMatch) { + title = normalizeNip54(chapterSectionMatch[1].trim()) + const chapterStr = chapterSectionMatch[2] + chapter = /^\d+$/.test(chapterStr) ? parseInt(chapterStr, 10) : undefined + if (chapterSectionMatch[3]) { + verse = chapterSectionMatch[3].trim() + } + } else { + title = normalizeNip54(refString) } - } else { - title = normalizeNip54(titlePart) + + // Create reference + const reference: BookReference = { + book: title + } + if (chapter !== undefined) { + reference.chapter = chapter + } + if (verse) { + reference.verse = verse + } + if (versions && versions.length > 0) { + reference.version = versions[0] // Use first version for backward compatibility + } + + references.push(reference) } - // Parse versions - const versions = versionPart ? versionPart.split(/\s+/).map(v => normalizeNip54(v).toUpperCase()).filter(v => v) : undefined - // Use collection as bookType (e.g., "bible", "quran", "torah") // If no collection, default to "bible" const inferredBookType = collection || 'bible' - // Create reference - const reference: BookReference = { - book: title - } - if (chapter !== undefined) { - reference.chapter = chapter - } - if (verse) { - reference.verse = verse - } - if (versions && versions.length > 0) { - reference.version = versions[0] // Use first version for backward compatibility - } - - return { references: [reference], versions, bookType: inferredBookType } + return { references, versions, bookType: inferredBookType } } /** * Extract book metadata from event tags + * Tags: C (collection), T (title), c (chapter), s (section), v (version) */ export function extractBookMetadata(event: { tags: string[][] }): { type?: string @@ -136,19 +146,23 @@ export function extractBookMetadata(event: { tags: string[][] }): { for (const [tag, value] of event.tags) { switch (tag) { - case 'type': + case 'C': // Collection metadata.type = value break - case 'book': + case 'T': // Title (book name) metadata.book = value break - case 'chapter': + case 'c': // Chapter metadata.chapter = value break - case 'verse': - metadata.verse = value + case 's': // Section + // Section might be used for verse or other metadata + // If we don't have verse yet, use section as verse + if (!metadata.verse) { + metadata.verse = value + } break - case 'version': + case 'v': // Version metadata.version = value break } diff --git a/src/services/client.service.ts b/src/services/client.service.ts index ba61a56..27266f0 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -2474,13 +2474,25 @@ class ClientService extends EventTarget { let events: NEvent[] = [] try { - // Query ONLY 30040s (publications/indexes) by pubkey and kind + // Query ONLY 30040s (publications/indexes) by pubkey and kind with precise tag filters const publicationFilter: Filter = { authors: [publicationPubkey], kinds: [ExtendedKind.PUBLICATION], limit: 500 } + // Add precise tag filters for collection, title, and chapter + if (filters.type) { + publicationFilter['#C'] = [filters.type.toLowerCase()] + } + if (filters.book) { + const normalizedBook = filters.book.toLowerCase().replace(/\s+/g, '-') + publicationFilter['#T'] = [normalizedBook] + } + if (filters.chapter !== undefined) { + publicationFilter['#c'] = [filters.chapter.toString()] + } + const allPublications = await this.fetchEvents(prioritizedFallbackRelaysWithCitadel, publicationFilter, { eoseTimeout: 5000, globalTimeout: 8000 @@ -2536,6 +2548,29 @@ class ClientService extends EventTarget { if (d) { aTagFilter['#d'] = [d] } + // Add all precise tag filters: C (collection), T (title), c (chapter), s (section/verse), v (version) + if (filters.type) { + aTagFilter['#C'] = [filters.type.toLowerCase()] + } + if (filters.book) { + const normalizedBook = filters.book.toLowerCase().replace(/\s+/g, '-') + aTagFilter['#T'] = [normalizedBook] + } + if (filters.chapter !== undefined) { + aTagFilter['#c'] = [filters.chapter.toString()] + } + if (filters.verse) { + // Section tag (s) is used for verse + // For verse ranges, we'll need to expand and query each verse + // For now, just add the first verse if it's a single verse + const verseParts = filters.verse.split(/[,\s-]+/).map(v => v.trim()).filter(v => v) + if (verseParts.length === 1 && !verseParts[0].includes('-')) { + aTagFilter['#s'] = [verseParts[0]] + } + } + if (filters.version) { + aTagFilter['#v'] = [filters.version.toLowerCase()] + } try { const aTagEvents = await this.fetchEvents(prioritizedFallbackRelaysWithCitadel, aTagFilter, { @@ -2612,110 +2647,64 @@ class ClientService extends EventTarget { try { const bookstrPublisherPubkey = '3e1ad0f3a5d3c12245db7788546c43ade3d97c6e046c594f6017cd6cd4164690' - // Query ONLY 30040s (publications/indexes) with just type and kind filters + // Query BOTH 30040s (publications/indexes) AND 30041s (content) together + // Only use #T (title) and #c (chapter) in relay filter - filter #C, #s, #v client-side + // This matches wikistr's approach and avoids relay compatibility issues const publicationFilter: Filter = { - kinds: [ExtendedKind.PUBLICATION], + kinds: [ExtendedKind.PUBLICATION, ExtendedKind.PUBLICATION_CONTENT], authors: [bookstrPublisherPubkey], limit: 500 } - // Only add #type filter if we have a type - if (filters.type) { - publicationFilter['#type'] = [filters.type.toLowerCase()] + // Only add #T (title) and #c (chapter) filters - filter rest client-side + if (filters.book) { + // Normalize book name: lowercase, replace spaces with hyphens (NIP-54 style) + // The parser already normalized it, but ensure consistency + const normalizedBook = filters.book.toLowerCase().replace(/\s+/g, '-') + publicationFilter['#T'] = [normalizedBook] + } + if (filters.chapter !== undefined) { + publicationFilter['#c'] = [filters.chapter.toString()] } + // Don't include #C, #s, or #v in relay filter - filter client-side instead const publisherPublications = await this.fetchEvents(prioritizedFallbackRelays, publicationFilter, { eoseTimeout: 5000, globalTimeout: 8000 }) - logger.info('fetchBookstrEventsFromRelays: Fetched 30040 publications', { + logger.info('fetchBookstrEventsFromRelays: Fetched events', { count: publisherPublications.length, filters: JSON.stringify(filters) }) - // Filter 30040s client-side to find matching book/chapter - // Note: Don't filter by verse for 30040s - verses are in 30041s - const matchingPublications = publisherPublications.filter(pub => { - return this.eventMatchesBookstrFilters(pub, filters) + // Filter ALL events (both 30040 and 30041) client-side + // This matches wikistr's approach - filter #C, #s, #v client-side + const matchingEvents = publisherPublications.filter(event => { + return this.eventMatchesBookstrFilters(event, filters) }) - logger.info('fetchBookstrEventsFromRelays: Filtered 30040 publications', { + logger.info('fetchBookstrEventsFromRelays: Filtered events', { total: publisherPublications.length, - matching: matchingPublications.length, + matching: matchingEvents.length, filters: JSON.stringify(filters) }) - // For each matching 30040, fetch its a-tagged 30041 events (content) - for (const publication of matchingPublications) { - const aTags = publication.tags - .filter(tag => tag[0] === 'a' && tag[1]) - .map(tag => tag[1]) - - logger.info('fetchBookstrEventsFromRelays: Fetching 30041s from matching publication', { - publicationId: publication.id.substring(0, 8), - aTagCount: aTags.length, - filters: JSON.stringify(filters) - }) - - // Fetch all a-tagged 30041 events in parallel - const aTagPromises = aTags.map(async (aTag) => { - const parts = aTag.split(':') - if (parts.length < 2) return null - - const kind = parseInt(parts[0]) - const pubkey = parts[1] - const d = parts[2] || '' - - // Only fetch 30041 events (content events) - if (kind !== ExtendedKind.PUBLICATION_CONTENT) { - return null - } - - const aTagFilter: Filter = { - authors: [pubkey], - kinds: [ExtendedKind.PUBLICATION_CONTENT], - limit: 1 - } - if (d) { - aTagFilter['#d'] = [d] - } - - try { - const aTagEvents = await this.fetchEvents(prioritizedFallbackRelays, aTagFilter, { - eoseTimeout: 3000, - globalTimeout: 5000 - }) - - // Filter 30041s client-side by book, type, version, chapter, verse - return aTagEvents.filter(event => { - return this.eventMatchesBookstrFilters(event, filters) - }) - } catch (err) { - logger.debug('fetchBookstrEventsFromRelays: Error fetching a-tag event', { - aTag, - error: err - }) - return [] - } - }) - - const aTagResults = await Promise.all(aTagPromises) - const aTagEvents = aTagResults.flat().filter((e): e is NEvent => e !== null) - - logger.info('fetchBookstrEventsFromRelays: Fetched 30041s from publication', { - publicationId: publication.id.substring(0, 8), - fetched: aTagEvents.length, - totalSoFar: events.length + aTagEvents.length - }) - - events.push(...aTagEvents) - } + // Separate 30040s (publications) and 30041s (content) + // We queried for both kinds, so we get content events directly + const contentEvents = matchingEvents.filter(e => e.kind === ExtendedKind.PUBLICATION_CONTENT) + + events.push(...contentEvents) + + // Note: We could also process 30040 publications to fetch their a-tagged 30041s, + // but since we already queried for 30041s directly, we should have them. + // If we need more, we can fetch from 30040 a-tags, but for now this is simpler. if (events.length > 0) { logger.info('fetchBookstrEventsFromRelays: Successfully fetched content events', { - publicationCount: matchingPublications.length, - eventCount: events.length, + totalQueried: publisherPublications.length, + matchingAfterFilter: matchingEvents.length, + contentEvents: events.length, filters: JSON.stringify(filters) }) return events @@ -2865,8 +2854,9 @@ class ClientService extends EventTarget { if (filters.book) { const normalizedBook = filters.book.toLowerCase().replace(/\s+/g, '-') // Get ALL book tags from the event (events can have multiple book tags) + // Check 'T' (title/book) tags const eventBookTags = event.tags - .filter(tag => tag[0] === 'book' && tag[1]) + .filter(tag => tag[0] === 'T' && tag[1]) .map(tag => tag[1].toLowerCase()) // Check if any of the book tags match @@ -3393,6 +3383,7 @@ class ClientService extends EventTarget { /** * Extract book metadata from event tags (helper method) + * Tags: C (collection), T (title), c (chapter), s (section), v (version) */ private extractBookMetadataFromEvent(event: NEvent): { type?: string @@ -3404,19 +3395,23 @@ class ClientService extends EventTarget { const metadata: any = {} for (const [tag, value] of event.tags) { switch (tag) { - case 'type': + case 'C': // Collection metadata.type = value break - case 'book': + case 'T': // Title (book name) metadata.book = value break - case 'chapter': + case 'c': // Chapter metadata.chapter = value break - case 'verse': - metadata.verse = value + case 's': // Section + // Section might be used for verse or other metadata + // If we don't have verse yet, use section as verse + if (!metadata.verse) { + metadata.verse = value + } break - case 'version': + case 'v': // Version metadata.version = value break }