diff --git a/README.md b/README.md index 2bcca33..2a39adc 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ 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) formats - Automatic book/publication parsing into nested 30040/30041 structures -- YAML metadata management with validation +- Metadata stored in Markdown frontmatter and AsciiDoc header attributes - Structure preview before creating events - Two-step workflow: create/sign events separately from publishing - Automatic relay list fetching (kind 10002) with AUTH support @@ -96,7 +96,33 @@ echo 'export SCRIPTORIUM_OBSIDIAN_KEY="nsec1..."' >> ~/.bashrc - **AsciiDoc** (`.adoc`): Kinds 30040, 30041, 30818 - **Structured AsciiDoc** (starts with `= Title`): Kind 30040 with nested 30041 events -Metadata is stored as `{filename}_metadata.yml` in the same directory. +### Metadata + +Metadata is stored directly in the file: +- **Markdown files**: YAML frontmatter at the top of the file (between `---` markers) +- **AsciiDoc files**: Header attributes (lines starting with `:key: value`) after the title + +When publishing, metadata is automatically stripped from the content before creating events. + +Example Markdown frontmatter: +```yaml +--- +kind: 30023 +title: "My Article" +author: "Author Name" +summary: "Article summary" +--- +``` + +Example AsciiDoc attributes: +```asciidoc += Book Title + +:kind: 30040 +:author: Author Name +:type: book +:summary: Book description +``` ## Development diff --git a/package-lock.json b/package-lock.json index 5226b94..d9a5e9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,9 @@ "version": "0.1.0", "license": "MIT", "dependencies": { - "js-yaml": "^4.1.0", "nostr-tools": "^2.20.0" }, "devDependencies": { - "@types/js-yaml": "^4.0.9", "@types/node": "^20.0.0", "@typescript-eslint/eslint-plugin": "5.29.0", "@typescript-eslint/parser": "5.29.0", @@ -549,13 +547,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/js-yaml": { - "version": "4.0.9", - "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", - "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -775,12 +766,6 @@ "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "license": "Python-2.0" - }, "node_modules/array-union": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", @@ -1099,18 +1084,6 @@ "node": ">=0.12.0" } }, - "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", diff --git a/package.json b/package.json index fbc6e8a..3ce34cf 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,6 @@ "homepage": "https://gitcitadel.com", "license": "MIT", "devDependencies": { - "@types/js-yaml": "^4.0.9", "@types/node": "^20.0.0", "@typescript-eslint/eslint-plugin": "5.29.0", "@typescript-eslint/parser": "5.29.0", @@ -24,7 +23,6 @@ "typescript": "5.3.3" }, "dependencies": { - "js-yaml": "^4.1.0", "nostr-tools": "^2.20.0" } } diff --git a/src/eventManager.ts b/src/eventManager.ts index af8a58c..1daba01 100644 --- a/src/eventManager.ts +++ b/src/eventManager.ts @@ -15,7 +15,7 @@ import { getPubkeyFromPrivkey, } from "./nostr/eventBuilder"; import { parseAsciiDocStructure } from "./asciidocParser"; -import { readMetadata, mergeWithHeaderTitle } from "./metadataManager"; +import { readMetadata, mergeWithHeaderTitle, stripMetadataFromContent } from "./metadataManager"; /** * Build events from a simple document (non-AsciiDoc) @@ -27,8 +27,10 @@ export async function buildSimpleEvent( privkey: string, app: any ): Promise { + // Strip metadata from content before publishing + const cleanContent = stripMetadataFromContent(file, content); const tags = buildTagsFromMetadata(metadata, getPubkeyFromPrivkey(privkey)); - const event = createSignedEvent(metadata.kind, content, tags, privkey); + const event = createSignedEvent(metadata.kind, cleanContent, tags, privkey); return [event]; } @@ -50,8 +52,12 @@ export async function buildAsciiDocEvents( const events: SignedEvent[] = []; const pubkey = getPubkeyFromPrivkey(privkey); + // Strip metadata attributes from content before parsing structure + // (but keep the title header for structure parsing) + const cleanContent = stripMetadataFromContent(file, content); + // Parse structure - const header = parseAsciiDocStructure(content, metadata as Kind30040Metadata); + const header = parseAsciiDocStructure(cleanContent, metadata as Kind30040Metadata); if (header.length === 0) { errors.push("Failed to parse AsciiDoc structure"); return { events: [], structure: [], errors }; @@ -63,10 +69,11 @@ export async function buildAsciiDocEvents( // Recursively build events from structure async function buildEventsFromNode(node: StructureNode, parentMetadata?: Kind30040Metadata): Promise { if (node.kind === 30041) { - // Content event + // Content event - nested under 30040, so use NKBIP-08 tags const contentMetadata: 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, @@ -82,9 +89,31 @@ export async function buildAsciiDocEvents( // Index event - need to build children first const childEvents: Array<{ kind: number; dTag: string; eventId?: string }> = []; - // Build all children first + // 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; + 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, + version: parentMetadata?.version || baseMetadata.version, + published_on: parentMetadata?.published_on || baseMetadata.published_on, + published_by: parentMetadata?.published_by || baseMetadata.published_by, + summary: parentMetadata?.summary || baseMetadata.summary, + source: parentMetadata?.source || baseMetadata.source, + image: parentMetadata?.image || baseMetadata.image, + auto_update: parentMetadata?.auto_update || baseMetadata.auto_update, + }; + + // Build all children first, passing merged metadata as parent for (const child of node.children) { - await buildEventsFromNode(child, node.metadata as Kind30040Metadata); + await buildEventsFromNode(child, mergedMetadata); // Find the event we just created for this child const childEvent = events.find((e) => { @@ -102,17 +131,10 @@ export async function buildAsciiDocEvents( } // Now build this index event with references to children - const baseMetadata = node.metadata as Kind30040Metadata; - const indexMetadata: Kind30040Metadata = { - ...baseMetadata, - kind: 30040, - title: node.title, - }; - - const tags = buildTagsFromMetadata(indexMetadata, pubkey, childEvents); + const tags = buildTagsFromMetadata(mergedMetadata, pubkey, childEvents); const event = createSignedEvent(30040, "", tags, privkey); events.push(event); - node.metadata = indexMetadata; + node.metadata = mergedMetadata; } } diff --git a/src/main.ts b/src/main.ts index a06ccb9..3ef011d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,6 +4,7 @@ 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"; @@ -25,9 +26,8 @@ export default class ScriptoriumPlugin extends Plugin { await this.loadSettings(); await this.loadPrivateKey(); - // Note: We don't register file extensions for .yml, .yaml, .adoc, or .asciidoc files + // Note: We don't register file extensions for .adoc or .asciidoc files // Users should install the obsidian-asciidoc plugin for .adoc file support - // YAML files can be edited with external editors or Obsidian may handle them natively 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"); @@ -201,9 +201,12 @@ export default class ScriptoriumPlugin extends Plugin { // Ensure folder structure exists before creating events await this.ensureNostrNotesFolder(eventKind); - // Create default metadata if none exists + // 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 @@ -212,40 +215,62 @@ export default class ScriptoriumPlugin extends Plugin { metadata = mergeWithHeaderTitle(metadata, headerTitle); } - // Validate metadata - const validation = validateMetadata(metadata, eventKind); - if (!validation.valid) { - new Notice(`Metadata validation failed: ${validation.errors.join(", ")}`); - return; - } + // 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); + } - // Build events - const result = await buildEvents(file, content, metadata, this.settings.privateKey, this.app); + // Merge with header title for 30040 + if (eventKind === 30040 && isAsciiDocDocument(updatedContent)) { + const headerTitle = updatedContent.split("\n")[0]?.replace(/^=+\s*/, "").trim() || ""; + updatedMetadata = mergeWithHeaderTitle(updatedMetadata, headerTitle); + } - if (result.errors.length > 0) { - new Notice(`Errors: ${result.errors.join(", ")}`); - return; - } + // Validate metadata + const validation = validateMetadata(updatedMetadata, eventKind); + if (!validation.valid) { + new Notice(`Metadata validation failed: ${validation.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"); + // 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 () => { + // 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(); - } 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}`); @@ -453,13 +478,15 @@ export default class ScriptoriumPlugin extends Plugin { return; } - // Create metadata + // Create metadata with title preset from the filename const metadata = createDefaultMetadata(kind); - if (metadata.title === "" && title) { - (metadata as any).title = title; + // Always set title if provided (even for kind 1 where it's optional) + if (title && title.trim()) { + (metadata as any).title = title.trim(); } try { + // 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"; diff --git a/src/metadataManager.ts b/src/metadataManager.ts index 0a9f7ed..2a3327c 100644 --- a/src/metadataManager.ts +++ b/src/metadataManager.ts @@ -1,34 +1,293 @@ -import * as yaml from "js-yaml"; import { TFile } from "obsidian"; import { EventKind, EventMetadata } from "./types"; import { safeConsoleError } from "./utils/security"; /** - * Get metadata file path for a given file + * Tag definitions with descriptions for each event kind */ -export function getMetadataFilePath(file: TFile): string { - const path = file.path; - const ext = file.extension; - const basePath = path.slice(0, -(ext.length + 1)); // Remove extension and dot - return `${basePath}_metadata.yml`; +interface TagDefinition { + key: string; + description: string; + required?: boolean; +} + +const TAG_DEFINITIONS: Record = { + 1: [ + { key: "title", description: "Note title (optional)", required: false }, + { key: "author", description: "Author name", required: false }, + { key: "summary", description: "Brief summary", required: false }, + { key: "topics", description: "Comma-separated topics (e.g., 'bitcoin, nostr')", required: false }, + ], + 11: [ + { key: "title", description: "Thread title (required)", required: true }, + { key: "author", description: "Author name", required: false }, + { key: "summary", description: "Brief summary", required: false }, + { key: "topics", description: "Comma-separated topics (e.g., 'bitcoin, nostr')", required: false }, + ], + 30023: [ + { key: "title", description: "Article title (required)", required: true }, + { key: "author", description: "Author name", required: false }, + { key: "summary", description: "Article summary", required: false }, + { key: "image", description: "Image URL", required: false }, + { key: "published_at", description: "Unix timestamp of first publication", required: false }, + { key: "topics", description: "Comma-separated topics (e.g., 'bitcoin, nostr')", required: false }, + ], + 30040: [ + { key: "title", description: "Publication title (required)", required: true }, + { key: "author", description: "Author name", required: false }, + { key: "type", description: "Publication type: book, illustrated, magazine, documentation, academic, blog", required: false }, + { key: "version", description: "Version or edition", required: false }, + { key: "published_on", description: "Publication date (e.g., 2003-05-13)", required: false }, + { key: "published_by", description: "Publisher or source", required: false }, + { key: "summary", description: "Brief description", required: false }, + { key: "source", description: "URL to original source", required: false }, + { 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: "version_tag", description: "NKBIP-08 version identifier (e.g., kjv, drb)", required: false }, + ], + 30041: [ + { key: "title", description: "Chapter/section title (required)", required: true }, + { key: "image", description: "Image URL", required: false }, + { key: "summary", description: "Article summary", required: false }, + { key: "published_at", description: "Unix timestamp of first publication", required: false }, + { 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 + ], + 30817: [ + { key: "title", description: "Wiki page title (required)", required: true }, + { key: "author", description: "Author name", required: false }, + { key: "summary", description: "Brief summary", required: false }, + { key: "image", description: "Image URL", required: false }, + { key: "topics", description: "Comma-separated topics (e.g., 'bitcoin, nostr')", required: false }, + ], + 30818: [ + { key: "title", description: "Wiki page title (required)", required: true }, + { key: "author", description: "Author name", required: false }, + { key: "summary", description: "Brief summary", required: false }, + { key: "image", description: "Image URL", required: false }, + { key: "topics", description: "Comma-separated topics (e.g., 'bitcoin, nostr')", required: false }, + ], +}; + +/** + * Get placeholder value for a tag + */ +function getPlaceholder(key: string, kind: EventKind): string { + const definitions = TAG_DEFINITIONS[kind]; + const def = definitions.find(d => d.key === key); + return def ? def.description : `Enter ${key}`; } /** - * Read metadata from YAML file + * Check if a value is a placeholder (still has the description) + */ +function isPlaceholder(value: any, key: string, kind: EventKind): boolean { + if (value === null || value === undefined || value === "") return true; + if (typeof value !== "string") return false; + const placeholder = getPlaceholder(key, kind); + // Check if the value exactly matches the placeholder or contains it as a substring + return value === placeholder || value.trim() === placeholder || value.includes(placeholder); +} + +/** + * Parse YAML frontmatter from Markdown file + */ +function parseMarkdownFrontmatter(content: string): { metadata: Record; body: string } { + const frontmatterRegex = /^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/; + const match = content.match(frontmatterRegex); + + if (!match) { + return { metadata: {}, body: content }; + } + + const frontmatterText = match[1]; + const body = match[2]; + const metadata: Record = {}; + + // Simple YAML parser for frontmatter (key: value pairs) + const lines = frontmatterText.split("\n"); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + + const colonIndex = trimmed.indexOf(":"); + if (colonIndex === -1) continue; + + const key = trimmed.substring(0, colonIndex).trim(); + let value = trimmed.substring(colonIndex + 1).trim(); + + // Remove quotes if present + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + + // Parse arrays (simple format: [item1, item2] or - item1) + if (value.startsWith("[") && value.endsWith("]")) { + const arrayContent = value.slice(1, -1).trim(); + metadata[key] = arrayContent.split(",").map(item => item.trim().replace(/^["']|["']$/g, "")); + } else if (trimmed.startsWith("-")) { + // Array item + const arrayKey = lines[lines.indexOf(line) - 1]?.split(":")[0]?.trim(); + if (arrayKey) { + if (!metadata[arrayKey]) metadata[arrayKey] = []; + metadata[arrayKey].push(value.replace(/^-\s*/, "").replace(/^["']|["']$/g, "")); + } + } else { + // Try to parse as number or boolean + if (value === "true") { + metadata[key] = true; + } else if (value === "false") { + metadata[key] = false; + } else if (/^-?\d+$/.test(value)) { + metadata[key] = parseInt(value, 10); + } else { + metadata[key] = value; + } + + // Ensure kind is a number + if (key === "kind" && typeof metadata[key] === "string") { + metadata[key] = parseInt(metadata[key] as string, 10); + } + } + } + + return { metadata, body }; +} + +/** + * Parse AsciiDoc header attributes + */ +function parseAsciiDocAttributes(content: string): { metadata: Record; body: string } { + const metadata: Record = {}; + const lines = content.split("\n"); + let bodyStartIndex = 0; + + // Find where the document body starts (after title and attributes) + let foundTitle = false; + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + // Document title (single =) + if (line.startsWith("=") && !line.startsWith("==") && !foundTitle) { + const title = line.slice(1).trim(); + metadata.title = title; + foundTitle = true; + bodyStartIndex = i + 1; + continue; + } + + // Attribute lines (:key: value or :key!: value) + if (line.startsWith(":") && line.includes(":")) { + const colonIndex = line.indexOf(":", 1); + if (colonIndex !== -1) { + let key = line.substring(1, colonIndex).trim(); + const isRequired = key.endsWith("!"); + if (isRequired) { + key = key.slice(0, -1); + } + let value = line.substring(colonIndex + 1).trim(); + + // Remove quotes if present + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + + // Try to parse as number or boolean + if (value === "true") { + metadata[key] = true; + } else if (value === "false") { + metadata[key] = false; + } else if (/^-?\d+$/.test(value)) { + metadata[key] = parseInt(value, 10); + } else { + metadata[key] = value; + } + + // Ensure kind is a number + if (key === "kind" && typeof metadata[key] === "string") { + metadata[key] = parseInt(metadata[key] as string, 10); + } + } + bodyStartIndex = i + 1; + } else if (foundTitle && line === "") { + // Empty line after title/attributes - body starts after this + bodyStartIndex = i + 1; + break; + } else if (foundTitle && !line.startsWith(":")) { + // Non-attribute line after title - body starts here + bodyStartIndex = i; + break; + } + } + + const body = lines.slice(bodyStartIndex).join("\n"); + return { metadata, body }; +} + +/** + * Filter out placeholder values from metadata + */ +function filterPlaceholders(metadata: Record, kind: EventKind): Record { + const filtered: Record = {}; + + for (const [key, value] of Object.entries(metadata)) { + // Always keep kind + if (key === "kind") { + filtered[key] = value; + continue; + } + + // Skip placeholder values + if (isPlaceholder(value, key, kind)) { + continue; + } + + // For arrays, filter out placeholder items + if (Array.isArray(value)) { + const filteredArray = value.filter((item: any) => !isPlaceholder(item, key, kind)); + if (filteredArray.length > 0) { + filtered[key] = filteredArray; + } + } else if (value !== "" && value != null) { + filtered[key] = value; + } + } + + return filtered; +} + +/** + * Read metadata from file content (frontmatter or AsciiDoc attributes) */ export async function readMetadata( file: TFile, app: any ): Promise { - const metadataPath = getMetadataFilePath(file); try { - const metadataFile = app.vault.getAbstractFileByPath(metadataPath); - if (!metadataFile || !(metadataFile instanceof TFile)) { - return null; + const content = await app.vault.read(file); + + if (file.extension === "md" || file.extension === "markdown") { + const { metadata } = parseMarkdownFrontmatter(content); + if (Object.keys(metadata).length === 0) { + return null; + } + const kind = (metadata.kind as EventKind) || 1; + const filtered = filterPlaceholders(metadata, kind); + return filtered as EventMetadata; + } else if (file.extension === "adoc" || file.extension === "asciidoc") { + const { metadata } = parseAsciiDocAttributes(content); + if (Object.keys(metadata).length === 0) { + return null; + } + const kind = (metadata.kind as EventKind) || 30040; + const filtered = filterPlaceholders(metadata, kind); + return filtered as EventMetadata; } - const content = await app.vault.read(metadataFile); - const parsed = yaml.load(content) as any; - return parsed as EventMetadata; + + return null; } catch (error) { safeConsoleError("Error reading metadata:", error); return null; @@ -36,28 +295,212 @@ export async function readMetadata( } /** - * Write metadata to YAML file + * Strip frontmatter/attributes from content for publishing + * 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") { + const { body } = parseMarkdownFrontmatter(content); + return body; + } else if (file.extension === "adoc" || file.extension === "asciidoc") { + const lines = content.split("\n"); + const result: string[] = []; + let foundTitle = false; + let inAttributes = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + + // Keep title header (single =) + if (line.startsWith("=") && !line.startsWith("==") && !foundTitle) { + result.push(lines[i]); + foundTitle = true; + inAttributes = true; + continue; + } + + // Skip attribute lines (but keep empty lines after title) + if (inAttributes && line.startsWith(":")) { + continue; + } + + // If we hit a non-empty, non-attribute line after title, we're in the body + if (inAttributes && line !== "") { + inAttributes = false; + } + + // Add all body lines + if (!inAttributes || line === "") { + result.push(lines[i]); + } + } + + return result.join("\n"); + } + return content; +} + +/** + * Write metadata to file content (as frontmatter or AsciiDoc attributes) */ export async function writeMetadata( file: TFile, metadata: EventMetadata, app: any ): Promise { - const metadataPath = getMetadataFilePath(file); - const yamlContent = yaml.dump(metadata, { - indent: 2, - lineWidth: -1, - }); + try { + const currentContent = await app.vault.read(file); + + if (file.extension === "md" || file.extension === "markdown") { + 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") { + // For AsciiDoc, we need to preserve the title if it exists in the body + // and remove old attributes + const { body } = parseAsciiDocAttributes(currentContent); + const bodyLines = body.split("\n"); + + // Find title line if it exists + let titleLine: string | null = null; + let bodyStartIndex = 0; + for (let i = 0; i < bodyLines.length; i++) { + const line = bodyLines[i].trim(); + if (line.startsWith("=") && !line.startsWith("==")) { + titleLine = bodyLines[i]; + bodyStartIndex = i + 1; + break; + } + } + + // Get body content (after title, skipping empty lines and old attributes) + let actualBodyStart = bodyStartIndex; + for (let i = bodyStartIndex; i < bodyLines.length; i++) { + const line = bodyLines[i].trim(); + if (line === "") { + actualBodyStart = i + 1; + } else if (line.startsWith(":")) { + actualBodyStart = i + 1; + } else { + break; + } + } + const actualBody = bodyLines.slice(actualBodyStart).join("\n"); + + // Format new content with title + attributes + body + const lines: string[] = []; + if (titleLine) { + lines.push(titleLine); + } else if (metadata.title) { + lines.push(`= ${metadata.title}`); + } + lines.push(""); + + // Add all predefined attributes with placeholders or actual values + const kind = metadata.kind; + const definitions = TAG_DEFINITIONS[kind]; + const meta = metadata as any; + + // Add all predefined tags + for (const def of definitions) { + const value = meta[def.key]; + + // For title: if it's in the header, still include it in attributes if it's set in metadata + // This ensures the title is visible and can be edited + if (def.key === "title" && titleLine && value && !isPlaceholder(value, def.key, kind)) { + // Title is in header, but also include it in attributes for visibility + lines.push(`:${def.key}: ${value}`); + continue; + } + + // Skip title attribute if it's only in header and not set in metadata + if (def.key === "title" && titleLine && (!value || isPlaceholder(value, def.key, kind))) { + continue; + } + + if (value !== undefined && value !== null && value !== "" && !isPlaceholder(value, def.key, kind)) { + // Use actual value + if (Array.isArray(value)) { + lines.push(`:${def.key}: ${value.join(", ")}`); + } else { + lines.push(`:${def.key}: ${value}`); + } + } else { + // Use placeholder + lines.push(`:${def.key}: ${getPlaceholder(def.key, kind)}`); + } + } + + // Always include kind + lines.push(`:kind: ${kind}`); + + // Add any custom attributes that aren't in the definitions + for (const [key, value] of Object.entries(meta)) { + if (key === "kind") continue; + if (definitions.some(d => d.key === key)) continue; // Already handled + if (value !== undefined && value !== null && value !== "" && !isPlaceholder(value, key, kind)) { + if (Array.isArray(value)) { + lines.push(`:${key}: ${value.join(", ")}`); + } else { + lines.push(`:${key}: ${value}`); + } + } + } + + lines.push(""); + + const newContent = lines.join("\n") + actualBody; + await app.vault.modify(file, newContent); + } + } catch (error) { + safeConsoleError("Error writing metadata:", error); + throw error; + } +} + +/** + * Format metadata as Markdown frontmatter with all predefined tags + */ +function formatMarkdownFrontmatter(metadata: EventMetadata): string { + const lines: string[] = []; + const kind = metadata.kind; + const definitions = TAG_DEFINITIONS[kind]; + const meta = metadata as any; - // Check if metadata file already exists - const existingMetadataFile = app.vault.getAbstractFileByPath(metadataPath); - if (existingMetadataFile && existingMetadataFile instanceof TFile) { - // Update existing file - await app.vault.modify(existingMetadataFile, yamlContent); - } else { - // Create new file using vault.create() so it shows up in Obsidian - await app.vault.create(metadataPath, yamlContent); + // Always include kind first + lines.push(`kind: ${kind}`); + + // Add all predefined tags with placeholders or actual values + for (const def of definitions) { + const value = meta[def.key]; + if (value !== undefined && value !== null && value !== "" && !isPlaceholder(value, def.key, kind)) { + // Use actual value + if (Array.isArray(value)) { + lines.push(`${def.key}: [${value.map((t: string) => `"${t}"`).join(", ")}]`); + } else { + lines.push(`${def.key}: "${value}"`); + } + } else { + // Use placeholder + lines.push(`${def.key}: "${getPlaceholder(def.key, kind)}"`); + } + } + + // Add any custom tags that aren't in the definitions + for (const [key, value] of Object.entries(meta)) { + if (key === "kind") continue; + if (definitions.some(d => d.key === key)) continue; // Already handled + if (value !== undefined && value !== null && value !== "" && !isPlaceholder(value, key, kind)) { + if (Array.isArray(value)) { + lines.push(`${key}: [${value.map((t: string) => `"${t}"`).join(", ")}]`); + } else { + lines.push(`${key}: "${value}"`); + } + } } + + return lines.join("\n") + "\n"; } /** @@ -77,8 +520,13 @@ export function validateMetadata( // Validate based on kind switch (kind) { case 1: + // Title is optional for kind 1 + break; + case 11: - // No special requirements + if (!metadata.title) { + errors.push("Title is mandatory for kind 11"); + } break; case 30023: diff --git a/src/nostr/eventBuilder.ts b/src/nostr/eventBuilder.ts index 22a0612..dede40a 100644 --- a/src/nostr/eventBuilder.ts +++ b/src/nostr/eventBuilder.ts @@ -1,5 +1,5 @@ import { finalizeEvent, getEventHash, getPublicKey, nip19 } from "nostr-tools"; -import { EventKind, EventMetadata, SignedEvent } from "../types"; +import { EventKind, EventMetadata, SignedEvent, Kind30041Metadata } from "../types"; import { sanitizeString } from "../utils/security"; /** @@ -76,11 +76,26 @@ export function buildTagsFromMetadata( switch (metadata.kind) { case 1: - // No special tags required + // No special tags required (title is optional) + if (metadata.title) { + tags.push(["title", metadata.title]); + } + // Topics available for all events + if (metadata.topics) { + metadata.topics.forEach((topic) => tags.push(["t", topic])); + } break; case 11: - // No special tags required + // Thread OP + if (!metadata.title) { + throw new Error("Title is mandatory for kind 11"); + } + if (metadata.title) tags.push(["title", metadata.title]); + // Topics available for all events + if (metadata.topics) { + metadata.topics.forEach((topic) => tags.push(["t", topic])); + } break; case 30023: @@ -125,6 +140,10 @@ export function buildTagsFromMetadata( if (metadata.derivative_pubkey) eTag.push(metadata.derivative_pubkey); tags.push(eTag); } + // Topics available for all events + if (metadata.topics) { + 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]); @@ -149,12 +168,22 @@ export function buildTagsFromMetadata( } tags.push(["d", normalizeDTag(metadata.title)]); if (metadata.title) tags.push(["title", metadata.title]); - // NKBIP-08 tags - if (metadata.collection_id) tags.push(["C", metadata.collection_id]); - if (metadata.title_id) tags.push(["T", metadata.title_id]); - if (metadata.chapter_id) tags.push(["c", metadata.chapter_id]); - if (metadata.section_id) tags.push(["s", metadata.section_id]); - if (metadata.version_tag) tags.push(["v", metadata.version_tag]); + + const meta30041 = metadata as Kind30041Metadata; + // Stand-alone 30041 can have same tags as 30023 + if (meta30041.image) tags.push(["image", meta30041.image]); + if (meta30041.summary) tags.push(["summary", meta30041.summary]); + if (meta30041.published_at) tags.push(["published_at", meta30041.published_at]); + if (meta30041.topics) { + meta30041.topics.forEach((topic) => tags.push(["t", topic])); + } + + // 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]); break; case 30817: @@ -165,6 +194,11 @@ export function buildTagsFromMetadata( tags.push(["d", normalizeDTag(metadata.title)]); if (metadata.title) tags.push(["title", metadata.title]); if (metadata.summary) tags.push(["summary", metadata.summary]); + const meta30817 = metadata as any; + if (meta30817.image) tags.push(["image", meta30817.image]); + if (metadata.topics) { + metadata.topics.forEach((topic) => tags.push(["t", topic])); + } break; case 30818: @@ -175,6 +209,11 @@ export function buildTagsFromMetadata( tags.push(["d", normalizeDTag(metadata.title)]); if (metadata.title) tags.push(["title", metadata.title]); if (metadata.summary) tags.push(["summary", metadata.summary]); + const meta30818 = metadata as any; + if (meta30818.image) tags.push(["image", meta30818.image]); + if (metadata.topics) { + metadata.topics.forEach((topic) => tags.push(["t", topic])); + } break; } diff --git a/src/types.ts b/src/types.ts index 2daadc1..988bc2c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,6 +31,7 @@ export interface BaseMetadata { author?: string; published_on?: string; summary?: string; + topics?: string[]; // t tags (available for all event kinds) } /** @@ -89,7 +90,11 @@ export interface Kind30040Metadata extends BaseMetadata { export interface Kind30041Metadata extends BaseMetadata { kind: 30041; title: string; // mandatory - // NKBIP-08 tags + // Stand-alone 30041 can have same tags as 30023 + image?: string; + published_at?: string; + topics?: string[]; // t tags + // NKBIP-08 tags (only for nested 30041 under 30040) collection_id?: string; // C tag title_id?: string; // T tag chapter_id?: string; // c tag @@ -103,6 +108,8 @@ export interface Kind30041Metadata extends BaseMetadata { export interface Kind30817Metadata extends BaseMetadata { kind: 30817; title: string; // mandatory + image?: string; + topics?: string[]; // t tags } /** @@ -111,6 +118,8 @@ export interface Kind30817Metadata extends BaseMetadata { export interface Kind30818Metadata extends BaseMetadata { kind: 30818; title: string; // mandatory + image?: string; + topics?: string[]; // t tags } /** diff --git a/src/types/js-yaml.d.ts b/src/types/js-yaml.d.ts deleted file mode 100644 index 6740685..0000000 --- a/src/types/js-yaml.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -declare module "js-yaml" { - export function load(str: string, options?: any): any; - export function dump(obj: any, options?: any): string; - export function safeLoad(str: string, options?: any): any; - export function safeDump(obj: any, options?: any): string; -} diff --git a/src/ui/metadataReminderModal.ts b/src/ui/metadataReminderModal.ts new file mode 100644 index 0000000..d35bff2 --- /dev/null +++ b/src/ui/metadataReminderModal.ts @@ -0,0 +1,79 @@ +import { Modal, App } from "obsidian"; +import { EventKind } from "../types"; + +/** + * Modal to remind users to update metadata before creating events + */ +export class MetadataReminderModal extends Modal { + private onConfirm: () => void; + private kind: EventKind; + + constructor(app: App, kind: EventKind, onConfirm: () => void) { + super(app); + this.kind = kind; + this.onConfirm = onConfirm; + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + + contentEl.createEl("h2", { text: "Update Metadata Before Creating Events" }); + + const infoDiv = contentEl.createDiv({ cls: "scriptorium-reminder-info" }); + + infoDiv.createEl("p", { + text: "Please update the metadata in your file before creating events:", + }); + + const list = infoDiv.createEl("ul"); + + // Get required fields for this kind (title is optional for kind 1, mandatory for all others) + const requiresTitle = this.kind !== 1; + + if (requiresTitle) { + const titleItem = list.createEl("li"); + titleItem.createEl("strong", { text: "Title is mandatory" }); + titleItem.createEl("span", { text: " - Update the title field in the frontmatter/attributes" }); + } + + list.createEl("li", { + text: "Review all metadata fields in the frontmatter (Markdown) or header attributes (AsciiDoc)", + }); + + list.createEl("li", { + text: "Remove or update any placeholder descriptions you don't want to use", + }); + + list.createEl("li", { + text: "Add any custom tags you need", + }); + + const noteDiv = contentEl.createDiv({ cls: "scriptorium-reminder-note" }); + noteDiv.createEl("p", { + text: "Note: Placeholder values (descriptions) will be automatically skipped when creating events.", + }); + + // Buttons + const buttonContainer = contentEl.createDiv({ cls: "scriptorium-modal-buttons" }); + + const okButton = buttonContainer.createEl("button", { + text: "OK, I've Updated the Metadata", + cls: "mod-cta", + }); + okButton.addEventListener("click", () => { + this.onConfirm(); + this.close(); + }); + + const cancelButton = buttonContainer.createEl("button", { text: "Cancel" }); + cancelButton.addEventListener("click", () => { + this.close(); + }); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +}