diff --git a/src/lib/components/publications/table_of_contents.svelte.ts b/src/lib/components/publications/table_of_contents.svelte.ts index daf190a..14066e9 100644 --- a/src/lib/components/publications/table_of_contents.svelte.ts +++ b/src/lib/components/publications/table_of_contents.svelte.ts @@ -1,98 +1,52 @@ -import { SveltePublicationTree } from "./svelte_publication_tree.svelte.ts"; +import { SvelteMap } from 'svelte/reactivity'; +import type { SveltePublicationTree } from './svelte_publication_tree.svelte.ts'; +import type { NDKEvent } from '../../utils/nostrUtils.ts'; export interface TocEntry { address: string; title: string; - href: string; + href?: string; + children: TocEntry[]; + parent?: TocEntry; depth: number; - expanded: boolean; - children: Array | null; } export class TableOfContents { - #tocRoot: TocEntry | null = null; - #addresses = $state>(new Map()); + public addressMap: SvelteMap = new SvelteMap(); + + #root: TocEntry | null = null; #publicationTree: SveltePublicationTree; #pagePathname: string; /** - * Constructor for the `TableOfContents` class. The constructed ToC initially contains only the - * root entry. Additional entries must be inserted programmatically using class methods. - * - * The `TableOfContents` class should be instantiated as a page-scoped singleton so that - * `pagePathname` is correct wherever the instance is used. The singleton should be made - * made available to the entire component tree under that page. + * Constructs a `TableOfContents` from a `SveltePublicationTree`. + * + * @param rootAddress The address of the root event. + * @param publicationTree The SveltePublicationTree instance. + * @param pagePathname The current page pathname for href generation. */ constructor(rootAddress: string, publicationTree: SveltePublicationTree, pagePathname: string) { this.#publicationTree = publicationTree; this.#pagePathname = pagePathname; - - this.insertIntoTocFromPublicationTree(rootAddress); + void this.#initRoot(rootAddress); + this.#publicationTree.onNodeResolved((address: string) => { + void this.#handleNodeResolved(address); + }); } - #normalizeHashPath(title: string): string { - // TODO: Confirm this uses good normalization logic to produce unique hrefs within the page. - return title.toLowerCase().replace(/ /g, '-'); - } + // #region Public Methods - get addresses(): Map { - return this.#addresses; + /** + * Returns the root entry of the ToC. + * + * @returns The root entry of the ToC, or `null` if the ToC has not been initialized. + */ + getRootEntry(): TocEntry | null { + return this.#root; } - async insertIntoTocFromPublicationTree(address: string): Promise { - const targetEvent = await this.#publicationTree.getEvent(address); - if (!targetEvent) { - console.warn(`[ToC] Event ${address} not found.`); - // TODO: Determine how to handle this case in the UI. - return; - } - - const hierarchyEvents = await this.#publicationTree.getHierarchy(address); - if (hierarchyEvents.length === 0) { - // This means we are at root. - return; - } - - // Michael J 05 May 2025 - In this loop, we assume that the parent of the current event has - // already been populated into the ToC. As long as the root is set when the component is - // initialized, this code will work fine. - let currentParentTocNode: TocEntry | null = this.#tocRoot; - for (let i = 0; i < hierarchyEvents.length; i++) { - const currentEvent = hierarchyEvents[i]; - const currentAddress = currentEvent.tagAddress(); - - if (this.#addresses.has(currentAddress)) { - continue; - } - - const currentEventChildAddresses = await this.#publicationTree.getChildAddresses(currentAddress); - for (const address of currentEventChildAddresses) { - if (address === null) { - continue; - } - - const childEvent = await this.#publicationTree.getEvent(address); - if (!childEvent) { - console.warn(`[ToC] Event ${address} not found.`); - continue; - } - - currentParentTocNode!.children ??= []; - - const childTocEntry: TocEntry = { - address, - title: childEvent.getMatchingTags('title')[0][1], - href: `${this.#pagePathname}#${this.#normalizeHashPath(childEvent.getMatchingTags('title')[0][1])}`, - depth: i + 1, - expanded: false, - children: null, - }; - currentParentTocNode!.children.push(childTocEntry); - this.#addresses.set(address, childTocEntry); - } - - currentParentTocNode = this.#addresses.get(currentAddress)!; - } + getEntry(address: string): TocEntry | undefined { + return this.addressMap.get(address); } /** @@ -128,10 +82,8 @@ export class TableOfContents { title, href, depth, - expanded: false, - children: null, + children: [], }; - parentEntry.children ??= []; parentEntry.children.push(tocEntry); this.buildTocFromDocument(header, tocEntry, depth + 1); @@ -139,6 +91,10 @@ export class TableOfContents { }); } + // #endregion + + // #region Iterator Methods + /** * Iterates over all ToC entries in depth-first order. */ @@ -157,6 +113,92 @@ export class TableOfContents { } } - yield* traverse(this.#tocRoot); + yield* traverse(this.#root); } + + // #endregion + + // #region Private Methods + + async #initRoot(rootAddress: string) { + const rootEvent = await this.#publicationTree.getEvent(rootAddress); + if (!rootEvent) { + throw new Error(`[ToC] Root event ${rootAddress} not found.`); + } + + this.#root = { + address: rootAddress, + title: this.#getTitle(rootEvent), + children: [], + depth: 0, + }; + + this.addressMap.set(rootAddress, this.#root); + // Handle any other nodes that have already been resolved. + await this.#handleNodeResolved(rootAddress); + } + + async #handleNodeResolved(address: string) { + if (this.addressMap.has(address)) { + return; + } + const event = await this.#publicationTree.getEvent(address); + if (!event) { + return; + } + + const parentEvent = await this.#publicationTree.getParent(address); + const parentAddress = parentEvent?.tagAddress(); + if (!parentAddress) { + // All non-root nodes must have a parent. + if (!this.#root || address !== this.#root.address) { + throw new Error(`[ToC] Parent not found for address ${address}`); + } + return; + } + + const parentEntry = this.addressMap.get(parentAddress); + if (!parentEntry) { + throw new Error(`[ToC] Parent ToC entry not found for address ${address}`); + } + + const entry: TocEntry = { + address, + title: this.#getTitle(event), + children: [], + parent: parentEntry, + depth: parentEntry.depth + 1, + }; + + // Michael J - 05 June 2025 - The `getChildAddresses` method forces node resolution on the + // publication tree. This is acceptable here, because the tree is always resolved top-down. + // Therefore, by the time we handle a node's resolution, its parent and siblings have already + // been resolved. + const childAddresses = await this.#publicationTree.getChildAddresses(parentAddress); + const filteredChildAddresses = childAddresses.filter((a): a is string => !!a); + const insertIndex = filteredChildAddresses.findIndex(a => a === address); + if (insertIndex === -1 || insertIndex > parentEntry.children.length) { + parentEntry.children.push(entry); + } else { + parentEntry.children.splice(insertIndex, 0, entry); + } + + this.addressMap.set(address, entry); + } + + #getTitle(event: NDKEvent | null): string { + if (!event) { + // TODO: What do we want to return in this case? + return '[untitled]'; + } + const titleTag = event.getMatchingTags?.('title')?.[0]?.[1]; + return titleTag || event.tagAddress() || '[untitled]'; + } + + #normalizeHashPath(title: string): string { + // TODO: Confirm this uses good normalization logic to produce unique hrefs within the page. + return title.toLowerCase().replace(/ /g, '-'); + } + + // #endregion }