Browse Source

Fix ZettelPublisher metadata extraction and pubkey handling

- Replace complex Asciidoctor attribute parsing with simple regex approach
- Parse :tagname: tagvalue directly from content using /^:([^:]+):\s*(.+)$/
- Handle :tags: comma,separated -> multiple ["t", "tag"] entries
- Fix pubkey placeholders in a-tags during publishing in publishSingleEvent
- Eliminate metadata corruption (no more "== Preface" authors or "Version" versions)
- Add iterative parsing functions: parseAsciiDocIterative, generateNostrEvents
- Preserve document attributes in content for proper tag extraction
- Add simple parseSimpleAttributes function for clean metadata conversion

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
master
limina1 7 months ago
parent
commit
66ece8ca1e
  1. 272
      src/lib/components/ZettelEditor.svelte
  2. 76
      src/lib/services/publisher.ts
  3. 290
      src/lib/utils/asciidoc_metadata.ts
  4. 223
      src/routes/new/compose/+page.svelte

272
src/lib/components/ZettelEditor.svelte

@ -4,6 +4,9 @@ @@ -4,6 +4,9 @@
import {
extractSmartMetadata,
parseAsciiDocWithMetadata,
parseAsciiDocIterative,
generateNostrEvents,
detectContentType,
type AsciiDocMetadata,
metadataToTags,
} from "$lib/utils/asciidoc_metadata";
@ -12,65 +15,97 @@ import asciidoctor from "asciidoctor"; @@ -12,65 +15,97 @@ 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)
= 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
== Chapter 1: Introduction
:author: Your Name
:keywords: introduction, chapter, example
:description: This is the first chapter of the article
Chapter content here...
== 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...
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
= Understanding Knowledge
:image: https://i.nostr.build/IUs0xNyUEf5hXTFL.jpg
:published: 2025-04-21
:tags: knowledge, philosophy, education
:type: text
== Preface
:tags: introduction, preface
This essay outlines the purpose of Alexandria...
== Introduction: Knowledge as a Living Ecosystem
:tags: introduction, ecosystem
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 [];
// Use smart metadata extraction that handles both document headers and section-only content
const { metadata: docMetadata } = extractSmartMetadata(content);
// Parse content using iterative parsing
let parsedContent = $derived.by(() => {
if (!content.trim()) return null;
// 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("Parsed sections:", parsed.sections);
console.log("Iterative parsed content:", parsed);
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
const tags = metadataToTags(section.metadata);
return parsed;
} catch (error) {
console.error("Parsing error:", error);
return null;
}
});
// Debug logging
console.log(`Section "${section.title}":`, { metadata: section.metadata, tags });
// Generate events from parsed content
let generatedEvents = $derived.by(() => {
if (!parsedContent) return null;
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 parsedContent.sections.map((section: { metadata: AsciiDocMetadata; content: string; title: string }) => {
const tags = metadataToTags(section.metadata);
return {
title: section.title || "Untitled",
@ -80,23 +115,21 @@ Note content here... @@ -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... @@ -112,87 +145,72 @@ Note content here...
</script>
<div class="flex flex-col space-y-4">
<!-- Error message for publication format -->
{#if hasPublicationHeader}
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-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-red-600 dark:text-red-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>
<!-- Smart Publishing Interface -->
<div class="bg-gradient-to-r from-blue-50 to-green-50 dark:from-blue-900/20 dark:to-green-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-4">
<div class="flex items-start justify-between">
<div class="flex-1">
<h3 class="text-sm font-medium text-red-800 dark:text-red-200 mb-1">
Publication Format Detected
<h3 class="text-sm font-medium text-gray-800 dark:text-gray-200 mb-2">
Unified AsciiDoc Publisher
</h3>
<p class="text-sm text-red-700 dark:text-red-300 mb-3">
You're using a publication format (document title with <code>=</code> or "index card").
This editor is for individual notes only. Use the
<a href="/events?kind=30040" class="font-medium underline hover:text-red-600 dark:hover:text-red-400">Events form</a>
to create structured publications.
</p>
<div class="flex space-x-2">
<a
href="/events?kind=30040"
onclick={() => {
// 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
</a>
<button
onclick={() => {
// 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"
<div class="flex items-center space-x-4 mb-3">
<div class="flex items-center space-x-2">
<label for="parse-level" class="text-xs text-gray-600 dark:text-gray-400 font-medium">Parse Level:</label>
<select
id="parse-level"
bind:value={parseLevel}
class="text-xs px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
>
Convert to Notes Format
</button>
</div>
<option value={2}>Level 2 (== sections events)</option>
<option value={3}>Level 3 (== indices, === events)</option>
<option value={4}>Level 4 (=== indices, ==== events)</option>
</select>
</div>
<div class="text-xs text-gray-600 dark:text-gray-400">
<span class="font-medium">Content Type:</span>
<span class="ml-1 px-2 py-0.5 rounded-full text-xs font-medium {
contentType === 'article' ? 'bg-blue-100 text-blue-800 dark:bg-blue-800 dark:text-blue-200' :
contentType === 'scattered-notes' ? 'bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-200' :
'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200'
}">
{contentType === 'article' ? 'Article' : contentType === 'scattered-notes' ? 'Notes' : 'None'}
</span>
</div>
{#if generatedEvents}
<div class="text-xs text-gray-600 dark:text-gray-400">
<span class="font-medium">Events:</span>
<span class="ml-1">{generatedEvents.contentEvents.length + (generatedEvents.indexEvent ? 1 : 0)}</span>
</div>
{:else}
<!-- 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 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
{/if}
</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.
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 <a href="/events?kind=30040" class="font-medium underline hover:text-blue-600 dark:hover:text-blue-400">Events form</a>.
</p>
<!-- Unified Publishing Button -->
<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"
{#if generatedEvents && contentType !== 'none'}
<Button
color={contentType === 'article' ? 'blue' : 'green'}
size="sm"
on:click={handlePublish}
class="flex items-center space-x-1"
>
Create Publication
</a>
{#if contentType === 'article'}
<span>📚 Publish Article</span>
<span class="text-xs opacity-75">({generatedEvents.contentEvents.length + 1} events)</span>
{:else}
<span>📝 Publish Notes</span>
<span class="text-xs opacity-75">({generatedEvents.contentEvents.length} events)</span>
{/if}
</Button>
{:else}
<div class="text-xs text-gray-500 dark:text-gray-400 italic">
Add content to enable publishing
</div>
{/if}
</div>
</div>
</div>
</div>
{/if}
<div class="flex items-center justify-between">
<Button
@ -200,7 +218,6 @@ Note content here... @@ -200,7 +218,6 @@ Note content here...
size="sm"
on:click={togglePreview}
class="flex items-center space-x-1"
disabled={hasPublicationHeader}
>
{#if showPreview}
<EyeOutline class="w-4 h-4" />
@ -220,15 +237,14 @@ Note content here... @@ -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}
/>
</div>
</div>
<!-- Preview Panel -->
{#if showPreview && !hasPublicationHeader}
{#if showPreview}
<div class="w-1/2 border-l border-gray-200 dark:border-gray-700 pl-4">
<div class="sticky top-4">
<h3

76
src/lib/services/publisher.ts

@ -104,6 +104,82 @@ export async function publishZettel( @@ -104,6 +104,82 @@ export async function publishZettel(
}
}
/**
* Publishes a single Nostr event directly
* @param options - Publishing options for a single event
* @returns Promise resolving to publish result
*/
export async function publishSingleEvent(
options: {
content: string;
kind: number;
tags: string[][];
onError?: (error: string) => void;
},
): Promise<PublishResult> {
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

290
src/lib/utils/asciidoc_metadata.ts

@ -24,6 +24,7 @@ export interface AsciiDocMetadata { @@ -24,6 +24,7 @@ export interface AsciiDocMetadata {
publishedBy?: string;
type?: string;
autoUpdate?: 'yes' | 'ask' | 'no';
customAttributes?: Record<string, string>;
}
export type SectionMetadata = AsciiDocMetadata;
@ -31,6 +32,7 @@ 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, any>): string[] { @@ -96,6 +98,20 @@ function extractTagsFromAttributes(attributes: Record<string, any>): string[] {
* Maps attributes to metadata with special handling for authors and tags
*/
function mapAttributesToMetadata(attributes: Record<string, any>, 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<string, any>, metadata: Asci @@ -114,6 +130,12 @@ function mapAttributesToMetadata(attributes: Record<string, any>, 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 @@ -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 <john@example.com>"
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): @@ -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): @@ -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<string, any> {
const attributes: Record<string, any> = {};
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): { @@ -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): { @@ -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 { @@ -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 @@ -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): { @@ -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
*/

223
src/routes/new/compose/+page.svelte

@ -4,8 +4,7 @@ @@ -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,28 +27,69 @@ @@ -28,28 +27,69 @@
showPreview = show;
}
async function handlePublish() {
// Handle unified publishing from ZettelEditor
async function handlePublishArticle(events: any) {
isPublishing = true;
publishResults = null;
const results = await publishMultipleZettels({
content,
// 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}`);
// 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) => {
// Only used for catastrophic errors
publishResults = { successCount: 0, total: 0, errors: [error], successfulEvents: [], failedEvents: [] };
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 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}`
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
@ -57,7 +97,7 @@ @@ -57,7 +97,7 @@
.map((r, index) => ({ result: r, index }))
.filter(({ result }) => !result.success)
.map(({ result, index }) => ({
title: parsed.sections[index]?.title || `Note ${index + 1}`,
title: index === 0 && events.indexEvent ? 'Article Index' : events.contentEvents[index - (events.indexEvent ? 1 : 0)]?.title || `Note ${index}`,
error: result.error || 'Unknown error',
sectionIndex: index
}));
@ -69,52 +109,141 @@ @@ -69,52 +109,141 @@
successfulEvents,
failedEvents,
};
isPublishing = false;
// 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(' ---');
}
async function retryFailedEvent(sectionIndex: number) {
if (!publishResults) return;
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: []
};
}
isPublishing = true;
isPublishing = false;
}
// Get the specific section content
const parsed = parseAsciiDocWithMetadata(content);
const section = parsed.sections[sectionIndex];
if (!section) return;
async function handlePublishScatteredNotes(events: any) {
isPublishing = true;
publishResults = null;
// Reconstruct the section content for publishing
const sectionContent = `== ${section.title}\n\n${section.content}`;
// 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 result = await publishMultipleZettels({
content: sectionContent,
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('Retry failed:', error);
console.error(`Content event ${i + 1} publish failed:`, error);
},
});
results.push(result);
}
if (result[0]?.success && result[0]?.eventId) {
// Update the successful events list
const newSuccessfulEvent = {
eventId: result[0].eventId,
title: section.title
};
// 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}`
}));
// Remove from failed events
const updatedFailedEvents = publishResults.failedEvents.filter(
(_, index) => index !== sectionIndex
);
// 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
}));
// Add to successful events
const updatedSuccessfulEvents = [...publishResults.successfulEvents, newSuccessfulEvent];
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 = {
...publishResults,
successCount: publishResults.successCount + 1,
successfulEvents: updatedSuccessfulEvents,
failedEvents: updatedFailedEvents,
successCount: 0,
total: 0,
errors: [error instanceof Error ? error.message : 'Unknown error'],
successfulEvents: [],
failedEvents: []
};
}
isPublishing = false;
}
async function retryFailedEvent(sectionIndex: number) {
if (!publishResults) return;
isPublishing = true;
try {
// 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 @@ @@ -142,22 +271,10 @@
{showPreview}
onContentChange={handleContentChange}
onPreviewToggle={handlePreviewToggle}
onPublishArticle={handlePublishArticle}
onPublishScatteredNotes={handlePublishScatteredNotes}
/>
<!-- Publish Button -->
<Button
on:click={handlePublish}
disabled={isPublishing || !content.trim()}
class="w-full"
>
{#if isPublishing}
Publishing...
{:else}
<PaperPlaneOutline class="w-4 h-4 mr-2" />
Publish
{/if}
</Button>
<!-- Status Messages -->
{#if publishResults}
{#if publishResults.successCount === publishResults.total}

Loading…
Cancel
Save