You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
516 lines
16 KiB
516 lines
16 KiB
import { Plugin, TFile, TFolder, 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 { NewDocumentModal } from "./ui/newDocumentModal"; |
|
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"; |
|
import { safeConsoleError, safeConsoleLog, verifyEventSecurity } from "./utils/security"; |
|
|
|
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(), |
|
}); |
|
|
|
this.addCommand({ |
|
id: "new-nostr-document", |
|
name: "New Nostr Document", |
|
callback: () => this.handleNewDocument(), |
|
}); |
|
|
|
// Add ribbon icon for creating new documents |
|
this.addRibbonIcon("file-plus", "New Nostr Document", () => { |
|
this.handleNewDocument(); |
|
}); |
|
|
|
// 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(): Promise<boolean> { |
|
// Try multiple methods to load the private key |
|
|
|
// Method 1: Try environment variable (may not work in Obsidian's sandbox) |
|
try { |
|
// @ts-ignore - process.env may not be typed in Obsidian context |
|
if (typeof process !== "undefined" && process.env?.SCRIPTORIUM_OBSIDIAN_KEY) { |
|
const envKey = process.env.SCRIPTORIUM_OBSIDIAN_KEY.trim(); |
|
if (envKey) { |
|
this.settings.privateKey = envKey; |
|
await this.saveSettings(); |
|
return true; |
|
} |
|
} |
|
} catch (error) { |
|
// Environment variable access not available |
|
} |
|
|
|
// Method 2: Try reading from a file in the vault (.scriptorium_key) |
|
try { |
|
const keyFile = this.app.vault.getAbstractFileByPath(".scriptorium_key"); |
|
if (keyFile && keyFile instanceof TFile) { |
|
const keyContent = await this.app.vault.read(keyFile); |
|
const key = keyContent.trim(); |
|
if (key && (key.startsWith("nsec1") || /^[0-9a-f]{64}$/i.test(key))) { |
|
this.settings.privateKey = key; |
|
await this.saveSettings(); |
|
return true; |
|
} |
|
} |
|
} catch (error) { |
|
// File doesn't exist or can't be read |
|
} |
|
|
|
// Method 3: Try reading from .obsidian/scriptorium_key (hidden file) |
|
try { |
|
const hiddenKeyFile = this.app.vault.getAbstractFileByPath(".obsidian/scriptorium_key"); |
|
if (hiddenKeyFile && hiddenKeyFile instanceof TFile) { |
|
const keyContent = await this.app.vault.read(hiddenKeyFile); |
|
const key = keyContent.trim(); |
|
if (key && (key.startsWith("nsec1") || /^[0-9a-f]{64}$/i.test(key))) { |
|
this.settings.privateKey = key; |
|
await this.saveSettings(); |
|
return true; |
|
} |
|
} |
|
} catch (error) { |
|
// File doesn't exist or can't be read |
|
} |
|
|
|
return false; |
|
} |
|
|
|
/** |
|
* Get folder name for an event kind |
|
*/ |
|
private getFolderNameForKind(kind: EventKind): string { |
|
switch (kind) { |
|
case 1: |
|
return "kind-1-notes"; |
|
case 11: |
|
return "kind-11-threads"; |
|
case 30023: |
|
return "kind-30023-articles"; |
|
case 30040: |
|
return "kind-30040-publications"; |
|
case 30041: |
|
return "kind-30041-chapters"; |
|
case 30817: |
|
return "kind-30817-wiki-md"; |
|
case 30818: |
|
return "kind-30818-wiki-adoc"; |
|
} |
|
} |
|
|
|
/** |
|
* Ensure the Nostr notes folder structure exists |
|
*/ |
|
private async ensureNostrNotesFolder(kind: EventKind): Promise<string> { |
|
const baseFolder = "Nostr notes"; |
|
const kindFolder = this.getFolderNameForKind(kind); |
|
const fullPath = `${baseFolder}/${kindFolder}`; |
|
|
|
// Check if base folder exists |
|
const baseFolderObj = this.app.vault.getAbstractFileByPath(baseFolder); |
|
if (!baseFolderObj || !(baseFolderObj instanceof TFolder)) { |
|
await this.app.vault.createFolder(baseFolder); |
|
} |
|
|
|
// Check if kind folder exists |
|
const kindFolderObj = this.app.vault.getAbstractFileByPath(fullPath); |
|
if (!kindFolderObj || !(kindFolderObj instanceof TFolder)) { |
|
await this.app.vault.createFolder(fullPath); |
|
} |
|
|
|
// Refresh file explorer to show new folders |
|
// @ts-ignore - refresh file explorer |
|
if (this.app.workspace.leftSplit) { |
|
// @ts-ignore |
|
this.app.workspace.leftSplit.refresh(); |
|
} |
|
|
|
return fullPath; |
|
} |
|
|
|
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; |
|
} |
|
|
|
// Ensure folder structure exists before creating events |
|
await this.ensureNostrNotesFolder(eventKind); |
|
|
|
// 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; |
|
} |
|
|
|
// Security check: verify events don't contain private keys |
|
for (const event of result.events) { |
|
if (!verifyEventSecurity(event)) { |
|
new Notice("Security error: Event contains private key. Aborting."); |
|
safeConsoleError("Event security check failed - event may contain private key"); |
|
return; |
|
} |
|
} |
|
|
|
// Show preview for structured documents |
|
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) { |
|
const safeMessage = error?.message ? String(error.message).replace(/nsec1[a-z0-9]{58,}/gi, "[REDACTED]").replace(/[0-9a-f]{64}/gi, "[REDACTED]") : "Unknown error"; |
|
new Notice(`Error creating events: ${safeMessage}`); |
|
safeConsoleError("Error creating events:", 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) { |
|
const safeMessage = error?.message ? String(error.message).replace(/nsec1[a-z0-9]{58,}/gi, "[REDACTED]").replace(/[0-9a-f]{64}/gi, "[REDACTED]") : "Unknown error"; |
|
new Notice(`Error previewing structure: ${safeMessage}`); |
|
safeConsoleError("Error previewing structure:", 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; |
|
} |
|
|
|
// Relays are already normalized and deduplicated by getWriteRelays |
|
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) { |
|
const safeMessage = error?.message ? String(error.message).replace(/nsec1[a-z0-9]{58,}/gi, "[REDACTED]").replace(/[0-9a-f]{64}/gi, "[REDACTED]") : "Unknown error"; |
|
new Notice(`Error publishing events: ${safeMessage}`); |
|
safeConsoleError("Error publishing events:", 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) { |
|
const safeMessage = error?.message ? String(error.message).replace(/nsec1[a-z0-9]{58,}/gi, "[REDACTED]").replace(/[0-9a-f]{64}/gi, "[REDACTED]") : "Unknown error"; |
|
new Notice(`Error editing metadata: ${safeMessage}`); |
|
safeConsoleError("Error editing metadata:", error); |
|
} |
|
} |
|
|
|
private async handleNewDocument() { |
|
new NewDocumentModal(this.app, async (kind: EventKind, title: string) => { |
|
try { |
|
// Ensure folder structure exists |
|
const folderPath = await this.ensureNostrNotesFolder(kind); |
|
|
|
// Sanitize filename from title |
|
const sanitizedTitle = this.sanitizeFilename(title); |
|
|
|
// Determine file extension based on kind |
|
let extension = "md"; |
|
if (kind === 30040 || kind === 30041 || kind === 30818) { |
|
extension = "adoc"; |
|
} |
|
|
|
// Create file path in the appropriate folder |
|
const filename = `${sanitizedTitle}.${extension}`; |
|
const filePath = `${folderPath}/${filename}`; |
|
|
|
// Check if file already exists |
|
const existingFile = this.app.vault.getAbstractFileByPath(filePath); |
|
if (existingFile) { |
|
new Notice(`File ${filename} already exists`); |
|
return; |
|
} |
|
|
|
// Create default content based on kind |
|
let content = ""; |
|
if (kind === 30040) { |
|
// AsciiDoc document header for 30040 |
|
content = `= ${title}\n\n`; |
|
} else if (kind === 30023 || kind === 30817 || kind === 30818) { |
|
// Add title as heading for other kinds that require title |
|
if (kind === 30817 || kind === 30818) { |
|
content = `# ${title}\n\n`; |
|
} else { |
|
content = `# ${title}\n\n`; |
|
} |
|
} else if (kind === 1 || kind === 11) { |
|
// For kind 1 and 11, add a simple placeholder |
|
content = `\n`; |
|
} |
|
|
|
// Create the file - ensure we have at least a newline for empty files |
|
if (!content) { |
|
content = "\n"; |
|
} |
|
|
|
let file: TFile; |
|
try { |
|
file = await this.app.vault.create(filePath, content); |
|
|
|
// Verify file was actually created |
|
const verifyFile = this.app.vault.getAbstractFileByPath(filePath); |
|
if (!verifyFile || !(verifyFile instanceof TFile)) { |
|
new Notice(`Error: File ${filename} was not created properly`); |
|
safeConsoleError(`File creation verification failed for ${filePath}`); |
|
return; |
|
} |
|
} catch (error: any) { |
|
const safeMessage = error?.message ? String(error.message).replace(/nsec1[a-z0-9]{58,}/gi, "[REDACTED]").replace(/[0-9a-f]{64}/gi, "[REDACTED]") : "Unknown error"; |
|
new Notice(`Error creating file: ${safeMessage}`); |
|
safeConsoleError("Error creating file:", error); |
|
safeConsoleError("File path was:", filePath); |
|
return; |
|
} |
|
|
|
// Create metadata |
|
const metadata = createDefaultMetadata(kind); |
|
if (metadata.title === "" && title) { |
|
(metadata as any).title = title; |
|
} |
|
|
|
try { |
|
await writeMetadata(file, metadata, this.app); |
|
} catch (error: any) { |
|
const safeMessage = error?.message ? String(error.message).replace(/nsec1[a-z0-9]{58,}/gi, "[REDACTED]").replace(/[0-9a-f]{64}/gi, "[REDACTED]") : "Unknown error"; |
|
new Notice(`Error creating metadata: ${safeMessage}`); |
|
safeConsoleError("Error creating metadata:", error); |
|
// Continue anyway - file was created |
|
} |
|
|
|
// Open the new file in Obsidian workspace (use active leaf or create new) |
|
try { |
|
const leaf = this.app.workspace.getMostRecentLeaf(); |
|
if (leaf) { |
|
await leaf.openFile(file); |
|
} else { |
|
// Fallback: open in new leaf |
|
const newLeaf = this.app.workspace.getLeaf("tab"); |
|
await newLeaf.openFile(file); |
|
} |
|
} catch (error: any) { |
|
safeConsoleError("Error opening file:", error); |
|
// File was created, just couldn't open it |
|
} |
|
|
|
new Notice(`Created ${filename} in ${folderPath}`); |
|
} catch (error: any) { |
|
const safeMessage = error?.message ? String(error.message).replace(/nsec1[a-z0-9]{58,}/gi, "[REDACTED]").replace(/[0-9a-f]{64}/gi, "[REDACTED]") : "Unknown error"; |
|
new Notice(`Error creating document: ${safeMessage}`); |
|
safeConsoleError("Error creating document:", error); |
|
} |
|
}).open(); |
|
} |
|
|
|
/** |
|
* Sanitize a string to be used as a filename |
|
*/ |
|
private sanitizeFilename(title: string): string { |
|
// Remove or replace invalid filename characters |
|
let sanitized = title |
|
.replace(/[<>:"/\\|?*]/g, "-") // Replace invalid chars with dash |
|
.replace(/\s+/g, "-") // Replace spaces with dash |
|
.replace(/-+/g, "-") // Replace multiple dashes with single |
|
.replace(/^-+|-+$/g, ""); // Remove leading/trailing dashes |
|
|
|
// Limit length |
|
if (sanitized.length > 100) { |
|
sanitized = sanitized.substring(0, 100); |
|
} |
|
|
|
// Ensure it's not empty |
|
if (!sanitized) { |
|
sanitized = "untitled"; |
|
} |
|
|
|
return sanitized; |
|
} |
|
}
|
|
|