Browse Source

handle creating and publishing events

remove published_at time stamp from non-replaceable events
master v0.1.0
Silberengel 7 days ago
parent
commit
c12e3a50eb
  1. 39
      src/asciidocParser.ts
  2. 3
      src/asciidocValidator.ts
  3. 81
      src/commands/commandHandlers.ts
  4. 57
      src/eventManager.ts
  5. 113
      src/nostr/eventBuilder.ts
  6. 12
      src/nostr/nkbip08Tags.ts
  7. 9
      src/ui/metadataReminderModal.ts
  8. 51
      src/ui/structurePreviewModal.ts
  9. 5
      src/utils/eventKind.ts

39
src/asciidocParser.ts

@ -82,12 +82,11 @@ export function parseAsciiDocStructure(
const headerInfo = parseHeaderLine(line); const headerInfo = parseHeaderLine(line);
if (headerInfo) { if (headerInfo) {
// Save content to current node if any // Save content to current node if any (save to all nodes, we'll determine kind later)
if (currentContent.length > 0 && stack.length > 0) { if (currentContent.length > 0 && stack.length > 0) {
const currentNode = stack[stack.length - 1]; const currentNode = stack[stack.length - 1];
if (currentNode.kind === 30041) { // Save content to the node - it will be used if it becomes a 30041
currentNode.content = currentContent.join("\n").trim(); currentNode.content = currentContent.join("\n").trim();
}
currentContent = []; currentContent = [];
} }
@ -124,12 +123,10 @@ export function parseAsciiDocStructure(
} }
} }
// Save remaining content to the last node // Save remaining content to the last node (save to all nodes, we'll determine kind later)
if (currentContent.length > 0 && stack.length > 0) { if (currentContent.length > 0 && stack.length > 0) {
const currentNode = stack[stack.length - 1]; const currentNode = stack[stack.length - 1];
if (currentNode.kind === 30041) { currentNode.content = currentContent.join("\n").trim();
currentNode.content = currentContent.join("\n").trim();
}
} }
// Post-process: mark lowest level nodes as 30041 // Post-process: mark lowest level nodes as 30041
@ -140,25 +137,23 @@ export function parseAsciiDocStructure(
/** /**
* Recursively mark the lowest level nodes in each branch as 30041 * Recursively mark the lowest level nodes in each branch as 30041
* Leaf nodes (nodes with no children) should always be 30041
* Nodes with children should be 30040 (index events)
*/ */
function markLowestLevelAs30041(node: StructureNode): void { function markLowestLevelAs30041(node: StructureNode): void {
if (node.children.length === 0) { // Process children first (depth-first)
// Leaf node - should be 30041 if it has content
if (node.content && node.content.trim().length > 0) {
node.kind = 30041;
}
return;
}
// Process children first
node.children.forEach((child) => markLowestLevelAs30041(child)); node.children.forEach((child) => markLowestLevelAs30041(child));
// Check if all children are 30041 - if so, this node should be 30040 // After processing children, determine this node's kind
// Otherwise, find the deepest 30040 node if (node.children.length === 0) {
const has30040Children = node.children.some((child) => child.kind === 30040); // Leaf node - always 30041 (content event)
if (!has30040Children) { node.kind = 30041;
// All children are 30041, so this is an index (30040) // Ensure content is preserved (it was collected during parsing)
} else {
// Node with children - always 30040 (index event)
node.kind = 30040; node.kind = 30040;
// Clear content for index nodes (they don't have content, only references)
node.content = "";
} }
} }

3
src/asciidocValidator.ts

@ -144,7 +144,8 @@ function validateHeaderText(headerLines: HeaderInfo[], errors: string[]): void {
* Validate header hierarchy - no skipped levels * Validate header hierarchy - no skipped levels
*/ */
function validateHeaderHierarchy(headerLines: HeaderInfo[], errors: string[]): void { function validateHeaderHierarchy(headerLines: HeaderInfo[], errors: string[]): void {
let previousLevel = 0; // Start with level 1 (document header level) since section headers start at level 2
let previousLevel = 1;
for (const header of headerLines) { for (const header of headerLines) {
// Only check for skipped levels when going deeper (not when going back up) // Only check for skipped levels when going deeper (not when going back up)

81
src/commands/commandHandlers.ts

@ -2,7 +2,7 @@ import { TFile, TFolder, App, Notice } from "obsidian";
import { EventKind, EventMetadata, ScriptoriumSettings } from "../types"; import { EventKind, EventMetadata, ScriptoriumSettings } from "../types";
import { readMetadata, writeMetadata, createDefaultMetadata, validateMetadata, mergeWithHeaderTitle } from "../metadataManager"; import { readMetadata, writeMetadata, createDefaultMetadata, validateMetadata, mergeWithHeaderTitle } from "../metadataManager";
import { buildEvents } from "../eventManager"; import { buildEvents } from "../eventManager";
import { saveEvents, loadEvents, eventsFileExists } from "../eventStorage"; import { saveEvents, loadEvents, eventsFileExists, getEventsFilePath } from "../eventStorage";
import { publishEventsWithRetry } from "../nostr/relayClient"; import { publishEventsWithRetry } from "../nostr/relayClient";
import { getWriteRelays } from "../relayManager"; import { getWriteRelays } from "../relayManager";
import { parseAsciiDocStructure, isAsciiDocDocument } from "../asciidocParser"; import { parseAsciiDocStructure, isAsciiDocDocument } from "../asciidocParser";
@ -98,8 +98,10 @@ export async function handleCreateEvents(
// Show reminder modal before proceeding // Show reminder modal before proceeding
new MetadataReminderModal(app, eventKind, async () => { new MetadataReminderModal(app, eventKind, async () => {
// Re-read metadata after user confirms (they may have updated it) try {
const updatedContent = await app.vault.read(file); log("Metadata reminder modal confirmed, starting event creation");
// Re-read metadata after user confirms (they may have updated it)
const updatedContent = await app.vault.read(file);
let updatedMetadata: EventMetadata = await readMetadata(file, app) || metadata || createDefaultMetadata(eventKind); let updatedMetadata: EventMetadata = await readMetadata(file, app) || metadata || createDefaultMetadata(eventKind);
// Ensure we have valid metadata // Ensure we have valid metadata
@ -142,10 +144,21 @@ export async function handleCreateEvents(
new Notice("Please set your private key in settings"); new Notice("Please set your private key in settings");
return; return;
} }
log(`Building events for file: ${file.path}, kind: ${eventKind}`);
const result = await buildEvents(file, updatedContent, updatedMetadata, settings.privateKey, app); const result = await buildEvents(file, updatedContent, updatedMetadata, settings.privateKey, app);
log(`buildEvents returned: ${result.events.length} events, ${result.errors.length} errors`);
if (result.errors.length > 0) { if (result.errors.length > 0) {
new Notice(`Errors: ${result.errors.join(", ")}`); new Notice(`Errors: ${result.errors.join(", ")}`);
logError("buildEvents returned errors", result.errors);
return;
}
// Check if any events were created
if (result.events.length === 0) {
new Notice("No events were created. Check metadata and content.");
logError("buildEvents returned 0 events", { file: file.path, metadata: updatedMetadata });
return; return;
} }
@ -161,12 +174,66 @@ export async function handleCreateEvents(
// Show preview for structured documents // Show preview for structured documents
if (result.structure.length > 0) { if (result.structure.length > 0) {
new StructurePreviewModal(app, result.structure, async () => { new StructurePreviewModal(app, result.structure, async () => {
await saveEvents(file, result.events, app); try {
new Notice(`Created ${result.events.length} event(s) and saved to ${file.basename}_events.jsonl`); const eventsPath = getEventsFilePath(file);
await saveEvents(file, result.events, app);
// Try to open the events file in Obsidian
const eventsFile = app.vault.getAbstractFileByPath(eventsPath);
if (eventsFile && eventsFile instanceof TFile) {
try {
const leaf = app.workspace.getMostRecentLeaf();
if (leaf && leaf.view) {
await leaf.openFile(eventsFile, { active: true });
} else {
const newLeaf = app.workspace.getLeaf("tab");
await newLeaf.openFile(eventsFile, { active: true });
}
} catch (openError: any) {
// If opening fails, just show the notice - file was saved successfully
logError("Error opening events file", openError);
}
}
new Notice(`Created ${result.events.length} event(s) and saved to ${eventsPath}`);
log(`Events saved to: ${eventsPath}`);
} catch (error: any) {
showErrorNotice("Error saving events", error);
logError("Error saving events", error);
}
}).open(); }).open();
} else { } else {
await saveEvents(file, result.events, app); try {
new Notice(`Created ${result.events.length} event(s) and saved to ${file.basename}_events.jsonl`); const eventsPath = getEventsFilePath(file);
await saveEvents(file, result.events, app);
// Try to open the events file in Obsidian
const eventsFile = app.vault.getAbstractFileByPath(eventsPath);
if (eventsFile && eventsFile instanceof TFile) {
try {
const leaf = app.workspace.getMostRecentLeaf();
if (leaf && leaf.view) {
await leaf.openFile(eventsFile, { active: true });
} else {
const newLeaf = app.workspace.getLeaf("tab");
await newLeaf.openFile(eventsFile, { active: true });
}
} catch (openError: any) {
// If opening fails, just show the notice - file was saved successfully
logError("Error opening events file", openError);
}
}
new Notice(`Created ${result.events.length} event(s) and saved to ${eventsPath}`);
log(`Events saved to: ${eventsPath}`);
} catch (error: any) {
showErrorNotice("Error saving events", error);
logError("Error saving events", error);
}
}
} catch (error: any) {
showErrorNotice("Error creating events", error);
logError("Error in event creation callback", error);
} }
}).open(); }).open();
} catch (error: any) { } catch (error: any) {

57
src/eventManager.ts

@ -12,6 +12,7 @@ import {
createSignedEvent, createSignedEvent,
buildTagsFromMetadata, buildTagsFromMetadata,
getPubkeyFromPrivkey, getPubkeyFromPrivkey,
normalizeDTag,
} from "./nostr/eventBuilder"; } from "./nostr/eventBuilder";
import { parseAsciiDocStructure, isAsciiDocDocument } from "./asciidocParser"; import { parseAsciiDocStructure, isAsciiDocDocument } from "./asciidocParser";
import { readMetadata, mergeWithHeaderTitle, stripMetadataFromContent } from "./metadataManager"; import { readMetadata, mergeWithHeaderTitle, stripMetadataFromContent } from "./metadataManager";
@ -98,9 +99,14 @@ export async function buildAsciiDocEvents(
} }
// Build base 30041 metadata // Build base 30041 metadata
// Ensure title is always a string (required for 30041)
if (!node.title || typeof node.title !== "string") {
errors.push(`30041 event missing required title at level ${node.level}`);
return;
}
const baseMetadata: Kind30041Metadata = { const baseMetadata: Kind30041Metadata = {
kind: 30041, kind: 30041,
title: node.title, title: String(node.title),
}; };
// Determine if this 30041 is directly under root (making it a chapter) or under a chapter (making it a section) // Determine if this 30041 is directly under root (making it a chapter) or under a chapter (making it a section)
@ -129,25 +135,35 @@ export async function buildAsciiDocEvents(
const childEvents: Array<{ kind: number; dTag: string; eventId?: string }> = []; const childEvents: Array<{ kind: number; dTag: string; eventId?: string }> = [];
// Merge parent metadata with node metadata for nested 30040 events // Merge parent metadata with node metadata for nested 30040 events
const baseMetadata = node.metadata as Kind30040Metadata; // If node.metadata is undefined, create a minimal metadata object
// Ensure title is always a string (required for 30040)
if (!node.title || typeof node.title !== "string") {
errors.push(`30040 event missing required title at level ${node.level}`);
return;
}
const baseMetadata = (node.metadata as Kind30040Metadata | undefined) || {
kind: 30040,
title: String(node.title),
} as Kind30040Metadata;
// Merge NKBIP-08 tags (inherits collection_id from root, version_tag from parent if present, otherwise uses own) // Merge NKBIP-08 tags (inherits collection_id from root, version_tag from parent if present, otherwise uses own)
const mergedNKBIP08Tags = mergeNKBIP08TagsFor30040(parentMetadata, baseMetadata, currentRootMetadata); const mergedNKBIP08Tags = mergeNKBIP08TagsFor30040(parentMetadata, baseMetadata, currentRootMetadata);
// Build merged metadata with inherited NKBIP-08 tags // Build merged metadata with inherited NKBIP-08 tags
// Ensure all string properties are properly normalized
const mergedMetadata: Kind30040Metadata = { const mergedMetadata: Kind30040Metadata = {
...baseMetadata, ...baseMetadata,
kind: 30040, kind: 30040,
title: node.title, title: String(node.title), // Ensure title is always a string
// Inherit other 30040 tags from parent // Inherit other 30040 tags from parent, ensuring strings
author: parentMetadata?.author || baseMetadata.author, author: parentMetadata?.author ? String(parentMetadata.author) : baseMetadata.author ? String(baseMetadata.author) : undefined,
type: parentMetadata?.type || baseMetadata.type, type: parentMetadata?.type ? String(parentMetadata.type) : baseMetadata.type ? String(baseMetadata.type) : undefined,
version: parentMetadata?.version || baseMetadata.version, version: parentMetadata?.version ? String(parentMetadata.version) : baseMetadata.version ? String(baseMetadata.version) : undefined,
published_on: parentMetadata?.published_on || baseMetadata.published_on, published_on: parentMetadata?.published_on ? String(parentMetadata.published_on) : baseMetadata.published_on ? String(baseMetadata.published_on) : undefined,
published_by: parentMetadata?.published_by || baseMetadata.published_by, published_by: parentMetadata?.published_by ? String(parentMetadata.published_by) : baseMetadata.published_by ? String(baseMetadata.published_by) : undefined,
summary: parentMetadata?.summary || baseMetadata.summary, summary: parentMetadata?.summary ? String(parentMetadata.summary) : baseMetadata.summary ? String(baseMetadata.summary) : undefined,
source: parentMetadata?.source || baseMetadata.source, source: parentMetadata?.source ? String(parentMetadata.source) : baseMetadata.source ? String(baseMetadata.source) : undefined,
image: parentMetadata?.image || baseMetadata.image, image: parentMetadata?.image ? String(parentMetadata.image) : baseMetadata.image ? String(baseMetadata.image) : undefined,
auto_update: parentMetadata?.auto_update || baseMetadata.auto_update, auto_update: parentMetadata?.auto_update || baseMetadata.auto_update,
}; };
@ -204,8 +220,17 @@ export async function buildAsciiDocEvents(
// Build events starting from root (no parent, book title is root title, isParentRoot=false for root itself) // Build events starting from root (no parent, book title is root title, isParentRoot=false for root itself)
await buildEventsFromNode(rootNode, metadata as Kind30040Metadata, rootBookTitle, false, metadata as Kind30040Metadata); await buildEventsFromNode(rootNode, metadata as Kind30040Metadata, rootBookTitle, false, metadata as Kind30040Metadata);
// Sort events: indexes first, then content (for proper dependency order) // Sort events: root book first, then chapter indexes, then content (for proper dependency order)
// Root book is identified by having the root d-tag
const rootDTag = normalizeDTag(metadata.title);
events.sort((a, b) => { events.sort((a, b) => {
// Root book (30040 with root d-tag) comes first
const aIsRoot = a.kind === 30040 && a.tags.find(t => t[0] === "d")?.[1] === rootDTag;
const bIsRoot = b.kind === 30040 && b.tags.find(t => t[0] === "d")?.[1] === rootDTag;
if (aIsRoot && !bIsRoot) return -1;
if (!aIsRoot && bIsRoot) return 1;
// Then other 30040 indexes, then 30041 content
if (a.kind === 30040 && b.kind === 30041) return -1; if (a.kind === 30040 && b.kind === 30041) return -1;
if (a.kind === 30041 && b.kind === 30040) return 1; if (a.kind === 30041 && b.kind === 30040) return 1;
return 0; return 0;
@ -227,13 +252,15 @@ export async function buildEvents(
// Check if this is an AsciiDoc document with structure // Check if this is an AsciiDoc document with structure
const hasStructure = isAsciiDocFile(file) && isAsciiDocDocument(content); const hasStructure = isAsciiDocFile(file) && isAsciiDocDocument(content);
if (hasStructure && (metadata.kind === 30040 || metadata.kind === 30041)) { // Only use buildAsciiDocEvents for 30040 (publication index) with structure
// Standalone 30041 events should use buildSimpleEvent, even if they have a document header
if (hasStructure && metadata.kind === 30040) {
// Parse header title and merge with metadata // Parse header title and merge with metadata
const headerTitle = content.split("\n")[0]?.replace(/^=+\s*/, "").trim() || ""; const headerTitle = content.split("\n")[0]?.replace(/^=+\s*/, "").trim() || "";
const mergedMetadata = mergeWithHeaderTitle(metadata, headerTitle); const mergedMetadata = mergeWithHeaderTitle(metadata, headerTitle);
return buildAsciiDocEvents(file, content, mergedMetadata, privkey, app); return buildAsciiDocEvents(file, content, mergedMetadata, privkey, app);
} else { } else {
// Simple event // Simple event (including standalone 30041, even if it has a document header)
const events = await buildSimpleEvent(file, content, metadata, privkey, app); const events = await buildSimpleEvent(file, content, metadata, privkey, app);
return { events, structure: [], errors: [] }; return { events, structure: [], errors: [] };
} }

113
src/nostr/eventBuilder.ts

@ -76,10 +76,27 @@ export function buildTagsFromMetadata(
): string[][] { ): string[][] {
const tags: string[][] = []; const tags: string[][] = [];
// All event kinds in this plugin are replaceable (0-9999 range) // Helper function to normalize topics (can be string or array)
// Add published_at tag automatically with current UNIX timestamp const normalizeTopics = (topics: string | string[] | undefined): string[] => {
const publishedAt = Math.floor(Date.now() / 1000).toString(); if (!topics) return [];
tags.push(["published_at", publishedAt]); if (Array.isArray(topics)) return topics;
if (typeof topics === "string") {
// Split by comma and trim each topic
return topics.split(",").map(t => t.trim()).filter(t => t.length > 0);
}
return [];
};
// Helper function to add published_at tag for replaceable event kinds
const addPublishedAtIfReplaceable = () => {
// Replaceable event kinds: 30023, 30040, 30041, 30817, 30818 (0-9999 range)
// Non-replaceable: 1, 11
const replaceableKinds = [30023, 30040, 30041, 30817, 30818];
if (replaceableKinds.includes(metadata.kind)) {
const publishedAt = Math.floor(Date.now() / 1000).toString();
tags.push(["published_at", publishedAt]);
}
};
switch (metadata.kind) { switch (metadata.kind) {
case 1: case 1:
@ -88,9 +105,7 @@ export function buildTagsFromMetadata(
tags.push(["title", metadata.title]); tags.push(["title", metadata.title]);
} }
// Topics available for all events // Topics available for all events
if (metadata.topics) { normalizeTopics(metadata.topics).forEach((topic) => tags.push(["t", topic]));
metadata.topics.forEach((topic) => tags.push(["t", topic]));
}
break; break;
case 11: case 11:
@ -100,13 +115,12 @@ export function buildTagsFromMetadata(
} }
if (metadata.title) tags.push(["title", metadata.title]); if (metadata.title) tags.push(["title", metadata.title]);
// Topics available for all events // Topics available for all events
if (metadata.topics) { normalizeTopics(metadata.topics).forEach((topic) => tags.push(["t", topic]));
metadata.topics.forEach((topic) => tags.push(["t", topic]));
}
break; break;
case 30023: case 30023:
// Long-form article // Long-form article (replaceable)
addPublishedAtIfReplaceable();
if (!metadata.title) { if (!metadata.title) {
throw new Error("Title is mandatory for kind 30023"); throw new Error("Title is mandatory for kind 30023");
} }
@ -114,13 +128,12 @@ export function buildTagsFromMetadata(
if (metadata.title) tags.push(["title", metadata.title]); if (metadata.title) tags.push(["title", metadata.title]);
if (metadata.image) tags.push(["image", metadata.image]); if (metadata.image) tags.push(["image", metadata.image]);
if (metadata.summary) tags.push(["summary", metadata.summary]); if (metadata.summary) tags.push(["summary", metadata.summary]);
if (metadata.topics) { normalizeTopics(metadata.topics).forEach((topic) => tags.push(["t", topic]));
metadata.topics.forEach((topic) => tags.push(["t", topic]));
}
break; break;
case 30040: case 30040:
// Publication index // Publication index (replaceable)
addPublishedAtIfReplaceable();
if (!metadata.title) { if (!metadata.title) {
throw new Error("Title is mandatory for kind 30040"); throw new Error("Title is mandatory for kind 30040");
} }
@ -147,30 +160,39 @@ export function buildTagsFromMetadata(
tags.push(eTag); tags.push(eTag);
} }
// Topics available for all events // Topics available for all events
if (metadata.topics) { normalizeTopics(metadata.topics).forEach((topic) => tags.push(["t", topic]));
metadata.topics.forEach((topic) => tags.push(["t", topic]));
}
// NKBIP-08 tags // NKBIP-08 tags
// Note: For structured documents, NKBIP-08 tags are added in eventManager.ts // Note: For structured documents, NKBIP-08 tags are added in eventManager.ts
// with proper book/chapter identification. For simple 30040 events, treat as book. // with proper book/chapter identification. For simple 30040 events, treat as book.
const meta30040 = metadata as Kind30040Metadata; const meta30040 = metadata as Kind30040Metadata;
addNKBIP08TagsTo30040(tags, meta30040, true, false, undefined, meta30040); // Simple 30040 is a book, use itself as root addNKBIP08TagsTo30040(tags, meta30040, true, false, undefined, meta30040); // Simple 30040 is a book, use itself as root
// Additional tags // Additional tags - ensure all values are strings
if (metadata.additional_tags) { if (metadata.additional_tags) {
metadata.additional_tags.forEach((tag) => tags.push(tag)); metadata.additional_tags.forEach((tag) => {
if (Array.isArray(tag) && tag.length > 0) {
const normalizedTag = tag.map(val => String(val ?? ""));
tags.push(normalizedTag);
}
});
} }
// a tags for child events // a tags for child events
// Format: ["a", "kind:pubkey:d-tag", "relay-url", "event-id"]
// If no relay URL, use empty string as placeholder before event ID
if (childEvents) { if (childEvents) {
childEvents.forEach((child) => { childEvents.forEach((child) => {
const aTag = ["a", `${child.kind}:${pubkey}:${child.dTag}`]; const aTag: string[] = ["a", `${child.kind}:${pubkey}:${child.dTag}`];
if (child.eventId) aTag.push("", child.eventId); if (child.eventId) {
// Add empty relay URL placeholder, then event ID
aTag.push("", String(child.eventId));
}
tags.push(aTag); tags.push(aTag);
}); });
} }
break; break;
case 30041: case 30041:
// Publication content // Publication content (replaceable)
addPublishedAtIfReplaceable();
if (!metadata.title) { if (!metadata.title) {
throw new Error("Title is mandatory for kind 30041"); throw new Error("Title is mandatory for kind 30041");
} }
@ -181,16 +203,15 @@ export function buildTagsFromMetadata(
// Stand-alone 30041 can have same tags as 30023 // Stand-alone 30041 can have same tags as 30023
if (meta30041.image) tags.push(["image", meta30041.image]); if (meta30041.image) tags.push(["image", meta30041.image]);
if (meta30041.summary) tags.push(["summary", meta30041.summary]); if (meta30041.summary) tags.push(["summary", meta30041.summary]);
if (meta30041.topics) { normalizeTopics(meta30041.topics).forEach((topic) => tags.push(["t", topic]));
meta30041.topics.forEach((topic) => tags.push(["t", topic]));
}
// NKBIP-08 tags (only for nested 30041 under 30040) // NKBIP-08 tags (only for nested 30041 under 30040)
addNKBIP08TagsTo30041(tags, meta30041); addNKBIP08TagsTo30041(tags, meta30041);
break; break;
case 30817: case 30817:
// Wiki page (Markdown) // Wiki page (Markdown) (replaceable)
addPublishedAtIfReplaceable();
if (!metadata.title) { if (!metadata.title) {
throw new Error("Title is mandatory for kind 30817"); throw new Error("Title is mandatory for kind 30817");
} }
@ -199,13 +220,12 @@ export function buildTagsFromMetadata(
if (metadata.summary) tags.push(["summary", metadata.summary]); if (metadata.summary) tags.push(["summary", metadata.summary]);
const meta30817 = metadata as any; const meta30817 = metadata as any;
if (meta30817.image) tags.push(["image", meta30817.image]); if (meta30817.image) tags.push(["image", meta30817.image]);
if (metadata.topics) { normalizeTopics(metadata.topics).forEach((topic) => tags.push(["t", topic]));
metadata.topics.forEach((topic) => tags.push(["t", topic]));
}
break; break;
case 30818: case 30818:
// Wiki page (AsciiDoc) // Wiki page (AsciiDoc) (replaceable)
addPublishedAtIfReplaceable();
if (!metadata.title) { if (!metadata.title) {
throw new Error("Title is mandatory for kind 30818"); throw new Error("Title is mandatory for kind 30818");
} }
@ -214,9 +234,7 @@ export function buildTagsFromMetadata(
if (metadata.summary) tags.push(["summary", metadata.summary]); if (metadata.summary) tags.push(["summary", metadata.summary]);
const meta30818 = metadata as any; const meta30818 = metadata as any;
if (meta30818.image) tags.push(["image", meta30818.image]); if (meta30818.image) tags.push(["image", meta30818.image]);
if (metadata.topics) { normalizeTopics(metadata.topics).forEach((topic) => tags.push(["t", topic]));
metadata.topics.forEach((topic) => tags.push(["t", topic]));
}
break; break;
} }
@ -261,17 +279,32 @@ export function createSignedEvent(
const pubkey = getPublicKey(normalizedKey); const pubkey = getPublicKey(normalizedKey);
const created_at = createdAt || Math.floor(Date.now() / 1000); const created_at = createdAt || Math.floor(Date.now() / 1000);
// Ensure tags is always an array (never undefined or null)
const safeTags = Array.isArray(tags) ? tags : [];
// Ensure all tag values are strings (required by Nostr spec)
const normalizedTags = safeTags.map(tag =>
Array.isArray(tag) ? tag.map(val => String(val ?? "")) : tag
);
// Ensure content is always a string (never undefined or null)
const safeContent = typeof content === "string" ? content : "";
const eventTemplate = { const eventTemplate = {
kind, kind: Number(kind),
created_at, created_at: Number(created_at),
tags, tags: normalizedTags,
content, content: safeContent,
}; };
const signedEvent = finalizeEvent(eventTemplate, normalizedKey); const signedEvent = finalizeEvent(eventTemplate, normalizedKey);
// Ensure all required properties are present
return { return {
...signedEvent, id: signedEvent.id,
kind: kind as EventKind, pubkey: signedEvent.pubkey,
created_at: signedEvent.created_at,
kind: signedEvent.kind as EventKind,
tags: signedEvent.tags,
content: signedEvent.content,
sig: signedEvent.sig,
}; };
} }

12
src/nostr/nkbip08Tags.ts

@ -23,8 +23,8 @@ export const NKBIP08_TAGS = {
* IMPORTANT: This handles hierarchical paths with colons (e.g., "part-1:question-2:article-3") * IMPORTANT: This handles hierarchical paths with colons (e.g., "part-1:question-2:article-3")
* by converting colons to hyphens, resulting in "part-1-question-2-article-3" as per NKBIP-08 spec. * by converting colons to hyphens, resulting in "part-1-question-2-article-3" as per NKBIP-08 spec.
*/ */
export function normalizeNKBIP08TagValue(text: string): string { export function normalizeNKBIP08TagValue(text: string | undefined | null): string {
if (!text) { if (!text || typeof text !== "string") {
return ""; return "";
} }
@ -110,7 +110,7 @@ export function extractNKBIP08TagsFrom30040(
*/ */
export function buildNKBIP08TagsFor30041( export function buildNKBIP08TagsFor30041(
parentMetadata: Kind30040Metadata, parentMetadata: Kind30040Metadata,
rootMetadata: Kind30040Metadata, rootMetadata: Kind30040Metadata | undefined,
bookTitle: string, bookTitle: string,
chapterTitle: string, chapterTitle: string,
sectionTitle: string, sectionTitle: string,
@ -118,7 +118,7 @@ export function buildNKBIP08TagsFor30041(
): NKBIP08_30041Tags { ): NKBIP08_30041Tags {
return { return {
// C tag: Inherited from root 30040 (optional - for compendiums, digests, libraries) // C tag: Inherited from root 30040 (optional - for compendiums, digests, libraries)
collection_id: rootMetadata.collection_id ? normalizeNKBIP08TagValue(rootMetadata.collection_id) : undefined, collection_id: rootMetadata?.collection_id ? normalizeNKBIP08TagValue(rootMetadata.collection_id) : undefined,
// Inherit from parent 30040 // Inherit from parent 30040
version_tag: parentMetadata.version_tag ? normalizeNKBIP08TagValue(parentMetadata.version_tag) : undefined, version_tag: parentMetadata.version_tag ? normalizeNKBIP08TagValue(parentMetadata.version_tag) : undefined,
// T tag: Normalized book title (from root 30040) // T tag: Normalized book title (from root 30040)
@ -145,8 +145,8 @@ export function mergeNKBIP08TagsFor30040(
rootMetadata?: Kind30040Metadata rootMetadata?: Kind30040Metadata
): NKBIP08_30040Tags { ): NKBIP08_30040Tags {
// Collection ID is inherited from root (if present), not from parent // Collection ID is inherited from root (if present), not from parent
const collectionId = rootMetadata?.collection_id || childMetadata.collection_id; const collectionId = rootMetadata?.collection_id || childMetadata?.collection_id;
const versionTag = parentMetadata?.version_tag || childMetadata.version_tag; const versionTag = parentMetadata?.version_tag || childMetadata?.version_tag;
return { return {
collection_id: collectionId ? normalizeNKBIP08TagValue(collectionId) : undefined, collection_id: collectionId ? normalizeNKBIP08TagValue(collectionId) : undefined,

9
src/ui/metadataReminderModal.ts

@ -61,9 +61,14 @@ export class MetadataReminderModal extends Modal {
text: "OK, I've Updated the Metadata", text: "OK, I've Updated the Metadata",
cls: "mod-cta", cls: "mod-cta",
}); });
okButton.addEventListener("click", () => { okButton.addEventListener("click", async () => {
this.onConfirm();
this.close(); this.close();
try {
await this.onConfirm();
} catch (error: any) {
// Error handling is done in the callback, but ensure we catch any unhandled errors
console.error("Error in metadata reminder modal callback:", error);
}
}); });
const cancelButton = buttonContainer.createEl("button", { text: "Cancel" }); const cancelButton = buttonContainer.createEl("button", { text: "Cancel" });

51
src/ui/structurePreviewModal.ts

@ -15,18 +15,37 @@ export class StructurePreviewModal extends Modal {
} }
onOpen() { onOpen() {
const { contentEl } = this; const { contentEl, modalEl } = this;
contentEl.empty(); contentEl.empty();
contentEl.createEl("h2", { text: "Document Structure Preview" }); // Set max height on the modal content
modalEl.style.maxHeight = "1000px";
modalEl.style.display = "flex";
modalEl.style.flexDirection = "column";
contentEl.style.display = "flex";
contentEl.style.flexDirection = "column";
contentEl.style.maxHeight = "1000px";
contentEl.style.overflow = "hidden";
const title = contentEl.createEl("h2", { text: "Document Structure Preview" });
title.style.marginBottom = "1.5em";
title.style.flexShrink = "0";
const structureContainer = contentEl.createDiv({ cls: "scriptorium-structure-preview" }); const structureContainer = contentEl.createDiv({ cls: "scriptorium-structure-preview" });
structureContainer.style.marginBottom = "2em";
structureContainer.style.overflowY = "auto";
structureContainer.style.flex = "1";
structureContainer.style.minHeight = "0";
this.structure.forEach((node) => { this.structure.forEach((node) => {
this.renderNode(structureContainer, node, 0); this.renderNode(structureContainer, node, 0);
}); });
const buttonContainer = contentEl.createDiv({ cls: "scriptorium-modal-buttons" }); const buttonContainer = contentEl.createDiv({ cls: "scriptorium-modal-buttons" });
buttonContainer.style.marginTop = "1.5em";
buttonContainer.style.paddingTop = "1em";
buttonContainer.style.borderTop = "1px solid var(--background-modifier-border)";
buttonContainer.style.flexShrink = "0";
const confirmButton = buttonContainer.createEl("button", { const confirmButton = buttonContainer.createEl("button", {
text: "Create Events", text: "Create Events",
cls: "mod-cta", cls: "mod-cta",
@ -44,22 +63,44 @@ export class StructurePreviewModal extends Modal {
private renderNode(container: HTMLElement, node: StructureNode, indent: number) { private renderNode(container: HTMLElement, node: StructureNode, indent: number) {
const nodeDiv = container.createDiv({ cls: "scriptorium-structure-node" }); const nodeDiv = container.createDiv({ cls: "scriptorium-structure-node" });
nodeDiv.style.paddingLeft = `${indent * 20}px`; nodeDiv.style.marginBottom = "1.2em";
nodeDiv.style.paddingTop = "0.8em";
nodeDiv.style.paddingBottom = "0.8em";
// For nested nodes, indent the entire div and add border at the indented position
if (indent > 0) {
const indentPx = indent * 24;
// Position the border at the indentation level (not at the left edge)
nodeDiv.style.marginLeft = `${indentPx}px`;
nodeDiv.style.borderLeft = "2px solid var(--background-modifier-border)";
nodeDiv.style.paddingLeft = "8px";
} else {
nodeDiv.style.paddingLeft = "0px";
}
const kindBadge = nodeDiv.createSpan({ const kindBadge = nodeDiv.createSpan({
cls: `scriptorium-kind-badge kind-${node.kind}`, cls: `scriptorium-kind-badge kind-${node.kind}`,
text: `Kind ${node.kind}`, text: `Kind ${node.kind}`,
}); });
kindBadge.style.marginBottom = "0.5em";
kindBadge.style.display = "block";
const titleEl = nodeDiv.createEl("div", { cls: "scriptorium-node-title" }); const titleEl = nodeDiv.createEl("div", { cls: "scriptorium-node-title" });
titleEl.style.marginBottom = "0.4em";
titleEl.createEl("strong", { text: node.title }); titleEl.createEl("strong", { text: node.title });
const dTagEl = nodeDiv.createEl("div", { cls: "scriptorium-node-dtag" }); const dTagEl = nodeDiv.createEl("div", { cls: "scriptorium-node-dtag" });
dTagEl.style.marginTop = "0.3em";
dTagEl.style.fontSize = "0.9em";
dTagEl.style.color = "var(--text-muted)";
dTagEl.createEl("span", { text: `d-tag: `, cls: "scriptorium-label" }); dTagEl.createEl("span", { text: `d-tag: `, cls: "scriptorium-label" });
dTagEl.createEl("code", { text: node.dTag }); dTagEl.createEl("code", { text: node.dTag });
if (node.kind === 30041 && node.content) { if (node.kind === 30041 && node.content) {
const contentPreview = nodeDiv.createDiv({ cls: "scriptorium-content-preview" }); const contentPreview = nodeDiv.createDiv({ cls: "scriptorium-content-preview" });
contentPreview.style.marginTop = "0.5em";
contentPreview.style.fontSize = "0.85em";
contentPreview.style.color = "var(--text-muted)";
const previewText = node.content.substring(0, 100); const previewText = node.content.substring(0, 100);
contentPreview.createEl("em", { contentPreview.createEl("em", {
text: previewText + (node.content.length > 100 ? "..." : ""), text: previewText + (node.content.length > 100 ? "..." : ""),
@ -67,8 +108,10 @@ export class StructurePreviewModal extends Modal {
} }
if (node.children.length > 0) { if (node.children.length > 0) {
const childrenContainer = container.createDiv();
childrenContainer.style.marginTop = "0.5em";
node.children.forEach((child) => { node.children.forEach((child) => {
this.renderNode(container, child, indent + 1); this.renderNode(childrenContainer, child, indent + 1);
}); });
} }
} }

5
src/utils/eventKind.ts

@ -13,6 +13,11 @@ export function determineEventKind(
metadataKind?: EventKind metadataKind?: EventKind
): EventKind { ): EventKind {
if (isAsciiDocFile(file)) { if (isAsciiDocFile(file)) {
// If metadata specifies a kind, use it (allows standalone 30041 or 30818)
if (metadataKind) {
return metadataKind;
}
// Otherwise, determine from content structure
if (isAsciiDocDocument(content)) { if (isAsciiDocDocument(content)) {
return 30040; return 30040;
} }

Loading…
Cancel
Save