Browse Source

updated event form to handle complex, empty, and skeleton 30040 creation

added informative text to compose notes page
master
silberengel 8 months ago
parent
commit
4ac2ab8eb0
  1. 161
      src/lib/components/EventInput.svelte
  2. 29
      src/lib/components/ZettelEditor.svelte
  3. 4
      src/lib/components/publications/PublicationFeed.svelte
  4. 489
      src/lib/utils/asciidoc_metadata.ts
  5. 342
      src/lib/utils/event_input_utils.ts
  6. 4
      src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts
  7. 2
      src/lib/utils/network_detection.ts
  8. 446
      tests/unit/eventInput30040.test.ts
  9. 183
      tests/unit/metadataExtraction.test.ts

161
src/lib/components/EventInput.svelte

@ -12,6 +12,11 @@
analyze30040Event, analyze30040Event,
get30040FixGuidance, get30040FixGuidance,
} from "$lib/utils/event_input_utils"; } from "$lib/utils/event_input_utils";
import {
extractDocumentMetadata,
metadataToTags,
removeMetadataFromContent
} from "$lib/utils/asciidoc_metadata";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { ndkInstance } from "$lib/ndk"; import { ndkInstance } from "$lib/ndk";
import { userPubkey } from "$lib/stores/authStore.Svelte"; import { userPubkey } from "$lib/stores/authStore.Svelte";
@ -24,7 +29,7 @@
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
import { WebSocketPool } from "$lib/data_structures/websocket_pool"; import { WebSocketPool } from "$lib/data_structures/websocket_pool";
let kind = $state<number>(30023); let kind = $state<number>(30040);
let tags = $state<[string, string][]>([]); let tags = $state<[string, string][]>([]);
let content = $state(""); let content = $state("");
let createdAt = $state<number>(Math.floor(Date.now() / 1000)); let createdAt = $state<number>(Math.floor(Date.now() / 1000));
@ -39,14 +44,29 @@
let dTagManuallyEdited = $state(false); let dTagManuallyEdited = $state(false);
let dTagError = $state(""); let dTagError = $state("");
let lastPublishedEventId = $state<string | null>(null); let lastPublishedEventId = $state<string | null>(null);
let showWarning = $state(false);
let warningMessage = $state("");
let pendingPublish = $state(false);
let extractedMetadata = $state<[string, string][]>([]);
/** /**
* Extracts the first Markdown/AsciiDoc header as the title. * Extracts the first Markdown/AsciiDoc header as the title.
*/ */
function extractTitleFromContent(content: string): string { function extractTitleFromContent(content: string): string {
// Match Markdown (# Title) or AsciiDoc (= Title) headers // Match Markdown (# Title) or AsciiDoc (= Title) headers
const match = content.match(/^(#|=)\s*(.+)$/m); // Look for document title (=) first, then fall back to section headers (==)
return match ? match[2].trim() : ""; const documentMatch = content.match(/^=\s*(.+)$/m);
if (documentMatch) {
return documentMatch[1].trim();
}
// If no document title, look for the first section header
const sectionMatch = content.match(/^==\s*(.+)$/m);
if (sectionMatch) {
return sectionMatch[1].trim();
}
return "";
} }
function handleContentInput(e: Event) { function handleContentInput(e: Event) {
@ -56,6 +76,22 @@
console.log("Content input - extracted title:", extracted); console.log("Content input - extracted title:", extracted);
title = extracted; title = extracted;
} }
// Extract metadata from AsciiDoc content for 30040 and 30041 events
if (kind === 30040 || kind === 30041) {
try {
const { metadata } = extractDocumentMetadata(content);
const metadataTags = metadataToTags(metadata);
extractedMetadata = metadataTags;
console.log("Extracted metadata:", metadata);
console.log("Metadata tags:", metadataTags);
} catch (error) {
console.error("Error extracting metadata:", error);
extractedMetadata = [];
}
} else {
extractedMetadata = [];
}
} }
function handleTitleInput(e: Event) { function handleTitleInput(e: Event) {
@ -92,12 +128,24 @@
tags = tags.filter((_, i) => i !== index); tags = tags.filter((_, i) => i !== index);
} }
function addExtractedTag(key: string, value: string): void {
// Check if tag already exists
const existingIndex = tags.findIndex(([k]) => k === key);
if (existingIndex >= 0) {
// Update existing tag
tags = tags.map((t, i) => (i === existingIndex ? [key, value] : t));
} else {
// Add new tag
tags = [...tags, [key, value]];
}
}
function isValidKind(kind: number | string): boolean { function isValidKind(kind: number | string): boolean {
const n = Number(kind); const n = Number(kind);
return Number.isInteger(n) && n >= 0 && n <= 65535; return Number.isInteger(n) && n >= 0 && n <= 65535;
} }
function validate(): { valid: boolean; reason?: string } { function validate(): { valid: boolean; reason?: string; warning?: string } {
const currentUserPubkey = get(userPubkey as any); const currentUserPubkey = get(userPubkey as any);
const userState = get(userStore); const userState = get(userStore);
@ -113,6 +161,7 @@
if (kind === 30040) { if (kind === 30040) {
const v = validate30040EventSet(content); const v = validate30040EventSet(content);
if (!v.valid) return v; if (!v.valid) return v;
if (v.warning) return { valid: true, warning: v.warning };
} }
if (kind === 30041 || kind === 30818) { if (kind === 30041 || kind === 30818) {
const v = validateAsciiDoc(content); const v = validateAsciiDoc(content);
@ -124,10 +173,26 @@
function handleSubmit(e: Event) { function handleSubmit(e: Event) {
e.preventDefault(); e.preventDefault();
dTagError = ""; dTagError = "";
error = null; // Clear any previous errors
if (requiresDTag(kind) && (!dTag || dTag.trim() === "")) { if (requiresDTag(kind) && (!dTag || dTag.trim() === "")) {
dTagError = "A d-tag is required."; dTagError = "A d-tag is required.";
return; return;
} }
const validation = validate();
if (!validation.valid) {
error = validation.reason || "Validation failed.";
return;
}
if (validation.warning) {
warningMessage = validation.warning;
showWarning = true;
pendingPublish = true;
return;
}
handlePublish(); handlePublish();
} }
@ -235,8 +300,14 @@
eventTags = [...eventTags, ["title", titleValue]]; eventTags = [...eventTags, ["title", titleValue]];
} }
// For AsciiDoc events, remove metadata from content
let finalContent = content;
if (kind === 30040 || kind === 30041) {
finalContent = removeMetadataFromContent(content);
}
// Prefix Nostr addresses before publishing // Prefix Nostr addresses before publishing
const prefixedContent = prefixNostrAddresses(content); const prefixedContent = prefixNostrAddresses(finalContent);
// Create event with proper serialization // Create event with proper serialization
const eventData = { const eventData = {
@ -330,6 +401,9 @@
} }
} }
}; };
// Send the event to the relay
ws.send(JSON.stringify(["EVENT", signedEvent]));
}); });
if (published) break; if (published) break;
} catch (e) { } catch (e) {
@ -391,6 +465,18 @@
goto(`/events?id=${encodeURIComponent(lastPublishedEventId)}`); goto(`/events?id=${encodeURIComponent(lastPublishedEventId)}`);
} }
} }
function confirmWarning() {
showWarning = false;
pendingPublish = false;
handlePublish();
}
function cancelWarning() {
showWarning = false;
pendingPublish = false;
warningMessage = "";
}
</script> </script>
<div <div
@ -412,9 +498,9 @@
Kind must be an integer between 0 and 65535 (NIP-01). Kind must be an integer between 0 and 65535 (NIP-01).
</div> </div>
{/if} {/if}
{#if kind === 30040} {#if Number(kind) === 30040}
<div <div
class="text-blue-600 text-sm mt-1 bg-blue-50 dark:bg-blue-900 p-2 rounded" class="text-blue-600 text-sm mt-1 bg-blue-50 dark:bg-blue-50 dark:text-blue-800 p-2 rounded whitespace-pre-wrap"
> >
<strong>30040 - Publication Index:</strong> <strong>30040 - Publication Index:</strong>
{get30040EventDescription()} {get30040EventDescription()}
@ -423,6 +509,36 @@
</div> </div>
<div> <div>
<label class="block font-medium mb-1" for="tags-container">Tags</label> <label class="block font-medium mb-1" for="tags-container">Tags</label>
<!-- Extracted Metadata Section -->
{#if extractedMetadata.length > 0}
<div class="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<h4 class="text-sm font-medium text-blue-800 dark:text-blue-200 mb-2">
Extracted Metadata (from AsciiDoc header)
</h4>
<div class="space-y-2">
{#each extractedMetadata as [key, value], i}
<div class="flex gap-2 items-center">
<span class="text-xs text-blue-600 dark:text-blue-400 min-w-[60px]">{key}:</span>
<input
type="text"
class="input input-bordered input-sm flex-1 text-sm"
value={value}
readonly
/>
<button
type="button"
class="btn btn-sm btn-outline btn-primary"
onclick={() => addExtractedTag(key, value)}
>
Add to Tags
</button>
</div>
{/each}
</div>
</div>
{/if}
<div id="tags-container" class="space-y-2"> <div id="tags-container" class="space-y-2">
{#each tags as [key, value], i} {#each tags as [key, value], i}
<div class="flex gap-2"> <div class="flex gap-2">
@ -525,6 +641,31 @@
</Button> </Button>
</div> </div>
{/if} {/if}
{/if} {/if}
</form> </form>
</div> </div>
{#if showWarning}
<div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white dark:bg-gray-800 p-6 rounded-lg max-w-md mx-4">
<h3 class="text-lg font-bold mb-4">Warning</h3>
<p class="mb-4">{warningMessage}</p>
<div class="flex justify-end space-x-2">
<button
type="button"
class="btn btn-secondary"
onclick={cancelWarning}
>
Cancel
</button>
<button
type="button"
class="btn btn-primary"
onclick={confirmWarning}
>
Continue
</button>
</div>
</div>
</div>
{/if}

29
src/lib/components/ZettelEditor.svelte

@ -51,6 +51,35 @@ Note content here...
</script> </script>
<div class="flex flex-col space-y-4"> <div class="flex flex-col space-y-4">
<!-- Informative text about ZettelEditor purpose -->
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-4">
<div class="flex items-start space-x-3">
<div class="flex-shrink-0">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
</div>
<div class="flex-1">
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200 mb-1">
Note-Taking Tool
</h3>
<p class="text-sm text-blue-700 dark:text-blue-300 mb-3">
This editor is for creating individual notes (30041 events) only. Each section becomes a separate note event.
To create structured publications with a 30040 index event that ties multiple notes together,
use the <a href="/events?kind=30040" class="font-medium underline hover:text-blue-600 dark:hover:text-blue-400">Events form</a>.
</p>
<div class="flex space-x-2">
<a
href="/events?kind=30040"
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-800 border border-blue-200 dark:border-blue-700 rounded-md hover:bg-blue-200 dark:hover:bg-blue-700 transition-colors"
>
Create Publication
</a>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<Button <Button
color="light" color="light"

4
src/lib/components/publications/PublicationFeed.svelte

@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { indexKind } from "$lib/consts"; import { indexKind } from "$lib/consts";
import { ndkInstance, activeInboxRelays, activeOutboxRelays } from "$lib/ndk"; import { ndkInstance, activeInboxRelays, activeOutboxRelays } from "$lib/ndk";
import { filterValidIndexEvents, debounce } from "$lib/utils"; import { filterValidIndexEvents, debounceAsync } from "$lib/utils";
import { Button, P, Skeleton, Spinner } from "flowbite-svelte"; import { Button, P, Skeleton, Spinner } from "flowbite-svelte";
import ArticleHeader from "./PublicationHeader.svelte"; import ArticleHeader from "./PublicationHeader.svelte";
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
@ -290,7 +290,7 @@
}; };
// Debounced search function // Debounced search function
const debouncedSearch = debounce(async (query: string) => { const debouncedSearch = debounceAsync(async (query: string) => {
console.debug("[PublicationFeed] Search query changed:", query); console.debug("[PublicationFeed] Search query changed:", query);
if (query.trim()) { if (query.trim()) {
const filtered = filterEventsBySearch(allIndexEvents); const filtered = filterEventsBySearch(allIndexEvents);

489
src/lib/utils/asciidoc_metadata.ts

@ -0,0 +1,489 @@
/**
* AsciiDoc Metadata Extraction Service
*
* Extracts metadata from AsciiDoc document headers and section headers,
* mapping them to Nostr event tags according to NKBIP-01 specification.
*
* Document header structure:
* = Document Title
* Author Name <email@example.com>
* version, date, revision info
* :attribute: value
*
* The first empty line marks the end of the header and start of the document body.
*/
export interface AsciiDocMetadata {
title?: string;
authors?: string[];
version?: string;
edition?: string;
publicationDate?: string;
publisher?: string;
summary?: string;
coverImage?: string;
isbn?: string;
tags?: string[];
source?: string;
publishedBy?: string;
type?: string;
autoUpdate?: 'yes' | 'ask' | 'no';
}
// Sections use the same metadata structure as documents
export type SectionMetadata = AsciiDocMetadata;
export interface ParsedAsciiDoc {
metadata: AsciiDocMetadata;
content: string;
sections: Array<{
metadata: SectionMetadata;
content: string;
title: string;
}>;
}
/**
* Shared function to parse metadata from attribute entries
* @param metadata The metadata object to populate
* @param key The attribute key
* @param value The attribute value
*/
function parseMetadataAttribute(metadata: AsciiDocMetadata, key: string, value: string): void {
switch (key.toLowerCase()) {
case 'author':
// Accumulate multiple authors
if (!metadata.authors) {
metadata.authors = [];
}
metadata.authors.push(value);
break;
case 'version':
// Only set version if not already set from revision line
if (!metadata.version) {
metadata.version = value;
}
break;
case 'edition':
metadata.edition = value;
break;
case 'published_on':
case 'date':
metadata.publicationDate = value;
break;
case 'published_by':
case 'publisher':
// Only set publishedBy if not already set from revision line
if (!metadata.publishedBy) {
metadata.publishedBy = value;
}
break;
case 'summary':
case 'description':
// Accumulate multiple summaries/descriptions
if (!metadata.summary) {
metadata.summary = value;
} else {
// If we already have a summary, append this one
metadata.summary = metadata.summary + ' ' + value;
}
break;
case 'image':
case 'cover':
metadata.coverImage = value;
break;
case 'isbn':
metadata.isbn = value;
break;
case 'source':
metadata.source = value;
break;
case 'type':
metadata.type = value;
break;
case 'auto-update':
if (value === 'yes' || value === 'ask' || value === 'no') {
metadata.autoUpdate = value;
}
break;
case 'tags':
case 'keywords':
// Accumulate multiple tag sets
if (!metadata.tags) {
metadata.tags = [];
}
const newTags = value.split(',').map(tag => tag.trim());
metadata.tags.push(...newTags);
break;
}
}
/**
* Shared function to extract metadata from header lines
* @param lines The lines to process
* @param startLine The starting line index
* @param metadata The metadata object to populate
* @returns The index of the line after the header metadata
*/
function extractHeaderMetadata(lines: string[], startLine: number, metadata: AsciiDocMetadata): number {
let currentLine = startLine;
// Process the next two lines for author and revision info
let processedLines = 0;
for (let i = 0; i < 2 && currentLine + i < lines.length; i++) {
const line = lines[currentLine + i];
// Skip empty lines
if (line.trim() === '') {
continue;
}
// Skip attribute lines (they'll be processed later)
if (line.startsWith(':')) {
continue;
}
// Check if this is an author line (contains <email>)
if (line.includes('<') && line.includes('>')) {
const authorMatch = line.match(/^(.+?)\s*<(.+?)>$/);
if (authorMatch) {
const authorName = authorMatch[1].trim();
metadata.authors = [authorName];
processedLines++;
continue;
}
}
// Check if this is a revision line (contains version, date, revision info)
const revisionMatch = line.match(/^(.+?),\s*(.+?),\s*(.+)$/);
if (revisionMatch) {
metadata.version = revisionMatch[1].trim();
metadata.publicationDate = revisionMatch[2].trim();
metadata.publishedBy = revisionMatch[3].trim();
processedLines++;
continue;
}
// If it's not author or revision, it might be a simple author name
if (!metadata.authors) {
metadata.authors = [line.trim()];
processedLines++;
}
}
// Move past the author/revision lines that were actually processed
currentLine += processedLines;
// Process attribute entries (lines starting with :)
while (currentLine < lines.length) {
const line = lines[currentLine];
// Empty line marks the end of the header
if (line.trim() === '') {
break;
}
// Check for attribute entries
const attrMatch = line.match(/^:([^:]+):\s*(.+)$/);
if (attrMatch) {
const key = attrMatch[1].trim();
const value = attrMatch[2].trim();
parseMetadataAttribute(metadata, key, value);
}
currentLine++;
}
return currentLine;
}
/**
* Extracts metadata from AsciiDoc document header
* @param content The full AsciiDoc content
* @returns Object containing metadata and cleaned content
*/
export function extractDocumentMetadata(inputContent: string): {
metadata: AsciiDocMetadata;
content: string;
} {
const lines = inputContent.split(/\r?\n/);
const metadata: AsciiDocMetadata = {};
let headerEndIndex = -1;
let currentLine = 0;
// Find the document title (first line starting with =)
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const titleMatch = line.match(/^=\s+(.+)$/);
if (titleMatch) {
metadata.title = titleMatch[1].trim();
currentLine = i + 1;
break;
}
}
// If no document title found, return empty metadata
if (!metadata.title) {
return { metadata: {}, content: inputContent };
}
// Check if this is an index card format (title followed immediately by "index card")
if (currentLine < lines.length && lines[currentLine].trim() === 'index card') {
// This is index card format - content starts immediately after title
headerEndIndex = currentLine;
} else {
// Extract header metadata using shared function
currentLine = extractHeaderMetadata(lines, currentLine, metadata);
// If we didn't find an empty line, the header ends at the first section
if (currentLine < lines.length && lines[currentLine].trim() === '') {
headerEndIndex = currentLine + 1; // Skip the empty line
} else {
for (let i = currentLine; i < lines.length; i++) {
if (lines[i].match(/^==\s+/)) {
headerEndIndex = i;
break;
}
}
// If no section found and no empty line, the header ends at the current line
if (headerEndIndex === -1) {
headerEndIndex = currentLine;
}
}
}
// If still no header end found, use the entire content
if (headerEndIndex === -1) {
headerEndIndex = lines.length;
}
// Extract the content (everything after the header)
let content = lines.slice(headerEndIndex).join('\n');
// Remove metadata attributes from sections in the content
content = content.replace(/^:([^:]+):\s*(.+)$/gm, '');
return { metadata, content };
}
/**
* Extracts metadata from a section header
* @param sectionContent The section content including its header
* @returns Object containing section metadata and cleaned content
*/
export function extractSectionMetadata(inputSectionContent: string): {
metadata: SectionMetadata;
content: string;
title: string;
} {
const lines = inputSectionContent.split(/\r?\n/);
const metadata: SectionMetadata = {};
let title = '';
let headerEndIndex = -1;
let currentLine = 0;
// Find the section title (first line starting with ==)
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const titleMatch = line.match(/^==\s+(.+)$/);
if (titleMatch) {
title = titleMatch[1].trim();
metadata.title = title;
currentLine = i + 1;
break;
}
}
// If no section title found, return empty metadata
if (!title) {
return { metadata: {}, content: inputSectionContent, title: '' };
}
// Extract header metadata using shared function
currentLine = extractHeaderMetadata(lines, currentLine, metadata);
// If we didn't find an empty line, the header ends at the next section
if (currentLine < lines.length && lines[currentLine].trim() === '') {
headerEndIndex = currentLine + 1; // Skip the empty line
} else {
for (let i = currentLine; i < lines.length; i++) {
if (lines[i].match(/^==\s+/)) {
headerEndIndex = i;
break;
}
}
}
// If still no header end found, use the entire content
if (headerEndIndex === -1) {
headerEndIndex = lines.length;
}
// Extract the content (everything after the header)
const content = lines.slice(headerEndIndex).join('\n');
return { metadata, content, title };
}
/**
* Splits AsciiDoc content into sections and extracts metadata from each
* @param content The full AsciiDoc content
* @returns Object containing document metadata and sections with their metadata
*/
export function parseAsciiDocWithMetadata(content: string): ParsedAsciiDoc {
// First extract document metadata
const { metadata: docMetadata } = extractDocumentMetadata(content);
// Find the document header end to get the content after the header
const lines = content.split(/\r?\n/);
let currentLine = 0;
// Find the document title
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const titleMatch = line.match(/^=\s+(.+)$/);
if (titleMatch) {
currentLine = i + 1;
break;
}
}
// Extract header metadata to find where content starts
const tempMetadata: AsciiDocMetadata = {};
currentLine = extractHeaderMetadata(lines, currentLine, tempMetadata);
// Get the content after the header (including sections with metadata)
const docContent = lines.slice(currentLine).join('\n');
// Split into sections
const sections = splitAsciiDocSections(docContent);
// Extract metadata from each section
const sectionsWithMetadata = sections.map(section => {
return extractSectionMetadata(section);
});
return {
metadata: docMetadata,
content: docContent,
sections: sectionsWithMetadata
};
}
/**
* Splits AsciiDoc content into sections at each '==' header
* @param content The AsciiDoc content (without document header)
* @returns Array of section strings
*/
function splitAsciiDocSections(content: string): string[] {
const lines = content.split(/\r?\n/);
const sections: string[] = [];
let currentSection: string[] = [];
let inSection = false;
for (const line of lines) {
// Check if this is a section header
if (line.match(/^==\s+/)) {
// Save the previous section if we have one
if (inSection && currentSection.length > 0) {
sections.push(currentSection.join('\n').trim());
currentSection = [];
}
// Start new section
currentSection = [line];
inSection = true;
} else if (inSection) {
// Add line to current section
currentSection.push(line);
}
}
// Add the last section
if (currentSection.length > 0) {
sections.push(currentSection.join('\n').trim());
}
return sections;
}
/**
* Converts metadata to Nostr event tags
* @param metadata The metadata object
* @returns Array of [tag, value] pairs
*/
export function metadataToTags(metadata: AsciiDocMetadata | SectionMetadata): [string, string][] {
const tags: [string, string][] = [];
if (metadata.title) {
tags.push(['title', metadata.title]);
}
if (metadata.authors && metadata.authors.length > 0) {
metadata.authors.forEach(author => {
tags.push(['author', author]);
});
}
if (metadata.version) {
tags.push(['version', metadata.version]);
}
if (metadata.edition) {
tags.push(['edition', metadata.edition]);
}
if (metadata.publicationDate) {
tags.push(['published_on', metadata.publicationDate]);
}
if (metadata.publishedBy) {
tags.push(['published_by', metadata.publishedBy]);
}
if (metadata.summary) {
tags.push(['summary', metadata.summary]);
}
if (metadata.coverImage) {
tags.push(['image', metadata.coverImage]);
}
if (metadata.isbn) {
tags.push(['i', metadata.isbn]);
}
if (metadata.source) {
tags.push(['source', metadata.source]);
}
if (metadata.type) {
tags.push(['type', metadata.type]);
}
if (metadata.autoUpdate) {
tags.push(['auto-update', metadata.autoUpdate]);
}
if (metadata.tags && metadata.tags.length > 0) {
metadata.tags.forEach(tag => {
tags.push(['t', tag]);
});
}
return tags;
}
/**
* Removes metadata from AsciiDoc content, leaving only the actual content
* @param content The full AsciiDoc content
* @returns Cleaned content without metadata
*/
export function removeMetadataFromContent(content: string): string {
const { content: docContent } = extractDocumentMetadata(content);
// Remove metadata attributes from sections in the content
const cleanedContent = docContent.replace(/^:([^:]+):\s*(.+)$/gm, '');
return cleanedContent;
}

342
src/lib/utils/event_input_utils.ts

@ -3,6 +3,13 @@ import { get } from "svelte/store";
import { ndkInstance } from "../ndk.ts"; import { ndkInstance } from "../ndk.ts";
import { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk"; import { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk";
import { EVENT_KINDS } from "./search_constants"; import { EVENT_KINDS } from "./search_constants";
import {
extractDocumentMetadata,
extractSectionMetadata,
parseAsciiDocWithMetadata,
metadataToTags,
removeMetadataFromContent
} from "./asciidoc_metadata";
// ========================= // =========================
// Validation // Validation
@ -79,24 +86,23 @@ export function validateAsciiDoc(content: string): {
export function validate30040EventSet(content: string): { export function validate30040EventSet(content: string): {
valid: boolean; valid: boolean;
reason?: string; reason?: string;
warning?: string;
} { } {
// First validate as AsciiDoc // Check for "index card" format first
const asciiDocValidation = validateAsciiDoc(content); const lines = content.split(/\r?\n/);
if (!asciiDocValidation.valid) { const { metadata } = extractDocumentMetadata(content);
return asciiDocValidation; const documentTitle = metadata.title;
} const nonEmptyLines = lines.filter(line => line.trim() !== "").map(line => line.trim());
const isIndexCardFormat = documentTitle &&
// Check that we have at least one section nonEmptyLines.length === 2 &&
const sectionsResult = splitAsciiDocSections(content); nonEmptyLines[0].startsWith("=") &&
if (sectionsResult.sections.length === 0) { nonEmptyLines[1].toLowerCase() === "index card";
return {
valid: false, if (isIndexCardFormat) {
reason: "30040 events must contain at least one section.", return { valid: true };
};
} }
// Check that we have a document title // Check that we have a document title
const documentTitle = extractAsciiDocDocumentHeader(content);
if (!documentTitle) { if (!documentTitle) {
return { return {
valid: false, valid: false,
@ -114,6 +120,41 @@ export function validate30040EventSet(content: string): {
}; };
} }
// Check for duplicate document headers (=)
const documentHeaderMatches = content.match(/^=\s+/gm);
if (documentHeaderMatches && documentHeaderMatches.length > 1) {
return {
valid: false,
reason: '30040 events must have exactly one document title ("="). Found multiple document headers.',
};
}
// Parse the content to check sections
const parsed = parseAsciiDocWithMetadata(content);
const hasSections = parsed.sections.length > 0;
if (!hasSections) {
return {
valid: true,
warning: "No section headers (==) found. This will create a 30040 index event and a single 30041 preamble section. Continue?",
};
}
// Only validate as AsciiDoc if we have sections
const asciiDocValidation = validateAsciiDoc(content);
if (!asciiDocValidation.valid) {
return asciiDocValidation;
}
// Check for empty sections
const emptySections = parsed.sections.filter(section => section.content.trim() === "");
if (emptySections.length > 0) {
return {
valid: true,
warning: "You are creating sections that contain no content. Proceed?",
};
}
return { valid: true }; return { valid: true };
} }
@ -141,14 +182,6 @@ export function titleToDTag(title: string): string {
.replace(/^-+|-+$/g, ""); // Trim leading/trailing hyphens .replace(/^-+|-+$/g, ""); // Trim leading/trailing hyphens
} }
/**
* Extracts the first AsciiDoc document header (line starting with '= ').
*/
function extractAsciiDocDocumentHeader(content: string): string | null {
const match = content.match(/^=\s+(.+)$/m);
return match ? match[1].trim() : null;
}
/** /**
* Extracts the topmost Markdown # header (line starting with '# '). * Extracts the topmost Markdown # header (line starting with '# ').
*/ */
@ -157,71 +190,6 @@ function extractMarkdownTopHeader(content: string): string | null {
return match ? match[1].trim() : null; return match ? match[1].trim() : null;
} }
/**
* Splits AsciiDoc content into sections at each '==' header. Returns array of section strings.
* Document title (= header) is excluded from sections and only used for the index event title.
* Section headers (==) are discarded from content.
* Text between document header and first section becomes a "Preamble" section.
*/
function splitAsciiDocSections(content: string): {
sections: string[];
sectionHeaders: string[];
hasPreamble: boolean;
} {
const lines = content.split(/\r?\n/);
const sections: string[] = [];
const sectionHeaders: string[] = [];
let current: string[] = [];
let foundFirstSection = false;
let hasPreamble = false;
const preambleContent: string[] = [];
for (const line of lines) {
// Skip document title lines (= header)
if (/^=\s+/.test(line)) {
continue;
}
// If we encounter a section header (==) and we have content, start a new section
if (/^==\s+/.test(line)) {
if (current.length > 0) {
sections.push(current.join("\n").trim());
current = [];
}
// Extract section header for title tag
const headerMatch = line.match(/^==\s+(.+)$/);
if (headerMatch) {
sectionHeaders.push(headerMatch[1].trim());
}
foundFirstSection = true;
} else if (foundFirstSection) {
// Only add lines to current section if we've found the first section
current.push(line);
} else {
// Text before first section becomes preamble
if (line.trim() !== "") {
preambleContent.push(line);
}
}
}
// Add the last section
if (current.length > 0) {
sections.push(current.join("\n").trim());
}
// Add preamble as first section if it exists
if (preambleContent.length > 0) {
sections.unshift(preambleContent.join("\n").trim());
sectionHeaders.unshift("Preamble");
hasPreamble = true;
}
return { sections, sectionHeaders, hasPreamble };
}
// ========================= // =========================
// Event Construction // Event Construction
// ========================= // =========================
@ -251,44 +219,90 @@ export function build30040EventSet(
const ndk = getNdk(); const ndk = getNdk();
console.log("NDK instance:", ndk); console.log("NDK instance:", ndk);
const sectionsResult = splitAsciiDocSections(content); // Parse the AsciiDoc content with metadata extraction
const sections = sectionsResult.sections; const parsed = parseAsciiDocWithMetadata(content);
const sectionHeaders = sectionsResult.sectionHeaders; console.log("Parsed AsciiDoc:", parsed);
console.log("Sections:", sections);
console.log("Section headers:", sectionHeaders); // Check if this is an "index card" format (no sections, just title + "index card")
const lines = content.split(/\r?\n/);
const dTags = const documentTitle = parsed.metadata.title;
sectionHeaders.length === sections.length
? sectionHeaders.map(normalizeDTagValue) // For index card format, the content should be exactly: title + "index card"
: sections.map((_, i) => `section${i}`); const nonEmptyLines = lines.filter(line => line.trim() !== "").map(line => line.trim());
console.log("D tags:", dTags); const isIndexCardFormat = documentTitle &&
nonEmptyLines.length === 2 &&
const sectionEvents: NDKEvent[] = sections.map((section, i) => { nonEmptyLines[0].startsWith("=") &&
const header = sectionHeaders[i] || `Section ${i + 1}`; nonEmptyLines[1].toLowerCase() === "index card";
const dTag = dTags[i];
console.log(`Creating section ${i}:`, { header, dTag, content: section }); if (isIndexCardFormat) {
console.log("Creating index card format (no sections)");
const indexDTag = normalizeDTagValue(documentTitle);
// Convert document metadata to tags
const metadataTags = metadataToTags(parsed.metadata);
const indexEvent: NDKEvent = new NDKEventClass(ndk, {
kind: 30040,
content: "",
tags: [
...tags,
...metadataTags,
["d", indexDTag],
["title", documentTitle],
],
pubkey: baseEvent.pubkey,
created_at: baseEvent.created_at,
});
console.log("Final index event (index card):", indexEvent);
console.log("=== build30040EventSet completed (index card) ===");
return { indexEvent, sectionEvents: [] };
}
// Generate the index d-tag first
const indexDTag = documentTitle ? normalizeDTagValue(documentTitle) : "index";
console.log("Index event:", { documentTitle, indexDTag });
// Create section events with their metadata
const sectionEvents: NDKEvent[] = parsed.sections.map((section, i) => {
const sectionDTag = `${indexDTag}-${normalizeDTagValue(section.title)}`;
console.log(`Creating section ${i}:`, {
title: section.title,
dTag: sectionDTag,
content: section.content,
metadata: section.metadata
});
// Convert section metadata to tags
const sectionMetadataTags = metadataToTags(section.metadata);
return new NDKEventClass(ndk, { return new NDKEventClass(ndk, {
kind: 30041, kind: 30041,
content: section, content: section.content,
tags: [...tags, ["d", dTag], ["title", header]], tags: [
...tags,
...sectionMetadataTags,
["d", sectionDTag],
["title", section.title]
],
pubkey: baseEvent.pubkey, pubkey: baseEvent.pubkey,
created_at: baseEvent.created_at, created_at: baseEvent.created_at,
}); });
}); });
// Create proper a tags with format: kind:pubkey:d-tag // Create proper a tags with format: kind:pubkey:d-tag
const aTags = dTags.map( const aTags = sectionEvents.map(event => {
(dTag) => ["a", `30041:${baseEvent.pubkey}:${dTag}`] as [string, string], const dTag = event.tags.find(([k]) => k === "d")?.[1];
); return ["a", `30041:${baseEvent.pubkey}:${dTag}`] as [string, string];
});
console.log("A tags:", aTags); console.log("A tags:", aTags);
// Extract document title for the index event // Convert document metadata to tags
const documentTitle = extractAsciiDocDocumentHeader(content); const metadataTags = metadataToTags(parsed.metadata);
const indexDTag = documentTitle ? normalizeDTagValue(documentTitle) : "index";
console.log("Index event:", { documentTitle, indexDTag });
const indexTags = [ const indexTags = [
...tags, ...tags,
...metadataTags,
["d", indexDTag], ["d", indexDTag],
["title", documentTitle || "Untitled"], ["title", documentTitle || "Untitled"],
...aTags, ...aTags,
@ -316,7 +330,8 @@ export function getTitleTagForEvent(
content: string, content: string,
): string | null { ): string | null {
if (kind === 30041 || kind === 30818) { if (kind === 30041 || kind === 30818) {
return extractAsciiDocDocumentHeader(content); const { metadata } = extractDocumentMetadata(content);
return metadata.title || null;
} }
if (kind === 30023) { if (kind === 30023) {
return extractMarkdownTopHeader(content); return extractMarkdownTopHeader(content);
@ -345,8 +360,8 @@ export function getDTagForEvent(
} }
if (kind === 30041 || kind === 30818) { if (kind === 30041 || kind === 30818) {
const title = extractAsciiDocDocumentHeader(content); const { metadata } = extractDocumentMetadata(content);
return title ? normalizeDTagValue(title) : null; return metadata.title ? normalizeDTagValue(metadata.title) : null;
} }
return null; return null;
@ -356,13 +371,59 @@ export function getDTagForEvent(
* Returns a description of what a 30040 event structure should be. * Returns a description of what a 30040 event structure should be.
*/ */
export function get30040EventDescription(): string { export function get30040EventDescription(): string {
return `30040 events are publication indexes that contain: return `30040 events are publication indexes that organize AsciiDoc content into structured publications.
- Empty content (metadata only)
- A d-tag for the publication identifier **Supported Structures:**
- A title tag for the publication title
- A tags referencing 30041 content events (one per section) 1. **Normal Document** (with sections):
= Document Title
:author: Author Name
:summary: Document description
:keywords: tag1, tag2, tag3
== Section 1
Section content here...
== Section 2
More content...
2. **Index Card** (empty publication):
= Publication Title
index card
The content is split into sections, each published as a separate 30041 event.`; 3. **Skeleton Document** (empty sections):
= Document Title
== Empty Section 1
== Empty Section 2
4. **Preamble Document** (with preamble content):
= Document Title
:author: Author Name
:summary: Document description
:keywords: tag1, tag2, tag3
Preamble content here...
== Section 1
Section content here...
**Metadata Extraction:**
- Document title, authors, version, publication date, and publisher are extracted from header lines
- Additional metadata (summary/description, keywords/tags, image, ISBN, etc.) are extracted from attributes
- Multiple authors and summaries are preserved
- All metadata is converted to appropriate Nostr event tags
**Event Structure:**
- 30040 index event: Empty content with metadata tags and a-tags referencing sections
- 30041 section events: Individual section content with section-specific metadata
**Special Features:**
- Preamble content (between header and first section) is preserved
- Multiple authors and descriptions are supported
- Keywords and tags are automatically converted to Nostr t-tags
- Index card format creates empty publications without sections`;
} }
/** /**
@ -422,16 +483,31 @@ export function analyze30040Event(event: {
export function get30040FixGuidance(): string { export function get30040FixGuidance(): string {
return `To fix a 30040 event: return `To fix a 30040 event:
1. **Content Issue**: 30040 events should have empty content. All content should be split into separate 30041 events. 1. **Content Structure**: Ensure your AsciiDoc starts with a document title (= Title)
- Add at least one section (== Section) for normal documents
2. **Structure**: A proper 30040 event should contain: - Use "index card" format for empty publications
- Empty content - Include metadata in header lines or attributes,
- d tag: publication identifier or add them manually to the tag list
- title tag: publication title
- a tags: references to 30041 content events (format: "30041:pubkey:d-tag") 2. **Metadata**: Add relevant metadata to improve discoverability:
- Author: Use header line or :author: attribute
3. **Process**: When creating a 30040 event: - Summary: Use :summary: or :description: attribute
- Write your content with document title (= Title) and sections (== Section) - Keywords: Use :keywords: or :tags: attribute
- The system will automatically split it into one 30040 index event and multiple 30041 content events - Version: Use revision line or :version: attribute
- The 30040 will have empty content and reference the 30041s via a tags`; - Publication date: Use revision line or :published_on: attribute
3. **Event Structure**: The system will automatically create:
- 30040 index event: Empty content with metadata and a-tags
- 30041 section events: Individual section content with section metadata
4. **Common Issues**:
- Missing document title: Start with "= Your Title"
- No sections: Add "== Section Name" or use "index card" format
- Invalid metadata: Use proper AsciiDoc attribute syntax (:key: value)
5. **Best Practices**:
- Include descriptive titles and summaries
- Use keywords for better searchability
- Add author information when relevant
- Consider using preamble content for introductions`;
} }

4
src/lib/utils/markup/advancedAsciidoctorPostProcessor.ts

@ -32,9 +32,9 @@ export async function postProcessAdvancedAsciidoctorHtml(
} }
if ( if (
typeof globalThis !== "undefined" && typeof globalThis !== "undefined" &&
typeof globalThis.MathJax?.typesetPromise === "function" typeof (globalThis.MathJax as any)?.typesetPromise === "function"
) { ) {
setTimeout(() => globalThis.MathJax.typesetPromise(), 0); setTimeout(() => (globalThis.MathJax as any).typesetPromise(), 0);
} }
return processedHtml; return processedHtml;
} catch (error) { } catch (error) {

2
src/lib/utils/network_detection.ts

@ -156,7 +156,7 @@ export function startNetworkMonitoring(
checkInterval: number = 60000 // Increased to 60 seconds to reduce spam checkInterval: number = 60000 // Increased to 60 seconds to reduce spam
): () => void { ): () => void {
let lastCondition: NetworkCondition | null = null; let lastCondition: NetworkCondition | null = null;
let intervalId: number | null = null; let intervalId: ReturnType<typeof setInterval> | null = null;
const checkNetwork = async () => { const checkNetwork = async () => {
try { try {

446
tests/unit/eventInput30040.test.ts

@ -0,0 +1,446 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { build30040EventSet, validate30040EventSet } from "../../src/lib/utils/event_input_utils";
import { extractDocumentMetadata, parseAsciiDocWithMetadata } from "../../src/lib/utils/asciidoc_metadata";
// Mock NDK and other dependencies
vi.mock("@nostr-dev-kit/ndk", () => ({
NDKEvent: vi.fn().mockImplementation((ndk, eventData) => ({
...eventData,
id: "mock-event-id",
sig: "mock-signature",
kind: eventData.kind,
content: eventData.content,
tags: eventData.tags,
pubkey: eventData.pubkey,
created_at: eventData.created_at,
})),
}));
vi.mock("../../src/lib/ndk", () => ({
ndkInstance: {
subscribe: vi.fn(),
},
getNdk: vi.fn(() => ({})),
}));
vi.mock("svelte/store", () => ({
get: vi.fn(() => ({})),
}));
describe("EventInput 30040 Publishing", () => {
const baseEvent = {
pubkey: "test-pubkey",
created_at: 1234567890,
};
beforeEach(() => {
vi.clearAllMocks();
});
describe("Normal Structure with Preamble", () => {
it("should build 30040 event set with preamble content", () => {
const content = `= Test Document with Preamble
John Doe <john@example.com>
1.0, 2024-01-15, Alexandria Test
:summary: This is a test document with preamble
:keywords: test, preamble, asciidoc
This is the preamble content that should be included.
== First Section
:author: Section Author
:summary: This is the first section
This is the content of the first section.
== Second Section
:summary: This is the second section
This is the content of the second section.`;
const tags: [string, string][] = [["type", "article"]];
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent);
// Test index event
expect(indexEvent.kind).toBe(30040);
expect(indexEvent.content).toBe("");
expect(indexEvent.tags).toContainEqual(["d", "test-document-with-preamble"]);
expect(indexEvent.tags).toContainEqual(["title", "Test Document with Preamble"]);
expect(indexEvent.tags).toContainEqual(["author", "John Doe"]);
expect(indexEvent.tags).toContainEqual(["version", "1.0"]);
expect(indexEvent.tags).toContainEqual(["summary", "This is a test document with preamble"]);
expect(indexEvent.tags).toContainEqual(["t", "test"]);
expect(indexEvent.tags).toContainEqual(["t", "preamble"]);
expect(indexEvent.tags).toContainEqual(["t", "asciidoc"]);
expect(indexEvent.tags).toContainEqual(["type", "article"]);
// Test section events
expect(sectionEvents).toHaveLength(2);
// First section
expect(sectionEvents[0].kind).toBe(30041);
expect(sectionEvents[0].content).toBe("This is the content of the first section.");
expect(sectionEvents[0].tags).toContainEqual(["d", "test-document-with-preamble-first-section"]);
expect(sectionEvents[0].tags).toContainEqual(["title", "First Section"]);
expect(sectionEvents[0].tags).toContainEqual(["author", "Section Author"]);
expect(sectionEvents[0].tags).toContainEqual(["summary", "This is the first section"]);
// Second section
expect(sectionEvents[1].kind).toBe(30041);
expect(sectionEvents[1].content).toBe("This is the content of the second section.");
expect(sectionEvents[1].tags).toContainEqual(["d", "test-document-with-preamble-second-section"]);
expect(sectionEvents[1].tags).toContainEqual(["title", "Second Section"]);
expect(sectionEvents[1].tags).toContainEqual(["summary", "This is the second section"]);
// Test a-tags in index event
expect(indexEvent.tags).toContainEqual(["a", "30041:test-pubkey:test-document-with-preamble-first-section"]);
expect(indexEvent.tags).toContainEqual(["a", "30041:test-pubkey:test-document-with-preamble-second-section"]);
});
});
describe("Normal Structure without Preamble", () => {
it("should build 30040 event set without preamble content", () => {
const content = `= Test Document without Preamble
:summary: This is a test document without preamble
:keywords: test, no-preamble, asciidoc
== First Section
:author: Section Author
:summary: This is the first section
This is the content of the first section.
== Second Section
:summary: This is the second section
This is the content of the second section.`;
const tags: [string, string][] = [["type", "article"]];
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent);
// Test index event
expect(indexEvent.kind).toBe(30040);
expect(indexEvent.content).toBe("");
expect(indexEvent.tags).toContainEqual(["d", "test-document-without-preamble"]);
expect(indexEvent.tags).toContainEqual(["title", "Test Document without Preamble"]);
expect(indexEvent.tags).toContainEqual(["summary", "This is a test document without preamble"]);
// Test section events
expect(sectionEvents).toHaveLength(2);
// First section
expect(sectionEvents[0].kind).toBe(30041);
expect(sectionEvents[0].content).toBe("This is the content of the first section.");
expect(sectionEvents[0].tags).toContainEqual(["d", "test-document-without-preamble-first-section"]);
expect(sectionEvents[0].tags).toContainEqual(["title", "First Section"]);
expect(sectionEvents[0].tags).toContainEqual(["author", "Section Author"]);
expect(sectionEvents[0].tags).toContainEqual(["summary", "This is the first section"]);
// Second section
expect(sectionEvents[1].kind).toBe(30041);
expect(sectionEvents[1].content).toBe("This is the content of the second section.");
expect(sectionEvents[1].tags).toContainEqual(["d", "test-document-without-preamble-second-section"]);
expect(sectionEvents[1].tags).toContainEqual(["title", "Second Section"]);
expect(sectionEvents[1].tags).toContainEqual(["summary", "This is the second section"]);
});
});
describe("Skeleton Structure with Preamble", () => {
it("should build 30040 event set with skeleton structure and preamble", () => {
const content = `= Skeleton Document with Preamble
:summary: This is a skeleton document with preamble
:keywords: skeleton, preamble, empty
This is the preamble content.
== Empty Section 1
== Empty Section 2
== Empty Section 3`;
const tags: [string, string][] = [["type", "skeleton"]];
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent);
// Test index event
expect(indexEvent.kind).toBe(30040);
expect(indexEvent.content).toBe("");
expect(indexEvent.tags).toContainEqual(["d", "skeleton-document-with-preamble"]);
expect(indexEvent.tags).toContainEqual(["title", "Skeleton Document with Preamble"]);
expect(indexEvent.tags).toContainEqual(["summary", "This is a skeleton document with preamble"]);
// Test section events
expect(sectionEvents).toHaveLength(3);
// All sections should have empty content
sectionEvents.forEach((section, index) => {
expect(section.kind).toBe(30041);
expect(section.content).toBe("");
expect(section.tags).toContainEqual(["d", `skeleton-document-with-preamble-empty-section-${index + 1}`]);
expect(section.tags).toContainEqual(["title", `Empty Section ${index + 1}`]);
});
});
});
describe("Skeleton Structure without Preamble", () => {
it("should build 30040 event set with skeleton structure without preamble", () => {
const content = `= Skeleton Document without Preamble
:summary: This is a skeleton document without preamble
:keywords: skeleton, no-preamble, empty
== Empty Section 1
== Empty Section 2
== Empty Section 3`;
const tags: [string, string][] = [["type", "skeleton"]];
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent);
// Test index event
expect(indexEvent.kind).toBe(30040);
expect(indexEvent.content).toBe("");
expect(indexEvent.tags).toContainEqual(["d", "skeleton-document-without-preamble"]);
expect(indexEvent.tags).toContainEqual(["title", "Skeleton Document without Preamble"]);
expect(indexEvent.tags).toContainEqual(["summary", "This is a skeleton document without preamble"]);
// Test section events
expect(sectionEvents).toHaveLength(3);
// All sections should have empty content
sectionEvents.forEach((section, index) => {
expect(section.kind).toBe(30041);
expect(section.content).toBe("");
expect(section.tags).toContainEqual(["d", `skeleton-document-without-preamble-empty-section-${index + 1}`]);
expect(section.tags).toContainEqual(["title", `Empty Section ${index + 1}`]);
});
});
});
describe("Index Card Format", () => {
it("should build 30040 event set for index card format", () => {
const content = `= Test Index Card
index card`;
const tags: [string, string][] = [["type", "index-card"]];
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent);
// Test index event
expect(indexEvent.kind).toBe(30040);
expect(indexEvent.content).toBe("");
expect(indexEvent.tags).toContainEqual(["d", "test-index-card"]);
expect(indexEvent.tags).toContainEqual(["title", "Test Index Card"]);
expect(indexEvent.tags).toContainEqual(["type", "index-card"]);
// Should have no section events for index card
expect(sectionEvents).toHaveLength(0);
});
it("should build 30040 event set for index card with metadata", () => {
const content = `= Test Index Card with Metadata
:summary: This is an index card with metadata
:keywords: index, card, metadata
index card`;
const tags: [string, string][] = [["type", "index-card"]];
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent);
// Test index event
expect(indexEvent.kind).toBe(30040);
expect(indexEvent.content).toBe("");
expect(indexEvent.tags).toContainEqual(["d", "test-index-card-with-metadata"]);
expect(indexEvent.tags).toContainEqual(["title", "Test Index Card with Metadata"]);
expect(indexEvent.tags).toContainEqual(["summary", "This is an index card with metadata"]);
expect(indexEvent.tags).toContainEqual(["t", "index"]);
expect(indexEvent.tags).toContainEqual(["t", "card"]);
expect(indexEvent.tags).toContainEqual(["t", "metadata"]);
expect(indexEvent.tags).toContainEqual(["type", "index-card"]);
// Should have no section events for index card
expect(sectionEvents).toHaveLength(0);
});
});
describe("Complex Metadata Structures", () => {
it("should handle complex metadata with all attribute types", () => {
const content = `= Complex Metadata Document
Jane Smith <jane@example.com>
2.0, 2024-02-20, Alexandria Complex
:summary: This is a complex document with all metadata types
:description: Alternative description field
:keywords: complex, metadata, all-types
:tags: additional, tags, here
:author: Override Author
:author: Third Author
:version: 3.0
:published_on: 2024-03-01
:published_by: Alexandria Complex
:type: book
:image: https://example.com/cover.jpg
:isbn: 978-0-123456-78-9
:source: https://github.com/alexandria/complex
:auto-update: yes
This is the preamble content.
== Section with Complex Metadata
:author: Section Author
:author: Section Co-Author
:summary: This section has complex metadata
:description: Alternative description for section
:keywords: section, complex, metadata
:tags: section, tags
:type: chapter
:image: https://example.com/section-image.jpg
This is the section content.`;
const tags: [string, string][] = [["type", "complex"]];
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent);
// Test index event metadata
expect(indexEvent.kind).toBe(30040);
expect(indexEvent.tags).toContainEqual(["d", "complex-metadata-document"]);
expect(indexEvent.tags).toContainEqual(["title", "Complex Metadata Document"]);
expect(indexEvent.tags).toContainEqual(["author", "Jane Smith"]); // Should use header line author
expect(indexEvent.tags).toContainEqual(["author", "Override Author"]); // Additional author from attribute
expect(indexEvent.tags).toContainEqual(["author", "Third Author"]); // Additional author from attribute
expect(indexEvent.tags).toContainEqual(["version", "2.0"]); // Should use revision line version
expect(indexEvent.tags).toContainEqual(["summary", "This is a complex document with all metadata types Alternative description field"]);
expect(indexEvent.tags).toContainEqual(["published_on", "2024-03-01"]);
expect(indexEvent.tags).toContainEqual(["published_by", "Alexandria Complex"]);
expect(indexEvent.tags).toContainEqual(["type", "book"]);
expect(indexEvent.tags).toContainEqual(["image", "https://example.com/cover.jpg"]);
expect(indexEvent.tags).toContainEqual(["i", "978-0-123456-78-9"]);
expect(indexEvent.tags).toContainEqual(["source", "https://github.com/alexandria/complex"]);
expect(indexEvent.tags).toContainEqual(["auto-update", "yes"]);
expect(indexEvent.tags).toContainEqual(["t", "complex"]);
expect(indexEvent.tags).toContainEqual(["t", "metadata"]);
expect(indexEvent.tags).toContainEqual(["t", "all-types"]);
expect(indexEvent.tags).toContainEqual(["t", "additional"]);
expect(indexEvent.tags).toContainEqual(["t", "tags"]);
expect(indexEvent.tags).toContainEqual(["t", "here"]);
// Test section metadata
expect(sectionEvents).toHaveLength(1);
expect(sectionEvents[0].kind).toBe(30041);
expect(sectionEvents[0].content).toBe("This is the section content.");
expect(sectionEvents[0].tags).toContainEqual(["d", "complex-metadata-document-section-with-complex-metadata"]);
expect(sectionEvents[0].tags).toContainEqual(["title", "Section with Complex Metadata"]);
expect(sectionEvents[0].tags).toContainEqual(["author", "Section Author"]);
expect(sectionEvents[0].tags).toContainEqual(["author", "Section Co-Author"]);
expect(sectionEvents[0].tags).toContainEqual(["summary", "This section has complex metadata Alternative description for section"]);
expect(sectionEvents[0].tags).toContainEqual(["type", "chapter"]);
expect(sectionEvents[0].tags).toContainEqual(["image", "https://example.com/section-image.jpg"]);
expect(sectionEvents[0].tags).toContainEqual(["t", "section"]);
expect(sectionEvents[0].tags).toContainEqual(["t", "complex"]);
expect(sectionEvents[0].tags).toContainEqual(["t", "metadata"]);
expect(sectionEvents[0].tags).toContainEqual(["t", "tags"]);
});
});
describe("Validation Tests", () => {
it("should validate normal structure correctly", () => {
const content = `= Valid Document
:summary: This is a valid document
== Section 1
Content here.
== Section 2
More content.`;
const validation = validate30040EventSet(content);
expect(validation.valid).toBe(true);
});
it("should validate index card format correctly", () => {
const content = `= Valid Index Card
index card`;
const validation = validate30040EventSet(content);
expect(validation.valid).toBe(true);
});
it("should validate skeleton structure correctly", () => {
const content = `= Skeleton Document
== Empty Section 1
== Empty Section 2`;
const validation = validate30040EventSet(content);
expect(validation.valid).toBe(true);
});
it("should reject invalid structure", () => {
const content = `This is not a valid AsciiDoc document.`;
const validation = validate30040EventSet(content);
expect(validation.valid).toBe(false);
expect(validation.reason).toContain("30040 events must have a document title");
});
});
describe("Edge Cases", () => {
it("should handle document with only title and no sections", () => {
const content = `= Document with No Sections
:summary: This document has no sections
This is just preamble content.`;
const tags: [string, string][] = [];
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent);
expect(indexEvent.kind).toBe(30040);
expect(indexEvent.tags).toContainEqual(["d", "document-with-no-sections"]);
expect(indexEvent.tags).toContainEqual(["title", "Document with No Sections"]);
expect(sectionEvents).toHaveLength(0);
});
it("should handle document with special characters in title", () => {
const content = `= Document with Special Characters: Test & More!
:summary: This document has special characters in the title
== Section 1
Content here.`;
const tags: [string, string][] = [];
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent);
expect(indexEvent.kind).toBe(30040);
expect(indexEvent.tags).toContainEqual(["d", "document-with-special-characters-test-more"]);
expect(indexEvent.tags).toContainEqual(["title", "Document with Special Characters: Test & More!"]);
expect(sectionEvents).toHaveLength(1);
});
it("should handle document with very long title", () => {
const content = `= This is a very long document title that should be handled properly by the system and should not cause any issues with the d-tag generation or any other functionality
:summary: This document has a very long title
== Section 1
Content here.`;
const tags: [string, string][] = [];
const { indexEvent, sectionEvents } = build30040EventSet(content, tags, baseEvent);
expect(indexEvent.kind).toBe(30040);
expect(indexEvent.tags).toContainEqual(["title", "This is a very long document title that should be handled properly by the system and should not cause any issues with the d-tag generation or any other functionality"]);
expect(sectionEvents).toHaveLength(1);
});
});
});

183
tests/unit/metadataExtraction.test.ts

@ -0,0 +1,183 @@
import { describe, it, expect } from "vitest";
import {
extractDocumentMetadata,
extractSectionMetadata,
parseAsciiDocWithMetadata,
metadataToTags
} from "../../src/lib/utils/asciidoc_metadata.ts";
describe("AsciiDoc Metadata Extraction", () => {
const testContent = `= Test Document with Metadata
John Doe <john@example.com>
1.0, 2024-01-15, Alexandria Test
:summary: This is a test document for metadata extraction
:author: Jane Smith
:version: 2.0
:published_on: 2024-01-15
:published_by: Alexandria Project
:type: article
:keywords: test, metadata, asciidoc
:image: https://example.com/cover.jpg
:isbn: 978-0-123456-78-9
:source: https://github.com/alexandria/test
:auto-update: yes
This is the preamble content that should be included in the document body.
== First Section
:author: Section Author
:summary: This is the first section
:keywords: section1, content
This is the content of the first section.
== Second Section
:summary: This is the second section
:type: chapter
This is the content of the second section.`;
it("extractDocumentMetadata should extract document metadata correctly", () => {
const { metadata, content } = extractDocumentMetadata(testContent);
expect(metadata.title).toBe("Test Document with Metadata");
expect(metadata.authors).toEqual(["John Doe", "Jane Smith"]);
expect(metadata.version).toBe("1.0");
expect(metadata.publicationDate).toBe("2024-01-15");
expect(metadata.publishedBy).toBe("Alexandria Test");
expect(metadata.summary).toBe("This is a test document for metadata extraction");
expect(metadata.authors).toEqual(["John Doe", "Jane Smith"]);
expect(metadata.type).toBe("article");
expect(metadata.tags).toEqual(["test", "metadata", "asciidoc"]);
expect(metadata.coverImage).toBe("https://example.com/cover.jpg");
expect(metadata.isbn).toBe("978-0-123456-78-9");
expect(metadata.source).toBe("https://github.com/alexandria/test");
expect(metadata.autoUpdate).toBe("yes");
// Content should not include the header metadata
expect(content).toContain("This is the preamble content");
expect(content).toContain("== First Section");
expect(content).not.toContain("= Test Document with Metadata");
expect(content).not.toContain(":summary:");
});
it("extractSectionMetadata should extract section metadata correctly", () => {
const sectionContent = `== First Section
:author: Section Author
:description: This is the first section
:tags: section1, content
This is the content of the first section.`;
const { metadata, content, title } = extractSectionMetadata(sectionContent);
expect(title).toBe("First Section");
expect(metadata.authors).toEqual(["Section Author"]);
expect(metadata.summary).toBe("This is the first section");
expect(metadata.tags).toEqual(["section1", "content"]);
expect(content).toBe("This is the content of the first section.");
});
it("parseAsciiDocWithMetadata should parse complete document", () => {
const parsed = parseAsciiDocWithMetadata(testContent);
expect(parsed.metadata.title).toBe("Test Document with Metadata");
expect(parsed.sections).toHaveLength(2);
expect(parsed.sections[0].title).toBe("First Section");
expect(parsed.sections[1].title).toBe("Second Section");
expect(parsed.sections[0].metadata.authors).toEqual(["Section Author"]);
expect(parsed.sections[1].metadata.summary).toBe("This is the second section");
});
it("metadataToTags should convert metadata to Nostr tags", () => {
const metadata = {
title: "Test Title",
authors: ["Author 1", "Author 2"],
version: "1.0",
summary: "Test summary",
tags: ["tag1", "tag2"]
};
const tags = metadataToTags(metadata);
expect(tags).toContainEqual(["title", "Test Title"]);
expect(tags).toContainEqual(["author", "Author 1"]);
expect(tags).toContainEqual(["author", "Author 2"]);
expect(tags).toContainEqual(["version", "1.0"]);
expect(tags).toContainEqual(["summary", "Test summary"]);
expect(tags).toContainEqual(["t", "tag1"]);
expect(tags).toContainEqual(["t", "tag2"]);
});
it("should handle index card format correctly", () => {
const indexCardContent = `= Test Index Card
index card`;
const { metadata, content } = extractDocumentMetadata(indexCardContent);
expect(metadata.title).toBe("Test Index Card");
expect(content.trim()).toBe("index card");
});
it("should handle empty content gracefully", () => {
const emptyContent = "";
const { metadata, content } = extractDocumentMetadata(emptyContent);
expect(metadata.title).toBeUndefined();
expect(content).toBe("");
});
it("should handle keywords as tags", () => {
const contentWithKeywords = `= Test Document
:keywords: keyword1, keyword2, keyword3
Content here.`;
const { metadata } = extractDocumentMetadata(contentWithKeywords);
expect(metadata.tags).toEqual(["keyword1", "keyword2", "keyword3"]);
});
it("should handle both tags and keywords", () => {
const contentWithBoth = `= Test Document
:tags: tag1, tag2
:keywords: keyword1, keyword2
Content here.`;
const { metadata } = extractDocumentMetadata(contentWithBoth);
// Both tags and keywords are valid, both should be accumulated
expect(metadata.tags).toEqual(["tag1", "tag2", "keyword1", "keyword2"]);
});
it("should handle tags only", () => {
const contentWithTags = `= Test Document
:tags: tag1, tag2, tag3
Content here.`;
const { metadata } = extractDocumentMetadata(contentWithTags);
expect(metadata.tags).toEqual(["tag1", "tag2", "tag3"]);
});
it("should handle both summary and description", () => {
const contentWithSummary = `= Test Document
:summary: This is a summary
Content here.`;
const contentWithDescription = `= Test Document
:description: This is a description
Content here.`;
const { metadata: summaryMetadata } = extractDocumentMetadata(contentWithSummary);
const { metadata: descriptionMetadata } = extractDocumentMetadata(contentWithDescription);
expect(summaryMetadata.summary).toBe("This is a summary");
expect(descriptionMetadata.summary).toBe("This is a description");
});
});
Loading…
Cancel
Save