11 changed files with 574 additions and 19 deletions
@ -0,0 +1,16 @@ |
|||||||
|
export class Lazy<T> { |
||||||
|
#value?: T; |
||||||
|
#resolver: () => Promise<T>; |
||||||
|
|
||||||
|
constructor(resolver: () => Promise<T>) { |
||||||
|
this.#resolver = resolver; |
||||||
|
} |
||||||
|
|
||||||
|
async value(): Promise<T> { |
||||||
|
if (!this.#value) { |
||||||
|
this.#value = await this.#resolver(); |
||||||
|
} |
||||||
|
|
||||||
|
return this.#value; |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,430 @@ |
|||||||
|
import type NDK from "@nostr-dev-kit/ndk"; |
||||||
|
import type { NDKEvent } from "@nostr-dev-kit/ndk"; |
||||||
|
import { Lazy } from "./lazy.ts"; |
||||||
|
import { findIndexAsync as _findIndexAsync } from '../utils.ts'; |
||||||
|
|
||||||
|
enum PublicationTreeNodeType { |
||||||
|
Root, |
||||||
|
Branch, |
||||||
|
Leaf, |
||||||
|
} |
||||||
|
|
||||||
|
interface PublicationTreeNode { |
||||||
|
type: PublicationTreeNodeType; |
||||||
|
address: string; |
||||||
|
parent?: PublicationTreeNode; |
||||||
|
children?: Array<Lazy<PublicationTreeNode>>; |
||||||
|
} |
||||||
|
|
||||||
|
export class PublicationTree implements AsyncIterable<NDKEvent> { |
||||||
|
/** |
||||||
|
* The root node of the tree. |
||||||
|
*/ |
||||||
|
#root: PublicationTreeNode; |
||||||
|
|
||||||
|
/** |
||||||
|
* A map of addresses in the tree to their corresponding nodes. |
||||||
|
*/ |
||||||
|
#nodes: Map<string, Lazy<PublicationTreeNode>>; |
||||||
|
|
||||||
|
/** |
||||||
|
* A map of addresses in the tree to their corresponding events. |
||||||
|
*/ |
||||||
|
#events: Map<string, NDKEvent>; |
||||||
|
|
||||||
|
/** |
||||||
|
* An ordered list of the addresses of the leaves of the tree. |
||||||
|
*/ |
||||||
|
#leaves: string[] = []; |
||||||
|
|
||||||
|
/** |
||||||
|
* The address of the last-visited node. Used for iteration and progressive retrieval. |
||||||
|
*/ |
||||||
|
#bookmark?: string; |
||||||
|
|
||||||
|
/** |
||||||
|
* The NDK instance used to fetch events. |
||||||
|
*/ |
||||||
|
#ndk: NDK; |
||||||
|
|
||||||
|
constructor(rootEvent: NDKEvent, ndk: NDK) { |
||||||
|
const rootAddress = rootEvent.tagAddress(); |
||||||
|
this.#root = { |
||||||
|
type: PublicationTreeNodeType.Root, |
||||||
|
address: rootAddress, |
||||||
|
children: [], |
||||||
|
}; |
||||||
|
|
||||||
|
this.#nodes = new Map<string, Lazy<PublicationTreeNode>>(); |
||||||
|
this.#nodes.set(rootAddress, new Lazy<PublicationTreeNode>(() => Promise.resolve(this.#root))); |
||||||
|
|
||||||
|
this.#events = new Map<string, NDKEvent>(); |
||||||
|
this.#events.set(rootAddress, rootEvent); |
||||||
|
|
||||||
|
this.#ndk = ndk; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Adds an event to the publication tree. |
||||||
|
* @param event The event to be added. |
||||||
|
* @param parentEvent The parent event of the event to be added. |
||||||
|
* @throws An error if the parent event is not in the tree. |
||||||
|
* @description The parent event must already be in the tree. Use |
||||||
|
* {@link PublicationTree.getEvent} to retrieve an event already in the tree. |
||||||
|
*/ |
||||||
|
async addEvent(event: NDKEvent, parentEvent: NDKEvent) { |
||||||
|
const address = event.tagAddress(); |
||||||
|
const parentAddress = parentEvent.tagAddress(); |
||||||
|
const parentNode = await this.#nodes.get(parentAddress)?.value(); |
||||||
|
|
||||||
|
if (!parentNode) { |
||||||
|
throw new Error( |
||||||
|
`PublicationTree: Parent node with address ${parentAddress} not found.` |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
const node: PublicationTreeNode = { |
||||||
|
type: await this.#getNodeType(event), |
||||||
|
address, |
||||||
|
parent: parentNode, |
||||||
|
children: [], |
||||||
|
}; |
||||||
|
const lazyNode = new Lazy<PublicationTreeNode>(() => Promise.resolve(node)); |
||||||
|
parentNode.children!.push(lazyNode); |
||||||
|
this.#nodes.set(address, lazyNode); |
||||||
|
this.#events.set(address, event); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Lazily adds an event to the publication tree by address if the full event is not already |
||||||
|
* loaded into memory. |
||||||
|
* @param address The address of the event to add. |
||||||
|
* @param parentEvent The parent event of the event to add. |
||||||
|
* @description The parent event must already be in the tree. Use |
||||||
|
* {@link PublicationTree.getEvent} to retrieve an event already in the tree. |
||||||
|
*/ |
||||||
|
async addEventByAddress(address: string, parentEvent: NDKEvent) { |
||||||
|
const parentAddress = parentEvent.tagAddress(); |
||||||
|
const parentNode = await this.#nodes.get(parentAddress)?.value(); |
||||||
|
|
||||||
|
if (!parentNode) { |
||||||
|
throw new Error( |
||||||
|
`PublicationTree: Parent node with address ${parentAddress} not found.` |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
await this.#addNode(address, parentNode); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Retrieves an event from the publication tree. |
||||||
|
* @param address The address of the event to retrieve. |
||||||
|
* @returns The event, or null if the event is not found. |
||||||
|
*/ |
||||||
|
async getEvent(address: string): Promise<NDKEvent | null> { |
||||||
|
let event = this.#events.get(address) ?? null; |
||||||
|
if (!event) { |
||||||
|
event = await this.#depthFirstRetrieve(address); |
||||||
|
} |
||||||
|
|
||||||
|
return event; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Retrieves the addresses of the loaded children, if any, of the node with the given address. |
||||||
|
* @param address The address of the parent node. |
||||||
|
* @returns An array of addresses of any loaded child nodes. |
||||||
|
*/ |
||||||
|
async getChildAddresses(address: string): Promise<string[]> { |
||||||
|
const node = await this.#nodes.get(address)?.value(); |
||||||
|
if (!node) { |
||||||
|
throw new Error(`PublicationTree: Node with address ${address} not found.`); |
||||||
|
} |
||||||
|
|
||||||
|
return Promise.all( |
||||||
|
node.children?.map(async child => |
||||||
|
(await child.value()).address |
||||||
|
) ?? [] |
||||||
|
); |
||||||
|
} |
||||||
|
/** |
||||||
|
* Retrieves the events in the hierarchy of the event with the given address. |
||||||
|
* @param address The address of the event for which to retrieve the hierarchy. |
||||||
|
* @returns Returns an array of events in the addressed event's hierarchy, beginning with the |
||||||
|
* root and ending with the addressed event. |
||||||
|
*/ |
||||||
|
async getHierarchy(address: string): Promise<NDKEvent[]> { |
||||||
|
let node = await this.#nodes.get(address)?.value(); |
||||||
|
if (!node) { |
||||||
|
throw new Error(`PublicationTree: Node with address ${address} not found.`); |
||||||
|
} |
||||||
|
|
||||||
|
const hierarchy: NDKEvent[] = [this.#events.get(address)!]; |
||||||
|
|
||||||
|
while (node.parent) { |
||||||
|
hierarchy.push(this.#events.get(node.parent.address)!); |
||||||
|
node = node.parent; |
||||||
|
} |
||||||
|
|
||||||
|
return hierarchy.reverse(); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Sets a start point for iteration over the leaves of the tree. |
||||||
|
* @param address The address of the event to bookmark. |
||||||
|
*/ |
||||||
|
setBookmark(address: string) { |
||||||
|
this.#bookmark = address; |
||||||
|
this.#cursor.tryMoveTo(address); |
||||||
|
} |
||||||
|
|
||||||
|
// #region Iteration Cursor
|
||||||
|
|
||||||
|
#cursor = new class { |
||||||
|
target: PublicationTreeNode | null | undefined; |
||||||
|
|
||||||
|
#tree: PublicationTree; |
||||||
|
|
||||||
|
constructor(tree: PublicationTree) { |
||||||
|
this.#tree = tree; |
||||||
|
} |
||||||
|
|
||||||
|
async tryMoveTo(address?: string) { |
||||||
|
if (!address) { |
||||||
|
const startEvent = await this.#tree.#depthFirstRetrieve(); |
||||||
|
this.target = await this.#tree.#nodes.get(startEvent!.tagAddress())?.value(); |
||||||
|
} else { |
||||||
|
this.target = await this.#tree.#nodes.get(address)?.value(); |
||||||
|
} |
||||||
|
|
||||||
|
if (!this.target) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
async tryMoveToFirstChild(): Promise<boolean> { |
||||||
|
if (!this.target) { |
||||||
|
throw new Error("Cursor: Target node is null or undefined."); |
||||||
|
} |
||||||
|
|
||||||
|
if (this.target.type === PublicationTreeNodeType.Leaf) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
this.target = (await this.target.children?.at(0)?.value())!; |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
async tryMoveToNextSibling(): Promise<boolean> { |
||||||
|
if (!this.target) { |
||||||
|
throw new Error("Cursor: Target node is null or undefined."); |
||||||
|
} |
||||||
|
|
||||||
|
const parent = this.target.parent; |
||||||
|
const siblings = parent?.children; |
||||||
|
if (!siblings) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
const currentIndex = await siblings.findIndexAsync( |
||||||
|
async (sibling: Lazy<PublicationTreeNode>) => (await sibling.value()).address === this.target!.address |
||||||
|
); |
||||||
|
|
||||||
|
if (currentIndex === -1) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
const nextSibling = (await siblings.at(currentIndex + 1)?.value()) ?? null; |
||||||
|
if (!nextSibling) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
this.target = nextSibling; |
||||||
|
return true; |
||||||
|
} |
||||||
|
|
||||||
|
tryMoveToParent(): boolean { |
||||||
|
if (!this.target) { |
||||||
|
throw new Error("Cursor: Target node is null or undefined."); |
||||||
|
} |
||||||
|
|
||||||
|
const parent = this.target.parent; |
||||||
|
if (!parent) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
|
||||||
|
this.target = parent; |
||||||
|
return true; |
||||||
|
} |
||||||
|
}(this); |
||||||
|
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region Async Iterator Implementation
|
||||||
|
|
||||||
|
[Symbol.asyncIterator](): AsyncIterator<NDKEvent> { |
||||||
|
return this; |
||||||
|
} |
||||||
|
|
||||||
|
async next(): Promise<IteratorResult<NDKEvent>> { |
||||||
|
if (!this.#cursor.target) { |
||||||
|
await this.#cursor.tryMoveTo(this.#bookmark); |
||||||
|
} |
||||||
|
|
||||||
|
do { |
||||||
|
if (await this.#cursor.tryMoveToFirstChild()) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
if (await this.#cursor.tryMoveToNextSibling()) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
if (this.#cursor.tryMoveToParent()) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
if (this.#cursor.target?.type === PublicationTreeNodeType.Root) { |
||||||
|
return { done: true, value: null }; |
||||||
|
} |
||||||
|
} while (this.#cursor.target?.type !== PublicationTreeNodeType.Leaf); |
||||||
|
|
||||||
|
const event = await this.getEvent(this.#cursor.target!.address); |
||||||
|
return { done: false, value: event! }; |
||||||
|
} |
||||||
|
|
||||||
|
// #endregion
|
||||||
|
|
||||||
|
// #region Private Methods
|
||||||
|
|
||||||
|
/** |
||||||
|
* Traverses the publication tree in a depth-first manner to retrieve an event, filling in |
||||||
|
* missing nodes during the traversal. |
||||||
|
* @param address The address of the event to retrieve. If no address is provided, the function |
||||||
|
* will return the first leaf in the tree. |
||||||
|
* @returns The event, or null if the event is not found. |
||||||
|
*/ |
||||||
|
async #depthFirstRetrieve(address?: string): Promise<NDKEvent | null> { |
||||||
|
if (address && this.#nodes.has(address)) { |
||||||
|
return this.#events.get(address)!; |
||||||
|
} |
||||||
|
|
||||||
|
const stack: string[] = [this.#root.address]; |
||||||
|
let currentNode: PublicationTreeNode | null | undefined = this.#root; |
||||||
|
let currentEvent: NDKEvent | null | undefined = this.#events.get(this.#root.address)!; |
||||||
|
while (stack.length > 0) { |
||||||
|
const currentAddress = stack.pop(); |
||||||
|
currentNode = await this.#nodes.get(currentAddress!)?.value(); |
||||||
|
if (!currentNode) { |
||||||
|
throw new Error(`PublicationTree: Node with address ${currentAddress} not found.`); |
||||||
|
} |
||||||
|
|
||||||
|
currentEvent = this.#events.get(currentAddress!); |
||||||
|
if (!currentEvent) { |
||||||
|
throw new Error(`PublicationTree: Event with address ${currentAddress} not found.`); |
||||||
|
} |
||||||
|
|
||||||
|
// Stop immediately if the target of the search is found.
|
||||||
|
if (address != null && currentAddress === address) { |
||||||
|
return currentEvent; |
||||||
|
} |
||||||
|
|
||||||
|
const currentChildAddresses = currentEvent.tags |
||||||
|
.filter(tag => tag[0] === 'a') |
||||||
|
.map(tag => tag[1]); |
||||||
|
|
||||||
|
// If the current event has no children, it is a leaf.
|
||||||
|
if (currentChildAddresses.length === 0) { |
||||||
|
// Return the first leaf if no address was provided.
|
||||||
|
if (address == null) { |
||||||
|
return currentEvent!; |
||||||
|
} |
||||||
|
|
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// Augment the tree with the children of the current event.
|
||||||
|
for (const childAddress of currentChildAddresses) { |
||||||
|
if (this.#nodes.has(childAddress)) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
await this.#addNode(childAddress, currentNode!); |
||||||
|
} |
||||||
|
|
||||||
|
// Push the popped address's children onto the stack for the next iteration.
|
||||||
|
while (currentChildAddresses.length > 0) { |
||||||
|
const nextAddress = currentChildAddresses.pop()!; |
||||||
|
stack.push(nextAddress); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
#addNode(address: string, parentNode: PublicationTreeNode) { |
||||||
|
const lazyNode = new Lazy<PublicationTreeNode>(() => this.#resolveNode(address, parentNode)); |
||||||
|
parentNode.children!.push(lazyNode); |
||||||
|
this.#nodes.set(address, lazyNode); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Resolves a node address into an event, and creates new nodes for its children. |
||||||
|
*
|
||||||
|
* This method is intended for use as a {@link Lazy} resolver. |
||||||
|
*
|
||||||
|
* @param address The address of the node to resolve. |
||||||
|
* @param parentNode The parent node of the node to resolve. |
||||||
|
* @returns The resolved node. |
||||||
|
*/ |
||||||
|
async #resolveNode( |
||||||
|
address: string, |
||||||
|
parentNode: PublicationTreeNode |
||||||
|
): Promise<PublicationTreeNode> { |
||||||
|
const [kind, pubkey, dTag] = address.split(':'); |
||||||
|
const event = await this.#ndk.fetchEvent({ |
||||||
|
kinds: [parseInt(kind)], |
||||||
|
authors: [pubkey], |
||||||
|
'#d': [dTag], |
||||||
|
}); |
||||||
|
|
||||||
|
if (!event) { |
||||||
|
throw new Error( |
||||||
|
`PublicationTree: Event with address ${address} not found.` |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
this.#events.set(address, event); |
||||||
|
|
||||||
|
const childAddresses = event.tags.filter(tag => tag[0] === 'a').map(tag => tag[1]); |
||||||
|
|
||||||
|
const node: PublicationTreeNode = { |
||||||
|
type: await this.#getNodeType(event), |
||||||
|
address, |
||||||
|
parent: parentNode, |
||||||
|
children: [], |
||||||
|
}; |
||||||
|
|
||||||
|
for (const address of childAddresses) { |
||||||
|
this.addEventByAddress(address, event); |
||||||
|
} |
||||||
|
|
||||||
|
return node; |
||||||
|
} |
||||||
|
|
||||||
|
async #getNodeType(event: NDKEvent): Promise<PublicationTreeNodeType> { |
||||||
|
if (event.tagAddress() === this.#root.address) { |
||||||
|
return PublicationTreeNodeType.Root; |
||||||
|
} |
||||||
|
|
||||||
|
if (event.kind === 30040 && event.tags.some(tag => tag[0] === 'a')) { |
||||||
|
return PublicationTreeNodeType.Branch; |
||||||
|
} |
||||||
|
|
||||||
|
return PublicationTreeNodeType.Leaf; |
||||||
|
} |
||||||
|
|
||||||
|
// #endregion
|
||||||
|
} |
||||||
@ -0,0 +1,45 @@ |
|||||||
|
<script module lang='ts'> |
||||||
|
import { P } from 'flowbite-svelte'; |
||||||
|
|
||||||
|
export { contentParagraph, sectionHeading }; |
||||||
|
</script> |
||||||
|
|
||||||
|
{#snippet sectionHeading(title: string, depth: number)} |
||||||
|
{#if depth === 0} |
||||||
|
<h1 class='h-leather'> |
||||||
|
{title} |
||||||
|
</h1> |
||||||
|
{:else if depth === 1} |
||||||
|
<h2 class='h-leather'> |
||||||
|
{title} |
||||||
|
</h2> |
||||||
|
{:else if depth === 2} |
||||||
|
<h3 class='h-leather'> |
||||||
|
{title} |
||||||
|
</h3> |
||||||
|
{:else if depth === 3} |
||||||
|
<h4 class='h-leather'> |
||||||
|
{title} |
||||||
|
</h4> |
||||||
|
{:else if depth === 4} |
||||||
|
<h5 class='h-leather'> |
||||||
|
{title} |
||||||
|
</h5> |
||||||
|
{:else} |
||||||
|
<h6 class='h-leather'> |
||||||
|
{title} |
||||||
|
</h6> |
||||||
|
{/if} |
||||||
|
{/snippet} |
||||||
|
|
||||||
|
{#snippet contentParagraph(content: string, publicationType: string, isSectionStart: boolean)} |
||||||
|
{#if publicationType === 'novel'} |
||||||
|
<P class='whitespace-normal' firstupper={isSectionStart}> |
||||||
|
{@html content} |
||||||
|
</P> |
||||||
|
{:else} |
||||||
|
<P class='whitespace-normal' firstupper={false}> |
||||||
|
{@html content} |
||||||
|
</P> |
||||||
|
{/if} |
||||||
|
{/snippet} |
||||||
Loading…
Reference in new issue