diff --git a/src/lib/components/ZettelEditor.svelte b/src/lib/components/ZettelEditor.svelte
index c28be59..5bac793 100644
--- a/src/lib/components/ZettelEditor.svelte
+++ b/src/lib/components/ZettelEditor.svelte
@@ -4,6 +4,9 @@
import {
extractSmartMetadata,
parseAsciiDocWithMetadata,
+ parseAsciiDocIterative,
+ generateNostrEvents,
+ detectContentType,
type AsciiDocMetadata,
metadataToTags,
} from "$lib/utils/asciidoc_metadata";
@@ -12,66 +15,98 @@ import asciidoctor from "asciidoctor";
// Component props
let {
content = "",
- placeholder = `// PUBLISHING MODES:
-// Use "= Article Title" to publish an article/collection of notes
-// Use only "== Note Title" headings for scattered notes (30041 events only)
+ placeholder = `// ITERATIVE PARSING - Choose your publishing level:
+// Level 2: Only == sections become events (containing === and deeper)
+// Level 3: == sections become indices, === sections become events
+// Level 4: === sections become indices, ==== sections become events
-= Article Title (Full Collection)
-:author: Your Name
-:version: 1.0
-:published_on: 2024-01-01
-:published_by: Alexandria
-:summary: A brief description of this article
-:tags: article, example, metadata
-:image: https://example.com/image.jpg
-:type: book
+= Understanding Knowledge
+:image: https://i.nostr.build/IUs0xNyUEf5hXTFL.jpg
+:published: 2025-04-21
+:tags: knowledge, philosophy, education
+:type: text
-== Chapter 1: Introduction
-:author: Your Name
-:keywords: introduction, chapter, example
-:description: This is the first chapter of the article
+== Preface
+:tags: introduction, preface
-Chapter content here...
+This essay outlines the purpose of Alexandria...
+== Introduction: Knowledge as a Living Ecosystem
+:tags: introduction, ecosystem
-== Note Title 2
-Some Other Author (this works even if there is no :author: attribute)
-:keywords: second, note, example (keywords are converted to tags)
-:description: This is a description of the note (description is converted to a summary tag)
-Note content here...
+Knowledge exists as dynamic representations...
+
+=== Why Investigate the Nature of Knowledge?
+:difficulty: intermediate
+
+Understanding the nature of knowledge itself...
+
+==== The Four Perspectives
+:complexity: high
+
+1. Material Cause: The building blocks...
`,
showPreview = false,
+ parseLevel = 2,
onContentChange = (content: string) => {},
onPreviewToggle = (show: boolean) => {},
+ onPublishArticle = (events: any) => {},
+ onPublishScatteredNotes = (events: any) => {},
} = $props<{
content?: string;
placeholder?: string;
showPreview?: boolean;
+ parseLevel?: number;
onContentChange?: (content: string) => void;
onPreviewToggle?: (show: boolean) => void;
+ onPublishArticle?: (events: any) => void;
+ onPublishScatteredNotes?: (events: any) => void;
}>();
- // Parse sections for preview using the smart metadata service
- let parsedSections = $derived.by(() => {
- if (!content.trim()) return [];
+ // Parse content using iterative parsing
+ let parsedContent = $derived.by(() => {
+ if (!content.trim()) return null;
- // Use smart metadata extraction that handles both document headers and section-only content
- const { metadata: docMetadata } = extractSmartMetadata(content);
-
- // Parse the content using the standardized parser
- const parsed = parseAsciiDocWithMetadata(content);
+ try {
+ // Use iterative parsing with selected level
+ const parsed = parseAsciiDocIterative(content, parseLevel);
+
+ // Debug logging
+ console.log("Iterative parsed content:", parsed);
+
+ return parsed;
+ } catch (error) {
+ console.error("Parsing error:", error);
+ return null;
+ }
+ });
+
+ // Generate events from parsed content
+ let generatedEvents = $derived.by(() => {
+ if (!parsedContent) return null;
- // Debug logging
- console.log("Parsed sections:", parsed.sections);
+ try {
+ const events = generateNostrEvents(parsedContent, parseLevel);
+ console.log("Generated events:", events);
+ return events;
+ } catch (error) {
+ console.error("Event generation error:", error);
+ return null;
+ }
+ });
+
+ // Detect content type for smart publishing
+ let contentType = $derived.by(() => {
+ return detectContentType(content);
+ });
+
+ // Parse sections for preview display
+ let parsedSections = $derived.by(() => {
+ if (!parsedContent) return [];
- return parsed.sections.map((section: { metadata: AsciiDocMetadata; content: string; title: string }) => {
- // Use only section metadata for each section
- // Don't combine with document metadata to avoid overriding section-specific metadata
+ return parsedContent.sections.map((section: { metadata: AsciiDocMetadata; content: string; title: string }) => {
const tags = metadataToTags(section.metadata);
- // Debug logging
- console.log(`Section "${section.title}":`, { metadata: section.metadata, tags });
-
return {
title: section.title || "Untitled",
content: section.content.trim(),
@@ -80,23 +115,21 @@ Note content here...
});
});
- // Check for 30040-style document headers (publication format)
- let hasPublicationHeader = $derived.by(() => {
- if (!content.trim()) return false;
+ // Publishing handlers
+ function handlePublish() {
+ if (!generatedEvents) return;
- const lines = content.split(/\r?\n/);
- for (const line of lines) {
- // Check for document title (level 0 header)
- if (line.match(/^=\s+(.+)$/)) {
- return true;
- }
- // Check for "index card" format (case insensitive)
- if (line.trim().toLowerCase() === 'index card') {
- return true;
- }
+ if (contentType === 'article' && generatedEvents.indexEvent) {
+ // Full article: publish both index event (30040) and content events (30041)
+ onPublishArticle(generatedEvents);
+ } else if (contentType === 'scattered-notes') {
+ // Only notes: publish just the content events (30041)
+ const notesOnly = {
+ contentEvents: generatedEvents.contentEvents
+ };
+ onPublishScatteredNotes(notesOnly);
}
- return false;
- });
+ }
// Toggle preview panel
function togglePreview() {
@@ -112,87 +145,72 @@ Note content here...
-
- {#if hasPublicationHeader}
-
-
-
-
-
- Publication Format Detected
-
-
- You're using a publication format (document title with = or "index card").
- This editor is for individual notes only. Use the
- Events form
- to create structured publications.
-
-
-
{
- // Store the content in sessionStorage so it can be loaded in the Events form
- sessionStorage.setItem('zettelEditorContent', content);
- sessionStorage.setItem('zettelEditorSource', 'publication-format');
- }}
- class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-red-700 dark:text-red-300 bg-red-100 dark:bg-red-800 border border-red-200 dark:border-red-700 rounded-md hover:bg-red-200 dark:hover:bg-red-700 transition-colors"
- >
- Switch to Publication Editor
-
-
{
- // Remove publication format by converting document title to section title
- let convertedContent = content.replace(/^=\s+(.+)$/gm, '== $1');
- // Remove "index card" line (case insensitive)
- convertedContent = convertedContent.replace(/^index card$/gim, '');
- // Clean up any double newlines that might result
- const finalContent = convertedContent.replace(/\n\s*\n\s*\n/g, '\n\n');
- // Update content through the prop callback
- onContentChange(finalContent);
- }}
- class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-red-700 dark:text-red-300 bg-red-100 dark:bg-red-800 border border-red-200 dark:border-red-700 rounded-md hover:bg-red-200 dark:hover:bg-red-700 transition-colors"
+
+
+
+
+
+ Unified AsciiDoc Publisher
+
+
+
+ Parse Level:
+
- Convert to Notes Format
-
+ Level 2 (== sections → events)
+ Level 3 (== → indices, === → events)
+ Level 4 (=== → indices, ==== → events)
+
+
+
+ Content Type:
+
+ {contentType === 'article' ? 'Article' : contentType === 'scattered-notes' ? 'Notes' : 'None'}
+
+
+
+ {#if generatedEvents}
+
+ Events:
+ {generatedEvents.contentEvents.length + (generatedEvents.indexEvent ? 1 : 0)}
+
+ {/if}
-
-
- {:else}
-
-
-
-
-
-
- Note-Taking Tool
-
-
- This editor is for creating individual notes (30041 events) only. Each section becomes a separate note event.
- You can add metadata like author, version, publication date, summary, and tags using AsciiDoc attributes.
- To create structured publications with a 30040 index event that ties multiple notes together,
- use the Events form .
-
-
- {/if}
+
{#if showPreview}
@@ -220,15 +237,14 @@ Note content here...
bind:value={content}
on:input={handleContentChange}
{placeholder}
- class="h-full min-h-64 resize-none {hasPublicationHeader ? 'opacity-50 cursor-not-allowed' : ''}"
+ class="h-full min-h-64 resize-none"
rows={12}
- disabled={hasPublicationHeader}
/>
- {#if showPreview && !hasPublicationHeader}
+ {#if showPreview}
void;
+ },
+): Promise {
+ const { content, kind, tags, onError } = options;
+
+ const ndk = get(ndkInstance);
+ if (!ndk?.activeUser) {
+ const error = 'Please log in first';
+ onError?.(error);
+ return { success: false, error };
+ }
+
+ try {
+ const allRelayUrls = Array.from(ndk.pool?.relays.values() || []).map((r) => r.url);
+ if (allRelayUrls.length === 0) {
+ throw new Error('No relays available in NDK pool');
+ }
+ const relaySet = NDKRelaySet.fromRelayUrls(allRelayUrls, ndk);
+
+ // Fix a-tags that have placeholder "pubkey" with actual pubkey
+ const fixedTags = tags.map(tag => {
+ if (tag[0] === 'a' && tag[1] && tag[1].includes(':pubkey:') && ndk.activeUser) {
+ // Replace "pubkey" placeholder with actual pubkey
+ const fixedATag = tag[1].replace(':pubkey:', `:${ndk.activeUser.pubkey}:`);
+ return [tag[0], fixedATag, tag[2] || '', tag[3] || ''];
+ }
+ return tag;
+ });
+
+ // Create and sign NDK event
+ const ndkEvent = new NDKEvent(ndk);
+ ndkEvent.kind = kind;
+ ndkEvent.created_at = Math.floor(Date.now() / 1000);
+ ndkEvent.tags = fixedTags;
+ ndkEvent.content = content;
+ ndkEvent.pubkey = ndk.activeUser.pubkey;
+
+ await ndkEvent.sign();
+
+ // Publish to relays
+ const publishedToRelays = await ndkEvent.publish(relaySet);
+
+ if (publishedToRelays.size > 0) {
+ // Debug: Log the event structure in a clean, concise format
+ const dTagEntry = tags.find(t => t[0] === 'd');
+ const dTag = dTagEntry ? dTagEntry[1] : '';
+ const titleTag = tags.find(t => t[0] === 'title');
+ const title = titleTag ? titleTag[1] : 'Untitled';
+
+ console.log(`Event verified: ${ndkEvent.id}`);
+
+ return { success: true, eventId: ndkEvent.id };
+ } else {
+ const titleTag = tags.find(t => t[0] === 'title');
+ const title = titleTag ? titleTag[1] : 'Untitled';
+ console.error(`Failed to publish event: ${title} (${kind}) - no relays responded`);
+ throw new Error('Failed to publish to any relays');
+ }
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
+ console.error(`Error publishing event: ${errorMessage}`);
+ onError?.(errorMessage);
+ return { success: false, error: errorMessage };
+ }
+}
+
/**
* Publishes all AsciiDoc sections as separate Nostr events
* @param options - Publishing options
diff --git a/src/lib/utils/asciidoc_metadata.ts b/src/lib/utils/asciidoc_metadata.ts
index 6d6754c..59a5dfd 100644
--- a/src/lib/utils/asciidoc_metadata.ts
+++ b/src/lib/utils/asciidoc_metadata.ts
@@ -24,6 +24,7 @@ export interface AsciiDocMetadata {
publishedBy?: string;
type?: string;
autoUpdate?: 'yes' | 'ask' | 'no';
+ customAttributes?: Record;
}
export type SectionMetadata = AsciiDocMetadata;
@@ -31,6 +32,7 @@ export type SectionMetadata = AsciiDocMetadata;
export interface ParsedAsciiDoc {
metadata: AsciiDocMetadata;
content: string;
+ title: string;
sections: Array<{
metadata: SectionMetadata;
content: string;
@@ -96,6 +98,20 @@ function extractTagsFromAttributes(attributes: Record): string[] {
* Maps attributes to metadata with special handling for authors and tags
*/
function mapAttributesToMetadata(attributes: Record, metadata: AsciiDocMetadata, isDocument: boolean = false): void {
+ // List of AsciiDoc system attributes to ignore
+ const systemAttributes = [
+ 'attribute-undefined', 'attribute-missing', 'appendix-caption', 'appendix-refsig',
+ 'caution-caption', 'chapter-refsig', 'example-caption', 'figure-caption',
+ 'important-caption', 'last-update-label', 'note-caption', 'part-refsig',
+ 'section-refsig', 'table-caption', 'tip-caption', 'toc-placement',
+ 'toc-title', 'untitled-label', 'warning-caption', 'asciidoctor-version',
+ 'safe-mode-name', 'backend', 'user-home', 'doctype', 'htmlsyntax',
+ 'outfilesuffix', 'filetype', 'basebackend', 'stylesdir', 'iconsdir',
+ 'localdate', 'localyear', 'localtime', 'localdatetime', 'docdate',
+ 'docyear', 'doctime', 'docdatetime', 'doctitle', 'language',
+ 'firstname', 'authorinitials', 'authors'
+ ];
+
for (const [key, value] of Object.entries(attributes)) {
const metadataKey = ATTRIBUTE_MAP[key.toLowerCase()];
if (metadataKey && value && typeof value === 'string') {
@@ -114,6 +130,12 @@ function mapAttributesToMetadata(attributes: Record, metadata: Asci
} else {
(metadata as any)[metadataKey] = value;
}
+ } else if (value && typeof value === 'string' && !systemAttributes.includes(key)) {
+ // Handle unknown/custom attributes - but only if they're not system attributes
+ if (!metadata.customAttributes) {
+ metadata.customAttributes = {};
+ }
+ metadata.customAttributes[key] = value;
}
}
}
@@ -139,13 +161,20 @@ function extractAuthorsFromHeader(sourceContent: string, isSection: boolean = fa
break;
}
+ // Skip section headers at any level (they start with ==, ===, etc.)
+ if (authorLine.match(/^==+\s+/)) {
+ // This is a section header, stop looking for authors
+ break;
+ }
+
if (authorLine.includes('<') && !authorLine.startsWith(':')) {
// This is an author line like "John Doe "
const authorName = authorLine.split('<')[0].trim();
if (authorName) {
authors.push(authorName);
}
- } else if (isSection && authorLine.match(/^[A-Za-z\s]+$/) && authorLine.trim() !== '' && authorLine.trim().split(/\s+/).length <= 2) {
+ } else if (isSection && authorLine.match(/^[A-Za-z\s]+$/) && authorLine.trim() !== '' &&
+ authorLine.trim().split(/\s+/).length <= 2) {
// This is a simple author name without email (for sections)
authors.push(authorLine.trim());
} else if (authorLine.startsWith(':')) {
@@ -173,6 +202,7 @@ function stripHeaderAndAttributes(content: string, isSection: boolean = false):
let contentStart = 0;
const headerPattern = isSection ? /^==\s+/ : /^=\s+/;
+ // Find the first line that is actual content (not header, author, or attribute)
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Skip title line, author line, revision line, and attribute lines
@@ -197,26 +227,63 @@ function stripHeaderAndAttributes(content: string, isSection: boolean = false):
return true;
});
+ // Ensure deeper headers (====) have proper newlines around them
+ const processedLines = [];
+ for (let i = 0; i < filteredLines.length; i++) {
+ const line = filteredLines[i];
+ const prevLine = i > 0 ? filteredLines[i - 1] : '';
+ const nextLine = i < filteredLines.length - 1 ? filteredLines[i + 1] : '';
+
+ // If this is a deeper header (====+), ensure it has newlines around it
+ if (line.match(/^====+\s+/)) {
+ // Add newline before if previous line isn't blank
+ if (prevLine && prevLine.trim() !== '') {
+ processedLines.push('');
+ }
+ processedLines.push(line);
+ // Add newline after if next line isn't blank and exists
+ if (nextLine && nextLine.trim() !== '') {
+ processedLines.push('');
+ }
+ } else {
+ processedLines.push(line);
+ }
+ }
+
// Remove extra blank lines and normalize newlines
- return filteredLines.join('\n').replace(/\n\s*\n\s*\n/g, '\n\n').replace(/\n\s*\n/g, '\n').trim();
+ return processedLines.join('\n').replace(/\n\s*\n\s*\n/g, '\n\n').trim();
}
/**
- * Parses attributes from section content
+ * Parses attributes from section content using simple regex
+ * Converts :tagname: tagvalue -> [tagname, tagvalue]
+ * Converts :tags: comma,separated -> [t, tag1], [t, tag2], etc.
*/
-function parseSectionAttributes(sectionContent: string): Record {
- const attributes: Record = {};
- const lines = sectionContent.split(/\r?\n/);
+function parseSimpleAttributes(content: string): [string, string][] {
+ const tags: [string, string][] = [];
+ const lines = content.split(/\r?\n/);
for (const line of lines) {
const match = line.match(/^:([^:]+):\s*(.+)$/);
if (match) {
const [, key, value] = match;
- attributes[key.trim()] = value.trim();
+ const tagName = key.trim();
+ const tagValue = value.trim();
+
+ if (tagName === 'tags') {
+ // Special handling for :tags: - split into individual t-tags
+ const tags_list = tagValue.split(',').map(t => t.trim()).filter(t => t.length > 0);
+ tags_list.forEach(tag => {
+ tags.push(['t', tag]);
+ });
+ } else {
+ // Regular attribute -> [tagname, tagvalue]
+ tags.push([tagName, tagValue]);
+ }
}
}
- return attributes;
+ return tags;
}
@@ -253,15 +320,21 @@ export function extractDocumentMetadata(inputContent: string): {
metadata.authors = [...new Set(authors)]; // Remove duplicates
}
- // Extract revision info
+ // Extract revision info (only if it looks like valid revision data)
const revisionNumber = document.getRevisionNumber();
- if (revisionNumber) metadata.version = revisionNumber;
+ if (revisionNumber && revisionNumber !== 'Version' && !revisionNumber.includes('==')) {
+ metadata.version = revisionNumber;
+ }
const revisionRemark = document.getRevisionRemark();
- if (revisionRemark) metadata.publishedBy = revisionRemark;
+ if (revisionRemark && !revisionRemark.includes('[NOTE]') && !revisionRemark.includes('==')) {
+ metadata.publishedBy = revisionRemark;
+ }
const revisionDate = document.getRevisionDate();
- if (revisionDate) metadata.publicationDate = revisionDate;
+ if (revisionDate && !revisionDate.includes('[NOTE]') && !revisionDate.includes('==')) {
+ metadata.publicationDate = revisionDate;
+ }
// Map attributes to metadata (but skip version and publishedBy if we already have them from revision)
mapAttributesToMetadata(attributes, metadata, true);
@@ -306,8 +379,8 @@ export function extractSectionMetadata(inputSectionContent: string): {
const title = section.getTitle() || '';
const metadata: SectionMetadata = { title };
- // Parse attributes from the section content
- const attributes = parseSectionAttributes(inputSectionContent);
+ // Parse attributes from the section content (no longer used - we use simple parsing in generateNostrEvents)
+ const attributes = {};
// Extract authors from section content
const authors = extractAuthorsFromHeader(inputSectionContent, true);
@@ -371,6 +444,7 @@ export function parseAsciiDocWithMetadata(content: string): ParsedAsciiDoc {
return {
metadata: docMetadata,
content: document.getSource(),
+ title: docMetadata.title || '',
sections: sectionsWithMetadata
};
}
@@ -399,6 +473,28 @@ export function metadataToTags(metadata: AsciiDocMetadata | SectionMetadata): [s
metadata.tags.forEach(tag => tags.push(['t', tag]));
}
+ // Add custom attributes as tags, but filter out system attributes
+ if (metadata.customAttributes) {
+ const systemAttributes = [
+ 'attribute-undefined', 'attribute-missing', 'appendix-caption', 'appendix-refsig',
+ 'caution-caption', 'chapter-refsig', 'example-caption', 'figure-caption',
+ 'important-caption', 'last-update-label', 'note-caption', 'part-refsig',
+ 'section-refsig', 'table-caption', 'tip-caption', 'toc-placement',
+ 'toc-title', 'untitled-label', 'warning-caption', 'asciidoctor-version',
+ 'safe-mode-name', 'backend', 'user-home', 'doctype', 'htmlsyntax',
+ 'outfilesuffix', 'filetype', 'basebackend', 'stylesdir', 'iconsdir',
+ 'localdate', 'localyear', 'localtime', 'localdatetime', 'docdate',
+ 'docyear', 'doctime', 'docdatetime', 'doctitle', 'language',
+ 'firstname', 'authorinitials', 'authors'
+ ];
+
+ Object.entries(metadata.customAttributes).forEach(([key, value]) => {
+ if (!systemAttributes.includes(key)) {
+ tags.push([key, value]);
+ }
+ });
+ }
+
return tags;
}
@@ -461,6 +557,172 @@ export function extractMetadataFromSectionsOnly(content: string): {
return { metadata, content };
}
+/**
+ * Iterative AsciiDoc parsing based on specified level
+ * Level 2: Only == sections become events (containing all subsections)
+ * Level 3: == sections become indices, === sections become events
+ * Level 4: === sections become indices, ==== sections become events, etc.
+ */
+export function parseAsciiDocIterative(content: string, parseLevel: number = 2): ParsedAsciiDoc {
+ const asciidoctor = createProcessor();
+ const document = asciidoctor.load(content, { standalone: false }) as Document;
+ const { metadata: docMetadata } = extractDocumentMetadata(content);
+
+ const lines = content.split(/\r?\n/);
+ const targetHeaderPattern = new RegExp(`^${'='.repeat(parseLevel)}\\s+`);
+ const sections: Array<{
+ metadata: SectionMetadata;
+ content: string;
+ title: string;
+ }> = [];
+
+ let currentSection: string | null = null;
+ let currentSectionContent: string[] = [];
+ let documentContent: string[] = [];
+ let inDocumentHeader = true;
+
+ for (const line of lines) {
+ // Check if we've hit the first section at our target level
+ if (line.match(targetHeaderPattern)) {
+ inDocumentHeader = false;
+
+ // Save previous section if exists
+ if (currentSection) {
+ const sectionContent = currentSectionContent.join('\n');
+ sections.push(extractSectionMetadata(sectionContent));
+ }
+
+ // Start new section
+ currentSection = line;
+ currentSectionContent = [line];
+ } else if (currentSection) {
+ // We're in a section - add content
+ currentSectionContent.push(line);
+ } else if (inDocumentHeader) {
+ // We're still in document content (before first section)
+ documentContent.push(line);
+ }
+ }
+
+ // Save the last section
+ if (currentSection) {
+ const sectionContent = currentSectionContent.join('\n');
+ sections.push(extractSectionMetadata(sectionContent));
+ }
+
+ // Extract document content (everything before first section at target level)
+ // Keep the original content with attributes for simple parsing
+ const docContent = documentContent.join('\n');
+
+ return {
+ metadata: docMetadata,
+ content: docContent,
+ title: docMetadata.title || '',
+ sections: sections
+ };
+}
+
+/**
+ * Generates Nostr events from parsed AsciiDoc
+ * Based on docreference.md specifications
+ */
+export function generateNostrEvents(parsed: ParsedAsciiDoc, parseLevel: number = 2, pubkey?: string): {
+ indexEvent?: any;
+ contentEvents: any[];
+} {
+ const events: any[] = [];
+
+ // Create content events for each section (30041)
+ const contentEvents = parsed.sections.map(section => {
+ const sectionId = section.title
+ .toLowerCase()
+ .replace(/[^a-z0-9\s]/g, '')
+ .replace(/\s+/g, '-')
+ .trim();
+
+ // Extract tags directly from section content using simple regex
+ const sectionTags = parseSimpleAttributes(section.content);
+
+ return {
+ id: '', // Will be generated by Nostr client
+ pubkey: '', // Will be set by client
+ created_at: Math.floor(Date.now() / 1000),
+ kind: 30041,
+ tags: [
+ ['d', sectionId],
+ ['title', section.title],
+ ...sectionTags
+ ],
+ content: section.content,
+ sig: '' // Will be generated by client
+ };
+ });
+
+ // Only create index event if we have a document title (article format)
+ if (parsed.title && parsed.title.trim() !== '') {
+ // Generate document identifier from title
+ const documentId = parsed.title
+ .toLowerCase()
+ .replace(/[^a-z0-9\s]/g, '')
+ .replace(/\s+/g, '-')
+ .trim();
+
+ // Extract tags directly from document content using simple regex
+ const documentTags = parseSimpleAttributes(parsed.content);
+
+ // Create main index event (30040)
+ const indexEvent = {
+ id: '', // Will be generated by Nostr client
+ pubkey: '', // Will be set by client
+ created_at: Math.floor(Date.now() / 1000),
+ kind: 30040,
+ tags: [
+ ['d', documentId],
+ ['title', parsed.title],
+ ...documentTags,
+ // Add a-tags for each section
+ ...parsed.sections.map(section => {
+ const sectionId = section.title
+ .toLowerCase()
+ .replace(/[^a-z0-9\s]/g, '')
+ .replace(/\s+/g, '-')
+ .trim();
+ const actualPubkey = pubkey || 'pubkey'; // Use actual pubkey if provided, fallback for compatibility
+ return ['a', `30041:${actualPubkey}:${sectionId}`, '', '']; // relay will be filled by client
+ })
+ ],
+ content: '', // Index events have empty content
+ sig: '' // Will be generated by client
+ };
+
+ return {
+ indexEvent,
+ contentEvents
+ };
+ }
+
+ // For scattered notes, return only content events
+ return {
+ contentEvents
+ };
+}
+
+/**
+ * Detects content type for smart publishing
+ */
+export function detectContentType(content: string): 'article' | 'scattered-notes' | 'none' {
+ const hasDocTitle = content.trim().startsWith('=') && !content.trim().startsWith('==');
+ const hasSections = content.includes('==');
+
+ if (hasDocTitle) {
+ return 'article';
+ } else if (hasSections) {
+ return 'scattered-notes';
+ } else {
+ return 'none';
+ }
+}
+
/**
* Smart metadata extraction that handles both document headers and section-only content
*/
diff --git a/src/routes/new/compose/+page.svelte b/src/routes/new/compose/+page.svelte
index a62ffe8..23a029c 100644
--- a/src/routes/new/compose/+page.svelte
+++ b/src/routes/new/compose/+page.svelte
@@ -4,8 +4,7 @@
import ZettelEditor from "$lib/components/ZettelEditor.svelte";
import { goto } from "$app/navigation";
import { nip19 } from "nostr-tools";
- import { publishMultipleZettels } from "$lib/services/publisher";
- import { parseAsciiDocWithMetadata } from "$lib/utils/asciidoc_metadata";
+ import { publishSingleEvent } from "$lib/services/publisher";
let content = $state("");
let showPreview = $state(false);
@@ -28,47 +27,211 @@
showPreview = show;
}
- async function handlePublish() {
+ // Handle unified publishing from ZettelEditor
+ async function handlePublishArticle(events: any) {
isPublishing = true;
publishResults = null;
- const results = await publishMultipleZettels({
- content,
- onError: (error) => {
- // Only used for catastrophic errors
- publishResults = { successCount: 0, total: 0, errors: [error], successfulEvents: [], failedEvents: [] };
- },
- });
+ // Debug: Log the structure of events being published (without content)
+ console.log('=== PUBLISHING ARTICLE ===');
+ if (events.indexEvent) {
+ console.log('Creating root index event...');
+ }
+ console.log(`Number of content events: ${events.contentEvents.length}`);
- const successCount = results.filter(r => r.success).length;
- const errors = results.filter(r => !r.success && r.error).map(r => r.error!);
-
- // Extract successful events with their titles
- const parsed = parseAsciiDocWithMetadata(content);
- const successfulEvents = results
- .filter(r => r.success && r.eventId)
- .map((r, index) => ({
- eventId: r.eventId!,
- title: parsed.sections[index]?.title || `Note ${index + 1}`
- }));
+ // Debug: Log the first content event to see its structure
+ if (events.contentEvents.length > 0) {
+ console.log('First content event structure:', {
+ kind: events.contentEvents[0].kind,
+ tags: events.contentEvents[0].tags,
+ contentLength: events.contentEvents[0].content.length,
+ contentPreview: events.contentEvents[0].content.substring(0, 100)
+ });
+ }
+
+ try {
+ const results: any[] = [];
+
+ // Publish index event first
+ if (events.indexEvent) {
+ const indexResult = await publishSingleEvent({
+ content: events.indexEvent.content,
+ kind: events.indexEvent.kind,
+ tags: events.indexEvent.tags,
+ onError: (error) => {
+ console.error('Index event publish failed:', error);
+ },
+ });
+ results.push(indexResult);
+ }
+
+ // Publish content events
+ for (let i = 0; i < events.contentEvents.length; i++) {
+ const event = events.contentEvents[i];
+ console.log(`Publishing content event ${i + 1}: ${event.tags.find((t: any) => t[0] === 'title')?.[1] || 'Untitled'}`);
+ const result = await publishSingleEvent({
+ content: event.content,
+ kind: event.kind,
+ tags: event.tags,
+ onError: (error) => {
+ console.error(`Content event ${i + 1} publish failed:`, error);
+ },
+ });
+ results.push(result);
+ }
+
+ // Process results
+ const successCount = results.filter(r => r.success).length;
+ const errors = results.filter(r => !r.success && r.error).map(r => r.error!);
+
+ // Extract successful events with their titles
+ const successfulEvents = results
+ .filter(r => r.success && r.eventId)
+ .map((r, index) => ({
+ eventId: r.eventId!,
+ title: index === 0 && events.indexEvent ? 'Article Index' : events.contentEvents[index - (events.indexEvent ? 1 : 0)]?.title || `Note ${index}`
+ }));
+
+ // Extract failed events with their titles and errors
+ const failedEvents = results
+ .map((r, index) => ({ result: r, index }))
+ .filter(({ result }) => !result.success)
+ .map(({ result, index }) => ({
+ title: index === 0 && events.indexEvent ? 'Article Index' : events.contentEvents[index - (events.indexEvent ? 1 : 0)]?.title || `Note ${index}`,
+ error: result.error || 'Unknown error',
+ sectionIndex: index
+ }));
+
+ publishResults = {
+ successCount,
+ total: results.length,
+ errors,
+ successfulEvents,
+ failedEvents,
+ };
+
+ // Show summary
+ console.log('\n=== Events Summary ===');
+ if (events.indexEvent) {
+ console.log('\nRoot Index:');
+ console.log(`Event Summary:`);
+ console.log(` ID: ${successfulEvents[0]?.eventId || 'Failed'}`);
+ console.log(` Kind: 30040`);
+ console.log(` Tags:`);
+ events.indexEvent.tags.forEach((tag: string[]) => {
+ console.log(` - ${JSON.stringify(tag)}`);
+ });
+ console.log(' ---');
+ }
+
+ console.log('\nContent:');
+ events.contentEvents.forEach((event: any, index: number) => {
+ const eventId = successfulEvents.find(e => e.title === event.title)?.eventId || 'Failed';
+ console.log(`\nEvent Summary:`);
+ console.log(` ID: ${eventId}`);
+ console.log(` Kind: 30041`);
+ console.log(` Tags:`);
+ event.tags.forEach((tag: any) => {
+ console.log(` - ${JSON.stringify(tag)}`);
+ });
+ console.log(` Content preview: ${event.content.substring(0, 100)}...`);
+ console.log(' ---');
+ });
+ } catch (error) {
+ console.error('Publishing failed:', error);
+ publishResults = {
+ successCount: 0,
+ total: 0,
+ errors: [error instanceof Error ? error.message : 'Unknown error'],
+ successfulEvents: [],
+ failedEvents: []
+ };
+ }
- // Extract failed events with their titles and errors
- const failedEvents = results
- .map((r, index) => ({ result: r, index }))
- .filter(({ result }) => !result.success)
- .map(({ result, index }) => ({
- title: parsed.sections[index]?.title || `Note ${index + 1}`,
- error: result.error || 'Unknown error',
- sectionIndex: index
- }));
+ isPublishing = false;
+ }
+
+ async function handlePublishScatteredNotes(events: any) {
+ isPublishing = true;
+ publishResults = null;
+
+ // Debug: Log the structure of events being published (without content)
+ console.log('=== PUBLISHING SCATTERED NOTES ===');
+ console.log(`Number of content events: ${events.contentEvents.length}`);
+
+ try {
+ const results: any[] = [];
+
+ // Publish only content events for scattered notes
+ for (let i = 0; i < events.contentEvents.length; i++) {
+ const event = events.contentEvents[i];
+ const result = await publishSingleEvent({
+ content: event.content,
+ kind: event.kind,
+ tags: event.tags,
+ onError: (error) => {
+ console.error(`Content event ${i + 1} publish failed:`, error);
+ },
+ });
+ results.push(result);
+ }
+
+ // Process results
+ const successCount = results.filter(r => r.success).length;
+ const errors = results.filter(r => !r.success && r.error).map(r => r.error!);
+
+ // Extract successful events with their titles
+ const successfulEvents = results
+ .filter(r => r.success && r.eventId)
+ .map((r, index) => ({
+ eventId: r.eventId!,
+ title: events.contentEvents[index]?.title || `Note ${index + 1}`
+ }));
+
+ // Extract failed events with their titles and errors
+ const failedEvents = results
+ .map((r, index) => ({ result: r, index }))
+ .filter(({ result }) => !result.success)
+ .map(({ result, index }) => ({
+ title: events.contentEvents[index]?.title || `Note ${index + 1}`,
+ error: result.error || 'Unknown error',
+ sectionIndex: index
+ }));
+
+ publishResults = {
+ successCount,
+ total: results.length,
+ errors,
+ successfulEvents,
+ failedEvents,
+ };
+
+ // Show summary
+ console.log('\n=== Events Summary ===');
+ console.log('\nContent:');
+ events.contentEvents.forEach((event: any, index: number) => {
+ const eventId = successfulEvents.find(e => e.title === event.title)?.eventId || 'Failed';
+ console.log(`\nEvent Summary:`);
+ console.log(` ID: ${eventId}`);
+ console.log(` Kind: 30041`);
+ console.log(` Tags:`);
+ event.tags.forEach((tag: any) => {
+ console.log(` - ${JSON.stringify(tag)}`);
+ });
+ console.log(` Content preview: ${event.content.substring(0, 100)}...`);
+ console.log(' ---');
+ });
+ } catch (error) {
+ console.error('Publishing failed:', error);
+ publishResults = {
+ successCount: 0,
+ total: 0,
+ errors: [error instanceof Error ? error.message : 'Unknown error'],
+ successfulEvents: [],
+ failedEvents: []
+ };
+ }
- publishResults = {
- successCount,
- total: results.length,
- errors,
- successfulEvents,
- failedEvents,
- };
isPublishing = false;
}
@@ -77,44 +240,10 @@
isPublishing = true;
- // Get the specific section content
- const parsed = parseAsciiDocWithMetadata(content);
- const section = parsed.sections[sectionIndex];
- if (!section) return;
-
- // Reconstruct the section content for publishing
- const sectionContent = `== ${section.title}\n\n${section.content}`;
-
try {
- const result = await publishMultipleZettels({
- content: sectionContent,
- onError: (error) => {
- console.error('Retry failed:', error);
- },
- });
-
- if (result[0]?.success && result[0]?.eventId) {
- // Update the successful events list
- const newSuccessfulEvent = {
- eventId: result[0].eventId,
- title: section.title
- };
-
- // Remove from failed events
- const updatedFailedEvents = publishResults.failedEvents.filter(
- (_, index) => index !== sectionIndex
- );
-
- // Add to successful events
- const updatedSuccessfulEvents = [...publishResults.successfulEvents, newSuccessfulEvent];
-
- publishResults = {
- ...publishResults,
- successCount: publishResults.successCount + 1,
- successfulEvents: updatedSuccessfulEvents,
- failedEvents: updatedFailedEvents,
- };
- }
+ // For now, we'll just retry the specific event
+ // This could be enhanced to retry specific events based on their type
+ console.log('Retry functionality needs to be implemented for the new unified system');
} catch (error) {
console.error('Retry failed:', error);
}
@@ -142,22 +271,10 @@
{showPreview}
onContentChange={handleContentChange}
onPreviewToggle={handlePreviewToggle}
+ onPublishArticle={handlePublishArticle}
+ onPublishScatteredNotes={handlePublishScatteredNotes}
/>
-
-
- {#if isPublishing}
- Publishing...
- {:else}
-
- Publish
- {/if}
-
-
{#if publishResults}
{#if publishResults.successCount === publishResults.total}