Browse Source

get rid of yml structure. add frontmatter metadata

master
Silberengel 1 week ago
parent
commit
f32c852e6a
  1. 30
      README.md
  2. 27
      package-lock.json
  3. 2
      package.json
  4. 52
      src/eventManager.ts
  5. 91
      src/main.ts
  6. 508
      src/metadataManager.ts
  7. 57
      src/nostr/eventBuilder.ts
  8. 11
      src/types.ts
  9. 6
      src/types/js-yaml.d.ts
  10. 79
      src/ui/metadataReminderModal.ts

30
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 - Multiple event kinds: Markdown (1, 11, 30023, 30817) and AsciiDoc (30040, 30041, 30818) formats
- Automatic book/publication parsing into nested 30040/30041 structures - 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 - Structure preview before creating events
- Two-step workflow: create/sign events separately from publishing - Two-step workflow: create/sign events separately from publishing
- Automatic relay list fetching (kind 10002) with AUTH support - 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 - **AsciiDoc** (`.adoc`): Kinds 30040, 30041, 30818
- **Structured AsciiDoc** (starts with `= Title`): Kind 30040 with nested 30041 events - **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 ## Development

27
package-lock.json generated

@ -9,11 +9,9 @@
"version": "0.1.0", "version": "0.1.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"js-yaml": "^4.1.0",
"nostr-tools": "^2.20.0" "nostr-tools": "^2.20.0"
}, },
"devDependencies": { "devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"@typescript-eslint/eslint-plugin": "5.29.0", "@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0", "@typescript-eslint/parser": "5.29.0",
@ -549,13 +547,6 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@types/json-schema": {
"version": "7.0.15", "version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@ -775,12 +766,6 @@
"url": "https://opencollective.com/typescript-eslint" "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": { "node_modules/array-union": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
@ -1099,18 +1084,6 @@
"node": ">=0.12.0" "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": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",

2
package.json

@ -13,7 +13,6 @@
"homepage": "https://gitcitadel.com", "homepage": "https://gitcitadel.com",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/node": "^20.0.0", "@types/node": "^20.0.0",
"@typescript-eslint/eslint-plugin": "5.29.0", "@typescript-eslint/eslint-plugin": "5.29.0",
"@typescript-eslint/parser": "5.29.0", "@typescript-eslint/parser": "5.29.0",
@ -24,7 +23,6 @@
"typescript": "5.3.3" "typescript": "5.3.3"
}, },
"dependencies": { "dependencies": {
"js-yaml": "^4.1.0",
"nostr-tools": "^2.20.0" "nostr-tools": "^2.20.0"
} }
} }

52
src/eventManager.ts

@ -15,7 +15,7 @@ import {
getPubkeyFromPrivkey, getPubkeyFromPrivkey,
} from "./nostr/eventBuilder"; } from "./nostr/eventBuilder";
import { parseAsciiDocStructure } from "./asciidocParser"; import { parseAsciiDocStructure } from "./asciidocParser";
import { readMetadata, mergeWithHeaderTitle } from "./metadataManager"; import { readMetadata, mergeWithHeaderTitle, stripMetadataFromContent } from "./metadataManager";
/** /**
* Build events from a simple document (non-AsciiDoc) * Build events from a simple document (non-AsciiDoc)
@ -27,8 +27,10 @@ export async function buildSimpleEvent(
privkey: string, privkey: string,
app: any app: any
): Promise<SignedEvent[]> { ): Promise<SignedEvent[]> {
// Strip metadata from content before publishing
const cleanContent = stripMetadataFromContent(file, content);
const tags = buildTagsFromMetadata(metadata, getPubkeyFromPrivkey(privkey)); const tags = buildTagsFromMetadata(metadata, getPubkeyFromPrivkey(privkey));
const event = createSignedEvent(metadata.kind, content, tags, privkey); const event = createSignedEvent(metadata.kind, cleanContent, tags, privkey);
return [event]; return [event];
} }
@ -50,8 +52,12 @@ export async function buildAsciiDocEvents(
const events: SignedEvent[] = []; const events: SignedEvent[] = [];
const pubkey = getPubkeyFromPrivkey(privkey); 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 // Parse structure
const header = parseAsciiDocStructure(content, metadata as Kind30040Metadata); const header = parseAsciiDocStructure(cleanContent, metadata as Kind30040Metadata);
if (header.length === 0) { if (header.length === 0) {
errors.push("Failed to parse AsciiDoc structure"); errors.push("Failed to parse AsciiDoc structure");
return { events: [], structure: [], errors }; return { events: [], structure: [], errors };
@ -63,10 +69,11 @@ export async function buildAsciiDocEvents(
// Recursively build events from structure // Recursively build events from structure
async function buildEventsFromNode(node: StructureNode, parentMetadata?: Kind30040Metadata): Promise<void> { async function buildEventsFromNode(node: StructureNode, parentMetadata?: Kind30040Metadata): Promise<void> {
if (node.kind === 30041) { if (node.kind === 30041) {
// Content event // Content event - nested under 30040, so use NKBIP-08 tags
const contentMetadata: Kind30041Metadata = { const contentMetadata: Kind30041Metadata = {
kind: 30041, kind: 30041,
title: node.title, title: node.title,
// Inherit NKBIP-08 tags from parent 30040
collection_id: parentMetadata?.collection_id, collection_id: parentMetadata?.collection_id,
title_id: parentMetadata ? normalizeDTag(parentMetadata.title) : undefined, title_id: parentMetadata ? normalizeDTag(parentMetadata.title) : undefined,
chapter_id: node.dTag, chapter_id: node.dTag,
@ -82,9 +89,31 @@ export async function buildAsciiDocEvents(
// Index event - need to build children first // Index event - need to build children first
const childEvents: Array<{ kind: number; dTag: string; eventId?: string }> = []; 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) { 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 // Find the event we just created for this child
const childEvent = events.find((e) => { const childEvent = events.find((e) => {
@ -102,17 +131,10 @@ export async function buildAsciiDocEvents(
} }
// Now build this index event with references to children // Now build this index event with references to children
const baseMetadata = node.metadata as Kind30040Metadata; const tags = buildTagsFromMetadata(mergedMetadata, pubkey, childEvents);
const indexMetadata: Kind30040Metadata = {
...baseMetadata,
kind: 30040,
title: node.title,
};
const tags = buildTagsFromMetadata(indexMetadata, pubkey, childEvents);
const event = createSignedEvent(30040, "", tags, privkey); const event = createSignedEvent(30040, "", tags, privkey);
events.push(event); events.push(event);
node.metadata = indexMetadata; node.metadata = mergedMetadata;
} }
} }

91
src/main.ts

@ -4,6 +4,7 @@ import { ScriptoriumSettingTab } from "./ui/settingsTab";
import { MetadataModal } from "./ui/metadataModal"; import { MetadataModal } from "./ui/metadataModal";
import { StructurePreviewModal } from "./ui/structurePreviewModal"; import { StructurePreviewModal } from "./ui/structurePreviewModal";
import { NewDocumentModal } from "./ui/newDocumentModal"; import { NewDocumentModal } from "./ui/newDocumentModal";
import { MetadataReminderModal } from "./ui/metadataReminderModal";
import { readMetadata, writeMetadata, createDefaultMetadata, validateMetadata, mergeWithHeaderTitle } from "./metadataManager"; import { readMetadata, writeMetadata, createDefaultMetadata, validateMetadata, mergeWithHeaderTitle } from "./metadataManager";
import { buildEvents } from "./eventManager"; import { buildEvents } from "./eventManager";
import { saveEvents, loadEvents, eventsFileExists } from "./eventStorage"; import { saveEvents, loadEvents, eventsFileExists } from "./eventStorage";
@ -25,9 +26,8 @@ export default class ScriptoriumPlugin extends Plugin {
await this.loadSettings(); await this.loadSettings();
await this.loadPrivateKey(); 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 // 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"); console.error("[Scriptorium] Plugin loaded - file extensions not registered");
process.stderr.write("[Scriptorium] Plugin loaded - file extensions not registered\n"); process.stderr.write("[Scriptorium] Plugin loaded - file extensions not registered\n");
console.error("[Scriptorium] Install obsidian-asciidoc plugin for .adoc file editing support"); 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 // Ensure folder structure exists before creating events
await this.ensureNostrNotesFolder(eventKind); await this.ensureNostrNotesFolder(eventKind);
// Create default metadata if none exists // Create default metadata if none exists and write it with placeholders
if (!metadata) { if (!metadata) {
metadata = createDefaultMetadata(eventKind); 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 // Merge with header title for 30040
@ -212,40 +215,62 @@ export default class ScriptoriumPlugin extends Plugin {
metadata = mergeWithHeaderTitle(metadata, headerTitle); metadata = mergeWithHeaderTitle(metadata, headerTitle);
} }
// Validate metadata // Show reminder modal before proceeding
const validation = validateMetadata(metadata, eventKind); new MetadataReminderModal(this.app, eventKind, async () => {
if (!validation.valid) { // Re-read metadata after user confirms (they may have updated it)
new Notice(`Metadata validation failed: ${validation.errors.join(", ")}`); const updatedContent = await this.app.vault.read(file);
return; let updatedMetadata: EventMetadata = await readMetadata(file, this.app) || metadata || createDefaultMetadata(eventKind);
}
// Ensure we have valid metadata
if (!updatedMetadata) {
updatedMetadata = createDefaultMetadata(eventKind);
}
// Build events // Merge with header title for 30040
const result = await buildEvents(file, content, metadata, this.settings.privateKey, this.app); if (eventKind === 30040 && isAsciiDocDocument(updatedContent)) {
const headerTitle = updatedContent.split("\n")[0]?.replace(/^=+\s*/, "").trim() || "";
updatedMetadata = mergeWithHeaderTitle(updatedMetadata, headerTitle);
}
if (result.errors.length > 0) { // Validate metadata
new Notice(`Errors: ${result.errors.join(", ")}`); const validation = validateMetadata(updatedMetadata, eventKind);
return; if (!validation.valid) {
} new Notice(`Metadata validation failed: ${validation.errors.join(", ")}`);
return;
}
// Security check: verify events don't contain private keys // Build events
for (const event of result.events) { if (!this.settings.privateKey) {
if (!verifyEventSecurity(event)) { new Notice("Please set your private key in settings");
new Notice("Security error: Event contains private key. Aborting.");
safeConsoleError("Event security check failed - event may contain private key");
return; 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 // Show preview for structured documents
if (result.structure.length > 0) { if (result.structure.length > 0) {
new StructurePreviewModal(this.app, result.structure, async () => { 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); await saveEvents(file, result.events, this.app);
new Notice(`Created ${result.events.length} event(s) and saved to ${file.basename}_events.jsonl`); new Notice(`Created ${result.events.length} event(s) and saved to ${file.basename}_events.jsonl`);
}).open(); }
} else { }).open();
await saveEvents(file, result.events, this.app);
new Notice(`Created ${result.events.length} event(s) and saved to ${file.basename}_events.jsonl`);
}
} catch (error: any) { } 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"; 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}`); new Notice(`Error creating events: ${safeMessage}`);
@ -453,13 +478,15 @@ export default class ScriptoriumPlugin extends Plugin {
return; return;
} }
// Create metadata // Create metadata with title preset from the filename
const metadata = createDefaultMetadata(kind); const metadata = createDefaultMetadata(kind);
if (metadata.title === "" && title) { // Always set title if provided (even for kind 1 where it's optional)
(metadata as any).title = title; if (title && title.trim()) {
(metadata as any).title = title.trim();
} }
try { try {
// Write metadata with all placeholders (title will be included if set)
await writeMetadata(file, metadata, this.app); await writeMetadata(file, metadata, this.app);
} catch (error: any) { } 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"; const safeMessage = error?.message ? String(error.message).replace(/nsec1[a-z0-9]{58,}/gi, "[REDACTED]").replace(/[0-9a-f]{64}/gi, "[REDACTED]") : "Unknown error";

508
src/metadataManager.ts

@ -1,34 +1,293 @@
import * as yaml from "js-yaml";
import { TFile } from "obsidian"; import { TFile } from "obsidian";
import { EventKind, EventMetadata } from "./types"; import { EventKind, EventMetadata } from "./types";
import { safeConsoleError } from "./utils/security"; 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 { interface TagDefinition {
const path = file.path; key: string;
const ext = file.extension; description: string;
const basePath = path.slice(0, -(ext.length + 1)); // Remove extension and dot required?: boolean;
return `${basePath}_metadata.yml`; }
const TAG_DEFINITIONS: Record<EventKind, TagDefinition[]> = {
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<string, any>; 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<string, any> = {};
// 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<string, any>; body: string } {
const metadata: Record<string, any> = {};
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<string, any>, kind: EventKind): Record<string, any> {
const filtered: Record<string, any> = {};
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( export async function readMetadata(
file: TFile, file: TFile,
app: any app: any
): Promise<EventMetadata | null> { ): Promise<EventMetadata | null> {
const metadataPath = getMetadataFilePath(file);
try { try {
const metadataFile = app.vault.getAbstractFileByPath(metadataPath); const content = await app.vault.read(file);
if (!metadataFile || !(metadataFile instanceof TFile)) {
return null; 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 null;
return parsed as EventMetadata;
} catch (error) { } catch (error) {
safeConsoleError("Error reading metadata:", error); safeConsoleError("Error reading metadata:", error);
return null; 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( export async function writeMetadata(
file: TFile, file: TFile,
metadata: EventMetadata, metadata: EventMetadata,
app: any app: any
): Promise<void> { ): Promise<void> {
const metadataPath = getMetadataFilePath(file); try {
const yamlContent = yaml.dump(metadata, { const currentContent = await app.vault.read(file);
indent: 2,
lineWidth: -1, 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 // Always include kind first
const existingMetadataFile = app.vault.getAbstractFileByPath(metadataPath); lines.push(`kind: ${kind}`);
if (existingMetadataFile && existingMetadataFile instanceof TFile) {
// Update existing file // Add all predefined tags with placeholders or actual values
await app.vault.modify(existingMetadataFile, yamlContent); for (const def of definitions) {
} else { const value = meta[def.key];
// Create new file using vault.create() so it shows up in Obsidian if (value !== undefined && value !== null && value !== "" && !isPlaceholder(value, def.key, kind)) {
await app.vault.create(metadataPath, yamlContent); // 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 // Validate based on kind
switch (kind) { switch (kind) {
case 1: case 1:
// Title is optional for kind 1
break;
case 11: case 11:
// No special requirements if (!metadata.title) {
errors.push("Title is mandatory for kind 11");
}
break; break;
case 30023: case 30023:

57
src/nostr/eventBuilder.ts

@ -1,5 +1,5 @@
import { finalizeEvent, getEventHash, getPublicKey, nip19 } from "nostr-tools"; 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"; import { sanitizeString } from "../utils/security";
/** /**
@ -76,11 +76,26 @@ export function buildTagsFromMetadata(
switch (metadata.kind) { switch (metadata.kind) {
case 1: 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; break;
case 11: 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; break;
case 30023: case 30023:
@ -125,6 +140,10 @@ export function buildTagsFromMetadata(
if (metadata.derivative_pubkey) eTag.push(metadata.derivative_pubkey); if (metadata.derivative_pubkey) eTag.push(metadata.derivative_pubkey);
tags.push(eTag); tags.push(eTag);
} }
// Topics available for all events
if (metadata.topics) {
metadata.topics.forEach((topic) => tags.push(["t", topic]));
}
// NKBIP-08 tags // NKBIP-08 tags
if (metadata.collection_id) tags.push(["C", metadata.collection_id]); if (metadata.collection_id) tags.push(["C", metadata.collection_id]);
if (metadata.version_tag) tags.push(["v", metadata.version_tag]); if (metadata.version_tag) tags.push(["v", metadata.version_tag]);
@ -149,12 +168,22 @@ export function buildTagsFromMetadata(
} }
tags.push(["d", normalizeDTag(metadata.title)]); tags.push(["d", normalizeDTag(metadata.title)]);
if (metadata.title) tags.push(["title", metadata.title]); if (metadata.title) tags.push(["title", metadata.title]);
// NKBIP-08 tags
if (metadata.collection_id) tags.push(["C", metadata.collection_id]); const meta30041 = metadata as Kind30041Metadata;
if (metadata.title_id) tags.push(["T", metadata.title_id]); // Stand-alone 30041 can have same tags as 30023
if (metadata.chapter_id) tags.push(["c", metadata.chapter_id]); if (meta30041.image) tags.push(["image", meta30041.image]);
if (metadata.section_id) tags.push(["s", metadata.section_id]); if (meta30041.summary) tags.push(["summary", meta30041.summary]);
if (metadata.version_tag) tags.push(["v", metadata.version_tag]); 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; break;
case 30817: case 30817:
@ -165,6 +194,11 @@ export function buildTagsFromMetadata(
tags.push(["d", normalizeDTag(metadata.title)]); tags.push(["d", normalizeDTag(metadata.title)]);
if (metadata.title) tags.push(["title", metadata.title]); if (metadata.title) tags.push(["title", metadata.title]);
if (metadata.summary) tags.push(["summary", metadata.summary]); 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; break;
case 30818: case 30818:
@ -175,6 +209,11 @@ export function buildTagsFromMetadata(
tags.push(["d", normalizeDTag(metadata.title)]); tags.push(["d", normalizeDTag(metadata.title)]);
if (metadata.title) tags.push(["title", metadata.title]); if (metadata.title) tags.push(["title", metadata.title]);
if (metadata.summary) tags.push(["summary", metadata.summary]); 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; break;
} }

11
src/types.ts

@ -31,6 +31,7 @@ export interface BaseMetadata {
author?: string; author?: string;
published_on?: string; published_on?: string;
summary?: 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 { export interface Kind30041Metadata extends BaseMetadata {
kind: 30041; kind: 30041;
title: string; // mandatory 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 collection_id?: string; // C tag
title_id?: string; // T tag title_id?: string; // T tag
chapter_id?: string; // c tag chapter_id?: string; // c tag
@ -103,6 +108,8 @@ export interface Kind30041Metadata extends BaseMetadata {
export interface Kind30817Metadata extends BaseMetadata { export interface Kind30817Metadata extends BaseMetadata {
kind: 30817; kind: 30817;
title: string; // mandatory title: string; // mandatory
image?: string;
topics?: string[]; // t tags
} }
/** /**
@ -111,6 +118,8 @@ export interface Kind30817Metadata extends BaseMetadata {
export interface Kind30818Metadata extends BaseMetadata { export interface Kind30818Metadata extends BaseMetadata {
kind: 30818; kind: 30818;
title: string; // mandatory title: string; // mandatory
image?: string;
topics?: string[]; // t tags
} }
/** /**

6
src/types/js-yaml.d.ts vendored

@ -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;
}

79
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();
}
}
Loading…
Cancel
Save