From 944c9c70d14c1e806e9d0c04e5cd7650a6437ba3 Mon Sep 17 00:00:00 2001 From: limina1 Date: Wed, 30 Jul 2025 14:46:05 -0400 Subject: [PATCH 01/55] add note and article placeholder, hinting at how to format in zetteleditor --- src/lib/components/ZettelEditor.svelte | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/lib/components/ZettelEditor.svelte b/src/lib/components/ZettelEditor.svelte index da96f74..c28be59 100644 --- a/src/lib/components/ZettelEditor.svelte +++ b/src/lib/components/ZettelEditor.svelte @@ -12,19 +12,30 @@ import asciidoctor from "asciidoctor"; // Component props let { content = "", - placeholder = `== Note Title + 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 note -:tags: note, example, metadata +: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 content here... == Note Title 2 -Some Other Author (this weeks even if there is no :author: attribute) +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... From 66ece8ca1efd560a82d534a36c6620cfd941af82 Mon Sep 17 00:00:00 2001 From: limina1 Date: Tue, 5 Aug 2025 17:02:22 -0400 Subject: [PATCH 02/55] Fix ZettelPublisher metadata extraction and pubkey handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/lib/components/ZettelEditor.svelte | 278 ++++++++++++----------- src/lib/services/publisher.ts | 76 +++++++ src/lib/utils/asciidoc_metadata.ts | 290 ++++++++++++++++++++++-- src/routes/new/compose/+page.svelte | 293 +++++++++++++++++-------- 4 files changed, 704 insertions(+), 233 deletions(-) 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 - -
+ {#if contentType === 'article'} + ๐Ÿ“š Publish Article + ({generatedEvents.contentEvents.length + 1} events) + {:else} + ๐Ÿ“ Publish Notes + ({generatedEvents.contentEvents.length} events) + {/if} + + {:else} +
+ Add content to enable publishing +
+ {/if}
- {/if} +
- {#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 publishResults} {#if publishResults.successCount === publishResults.total} From 088dc453bbaafd245557a9a219fa90eee7cbc635 Mon Sep 17 00:00:00 2001 From: limina1 Date: Tue, 5 Aug 2025 17:14:29 -0400 Subject: [PATCH 03/55] Add auto-author functionality and move publish button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Auto-add author identity when not publishing on behalf of others - Check for existing :author: and :p: tags, only add if missing - Add ["author", displayName] and ["p", pubkey] tags automatically - Move publish button from top gradient to underneath text area - Preserve smart color functionality: blue for articles, green for notes - Position publish button next to preview button for better UX ๐Ÿค– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/lib/components/ZettelEditor.svelte | 45 +++++++++++++------------- src/lib/services/publisher.ts | 19 ++++++++++- 2 files changed, 40 insertions(+), 24 deletions(-) diff --git a/src/lib/components/ZettelEditor.svelte b/src/lib/components/ZettelEditor.svelte index 5bac793..fe545cd 100644 --- a/src/lib/components/ZettelEditor.svelte +++ b/src/lib/components/ZettelEditor.svelte @@ -185,29 +185,6 @@ Understanding the nature of knowledge itself... {/if}

- -
- {#if generatedEvents && contentType !== 'none'} - - {:else} -
- Add content to enable publishing -
- {/if} -
@@ -227,6 +204,28 @@ Understanding the nature of knowledge itself... Show Preview {/if} + + + {#if generatedEvents && contentType !== 'none'} + + {:else} +
+ Add content to enable publishing +
+ {/if}
diff --git a/src/lib/services/publisher.ts b/src/lib/services/publisher.ts index ad2831a..5d58923 100644 --- a/src/lib/services/publisher.ts +++ b/src/lib/services/publisher.ts @@ -143,11 +143,28 @@ export async function publishSingleEvent( return tag; }); + // Auto-add author identity if not publishing on behalf of others + const hasAuthorTag = fixedTags.some(tag => tag[0] === 'author'); + const hasPTag = fixedTags.some(tag => tag[0] === 'p'); + + const finalTags = [...fixedTags]; + + if (!hasAuthorTag && ndk.activeUser) { + // Add display name as author + const displayName = ndk.activeUser.profile?.displayName || ndk.activeUser.profile?.name || 'Anonymous'; + finalTags.push(['author', displayName]); + } + + if (!hasPTag && ndk.activeUser) { + // Add pubkey as p-tag + finalTags.push(['p', ndk.activeUser.pubkey]); + } + // 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.tags = finalTags; ndkEvent.content = content; ndkEvent.pubkey = ndk.activeUser.pubkey; From e999ec4272404abb56e6d614386f066d4018fc99 Mon Sep 17 00:00:00 2001 From: limina1 Date: Tue, 5 Aug 2025 17:54:07 -0400 Subject: [PATCH 04/55] Fix hierarchical parsing and event generation for multi-level AsciiDoc publishing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Completely rewrote parseAsciiDocIterative to handle Level 3+ parsing correctly - Fixed generateNostrEvents to build proper tree structure and create hierarchical 30040/30041 events - Updated preview to show document titles for articles and only display hashtags (t-tags) - Added parseSimpleAttributes export for direct tag parsing from section content - Now supports proper index chains: Main 30040 โ†’ Sub 30040s โ†’ Content 30041s at configurable depth ๐Ÿค– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/lib/components/ZettelEditor.svelte | 42 +++- src/lib/utils/asciidoc_metadata.ts | 329 ++++++++++++++++++++----- 2 files changed, 303 insertions(+), 68 deletions(-) diff --git a/src/lib/components/ZettelEditor.svelte b/src/lib/components/ZettelEditor.svelte index fe545cd..e3fb92e 100644 --- a/src/lib/components/ZettelEditor.svelte +++ b/src/lib/components/ZettelEditor.svelte @@ -9,6 +9,7 @@ detectContentType, type AsciiDocMetadata, metadataToTags, + parseSimpleAttributes, } from "$lib/utils/asciidoc_metadata"; import asciidoctor from "asciidoctor"; @@ -105,7 +106,8 @@ Understanding the nature of knowledge itself... if (!parsedContent) return []; return parsedContent.sections.map((section: { metadata: AsciiDocMetadata; content: string; title: string }) => { - const tags = metadataToTags(section.metadata); + // Use simple parsing directly on section content for accurate tag extraction + const tags = parseSimpleAttributes(section.content); return { title: section.title || "Untitled", @@ -261,6 +263,33 @@ Understanding the nature of knowledge itself...
{:else}
+ + {#if contentType === 'article' && parsedContent?.title} +
+

+ {parsedContent.title} +

+ + {#if parsedContent.content} + {@const documentTags = parseSimpleAttributes(parsedContent.content)} + {#if documentTags.filter(tag => tag[0] === 't').length > 0} +
+
+ Document tags: + + {#each documentTags.filter(tag => tag[0] === 't') as tag} +
+ # + {tag[1]} +
+ {/each} +
+
+ {/if} + {/if} +
+ {/if} + {#each parsedSections as section, index}
- {#if section.tags && section.tags.length > 0} - {#each section.tags as tag} + {#if section.tags && section.tags.filter(tag => tag[0] === 't').length > 0} + + {#each section.tags.filter(tag => tag[0] === 't') as tag}
- {tag[0]}: + # {tag[1]}
{/each} {:else} No tagsNo hashtags {/if}
diff --git a/src/lib/utils/asciidoc_metadata.ts b/src/lib/utils/asciidoc_metadata.ts index 59a5dfd..3f739d5 100644 --- a/src/lib/utils/asciidoc_metadata.ts +++ b/src/lib/utils/asciidoc_metadata.ts @@ -259,7 +259,7 @@ function stripHeaderAndAttributes(content: string, isSection: boolean = false): * Converts :tagname: tagvalue -> [tagname, tagvalue] * Converts :tags: comma,separated -> [t, tag1], [t, tag2], etc. */ -function parseSimpleAttributes(content: string): [string, string][] { +export function parseSimpleAttributes(content: string): [string, string][] { const tags: [string, string][] = []; const lines = content.split(/\r?\n/); @@ -559,9 +559,9 @@ export function extractMetadataFromSectionsOnly(content: string): { /** * 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. + * Level 2: Only == sections become content events (containing all subsections) + * Level 3: == sections become indices + content events, === sections become content events + * Level 4: === sections become indices + content events, ==== sections become content events, etc. */ export function parseAsciiDocIterative(content: string, parseLevel: number = 2): ParsedAsciiDoc { const asciidoctor = createProcessor(); @@ -569,21 +569,67 @@ export function parseAsciiDocIterative(content: string, parseLevel: number = 2): 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; }> = []; + if (parseLevel === 2) { + // Level 2: Only == sections become events + const level2Pattern = /^==\s+/; + let currentSection: string | null = null; + let currentSectionContent: string[] = []; + let documentContent: string[] = []; + let inDocumentHeader = true; + + for (const line of lines) { + if (line.match(level2Pattern)) { + 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) { + currentSectionContent.push(line); + } else if (inDocumentHeader) { + documentContent.push(line); + } + } + + // Save the last section + if (currentSection) { + const sectionContent = currentSectionContent.join('\n'); + sections.push(extractSectionMetadata(sectionContent)); + } + + const docContent = documentContent.join('\n'); + return { + metadata: docMetadata, + content: docContent, + title: docMetadata.title || '', + sections: sections + }; + } + + // Level 3+: Parse both index level (parseLevel-1) and content level (parseLevel) + const indexLevelPattern = new RegExp(`^${'='.repeat(parseLevel - 1)}\\s+`); + const contentLevelPattern = new RegExp(`^${'='.repeat(parseLevel)}\\s+`); + 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)) { + // Check for both index level and content level headers + if (line.match(indexLevelPattern) || line.match(contentLevelPattern)) { inDocumentHeader = false; // Save previous section if exists @@ -596,10 +642,8 @@ export function parseAsciiDocIterative(content: string, parseLevel: number = 2): 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); } } @@ -610,10 +654,7 @@ export function parseAsciiDocIterative(content: string, parseLevel: number = 2): 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, @@ -623,87 +664,251 @@ export function parseAsciiDocIterative(content: string, parseLevel: number = 2): } /** - * Generates Nostr events from parsed AsciiDoc + * Helper function to determine the header level of a section + */ +function getSectionLevel(sectionContent: string): number { + const lines = sectionContent.split(/\r?\n/); + for (const line of lines) { + const match = line.match(/^(=+)\s+/); + if (match) { + return match[1].length; + } + } + return 0; +} + +/** + * Helper function to extract just the intro content (before first subsection) + */ +function extractIntroContent(sectionContent: string, currentLevel: number): string { + const lines = sectionContent.split(/\r?\n/); + const introLines: string[] = []; + let foundHeader = false; + + for (const line of lines) { + const headerMatch = line.match(/^(=+)\s+/); + if (headerMatch) { + const level = headerMatch[1].length; + if (level === currentLevel && !foundHeader) { + // This is the section header itself + foundHeader = true; + continue; // Skip the header line itself for intro content + } else if (level > currentLevel) { + // This is a subsection, stop collecting intro content + break; + } + } else if (foundHeader) { + // This is intro content after the header + introLines.push(line); + } + } + + return introLines.join('\n').trim(); +} + +/** + * Generates Nostr events from parsed AsciiDoc with proper hierarchical structure * Based on docreference.md specifications */ -export function generateNostrEvents(parsed: ParsedAsciiDoc, parseLevel: number = 2, pubkey?: string): { +export function generateNostrEvents(parsed: ParsedAsciiDoc, parseLevel: number = 2, pubkey?: string, maxDepth: number = 6): { indexEvent?: any; contentEvents: any[]; } { - const events: any[] = []; + const allEvents: any[] = []; + const actualPubkey = pubkey || 'pubkey'; - // Create content events for each section (30041) - const contentEvents = parsed.sections.map(section => { - const sectionId = section.title + // Helper function to generate section ID + const generateSectionId = (title: string): string => { + return title .toLowerCase() .replace(/[^a-z0-9\s]/g, '') .replace(/\s+/g, '-') .trim(); + }; + + // Build hierarchical tree structure + interface TreeNode { + section: { + metadata: any; + content: string; + title: string; + }; + level: number; + sectionId: string; + tags: [string, string][]; + children: TreeNode[]; + parent?: TreeNode; + } + + // Convert flat sections to tree structure + const buildTree = (): TreeNode[] => { + const roots: TreeNode[] = []; + const stack: TreeNode[] = []; - // Extract tags directly from section content using simple regex - const sectionTags = parseSimpleAttributes(section.content); + for (const section of parsed.sections) { + const level = getSectionLevel(section.content); + const sectionId = generateSectionId(section.title); + const tags = parseSimpleAttributes(section.content); + + const node: TreeNode = { + section, + level, + sectionId, + tags, + children: [], + }; + + // Find the correct parent based on header hierarchy + while (stack.length > 0 && stack[stack.length - 1].level >= level) { + stack.pop(); + } + + if (stack.length === 0) { + // This is a root level section + roots.push(node); + } else { + // This is a child of the last item in stack + const parent = stack[stack.length - 1]; + parent.children.push(node); + node.parent = parent; + } + + stack.push(node); + } - 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 - }; - }); + return roots; + }; - // 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(); + const tree = buildTree(); + + // Recursively create events from tree + const createEventsFromNode = (node: TreeNode): void => { + const { section, level, sectionId, tags, children } = node; + + // Determine if this node should become an index + const hasChildrenAtTargetLevel = children.some(child => child.level === parseLevel); + const shouldBeIndex = level < parseLevel && (hasChildrenAtTargetLevel || children.some(child => child.level <= parseLevel)); - // Extract tags directly from document content using simple regex + if (shouldBeIndex) { + // Create content event for intro text (30041) + const introContent = extractIntroContent(section.content, level); + if (introContent.trim()) { + const contentEvent = { + id: '', + pubkey: '', + created_at: Math.floor(Date.now() / 1000), + kind: 30041, + tags: [ + ['d', `${sectionId}-content`], + ['title', section.title], + ...tags + ], + content: introContent, + sig: '' + }; + allEvents.push(contentEvent); + } + + // Create index event (30040) + const childATags: string[][] = []; + + // Add a-tag for intro content if it exists + if (introContent.trim()) { + childATags.push(['a', `30041:${actualPubkey}:${sectionId}-content`, '', '']); + } + + // Add a-tags for direct children + for (const child of children) { + const childHasSubChildren = child.children.some(grandchild => grandchild.level <= parseLevel); + const childShouldBeIndex = child.level < parseLevel && childHasSubChildren; + const childKind = childShouldBeIndex ? 30040 : 30041; + childATags.push(['a', `${childKind}:${actualPubkey}:${child.sectionId}`, '', '']); + } + + const indexEvent = { + id: '', + pubkey: '', + created_at: Math.floor(Date.now() / 1000), + kind: 30040, + tags: [ + ['d', sectionId], + ['title', section.title], + ...tags, + ...childATags + ], + content: '', + sig: '' + }; + allEvents.push(indexEvent); + } else { + // Create regular content event (30041) + const contentEvent = { + id: '', + pubkey: '', + created_at: Math.floor(Date.now() / 1000), + kind: 30041, + tags: [ + ['d', sectionId], + ['title', section.title], + ...tags + ], + content: section.content, + sig: '' + }; + allEvents.push(contentEvent); + } + + // Recursively process children + for (const child of children) { + createEventsFromNode(child); + } + }; + + // Process all root level sections + for (const rootNode of tree) { + createEventsFromNode(rootNode); + } + + // Create main document index if we have a document title (article format) + if (parsed.title && parsed.title.trim() !== '') { + const documentId = generateSectionId(parsed.title); 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 + // Create a-tags for all root level sections (level 2) + const mainIndexATags = tree.map(rootNode => { + const hasSubChildren = rootNode.children.some(child => child.level <= parseLevel); + const shouldBeIndex = rootNode.level < parseLevel && hasSubChildren; + const kind = shouldBeIndex ? 30040 : 30041; + return ['a', `${kind}:${actualPubkey}:${rootNode.sectionId}`, '', '']; + }); + + console.log('Debug: Root sections found:', tree.length); + console.log('Debug: Main index a-tags:', mainIndexATags); + + const mainIndexEvent = { + id: '', + pubkey: '', 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 - }) + ...mainIndexATags ], - content: '', // Index events have empty content - sig: '' // Will be generated by client + content: '', + sig: '' }; return { - indexEvent, - contentEvents + indexEvent: mainIndexEvent, + contentEvents: allEvents }; } // For scattered notes, return only content events return { - contentEvents + contentEvents: allEvents }; } From f5685b2f3a1ffe0edc3b5fa95fb182d402b923c1 Mon Sep 17 00:00:00 2001 From: limina1 Date: Tue, 5 Aug 2025 18:45:33 -0400 Subject: [PATCH 05/55] Fix TypeScript errors and improve ZettelEditor preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix {@const} placement errors by using Svelte 5 snippets - Add proper TypeScript types to levelColors objects - Rename and fix test file from .js to .ts with proper typing - Remove indent guides from editor text area for cleaner writing - Improve preview layout with proper indentation and spacing - Add continuous vertical guides in preview that don't overlap text ๐Ÿค– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/lib/components/ZettelEditor.svelte | 215 +++++----- tests/zettel-publisher-tdd.test.ts | 560 +++++++++++++++++++++++++ 2 files changed, 676 insertions(+), 99 deletions(-) create mode 100644 tests/zettel-publisher-tdd.test.ts diff --git a/src/lib/components/ZettelEditor.svelte b/src/lib/components/ZettelEditor.svelte index e3fb92e..a993d26 100644 --- a/src/lib/components/ZettelEditor.svelte +++ b/src/lib/components/ZettelEditor.svelte @@ -101,6 +101,18 @@ Understanding the nature of knowledge itself... return detectContentType(content); }); + // Helper function to get section level from content + function getSectionLevel(sectionContent: string): number { + const lines = sectionContent.split(/\r?\n/); + for (const line of lines) { + const match = line.match(/^(=+)\s+/); + if (match) { + return match[1].length; + } + } + return 2; // Default to level 2 + } + // Parse sections for preview display let parsedSections = $derived.by(() => { if (!parsedContent) return []; @@ -108,11 +120,13 @@ Understanding the nature of knowledge itself... return parsedContent.sections.map((section: { metadata: AsciiDocMetadata; content: string; title: string }) => { // Use simple parsing directly on section content for accurate tag extraction const tags = parseSimpleAttributes(section.content); + const level = getSectionLevel(section.content); return { title: section.title || "Untitled", content: section.content.trim(), tags, + level, }; }); }); @@ -230,136 +244,139 @@ Understanding the nature of knowledge itself... {/if}
-
+
-
-
+
+
`, + })), + P: vi.fn().mockImplementation(() => ({ + $$render: () => `

`, + })), +})); + +// Mock flowbite-svelte-icons +vi.mock("flowbite-svelte-icons", () => ({ + FontHighlightOutline: vi.fn().mockImplementation(() => ({ + $$render: () => ``, + })), +})); + +describe("HighlightButton Component Logic", () => { + let isActive: boolean; + + beforeEach(() => { + isActive = false; + }); + + describe("Initial State", () => { + it("should initialize with inactive state", () => { + expect(isActive).toBe(false); + }); + + it("should have correct inactive label", () => { + const label = isActive ? "Exit Highlight Mode" : "Add Highlight"; + expect(label).toBe("Add Highlight"); + }); + + it("should have correct inactive title", () => { + const title = isActive ? "Exit highlight mode" : "Enter highlight mode"; + expect(title).toBe("Enter highlight mode"); + }); + + it("should have correct inactive color", () => { + const color = isActive ? "primary" : "light"; + expect(color).toBe("light"); + }); + + it("should not have ring styling when inactive", () => { + const ringClass = isActive ? "ring-2 ring-primary-500" : ""; + expect(ringClass).toBe(""); + }); + }); + + describe("Toggle Functionality", () => { + it("should toggle to active state when clicked", () => { + // Simulate toggle + isActive = !isActive; + expect(isActive).toBe(true); + }); + + it("should toggle back to inactive state on second click", () => { + // Simulate two toggles + isActive = !isActive; + isActive = !isActive; + expect(isActive).toBe(false); + }); + + it("should show correct label when active", () => { + isActive = true; + const label = isActive ? "Exit Highlight Mode" : "Add Highlight"; + expect(label).toBe("Exit Highlight Mode"); + }); + + it("should show correct title when active", () => { + isActive = true; + const title = isActive ? "Exit highlight mode" : "Enter highlight mode"; + expect(title).toBe("Exit highlight mode"); + }); + }); + + describe("Active State Styling", () => { + it("should apply primary color when active", () => { + isActive = true; + const color = isActive ? "primary" : "light"; + expect(color).toBe("primary"); + }); + + it("should apply ring styling when active", () => { + isActive = true; + const ringClass = isActive ? "ring-2 ring-primary-500" : ""; + expect(ringClass).toBe("ring-2 ring-primary-500"); + }); + }); +}); + +describe("HighlightSelectionHandler Component Logic", () => { + let mockNDK: NDKEvent; + let mockUserStore: any; + let mockSelection: Selection; + let mockPublicationEvent: NDKEvent; + let isActive: boolean; + + beforeEach(() => { + // Reset mocks + vi.clearAllMocks(); + isActive = false; + + // Mock document and DOM elements + const mockElement = { + createElement: vi.fn((tag: string) => ({ + tagName: tag.toUpperCase(), + textContent: "", + className: "", + closest: vi.fn(), + parentElement: null, + })), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + body: { + classList: { + add: vi.fn(), + remove: vi.fn(), + }, + }, + }; + + global.document = mockElement as any; + + // Mock NDK event + mockPublicationEvent = { + id: "test-event-id", + pubkey: "test-pubkey", + kind: 30023, + tagAddress: vi.fn().mockReturnValue("30023:test-pubkey:test-d-tag"), + tags: [], + content: "", + } as unknown as NDKEvent; + + // Mock user store + mockUserStore = { + signedIn: true, + signer: { + sign: vi.fn().mockResolvedValue(undefined), + }, + }; + + // Mock window.getSelection + const mockParagraph = { + textContent: "This is the full paragraph context", + closest: vi.fn(), + }; + + mockSelection = { + toString: vi.fn().mockReturnValue("Selected text from publication"), + isCollapsed: false, + removeAllRanges: vi.fn(), + anchorNode: { + parentElement: mockParagraph, + }, + } as unknown as Selection; + + global.window = { + getSelection: vi.fn().mockReturnValue(mockSelection), + } as any; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe("Selection Detection", () => { + it("should ignore mouseup events when isActive is false", () => { + isActive = false; + const shouldProcess = isActive; + expect(shouldProcess).toBe(false); + }); + + it("should process mouseup events when isActive is true", () => { + isActive = true; + const shouldProcess = isActive; + expect(shouldProcess).toBe(true); + }); + + it("should ignore collapsed selections", () => { + const selection = { isCollapsed: true } as Selection; + const shouldIgnore = selection.isCollapsed; + expect(shouldIgnore).toBe(true); + }); + + it("should process non-collapsed selections", () => { + const selection = { isCollapsed: false } as Selection; + const shouldIgnore = selection.isCollapsed; + expect(shouldIgnore).toBe(false); + }); + + it("should ignore selections with less than 3 characters", () => { + const text = "ab"; + const isValid = text.length >= 3; + expect(isValid).toBe(false); + }); + + it("should accept selections with 3 or more characters", () => { + const text = "abc"; + const isValid = text.length >= 3; + expect(isValid).toBe(true); + }); + + it("should ignore empty selections after trim", () => { + const text = " "; + const trimmed = text.trim(); + const isValid = trimmed.length >= 3; + expect(isValid).toBe(false); + }); + }); + + describe("User Authentication", () => { + it("should reject selection when user not signed in", () => { + const userStore = { signedIn: false }; + expect(userStore.signedIn).toBe(false); + }); + + it("should process selection when user signed in", () => { + const userStore = { signedIn: true }; + expect(userStore.signedIn).toBe(true); + }); + + it("should check for signer before creating highlight", () => { + const userStore = { + signedIn: true, + signer: { sign: vi.fn() }, + }; + expect(userStore.signer).toBeDefined(); + }); + + it("should reject creation without signer", () => { + const userStore = { + signedIn: true, + signer: null, + }; + expect(userStore.signer).toBeNull(); + }); + }); + + describe("Publication Context Detection", () => { + it("should detect selection within publication-leather class", () => { + const mockElement = { + className: "publication-leather", + closest: vi.fn((selector: string) => { + return selector === ".publication-leather" ? mockElement : null; + }), + }; + const target = mockElement; + const publicationSection = target.closest(".publication-leather"); + expect(publicationSection).toBeTruthy(); + }); + + it("should reject selection outside publication-leather class", () => { + const mockElement = { + className: "other-section", + closest: vi.fn((selector: string) => { + return selector === ".publication-leather" ? null : mockElement; + }), + }; + const target = mockElement; + const publicationSection = target.closest(".publication-leather"); + expect(publicationSection).toBeNull(); + }); + }); + + describe("Context Extraction", () => { + it("should extract context from parent paragraph", () => { + const paragraph = { + textContent: "This is the full paragraph context with selected text inside.", + }; + + const context = paragraph.textContent?.trim() || ""; + expect(context).toBe("This is the full paragraph context with selected text inside."); + }); + + it("should extract context from parent section", () => { + const section = { + textContent: "Full section context including selected text.", + }; + + const context = section.textContent?.trim() || ""; + expect(context).toBe("Full section context including selected text."); + }); + + it("should extract context from parent div", () => { + const div = { + textContent: "Full div context including selected text.", + }; + + const context = div.textContent?.trim() || ""; + expect(context).toBe("Full div context including selected text."); + }); + + it("should handle missing context gracefully", () => { + const context = ""; + expect(context).toBe(""); + }); + }); + + describe("NIP-84 Event Creation - Addressable Events", () => { + it("should use 'a' tag for addressable events", () => { + const eventAddress = "30023:pubkey:d-tag"; + const tags: string[][] = []; + + if (eventAddress) { + tags.push(["a", eventAddress, ""]); + } + + expect(tags).toContainEqual(["a", eventAddress, ""]); + }); + + it("should create event with correct kind 9802", () => { + const event = { + kind: 9802, + content: "", + tags: [], + }; + + expect(event.kind).toBe(9802); + }); + + it("should include selected text as content", () => { + const selectedText = "This is the selected highlight text"; + const event = { + kind: 9802, + content: selectedText, + tags: [], + }; + + expect(event.content).toBe(selectedText); + }); + + it("should include context tag", () => { + const context = "This is the surrounding context"; + const tags: string[][] = []; + + if (context) { + tags.push(["context", context]); + } + + expect(tags).toContainEqual(["context", context]); + }); + + it("should include author p-tag with role", () => { + const pubkey = "author-pubkey-hex"; + const tags: string[][] = []; + + if (pubkey) { + tags.push(["p", pubkey, "", "author"]); + } + + expect(tags).toContainEqual(["p", pubkey, "", "author"]); + }); + + it("should include comment tag when comment provided", () => { + const comment = "This is my insightful comment"; + const tags: string[][] = []; + + if (comment.trim()) { + tags.push(["comment", comment.trim()]); + } + + expect(tags).toContainEqual(["comment", comment]); + }); + + it("should not include comment tag when comment is empty", () => { + const comment = ""; + const tags: string[][] = []; + + if (comment.trim()) { + tags.push(["comment", comment.trim()]); + } + + expect(tags).not.toContainEqual(["comment", ""]); + expect(tags.length).toBe(0); + }); + + it("should not include comment tag when comment is only whitespace", () => { + const comment = " "; + const tags: string[][] = []; + + if (comment.trim()) { + tags.push(["comment", comment.trim()]); + } + + expect(tags.length).toBe(0); + }); + }); + + describe("NIP-84 Event Creation - Regular Events", () => { + it("should use 'e' tag for regular events", () => { + const eventId = "regular-event-id"; + const eventAddress = null; // No address means regular event + const tags: string[][] = []; + + if (eventAddress) { + tags.push(["a", eventAddress, ""]); + } else { + tags.push(["e", eventId, ""]); + } + + expect(tags).toContainEqual(["e", eventId, ""]); + }); + + it("should prefer addressable event over regular event", () => { + const eventId = "regular-event-id"; + const eventAddress = "30023:pubkey:d-tag"; + const tags: string[][] = []; + + if (eventAddress) { + tags.push(["a", eventAddress, ""]); + } else { + tags.push(["e", eventId, ""]); + } + + expect(tags).toContainEqual(["a", eventAddress, ""]); + expect(tags).not.toContainEqual(["e", eventId, ""]); + }); + }); + + describe("Complete Event Structure", () => { + it("should create complete highlight event with all required tags", () => { + const selectedText = "Highlighted text"; + const context = "Full context paragraph"; + const pubkey = "author-pubkey"; + const eventAddress = "30023:pubkey:d-tag"; + + const event = { + kind: 9802, + content: selectedText, + tags: [ + ["a", eventAddress, ""], + ["context", context], + ["p", pubkey, "", "author"], + ], + }; + + expect(event.kind).toBe(9802); + expect(event.content).toBe(selectedText); + expect(event.tags).toHaveLength(3); + expect(event.tags[0]).toEqual(["a", eventAddress, ""]); + expect(event.tags[1]).toEqual(["context", context]); + expect(event.tags[2]).toEqual(["p", pubkey, "", "author"]); + }); + + it("should create complete quote highlight with comment", () => { + const selectedText = "Highlighted text"; + const context = "Full context paragraph"; + const pubkey = "author-pubkey"; + const eventAddress = "30023:pubkey:d-tag"; + const comment = "My thoughtful comment"; + + const event = { + kind: 9802, + content: selectedText, + tags: [ + ["a", eventAddress, ""], + ["context", context], + ["p", pubkey, "", "author"], + ["comment", comment], + ], + }; + + expect(event.kind).toBe(9802); + expect(event.content).toBe(selectedText); + expect(event.tags).toHaveLength(4); + expect(event.tags[3]).toEqual(["comment", comment]); + }); + + it("should handle event without context", () => { + const selectedText = "Highlighted text"; + const context = ""; + const pubkey = "author-pubkey"; + const eventId = "event-id"; + + const tags: string[][] = []; + tags.push(["e", eventId, ""]); + if (context) { + tags.push(["context", context]); + } + tags.push(["p", pubkey, "", "author"]); + + expect(tags).toHaveLength(2); + expect(tags).not.toContainEqual(["context", ""]); + }); + }); + + describe("Event Signing and Publishing", () => { + it("should sign event before publishing", async () => { + const mockSigner = { + sign: vi.fn().mockResolvedValue(undefined), + }; + + const mockEvent = { + kind: 9802, + content: "test", + tags: [], + sign: vi.fn().mockResolvedValue(undefined), + publish: vi.fn().mockResolvedValue(undefined), + }; + + await mockEvent.sign(mockSigner); + expect(mockEvent.sign).toHaveBeenCalledWith(mockSigner); + }); + + it("should publish event after signing", async () => { + const mockEvent = { + sign: vi.fn().mockResolvedValue(undefined), + publish: vi.fn().mockResolvedValue(undefined), + }; + + await mockEvent.sign({}); + await mockEvent.publish(); + + expect(mockEvent.publish).toHaveBeenCalled(); + }); + + it("should handle signing errors", async () => { + const mockEvent = { + sign: vi.fn().mockRejectedValue(new Error("Signing failed")), + }; + + await expect(mockEvent.sign({})).rejects.toThrow("Signing failed"); + }); + + it("should handle publishing errors", async () => { + const mockEvent = { + sign: vi.fn().mockResolvedValue(undefined), + publish: vi.fn().mockRejectedValue(new Error("Publishing failed")), + }; + + await mockEvent.sign({}); + await expect(mockEvent.publish()).rejects.toThrow("Publishing failed"); + }); + }); + + describe("Selection Cleanup", () => { + it("should clear selection after successful highlight creation", () => { + const mockSelection = { + removeAllRanges: vi.fn(), + }; + + mockSelection.removeAllRanges(); + expect(mockSelection.removeAllRanges).toHaveBeenCalled(); + }); + + it("should reset selectedText after creation", () => { + let selectedText = "Some text"; + selectedText = ""; + expect(selectedText).toBe(""); + }); + + it("should reset comment after creation", () => { + let comment = "Some comment"; + comment = ""; + expect(comment).toBe(""); + }); + + it("should reset context after creation", () => { + let context = "Some context"; + context = ""; + expect(context).toBe(""); + }); + + it("should close modal after creation", () => { + let showModal = true; + showModal = false; + expect(showModal).toBe(false); + }); + }); + + describe("Cancel Functionality", () => { + it("should clear selection when cancelled", () => { + const mockSelection = { + removeAllRanges: vi.fn(), + }; + + // Simulate cancel + mockSelection.removeAllRanges(); + expect(mockSelection.removeAllRanges).toHaveBeenCalled(); + }); + + it("should reset all state when cancelled", () => { + let selectedText = "text"; + let comment = "comment"; + let context = "context"; + let showModal = true; + + // Simulate cancel + selectedText = ""; + comment = ""; + context = ""; + showModal = false; + + expect(selectedText).toBe(""); + expect(comment).toBe(""); + expect(context).toBe(""); + expect(showModal).toBe(false); + }); + }); + + describe("Feedback Messages", () => { + it("should show success message after creation", () => { + const message = "Highlight created successfully!"; + const type = "success"; + + expect(message).toBe("Highlight created successfully!"); + expect(type).toBe("success"); + }); + + it("should show error message on failure", () => { + const message = "Failed to create highlight. Please try again."; + const type = "error"; + + expect(message).toBe("Failed to create highlight. Please try again."); + expect(type).toBe("error"); + }); + + it("should show error when not signed in", () => { + const message = "Please sign in to create highlights"; + const type = "error"; + + expect(message).toBe("Please sign in to create highlights"); + expect(type).toBe("error"); + }); + + it("should auto-hide feedback after delay", () => { + let showFeedback = true; + + // Simulate timeout + setTimeout(() => { + showFeedback = false; + }, 3000); + + // Initially shown + expect(showFeedback).toBe(true); + }); + }); + + describe("Event Listeners", () => { + it("should add mouseup listener on mount", () => { + const mockAddEventListener = vi.fn(); + document.addEventListener = mockAddEventListener; + + document.addEventListener("mouseup", () => {}); + expect(mockAddEventListener).toHaveBeenCalledWith("mouseup", expect.any(Function)); + }); + + it("should remove mouseup listener on unmount", () => { + const mockRemoveEventListener = vi.fn(); + document.removeEventListener = mockRemoveEventListener; + + const handler = () => {}; + document.removeEventListener("mouseup", handler); + expect(mockRemoveEventListener).toHaveBeenCalledWith("mouseup", handler); + }); + }); + + describe("Highlight Mode Body Class", () => { + it("should add highlight-mode-active class when active", () => { + const mockClassList = { + add: vi.fn(), + remove: vi.fn(), + }; + document.body.classList = mockClassList as any; + + // Simulate active mode + document.body.classList.add("highlight-mode-active"); + expect(mockClassList.add).toHaveBeenCalledWith("highlight-mode-active"); + }); + + it("should remove highlight-mode-active class when inactive", () => { + const mockClassList = { + add: vi.fn(), + remove: vi.fn(), + }; + document.body.classList = mockClassList as any; + + // Simulate inactive mode + document.body.classList.remove("highlight-mode-active"); + expect(mockClassList.remove).toHaveBeenCalledWith("highlight-mode-active"); + }); + + it("should clean up class on unmount", () => { + const mockClassList = { + add: vi.fn(), + remove: vi.fn(), + }; + document.body.classList = mockClassList as any; + + // Simulate cleanup + document.body.classList.remove("highlight-mode-active"); + expect(mockClassList.remove).toHaveBeenCalledWith("highlight-mode-active"); + }); + }); + + describe("Modal Display", () => { + it("should show modal when text is selected", () => { + let showModal = false; + + // Simulate successful selection + showModal = true; + expect(showModal).toBe(true); + }); + + it("should display selected text in modal", () => { + const selectedText = "This is the selected text"; + const displayText = `"${selectedText}"`; + + expect(displayText).toBe('"This is the selected text"'); + }); + + it("should provide textarea for optional comment", () => { + let comment = ""; + const placeholder = "Share your thoughts about this highlight..."; + + expect(placeholder).toBe("Share your thoughts about this highlight..."); + expect(comment).toBe(""); + }); + + it("should disable buttons while submitting", () => { + const isSubmitting = true; + const disabled = isSubmitting; + + expect(disabled).toBe(true); + }); + + it("should show 'Creating...' text while submitting", () => { + const isSubmitting = true; + const buttonText = isSubmitting ? "Creating..." : "Create Highlight"; + + expect(buttonText).toBe("Creating..."); + }); + + it("should show normal text when not submitting", () => { + const isSubmitting = false; + const buttonText = isSubmitting ? "Creating..." : "Create Highlight"; + + expect(buttonText).toBe("Create Highlight"); + }); + }); + + describe("Callback Execution", () => { + it("should call onHighlightCreated callback after creation", () => { + const mockCallback = vi.fn(); + + // Simulate successful creation + mockCallback(); + + expect(mockCallback).toHaveBeenCalled(); + }); + + it("should not call callback if creation fails", () => { + const mockCallback = vi.fn(); + + // Simulate failed creation - callback not called + expect(mockCallback).not.toHaveBeenCalled(); + }); + + it("should handle missing callback gracefully", () => { + const callback = undefined; + + // Should not throw error + expect(() => { + if (callback) { + callback(); + } + }).not.toThrow(); + }); + }); + + describe("Integration Scenarios", () => { + it("should handle complete highlight workflow", () => { + // Setup + let isActive = true; + let showModal = false; + let selectedText = ""; + const userSignedIn = true; + const selection = { + toString: () => "Selected text for highlighting", + isCollapsed: false, + }; + + // User selects text + if (isActive && userSignedIn && !selection.isCollapsed) { + selectedText = selection.toString(); + showModal = true; + } + + expect(selectedText).toBe("Selected text for highlighting"); + expect(showModal).toBe(true); + }); + + it("should handle complete quote highlight workflow with comment", () => { + // Setup + let isActive = true; + let showModal = false; + let selectedText = ""; + let comment = ""; + const userSignedIn = true; + const selection = { + toString: () => "Selected text", + isCollapsed: false, + }; + + // User selects text + if (isActive && userSignedIn && !selection.isCollapsed) { + selectedText = selection.toString(); + showModal = true; + } + + // User adds comment + comment = "This is insightful"; + + // Create event with comment + const tags: string[][] = []; + if (comment.trim()) { + tags.push(["comment", comment]); + } + + expect(selectedText).toBe("Selected text"); + expect(comment).toBe("This is insightful"); + expect(tags).toContainEqual(["comment", "This is insightful"]); + }); + + it("should reject workflow when user not signed in", () => { + let isActive = true; + let showModal = false; + const userSignedIn = false; + const selection = { + toString: () => "Selected text", + isCollapsed: false, + }; + + // User tries to select text + if (isActive && userSignedIn && !selection.isCollapsed) { + showModal = true; + } + + expect(showModal).toBe(false); + }); + + it("should handle workflow cancellation", () => { + // Setup initial state + let showModal = true; + let selectedText = "Some text"; + let comment = "Some comment"; + const mockSelection = { + removeAllRanges: vi.fn(), + }; + + // User cancels + showModal = false; + selectedText = ""; + comment = ""; + mockSelection.removeAllRanges(); + + expect(showModal).toBe(false); + expect(selectedText).toBe(""); + expect(comment).toBe(""); + expect(mockSelection.removeAllRanges).toHaveBeenCalled(); + }); + }); +}); From 5ec17ad1b1363ddae2255c8c6853cf77adb9fbc3 Mon Sep 17 00:00:00 2001 From: limina1 Date: Thu, 6 Nov 2025 10:46:37 -0500 Subject: [PATCH 42/55] Add article and section commenting system with NIP-22 support --- .../publications/CommentButton.svelte | 520 ++++++++++ .../publications/CommentLayer.svelte | 282 ++++++ .../publications/CommentPanel.svelte | 280 ++++++ .../publications/SectionComments.svelte | 323 +++++++ src/lib/utils/mockCommentData.ts | 177 ++++ src/lib/utils/nostrUtils.ts | 31 + tests/unit/commentButton.test.ts | 911 ++++++++++++++++++ 7 files changed, 2524 insertions(+) create mode 100644 src/lib/components/publications/CommentButton.svelte create mode 100644 src/lib/components/publications/CommentLayer.svelte create mode 100644 src/lib/components/publications/CommentPanel.svelte create mode 100644 src/lib/components/publications/SectionComments.svelte create mode 100644 src/lib/utils/mockCommentData.ts create mode 100644 tests/unit/commentButton.test.ts diff --git a/src/lib/components/publications/CommentButton.svelte b/src/lib/components/publications/CommentButton.svelte new file mode 100644 index 0000000..fb6ee25 --- /dev/null +++ b/src/lib/components/publications/CommentButton.svelte @@ -0,0 +1,520 @@ + + + +
+ + + + {#if showCommentUI} +
+
+

Add Comment

+ {#if $userStore.profile} + + {/if} +
+ + {:else} @@ -335,10 +337,9 @@ {#if isEditing} - toggleEditing(rootId, false)} - /> + {#snippet right()} + toggleEditing(rootId, false)} /> + {/snippet}
-
+
- {#if !content.trim()} -
- Start typing to see the preview... -
- {:else} -
- - {#if contentType === "article" && publicationResult?.metadata.title} - {@const documentHeader = content.split(/\n==\s+/)[0]} -
-
- {@html asciidoctor.convert(documentHeader, { - standalone: false, - attributes: { - showtitle: true, - sectids: false, - }, - })} + {#if !content.trim()} +
+ Start typing to see the preview... +
+ {:else} +
+ + {#if contentType === "article" && publicationResult?.metadata.title} + {@const documentHeader = content.split(/\n==\s+/)[0]} +
+
+ {@html asciidoctor.convert(documentHeader, { + standalone: false, + attributes: { + showtitle: true, + sectids: false, + }, + })} +
-
- {/if} - - {#each parsedSections as section, index} -
- {#if section.isIndex} - -
- -
- Index Event (30040) -
+ {/if} - -

- {section.title} -

- - - {#if section.tags && section.tags.length > 0} - {@const tTags = section.tags.filter((tag) => tag[0] === 't')} - {@const wTags = section.tags.filter((tag) => tag[0] === 'w')} - - {#if tTags.length > 0 || wTags.length > 0} -
- - {#if tTags.length > 0} -
- {#each tTags as tag} - - #{tag[1]} - - {/each} -
- {/if} + {#each parsedSections as section, index} +
+ {#if section.isIndex} + +
+ +
+ Index Event (30040) +
- - {#if wTags.length > 0} -
- {#each wTags as tag} - - ๐Ÿ”— {tag[2] || tag[1]} - - {/each} + +

+ {section.title} +

+ + + {#if section.tags && section.tags.length > 0} + {@const tTags = section.tags.filter( + (tag: any) => tag[0] === "t", + )} + {@const wTags = section.tags.filter( + (tag: any) => tag[0] === "w", + )} + + {#if tTags.length > 0 || wTags.length > 0} +
+ + {#if tTags.length > 0} +
+ {#each tTags as tag} + + #{tag[1]} + + {/each} +
+ {/if} + + + {#if wTags.length > 0} +
+ {#each wTags as tag} + + ๐Ÿ”— {tag[2] || tag[1]} + + {/each} +
+ {/if}
{/if} -
{/if} - {/if} -
- {:else} - -
- -
- Content Event (30041)
+ {:else} + +
+ +
+ Content Event (30041) +
- -
- {@html asciidoctor.convert( - `${"=".repeat(section.level)} ${section.title}`, - { - standalone: false, - attributes: { - showtitle: false, - sectids: false, + +
+ {@html asciidoctor.convert( + `${"=".repeat(section.level)} ${section.title}`, + { + standalone: false, + attributes: { + showtitle: false, + sectids: false, + }, }, - }, - )} -
- - - {#if section.tags && section.tags.length > 0} - {@const tTags = section.tags.filter((tag) => tag[0] === 't')} - {@const wTags = section.tags.filter((tag) => tag[0] === 'w')} - - {#if tTags.length > 0 || wTags.length > 0} -
- - {#if tTags.length > 0} -
- {#each tTags as tag} - - #{tag[1]} - - {/each} -
- {/if} + )} +
- - {#if wTags.length > 0} -
- {#each wTags as tag} - - ๐Ÿ”— {tag[2] || tag[1]} - - {/each} + + {#if section.tags && section.tags.length > 0} + {@const tTags = section.tags.filter( + (tag: any) => tag[0] === "t", + )} + {@const wTags = section.tags.filter( + (tag: any) => tag[0] === "w", + )} + + {#if tTags.length > 0 || wTags.length > 0} +
+ + {#if tTags.length > 0} +
+ {#each tTags as tag} + + #{tag[1]} + + {/each} +
+ {/if} + + + {#if wTags.length > 0} +
+ {#each wTags as tag} + + ๐Ÿ”— {tag[2] || tag[1]} + + {/each} +
+ {/if}
{/if} -
{/if} - {/if} - - {#if section.content} -
- {@html (() => { - // Extract wiki links and replace with placeholders BEFORE Asciidoctor - const wikiLinks = extractWikiLinks(section.content); - let contentWithPlaceholders = section.content; - const placeholders = new Map(); - - wikiLinks.forEach((link, index) => { - // Use a placeholder inside a passthrough macro - Asciidoctor will strip pass:[...] and leave the inner text - const innerPlaceholder = `WIKILINK${index}PLACEHOLDER`; - const placeholder = `pass:[${innerPlaceholder}]`; - placeholders.set(innerPlaceholder, link); // Store by inner placeholder (what will remain after Asciidoctor) - contentWithPlaceholders = contentWithPlaceholders.replace(link.fullMatch, placeholder); - }); - - // Check if content contains nested headers - const hasNestedHeaders = contentWithPlaceholders.includes('\n===') || contentWithPlaceholders.includes('\n===='); - - let rendered; - if (hasNestedHeaders) { - // For proper nested header parsing, we need full document context - // Create a complete AsciiDoc document structure - // Important: Ensure proper level sequence for nested headers - const fullDoc = `= Temporary Document\n\n${"=".repeat(section.level)} ${section.title}\n\n${contentWithPlaceholders}`; - - rendered = asciidoctor.convert(fullDoc, { - standalone: false, - attributes: { - showtitle: false, - sectids: false, - }, + + {#if section.content} +
+ {@html (() => { + // Extract wiki links and replace with placeholders BEFORE Asciidoctor + const wikiLinks = extractWikiLinks( + section.content, + ); + let contentWithPlaceholders = section.content; + const placeholders = new Map(); + + wikiLinks.forEach((link, index) => { + // Use a placeholder inside a passthrough macro - Asciidoctor will strip pass:[...] and leave the inner text + const innerPlaceholder = `WIKILINK${index}PLACEHOLDER`; + const placeholder = `pass:[${innerPlaceholder}]`; + placeholders.set(innerPlaceholder, link); // Store by inner placeholder (what will remain after Asciidoctor) + contentWithPlaceholders = + contentWithPlaceholders.replace( + link.fullMatch, + placeholder, + ); }); - // Extract just the content we want (remove the temporary structure) - // Find the section we care about - const sectionStart = rendered.indexOf(``, sectionStart); - if (nextSectionStart !== -1) { - // Get everything after our section header - const afterHeader = rendered.substring(nextSectionStart + ``.length); - // Find where the section ends (at the closing div) - const sectionEnd = afterHeader.lastIndexOf('
'); - if (sectionEnd !== -1) { - rendered = afterHeader.substring(0, sectionEnd); + // Check if content contains nested headers + const hasNestedHeaders = + contentWithPlaceholders.includes("\n===") || + contentWithPlaceholders.includes("\n===="); + + let rendered: string | Document; + if (hasNestedHeaders) { + // For proper nested header parsing, we need full document context + // Create a complete AsciiDoc document structure + // Important: Ensure proper level sequence for nested headers + const fullDoc = `= Temporary Document\n\n${"=".repeat(section.level)} ${section.title}\n\n${contentWithPlaceholders}`; + + rendered = asciidoctor.convert(fullDoc, { + standalone: false, + attributes: { + showtitle: false, + sectids: false, + }, + }); + + // Extract just the content we want (remove the temporary structure) + // Find the section we care about + const sectionStart = rendered + .toString() + .indexOf(``, + sectionStart, + ); + if (nextSectionStart !== -1) { + // Get everything after our section header + const afterHeader = rendered + .toString() + .substring( + nextSectionStart + + ``.length, + ); + // Find where the section ends (at the closing div) + const sectionEnd = + afterHeader.lastIndexOf("
"); + if (sectionEnd !== -1) { + rendered = afterHeader.substring( + 0, + sectionEnd, + ); + } } } + } else { + // Simple content without nested headers + rendered = asciidoctor.convert( + contentWithPlaceholders, + { + standalone: false, + attributes: { + showtitle: false, + sectids: false, + }, + }, + ); } - } else { - // Simple content without nested headers - rendered = asciidoctor.convert(contentWithPlaceholders, { - standalone: false, - attributes: { - showtitle: false, - sectids: false, - }, + + // Replace placeholders with actual wiki link HTML + // Use a global regex to catch all occurrences (Asciidoctor might have duplicated them) + placeholders.forEach((link, placeholder) => { + const className = + link.type === "auto" + ? "wiki-link wiki-link-auto" + : link.type === "w" + ? "wiki-link wiki-link-ref" + : "wiki-link wiki-link-def"; + + const title = + link.type === "w" + ? "Wiki reference (mentions this concept)" + : link.type === "d" + ? "Wiki definition (defines this concept)" + : "Wiki link (searches both references and definitions)"; + + const html = `${link.displayText}`; + + // Use global replace to handle all occurrences + const regex = new RegExp( + placeholder.replace( + /[.*+?^${}()|[\]\\]/g, + "\\$&", + ), + "g", + ); + rendered = rendered + .toString() + .replace(regex, html); }); - } - - // Replace placeholders with actual wiki link HTML - // Use a global regex to catch all occurrences (Asciidoctor might have duplicated them) - placeholders.forEach((link, placeholder) => { - const className = - link.type === 'auto' - ? 'wiki-link wiki-link-auto' - : link.type === 'w' - ? 'wiki-link wiki-link-ref' - : 'wiki-link wiki-link-def'; - - const title = - link.type === 'w' - ? 'Wiki reference (mentions this concept)' - : link.type === 'd' - ? 'Wiki definition (defines this concept)' - : 'Wiki link (searches both references and definitions)'; - - const html = `${link.displayText}`; - - // Use global replace to handle all occurrences - const regex = new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'); - rendered = rendered.replace(regex, html); - }); - - return rendered; - })()} -
- {/if} -
- {/if} - - {#if index < parsedSections.length - 1} -
-
-
+ return rendered; + })()} +
+ {/if}
-
- - Event Boundary - + {/if} + + + {#if index < parsedSections.length - 1} +
+
+
+
+
+ + Event Boundary + +
-
- {/if} -
- {/each} -
+ {/if} +
+ {/each} +
-
- Event Count: - {#if generatedEvents} - {@const indexEvents = generatedEvents.contentEvents.filter( - (e: any) => e.kind === 30040, - )} - {@const contentOnlyEvents = - generatedEvents.contentEvents.filter( - (e: any) => e.kind === 30041, +
+ Event Count: + {#if generatedEvents} + {@const indexEvents = generatedEvents.contentEvents.filter( + (e: any) => e.kind === 30040, )} - {@const totalIndexEvents = - indexEvents.length + (generatedEvents.indexEvent ? 1 : 0)} - {@const totalEvents = - totalIndexEvents + contentOnlyEvents.length} - {totalEvents} event{totalEvents !== 1 ? "s" : ""} - ({totalIndexEvents} index{totalIndexEvents !== 1 - ? " events" - : ""} + {contentOnlyEvents.length} content{contentOnlyEvents.length !== - 1 - ? " events" - : ""}) - {:else} - 0 events - {/if} -
- {/if} + {@const contentOnlyEvents = + generatedEvents.contentEvents.filter( + (e: any) => e.kind === 30041, + )} + {@const totalIndexEvents = + indexEvents.length + (generatedEvents.indexEvent ? 1 : 0)} + {@const totalEvents = + totalIndexEvents + contentOnlyEvents.length} + {totalEvents} event{totalEvents !== 1 ? "s" : ""} + ({totalIndexEvents} index{totalIndexEvents !== 1 + ? " events" + : ""} + {contentOnlyEvents.length} content{contentOnlyEvents.length !== + 1 + ? " events" + : ""}) + {:else} + 0 events + {/if} +
+ {/if}
@@ -1497,23 +1555,42 @@ Understanding the nature of knowledge...

  • - [[term]] - - Auto link (queries both w and d tags) + [[term]] + - Auto link (queries both w and d tags)
  • - [[w:term]] - - Reference/mention (backward link) + [[w:term]] + - Reference/mention (backward link)
  • - [[d:term]] - - Definition link (forward link) + [[d:term]] + - Definition link (forward link)
  • - Custom text: [[term|display text]] + Custom text: + [[term|display text]]

- Example: "The concept of [[Knowledge Graphs]] enables..." creates a w-tag automatically. + Example: "The concept of [[Knowledge Graphs]] enables..." + creates a w-tag automatically.

@@ -1591,7 +1668,7 @@ Understanding the nature of knowledge...
- {#snippet renderEventNode(node, depth = 0)} + {#snippet renderEventNode(node: any, depth = 0)}
{node.eventKind === 30040 ? "๐Ÿ“" : "๐Ÿ“„"} [{node.eventKind}] {node.title || "Untitled"} diff --git a/src/lib/components/publications/HighlightLayer.svelte b/src/lib/components/publications/HighlightLayer.svelte index c6f273e..48b00f6 100644 --- a/src/lib/components/publications/HighlightLayer.svelte +++ b/src/lib/components/publications/HighlightLayer.svelte @@ -1,5 +1,9 @@ {#if loading && visible} -
-

Loading highlights...

+
+

+ Loading highlights... +

{/if} {#if visible && highlights.length > 0} -
+

Highlights

@@ -707,19 +839,28 @@ class="w-3 h-3 rounded flex-shrink-0" style="background-color: {color};" >
- + {displayName} ({authorHighlights.length}) - + @@ -727,14 +868,18 @@ {#if isExpanded}
{#each sortedHighlights as highlight} - {@const truncated = useMockHighlights ? "test data" : truncateHighlight(highlight.content)} + {@const truncated = useMockHighlights + ? "test data" + : truncateHighlight(highlight.content)} {@const showCopied = copyFeedback === highlight.id}
@@ -744,12 +889,30 @@ title="Copy naddr" > {#if showCopied} - - + + {:else} - - + + {/if} @@ -776,8 +939,9 @@ animation: flash 1.5s ease-in-out; } - @keyframes :global(flash) { - 0%, 100% { + @keyframes -global-flash { + 0%, + 100% { filter: brightness(1); } 50% { diff --git a/src/lib/components/publications/HighlightSelectionHandler.svelte b/src/lib/components/publications/HighlightSelectionHandler.svelte index 1a17986..3e2efd6 100644 --- a/src/lib/components/publications/HighlightSelectionHandler.svelte +++ b/src/lib/components/publications/HighlightSelectionHandler.svelte @@ -73,7 +73,7 @@ tags: tags, content: selectedText, id: "", - sig: "" + sig: "", }; }); @@ -110,7 +110,7 @@ address: sectionAddress, eventId: sectionEventId, allDataAttrs: publicationSection.dataset, - sectionId: publicationSection.id + sectionId: publicationSection.id, }); currentSelection = selection; @@ -151,13 +151,14 @@ event.pubkey = $userStore.pubkey; // Set pubkey from user store // Use the specific section's address/ID if available, otherwise fall back to publication event - const useAddress = selectedSectionAddress || publicationEvent.tagAddress(); + const useAddress = + selectedSectionAddress || publicationEvent.tagAddress(); const useEventId = selectedSectionEventId || publicationEvent.id; console.log("[HighlightSelectionHandler] Creating highlight with:", { address: useAddress, eventId: useEventId, - fallbackUsed: !selectedSectionAddress + fallbackUsed: !selectedSectionAddress, }); const tags: string[][] = []; @@ -202,7 +203,11 @@ content: String(event.content), }; - if (typeof window !== "undefined" && window.nostr && window.nostr.signEvent) { + if ( + typeof window !== "undefined" && + window.nostr && + window.nostr.signEvent + ) { const signed = await window.nostr.signEvent(plainEvent); event.sig = signed.sig; if ("id" in signed) { @@ -222,7 +227,10 @@ // Remove duplicates const uniqueRelays = Array.from(new Set(relays)); - console.log("[HighlightSelectionHandler] Publishing to relays:", uniqueRelays); + console.log( + "[HighlightSelectionHandler] Publishing to relays:", + uniqueRelays, + ); const signedEvent = { ...plainEvent, @@ -248,11 +256,15 @@ clearTimeout(timeout); if (ok) { publishedCount++; - console.log(`[HighlightSelectionHandler] Published to ${relayUrl}`); + console.log( + `[HighlightSelectionHandler] Published to ${relayUrl}`, + ); WebSocketPool.instance.release(ws); resolve(); } else { - console.warn(`[HighlightSelectionHandler] ${relayUrl} rejected: ${message}`); + console.warn( + `[HighlightSelectionHandler] ${relayUrl} rejected: ${message}`, + ); WebSocketPool.instance.release(ws); reject(new Error(message)); } @@ -263,7 +275,10 @@ ws.send(JSON.stringify(["EVENT", signedEvent])); }); } catch (e) { - console.error(`[HighlightSelectionHandler] Failed to publish to ${relayUrl}:`, e); + console.error( + `[HighlightSelectionHandler] Failed to publish to ${relayUrl}:`, + e, + ); } } @@ -271,7 +286,10 @@ throw new Error("Failed to publish to any relays"); } - showFeedbackMessage(`Highlight created and published to ${publishedCount} relay(s)!`, "success"); + showFeedbackMessage( + `Highlight created and published to ${publishedCount} relay(s)!`, + "success", + ); // Clear the selection if (currentSelection) { @@ -294,7 +312,10 @@ } } catch (error) { console.error("Failed to create highlight:", error); - showFeedbackMessage("Failed to create highlight. Please try again.", "error"); + showFeedbackMessage( + "Failed to create highlight. Please try again.", + "error", + ); } finally { isSubmitting = false; } @@ -349,11 +370,18 @@ {#if showConfirmModal} - +

Selected Text:

-
+

"{selectedText}"

@@ -366,16 +394,21 @@ id="comment" bind:value={comment} placeholder="Share your thoughts about this highlight..." - rows="3" + rows={3} class="w-full" />
{#if showJsonPreview && previewJson} -
+

Event JSON Preview:

-
{JSON.stringify(previewJson, null, 2)}
+
{JSON.stringify(previewJson, null, 2)}
{/if} @@ -383,7 +416,7 @@
- -
@@ -409,7 +450,9 @@ {#if showFeedback}
diff --git a/src/lib/components/publications/Publication.svelte b/src/lib/components/publications/Publication.svelte index 422abf6..fdfc7b1 100644 --- a/src/lib/components/publications/Publication.svelte +++ b/src/lib/components/publications/Publication.svelte @@ -7,7 +7,8 @@ SidebarGroup, SidebarWrapper, Heading, - CloseButton, uiHelpers + CloseButton, + uiHelpers, } from "flowbite-svelte"; import { getContext, onDestroy, onMount } from "svelte"; import { @@ -37,13 +38,14 @@ import { Textarea, P } from "flowbite-svelte"; import { userStore } from "$lib/stores/userStore"; - let { rootAddress, publicationType, indexEvent, publicationTree, toc } = $props<{ - rootAddress: string; - publicationType: string; - indexEvent: NDKEvent; - publicationTree: SveltePublicationTree; - toc: TocType; - }>(); + let { rootAddress, publicationType, indexEvent, publicationTree, toc } = + $props<{ + rootAddress: string; + publicationType: string; + indexEvent: NDKEvent; + publicationTree: SveltePublicationTree; + toc: TocType; + }>(); const ndk = getNdkContext(); @@ -64,23 +66,25 @@ // Toggle between mock and real data for testing (DEBUG MODE) // Can be controlled via VITE_USE_MOCK_COMMENTS and VITE_USE_MOCK_HIGHLIGHTS environment variables - let useMockComments = $state(import.meta.env.VITE_USE_MOCK_COMMENTS === "true"); - let useMockHighlights = $state(import.meta.env.VITE_USE_MOCK_HIGHLIGHTS === "true"); + let useMockComments = $state( + import.meta.env.VITE_USE_MOCK_COMMENTS === "true", + ); + let useMockHighlights = $state( + import.meta.env.VITE_USE_MOCK_HIGHLIGHTS === "true", + ); // Log initial state for debugging - console.log('[Publication] Mock data initialized:', { - useMockComments, - useMockHighlights, + console.log("[Publication] Mock data initialized:", { envVars: { VITE_USE_MOCK_COMMENTS: import.meta.env.VITE_USE_MOCK_COMMENTS, VITE_USE_MOCK_HIGHLIGHTS: import.meta.env.VITE_USE_MOCK_HIGHLIGHTS, - } + }, }); // Derive all event IDs and addresses for highlight fetching let allEventIds = $derived.by(() => { const ids = [indexEvent.id]; - leaves.forEach(leaf => { + leaves.forEach((leaf) => { if (leaf?.id) ids.push(leaf.id); }); return ids; @@ -88,7 +92,7 @@ let allEventAddresses = $derived.by(() => { const addresses = [rootAddress]; - leaves.forEach(leaf => { + leaves.forEach((leaf) => { if (leaf) { const addr = leaf.tagAddress(); if (addr) addresses.push(addr); @@ -99,11 +103,11 @@ // Filter comments for the root publication (kind 30040) let articleComments = $derived( - comments.filter(comment => { + comments.filter((comment) => { // Check if comment targets the root publication via #a tag - const aTag = comment.tags.find(t => t[0] === 'a'); + const aTag = comment.tags.find((t) => t[0] === "a"); return aTag && aTag[1] === rootAddress; - }) + }), ); // #region Loading @@ -124,9 +128,11 @@ console.warn("[Publication] publicationTree is not available"); return; } - - console.log(`[Publication] Loading ${count} more events. Current leaves: ${leaves.length}, loaded addresses: ${loadedAddresses.size}`); - + + console.log( + `[Publication] Loading ${count} more events. Current leaves: ${leaves.length}, loaded addresses: ${loadedAddresses.size}`, + ); + isLoading = true; try { @@ -159,7 +165,9 @@ console.error("[Publication] Error loading more content:", error); } finally { isLoading = false; - console.log(`[Publication] Finished loading. Total leaves: ${leaves.length}, loaded addresses: ${loadedAddresses.size}`); + console.log( + `[Publication] Finished loading. Total leaves: ${leaves.length}, loaded addresses: ${loadedAddresses.size}`, + ); } } @@ -196,12 +204,12 @@ lastElementRef = null; loadedAddresses = new Set(); hasInitialized = false; - + // Reset the publication tree iterator to prevent duplicate events - if (typeof publicationTree.resetIterator === 'function') { + if (typeof publicationTree.resetIterator === "function") { publicationTree.resetIterator(); } - + // AI-NOTE: Use setTimeout to ensure iterator reset completes before loading // This prevents race conditions where loadMore is called before the iterator is fully reset setTimeout(() => { @@ -298,7 +306,9 @@ const kind = parseInt(kindStr); // Create comment event (kind 1111) - const commentEvent = new (await import("@nostr-dev-kit/ndk")).NDKEvent(ndk); + const commentEvent = new (await import("@nostr-dev-kit/ndk")).NDKEvent( + ndk, + ); commentEvent.kind = 1111; commentEvent.content = articleCommentContent; @@ -330,10 +340,10 @@ articleCommentSuccess = false; handleCommentPosted(); }, 1500); - } catch (err) { console.error("[Publication] Error posting article comment:", err); - articleCommentError = err instanceof Error ? err.message : "Failed to post comment"; + articleCommentError = + err instanceof Error ? err.message : "Failed to post comment"; } finally { isSubmittingArticleComment = false; } @@ -344,30 +354,36 @@ */ async function handleDeletePublication() { const confirmed = confirm( - "Are you sure you want to delete this entire publication? This action will publish a deletion request to all relays." + "Are you sure you want to delete this entire publication? This action will publish a deletion request to all relays.", ); if (!confirmed) return; try { - await deleteEvent({ - eventAddress: indexEvent.tagAddress(), - eventKind: indexEvent.kind, - reason: "User deleted publication", - onSuccess: (deletionEventId) => { - console.log("[Publication] Deletion event published:", deletionEventId); - publicationDeleted = true; - - // Redirect after 2 seconds - setTimeout(() => { - goto("/publications"); - }, 2000); + await deleteEvent( + { + eventAddress: indexEvent.tagAddress(), + eventKind: indexEvent.kind, + reason: "User deleted publication", + onSuccess: (deletionEventId) => { + console.log( + "[Publication] Deletion event published:", + deletionEventId, + ); + publicationDeleted = true; + + // Redirect after 2 seconds + setTimeout(() => { + goto("/publications"); + }, 2000); + }, + onError: (error) => { + console.error("[Publication] Failed to delete publication:", error); + alert(`Failed to delete publication: ${error}`); + }, }, - onError: (error) => { - console.error("[Publication] Failed to delete publication:", error); - alert(`Failed to delete publication: ${error}`); - }, - }); + ndk, + ); } catch (error) { console.error("[Publication] Error deleting publication:", error); alert(`Error: ${error}`); @@ -422,14 +438,19 @@ observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { - if (entry.isIntersecting && !isLoading && !isDone && publicationTree) { + if ( + entry.isIntersecting && + !isLoading && + !isDone && + publicationTree + ) { loadMore(1); } }); }, { threshold: 0.5 }, ); - + // AI-NOTE: Removed duplicate loadMore call // Initial content loading is handled by the $effect that watches publicationTree // This prevents duplicate loading when both onMount and $effect trigger @@ -450,14 +471,11 @@ -
+
- - +
-
+
{#if publicationType !== "blog" && !isLeaf} {#if $publicationColumnVisibility.toc} - + publicationTree.setBookmark(address)} + onSectionFocused={(address: string) => + publicationTree.setBookmark(address)} onLoadMore={() => { - if (!isLoading && !isDone && publicationTree) { - loadMore(4); - } - }} + if (!isLoading && !isDone && publicationTree) { + loadMore(4); + } + }} /> - {/if} {/if} -
{#if $publicationColumnVisibility.main} -
+
@@ -521,7 +549,10 @@
-
+
{#if publicationDeleted} @@ -542,7 +573,9 @@
-