You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

308 lines
9.3 KiB

import { ExtendedKind } from '@/constants'
import logger from '@/lib/logger'
import type { Event as NEvent } from 'nostr-tools'
import indexedDb, { StoreNames } from './indexed-db.service'
import type { QueryService } from './client-query.service'
export interface MacroFilters {
type?: string
book?: string
chapter?: number
verse?: string
version?: string
}
export class MacroService {
private macroType: 'bookstr' | 'wikistr' | 'other' = 'bookstr'
constructor(_queryService: QueryService, macroType: 'bookstr' | 'wikistr' | 'other' = 'bookstr') {
this.macroType = macroType
}
/**
* Fetch macro events (Bookstr, Wikistr, etc.)
*/
async fetchMacroEvents(filters: MacroFilters): Promise<NEvent[]> {
logger.info(`fetchMacroEvents[${this.macroType}]: Called`, { filters })
try {
// Step 1: Check cache FIRST before any network requests
const cachedEvents = await this.getCachedMacroEvents(filters)
if (cachedEvents.length > 0) {
logger.info(`fetchMacroEvents[${this.macroType}]: Found cached events`, {
count: cachedEvents.length,
filters
})
// Still fetch in background to get updates, but return cached immediately
this.fetchMacroEventsFromRelays(filters).catch(err => {
logger.warn(`fetchMacroEvents[${this.macroType}]: Background fetch failed`, { error: err })
})
return cachedEvents
}
// Step 2: If verse is specified and contains a range, expand it
if (filters.verse) {
const verseNumbers = this.expandVerseRange(filters.verse)
if (verseNumbers.length > 1) {
logger.info(`fetchMacroEvents[${this.macroType}]: Expanding verse range`, {
originalVerse: filters.verse,
expandedVerses: verseNumbers
})
const allEvents: NEvent[] = []
const seenEventIds = new Set<string>()
for (const verseNum of verseNumbers) {
const verseFilter = { ...filters, verse: verseNum.toString() }
const verseCachedEvents = await this.getCachedMacroEvents(verseFilter)
if (verseCachedEvents.length > 0) {
for (const event of verseCachedEvents) {
if (!seenEventIds.has(event.id)) {
seenEventIds.add(event.id)
allEvents.push(event)
}
}
this.fetchMacroEventsFromRelays(verseFilter).catch(err => {
logger.warn(`fetchMacroEvents[${this.macroType}]: Background fetch failed for verse`, { verse: verseNum, error: err })
})
} else {
const verseEvents = await this.fetchMacroEvents(verseFilter)
for (const event of verseEvents) {
if (!seenEventIds.has(event.id)) {
seenEventIds.add(event.id)
allEvents.push(event)
}
}
}
}
return allEvents
}
}
// Step 3: Fetch from relays
const events = await this.fetchMacroEventsFromRelays(filters)
// Step 4: Save events to cache
if (events.length > 0) {
try {
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)
}
for (const [pubkey, pubEvents] of eventsByPubkey) {
for (const event of pubEvents) {
await indexedDb.putNonReplaceableEventWithMaster(event, `${ExtendedKind.PUBLICATION}:${pubkey}:`)
}
}
logger.info(`fetchMacroEvents[${this.macroType}]: Saved events to cache`, {
count: events.length,
filters
})
} catch (cacheError) {
logger.warn(`fetchMacroEvents[${this.macroType}]: Error saving to cache`, {
error: cacheError,
filters
})
}
}
return events
} catch (error) {
logger.warn(`Error querying ${this.macroType} events`, { error, filters })
return []
}
}
/**
* Get cached macro events from IndexedDB
*/
async getCachedMacroEvents(filters: MacroFilters): Promise<NEvent[]> {
try {
const allCached = await indexedDb.getStoreItems(StoreNames.PUBLICATION_EVENTS)
const cachedEvents: NEvent[] = []
for (const item of allCached) {
const event = item.value as NEvent | undefined
if (!event) continue
if (this.eventMatchesMacroFilters(event, filters)) {
cachedEvents.push(event)
}
}
logger.debug(`getCachedMacroEvents[${this.macroType}]: Found cached events`, {
count: cachedEvents.length,
filters
})
return cachedEvents
} catch (error) {
logger.warn(`getCachedMacroEvents[${this.macroType}]: Error reading cache`, { error, filters })
return []
}
}
/**
* Fetch macro events from relays
*/
private async fetchMacroEventsFromRelays(filters: MacroFilters): Promise<NEvent[]> {
// This would be implemented based on the specific macro type
// For Bookstr, it would use the publication pubkey and filters
// For now, return empty array as placeholder
logger.debug(`fetchMacroEventsFromRelays[${this.macroType}]: Fetching from relays`, { filters })
return []
}
/**
* Expand verse range (e.g., "1-5" -> [1,2,3,4,5])
*/
private expandVerseRange(verse: string): number[] {
const parts = verse.split('-')
if (parts.length === 1) {
const num = parseInt(parts[0]!, 10)
return isNaN(num) ? [] : [num]
}
const start = parseInt(parts[0]!, 10)
const end = parseInt(parts[1]!, 10)
if (isNaN(start) || isNaN(end) || start > end) {
return []
}
const result: number[] = []
for (let i = start; i <= end; i++) {
result.push(i)
}
return result
}
/**
* Check if event matches macro filters
*/
private eventMatchesMacroFilters(event: NEvent, filters: MacroFilters): boolean {
if (event.kind !== ExtendedKind.PUBLICATION && event.kind !== ExtendedKind.PUBLICATION_CONTENT) {
return false
}
const metadata = this.extractMacroMetadataFromEvent(event)
if (filters.type && metadata.type?.toLowerCase() !== filters.type.toLowerCase()) {
return false
}
if (filters.book) {
const normalizedBook = filters.book.toLowerCase().replace(/\s+/g, '-')
const eventBookTags = event.tags
.filter(tag => tag[0] === 'T' && tag[1])
.map(tag => tag[1]!.toLowerCase().replace(/\s+/g, '-'))
.filter((book): book is string => Boolean(book))
if (!eventBookTags.some(book => this.bookNamesMatch(book, normalizedBook))) {
return false
}
}
if (filters.chapter !== undefined) {
const eventChapters = event.tags
.filter(tag => tag[0] === 'c')
.map(tag => parseInt(tag[1] || '0', 10))
.filter(num => !isNaN(num))
if (!eventChapters.includes(filters.chapter)) {
return false
}
}
if (filters.verse) {
const verseNum = parseInt(filters.verse, 10)
if (!isNaN(verseNum)) {
const eventVerses = event.tags
.filter(tag => tag[0] === 's')
.map(tag => parseInt(tag[1] || '0', 10))
.filter(num => !isNaN(num))
if (!eventVerses.includes(verseNum)) {
return false
}
}
}
if (filters.version) {
const normalizedVersion = filters.version.toLowerCase()
const eventVersions = event.tags
.filter(tag => tag[0] === 'v')
.map(tag => tag[1]?.toLowerCase())
if (!eventVersions.includes(normalizedVersion)) {
return false
}
}
return true
}
/**
* Extract macro metadata from event tags
*/
private extractMacroMetadataFromEvent(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 'C':
metadata.type = value
break
case 'T':
metadata.book = value
break
case 'c':
metadata.chapter = value
break
case 's':
if (!metadata.verse) {
metadata.verse = value
}
break
case 'v':
metadata.version = value
break
}
}
return metadata
}
/**
* Check if book names match (handles variations)
*/
private bookNamesMatch(book1: string | undefined, book2: string): boolean {
if (!book1) return false
const normalize = (s: string) => s.toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '')
return normalize(book1) === normalize(book2)
}
}
/**
* Create Bookstr service instance
*/
export function createBookstrService(queryService: QueryService): MacroService {
return new MacroService(queryService, 'bookstr')
}
/**
* Create Wikistr service instance
*/
export function createWikistrService(queryService: QueryService): MacroService {
return new MacroService(queryService, 'wikistr')
}