From 3a252788513acf26bdcb83e28649f3d7ab6b68f5 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 17 Nov 2025 06:57:28 +0100 Subject: [PATCH] implement bookstr rendering --- src/components/Bookstr/BookstrContent.tsx | 583 ++++++++++++++++++ src/components/Bookstr/index.ts | 2 + .../Note/AsciidocArticle/AsciidocArticle.tsx | 28 +- .../Note/MarkdownArticle/MarkdownArticle.tsx | 44 +- src/lib/bookstr-parser.ts | 181 ++++++ src/services/client.service.ts | 162 +++++ src/services/content-parser.service.ts | 13 +- src/services/indexed-db.service.ts | 2 +- 8 files changed, 996 insertions(+), 19 deletions(-) create mode 100644 src/components/Bookstr/BookstrContent.tsx create mode 100644 src/components/Bookstr/index.ts create mode 100644 src/lib/bookstr-parser.ts diff --git a/src/components/Bookstr/BookstrContent.tsx b/src/components/Bookstr/BookstrContent.tsx new file mode 100644 index 0000000..47e0f63 --- /dev/null +++ b/src/components/Bookstr/BookstrContent.tsx @@ -0,0 +1,583 @@ +import { useState, useEffect, useMemo } from 'react' +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 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { cn } from '@/lib/utils' +import logger from '@/lib/logger' +import { contentParserService } from '@/services/content-parser.service' + +interface BookstrContentProps { + wikilink: string + className?: string +} + +interface BookSection { + reference: BookReference + events: Event[] + versions: string[] + originalVerses?: string + originalChapter?: number +} + +export function BookstrContent({ wikilink, className }: BookstrContentProps) { + const [sections, setSections] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const [expandedSections, setExpandedSections] = useState>(new Set()) + const [selectedVersions, setSelectedVersions] = useState>(new Map()) + + // Parse the wikilink + const parsed = useMemo(() => { + try { + // Extract book type from wikilink (e.g., "book:bible:Genesis 3:1") + let bookType = 'bible' + let content = wikilink + + if (wikilink.startsWith('book:')) { + const parts = wikilink.substring(5).split(':') + if (parts.length >= 2) { + bookType = parts[0] + content = parts.slice(1).join(':') + } + } else if (wikilink.includes(':')) { + // Might be "bible:Genesis 3:1" format + const firstColon = wikilink.indexOf(':') + const potentialType = wikilink.substring(0, firstColon) + if (['bible', 'quran', 'catechism', 'torah'].includes(potentialType.toLowerCase())) { + bookType = potentialType.toLowerCase() + content = wikilink.substring(firstColon + 1) + } + } + + const result = parseBookWikilink(`[[book:${bookType}:${content}]]`, bookType) + return result ? { ...result, bookType } : null + } catch (err) { + logger.error('Error parsing bookstr wikilink', { error: err, wikilink }) + return null + } + }, [wikilink]) + + // Fetch events for each reference + useEffect(() => { + if (!parsed || !parsed.references.length) { + setIsLoading(false) + setError('Invalid bookstr reference') + return + } + + const fetchEvents = async () => { + setIsLoading(true) + setError(null) + + try { + const newSections: BookSection[] = [] + + for (const ref of parsed.references) { + // Normalize book name (lowercase, hyphenated) + const normalizedBook = ref.book.toLowerCase().replace(/\s+/g, '-') + const bookType = (parsed as any).bookType || 'bible' + + // Determine which versions to fetch + const versionsToFetch = parsed.versions || (ref.version ? [ref.version] : []) + + // If no versions specified, try to find available versions + if (versionsToFetch.length === 0) { + // First, try to find any version for this book/chapter/verse + const allEvents = await client.fetchBookstrEvents({ + type: bookType, + book: normalizedBook, + chapter: ref.chapter, + verse: ref.verse + }) + + // Extract unique versions + const availableVersions = new Set() + allEvents.forEach(event => { + const metadata = extractBookMetadata(event) + if (metadata.version) { + availableVersions.add(metadata.version.toUpperCase()) + } + }) + + if (availableVersions.size > 0) { + versionsToFetch.push(Array.from(availableVersions)[0]) // Use first available + } else { + // No versions found, try without version filter + const eventsWithoutVersion = await client.fetchBookstrEvents({ + type: bookType, + book: normalizedBook, + chapter: ref.chapter, + verse: ref.verse + }) + + if (eventsWithoutVersion.length > 0) { + // Use events without version filter + newSections.push({ + reference: ref, + events: eventsWithoutVersion, + versions: [], + originalVerses: ref.verse, + originalChapter: ref.chapter + }) + continue + } + } + } + + // Fetch events for each version + const allEvents: Event[] = [] + const allVersions = new Set() + + for (const version of versionsToFetch) { + const events = await client.fetchBookstrEvents({ + type: bookType, + book: normalizedBook, + chapter: ref.chapter, + verse: ref.verse, + version: version.toLowerCase() + }) + + events.forEach(event => { + allEvents.push(event) + const metadata = extractBookMetadata(event) + if (metadata.version) { + allVersions.add(metadata.version.toUpperCase()) + } + }) + } + + // Sort events by verse number + allEvents.sort((a, b) => { + const aMeta = extractBookMetadata(a) + const bMeta = extractBookMetadata(b) + const aVerse = parseInt(aMeta.verse || '0') + const bVerse = parseInt(bMeta.verse || '0') + return aVerse - bVerse + }) + + newSections.push({ + reference: ref, + events: allEvents, + versions: Array.from(allVersions), + originalVerses: ref.verse, + originalChapter: ref.chapter + }) + } + + setSections(newSections) + + // Set initial selected versions + const initialVersions = new Map() + newSections.forEach((section, index) => { + if (section.versions.length > 0) { + initialVersions.set(index, section.versions[0]) + } + }) + setSelectedVersions(initialVersions) + } catch (err) { + logger.error('Error fetching bookstr events', { error: err, wikilink }) + setError(err instanceof Error ? err.message : 'Failed to fetch book content') + } finally { + setIsLoading(false) + } + } + + fetchEvents() + }, [parsed, wikilink]) + + if (isLoading) { + return ( + + {wikilink} + + + ) + } + + if (error) { + return ( + + {wikilink} + + + ) + } + + if (sections.length === 0) { + return ( + + {wikilink} + + + ) + } + + 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 ( +
+ {/* Header */} +
+

+ {section.reference.book} + {section.reference.chapter && ` ${section.reference.chapter}`} + {section.reference.verse && `:${section.reference.verse}`} + {selectedVersion && ` (${selectedVersion})`} +

+ { + const newVersions = new Map(selectedVersions) + newVersions.set(sectionIndex, version) + setSelectedVersions(newVersions) + }} + /> +
+ + {/* Verses */} + + + {/* Expand/Collapse buttons */} + {hasVerses && ( + + )} + {hasChapter && !hasVerses && ( + + )} + + {/* Expanded content */} + {isExpanded && ( +
+ {/* Fetch and display full chapter/book */} + +
+ )} +
+ ) + })} +
+ ) +} + +interface ExpandedContentProps { + section: BookSection + selectedVersion: string + originalVerses?: string + originalChapter?: number +} + +function ExpandedContent({ section, selectedVersion, originalVerses, originalChapter }: 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...
+ } + + return ( + + ) +} + +interface VerseContentProps { + events: Event[] + hasVerses: boolean + originalVerses?: string + isExpanded: boolean + originalChapter?: number +} + +function VerseContent({ events, hasVerses, originalVerses, isExpanded, originalChapter }: VerseContentProps) { + const [parsedContents, setParsedContents] = useState>(new Map()) + + useEffect(() => { + const parseAll = async () => { + const newParsed = new Map() + for (const event of events) { + if (!parsedContents.has(event.id)) { + try { + const result = await contentParserService.parseContent(event.content, { + eventKind: ExtendedKind.PUBLICATION_CONTENT + }) + newParsed.set(event.id, result.html) + } catch (err) { + logger.warn('Error parsing verse content', { error: err, eventId: event.id.substring(0, 8) }) + newParsed.set(event.id, event.content) + } + } else { + // Already parsed, copy it + newParsed.set(event.id, parsedContents.get(event.id)!) + } + } + if (newParsed.size > 0) { + setParsedContents(newParsed) + } + } + parseAll() + }, [events]) + + return ( +
+ {events.map((event) => { + const metadata = extractBookMetadata(event) + const verseNum = metadata.verse + const chapterNum = metadata.chapter + // Check if this verse is in the original verses list + const isOriginalVerse = hasVerses && originalVerses && verseNum && (() => { + const verseParts = originalVerses.split(/[,\s-]+/).map(v => v.trim()) + const verseNumInt = parseInt(verseNum) + // Check exact match or range + for (const part of verseParts) { + if (part.includes('-')) { + const [start, end] = part.split('-').map(v => parseInt(v.trim())) + if (!isNaN(start) && !isNaN(end) && verseNumInt >= start && verseNumInt <= end) { + return true + } + } else { + const partNum = parseInt(part) + if (!isNaN(partNum) && partNum === verseNumInt) { + return true + } + } + } + return false + })() + const isOriginalChapter = originalChapter !== undefined && + chapterNum && parseInt(chapterNum) === originalChapter + + const content = parsedContents.get(event.id) || event.content + + return ( +
+ {chapterNum && verseNum ? ( + {chapterNum}:{verseNum} + ) : verseNum && ( + {verseNum} + )} + +
+ ) + })} +
+ ) +} + +interface VersionSelectorProps { + section: BookSection + sectionIndex: number + selectedVersion: string + onVersionChange: (version: string) => void +} + +function VersionSelector({ section, selectedVersion, onVersionChange }: VersionSelectorProps) { + const [availableVersions, setAvailableVersions] = useState(section.versions) + const [isLoadingVersions, setIsLoadingVersions] = useState(false) + + // When component mounts or section changes, try to fetch more versions if needed + useEffect(() => { + const fetchAvailableVersions = async () => { + if (availableVersions.length > 1) return // Already have multiple versions + + setIsLoadingVersions(true) + try { + // Query for all versions of this book/chapter/verse + const normalizedBook = section.reference.book.toLowerCase().replace(/\s+/g, '-') + const allEvents = await client.fetchBookstrEvents({ + type: 'bible', + book: normalizedBook, + chapter: section.reference.chapter, + verse: section.reference.verse + }) + + const versions = new Set() + allEvents.forEach(event => { + const metadata = extractBookMetadata(event) + if (metadata.version) { + versions.add(metadata.version.toUpperCase()) + } + }) + + if (versions.size > availableVersions.length) { + setAvailableVersions(Array.from(versions).sort()) + } + } catch (err) { + logger.warn('Error fetching available versions', { error: err }) + } finally { + setIsLoadingVersions(false) + } + } + + fetchAvailableVersions() + }, [section.reference.book, section.reference.chapter, section.reference.verse, availableVersions.length]) + + // Don't show selector if only one version available + if (availableVersions.length <= 1) { + return null + } + + return ( + + ) +} + diff --git a/src/components/Bookstr/index.ts b/src/components/Bookstr/index.ts new file mode 100644 index 0000000..fa2081d --- /dev/null +++ b/src/components/Bookstr/index.ts @@ -0,0 +1,2 @@ +export { BookstrContent } from './BookstrContent' + diff --git a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx index 85330ab..d63738d 100644 --- a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx +++ b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx @@ -16,6 +16,7 @@ import Zoom from 'yet-another-react-lightbox/plugins/zoom' import { EmbeddedNote, EmbeddedMention } from '@/components/Embedded' import EmbeddedCitation from '@/components/EmbeddedCitation' import Wikilink from '@/components/UniversalContent/Wikilink' +import { BookstrContent } from '@/components/Bookstr' import { preprocessAsciidocMediaLinks } from '../MarkdownArticle/preprocessMarkup' import logger from '@/lib/logger' import katex from 'katex' @@ -945,21 +946,38 @@ export default function AsciidocArticle({ reactRootsRef.current.set(container, root) }) + // Process bookstr wikilinks - replace placeholders with React components + const bookstrPlaceholders = contentRef.current.querySelectorAll('.bookstr-placeholder[data-bookstr]') + bookstrPlaceholders.forEach((element) => { + const bookstrContent = element.getAttribute('data-bookstr') + if (!bookstrContent) return + + // Create a container for React component + const container = document.createElement('div') + container.className = 'bookstr-container' + element.parentNode?.replaceChild(container, element) + + // Use React to render the component + const root = createRoot(container) + root.render() + reactRootsRef.current.set(container, root) + }) + // Process wikilinks - replace placeholders with React components const wikilinks = contentRef.current.querySelectorAll('.wikilink-placeholder[data-wikilink]') wikilinks.forEach((element) => { const linkContent = element.getAttribute('data-wikilink') if (!linkContent) return + // Skip if this is a bookstr wikilink (already processed) + if (linkContent.startsWith('book:')) { + return + } + // Parse wikilink: extract target and display text let target = linkContent.includes('|') ? linkContent.split('|')[0].trim() : linkContent.trim() let displayText = linkContent.includes('|') ? linkContent.split('|')[1].trim() : linkContent.trim() - // Handle book: prefix - if (linkContent.startsWith('book:')) { - target = linkContent.replace('book:', '').trim() - } - // Convert to d-tag format (same as MarkdownArticle) const dtag = target.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index b497fb3..6566181 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -2,6 +2,7 @@ import { useSecondaryPage, useSmartHashtagNavigation, useSmartRelayNavigation } import Image from '@/components/Image' import MediaPlayer from '@/components/MediaPlayer' import Wikilink from '@/components/UniversalContent/Wikilink' +import { BookstrContent } from '@/components/Bookstr' import WebPreview from '@/components/WebPreview' import YoutubeEmbeddedPlayer from '@/components/YoutubeEmbeddedPlayer' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' @@ -2024,18 +2025,41 @@ function parseMarkdownContent( } } else if (pattern.type === 'wikilink') { const linkContent = pattern.data - let target = linkContent.includes('|') ? linkContent.split('|')[0].trim() : linkContent.trim() - let displayText = linkContent.includes('|') ? linkContent.split('|')[1].trim() : linkContent.trim() - if (linkContent.startsWith('book:')) { - target = linkContent.replace('book:', '').trim() - } - - const dtag = target.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') + // Check if this is a bookstr wikilink + // Formats: book:bible:..., bible:..., quran:..., etc. + const isBookstrLink = linkContent.startsWith('book:') || + ['bible', 'quran', 'catechism', 'torah'].some(type => + linkContent.toLowerCase().startsWith(`${type}:`) + ) - parts.push( - - ) + if (isBookstrLink) { + // Extract the bookstr content + let bookstrContent = linkContent.trim() + // If it doesn't start with "book:", add it for consistency + if (!bookstrContent.startsWith('book:')) { + // Format: "bible:Genesis 3:1" -> "book:bible:Genesis 3:1" + const firstColon = bookstrContent.indexOf(':') + if (firstColon > 0) { + const bookType = bookstrContent.substring(0, firstColon) + const rest = bookstrContent.substring(firstColon + 1) + bookstrContent = `book:${bookType}:${rest}` + } + } + parts.push( + + ) + } 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( + + ) + } } lastIndex = pattern.end diff --git a/src/lib/bookstr-parser.ts b/src/lib/bookstr-parser.ts new file mode 100644 index 0000000..7b51c86 --- /dev/null +++ b/src/lib/bookstr-parser.ts @@ -0,0 +1,181 @@ +/** + * Bookstr parsing utilities + * Ported from wikistr/src/lib/books.ts for use in jumble + */ + +export interface BookReference { + book: string + chapter?: number + verse?: string // Can be "1", "1-3", "1,3,5", etc. + version?: string +} + +/** + * Normalize whitespace and case in book reference strings + */ +function normalizeBookReferenceWhitespace(ref: string): string { + let normalized = ref.trim() + + // Handle cases where there's no space between book name and chapter/verse + normalized = normalized.replace(/^([A-Za-z]+)(\d+)/, '$1 $2') + + // Normalize multiple spaces to single spaces + normalized = normalized.replace(/\s+/g, ' ') + + return normalized.trim() +} + +/** + * Parse book notation like "John 1–3; 3:16; 6:14, 44" for any book type + * Returns an array of BookReference objects + */ +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()) + + for (const part of parts) { + const normalizedPart = normalizeBookReferenceWhitespace(part) + const ref = parseSingleBookReference(normalizedPart, bookType) + if (ref) { + references.push(ref) + } + } + + return references +} + +/** + * Parse a single book reference like "John 3:16" or "John 1-3" or "John 3:16 KJV" + */ +function parseSingleBookReference(ref: string, _bookType: string = 'bible'): BookReference | null { + // Remove extra whitespace + ref = ref.trim() + + // First, try to extract version from the end + let version: string | undefined + let refWithoutVersion = ref + + // Common version abbreviations (can be extended) + const versionPattern = /\s+(KJV|NKJV|NIV|ESV|NASB|NLT|MSG|CEV|NRSV|RSV|ASV|YLT|WEB|GNV|DRB|SAHIH|PICKTHALL|YUSUFALI|SHAKIR|CCC|YOUCAT|COMPENDIUM)$/i + const versionMatch = ref.match(versionPattern) + if (versionMatch) { + version = versionMatch[1].toUpperCase() + refWithoutVersion = ref.replace(versionPattern, '').trim() + } + + // Match patterns + const patterns = [ + // Book Chapter:Verses (e.g., "John 3:16", "John 3:16,18") + /^(.+?)\s+(\d+):(.+)$/, + // Book Chapter-Verses (e.g., "John 1-3", "John 1-3,5") + /^(.+?)\s+(\d+)-(.+)$/, + // Book Chapter (e.g., "John 3") + /^(.+?)\s+(\d+)$/, + // Just Book (e.g., "John") + /^(.+)$/ + ] + + for (const pattern of patterns) { + const match = refWithoutVersion.match(pattern) + if (match) { + const bookName = match[1].trim() + + const reference: BookReference = { + book: bookName + } + + if (match[2]) { + reference.chapter = parseInt(match[2]) + } + + if (match[3]) { + reference.verse = match[3] + } + + if (version) { + reference.version = version + } + + return reference + } + } + + return null +} + +/** + * Parse book wikilink notation like "[[book:bible:John 3:16 | KJV]]" or "[[book:bible:John 3:16 | KJV DRB]]" + */ +export function parseBookWikilink(wikilink: string, bookType: string = 'bible'): { references: BookReference[], versions?: string[] } | null { + // Remove the [[ and ]] brackets + const content = wikilink.replace(/^\[\[|\]\]$/g, '') + + // Handle book: prefix (e.g., "book:bible:John 3:16") + let referenceContent = content + if (content.startsWith('book:')) { + const parts = content.substring(5).split(':') + if (parts.length >= 2) { + bookType = parts[0] + referenceContent = parts.slice(1).join(':') + } + } else if (content.startsWith('bible:')) { + // Legacy Bible prefix support + bookType = 'bible' + referenceContent = content.substring(6).trim() + } + + // Split by | to separate references from versions + const parts = referenceContent.split('|').map(p => p.trim()) + + if (parts.length === 0) return null + + // Normalize whitespace in the reference part + const normalizedReference = normalizeBookReferenceWhitespace(parts[0]) + const references = parseBookNotation(normalizedReference, bookType) + + // Parse multiple versions if provided + let versions: string[] | undefined + if (parts[1]) { + versions = parts[1].split(/\s+/).map(v => v.trim().toUpperCase()).filter(v => v.length > 0) + } + + return { references, versions } +} + +/** + * Extract book metadata from event tags + */ +export function extractBookMetadata(event: { tags: string[][] }): { + type?: string + book?: string + chapter?: string + verse?: string + version?: string +} { + const metadata: any = {} + + for (const [tag, value] of event.tags) { + switch (tag) { + case 'type': + metadata.type = value + break + case 'book': + metadata.book = value + break + case 'chapter': + metadata.chapter = value + break + case 'verse': + metadata.verse = value + break + case 'version': + metadata.version = value + break + } + } + + return metadata +} + diff --git a/src/services/client.service.ts b/src/services/client.service.ts index b1badcd..6720e4f 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -2097,6 +2097,168 @@ class ClientService extends EventTarget { filter: { authors: Array.from(authors) } })) } + + /** + * Fetch bookstr events by tag filters + * Note: Most relays only index single-letter tags, so we fetch all kind 30041 events + * and filter client-side based on the custom tags (type, book, chapter, verse, version) + */ + async fetchBookstrEvents(filters: { + type?: string + book?: string + chapter?: number + verse?: string + version?: string + }): Promise { + // Build filter for querying - only use indexed tags (single letters) + // We'll filter by the custom tags client-side + const filter: Filter = { + kinds: [ExtendedKind.PUBLICATION_CONTENT] + } + + // Note: We can't use #type, #book, #chapter, #verse, #version filters + // because relays only index single-letter tags. We'll fetch and filter client-side. + + // First, try to get from cache + // Note: For now, we'll query the relay directly. The cache will be populated + // when publications are loaded through normal channels. We can enhance this + // later to check the cache first if needed. + const cachedEvents: NEvent[] = [] + + // Query from relays - fetch all kind 30041 events (we'll filter client-side) + // Use BIG_RELAY_URLS which includes both thecitadel.nostr1.com and nostr.land + const relayUrls = BIG_RELAY_URLS + let relayEvents: NEvent[] = [] + + try { + relayEvents = await this.fetchEvents(relayUrls, filter, { + eoseTimeout: 5000, + globalTimeout: 10000 + }) + + // Filter events client-side based on the custom tags + // Since relays don't index multi-letter tags, we need to check tags manually + relayEvents = relayEvents.filter(event => { + return this.eventMatchesBookstrFilters(event, filters) + }) + } catch (error) { + logger.warn('Error querying bookstr events from relays', { error, filters, relayUrls }) + } + + // Combine cached and relay events, deduplicate by event ID + const eventMap = new Map() + cachedEvents.forEach(event => eventMap.set(event.id, event)) + relayEvents.forEach(event => eventMap.set(event.id, event)) + + return Array.from(eventMap.values()) + } + + /** + * Check if an event matches bookstr filters + */ + private eventMatchesBookstrFilters(event: NEvent, filters: { + type?: string + book?: string + chapter?: number + verse?: string + version?: string + }): boolean { + if (event.kind !== ExtendedKind.PUBLICATION_CONTENT) { + return false + } + + const getTagValue = (tagName: string): string | undefined => { + const tag = event.tags.find(t => t[0] === tagName) + return tag?.[1] + } + + if (filters.type) { + const eventType = getTagValue('type') + if (!eventType || eventType.toLowerCase() !== filters.type.toLowerCase()) { + return false + } + } + + if (filters.book) { + const eventBook = getTagValue('book') + const normalizedBook = filters.book.toLowerCase().replace(/\s+/g, '-') + if (!eventBook || eventBook.toLowerCase() !== normalizedBook) { + return false + } + } + + if (filters.chapter !== undefined) { + const eventChapter = getTagValue('chapter') + if (!eventChapter || parseInt(eventChapter) !== filters.chapter) { + return false + } + } + + if (filters.verse) { + const eventVerse = getTagValue('verse') + if (!eventVerse) { + return false + } + // Check if verse matches (handle ranges like "1-3", "1,3,5", etc.) + const verseMatches = this.verseMatches(eventVerse, filters.verse) + if (!verseMatches) { + return false + } + } + + if (filters.version) { + const eventVersion = getTagValue('version') + if (!eventVersion || eventVersion.toLowerCase() !== filters.version.toLowerCase()) { + return false + } + } + + return true + } + + /** + * Check if a verse string matches a verse filter + * Handles ranges like "1-3", "1,3,5", etc. + */ + private verseMatches(eventVerse: string, filterVerse: string): boolean { + // Normalize both verses + const normalize = (v: string) => v.trim().toLowerCase() + const eventV = normalize(eventVerse) + const filterV = normalize(filterVerse) + + // If exact match + if (eventV === filterV) { + return true + } + + // Parse filter verse (could be "1", "1-3", "1,3,5", etc.) + const filterParts = filterV.split(/[,\s]+/) + for (const part of filterParts) { + if (part.includes('-')) { + // Range like "1-3" + const [start, end] = part.split('-').map(v => parseInt(v.trim())) + const eventNum = parseInt(eventV) + if (!isNaN(start) && !isNaN(end) && !isNaN(eventNum)) { + if (eventNum >= start && eventNum <= end) { + return true + } + } + } else { + // Single verse + const filterNum = parseInt(part) + const eventNum = parseInt(eventV) + if (!isNaN(filterNum) && !isNaN(eventNum) && filterNum === eventNum) { + return true + } + // Also check if event verse contains the filter verse + if (eventV.includes(part)) { + return true + } + } + } + + return false + } } const instance = ClientService.getInstance() diff --git a/src/services/content-parser.service.ts b/src/services/content-parser.service.ts index cd1af4e..6c5a0af 100644 --- a/src/services/content-parser.service.ts +++ b/src/services/content-parser.service.ts @@ -518,11 +518,11 @@ class ContentParserService { let processed = content // Process bookstr macro wikilinks: [[book:...]] where ... can be any book type and reference + // These should be converted to a special marker that will be processed in HTML processed = processed.replace(/\[\[book:([^\]]+)\]\]/g, (_match, bookContent) => { const cleanContent = bookContent.trim() - const dTag = this.normalizeDtag(cleanContent) - - return `wikilink:${dTag}[${cleanContent}]` + // Use a passthrough marker that will be converted to HTML placeholder in processWikilinksInHtml + return `BOOKSTR:${cleanContent}` }) // Process standard wikilinks: [[Target Page]] or [[target page|see this]] @@ -553,6 +553,13 @@ class ContentParserService { private processWikilinksInHtml(html: string): string { let processed = html + // Convert bookstr markers to HTML placeholders + processed = processed.replace(/BOOKSTR:([^<>\s]+)/g, (_match, bookContent) => { + // Escape special characters for HTML attributes + const escaped = bookContent.replace(/"/g, '"').replace(/'/g, ''') + return `` + }) + // Convert hashtag links to HTML with green styling processed = processed.replace(/hashtag:([^[]+)\[([^\]]+)\]/g, (_match, normalizedHashtag, displayText) => { return `${displayText}` diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index 697ebba..2c30887 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -12,7 +12,7 @@ type TValue = { masterPublicationKey?: string // For nested publication events, link to master publication } -const StoreNames = { +export const StoreNames = { PROFILE_EVENTS: 'profileEvents', RELAY_LIST_EVENTS: 'relayListEvents', FOLLOW_LIST_EVENTS: 'followListEvents',