Browse Source

30040 e-books

master
Silberengel 1 month ago
parent
commit
13e136d89a
  1. 7
      src/lib/components/content/MarkdownRenderer.svelte
  2. 182
      src/lib/modules/events/EventView.svelte
  3. 11
      src/lib/services/nostr/config.ts
  4. 279
      src/lib/services/nostr/event-index-loader.ts

7
src/lib/components/content/MarkdownRenderer.svelte

@ -465,11 +465,13 @@ @@ -465,11 +465,13 @@
const isKind30040 = $derived(event?.kind === 30040);
// Load highlights for event
// Highlights are loaded for all content events (kind 30041, kind 1, etc.)
// but NOT for kind 30040 index events (which have no content)
async function loadHighlights(abortSignal: AbortSignal) {
if (!event || !content) return;
// Skip highlights for kind 30040 index events (they have no content)
// Highlights should be loaded for the indexed sections instead
// Highlights should be loaded for the indexed sections (kind 30041) instead
if (isKind30040 && !content.trim()) {
if (!abortSignal.aborted) {
highlightsLoaded = true;
@ -486,7 +488,8 @@ @@ -486,7 +488,8 @@
}
try {
// Load highlights for this specific event (which could be a section event)
// Load highlights for this specific event (kind 30041, kind 1, etc.)
// Highlights will be displayed with user badges and links to the highlight events
const dTag = event.tags.find(t => t[0] === 'd' && t[1])?.[1];
const eventHighlights = await getHighlightsForEvent(
event.id,

182
src/lib/modules/events/EventView.svelte

@ -63,6 +63,7 @@ @@ -63,6 +63,7 @@
let totalItemCount = $derived(countTotalItems(eventIndexItems));
// Check if event is metadata-only (no content to display)
// Note: kind 30040 (Publication Index) is excluded because it should render its children
let isMetadataOnly = $derived(rootEvent ? (
rootEvent.kind === KIND.METADATA ||
rootEvent.kind === KIND.RELAY_LIST ||
@ -70,7 +71,7 @@ @@ -70,7 +71,7 @@
rootEvent.kind === KIND.BLOCKED_RELAYS ||
rootEvent.kind === KIND.LOCAL_RELAYS ||
rootEvent.kind === KIND.USER_STATUS ||
(rootEvent.content === '' && rootEvent.tags.length > 0)
(rootEvent.content === '' && rootEvent.tags.length > 0 && rootEvent.kind !== 30040)
) : false);
onMount(async () => {
@ -130,6 +131,15 @@ @@ -130,6 +131,15 @@
eventIndexItems = result.items;
missingEvents = result.missingEvents;
console.log(`Loaded ${result.items.length} events from index hierarchy`);
// Debug: log items with children
const itemsWithChildren = result.items.filter(item => item.children && item.children.length > 0);
console.log(`[EventView] Items with children: ${itemsWithChildren.length}`, itemsWithChildren.map(item => ({
id: item.event.id,
kind: item.event.kind,
level: item.level,
childrenCount: item.children?.length || 0,
title: item.event.tags.find(t => t[0] === 'title')?.[1]
})));
if (result.missingEvents.length > 0) {
console.warn(`[EventView] ${result.missingEvents.length} events are missing from the index hierarchy`);
}
@ -148,14 +158,114 @@ @@ -148,14 +158,114 @@
<p class="text-fog-text dark:text-fog-dark-text error-message">Error: {error}</p>
{:else if rootEvent}
<article class="event-view">
<!-- Display title prominently for kind 30040 (book index) -->
<!-- Display title and metadata for kind 30040 (book index) -->
{#if rootEvent.kind === 30040}
{@const titleTag = rootEvent.tags.find(t => t[0] === 'title')}
{@const authorTag = rootEvent.tags.find(t => t[0] === 'author')}
{@const typeTag = rootEvent.tags.find(t => t[0] === 'type')}
{@const versionTag = rootEvent.tags.find(t => t[0] === 'version')}
{@const publishedOnTag = rootEvent.tags.find(t => t[0] === 'published_on')}
{@const publishedByTag = rootEvent.tags.find(t => t[0] === 'published_by')}
{@const imageTag = rootEvent.tags.find(t => t[0] === 'image')}
{@const summaryTag = rootEvent.tags.find(t => t[0] === 'summary')}
{@const sourceTag = rootEvent.tags.find(t => t[0] === 'source')}
{@const iTags = rootEvent.tags.filter(t => t[0] === 'i')}
{@const tTags = rootEvent.tags.filter(t => t[0] === 't')}
{@const pTag = rootEvent.tags.find(t => t[0] === 'p')}
{@const ETag = rootEvent.tags.find(t => t[0] === 'E')}
{#if titleTag && titleTag[1]}
<h1 class="book-title text-3xl font-bold mb-6 text-fog-text dark:text-fog-dark-text">
{titleTag[1]}
</h1>
{/if}
<!-- Publication metadata -->
<div class="publication-metadata mb-6 space-y-2">
{#if authorTag && authorTag[1]}
<p class="text-fog-text dark:text-fog-dark-text">
<span class="font-semibold">Author:</span> {authorTag[1]}
</p>
{/if}
{#if typeTag && typeTag[1]}
<p class="text-fog-text dark:text-fog-dark-text">
<span class="font-semibold">Type:</span> {typeTag[1]}
</p>
{/if}
{#if versionTag && versionTag[1]}
<p class="text-fog-text dark:text-fog-dark-text">
<span class="font-semibold">Version:</span> {versionTag[1]}
</p>
{/if}
{#if publishedOnTag && publishedOnTag[1]}
<p class="text-fog-text dark:text-fog-dark-text">
<span class="font-semibold">Published:</span> {publishedOnTag[1]}
{#if publishedByTag && publishedByTag[1]}
<span> by {publishedByTag[1]}</span>
{/if}
</p>
{:else if publishedByTag && publishedByTag[1]}
<p class="text-fog-text dark:text-fog-dark-text">
<span class="font-semibold">Published by:</span> {publishedByTag[1]}
</p>
{/if}
{#if iTags.length > 0}
{@const plural = iTags.length > 1 ? 's' : ''}
<p class="text-fog-text dark:text-fog-dark-text">
<span class="font-semibold">Identifier{plural}:</span>
{#each iTags as iTag, index}
{iTag[1]}{#if index < iTags.length - 1}, {/if}
{/each}
</p>
{/if}
{#if tTags.length > 0}
{@const plural = tTags.length > 1 ? 's' : ''}
<p class="text-fog-text dark:text-fog-dark-text">
<span class="font-semibold">Topic{plural}:</span>
{#each tTags as tTag}
<span class="topic-tag">{tTag[1]}</span>
{/each}
</p>
{/if}
{#if sourceTag && sourceTag[1]}
<p class="text-fog-text dark:text-fog-dark-text">
<span class="font-semibold">Source:</span>
<a href={sourceTag[1]} target="_blank" rel="noopener noreferrer" class="text-blue-600 dark:text-blue-400 hover:underline">
{sourceTag[1]}
</a>
</p>
{/if}
{#if summaryTag && summaryTag[1]}
<p class="text-fog-text dark:text-fog-dark-text mt-4">
{summaryTag[1]}
</p>
{/if}
{#if imageTag && imageTag[1]}
<div class="publication-cover-image mt-4">
<img src={imageTag[1]} alt="Cover image for {titleTag?.[1] || 'publication'}" class="max-w-md rounded-lg shadow-lg" />
</div>
{/if}
{#if pTag && pTag[1]}
<p class="text-fog-text dark:text-fog-dark-text mt-4 text-sm italic">
<span class="font-semibold">Original author:</span>
<a href="/profile/{pTag[1]}" class="text-blue-600 dark:text-blue-400 hover:underline">
{pTag[1]}
</a>
{#if ETag && ETag[1]}
<span> (derivative work)</span>
{/if}
</p>
{/if}
</div>
{/if}
<!-- Display metadata-only events with MetadataCard -->
@ -191,12 +301,12 @@ @@ -191,12 +301,12 @@
{#snippet renderIndexItems(items: EventIndexItem[], parentLevel: number = 0)}
{#each items as item (item.event.id)}
<div class="event-index-item" data-index={item.order} data-level={item.level}>
<!-- Display title for nested kind 30040 indexes (chapter/subchapter indexes) -->
{#if item.event.kind === 30040 && item.level > 0}
<!-- Display title for kind 30040 indexes (chapter/subchapter indexes) -->
{#if item.event.kind === 30040}
{@const indexTitleTag = item.event.tags.find(t => t[0] === 'title')}
{#if indexTitleTag && indexTitleTag[1]}
{@const headingLevel = Math.min(2 + item.level, 6)}
{@const textSize = item.level === 1 ? 'xl' : item.level === 2 ? 'lg' : 'base'}
{@const textSize = item.level === 0 ? 'xl' : item.level === 1 ? 'lg' : 'base'}
{#if headingLevel === 2}
<h2 class="index-title text-{textSize} font-semibold mb-3 mt-4 text-fog-text dark:text-fog-dark-text">
{indexTitleTag[1]}
@ -219,10 +329,15 @@ @@ -219,10 +329,15 @@
</h6>
{/if}
{/if}
{/if}
<!-- For content events (not kind 30040 indexes), render the content -->
{#if item.event.kind !== 30040}
<!-- Recursively render children of kind 30040 indexes -->
{#if item.children && item.children.length > 0}
<div class="nested-index" style="margin-left: {item.level * 1.5}rem; margin-top: 1rem;">
{@render renderIndexItems(item.children, item.level)}
</div>
{/if}
{:else}
<!-- For content events (not kind 30040 indexes), render the content -->
<!-- Display chapter title prominently for kind 30041 (chapter sections) -->
{#if item.event.kind === 30041 || item.event.kind === 1 || item.event.kind === 30817}
{@const chapterTitleTag = item.event.tags.find(t => t[0] === 'title')}
@ -243,13 +358,13 @@ @@ -243,13 +358,13 @@
<CommentThread threadId={item.event.id} event={item.event} />
</div>
</div>
{/if}
<!-- Recursively render children if this is a nested index -->
{#if item.children && item.children.length > 0}
<div class="nested-index" style="margin-left: {item.level * 1.5}rem; margin-top: 1rem;">
{@render renderIndexItems(item.children, item.level)}
</div>
<!-- Recursively render children if this content event has nested children -->
{#if item.children && item.children.length > 0}
<div class="nested-index" style="margin-left: {item.level * 1.5}rem; margin-top: 1rem;">
{@render renderIndexItems(item.children, item.level)}
</div>
{/if}
{/if}
</div>
{/each}
@ -350,6 +465,41 @@ @@ -350,6 +465,41 @@
border-bottom-color: var(--fog-dark-border, #374151);
}
.publication-metadata {
padding: 1rem;
background: var(--fog-bg-secondary, #f9fafb);
border-radius: 0.5rem;
border: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .publication-metadata {
background: var(--fog-dark-bg-secondary, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
.topic-tag {
display: inline-block;
padding: 0.25rem 0.5rem;
margin: 0.125rem;
background: var(--fog-accent, #3b82f6);
color: white;
border-radius: 0.25rem;
font-size: 0.875rem;
}
:global(.dark) .topic-tag {
background: var(--fog-dark-accent, #2563eb);
}
.publication-cover-image {
margin-top: 1rem;
}
.publication-cover-image img {
max-width: 100%;
height: auto;
}
.chapter-title {
margin-top: 1.5rem;
margin-bottom: 0.75rem;

11
src/lib/services/nostr/config.ts

@ -5,17 +5,18 @@ @@ -5,17 +5,18 @@
const DEFAULT_RELAYS = [
'wss://theforest.nostr1.com',
'wss://nostr21.com',
'wss://nostr.land',
'wss://orly-relay.imwald.eu',
'wss://nostr.wine'
'wss://thecitadel.nostr1.com',
'wss://nostr.land'
];
const PROFILE_RELAYS = [
'wss://relay.damus.io',
'wss://aggr.nostr.land',
'wss://profiles.nostr1.com',
'wss://relay.primal.net'
'wss://relay.primal.net',
'wss://orly-relay.imwald.eu',
'wss://nostr.wine',
'wss://nostr21.com'
];
const THREAD_PUBLISH_RELAYS = [

279
src/lib/services/nostr/event-index-loader.ts

@ -38,6 +38,17 @@ export interface LoadEventIndexResult { @@ -38,6 +38,17 @@ export interface LoadEventIndexResult {
missingEvents: MissingEventInfo[];
}
/**
* Chunk an array into batches of specified size
*/
function chunkArray<T>(array: T[], chunkSize: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < array.length; i += chunkSize) {
chunks.push(array.slice(i, i + chunkSize));
}
return chunks;
}
/**
* Internal recursive function to load event index hierarchy
* @param opEvent The kind 30040 event to load
@ -122,85 +133,193 @@ async function loadEventIndexRecursive( @@ -122,85 +133,193 @@ async function loadEventIndexRecursive(
// Track which event IDs are already loaded by a-tags (a-tags take precedence)
const eventIdsLoadedByATags = new Set<string>();
// Batch fetch all a-tags and e-tags in parallel
// Load events by address (a-tags) - per NKBIP-01, a-tags are the standard method
// Format: ["a", "<kind:pubkey:dtag>", "<relay hint>", "<event id>"]
if (aTags.length > 0) {
for (const aTagInfo of aTags) {
const parts = aTagInfo.address.split(':');
// Collect all event IDs from a-tags for batch fetching
const aTagEventIds: string[] = [];
const aTagEventIdMap = new Map<string, ATagInfo>(); // eventId -> aTagInfo
// Collect all address queries for a-tags (group by kind/pubkey/d-tag)
const addressQueries: Array<{ kind: number; pubkey: string; dTag: string; aTagInfo: ATagInfo }> = [];
for (const aTagInfo of aTags) {
const parts = aTagInfo.address.split(':');
if (parts.length === 3) {
const kind = parseInt(parts[0], 10);
const pubkey = parts[1];
const dTag = parts[2];
if (!isNaN(kind) && pubkey && dTag) {
// If event ID is provided, add to batch fetch list
if (aTagInfo.eventId) {
aTagEventIds.push(aTagInfo.eventId);
aTagEventIdMap.set(aTagInfo.eventId, aTagInfo);
}
// Also add address query
addressQueries.push({ kind, pubkey, dTag, aTagInfo });
}
}
}
// Collect all relay hints from a-tags
const relayHintsForATags = new Set<string>();
for (const aTagInfo of aTags) {
if (aTagInfo.relayHint) {
relayHintsForATags.add(aTagInfo.relayHint);
}
}
const allRelaysForATags = relayHintsForATags.size > 0
? [...new Set([...Array.from(relayHintsForATags), ...relays])]
: relays;
// Batch fetch all a-tag event IDs in parallel (chunked to 100 per batch)
const BATCH_SIZE = 100;
const aTagEventsById: NostrEvent[] = [];
if (aTagEventIds.length > 0) {
const aTagEventIdChunks = chunkArray(aTagEventIds, BATCH_SIZE);
const aTagFetchPromises = aTagEventIdChunks.map(chunk =>
nostrClient.fetchEvents(
[{ ids: chunk, limit: chunk.length }],
allRelaysForATags,
{ useCache: true, cacheResults: true }
)
);
const aTagFetchResults = await Promise.all(aTagFetchPromises);
for (const result of aTagFetchResults) {
aTagEventsById.push(...result);
}
}
// Group address queries by (kind, pubkey) to batch fetch
const addressQueryGroups = new Map<string, Array<{ dTag: string; aTagInfo: ATagInfo }>>();
for (const query of addressQueries) {
const key = `${query.kind}:${query.pubkey}`;
if (!addressQueryGroups.has(key)) {
addressQueryGroups.set(key, []);
}
addressQueryGroups.get(key)!.push({ dTag: query.dTag, aTagInfo: query.aTagInfo });
}
// Batch fetch all addresses in parallel (chunked to 100 per batch)
const addressFetchPromises: Promise<NostrEvent[]>[] = [];
const addressFetchMap = new Map<string, ATagInfo>(); // query key -> aTagInfo
for (const [key, queries] of addressQueryGroups.entries()) {
const [kindStr, pubkey] = key.split(':');
const kind = parseInt(kindStr, 10);
// Chunk queries into batches of 100
const queryChunks = chunkArray(queries, BATCH_SIZE);
for (const queryChunk of queryChunks) {
const dTags = queryChunk.map(q => q.dTag);
// Create fetch promise for this chunk
const fetchPromise = nostrClient.fetchEvents(
[{ kinds: [kind], authors: [pubkey], '#d': dTags, limit: dTags.length }],
allRelaysForATags,
{ useCache: true, cacheResults: true }
);
addressFetchPromises.push(fetchPromise);
// Store mapping for each d-tag in this chunk
for (const query of queryChunk) {
addressFetchMap.set(`${key}:${query.dTag}`, query.aTagInfo);
}
}
}
// Wait for all address fetches in parallel
const addressFetchResults = await Promise.all(addressFetchPromises);
const allAddressEvents: NostrEvent[] = [];
for (const result of addressFetchResults) {
allAddressEvents.push(...result);
}
// Process a-tag results
const aTagResults = new Map<string, NostrEvent>(); // address or eventId -> event
// Process events fetched by ID
for (const event of aTagEventsById) {
const aTagInfo = aTagEventIdMap.get(event.id);
if (aTagInfo) {
const parts = aTagInfo.address.split(':');
if (parts.length === 3) {
const kind = parseInt(parts[0], 10);
const pubkey = parts[1];
const dTag = parts[2];
// Verify the event matches the address
if (event.kind === kind &&
event.pubkey === pubkey &&
event.tags.some(t => t[0] === 'd' && t[1] === dTag)) {
aTagResults.set(aTagInfo.address, event);
eventIdsLoadedByATags.add(event.id);
}
}
}
}
// Process events fetched by address
for (const event of allAddressEvents) {
const dTag = event.tags.find(t => t[0] === 'd')?.[1];
if (dTag) {
const key = `${event.kind}:${event.pubkey}:${dTag}`;
const aTagInfo = addressFetchMap.get(key);
if (aTagInfo && !aTagResults.has(aTagInfo.address)) {
// Get newest version if multiple found
const existing = aTagResults.get(aTagInfo.address);
if (!existing || event.created_at > existing.created_at) {
aTagResults.set(aTagInfo.address, event);
eventIdsLoadedByATags.add(event.id);
}
}
}
}
// Process all a-tag results
console.log(`[EventIndex] Processing ${aTags.length} a-tags, found ${aTagResults.size} events`);
for (const aTagInfo of aTags) {
const parts = aTagInfo.address.split(':');
if (parts.length === 3) {
const kind = parseInt(parts[0], 10);
const pubkey = parts[1];
const dTag = parts[2];
if (!isNaN(kind) && pubkey && dTag) {
const event = aTagResults.get(aTagInfo.address);
if (!isNaN(kind) && pubkey && dTag) {
let event: NostrEvent | undefined;
// If event ID is provided in a-tag (4th element), try to fetch by ID first for version tracking
if (aTagInfo.eventId) {
const eventsById = await nostrClient.fetchEvents(
[{ ids: [aTagInfo.eventId], limit: 1 }],
aTagInfo.relayHint ? [aTagInfo.relayHint] : relays,
{ useCache: true, cacheResults: true }
);
if (eventsById.length > 0) {
const fetchedEvent = eventsById[0];
// Verify the event matches the address (kind, pubkey, d-tag)
if (fetchedEvent.kind === kind &&
fetchedEvent.pubkey === pubkey &&
fetchedEvent.tags.some(t => t[0] === 'd' && t[1] === dTag)) {
event = fetchedEvent;
}
}
}
// If not found by event ID, or no event ID provided, fetch by address
if (!event) {
const fetchRelays = aTagInfo.relayHint ? [aTagInfo.relayHint, ...relays] : relays;
const events = await nostrClient.fetchEvents(
[{ kinds: [kind], authors: [pubkey], '#d': [dTag], limit: 1 }],
fetchRelays,
{ useCache: true, cacheResults: true }
);
if (events.length > 0) {
// Get newest version (for replaceable events)
event = events.sort((a, b) => b.created_at - a.created_at)[0];
}
}
if (event) {
// Check if this event is also a kind 30040 (nested index)
if (event.kind === 30040) {
// Recursively load nested index
const nestedResult = await loadEventIndexRecursive(event, level + 1, maxDepth);
// Create a parent item with children
const parentItem: EventIndexItem = {
event,
order: aTagInfo.order,
level,
children: nestedResult.items
};
items.push(parentItem);
// Merge missing events from nested index
missingEvents.push(...nestedResult.missingEvents);
} else {
// Regular event (content section)
items.push({ event, order: aTagInfo.order, level });
}
loadedAddresses.add(aTagInfo.address);
if (aTagInfo.eventId) {
loadedEventIds.add(aTagInfo.eventId);
eventIdsLoadedByATags.add(aTagInfo.eventId);
}
// Also track by event ID if we have it
eventIdsLoadedByATags.add(event.id);
if (event) {
console.log(`[EventIndex] Processing event ${event.id}, kind ${event.kind}, level ${level}`);
// Check if this event is also a kind 30040 (nested index)
if (event.kind === 30040) {
console.log(`[EventIndex] Found kind 30040 event ${event.id} at level ${level}, loading children...`);
// Recursively load nested index
const nestedResult = await loadEventIndexRecursive(event, level + 1, maxDepth);
console.log(`[EventIndex] Loaded nested index ${event.id} with ${nestedResult.items.length} children at level ${level + 1}`, nestedResult.items.map(i => ({ id: i.event.id, kind: i.event.kind, level: i.level })));
// Create a parent item with children
const parentItem: EventIndexItem = {
event,
order: aTagInfo.order,
level,
children: nestedResult.items
};
items.push(parentItem);
// Merge missing events from nested index
missingEvents.push(...nestedResult.missingEvents);
} else {
missingAddresses.push(aTagInfo.address);
missingEvents.push({ dTag, order: aTagInfo.order, type: 'a-tag' });
console.warn(`[EventIndex] Missing event referenced by a-tag: ${aTagInfo.address} (d-tag: ${dTag})`);
// Regular event (content section)
items.push({ event, order: aTagInfo.order, level });
}
loadedAddresses.add(aTagInfo.address);
if (aTagInfo.eventId) {
loadedEventIds.add(aTagInfo.eventId);
}
} else {
missingAddresses.push(aTagInfo.address);
missingEvents.push({ dTag, order: aTagInfo.order, type: 'a-tag' });
console.warn(`[EventIndex] Missing event referenced by a-tag: ${aTagInfo.address} (d-tag: ${dTag})`);
}
}
}
@ -223,15 +342,24 @@ async function loadEventIndexRecursive( @@ -223,15 +342,24 @@ async function loadEventIndexRecursive(
}
}
// Fetch all e-tag events at once
// Fetch all e-tag events in batches of 100
const allRelays = relayHintsForETags.size > 0
? [...new Set([...Array.from(relayHintsForETags), ...relays])]
: relays;
const eventsById = await nostrClient.fetchEvents(
[{ ids: eTagIdsToLoad, limit: eTagIdsToLoad.length }],
allRelays,
{ useCache: true, cacheResults: true }
const eTagIdChunks = chunkArray(eTagIdsToLoad, BATCH_SIZE);
const eTagFetchPromises = eTagIdChunks.map(chunk =>
nostrClient.fetchEvents(
[{ ids: chunk, limit: chunk.length }],
allRelays,
{ useCache: true, cacheResults: true }
)
);
const eTagFetchResults = await Promise.all(eTagFetchPromises);
const eventsById: NostrEvent[] = [];
for (const result of eTagFetchResults) {
eventsById.push(...result);
}
for (const eTagInfo of eTags) {
// Skip if already loaded by a-tag
@ -396,6 +524,9 @@ async function loadEventIndexRecursive( @@ -396,6 +524,9 @@ async function loadEventIndexRecursive(
items.sort((a, b) => a.order - b.order);
missingEvents.sort((a, b) => a.order - b.order);
const itemsWithChildren = items.filter(item => item.children && item.children.length > 0);
console.log(`[EventIndex] Returning ${items.length} items (${itemsWithChildren.length} with children) at level ${level}`);
return { items, missingEvents };
}

Loading…
Cancel
Save