Browse Source

initial commit

master
Silberengel 1 week ago
commit
68e76968bf
  1. 6
      .gitignore
  2. 88
      README.md
  3. 51
      esbuild.config.mjs
  4. 11
      manifest.json
  5. 28
      package.json
  6. 197
      src/asciidocParser.ts
  7. 155
      src/eventManager.ts
  8. 72
      src/eventStorage.ts
  9. 268
      src/main.ts
  10. 162
      src/metadataManager.ts
  11. 119
      src/nostr/authHandler.ts
  12. 202
      src/nostr/eventBuilder.ts
  13. 151
      src/nostr/relayClient.ts
  14. 171
      src/relayManager.ts
  15. 197
      src/types.ts
  16. 320
      src/ui/metadataModal.ts
  17. 188
      src/ui/settingsTab.ts
  18. 80
      src/ui/structurePreviewModal.ts
  19. 29
      tsconfig.json

6
.gitignore vendored

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
node_modules/
.DS_Store
*.log
main.js
main.js.map
versions.json

88
README.md

@ -0,0 +1,88 @@ @@ -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

51
esbuild.config.mjs

@ -0,0 +1,51 @@ @@ -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));

11
manifest.json

@ -0,0 +1,11 @@ @@ -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
}

28
package.json

@ -0,0 +1,28 @@ @@ -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"
}
}

197
src/asciidocParser.ts

@ -0,0 +1,197 @@ @@ -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;
}

155
src/eventManager.ts

@ -0,0 +1,155 @@ @@ -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<SignedEvent[]> {
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<EventCreationResult> {
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<void> {
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<EventCreationResult> {
// 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: [] };
}
}

72
src/eventStorage.ts

@ -0,0 +1,72 @@ @@ -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<void> {
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<SignedEvent[]> {
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<boolean> {
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<void> {
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);
}
}

268
src/main.ts

@ -0,0 +1,268 @@ @@ -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<TFile | null> {
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);
}
}
}

162
src/metadataManager.ts

@ -0,0 +1,162 @@ @@ -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<EventMetadata | null> {
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<void> {
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;
}

119
src/nostr/authHandler.ts

@ -0,0 +1,119 @@ @@ -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<boolean> {
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<boolean> {
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<any>
): Promise<any> {
// 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();
}

202
src/nostr/eventBuilder.ts

@ -0,0 +1,202 @@ @@ -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,
};
}

151
src/nostr/relayClient.ts

@ -0,0 +1,151 @@ @@ -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<PublishingResult> {
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<PublishingResult>((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<PublishingResult[][]> {
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<PublishingResult[][]> {
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;
}

171
src/relayManager.ts

@ -0,0 +1,171 @@ @@ -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<RelayInfo[] | null> {
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<RelayInfo[]> {
// 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,
},
];
}

197
src/types.ts

@ -0,0 +1,197 @@ @@ -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,
};

320
src/ui/metadataModal.ts

@ -0,0 +1,320 @@ @@ -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();
}
}

188
src/ui/settingsTab.ts

@ -0,0 +1,188 @@ @@ -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();
}
}
});
});
}
}

80
src/ui/structurePreviewModal.ts

@ -0,0 +1,80 @@ @@ -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();
}
}

29
tsconfig.json

@ -0,0 +1,29 @@ @@ -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"
]
}
Loading…
Cancel
Save