Browse Source

fix author tags on ZettelEditor and write test

master
silberengel 8 months ago
parent
commit
3a275c510c
  1. 52
      src/lib/components/EventInput.svelte
  2. 239
      src/lib/components/ZettelEditor.svelte
  3. 90
      src/lib/utils/ZettelParser.ts
  4. 142
      src/routes/new/compose/+page.svelte
  5. 429
      tests/unit/ZettelEditor.test.ts

52
src/lib/components/EventInput.svelte

@ -48,6 +48,38 @@ @@ -48,6 +48,38 @@
let warningMessage = $state("");
let pendingPublish = $state(false);
let extractedMetadata = $state<[string, string][]>([]);
let hasLoadedFromStorage = $state(false);
// Load content from sessionStorage if available (from ZettelEditor)
$effect(() => {
if (hasLoadedFromStorage) return; // Prevent multiple loads
const storedContent = sessionStorage.getItem('zettelEditorContent');
const storedSource = sessionStorage.getItem('zettelEditorSource');
if (storedContent && storedSource === 'publication-format') {
content = storedContent;
hasLoadedFromStorage = true;
// Clear the stored content after loading
sessionStorage.removeItem('zettelEditorContent');
sessionStorage.removeItem('zettelEditorSource');
// Extract title from content
const extracted = extractTitleFromContent(content);
if (extracted) {
title = extracted;
titleManuallyEdited = false;
dTagManuallyEdited = false;
}
// For content from ZettelEditor, don't extract any metadata
// since ZettelEditor content never has document metadata
if (kind === 30040 || kind === 30041) {
extractedMetadata = [];
}
}
});
/**
* Extracts the first Markdown/AsciiDoc header as the title.
@ -57,10 +89,14 @@ @@ -57,10 +89,14 @@
// Look for document title (=) first, then fall back to section headers (==)
const documentMatch = content.match(/^=\s*(.+)$/m);
if (documentMatch) {
return documentMatch[1].trim();
const title = documentMatch[1].trim();
// Only return the title if it's not empty (malformed titles like "=|" will be empty)
if (title) {
return title;
}
}
// If no document title, look for the first section header
// If no valid document title, look for the first section header
const sectionMatch = content.match(/^==\s*(.+)$/m);
if (sectionMatch) {
return sectionMatch[1].trim();
@ -81,16 +117,8 @@ @@ -81,16 +117,8 @@
// Extract metadata from AsciiDoc content for 30040 and 30041 events
if (kind === 30040 || kind === 30041) {
try {
const { metadata } = extractDocumentMetadata(content);
const metadataTags = metadataToTags(metadata);
extractedMetadata = metadataTags;
console.log("Extracted metadata:", metadata);
console.log("Metadata tags:", metadataTags);
} catch (error) {
console.error("Error extracting metadata:", error);
extractedMetadata = [];
}
// Don't extract metadata - let users add tags manually
extractedMetadata = [];
} else {
extractedMetadata = [];
}

239
src/lib/components/ZettelEditor.svelte

@ -5,19 +5,33 @@ @@ -5,19 +5,33 @@
parseAsciiDocSections,
type ZettelSection,
} from "$lib/utils/ZettelParser";
import {
extractDocumentMetadata,
extractSectionMetadata,
parseAsciiDocWithMetadata,
type AsciiDocMetadata,
metadataToTags,
} from "$lib/utils/asciidoc_metadata";
import asciidoctor from "asciidoctor";
// Component props
let {
content = "",
placeholder = `== Note Title
:author: {author} // author is optional
:tags: tag1, tag2, tag3 // tags are optional
: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
:image: https://example.com/image.jpg
note content here...
== Note Title 2
:tags: tag1, tag2, tag3
Some Other Author (this weeks 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...
`,
showPreview = false,
@ -34,8 +48,63 @@ Note content here... @@ -34,8 +48,63 @@ Note content here...
// Initialize AsciiDoctor processor
const asciidoctorProcessor = asciidoctor();
// Parse sections for preview
let parsedSections = $derived(parseAsciiDocSections(content, 2));
// Parse sections for preview using the new metadata service
let parsedSections = $derived.by(() => {
if (!content.trim()) return [];
// Check if content starts with a document header (level 0 header)
const hasDocumentHeader = content.match(/^=\s+/m);
let sections;
if (hasDocumentHeader) {
// Use the proper metadata service for documents with headers
const parsed = parseAsciiDocWithMetadata(content);
sections = parsed.sections;
} else {
// For content that starts directly with sections, split manually
const sectionStrings = content.split(/(?=^==\s+)/gm).filter((section: string) => section.trim());
sections = sectionStrings.map((sectionString: string) => {
const { metadata, content, title } = extractSectionMetadata(sectionString);
return { metadata, content, title };
});
}
// Debug logging
console.log("Parsed sections:", sections);
return sections.map((section: { metadata: AsciiDocMetadata; content: string; title: string }) => {
// Use only section metadata for each section
// Don't combine with document metadata to avoid overriding section-specific metadata
const tags = metadataToTags(section.metadata);
// Debug logging
console.log(`Section "${section.title}":`, { metadata: section.metadata, tags });
return {
title: section.title || "Untitled",
content: section.content.trim(),
tags,
};
});
});
// Check for 30040-style document headers (publication format)
let hasPublicationHeader = $derived.by(() => {
if (!content.trim()) return false;
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;
}
}
return false;
});
// Toggle preview panel
function togglePreview() {
@ -51,34 +120,87 @@ Note content here... @@ -51,34 +120,87 @@ Note content here...
</script>
<div class="flex flex-col space-y-4">
<!-- Informative text about ZettelEditor purpose -->
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-4">
<div class="flex items-start space-x-3">
<div class="flex-shrink-0">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
<!-- Error message for publication format -->
{#if hasPublicationHeader}
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-4">
<div class="flex items-start space-x-3">
<div class="flex-shrink-0">
<svg class="w-5 h-5 text-red-600 dark:text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
</div>
<div class="flex-1">
<h3 class="text-sm font-medium text-red-800 dark:text-red-200 mb-1">
Publication Format Detected
</h3>
<p class="text-sm text-red-700 dark:text-red-300 mb-3">
You're using a publication format (document title with <code>=</code> or "index card").
This editor is for individual notes only. Use the
<a href="/events?kind=30040" class="font-medium underline hover:text-red-600 dark:hover:text-red-400">Events form</a>
to create structured publications.
</p>
<div class="flex space-x-2">
<a
href="/events?kind=30040"
onclick={() => {
// Store the content in sessionStorage so it can be loaded in the Events form
sessionStorage.setItem('zettelEditorContent', content);
sessionStorage.setItem('zettelEditorSource', 'publication-format');
}}
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-red-700 dark:text-red-300 bg-red-100 dark:bg-red-800 border border-red-200 dark:border-red-700 rounded-md hover:bg-red-200 dark:hover:bg-red-700 transition-colors"
>
Switch to Publication Editor
</a>
<button
onclick={() => {
// Remove publication format by converting document title to section title
let convertedContent = content.replace(/^=\s+(.+)$/gm, '== $1');
// Remove "index card" line (case insensitive)
convertedContent = convertedContent.replace(/^index card$/gim, '');
// Clean up any double newlines that might result
const finalContent = convertedContent.replace(/\n\s*\n\s*\n/g, '\n\n');
// Update content through the prop callback
onContentChange(finalContent);
}}
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-red-700 dark:text-red-300 bg-red-100 dark:bg-red-800 border border-red-200 dark:border-red-700 rounded-md hover:bg-red-200 dark:hover:bg-red-700 transition-colors"
>
Convert to Notes Format
</button>
</div>
</div>
</div>
<div class="flex-1">
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200 mb-1">
Note-Taking Tool
</h3>
<p class="text-sm text-blue-700 dark:text-blue-300 mb-3">
This editor is for creating individual notes (30041 events) only. Each section becomes a separate note event.
To create structured publications with a 30040 index event that ties multiple notes together,
use the <a href="/events?kind=30040" class="font-medium underline hover:text-blue-600 dark:hover:text-blue-400">Events form</a>.
</p>
<div class="flex space-x-2">
<a
href="/events?kind=30040"
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-800 border border-blue-200 dark:border-blue-700 rounded-md hover:bg-blue-200 dark:hover:bg-blue-700 transition-colors"
>
Create Publication
</a>
</div>
{:else}
<!-- Informative text about ZettelEditor purpose -->
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-4">
<div class="flex items-start space-x-3">
<div class="flex-shrink-0">
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 00-1-1H9z" clip-rule="evenodd"></path>
</svg>
</div>
<div class="flex-1">
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-200 mb-1">
Note-Taking Tool
</h3>
<p class="text-sm text-blue-700 dark:text-blue-300 mb-3">
This editor is for creating individual notes (30041 events) only. Each section becomes a separate note event.
You can add metadata like author, version, publication date, summary, and tags using AsciiDoc attributes.
To create structured publications with a 30040 index event that ties multiple notes together,
use the <a href="/events?kind=30040" class="font-medium underline hover:text-blue-600 dark:hover:text-blue-400">Events form</a>.
</p>
<div class="flex space-x-2">
<a
href="/events?kind=30040"
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-800 border border-blue-200 dark:border-blue-700 rounded-md hover:bg-blue-200 dark:hover:bg-blue-700 transition-colors"
>
Create Publication
</a>
</div>
</div>
</div>
</div>
</div>
{/if}
<div class="flex items-center justify-between">
<Button
@ -86,6 +208,7 @@ Note content here... @@ -86,6 +208,7 @@ Note content here...
size="sm"
on:click={togglePreview}
class="flex items-center space-x-1"
disabled={hasPublicationHeader}
>
{#if showPreview}
<EyeOutline class="w-4 h-4" />
@ -105,14 +228,15 @@ Note content here... @@ -105,14 +228,15 @@ Note content here...
bind:value={content}
on:input={handleContentChange}
{placeholder}
class="h-full min-h-64 resize-none"
class="h-full min-h-64 resize-none {hasPublicationHeader ? 'opacity-50 cursor-not-allowed' : ''}"
rows={12}
disabled={hasPublicationHeader}
/>
</div>
</div>
<!-- Preview Panel -->
{#if showPreview}
{#if showPreview && !hasPublicationHeader}
<div class="w-1/2 border-l border-gray-200 dark:border-gray-700 pl-4">
<div class="sticky top-4">
<h3
@ -148,33 +272,33 @@ Note content here... @@ -148,33 +272,33 @@ Note content here...
)}
</div>
{#if index < parsedSections.length - 1}
<!-- Gray area with tag bubbles above event boundary -->
<div class="my-4 relative">
<!-- Gray background area -->
<div
class="bg-gray-200 dark:bg-gray-700 rounded-lg p-3 mb-2"
>
<div class="flex flex-wrap gap-2 items-center">
{#if section.tags && section.tags.length > 0}
{#each section.tags as tag}
<div
class="bg-amber-900 text-amber-100 px-2 py-1 rounded-full text-xs font-medium flex items-baseline"
>
<span class="font-mono">{tag[0]}:</span>
<span>{tag[1]}</span>
</div>
{/each}
{:else}
<span
class="text-gray-500 dark:text-gray-400 text-xs italic"
>No tags</span
<!-- Gray area with tag bubbles for all sections -->
<div class="my-4 relative">
<!-- Gray background area -->
<div
class="bg-gray-200 dark:bg-gray-700 rounded-lg p-3 mb-2"
>
<div class="flex flex-wrap gap-2 items-center">
{#if section.tags && section.tags.length > 0}
{#each section.tags as tag}
<div
class="bg-amber-900 text-amber-100 px-2 py-1 rounded-full text-xs font-medium flex items-baseline"
>
{/if}
</div>
<span class="font-mono">{tag[0]}:</span>
<span>{tag[1]}</span>
</div>
{/each}
{:else}
<span
class="text-gray-500 dark:text-gray-400 text-xs italic"
>No tags</span
>
{/if}
</div>
</div>
<!-- Event boundary line -->
{#if index < parsedSections.length - 1}
<!-- Event boundary line only between sections -->
<div
class="border-t-2 border-dashed border-blue-400 relative"
>
@ -184,8 +308,8 @@ Note content here... @@ -184,8 +308,8 @@ Note content here...
Event Boundary
</div>
</div>
</div>
{/if}
{/if}
</div>
</div>
{/each}
</div>
@ -198,7 +322,6 @@ Note content here... @@ -198,7 +322,6 @@ Note content here...
? "s"
: ""}
<br />
<strong>Note:</strong> Currently only the first event will be published.
</div>
{/if}
</div>

90
src/lib/utils/ZettelParser.ts

@ -32,21 +32,40 @@ export function parseZettelSection(section: string): ZettelSection { @@ -32,21 +32,40 @@ export function parseZettelSection(section: string): ZettelSection {
const lines = section.split("\n");
let title = "Untitled";
const contentLines: string[] = [];
let inHeader = true;
let tags: string[][] = [];
tags = extractTags(section);
for (const line of lines) {
// Find the section title first
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
if (inHeader && trimmed.startsWith("==")) {
if (trimmed.startsWith("==")) {
title = trimmed.replace(/^==+/, "").trim();
continue;
} else if (inHeader && trimmed.startsWith(":")) {
continue;
// Process header metadata (everything after title until blank line)
let j = i + 1;
while (j < lines.length && lines[j].trim() !== "") {
const headerLine = lines[j].trim();
if (headerLine.startsWith(":")) {
// This is metadata, already handled by extractTags
j++;
} else {
// This is header content (like author name), skip from content
j++;
}
}
// Skip the blank line
if (j < lines.length && lines[j].trim() === "") {
j++;
}
// Everything after the blank line is content
for (let k = j; k < lines.length; k++) {
contentLines.push(lines[k]);
}
break;
}
inHeader = false;
contentLines.push(line);
}
return {
@ -69,6 +88,7 @@ export function parseAsciiDocSections( @@ -69,6 +88,7 @@ export function parseAsciiDocSections(
/**
* Extracts tag names and values from the content.
* :tagname: tagvalue // tags are optional
* Also handles AsciiDoc author line convention
* @param content The AsciiDoc string.
* @returns Array of tags.
*/
@ -76,30 +96,46 @@ export function extractTags(content: string): string[][] { @@ -76,30 +96,46 @@ export function extractTags(content: string): string[][] {
const tags: string[][] = [];
const lines = content.split("\n");
for (const line of lines) {
// Find the section title and process header metadata
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trim();
if (trimmed.startsWith(":")) {
// Parse AsciiDoc attribute format: :tagname: value
const match = trimmed.match(/^:([^:]+):\s*(.*)$/);
if (match) {
const tagName = match[1].trim();
const tagValue = match[2].trim();
if (trimmed.startsWith("==")) {
// Process header metadata (everything after title until blank line)
let j = i + 1;
while (j < lines.length && lines[j].trim() !== "") {
const headerLine = lines[j].trim();
if (headerLine.startsWith(":")) {
// Parse AsciiDoc attribute format: :tagname: value
const match = headerLine.match(/^:([^:]+):\s*(.*)$/);
if (match) {
const tagName = match[1].trim();
const tagValue = match[2].trim();
// Special handling for tags attribute
if (tagName === "tags") {
// Split comma-separated values and create individual "t" tags
const tagValues = tagValue
.split(",")
.map((v) => v.trim())
.filter((v) => v.length > 0);
for (const value of tagValues) {
tags.push(["t", value]);
// Special handling for tags attribute
if (tagName === "tags") {
// Split comma-separated values and create individual "t" tags
const tagValues = tagValue
.split(",")
.map((v) => v.trim())
.filter((v) => v.length > 0);
for (const value of tagValues) {
tags.push(["t", value]);
}
} else {
// Regular attribute becomes a tag
tags.push([tagName, tagValue]);
}
}
} else {
// Regular attribute becomes a tag
tags.push([tagName, tagValue]);
// This is header content (like author name)
tags.push(["author", headerLine]);
}
j++;
}
break;
}
}

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

@ -5,6 +5,7 @@ @@ -5,6 +5,7 @@
import { goto } from "$app/navigation";
import { nip19 } from "nostr-tools";
import { publishMultipleZettels } from "$lib/services/publisher";
import { parseAsciiDocSections } from "$lib/utils/ZettelParser";
let content = $state("");
let showPreview = $state(false);
@ -13,6 +14,8 @@ @@ -13,6 +14,8 @@
successCount: number;
total: number;
errors: string[];
successfulEvents: Array<{ eventId: string; title: string }>;
failedEvents: Array<{ title: string; error: string; sectionIndex: number }>;
} | null>(null);
// Handle content changes from ZettelEditor
@ -33,19 +36,91 @@ @@ -33,19 +36,91 @@
content,
onError: (error) => {
// Only used for catastrophic errors
publishResults = { successCount: 0, total: 0, errors: [error] };
publishResults = { successCount: 0, total: 0, errors: [error], successfulEvents: [], failedEvents: [] };
},
});
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 sections = parseAsciiDocSections(content, 2);
const successfulEvents = results
.filter(r => r.success && r.eventId)
.map((r, index) => ({
eventId: r.eventId!,
title: sections[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: sections[index]?.title || `Note ${index + 1}`,
error: result.error || 'Unknown error',
sectionIndex: index
}));
publishResults = {
successCount,
total: results.length,
errors,
successfulEvents,
failedEvents,
};
isPublishing = false;
}
async function retryFailedEvent(sectionIndex: number) {
if (!publishResults) return;
isPublishing = true;
// Get the specific section content
const sections = parseAsciiDocSections(content, 2);
const section = 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,
};
}
} catch (error) {
console.error('Retry failed:', error);
}
isPublishing = false;
}
</script>
<svelte:head>
@ -89,14 +164,71 @@ @@ -89,14 +164,71 @@
<Alert color="green" dismissable>
<span class="font-medium">Success!</span>
{publishResults.successCount} events published.
{#if publishResults.successfulEvents.length > 0}
<div class="mt-2">
<span class="text-sm font-medium">Published events:</span>
<div class="mt-1 space-y-1">
{#each publishResults.successfulEvents as event}
{@const nevent = nip19.neventEncode({ id: event.eventId })}
<div class="text-sm">
<a
href="/events?id={encodeURIComponent(event.eventId)}"
class="text-blue-600 dark:text-blue-400 hover:underline font-mono"
>
{event.title} ({nevent})
</a>
</div>
{/each}
</div>
</div>
{/if}
</Alert>
{:else}
<Alert color="red" dismissable>
<span class="font-medium">Some events failed to publish.</span>
{publishResults.successCount} of {publishResults.total} events published.<br />
{#each publishResults.errors as error}
<div>{error}</div>
{/each}
{publishResults.successCount} of {publishResults.total} events published.
{#if publishResults.successfulEvents.length > 0}
<div class="mt-2">
<span class="text-sm font-medium">Successfully published:</span>
<div class="mt-1 space-y-1">
{#each publishResults.successfulEvents as event}
{@const nevent = nip19.neventEncode({ id: event.eventId })}
<div class="text-sm">
<a
href="/events?id={encodeURIComponent(event.eventId)}"
class="text-blue-600 dark:text-blue-400 hover:underline font-mono"
>
{event.title} ({nevent})
</a>
</div>
{/each}
</div>
</div>
{/if}
{#if publishResults.failedEvents.length > 0}
<div class="mt-2">
<span class="text-sm font-medium">Failed to publish:</span>
<div class="mt-1 space-y-2">
{#each publishResults.failedEvents as failedEvent, index}
<div class="text-sm bg-red-50 dark:bg-red-900/20 p-2 rounded">
<div class="font-medium">{failedEvent.title}</div>
<div class="text-red-600 dark:text-red-400 text-xs">{failedEvent.error}</div>
<Button
size="xs"
color="light"
onclick={() => retryFailedEvent(failedEvent.sectionIndex)}
disabled={isPublishing}
class="mt-1"
>
{isPublishing ? 'Retrying...' : 'Retry'}
</Button>
</div>
{/each}
</div>
</div>
{/if}
</Alert>
{/if}
{/if}

429
tests/unit/ZettelEditor.test.ts

@ -0,0 +1,429 @@ @@ -0,0 +1,429 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { AsciiDocMetadata } from "../../src/lib/utils/asciidoc_metadata";
// Mock all Svelte components and dependencies
vi.mock("flowbite-svelte", () => ({
Textarea: vi.fn().mockImplementation((props) => {
return {
$$render: () => `<textarea data-testid="textarea" class="${props.class || ''}" rows="${props.rows || 12}" ${props.disabled ? 'disabled' : ''} placeholder="${props.placeholder || ''}"></textarea>`,
$$bind: { value: props.bind, oninput: props.oninput }
};
}),
Button: vi.fn().mockImplementation((props) => {
return {
$$render: () => `<button data-testid="preview-button" class="${props.class || ''}" ${props.disabled ? 'disabled' : ''} onclick="${props.onclick || ''}">${props.children || ''}</button>`,
$$bind: { onclick: props.onclick }
};
})
}));
vi.mock("flowbite-svelte-icons", () => ({
EyeOutline: vi.fn().mockImplementation(() => ({
$$render: () => `<svg data-testid="eye-icon"></svg>`
}))
}));
vi.mock("asciidoctor", () => ({
default: vi.fn(() => ({
convert: vi.fn((content, options) => {
// Mock AsciiDoctor conversion - return simple HTML
return content.replace(/^==\s+(.+)$/gm, '<h2>$1</h2>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>');
})
}))
}));
// Mock sessionStorage
const mockSessionStorage = {
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
};
Object.defineProperty(global, 'sessionStorage', {
value: mockSessionStorage,
writable: true
});
// Mock window object for DOM manipulation
Object.defineProperty(global, 'window', {
value: {
sessionStorage: mockSessionStorage,
document: {
querySelector: vi.fn(),
createElement: vi.fn(),
}
},
writable: true
});
// Mock DOM methods
const mockQuerySelector = vi.fn();
const mockCreateElement = vi.fn();
const mockAddEventListener = vi.fn();
const mockRemoveEventListener = vi.fn();
Object.defineProperty(global, 'document', {
value: {
querySelector: mockQuerySelector,
createElement: mockCreateElement,
addEventListener: mockAddEventListener,
removeEventListener: mockRemoveEventListener,
},
writable: true
});
describe("ZettelEditor Component Logic", () => {
let mockOnContentChange: ReturnType<typeof vi.fn>;
let mockOnPreviewToggle: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.clearAllMocks();
mockOnContentChange = vi.fn();
mockOnPreviewToggle = vi.fn();
});
afterEach(() => {
vi.clearAllMocks();
});
describe("Publication Format Detection Logic", () => {
it("should detect document header format", () => {
const contentWithDocumentHeader = "= Document Title\n\n== Section 1\nContent";
// Test the regex pattern used in the component
const hasDocumentHeader = contentWithDocumentHeader.match(/^=\s+/m);
expect(hasDocumentHeader).toBeTruthy();
});
it("should detect index card format", () => {
const contentWithIndexCard = "index card\n\n== Section 1\nContent";
// Test the logic used in the component
const lines = contentWithIndexCard.split(/\r?\n/);
let hasIndexCard = false;
for (const line of lines) {
if (line.trim().toLowerCase() === 'index card') {
hasIndexCard = true;
break;
}
}
expect(hasIndexCard).toBe(true);
});
it("should not detect publication format for normal section content", () => {
const normalContent = "== Section 1\nContent\n\n== Section 2\nMore content";
// Test the logic used in the component
const lines = normalContent.split(/\r?\n/);
let hasPublicationHeader = false;
for (const line of lines) {
if (line.match(/^=\s+(.+)$/)) {
hasPublicationHeader = true;
break;
}
if (line.trim().toLowerCase() === 'index card') {
hasPublicationHeader = true;
break;
}
}
expect(hasPublicationHeader).toBe(false);
});
});
describe("Content Parsing Logic", () => {
it("should parse sections with document header", () => {
const content = "== Section 1\n:author: Test Author\n\nContent 1";
// Test the parsing logic
const hasDocumentHeader = content.match(/^=\s+/m);
expect(hasDocumentHeader).toBeFalsy(); // This content doesn't have a document header
// Test section splitting logic
const sectionStrings = content.split(/(?=^==\s+)/gm).filter((section: string) => section.trim());
expect(sectionStrings).toHaveLength(1);
expect(sectionStrings[0]).toContain("== Section 1");
});
it("should parse sections without document header", () => {
const content = "== Section 1\nContent 1";
// Test the parsing logic
const hasDocumentHeader = content.match(/^=\s+/m);
expect(hasDocumentHeader).toBeFalsy();
// Test section splitting logic
const sectionStrings = content.split(/(?=^==\s+)/gm).filter((section: string) => section.trim());
expect(sectionStrings).toHaveLength(1);
expect(sectionStrings[0]).toContain("== Section 1");
});
it("should handle empty content", () => {
const content = "";
const hasDocumentHeader = content.match(/^=\s+/m);
expect(hasDocumentHeader).toBeFalsy();
});
});
describe("Content Conversion Logic", () => {
it("should convert document title to section title", () => {
const contentWithDocumentHeader = "= Document Title\n\n== Section 1\nContent";
// Test the conversion logic
let convertedContent = contentWithDocumentHeader.replace(/^=\s+(.+)$/gm, '== $1');
convertedContent = convertedContent.replace(/^index card$/gim, '');
const finalContent = convertedContent.replace(/\n\s*\n\s*\n/g, '\n\n');
expect(finalContent).toBe("== Document Title\n\n== Section 1\nContent");
});
it("should remove index card line", () => {
const contentWithIndexCard = "index card\n\n== Section 1\nContent";
// Test the conversion logic
let convertedContent = contentWithIndexCard.replace(/^=\s+(.+)$/gm, '== $1');
convertedContent = convertedContent.replace(/^index card$/gim, '');
const finalContent = convertedContent.replace(/\n\s*\n\s*\n/g, '\n\n');
expect(finalContent).toBe("\n\n== Section 1\nContent");
});
it("should clean up double newlines", () => {
const contentWithExtraNewlines = "= Document Title\n\n\n== Section 1\nContent";
// Test the conversion logic
let convertedContent = contentWithExtraNewlines.replace(/^=\s+(.+)$/gm, '== $1');
convertedContent = convertedContent.replace(/^index card$/gim, '');
const finalContent = convertedContent.replace(/\n\s*\n\s*\n/g, '\n\n');
expect(finalContent).toBe("== Document Title\n\n== Section 1\nContent");
});
});
describe("SessionStorage Integration", () => {
it("should store content in sessionStorage when switching to publication editor", () => {
const contentWithDocumentHeader = "= Document Title\n\n== Section 1\nContent";
// Test the sessionStorage logic
mockSessionStorage.setItem('zettelEditorContent', contentWithDocumentHeader);
mockSessionStorage.setItem('zettelEditorSource', 'publication-format');
expect(mockSessionStorage.setItem).toHaveBeenCalledWith('zettelEditorContent', contentWithDocumentHeader);
expect(mockSessionStorage.setItem).toHaveBeenCalledWith('zettelEditorSource', 'publication-format');
});
});
describe("Event Count Logic", () => {
it("should calculate correct event count for single section", () => {
const sections = [{ title: "Section 1", content: "Content 1", tags: [] }];
const eventCount = sections.length;
const eventText = `${eventCount} event${eventCount !== 1 ? "s" : ""}`;
expect(eventCount).toBe(1);
expect(eventText).toBe("1 event");
});
it("should calculate correct event count for multiple sections", () => {
const sections = [
{ title: "Section 1", content: "Content 1", tags: [] },
{ title: "Section 2", content: "Content 2", tags: [] }
];
const eventCount = sections.length;
const eventText = `${eventCount} event${eventCount !== 1 ? "s" : ""}`;
expect(eventCount).toBe(2);
expect(eventText).toBe("2 events");
});
});
describe("Tag Processing Logic", () => {
it("should process tags correctly", () => {
// Mock the metadataToTags function
const mockMetadataToTags = vi.fn().mockReturnValue([["author", "Test Author"]]);
const mockMetadata = { title: "Section 1", author: "Test Author" } as AsciiDocMetadata;
const tags = mockMetadataToTags(mockMetadata);
expect(tags).toEqual([["author", "Test Author"]]);
expect(mockMetadataToTags).toHaveBeenCalledWith(mockMetadata);
});
it("should handle empty tags", () => {
// Mock the metadataToTags function
const mockMetadataToTags = vi.fn().mockReturnValue([]);
const mockMetadata = { title: "Section 1" } as AsciiDocMetadata;
const tags = mockMetadataToTags(mockMetadata);
expect(tags).toEqual([]);
});
});
describe("AsciiDoctor Processing", () => {
it("should process AsciiDoc content correctly", () => {
// Mock the asciidoctor conversion
const mockConvert = vi.fn((content, options) => {
return content.replace(/^==\s+(.+)$/gm, '<h2>$1</h2>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>');
});
const content = "== Test Section\n\nThis is **bold** and *italic* text.";
const processedContent = mockConvert(content, {
standalone: false,
doctype: "article",
attributes: {
showtitle: true,
sectids: true,
},
});
expect(processedContent).toContain('<h2>Test Section</h2>');
expect(processedContent).toContain('<strong>bold</strong>');
expect(processedContent).toContain('<em>italic</em>');
});
});
describe("Error Handling", () => {
it("should handle parsing errors gracefully", () => {
// Mock a function that might throw an error
const mockParseFunction = vi.fn().mockImplementation(() => {
throw new Error("Parsing error");
});
const content = "== Section 1\nContent 1";
// Should not throw error when called
expect(() => {
try {
mockParseFunction(content);
} catch (error) {
// Expected error, but should be handled gracefully
}
}).not.toThrow();
});
it("should handle empty content without errors", () => {
const content = "";
const hasDocumentHeader = content.match(/^=\s+/m);
expect(hasDocumentHeader).toBeFalsy();
});
});
describe("Component Props Interface", () => {
it("should have correct prop types", () => {
// Test that the component props interface is correctly defined
const expectedProps = {
content: "",
placeholder: "Default placeholder",
showPreview: false,
onContentChange: vi.fn(),
onPreviewToggle: vi.fn(),
};
expect(expectedProps).toHaveProperty('content');
expect(expectedProps).toHaveProperty('placeholder');
expect(expectedProps).toHaveProperty('showPreview');
expect(expectedProps).toHaveProperty('onContentChange');
expect(expectedProps).toHaveProperty('onPreviewToggle');
});
});
describe("Utility Function Integration", () => {
it("should integrate with ZettelParser utilities", () => {
// Mock the parseAsciiDocSections function
const mockParseAsciiDocSections = vi.fn().mockReturnValue([
{ title: "Section 1", content: "Content 1", tags: [] }
]);
const content = "== Section 1\nContent 1";
const sections = mockParseAsciiDocSections(content, 2);
expect(sections).toHaveLength(1);
expect(sections[0].title).toBe("Section 1");
});
it("should integrate with asciidoc_metadata utilities", () => {
// Mock the utility functions
const mockExtractDocumentMetadata = vi.fn().mockReturnValue({
metadata: { title: "Document Title" } as AsciiDocMetadata,
content: "Document content"
});
const mockExtractSectionMetadata = vi.fn().mockReturnValue({
metadata: { title: "Section Title" } as AsciiDocMetadata,
content: "Section content",
title: "Section Title"
});
const documentContent = "= Document Title\nDocument content";
const sectionContent = "== Section Title\nSection content";
const documentResult = mockExtractDocumentMetadata(documentContent);
const sectionResult = mockExtractSectionMetadata(sectionContent);
expect(documentResult.metadata.title).toBe("Document Title");
expect(sectionResult.title).toBe("Section Title");
});
});
describe("Content Validation", () => {
it("should validate content structure", () => {
const validContent = "== Section 1\nContent here\n\n== Section 2\nMore content";
const invalidContent = "Just some text without sections";
// Test section detection
const validSections = validContent.split(/(?=^==\s+)/gm).filter((section: string) => section.trim());
const invalidSections = invalidContent.split(/(?=^==\s+)/gm).filter((section: string) => section.trim());
expect(validSections.length).toBeGreaterThan(0);
// The invalid content will have one section (the entire content) since it doesn't start with ==
expect(invalidSections.length).toBe(1);
});
it("should handle mixed content types", () => {
const mixedContent = "= Document Title\n\n== Section 1\nContent\n\n== Section 2\nMore content";
// Test document header detection
const hasDocumentHeader = mixedContent.match(/^=\s+/m);
expect(hasDocumentHeader).toBeTruthy();
// Test section extraction
const sections = mixedContent.split(/(?=^==\s+)/gm).filter((section: string) => section.trim());
expect(sections.length).toBeGreaterThan(0);
});
});
describe("String Manipulation", () => {
it("should handle string replacements correctly", () => {
const originalContent = "= Title\n\n== Section\nContent";
// Test various string manipulations
const convertedContent = originalContent
.replace(/^=\s+(.+)$/gm, '== $1')
.replace(/^index card$/gim, '')
.replace(/\n\s*\n\s*\n/g, '\n\n');
expect(convertedContent).toBe("== Title\n\n== Section\nContent");
});
it("should handle edge cases in string manipulation", () => {
const edgeCases = [
"= Title\n\n\n== Section\nContent", // Multiple newlines
"index card\n\n== Section\nContent", // Index card
"= Title\nindex card\n== Section\nContent", // Both
];
edgeCases.forEach(content => {
const converted = content
.replace(/^=\s+(.+)$/gm, '== $1')
.replace(/^index card$/gim, '')
.replace(/\n\s*\n\s*\n/g, '\n\n');
expect(converted).toBeDefined();
expect(typeof converted).toBe('string');
});
});
});
});
Loading…
Cancel
Save