From 1e104fc56c5d6037db5b467c77e5d8d24f9e4ba7 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 27 Jan 2026 07:34:17 +0100 Subject: [PATCH] refactor added event validator --- README.md | 61 ++++- src/asciidocParser.ts | 3 +- src/asciidocValidator.ts | 255 ++++++++++++++++++ src/commands/commandHandlers.ts | 294 +++++++++++++++++++++ src/eventManager.ts | 110 ++++++-- src/main.ts | 453 ++++++-------------------------- src/metadataManager.ts | 16 +- src/nostr/eventBuilder.ts | 15 +- src/nostr/nkbip08Tags.ts | 319 ++++++++++++++++++++++ src/nostr/relayClient.ts | 7 +- src/types.ts | 4 +- src/ui/metadataModal.ts | 15 +- src/utils/console.ts | 27 ++ src/utils/errorHandling.ts | 22 ++ src/utils/eventKind.ts | 50 ++++ src/utils/fileExtensions.ts | 25 ++ 16 files changed, 1240 insertions(+), 436 deletions(-) create mode 100644 src/asciidocValidator.ts create mode 100644 src/commands/commandHandlers.ts create mode 100644 src/nostr/nkbip08Tags.ts create mode 100644 src/utils/console.ts create mode 100644 src/utils/errorHandling.ts create mode 100644 src/utils/eventKind.ts create mode 100644 src/utils/fileExtensions.ts diff --git a/README.md b/README.md index 5eca45f..8b64c1f 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ An Obsidian plugin for creating, editing, and publishing Nostr document events d - **Multiple event kinds**: Markdown (1, 11, 30023, 30817) and AsciiDoc (30040, 30041, 30818) - **Automatic structure parsing**: AsciiDoc documents with headers are parsed into nested 30040/30041 event hierarchies +- **NKBIP-08 support**: Hierarchical book wikilinks with optional collection tags for compendiums, digests, and libraries +- **Flexible structure**: Supports both two-level (book + chapters) and three-level (book + chapters + sections) hierarchies - **Metadata in files**: Metadata stored directly in Markdown frontmatter or AsciiDoc header attributes - **Two-step workflow**: Create/sign events separately from publishing to relays - **Automatic relay management**: Fetch relay lists (kind 10002) with AUTH support @@ -111,8 +113,8 @@ Supported event kinds: **30040**, **30041**, **30818** :author: Author Name :type: book :summary: Book description -:collection_id: my-collection -:version_tag: v1 +:collection_id: bible +:version_tag: kjv == Chapter 1 @@ -123,8 +125,42 @@ Chapter content here... Section content... ``` +**Two-level structure** (book + chapters, no sections): +```asciidoc += Book Title + +:kind: 30040 +:author: Author Name +:type: book +:summary: Book description + +== Chapter 1 + +Chapter content here... + +== Chapter 2 + +Chapter content here... +``` + +In two-level structures, chapters are created as 30041 events directly under the root 30040. + When publishing, metadata is automatically stripped from content before creating events. +### NKBIP-08 Tag Inheritance + +For structured AsciiDoc documents (kind 30040), NKBIP-08 tags are automatically assigned based on the document hierarchy: + +- **C tag (collection_id)**: Optional, set in root 30040 metadata. If set, inherited by all events in the hierarchy. Use for compendiums, digests, or libraries of related books (e.g., "bible", "goethe-complete-works", "encyclopedia-britannica"). +- **T tag (title_id)**: Always set from root 30040 book title, inherited by all nested events. +- **c tag (chapter_id)**: + - Two-level structure: from 30041 chapter title (chapters are 30041 events) + - Three-level structure: from parent 30040 chapter title +- **s tag (section_id)**: Only in three-level structures, from 30041 section title +- **v tag (version_tag)**: If set in root 30040, inherited by all events in the hierarchy + +All tag values are normalized per NKBIP-08 spec (lowercase, hyphens, numbers only). + ## Event Kinds | Kind | Format | Description | Title Required | @@ -140,7 +176,11 @@ When publishing, metadata is automatically stripped from content before creating ### Stand-alone vs Nested 30041 - **Stand-alone 30041**: Uses NKBIP-01 tags (d, title, image, summary, published_at, topics) -- **Nested 30041** (under 30040): Uses NKBIP-08 tags (inherits collection_id, version_tag from parent) +- **Nested 30041** (under 30040): Uses NKBIP-08 tags + - **Two-level structure** (book + chapters): 30041 events are chapters (c tag from chapter title, no s tag) + - **Three-level structure** (book + chapters + sections): 30041 events are sections (c tag from parent chapter, s tag from section title) + - All nested 30041 events inherit C tag (collection_id) and v tag (version_tag) from root 30040 + - All nested 30041 events get T tag (title_id) from root 30040 book title ## Metadata Fields @@ -167,12 +207,19 @@ All predefined metadata fields are shown in frontmatter/attributes with placehol - `published_by` - Publisher - `source` - Source URL - `auto_update` - Auto-update behavior (yes, ask, no) -- `collection_id` - NKBIP-08 collection identifier -- `version_tag` - NKBIP-08 version identifier +- `collection_id` - NKBIP-08 collection identifier (C tag) - **Optional**: compendium, digest, or library of related books (e.g., "bible", "goethe-complete-works", "encyclopedia-britannica"). If set in root 30040, inherited by all events in the hierarchy. +- `version_tag` - NKBIP-08 version identifier (v tag) - If set in root 30040, inherited by all events in the hierarchy **30041 (Publication Content)**: -- Stand-alone: Same as 30023 (image, summary, published_at, topics) -- Nested: NKBIP-08 tags (collection_id, title_id, chapter_id, section_id, version_tag) +- **Stand-alone**: Same as 30023 (image, summary, published_at, topics) +- **Nested** (under 30040): NKBIP-08 tags + - `collection_id` - Inherited from root 30040 (C tag) + - `title_id` - From root 30040 book title (T tag) + - `chapter_id` - From chapter title (c tag) + - Two-level: from 30041's own title (it is the chapter) + - Three-level: from parent 30040's title + - `section_id` - From 30041's title (s tag) - Only in three-level structures + - `version_tag` - Inherited from root 30040 (v tag) ## Manual Installation diff --git a/src/asciidocParser.ts b/src/asciidocParser.ts index a73ead0..ab08719 100644 --- a/src/asciidocParser.ts +++ b/src/asciidocParser.ts @@ -26,8 +26,9 @@ export function isAsciiDocDocument(content: string): boolean { /** * Parse AsciiDoc line to extract header level and title + * Exported for use in validator */ -function parseHeaderLine(line: string): { level: number; title: string } | null { +export function parseHeaderLine(line: string): { level: number; title: string } | null { const trimmed = line.trim(); if (!trimmed.startsWith("=")) { return null; diff --git a/src/asciidocValidator.ts b/src/asciidocValidator.ts new file mode 100644 index 0000000..c48bfb5 --- /dev/null +++ b/src/asciidocValidator.ts @@ -0,0 +1,255 @@ +import { parseDocumentHeader, parseHeaderLine } from "./asciidocParser"; + +/** + * Validation result for AsciiDoc documents + */ +export interface AsciiDocValidationResult { + valid: boolean; + errors: string[]; + warnings: string[]; +} + +/** + * Header information extracted from document + */ +interface HeaderInfo { + lineNumber: number; + level: number; + title: string; + originalLine: string; +} + +/** + * Validate AsciiDoc document structure + * + * Checks: + * - Document header (single =) exists and has title + * - At least one additional header exists + * - Headers have proper text (not empty) + * - Leaf headers (last in their branch) have content beneath them + * - Interim headers (with child headers) don't need content + * - Headers form intact branches (no skipped levels - this would create orphaned events) + * + * @param content - The AsciiDoc content to validate + * @returns Validation result with errors and warnings + */ +export function validateAsciiDocDocument(content: string): AsciiDocValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + // Early return for empty document + if (!content || content.trim().length === 0) { + errors.push("Document is empty"); + return { valid: false, errors, warnings }; + } + + // Validate document header + const documentHeader = validateDocumentHeader(content, errors); + if (!documentHeader) { + return { valid: false, errors, warnings }; + } + + // Parse section headers + const lines = content.split("\n"); + const headerLines = parseSectionHeaders(lines); + + // Validate section headers exist + if (!validateSectionHeadersExist(headerLines, errors)) { + return { valid: false, errors, warnings }; + } + + // Validate header text + validateHeaderText(headerLines, errors); + + // Validate header hierarchy (no skipped levels) + validateHeaderHierarchy(headerLines, errors); + + // Validate leaf headers have content + validateLeafHeaderContent(headerLines, lines, errors); + + return { + valid: errors.length === 0, + errors, + warnings, + }; +} + +/** + * Validate document header exists and has a title + */ +function validateDocumentHeader( + content: string, + errors: string[] +): { title: string; remaining: string } | null { + const documentHeader = parseDocumentHeader(content); + if (!documentHeader) { + errors.push("Document must start with a document header (single = followed by title)"); + return null; + } + + if (!documentHeader.title || documentHeader.title.trim().length === 0) { + errors.push("Document header must have a title"); + return null; + } + + return documentHeader; +} + +/** + * Parse all section headers from document lines (excluding document header) + */ +function parseSectionHeaders(lines: string[]): HeaderInfo[] { + const headerLines: HeaderInfo[] = []; + + // Start from line 2 (index 1) to skip document header + for (let i = 1; i < lines.length; i++) { + const line = lines[i]; + const headerInfo = parseHeaderLine(line); + if (headerInfo) { + headerLines.push({ + lineNumber: i + 1, // 1-indexed for user display + level: headerInfo.level, + title: headerInfo.title, + originalLine: line.trim(), + }); + } + } + + return headerLines; +} + +/** + * Validate that at least one section header exists + */ +function validateSectionHeadersExist(headerLines: HeaderInfo[], errors: string[]): boolean { + if (headerLines.length === 0) { + errors.push("Document must have at least one section header (==, ===, etc.) after the document title"); + return false; + } + return true; +} + +/** + * Validate that all headers have text + */ +function validateHeaderText(headerLines: HeaderInfo[], errors: string[]): void { + for (const header of headerLines) { + if (!header.title || header.title.trim().length === 0) { + errors.push(`Header on line ${header.lineNumber} has no title text: "${header.originalLine}"`); + } + } +} + +/** + * Validate header hierarchy - no skipped levels + */ +function validateHeaderHierarchy(headerLines: HeaderInfo[], errors: string[]): void { + let previousLevel = 0; + + for (const header of headerLines) { + // Only check for skipped levels when going deeper (not when going back up) + if (header.level > previousLevel + 1) { + errors.push( + `Header on line ${header.lineNumber} ("${header.title}") skips a level (from level ${previousLevel} to ${header.level}). ` + + `This would create orphaned chapter-events. Headers must form intact branches.` + ); + } + previousLevel = header.level; + } +} + +/** + * Identify leaf headers (headers that are last in their branch) + * A header is a leaf if there are no child headers (deeper level) after it + * before the next header at the same or higher level + */ +function identifyLeafHeaders(headerLines: HeaderInfo[]): Set { + const leafHeaders = new Set(); + + for (let i = 0; i < headerLines.length; i++) { + const currentHeader = headerLines[i]; + const hasChildren = hasChildHeaders(headerLines, i); + + if (!hasChildren) { + leafHeaders.add(currentHeader.lineNumber); + } + } + + return leafHeaders; +} + +/** + * Check if a header has child headers after it + */ +function hasChildHeaders(headerLines: HeaderInfo[], currentIndex: number): boolean { + const currentHeader = headerLines[currentIndex]; + + // Look ahead to find the next header at same or higher level + for (let j = currentIndex + 1; j < headerLines.length; j++) { + const nextHeader = headerLines[j]; + + if (nextHeader.level <= currentHeader.level) { + // Found a header at same or higher level - end of this branch + // If we haven't found any children, this is a leaf + return false; + } + + if (nextHeader.level > currentHeader.level) { + // Found a child header - this header is not a leaf + return true; + } + } + + // No more headers - this is a leaf + return false; +} + +/** + * Validate that leaf headers have content beneath them + */ +function validateLeafHeaderContent( + headerLines: HeaderInfo[], + lines: string[], + errors: string[] +): void { + const leafHeaders = identifyLeafHeaders(headerLines); + + for (const header of headerLines) { + if (leafHeaders.has(header.lineNumber)) { + if (!hasContentAfterHeader(header, lines)) { + errors.push( + `Leaf header on line ${header.lineNumber} ("${header.title}") must have content beneath it` + ); + } + } + } +} + +/** + * Check if there is content after a header (before the next header at same or higher level) + */ +function hasContentAfterHeader(header: HeaderInfo, lines: string[]): boolean { + const headerIndex = header.lineNumber - 1; // Convert to 0-indexed + + // Look for content between this header and the next header at same or higher level + for (let i = headerIndex + 1; i < lines.length; i++) { + const headerInfo = parseHeaderLine(lines[i]); + + if (headerInfo) { + // Found a header - if it's at same or higher level, we've reached the end of this branch + if (headerInfo.level <= header.level) { + break; + } + // If it's deeper, continue (shouldn't happen for a leaf, but handle gracefully) + continue; + } + + // Check for non-empty content (not attribute lines) + const line = lines[i].trim(); + if (line.length > 0 && !line.startsWith(":")) { + return true; + } + } + + return false; +} diff --git a/src/commands/commandHandlers.ts b/src/commands/commandHandlers.ts new file mode 100644 index 0000000..6b8542e --- /dev/null +++ b/src/commands/commandHandlers.ts @@ -0,0 +1,294 @@ +import { TFile, TFolder, App, Notice } from "obsidian"; +import { EventKind, EventMetadata, ScriptoriumSettings } from "../types"; +import { readMetadata, writeMetadata, createDefaultMetadata, validateMetadata, mergeWithHeaderTitle } from "../metadataManager"; +import { buildEvents } from "../eventManager"; +import { saveEvents, loadEvents, eventsFileExists } from "../eventStorage"; +import { publishEventsWithRetry } from "../nostr/relayClient"; +import { getWriteRelays } from "../relayManager"; +import { parseAsciiDocStructure, isAsciiDocDocument } from "../asciidocParser"; +import { validateAsciiDocDocument } from "../asciidocValidator"; +import { verifyEventSecurity } from "../utils/security"; +import { showErrorNotice } from "../utils/errorHandling"; +import { log, logError } from "../utils/console"; +import { determineEventKind, getFolderNameForKind } from "../utils/eventKind"; +import { StructurePreviewModal } from "../ui/structurePreviewModal"; +import { MetadataReminderModal } from "../ui/metadataReminderModal"; +import { MetadataModal } from "../ui/metadataModal"; +import { isAsciiDocFile } from "../utils/fileExtensions"; + +/** + * Get the current active file + */ +export async function getCurrentFile(app: App): Promise { + const activeFile = app.workspace.getActiveFile(); + if (!activeFile) { + new Notice("No active file"); + return null; + } + return activeFile; +} + +/** + * Ensure the Nostr notes folder structure exists + */ +export async function ensureNostrNotesFolder( + app: App, + kind: EventKind +): Promise { + const baseFolder = "Nostr notes"; + const kindFolder = getFolderNameForKind(kind); + const fullPath = `${baseFolder}/${kindFolder}`; + + // Check if base folder exists + const baseFolderObj = app.vault.getAbstractFileByPath(baseFolder); + if (!baseFolderObj || !(baseFolderObj instanceof TFolder)) { + await app.vault.createFolder(baseFolder); + } + + // Check if kind folder exists + const kindFolderObj = app.vault.getAbstractFileByPath(fullPath); + if (!kindFolderObj || !(kindFolderObj instanceof TFolder)) { + await app.vault.createFolder(fullPath); + } + + return fullPath; +} + +/** + * Handle creating Nostr events from current file + */ +export async function handleCreateEvents( + app: App, + file: TFile, + settings: ScriptoriumSettings +): Promise { + if (!settings.privateKey) { + new Notice("Please set your private key in settings"); + return; + } + + try { + const content = await app.vault.read(file); + let metadata = await readMetadata(file, app); + + // Determine event kind from file extension or metadata + const eventKind = determineEventKind( + file, + content, + settings.defaultEventKind, + metadata?.kind + ); + + // Ensure folder structure exists before creating events + await ensureNostrNotesFolder(app, eventKind); + + // Create default metadata if none exists and write it with placeholders + if (!metadata) { + metadata = createDefaultMetadata(eventKind); + await writeMetadata(file, metadata, app); + // Re-read to get the formatted version with placeholders + metadata = await readMetadata(file, app) || metadata; + } + + // Merge with header title for 30040 + if (eventKind === 30040 && isAsciiDocDocument(content)) { + const headerTitle = content.split("\n")[0]?.replace(/^=+\s*/, "").trim() || ""; + metadata = mergeWithHeaderTitle(metadata, headerTitle); + } + + // Show reminder modal before proceeding + new MetadataReminderModal(app, eventKind, async () => { + // 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); + + // Ensure we have valid metadata + if (!updatedMetadata) { + updatedMetadata = createDefaultMetadata(eventKind); + } + + // Merge with header title for 30040 + if (eventKind === 30040 && isAsciiDocDocument(updatedContent)) { + const headerTitle = updatedContent.split("\n")[0]?.replace(/^=+\s*/, "").trim() || ""; + updatedMetadata = mergeWithHeaderTitle(updatedMetadata, headerTitle); + } + + // Validate metadata + const validation = validateMetadata(updatedMetadata, eventKind); + if (!validation.valid) { + new Notice(`Metadata validation failed: ${validation.errors.join(", ")}`); + return; + } + + // Validate AsciiDoc structure if this is a structured AsciiDoc document + if (isAsciiDocFile(file) && eventKind === 30040 && isAsciiDocDocument(updatedContent)) { + const asciiDocValidation = validateAsciiDocDocument(updatedContent); + if (!asciiDocValidation.valid) { + const errorMsg = `AsciiDoc validation failed:\n${asciiDocValidation.errors.join("\n")}`; + if (asciiDocValidation.warnings.length > 0) { + new Notice(`${errorMsg}\n\nWarnings:\n${asciiDocValidation.warnings.join("\n")}`); + } else { + new Notice(errorMsg); + } + return; + } + if (asciiDocValidation.warnings.length > 0) { + log(`AsciiDoc validation warnings: ${asciiDocValidation.warnings.join("; ")}`); + } + } + + // Build events + if (!settings.privateKey) { + new Notice("Please set your private key in settings"); + return; + } + const result = await buildEvents(file, updatedContent, updatedMetadata, settings.privateKey, app); + + if (result.errors.length > 0) { + new Notice(`Errors: ${result.errors.join(", ")}`); + return; + } + + // Security check: verify events don't contain private keys + for (const event of result.events) { + if (!verifyEventSecurity(event)) { + new Notice("Security error: Event contains private key. Aborting."); + logError("Event security check failed - event may contain private key"); + return; + } + } + + // Show preview for structured documents + if (result.structure.length > 0) { + new StructurePreviewModal(app, result.structure, async () => { + await saveEvents(file, result.events, app); + new Notice(`Created ${result.events.length} event(s) and saved to ${file.basename}_events.jsonl`); + }).open(); + } else { + await saveEvents(file, result.events, app); + new Notice(`Created ${result.events.length} event(s) and saved to ${file.basename}_events.jsonl`); + } + }).open(); + } catch (error: any) { + showErrorNotice("Error creating events", error); + logError("Error creating events", error); + } +} + +/** + * Handle previewing document structure + */ +export async function handlePreviewStructure( + app: App, + file: TFile +): Promise { + try { + const content = await app.vault.read(file); + if (!isAsciiDocDocument(content)) { + new Notice("This file is not an AsciiDoc document with structure"); + return; + } + + let metadata = await readMetadata(file, app); + if (!metadata || metadata.kind !== 30040) { + metadata = createDefaultMetadata(30040); + } + + const headerTitle = content.split("\n")[0]?.replace(/^=+\s*/, "").trim() || ""; + metadata = mergeWithHeaderTitle(metadata, headerTitle); + + const structure = parseAsciiDocStructure(content, metadata as any); + new StructurePreviewModal(app, structure, () => {}).open(); + } catch (error: any) { + showErrorNotice("Error previewing structure", error); + logError("Error previewing structure", error); + } +} + +/** + * Handle publishing events to relays + */ +export async function handlePublishEvents( + app: App, + file: TFile, + settings: ScriptoriumSettings +): Promise { + if (!settings.privateKey) { + new Notice("Please set your private key in settings"); + return; + } + + const exists = await eventsFileExists(file, app); + if (!exists) { + new Notice("No events file found. Please create events first."); + return; + } + + try { + const events = await loadEvents(file, app); + if (events.length === 0) { + new Notice("No events to publish"); + return; + } + + const writeRelays = getWriteRelays(settings.relayList); + if (writeRelays.length === 0) { + new Notice("No write relays configured. Please fetch relay list in settings."); + return; + } + + // Relays are already normalized and deduplicated by getWriteRelays + new Notice(`Publishing ${events.length} event(s) to ${writeRelays.length} relay(s)...`); + + const results = await publishEventsWithRetry(writeRelays, events, settings.privateKey); + + // Count successes + let successCount = 0; + let failureCount = 0; + results.forEach((relayResults) => { + relayResults.forEach((result) => { + if (result.success) { + successCount++; + } else { + failureCount++; + } + }); + }); + + if (failureCount === 0) { + new Notice(`Successfully published all ${successCount} event(s)`); + } else { + new Notice(`Published ${successCount} event(s), ${failureCount} failed`); + } + } catch (error: any) { + showErrorNotice("Error publishing events", error); + logError("Error publishing events", error); + } +} + +/** + * Handle editing metadata + */ +export async function handleEditMetadata( + app: App, + file: TFile, + defaultEventKind: EventKind +): Promise { + try { + let metadata = await readMetadata(file, app); + if (!metadata) { + // Determine kind from file extension + const content = await app.vault.read(file); + const eventKind = determineEventKind(file, content, defaultEventKind); + metadata = createDefaultMetadata(eventKind); + } + + new MetadataModal(app, metadata, async (updatedMetadata) => { + await writeMetadata(file, updatedMetadata, app); + new Notice("Metadata saved"); + }).open(); + } catch (error: any) { + showErrorNotice("Error editing metadata", error); + logError("Error editing metadata", error); + } +} diff --git a/src/eventManager.ts b/src/eventManager.ts index 1daba01..2f4afe3 100644 --- a/src/eventManager.ts +++ b/src/eventManager.ts @@ -11,11 +11,19 @@ import { import { createSignedEvent, buildTagsFromMetadata, - normalizeDTag, getPubkeyFromPrivkey, } from "./nostr/eventBuilder"; -import { parseAsciiDocStructure } from "./asciidocParser"; +import { parseAsciiDocStructure, isAsciiDocDocument } from "./asciidocParser"; import { readMetadata, mergeWithHeaderTitle, stripMetadataFromContent } from "./metadataManager"; +import { isAsciiDocFile } from "./utils/fileExtensions"; +import { + buildNKBIP08TagsFor30041, + applyNKBIP08TagsTo30041, + mergeNKBIP08TagsFor30040, + applyNKBIP08TagsTo30040, + addNKBIP08TagsTo30040, + NKBIP08_TAGS, +} from "./nostr/nkbip08Tags"; /** * Build events from a simple document (non-AsciiDoc) @@ -65,22 +73,53 @@ export async function buildAsciiDocEvents( const rootNode = header[0]; const structure: StructureNode[] = [rootNode]; + + // Track the root book title for T tag inheritance + const rootBookTitle = rootNode.title; // Recursively build events from structure - async function buildEventsFromNode(node: StructureNode, parentMetadata?: Kind30040Metadata): Promise { + async function buildEventsFromNode( + node: StructureNode, + parentMetadata?: Kind30040Metadata, + bookTitle?: string, + isParentRoot: boolean = false, + rootMetadata?: Kind30040Metadata + ): Promise { + // Determine book title: use root if this is root, otherwise inherit from parent + const currentBookTitle = bookTitle || rootBookTitle; + // Track root metadata for collection_id inheritance + const currentRootMetadata = rootMetadata || (metadata as Kind30040Metadata); + if (node.kind === 30041) { // Content event - nested under 30040, so use NKBIP-08 tags - const contentMetadata: Kind30041Metadata = { + if (!parentMetadata) { + errors.push("30041 event must have a parent 30040 metadata"); + return; + } + + // Build base 30041 metadata + const baseMetadata: Kind30041Metadata = { kind: 30041, title: node.title, - // Inherit NKBIP-08 tags from parent 30040 - collection_id: parentMetadata?.collection_id, - title_id: parentMetadata ? normalizeDTag(parentMetadata.title) : undefined, - chapter_id: node.dTag, - section_id: node.dTag, - version_tag: parentMetadata?.version_tag, }; + // Determine if this 30041 is directly under root (making it a chapter) or under a chapter (making it a section) + // If parent is root, this 30041 is a chapter (not a section) + const isChapter = isParentRoot; + + // Build and apply NKBIP-08 tags from parent + // If isChapter: c tag uses 30041's own title, no s tag + // If not isChapter: c tag uses parent chapter title, s tag uses section title + const nkbip08Tags = buildNKBIP08TagsFor30041( + parentMetadata, + currentRootMetadata, // Root metadata for collection_id inheritance + currentBookTitle, // T tag: book title + isChapter ? node.title : parentMetadata.title, // c tag: chapter title (30041's title if chapter, parent's if section) + node.title, // s tag: section title (this 30041, but only used if not isChapter) + isChapter // Flag: true if this is a chapter (direct child of root) + ); + const contentMetadata = applyNKBIP08TagsTo30041(baseMetadata, nkbip08Tags); + const tags = buildTagsFromMetadata(contentMetadata, pubkey); const event = createSignedEvent(30041, node.content || "", tags, privkey); events.push(event); @@ -90,15 +129,16 @@ export async function buildAsciiDocEvents( const childEvents: Array<{ kind: number; dTag: string; eventId?: string }> = []; // Merge parent metadata with node metadata for nested 30040 events - // Inherit NKBIP-08 tags from parent if this is a nested 30040 const baseMetadata = node.metadata as Kind30040Metadata; + + // Merge NKBIP-08 tags (inherits collection_id from root, version_tag from parent if present, otherwise uses own) + const mergedNKBIP08Tags = mergeNKBIP08TagsFor30040(parentMetadata, baseMetadata, currentRootMetadata); + + // Build merged metadata with inherited NKBIP-08 tags const mergedMetadata: Kind30040Metadata = { ...baseMetadata, kind: 30040, title: node.title, - // Inherit NKBIP-08 tags from parent 30040 if present - collection_id: parentMetadata?.collection_id || baseMetadata.collection_id, - version_tag: parentMetadata?.version_tag || baseMetadata.version_tag, // Inherit other 30040 tags from parent author: parentMetadata?.author || baseMetadata.author, type: parentMetadata?.type || baseMetadata.type, @@ -110,10 +150,18 @@ export async function buildAsciiDocEvents( image: parentMetadata?.image || baseMetadata.image, auto_update: parentMetadata?.auto_update || baseMetadata.auto_update, }; - - // Build all children first, passing merged metadata as parent + + // Apply merged NKBIP-08 tags + const finalMetadata = applyNKBIP08TagsTo30040(mergedMetadata, mergedNKBIP08Tags); + + // Determine if this is a book (root) or chapter (has parent) + const isBook = !parentMetadata; + const isChapter = !!parentMetadata; + const isRoot = !parentMetadata; // This node is the root + + // Build all children first, passing final metadata as parent and book title for (const child of node.children) { - await buildEventsFromNode(child, mergedMetadata); + await buildEventsFromNode(child, finalMetadata, currentBookTitle, isRoot, currentRootMetadata); // Find the event we just created for this child const childEvent = events.find((e) => { @@ -131,15 +179,30 @@ export async function buildAsciiDocEvents( } // Now build this index event with references to children - const tags = buildTagsFromMetadata(mergedMetadata, pubkey, childEvents); - const event = createSignedEvent(30040, "", tags, privkey); + // We need to manually add NKBIP-08 tags with proper book/chapter flags + const tags = buildTagsFromMetadata(finalMetadata, pubkey, childEvents); + + // Override NKBIP-08 tags with proper book/chapter identification + // Remove any existing NKBIP-08 tags first + const filteredTags = tags.filter(t => + t[0] !== NKBIP08_TAGS.COLLECTION && + t[0] !== NKBIP08_TAGS.TITLE && + t[0] !== NKBIP08_TAGS.CHAPTER && + t[0] !== NKBIP08_TAGS.VERSION + ); + + // Add NKBIP-08 tags with proper flags + // Chapters inherit T tag from book + addNKBIP08TagsTo30040(filteredTags, finalMetadata, isBook, isChapter, currentBookTitle, currentRootMetadata); + + const event = createSignedEvent(30040, "", filteredTags, privkey); events.push(event); - node.metadata = mergedMetadata; + node.metadata = finalMetadata; } } - // Build events starting from root - await buildEventsFromNode(rootNode, metadata as Kind30040Metadata); + // 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); // Sort events: indexes first, then content (for proper dependency order) events.sort((a, b) => { @@ -162,8 +225,7 @@ export async function buildEvents( app: any ): Promise { // Check if this is an AsciiDoc document with structure - const isAsciiDoc = file.extension === "adoc" || file.extension === "asciidoc"; - const hasStructure = isAsciiDoc && content.trim().startsWith("=") && !content.trim().startsWith("=="); + const hasStructure = isAsciiDocFile(file) && isAsciiDocDocument(content); if (hasStructure && (metadata.kind === 30040 || metadata.kind === 30041)) { // Parse header title and merge with metadata diff --git a/src/main.ts b/src/main.ts index 3ef011d..13f874c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,26 +1,26 @@ -import { Plugin, TFile, TFolder, Notice } from "obsidian"; -import { ScriptoriumSettings, EventKind, EventMetadata, DEFAULT_SETTINGS } from "./types"; +import { Plugin, TFile, Notice } from "obsidian"; +import { ScriptoriumSettings, EventKind, DEFAULT_SETTINGS } from "./types"; import { ScriptoriumSettingTab } from "./ui/settingsTab"; -import { MetadataModal } from "./ui/metadataModal"; -import { StructurePreviewModal } from "./ui/structurePreviewModal"; import { NewDocumentModal } from "./ui/newDocumentModal"; -import { MetadataReminderModal } from "./ui/metadataReminderModal"; -import { readMetadata, writeMetadata, createDefaultMetadata, validateMetadata, mergeWithHeaderTitle } from "./metadataManager"; -import { buildEvents } from "./eventManager"; -import { saveEvents, loadEvents, eventsFileExists } from "./eventStorage"; -import { publishEventsWithRetry } from "./nostr/relayClient"; -import { getWriteRelays } from "./relayManager"; -import { parseAsciiDocStructure, isAsciiDocDocument } from "./asciidocParser"; -import { normalizeSecretKey, getPubkeyFromPrivkey } from "./nostr/eventBuilder"; -import { safeConsoleError, safeConsoleLog, verifyEventSecurity } from "./utils/security"; +import { writeMetadata, createDefaultMetadata } from "./metadataManager"; +import { safeConsoleError } from "./utils/security"; +import { showErrorNotice } from "./utils/errorHandling"; +import { log, logError } from "./utils/console"; +import { getFolderNameForKind } from "./utils/eventKind"; +import { + getCurrentFile, + ensureNostrNotesFolder, + handleCreateEvents, + handlePreviewStructure, + handlePublishEvents, + handleEditMetadata, +} from "./commands/commandHandlers"; export default class ScriptoriumPlugin extends Plugin { settings!: ScriptoriumSettings; async onload() { - // Log to terminal console that started Obsidian - console.error("[Scriptorium] Plugin loading..."); - process.stderr.write("[Scriptorium] Plugin loading...\n"); + log("Plugin loading..."); try { await this.loadSettings(); @@ -28,64 +28,54 @@ export default class ScriptoriumPlugin extends Plugin { // Note: We don't register file extensions for .adoc or .asciidoc files // Users should install the obsidian-asciidoc plugin for .adoc file support - console.error("[Scriptorium] Plugin loaded - file extensions not registered"); - process.stderr.write("[Scriptorium] Plugin loaded - file extensions not registered\n"); - console.error("[Scriptorium] Install obsidian-asciidoc plugin for .adoc file editing support"); - process.stderr.write("[Scriptorium] Install obsidian-asciidoc plugin for .adoc file editing support\n"); - - // Add settings tab - this.addSettingTab(new ScriptoriumSettingTab(this.app, this)); - - // Register commands - this.addCommand({ - id: "create-nostr-events", - name: "Create Nostr Events", - callback: () => this.handleCreateEvents(), - }); - - this.addCommand({ - id: "preview-structure", - name: "Preview Document Structure", - callback: () => this.handlePreviewStructure(), - }); - - this.addCommand({ - id: "publish-events", - name: "Publish Events to Relays", - callback: () => this.handlePublishEvents(), - }); - - this.addCommand({ - id: "edit-metadata", - name: "Edit Metadata", - callback: () => this.handleEditMetadata(), - }); - - this.addCommand({ - id: "new-nostr-document", - name: "New Nostr Document", - callback: () => this.handleNewDocument(), - }); - - // Add ribbon icon for creating new documents - this.addRibbonIcon("file-plus", "New Nostr Document", () => { - this.handleNewDocument(); - }); + log("Plugin loaded - file extensions not registered"); + log("Install obsidian-asciidoc plugin for .adoc file editing support"); + + // Add settings tab + this.addSettingTab(new ScriptoriumSettingTab(this.app, this)); + + // Register commands + this.addCommand({ + id: "create-nostr-events", + name: "Create Nostr Events", + callback: () => this.handleCreateEvents(), + }); + + this.addCommand({ + id: "preview-structure", + name: "Preview Document Structure", + callback: () => this.handlePreviewStructure(), + }); + + this.addCommand({ + id: "publish-events", + name: "Publish Events to Relays", + callback: () => this.handlePublishEvents(), + }); + + this.addCommand({ + id: "edit-metadata", + name: "Edit Metadata", + callback: () => this.handleEditMetadata(), + }); + + this.addCommand({ + id: "new-nostr-document", + name: "New Nostr Document", + callback: () => this.handleNewDocument(), + }); + + // Add ribbon icon for creating new documents + this.addRibbonIcon("file-plus", "New Nostr Document", () => { + this.handleNewDocument(); + }); // Status bar this.addStatusBarItem().setText("Scriptorium"); - console.error("[Scriptorium] Plugin loaded successfully"); - process.stderr.write("[Scriptorium] Plugin loaded successfully\n"); + log("Plugin loaded successfully"); } catch (error: any) { - const errorMsg = error?.message || String(error); - const stackTrace = error?.stack || ""; - console.error(`[Scriptorium] Error loading plugin: ${errorMsg}`); - process.stderr.write(`[Scriptorium] Error loading plugin: ${errorMsg}\n`); - if (stackTrace) { - console.error(`[Scriptorium] Stack trace:`, stackTrace); - process.stderr.write(`[Scriptorium] Stack trace: ${stackTrace}\n`); - } + logError("Error loading plugin", error); safeConsoleError("Error loading plugin:", error); } } @@ -119,290 +109,38 @@ export default class ScriptoriumPlugin extends Plugin { return false; } - /** - * Get folder name for an event kind - */ - private getFolderNameForKind(kind: EventKind): string { - switch (kind) { - case 1: - return "kind-1-notes"; - case 11: - return "kind-11-threads"; - case 30023: - return "kind-30023-articles"; - case 30040: - return "kind-30040-publications"; - case 30041: - return "kind-30041-chapters"; - case 30817: - return "kind-30817-wiki-md"; - case 30818: - return "kind-30818-wiki-adoc"; - } - } - - /** - * Ensure the Nostr notes folder structure exists - */ - private async ensureNostrNotesFolder(kind: EventKind): Promise { - const baseFolder = "Nostr notes"; - const kindFolder = this.getFolderNameForKind(kind); - const fullPath = `${baseFolder}/${kindFolder}`; - - // Check if base folder exists - const baseFolderObj = this.app.vault.getAbstractFileByPath(baseFolder); - if (!baseFolderObj || !(baseFolderObj instanceof TFolder)) { - await this.app.vault.createFolder(baseFolder); - } - - // Check if kind folder exists - const kindFolderObj = this.app.vault.getAbstractFileByPath(fullPath); - if (!kindFolderObj || !(kindFolderObj instanceof TFolder)) { - await this.app.vault.createFolder(fullPath); - } - - return fullPath; - } - - private async getCurrentFile(): Promise { - const activeFile = this.app.workspace.getActiveFile(); - if (!activeFile) { - new Notice("No active file"); - return null; - } - return activeFile; - } private async handleCreateEvents() { - const file = await this.getCurrentFile(); + const file = await getCurrentFile(this.app); if (!file) return; - - if (!this.settings.privateKey) { - new Notice("Please set your private key in settings"); - return; - } - - try { - const content = await this.app.vault.read(file); - let metadata = await readMetadata(file, this.app); - - // Determine event kind from file extension or metadata - let eventKind: EventKind = this.settings.defaultEventKind; - if (file.extension === "adoc" || file.extension === "asciidoc") { - if (isAsciiDocDocument(content)) { - eventKind = 30040; - } else { - eventKind = 30818; - } - } else if (file.extension === "md") { - eventKind = metadata?.kind || this.settings.defaultEventKind; - } - - // Ensure folder structure exists before creating events - await this.ensureNostrNotesFolder(eventKind); - - // Create default metadata if none exists and write it with placeholders - if (!metadata) { - metadata = createDefaultMetadata(eventKind); - await writeMetadata(file, metadata, this.app); - // Re-read to get the formatted version with placeholders - metadata = await readMetadata(file, this.app) || metadata; - } - - // Merge with header title for 30040 - if (eventKind === 30040 && isAsciiDocDocument(content)) { - const headerTitle = content.split("\n")[0]?.replace(/^=+\s*/, "").trim() || ""; - metadata = mergeWithHeaderTitle(metadata, headerTitle); - } - - // Show reminder modal before proceeding - new MetadataReminderModal(this.app, eventKind, async () => { - // Re-read metadata after user confirms (they may have updated it) - const updatedContent = await this.app.vault.read(file); - let updatedMetadata: EventMetadata = await readMetadata(file, this.app) || metadata || createDefaultMetadata(eventKind); - - // Ensure we have valid metadata - if (!updatedMetadata) { - updatedMetadata = createDefaultMetadata(eventKind); - } - - // Merge with header title for 30040 - if (eventKind === 30040 && isAsciiDocDocument(updatedContent)) { - const headerTitle = updatedContent.split("\n")[0]?.replace(/^=+\s*/, "").trim() || ""; - updatedMetadata = mergeWithHeaderTitle(updatedMetadata, headerTitle); - } - - // Validate metadata - const validation = validateMetadata(updatedMetadata, eventKind); - if (!validation.valid) { - new Notice(`Metadata validation failed: ${validation.errors.join(", ")}`); - return; - } - - // Build events - if (!this.settings.privateKey) { - new Notice("Please set your private key in settings"); - return; - } - const result = await buildEvents(file, updatedContent, updatedMetadata, this.settings.privateKey, this.app); - - if (result.errors.length > 0) { - new Notice(`Errors: ${result.errors.join(", ")}`); - return; - } - - // Security check: verify events don't contain private keys - for (const event of result.events) { - if (!verifyEventSecurity(event)) { - new Notice("Security error: Event contains private key. Aborting."); - safeConsoleError("Event security check failed - event may contain private key"); - return; - } - } - - // Show preview for structured documents - if (result.structure.length > 0) { - new StructurePreviewModal(this.app, result.structure, async () => { - await saveEvents(file, result.events, this.app); - new Notice(`Created ${result.events.length} event(s) and saved to ${file.basename}_events.jsonl`); - }).open(); - } else { - await saveEvents(file, result.events, this.app); - new Notice(`Created ${result.events.length} event(s) and saved to ${file.basename}_events.jsonl`); - } - }).open(); - } catch (error: any) { - const safeMessage = error?.message ? String(error.message).replace(/nsec1[a-z0-9]{58,}/gi, "[REDACTED]").replace(/[0-9a-f]{64}/gi, "[REDACTED]") : "Unknown error"; - new Notice(`Error creating events: ${safeMessage}`); - safeConsoleError("Error creating events:", error); - } + await handleCreateEvents(this.app, file, this.settings); } private async handlePreviewStructure() { - const file = await this.getCurrentFile(); + const file = await getCurrentFile(this.app); if (!file) return; - - try { - const content = await this.app.vault.read(file); - if (!isAsciiDocDocument(content)) { - new Notice("This file is not an AsciiDoc document with structure"); - return; - } - - let metadata = await readMetadata(file, this.app); - if (!metadata || metadata.kind !== 30040) { - metadata = createDefaultMetadata(30040); - } - - const headerTitle = content.split("\n")[0]?.replace(/^=+\s*/, "").trim() || ""; - metadata = mergeWithHeaderTitle(metadata, headerTitle); - - const structure = parseAsciiDocStructure(content, metadata as any); - new StructurePreviewModal(this.app, structure, () => {}).open(); - } catch (error: any) { - const safeMessage = error?.message ? String(error.message).replace(/nsec1[a-z0-9]{58,}/gi, "[REDACTED]").replace(/[0-9a-f]{64}/gi, "[REDACTED]") : "Unknown error"; - new Notice(`Error previewing structure: ${safeMessage}`); - safeConsoleError("Error previewing structure:", error); - } + await handlePreviewStructure(this.app, file); } private async handlePublishEvents() { - const file = await this.getCurrentFile(); + const file = await getCurrentFile(this.app); if (!file) return; - - if (!this.settings.privateKey) { - new Notice("Please set your private key in settings"); - return; - } - - const exists = await eventsFileExists(file, this.app); - if (!exists) { - new Notice("No events file found. Please create events first."); - return; - } - - try { - const events = await loadEvents(file, this.app); - if (events.length === 0) { - new Notice("No events to publish"); - return; - } - - const writeRelays = getWriteRelays(this.settings.relayList); - if (writeRelays.length === 0) { - new Notice("No write relays configured. Please fetch relay list in settings."); - return; - } - - // Relays are already normalized and deduplicated by getWriteRelays - new Notice(`Publishing ${events.length} event(s) to ${writeRelays.length} relay(s)...`); - - const results = await publishEventsWithRetry(writeRelays, events, this.settings.privateKey); - - // Count successes - let successCount = 0; - let failureCount = 0; - results.forEach((relayResults) => { - relayResults.forEach((result) => { - if (result.success) { - successCount++; - } else { - failureCount++; - } - }); - }); - - if (failureCount === 0) { - new Notice(`Successfully published all ${successCount} event(s)`); - } else { - new Notice(`Published ${successCount} event(s), ${failureCount} failed`); - } - } catch (error: any) { - const safeMessage = error?.message ? String(error.message).replace(/nsec1[a-z0-9]{58,}/gi, "[REDACTED]").replace(/[0-9a-f]{64}/gi, "[REDACTED]") : "Unknown error"; - new Notice(`Error publishing events: ${safeMessage}`); - safeConsoleError("Error publishing events:", error); - } + await handlePublishEvents(this.app, file, this.settings); } private async handleEditMetadata() { - const file = await this.getCurrentFile(); + const file = await getCurrentFile(this.app); if (!file) return; - - try { - let metadata = await readMetadata(file, this.app); - if (!metadata) { - // Determine kind from file extension - let eventKind: EventKind = this.settings.defaultEventKind; - if (file.extension === "adoc" || file.extension === "asciidoc") { - const content = await this.app.vault.read(file); - if (isAsciiDocDocument(content)) { - eventKind = 30040; - } else { - eventKind = 30818; - } - } - metadata = createDefaultMetadata(eventKind); - } - - new MetadataModal(this.app, metadata, async (updatedMetadata) => { - await writeMetadata(file, updatedMetadata, this.app); - new Notice("Metadata saved"); - }).open(); - } catch (error: any) { - const safeMessage = error?.message ? String(error.message).replace(/nsec1[a-z0-9]{58,}/gi, "[REDACTED]").replace(/[0-9a-f]{64}/gi, "[REDACTED]") : "Unknown error"; - new Notice(`Error editing metadata: ${safeMessage}`); - safeConsoleError("Error editing metadata:", error); - } + await handleEditMetadata(this.app, file, this.settings.defaultEventKind); } private async handleNewDocument() { new NewDocumentModal(this.app, async (kind: EventKind, title: string) => { try { - console.error(`[Scriptorium] Creating new document: kind=${kind}, title=${title}`); - process.stderr.write(`[Scriptorium] Creating new document: kind=${kind}, title=${title}\n`); + log(`Creating new document: kind=${kind}, title=${title}`); // Ensure folder structure exists - const folderPath = await this.ensureNostrNotesFolder(kind); + const folderPath = await ensureNostrNotesFolder(this.app, kind); // Sanitize filename from title const sanitizedTitle = this.sanitizeFilename(title); @@ -448,33 +186,22 @@ export default class ScriptoriumPlugin extends Plugin { let file: TFile; try { - console.error(`[Scriptorium] Creating file: ${filePath}`); - process.stderr.write(`[Scriptorium] Creating file: ${filePath}\n`); + log(`Creating file: ${filePath}`); file = await this.app.vault.create(filePath, content); - console.error(`[Scriptorium] File created successfully: ${file.path}`); - process.stderr.write(`[Scriptorium] File created successfully: ${file.path}\n`); + log(`File created successfully: ${file.path}`); // Verify file was actually created const verifyFile = this.app.vault.getAbstractFileByPath(filePath); if (!verifyFile || !(verifyFile instanceof TFile)) { const msg = `Error: File ${filename} was not created properly`; - console.error(`[Scriptorium] ${msg}`); - process.stderr.write(`[Scriptorium] ${msg}\n`); + log(msg); new Notice(msg); - safeConsoleError(`File creation verification failed for ${filePath}`); + logError("File creation verification failed", { filePath }); return; } } catch (error: any) { - const safeMessage = error?.message ? String(error.message).replace(/nsec1[a-z0-9]{58,}/gi, "[REDACTED]").replace(/[0-9a-f]{64}/gi, "[REDACTED]") : "Unknown error"; - console.error(`[Scriptorium] Error creating file: ${safeMessage}`); - process.stderr.write(`[Scriptorium] Error creating file: ${safeMessage}\n`); - if (error?.stack) { - console.error(`[Scriptorium] Stack trace:`, error.stack); - process.stderr.write(`[Scriptorium] Stack trace: ${error.stack}\n`); - } - new Notice(`Error creating file: ${safeMessage}`); - safeConsoleError("Error creating file:", error); - safeConsoleError("File path was:", filePath); + logError("Error creating file", error); + showErrorNotice("Error creating file", error); return; } @@ -489,16 +216,14 @@ export default class ScriptoriumPlugin extends Plugin { // Write metadata with all placeholders (title will be included if set) await writeMetadata(file, metadata, this.app); } catch (error: any) { - const safeMessage = error?.message ? String(error.message).replace(/nsec1[a-z0-9]{58,}/gi, "[REDACTED]").replace(/[0-9a-f]{64}/gi, "[REDACTED]") : "Unknown error"; - new Notice(`Error creating metadata: ${safeMessage}`); + showErrorNotice("Error creating metadata", error); safeConsoleError("Error creating metadata:", error); // Continue anyway - file was created } // For .adoc files, ensure file is visible in explorer but don't auto-open if (file.extension === "adoc" || file.extension === "asciidoc") { - console.error(`[Scriptorium] AsciiDoc file created: ${file.path}`); - process.stderr.write(`[Scriptorium] AsciiDoc file created: ${file.path}\n`); + log(`AsciiDoc file created: ${file.path}`); // File should be visible in Obsidian's file explorer automatically // since we used vault.create(). The file explorer will refresh automatically. @@ -508,35 +233,24 @@ export default class ScriptoriumPlugin extends Plugin { } else { // Open the new file in Obsidian workspace (use active leaf or create new) // Use a small delay to ensure file is fully created before opening - console.error(`[Scriptorium] Waiting before opening file...`); - process.stderr.write(`[Scriptorium] Waiting before opening file...\n`); + log("Waiting before opening file..."); await new Promise(resolve => setTimeout(resolve, 200)); try { - console.error(`[Scriptorium] Attempting to open file: ${file.path} (extension: ${file.extension})`); - process.stderr.write(`[Scriptorium] Attempting to open file: ${file.path} (extension: ${file.extension})\n`); + log(`Attempting to open file: ${file.path} (extension: ${file.extension})`); const leaf = this.app.workspace.getMostRecentLeaf(); if (leaf && leaf.view) { await leaf.openFile(file, { active: true }); - console.error(`[Scriptorium] File opened successfully in existing leaf`); - process.stderr.write(`[Scriptorium] File opened successfully in existing leaf\n`); + log("File opened successfully in existing leaf"); } else { // Fallback: open in new leaf const newLeaf = this.app.workspace.getLeaf("tab"); await newLeaf.openFile(file, { active: true }); - console.error(`[Scriptorium] File opened successfully in new leaf`); - process.stderr.write(`[Scriptorium] File opened successfully in new leaf\n`); + log("File opened successfully in new leaf"); } } catch (error: any) { - const errorMsg = error?.message || String(error); - const stackTrace = error?.stack || ""; - console.error(`[Scriptorium] Error opening file: ${errorMsg}`); - process.stderr.write(`[Scriptorium] Error opening file: ${errorMsg}\n`); - if (stackTrace) { - console.error(`[Scriptorium] Stack trace:`, stackTrace); - process.stderr.write(`[Scriptorium] Stack trace: ${stackTrace}\n`); - } + logError("Error opening file", error); safeConsoleError("Error opening file:", error); // File was created, just couldn't open it - show a notice new Notice(`File created but couldn't open: ${file.name}`); @@ -545,8 +259,7 @@ export default class ScriptoriumPlugin extends Plugin { new Notice(`Created ${filename} in ${folderPath}`); } catch (error: any) { - const safeMessage = error?.message ? String(error.message).replace(/nsec1[a-z0-9]{58,}/gi, "[REDACTED]").replace(/[0-9a-f]{64}/gi, "[REDACTED]") : "Unknown error"; - new Notice(`Error creating document: ${safeMessage}`); + showErrorNotice("Error creating document", error); safeConsoleError("Error creating document:", error); } }).open(); diff --git a/src/metadataManager.ts b/src/metadataManager.ts index 2a3327c..a6931ad 100644 --- a/src/metadataManager.ts +++ b/src/metadataManager.ts @@ -1,6 +1,7 @@ import { TFile } from "obsidian"; import { EventKind, EventMetadata } from "./types"; import { safeConsoleError } from "./utils/security"; +import { isMarkdownFile, isAsciiDocFile } from "./utils/fileExtensions"; /** * Tag definitions with descriptions for each event kind @@ -44,7 +45,7 @@ const TAG_DEFINITIONS: Record = { { key: "image", description: "Cover image URL", required: false }, { key: "auto_update", description: "Auto-update: yes, ask, or no", required: false }, { key: "topics", description: "Comma-separated topics (e.g., 'bitcoin, nostr')", required: false }, - { key: "collection_id", description: "NKBIP-08 collection identifier (C tag)", required: false }, + { key: "collection_id", description: "NKBIP-08 collection identifier (C tag) - Optional: compendium, digest, or library of related books (e.g., 'bible', 'goethe-complete-works', 'encyclopedia-britannica')", required: false }, { key: "version_tag", description: "NKBIP-08 version identifier (e.g., kjv, drb)", required: false }, ], 30041: [ @@ -55,6 +56,7 @@ const TAG_DEFINITIONS: Record = { { key: "topics", description: "Comma-separated topics (e.g., 'bitcoin, nostr')", required: false }, // Note: NKBIP-08 tags (collection_id, title_id, chapter_id, section_id, version_tag) // are only used when 30041 is nested under 30040, not for stand-alone 30041 events + // collection_id is inherited from root 30040 if present ], 30817: [ { key: "title", description: "Wiki page title (required)", required: true }, @@ -269,7 +271,7 @@ export async function readMetadata( try { const content = await app.vault.read(file); - if (file.extension === "md" || file.extension === "markdown") { + if (isMarkdownFile(file)) { const { metadata } = parseMarkdownFrontmatter(content); if (Object.keys(metadata).length === 0) { return null; @@ -277,7 +279,7 @@ export async function readMetadata( const kind = (metadata.kind as EventKind) || 1; const filtered = filterPlaceholders(metadata, kind); return filtered as EventMetadata; - } else if (file.extension === "adoc" || file.extension === "asciidoc") { + } else if (isAsciiDocFile(file)) { const { metadata } = parseAsciiDocAttributes(content); if (Object.keys(metadata).length === 0) { return null; @@ -299,10 +301,10 @@ export async function readMetadata( * For AsciiDoc, keeps the title header but removes attribute lines */ export function stripMetadataFromContent(file: TFile, content: string): string { - if (file.extension === "md" || file.extension === "markdown") { + if (isMarkdownFile(file)) { const { body } = parseMarkdownFrontmatter(content); return body; - } else if (file.extension === "adoc" || file.extension === "asciidoc") { + } else if (isAsciiDocFile(file)) { const lines = content.split("\n"); const result: string[] = []; let foundTitle = false; @@ -351,12 +353,12 @@ export async function writeMetadata( try { const currentContent = await app.vault.read(file); - if (file.extension === "md" || file.extension === "markdown") { + if (isMarkdownFile(file)) { const { body } = parseMarkdownFrontmatter(currentContent); const frontmatter = formatMarkdownFrontmatter(metadata); const newContent = frontmatter ? `---\n${frontmatter}---\n${body}` : body; await app.vault.modify(file, newContent); - } else if (file.extension === "adoc" || file.extension === "asciidoc") { + } else if (isAsciiDocFile(file)) { // For AsciiDoc, we need to preserve the title if it exists in the body // and remove old attributes const { body } = parseAsciiDocAttributes(currentContent); diff --git a/src/nostr/eventBuilder.ts b/src/nostr/eventBuilder.ts index dede40a..70a1c92 100644 --- a/src/nostr/eventBuilder.ts +++ b/src/nostr/eventBuilder.ts @@ -1,6 +1,7 @@ import { finalizeEvent, getEventHash, getPublicKey, nip19 } from "nostr-tools"; -import { EventKind, EventMetadata, SignedEvent, Kind30041Metadata } from "../types"; +import { EventKind, EventMetadata, SignedEvent, Kind30041Metadata, Kind30040Metadata } from "../types"; import { sanitizeString } from "../utils/security"; +import { addNKBIP08TagsTo30040, addNKBIP08TagsTo30041 } from "./nkbip08Tags"; /** * Normalize secret key from bech32 nsec or hex format to hex @@ -145,8 +146,10 @@ export function buildTagsFromMetadata( metadata.topics.forEach((topic) => tags.push(["t", topic])); } // NKBIP-08 tags - if (metadata.collection_id) tags.push(["C", metadata.collection_id]); - if (metadata.version_tag) tags.push(["v", metadata.version_tag]); + // Note: For structured documents, NKBIP-08 tags are added in eventManager.ts + // with proper book/chapter identification. For simple 30040 events, treat as book. + const meta30040 = metadata as Kind30040Metadata; + addNKBIP08TagsTo30040(tags, meta30040, true, false, undefined, meta30040); // Simple 30040 is a book, use itself as root // Additional tags if (metadata.additional_tags) { metadata.additional_tags.forEach((tag) => tags.push(tag)); @@ -179,11 +182,7 @@ export function buildTagsFromMetadata( } // NKBIP-08 tags (only for nested 30041 under 30040) - if (meta30041.collection_id) tags.push(["C", meta30041.collection_id]); - if (meta30041.title_id) tags.push(["T", meta30041.title_id]); - if (meta30041.chapter_id) tags.push(["c", meta30041.chapter_id]); - if (meta30041.section_id) tags.push(["s", meta30041.section_id]); - if (meta30041.version_tag) tags.push(["v", meta30041.version_tag]); + addNKBIP08TagsTo30041(tags, meta30041); break; case 30817: diff --git a/src/nostr/nkbip08Tags.ts b/src/nostr/nkbip08Tags.ts new file mode 100644 index 0000000..42fee68 --- /dev/null +++ b/src/nostr/nkbip08Tags.ts @@ -0,0 +1,319 @@ +import { Kind30040Metadata, Kind30041Metadata, EventMetadata } from "../types"; + +/** + * NKBIP-08 tag names + */ +export const NKBIP08_TAGS = { + COLLECTION: "C", + TITLE: "T", + CHAPTER: "c", + SECTION: "s", + VERSION: "v", +} as const; + +/** + * Normalize tag values according to NKBIP-08 (NIP-54 rules): + * - Remove quotes (single and double) + * - Convert any non-letter non-number character to a hyphen + * - Convert all letters to lowercase + * - Numbers are preserved (not converted to hyphens) + * - Collapse multiple hyphens to single hyphen + * - Trim leading/trailing hyphens + * + * 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. + */ +export function normalizeNKBIP08TagValue(text: string): string { + if (!text) { + return ""; + } + + // Remove quotes (single and double) + let normalized = text.trim().replace(/^["']|["']$/g, ""); + + // Normalize: lowercase, convert non-letter non-number to hyphen + // Per NKBIP-08: "Section identifiers cannot contain colons in tag values. + // Hierarchical paths with colons MUST be normalized: colons → hyphens" + let result = ""; + for (let i = 0; i < normalized.length; i++) { + const char = normalized[i]; + if ((char >= "a" && char <= "z") || (char >= "A" && char <= "Z") || (char >= "0" && char <= "9")) { + result += char.toLowerCase(); + } else { + // Non-alphanumeric (including colons) becomes hyphen (but don't add consecutive hyphens) + if (result && result[result.length - 1] !== "-") { + result += "-"; + } + } + } + + // Collapse multiple hyphens + result = result.replace(/-+/g, "-"); + + // Trim leading/trailing hyphens + result = result.replace(/^-+|-+$/g, ""); + + return result; +} + +/** + * NKBIP-08 tag metadata for 30040 (publication index) + */ +export interface NKBIP08_30040Tags { + collection_id?: string; // C tag (optional - for compendiums, digests, libraries) + version_tag?: string; // v tag +} + +/** + * NKBIP-08 tag metadata for 30041 (publication content) when nested under 30040 + */ +export interface NKBIP08_30041Tags { + collection_id?: string; // C tag (inherited from root 30040) + title_id?: string; // T tag (derived from root 30040 title) + chapter_id?: string; // c tag (from chapter title) + section_id?: string; // s tag (from section title, only if not a chapter) + version_tag?: string; // v tag (inherited from parent 30040) +} + +/** + * Extract NKBIP-08 tags from a 30040 metadata object + */ +export function extractNKBIP08TagsFrom30040( + metadata: Kind30040Metadata +): NKBIP08_30040Tags { + return { + collection_id: metadata.collection_id, + version_tag: metadata.version_tag, + }; +} + +/** + * Build NKBIP-08 tags for a nested 30041 event under a 30040 parent + * + * Per NKBIP-08 spec: + * - C tag (collection_id): Inherited from root 30040 (optional - for compendiums, digests, libraries) + * - T tag (title_id): Normalized book title (from root 30040, not just immediate parent) + * - c tag (chapter_id): Normalized chapter identifier + * - If 30041 is directly under root (isChapter=true): from 30041's own title + * - If 30041 is under a chapter (isChapter=false): from parent 30040's title + * - s tag (section_id): Normalized section identifier (from 30041 node's title) + * - Only added if isChapter=false (i.e., this is a section, not a chapter) + * - v tag (version_tag): Inherited from parent 30040 + * + * @param parentMetadata - The parent 30040 metadata (source of inheritance) + * @param rootMetadata - The root 30040 metadata (source of collection_id) + * @param bookTitle - The book title (root 30040 title) for T tag + * @param chapterTitle - The chapter title (parent 30040 title if nested, or 30041 title if direct child) + * @param sectionTitle - The section title (30041 node title) + * @param isChapter - Whether this 30041 is directly under root (making it a chapter, not a section) + * @returns NKBIP-08 tags for the 30041 event + */ +export function buildNKBIP08TagsFor30041( + parentMetadata: Kind30040Metadata, + rootMetadata: Kind30040Metadata, + bookTitle: string, + chapterTitle: string, + sectionTitle: string, + isChapter: boolean = false +): NKBIP08_30041Tags { + return { + // C tag: Inherited from root 30040 (optional - for compendiums, digests, libraries) + collection_id: rootMetadata.collection_id ? normalizeNKBIP08TagValue(rootMetadata.collection_id) : undefined, + // Inherit from parent 30040 + version_tag: parentMetadata.version_tag ? normalizeNKBIP08TagValue(parentMetadata.version_tag) : undefined, + // T tag: Normalized book title (from root 30040) + title_id: bookTitle ? normalizeNKBIP08TagValue(bookTitle) : undefined, + // c tag: If this is a chapter (direct child of root), use its own title; otherwise use parent's title + chapter_id: chapterTitle ? normalizeNKBIP08TagValue(chapterTitle) : undefined, + // s tag: Only add if this is NOT a chapter (i.e., it's a section under a chapter) + section_id: isChapter ? undefined : (sectionTitle ? normalizeNKBIP08TagValue(sectionTitle) : undefined), + }; +} + +/** + * Merge NKBIP-08 tags for nested 30040 events + * Child 30040 inherits from parent 30040 if parent values exist, otherwise uses own values + * All values are normalized per NKBIP-08 spec + * + * @param parentMetadata - Parent 30040 metadata (optional) + * @param childMetadata - Child 30040 metadata + * @returns Merged NKBIP-08 tags (normalized) + */ +export function mergeNKBIP08TagsFor30040( + parentMetadata: Kind30040Metadata | undefined, + childMetadata: Kind30040Metadata, + rootMetadata?: Kind30040Metadata +): NKBIP08_30040Tags { + // Collection ID is inherited from root (if present), not from parent + const collectionId = rootMetadata?.collection_id || childMetadata.collection_id; + const versionTag = parentMetadata?.version_tag || childMetadata.version_tag; + + return { + collection_id: collectionId ? normalizeNKBIP08TagValue(collectionId) : undefined, + version_tag: versionTag ? normalizeNKBIP08TagValue(versionTag) : undefined, + }; +} + +/** + * Apply NKBIP-08 tags to a 30041 metadata object + * Used when building nested 30041 events under 30040 + * + * @param metadata - The 30041 metadata to update + * @param nkbip08Tags - The NKBIP-08 tags to apply + * @returns Updated metadata with NKBIP-08 tags + */ +export function applyNKBIP08TagsTo30041( + metadata: Kind30041Metadata, + nkbip08Tags: NKBIP08_30041Tags +): Kind30041Metadata { + return { + ...metadata, + collection_id: nkbip08Tags.collection_id, + title_id: nkbip08Tags.title_id, + chapter_id: nkbip08Tags.chapter_id, + section_id: nkbip08Tags.section_id, + version_tag: nkbip08Tags.version_tag, + }; +} + +/** + * Apply NKBIP-08 tags to a 30040 metadata object + * Used when building nested 30040 events + * + * @param metadata - The 30040 metadata to update + * @param nkbip08Tags - The NKBIP-08 tags to apply + * @returns Updated metadata with NKBIP-08 tags + */ +export function applyNKBIP08TagsTo30040( + metadata: Kind30040Metadata, + nkbip08Tags: NKBIP08_30040Tags +): Kind30040Metadata { + return { + ...metadata, + collection_id: nkbip08Tags.collection_id, + version_tag: nkbip08Tags.version_tag, + }; +} + +/** + * Add NKBIP-08 tags to a tags array for a 30040 event + * + * Per NKBIP-08 spec: + * - C tag (collection_id): Optional, normalized (for compendiums, digests, libraries - inherited from root) + * - T tag (title_id): MANDATORY for book/title events, also added to chapters (inherited from book) + * - c tag (chapter_id): Optional, for chapter index events, normalized + * - v tag (version_tag): Optional, normalized + * + * @param tags - The tags array to add to + * @param metadata - The 30040 metadata containing NKBIP-08 tag values + * @param isBook - Whether this is a book/title event (requires T tag) + * @param isChapter - Whether this is a chapter event (requires c tag and inherits T tag) + * @param bookTitle - The book title for T tag (used for chapters to inherit from book) + */ +export function addNKBIP08TagsTo30040( + tags: string[][], + metadata: Kind30040Metadata, + isBook: boolean = false, + isChapter: boolean = false, + bookTitle?: string, + rootMetadata?: Kind30040Metadata +): void { + // C tag (collection) - optional, inherited from root if present + if (rootMetadata?.collection_id) { + const normalized = normalizeNKBIP08TagValue(rootMetadata.collection_id); + if (normalized) { + tags.push([NKBIP08_TAGS.COLLECTION, normalized]); + } + } else if (metadata.collection_id) { + // Fallback: use own collection_id if root not provided (for root itself) + const normalized = normalizeNKBIP08TagValue(metadata.collection_id); + if (normalized) { + tags.push([NKBIP08_TAGS.COLLECTION, normalized]); + } + } + + // T tag (title) - MANDATORY for book/title events per NKBIP-08 spec + // Also added to chapters (inherited from book) + if (isBook && metadata.title) { + const normalized = normalizeNKBIP08TagValue(metadata.title); + if (normalized) { + tags.push([NKBIP08_TAGS.TITLE, normalized]); + } + } else if (isChapter && bookTitle) { + // Chapter inherits T tag from book + const normalized = normalizeNKBIP08TagValue(bookTitle); + if (normalized) { + tags.push([NKBIP08_TAGS.TITLE, normalized]); + } + } + + // c tag (chapter) - for chapter index events + if (isChapter && metadata.title) { + const normalized = normalizeNKBIP08TagValue(metadata.title); + if (normalized) { + tags.push([NKBIP08_TAGS.CHAPTER, normalized]); + } + } + + // v tag (version) - optional, add if present + if (metadata.version_tag) { + const normalized = normalizeNKBIP08TagValue(metadata.version_tag); + if (normalized) { + tags.push([NKBIP08_TAGS.VERSION, normalized]); + } + } +} + +/** + * Add NKBIP-08 tags to a tags array for a 30041 event + * Only adds tags if they are present (for nested 30041 under 30040) + * Stand-alone 30041 events don't have NKBIP-08 tags + * + * Per NKBIP-08 spec: + * - C tag (collection_id): Optional, normalized (inherited from root 30040) + * - T tag (title_id): MANDATORY for nested 30041, normalized + * - c tag (chapter_id): Optional, normalized + * - s tag (section_id): Optional, normalized + * - v tag (version_tag): Optional, normalized + * + * @param tags - The tags array to add to + * @param metadata - The 30041 metadata containing NKBIP-08 tag values + */ +export function addNKBIP08TagsTo30041( + tags: string[][], + metadata: Kind30041Metadata +): void { + // Only add NKBIP-08 tags if they exist (indicating this is a nested 30041) + // All tag values are already normalized when stored in metadata + if (metadata.collection_id) { + tags.push([NKBIP08_TAGS.COLLECTION, metadata.collection_id]); + } + if (metadata.title_id) { + tags.push([NKBIP08_TAGS.TITLE, metadata.title_id]); + } + if (metadata.chapter_id) { + tags.push([NKBIP08_TAGS.CHAPTER, metadata.chapter_id]); + } + if (metadata.section_id) { + tags.push([NKBIP08_TAGS.SECTION, metadata.section_id]); + } + if (metadata.version_tag) { + tags.push([NKBIP08_TAGS.VERSION, metadata.version_tag]); + } +} + +/** + * Check if a 30041 event has NKBIP-08 tags (indicating it's nested under 30040) + * + * @param metadata - The 30041 metadata to check + * @returns True if the event has NKBIP-08 tags + */ +export function hasNKBIP08Tags(metadata: Kind30041Metadata): boolean { + return !!( + metadata.collection_id || + metadata.title_id || + metadata.chapter_id || + metadata.section_id || + metadata.version_tag + ); +} diff --git a/src/nostr/relayClient.ts b/src/nostr/relayClient.ts index 4897745..3c2a99b 100644 --- a/src/nostr/relayClient.ts +++ b/src/nostr/relayClient.ts @@ -2,6 +2,7 @@ import { Relay } from "nostr-tools"; import { SignedEvent, PublishingResult } from "../types"; import { ensureAuthenticated, handleAuthRequiredError } from "./authHandler"; import { safeConsoleError } from "../utils/security"; +import { sanitizeErrorMessage } from "../utils/errorHandling"; import { deduplicateRelayUrls, normalizeRelayUrl } from "../relayManager"; /** @@ -66,24 +67,22 @@ export async function publishEventToRelay( }; } catch (error: any) { relay.close(); - const safeMessage = error?.message ? String(error.message).replace(/nsec1[a-z0-9]{58,}/gi, "[REDACTED]").replace(/[0-9a-f]{64}/gi, "[REDACTED]") : "Publish failed"; return { eventId: event.id, relay: relayUrl, success: false, - message: safeMessage, + message: sanitizeErrorMessage(error) || "Publish failed", }; } } catch (error: any) { if (relay) { relay.close(); } - const safeMessage = error?.message ? String(error.message).replace(/nsec1[a-z0-9]{58,}/gi, "[REDACTED]").replace(/[0-9a-f]{64}/gi, "[REDACTED]") : "Failed to connect to relay"; return { eventId: event.id, relay: relayUrl, success: false, - message: safeMessage, + message: sanitizeErrorMessage(error) || "Failed to connect to relay", }; } } diff --git a/src/types.ts b/src/types.ts index 988bc2c..6e1a203 100644 --- a/src/types.ts +++ b/src/types.ts @@ -80,7 +80,7 @@ export interface Kind30040Metadata extends BaseMetadata { derivative_pubkey?: string; additional_tags?: string[][]; // custom tags // NKBIP-08 tags - collection_id?: string; // C tag + collection_id?: string; // C tag (optional - for compendiums, digests, libraries of related books) version_tag?: string; // v tag } @@ -95,7 +95,7 @@ export interface Kind30041Metadata extends BaseMetadata { published_at?: string; topics?: string[]; // t tags // NKBIP-08 tags (only for nested 30041 under 30040) - collection_id?: string; // C tag + collection_id?: string; // C tag (inherited from root 30040) title_id?: string; // T tag chapter_id?: string; // c tag section_id?: string; // s tag diff --git a/src/ui/metadataModal.ts b/src/ui/metadataModal.ts index b44c4f9..b04a175 100644 --- a/src/ui/metadataModal.ts +++ b/src/ui/metadataModal.ts @@ -233,10 +233,10 @@ export class MetadataModal extends Modal { new Setting(container) .setName("Collection ID") - .setDesc("NKBIP-08 collection identifier (C tag)") + .setDesc("NKBIP-08 collection identifier (C tag) - Optional: compendium, digest, or library of related books (e.g., 'bible', 'goethe-complete-works', 'encyclopedia-britannica'). If set, will be inherited by all events in the hierarchy.") .addText((text) => { text.setValue(meta.collection_id || "") - .setPlaceholder("collection-id") + .setPlaceholder("e.g., bible, goethe-complete-works") .onChange((value) => { meta.collection_id = value; }); @@ -257,17 +257,6 @@ export class MetadataModal extends Modal { private render30041Fields(container: HTMLElement) { const meta = this.metadata as any; - new Setting(container) - .setName("Collection ID") - .setDesc("NKBIP-08 collection identifier (C tag)") - .addText((text) => { - text.setValue(meta.collection_id || "") - .setPlaceholder("collection-id") - .onChange((value) => { - meta.collection_id = value; - }); - }); - new Setting(container) .setName("Title ID") .setDesc("NKBIP-08 title identifier (T tag)") diff --git a/src/utils/console.ts b/src/utils/console.ts new file mode 100644 index 0000000..7325fe4 --- /dev/null +++ b/src/utils/console.ts @@ -0,0 +1,27 @@ +/** + * Centralized console logging utilities + * All logging goes through stderr for Obsidian plugin debugging + */ + +/** + * Log a message to stderr (Obsidian console) + */ +export function log(message: string): void { + console.error(`[Scriptorium] ${message}`); + if (typeof process !== "undefined" && process.stderr) { + process.stderr.write(`[Scriptorium] ${message}\n`); + } +} + +/** + * Log an error with optional stack trace + */ +export function logError(message: string, error?: any): void { + const errorMsg = error?.message || String(error || ""); + const stackTrace = error?.stack || ""; + + log(`Error: ${message} - ${errorMsg}`); + if (stackTrace) { + log(`Stack trace: ${stackTrace}`); + } +} diff --git a/src/utils/errorHandling.ts b/src/utils/errorHandling.ts new file mode 100644 index 0000000..9b63f45 --- /dev/null +++ b/src/utils/errorHandling.ts @@ -0,0 +1,22 @@ +import { Notice } from "obsidian"; +import { sanitizeString } from "./security"; + +/** + * Sanitize error message for display (removes private keys) + */ +export function sanitizeErrorMessage(error: any): string { + if (!error) return "Unknown error"; + + const message = error?.message || String(error); + return sanitizeString(message) + .replace(/nsec1[a-z0-9]{58,}/gi, "[REDACTED]") + .replace(/[0-9a-f]{64}/gi, "[REDACTED]"); +} + +/** + * Show a notice with sanitized error message + */ +export function showErrorNotice(message: string, error?: any): void { + const safeMessage = error ? sanitizeErrorMessage(error) : message; + new Notice(safeMessage); +} diff --git a/src/utils/eventKind.ts b/src/utils/eventKind.ts new file mode 100644 index 0000000..0672c2b --- /dev/null +++ b/src/utils/eventKind.ts @@ -0,0 +1,50 @@ +import { TFile } from "obsidian"; +import { EventKind } from "../types"; +import { isAsciiDocFile, isMarkdownFile } from "./fileExtensions"; +import { isAsciiDocDocument } from "../asciidocParser"; + +/** + * Determine event kind from file extension and content + */ +export function determineEventKind( + file: TFile, + content: string, + defaultKind: EventKind, + metadataKind?: EventKind +): EventKind { + if (isAsciiDocFile(file)) { + if (isAsciiDocDocument(content)) { + return 30040; + } + return 30818; + } + + if (isMarkdownFile(file)) { + return metadataKind || defaultKind; + } + + return defaultKind; +} + +/** + * Get folder name for an event kind + */ +export function getFolderNameForKind(kind: EventKind): string { + const folderMap: Record = { + 1: "kind-1-notes", + 11: "kind-11-threads", + 30023: "kind-30023-articles", + 30040: "kind-30040-publications", + 30041: "kind-30041-chapters", + 30817: "kind-30817-wiki-md", + 30818: "kind-30818-wiki-adoc", + }; + return folderMap[kind]; +} + +/** + * Check if event kind requires a title + */ +export function requiresTitle(kind: EventKind): boolean { + return kind !== 1; +} diff --git a/src/utils/fileExtensions.ts b/src/utils/fileExtensions.ts new file mode 100644 index 0000000..82cfbf4 --- /dev/null +++ b/src/utils/fileExtensions.ts @@ -0,0 +1,25 @@ +import { TFile } from "obsidian"; +import { EventKind } from "../types"; + +/** + * Check if file is a Markdown file + */ +export function isMarkdownFile(file: TFile): boolean { + return file.extension === "md" || file.extension === "markdown"; +} + +/** + * Check if file is an AsciiDoc file + */ +export function isAsciiDocFile(file: TFile): boolean { + return file.extension === "adoc" || file.extension === "asciidoc"; +} + +/** + * Get file extension type + */ +export function getFileType(file: TFile): "markdown" | "asciidoc" | "unknown" { + if (isMarkdownFile(file)) return "markdown"; + if (isAsciiDocFile(file)) return "asciidoc"; + return "unknown"; +}