commit 68e76968bf90f223ca127bc53dd85c5d30d8b5e0 Author: Silberengel Date: Mon Jan 26 15:30:43 2026 +0100 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..714fed7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +.DS_Store +*.log +main.js +main.js.map +versions.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..3cf001c --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# Scriptorium Obsidian Plugin + +An Obsidian plugin for creating, editing, and publishing Nostr document events directly from your vault. + +## Features + +- **Multiple Event Kinds**: Support for kinds 1, 11, 30023, 30040, 30041, 30817, 30818 +- **AsciiDoc Support**: Automatic parsing and splitting of AsciiDoc documents into nested 30040/30041 structures +- **Metadata Management**: YAML metadata files with validation per event kind +- **Structure Preview**: Visual preview of document structure before creating events +- **Two-Step Workflow**: Create and sign events separately from publishing +- **Relay Management**: Automatic fetching of relay lists (kind 10002) with AUTH support +- **d-tag Normalization**: Automatic NIP-54 compliant d-tag generation from titles + +## Installation + +1. Clone this repository +2. Run `npm install` +3. Run `npm run build` +4. Copy the `main.js`, `manifest.json`, and `styles.css` (if any) to your Obsidian vault's `.obsidian/plugins/scriptorium-obsidian/` directory + +## Setup + +1. Set your Nostr private key in the environment variable `SCRIPTORIUM_OBSIDIAN_KEY`: + - Format: `nsec1...` (bech32) or 64-character hex string +2. Open Obsidian settings → Scriptorium Nostr +3. Click "Refresh from Env" to load your private key +4. Click "Fetch" to get your relay list from Nostr relays + +## Usage + +### Creating Events + +1. Open a Markdown or AsciiDoc file +2. Run command: `Create Nostr Events` +3. If metadata doesn't exist, it will be created with defaults +4. For AsciiDoc documents with structure (`= Title`), a preview will be shown +5. Events are created, signed, and saved to `{filename}_events.jsonl` + +### Editing Metadata + +1. Open a file +2. Run command: `Edit Metadata` +3. Fill in the metadata form +4. Save + +### Publishing Events + +1. Ensure events have been created (check for `{filename}_events.jsonl`) +2. Run command: `Publish Events to Relays` +3. Events will be published to all configured write relays + +### Previewing Structure + +1. Open an AsciiDoc file with structure +2. Run command: `Preview Document Structure` +3. Review the event hierarchy before creating + +## File Formats + +- **Markdown** (`.md`): Kinds 1, 11, 30023, 30817 +- **AsciiDoc** (`.adoc`, `.asciidoc`): Kinds 30041, 30818 +- **AsciiDoc with Structure** (starts with `= Title`): Kind 30040 with nested 30041 events + +## Metadata Files + +Metadata is stored as `{filename}_metadata.yml` in the same directory as the document. + +For 30040 events, the title is derived from the document header (`= Title`) but can be overridden in the metadata file. + +## Commands + +- `Create Nostr Events` - Create and sign events from current file +- `Preview Document Structure` - Show event hierarchy preview +- `Publish Events to Relays` - Publish from .jsonl file to relays +- `Edit Metadata` - Open metadata form for current file + +## Development + +```bash +npm install +npm run dev # Watch mode +npm run build # Production build +``` + +## License + +MIT diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..ced901f --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,51 @@ +import esbuild from "esbuild"; +import process from "process"; +import builtins from "builtin-modules"; + +const isProduction = process.argv[2] === "production"; + +const banner = + "/* +THIS IS A GENERATED/BUNDLED FILE BY ESBUILD +if you want to view the source, please visit the github repository of this plugin +*/ +"; + +const prodConfig = { + banner: { + js: banner, + }, + entryPoints: ["src/main.ts"], + bundle: true, + external: [ + "obsidian", + "electron", + "@codemirror/autocomplete", + "@codemirror/collab", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + ...builtins, + ], + format: "cjs", + target: "es2018", + logLevel: "info", + sourcemap: isProduction ? false : "inline", + treeShaking: true, + outfile: "main.js", +}; + +const devConfig = { + ...prodConfig, + sourcemap: "inline", +}; + +const config = isProduction ? prodConfig : devConfig; + +esbuild.build(config).catch(() => process.exit(1)); diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..4989182 --- /dev/null +++ b/manifest.json @@ -0,0 +1,11 @@ +{ + "id": "scriptorium-obsidian", + "name": "Scriptorium Nostr", + "version": "0.1.0", + "minAppVersion": "0.15.0", + "description": "Create, edit, and publish Nostr document events from Obsidian", + "author": "Scriptorium", + "authorUrl": "", + "fundingUrl": "", + "isDesktopOnly": false +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..b2bb3b7 --- /dev/null +++ b/package.json @@ -0,0 +1,28 @@ +{ + "name": "scriptorium-obsidian", + "version": "0.1.0", + "description": "Obsidian plugin for creating and publishing Nostr document events", + "main": "main.js", + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", + "version": "node version-bump.mjs && git add manifest.json versions.json" + }, + "keywords": [], + "author": "", + "license": "MIT", + "devDependencies": { + "@types/node": "^16.11.6", + "@typescript-eslint/eslint-plugin": "5.29.0", + "@typescript-eslint/parser": "5.29.0", + "builtin-modules": "3.3.0", + "esbuild": "0.17.3", + "obsidian": "latest", + "tslib": "2.4.0", + "typescript": "4.7.4" + }, + "dependencies": { + "nostr-tools": "^2.4.0", + "js-yaml": "^4.1.0" + } +} diff --git a/src/asciidocParser.ts b/src/asciidocParser.ts new file mode 100644 index 0000000..a73ead0 --- /dev/null +++ b/src/asciidocParser.ts @@ -0,0 +1,197 @@ +import { EventKind, Kind30040Metadata, Kind30041Metadata, StructureNode } from "./types"; + +/** + * Parse AsciiDoc document header (single =) + */ +export function parseDocumentHeader(content: string): { title: string; remaining: string } | null { + const lines = content.split("\n"); + const firstLine = lines[0]?.trim(); + + if (firstLine && firstLine.startsWith("=") && !firstLine.startsWith("==")) { + const title = firstLine.slice(1).trim(); + const remaining = lines.slice(1).join("\n"); + return { title, remaining }; + } + + return null; +} + +/** + * Check if document starts with AsciiDoc header + */ +export function isAsciiDocDocument(content: string): boolean { + const firstLine = content.split("\n")[0]?.trim(); + return firstLine ? firstLine.startsWith("=") && !firstLine.startsWith("==") : false; +} + +/** + * Parse AsciiDoc line to extract header level and title + */ +function parseHeaderLine(line: string): { level: number; title: string } | null { + const trimmed = line.trim(); + if (!trimmed.startsWith("=")) { + return null; + } + + let level = 0; + let i = 0; + while (i < trimmed.length && trimmed[i] === "=" && level < 6) { + level++; + i++; + } + + if (level === 0 || level > 6) { + return null; + } + + const title = trimmed.slice(i).trim(); + return { level, title }; +} + +/** + * Parse AsciiDoc document into structure nodes + */ +export function parseAsciiDocStructure( + content: string, + rootMetadata?: Kind30040Metadata +): StructureNode[] { + const header = parseDocumentHeader(content); + if (!header) { + return []; + } + + const rootTitle = rootMetadata?.title || header.title; + const rootNode: StructureNode = { + level: 0, + title: rootTitle, + dTag: rootTitle.toLowerCase().replace(/\s+/g, "-").replace(/[^\w-]/g, ""), + kind: 30040, + children: [], + metadata: rootMetadata, + }; + + const lines = header.remaining.split("\n"); + const nodes: StructureNode[] = [rootNode]; + const stack: StructureNode[] = [rootNode]; + let currentContent: string[] = []; + let currentLevel = 0; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const headerInfo = parseHeaderLine(line); + + if (headerInfo) { + // Save content to current node if any + if (currentContent.length > 0 && stack.length > 0) { + const currentNode = stack[stack.length - 1]; + if (currentNode.kind === 30041) { + currentNode.content = currentContent.join("\n").trim(); + } + currentContent = []; + } + + const { level, title } = headerInfo; + + // Determine if this should be 30040 or 30041 + // The lowest level on each branch becomes 30041 + const shouldBe30041 = level === 6; // Maximum level is always 30041 + + // Pop stack until we find the parent + while (stack.length > 1 && stack[stack.length - 1].level >= level) { + stack.pop(); + } + + const parent = stack[stack.length - 1]; + const dTag = title.toLowerCase().replace(/\s+/g, "-").replace(/[^\w-]/g, ""); + + const newNode: StructureNode = { + level, + title, + dTag, + kind: shouldBe30041 ? 30041 : 30040, + children: [], + content: "", + }; + + parent.children.push(newNode); + nodes.push(newNode); + stack.push(newNode); + currentLevel = level; + } else { + // Content line + currentContent.push(line); + } + } + + // Save remaining content to the last node + if (currentContent.length > 0 && stack.length > 0) { + const currentNode = stack[stack.length - 1]; + if (currentNode.kind === 30041) { + currentNode.content = currentContent.join("\n").trim(); + } + } + + // Post-process: mark lowest level nodes as 30041 + markLowestLevelAs30041(rootNode); + + return [rootNode]; +} + +/** + * Recursively mark the lowest level nodes in each branch as 30041 + */ +function markLowestLevelAs30041(node: StructureNode): void { + if (node.children.length === 0) { + // Leaf node - should be 30041 if it has content + if (node.content && node.content.trim().length > 0) { + node.kind = 30041; + } + return; + } + + // Process children first + node.children.forEach((child) => markLowestLevelAs30041(child)); + + // Check if all children are 30041 - if so, this node should be 30040 + // Otherwise, find the deepest 30040 node + const has30040Children = node.children.some((child) => child.kind === 30040); + if (!has30040Children) { + // All children are 30041, so this is an index (30040) + node.kind = 30040; + } +} + +/** + * Extract content for a specific section + */ +export function extractSectionContent( + content: string, + startLine: number, + endLine?: number +): string { + const lines = content.split("\n"); + const start = startLine; + const end = endLine !== undefined ? endLine : lines.length; + return lines.slice(start, end).join("\n").trim(); +} + +/** + * Get all section boundaries (line numbers where headers start) + */ +export function getSectionBoundaries(content: string): Array<{ level: number; line: number; title: string }> { + const lines = content.split("\n"); + const boundaries: Array<{ level: number; line: number; title: string }> = []; + + for (let i = 0; i < lines.length; i++) { + const headerInfo = parseHeaderLine(lines[i]); + if (headerInfo) { + boundaries.push({ + level: headerInfo.level, + line: i, + title: headerInfo.title, + }); + } + } + + return boundaries; +} diff --git a/src/eventManager.ts b/src/eventManager.ts new file mode 100644 index 0000000..aa3deaa --- /dev/null +++ b/src/eventManager.ts @@ -0,0 +1,155 @@ +import { TFile } from "obsidian"; +import { + EventKind, + EventMetadata, + SignedEvent, + StructureNode, + EventCreationResult, + Kind30040Metadata, + Kind30041Metadata, +} from "./types"; +import { + createSignedEvent, + buildTagsFromMetadata, + normalizeDTag, + getPubkeyFromPrivkey, +} from "./nostr/eventBuilder"; +import { parseAsciiDocStructure } from "./asciidocParser"; +import { readMetadata, mergeWithHeaderTitle } from "./metadataManager"; + +/** + * Build events from a simple document (non-AsciiDoc) + */ +export async function buildSimpleEvent( + file: TFile, + content: string, + metadata: EventMetadata, + privkey: string, + app: any +): Promise { + const tags = buildTagsFromMetadata(metadata, getPubkeyFromPrivkey(privkey)); + const event = createSignedEvent(metadata.kind, content, tags, privkey); + return [event]; +} + +/** + * Build events from AsciiDoc structure (30040/30041) + */ +export async function buildAsciiDocEvents( + file: TFile, + content: string, + metadata: EventMetadata, + privkey: string, + app: any +): Promise { + if (metadata.kind !== 30040 && metadata.kind !== 30041 && metadata.kind !== 30818) { + throw new Error("AsciiDoc events must be kind 30040, 30041, or 30818"); + } + + const errors: string[] = []; + const events: SignedEvent[] = []; + const pubkey = getPubkeyFromPrivkey(privkey); + + // Parse structure + const header = parseAsciiDocStructure(content, metadata as Kind30040Metadata); + if (header.length === 0) { + errors.push("Failed to parse AsciiDoc structure"); + return { events: [], structure: [], errors }; + } + + const rootNode = header[0]; + const structure: StructureNode[] = [rootNode]; + + // Recursively build events from structure + async function buildEventsFromNode(node: StructureNode, parentMetadata?: Kind30040Metadata): Promise { + if (node.kind === 30041) { + // Content event + const contentMetadata: Kind30041Metadata = { + kind: 30041, + title: node.title, + 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, + }; + + const tags = buildTagsFromMetadata(contentMetadata, pubkey); + const event = createSignedEvent(30041, node.content || "", tags, privkey); + events.push(event); + node.metadata = contentMetadata; + } else if (node.kind === 30040) { + // Index event - need to build children first + const childEvents: Array<{ kind: number; dTag: string; eventId?: string }> = []; + + // Build all children first + for (const child of node.children) { + await buildEventsFromNode(child, node.metadata as Kind30040Metadata); + + // Find the event we just created for this child + const childEvent = events.find((e) => { + const dTag = e.tags.find((t) => t[0] === "d")?.[1]; + return dTag === child.dTag; + }); + + if (childEvent) { + childEvents.push({ + kind: child.kind, + dTag: child.dTag, + eventId: childEvent.id, + }); + } + } + + // Now build this index event with references to children + const indexMetadata: Kind30040Metadata = { + kind: 30040, + title: node.title, + ...(node.metadata as Kind30040Metadata), + }; + + const tags = buildTagsFromMetadata(indexMetadata, pubkey, childEvents); + const event = createSignedEvent(30040, "", tags, privkey); + events.push(event); + node.metadata = indexMetadata; + } + } + + // Build events starting from root + await buildEventsFromNode(rootNode, metadata as Kind30040Metadata); + + // Sort events: indexes first, then content (for proper dependency order) + events.sort((a, b) => { + if (a.kind === 30040 && b.kind === 30041) return -1; + if (a.kind === 30041 && b.kind === 30040) return 1; + return 0; + }); + + return { events, structure, errors }; +} + +/** + * Build events from document + */ +export async function buildEvents( + file: TFile, + content: string, + metadata: EventMetadata, + privkey: string, + 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("=="); + + if (hasStructure && (metadata.kind === 30040 || metadata.kind === 30041)) { + // Parse header title and merge with metadata + const headerTitle = content.split("\n")[0]?.replace(/^=+\s*/, "").trim() || ""; + const mergedMetadata = mergeWithHeaderTitle(metadata, headerTitle); + return buildAsciiDocEvents(file, content, mergedMetadata, privkey, app); + } else { + // Simple event + const events = await buildSimpleEvent(file, content, metadata, privkey, app); + return { events, structure: [], errors: [] }; + } +} diff --git a/src/eventStorage.ts b/src/eventStorage.ts new file mode 100644 index 0000000..6ece667 --- /dev/null +++ b/src/eventStorage.ts @@ -0,0 +1,72 @@ +import { TFile } from "obsidian"; +import { SignedEvent } from "./types"; + +/** + * Get events file path for a given file + */ +export function getEventsFilePath(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}_events.jsonl`; +} + +/** + * Save events to .jsonl file + */ +export async function saveEvents( + file: TFile, + events: SignedEvent[], + app: any +): Promise { + const eventsPath = getEventsFilePath(file); + const lines = events.map((event) => JSON.stringify(event)); + const content = lines.join("\n") + "\n"; + await app.vault.adapter.write(eventsPath, content); +} + +/** + * Load events from .jsonl file + */ +export async function loadEvents( + file: TFile, + app: any +): Promise { + const eventsPath = getEventsFilePath(file); + try { + const eventsFile = app.vault.getAbstractFileByPath(eventsPath); + if (!eventsFile || !(eventsFile instanceof TFile)) { + return []; + } + const content = await app.vault.read(eventsFile); + const lines = content.split("\n").filter((line) => line.trim().length > 0); + return lines.map((line) => JSON.parse(line) as SignedEvent); + } catch (error) { + console.error("Error loading events:", error); + return []; + } +} + +/** + * Check if events file exists + */ +export async function eventsFileExists(file: TFile, app: any): Promise { + const eventsPath = getEventsFilePath(file); + const eventsFile = app.vault.getAbstractFileByPath(eventsPath); + return eventsFile instanceof TFile; +} + +/** + * Delete events file + */ +export async function deleteEvents(file: TFile, app: any): Promise { + const eventsPath = getEventsFilePath(file); + try { + const eventsFile = app.vault.getAbstractFileByPath(eventsPath); + if (eventsFile && eventsFile instanceof TFile) { + await app.vault.delete(eventsFile); + } + } catch (error) { + console.error("Error deleting events file:", error); + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..b3b9dd6 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,268 @@ +import { Plugin, TFile, Notice } from "obsidian"; +import { ScriptoriumSettings, EventKind, EventMetadata, DEFAULT_SETTINGS } from "./types"; +import { ScriptoriumSettingTab } from "./ui/settingsTab"; +import { MetadataModal } from "./ui/metadataModal"; +import { StructurePreviewModal } from "./ui/structurePreviewModal"; +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"; + +export default class ScriptoriumPlugin extends Plugin { + settings: ScriptoriumSettings; + + async onload() { + await this.loadSettings(); + await this.loadPrivateKey(); + + // 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(), + }); + + // Status bar + this.addStatusBarItem().setText("Scriptorium"); + } + + onunload() {} + + async loadSettings() { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + } + + async saveSettings() { + await this.saveData(this.settings); + } + + async loadPrivateKey() { + // Try to load from environment variable + // Note: In Obsidian, process.env may not be available + // Users should set the key manually in settings or via system environment + try { + // @ts-ignore - process.env may not be typed in Obsidian context + const envKey = typeof process !== "undefined" && process.env?.SCRIPTORIUM_OBSIDIAN_KEY; + if (envKey) { + this.settings.privateKey = envKey; + await this.saveSettings(); + } + } catch (error) { + // Environment variable access not available, user must set manually + console.log("Environment variable access not available, use settings to set private key"); + } + } + + 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(); + 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; + } + + // Create default metadata if none exists + if (!metadata) { + metadata = createDefaultMetadata(eventKind); + } + + // Merge with header title for 30040 + if (eventKind === 30040 && isAsciiDocDocument(content)) { + const headerTitle = content.split("\n")[0]?.replace(/^=+\s*/, "").trim() || ""; + metadata = mergeWithHeaderTitle(metadata, headerTitle); + } + + // Validate metadata + const validation = validateMetadata(metadata, eventKind); + if (!validation.valid) { + new Notice(`Metadata validation failed: ${validation.errors.join(", ")}`); + return; + } + + // Build events + const result = await buildEvents(file, content, metadata, this.settings.privateKey, this.app); + + if (result.errors.length > 0) { + new Notice(`Errors: ${result.errors.join(", ")}`); + 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`); + } + } catch (error: any) { + new Notice(`Error creating events: ${error.message}`); + console.error(error); + } + } + + private async handlePreviewStructure() { + const file = await this.getCurrentFile(); + 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) { + new Notice(`Error previewing structure: ${error.message}`); + console.error(error); + } + } + + private async handlePublishEvents() { + const file = await this.getCurrentFile(); + 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; + } + + 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) { + new Notice(`Error publishing events: ${error.message}`); + console.error(error); + } + } + + private async handleEditMetadata() { + const file = await this.getCurrentFile(); + 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) { + new Notice(`Error editing metadata: ${error.message}`); + console.error(error); + } + } +} diff --git a/src/metadataManager.ts b/src/metadataManager.ts new file mode 100644 index 0000000..f45fabb --- /dev/null +++ b/src/metadataManager.ts @@ -0,0 +1,162 @@ +import * as yaml from "js-yaml"; +import { TFile } from "obsidian"; +import { EventKind, EventMetadata } from "./types"; + +/** + * Get metadata file path for a given file + */ +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`; +} + +/** + * Read metadata from YAML file + */ +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(metadataFile); + const parsed = yaml.load(content) as any; + return parsed as EventMetadata; + } catch (error) { + console.error("Error reading metadata:", error); + return null; + } +} + +/** + * Write metadata to YAML file + */ +export async function writeMetadata( + file: TFile, + metadata: EventMetadata, + app: any +): Promise { + const metadataPath = getMetadataFilePath(file); + const yamlContent = yaml.dump(metadata, { + indent: 2, + lineWidth: -1, + }); + await app.vault.adapter.write(metadataPath, yamlContent); +} + +/** + * Validate metadata for a specific event kind + */ +export function validateMetadata( + metadata: EventMetadata, + kind: EventKind +): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + // Check that kind matches + if (metadata.kind !== kind) { + errors.push(`Metadata kind ${metadata.kind} does not match expected kind ${kind}`); + } + + // Validate based on kind + switch (kind) { + case 1: + case 11: + // No special requirements + break; + + case 30023: + if (!metadata.title) { + errors.push("Title is mandatory for kind 30023"); + } + break; + + case 30040: + if (!metadata.title) { + errors.push("Title is mandatory for kind 30040"); + } + break; + + case 30041: + if (!metadata.title) { + errors.push("Title is mandatory for kind 30041"); + } + break; + + case 30817: + case 30818: + if (!metadata.title) { + errors.push(`Title is mandatory for kind ${kind}`); + } + break; + } + + return { + valid: errors.length === 0, + errors, + }; +} + +/** + * Create default metadata for a given kind + */ +export function createDefaultMetadata(kind: EventKind): EventMetadata { + switch (kind) { + case 1: + return { kind: 1 }; + case 11: + return { kind: 11 }; + case 30023: + return { + kind: 30023, + title: "", + }; + case 30040: + return { + kind: 30040, + title: "", + type: "book", + auto_update: "ask", + }; + case 30041: + return { + kind: 30041, + title: "", + }; + case 30817: + return { + kind: 30817, + title: "", + }; + case 30818: + return { + kind: 30818, + title: "", + }; + } +} + +/** + * Merge metadata with document header title (for 30040) + */ +export function mergeWithHeaderTitle( + metadata: EventMetadata, + headerTitle: string +): EventMetadata { + if (metadata.kind === 30040) { + // Only use header title if metadata doesn't have a title + if (!metadata.title || metadata.title.trim() === "") { + return { + ...metadata, + title: headerTitle, + }; + } + } + return metadata; +} diff --git a/src/nostr/authHandler.ts b/src/nostr/authHandler.ts new file mode 100644 index 0000000..280f83f --- /dev/null +++ b/src/nostr/authHandler.ts @@ -0,0 +1,119 @@ +import { Relay, finalizeEvent, getPublicKey } from "nostr-tools"; +import { normalizeSecretKey } from "./eventBuilder"; + +/** + * Handle AUTH challenge from relay (NIP-42) + */ +export async function handleAuthChallenge( + relay: Relay, + challenge: string, + privkey: string, + relayUrl: string +): Promise { + try { + const normalizedKey = normalizeSecretKey(privkey); + const pubkey = getPublicKey(normalizedKey); + + // Create kind 22242 AUTH event + const authEvent = finalizeEvent( + { + kind: 22242, + pubkey, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ["relay", relayUrl], + ["challenge", challenge], + ], + content: "", + }, + normalizedKey + ); + + // Send AUTH event + return new Promise((resolve) => { + const timeout = setTimeout(() => { + resolve(false); + }, 10000); + + relay.on("ok", (ok) => { + if (ok.id === authEvent.id && ok.ok) { + clearTimeout(timeout); + resolve(true); + } + }); + + relay.on("error", () => { + clearTimeout(timeout); + resolve(false); + }); + + relay.send(["AUTH", authEvent]); + + // Also listen for OK message directly + setTimeout(() => { + clearTimeout(timeout); + resolve(false); + }, 5000); + }); + } catch (error) { + console.error("Error handling AUTH challenge:", error); + return false; + } +} + +/** + * Check if relay requires AUTH and handle it + */ +export async function ensureAuthenticated( + relay: Relay, + privkey: string, + relayUrl: string +): Promise { + return new Promise((resolve) => { + let challengeReceived = false; + let authHandled = false; + + const timeout = setTimeout(() => { + if (!authHandled) { + resolve(true); // Assume no AUTH required if no challenge received + } + }, 2000); + + // Listen for AUTH challenge + relay.on("auth", async (challenge: string) => { + challengeReceived = true; + clearTimeout(timeout); + const success = await handleAuthChallenge(relay, challenge, privkey, relayUrl); + authHandled = true; + resolve(success); + }); + + // If no challenge received within timeout, assume no AUTH required + setTimeout(() => { + if (!challengeReceived) { + clearTimeout(timeout); + authHandled = true; + resolve(true); + } + }, 2000); + }); +} + +/** + * Handle auth-required error and retry with AUTH + */ +export async function handleAuthRequiredError( + relay: Relay, + privkey: string, + relayUrl: string, + originalOperation: () => Promise +): Promise { + // Try to authenticate + const authenticated = await ensureAuthenticated(relay, privkey, relayUrl); + if (!authenticated) { + throw new Error("Failed to authenticate with relay"); + } + + // Retry original operation + return originalOperation(); +} diff --git a/src/nostr/eventBuilder.ts b/src/nostr/eventBuilder.ts new file mode 100644 index 0000000..cf00668 --- /dev/null +++ b/src/nostr/eventBuilder.ts @@ -0,0 +1,202 @@ +import { finalizeEvent, getEventHash, getPublicKey, nip19 } from "nostr-tools"; +import { EventKind, EventMetadata, SignedEvent } from "../types"; + +/** + * Normalize secret key from bech32 nsec or hex format to hex + */ +export function normalizeSecretKey(key: string): string { + if (key.startsWith("nsec")) { + try { + const decoded = nip19.decode(key); + if (decoded.type === "nsec") { + return decoded.data; + } + } catch (e) { + throw new Error(`Invalid nsec format: ${e}`); + } + } + // Assume hex format (64 chars) + if (key.length === 64) { + return key.toLowerCase(); + } + throw new Error("Invalid key format. Expected nsec bech32 or 64-char hex string."); +} + +/** + * Get public key from private key + */ +export function getPubkeyFromPrivkey(privkey: string): string { + const normalized = normalizeSecretKey(privkey); + return getPublicKey(normalized); +} + +/** + * Build tags array from metadata + */ +export function buildTagsFromMetadata( + metadata: EventMetadata, + pubkey: string, + childEvents?: Array<{ kind: number; dTag: string; eventId?: string }> +): string[][] { + const tags: string[][] = []; + + switch (metadata.kind) { + case 1: + // No special tags required + break; + + case 11: + // No special tags required + break; + + case 30023: + // Long-form article + if (!metadata.title) { + throw new Error("Title is mandatory for kind 30023"); + } + tags.push(["d", normalizeDTag(metadata.title)]); + if (metadata.title) tags.push(["title", metadata.title]); + if (metadata.image) tags.push(["image", metadata.image]); + if (metadata.summary) tags.push(["summary", metadata.summary]); + if (metadata.published_at) tags.push(["published_at", metadata.published_at]); + if (metadata.topics) { + metadata.topics.forEach((topic) => tags.push(["t", topic])); + } + break; + + case 30040: + // Publication index + if (!metadata.title) { + throw new Error("Title is mandatory for kind 30040"); + } + tags.push(["d", normalizeDTag(metadata.title)]); + if (metadata.title) tags.push(["title", metadata.title]); + if (metadata.author) tags.push(["author", metadata.author]); + if (metadata.type) tags.push(["type", metadata.type]); + if (metadata.version) tags.push(["version", metadata.version]); + if (metadata.published_on) tags.push(["published_on", metadata.published_on]); + if (metadata.published_by) tags.push(["published_by", metadata.published_by]); + if (metadata.summary) tags.push(["summary", metadata.summary]); + if (metadata.source) tags.push(["source", metadata.source]); + if (metadata.image) tags.push(["image", metadata.image]); + if (metadata.auto_update) { + tags.push(["auto-update", metadata.auto_update]); + } + if (metadata.derivative_author) { + tags.push(["p", metadata.derivative_author]); + } + if (metadata.derivative_event) { + const eTag = ["E", metadata.derivative_event]; + if (metadata.derivative_relay) eTag.push(metadata.derivative_relay); + if (metadata.derivative_pubkey) eTag.push(metadata.derivative_pubkey); + tags.push(eTag); + } + // NKBIP-08 tags + if (metadata.collection_id) tags.push(["C", metadata.collection_id]); + if (metadata.version_tag) tags.push(["v", metadata.version_tag]); + // Additional tags + if (metadata.additional_tags) { + metadata.additional_tags.forEach((tag) => tags.push(tag)); + } + // a tags for child events + if (childEvents) { + childEvents.forEach((child) => { + const aTag = ["a", `${child.kind}:${pubkey}:${child.dTag}`]; + if (child.eventId) aTag.push("", child.eventId); + tags.push(aTag); + }); + } + break; + + case 30041: + // Publication content + if (!metadata.title) { + throw new Error("Title is mandatory for kind 30041"); + } + 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]); + break; + + case 30817: + // Wiki page (Markdown) + if (!metadata.title) { + throw new Error("Title is mandatory for kind 30817"); + } + tags.push(["d", normalizeDTag(metadata.title)]); + if (metadata.title) tags.push(["title", metadata.title]); + if (metadata.summary) tags.push(["summary", metadata.summary]); + break; + + case 30818: + // Wiki page (AsciiDoc) + if (!metadata.title) { + throw new Error("Title is mandatory for kind 30818"); + } + tags.push(["d", normalizeDTag(metadata.title)]); + if (metadata.title) tags.push(["title", metadata.title]); + if (metadata.summary) tags.push(["summary", metadata.summary]); + break; + } + + return tags; +} + +/** + * Normalize d-tag per NIP-54 rules + */ +export function normalizeDTag(title: string): string { + // All letters with uppercase/lowercase variants → lowercase + let normalized = title.toLowerCase(); + + // Whitespace → `-` + normalized = normalized.replace(/\s+/g, "-"); + + // Punctuation and symbols → removed (except hyphens) + normalized = normalized.replace(/[^\p{L}\p{N}-]/gu, ""); + + // Multiple consecutive `-` → single `-` + normalized = normalized.replace(/-+/g, "-"); + + // Leading and trailing `-` → removed + normalized = normalized.replace(/^-+|-+$/g, ""); + + // Non-ASCII letters and numbers are preserved (already handled by regex above) + + return normalized; +} + +/** + * Create and sign a Nostr event + */ +export function createSignedEvent( + kind: EventKind, + content: string, + tags: string[][], + privkey: string, + createdAt?: number +): SignedEvent { + const normalizedKey = normalizeSecretKey(privkey); + const pubkey = getPublicKey(normalizedKey); + const created_at = createdAt || Math.floor(Date.now() / 1000); + + const unsignedEvent = { + kind, + pubkey, + created_at, + tags, + content, + }; + + const signedEvent = finalizeEvent(unsignedEvent, normalizedKey); + + return { + ...signedEvent, + kind: kind as EventKind, + }; +} diff --git a/src/nostr/relayClient.ts b/src/nostr/relayClient.ts new file mode 100644 index 0000000..39e7581 --- /dev/null +++ b/src/nostr/relayClient.ts @@ -0,0 +1,151 @@ +import { Relay, relayInit } from "nostr-tools"; +import { SignedEvent, PublishingResult } from "../types"; +import { ensureAuthenticated, handleAuthRequiredError } from "./authHandler"; + +/** + * Publish a single event to a relay + */ +export async function publishEventToRelay( + relayUrl: string, + event: SignedEvent, + privkey: string, + timeout: number = 10000 +): Promise { + let relay: Relay | null = null; + + try { + relay = relayInit(relayUrl); + await relay.connect(); + + // Ensure authenticated if needed + await ensureAuthenticated(relay, privkey, relayUrl); + + return new Promise((resolve) => { + const timer = setTimeout(() => { + if (relay) { + relay.close(); + } + resolve({ + eventId: event.id, + relay: relayUrl, + success: false, + message: "Timeout waiting for relay response", + }); + }, timeout); + + const publishPromise = new Promise((innerResolve) => { + relay!.on("ok", (ok) => { + if (ok.id === event.id) { + clearTimeout(timer); + relay?.close(); + innerResolve({ + eventId: event.id, + relay: relayUrl, + success: ok.ok, + message: ok.message || undefined, + }); + } + }); + + relay!.on("error", (error) => { + clearTimeout(timer); + relay?.close(); + innerResolve({ + eventId: event.id, + relay: relayUrl, + success: false, + message: error.message || "Relay error", + }); + }); + + // Handle auth-required errors + relay!.on("notice", (notice) => { + if (notice.includes("auth-required")) { + handleAuthRequiredError(relay!, privkey, relayUrl, async () => { + relay!.publish(event); + }).catch((error) => { + clearTimeout(timer); + relay?.close(); + innerResolve({ + eventId: event.id, + relay: relayUrl, + success: false, + message: `Auth failed: ${error.message}`, + }); + }); + } + }); + + relay!.publish(event); + }); + + publishPromise.then(resolve); + }); + } catch (error: any) { + if (relay) { + relay.close(); + } + return { + eventId: event.id, + relay: relayUrl, + success: false, + message: error.message || "Failed to connect to relay", + }; + } +} + +/** + * Publish events to multiple relays + */ +export async function publishEventsToRelays( + relayUrls: string[], + events: SignedEvent[], + privkey: string +): Promise { + const results: PublishingResult[][] = []; + + for (const relayUrl of relayUrls) { + const relayResults: PublishingResult[] = []; + for (const event of events) { + const result = await publishEventToRelay(relayUrl, event, privkey); + relayResults.push(result); + } + results.push(relayResults); + } + + return results; +} + +/** + * Publish events to relays with retry logic + */ +export async function publishEventsWithRetry( + relayUrls: string[], + events: SignedEvent[], + privkey: string, + maxRetries: number = 3 +): Promise { + let attempts = 0; + let results: PublishingResult[][] = []; + + while (attempts < maxRetries) { + results = await publishEventsToRelays(relayUrls, events, privkey); + + // Check if all events succeeded on at least one relay + const allSucceeded = results.some((relayResults) => + relayResults.every((r) => r.success) + ); + + if (allSucceeded) { + break; + } + + attempts++; + if (attempts < maxRetries) { + // Wait before retry + await new Promise((resolve) => setTimeout(resolve, 1000 * attempts)); + } + } + + return results; +} diff --git a/src/relayManager.ts b/src/relayManager.ts new file mode 100644 index 0000000..954d61a --- /dev/null +++ b/src/relayManager.ts @@ -0,0 +1,171 @@ +import { Relay, relayInit, getPublicKey } from "nostr-tools"; +import { RelayInfo } from "./types"; + +/** + * Default relay URLs to query for kind 10002 + */ +const DEFAULT_RELAY_URLS = [ + "wss://profiles.nostr1.com", + "wss://relay.damus.io", + "wss://thecitadel.nostr1.com", +]; + +/** + * Default fallback relay + */ +const DEFAULT_FALLBACK_RELAY = "wss://thecitadel.nostr1.com"; + +/** + * Parse kind 10002 event to extract relay list + */ +export function parseRelayList(event: any): RelayInfo[] { + const relays: RelayInfo[] = []; + + if (!event.tags) { + return relays; + } + + for (const tag of event.tags) { + if (tag[0] === "r" && tag[1]) { + const url = tag[1]; + const read = tag.length > 2 ? tag[2] === "read" || tag[2] === undefined : true; + const write = tag.length > 2 ? tag[2] === "write" || tag[2] === undefined : true; + + relays.push({ + url, + read: read || (tag[2] === undefined && tag.length === 2), + write: write || (tag[2] === undefined && tag.length === 2), + }); + } + } + + return relays; +} + +/** + * Fetch kind 10002 relay list from a specific relay + */ +export async function fetchRelayListFromRelay( + relayUrl: string, + pubkey: string, + timeout: number = 5000 +): Promise { + return new Promise(async (resolve) => { + let relay: Relay | null = null; + const timer = setTimeout(() => { + if (relay) { + relay.close(); + } + resolve(null); + }, timeout); + + try { + relay = relayInit(relayUrl); + await relay.connect(); + + const sub = relay.subscribe( + [ + { + kinds: [10002], + authors: [pubkey], + }, + ], + { + onevent: (event) => { + clearTimeout(timer); + relay?.close(); + const relayList = parseRelayList(event); + resolve(relayList.length > 0 ? relayList : null); + }, + oneose: () => { + clearTimeout(timer); + relay?.close(); + resolve(null); + }, + } + ); + + // Wait a bit for response + setTimeout(() => { + sub.close(); + if (relay) { + relay.close(); + } + }, timeout - 100); + } catch (error) { + clearTimeout(timer); + if (relay) { + relay.close(); + } + console.error(`Error fetching relay list from ${relayUrl}:`, error); + resolve(null); + } + }); +} + +/** + * Fetch kind 10002 relay list from multiple relays + */ +export async function fetchRelayList( + pubkey: string, + relayUrls: string[] = DEFAULT_RELAY_URLS +): Promise { + // Try each relay in parallel + const promises = relayUrls.map((url) => fetchRelayListFromRelay(url, pubkey)); + const results = await Promise.all(promises); + + // Find first non-null result + for (const result of results) { + if (result && result.length > 0) { + return result; + } + } + + // If none found, return default fallback + return [ + { + url: DEFAULT_FALLBACK_RELAY, + read: true, + write: true, + }, + ]; +} + +/** + * Get write relays from relay list + */ +export function getWriteRelays(relayList: RelayInfo[]): string[] { + return relayList.filter((r) => r.write).map((r) => r.url); +} + +/** + * Get read relays from relay list + */ +export function getReadRelays(relayList: RelayInfo[]): string[] { + return relayList.filter((r) => r.read).map((r) => r.url); +} + +/** + * Check if relay list includes TheCitadel + */ +export function includesTheCitadel(relayList: RelayInfo[]): boolean { + return relayList.some((r) => r.url.includes("thecitadel.nostr1.com")); +} + +/** + * Add TheCitadel to relay list if not present + */ +export function addTheCitadelIfMissing(relayList: RelayInfo[]): RelayInfo[] { + if (includesTheCitadel(relayList)) { + return relayList; + } + + return [ + ...relayList, + { + url: DEFAULT_FALLBACK_RELAY, + read: true, + write: true, + }, + ]; +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..2daadc1 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,197 @@ +import { Event as NostrEvent } from "nostr-tools"; + +/** + * Supported Nostr event kinds + */ +export type EventKind = 1 | 11 | 30023 | 30040 | 30041 | 30817 | 30818; + +/** + * File content type + */ +export type ContentType = "markdown" | "asciidoc"; + +/** + * Nostr event with additional metadata + */ +export interface SignedEvent extends NostrEvent { + id: string; + pubkey: string; + created_at: number; + kind: EventKind; + tags: string[][]; + content: string; + sig: string; +} + +/** + * Base metadata structure + */ +export interface BaseMetadata { + title?: string; + author?: string; + published_on?: string; + summary?: string; +} + +/** + * Metadata for kind 1 (normal notes) + */ +export interface Kind1Metadata extends BaseMetadata { + kind: 1; +} + +/** + * Metadata for kind 11 (discussion thread OPs) + */ +export interface Kind11Metadata extends BaseMetadata { + kind: 11; +} + +/** + * Metadata for kind 30023 (long-form articles) + */ +export interface Kind30023Metadata extends BaseMetadata { + kind: 30023; + title: string; // mandatory + image?: string; + published_at?: string; + topics?: string[]; // t tags +} + +/** + * Metadata for kind 30040 (publication index) + */ +export interface Kind30040Metadata extends BaseMetadata { + kind: 30040; + title: string; // mandatory (derived from header, can be overridden) + author?: string; + type?: string; // book, illustrated, magazine, documentation, academic, blog + version?: string; + published_on?: string; + published_by?: string; + summary?: string; + source?: string; + image?: string; + auto_update?: "yes" | "ask" | "no"; + derivative_author?: string; // p tag + derivative_event?: string; // E tag + derivative_relay?: string; + derivative_pubkey?: string; + additional_tags?: string[][]; // custom tags + // NKBIP-08 tags + collection_id?: string; // C tag + version_tag?: string; // v tag +} + +/** + * Metadata for kind 30041 (publication content) + */ +export interface Kind30041Metadata extends BaseMetadata { + kind: 30041; + title: string; // mandatory + // NKBIP-08 tags + collection_id?: string; // C tag + title_id?: string; // T tag + chapter_id?: string; // c tag + section_id?: string; // s tag + version_tag?: string; // v tag +} + +/** + * Metadata for kind 30817 (wiki pages - Markdown) + */ +export interface Kind30817Metadata extends BaseMetadata { + kind: 30817; + title: string; // mandatory +} + +/** + * Metadata for kind 30818 (wiki pages - AsciiDoc) + */ +export interface Kind30818Metadata extends BaseMetadata { + kind: 30818; + title: string; // mandatory +} + +/** + * Union type for all metadata types + */ +export type EventMetadata = + | Kind1Metadata + | Kind11Metadata + | Kind30023Metadata + | Kind30040Metadata + | Kind30041Metadata + | Kind30817Metadata + | Kind30818Metadata; + +/** + * Plugin settings + */ +export interface ScriptoriumSettings { + // Relay settings + relayList: RelayInfo[]; + suggestTheCitadel: boolean; + defaultRelay: string; + + // Event settings + defaultEventKind: EventKind; + + // Key management + privateKey?: string; // from SCRIPTORIUM_OBSIDIAN_KEY env var + + // AUTH preferences + autoAuth: boolean; +} + +/** + * Relay information + */ +export interface RelayInfo { + url: string; + read: boolean; + write: boolean; +} + +/** + * Document structure node for preview + */ +export interface StructureNode { + level: number; + title: string; + dTag: string; + kind: 30040 | 30041; + content?: string; + children: StructureNode[]; + metadata?: EventMetadata; +} + +/** + * Event creation result + */ +export interface EventCreationResult { + events: SignedEvent[]; + structure: StructureNode[]; + errors: string[]; +} + +/** + * Publishing result + */ +export interface PublishingResult { + eventId: string; + relay: string; + success: boolean; + message?: string; +} + +/** + * Default plugin settings + */ +export const DEFAULT_SETTINGS: ScriptoriumSettings = { + relayList: [], + suggestTheCitadel: true, + defaultRelay: "wss://thecitadel.nostr1.com", + defaultEventKind: 1, + autoAuth: true, +}; diff --git a/src/ui/metadataModal.ts b/src/ui/metadataModal.ts new file mode 100644 index 0000000..b44c4f9 --- /dev/null +++ b/src/ui/metadataModal.ts @@ -0,0 +1,320 @@ +import { Modal, App, Setting } from "obsidian"; +import { EventMetadata, EventKind } from "../types"; + +/** + * Modal for editing event metadata + */ +export class MetadataModal extends Modal { + private metadata: EventMetadata; + private onSave: (metadata: EventMetadata) => void; + + constructor(app: App, metadata: EventMetadata, onSave: (metadata: EventMetadata) => void) { + super(app); + this.metadata = { ...metadata }; + this.onSave = onSave; + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + + contentEl.createEl("h2", { text: "Edit Event Metadata" }); + + // Common fields + if (this.requiresTitle()) { + new Setting(contentEl) + .setName("Title") + .setDesc("Title is mandatory for this event kind") + .addText((text) => { + text.setValue(this.metadata.title || "") + .setPlaceholder("Enter title") + .onChange((value) => { + (this.metadata as any).title = value; + }); + }); + } + + new Setting(contentEl) + .setName("Author") + .setDesc("Author name") + .addText((text) => { + text.setValue(this.metadata.author || "") + .setPlaceholder("Enter author") + .onChange((value) => { + this.metadata.author = value; + }); + }); + + new Setting(contentEl) + .setName("Summary") + .setDesc("Brief summary or description") + .addTextArea((text) => { + text.setValue(this.metadata.summary || "") + .setPlaceholder("Enter summary") + .onChange((value) => { + this.metadata.summary = value; + }); + text.inputEl.rows = 3; + }); + + // Kind-specific fields + this.renderKindSpecificFields(contentEl); + + // Buttons + const buttonContainer = contentEl.createDiv({ cls: "scriptorium-modal-buttons" }); + const saveButton = buttonContainer.createEl("button", { + text: "Save", + cls: "mod-cta", + }); + saveButton.addEventListener("click", () => { + this.onSave(this.metadata); + this.close(); + }); + + const cancelButton = buttonContainer.createEl("button", { text: "Cancel" }); + cancelButton.addEventListener("click", () => { + this.close(); + }); + } + + private requiresTitle(): boolean { + return ( + this.metadata.kind === 30023 || + this.metadata.kind === 30040 || + this.metadata.kind === 30041 || + this.metadata.kind === 30817 || + this.metadata.kind === 30818 + ); + } + + private renderKindSpecificFields(container: HTMLElement) { + switch (this.metadata.kind) { + case 30023: + this.render30023Fields(container); + break; + case 30040: + this.render30040Fields(container); + break; + case 30041: + this.render30041Fields(container); + break; + case 30817: + case 30818: + // No additional fields beyond common ones + break; + } + } + + private render30023Fields(container: HTMLElement) { + const meta = this.metadata as any; + + new Setting(container) + .setName("Image URL") + .setDesc("URL to an image for the article") + .addText((text) => { + text.setValue(meta.image || "") + .setPlaceholder("https://...") + .onChange((value) => { + meta.image = value; + }); + }); + + new Setting(container) + .setName("Published At") + .setDesc("Unix timestamp of first publication") + .addText((text) => { + text.setValue(meta.published_at || "") + .setPlaceholder("Unix timestamp") + .onChange((value) => { + meta.published_at = value; + }); + }); + + new Setting(container) + .setName("Topics") + .setDesc("Comma-separated topics (t tags)") + .addText((text) => { + text.setValue(meta.topics?.join(", ") || "") + .setPlaceholder("topic1, topic2, ...") + .onChange((value) => { + meta.topics = value.split(",").map((t: string) => t.trim()).filter((t: string) => t.length > 0); + }); + }); + } + + private render30040Fields(container: HTMLElement) { + const meta = this.metadata as any; + + new Setting(container) + .setName("Type") + .setDesc("Publication type") + .addDropdown((dropdown) => { + dropdown + .addOption("book", "Book") + .addOption("illustrated", "Illustrated") + .addOption("magazine", "Magazine") + .addOption("documentation", "Documentation") + .addOption("academic", "Academic") + .addOption("blog", "Blog") + .setValue(meta.type || "book") + .onChange((value) => { + meta.type = value; + }); + }); + + new Setting(container) + .setName("Version") + .setDesc("Version or edition") + .addText((text) => { + text.setValue(meta.version || "") + .setPlaceholder("e.g., 1st edition") + .onChange((value) => { + meta.version = value; + }); + }); + + new Setting(container) + .setName("Published On") + .setDesc("Publication date") + .addText((text) => { + text.setValue(meta.published_on || "") + .setPlaceholder("e.g., 2003-05-13") + .onChange((value) => { + meta.published_on = value; + }); + }); + + new Setting(container) + .setName("Published By") + .setDesc("Publisher or source") + .addText((text) => { + text.setValue(meta.published_by || "") + .setPlaceholder("e.g., public domain") + .onChange((value) => { + meta.published_by = value; + }); + }); + + new Setting(container) + .setName("Source URL") + .setDesc("URL to original source") + .addText((text) => { + text.setValue(meta.source || "") + .setPlaceholder("https://...") + .onChange((value) => { + meta.source = value; + }); + }); + + new Setting(container) + .setName("Image URL") + .setDesc("Cover image URL") + .addText((text) => { + text.setValue(meta.image || "") + .setPlaceholder("https://...") + .onChange((value) => { + meta.image = value; + }); + }); + + new Setting(container) + .setName("Auto Update") + .setDesc("Auto-update behavior") + .addDropdown((dropdown) => { + dropdown + .addOption("yes", "Yes") + .addOption("ask", "Ask") + .addOption("no", "No") + .setValue(meta.auto_update || "ask") + .onChange((value) => { + meta.auto_update = value; + }); + }); + + 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("Version Tag") + .setDesc("NKBIP-08 version identifier (v tag)") + .addText((text) => { + text.setValue(meta.version_tag || "") + .setPlaceholder("e.g., kjv, drb") + .onChange((value) => { + meta.version_tag = value; + }); + }); + } + + 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)") + .addText((text) => { + text.setValue(meta.title_id || "") + .setPlaceholder("title-id") + .onChange((value) => { + meta.title_id = value; + }); + }); + + new Setting(container) + .setName("Chapter ID") + .setDesc("NKBIP-08 chapter identifier (c tag)") + .addText((text) => { + text.setValue(meta.chapter_id || "") + .setPlaceholder("chapter-id") + .onChange((value) => { + meta.chapter_id = value; + }); + }); + + new Setting(container) + .setName("Section ID") + .setDesc("NKBIP-08 section identifier (s tag)") + .addText((text) => { + text.setValue(meta.section_id || "") + .setPlaceholder("section-id") + .onChange((value) => { + meta.section_id = value; + }); + }); + + new Setting(container) + .setName("Version Tag") + .setDesc("NKBIP-08 version identifier (v tag)") + .addText((text) => { + text.setValue(meta.version_tag || "") + .setPlaceholder("e.g., kjv, drb") + .onChange((value) => { + meta.version_tag = value; + }); + }); + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} diff --git a/src/ui/settingsTab.ts b/src/ui/settingsTab.ts new file mode 100644 index 0000000..2a7e9be --- /dev/null +++ b/src/ui/settingsTab.ts @@ -0,0 +1,188 @@ +import { App, PluginSettingTab, Setting } from "obsidian"; +import ScriptoriumPlugin from "../main"; +import { EventKind } from "../types"; +import { fetchRelayList, addTheCitadelIfMissing, includesTheCitadel } from "../relayManager"; +import { getPubkeyFromPrivkey } from "../nostr/eventBuilder"; + +/** + * Settings tab for the plugin + */ +export class ScriptoriumSettingTab extends PluginSettingTab { + plugin: ScriptoriumPlugin; + + constructor(app: App, plugin: ScriptoriumPlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const { containerEl } = this; + + containerEl.empty(); + + containerEl.createEl("h2", { text: "Scriptorium Nostr Settings" }); + + // Private Key + new Setting(containerEl) + .setName("Private Key") + .setDesc("Your Nostr private key (nsec or hex). Loaded from SCRIPTORIUM_OBSIDIAN_KEY environment variable.") + .addText((text) => { + const key = this.plugin.settings.privateKey || ""; + text.setValue(key ? "***" + key.slice(-4) : "") + .setPlaceholder("nsec1... or hex") + .setDisabled(true); + }) + .addButton((button) => { + button.setButtonText("Refresh from Env") + .setCta() + .onClick(async () => { + await this.plugin.loadPrivateKey(); + this.display(); + }); + }); + + // Default Event Kind + new Setting(containerEl) + .setName("Default Event Kind") + .setDesc("Default event kind for new documents") + .addDropdown((dropdown) => { + dropdown + .addOption("1", "1 - Normal Note") + .addOption("11", "11 - Discussion Thread OP") + .addOption("30023", "30023 - Long-form Article") + .addOption("30040", "30040 - Publication Index") + .addOption("30041", "30041 - Publication Content") + .addOption("30817", "30817 - Wiki Page (Markdown)") + .addOption("30818", "30818 - Wiki Page (AsciiDoc)") + .setValue(String(this.plugin.settings.defaultEventKind)) + .onChange(async (value) => { + this.plugin.settings.defaultEventKind = parseInt(value) as EventKind; + await this.plugin.saveSettings(); + }); + }); + + // Suggest TheCitadel + new Setting(containerEl) + .setName("Suggest TheCitadel Relay") + .setDesc("Automatically suggest adding wss://thecitadel.nostr1.com to relay list") + .addToggle((toggle) => { + toggle.setValue(this.plugin.settings.suggestTheCitadel) + .onChange(async (value) => { + this.plugin.settings.suggestTheCitadel = value; + await this.plugin.saveSettings(); + }); + }); + + // Default Relay + new Setting(containerEl) + .setName("Default Relay") + .setDesc("Fallback relay URL if no relay list is found") + .addText((text) => { + text.setValue(this.plugin.settings.defaultRelay) + .setPlaceholder("wss://relay.example.com") + .onChange(async (value) => { + this.plugin.settings.defaultRelay = value; + await this.plugin.saveSettings(); + }); + }); + + // Auto AUTH + new Setting(containerEl) + .setName("Auto AUTH") + .setDesc("Automatically handle relay authentication when required") + .addToggle((toggle) => { + toggle.setValue(this.plugin.settings.autoAuth) + .onChange(async (value) => { + this.plugin.settings.autoAuth = value; + await this.plugin.saveSettings(); + }); + }); + + // Relay List Management + containerEl.createEl("h3", { text: "Relay List" }); + + new Setting(containerEl) + .setName("Fetch Relay List") + .setDesc("Fetch your relay list (kind 10002) from Nostr relays") + .addButton((button) => { + button.setButtonText("Fetch") + .setCta() + .onClick(async () => { + if (!this.plugin.settings.privateKey) { + alert("Please set your private key first"); + return; + } + + try { + const pubkey = getPubkeyFromPrivkey(this.plugin.settings.privateKey); + const relayList = await fetchRelayList(pubkey); + + // Add TheCitadel if suggested + let finalList = relayList; + if (this.plugin.settings.suggestTheCitadel && !includesTheCitadel(relayList)) { + finalList = addTheCitadelIfMissing(relayList); + } + + this.plugin.settings.relayList = finalList; + await this.plugin.saveSettings(); + this.display(); + } catch (error: any) { + alert(`Error fetching relay list: ${error.message}`); + } + }); + }); + + // Display current relay list + if (this.plugin.settings.relayList.length > 0) { + containerEl.createEl("h4", { text: "Current Relays" }); + this.plugin.settings.relayList.forEach((relay, index) => { + const relayDiv = containerEl.createDiv({ cls: "scriptorium-relay-item" }); + relayDiv.createSpan({ text: relay.url }); + const badges = relayDiv.createSpan({ cls: "scriptorium-relay-badges" }); + if (relay.read) { + badges.createSpan({ text: "Read", cls: "scriptorium-badge" }); + } + if (relay.write) { + badges.createSpan({ text: "Write", cls: "scriptorium-badge" }); + } + new Setting(relayDiv) + .addButton((button) => { + button.setButtonText("Remove") + .setWarning() + .onClick(async () => { + this.plugin.settings.relayList.splice(index, 1); + await this.plugin.saveSettings(); + this.display(); + }); + }); + }); + } + + // Manual relay addition + containerEl.createEl("h4", { text: "Add Relay" }); + new Setting(containerEl) + .setName("Relay URL") + .addText((text) => { + text.setPlaceholder("wss://relay.example.com"); + }) + .addButton((button) => { + button.setButtonText("Add") + .setCta() + .onClick(async () => { + const input = button.buttonEl.previousElementSibling as HTMLInputElement; + const url = input.value.trim(); + if (url) { + if (!this.plugin.settings.relayList.some((r) => r.url === url)) { + this.plugin.settings.relayList.push({ + url, + read: true, + write: true, + }); + await this.plugin.saveSettings(); + this.display(); + } + } + }); + }); + } +} diff --git a/src/ui/structurePreviewModal.ts b/src/ui/structurePreviewModal.ts new file mode 100644 index 0000000..6b58c33 --- /dev/null +++ b/src/ui/structurePreviewModal.ts @@ -0,0 +1,80 @@ +import { Modal, App } from "obsidian"; +import { StructureNode } from "../types"; + +/** + * Modal for previewing document structure before creating events + */ +export class StructurePreviewModal extends Modal { + private structure: StructureNode[]; + private onConfirm: () => void; + + constructor(app: App, structure: StructureNode[], onConfirm: () => void) { + super(app); + this.structure = structure; + this.onConfirm = onConfirm; + } + + onOpen() { + const { contentEl } = this; + contentEl.empty(); + + contentEl.createEl("h2", { text: "Document Structure Preview" }); + + const structureContainer = contentEl.createDiv({ cls: "scriptorium-structure-preview" }); + + this.structure.forEach((node) => { + this.renderNode(structureContainer, node, 0); + }); + + const buttonContainer = contentEl.createDiv({ cls: "scriptorium-modal-buttons" }); + const confirmButton = buttonContainer.createEl("button", { + text: "Create Events", + cls: "mod-cta", + }); + confirmButton.addEventListener("click", () => { + this.onConfirm(); + this.close(); + }); + + const cancelButton = buttonContainer.createEl("button", { text: "Cancel" }); + cancelButton.addEventListener("click", () => { + this.close(); + }); + } + + private renderNode(container: HTMLElement, node: StructureNode, indent: number) { + const nodeDiv = container.createDiv({ cls: "scriptorium-structure-node" }); + nodeDiv.style.paddingLeft = `${indent * 20}px`; + + const kindBadge = nodeDiv.createSpan({ + cls: `scriptorium-kind-badge kind-${node.kind}`, + text: `Kind ${node.kind}`, + }); + + const titleEl = nodeDiv.createEl("div", { cls: "scriptorium-node-title" }); + titleEl.createEl("strong", { text: node.title }); + + const dTagEl = nodeDiv.createEl("div", { cls: "scriptorium-node-dtag" }); + dTagEl.createEl("span", { text: `d-tag: `, cls: "scriptorium-label" }); + dTagEl.createEl("code", { text: node.dTag }); + + if (node.kind === 30041 && node.content) { + const contentPreview = nodeDiv.createDiv({ cls: "scriptorium-content-preview" }); + const previewText = node.content.substring(0, 100); + contentPreview.createEl("em", { + text: previewText + (node.content.length > 100 ? "..." : ""), + }); + } + + if (node.children.length > 0) { + node.children.forEach((child) => { + this.renderNode(container, child, indent + 1); + }); + } + } + + onClose() { + const { contentEl } = this; + contentEl.empty(); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..576b09c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES6", + "allowJs": true, + "noImplicitAny": true, + "moduleResolution": "node", + "importHelpers": true, + "lib": [ + "DOM", + "ES5", + "ES6", + "ES7", + "ES2017", + "ES2018", + "ES2019", + "ES2020" + ], + "skipLibCheck": true, + "strictNullChecks": true, + "strict": true + }, + "include": [ + "**/*.ts" + ] +}