Browse Source

Update ToC on publication tree node resolution

master
buttercat1791 9 months ago
parent
commit
ddf8b9006b
  1. 198
      src/lib/components/publications/table_of_contents.svelte.ts

198
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 { export interface TocEntry {
address: string; address: string;
title: string; title: string;
href: string; href?: string;
children: TocEntry[];
parent?: TocEntry;
depth: number; depth: number;
expanded: boolean;
children: Array<TocEntry> | null;
} }
export class TableOfContents { export class TableOfContents {
#tocRoot: TocEntry | null = null; public addressMap: SvelteMap<string, TocEntry> = new SvelteMap();
#addresses = $state<Map<string, TocEntry>>(new Map());
#root: TocEntry | null = null;
#publicationTree: SveltePublicationTree; #publicationTree: SveltePublicationTree;
#pagePathname: string; #pagePathname: string;
/** /**
* Constructor for the `TableOfContents` class. The constructed ToC initially contains only the * Constructs a `TableOfContents` from a `SveltePublicationTree`.
* root entry. Additional entries must be inserted programmatically using class methods. *
* * @param rootAddress The address of the root event.
* The `TableOfContents` class should be instantiated as a page-scoped singleton so that * @param publicationTree The SveltePublicationTree instance.
* `pagePathname` is correct wherever the instance is used. The singleton should be made * @param pagePathname The current page pathname for href generation.
* made available to the entire component tree under that page.
*/ */
constructor(rootAddress: string, publicationTree: SveltePublicationTree, pagePathname: string) { constructor(rootAddress: string, publicationTree: SveltePublicationTree, pagePathname: string) {
this.#publicationTree = publicationTree; this.#publicationTree = publicationTree;
this.#pagePathname = pagePathname; this.#pagePathname = pagePathname;
void this.#initRoot(rootAddress);
this.insertIntoTocFromPublicationTree(rootAddress); this.#publicationTree.onNodeResolved((address: string) => {
void this.#handleNodeResolved(address);
});
} }
#normalizeHashPath(title: string): string { // #region Public Methods
// TODO: Confirm this uses good normalization logic to produce unique hrefs within the page.
return title.toLowerCase().replace(/ /g, '-');
}
get addresses(): Map<string, TocEntry> { /**
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<void> { getEntry(address: string): TocEntry | undefined {
const targetEvent = await this.#publicationTree.getEvent(address); return this.addressMap.get(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)!;
}
} }
/** /**
@ -128,10 +82,8 @@ export class TableOfContents {
title, title,
href, href,
depth, depth,
expanded: false, children: [],
children: null,
}; };
parentEntry.children ??= [];
parentEntry.children.push(tocEntry); parentEntry.children.push(tocEntry);
this.buildTocFromDocument(header, tocEntry, depth + 1); 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. * 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
} }

Loading…
Cancel
Save