commit
68e76968bf
19 changed files with 2495 additions and 0 deletions
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
node_modules/ |
||||
.DS_Store |
||||
*.log |
||||
main.js |
||||
main.js.map |
||||
versions.json |
||||
@ -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 |
||||
@ -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)); |
||||
@ -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 |
||||
} |
||||
@ -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" |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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: [] }; |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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(); |
||||
} |
||||
@ -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, |
||||
}; |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -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, |
||||
}, |
||||
]; |
||||
} |
||||
@ -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, |
||||
}; |
||||
@ -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(); |
||||
} |
||||
} |
||||
@ -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(); |
||||
} |
||||
} |
||||
}); |
||||
}); |
||||
} |
||||
} |
||||
@ -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(); |
||||
} |
||||
} |
||||
@ -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…
Reference in new issue