Browse Source

bug-fix publications

imwald
Silberengel 1 week ago
parent
commit
5e9a6fa419
  1. 4
      src/components/Note/PublicationCoverFallback.tsx
  2. 13
      src/components/Note/PublicationCoverImage.tsx
  3. 4
      src/components/Note/PublicationIndexBody.tsx
  4. 1
      src/constants.ts
  5. 105
      src/hooks/useLibraryPublications.ts
  6. 9
      src/lib/library-index-idb-cache.ts
  7. 436
      src/lib/library-publication-index.ts
  8. 10
      src/lib/publication-asciidoc-assembler.ts
  9. 33
      src/lib/publication-index.ts
  10. 104
      src/lib/publication-section-fetch.ts
  11. 17
      src/lib/publication-section-tree.test.ts
  12. 13
      src/lib/publication-section-tree.ts
  13. 23
      src/lib/roman-numeral-display.test.ts
  14. 19
      src/lib/roman-numeral-display.ts
  15. 5
      src/pages/primary/LibraryPage/index.tsx

4
src/components/Note/PublicationCoverFallback.tsx

@ -17,7 +17,9 @@ export default function PublicationCoverFallback({
const isLibrary = size === 'library' const isLibrary = size === 'library'
const maxClass = isLibrary ? LIBRARY_PUBLICATION_COVER_MAX_CLASS : PUBLICATION_COVER_MAX_CLASS const maxClass = isLibrary ? LIBRARY_PUBLICATION_COVER_MAX_CLASS : PUBLICATION_COVER_MAX_CLASS
const stackedLayoutClass = isLibrary ? 'aspect-[3/4] w-full' : 'aspect-[3/4] w-48 max-w-full' const stackedLayoutClass = isLibrary
? 'h-[200px] w-[200px] max-h-[200px] max-w-[200px]'
: 'aspect-[3/4] w-48 max-w-full'
return ( return (
<div <div

13
src/components/Note/PublicationCoverImage.tsx

@ -4,8 +4,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
import Image from '../Image' import Image from '../Image'
import PublicationCoverFallback from './PublicationCoverFallback' import PublicationCoverFallback from './PublicationCoverFallback'
/** Max cover height in the library grid (3-column cards). */ /** Library grid: larger axis capped at 200px; aspect ratio preserved (no crop). */
export const LIBRARY_PUBLICATION_COVER_MAX_CLASS = 'max-h-48' export const LIBRARY_PUBLICATION_COVER_MAX_CLASS = 'max-h-[200px] max-w-[200px]'
/** Max cover box in publication detail / note panel (larger axis capped at 400px). */ /** Max cover box in publication detail / note panel (larger axis capped at 400px). */
export const PUBLICATION_COVER_MAX_CLASS = 'max-h-[400px] max-w-[400px]' export const PUBLICATION_COVER_MAX_CLASS = 'max-h-[400px] max-w-[400px]'
@ -53,7 +53,7 @@ export default function PublicationCoverImage({
return <PublicationCoverFallback layout={layout} size={size} className={className} /> return <PublicationCoverFallback layout={layout} size={size} className={className} />
} }
const stackedLayoutClass = isLibrary ? 'aspect-[3/4] w-full' : 'w-fit' const stackedLayoutClass = isLibrary ? 'w-fit max-w-full' : 'w-fit'
// Library grid: always load covers (user opened Bibliothek). Tap-to-reveal on the card would // Library grid: always load covers (user opened Bibliothek). Tap-to-reveal on the card would
// fight PublicationCard navigation, leaving blurhash placeholders stuck forever. // fight PublicationCard navigation, leaving blurhash placeholders stuck forever.
@ -62,11 +62,12 @@ export default function PublicationCoverImage({
return ( return (
<div <div
className={cn( className={cn(
'flex shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted', 'flex shrink-0 items-center justify-center rounded-lg bg-muted',
maxClass, maxClass,
layout === 'stacked' ? stackedLayoutClass : 'aspect-[3/4] w-full max-w-[9rem] sm:max-w-[10rem]', layout === 'stacked' ? stackedLayoutClass : 'aspect-[3/4] w-full max-w-[9rem] sm:max-w-[10rem]',
layout === 'stacked' && size === 'default' && 'mb-3', layout === 'stacked' && size === 'default' && 'mb-3',
layout === 'stacked' && isLibrary && 'mb-2', layout === 'stacked' && isLibrary && 'mb-2',
!isLibrary && 'overflow-hidden',
className className
)} )}
onClick={holdCoverUntilClick ? (e) => e.stopPropagation() : undefined} onClick={holdCoverUntilClick ? (e) => e.stopPropagation() : undefined}
@ -74,8 +75,8 @@ export default function PublicationCoverImage({
<Image <Image
key={activeUrl} key={activeUrl}
image={{ url: activeUrl, pubkey }} image={{ url: activeUrl, pubkey }}
className={cn(maxClass, 'h-auto w-auto max-w-full object-contain')} className={cn(maxClass, 'h-auto w-auto object-contain')}
classNames={{ wrapper: cn('block max-w-full', isLibrary ? 'w-full' : 'w-fit') }} classNames={{ wrapper: cn('block w-fit max-w-full') }}
hideIfError hideIfError
onFinalError={handleImageError} onFinalError={handleImageError}
holdUntilClick={holdCoverUntilClick} holdUntilClick={holdCoverUntilClick}

4
src/components/Note/PublicationIndexBody.tsx

@ -1,7 +1,7 @@
import AsciidocArticle from '@/components/Note/AsciidocArticle/AsciidocArticle' import AsciidocArticle from '@/components/Note/AsciidocArticle/AsciidocArticle'
import MarkdownArticle from '@/components/Note/MarkdownArticle/MarkdownArticle' import MarkdownArticle from '@/components/Note/MarkdownArticle/MarkdownArticle'
import NoteOptions from '@/components/NoteOptions' import NoteOptions from '@/components/NoteOptions'
import { ExtendedKind, FAST_READ_RELAY_URLS } from '@/constants' import { DOCUMENT_RELAY_URLS, ExtendedKind, FAST_READ_RELAY_URLS, LIBRARY_RELAY_URLS } from '@/constants'
import { orderedPublicationRefsFromIndex } from '@/lib/publication-asciidoc-assembler' import { orderedPublicationRefsFromIndex } from '@/lib/publication-asciidoc-assembler'
import { fetchPublicationTreeForExport } from '@/lib/publication-export' import { fetchPublicationTreeForExport } from '@/lib/publication-export'
import { import {
@ -145,6 +145,8 @@ export default function PublicationIndexBody({
() => () =>
Array.from( Array.from(
new Set([ new Set([
...LIBRARY_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url),
...DOCUMENT_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url),
...currentBrowsingRelayUrls.map((url) => normalizeAnyRelayUrl(url) || url), ...currentBrowsingRelayUrls.map((url) => normalizeAnyRelayUrl(url) || url),
...favoriteRelays.map((url) => normalizeAnyRelayUrl(url) || url), ...favoriteRelays.map((url) => normalizeAnyRelayUrl(url) || url),
...FAST_READ_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url) ...FAST_READ_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url)

1
src/constants.ts

@ -463,6 +463,7 @@ export const BOOKSTR_RELAY_URLS = [
*/ */
export const DOCUMENT_RELAY_URLS = [ export const DOCUMENT_RELAY_URLS = [
'wss://thecitadel.nostr1.com', 'wss://thecitadel.nostr1.com',
'wss://theforest.nostr1.com',
'wss://relay.wikifreedia.xyz', 'wss://relay.wikifreedia.xyz',
'wss://essayist.decentnewsroom.com' 'wss://essayist.decentnewsroom.com'
] as const ] as const

105
src/hooks/useLibraryPublications.ts

@ -72,8 +72,9 @@ export function useLibraryPublications(isActive: boolean) {
const [pinListEvent, setPinListEvent] = useState<Event | null>(null) const [pinListEvent, setPinListEvent] = useState<Event | null>(null)
const [myBooklistTargets, setMyBooklistTargets] = useState(EMPTY_BOOKLIST_TARGETS) const [myBooklistTargets, setMyBooklistTargets] = useState(EMPTY_BOOKLIST_TARGETS)
const [booklistTargetsLoading, setBooklistTargetsLoading] = useState(false) const [booklistTargetsLoading, setBooklistTargetsLoading] = useState(false)
const loadGenRef = useRef(0) const [reloadNonce, setReloadNonce] = useState(0)
const indexesReadyGenRef = useRef(0) const forceRefreshNextLoadRef = useRef(false)
const indexesReadyRef = useRef(false)
const [mineIndexEntries, setMineIndexEntries] = useState<LibraryPublicationEntry[]>([]) const [mineIndexEntries, setMineIndexEntries] = useState<LibraryPublicationEntry[]>([])
const [mineFilterComputing, setMineFilterComputing] = useState(false) const [mineFilterComputing, setMineFilterComputing] = useState(false)
const mineIndexCacheRef = useRef<{ const mineIndexCacheRef = useRef<{
@ -147,69 +148,95 @@ export function useLibraryPublications(isActive: boolean) {
[] []
) )
const load = useCallback( const applyIndexesSnapshot = useCallback(
async (forceRefresh = false) => { (
const gen = ++loadGenRef.current snapshot: {
setLoading(true) indexEvents: Event[]
setEngagementLoading(false) allIndexCount: number
setError(null) topLevelCount: number
setFeedPageIndex(0) },
if (import.meta.env.DEV) { engagementMaps: PublicationEngagementMaps,
logger.info('[Library] page load requested', { forceRefresh, gen }) pageIndex: number
} ) => {
setIndexEvents(snapshot.indexEvents)
setAllIndexCount(snapshot.allIndexCount)
setTopLevelCount(snapshot.topLevelCount)
applyDefaultFeedSlice(snapshot.indexEvents, engagementMaps, pageIndex)
},
[applyDefaultFeedSlice]
)
useEffect(() => {
if (!isActive) return
let cancelled = false
indexesReadyRef.current = false
setLoading(true)
setEngagementLoading(false)
setError(null)
setFeedPageIndex(0)
const forceRefresh = forceRefreshNextLoadRef.current
forceRefreshNextLoadRef.current = false
if (import.meta.env.DEV) {
logger.info('[Library] page load requested', { forceRefresh, reloadNonce })
}
void (async () => {
try { try {
const relays = await buildLibraryRelayUrls(pubkey || undefined, blockedRelays ?? []) const relays = await buildLibraryRelayUrls(pubkey || undefined, blockedRelays ?? [])
indexesReadyGenRef.current = 0 if (cancelled) return
const result = await loadLibraryPublicationIndex(relays, { const result = await loadLibraryPublicationIndex(relays, {
forceRefresh, forceRefresh,
viewerPubkey: pubkey || undefined, viewerPubkey: pubkey || undefined,
onIndexesReady: (snapshot) => { onIndexesReady: (snapshot) => {
if (gen !== loadGenRef.current) return if (cancelled) return
indexesReadyGenRef.current = gen indexesReadyRef.current = true
setIndexEvents(snapshot.indexEvents) if (import.meta.env.DEV && snapshot.indexEvents.length > 0) {
setAllIndexCount(snapshot.allIndexCount) logger.info('[Library] indexes ready (progress)', {
setTopLevelCount(snapshot.topLevelCount) validCount: snapshot.indexEvents.length,
applyDefaultFeedSlice(snapshot.indexEvents, EMPTY_ENGAGEMENT, 0) topLevelCount: snapshot.topLevelCount,
entryCount: snapshot.engaged.length
})
}
applyIndexesSnapshot(snapshot, EMPTY_ENGAGEMENT, 0)
setLoading(false) setLoading(false)
setEngagementLoading(true) setEngagementLoading(true)
} }
}) })
if (gen !== loadGenRef.current) return if (cancelled) return
setIndexEvents(result.indexEvents) applyIndexesSnapshot(result, result.engagement, 0)
setEngagement(result.engagement) setEngagement(result.engagement)
setAllIndexCount(result.allIndexCount)
setTopLevelCount(result.topLevelCount)
applyDefaultFeedSlice(result.indexEvents, result.engagement, 0)
} catch (e) { } catch (e) {
if (gen !== loadGenRef.current) return if (cancelled) return
if (indexesReadyGenRef.current === gen) { if (indexesReadyRef.current) {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
logger.warn('[Library] engagement phase failed after indexes loaded', { logger.warn('[Library] engagement phase failed after indexes loaded', {
message: e instanceof Error ? e.message : String(e), message: e instanceof Error ? e.message : String(e)
gen
}) })
} }
} else { } else {
const message = e instanceof Error ? e.message : 'Failed to load library' const message = e instanceof Error ? e.message : 'Failed to load library'
setError(message) setError(message)
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
logger.warn('[Library] page load failed', { message, gen }) logger.warn('[Library] page load failed', { message })
} }
} }
} finally { } finally {
if (gen === loadGenRef.current) { if (!cancelled) {
setLoading(false) setLoading(false)
setEngagementLoading(false) setEngagementLoading(false)
} }
} }
}, })()
[pubkey, blockedRelays, applyDefaultFeedSlice]
)
useEffect(() => { return () => {
if (!isActive) return cancelled = true
void load(false) }
}, [isActive, load]) }, [isActive, pubkey, blockedRelays, reloadNonce, applyIndexesSnapshot])
const refresh = useCallback(() => {
forceRefreshNextLoadRef.current = true
void clearAllLibraryIndexCaches().then(() => setReloadNonce((n) => n + 1))
}, [])
useEffect(() => { useEffect(() => {
if (!isActive || !pubkey || indexEvents.length === 0) return if (!isActive || !pubkey || indexEvents.length === 0) return
@ -266,10 +293,6 @@ export function useLibraryPublications(isActive: boolean) {
} }
}, [debouncedSearch, indexEvents, engagement]) }, [debouncedSearch, indexEvents, engagement])
const refresh = useCallback(() => {
void clearAllLibraryIndexCaches().then(() => load(true))
}, [load])
const searchOnRelays = useCallback(async () => { const searchOnRelays = useCallback(async () => {
const q = searchQuery.trim() const q = searchQuery.trim()
if (!q) return if (!q) return

9
src/lib/library-index-idb-cache.ts

@ -4,20 +4,21 @@ import {
getLibraryIndexCacheBudget getLibraryIndexCacheBudget
} from '@/lib/library-index-cache-config' } from '@/lib/library-index-cache-config'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { isVerifiedPublicationIndex } from '@/lib/publication-index' import { filterStructuralIndexEvents } from '@/lib/publication-index'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
export async function loadLibraryIndexCacheEvents(): Promise<Event[]> { export async function loadLibraryIndexCacheEvents(): Promise<Event[]> {
try { try {
const cached = await indexedDb.getLibraryPublicationIndexCacheEvents() const cached = await indexedDb.getLibraryPublicationIndexCacheEvents()
const verified = cached.filter(isVerifiedPublicationIndex) // IDB rows were verified on write; structural re-check only (avoid ~5k verifyEvent on read).
if (verified.length < cached.length) { const structural = filterStructuralIndexEvents(cached)
if (structural.length < cached.length) {
void indexedDb void indexedDb
.pruneUnverifiedLibraryPublicationIndexCacheEvents() .pruneUnverifiedLibraryPublicationIndexCacheEvents()
.catch(() => {}) .catch(() => {})
} }
return verified return structural
} catch (e) { } catch (e) {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
logger.warn('[Library] index IDB read failed', { logger.warn('[Library] index IDB read failed', {

436
src/lib/library-publication-index.ts

@ -23,6 +23,7 @@ import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/eve
import { verifyEvent } from 'nostr-tools' import { verifyEvent } from 'nostr-tools'
import { isEventInPinList } from '@/lib/replaceable-list-latest' import { isEventInPinList } from '@/lib/replaceable-list-latest'
import { isRelayBlockedByUser } from '@/lib/relay-blocked' import { isRelayBlockedByUser } from '@/lib/relay-blocked'
import { stripLocalNetworkRelaysForWssReq } from '@/lib/relay-list-sanitize'
import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' import { buildComprehensiveRelayList } from '@/lib/relay-list-builder'
import { import {
clearLibraryIndexIdbCache, clearLibraryIndexIdbCache,
@ -41,9 +42,12 @@ import { queryService } from '@/services/client.service'
import type { Event, Filter } from 'nostr-tools' import type { Event, Filter } from 'nostr-tools'
import { kinds, nip19 } from 'nostr-tools' import { kinds, nip19 } from 'nostr-tools'
const INDEX_FETCH_LIMIT = 500 const INDEX_WS_PAGE_LIMIT = 500
const INDEX_HTTP_PAGE_LIMIT = 100 const INDEX_HTTP_PAGE_LIMIT = 100
const INDEX_HTTP_MAX_PAGES = 5 /** Cursor pages per relay for library index bulk load (up to ~50k WS / ~10k HTTP rows each). */
const INDEX_MAX_PAGES_PER_RELAY = 100
/** verifyEvent batch size — yield between chunks so the main thread stays responsive. */
const INDEX_VERIFY_CHUNK = 80
const ENGAGEMENT_ADDRESS_CHUNK = 36 const ENGAGEMENT_ADDRESS_CHUNK = 36
const ENGAGEMENT_EVENT_ID_CHUNK = 44 const ENGAGEMENT_EVENT_ID_CHUNK = 44
const MAX_TARGET_ADDRESSES = 480 const MAX_TARGET_ADDRESSES = 480
@ -56,11 +60,21 @@ export const LIBRARY_RELAY_SEARCH_LIMIT = 100
const LIBRARY_RELAY_SEARCH_TIMEOUT_MS = 28_000 const LIBRARY_RELAY_SEARCH_TIMEOUT_MS = 28_000
/** NIP-51 pin list (kind 10001). */ /** NIP-51 pin list (kind 10001). */
const PIN_LIST_KIND = 10001 const PIN_LIST_KIND = 10001
const QUERY_OPTS = { /** Per-relay WS page fetch — one relay at a time avoids multi-relay onclose resolving after ~1s. */
globalTimeout: 18_000, const LIBRARY_INDEX_QUERY_OPTS = {
eoseTimeout: 3_000, globalTimeout: 45_000,
firstRelayResultGraceMs: false as const eoseTimeout: 8_000,
} firstRelayResultGraceMs: false as const,
foreground: true
} as const
/** First-page batch: unblock the library grid quickly. */
const LIBRARY_INDEX_FIRST_PAGE_OPTS = {
globalTimeout: 12_000,
eoseTimeout: 5_000,
firstRelayResultGraceMs: false as const,
foreground: true
} as const
const ENGAGEMENT_QUERY_OPTS = { const ENGAGEMENT_QUERY_OPTS = {
globalTimeout: 45_000, globalTimeout: 45_000,
@ -117,6 +131,56 @@ type LibraryIndexCache = {
let sessionCache: LibraryIndexCache | null = null let sessionCache: LibraryIndexCache | null = null
type LibraryIndexLoadSnapshot = {
engaged: LibraryPublicationEntry[]
allIndexCount: number
topLevelCount: number
indexEvents: Event[]
}
type LibraryIndexLoadResult = LibraryIndexLoadSnapshot & {
engagement: PublicationEngagementMaps
}
type LibraryIndexLoadJob = {
relayKey: string
forceRefresh: boolean
promise: Promise<LibraryIndexLoadResult>
onIndexesReadyListeners: Array<(snapshot: LibraryIndexLoadSnapshot) => void>
lastProgressEvents: Event[] | null
}
let indexLoadJob: LibraryIndexLoadJob | null = null
function emitIndexesReadySnapshot(
listeners: Array<(snapshot: LibraryIndexLoadSnapshot) => void>,
indexEvents: Event[]
) {
if (listeners.length === 0) return
const indexByAddress = buildIndexByAddress(indexEvents)
const topLevel = getTopLevelIndexEvents(indexEvents)
const snapshot: LibraryIndexLoadSnapshot = {
engaged: buildRecentPublicationEntries(topLevel, indexByAddress, emptyPublicationEngagementMaps()),
allIndexCount: indexEvents.length,
topLevelCount: topLevel.length,
indexEvents
}
for (const listener of listeners) {
listener(snapshot)
}
}
function registerIndexesReadyListener(
job: LibraryIndexLoadJob,
listener?: (snapshot: LibraryIndexLoadSnapshot) => void
) {
if (!listener) return
job.onIndexesReadyListeners.push(listener)
if (job.lastProgressEvents) {
emitIndexesReadySnapshot([listener], job.lastProgressEvents)
}
}
type LibrarySearchSessionRow = { type LibrarySearchSessionRow = {
fingerprint: string fingerprint: string
entries: LibraryPublicationEntry[] entries: LibraryPublicationEntry[]
@ -238,16 +302,120 @@ function chunkArray<T>(items: T[], size: number): T[][] {
return out return out
} }
async function fetchPaginatedFromHttpIndexRelay(baseUrl: string, filter: Filter): Promise<Event[]> { function isLibraryDeepIndexRelay(url: string): boolean {
const key = canonicalRelaySessionKey(normalizeLibraryRelayUrl(url) || url)
return key !== '' && LIBRARY_DEEP_INDEX_RELAY_KEYS.has(key)
}
function oldestCreatedAt(events: Event[]): number {
let oldest = Number.MAX_SAFE_INTEGER
for (const ev of events) {
if (ev.created_at < oldest) oldest = ev.created_at
}
return oldest
}
function mergeIndexPageBatch(out: Event[], seen: Set<string>, batch: Event[]): number {
let oldest = batch[0]?.created_at ?? Number.MAX_SAFE_INTEGER
for (const ev of batch) {
if (ev.created_at < oldest) oldest = ev.created_at
if (seen.has(ev.id)) continue
seen.add(ev.id)
out.push(ev)
}
return oldest
}
async function fetchWsIndexFirstPage(wsRelay: string, filter: Filter): Promise<Event[]> {
const pageFilter: Filter = { ...filter, limit: INDEX_WS_PAGE_LIMIT }
try {
return await queryService.fetchEvents([wsRelay], [pageFilter], LIBRARY_INDEX_FIRST_PAGE_OPTS)
} catch (e) {
if (import.meta.env.DEV) {
logger.warn('[Library] WS index first page failed', {
wsRelay,
message: e instanceof Error ? e.message : String(e)
})
}
return []
}
}
async function fetchHttpIndexFirstPage(baseUrl: string, filter: Filter): Promise<Event[]> {
const pageFilter: Filter = { ...filter, limit: INDEX_HTTP_PAGE_LIMIT }
try {
const pageResult = await queryIndexRelayForLibrary(baseUrl, pageFilter)
return pageResult.events
} catch (e) {
if (import.meta.env.DEV) {
logger.warn('[Library] HTTP index first page failed', {
baseUrl,
message: e instanceof Error ? e.message : String(e)
})
}
return []
}
}
async function fetchRemainingPagesFromWsIndexRelay(
wsRelay: string,
filter: Filter,
firstPage: Event[]
): Promise<Event[]> {
if (firstPage.length < INDEX_WS_PAGE_LIMIT) return []
const out: Event[] = [] const out: Event[] = []
const seen = new Set<string>() const seen = new Set(firstPage.map((ev) => ev.id))
let until: number | undefined let until = oldestCreatedAt(firstPage) - 1
if (until < 0) return []
for (let page = 0; page < INDEX_HTTP_MAX_PAGES; page++) { for (let page = 1; page < INDEX_MAX_PAGES_PER_RELAY; page++) {
const pageFilter: Filter = {
...filter,
limit: INDEX_WS_PAGE_LIMIT,
until
}
let batch: Event[] = []
try {
batch = await queryService.fetchEvents([wsRelay], [pageFilter], LIBRARY_INDEX_QUERY_OPTS)
} catch (e) {
if (import.meta.env.DEV) {
logger.warn('[Library] WS index page failed', {
wsRelay,
page,
message: e instanceof Error ? e.message : String(e)
})
}
break
}
if (batch.length === 0) break
const oldest = mergeIndexPageBatch(out, seen, batch)
if (batch.length < INDEX_WS_PAGE_LIMIT) break
if (oldest === Number.MAX_SAFE_INTEGER) break
until = oldest - 1
}
return out
}
async function fetchRemainingPagesFromHttpIndexRelay(
baseUrl: string,
filter: Filter,
firstPage: Event[]
): Promise<Event[]> {
if (firstPage.length === 0) return []
const out: Event[] = []
const seen = new Set(firstPage.map((ev) => ev.id))
let until = oldestCreatedAt(firstPage) - 1
if (until < 0) return []
for (let page = 1; page < INDEX_MAX_PAGES_PER_RELAY; page++) {
const pageFilter: Filter = { const pageFilter: Filter = {
...filter, ...filter,
limit: INDEX_HTTP_PAGE_LIMIT, limit: INDEX_HTTP_PAGE_LIMIT,
...(until != null ? { until } : {}) until
} }
let batch: Event[] = [] let batch: Event[] = []
let apiRowCount = 0 let apiRowCount = 0
@ -267,14 +435,7 @@ async function fetchPaginatedFromHttpIndexRelay(baseUrl: string, filter: Filter)
} }
if (apiRowCount === 0) break if (apiRowCount === 0) break
let oldest = batch[0]?.created_at ?? Number.MAX_SAFE_INTEGER const oldest = mergeIndexPageBatch(out, seen, batch)
for (const ev of batch) {
if (ev.created_at < oldest) oldest = ev.created_at
if (seen.has(ev.id)) continue
seen.add(ev.id)
out.push(ev)
}
if (apiRowCount < INDEX_HTTP_PAGE_LIMIT) break if (apiRowCount < INDEX_HTTP_PAGE_LIMIT) break
if (oldest === Number.MAX_SAFE_INTEGER) break if (oldest === Number.MAX_SAFE_INTEGER) break
until = oldest - 1 until = oldest - 1
@ -291,6 +452,12 @@ function normalizeLibraryRelayUrl(url: string): string {
return normalizeUrl(trimmed) || trimmed return normalizeUrl(trimmed) || trimmed
} }
const LIBRARY_DEEP_INDEX_RELAY_KEYS = new Set(
LIBRARY_RELAY_URLS.map((url) =>
canonicalRelaySessionKey(normalizeLibraryRelayUrl(url) || url)
).filter(Boolean)
)
function filterBlockedLibraryRelays(urls: string[], blockedRelays: readonly string[] = []): string[] { function filterBlockedLibraryRelays(urls: string[], blockedRelays: readonly string[] = []): string[] {
if (blockedRelays.length === 0) return urls if (blockedRelays.length === 0) return urls
return urls.filter((url) => !isRelayBlockedByUser(url, blockedRelays)) return urls.filter((url) => !isRelayBlockedByUser(url, blockedRelays))
@ -379,50 +546,147 @@ function engagementMapsSizeSummary(maps: PublicationEngagementMaps): Record<stri
} }
} }
export async function fetchLibraryIndexEvents(relayUrls: string[]): Promise<Event[]> { export type FetchLibraryIndexEventsOptions = {
/** Called when IDB cache and each network batch are ready — unblocks the library grid early. */
onProgress?: (events: Event[]) => void
}
async function filterValidNewIndexEvents(
incoming: Event[],
knownIds: ReadonlySet<string>
): Promise<Event[]> {
const novel = incoming.filter((event) => !knownIds.has(event.id))
if (novel.length === 0) return []
const out: Event[] = []
for (let i = 0; i < novel.length; i += INDEX_VERIFY_CHUNK) {
out.push(...filterValidIndexEvents(novel.slice(i, i + INDEX_VERIFY_CHUNK)))
if (i + INDEX_VERIFY_CHUNK < novel.length) {
await new Promise<void>((resolve) => {
setTimeout(resolve, 0)
})
}
}
return out
}
async function mergeValidIndexBatch(
existing: Event[],
knownIds: Set<string>,
incoming: Event[]
): Promise<Event[]> {
const newValid = await filterValidNewIndexEvents(incoming, knownIds)
if (newValid.length === 0) return existing
for (const event of newValid) knownIds.add(event.id)
return dedupeEventsById([...existing, ...newValid])
}
export async function fetchLibraryIndexEvents(
relayUrls: string[],
options?: FetchLibraryIndexEventsOptions
): Promise<Event[]> {
const indexRelays = libraryIndexRelayUrls(relayUrls) const indexRelays = libraryIndexRelayUrls(relayUrls)
if (indexRelays.length === 0) return [] if (indexRelays.length === 0) return []
const cached = await loadLibraryIndexCacheEvents() const cached = await loadLibraryIndexCacheEvents()
const filter: Filter = { kinds: [ExtendedKind.PUBLICATION], limit: INDEX_FETCH_LIMIT } let validMerged = dedupeEventsById(cached)
const { wsRelays, httpRelays } = splitWsAndHttpRelays(indexRelays) const knownValidIds = new Set(validMerged.map((event) => event.id))
const emitProgress = () => {
options?.onProgress?.(validMerged)
}
emitProgress()
const filter: Filter = { kinds: [ExtendedKind.PUBLICATION], limit: INDEX_WS_PAGE_LIMIT }
const { wsRelays: rawWsRelays, httpRelays } = splitWsAndHttpRelays(indexRelays)
const wsRelays = stripLocalNetworkRelaysForWssReq(rawWsRelays)
const firstPageByRelay = new Map<string, Event[]>()
const firstPagePromises: Promise<{ relay: string; events: Event[] }>[] = [
...wsRelays.map(async (wsRelay) => {
const events = await fetchWsIndexFirstPage(wsRelay, filter)
return { relay: wsRelay, events }
}),
...httpRelays.map(async (httpRelay) => {
const events = await fetchHttpIndexFirstPage(httpRelay, filter)
return { relay: httpRelay, events }
})
]
const batches: Promise<Event[]>[] = [] const firstSettled = await Promise.allSettled(firstPagePromises)
if (wsRelays.length > 0) { for (const result of firstSettled) {
batches.push( if (result.status !== 'fulfilled') continue
queryService.fetchEvents(wsRelays, [filter], QUERY_OPTS).catch((e) => { firstPageByRelay.set(result.value.relay, result.value.events)
if (import.meta.env.DEV) {
logger.warn('[Library] WS index fetch failed', {
message: e instanceof Error ? e.message : String(e)
})
}
return [] as Event[]
})
)
}
for (const httpRelay of httpRelays) {
batches.push(fetchPaginatedFromHttpIndexRelay(httpRelay, filter))
} }
const settled = await Promise.allSettled(batches) const firstPageNetwork = dedupeEventsById(
const networkMerged = dedupeEventsById( firstSettled.flatMap((r) => (r.status === 'fulfilled' ? r.value.events : []))
settled.flatMap((r) => (r.status === 'fulfilled' ? r.value : []))
) )
const merged = dedupeEventsById([...cached, ...networkMerged]) validMerged = await mergeValidIndexBatch(validMerged, knownValidIds, firstPageNetwork)
const valid = filterValidIndexEvents(merged) void persistLibraryIndexCacheEvents(validMerged)
void persistLibraryIndexCacheEvents(valid) emitProgress()
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
logger.info('[Library] index fetch', { const perRelayFirstPageCounts = firstSettled.map((r) =>
r.status === 'fulfilled'
? { relay: r.value.relay, count: r.value.events.length }
: { relay: 'unknown', count: 0 }
)
logger.info('[Library] index first page', {
indexRelays: indexRelays.length, indexRelays: indexRelays.length,
wsRelays: wsRelays.length, wsRelays: wsRelays.length,
httpRelays: httpRelays.length, httpRelays: httpRelays.length,
strippedWsRelays: rawWsRelays.length - wsRelays.length,
cachedCount: cached.length, cachedCount: cached.length,
networkCount: networkMerged.length, firstPageCount: firstPageNetwork.length,
mergedCount: merged.length, mergedCount: validMerged.length,
validCount: valid.length validCount: validMerged.length,
topLevelCount: getTopLevelIndexEvents(validMerged).length,
perRelayCounts: perRelayFirstPageCounts
}) })
} }
return valid
const deepBatches: Promise<{ relay: string; events: Event[] }>[] = []
for (const wsRelay of wsRelays) {
if (!isLibraryDeepIndexRelay(wsRelay)) continue
deepBatches.push(
fetchRemainingPagesFromWsIndexRelay(wsRelay, filter, firstPageByRelay.get(wsRelay) ?? []).then(
(events) => ({ relay: wsRelay, events })
)
)
}
for (const httpRelay of httpRelays) {
if (!isLibraryDeepIndexRelay(httpRelay)) continue
deepBatches.push(
fetchRemainingPagesFromHttpIndexRelay(
httpRelay,
filter,
firstPageByRelay.get(httpRelay) ?? []
).then((events) => ({ relay: httpRelay, events }))
)
}
const deepSettled = await Promise.allSettled(deepBatches)
const deepNetwork = dedupeEventsById(
deepSettled.flatMap((r) => (r.status === 'fulfilled' ? r.value.events : []))
)
validMerged = await mergeValidIndexBatch(validMerged, knownValidIds, deepNetwork)
void persistLibraryIndexCacheEvents(validMerged)
emitProgress()
if (import.meta.env.DEV) {
const perRelayDeepCounts = deepSettled.map((r) =>
r.status === 'fulfilled'
? { relay: r.value.relay, count: r.value.events.length }
: { relay: 'unknown', count: 0 }
)
logger.info('[Library] index fetch complete', {
deepPageCount: deepNetwork.length,
mergedCount: validMerged.length,
validCount: validMerged.length,
topLevelCount: getTopLevelIndexEvents(validMerged).length,
perRelayDeepCounts
})
}
return validMerged
} }
export function buildEngagementMapsFromEvents( export function buildEngagementMapsFromEvents(
@ -1749,26 +2013,58 @@ export async function loadLibraryPublicationIndex(
forceRefresh?: boolean forceRefresh?: boolean
viewerPubkey?: string | null viewerPubkey?: string | null
/** Called as soon as kind-30040 indexes are loaded — before engagement (which can take minutes). */ /** Called as soon as kind-30040 indexes are loaded — before engagement (which can take minutes). */
onIndexesReady?: (snapshot: { onIndexesReady?: (snapshot: LibraryIndexLoadSnapshot) => void
engaged: LibraryPublicationEntry[]
allIndexCount: number
topLevelCount: number
indexEvents: Event[]
}) => void
} }
): Promise<{ ): Promise<LibraryIndexLoadResult> {
engaged: LibraryPublicationEntry[] const relayKey = relaySetKey(relayUrls)
allIndexCount: number const forceRefresh = options?.forceRefresh ?? false
topLevelCount: number
indexEvents: Event[] if (!forceRefresh && indexLoadJob?.relayKey === relayKey && !indexLoadJob.forceRefresh) {
engagement: PublicationEngagementMaps registerIndexesReadyListener(indexLoadJob, options?.onIndexesReady)
}> { if (import.meta.env.DEV) {
const key = relaySetKey(relayUrls) logger.info('[Library] load joined in-flight', {
relayCount: relayUrls.length,
hasProgress: indexLoadJob.lastProgressEvents != null
})
}
return indexLoadJob.promise
}
const job: LibraryIndexLoadJob = {
relayKey,
forceRefresh,
onIndexesReadyListeners: [],
lastProgressEvents: null,
promise: Promise.resolve(null as unknown as LibraryIndexLoadResult)
}
registerIndexesReadyListener(job, options?.onIndexesReady)
indexLoadJob = job
job.promise = runLibraryPublicationIndexLoad(relayUrls, options, job).finally(() => {
if (indexLoadJob === job) indexLoadJob = null
})
return job.promise
}
async function runLibraryPublicationIndexLoad(
relayUrls: string[],
options: {
forceRefresh?: boolean
viewerPubkey?: string | null
onIndexesReady?: (snapshot: LibraryIndexLoadSnapshot) => void
} | undefined,
job: LibraryIndexLoadJob
): Promise<LibraryIndexLoadResult> {
const key = job.relayKey
const viewerPubkey = options?.viewerPubkey ?? null const viewerPubkey = options?.viewerPubkey ?? null
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
logger.info('[Library] load start', { relayCount: relayUrls.length, cached: sessionCache?.relayKey === key }) logger.info('[Library] load start', { relayCount: relayUrls.length, cached: sessionCache?.relayKey === key })
} }
const emitIndexesReady = (indexEvents: Event[]) => {
job.lastProgressEvents = indexEvents
emitIndexesReadySnapshot(job.onIndexesReadyListeners, indexEvents)
}
if (!options?.forceRefresh && sessionCache?.relayKey === key) { if (!options?.forceRefresh && sessionCache?.relayKey === key) {
if (sessionCache.viewerPubkey !== viewerPubkey) { if (sessionCache.viewerPubkey !== viewerPubkey) {
const targetAddresses = collectTargetAddressesFromIndexes( const targetAddresses = collectTargetAddressesFromIndexes(
@ -1802,6 +2098,7 @@ export async function loadLibraryPublicationIndex(
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
logger.info('[Library] load from cache', { engaged: engaged.length }) logger.info('[Library] load from cache', { engaged: engaged.length })
} }
emitIndexesReady(sessionCache.indexEvents)
return { return {
engaged, engaged,
allIndexCount: sessionCache.indexEvents.length, allIndexCount: sessionCache.indexEvents.length,
@ -1811,7 +2108,9 @@ export async function loadLibraryPublicationIndex(
} }
} }
const indexEvents = await fetchLibraryIndexEvents(relayUrls) const indexEvents = await fetchLibraryIndexEvents(relayUrls, {
onProgress: emitIndexesReady
})
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
logger.info('[Library] indexes fetched', { validCount: indexEvents.length }) logger.info('[Library] indexes fetched', { validCount: indexEvents.length })
} }
@ -1819,12 +2118,7 @@ export async function loadLibraryPublicationIndex(
const indexByAddress = buildIndexByAddress(indexEvents) const indexByAddress = buildIndexByAddress(indexEvents)
let topLevel = getTopLevelIndexEvents(indexEvents) let topLevel = getTopLevelIndexEvents(indexEvents)
options?.onIndexesReady?.({ emitIndexesReady(indexEvents)
engaged: buildRecentPublicationEntries(topLevel, indexByAddress, emptyPublicationEngagementMaps()),
allIndexCount: indexEvents.length,
topLevelCount: topLevel.length,
indexEvents
})
const topLevelForHydrate = topLevel const topLevelForHydrate = topLevel
await hydrateNestedIndexEvents(indexEvents, indexByAddress, relayUrls, { await hydrateNestedIndexEvents(indexEvents, indexByAddress, relayUrls, {
@ -1895,12 +2189,14 @@ export async function loadLibraryPublicationIndex(
export function clearLibraryPublicationIndexCache(): void { export function clearLibraryPublicationIndexCache(): void {
sessionCache = null sessionCache = null
indexLoadJob = null
clearLibrarySearchSessionCache() clearLibrarySearchSessionCache()
} }
/** Clears Library tab session + IDB index cache only (publication reading cache is unchanged). */ /** Clears Library tab session + IDB index cache only (publication reading cache is unchanged). */
export async function clearAllLibraryIndexCaches(): Promise<void> { export async function clearAllLibraryIndexCaches(): Promise<void> {
sessionCache = null sessionCache = null
indexLoadJob = null
clearLibrarySearchSessionCache() clearLibrarySearchSessionCache()
await clearLibraryIndexIdbCache() await clearLibraryIndexIdbCache()
} }

10
src/lib/publication-asciidoc-assembler.ts

@ -10,6 +10,7 @@ import {
publicationRefKey, publicationRefKey,
type PublicationSectionRef type PublicationSectionRef
} from '@/lib/publication-section-fetch' } from '@/lib/publication-section-fetch'
import { uppercaseRomanNumeralsInText } from '@/lib/roman-numeral-display'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
const MAX_NEST_DEPTH = 8 const MAX_NEST_DEPTH = 8
@ -40,7 +41,8 @@ function tagValue(event: Event, name: string): string | undefined {
} }
function titleFromIndex(event: Event): string { function titleFromIndex(event: Event): string {
return tagValue(event, 'title') || tagValue(event, 'd') || 'Publication' const raw = tagValue(event, 'title') || tagValue(event, 'd') || 'Publication'
return uppercaseRomanNumeralsInText(raw)
} }
function authorFromMetadata(metadata: PublicationIndexMetadata, pubkey: string): string { function authorFromMetadata(metadata: PublicationIndexMetadata, pubkey: string): string {
@ -121,7 +123,7 @@ function appendIndexBody(
if (!sectionTitle && ref.coordinate) { if (!sectionTitle && ref.coordinate) {
sectionTitle = ref.coordinate.split(':').slice(2).join(':') sectionTitle = ref.coordinate.split(':').slice(2).join(':')
} }
if (sectionTitle) parts.push(heading(headingLevel, sectionTitle)) if (sectionTitle) parts.push(heading(headingLevel, uppercaseRomanNumeralsInText(sectionTitle)))
const body = article.content.trim() const body = article.content.trim()
if (body) parts.push(`${body}\n\n`) if (body) parts.push(`${body}\n\n`)
@ -129,7 +131,9 @@ function appendIndexBody(
} else if (ref.type === 'e') { } else if (ref.type === 'e') {
const article = resolveRefEvent(ref, fetched) const article = resolveRefEvent(ref, fetched)
if (!article) continue if (!article) continue
const sectionTitle = tagValue(article, 'title')?.trim() || 'Section' const sectionTitle = uppercaseRomanNumeralsInText(
tagValue(article, 'title')?.trim() || 'Section'
)
parts.push(heading(headingLevel, sectionTitle)) parts.push(heading(headingLevel, sectionTitle))
const body = article.content.trim() const body = article.content.trim()
if (body) parts.push(`${body}\n\n`) if (body) parts.push(`${body}\n\n`)

33
src/lib/publication-index.ts

@ -29,19 +29,28 @@ export function eventTagAddress(event: Event): string | null {
return `${event.kind}:${event.pubkey.toLowerCase()}:${d}` return `${event.kind}:${event.pubkey.toLowerCase()}:${d}`
} }
/** Removes kind 30040 index events that don't comply with NKBIP-01. */ /** NKBIP-01 shape checks only — no signature verification (cheap for large IDB reads). */
export function isStructuralPublicationIndex(event: Event): boolean {
if (event.kind !== ExtendedKind.PUBLICATION) return false
if ((event.content ?? '') !== '') return false
const hasTitle = event.tags.some(
(t) => (t[0] || '').trim().toLowerCase() === 'title' && t[1]
)
const hasD = event.tags.some((t) => (t[0] || '').trim().toLowerCase() === 'd' && t[1])
const hasA = event.tags.some((t) => t[0] === 'a' && t[1])
const hasE = event.tags.some((t) => t[0] === 'e' && t[1])
return hasTitle && hasD && (hasA || hasE)
}
export function filterStructuralIndexEvents(events: Event[]): Event[] {
return events.filter(isStructuralPublicationIndex)
}
/** Removes kind 30040 index events that don't comply with NKBIP-01 (includes signature check). */
export function filterValidIndexEvents(events: Event[]): Event[] { export function filterValidIndexEvents(events: Event[]): Event[] {
return events.filter((event) => { return events.filter(
if (event.kind !== ExtendedKind.PUBLICATION) return false (event) => isStructuralPublicationIndex(event) && isVerifiedPublicationIndex(event)
if ((event.content ?? '') !== '') return false )
const hasTitle = event.tags.some(
(t) => (t[0] || '').trim().toLowerCase() === 'title' && t[1]
)
const hasD = event.tags.some((t) => (t[0] || '').trim().toLowerCase() === 'd' && t[1])
const hasA = event.tags.some((t) => t[0] === 'a' && t[1])
const hasE = event.tags.some((t) => t[0] === 'e' && t[1])
return hasTitle && hasD && (hasA || hasE) && isVerifiedPublicationIndex(event)
})
} }
export function collectPublicationATagRefs(event: Event): PublicationSectionRef[] { export function collectPublicationATagRefs(event: Event): PublicationSectionRef[] {

104
src/lib/publication-section-fetch.ts

@ -1,11 +1,12 @@
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { FAST_READ_RELAY_URLS } from '@/constants' import { DOCUMENT_RELAY_URLS, ExtendedKind, FAST_READ_RELAY_URLS } from '@/constants'
import { publicationCoordinateLookupKeys, splitPublicationCoordinate } from '@/lib/publication-coordinate' import { publicationCoordinateLookupKeys, splitPublicationCoordinate } from '@/lib/publication-coordinate'
import { buildComprehensiveRelayList } from '@/lib/relay-list-builder' import { buildComprehensiveRelayList } from '@/lib/relay-list-builder'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import client, { queryService } from '@/services/client.service' import client, { queryService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import type { Event, Filter } from 'nostr-tools' import type { Event, Filter } from 'nostr-tools'
import { nip19 } from 'nostr-tools' import { kinds, nip19 } from 'nostr-tools'
export type PublicationSectionRef = { export type PublicationSectionRef = {
type: 'a' | 'e' type: 'a' | 'e'
@ -65,6 +66,47 @@ function collectRelayHints(refs: PublicationSectionRef[]): string[] {
return [...new Set(out)] return [...new Set(out)]
} }
const PUBLICATION_SECTION_QUERY_OPTS = {
globalTimeout: 22_000,
eoseTimeout: 5_000,
/** Document relays (thecitadel, etc.) are often slower than fast-read mirrors. */
firstRelayResultGraceMs: 4_000,
foreground: true
} as const
const PUBLICATION_CONTENT_KINDS = [
ExtendedKind.PUBLICATION_CONTENT,
ExtendedKind.PUBLICATION,
ExtendedKind.WIKI_ARTICLE,
kinds.LongFormArticle
] as const
async function seedSectionsFromLocalCache(
refs: PublicationSectionRef[]
): Promise<Map<string, Event>> {
const out = new Map<string, Event>()
for (const ref of refs) {
const key = publicationRefKey(ref)
if (!key || out.has(key)) continue
try {
if (ref.type === 'a' && ref.coordinate) {
const ev = await indexedDb.getPublicationEvent(ref.coordinate)
if (ev) out.set(key, ev)
} else if (ref.type === 'e' && ref.eventId) {
const hex = resolvePublicationEventIdToHex(ref.eventId)
if (!hex) continue
const ev =
(await indexedDb.getEventFromPublicationStore(hex)) ??
(await client.fetchEvent(hex).catch(() => undefined))
if (ev) out.set(key, ev)
}
} catch {
// ignore per-ref cache misses
}
}
return out
}
export async function buildPublicationSectionRelayUrls( export async function buildPublicationSectionRelayUrls(
indexEvent: Event, indexEvent: Event,
refs: PublicationSectionRef[], refs: PublicationSectionRef[],
@ -72,6 +114,7 @@ export async function buildPublicationSectionRelayUrls(
includeSearchableRelays = false includeSearchableRelays = false
): Promise<string[]> { ): Promise<string[]> {
const hints = collectRelayHints(refs) const hints = collectRelayHints(refs)
const documentRelays = DOCUMENT_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter((u) => !!u)
const fastReadRelays = FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter((u) => !!u) const fastReadRelays = FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter((u) => !!u)
const seenOnRelays = queryService const seenOnRelays = queryService
.getSeenEventRelayUrls(indexEvent.id) .getSeenEventRelayUrls(indexEvent.id)
@ -80,7 +123,7 @@ export async function buildPublicationSectionRelayUrls(
const urls = await buildComprehensiveRelayList({ const urls = await buildComprehensiveRelayList({
authorPubkey: indexEvent.pubkey, authorPubkey: indexEvent.pubkey,
userPubkey: client.pubkey || undefined, userPubkey: client.pubkey || undefined,
relayHints: [...hints, ...seenOnRelays], relayHints: [...documentRelays, ...hints, ...seenOnRelays],
includeUserOwnRelays: true, includeUserOwnRelays: true,
includeProfileFetchRelays: true, includeProfileFetchRelays: true,
includeFastReadRelays: true, includeFastReadRelays: true,
@ -88,8 +131,10 @@ export async function buildPublicationSectionRelayUrls(
includeFavoriteRelays: true, includeFavoriteRelays: true,
includeLocalRelays: true includeLocalRelays: true
}) })
// Keep fast-read relays pinned at the front so slicing can never drop them. // Pin document relays first — 30040/30041 content lives there, not on fast-read mirrors.
const prioritized = [...new Set([...fastReadRelays, ...hints, ...seenOnRelays, ...urls])] const prioritized = [
...new Set([...documentRelays, ...hints, ...seenOnRelays, ...fastReadRelays, ...urls])
]
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
logger.info('[PublicationSection] relay_urls_built', { logger.info('[PublicationSection] relay_urls_built', {
indexId: indexEvent.id, indexId: indexEvent.id,
@ -129,14 +174,19 @@ export async function batchFetchPublicationSectionEvents(
refs: PublicationSectionRef[], refs: PublicationSectionRef[],
relayUrls: string[] relayUrls: string[]
): Promise<Map<string, Event>> { ): Promise<Map<string, Event>> {
const out = new Map<string, Event>() const out = await seedSectionsFromLocalCache(refs)
if (refs.length === 0 || relayUrls.length === 0) return out if (refs.length === 0) return out
const unresolvedForNetwork = refs.filter((r) => !out.has(publicationRefKey(r)))
if (unresolvedForNetwork.length === 0 || relayUrls.length === 0) return out
const eRefs: PublicationSectionRef[] = [] const eRefs: PublicationSectionRef[] = []
const eHexByKey = new Map<string, string>() const eHexByKey = new Map<string, string>()
const aRefs = refs.filter((r) => r.type === 'a' && r.coordinate && r.pubkey && typeof r.kind === 'number') const aRefs = unresolvedForNetwork.filter(
(r) => r.type === 'a' && r.coordinate && r.pubkey && typeof r.kind === 'number'
)
for (const ref of refs) { for (const ref of unresolvedForNetwork) {
// Only explicit `e` refs are resolved by id. For `a` refs, tag[3] is historization metadata only. // Only explicit `e` refs are resolved by id. For `a` refs, tag[3] is historization metadata only.
if (ref.type !== 'e' || !ref.eventId) continue if (ref.type !== 'e' || !ref.eventId) continue
const key = publicationRefKey(ref) const key = publicationRefKey(ref)
@ -199,11 +249,7 @@ export async function batchFetchPublicationSectionEvents(
let events: Event[] = [] let events: Event[] = []
if (filters.length > 0) { if (filters.length > 0) {
try { try {
events = await queryService.fetchEvents(relayUrls, filters, { events = await queryService.fetchEvents(relayUrls, filters, PUBLICATION_SECTION_QUERY_OPTS)
globalTimeout: 12_000,
eoseTimeout: 2_000,
firstRelayResultGraceMs: false
})
} catch (err) { } catch (err) {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
logger.warn('[PublicationSection] batch_fetch_error', { logger.warn('[PublicationSection] batch_fetch_error', {
@ -437,12 +483,22 @@ export async function batchFetchPublicationSectionEvents(
} }
g.dTags.push(d) g.dTags.push(d)
} }
const kindsForFallback = [
...new Set(
unresolvedAfterHint
.map((r) => r.kind)
.filter((k): k is number => typeof k === 'number' && k > 0)
)
]
const fallbackKinds =
kindsForFallback.length > 0 ? kindsForFallback : [...PUBLICATION_CONTENT_KINDS]
for (const g of groups.values()) { for (const g of groups.values()) {
const uniqueD = [...new Set(g.dTags)] const uniqueD = [...new Set(g.dTags)]
for (let i = 0; i < uniqueD.length; i += D_CHUNK) { for (let i = 0; i < uniqueD.length; i += D_CHUNK) {
const dChunk = uniqueD.slice(i, i + D_CHUNK) const dChunk = uniqueD.slice(i, i + D_CHUNK)
fallbackFilters.push({ fallbackFilters.push({
authors: [g.pubkey], authors: [g.pubkey],
kinds: fallbackKinds,
'#d': dChunk, '#d': dChunk,
limit: dChunk.length * ANY_KIND_LIMIT_PER_D limit: dChunk.length * ANY_KIND_LIMIT_PER_D
}) })
@ -457,11 +513,11 @@ export async function batchFetchPublicationSectionEvents(
}) })
} }
try { try {
const fallbackEvents = await queryService.fetchEvents(relayUrls, fallbackFilters, { const fallbackEvents = await queryService.fetchEvents(
globalTimeout: 10_000, relayUrls,
eoseTimeout: 2_000, fallbackFilters,
firstRelayResultGraceMs: false PUBLICATION_SECTION_QUERY_OPTS
}) )
const byAuthorD = new Map<string, Event[]>() const byAuthorD = new Map<string, Event[]>()
for (const ev of fallbackEvents) { for (const ev of fallbackEvents) {
const d = dTagOf(ev) const d = dTagOf(ev)
@ -533,11 +589,11 @@ export async function batchFetchPublicationSectionEvents(
}) })
} }
try { try {
const scanEvents = await queryService.fetchEvents(relayUrls, scanFilters, { const scanEvents = await queryService.fetchEvents(
globalTimeout: 12_000, relayUrls,
eoseTimeout: 2_000, scanFilters,
firstRelayResultGraceMs: false PUBLICATION_SECTION_QUERY_OPTS
}) )
const scanByCoord = new Map<string, Event>() const scanByCoord = new Map<string, Event>()
for (const ev of scanEvents) { for (const ev of scanEvents) {
const coord = coordinateOfEvent(ev) const coord = coordinateOfEvent(ev)

17
src/lib/publication-section-tree.test.ts

@ -117,6 +117,23 @@ describe('buildPublicationSectionTree', () => {
expect(contentOrder).toEqual(toc.map((e) => e.title)) expect(contentOrder).toEqual(toc.map((e) => e.title))
}) })
it('uppercases Roman numerals in section titles', () => {
const c1 = `30041:${PK}:chapter-iii`
const root = indexEvent(
[
['d', 'book'],
['title', 'Book'],
['a', c1]
],
'root-id'
)
const ch1 = sectionEvent('chapter-iii', 'Chapitre Iii', 'ch1-id')
const fetched = new Map<string, Event>([[c1, ch1]])
const tree = buildPublicationSectionTree(root, fetched)
expect(tree[0]?.title).toBe('Chapitre III')
})
it('orderedPublicationRefsFromIndex assigns tagOrder in tag-list sequence', () => { it('orderedPublicationRefsFromIndex assigns tagOrder in tag-list sequence', () => {
const root = indexEvent( const root = indexEvent(
[ [

13
src/lib/publication-section-tree.ts

@ -1,5 +1,6 @@
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { eventTagAddress } from '@/lib/publication-index' import { eventTagAddress } from '@/lib/publication-index'
import { uppercaseRomanNumeralsInText } from '@/lib/roman-numeral-display'
import { orderedPublicationRefsFromIndex } from '@/lib/publication-asciidoc-assembler' import { orderedPublicationRefsFromIndex } from '@/lib/publication-asciidoc-assembler'
import { import {
publicationRefKey, publicationRefKey,
@ -65,6 +66,10 @@ function humanizeIdentifier(identifier: string): string | undefined {
return identifier.replace(/-/g, ' ') return identifier.replace(/-/g, ' ')
} }
function finalizeSectionTitle(title: string): string {
return uppercaseRomanNumeralsInText(title)
}
export function sectionTitle( export function sectionTitle(
ref: PublicationSectionRef, ref: PublicationSectionRef,
event: Event | undefined, event: Event | undefined,
@ -72,23 +77,23 @@ export function sectionTitle(
): string { ): string {
if (event) { if (event) {
const title = tagValue(event, 'title') const title = tagValue(event, 'title')
if (title) return title if (title) return finalizeSectionTitle(title)
const dTag = tagValue(event, 'd') const dTag = tagValue(event, 'd')
const humanizedD = dTag ? humanizeIdentifier(dTag) : undefined const humanizedD = dTag ? humanizeIdentifier(dTag) : undefined
if (humanizedD) return humanizedD if (humanizedD) return finalizeSectionTitle(humanizedD)
} }
const coordinate = coordinateForRef(ref, event) const coordinate = coordinateForRef(ref, event)
if (coordinate) { if (coordinate) {
const label = labelMap.get(coordinate) const label = labelMap.get(coordinate)
if (label) return label if (label) return finalizeSectionTitle(label)
} }
const identifier = const identifier =
ref.identifier ?? ref.identifier ??
(coordinate ? coordinate.split(':').slice(2).join(':') : undefined) (coordinate ? coordinate.split(':').slice(2).join(':') : undefined)
const humanized = identifier ? humanizeIdentifier(identifier) : undefined const humanized = identifier ? humanizeIdentifier(identifier) : undefined
if (humanized) return humanized if (humanized) return finalizeSectionTitle(humanized)
return 'Section' return 'Section'
} }

23
src/lib/roman-numeral-display.test.ts

@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { isRomanNumeralToken, uppercaseRomanNumeralsInText } from '@/lib/roman-numeral-display'
describe('roman-numeral-display', () => {
it('detects common chapter numerals', () => {
expect(isRomanNumeralToken('iii')).toBe(true)
expect(isRomanNumeralToken('Iv')).toBe(true)
expect(isRomanNumeralToken('XII')).toBe(true)
expect(isRomanNumeralToken('Introduction')).toBe(false)
})
it('uppercases title-cased Roman numerals in section titles', () => {
expect(uppercaseRomanNumeralsInText('Chapitre Ii')).toBe('Chapitre II')
expect(uppercaseRomanNumeralsInText('Chapitre Iii')).toBe('Chapitre III')
expect(uppercaseRomanNumeralsInText('Chapitre Iv')).toBe('Chapitre IV')
expect(uppercaseRomanNumeralsInText('Chapitre Vi')).toBe('Chapitre VI')
expect(uppercaseRomanNumeralsInText('Chapitre Vii')).toBe('Chapitre VII')
expect(uppercaseRomanNumeralsInText('Chapitre Viii')).toBe('Chapitre VIII')
expect(uppercaseRomanNumeralsInText('Chapitre Ix')).toBe('Chapitre IX')
expect(uppercaseRomanNumeralsInText('Chapter XII')).toBe('Chapter XII')
expect(uppercaseRomanNumeralsInText('Introduction')).toBe('Introduction')
})
})

19
src/lib/roman-numeral-display.ts

@ -0,0 +1,19 @@
/** Token is letters I/V/X/L/C/D/M only (case-insensitive). */
const ROMAN_LETTERS_ONLY = /^[mdclxvi]+$/i
/** Standard 1–3999 Roman numeral grammar. */
const ROMAN_NUMERAL_GRAMMAR =
/^(?=[mdclxvi])m{0,4}(cm|cd|d?c{0,3})(xc|xl|l?x{0,3})(ix|iv|v?i{0,3})$/i
export function isRomanNumeralToken(word: string): boolean {
if (!word || !ROMAN_LETTERS_ONLY.test(word)) return false
return ROMAN_NUMERAL_GRAMMAR.test(word)
}
/** Uppercase Roman-numeral words in titles (e.g. "Chapitre Iii" → "Chapitre III"). */
export function uppercaseRomanNumeralsInText(text: string): string {
return text.replace(/\b([mdclxvi]{1,8})\b/gi, (match, word: string) => {
if (!isRomanNumeralToken(word)) return match
return word.toUpperCase()
})
}

5
src/pages/primary/LibraryPage/index.tsx

@ -97,7 +97,10 @@ const LibraryPage = forwardRef<TPageRef>((_props, ref) => {
) : null} ) : null}
<LibraryPublicationGrid <LibraryPublicationGrid
entries={entries} entries={entries}
loading={(loading && entries.length === 0) || (showOnlyMine && mineFilterLoading)} loading={
(loading && entries.length === 0 && !hasIndexData) ||
(showOnlyMine && mineFilterLoading)
}
emptyMessage={ emptyMessage={
searchQuery.trim() || showOnlyMine ? t('Library empty filtered') : t('Library empty') searchQuery.trim() || showOnlyMine ? t('Library empty filtered') : t('Library empty')
} }

Loading…
Cancel
Save