diff --git a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx index 83df540b..ab847e61 100644 --- a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx +++ b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx @@ -350,12 +350,15 @@ export default function AsciidocArticle({ event, className, hideImagesAndInfo = false, + hideTitle = false, parentImageUrl, footnotesContainerId }: { event: Event className?: string hideImagesAndInfo?: boolean + /** Suppress title headings (e.g. when a parent renders the section title). */ + hideTitle?: boolean parentImageUrl?: string footnotesContainerId?: string }) { @@ -1956,8 +1959,8 @@ export default function AsciidocArticle({ `}
{/* Metadata */} - {!hideImagesAndInfo && metadata.title &&

{metadata.title}

} - {!hideImagesAndInfo && !metadata.title && isBookstrEvent && ( + {!hideTitle && !hideImagesAndInfo && metadata.title &&

{metadata.title}

} + {!hideTitle && !hideImagesAndInfo && !metadata.title && isBookstrEvent && (

{bookMetadata.book ? bookMetadata.book @@ -1984,10 +1987,10 @@ export default function AsciidocArticle({

{metadata.summary}

)} - {hideImagesAndInfo && metadata.title && ( + {!hideTitle && hideImagesAndInfo && metadata.title && (

{metadata.title}

)} - {hideImagesAndInfo && !metadata.title && isBookstrEvent && ( + {!hideTitle && hideImagesAndInfo && !metadata.title && isBookstrEvent && (

{bookMetadata.book ? bookMetadata.book diff --git a/src/components/Note/PublicationIndexBody.tsx b/src/components/Note/PublicationIndexBody.tsx new file mode 100644 index 00000000..87c5b0a7 --- /dev/null +++ b/src/components/Note/PublicationIndexBody.tsx @@ -0,0 +1,224 @@ +import AsciidocArticle from '@/components/Note/AsciidocArticle/AsciidocArticle' +import MarkdownArticle from '@/components/Note/MarkdownArticle/MarkdownArticle' +import NoteOptions from '@/components/NoteOptions' +import { ExtendedKind, FAST_READ_RELAY_URLS } from '@/constants' +import { orderedPublicationRefsFromIndex } from '@/lib/publication-asciidoc-assembler' +import { fetchPublicationTreeForExport } from '@/lib/publication-export' +import { + buildPublicationSectionTree, + flattenPublicationSectionTreeForToc, + type PublicationSectionTreeNode +} from '@/lib/publication-section-tree' +import { normalizeAnyRelayUrl } from '@/lib/url' +import { cn } from '@/lib/utils' +import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import { BookOpen, Loader2 } from 'lucide-react' +import { Event, kinds } from 'nostr-tools' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' + +const ASCIIDOC_CONTENT_KINDS = new Set([ + ExtendedKind.PUBLICATION_CONTENT, + ExtendedKind.WIKI_ARTICLE +]) + +type HeadingTag = 'h2' | 'h3' | 'h4' | 'h5' | 'h6' + +function SectionHeadingRow({ + title, + event, + Heading +}: { + title: string + event?: Event + Heading: HeadingTag +}) { + return ( +
+ + {title} + + {event ? : null} +
+ ) +} + +function SectionContent({ event }: { event: Event }) { + if (ASCIIDOC_CONTENT_KINDS.has(event.kind)) { + return ( + + ) + } + if (event.kind === kinds.LongFormArticle) { + return + } + if ((event.content ?? '').trim()) { + return ( +
+ {event.content} +
+ ) + } + return null +} + +function PublicationSectionNodeView({ node }: { node: PublicationSectionTreeNode }) { + const Heading = `h${Math.min(6, node.depth + 2)}` as HeadingTag + + return ( +
+ + {node.isPublicationBranch && node.event?.content.trim() ? ( +
+ {node.event.content.trim()} +
+ ) : null} + {node.isPublicationBranch ? ( + node.children.length > 0 ? ( +
+ {node.children.map((child) => ( + + ))} +
+ ) : null + ) : node.event ? ( + + ) : null} +
+ ) +} + +function PublicationTableOfContents({ + entries, + className +}: { + entries: ReturnType + className?: string +}) { + const { t } = useTranslation() + + const scrollToSection = useCallback((id: string) => { + document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }, []) + + if (entries.length === 0) return null + + return ( + + ) +} + +export default function PublicationIndexBody({ + event, + className +}: { + event: Event + className?: string +}) { + const { t } = useTranslation() + const { relayUrls: currentBrowsingRelayUrls } = useCurrentRelays() + const { favoriteRelays } = useFavoriteRelays() + const relayUrls = useMemo( + () => + Array.from( + new Set([ + ...currentBrowsingRelayUrls.map((url) => normalizeAnyRelayUrl(url) || url), + ...favoriteRelays.map((url) => normalizeAnyRelayUrl(url) || url), + ...FAST_READ_RELAY_URLS.map((url) => normalizeAnyRelayUrl(url) || url) + ]) + ).filter(Boolean) as string[], + [currentBrowsingRelayUrls, favoriteRelays] + ) + + const [fetched, setFetched] = useState | null>(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + setLoading(true) + setError(null) + setFetched(null) + + fetchPublicationTreeForExport(event, relayUrls) + .then((tree) => { + if (cancelled) return + setFetched(tree) + }) + .catch((err: unknown) => { + if (cancelled) return + setError(err instanceof Error ? err.message : String(err)) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + + return () => { + cancelled = true + } + }, [event, relayUrls]) + + const sectionTree = useMemo( + () => (fetched ? buildPublicationSectionTree(event, fetched) : []), + [event, fetched] + ) + + const tocEntries = useMemo( + () => flattenPublicationSectionTreeForToc(sectionTree), + [sectionTree] + ) + + const hasRefs = orderedPublicationRefsFromIndex(event).length > 0 + if (!hasRefs) return null + + return ( +
+ {loading ? ( +
+ + {t('Publication contents loading')} +
+ ) : null} + + {error ? ( +

+ {t('Publication contents load failed')}: {error} +

+ ) : null} + + {fetched && !error ? ( + <> + +
+ {sectionTree.map((node) => ( + + ))} +
+ + ) : null} +
+ ) +} diff --git a/src/components/Note/PublicationIndexMetadata.tsx b/src/components/Note/PublicationIndexMetadata.tsx index 1e8312cd..93278373 100644 --- a/src/components/Note/PublicationIndexMetadata.tsx +++ b/src/components/Note/PublicationIndexMetadata.tsx @@ -7,13 +7,14 @@ import { toNoteList } from '@/lib/link' import { cn } from '@/lib/utils' import { useSecondaryPageOptional } from '@/PageManager' import { useShouldAutoLoadMedia } from '@/hooks/useShouldAutoLoadMedia' -import { BookOpen, ExternalLink } from 'lucide-react' +import { ExternalLink } from 'lucide-react' import { Event, kinds } from 'nostr-tools' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import PublicationCoverFallback from './PublicationCoverFallback' import PublicationCoverImage from './PublicationCoverImage' import PublicationBooklistButton from './PublicationBooklistButton' +import PublicationIndexBody from './PublicationIndexBody' function formatAuthorLine(authors: PublicationAuthor[]): string { if (authors.length === 0) return '' @@ -201,26 +202,7 @@ export default function PublicationIndexMetadata({

) : null} - {isFull && metadata.sections.length > 0 ? ( -
-
- - {t('Publication table of contents')} -
-
    - {metadata.sections.map((section, index) => ( -
  1. - {index + 1}. - - {section.label || - section.coordinate.split(':').pop()?.replace(/-/g, ' ') || - section.coordinate} - -
  2. - ))} -
-
- ) : null} + {isFull && metadata.sectionCount > 0 ? : null} ) } diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index c4c70a7b..07e2bf5b 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -1671,6 +1671,8 @@ export default { 'Publication sections_other': '{{count}} Abschnitte', 'Publication released': 'Veröffentlicht {{date}}', 'Publication table of contents': 'Inhalt', + 'Publication contents loading': 'Inhalt wird geladen…', + 'Publication contents load failed': 'Inhalt konnte nicht geladen werden', 'libraryIndexCache.sectionTitle': 'Bibliotheks-Publikationsindex', 'libraryIndexCache.sectionBlurb': 'Zwischengespeicherte Kind-30040-Index-Events für den Bibliotheks-Tab. Beim Leeren wird nur der Entdeckungslisten-Cache entfernt — geöffnete Publikationen bleiben im Lese-Cache.', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 11409506..3cac94e1 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1702,6 +1702,8 @@ export default { 'Publication sections_other': '{{count}} sections', 'Publication released': 'Released {{date}}', 'Publication table of contents': 'Contents', + 'Publication contents loading': 'Loading contents…', + 'Publication contents load failed': 'Could not load publication contents', 'libraryIndexCache.sectionTitle': 'Library publication index', 'libraryIndexCache.sectionBlurb': 'Cached kind-30040 index events used to populate the Library tab. Clearing this only removes the discovery list cache—not publications you have opened for reading.', diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts index dc75c393..1a034f55 100644 --- a/src/lib/event-metadata.ts +++ b/src/lib/event-metadata.ts @@ -717,10 +717,16 @@ export function getPublicationIndexMetadataFromEvent(event: Event): PublicationI } else if (name === 'l' && !language) { language = value } else if (name === 'a') { - const label = tag[3]?.trim() || tag[2]?.trim() + const label = tag[3]?.trim() sections.push({ coordinate: value, - label: label && !label.startsWith('wss://') && !label.startsWith('ws://') ? label : undefined + label: + label && + !label.startsWith('wss://') && + !label.startsWith('ws://') && + !/^[0-9a-f]{64}$/i.test(label) + ? label + : undefined }) } } diff --git a/src/lib/publication-asciidoc-assembler.ts b/src/lib/publication-asciidoc-assembler.ts index 084505ee..f6651fd4 100644 --- a/src/lib/publication-asciidoc-assembler.ts +++ b/src/lib/publication-asciidoc-assembler.ts @@ -55,6 +55,7 @@ function authorFromMetadata(metadata: PublicationIndexMetadata, pubkey: string): /** Ordered `a` / `e` refs from index tags (NKBIP-01 section order). */ export function orderedPublicationRefsFromIndex(event: Event): PublicationSectionRef[] { const refs: PublicationSectionRef[] = [] + let tagOrder = 0 for (const tag of event.tags) { const name = (tag[0] || '').trim().toLowerCase() if (name === 'a' && tag[1]) { @@ -66,10 +67,11 @@ export function orderedPublicationRefsFromIndex(event: Event): PublicationSectio kind: parsed.kind, pubkey: parsed.pubkey, identifier: parsed.identifier, - relay: tag[2] + relay: tag[2], + tagOrder: tagOrder++ }) } else if (name === 'e' && tag[1]) { - refs.push({ type: 'e', eventId: tag[1], relay: tag[2] }) + refs.push({ type: 'e', eventId: tag[1], relay: tag[2], tagOrder: tagOrder++ }) } } return refs diff --git a/src/lib/publication-section-fetch.ts b/src/lib/publication-section-fetch.ts index 11e9d8cc..4e8d18f5 100644 --- a/src/lib/publication-section-fetch.ts +++ b/src/lib/publication-section-fetch.ts @@ -15,6 +15,8 @@ export type PublicationSectionRef = { pubkey?: string identifier?: string relay?: string + /** Zero-based order among `a` / `e` refs in the index event tag list. */ + tagOrder?: number } export function publicationRefKey(ref: PublicationSectionRef): string { diff --git a/src/lib/publication-section-tree.test.ts b/src/lib/publication-section-tree.test.ts new file mode 100644 index 00000000..6ef1ecc4 --- /dev/null +++ b/src/lib/publication-section-tree.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest' +import { ExtendedKind } from '@/constants' +import { orderedPublicationRefsFromIndex } from '@/lib/publication-asciidoc-assembler' +import { + buildPublicationSectionTree, + flattenPublicationSectionTreeForToc +} from '@/lib/publication-section-tree' +import type { Event } from 'nostr-tools' + +const PK = 'a'.repeat(64) + +function indexEvent(tags: string[][], id: string): Event { + return { + id, + kind: ExtendedKind.PUBLICATION, + pubkey: PK, + created_at: 100, + content: '', + tags, + sig: 'c'.repeat(128) + } +} + +function sectionEvent(d: string, title: string, id: string): Event { + return { + id, + kind: ExtendedKind.PUBLICATION_CONTENT, + pubkey: PK, + created_at: 50, + content: `Body of ${title}`, + tags: [['d', d], ['title', title]], + sig: 'd'.repeat(128) + } +} + +describe('buildPublicationSectionTree', () => { + it('preserves a-tag order from each 30040 index', () => { + const c1 = `30041:${PK}:chapter-1` + const c2 = `30041:${PK}:chapter-2` + const c3 = `30041:${PK}:chapter-3` + const root = indexEvent( + [ + ['d', 'book'], + ['title', 'Book'], + ['a', c3, '', 'Three'], + ['t', 'ignored'], + ['a', c1, '', 'One'], + ['a', c2, '', 'Two'] + ], + 'root-id' + ) + const ch1 = sectionEvent('chapter-1', 'Chapter One', 'ch1-id') + const ch2 = sectionEvent('chapter-2', 'Chapter Two', 'ch2-id') + const ch3 = sectionEvent('chapter-3', 'Chapter Three', 'ch3-id') + const fetched = new Map([ + [c1, ch1], + [c2, ch2], + [c3, ch3] + ]) + + const tree = buildPublicationSectionTree(root, fetched) + expect(tree.map((n) => n.title)).toEqual(['Chapter Three', 'Chapter One', 'Chapter Two']) + expect(tree.map((n) => n.tagOrder)).toEqual([0, 1, 2]) + }) + + it('keeps TOC and content traversal in the same order', () => { + const part = `30040:${PK}:part-1` + const c1 = `30041:${PK}:chapter-1` + const c2 = `30041:${PK}:chapter-2` + const root = indexEvent( + [ + ['d', 'book'], + ['title', 'Book'], + ['a', part], + ['a', c2], + ['a', c1] + ], + 'root-id' + ) + const partIndex = indexEvent( + [ + ['d', 'part-1'], + ['title', 'Part One'], + ['a', c1, '', 'First'], + ['a', c2, '', 'Second'] + ], + 'part-id' + ) + const ch1 = sectionEvent('chapter-1', 'Chapter One', 'ch1-id') + const ch2 = sectionEvent('chapter-2', 'Chapter Two', 'ch2-id') + const fetched = new Map([ + [part, partIndex], + [c1, ch1], + [c2, ch2] + ]) + + const tree = buildPublicationSectionTree(root, fetched) + const toc = flattenPublicationSectionTreeForToc(tree) + + expect(toc.map((e) => e.title)).toEqual([ + 'Part One', + 'Chapter One', + 'Chapter Two', + 'Chapter Two', + 'Chapter One' + ]) + + const contentOrder = (function walk(nodes: typeof tree): string[] { + const titles: string[] = [] + for (const node of nodes) { + titles.push(node.title) + if (node.children.length > 0) titles.push(...walk(node.children)) + } + return titles + })(tree) + + expect(contentOrder).toEqual(toc.map((e) => e.title)) + }) + + it('orderedPublicationRefsFromIndex assigns tagOrder in tag-list sequence', () => { + const root = indexEvent( + [ + ['d', 'book'], + ['title', 'Book'], + ['a', `30041:${PK}:b`], + ['summary', 'x'], + ['a', `30041:${PK}:a`], + ['e', 'f'.repeat(64)] + ], + 'root-id' + ) + const refs = orderedPublicationRefsFromIndex(root) + expect(refs.map((r) => r.tagOrder)).toEqual([0, 1, 2]) + expect(refs.map((r) => r.type)).toEqual(['a', 'a', 'e']) + }) +}) diff --git a/src/lib/publication-section-tree.ts b/src/lib/publication-section-tree.ts new file mode 100644 index 00000000..17cb6403 --- /dev/null +++ b/src/lib/publication-section-tree.ts @@ -0,0 +1,181 @@ +import { ExtendedKind } from '@/constants' +import { eventTagAddress } from '@/lib/publication-index' +import { orderedPublicationRefsFromIndex } from '@/lib/publication-asciidoc-assembler' +import { + publicationRefKey, + type PublicationSectionRef +} from '@/lib/publication-section-fetch' +import type { Event } from 'nostr-tools' + +const MAX_NEST_DEPTH = 8 + +export type PublicationSectionTreeNode = { + ref: PublicationSectionRef + event?: Event + indexEvent: Event + depth: number + tagOrder: number + path: string + sectionId: string + title: string + isPublicationBranch: boolean + children: PublicationSectionTreeNode[] +} + +export type PublicationTocEntry = { + id: string + title: string + depth: number + tagOrder: number + path: string +} + +function tagValue(event: Event, name: string): string | undefined { + for (const tag of event.tags) { + if ((tag[0] || '').trim().toLowerCase() !== name) continue + const value = tag[1]?.trim() + if (value) return value + } + return undefined +} + +function isHex64(value: string): boolean { + return /^[0-9a-f]{64}$/i.test(value) +} + +function sectionLabelMapFromIndex(index: Event): Map { + const map = new Map() + for (const tag of index.tags) { + if (tag[0] !== 'a' || !tag[1]) continue + const label = tag[3]?.trim() + if (!label || label.startsWith('wss://') || label.startsWith('ws://') || isHex64(label)) continue + map.set(tag[1], label) + } + return map +} + +function coordinateForRef(ref: PublicationSectionRef, event?: Event): string | undefined { + if (ref.coordinate?.trim()) return ref.coordinate.trim() + if (event) return eventTagAddress(event) ?? undefined + return undefined +} + +function humanizeIdentifier(identifier: string): string | undefined { + if (!identifier || isHex64(identifier)) return undefined + return identifier.replace(/-/g, ' ') +} + +export function sectionTitle( + ref: PublicationSectionRef, + event: Event | undefined, + labelMap: Map +): string { + if (event) { + const title = tagValue(event, 'title') + if (title) return title + const dTag = tagValue(event, 'd') + const humanizedD = dTag ? humanizeIdentifier(dTag) : undefined + if (humanizedD) return humanizedD + } + + const coordinate = coordinateForRef(ref, event) + if (coordinate) { + const label = labelMap.get(coordinate) + if (label) return label + } + + const identifier = + ref.identifier ?? + (coordinate ? coordinate.split(':').slice(2).join(':') : undefined) + const humanized = identifier ? humanizeIdentifier(identifier) : undefined + if (humanized) return humanized + + return 'Section' +} + +function sectionAnchorId(path: string, refKey: string): string { + const slug = refKey + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80) + return slug ? `pub-section-${path.replace(/\//g, '-')}-${slug}` : `pub-section-${path.replace(/\//g, '-')}` +} + +function resolveRefEvent( + ref: PublicationSectionRef, + fetched: Map +): Event | undefined { + return fetched.get(publicationRefKey(ref)) +} + +function isPublicationBranchRef(ref: PublicationSectionRef): boolean { + if (ref.type !== 'a' || !ref.coordinate) return false + const kind = ref.kind ?? parseInt(ref.coordinate.split(':')[0], 10) + return kind === ExtendedKind.PUBLICATION +} + +/** + * Build a section tree mirroring each index event's `a` / `e` tag order. + * Sibling order matches `orderedPublicationRefsFromIndex`; children follow depth-first. + */ +export function buildPublicationSectionTree( + indexEvent: Event, + fetched: Map, + depth = 0, + pathPrefix = '' +): PublicationSectionTreeNode[] { + if (depth > MAX_NEST_DEPTH) return [] + + const labelMap = sectionLabelMapFromIndex(indexEvent) + const refs = orderedPublicationRefsFromIndex(indexEvent) + const nodes: PublicationSectionTreeNode[] = [] + + for (const ref of refs) { + const tagOrder = ref.tagOrder ?? nodes.length + const event = resolveRefEvent(ref, fetched) + const coordinate = coordinateForRef(ref, event) + const refKey = coordinate || publicationRefKey(ref) + const path = pathPrefix ? `${pathPrefix}/${tagOrder}` : String(tagOrder) + const isPublicationBranch = isPublicationBranchRef(ref) + const children = + isPublicationBranch && event + ? buildPublicationSectionTree(event, fetched, depth + 1, path) + : [] + + nodes.push({ + ref, + event, + indexEvent, + depth, + tagOrder, + path, + sectionId: sectionAnchorId(path, refKey), + title: sectionTitle(ref, event, labelMap), + isPublicationBranch, + children + }) + } + + return nodes +} + +/** Depth-first flattening preserves the same order as {@link buildPublicationSectionTree}. */ +export function flattenPublicationSectionTreeForToc( + nodes: PublicationSectionTreeNode[] +): PublicationTocEntry[] { + const entries: PublicationTocEntry[] = [] + for (const node of nodes) { + entries.push({ + id: node.sectionId, + title: node.title, + depth: node.depth, + tagOrder: node.tagOrder, + path: node.path + }) + if (node.children.length > 0) { + entries.push(...flattenPublicationSectionTreeForToc(node.children)) + } + } + return entries +}