30 changed files with 1130 additions and 213 deletions
@ -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; |
||||
} |
||||
} |
||||
@ -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
|
||||
} |
||||
@ -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} |
||||
@ -1,56 +1,118 @@
@@ -1,56 +1,118 @@
|
||||
<script lang='ts'> |
||||
import { Heading } from "flowbite-svelte"; |
||||
import { Heading, Img, P, A } from "flowbite-svelte"; |
||||
|
||||
// Get the git tag version from environment variables |
||||
const appVersion = import.meta.env.APP_VERSION || 'development'; |
||||
const isVersionKnown = appVersion !== 'development'; |
||||
</script> |
||||
|
||||
<div class='w-full flex justify-center'> |
||||
<main class='main-leather flex flex-col space-y-4 max-w-2xl w-full mt-4 mb-4'> |
||||
<Heading tag='h1' class='h-leather mb-2'>About</Heading> |
||||
<p>Alexandria is a reader and writer for <a href="https://github.com/nostr-protocol/nips/pull/1600" class='underline' target="_blank">curated publications</a> (in Asciidoc), and will eventually also support long-form articles (Markdown) and wiki pages (Asciidoc). It is produced by the <a href="https://wikistr.com/gitcitadel-project" class='underline' target="_blank">GitCitadel project team</a>.</p> |
||||
<main class='main-leather flex flex-col space-y-6 max-w-2xl w-full my-6 px-4'> |
||||
<div class="flex justify-between items-center"> |
||||
<Heading tag='h1' class='h-leather mb-2'>About the Library of Alexandria</Heading> |
||||
{#if isVersionKnown} |
||||
<span class="text-sm bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded text-nowrap">Version: {appVersion}</span> |
||||
{/if} |
||||
</div> |
||||
<Img src="/screenshots/old_books.jpg" alt="Alexandria icon" /> |
||||
|
||||
<P class="mb-3"> |
||||
Alexandria is a reader and writer for <A href="/publication?d=gitcitadel-project-documentation-curated-publications-specification-7-by-stella-v-1">curated publications</A> (in Asciidoc), wiki pages (Asciidoc), and will eventually also support long-form articles (Markdown). It is produced by the <A href="/publication?d=gitcitadel-project-documentation-gitcitadel-project-1-by-stella-v-1">GitCitadel project team</A>. |
||||
</P> |
||||
|
||||
<P class="mb-3"> |
||||
Please submit support issues on the <A href="https://gitcitadel.com/r/naddr1qvzqqqrhnypzquqjyy5zww7uq7hehemjt7juf0q0c9rgv6lv8r2yxcxuf0rvcx9eqy88wumn8ghj7mn0wvhxcmmv9uq3wamnwvaz7tmjv4kxz7fwdehhxarj9e3xzmny9uqsuamnwvaz7tmwdaejumr0dshsqzjpd3jhsctwv3exjcgtpg0n0/issues" target="_blank">Alexandria repo page</A> and follow us on <A href="https://github.com/ShadowySupercode/gitcitadel" target="_blank">GitHub</A> and <A href="https://geyser.fund/project/gitcitadel" target="_blank">Geyserfund</A>. |
||||
</P> |
||||
|
||||
<P> |
||||
We are easiest to contact over our Nostr address <A href="https://njump.me/nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg" title="npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz" target="_blank">npub1s3h…75wz</A>. |
||||
</P> |
||||
|
||||
<Heading tag='h2' class='h-leather mt-4 mb-2'>Overview</Heading> |
||||
|
||||
<P class="mb-4"> |
||||
Alexandria opens up to the <A href="./">landing page</A>, where the user can: login (top-right), select whether to only view the publications hosted on the <A href="https://thecitadel.nostr1.com/" target="_blank">thecitadel document relay</A> or add in their own relays, and scroll/search the publications. |
||||
</P> |
||||
|
||||
<div class="flex flex-col items-center space-y-4 my-4"> |
||||
<Img src="/screenshots/LandingPage.png" alt="Landing page" class='image-border rounded-lg' width="400" /> |
||||
<Img src="/screenshots/YourRelays.png" alt="Relay selection" class='image-border rounded-lg' width="400" /> |
||||
</div> |
||||
|
||||
<P class="mb-3"> |
||||
There is also the ability to view the publications as a diagram, if you click on "Visualize", and to publish an e-book or other document (coming soon). |
||||
</P> |
||||
|
||||
<p>Please submit support issues on the <a href="https://gitcitadel.com/r/naddr1qvzqqqrhnypzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqy88wumn8ghj7mn0wvhxcmmv9uqq5emfw33kjarpv3jkcs83wav" class='underline' target="_blank">project repo page</a> and follow us on <a href="https://github.com/ShadowySupercode/gitcitadel" class='underline' target="_blank">GitHub</a> and <a href="https://geyser.fund/project/gitcitadel" class='underline' target="_blank">Geyserfund</a>.</p> |
||||
<P class="mb-3"> |
||||
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. |
||||
</P> |
||||
|
||||
<p>We are easiest to contact over our Nostr address <a href="https://njump.me/nprofile1qqsggm4l0xs23qfjwnkfwf6fqcs66s3lz637gaxhl4nwd2vtle8rnfqprfmhxue69uhhg6r9vehhyetnwshxummnw3erztnrdaks5zhueg" class='underline' title="npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz" target="_blank">npub1s3h…75wz</a>.</p> |
||||
<P class="mb-3"> |
||||
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.) |
||||
</P> |
||||
|
||||
<Heading tag='h2' class='h-leather mb-2'>Overview</Heading> |
||||
<div class="flex flex-col items-center space-y-4 my-4"> |
||||
<Img src="/screenshots/ToC_icon.png" alt="ToC icon" class='image-border rounded-lg' width="400" /> |
||||
<Img src="/screenshots/TableOfContents.png" alt="Table of contents example" class='image-border rounded-lg' width="400" /> |
||||
</div> |
||||
|
||||
<p>Alexandria opens up to the <a href="https://next-alexandria.gitcitadel.eu/" class='underline'>landing page</a>, where the user can: login (top-right), select whether to only view the publications hosted on the <a href="https://thecitadel.nostr1.com/" class='underline' target="_blank">thecitadel document relay</a> or add in their own relays, and scroll/search the publications.</p> |
||||
<Heading tag='h2' class='h-leather mt-4 mb-2'>Typical use cases</Heading> |
||||
|
||||
<p><img src="/screenshots/LandingPage.png" alt="Landing page" class='image-border'></p> |
||||
<p><img src="/screenshots/YourRelays.png" alt="Relay selection" class='image-border'></p> |
||||
<Heading tag='h3' class='h-leather mb-3'>For e-books</Heading> |
||||
|
||||
<p>There is also the ability to view the publications as a diagram, if you click on "Visualize", and to publish an e-book or other document (coming soon).</p> |
||||
<P class="mb-3"> |
||||
The most common use for Alexandria is for e-books: both those users have written themselves and those uploaded to Nostr from other sources. The first minor version of the app, Gutenberg, is focused on displaying and producing these publications. |
||||
</P> |
||||
|
||||
<p>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.</p> |
||||
<P class="mb-3"> |
||||
An example of a book is <A href="/publication?d=jane-eyre-an-autobiography-by-charlotte-bront%C3%AB-v-3rd-edition">Jane Eyre</A> |
||||
</P> |
||||
|
||||
<p>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.)</p> |
||||
<div class="flex justify-center my-4"> |
||||
<Img src="/screenshots/JaneEyre.png" alt="Jane Eyre, by Charlotte Brontë" class='image-border rounded-lg' width="400" /> |
||||
</div> |
||||
|
||||
<p><img src="/screenshots/ToC_icon.png" alt="ToC icon" class='image-border'></p> |
||||
<p><img src="/screenshots/TableOfContents.png" alt="Table of contents example" class='image-border'></p> |
||||
<Heading tag='h3' class='h-leather mb-3'>For scientific papers</Heading> |
||||
|
||||
<Heading tag='h2' class='h-leather mb-2'>Typical use cases</Heading> |
||||
<P class="mb-3"> |
||||
Alexandria will also display research papers with Asciimath and LaTeX embedding, and the normal advanced formatting options available for Asciidoc. In addition, we will be implementing special citation events, which will serve as an alternative or addition to the normal footnotes. |
||||
</P> |
||||
|
||||
<Heading tag='h3' class='h-leather mb-2'>For e-books</Heading> |
||||
<p>The most common use for Alexandria is for e-books: both those users have written themselves and those uploaded to Nostr from other sources. The first minor version of the app, Gutenberg, is focused on displaying and producing these publications.</p> |
||||
<P class="mb-3"> |
||||
Correctly displaying such papers, integrating citations, and allowing them to be reviewed (with kind 1111 comments), and annotated (with highlights) by users, is the focus of the second minor version, Euler. |
||||
</P> |
||||
|
||||
<p>An example of a book is <a href="https://next-alexandria.gitcitadel.eu/publication?d=jane-eyre-an-autobiography-by-charlotte-bront%C3%83-v-third-edition" class='underline'>Jane Eyre</a></p> |
||||
<P class="mb-3"> |
||||
Euler will also pioneer the HTTP-based (rather than websocket-based) e-paper compatible version of the web app. |
||||
</P> |
||||
|
||||
<p><img src="/screenshots/JaneEyre.png" alt="Jane Eyre, by Charlotte Brontë" class='image-border'></p> |
||||
<P class="mb-3"> |
||||
An example of a research paper is <A href="/publication?d=less-partnering-less-children-or-both-by-j.i.s.-hellstrand-v-1">Less Partnering, Less Children, or Both?</A> |
||||
</P> |
||||
|
||||
<Heading tag='h3' class='h-leather mb-2'>For scientific papers</Heading> |
||||
<p>Alexandria will also display research papers with Asciimath and LaTeX embedding, and the normal advanced formatting options available for Asciidoc. In addition, we will be implementing special citation events, which will serve as an alternative or addition to the normal footnotes.</p> |
||||
<div class="flex justify-center my-4"> |
||||
<Img src="/screenshots/ResearchPaper.png" alt="Research paper" class='image-border rounded-lg' width="400" /> |
||||
</div> |
||||
|
||||
<p>Correctly displaying such papers, integrating citations, and allowing them to be reviewed (with kind 1111 comments), and annotated (with highlights) by users, is the focus of the second minor version, Euler.</p> |
||||
<Heading tag='h3' class='h-leather mb-3'>For documentation</Heading> |
||||
|
||||
<p>Euler will also pioneer the HTTP-based (rather than websocket-based) e-paper compatible version of the web app.</p> |
||||
<P class="mb-3"> |
||||
Our own team uses Alexandria to document the app, to display our <A href="/publication?d=the-gitcitadel-blog-by-stella-v-1">blog entries</A>, as well as to store copies of our most interesting <A href="/publication?d=gitcitadel-project-documentation-by-stella-v-1">technical specifications</A>. |
||||
</P> |
||||
|
||||
<p>An example of a research paper is <a href="https://next-alexandria.gitcitadel.eu/publication?d=less-partnering-less-children-or-both-by-j.i.s.-hellstrand-v-1" class='underline'>Less Partnering, Less Children, or Both?</a></p> |
||||
<div class="flex justify-center my-4"> |
||||
<Img src="/screenshots/Documentation.png" alt="Documentation" class='image-border rounded-lg' width="400" /> |
||||
</div> |
||||
|
||||
<p><img src="/screenshots/ResearchPaper.png" alt="Research paper" class='image-border'></p> |
||||
<Heading tag='h3' class='h-leather mb-3'>For wiki pages</Heading> |
||||
|
||||
<Heading tag='h3' class='h-leather mb-2'>For documentation</Heading> |
||||
<p>Our own team uses Alexandria to document the app, to display our blog entries, as well as to store copies of our most interesting technical specifications.</p> |
||||
<P class="mb-3"> |
||||
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. |
||||
</P> |
||||
|
||||
<p><img src="/screenshots/Documentation.png" alt="Documentation" class='image-border'></p> |
||||
<P class="mb-3"> |
||||
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. |
||||
</P> |
||||
|
||||
</main> |
||||
</main> |
||||
</div> |
||||
@ -1,48 +1,107 @@
@@ -1,48 +1,107 @@
|
||||
import { error } from '@sveltejs/kit'; |
||||
import { NDKRelay, NDKRelaySet, 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 type { Load } from '@sveltejs/kit'; |
||||
import type { NDKEvent } from '@nostr-dev-kit/ndk'; |
||||
import { nip19 } from 'nostr-tools'; |
||||
import { getActiveRelays } from '$lib/ndk.ts'; |
||||
|
||||
export const load: PageLoad = async ({ url, parent }) => { |
||||
/** |
||||
* Decodes an naddr identifier and returns a filter object |
||||
*/ |
||||
function decodeNaddr(id: string) { |
||||
try { |
||||
if (!id.startsWith('naddr')) 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 null; |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Fetches an event by ID or filter |
||||
*/ |
||||
async function fetchEventById(ndk: any, id: string): Promise<NDKEvent> { |
||||
const filter = decodeNaddr(id); |
||||
|
||||
// Handle the case where filter is null (decoding error)
|
||||
if (filter === null) { |
||||
// If we can't decode the naddr, try using the raw ID
|
||||
try { |
||||
const event = await 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.\n${err}`); |
||||
} |
||||
} |
||||
|
||||
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.\n${err}`); |
||||
} |
||||
} |
||||
|
||||
/** |
||||
* Fetches an event by d tag |
||||
*/ |
||||
async function fetchEventByDTag(ndk: any, dTag: string): Promise<NDKEvent> { |
||||
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.\n${err}`); |
||||
} |
||||
} |
||||
|
||||
export const load: Load = async ({ url, parent }: { url: URL; parent: () => Promise<any> }) => { |
||||
const id = url.searchParams.get('id'); |
||||
const dTag = url.searchParams.get('d'); |
||||
|
||||
const { ndk, parser } = await parent(); |
||||
|
||||
let eventPromise: Promise<NDKEvent | null>; |
||||
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<NDKEvent | null>(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); |
||||
|
||||
return { |
||||
waitable: fetchPromise, |
||||
publicationType, |
||||
indexEvent, |
||||
}; |
||||
}; |
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 203 KiB |
@ -1,9 +1,36 @@
@@ -1,9 +1,36 @@
|
||||
import { sveltekit } from "@sveltejs/kit/vite"; |
||||
import { defineConfig } from "vite"; |
||||
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 && 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) { |
||||
return 'development'; |
||||
} |
||||
} |
||||
|
||||
export default defineConfig({ |
||||
plugins: [sveltekit()], |
||||
resolve: { |
||||
alias: { |
||||
$lib: './src/lib', |
||||
$components: './src/components' |
||||
} |
||||
}, |
||||
test: { |
||||
include: ['./tests/unit/**/*.unit-test.js'] |
||||
}, |
||||
define: { |
||||
// Expose the app version as a global variable
|
||||
'import.meta.env.APP_VERSION': JSON.stringify(getAppVersionString()) |
||||
} |
||||
}); |
||||
|
||||
Loading…
Reference in new issue