Browse Source

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

added informative text to compose notes page
master
silberengel 11 months ago
parent
commit
4ac2ab8eb0
  1. 155
      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

155
src/lib/components/EventInput.svelte

@ -12,6 +12,11 @@ @@ -12,6 +12,11 @@
analyze30040Event,
get30040FixGuidance,
} from "$lib/utils/event_input_utils";
import {
extractDocumentMetadata,
metadataToTags,
removeMetadataFromContent
} from "$lib/utils/asciidoc_metadata";
import { get } from "svelte/store";
import { ndkInstance } from "$lib/ndk";
import { userPubkey } from "$lib/stores/authStore.Svelte";
@ -24,7 +29,7 @@ @@ -24,7 +29,7 @@
import { goto } from "$app/navigation";
import { WebSocketPool } from "$lib/data_structures/websocket_pool";
let kind = $state<number>(30023);
let kind = $state<number>(30040);
let tags = $state<[string, string][]>([]);
let content = $state("");
let createdAt = $state<number>(Math.floor(Date.now() / 1000));
@ -39,14 +44,29 @@ @@ -39,14 +44,29 @@
let dTagManuallyEdited = $state(false);
let dTagError = $state("");
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.
*/
function extractTitleFromContent(content: string): string {
// Match Markdown (# Title) or AsciiDoc (= Title) headers
const match = content.match(/^(#|=)\s*(.+)$/m);
return match ? match[2].trim() : "";
// Look for document title (=) first, then fall back to section headers (==)
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) {
@ -56,6 +76,22 @@ @@ -56,6 +76,22 @@
console.log("Content input - 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) {
@ -92,12 +128,24 @@ @@ -92,12 +128,24 @@
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 {
const n = Number(kind);
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 userState = get(userStore);
@ -113,6 +161,7 @@ @@ -113,6 +161,7 @@
if (kind === 30040) {
const v = validate30040EventSet(content);
if (!v.valid) return v;
if (v.warning) return { valid: true, warning: v.warning };
}
if (kind === 30041 || kind === 30818) {
const v = validateAsciiDoc(content);
@ -124,10 +173,26 @@ @@ -124,10 +173,26 @@
function handleSubmit(e: Event) {
e.preventDefault();
dTagError = "";
error = null; // Clear any previous errors
if (requiresDTag(kind) && (!dTag || dTag.trim() === "")) {
dTagError = "A d-tag is required.";
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();
}
@ -235,8 +300,14 @@ @@ -235,8 +300,14 @@
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
const prefixedContent = prefixNostrAddresses(content);
const prefixedContent = prefixNostrAddresses(finalContent);
// Create event with proper serialization
const eventData = {
@ -330,6 +401,9 @@ @@ -330,6 +401,9 @@
}
}
};
// Send the event to the relay
ws.send(JSON.stringify(["EVENT", signedEvent]));
});
if (published) break;
} catch (e) {
@ -391,6 +465,18 @@ @@ -391,6 +465,18 @@
goto(`/events?id=${encodeURIComponent(lastPublishedEventId)}`);
}
}
function confirmWarning() {
showWarning = false;
pendingPublish = false;
handlePublish();
}
function cancelWarning() {
showWarning = false;
pendingPublish = false;
warningMessage = "";
}
</script>
<div
@ -412,9 +498,9 @@ @@ -412,9 +498,9 @@
Kind must be an integer between 0 and 65535 (NIP-01).
</div>
{/if}
{#if kind === 30040}
{#if Number(kind) === 30040}
<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>
{get30040EventDescription()}
@ -423,6 +509,36 @@ @@ -423,6 +509,36 @@
</div>
<div>
<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">
{#each tags as [key, value], i}
<div class="flex gap-2">
@ -528,3 +644,28 @@ @@ -528,3 +644,28 @@
{/if}
</form>
</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... @@ -51,6 +51,35 @@ Note content here...
</script>
<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">
<Button
color="light"

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

@ -1,7 +1,7 @@ @@ -1,7 +1,7 @@
<script lang="ts">
import { indexKind } from "$lib/consts";
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 ArticleHeader from "./PublicationHeader.svelte";
import { onMount, onDestroy } from "svelte";
@ -290,7 +290,7 @@ @@ -290,7 +290,7 @@
};
// Debounced search function
const debouncedSearch = debounce(async (query: string) => {
const debouncedSearch = debounceAsync(async (query: string) => {
console.debug("[PublicationFeed] Search query changed:", query);
if (query.trim()) {
const filtered = filterEventsBySearch(allIndexEvents);

489
src/lib/utils/asciidoc_metadata.ts

@ -0,0 +1,489 @@ @@ -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"; @@ -3,6 +3,13 @@ import { get } from "svelte/store";
import { ndkInstance } from "../ndk.ts";
import { NDKEvent as NDKEventClass } from "@nostr-dev-kit/ndk";
import { EVENT_KINDS } from "./search_constants";
import {
extractDocumentMetadata,
extractSectionMetadata,
parseAsciiDocWithMetadata,
metadataToTags,
removeMetadataFromContent
} from "./asciidoc_metadata";
// =========================
// Validation
@ -79,24 +86,23 @@ export function validateAsciiDoc(content: string): { @@ -79,24 +86,23 @@ export function validateAsciiDoc(content: string): {
export function validate30040EventSet(content: string): {
valid: boolean;
reason?: string;
warning?: string;
} {
// First validate as AsciiDoc
const asciiDocValidation = validateAsciiDoc(content);
if (!asciiDocValidation.valid) {
return asciiDocValidation;
}
// Check that we have at least one section
const sectionsResult = splitAsciiDocSections(content);
if (sectionsResult.sections.length === 0) {
return {
valid: false,
reason: "30040 events must contain at least one section.",
};
// Check for "index card" format first
const lines = content.split(/\r?\n/);
const { metadata } = extractDocumentMetadata(content);
const documentTitle = metadata.title;
const nonEmptyLines = lines.filter(line => line.trim() !== "").map(line => line.trim());
const isIndexCardFormat = documentTitle &&
nonEmptyLines.length === 2 &&
nonEmptyLines[0].startsWith("=") &&
nonEmptyLines[1].toLowerCase() === "index card";
if (isIndexCardFormat) {
return { valid: true };
}
// Check that we have a document title
const documentTitle = extractAsciiDocDocumentHeader(content);
if (!documentTitle) {
return {
valid: false,
@ -114,6 +120,41 @@ export function validate30040EventSet(content: string): { @@ -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 };
}
@ -141,14 +182,6 @@ export function titleToDTag(title: string): string { @@ -141,14 +182,6 @@ export function titleToDTag(title: string): string {
.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 '# ').
*/
@ -157,71 +190,6 @@ function extractMarkdownTopHeader(content: string): string | null { @@ -157,71 +190,6 @@ function extractMarkdownTopHeader(content: string): string | 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
// =========================
@ -251,44 +219,90 @@ export function build30040EventSet( @@ -251,44 +219,90 @@ export function build30040EventSet(
const ndk = getNdk();
console.log("NDK instance:", ndk);
const sectionsResult = splitAsciiDocSections(content);
const sections = sectionsResult.sections;
const sectionHeaders = sectionsResult.sectionHeaders;
console.log("Sections:", sections);
console.log("Section headers:", sectionHeaders);
const dTags =
sectionHeaders.length === sections.length
? sectionHeaders.map(normalizeDTagValue)
: sections.map((_, i) => `section${i}`);
console.log("D tags:", dTags);
const sectionEvents: NDKEvent[] = sections.map((section, i) => {
const header = sectionHeaders[i] || `Section ${i + 1}`;
const dTag = dTags[i];
console.log(`Creating section ${i}:`, { header, dTag, content: section });
// Parse the AsciiDoc content with metadata extraction
const parsed = parseAsciiDocWithMetadata(content);
console.log("Parsed AsciiDoc:", parsed);
// Check if this is an "index card" format (no sections, just title + "index card")
const lines = content.split(/\r?\n/);
const documentTitle = parsed.metadata.title;
// For index card format, the content should be exactly: title + "index card"
const nonEmptyLines = lines.filter(line => line.trim() !== "").map(line => line.trim());
const isIndexCardFormat = documentTitle &&
nonEmptyLines.length === 2 &&
nonEmptyLines[0].startsWith("=") &&
nonEmptyLines[1].toLowerCase() === "index card";
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, {
kind: 30041,
content: section,
tags: [...tags, ["d", dTag], ["title", header]],
content: section.content,
tags: [
...tags,
...sectionMetadataTags,
["d", sectionDTag],
["title", section.title]
],
pubkey: baseEvent.pubkey,
created_at: baseEvent.created_at,
});
});
// Create proper a tags with format: kind:pubkey:d-tag
const aTags = dTags.map(
(dTag) => ["a", `30041:${baseEvent.pubkey}:${dTag}`] as [string, string],
);
const aTags = sectionEvents.map(event => {
const dTag = event.tags.find(([k]) => k === "d")?.[1];
return ["a", `30041:${baseEvent.pubkey}:${dTag}`] as [string, string];
});
console.log("A tags:", aTags);
// Extract document title for the index event
const documentTitle = extractAsciiDocDocumentHeader(content);
const indexDTag = documentTitle ? normalizeDTagValue(documentTitle) : "index";
console.log("Index event:", { documentTitle, indexDTag });
// Convert document metadata to tags
const metadataTags = metadataToTags(parsed.metadata);
const indexTags = [
...tags,
...metadataTags,
["d", indexDTag],
["title", documentTitle || "Untitled"],
...aTags,
@ -316,7 +330,8 @@ export function getTitleTagForEvent( @@ -316,7 +330,8 @@ export function getTitleTagForEvent(
content: string,
): string | null {
if (kind === 30041 || kind === 30818) {
return extractAsciiDocDocumentHeader(content);
const { metadata } = extractDocumentMetadata(content);
return metadata.title || null;
}
if (kind === 30023) {
return extractMarkdownTopHeader(content);
@ -345,8 +360,8 @@ export function getDTagForEvent( @@ -345,8 +360,8 @@ export function getDTagForEvent(
}
if (kind === 30041 || kind === 30818) {
const title = extractAsciiDocDocumentHeader(content);
return title ? normalizeDTagValue(title) : null;
const { metadata } = extractDocumentMetadata(content);
return metadata.title ? normalizeDTagValue(metadata.title) : null;
}
return null;
@ -356,13 +371,59 @@ export function getDTagForEvent( @@ -356,13 +371,59 @@ export function getDTagForEvent(
* Returns a description of what a 30040 event structure should be.
*/
export function get30040EventDescription(): string {
return `30040 events are publication indexes that contain:
- Empty content (metadata only)
- A d-tag for the publication identifier
- A title tag for the publication title
- A tags referencing 30041 content events (one per section)
return `30040 events are publication indexes that organize AsciiDoc content into structured publications.
**Supported Structures:**
1. **Normal Document** (with sections):
= Document Title
:author: Author Name
:summary: Document description
:keywords: tag1, tag2, tag3
== Section 1
Section content here...
The content is split into sections, each published as a separate 30041 event.`;
== Section 2
More content...
2. **Index Card** (empty publication):
= Publication Title
index card
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: { @@ -422,16 +483,31 @@ export function analyze30040Event(event: {
export function get30040FixGuidance(): string {
return `To fix a 30040 event:
1. **Content Issue**: 30040 events should have empty content. All content should be split into separate 30041 events.
2. **Structure**: A proper 30040 event should contain:
- Empty content
- d tag: publication identifier
- title tag: publication title
- a tags: references to 30041 content events (format: "30041:pubkey:d-tag")
3. **Process**: When creating a 30040 event:
- Write your content with document title (= Title) and sections (== Section)
- The system will automatically split it into one 30040 index event and multiple 30041 content events
- The 30040 will have empty content and reference the 30041s via a tags`;
1. **Content Structure**: Ensure your AsciiDoc starts with a document title (= Title)
- Add at least one section (== Section) for normal documents
- Use "index card" format for empty publications
- Include metadata in header lines or attributes,
or add them manually to the tag list
2. **Metadata**: Add relevant metadata to improve discoverability:
- Author: Use header line or :author: attribute
- Summary: Use :summary: or :description: attribute
- Keywords: Use :keywords: or :tags: attribute
- Version: Use revision line or :version: attribute
- 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( @@ -32,9 +32,9 @@ export async function postProcessAdvancedAsciidoctorHtml(
}
if (
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;
} catch (error) {

2
src/lib/utils/network_detection.ts

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

446
tests/unit/eventInput30040.test.ts

@ -0,0 +1,446 @@ @@ -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 @@ @@ -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