commit
68e76968bf
19 changed files with 2495 additions and 0 deletions
@ -0,0 +1,6 @@ |
|||||||
|
node_modules/ |
||||||
|
.DS_Store |
||||||
|
*.log |
||||||
|
main.js |
||||||
|
main.js.map |
||||||
|
versions.json |
||||||
@ -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 @@ |
|||||||
|
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 @@ |
|||||||
|
{ |
||||||
|
"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 @@ |
|||||||
|
{ |
||||||
|
"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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
{ |
||||||
|
"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