Browse Source

Merges pull request #15

Implement PublicationTree Data Structure
master
Michael J 11 months ago
parent
commit
3b5c9be7aa
No known key found for this signature in database
GPG Key ID: 962BEC8725790894
  1. 16
      deno.lock
  2. 0
      src/lib/components/Modal.svelte
  3. 5
      src/lib/components/Preview.svelte
  4. 16
      src/lib/data_structures/lazy.ts
  5. 430
      src/lib/data_structures/publication_tree.ts
  6. 25
      src/lib/parser.ts
  7. 45
      src/lib/snippets/PublicationSnippets.svelte
  8. 35
      src/lib/utils.ts
  9. 12
      src/routes/publication/+page.ts
  10. 3
      tsconfig.json
  11. 6
      vite.config.ts

16
deno.lock

@ -2862,6 +2862,22 @@ @@ -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",

0
src/lib/Modal.svelte → src/lib/components/Modal.svelte

5
src/lib/components/Preview.svelte

@ -1,8 +1,9 @@ @@ -1,8 +1,9 @@
<script lang='ts'>
import { pharosInstance, SiblingSearchDirection } from '$lib/parser';
import { Button, ButtonGroup, CloseButton, Input, P, Textarea, Tooltip } from 'flowbite-svelte';
import { Button, ButtonGroup, CloseButton, Input, Textarea, Tooltip } from 'flowbite-svelte';
import { CaretDownSolid, CaretUpSolid, EditOutline } from 'flowbite-svelte-icons';
import Self from './Preview.svelte';
import { contentParagraph, sectionHeading } from '$lib/snippets/PublicationSnippets.svelte';
// TODO: Fix move between parents.
@ -206,7 +207,7 @@ @@ -206,7 +207,7 @@
</Textarea>
</form>
{:else}
{@render contentParagraph(currentContent, publicationType)}
{@render contentParagraph(currentContent, publicationType, isSectionStart)}
{/if}
{/key}
{:else}

16
src/lib/data_structures/lazy.ts

@ -0,0 +1,16 @@ @@ -0,0 +1,16 @@
export class Lazy<T> {
#value?: T;
#resolver: () => Promise<T>;
constructor(resolver: () => Promise<T>) {
this.#resolver = resolver;
}
async value(): Promise<T> {
if (!this.#value) {
this.#value = await this.#resolver();
}
return this.#value;
}
}

430
src/lib/data_structures/publication_tree.ts

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

25
src/lib/parser.ts

@ -12,7 +12,7 @@ import type { @@ -12,7 +12,7 @@ import type {
} from 'asciidoctor';
import he from 'he';
import { writable, type Writable } from 'svelte/store';
import { zettelKinds } from './consts';
import { zettelKinds } from './consts.ts';
interface IndexMetadata {
authors?: string[];
@ -66,6 +66,8 @@ export default class Pharos { @@ -66,6 +66,8 @@ export default class Pharos {
private asciidoctor: Asciidoctor;
private pharosExtensions: Extensions.Registry;
private ndk: NDK;
private contextCounters: Map<string, number> = new Map<string, number>();
@ -130,25 +132,26 @@ export default class Pharos { @@ -130,25 +132,26 @@ export default class Pharos {
constructor(ndk: NDK) {
this.asciidoctor = asciidoctor();
this.pharosExtensions = this.asciidoctor.Extensions.create();
this.ndk = ndk;
const pharos = this;
this.asciidoctor.Extensions.register(function () {
const registry = this;
registry.treeProcessor(function () {
const dsl = this;
dsl.process(function (document) {
const treeProcessor = this;
pharos.treeProcessor(treeProcessor, document);
});
})
this.pharosExtensions.treeProcessor(function () {
const dsl = this;
dsl.process(function (document) {
const treeProcessor = this;
pharos.treeProcessor(treeProcessor, document);
});
});
}
parse(content: string, options?: ProcessorOptions | undefined): void {
try {
this.html = this.asciidoctor.convert(content, options) as string | Document | undefined;
this.html = this.asciidoctor.convert(content, {
'extension_registry': this.pharosExtensions,
...options,
}) as string | Document | undefined;
} catch (error) {
console.error(error);
throw new Error('Failed to parse AsciiDoc document.');

45
src/lib/snippets/PublicationSnippets.svelte

@ -0,0 +1,45 @@ @@ -0,0 +1,45 @@
<script module lang='ts'>
import { P } from 'flowbite-svelte';
export { contentParagraph, sectionHeading };
</script>
{#snippet sectionHeading(title: string, depth: number)}
{#if depth === 0}
<h1 class='h-leather'>
{title}
</h1>
{:else if depth === 1}
<h2 class='h-leather'>
{title}
</h2>
{:else if depth === 2}
<h3 class='h-leather'>
{title}
</h3>
{:else if depth === 3}
<h4 class='h-leather'>
{title}
</h4>
{:else if depth === 4}
<h5 class='h-leather'>
{title}
</h5>
{:else}
<h6 class='h-leather'>
{title}
</h6>
{/if}
{/snippet}
{#snippet contentParagraph(content: string, publicationType: string, isSectionStart: boolean)}
{#if publicationType === 'novel'}
<P class='whitespace-normal' firstupper={isSectionStart}>
{@html content}
</P>
{:else}
<P class='whitespace-normal' firstupper={false}>
{@html content}
</P>
{/if}
{/snippet}

35
src/lib/utils.ts

@ -109,3 +109,38 @@ export function filterValidIndexEvents(events: Set<NDKEvent>): Set<NDKEvent> { @@ -109,3 +109,38 @@ export function filterValidIndexEvents(events: Set<NDKEvent>): Set<NDKEvent> {
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<T>(
array: T[],
predicate: (element: T, index: number, array: T[]) => Promise<boolean>
): Promise<number> {
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<T> {
findIndexAsync(
predicate: (element: T, index: number, array: T[]) => Promise<boolean>
): Promise<number>;
}
}
Array.prototype.findIndexAsync = function<T>(
this: T[],
predicate: (element: T, index: number, array: T[]) => Promise<boolean>
): Promise<number> {
return findIndexAsync(this, predicate);
};

12
src/routes/publication/+page.ts

@ -1,11 +1,11 @@ @@ -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<any> }) => {
const id = url.searchParams.get('id');
const dTag = url.searchParams.get('d');
@ -41,6 +41,8 @@ export const load: PageLoad = async ({ url, parent }) => { @@ -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,

3
tsconfig.json

@ -8,7 +8,8 @@ @@ -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
//

6
vite.config.ts

@ -3,6 +3,12 @@ import { defineConfig } from "vite"; @@ -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']
}

Loading…
Cancel
Save