From 874103a6e0bbc81c35cad42afd4cf6bac7ae5c9e Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Tue, 4 Mar 2025 23:08:11 -0600 Subject: [PATCH 01/52] Start work on publication tree data structure --- src/lib/data_structures/publication_tree.ts | 57 +++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/lib/data_structures/publication_tree.ts diff --git a/src/lib/data_structures/publication_tree.ts b/src/lib/data_structures/publication_tree.ts new file mode 100644 index 0000000..07c7f84 --- /dev/null +++ b/src/lib/data_structures/publication_tree.ts @@ -0,0 +1,57 @@ +import type { NDKEvent } from "@nostr-dev-kit/ndk"; + +interface PublicationTreeNode { + address: string; + parent?: PublicationTreeNode; + children?: PublicationTreeNode[]; +} + +// TODO: Add public method(s) for event retrieval. +// TODO: Add methods for DFS and BFS traversal-retrieval. +export class PublicationTree { + private root: PublicationTreeNode; + private nodes: Map; + private events: Map; + + constructor(rootEvent: NDKEvent) { + const rootAddress = this.getAddress(rootEvent); + this.root = { address: rootAddress, children: [] }; + + this.nodes = new Map(); + this.nodes.set(rootAddress, this.root); + + this.events = new Map(); + this.events.set(rootAddress, rootEvent); + } + + addEvent(event: NDKEvent, parentEvent: NDKEvent) { + const address = this.getAddress(event); + const parentAddress = this.getAddress(parentEvent); + const parentNode = this.nodes.get(parentAddress); + + if (!parentNode) { + throw new Error( + `PublicationTree: Parent node with address ${parentAddress} not found.` + ); + } + + const node = { + address, + parent: parentNode, + children: [], + }; + parentNode.children!.push(node); + this.nodes.set(address, node); + this.events.set(address, event); + } + + private getAddress(event: NDKEvent): string { + if (event.kind! < 30000 || event.kind! >= 40000) { + throw new Error( + "PublicationTree: Invalid event kind. Event kind must be in the range 30000-39999" + ); + } + + return `${event.kind}:${event.pubkey}:${event.dTag}`; + } +} \ No newline at end of file From f11dca162cb86d84ff9c568459bbdb278402b17b Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Wed, 5 Mar 2025 08:43:54 -0600 Subject: [PATCH 02/52] Add depth-first traversal to the publication tree --- src/lib/data_structures/publication_tree.ts | 105 ++++++++++++++++++-- 1 file changed, 97 insertions(+), 8 deletions(-) diff --git a/src/lib/data_structures/publication_tree.ts b/src/lib/data_structures/publication_tree.ts index 07c7f84..a9076ea 100644 --- a/src/lib/data_structures/publication_tree.ts +++ b/src/lib/data_structures/publication_tree.ts @@ -1,4 +1,5 @@ -import type { NDKEvent } from "@nostr-dev-kit/ndk"; +import type NDK from "@nostr-dev-kit/ndk"; +import type { NDKEvent, NDKFilter } from "@nostr-dev-kit/ndk"; interface PublicationTreeNode { address: string; @@ -6,15 +7,15 @@ interface PublicationTreeNode { children?: PublicationTreeNode[]; } -// TODO: Add public method(s) for event retrieval. -// TODO: Add methods for DFS and BFS traversal-retrieval. +// TODO: Add an iterator over the leaves of the tree. export class PublicationTree { private root: PublicationTreeNode; private nodes: Map; private events: Map; + private ndk: NDK; - constructor(rootEvent: NDKEvent) { - const rootAddress = this.getAddress(rootEvent); + constructor(rootEvent: NDKEvent, ndk: NDK) { + const rootAddress = this.getAddressFromEvent(rootEvent); this.root = { address: rootAddress, children: [] }; this.nodes = new Map(); @@ -22,11 +23,21 @@ export class PublicationTree { this.events = new Map(); 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. + */ addEvent(event: NDKEvent, parentEvent: NDKEvent) { - const address = this.getAddress(event); - const parentAddress = this.getAddress(parentEvent); + const address = this.getAddressFromEvent(event); + const parentAddress = this.getAddressFromEvent(parentEvent); const parentNode = this.nodes.get(parentAddress); if (!parentNode) { @@ -45,7 +56,83 @@ export class PublicationTree { this.events.set(address, event); } - private getAddress(event: NDKEvent): string { + /** + * 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 { + let event = this.events.get(address) ?? null; + if (!event) { + event = await this.depthFirstRetrieve(address); + } + + return event; + } + + // #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. + * @returns The event, or null if the event is not found. + */ + private async depthFirstRetrieve(address: string): Promise { + if (this.nodes.has(address)) { + return this.events.get(address)!; + } + + const stack: string[] = [this.root.address]; + let currentEvent: NDKEvent | null | undefined; + while (stack.length > 0) { + const currentAddress = stack.pop(); + + // Stop immediately if the target of the search is found. + if (currentAddress === address) { + return this.events.get(address)!; + } + + // Augment the tree with the children of the current event. + const currentChildAddresses = this.events + .get(currentAddress!)!.tags + .filter(tag => tag[0] === 'a') + .map(tag => tag[1]); + + const kinds = new Set(); + const pubkeys = new Set(); + const dTags = new Set(); + for (const childAddress of currentChildAddresses) { + if (this.nodes.has(childAddress)) { + continue; + } + + const [kind, pubkey, dTag] = childAddress.split(':'); + kinds.add(parseInt(kind)); + pubkeys.add(pubkey); + dTags.add(dTag); + } + + const childEvents = await this.ndk.fetchEvents({ + kinds: Array.from(kinds), + authors: Array.from(pubkeys), + '#d': Array.from(dTags), + }); + + for (const childEvent of childEvents) { + this.addEvent(childEvent, currentEvent!); + } + + // Push the popped address's children onto the stack for the next iteration. + while (currentChildAddresses.length > 0) { + stack.push(currentChildAddresses.pop()!); + } + } + + return null; + } + + private getAddressFromEvent(event: NDKEvent): string { if (event.kind! < 30000 || event.kind! >= 40000) { throw new Error( "PublicationTree: Invalid event kind. Event kind must be in the range 30000-39999" @@ -54,4 +141,6 @@ export class PublicationTree { return `${event.kind}:${event.pubkey}:${event.dTag}`; } + + // #endregion } \ No newline at end of file From 8c9319aa64bef82ce6d508b8ad1ca06b7c169643 Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Thu, 6 Mar 2025 09:26:01 -0600 Subject: [PATCH 03/52] Stub out methods to make the tree both iterable and its own iterator Consumers should be able to invoke `.next()` on an instance of the tree to retrieve events one at a time. --- src/lib/data_structures/publication_tree.ts | 33 +++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/lib/data_structures/publication_tree.ts b/src/lib/data_structures/publication_tree.ts index a9076ea..c6e7802 100644 --- a/src/lib/data_structures/publication_tree.ts +++ b/src/lib/data_structures/publication_tree.ts @@ -7,11 +7,30 @@ interface PublicationTreeNode { children?: PublicationTreeNode[]; } -// TODO: Add an iterator over the leaves of the tree. -export class PublicationTree { +export class PublicationTree implements Iterable { + /** + * The root node of the tree. + */ private root: PublicationTreeNode; + + /** + * A map of addresses in the tree to their corresponding nodes. + */ private nodes: Map; + + /** + * A map of addresses in the tree to their corresponding events. + */ private events: Map; + + /** + * The address of the last-visited node. Used for iteration and progressive retrieval. + */ + private bookmark?: string; + + /** + * The NDK instance used to fetch events. + */ private ndk: NDK; constructor(rootEvent: NDKEvent, ndk: NDK) { @@ -70,6 +89,16 @@ export class PublicationTree { return event; } + [Symbol.iterator](): Iterator { + return this; + } + + next(): IteratorResult { + // TODO: Implement iteration from the bookmark over subsequent leaves. + + return { done: true, value: null }; + } + // #region Private Methods /** From baca84f487c515a51ced3d99f2d18dae4e08ada0 Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Fri, 7 Mar 2025 09:32:22 -0600 Subject: [PATCH 04/52] Iteration WIP --- src/lib/data_structures/publication_tree.ts | 93 +++++++++++++++++++-- 1 file changed, 85 insertions(+), 8 deletions(-) diff --git a/src/lib/data_structures/publication_tree.ts b/src/lib/data_structures/publication_tree.ts index c6e7802..9862608 100644 --- a/src/lib/data_structures/publication_tree.ts +++ b/src/lib/data_structures/publication_tree.ts @@ -7,7 +7,7 @@ interface PublicationTreeNode { children?: PublicationTreeNode[]; } -export class PublicationTree implements Iterable { +export class PublicationTree implements AsyncIterable { /** * The root node of the tree. */ @@ -23,6 +23,11 @@ export class PublicationTree implements Iterable { */ private events: Map; + /** + * An ordered list of the addresses of the leaves of the tree. + */ + private leaves: string[] = []; + /** * The address of the last-visited node. Used for iteration and progressive retrieval. */ @@ -89,12 +94,33 @@ export class PublicationTree implements Iterable { return event; } - [Symbol.iterator](): Iterator { + /** + * 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; + } + + [Symbol.asyncIterator](): AsyncIterator { return this; } - next(): IteratorResult { - // TODO: Implement iteration from the bookmark over subsequent leaves. + async next(): Promise> { + // If no bookmark is set, start at the first leaf. Retrieve that first leaf if necessary. + if (!this.bookmark) { + this.bookmark = this.leaves.at(0); + if (this.bookmark) { + const bookmarkEvent = await this.getEvent(this.bookmark); + return { done: false, value: bookmarkEvent! }; + } + + const firstLeafEvent = await this.depthFirstRetrieve(); + this.bookmark = this.getAddressFromEvent(firstLeafEvent!); + return { done: false, value: firstLeafEvent! }; + } + + // TODO: Invoke a funciton to retrieve the next sibling of the bookmark. return { done: true, value: null }; } @@ -104,11 +130,12 @@ export class PublicationTree implements Iterable { /** * 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. + * @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. */ - private async depthFirstRetrieve(address: string): Promise { - if (this.nodes.has(address)) { + private async depthFirstRetrieve(address?: string): Promise { + if (address && this.nodes.has(address)) { return this.events.get(address)!; } @@ -118,7 +145,7 @@ export class PublicationTree implements Iterable { const currentAddress = stack.pop(); // Stop immediately if the target of the search is found. - if (currentAddress === address) { + if (address != null && currentAddress === address) { return this.events.get(address)!; } @@ -152,6 +179,18 @@ export class PublicationTree implements Iterable { this.addEvent(childEvent, currentEvent!); } + // If the current event has no children, it is a leaf. + if (childEvents.size === 0) { + this.leaves.push(currentAddress!); + + // Return the first leaf if no address was provided. + if (address == null) { + return currentEvent!; + } + + continue; + } + // Push the popped address's children onto the stack for the next iteration. while (currentChildAddresses.length > 0) { stack.push(currentChildAddresses.pop()!); @@ -161,6 +200,44 @@ export class PublicationTree implements Iterable { return null; } + private async getNextSibling(address: string): Promise { + if (!this.leaves.includes(address)) { + throw new Error( + `PublicationTree: Address ${address} is not a leaf. Cannot retrieve next sibling.` + ); + } + + let currentNode = this.nodes.get(address); + if (!currentNode) { + return null; + } + + let parent = currentNode.parent; + if (!parent) { + throw new Error( + `PublicationTree: Address ${address} has no parent. Cannot retrieve next sibling.` + ); + } + + // TODO: Handle the case where the current node is the last leaf. + + let nextSibling: PublicationTreeNode | null = null; + do { + const siblings: PublicationTreeNode[] = parent!.children!; + const currentIndex = siblings.findIndex(sibling => sibling.address === currentNode!.address); + nextSibling = siblings.at(currentIndex + 1) ?? null; + + // If the next sibling has children, it is not a leaf. + if ((nextSibling?.children?.length ?? 0) > 0) { + currentNode = nextSibling!.children!.at(0)!; + parent = currentNode.parent; + nextSibling = null; + } + } while (nextSibling == null); + + return this.getEvent(nextSibling!.address); + } + private getAddressFromEvent(event: NDKEvent): string { if (event.kind! < 30000 || event.kind! >= 40000) { throw new Error( From 04c2a809377994a5e662c5c695cee6437aa8090c Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Sat, 8 Mar 2025 12:39:02 -0600 Subject: [PATCH 05/52] Define a nested Cursor class for tree iteration --- src/lib/data_structures/publication_tree.ts | 32 +++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/lib/data_structures/publication_tree.ts b/src/lib/data_structures/publication_tree.ts index 9862608..080a8db 100644 --- a/src/lib/data_structures/publication_tree.ts +++ b/src/lib/data_structures/publication_tree.ts @@ -249,4 +249,36 @@ export class PublicationTree implements AsyncIterable { } // #endregion + + // #region Iteration Cursor + + // TODO: Flesh out this class. + Cursor = class { + private tree: PublicationTree; + private currentNode: PublicationTreeNode | null | undefined; + + constructor(tree: PublicationTree, currentNode: PublicationTreeNode | null = null) { + this.tree = tree; + + if (!currentNode) { + this.currentNode = this.tree.bookmark + ? this.tree.nodes.get(this.tree.bookmark) + : null; + } + } + + firstChild(): PublicationTreeNode | null { + + } + + nextSibling(): PublicationTreeNode | null { + + } + + parent(): PublicationTreeNode | null { + + } + }; + + // #endregion } \ No newline at end of file From 866a54be496f20a76ca457dbd042b321e13d97ac Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Sun, 9 Mar 2025 12:16:41 -0500 Subject: [PATCH 06/52] Implement tree walking via internal Cursor class --- src/lib/data_structures/publication_tree.ts | 49 +++++++++++++++++++-- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/src/lib/data_structures/publication_tree.ts b/src/lib/data_structures/publication_tree.ts index 080a8db..1713dfd 100644 --- a/src/lib/data_structures/publication_tree.ts +++ b/src/lib/data_structures/publication_tree.ts @@ -252,7 +252,6 @@ export class PublicationTree implements AsyncIterable { // #region Iteration Cursor - // TODO: Flesh out this class. Cursor = class { private tree: PublicationTree; private currentNode: PublicationTreeNode | null | undefined; @@ -267,16 +266,58 @@ export class PublicationTree implements AsyncIterable { } } - firstChild(): PublicationTreeNode | null { + moveToFirstChild(): boolean { + if (!this.currentNode) { + throw new Error("Cursor: Current node is null or undefined."); + } + + const hasChildren = (this.currentNode.children?.length ?? 0) > 0; + const isLeaf = this.tree.leaves.includes(this.currentNode.address); + + if (!hasChildren && isLeaf) { + return false; + } + if (!hasChildren && !isLeaf) { + // TODO: Fetch any missing children, then return the first child. + } + + this.currentNode = this.currentNode.children?.at(0); + return true; } - nextSibling(): PublicationTreeNode | null { + moveToNextSibling(): boolean { + if (!this.currentNode) { + throw new Error("Cursor: Current node is null or undefined."); + } + + const parent = this.currentNode.parent; + const siblings = parent?.children; + const currentIndex = siblings?.findIndex(sibling => + sibling.address === this.currentNode!.address + ); + + const nextSibling = siblings?.at(currentIndex! + 1); + if (!nextSibling) { + return false; + } + this.currentNode = nextSibling; + return true; } - parent(): PublicationTreeNode | null { + moveToParent(): boolean { + if (!this.currentNode) { + throw new Error("Cursor: Current node is null or undefined."); + } + + const parent = this.currentNode.parent; + if (!parent) { + return false; + } + this.currentNode = parent; + return true; } }; From d723a56fe5094d41ce6f8c402ea5f87fc4f02ff7 Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Sun, 9 Mar 2025 23:17:32 -0500 Subject: [PATCH 07/52] Make node children lazy-load --- src/lib/data_structures/lazy.ts | 13 +++++++++ src/lib/data_structures/publication_tree.ts | 30 ++++++++++++--------- 2 files changed, 31 insertions(+), 12 deletions(-) create mode 100644 src/lib/data_structures/lazy.ts diff --git a/src/lib/data_structures/lazy.ts b/src/lib/data_structures/lazy.ts new file mode 100644 index 0000000..d7203b8 --- /dev/null +++ b/src/lib/data_structures/lazy.ts @@ -0,0 +1,13 @@ +export class Lazy { + #value?: T; + + constructor(private readonly resolver: () => Promise) {} + + async value(): Promise { + if (!this.#value) { + this.#value = await this.resolver(); + } + + return this.#value; + } +} \ No newline at end of file diff --git a/src/lib/data_structures/publication_tree.ts b/src/lib/data_structures/publication_tree.ts index 1713dfd..c2289f0 100644 --- a/src/lib/data_structures/publication_tree.ts +++ b/src/lib/data_structures/publication_tree.ts @@ -1,10 +1,11 @@ import type NDK from "@nostr-dev-kit/ndk"; import type { NDKEvent, NDKFilter } from "@nostr-dev-kit/ndk"; +import type { Lazy } from "./lazy"; interface PublicationTreeNode { address: string; parent?: PublicationTreeNode; - children?: PublicationTreeNode[]; + children?: Array>; } export class PublicationTree implements AsyncIterable { @@ -40,7 +41,10 @@ export class PublicationTree implements AsyncIterable { constructor(rootEvent: NDKEvent, ndk: NDK) { const rootAddress = this.getAddressFromEvent(rootEvent); - this.root = { address: rootAddress, children: [] }; + this.root = { + address: rootAddress, + children: [], + }; this.nodes = new Map(); this.nodes.set(rootAddress, this.root); @@ -70,11 +74,13 @@ export class PublicationTree implements AsyncIterable { ); } + // TODO: Determine node type. const node = { address, parent: parentNode, children: [], }; + // TODO: Define a resolver for the lazy node. parentNode.children!.push(node); this.nodes.set(address, node); this.events.set(address, event); @@ -223,13 +229,13 @@ export class PublicationTree implements AsyncIterable { let nextSibling: PublicationTreeNode | null = null; do { - const siblings: PublicationTreeNode[] = parent!.children!; - const currentIndex = siblings.findIndex(sibling => sibling.address === currentNode!.address); - nextSibling = siblings.at(currentIndex + 1) ?? null; + const siblings: Lazy[] = parent!.children!; + const currentIndex = siblings.findIndex(async sibling => (await sibling.value()).address === currentNode!.address); + nextSibling = (await siblings.at(currentIndex + 1)?.value()) ?? null; // If the next sibling has children, it is not a leaf. if ((nextSibling?.children?.length ?? 0) > 0) { - currentNode = nextSibling!.children!.at(0)!; + currentNode = (await nextSibling!.children!.at(0)!.value())!; parent = currentNode.parent; nextSibling = null; } @@ -266,7 +272,7 @@ export class PublicationTree implements AsyncIterable { } } - moveToFirstChild(): boolean { + async moveToFirstChild(): Promise { if (!this.currentNode) { throw new Error("Cursor: Current node is null or undefined."); } @@ -282,22 +288,22 @@ export class PublicationTree implements AsyncIterable { // TODO: Fetch any missing children, then return the first child. } - this.currentNode = this.currentNode.children?.at(0); + this.currentNode = (await this.currentNode.children?.at(0)?.value())!; return true; } - moveToNextSibling(): boolean { + async moveToNextSibling(): Promise { if (!this.currentNode) { throw new Error("Cursor: Current node is null or undefined."); } const parent = this.currentNode.parent; const siblings = parent?.children; - const currentIndex = siblings?.findIndex(sibling => - sibling.address === this.currentNode!.address + const currentIndex = siblings?.findIndex(async sibling => + (await sibling.value()).address === this.currentNode!.address ); - const nextSibling = siblings?.at(currentIndex! + 1); + const nextSibling = (await siblings?.at(currentIndex! + 1)?.value()) ?? null; if (!nextSibling) { return false; } From b4906215c5a789ea72745389a9223a84a678f185 Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Mon, 10 Mar 2025 09:33:45 -0500 Subject: [PATCH 08/52] Work with lazy-loading nodes --- src/lib/data_structures/publication_tree.ts | 116 +++++++++++++++----- 1 file changed, 86 insertions(+), 30 deletions(-) diff --git a/src/lib/data_structures/publication_tree.ts b/src/lib/data_structures/publication_tree.ts index c2289f0..0ec929c 100644 --- a/src/lib/data_structures/publication_tree.ts +++ b/src/lib/data_structures/publication_tree.ts @@ -1,8 +1,15 @@ import type NDK from "@nostr-dev-kit/ndk"; import type { NDKEvent, NDKFilter } from "@nostr-dev-kit/ndk"; -import type { Lazy } from "./lazy"; +import { Lazy } from "./lazy"; + +enum PublicationTreeNodeType { + Root, + Branch, + Leaf, +} interface PublicationTreeNode { + type: PublicationTreeNodeType; address: string; parent?: PublicationTreeNode; children?: Array>; @@ -19,6 +26,12 @@ export class PublicationTree implements AsyncIterable { */ private nodes: Map; + /** + * A map of addresses in the tree to their corresponding lazy-loaded nodes. When a lazy node is + * retrieved, it is added to the {@link PublicationTree.nodes} map. + */ + private lazyNodes: Map> = new Map(); + /** * A map of addresses in the tree to their corresponding events. */ @@ -40,8 +53,9 @@ export class PublicationTree implements AsyncIterable { private ndk: NDK; constructor(rootEvent: NDKEvent, ndk: NDK) { - const rootAddress = this.getAddressFromEvent(rootEvent); + const rootAddress = rootEvent.tagAddress(); this.root = { + type: PublicationTreeNodeType.Root, address: rootAddress, children: [], }; @@ -64,8 +78,8 @@ export class PublicationTree implements AsyncIterable { * {@link PublicationTree.getEvent} to retrieve an event already in the tree. */ addEvent(event: NDKEvent, parentEvent: NDKEvent) { - const address = this.getAddressFromEvent(event); - const parentAddress = this.getAddressFromEvent(parentEvent); + const address = event.tagAddress(); + const parentAddress = parentEvent.tagAddress(); const parentNode = this.nodes.get(parentAddress); if (!parentNode) { @@ -74,18 +88,66 @@ export class PublicationTree implements AsyncIterable { ); } - // TODO: Determine node type. const node = { + type: this.getNodeType(event), address, parent: parentNode, children: [], }; - // TODO: Define a resolver for the lazy node. - parentNode.children!.push(node); + parentNode.children!.push(new Lazy(() => Promise.resolve(node))); this.nodes.set(address, node); 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. + */ + addEventByAddress(address: string, parentEvent: NDKEvent) { + const parentAddress = parentEvent.tagAddress(); + const parentNode = this.nodes.get(parentAddress); + + if (!parentNode) { + throw new Error( + `PublicationTree: Parent node with address ${parentAddress} not found.` + ); + } + + const lazyNode = new Lazy(async () => { + 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.` + ); + } + + const node: PublicationTreeNode = { + type: this.getNodeType(event), + address, + parent: parentNode, + children: [], + }; + + this.nodes.set(address, node); + this.events.set(address, event); + + return node; + }); + + parentNode.children!.push(lazyNode); + this.lazyNodes.set(address, lazyNode); + } + /** * Retrieves an event from the publication tree. * @param address The address of the event to retrieve. @@ -122,7 +184,7 @@ export class PublicationTree implements AsyncIterable { } const firstLeafEvent = await this.depthFirstRetrieve(); - this.bookmark = this.getAddressFromEvent(firstLeafEvent!); + this.bookmark = firstLeafEvent!.tagAddress(); return { done: false, value: firstLeafEvent! }; } @@ -161,32 +223,16 @@ export class PublicationTree implements AsyncIterable { .filter(tag => tag[0] === 'a') .map(tag => tag[1]); - const kinds = new Set(); - const pubkeys = new Set(); - const dTags = new Set(); for (const childAddress of currentChildAddresses) { if (this.nodes.has(childAddress)) { continue; } - - const [kind, pubkey, dTag] = childAddress.split(':'); - kinds.add(parseInt(kind)); - pubkeys.add(pubkey); - dTags.add(dTag); - } - - const childEvents = await this.ndk.fetchEvents({ - kinds: Array.from(kinds), - authors: Array.from(pubkeys), - '#d': Array.from(dTags), - }); - for (const childEvent of childEvents) { - this.addEvent(childEvent, currentEvent!); + this.addEventByAddress(childAddress, currentEvent!); } // If the current event has no children, it is a leaf. - if (childEvents.size === 0) { + if (currentChildAddresses.length === 0) { this.leaves.push(currentAddress!); // Return the first leaf if no address was provided. @@ -244,14 +290,24 @@ export class PublicationTree implements AsyncIterable { return this.getEvent(nextSibling!.address); } - private getAddressFromEvent(event: NDKEvent): string { - if (event.kind! < 30000 || event.kind! >= 40000) { + private getNodeType(event: NDKEvent): PublicationTreeNodeType { + const address = event.tagAddress(); + const node = this.nodes.get(address); + if (!node) { throw new Error( - "PublicationTree: Invalid event kind. Event kind must be in the range 30000-39999" + `PublicationTree: Event with address ${address} not found in the tree.` ); } - return `${event.kind}:${event.pubkey}:${event.dTag}`; + if (!node.parent) { + return PublicationTreeNodeType.Root; + } + + if (event.tags.some(tag => tag[0] === 'a')) { + return PublicationTreeNodeType.Branch; + } + + return PublicationTreeNodeType.Leaf; } // #endregion From 7da145fb5ec2466c9b497459aca409de1517cbcd Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Tue, 11 Mar 2025 09:14:51 -0500 Subject: [PATCH 09/52] Implement iterator `next()` method using cursor --- src/lib/data_structures/publication_tree.ts | 190 +++++++++----------- 1 file changed, 81 insertions(+), 109 deletions(-) diff --git a/src/lib/data_structures/publication_tree.ts b/src/lib/data_structures/publication_tree.ts index 0ec929c..9fd26c5 100644 --- a/src/lib/data_structures/publication_tree.ts +++ b/src/lib/data_structures/publication_tree.ts @@ -26,12 +26,6 @@ export class PublicationTree implements AsyncIterable { */ private nodes: Map; - /** - * A map of addresses in the tree to their corresponding lazy-loaded nodes. When a lazy node is - * retrieved, it is added to the {@link PublicationTree.nodes} map. - */ - private lazyNodes: Map> = new Map(); - /** * A map of addresses in the tree to their corresponding events. */ @@ -117,35 +111,9 @@ export class PublicationTree implements AsyncIterable { ); } - const lazyNode = new Lazy(async () => { - 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.` - ); - } - - const node: PublicationTreeNode = { - type: this.getNodeType(event), - address, - parent: parentNode, - children: [], - }; - - this.nodes.set(address, node); - this.events.set(address, event); - - return node; - }); - - parentNode.children!.push(lazyNode); - this.lazyNodes.set(address, lazyNode); + parentNode.children!.push( + new Lazy(() => this.resolveNode(address, parentNode)) + ); } /** @@ -168,29 +136,35 @@ export class PublicationTree implements AsyncIterable { */ setBookmark(address: string) { this.bookmark = address; + this.#cursor.tryMoveTo(address); } [Symbol.asyncIterator](): AsyncIterator { + this.#cursor.tryMoveTo(this.bookmark); return this; } async next(): Promise> { - // If no bookmark is set, start at the first leaf. Retrieve that first leaf if necessary. - if (!this.bookmark) { - this.bookmark = this.leaves.at(0); - if (this.bookmark) { - const bookmarkEvent = await this.getEvent(this.bookmark); - return { done: false, value: bookmarkEvent! }; + while (this.#cursor.target?.type !== PublicationTreeNodeType.Leaf) { + if (await this.#cursor.tryMoveToFirstChild()) { + continue; } - const firstLeafEvent = await this.depthFirstRetrieve(); - this.bookmark = firstLeafEvent!.tagAddress(); - return { done: false, value: firstLeafEvent! }; - } + if (await this.#cursor.tryMoveToNextSibling()) { + continue; + } - // TODO: Invoke a funciton to retrieve the next sibling of the bookmark. + if (await this.#cursor.tryMoveToParent()) { + continue; + } - return { done: true, value: null }; + if (this.#cursor.target?.type === PublicationTreeNodeType.Root) { + return { done: true, value: null }; + } + } + + const event = await this.getEvent(this.#cursor.target!.address); + return { done: false, value: event! }; } // #region Private Methods @@ -252,42 +226,37 @@ export class PublicationTree implements AsyncIterable { return null; } - private async getNextSibling(address: string): Promise { - if (!this.leaves.includes(address)) { - throw new Error( - `PublicationTree: Address ${address} is not a leaf. Cannot retrieve next sibling.` - ); - } - - let currentNode = this.nodes.get(address); - if (!currentNode) { - return null; - } + private async resolveNode( + address: string, + parentNode: PublicationTreeNode + ): Promise { + const [kind, pubkey, dTag] = address.split(':'); + const event = await this.ndk.fetchEvent({ + kinds: [parseInt(kind)], + authors: [pubkey], + '#d': [dTag], + }); - let parent = currentNode.parent; - if (!parent) { + if (!event) { throw new Error( - `PublicationTree: Address ${address} has no parent. Cannot retrieve next sibling.` + `PublicationTree: Event with address ${address} not found.` ); } - // TODO: Handle the case where the current node is the last leaf. - - let nextSibling: PublicationTreeNode | null = null; - do { - const siblings: Lazy[] = parent!.children!; - const currentIndex = siblings.findIndex(async sibling => (await sibling.value()).address === currentNode!.address); - nextSibling = (await siblings.at(currentIndex + 1)?.value()) ?? null; + const childAddresses = event.tags.filter(tag => tag[0] === 'a').map(tag => tag[1]); + const node: PublicationTreeNode = { + type: this.getNodeType(event), + address, + parent: parentNode, + children: childAddresses.map( + address => new Lazy(() => this.resolveNode(address, node)) + ), + }; - // If the next sibling has children, it is not a leaf. - if ((nextSibling?.children?.length ?? 0) > 0) { - currentNode = (await nextSibling!.children!.at(0)!.value())!; - parent = currentNode.parent; - nextSibling = null; - } - } while (nextSibling == null); + this.nodes.set(address, node); + this.events.set(address, event); - return this.getEvent(nextSibling!.address); + return node; } private getNodeType(event: NDKEvent): PublicationTreeNodeType { @@ -314,49 +283,52 @@ export class PublicationTree implements AsyncIterable { // #region Iteration Cursor - Cursor = class { - private tree: PublicationTree; - private currentNode: PublicationTreeNode | null | undefined; + #cursor = new class { + target: PublicationTreeNode | null | undefined; - constructor(tree: PublicationTree, currentNode: PublicationTreeNode | null = null) { - this.tree = tree; - - if (!currentNode) { - this.currentNode = this.tree.bookmark - ? this.tree.nodes.get(this.tree.bookmark) - : null; - } + #tree: PublicationTree; + + constructor(tree: PublicationTree) { + this.#tree = tree; } - async moveToFirstChild(): Promise { - if (!this.currentNode) { - throw new Error("Cursor: Current node is null or undefined."); + async tryMoveTo(address?: string) { + if (!address) { + const startEvent = await this.#tree.depthFirstRetrieve(); + this.target = this.#tree.nodes.get(startEvent!.tagAddress()); + } else { + this.target = this.#tree.nodes.get(address); } - const hasChildren = (this.currentNode.children?.length ?? 0) > 0; - const isLeaf = this.tree.leaves.includes(this.currentNode.address); - - if (!hasChildren && isLeaf) { + if (!this.target) { return false; } - if (!hasChildren && !isLeaf) { - // TODO: Fetch any missing children, then return the first child. + return true; + } + + async tryMoveToFirstChild(): Promise { + if (!this.target) { + throw new Error("Cursor: Target node is null or undefined."); } - this.currentNode = (await this.currentNode.children?.at(0)?.value())!; + if (this.target.type === PublicationTreeNodeType.Leaf) { + return false; + } + + this.target = (await this.target.children?.at(0)?.value())!; return true; } - async moveToNextSibling(): Promise { - if (!this.currentNode) { - throw new Error("Cursor: Current node is null or undefined."); + async tryMoveToNextSibling(): Promise { + if (!this.target) { + throw new Error("Cursor: Target node is null or undefined."); } - const parent = this.currentNode.parent; + const parent = this.target.parent; const siblings = parent?.children; const currentIndex = siblings?.findIndex(async sibling => - (await sibling.value()).address === this.currentNode!.address + (await sibling.value()).address === this.target!.address ); const nextSibling = (await siblings?.at(currentIndex! + 1)?.value()) ?? null; @@ -364,24 +336,24 @@ export class PublicationTree implements AsyncIterable { return false; } - this.currentNode = nextSibling; + this.target = nextSibling; return true; } - moveToParent(): boolean { - if (!this.currentNode) { - throw new Error("Cursor: Current node is null or undefined."); + tryMoveToParent(): boolean { + if (!this.target) { + throw new Error("Cursor: Target node is null or undefined."); } - const parent = this.currentNode.parent; + const parent = this.target.parent; if (!parent) { return false; } - this.currentNode = parent; + this.target = parent; return true; } - }; + }(this); // #endregion } \ No newline at end of file From 43dac80578341c67c642d35808ecdd8cb45b17df Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Tue, 11 Mar 2025 09:23:25 -0500 Subject: [PATCH 10/52] Use ES2025 private member syntax --- src/lib/data_structures/lazy.ts | 7 +- src/lib/data_structures/publication_tree.ts | 82 ++++++++++----------- 2 files changed, 46 insertions(+), 43 deletions(-) diff --git a/src/lib/data_structures/lazy.ts b/src/lib/data_structures/lazy.ts index d7203b8..6be32fb 100644 --- a/src/lib/data_structures/lazy.ts +++ b/src/lib/data_structures/lazy.ts @@ -1,11 +1,14 @@ export class Lazy { #value?: T; + #resolver: () => Promise; - constructor(private readonly resolver: () => Promise) {} + constructor(resolver: () => Promise) { + this.#resolver = resolver; + } async value(): Promise { if (!this.#value) { - this.#value = await this.resolver(); + this.#value = await this.#resolver(); } return this.#value; diff --git a/src/lib/data_structures/publication_tree.ts b/src/lib/data_structures/publication_tree.ts index 9fd26c5..a6a1f56 100644 --- a/src/lib/data_structures/publication_tree.ts +++ b/src/lib/data_structures/publication_tree.ts @@ -19,48 +19,48 @@ export class PublicationTree implements AsyncIterable { /** * The root node of the tree. */ - private root: PublicationTreeNode; + #root: PublicationTreeNode; /** * A map of addresses in the tree to their corresponding nodes. */ - private nodes: Map; + #nodes: Map; /** * A map of addresses in the tree to their corresponding events. */ - private events: Map; + #events: Map; /** * An ordered list of the addresses of the leaves of the tree. */ - private leaves: string[] = []; + #leaves: string[] = []; /** * The address of the last-visited node. Used for iteration and progressive retrieval. */ - private bookmark?: string; + #bookmark?: string; /** * The NDK instance used to fetch events. */ - private ndk: NDK; + #ndk: NDK; constructor(rootEvent: NDKEvent, ndk: NDK) { const rootAddress = rootEvent.tagAddress(); - this.root = { + this.#root = { type: PublicationTreeNodeType.Root, address: rootAddress, children: [], }; - this.nodes = new Map(); - this.nodes.set(rootAddress, this.root); + this.#nodes = new Map(); + this.#nodes.set(rootAddress, this.#root); - this.events = new Map(); - this.events.set(rootAddress, rootEvent); + this.#events = new Map(); + this.#events.set(rootAddress, rootEvent); - this.ndk = ndk; + this.#ndk = ndk; } /** @@ -74,7 +74,7 @@ export class PublicationTree implements AsyncIterable { addEvent(event: NDKEvent, parentEvent: NDKEvent) { const address = event.tagAddress(); const parentAddress = parentEvent.tagAddress(); - const parentNode = this.nodes.get(parentAddress); + const parentNode = this.#nodes.get(parentAddress); if (!parentNode) { throw new Error( @@ -83,14 +83,14 @@ export class PublicationTree implements AsyncIterable { } const node = { - type: this.getNodeType(event), + type: this.#getNodeType(event), address, parent: parentNode, children: [], }; parentNode.children!.push(new Lazy(() => Promise.resolve(node))); - this.nodes.set(address, node); - this.events.set(address, event); + this.#nodes.set(address, node); + this.#events.set(address, event); } /** @@ -103,7 +103,7 @@ export class PublicationTree implements AsyncIterable { */ addEventByAddress(address: string, parentEvent: NDKEvent) { const parentAddress = parentEvent.tagAddress(); - const parentNode = this.nodes.get(parentAddress); + const parentNode = this.#nodes.get(parentAddress); if (!parentNode) { throw new Error( @@ -112,7 +112,7 @@ export class PublicationTree implements AsyncIterable { } parentNode.children!.push( - new Lazy(() => this.resolveNode(address, parentNode)) + new Lazy(() => this.#resolveNode(address, parentNode)) ); } @@ -122,9 +122,9 @@ export class PublicationTree implements AsyncIterable { * @returns The event, or null if the event is not found. */ async getEvent(address: string): Promise { - let event = this.events.get(address) ?? null; + let event = this.#events.get(address) ?? null; if (!event) { - event = await this.depthFirstRetrieve(address); + event = await this.#depthFirstRetrieve(address); } return event; @@ -135,12 +135,12 @@ export class PublicationTree implements AsyncIterable { * @param address The address of the event to bookmark. */ setBookmark(address: string) { - this.bookmark = address; + this.#bookmark = address; this.#cursor.tryMoveTo(address); } [Symbol.asyncIterator](): AsyncIterator { - this.#cursor.tryMoveTo(this.bookmark); + this.#cursor.tryMoveTo(this.#bookmark); return this; } @@ -176,29 +176,29 @@ export class PublicationTree implements AsyncIterable { * will return the first leaf in the tree. * @returns The event, or null if the event is not found. */ - private async depthFirstRetrieve(address?: string): Promise { - if (address && this.nodes.has(address)) { - return this.events.get(address)!; + async #depthFirstRetrieve(address?: string): Promise { + if (address && this.#nodes.has(address)) { + return this.#events.get(address)!; } - const stack: string[] = [this.root.address]; + const stack: string[] = [this.#root.address]; let currentEvent: NDKEvent | null | undefined; while (stack.length > 0) { const currentAddress = stack.pop(); // Stop immediately if the target of the search is found. if (address != null && currentAddress === address) { - return this.events.get(address)!; + return this.#events.get(address)!; } // Augment the tree with the children of the current event. - const currentChildAddresses = this.events + const currentChildAddresses = this.#events .get(currentAddress!)!.tags .filter(tag => tag[0] === 'a') .map(tag => tag[1]); for (const childAddress of currentChildAddresses) { - if (this.nodes.has(childAddress)) { + if (this.#nodes.has(childAddress)) { continue; } @@ -207,7 +207,7 @@ export class PublicationTree implements AsyncIterable { // If the current event has no children, it is a leaf. if (currentChildAddresses.length === 0) { - this.leaves.push(currentAddress!); + this.#leaves.push(currentAddress!); // Return the first leaf if no address was provided. if (address == null) { @@ -226,12 +226,12 @@ export class PublicationTree implements AsyncIterable { return null; } - private async resolveNode( + async #resolveNode( address: string, parentNode: PublicationTreeNode ): Promise { const [kind, pubkey, dTag] = address.split(':'); - const event = await this.ndk.fetchEvent({ + const event = await this.#ndk.fetchEvent({ kinds: [parseInt(kind)], authors: [pubkey], '#d': [dTag], @@ -245,23 +245,23 @@ export class PublicationTree implements AsyncIterable { const childAddresses = event.tags.filter(tag => tag[0] === 'a').map(tag => tag[1]); const node: PublicationTreeNode = { - type: this.getNodeType(event), + type: this.#getNodeType(event), address, parent: parentNode, children: childAddresses.map( - address => new Lazy(() => this.resolveNode(address, node)) + address => new Lazy(() => this.#resolveNode(address, node)) ), }; - this.nodes.set(address, node); - this.events.set(address, event); + this.#nodes.set(address, node); + this.#events.set(address, event); return node; } - private getNodeType(event: NDKEvent): PublicationTreeNodeType { + #getNodeType(event: NDKEvent): PublicationTreeNodeType { const address = event.tagAddress(); - const node = this.nodes.get(address); + const node = this.#nodes.get(address); if (!node) { throw new Error( `PublicationTree: Event with address ${address} not found in the tree.` @@ -294,10 +294,10 @@ export class PublicationTree implements AsyncIterable { async tryMoveTo(address?: string) { if (!address) { - const startEvent = await this.#tree.depthFirstRetrieve(); - this.target = this.#tree.nodes.get(startEvent!.tagAddress()); + const startEvent = await this.#tree.#depthFirstRetrieve(); + this.target = this.#tree.#nodes.get(startEvent!.tagAddress()); } else { - this.target = this.#tree.nodes.get(address); + this.target = this.#tree.#nodes.get(address); } if (!this.target) { From acea0fc0784a68eeb6070a8332a1c4c8b5b85ce2 Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Tue, 11 Mar 2025 09:25:09 -0500 Subject: [PATCH 11/52] Code organization --- src/lib/data_structures/publication_tree.ts | 156 ++++++++++---------- 1 file changed, 80 insertions(+), 76 deletions(-) diff --git a/src/lib/data_structures/publication_tree.ts b/src/lib/data_structures/publication_tree.ts index a6a1f56..0b16045 100644 --- a/src/lib/data_structures/publication_tree.ts +++ b/src/lib/data_structures/publication_tree.ts @@ -139,6 +139,84 @@ export class PublicationTree implements AsyncIterable { 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 = this.#tree.#nodes.get(startEvent!.tagAddress()); + } else { + this.target = this.#tree.#nodes.get(address); + } + + if (!this.target) { + return false; + } + + return true; + } + + async tryMoveToFirstChild(): Promise { + 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 { + if (!this.target) { + throw new Error("Cursor: Target node is null or undefined."); + } + + const parent = this.target.parent; + const siblings = parent?.children; + const currentIndex = siblings?.findIndex(async sibling => + (await sibling.value()).address === this.target!.address + ); + + 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 { this.#cursor.tryMoveTo(this.#bookmark); return this; @@ -167,6 +245,8 @@ export class PublicationTree implements AsyncIterable { return { done: false, value: event! }; } + // #endregion + // #region Private Methods /** @@ -280,80 +360,4 @@ export class PublicationTree implements AsyncIterable { } // #endregion - - // #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 = this.#tree.#nodes.get(startEvent!.tagAddress()); - } else { - this.target = this.#tree.#nodes.get(address); - } - - if (!this.target) { - return false; - } - - return true; - } - - async tryMoveToFirstChild(): Promise { - 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 { - if (!this.target) { - throw new Error("Cursor: Target node is null or undefined."); - } - - const parent = this.target.parent; - const siblings = parent?.children; - const currentIndex = siblings?.findIndex(async sibling => - (await sibling.value()).address === this.target!.address - ); - - 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 } \ No newline at end of file From 177a5711554a5117d7f1dee2952972d1d1af4262 Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Tue, 11 Mar 2025 22:08:41 -0500 Subject: [PATCH 12/52] Move Modal component to components directory --- src/lib/{ => components}/Modal.svelte | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/lib/{ => components}/Modal.svelte (100%) diff --git a/src/lib/Modal.svelte b/src/lib/components/Modal.svelte similarity index 100% rename from src/lib/Modal.svelte rename to src/lib/components/Modal.svelte From 7e0165fdc5c26fd50eefad7f8fbf9223dfd18f4b Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Tue, 11 Mar 2025 22:08:50 -0500 Subject: [PATCH 13/52] Update Deno lockfile --- deno.lock | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/deno.lock b/deno.lock index e3b827f..f113237 100644 --- a/deno.lock +++ b/deno.lock @@ -2862,6 +2862,22 @@ } }, "workspace": { + "dependencies": [ + "npm:@nostr-dev-kit/ndk-cache-dexie@2.5", + "npm:@nostr-dev-kit/ndk@2.11", + "npm:@popperjs/core@2.11", + "npm:@tailwindcss/forms@0.5", + "npm:@tailwindcss/typography@0.5", + "npm:asciidoctor@3.0", + "npm:d3@7.9", + "npm:flowbite-svelte-icons@2.0", + "npm:flowbite-svelte@0.44", + "npm:flowbite@2.2", + "npm:he@1.2", + "npm:nostr-tools@2.10", + "npm:svelte@5.0", + "npm:tailwind-merge@2.5" + ], "packageJson": { "dependencies": [ "npm:@nostr-dev-kit/ndk-cache-dexie@2.5", From 7ef3cb8ab895339e0b35fdffa15b47f7d5a942b1 Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Fri, 14 Mar 2025 08:02:42 -0500 Subject: [PATCH 14/52] Fix an iterator error that would cause an infinite loop --- src/lib/data_structures/publication_tree.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/data_structures/publication_tree.ts b/src/lib/data_structures/publication_tree.ts index 0b16045..4306b1e 100644 --- a/src/lib/data_structures/publication_tree.ts +++ b/src/lib/data_structures/publication_tree.ts @@ -223,7 +223,7 @@ export class PublicationTree implements AsyncIterable { } async next(): Promise> { - while (this.#cursor.target?.type !== PublicationTreeNodeType.Leaf) { + do { if (await this.#cursor.tryMoveToFirstChild()) { continue; } @@ -232,14 +232,14 @@ export class PublicationTree implements AsyncIterable { continue; } - if (await this.#cursor.tryMoveToParent()) { + 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! }; From 837b371551aa7678b9ecfa6911e6d03439b4efea Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Fri, 14 Mar 2025 08:03:12 -0500 Subject: [PATCH 15/52] Add a method to return the hierarchy in which an event lives --- src/lib/data_structures/publication_tree.ts | 22 +++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/lib/data_structures/publication_tree.ts b/src/lib/data_structures/publication_tree.ts index 4306b1e..bac54a0 100644 --- a/src/lib/data_structures/publication_tree.ts +++ b/src/lib/data_structures/publication_tree.ts @@ -130,6 +130,28 @@ export class PublicationTree implements AsyncIterable { return event; } + /** + * 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 { + let node = this.#nodes.get(address); + if (!node) { + return null; + } + + 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. From e8ed3ac08f37dd047b62d1a400dadac3a73dea0d Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Fri, 14 Mar 2025 08:09:02 -0500 Subject: [PATCH 16/52] Throw error rather than return `null` --- src/lib/data_structures/publication_tree.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/data_structures/publication_tree.ts b/src/lib/data_structures/publication_tree.ts index bac54a0..1cdbe16 100644 --- a/src/lib/data_structures/publication_tree.ts +++ b/src/lib/data_structures/publication_tree.ts @@ -136,10 +136,10 @@ export class PublicationTree implements AsyncIterable { * @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 { + async getHierarchy(address: string): Promise { let node = this.#nodes.get(address); if (!node) { - return null; + throw new Error(`PublicationTree: Node with address ${address} not found.`); } const hierarchy: NDKEvent[] = [this.#events.get(address)!]; From 9011eb643f8373d409f8e3af7a40ec89d9f41a29 Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Fri, 14 Mar 2025 08:11:05 -0500 Subject: [PATCH 17/52] Make `getHierarchy` synchronous --- src/lib/data_structures/publication_tree.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/data_structures/publication_tree.ts b/src/lib/data_structures/publication_tree.ts index 1cdbe16..ab011a2 100644 --- a/src/lib/data_structures/publication_tree.ts +++ b/src/lib/data_structures/publication_tree.ts @@ -136,7 +136,7 @@ export class PublicationTree implements AsyncIterable { * @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 { + getHierarchy(address: string): NDKEvent[] { let node = this.#nodes.get(address); if (!node) { throw new Error(`PublicationTree: Node with address ${address} not found.`); From c403a245b81f8c9d82bc8ccae08f82d87172dc14 Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Fri, 14 Mar 2025 08:26:47 -0500 Subject: [PATCH 18/52] Move some publication rendering snippets to a separate file --- src/lib/components/Preview.svelte | 45 ++------------------- src/lib/snippets/PublicationSnippets.svelte | 45 +++++++++++++++++++++ 2 files changed, 48 insertions(+), 42 deletions(-) create mode 100644 src/lib/snippets/PublicationSnippets.svelte diff --git a/src/lib/components/Preview.svelte b/src/lib/components/Preview.svelte index f7dfe03..c980b48 100644 --- a/src/lib/components/Preview.svelte +++ b/src/lib/components/Preview.svelte @@ -1,8 +1,9 @@ -{#snippet sectionHeading(title: string, depth: number)} - {#if depth === 0} -

- {title} -

- {:else if depth === 1} -

- {title} -

- {:else if depth === 2} -

- {title} -

- {:else if depth === 3} -

- {title} -

- {:else if depth === 4} -
- {title} -
- {:else} -
- {title} -
- {/if} -{/snippet} - -{#snippet contentParagraph(content: string, publicationType: string)} - {#if publicationType === 'novel'} -

- {@html content} -

- {:else} -

- {@html content} -

- {/if} -{/snippet} -
{:else} - {@render contentParagraph(currentContent, publicationType)} + {@render contentParagraph(currentContent, publicationType, isSectionStart)} {/if} {/key} {:else} diff --git a/src/lib/snippets/PublicationSnippets.svelte b/src/lib/snippets/PublicationSnippets.svelte new file mode 100644 index 0000000..26645fa --- /dev/null +++ b/src/lib/snippets/PublicationSnippets.svelte @@ -0,0 +1,45 @@ + + +{#snippet sectionHeading(title: string, depth: number)} + {#if depth === 0} +

+ {title} +

+ {:else if depth === 1} +

+ {title} +

+ {:else if depth === 2} +

+ {title} +

+ {:else if depth === 3} +

+ {title} +

+ {:else if depth === 4} +
+ {title} +
+ {:else} +
+ {title} +
+ {/if} +{/snippet} + +{#snippet contentParagraph(content: string, publicationType: string, isSectionStart: boolean)} + {#if publicationType === 'novel'} +

+ {@html content} +

+ {:else} +

+ {@html content} +

+ {/if} +{/snippet} From 3a313b1f3988c9ea65839577fccb10134c0826c1 Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Thu, 20 Mar 2025 09:33:07 -0500 Subject: [PATCH 19/52] Fix some bugs in the publication tree traversal --- src/lib/data_structures/publication_tree.ts | 120 +++++++++++--------- 1 file changed, 69 insertions(+), 51 deletions(-) diff --git a/src/lib/data_structures/publication_tree.ts b/src/lib/data_structures/publication_tree.ts index ab011a2..f67aa03 100644 --- a/src/lib/data_structures/publication_tree.ts +++ b/src/lib/data_structures/publication_tree.ts @@ -24,7 +24,7 @@ export class PublicationTree implements AsyncIterable { /** * A map of addresses in the tree to their corresponding nodes. */ - #nodes: Map; + #nodes: Map>; /** * A map of addresses in the tree to their corresponding events. @@ -54,8 +54,8 @@ export class PublicationTree implements AsyncIterable { children: [], }; - this.#nodes = new Map(); - this.#nodes.set(rootAddress, this.#root); + this.#nodes = new Map>(); + this.#nodes.set(rootAddress, new Lazy(() => Promise.resolve(this.#root))); this.#events = new Map(); this.#events.set(rootAddress, rootEvent); @@ -71,10 +71,10 @@ export class PublicationTree implements AsyncIterable { * @description The parent event must already be in the tree. Use * {@link PublicationTree.getEvent} to retrieve an event already in the tree. */ - addEvent(event: NDKEvent, parentEvent: NDKEvent) { + async addEvent(event: NDKEvent, parentEvent: NDKEvent) { const address = event.tagAddress(); const parentAddress = parentEvent.tagAddress(); - const parentNode = this.#nodes.get(parentAddress); + const parentNode = await this.#nodes.get(parentAddress)?.value(); if (!parentNode) { throw new Error( @@ -82,14 +82,14 @@ export class PublicationTree implements AsyncIterable { ); } - const node = { - type: this.#getNodeType(event), + const node: PublicationTreeNode = { + type: await this.#getNodeType(event), address, parent: parentNode, children: [], }; parentNode.children!.push(new Lazy(() => Promise.resolve(node))); - this.#nodes.set(address, node); + this.#nodes.set(address, new Lazy(() => Promise.resolve(node))); this.#events.set(address, event); } @@ -101,9 +101,9 @@ export class PublicationTree implements AsyncIterable { * @description The parent event must already be in the tree. Use * {@link PublicationTree.getEvent} to retrieve an event already in the tree. */ - addEventByAddress(address: string, parentEvent: NDKEvent) { + async addEventByAddress(address: string, parentEvent: NDKEvent) { const parentAddress = parentEvent.tagAddress(); - const parentNode = this.#nodes.get(parentAddress); + const parentNode = await this.#nodes.get(parentAddress)?.value(); if (!parentNode) { throw new Error( @@ -111,9 +111,7 @@ export class PublicationTree implements AsyncIterable { ); } - parentNode.children!.push( - new Lazy(() => this.#resolveNode(address, parentNode)) - ); + await this.#addNode(address, parentNode); } /** @@ -136,8 +134,8 @@ export class PublicationTree implements AsyncIterable { * @returns Returns an array of events in the addressed event's hierarchy, beginning with the * root and ending with the addressed event. */ - getHierarchy(address: string): NDKEvent[] { - let node = this.#nodes.get(address); + async getHierarchy(address: string): Promise { + let node = await this.#nodes.get(address)?.value(); if (!node) { throw new Error(`PublicationTree: Node with address ${address} not found.`); } @@ -175,9 +173,9 @@ export class PublicationTree implements AsyncIterable { async tryMoveTo(address?: string) { if (!address) { const startEvent = await this.#tree.#depthFirstRetrieve(); - this.target = this.#tree.#nodes.get(startEvent!.tagAddress()); + this.target = await this.#tree.#nodes.get(startEvent!.tagAddress())?.value(); } else { - this.target = this.#tree.#nodes.get(address); + this.target = await this.#tree.#nodes.get(address)?.value(); } if (!this.target) { @@ -240,11 +238,14 @@ export class PublicationTree implements AsyncIterable { // #region Async Iterator Implementation [Symbol.asyncIterator](): AsyncIterator { - this.#cursor.tryMoveTo(this.#bookmark); return this; } async next(): Promise> { + if (!this.#cursor.target) { + await this.#cursor.tryMoveTo(this.#bookmark); + } + do { if (await this.#cursor.tryMoveToFirstChild()) { continue; @@ -284,33 +285,31 @@ export class PublicationTree implements AsyncIterable { } const stack: string[] = [this.#root.address]; - let currentEvent: NDKEvent | null | undefined; + 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 this.#events.get(address)!; + return currentEvent; } - // Augment the tree with the children of the current event. - const currentChildAddresses = this.#events - .get(currentAddress!)!.tags + const currentChildAddresses = currentEvent.tags .filter(tag => tag[0] === 'a') .map(tag => tag[1]); - for (const childAddress of currentChildAddresses) { - if (this.#nodes.has(childAddress)) { - continue; - } - - this.addEventByAddress(childAddress, currentEvent!); - } - // If the current event has no children, it is a leaf. if (currentChildAddresses.length === 0) { - this.#leaves.push(currentAddress!); - // Return the first leaf if no address was provided. if (address == null) { return currentEvent!; @@ -319,15 +318,40 @@ export class PublicationTree implements AsyncIterable { 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) { - stack.push(currentChildAddresses.pop()!); + const nextAddress = currentChildAddresses.pop()!; + stack.push(nextAddress); } } return null; } + async #addNode(address: string, parentNode: PublicationTreeNode): Promise { + const lazyNode = new Lazy(() => 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 @@ -345,36 +369,30 @@ export class PublicationTree implements AsyncIterable { ); } + this.#events.set(address, event); + const childAddresses = event.tags.filter(tag => tag[0] === 'a').map(tag => tag[1]); + const node: PublicationTreeNode = { - type: this.#getNodeType(event), + type: await this.#getNodeType(event), address, parent: parentNode, - children: childAddresses.map( - address => new Lazy(() => this.#resolveNode(address, node)) - ), + children: [], }; - this.#nodes.set(address, node); - this.#events.set(address, event); + for (const address of childAddresses) { + this.addEventByAddress(address, event); + } return node; } - #getNodeType(event: NDKEvent): PublicationTreeNodeType { - const address = event.tagAddress(); - const node = this.#nodes.get(address); - if (!node) { - throw new Error( - `PublicationTree: Event with address ${address} not found in the tree.` - ); - } - - if (!node.parent) { + async #getNodeType(event: NDKEvent): Promise { + if (event.tagAddress() === this.#root.address) { return PublicationTreeNodeType.Root; } - if (event.tags.some(tag => tag[0] === 'a')) { + if (event.kind === 30040 && event.tags.some(tag => tag[0] === 'a')) { return PublicationTreeNodeType.Branch; } From 947470b3a1a095fbfc6631e43420ccde32bde09a Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Fri, 21 Mar 2025 08:09:23 -0500 Subject: [PATCH 20/52] Small edits to publication tree --- src/lib/data_structures/publication_tree.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/data_structures/publication_tree.ts b/src/lib/data_structures/publication_tree.ts index f67aa03..9c5cb64 100644 --- a/src/lib/data_structures/publication_tree.ts +++ b/src/lib/data_structures/publication_tree.ts @@ -1,6 +1,6 @@ import type NDK from "@nostr-dev-kit/ndk"; -import type { NDKEvent, NDKFilter } from "@nostr-dev-kit/ndk"; -import { Lazy } from "./lazy"; +import type { NDKEvent } from "@nostr-dev-kit/ndk"; +import { Lazy } from "./lazy.ts"; enum PublicationTreeNodeType { Root, @@ -337,7 +337,7 @@ export class PublicationTree implements AsyncIterable { return null; } - async #addNode(address: string, parentNode: PublicationTreeNode): Promise { + #addNode(address: string, parentNode: PublicationTreeNode) { const lazyNode = new Lazy(() => this.#resolveNode(address, parentNode)); parentNode.children!.push(lazyNode); this.#nodes.set(address, lazyNode); From 1272d312d9e827b999e28f05c8c549bf3d4dc8ee Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Fri, 28 Mar 2025 23:30:11 -0500 Subject: [PATCH 21/52] Support async find for lazy publication tree nodes --- src/lib/data_structures/publication_tree.ts | 14 +++++++-- src/lib/utils.ts | 35 +++++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/lib/data_structures/publication_tree.ts b/src/lib/data_structures/publication_tree.ts index 9c5cb64..e273711 100644 --- a/src/lib/data_structures/publication_tree.ts +++ b/src/lib/data_structures/publication_tree.ts @@ -205,11 +205,19 @@ export class PublicationTree implements AsyncIterable { const parent = this.target.parent; const siblings = parent?.children; - const currentIndex = siblings?.findIndex(async sibling => - (await sibling.value()).address === this.target!.address + if (!siblings) { + return false; + } + + const currentIndex = await siblings.findIndexAsync( + async (sibling: Lazy) => (await sibling.value()).address === this.target!.address ); - const nextSibling = (await siblings?.at(currentIndex! + 1)?.value()) ?? null; + if (currentIndex === -1) { + return false; + } + + const nextSibling = (await siblings.at(currentIndex + 1)?.value()) ?? null; if (!nextSibling) { return false; } diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 3a54d64..021c979 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -109,3 +109,38 @@ export function filterValidIndexEvents(events: Set): Set { console.debug(`Filtered index events: ${events.size} events remaining.`); return events; } + +/** + * Async version of Array.findIndex() that runs sequentially. + * Returns the index of the first element that satisfies the provided testing function. + * @param array The array to search + * @param predicate The async testing function + * @returns A promise that resolves to the index of the first matching element, or -1 if none found + */ +export async function findIndexAsync( + array: T[], + predicate: (element: T, index: number, array: T[]) => Promise +): Promise { + for (let i = 0; i < array.length; i++) { + if (await predicate(array[i], i, array)) { + return i; + } + } + return -1; +} + +// Extend Array prototype with findIndexAsync +declare global { + interface Array { + findIndexAsync( + predicate: (element: T, index: number, array: T[]) => Promise + ): Promise; + } +} + +Array.prototype.findIndexAsync = function( + this: T[], + predicate: (element: T, index: number, array: T[]) => Promise +): Promise { + return findIndexAsync(this, predicate); +}; From ef0c033d9bde1bcab21db537213883b92f0cb889 Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Fri, 28 Mar 2025 23:31:41 -0500 Subject: [PATCH 22/52] Fix import for findindexasync --- src/lib/data_structures/publication_tree.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/data_structures/publication_tree.ts b/src/lib/data_structures/publication_tree.ts index e273711..11fbf31 100644 --- a/src/lib/data_structures/publication_tree.ts +++ b/src/lib/data_structures/publication_tree.ts @@ -1,6 +1,7 @@ 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, From f1bdb20e21d41ba81b4f10b75b8f42c78e636cfb Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Fri, 28 Mar 2025 23:32:18 -0500 Subject: [PATCH 23/52] Add `getChildAddresses` function to publication tree --- src/lib/data_structures/publication_tree.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/lib/data_structures/publication_tree.ts b/src/lib/data_structures/publication_tree.ts index 11fbf31..e7e0e41 100644 --- a/src/lib/data_structures/publication_tree.ts +++ b/src/lib/data_structures/publication_tree.ts @@ -129,6 +129,23 @@ export class PublicationTree implements AsyncIterable { 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 { + 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. From 4292cf3b134ec4590ce27f6eb680418b0bb41bc9 Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Thu, 3 Apr 2025 09:04:59 -0500 Subject: [PATCH 24/52] Open #11 --- src/routes/publication/+page.ts | 12 +++++++----- tsconfig.json | 3 ++- vite.config.ts | 6 ++++++ 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/routes/publication/+page.ts b/src/routes/publication/+page.ts index 3d58b39..7fddcdc 100644 --- a/src/routes/publication/+page.ts +++ b/src/routes/publication/+page.ts @@ -1,11 +1,11 @@ import { error } from '@sveltejs/kit'; -import { NDKRelay, NDKRelaySet, type NDKEvent } from '@nostr-dev-kit/ndk'; +import type { NDKEvent } from '@nostr-dev-kit/ndk'; import type { PageLoad } from './$types'; -import { get } from 'svelte/store'; -import { getActiveRelays, inboxRelays, ndkInstance } from '$lib/ndk'; -import { standardRelays } from '$lib/consts'; +import { getActiveRelays } from '$lib/ndk.ts'; +import { setContext } from 'svelte'; +import { PublicationTree } from '$lib/data_structures/publication_tree.ts'; -export const load: PageLoad = async ({ url, parent }) => { +export const load: PageLoad = async ({ url, parent }: { url: URL; parent: () => Promise }) => { const id = url.searchParams.get('id'); const dTag = url.searchParams.get('d'); @@ -41,6 +41,8 @@ export const load: PageLoad = async ({ url, parent }) => { const publicationType = indexEvent?.getMatchingTags('type')[0]?.[1]; const fetchPromise = parser.fetch(indexEvent); + setContext('publicationTree', new PublicationTree(indexEvent, ndk)); + return { waitable: fetchPromise, publicationType, diff --git a/tsconfig.json b/tsconfig.json index 794b95b..ec41776 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,8 @@ "resolveJsonModule": true, "skipLibCheck": true, "sourceMap": true, - "strict": true + "strict": true, + "allowImportingTsExtensions": true } // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias // diff --git a/vite.config.ts b/vite.config.ts index b9ba52c..d723dc1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,6 +3,12 @@ import { defineConfig } from "vite"; export default defineConfig({ plugins: [sveltekit()], + resolve: { + alias: { + $lib: './src/lib', + $components: './src/components' + } + }, test: { include: ['./tests/unit/**/*.unit-test.js'] } From 4a2640e0b42e86ae6d815dfc7273ad696b57edff Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 5 Apr 2025 10:40:27 +0200 Subject: [PATCH 25/52] Pull the latest tag from git, on build, and display it top-right on the About page. --- src/routes/about/+page.svelte | 10 ++++++++-- vite.config.ts | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/routes/about/+page.svelte b/src/routes/about/+page.svelte index 9e4ee80..00a14c8 100644 --- a/src/routes/about/+page.svelte +++ b/src/routes/about/+page.svelte @@ -1,10 +1,16 @@
- About +
+ About + Version: {gitTagVersion} +

Alexandria is a reader and writer for curated publications (in Asciidoc), and will eventually also support long-form articles (Markdown) and wiki pages (Asciidoc). It is produced by the GitCitadel project team.

Please submit support issues on the project repo page and follow us on GitHub and Geyserfund.

@@ -53,4 +59,4 @@

Documentation

-
\ No newline at end of file + diff --git a/vite.config.ts b/vite.config.ts index b9ba52c..7a6e207 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,9 +1,26 @@ import { sveltekit } from "@sveltejs/kit/vite"; import { defineConfig } from "vite"; +import { execSync } from "child_process"; + +// Function to get the latest git tag +function getLatestGitTag() { + try { + // Get the latest git tag + const tag = execSync('git describe --tags --abbrev=0').toString().trim(); + return tag; + } catch (error) { + console.error("Failed to get git tag:", error); + return "unknown"; + } +} export default defineConfig({ plugins: [sveltekit()], test: { include: ['./tests/unit/**/*.unit-test.js'] + }, + define: { + // Expose the git tag as a global variable + 'import.meta.env.GIT_TAG': JSON.stringify(getLatestGitTag()) } }); From b763ecd99f09373c69770717025e84e739a8df3b Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 5 Apr 2025 11:47:23 +0200 Subject: [PATCH 26/52] Refactored a bit. Changed a tags to buttons that look like hyperlinks, to preserve normal button functionality. --- src/lib/components/Login.svelte | 2 +- src/lib/components/util/CardActions.svelte | 18 +++++++++--------- src/lib/components/util/CopyToClipboard.svelte | 4 ++-- src/lib/components/util/Profile.svelte | 10 +++++----- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/lib/components/Login.svelte b/src/lib/components/Login.svelte index 2d77763..1456149 100644 --- a/src/lib/components/Login.svelte +++ b/src/lib/components/Login.svelte @@ -50,7 +50,7 @@ @@ -157,7 +157,7 @@
{#if image}
- + Publication cover image for {title}
{/if}
diff --git a/src/lib/components/util/CopyToClipboard.svelte b/src/lib/components/util/CopyToClipboard.svelte index 9e72c79..63f1aa7 100644 --- a/src/lib/components/util/CopyToClipboard.svelte +++ b/src/lib/components/util/CopyToClipboard.svelte @@ -18,10 +18,10 @@ } - + diff --git a/src/lib/components/util/Profile.svelte b/src/lib/components/util/Profile.svelte index fd23c9f..a44cba1 100644 --- a/src/lib/components/util/Profile.svelte +++ b/src/lib/components/util/Profile.svelte @@ -46,10 +46,12 @@ function shortenNpub(long: string|undefined) { class='h-6 w-6 cursor-pointer' src={pfp} alt={username} + id="profile-avatar" /> {#key username || tag} @@ -70,15 +72,13 @@ function shortenNpub(long: string|undefined) { {#if isNav}
  • - Sign out - +
  • {:else}
    - {JSON.stringify(event.rawEvent())} +
    {JSON.stringify(event.rawEvent(), null, 2)}
    @@ -157,7 +148,7 @@
    {#if image}
    - + Publication cover
    {/if}
    diff --git a/src/lib/components/util/CopyToClipboard.svelte b/src/lib/components/util/CopyToClipboard.svelte index 9e72c79..d0bbba3 100644 --- a/src/lib/components/util/CopyToClipboard.svelte +++ b/src/lib/components/util/CopyToClipboard.svelte @@ -18,10 +18,10 @@ } - + diff --git a/src/lib/components/util/Profile.svelte b/src/lib/components/util/Profile.svelte index fd23c9f..6918677 100644 --- a/src/lib/components/util/Profile.svelte +++ b/src/lib/components/util/Profile.svelte @@ -70,25 +70,21 @@ function shortenNpub(long: string|undefined) { {#if isNav}
  • - Sign out - +
  • {:else} {/if} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 021c979..35d6e03 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -10,6 +10,20 @@ export function neventEncode(event: NDKEvent, relays: string[]) { }); } +export function naddrEncode(event: NDKEvent, relays: string[]) { + const dTag = event.getMatchingTags('d')[0]?.[1]; + if (!dTag) { + throw new Error('Event does not have a d tag'); + } + + return nip19.naddrEncode({ + identifier: dTag, + pubkey: event.pubkey, + kind: event.kind || 0, + relays, + }); +} + export function formatDate(unixtimestamp: number) { const months = [ "Jan", diff --git a/src/routes/publication/+page.ts b/src/routes/publication/+page.ts index 7fddcdc..052cc75 100644 --- a/src/routes/publication/+page.ts +++ b/src/routes/publication/+page.ts @@ -1,43 +1,90 @@ import { error } from '@sveltejs/kit'; import type { NDKEvent } from '@nostr-dev-kit/ndk'; import type { PageLoad } from './$types'; +import { nip19 } from 'nostr-tools'; import { getActiveRelays } from '$lib/ndk.ts'; import { setContext } from 'svelte'; import { PublicationTree } from '$lib/data_structures/publication_tree.ts'; +/** + * Decodes an naddr identifier and returns a filter object + */ +function decodeNaddr(id: string) { + try { + if (!id.startsWith('naddr1')) return {}; + + const decoded = nip19.decode(id); + if (decoded.type !== 'naddr') return {}; + + const data = decoded.data; + return { + kinds: [data.kind], + authors: [data.pubkey], + '#d': [data.identifier] + }; + } catch (e) { + console.error('Failed to decode naddr:', e); + return {}; + } +} + +/** + * Fetches an event by ID or filter + */ +async function fetchEventById(ndk: any, id: string): Promise { + const filter = decodeNaddr(id); + const hasFilter = Object.keys(filter).length > 0; + + try { + const event = await (hasFilter ? + ndk.fetchEvent(filter) : + ndk.fetchEvent(id)); + + if (!event) { + throw new Error(`Event not found for ID: ${id}`); + } + + return event; + } catch (err) { + throw error(404, `Failed to fetch publication root event for ID: ${id}\n${err}`); + } +} + +/** + * Fetches an event by d tag + */ +async function fetchEventByDTag(ndk: any, dTag: string): Promise { + try { + const event = await ndk.fetchEvent( + { '#d': [dTag] }, + { closeOnEose: false }, + getActiveRelays(ndk) + ); + + if (!event) { + throw new Error(`Event not found for d tag: ${dTag}`); + } + + return event; + } catch (err) { + throw error(404, `Failed to fetch publication root event for d tag: ${dTag}\n${err}`); + } +} + export const load: PageLoad = async ({ url, parent }: { url: URL; parent: () => Promise }) => { const id = url.searchParams.get('id'); const dTag = url.searchParams.get('d'); - const { ndk, parser } = await parent(); - - let eventPromise: Promise; - let indexEvent: NDKEvent | null; - - if (id) { - eventPromise = ndk.fetchEvent(id) - .then((ev: NDKEvent | null) => { - return ev; - }) - .catch((err: any) => { - error(404, `Failed to fetch publication root event for ID: ${id}\n${err}`); - }); - } else if (dTag) { - eventPromise = new Promise(resolve => { - ndk - .fetchEvent({ '#d': [dTag] }, { closeOnEose: false }, getActiveRelays(ndk)) - .then((event: NDKEvent | null) => { - resolve(event); - }) - .catch((err: any) => { - error(404, `Failed to fetch publication root event for d tag: ${dTag}\n${err}`); - }); - }); - } else { - error(400, 'No publication root event ID or d tag provided.'); + + if (!id && !dTag) { + throw error(400, 'No publication root event ID or d tag provided.'); } - - indexEvent = await eventPromise as NDKEvent; + + // Fetch the event based on available parameters + const indexEvent = id + ? await fetchEventById(ndk, id) + : await fetchEventByDTag(ndk, dTag!); + const publicationType = indexEvent?.getMatchingTags('type')[0]?.[1]; const fetchPromise = parser.fetch(indexEvent); From 732d518eac182eb3e1ef1fb24836402c2696a4c5 Mon Sep 17 00:00:00 2001 From: buttercat1791 Date: Tue, 8 Apr 2025 22:49:15 -0500 Subject: [PATCH 39/52] Fix errors blocking publication load --- src/lib/components/Preview.svelte | 4 ++-- src/routes/publication/+page.ts | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/lib/components/Preview.svelte b/src/lib/components/Preview.svelte index 30b7f10..0c85484 100644 --- a/src/lib/components/Preview.svelte +++ b/src/lib/components/Preview.svelte @@ -1,6 +1,6 @@
    {node.type} ({node.isContainer ? "30040" : "30041"})
    -
    - ID: {node.id} - {#if node.naddr} -
    {node.naddr}
    - {/if} - {#if node.nevent} -
    {node.nevent}
    - {/if} +
    + Author: {getAuthorTag(node)} +
    + {#if node.content}
    {/if}
    -
    \ No newline at end of file +
    From ece42f30218d275cba271a7caf3464a3a94c7f46 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 9 Apr 2025 17:53:30 +0200 Subject: [PATCH 42/52] Fix a type error. Got rid of unused style and applied a style from the main css. Implemented Svelte props. Made sure entire element is visible on page, and added a hyperlink to the publication based upon eventID. Limit content to 200 chars. --- .../navigator/EventNetwork/NodeTooltip.svelte | 66 +++++++++++++++---- src/lib/navigator/EventNetwork/index.svelte | 12 +--- 2 files changed, 57 insertions(+), 21 deletions(-) diff --git a/src/lib/navigator/EventNetwork/NodeTooltip.svelte b/src/lib/navigator/EventNetwork/NodeTooltip.svelte index 072ea96..0c40287 100644 --- a/src/lib/navigator/EventNetwork/NodeTooltip.svelte +++ b/src/lib/navigator/EventNetwork/NodeTooltip.svelte @@ -1,10 +1,17 @@
    -
    {node.title}
    +
    {node.type} ({node.isContainer ? "30040" : "30041"})
    Author: {getAuthorTag(node)}
    - + {#if node.content}
    - {node.content} + {truncateContent(node.content)}
    {/if} {#if selected} diff --git a/src/lib/navigator/EventNetwork/index.svelte b/src/lib/navigator/EventNetwork/index.svelte index 9246df3..23aa12b 100644 --- a/src/lib/navigator/EventNetwork/index.svelte +++ b/src/lib/navigator/EventNetwork/index.svelte @@ -8,6 +8,7 @@ import { createSimulation, setupDragHandlers, applyGlobalLogGravity, applyConnectedGravity } from "./utils/forceSimulation"; import Legend from "./Legend.svelte"; import NodeTooltip from "./NodeTooltip.svelte"; + import type { NetworkNode, NetworkLink } from "./types"; let { events = [] } = $props<{ events?: NDKEvent[] }>(); @@ -90,14 +91,14 @@ function updateGraph() { if (!svg || !events?.length || !svgGroup) return; - const { nodes, links } = generateGraph(events, currentLevels); + const { nodes, links } = generateGraph(events, Number(currentLevels)); if (!nodes.length) return; // Stop any existing simulation if (simulation) simulation.stop(); // Create new simulation - simulation = createSimulation(nodes, links, nodeRadius, linkDistance); + simulation = createSimulation(nodes, links, Number(nodeRadius), Number(linkDistance)); const dragHandler = setupDragHandlers(simulation); // Update links @@ -326,10 +327,3 @@
    - - \ No newline at end of file From 4f74eedbacb022e2e6791715971282d14bd7dcdf Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 9 Apr 2025 18:10:36 +0200 Subject: [PATCH 43/52] Fixed a warning about ambiguous tag closure. Added the summary field to the index card on visualization, as well as a close button on the cards, and added a maximum width for the cards. --- .../navigator/EventNetwork/NodeTooltip.svelte | 37 +++++++++++++++++-- src/lib/navigator/EventNetwork/index.svelte | 6 +++ src/routes/visualize/+page.svelte | 4 +- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/lib/navigator/EventNetwork/NodeTooltip.svelte b/src/lib/navigator/EventNetwork/NodeTooltip.svelte index 0c40287..d785c53 100644 --- a/src/lib/navigator/EventNetwork/NodeTooltip.svelte +++ b/src/lib/navigator/EventNetwork/NodeTooltip.svelte @@ -1,6 +1,6 @@
    {/if} diff --git a/src/routes/visualize/+page.svelte b/src/routes/visualize/+page.svelte index 35b837d..69341e2 100644 --- a/src/routes/visualize/+page.svelte +++ b/src/routes/visualize/+page.svelte @@ -155,6 +155,6 @@
    {:else} -
    +
    {/if} -
    \ No newline at end of file +
    From 307312d5e88e48c1b39a7e8fc15f71a0351bf921 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 9 Apr 2025 18:24:12 +0200 Subject: [PATCH 44/52] Moved the settings button, so that it doesn't overlap the profile drop-down on the right-top corner. fixes #174 fixes #187 --- src/routes/visualize/+page.svelte | 65 ++++++++++++++++--------------- 1 file changed, 33 insertions(+), 32 deletions(-) diff --git a/src/routes/visualize/+page.svelte b/src/routes/visualize/+page.svelte index 69341e2..b8d6f3c 100644 --- a/src/routes/visualize/+page.svelte +++ b/src/routes/visualize/+page.svelte @@ -79,44 +79,45 @@
    -

    Publication Network

    - - - - {#if !loading && !error} - +
    +

    Publication Network

    + + + {#if !loading && !error} + + {/if} +
    + {#if !loading && !error && showSettings} - {#if showSettings} -
    -
    -

    - Visualization Settings -

    +
    +
    +

    + Visualization Settings +

    -
    - - Showing {events.length} events from {$networkFetchLimit} headers - - - -
    +
    + + Showing {events.length} events from {$networkFetchLimit} headers + + +
    - {/if} +
    {/if} + {#if loading}
    From 52a353ebe18ac3168de58f6a7b4f36465aa13fee Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 9 Apr 2025 20:31:45 +0200 Subject: [PATCH 45/52] Changed logic to the sequence: package version git tag version development version -> hidden closes #162 --- src/routes/about/+page.svelte | 6 ++++-- vite.config.ts | 10 ++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/routes/about/+page.svelte b/src/routes/about/+page.svelte index f9c81cf..b6c0f6c 100644 --- a/src/routes/about/+page.svelte +++ b/src/routes/about/+page.svelte @@ -3,13 +3,16 @@ // Get the git tag version from environment variables const appVersion = import.meta.env.APP_VERSION || 'development'; + const isVersionKnown = appVersion !== 'development';
    About - Version: {appVersion} + {#if isVersionKnown} + Version: {appVersion} + {/if}

    @@ -102,4 +105,3 @@

    - diff --git a/vite.config.ts b/vite.config.ts index 5d73bc3..4dc4254 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,18 +5,16 @@ import { execSync } from "child_process"; // Function to get the latest git tag function getAppVersionString() { // if running in ci context, we can assume the package has been properly versioned - if (process.env.ALEXANDIRA_IS_CI_BUILD) { - return process.env.npm_package_version; + if (process.env.ALEXANDIRA_IS_CI_BUILD && process.env.npm_package_version && process.env.npm_package_version.trim() !== '') { + return process.env.npm_package_version; } - + try { // Get the latest git tag, assuming git is installed and tagged branch is available const tag = execSync('git describe --tags --abbrev=0').toString().trim(); return tag; } catch (error) { - console.error("Failed to get git tag:", error); - // Fallback to package version - return process.env.npm_package_version; + return 'development'; } } From 94178e5dba63e317efde2e87561c480515a44851 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 10 Apr 2025 10:33:13 +0200 Subject: [PATCH 46/52] first step for issue #173 --- src/lib/consts.ts | 2 +- src/lib/navigator/EventNetwork/Legend.svelte | 2 +- .../navigator/EventNetwork/NodeTooltip.svelte | 4 ++-- src/lib/stores.ts | 2 +- src/routes/about/+page.svelte | 16 +++++++++++++--- src/routes/visualize/+page.svelte | 4 ++-- 6 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/lib/consts.ts b/src/lib/consts.ts index 0df0ebf..17b8b87 100644 --- a/src/lib/consts.ts +++ b/src/lib/consts.ts @@ -1,6 +1,6 @@ export const wikiKind = 30818; export const indexKind = 30040; -export const zettelKinds = [ 30041 ]; +export const zettelKinds = [ 30041, 30818 ]; export const standardRelays = [ 'wss://thecitadel.nostr1.com', 'wss://relay.noswhere.com' ]; export const bootstrapRelays = [ 'wss://purplepag.es', 'wss://relay.noswhere.com' ]; diff --git a/src/lib/navigator/EventNetwork/Legend.svelte b/src/lib/navigator/EventNetwork/Legend.svelte index 7d27ffa..fe88aac 100644 --- a/src/lib/navigator/EventNetwork/Legend.svelte +++ b/src/lib/navigator/EventNetwork/Legend.svelte @@ -18,7 +18,7 @@ C
    - Content events (kind 30041) - Publication sections + Content events (kinds 30041, 30818) - Publication sections
  • diff --git a/src/lib/navigator/EventNetwork/NodeTooltip.svelte b/src/lib/navigator/EventNetwork/NodeTooltip.svelte index 9ebe042..545660d 100644 --- a/src/lib/navigator/EventNetwork/NodeTooltip.svelte +++ b/src/lib/navigator/EventNetwork/NodeTooltip.svelte @@ -15,7 +15,7 @@
    {node.title}
    - {node.type} ({node.isContainer ? "30040" : "30041"}) + {node.type} ({node.kind})
    {/if}
    -
    \ No newline at end of file +
  • diff --git a/src/lib/stores.ts b/src/lib/stores.ts index 04aa785..71f9fdc 100644 --- a/src/lib/stores.ts +++ b/src/lib/stores.ts @@ -3,6 +3,6 @@ import { FeedType } from "./consts"; export let idList = writable([]); -export let alexandriaKinds = readable([30040, 30041]); +export let alexandriaKinds = readable([30040, 30041, 30818]); export let feedType = writable(FeedType.StandardRelays); diff --git a/src/routes/about/+page.svelte b/src/routes/about/+page.svelte index 60d3738..9a2f461 100644 --- a/src/routes/about/+page.svelte +++ b/src/routes/about/+page.svelte @@ -7,7 +7,7 @@ About

    - Alexandria is a reader and writer for curated publications (in Asciidoc), and will eventually also support long-form articles (Markdown) and wiki pages (Asciidoc). It is produced by the GitCitadel project team. + Alexandria is a reader and writer for curated publications (in Asciidoc), wiki pages (Asciidoc), and will eventually also support long-form articles (Markdown). It is produced by the GitCitadel project team.

    @@ -34,11 +34,11 @@

    - If you click on a card, which represents a 30040 index event, the associated reading view opens to the publication. The app then pulls all of the content events (30041s), in the order in which they are indexed, and displays them as a single document. + If you click on a card, which represents a 30040 index event, the associated reading view opens to the publication. The app then pulls all of the content events (30041s and 30818s for wiki pages), in the order in which they are indexed, and displays them as a single document.

    - Each 30041 section is also a level in the table of contents, which can be accessed from the floating icon top-left in the reading view. This allows for navigation within the publication. (This functionality has been temporarily disabled.) + Each content section (30041 or 30818) is also a level in the table of contents, which can be accessed from the floating icon top-left in the reading view. This allows for navigation within the publication. (This functionality has been temporarily disabled.)

    @@ -93,6 +93,16 @@
    Documentation
    + + For wiki pages + +

    + Alexandria now supports wiki pages (kind 30818), allowing for collaborative knowledge bases and documentation. Wiki pages use the same Asciidoc format as other publications but are specifically designed for interconnected, evolving content. +

    + +

    + Wiki pages can be linked to from other publications and can contain links to other wiki pages, creating a web of knowledge that can be navigated and explored. +

    diff --git a/src/routes/visualize/+page.svelte b/src/routes/visualize/+page.svelte index 35b837d..c74ac8d 100644 --- a/src/routes/visualize/+page.svelte +++ b/src/routes/visualize/+page.svelte @@ -49,7 +49,7 @@ // Fetch the referenced content events const contentEvents = await $ndkInstance.fetchEvents( { - kinds: [30041], + kinds: [30041, 30818], ids: Array.from(contentEventIds), }, { @@ -157,4 +157,4 @@
    {/if} -
    \ No newline at end of file +
    From f240083dac71fcb60d2958a4f7e88d8b8f31879a Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 12 Apr 2025 18:50:26 +0200 Subject: [PATCH 47/52] implemen OpenGraph metadata --- src/lib/components/Publication.svelte | 48 ++++++++++++++++++++++++-- src/routes/publication/+page.svelte | 16 +++++++-- src/routes/publication/+page.ts | 5 +-- static/screenshots/old_books.jpg | Bin 0 -> 207556 bytes 4 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 static/screenshots/old_books.jpg diff --git a/src/lib/components/Publication.svelte b/src/lib/components/Publication.svelte index 0794cc9..9d79264 100644 --- a/src/lib/components/Publication.svelte +++ b/src/lib/components/Publication.svelte @@ -14,8 +14,28 @@ import Preview from "./Preview.svelte"; import { pharosInstance } from "$lib/parser"; import { page } from "$app/state"; - - let { rootId, publicationType } = $props<{ rootId: string, publicationType: string }>(); + import { ndkInstance } from "$lib/ndk"; + import type { NDKEvent } from "@nostr-dev-kit/ndk"; + + let { rootId, publicationType, indexEvent } = $props<{ + rootId: string, + publicationType: string, + indexEvent: NDKEvent + }>(); + + // Get publication metadata for OpenGraph tags + let title = $derived($pharosInstance.getIndexTitle(rootId) || 'Alexandria Publication'); + let currentUrl = page.url.href; + + // Get image and summary from the event tags if available + // If image unavailable, use the Alexandria default pic. + let image = $derived(indexEvent?.getMatchingTags('image')[0]?.[1] || '/screenshots/old_books.jpg'); + let summary = $derived(indexEvent?.getMatchingTags('summary')[0]?.[1] || ``); + + // Debug: Log the event and its tags + console.log('indexEvent:', indexEvent); + console.log('image tag:', indexEvent?.getMatchingTags('image')); + console.log('summary tag:', indexEvent?.getMatchingTags('summary')); if (rootId !== $pharosInstance.getRootIndexId()) { console.error("Root ID does not match parser root index ID"); @@ -94,6 +114,30 @@ }); + + + {title} + + + + + + + + + {#if image} + + {/if} + + + + + + {#if image} + + {/if} + + {#if showTocButton && !showToc}