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.
 
 
 
 

240 lines
7.8 KiB

import { ExtendedKind } from '@/constants'
import {
batchFetchPublicationSectionEvents,
parsePublicationATagCoordinate,
type PublicationSectionRef
} from '@/lib/publication-section-fetch'
import type { Event } from 'nostr-tools'
import { verifyEvent } from 'nostr-tools'
/** Normalize kind-30040 rows before signature check (NKBIP-01 empty content; lowercase hex). */
export function publicationIndexForVerify(event: Event): Event {
return {
...event,
id: event.id.toLowerCase(),
pubkey: event.pubkey.toLowerCase(),
content: event.content ?? ''
}
}
/** True when the event is a kind-30040 index and the signature matches the tag array. */
export function isVerifiedPublicationIndex(event: Event): boolean {
if (event.kind !== ExtendedKind.PUBLICATION) return false
return verifyEvent(publicationIndexForVerify(event))
}
export function eventTagAddress(event: Event): string | null {
const d = event.tags.find((t) => (t[0] || '').trim().toLowerCase() === 'd')?.[1]
if (!d) return null
return `${event.kind}:${event.pubkey.toLowerCase()}:${d}`
}
/** Removes kind 30040 index events that don't comply with NKBIP-01. */
export function filterValidIndexEvents(events: Event[]): Event[] {
return events.filter((event) => {
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) && isVerifiedPublicationIndex(event)
})
}
export function collectPublicationATagRefs(event: Event): PublicationSectionRef[] {
const refs: PublicationSectionRef[] = []
for (const tag of event.tags) {
if (tag[0] !== 'a' || !tag[1]) continue
const parsed = parsePublicationATagCoordinate(tag[1])
if (!parsed) continue
refs.push({
type: 'a',
coordinate: parsed.coordinate,
kind: parsed.kind,
pubkey: parsed.pubkey,
identifier: parsed.identifier,
relay: tag[2]
})
}
return refs
}
export function collectChildAddressesFromIndex(event: Event): string[] {
const out: string[] = []
for (const ref of collectPublicationATagRefs(event)) {
if (
ref.kind === ExtendedKind.PUBLICATION ||
ref.kind === ExtendedKind.PUBLICATION_CONTENT
) {
out.push(ref.coordinate!)
}
}
return out
}
export function getReferencedChild30040Addresses(events: Event[]): Set<string> {
const referenced = new Set<string>()
for (const event of events) {
for (const tag of event.tags) {
if (tag[0] !== 'a' || !tag[1]) continue
const parts = tag[1].split(':')
if (parts.length >= 3 && parts[0] === String(ExtendedKind.PUBLICATION)) {
referenced.add(tag[1])
}
}
}
return referenced
}
export function getTopLevelIndexEvents(events: Event[]): Event[] {
const referenced = getReferencedChild30040Addresses(events)
return events.filter((event) => {
const addr = eventTagAddress(event)
return addr && !referenced.has(addr)
})
}
export function buildIndexByAddress(events: Event[]): Map<string, Event> {
const map = new Map<string, Event>()
for (const event of events) {
const addr = eventTagAddress(event)
if (!addr) continue
const prev = map.get(addr)
if (!prev || event.created_at > prev.created_at) {
map.set(addr, event)
}
}
return map
}
/** BFS over addresses already present in `indexByAddress` (no network I/O). */
export function collectReachableAddressesCached(
root: Event,
indexByAddress: Map<string, Event>
): Set<string> {
const reachable = new Set<string>()
const rootAddr = eventTagAddress(root)
if (!rootAddr) return reachable
const queue = [rootAddr]
while (queue.length > 0) {
const addr = queue.shift()!
if (reachable.has(addr)) continue
reachable.add(addr)
const event = indexByAddress.get(addr)
if (!event || event.kind !== ExtendedKind.PUBLICATION) continue
for (const child of collectChildAddressesFromIndex(event)) {
if (!reachable.has(child)) queue.push(child)
}
}
return reachable
}
export function collectPublicationIndexEventIds(events: Event[]): Set<string> {
return new Set(events.map((ev) => ev.id.toLowerCase()))
}
export type HydrateNestedIndexOptions = {
maxPasses?: number
/** Cap missing nested 30040 fetches per pass (library bulk load). */
maxMissingPerPass?: number
/** When set, only scan these roots for missing nested 30040 refs. */
scanRoots?: Event[]
}
/** Batch-fetch nested kind 30040 indexes referenced by `a` tags but missing from cache. */
export async function hydrateNestedIndexEvents(
indexEvents: Event[],
indexByAddress: Map<string, Event>,
relayUrls: string[],
options?: HydrateNestedIndexOptions | number
): Promise<void> {
const opts: HydrateNestedIndexOptions =
typeof options === 'number' ? { maxPasses: options } : (options ?? {})
const maxPasses = opts.maxPasses ?? 2
const maxMissingPerPass = opts.maxMissingPerPass
const scanEvents = opts.scanRoots ?? indexEvents
for (let pass = 0; pass < maxPasses; pass++) {
const missingRefs: PublicationSectionRef[] = []
const seenCoords = new Set<string>()
for (const event of scanEvents) {
if (event.kind !== ExtendedKind.PUBLICATION) continue
for (const ref of collectPublicationATagRefs(event)) {
if (ref.kind !== ExtendedKind.PUBLICATION || !ref.coordinate) continue
if (indexByAddress.has(ref.coordinate) || seenCoords.has(ref.coordinate)) continue
seenCoords.add(ref.coordinate)
missingRefs.push(ref)
if (maxMissingPerPass != null && missingRefs.length >= maxMissingPerPass) break
}
if (maxMissingPerPass != null && missingRefs.length >= maxMissingPerPass) break
}
if (missingRefs.length === 0) break
const fetched = await batchFetchPublicationSectionEvents(missingRefs, relayUrls)
let added = 0
for (const ev of fetched.values()) {
const addr = eventTagAddress(ev)
if (!addr || indexByAddress.has(addr)) continue
indexByAddress.set(addr, ev)
indexEvents.push(ev)
added++
}
if (added === 0) break
}
}
export async function collectReachableAddresses(
root: Event,
indexByAddress: Map<string, Event>,
fetchMissingIndex: (address: string) => Promise<Event | null>
): Promise<Set<string>> {
const reachable = new Set<string>()
const rootAddr = eventTagAddress(root)
if (!rootAddr) return reachable
const queue = [rootAddr]
while (queue.length > 0) {
const addr = queue.shift()!
if (reachable.has(addr)) continue
reachable.add(addr)
let event = indexByAddress.get(addr)
if (!event) {
const parsed = parsePublicationATagCoordinate(addr)
if (parsed?.kind === ExtendedKind.PUBLICATION) {
event = (await fetchMissingIndex(addr)) ?? undefined
if (event) indexByAddress.set(addr, event)
}
}
if (!event || event.kind !== ExtendedKind.PUBLICATION) continue
for (const child of collectChildAddressesFromIndex(event)) {
if (!reachable.has(child)) queue.push(child)
}
}
return reachable
}
export async function fetchMissingIndexByAddress(
address: string,
relayUrls: string[]
): Promise<Event | null> {
const parsed = parsePublicationATagCoordinate(address)
if (!parsed || parsed.kind !== ExtendedKind.PUBLICATION) return null
const ref: PublicationSectionRef = {
type: 'a',
coordinate: parsed.coordinate,
kind: parsed.kind,
pubkey: parsed.pubkey,
identifier: parsed.identifier
}
const fetched = await batchFetchPublicationSectionEvents([ref], relayUrls)
return fetched.get(parsed.coordinate) ?? null
}