diff --git a/package.json b/package.json index 4235d71..ced9278 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jumble-imwald", - "version": "14.3", + "version": "14.4", "description": "A user-friendly Nostr client focused on relay feed browsing and relay discovery, forked from Jumble", "private": true, "type": "module", diff --git a/src/components/Bookstr/BookstrContent.tsx b/src/components/Bookstr/BookstrContent.tsx index 4dfec79..d8d01ec 100644 --- a/src/components/Bookstr/BookstrContent.tsx +++ b/src/components/Bookstr/BookstrContent.tsx @@ -69,12 +69,19 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { // Fetch events for each reference useEffect(() => { - if (!parsed || !parsed.references.length) { + // Early return if parsed is not ready + if (!parsed) { + return + } + + if (!parsed.references.length) { setIsLoading(false) setError('Invalid bookstr reference') return } + let isCancelled = false + const fetchEvents = async () => { setIsLoading(true) setError(null) @@ -165,12 +172,25 @@ 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 + // Filter events based on what was requested: + // - Book only: Show all events (all chapters) + // - Chapter only: Show all events for that chapter (all verses) + // - Verses: Show only the requested verses (but we have all verses cached for expansion) let filteredEvents = allEvents + + // Filter by chapter if specified + if (ref.chapter !== undefined) { + filteredEvents = filteredEvents.filter(event => { + const metadata = extractBookMetadata(event) + const eventChapter = parseInt(metadata.chapter || '0') + return eventChapter === ref.chapter + }) + } + + // Filter by verse if specified (for verse-level queries) if (ref.verse) { const verseParts = ref.verse.split(/[,\s-]+/).map(v => v.trim()).filter(v => v) - filteredEvents = allEvents.filter(event => { + filteredEvents = filteredEvents.filter(event => { const metadata = extractBookMetadata(event) const eventVerse = metadata.verse if (!eventVerse) return false @@ -225,6 +245,8 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { versions: s.versions })) }) + + if (isCancelled) return setSections(newSections) @@ -237,15 +259,23 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { }) setSelectedVersions(initialVersions) } catch (err) { + if (isCancelled) return logger.error('Error fetching bookstr events', { error: err, wikilink }) setError(err instanceof Error ? err.message : 'Failed to fetch book content') } finally { - setIsLoading(false) + if (!isCancelled) { + setIsLoading(false) + } } } fetchEvents() - }, [parsed, wikilink]) + + return () => { + isCancelled = true + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [wikilink]) // Only depend on wikilink - parsed is derived from it via useMemo if (isLoading) { return ( @@ -275,22 +305,30 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { } return ( -
- {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 ( -
+
+
+ {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 + const isLast = sectionIndex === sections.length - 1 + + return ( +
{/* Header */}

@@ -389,9 +427,10 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { />

)} -
- ) - })} +
+ ) + })} +
) } diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index 6566181..e128de6 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -2051,14 +2051,14 @@ function parseMarkdownContent( ) } 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, '') - - parts.push( - - ) + 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, '') + + parts.push( + + ) } } diff --git a/src/components/Note/PublicationIndex/PublicationIndex.tsx b/src/components/Note/PublicationIndex/PublicationIndex.tsx index 74b277b..7e9e72e 100644 --- a/src/components/Note/PublicationIndex/PublicationIndex.tsx +++ b/src/components/Note/PublicationIndex/PublicationIndex.tsx @@ -972,9 +972,9 @@ export default function PublicationIndex({ const promise = (async () => { try { const result = await fetchSingleReference(ref, currentVisited, isRetry) - if (result) { - if (result.event) { - fetchedRefs.push(result) + if (result) { + if (result.event) { + fetchedRefs.push(result) // Extract and add nested references const nestedRefs = extractNestedReferences(result.event, allRefs, currentVisited) for (const nestedRef of nestedRefs) { @@ -1012,11 +1012,11 @@ export default function PublicationIndex({ } else { // Add to queue for fetching pendingRefs.push(nestedRef) - } } } - } else { - failedRefs.push(result) + } + } else { + failedRefs.push(result) } } } catch (error) { @@ -1056,7 +1056,7 @@ export default function PublicationIndex({ const cached = cachedEvents.get(key) if (cached) { allFetchedRefs.push({ ...ref, event: cached }) - } else { + } else { const fetched = fetchedRefs.find(r => (r.coordinate || r.eventId) === key) if (fetched) { allFetchedRefs.push(fetched) @@ -1081,7 +1081,7 @@ export default function PublicationIndex({ return { fetched: allFetchedRefs, failed: allFetchedRefs.filter(ref => !ref.event) - } + } }, [fetchSingleReference, extractNestedReferences]) // Fetch referenced events @@ -1127,7 +1127,7 @@ export default function PublicationIndex({ (fetchedRefs) => { if (isMounted) { // Update state progressively as events are fetched - setReferences(fetchedRefs) + setReferences(fetchedRefs) } } ) diff --git a/src/constants.ts b/src/constants.ts index ed226e4..38c8ede 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -82,6 +82,11 @@ export const BIG_RELAY_URLS = [ 'wss://thecitadel.nostr1.com', ] +// Relay with bookstr composite index support +export const BOOKSTR_RELAY_URLS = [ + 'wss://orly-relay.imwald.eu' +] + // Optimized relay list for read operations (includes aggregator) export const FAST_READ_RELAY_URLS = [ 'wss://theforest.nostr1.com', diff --git a/src/lib/bookstr-parser.ts b/src/lib/bookstr-parser.ts index 7b51c86..83cff16 100644 --- a/src/lib/bookstr-parser.ts +++ b/src/lib/bookstr-parser.ts @@ -32,8 +32,70 @@ function normalizeBookReferenceWhitespace(ref: string): string { 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()) + // Split by comma or semicolon to handle multiple references + // Use a regex to split on commas/semicolons, but be careful with verse ranges like "1-3" + // We'll split on commas/semicolons that are followed by a space and a capital letter (new book name) + // or split on commas/semicolons that are not part of a verse range + const parts: string[] = [] + let currentPart = '' + let inVerseRange = false + + for (let i = 0; i < notation.length; i++) { + const char = notation[i] + const nextChar = notation[i + 1] + + if (char === '-' && /^\d/.test(currentPart.slice(-1))) { + // This is part of a verse range (e.g., "1-3") + inVerseRange = true + currentPart += char + } else if (char === ',' || char === ';') { + // Check if this comma/semicolon is separating references + // If the next non-whitespace character is a capital letter, it's likely a new book + const rest = notation.substring(i + 1).trim() + if (rest.length > 0 && /^[A-Z]/.test(rest)) { + // This is separating references - save current part and start new one + if (currentPart.trim()) { + parts.push(currentPart.trim()) + } + currentPart = '' + inVerseRange = false + } else { + // This is part of the current reference (e.g., verse list "1,3,5") + currentPart += char + inVerseRange = false + } + } else { + currentPart += char + if (char === ' ' && inVerseRange) { + inVerseRange = false + } + } + } + + // Add the last part + if (currentPart.trim()) { + parts.push(currentPart.trim()) + } + + // If no splitting occurred, try simple split as fallback + if (parts.length === 0) { + parts.push(notation.trim()) + } else if (parts.length === 1 && (notation.includes(',') || notation.includes(';'))) { + // Fallback: if we didn't split but there are commas/semicolons, try simple split + // This handles cases like "Genesis 1:1,2,3" (verse list, not multiple references) + const simpleParts = notation.split(/[,;]/).map(p => p.trim()) + if (simpleParts.length > 1) { + // Check if these look like separate references (each has a book name) + const looksLikeMultipleRefs = simpleParts.every(part => { + // Check if part starts with a capital letter (likely a book name) + return /^[A-Z]/.test(part.trim()) + }) + if (looksLikeMultipleRefs) { + parts.length = 0 + parts.push(...simpleParts) + } + } + } for (const part of parts) { const normalizedPart = normalizeBookReferenceWhitespace(part) diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 08fde26..fac0148 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -1,4 +1,4 @@ -import { BIG_RELAY_URLS, ExtendedKind, FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, PROFILE_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' +import { BIG_RELAY_URLS, BOOKSTR_RELAY_URLS, ExtendedKind, FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, PROFILE_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' import { compareEvents, getReplaceableCoordinate, @@ -30,7 +30,7 @@ import { VerifiedEvent } from 'nostr-tools' import { AbstractRelay } from 'nostr-tools/abstract-relay' -import indexedDb from './indexed-db.service' +import indexedDb, { StoreNames } from './indexed-db.service' type TTimelineRef = [string, number] @@ -2101,14 +2101,10 @@ class ClientService extends EventTarget { /** * Fetch bookstr events by tag filters * 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 + * 1. Check cache first + * 2. Use tag filters with composite bookstr index on orly relay (most efficient) + * 3. Fall back to other relays if needed + * 4. Save fetched events to cache */ async fetchBookstrEvents(filters: { type?: string @@ -2116,6 +2112,589 @@ class ClientService extends EventTarget { chapter?: number verse?: string version?: string + }): Promise { + logger.info('fetchBookstrEvents: Called', { filters }) + try { + // Step 1: Check cache first + const cachedEvents = await this.getCachedBookstrEvents(filters) + if (cachedEvents.length > 0) { + logger.info('fetchBookstrEvents: Found cached events', { + count: cachedEvents.length, + filters + }) + // Still fetch in background to get updates, but return cached immediately + // Skip orly relay in background fetch since it's consistently failing + this.fetchBookstrEventsFromRelays(filters, { skipOrly: true }).catch(err => { + logger.warn('fetchBookstrEvents: Background fetch failed', { error: err }) + }) + return cachedEvents + } + + // Step 2: Fetch from relays + const events = await this.fetchBookstrEventsFromRelays(filters) + + // Step 3: Save events to cache + if (events.length > 0) { + try { + // Group events by publication (master event) + const eventsByPubkey = new Map() + for (const event of events) { + if (!eventsByPubkey.has(event.pubkey)) { + eventsByPubkey.set(event.pubkey, []) + } + eventsByPubkey.get(event.pubkey)!.push(event) + } + + // Save each group to cache + for (const [pubkey, pubEvents] of eventsByPubkey) { + // Find or create master publication event + // For now, we'll save content events individually + // TODO: Find the actual master publication (kind 30040) and link them + for (const event of pubEvents) { + await indexedDb.putNonReplaceableEventWithMaster(event, `${ExtendedKind.PUBLICATION}:${pubkey}:`) + } + } + + logger.info('fetchBookstrEvents: Saved events to cache', { + count: events.length, + filters + }) + } catch (cacheError) { + logger.warn('fetchBookstrEvents: Error saving to cache', { + error: cacheError, + filters + }) + } + } + + logger.info('fetchBookstrEvents: Final results', { + filters, + count: events.length + }) + + return events + } catch (error) { + logger.warn('Error querying bookstr events', { error, filters }) + return [] + } + } + + /** + * Get cached bookstr events from IndexedDB + */ + private async getCachedBookstrEvents(filters: { + type?: string + book?: string + chapter?: number + verse?: string + version?: string + }): Promise { + try { + const allCached = await indexedDb.getStoreItems(StoreNames.PUBLICATION_EVENTS) + const cachedEvents: NEvent[] = [] + + logger.debug('getCachedBookstrEvents: Checking cache', { + totalCached: allCached.length, + filters + }) + + for (const item of allCached) { + if (!item?.value || item.value.kind !== ExtendedKind.PUBLICATION_CONTENT) { + continue + } + + const event = item.value as NEvent + if (this.eventMatchesBookstrFilters(event, filters)) { + cachedEvents.push(event) + } + } + + logger.debug('getCachedBookstrEvents: Found matching events', { + matched: cachedEvents.length, + filters + }) + + return cachedEvents + } catch (error) { + logger.warn('getCachedBookstrEvents: Error reading cache', { error }) + return [] + } + } + + /** + * Fetch bookstr events from relays + */ + private async fetchBookstrEventsFromRelays(filters: { + type?: string + book?: string + chapter?: number + verse?: string + version?: string + }, options: { skipOrly?: boolean } = {}): Promise { + // Strategy: + // 1. First try to find the 30040 publication that matches (it has the bookstr metadata) + // 2. Then fetch all a-tagged 30041 events from that publication + // 3. Also query for 30041 events directly (in case they're not nested) + + // Build tag filter for publication (30040) queries + const publicationTagFilter: Filter = { + kinds: [ExtendedKind.PUBLICATION] + } + + // Build tag filter for bookstr queries (30041) + const bookstrTagFilter: Filter = { + kinds: [ExtendedKind.PUBLICATION_CONTENT] + } + + // Add bookstr tags to both filters + // For publications (30040), we include chapter filter to find the right publication + // For content (30041), we don't filter by chapter/verse here - we fetch all from the publication + const addBookstrTags = (filter: Filter, includeChapter: boolean = true) => { + if (filters.type) { + filter['#type'] = [filters.type.toLowerCase()] + } + if (filters.book) { + // Normalize book name (slugify) + const normalizedBook = filters.book.toLowerCase().replace(/\s+/g, '-') + filter['#book'] = [normalizedBook] + } + // Only include chapter in publication filter (to find the right publication) + // Don't include chapter/verse in content filter - we fetch all from the publication + if (includeChapter && filters.chapter !== undefined) { + filter['#chapter'] = [filters.chapter.toString()] + } + // Never include verse in filters - we fetch all events and filter in BookstrContent + if (filters.version) { + filter['#version'] = [filters.version.toLowerCase()] + } + } + + // Publication filter: include chapter to find the right publication + addBookstrTags(publicationTagFilter, true) + // Content filter: don't include chapter/verse - we'll fetch all from the publication + addBookstrTags(bookstrTagFilter, false) + + const orlyRelays = BOOKSTR_RELAY_URLS + // Prioritize thecitadel relay for bookstr events since user confirmed events are there + const thecitadelRelay = 'wss://thecitadel.nostr1.com' + const fallbackRelays = BIG_RELAY_URLS.filter(url => !BOOKSTR_RELAY_URLS.includes(url)) + // Put thecitadel first in fallback list if it's there + const prioritizedFallbackRelays = fallbackRelays.includes(thecitadelRelay) + ? [thecitadelRelay, ...fallbackRelays.filter(url => url !== thecitadelRelay)] + : fallbackRelays + + logger.info('fetchBookstrEventsFromRelays: Querying with tag filters', { + filters: JSON.stringify(filters), + publicationTagFilter: JSON.stringify(publicationTagFilter), + bookstrTagFilter: JSON.stringify(bookstrTagFilter), + orlyRelays: orlyRelays.length, + fallbackRelays: fallbackRelays.length + }) + + let events: NEvent[] = [] + + // Step 1: Try to find the 30040 publication(s) first + // Strategy: + // - Book-level query (no chapter): Find all chapter-level 30040 publications for that book + // - Chapter-level query: Find the specific 30040 publication for that chapter + // - Verse-level query: Find the chapter 30040, fetch all a-tags (filtering happens in BookstrContent) + // Note: Only orly has bookstr tag indexes. For fallback relays, we query by kind only and filter client-side. + try { + // For fallback relays, we can't use bookstr tag filters - query by kind only + const fallbackPublicationFilter: Filter = { + kinds: [ExtendedKind.PUBLICATION] + } + + const publications = await this.fetchEvents(prioritizedFallbackRelays, fallbackPublicationFilter, { + eoseTimeout: 5000, + globalTimeout: 8000 + }) + + logger.info('fetchBookstrEventsFromRelays: Found publications (before filtering)', { + count: publications.length, + filters: JSON.stringify(filters), + queryType: filters.chapter === undefined ? 'book-level' : 'chapter-level' + }) + + // Filter publications client-side to match bookstr criteria + const matchingPublications = publications.filter(pub => { + return this.eventMatchesBookstrFilters(pub, filters) + }) + + logger.info('fetchBookstrEventsFromRelays: Found matching publications (after filtering)', { + total: publications.length, + matching: matchingPublications.length, + filters: JSON.stringify(filters) + }) + + // For each matching publication, fetch ALL a-tagged 30041 events + // We fetch all of them because: + // - For book-level queries, we want all chapters + // - For chapter-level queries, we want all verses in that chapter + // - For verse-level queries, we fetch all verses but filter in BookstrContent + for (const publication of matchingPublications) { + const aTags = publication.tags + .filter(tag => tag[0] === 'a' && tag[1]) + .map(tag => tag[1]) + + logger.debug('fetchBookstrEventsFromRelays: Fetching from publication', { + publicationId: publication.id.substring(0, 8), + aTagCount: aTags.length + }) + + // Fetch all a-tagged events in parallel batches + const aTagPromises = aTags.map(async (aTag) => { + // Parse a tag: "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] || '' + + // Only fetch 30041 events (content events) + if (kind !== ExtendedKind.PUBLICATION_CONTENT) { + // If it's a nested 30040 publication, we could recursively fetch from it + // But for now, we'll skip nested publications + 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 + }) + + // For verse-level queries, we still fetch all events but will filter in BookstrContent + // For book/chapter queries, we fetch all matching events + // Only filter by book/type/version here - chapter/verse filtering happens in BookstrContent + const matchingEvents = aTagEvents.filter(event => { + const metadata = this.extractBookMetadataFromEvent(event) + + // Must match type if specified + if (filters.type && metadata.type?.toLowerCase() !== filters.type.toLowerCase()) { + return false + } + + // Must match book if specified + if (filters.book) { + const normalizedBook = filters.book.toLowerCase().replace(/\s+/g, '-') + const eventBookTags = event.tags + .filter(tag => tag[0] === 'book' && tag[1]) + .map(tag => tag[1].toLowerCase()) + const hasMatchingBook = eventBookTags.some(eventBook => + this.bookNamesMatch(eventBook, normalizedBook) + ) + if (!hasMatchingBook) return false + } + + // Must match version if specified + if (filters.version && metadata.version?.toLowerCase() !== filters.version.toLowerCase()) { + return false + } + + // Chapter and verse filtering happens in BookstrContent for display + // We fetch all events from the publication here + return true + }) + + return matchingEvents + } catch (err) { + logger.debug('fetchBookstrEventsFromRelays: Error fetching a-tag event', { + aTag, + error: err + }) + return [] + } + }) + + const aTagResults = await Promise.all(aTagPromises) + const fetchedEvents = aTagResults.flat().filter((e): e is NEvent => e !== null) + events.push(...fetchedEvents) + } + + if (events.length > 0) { + logger.info('fetchBookstrEventsFromRelays: Fetched from publications', { + publicationCount: matchingPublications.length, + eventCount: events.length, + filters: JSON.stringify(filters) + }) + return events + } + } catch (pubError) { + logger.warn('fetchBookstrEventsFromRelays: Error querying publications', { + error: pubError, + filters: JSON.stringify(filters) + }) + } + + // Try orly relay first (supports composite bookstr index) + // Skip if explicitly requested or if it's consistently failing + if (!options.skipOrly && orlyRelays.length > 0) { + try { + events = await this.fetchEvents(orlyRelays, bookstrTagFilter, { + eoseTimeout: 5000, // Shorter timeout since it often fails + globalTimeout: 8000 + }) + logger.info('fetchBookstrEventsFromRelays: Fetched from orly relay', { + count: events.length, + filters + }) + } catch (orlyError) { + logger.warn('fetchBookstrEventsFromRelays: Error querying orly relay (will try fallback)', { + error: orlyError, + filters + }) + // Continue to fallback relays + } + } else if (options.skipOrly) { + logger.debug('fetchBookstrEventsFromRelays: Skipping orly relay (background fetch)', { filters }) + } + + // If no results from publications approach, try fallback relays directly + // (This is a fallback in case the publication approach didn't work) + if (events.length === 0 && prioritizedFallbackRelays.length > 0) { + logger.info('fetchBookstrEventsFromRelays: Trying fallback relays (direct content query)', { + fallbackRelays: prioritizedFallbackRelays.length, + prioritized: prioritizedFallbackRelays[0] === thecitadelRelay ? 'thecitadel first' : 'normal order' + }) + try { + // For fallback relays, we need to fetch all and filter client-side + // (they don't have multi-letter tag indexes) + // Query by kind only - no bookstr tag filters + const fallbackFilter: Filter = { + kinds: [ExtendedKind.PUBLICATION_CONTENT] + } + const fallbackEvents = await this.fetchEvents(prioritizedFallbackRelays, fallbackFilter, { + eoseTimeout: 5000, + globalTimeout: 10000 + }) + + // Filter client-side (this will check all book tags) + let matchedCount = 0 + let rejectedCount = 0 + const rejectionReasons: Record = {} + const sampleRejections: any[] = [] + + events = fallbackEvents.filter(event => { + const matches = this.eventMatchesBookstrFilters(event, filters) + if (!matches) { + rejectedCount++ + // Sample rejections to understand why (up to 10 samples) + if (sampleRejections.length < 10) { + const metadata = this.extractBookMetadataFromEvent(event) + const reason = this.getFilterRejectionReason(event, filters, metadata) + rejectionReasons[reason] = (rejectionReasons[reason] || 0) + 1 + sampleRejections.push({ + reason, + eventBook: metadata.book, + eventChapter: metadata.chapter, + eventVerse: metadata.verse, + eventVersion: metadata.version, + hasBookTag: !!metadata.book, + eventId: event.id.substring(0, 8) + }) + } else { + // Still count reasons even if we don't log details + const metadata = this.extractBookMetadataFromEvent(event) + const reason = this.getFilterRejectionReason(event, filters, metadata) + rejectionReasons[reason] = (rejectionReasons[reason] || 0) + 1 + } + } else { + matchedCount++ + } + return matches + }) + + logger.info('fetchBookstrEventsFromRelays: Fetched from fallback relays', { + totalFetched: fallbackEvents.length, + filtered: events.length, + filters: JSON.stringify(filters), + rejectionReasons: Object.keys(rejectionReasons).length > 0 ? rejectionReasons : undefined, + sampleRejections: sampleRejections.length > 0 ? sampleRejections : undefined + }) + } catch (fallbackError) { + logger.warn('fetchBookstrEventsFromRelays: Error querying fallback relays', { + error: fallbackError, + filters + }) + } + } + + return events + } + + /** + * Check if event matches bookstr filters (for client-side filtering) + */ + private eventMatchesBookstrFilters(event: NEvent, filters: { + type?: string + book?: string + chapter?: number + verse?: string + version?: string + }): boolean { + const metadata = this.extractBookMetadataFromEvent(event) + + if (filters.type && metadata.type?.toLowerCase() !== filters.type.toLowerCase()) { + return false + } + if (filters.book) { + const normalizedBook = filters.book.toLowerCase().replace(/\s+/g, '-') + // Get ALL book tags from the event (events can have multiple book tags) + const eventBookTags = event.tags + .filter(tag => tag[0] === 'book' && tag[1]) + .map(tag => tag[1].toLowerCase()) + + // Check if any of the book tags match + const hasMatchingBook = eventBookTags.some(eventBook => + this.bookNamesMatch(eventBook, normalizedBook) + ) + + if (!hasMatchingBook) { + logger.debug('eventMatchesBookstrFilters: Book mismatch', { + normalizedBook, + eventBookTags, + eventId: event.id.substring(0, 8) + }) + return false + } + } + if (filters.chapter !== undefined) { + const eventChapter = parseInt(metadata.chapter || '0') + if (eventChapter !== filters.chapter) { + return false + } + } + if (filters.verse) { + 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) + + const matches = 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 + } + }) + if (!matches) return false + } + if (filters.version && metadata.version?.toLowerCase() !== filters.version.toLowerCase()) { + return false + } + + return true + } + + /** + * Get the reason why an event was rejected by filters (for debugging) + */ + private getFilterRejectionReason(event: NEvent, filters: { + type?: string + book?: string + chapter?: number + verse?: string + version?: string + }, metadata: { + type?: string + book?: string + chapter?: string + verse?: string + version?: string + }): string { + if (filters.type && metadata.type?.toLowerCase() !== filters.type.toLowerCase()) { + return `type mismatch: ${metadata.type} != ${filters.type}` + } + if (filters.book) { + const normalizedBook = filters.book.toLowerCase().replace(/\s+/g, '-') + const eventBookTags = event.tags + .filter(tag => tag[0] === 'book' && tag[1]) + .map(tag => tag[1].toLowerCase()) + const hasMatchingBook = eventBookTags.some(eventBook => + this.bookNamesMatch(eventBook, normalizedBook) + ) + if (!hasMatchingBook) { + return `book mismatch: [${eventBookTags.join(', ')}] != ${normalizedBook}` + } + } + if (filters.chapter !== undefined) { + const eventChapter = parseInt(metadata.chapter || '0') + if (eventChapter !== filters.chapter) { + return `chapter mismatch: ${eventChapter} != ${filters.chapter}` + } + } + if (filters.verse) { + const eventVerse = metadata.verse + if (!eventVerse) { + return `no verse tag in event` + } + const verseParts = filters.verse.split(/[,\s-]+/).map(v => v.trim()).filter(v => v) + const verseNum = parseInt(eventVerse) + const matches = 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 + } + }) + if (!matches) { + return `verse mismatch: ${verseNum} not in [${verseParts.join(', ')}]` + } + } + if (filters.version && metadata.version?.toLowerCase() !== filters.version.toLowerCase()) { + return `version mismatch: ${metadata.version} != ${filters.version}` + } + return 'unknown' + } + + /** + * Match book names with fuzzy matching + */ + private bookNamesMatch(book1: string, book2: string): boolean { + const normalized1 = book1.toLowerCase().replace(/\s+/g, '-') + const normalized2 = book2.toLowerCase().replace(/\s+/g, '-') + + // Exact match + if (normalized1 === normalized2) return true + + // One contains the other + if (normalized1.includes(normalized2) || normalized2.includes(normalized1)) return true + + // Check if last parts match (e.g., "genesis" matches "the-book-of-genesis") + const parts1 = normalized1.split('-') + const parts2 = normalized2.split('-') + if (parts1.length > 0 && parts2.length > 0) { + if (parts1[parts1.length - 1] === parts2[parts2.length - 1]) return true + } + + return false + } + + /** + * Old implementation - keeping for reference but not using + */ + async fetchBookstrEventsOld(filters: { + type?: string + book?: string + chapter?: number + verse?: string + version?: string }): Promise { logger.info('fetchBookstrEvents: Called', { filters }) try { diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index 2c30887..55de786 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -702,7 +702,7 @@ class IndexedDbService { }) } - private async putNonReplaceableEventWithMaster(event: Event, masterKey: string): Promise { + async putNonReplaceableEventWithMaster(event: Event, masterKey: string): Promise { // For non-replaceable events, store by event ID in publication events store const storeName = StoreNames.PUBLICATION_EVENTS await this.initPromise