Browse Source

make bookstr links more performant

imwald
Silberengel 4 months ago
parent
commit
be417f2524
  1. 2
      package.json
  2. 89
      src/components/Bookstr/BookstrContent.tsx
  3. 12
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  4. 18
      src/components/Note/PublicationIndex/PublicationIndex.tsx
  5. 5
      src/constants.ts
  6. 66
      src/lib/bookstr-parser.ts
  7. 599
      src/services/client.service.ts
  8. 2
      src/services/indexed-db.service.ts

2
package.json

@ -1,6 +1,6 @@ @@ -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",

89
src/components/Bookstr/BookstrContent.tsx

@ -69,12 +69,19 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { @@ -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) { @@ -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
@ -226,6 +246,8 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { @@ -226,6 +246,8 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
}))
})
if (isCancelled) return
setSections(newSections)
// Set initial selected versions
@ -237,15 +259,23 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { @@ -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) { @@ -275,22 +305,30 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
}
return (
<div className={cn('my-2 space-y-4', className)}>
{sections.map((section, sectionIndex) => {
const selectedVersion = selectedVersions.get(sectionIndex) || section.versions[0] || ''
const filteredEvents = selectedVersion
? section.events.filter(event => {
const metadata = extractBookMetadata(event)
return metadata.version?.toUpperCase() === selectedVersion
})
: section.events
const isExpanded = expandedSections.has(sectionIndex)
const hasVerses = section.originalVerses !== undefined && section.originalVerses.length > 0
const hasChapter = section.originalChapter !== undefined && !hasVerses
return (
<div key={sectionIndex} className="border rounded-lg p-3 bg-muted/30">
<div className={cn('my-2', className)}>
<div className="border rounded-lg bg-muted/30 overflow-hidden">
{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 (
<div
key={sectionIndex}
className={cn(
'p-3',
!isLast && 'border-b'
)}
>
{/* Header */}
<div className="flex items-center gap-2 mb-2">
<h4 className="font-semibold text-sm">
@ -389,9 +427,10 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) { @@ -389,9 +427,10 @@ export function BookstrContent({ wikilink, className }: BookstrContentProps) {
/>
</div>
)}
</div>
)
})}
</div>
)
})}
</div>
</div>
)
}

12
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -2051,14 +2051,14 @@ function parseMarkdownContent( @@ -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()
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, '')
const dtag = target.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
parts.push(
<Wikilink key={`wikilink-${patternIdx}`} dTag={dtag} displayText={displayText} />
)
parts.push(
<Wikilink key={`wikilink-${patternIdx}`} dTag={dtag} displayText={displayText} />
)
}
}

18
src/components/Note/PublicationIndex/PublicationIndex.tsx

@ -972,9 +972,9 @@ export default function PublicationIndex({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -1127,7 +1127,7 @@ export default function PublicationIndex({
(fetchedRefs) => {
if (isMounted) {
// Update state progressively as events are fetched
setReferences(fetchedRefs)
setReferences(fetchedRefs)
}
}
)

5
src/constants.ts

@ -82,6 +82,11 @@ export const BIG_RELAY_URLS = [ @@ -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',

66
src/lib/bookstr-parser.ts

@ -32,8 +32,70 @@ function normalizeBookReferenceWhitespace(ref: string): string { @@ -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)

599
src/services/client.service.ts

@ -1,4 +1,4 @@ @@ -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 { @@ -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 { @@ -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 { @@ -2116,6 +2112,589 @@ class ClientService extends EventTarget {
chapter?: number
verse?: string
version?: string
}): Promise<NEvent[]> {
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<string, NEvent[]>()
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<NEvent[]> {
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<NEvent[]> {
// 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<string, number> = {}
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<NEvent[]> {
logger.info('fetchBookstrEvents: Called', { filters })
try {

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

@ -702,7 +702,7 @@ class IndexedDbService { @@ -702,7 +702,7 @@ class IndexedDbService {
})
}
private async putNonReplaceableEventWithMaster(event: Event, masterKey: string): Promise<Event> {
async putNonReplaceableEventWithMaster(event: Event, masterKey: string): Promise<Event> {
// For non-replaceable events, store by event ID in publication events store
const storeName = StoreNames.PUBLICATION_EVENTS
await this.initPromise

Loading…
Cancel
Save