Browse Source

refactor

added event validator
master
Silberengel 1 week ago
parent
commit
1e104fc56c
  1. 61
      README.md
  2. 3
      src/asciidocParser.ts
  3. 255
      src/asciidocValidator.ts
  4. 294
      src/commands/commandHandlers.ts
  5. 108
      src/eventManager.ts
  6. 375
      src/main.ts
  7. 16
      src/metadataManager.ts
  8. 15
      src/nostr/eventBuilder.ts
  9. 319
      src/nostr/nkbip08Tags.ts
  10. 7
      src/nostr/relayClient.ts
  11. 4
      src/types.ts
  12. 15
      src/ui/metadataModal.ts
  13. 27
      src/utils/console.ts
  14. 22
      src/utils/errorHandling.ts
  15. 50
      src/utils/eventKind.ts
  16. 25
      src/utils/fileExtensions.ts

61
README.md

@ -10,6 +10,8 @@ An Obsidian plugin for creating, editing, and publishing Nostr document events d
- **Multiple event kinds**: Markdown (1, 11, 30023, 30817) and AsciiDoc (30040, 30041, 30818) - **Multiple event kinds**: Markdown (1, 11, 30023, 30817) and AsciiDoc (30040, 30041, 30818)
- **Automatic structure parsing**: AsciiDoc documents with headers are parsed into nested 30040/30041 event hierarchies - **Automatic structure parsing**: AsciiDoc documents with headers are parsed into nested 30040/30041 event hierarchies
- **NKBIP-08 support**: Hierarchical book wikilinks with optional collection tags for compendiums, digests, and libraries
- **Flexible structure**: Supports both two-level (book + chapters) and three-level (book + chapters + sections) hierarchies
- **Metadata in files**: Metadata stored directly in Markdown frontmatter or AsciiDoc header attributes - **Metadata in files**: Metadata stored directly in Markdown frontmatter or AsciiDoc header attributes
- **Two-step workflow**: Create/sign events separately from publishing to relays - **Two-step workflow**: Create/sign events separately from publishing to relays
- **Automatic relay management**: Fetch relay lists (kind 10002) with AUTH support - **Automatic relay management**: Fetch relay lists (kind 10002) with AUTH support
@ -111,8 +113,8 @@ Supported event kinds: **30040**, **30041**, **30818**
:author: Author Name :author: Author Name
:type: book :type: book
:summary: Book description :summary: Book description
:collection_id: my-collection :collection_id: bible
:version_tag: v1 :version_tag: kjv
== Chapter 1 == Chapter 1
@ -123,8 +125,42 @@ Chapter content here...
Section content... Section content...
``` ```
**Two-level structure** (book + chapters, no sections):
```asciidoc
= Book Title
:kind: 30040
:author: Author Name
:type: book
:summary: Book description
== Chapter 1
Chapter content here...
== Chapter 2
Chapter content here...
```
In two-level structures, chapters are created as 30041 events directly under the root 30040.
When publishing, metadata is automatically stripped from content before creating events. When publishing, metadata is automatically stripped from content before creating events.
### NKBIP-08 Tag Inheritance
For structured AsciiDoc documents (kind 30040), NKBIP-08 tags are automatically assigned based on the document hierarchy:
- **C tag (collection_id)**: Optional, set in root 30040 metadata. If set, inherited by all events in the hierarchy. Use for compendiums, digests, or libraries of related books (e.g., "bible", "goethe-complete-works", "encyclopedia-britannica").
- **T tag (title_id)**: Always set from root 30040 book title, inherited by all nested events.
- **c tag (chapter_id)**:
- Two-level structure: from 30041 chapter title (chapters are 30041 events)
- Three-level structure: from parent 30040 chapter title
- **s tag (section_id)**: Only in three-level structures, from 30041 section title
- **v tag (version_tag)**: If set in root 30040, inherited by all events in the hierarchy
All tag values are normalized per NKBIP-08 spec (lowercase, hyphens, numbers only).
## Event Kinds ## Event Kinds
| Kind | Format | Description | Title Required | | Kind | Format | Description | Title Required |
@ -140,7 +176,11 @@ When publishing, metadata is automatically stripped from content before creating
### Stand-alone vs Nested 30041 ### Stand-alone vs Nested 30041
- **Stand-alone 30041**: Uses NKBIP-01 tags (d, title, image, summary, published_at, topics) - **Stand-alone 30041**: Uses NKBIP-01 tags (d, title, image, summary, published_at, topics)
- **Nested 30041** (under 30040): Uses NKBIP-08 tags (inherits collection_id, version_tag from parent) - **Nested 30041** (under 30040): Uses NKBIP-08 tags
- **Two-level structure** (book + chapters): 30041 events are chapters (c tag from chapter title, no s tag)
- **Three-level structure** (book + chapters + sections): 30041 events are sections (c tag from parent chapter, s tag from section title)
- All nested 30041 events inherit C tag (collection_id) and v tag (version_tag) from root 30040
- All nested 30041 events get T tag (title_id) from root 30040 book title
## Metadata Fields ## Metadata Fields
@ -167,12 +207,19 @@ All predefined metadata fields are shown in frontmatter/attributes with placehol
- `published_by` - Publisher - `published_by` - Publisher
- `source` - Source URL - `source` - Source URL
- `auto_update` - Auto-update behavior (yes, ask, no) - `auto_update` - Auto-update behavior (yes, ask, no)
- `collection_id` - NKBIP-08 collection identifier - `collection_id` - NKBIP-08 collection identifier (C tag) - **Optional**: compendium, digest, or library of related books (e.g., "bible", "goethe-complete-works", "encyclopedia-britannica"). If set in root 30040, inherited by all events in the hierarchy.
- `version_tag` - NKBIP-08 version identifier - `version_tag` - NKBIP-08 version identifier (v tag) - If set in root 30040, inherited by all events in the hierarchy
**30041 (Publication Content)**: **30041 (Publication Content)**:
- Stand-alone: Same as 30023 (image, summary, published_at, topics) - **Stand-alone**: Same as 30023 (image, summary, published_at, topics)
- Nested: NKBIP-08 tags (collection_id, title_id, chapter_id, section_id, version_tag) - **Nested** (under 30040): NKBIP-08 tags
- `collection_id` - Inherited from root 30040 (C tag)
- `title_id` - From root 30040 book title (T tag)
- `chapter_id` - From chapter title (c tag)
- Two-level: from 30041's own title (it is the chapter)
- Three-level: from parent 30040's title
- `section_id` - From 30041's title (s tag) - Only in three-level structures
- `version_tag` - Inherited from root 30040 (v tag)
## Manual Installation ## Manual Installation

3
src/asciidocParser.ts

@ -26,8 +26,9 @@ export function isAsciiDocDocument(content: string): boolean {
/** /**
* Parse AsciiDoc line to extract header level and title * Parse AsciiDoc line to extract header level and title
* Exported for use in validator
*/ */
function parseHeaderLine(line: string): { level: number; title: string } | null { export function parseHeaderLine(line: string): { level: number; title: string } | null {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed.startsWith("=")) { if (!trimmed.startsWith("=")) {
return null; return null;

255
src/asciidocValidator.ts

@ -0,0 +1,255 @@
import { parseDocumentHeader, parseHeaderLine } from "./asciidocParser";
/**
* Validation result for AsciiDoc documents
*/
export interface AsciiDocValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
}
/**
* Header information extracted from document
*/
interface HeaderInfo {
lineNumber: number;
level: number;
title: string;
originalLine: string;
}
/**
* Validate AsciiDoc document structure
*
* Checks:
* - Document header (single =) exists and has title
* - At least one additional header exists
* - Headers have proper text (not empty)
* - Leaf headers (last in their branch) have content beneath them
* - Interim headers (with child headers) don't need content
* - Headers form intact branches (no skipped levels - this would create orphaned events)
*
* @param content - The AsciiDoc content to validate
* @returns Validation result with errors and warnings
*/
export function validateAsciiDocDocument(content: string): AsciiDocValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
// Early return for empty document
if (!content || content.trim().length === 0) {
errors.push("Document is empty");
return { valid: false, errors, warnings };
}
// Validate document header
const documentHeader = validateDocumentHeader(content, errors);
if (!documentHeader) {
return { valid: false, errors, warnings };
}
// Parse section headers
const lines = content.split("\n");
const headerLines = parseSectionHeaders(lines);
// Validate section headers exist
if (!validateSectionHeadersExist(headerLines, errors)) {
return { valid: false, errors, warnings };
}
// Validate header text
validateHeaderText(headerLines, errors);
// Validate header hierarchy (no skipped levels)
validateHeaderHierarchy(headerLines, errors);
// Validate leaf headers have content
validateLeafHeaderContent(headerLines, lines, errors);
return {
valid: errors.length === 0,
errors,
warnings,
};
}
/**
* Validate document header exists and has a title
*/
function validateDocumentHeader(
content: string,
errors: string[]
): { title: string; remaining: string } | null {
const documentHeader = parseDocumentHeader(content);
if (!documentHeader) {
errors.push("Document must start with a document header (single = followed by title)");
return null;
}
if (!documentHeader.title || documentHeader.title.trim().length === 0) {
errors.push("Document header must have a title");
return null;
}
return documentHeader;
}
/**
* Parse all section headers from document lines (excluding document header)
*/
function parseSectionHeaders(lines: string[]): HeaderInfo[] {
const headerLines: HeaderInfo[] = [];
// Start from line 2 (index 1) to skip document header
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
const headerInfo = parseHeaderLine(line);
if (headerInfo) {
headerLines.push({
lineNumber: i + 1, // 1-indexed for user display
level: headerInfo.level,
title: headerInfo.title,
originalLine: line.trim(),
});
}
}
return headerLines;
}
/**
* Validate that at least one section header exists
*/
function validateSectionHeadersExist(headerLines: HeaderInfo[], errors: string[]): boolean {
if (headerLines.length === 0) {
errors.push("Document must have at least one section header (==, ===, etc.) after the document title");
return false;
}
return true;
}
/**
* Validate that all headers have text
*/
function validateHeaderText(headerLines: HeaderInfo[], errors: string[]): void {
for (const header of headerLines) {
if (!header.title || header.title.trim().length === 0) {
errors.push(`Header on line ${header.lineNumber} has no title text: "${header.originalLine}"`);
}
}
}
/**
* Validate header hierarchy - no skipped levels
*/
function validateHeaderHierarchy(headerLines: HeaderInfo[], errors: string[]): void {
let previousLevel = 0;
for (const header of headerLines) {
// Only check for skipped levels when going deeper (not when going back up)
if (header.level > previousLevel + 1) {
errors.push(
`Header on line ${header.lineNumber} ("${header.title}") skips a level (from level ${previousLevel} to ${header.level}). ` +
`This would create orphaned chapter-events. Headers must form intact branches.`
);
}
previousLevel = header.level;
}
}
/**
* Identify leaf headers (headers that are last in their branch)
* A header is a leaf if there are no child headers (deeper level) after it
* before the next header at the same or higher level
*/
function identifyLeafHeaders(headerLines: HeaderInfo[]): Set<number> {
const leafHeaders = new Set<number>();
for (let i = 0; i < headerLines.length; i++) {
const currentHeader = headerLines[i];
const hasChildren = hasChildHeaders(headerLines, i);
if (!hasChildren) {
leafHeaders.add(currentHeader.lineNumber);
}
}
return leafHeaders;
}
/**
* Check if a header has child headers after it
*/
function hasChildHeaders(headerLines: HeaderInfo[], currentIndex: number): boolean {
const currentHeader = headerLines[currentIndex];
// Look ahead to find the next header at same or higher level
for (let j = currentIndex + 1; j < headerLines.length; j++) {
const nextHeader = headerLines[j];
if (nextHeader.level <= currentHeader.level) {
// Found a header at same or higher level - end of this branch
// If we haven't found any children, this is a leaf
return false;
}
if (nextHeader.level > currentHeader.level) {
// Found a child header - this header is not a leaf
return true;
}
}
// No more headers - this is a leaf
return false;
}
/**
* Validate that leaf headers have content beneath them
*/
function validateLeafHeaderContent(
headerLines: HeaderInfo[],
lines: string[],
errors: string[]
): void {
const leafHeaders = identifyLeafHeaders(headerLines);
for (const header of headerLines) {
if (leafHeaders.has(header.lineNumber)) {
if (!hasContentAfterHeader(header, lines)) {
errors.push(
`Leaf header on line ${header.lineNumber} ("${header.title}") must have content beneath it`
);
}
}
}
}
/**
* Check if there is content after a header (before the next header at same or higher level)
*/
function hasContentAfterHeader(header: HeaderInfo, lines: string[]): boolean {
const headerIndex = header.lineNumber - 1; // Convert to 0-indexed
// Look for content between this header and the next header at same or higher level
for (let i = headerIndex + 1; i < lines.length; i++) {
const headerInfo = parseHeaderLine(lines[i]);
if (headerInfo) {
// Found a header - if it's at same or higher level, we've reached the end of this branch
if (headerInfo.level <= header.level) {
break;
}
// If it's deeper, continue (shouldn't happen for a leaf, but handle gracefully)
continue;
}
// Check for non-empty content (not attribute lines)
const line = lines[i].trim();
if (line.length > 0 && !line.startsWith(":")) {
return true;
}
}
return false;
}

294
src/commands/commandHandlers.ts

@ -0,0 +1,294 @@
import { TFile, TFolder, App, Notice } from "obsidian";
import { EventKind, EventMetadata, ScriptoriumSettings } from "../types";
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 { validateAsciiDocDocument } from "../asciidocValidator";
import { verifyEventSecurity } from "../utils/security";
import { showErrorNotice } from "../utils/errorHandling";
import { log, logError } from "../utils/console";
import { determineEventKind, getFolderNameForKind } from "../utils/eventKind";
import { StructurePreviewModal } from "../ui/structurePreviewModal";
import { MetadataReminderModal } from "../ui/metadataReminderModal";
import { MetadataModal } from "../ui/metadataModal";
import { isAsciiDocFile } from "../utils/fileExtensions";
/**
* Get the current active file
*/
export async function getCurrentFile(app: App): Promise<TFile | null> {
const activeFile = app.workspace.getActiveFile();
if (!activeFile) {
new Notice("No active file");
return null;
}
return activeFile;
}
/**
* Ensure the Nostr notes folder structure exists
*/
export async function ensureNostrNotesFolder(
app: App,
kind: EventKind
): Promise<string> {
const baseFolder = "Nostr notes";
const kindFolder = getFolderNameForKind(kind);
const fullPath = `${baseFolder}/${kindFolder}`;
// Check if base folder exists
const baseFolderObj = app.vault.getAbstractFileByPath(baseFolder);
if (!baseFolderObj || !(baseFolderObj instanceof TFolder)) {
await app.vault.createFolder(baseFolder);
}
// Check if kind folder exists
const kindFolderObj = app.vault.getAbstractFileByPath(fullPath);
if (!kindFolderObj || !(kindFolderObj instanceof TFolder)) {
await app.vault.createFolder(fullPath);
}
return fullPath;
}
/**
* Handle creating Nostr events from current file
*/
export async function handleCreateEvents(
app: App,
file: TFile,
settings: ScriptoriumSettings
): Promise<void> {
if (!settings.privateKey) {
new Notice("Please set your private key in settings");
return;
}
try {
const content = await app.vault.read(file);
let metadata = await readMetadata(file, app);
// Determine event kind from file extension or metadata
const eventKind = determineEventKind(
file,
content,
settings.defaultEventKind,
metadata?.kind
);
// Ensure folder structure exists before creating events
await ensureNostrNotesFolder(app, eventKind);
// Create default metadata if none exists and write it with placeholders
if (!metadata) {
metadata = createDefaultMetadata(eventKind);
await writeMetadata(file, metadata, app);
// Re-read to get the formatted version with placeholders
metadata = await readMetadata(file, app) || metadata;
}
// Merge with header title for 30040
if (eventKind === 30040 && isAsciiDocDocument(content)) {
const headerTitle = content.split("\n")[0]?.replace(/^=+\s*/, "").trim() || "";
metadata = mergeWithHeaderTitle(metadata, headerTitle);
}
// Show reminder modal before proceeding
new MetadataReminderModal(app, eventKind, async () => {
// Re-read metadata after user confirms (they may have updated it)
const updatedContent = await app.vault.read(file);
let updatedMetadata: EventMetadata = await readMetadata(file, app) || metadata || createDefaultMetadata(eventKind);
// Ensure we have valid metadata
if (!updatedMetadata) {
updatedMetadata = createDefaultMetadata(eventKind);
}
// Merge with header title for 30040
if (eventKind === 30040 && isAsciiDocDocument(updatedContent)) {
const headerTitle = updatedContent.split("\n")[0]?.replace(/^=+\s*/, "").trim() || "";
updatedMetadata = mergeWithHeaderTitle(updatedMetadata, headerTitle);
}
// Validate metadata
const validation = validateMetadata(updatedMetadata, eventKind);
if (!validation.valid) {
new Notice(`Metadata validation failed: ${validation.errors.join(", ")}`);
return;
}
// Validate AsciiDoc structure if this is a structured AsciiDoc document
if (isAsciiDocFile(file) && eventKind === 30040 && isAsciiDocDocument(updatedContent)) {
const asciiDocValidation = validateAsciiDocDocument(updatedContent);
if (!asciiDocValidation.valid) {
const errorMsg = `AsciiDoc validation failed:\n${asciiDocValidation.errors.join("\n")}`;
if (asciiDocValidation.warnings.length > 0) {
new Notice(`${errorMsg}\n\nWarnings:\n${asciiDocValidation.warnings.join("\n")}`);
} else {
new Notice(errorMsg);
}
return;
}
if (asciiDocValidation.warnings.length > 0) {
log(`AsciiDoc validation warnings: ${asciiDocValidation.warnings.join("; ")}`);
}
}
// Build events
if (!settings.privateKey) {
new Notice("Please set your private key in settings");
return;
}
const result = await buildEvents(file, updatedContent, updatedMetadata, settings.privateKey, 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.");
logError("Event security check failed - event may contain private key");
return;
}
}
// Show preview for structured documents
if (result.structure.length > 0) {
new StructurePreviewModal(app, result.structure, async () => {
await saveEvents(file, result.events, app);
new Notice(`Created ${result.events.length} event(s) and saved to ${file.basename}_events.jsonl`);
}).open();
} else {
await saveEvents(file, result.events, app);
new Notice(`Created ${result.events.length} event(s) and saved to ${file.basename}_events.jsonl`);
}
}).open();
} catch (error: any) {
showErrorNotice("Error creating events", error);
logError("Error creating events", error);
}
}
/**
* Handle previewing document structure
*/
export async function handlePreviewStructure(
app: App,
file: TFile
): Promise<void> {
try {
const content = await app.vault.read(file);
if (!isAsciiDocDocument(content)) {
new Notice("This file is not an AsciiDoc document with structure");
return;
}
let metadata = await readMetadata(file, 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(app, structure, () => {}).open();
} catch (error: any) {
showErrorNotice("Error previewing structure", error);
logError("Error previewing structure", error);
}
}
/**
* Handle publishing events to relays
*/
export async function handlePublishEvents(
app: App,
file: TFile,
settings: ScriptoriumSettings
): Promise<void> {
if (!settings.privateKey) {
new Notice("Please set your private key in settings");
return;
}
const exists = await eventsFileExists(file, app);
if (!exists) {
new Notice("No events file found. Please create events first.");
return;
}
try {
const events = await loadEvents(file, app);
if (events.length === 0) {
new Notice("No events to publish");
return;
}
const writeRelays = getWriteRelays(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, 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) {
showErrorNotice("Error publishing events", error);
logError("Error publishing events", error);
}
}
/**
* Handle editing metadata
*/
export async function handleEditMetadata(
app: App,
file: TFile,
defaultEventKind: EventKind
): Promise<void> {
try {
let metadata = await readMetadata(file, app);
if (!metadata) {
// Determine kind from file extension
const content = await app.vault.read(file);
const eventKind = determineEventKind(file, content, defaultEventKind);
metadata = createDefaultMetadata(eventKind);
}
new MetadataModal(app, metadata, async (updatedMetadata) => {
await writeMetadata(file, updatedMetadata, app);
new Notice("Metadata saved");
}).open();
} catch (error: any) {
showErrorNotice("Error editing metadata", error);
logError("Error editing metadata", error);
}
}

108
src/eventManager.ts

@ -11,11 +11,19 @@ import {
import { import {
createSignedEvent, createSignedEvent,
buildTagsFromMetadata, buildTagsFromMetadata,
normalizeDTag,
getPubkeyFromPrivkey, getPubkeyFromPrivkey,
} from "./nostr/eventBuilder"; } from "./nostr/eventBuilder";
import { parseAsciiDocStructure } from "./asciidocParser"; import { parseAsciiDocStructure, isAsciiDocDocument } from "./asciidocParser";
import { readMetadata, mergeWithHeaderTitle, stripMetadataFromContent } from "./metadataManager"; import { readMetadata, mergeWithHeaderTitle, stripMetadataFromContent } from "./metadataManager";
import { isAsciiDocFile } from "./utils/fileExtensions";
import {
buildNKBIP08TagsFor30041,
applyNKBIP08TagsTo30041,
mergeNKBIP08TagsFor30040,
applyNKBIP08TagsTo30040,
addNKBIP08TagsTo30040,
NKBIP08_TAGS,
} from "./nostr/nkbip08Tags";
/** /**
* Build events from a simple document (non-AsciiDoc) * Build events from a simple document (non-AsciiDoc)
@ -66,21 +74,52 @@ export async function buildAsciiDocEvents(
const rootNode = header[0]; const rootNode = header[0];
const structure: StructureNode[] = [rootNode]; const structure: StructureNode[] = [rootNode];
// Track the root book title for T tag inheritance
const rootBookTitle = rootNode.title;
// Recursively build events from structure // Recursively build events from structure
async function buildEventsFromNode(node: StructureNode, parentMetadata?: Kind30040Metadata): Promise<void> { async function buildEventsFromNode(
node: StructureNode,
parentMetadata?: Kind30040Metadata,
bookTitle?: string,
isParentRoot: boolean = false,
rootMetadata?: Kind30040Metadata
): Promise<void> {
// Determine book title: use root if this is root, otherwise inherit from parent
const currentBookTitle = bookTitle || rootBookTitle;
// Track root metadata for collection_id inheritance
const currentRootMetadata = rootMetadata || (metadata as Kind30040Metadata);
if (node.kind === 30041) { if (node.kind === 30041) {
// Content event - nested under 30040, so use NKBIP-08 tags // Content event - nested under 30040, so use NKBIP-08 tags
const contentMetadata: Kind30041Metadata = { if (!parentMetadata) {
errors.push("30041 event must have a parent 30040 metadata");
return;
}
// Build base 30041 metadata
const baseMetadata: Kind30041Metadata = {
kind: 30041, kind: 30041,
title: node.title, title: node.title,
// Inherit NKBIP-08 tags from parent 30040
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,
}; };
// Determine if this 30041 is directly under root (making it a chapter) or under a chapter (making it a section)
// If parent is root, this 30041 is a chapter (not a section)
const isChapter = isParentRoot;
// Build and apply NKBIP-08 tags from parent
// If isChapter: c tag uses 30041's own title, no s tag
// If not isChapter: c tag uses parent chapter title, s tag uses section title
const nkbip08Tags = buildNKBIP08TagsFor30041(
parentMetadata,
currentRootMetadata, // Root metadata for collection_id inheritance
currentBookTitle, // T tag: book title
isChapter ? node.title : parentMetadata.title, // c tag: chapter title (30041's title if chapter, parent's if section)
node.title, // s tag: section title (this 30041, but only used if not isChapter)
isChapter // Flag: true if this is a chapter (direct child of root)
);
const contentMetadata = applyNKBIP08TagsTo30041(baseMetadata, nkbip08Tags);
const tags = buildTagsFromMetadata(contentMetadata, pubkey); const tags = buildTagsFromMetadata(contentMetadata, pubkey);
const event = createSignedEvent(30041, node.content || "", tags, privkey); const event = createSignedEvent(30041, node.content || "", tags, privkey);
events.push(event); events.push(event);
@ -90,15 +129,16 @@ export async function buildAsciiDocEvents(
const childEvents: Array<{ kind: number; dTag: string; eventId?: string }> = []; const childEvents: Array<{ kind: number; dTag: string; eventId?: string }> = [];
// Merge parent metadata with node metadata for nested 30040 events // Merge parent metadata with node metadata for nested 30040 events
// Inherit NKBIP-08 tags from parent if this is a nested 30040
const baseMetadata = node.metadata as Kind30040Metadata; const baseMetadata = node.metadata as Kind30040Metadata;
// Merge NKBIP-08 tags (inherits collection_id from root, version_tag from parent if present, otherwise uses own)
const mergedNKBIP08Tags = mergeNKBIP08TagsFor30040(parentMetadata, baseMetadata, currentRootMetadata);
// Build merged metadata with inherited NKBIP-08 tags
const mergedMetadata: Kind30040Metadata = { const mergedMetadata: Kind30040Metadata = {
...baseMetadata, ...baseMetadata,
kind: 30040, kind: 30040,
title: node.title, title: node.title,
// Inherit NKBIP-08 tags from parent 30040 if present
collection_id: parentMetadata?.collection_id || baseMetadata.collection_id,
version_tag: parentMetadata?.version_tag || baseMetadata.version_tag,
// Inherit other 30040 tags from parent // Inherit other 30040 tags from parent
author: parentMetadata?.author || baseMetadata.author, author: parentMetadata?.author || baseMetadata.author,
type: parentMetadata?.type || baseMetadata.type, type: parentMetadata?.type || baseMetadata.type,
@ -111,9 +151,17 @@ export async function buildAsciiDocEvents(
auto_update: parentMetadata?.auto_update || baseMetadata.auto_update, auto_update: parentMetadata?.auto_update || baseMetadata.auto_update,
}; };
// Build all children first, passing merged metadata as parent // Apply merged NKBIP-08 tags
const finalMetadata = applyNKBIP08TagsTo30040(mergedMetadata, mergedNKBIP08Tags);
// Determine if this is a book (root) or chapter (has parent)
const isBook = !parentMetadata;
const isChapter = !!parentMetadata;
const isRoot = !parentMetadata; // This node is the root
// Build all children first, passing final metadata as parent and book title
for (const child of node.children) { for (const child of node.children) {
await buildEventsFromNode(child, mergedMetadata); await buildEventsFromNode(child, finalMetadata, currentBookTitle, isRoot, currentRootMetadata);
// Find the event we just created for this child // Find the event we just created for this child
const childEvent = events.find((e) => { const childEvent = events.find((e) => {
@ -131,15 +179,30 @@ export async function buildAsciiDocEvents(
} }
// Now build this index event with references to children // Now build this index event with references to children
const tags = buildTagsFromMetadata(mergedMetadata, pubkey, childEvents); // We need to manually add NKBIP-08 tags with proper book/chapter flags
const event = createSignedEvent(30040, "", tags, privkey); const tags = buildTagsFromMetadata(finalMetadata, pubkey, childEvents);
// Override NKBIP-08 tags with proper book/chapter identification
// Remove any existing NKBIP-08 tags first
const filteredTags = tags.filter(t =>
t[0] !== NKBIP08_TAGS.COLLECTION &&
t[0] !== NKBIP08_TAGS.TITLE &&
t[0] !== NKBIP08_TAGS.CHAPTER &&
t[0] !== NKBIP08_TAGS.VERSION
);
// Add NKBIP-08 tags with proper flags
// Chapters inherit T tag from book
addNKBIP08TagsTo30040(filteredTags, finalMetadata, isBook, isChapter, currentBookTitle, currentRootMetadata);
const event = createSignedEvent(30040, "", filteredTags, privkey);
events.push(event); events.push(event);
node.metadata = mergedMetadata; node.metadata = finalMetadata;
} }
} }
// Build events starting from root // Build events starting from root (no parent, book title is root title, isParentRoot=false for root itself)
await buildEventsFromNode(rootNode, metadata as Kind30040Metadata); await buildEventsFromNode(rootNode, metadata as Kind30040Metadata, rootBookTitle, false, metadata as Kind30040Metadata);
// Sort events: indexes first, then content (for proper dependency order) // Sort events: indexes first, then content (for proper dependency order)
events.sort((a, b) => { events.sort((a, b) => {
@ -162,8 +225,7 @@ export async function buildEvents(
app: any app: any
): Promise<EventCreationResult> { ): Promise<EventCreationResult> {
// Check if this is an AsciiDoc document with structure // Check if this is an AsciiDoc document with structure
const isAsciiDoc = file.extension === "adoc" || file.extension === "asciidoc"; const hasStructure = isAsciiDocFile(file) && isAsciiDocDocument(content);
const hasStructure = isAsciiDoc && content.trim().startsWith("=") && !content.trim().startsWith("==");
if (hasStructure && (metadata.kind === 30040 || metadata.kind === 30041)) { if (hasStructure && (metadata.kind === 30040 || metadata.kind === 30041)) {
// Parse header title and merge with metadata // Parse header title and merge with metadata

375
src/main.ts

@ -1,26 +1,26 @@
import { Plugin, TFile, TFolder, Notice } from "obsidian"; import { Plugin, TFile, Notice } from "obsidian";
import { ScriptoriumSettings, EventKind, EventMetadata, DEFAULT_SETTINGS } from "./types"; import { ScriptoriumSettings, EventKind, DEFAULT_SETTINGS } from "./types";
import { ScriptoriumSettingTab } from "./ui/settingsTab"; import { ScriptoriumSettingTab } from "./ui/settingsTab";
import { MetadataModal } from "./ui/metadataModal";
import { StructurePreviewModal } from "./ui/structurePreviewModal";
import { NewDocumentModal } from "./ui/newDocumentModal"; import { NewDocumentModal } from "./ui/newDocumentModal";
import { MetadataReminderModal } from "./ui/metadataReminderModal"; import { writeMetadata, createDefaultMetadata } from "./metadataManager";
import { readMetadata, writeMetadata, createDefaultMetadata, validateMetadata, mergeWithHeaderTitle } from "./metadataManager"; import { safeConsoleError } from "./utils/security";
import { buildEvents } from "./eventManager"; import { showErrorNotice } from "./utils/errorHandling";
import { saveEvents, loadEvents, eventsFileExists } from "./eventStorage"; import { log, logError } from "./utils/console";
import { publishEventsWithRetry } from "./nostr/relayClient"; import { getFolderNameForKind } from "./utils/eventKind";
import { getWriteRelays } from "./relayManager"; import {
import { parseAsciiDocStructure, isAsciiDocDocument } from "./asciidocParser"; getCurrentFile,
import { normalizeSecretKey, getPubkeyFromPrivkey } from "./nostr/eventBuilder"; ensureNostrNotesFolder,
import { safeConsoleError, safeConsoleLog, verifyEventSecurity } from "./utils/security"; handleCreateEvents,
handlePreviewStructure,
handlePublishEvents,
handleEditMetadata,
} from "./commands/commandHandlers";
export default class ScriptoriumPlugin extends Plugin { export default class ScriptoriumPlugin extends Plugin {
settings!: ScriptoriumSettings; settings!: ScriptoriumSettings;
async onload() { async onload() {
// Log to terminal console that started Obsidian log("Plugin loading...");
console.error("[Scriptorium] Plugin loading...");
process.stderr.write("[Scriptorium] Plugin loading...\n");
try { try {
await this.loadSettings(); await this.loadSettings();
@ -28,10 +28,8 @@ export default class ScriptoriumPlugin extends Plugin {
// Note: We don't register file extensions for .adoc or .asciidoc files // Note: We don't register file extensions for .adoc or .asciidoc files
// Users should install the obsidian-asciidoc plugin for .adoc file support // Users should install the obsidian-asciidoc plugin for .adoc file support
console.error("[Scriptorium] Plugin loaded - file extensions not registered"); log("Plugin loaded - file extensions not registered");
process.stderr.write("[Scriptorium] Plugin loaded - file extensions not registered\n"); log("Install obsidian-asciidoc plugin for .adoc file editing support");
console.error("[Scriptorium] Install obsidian-asciidoc plugin for .adoc file editing support");
process.stderr.write("[Scriptorium] Install obsidian-asciidoc plugin for .adoc file editing support\n");
// Add settings tab // Add settings tab
this.addSettingTab(new ScriptoriumSettingTab(this.app, this)); this.addSettingTab(new ScriptoriumSettingTab(this.app, this));
@ -75,17 +73,9 @@ export default class ScriptoriumPlugin extends Plugin {
// Status bar // Status bar
this.addStatusBarItem().setText("Scriptorium"); this.addStatusBarItem().setText("Scriptorium");
console.error("[Scriptorium] Plugin loaded successfully"); log("Plugin loaded successfully");
process.stderr.write("[Scriptorium] Plugin loaded successfully\n");
} catch (error: any) { } catch (error: any) {
const errorMsg = error?.message || String(error); logError("Error loading plugin", error);
const stackTrace = error?.stack || "";
console.error(`[Scriptorium] Error loading plugin: ${errorMsg}`);
process.stderr.write(`[Scriptorium] Error loading plugin: ${errorMsg}\n`);
if (stackTrace) {
console.error(`[Scriptorium] Stack trace:`, stackTrace);
process.stderr.write(`[Scriptorium] Stack trace: ${stackTrace}\n`);
}
safeConsoleError("Error loading plugin:", error); safeConsoleError("Error loading plugin:", error);
} }
} }
@ -119,290 +109,38 @@ export default class ScriptoriumPlugin extends Plugin {
return false; 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);
}
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() { private async handleCreateEvents() {
const file = await this.getCurrentFile(); const file = await getCurrentFile(this.app);
if (!file) return; if (!file) return;
await handleCreateEvents(this.app, file, this.settings);
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 and write it with placeholders
if (!metadata) {
metadata = createDefaultMetadata(eventKind);
await writeMetadata(file, metadata, this.app);
// Re-read to get the formatted version with placeholders
metadata = await readMetadata(file, this.app) || metadata;
}
// Merge with header title for 30040
if (eventKind === 30040 && isAsciiDocDocument(content)) {
const headerTitle = content.split("\n")[0]?.replace(/^=+\s*/, "").trim() || "";
metadata = mergeWithHeaderTitle(metadata, headerTitle);
}
// Show reminder modal before proceeding
new MetadataReminderModal(this.app, eventKind, async () => {
// Re-read metadata after user confirms (they may have updated it)
const updatedContent = await this.app.vault.read(file);
let updatedMetadata: EventMetadata = await readMetadata(file, this.app) || metadata || createDefaultMetadata(eventKind);
// Ensure we have valid metadata
if (!updatedMetadata) {
updatedMetadata = createDefaultMetadata(eventKind);
}
// Merge with header title for 30040
if (eventKind === 30040 && isAsciiDocDocument(updatedContent)) {
const headerTitle = updatedContent.split("\n")[0]?.replace(/^=+\s*/, "").trim() || "";
updatedMetadata = mergeWithHeaderTitle(updatedMetadata, headerTitle);
}
// Validate metadata
const validation = validateMetadata(updatedMetadata, eventKind);
if (!validation.valid) {
new Notice(`Metadata validation failed: ${validation.errors.join(", ")}`);
return;
}
// Build events
if (!this.settings.privateKey) {
new Notice("Please set your private key in settings");
return;
}
const result = await buildEvents(file, updatedContent, updatedMetadata, 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`);
}
}).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 creating events: ${safeMessage}`);
safeConsoleError("Error creating events:", error);
}
} }
private async handlePreviewStructure() { private async handlePreviewStructure() {
const file = await this.getCurrentFile(); const file = await getCurrentFile(this.app);
if (!file) return; if (!file) return;
await handlePreviewStructure(this.app, file);
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() { private async handlePublishEvents() {
const file = await this.getCurrentFile(); const file = await getCurrentFile(this.app);
if (!file) return; if (!file) return;
await handlePublishEvents(this.app, file, this.settings);
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() { private async handleEditMetadata() {
const file = await this.getCurrentFile(); const file = await getCurrentFile(this.app);
if (!file) return; if (!file) return;
await handleEditMetadata(this.app, file, this.settings.defaultEventKind);
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() { private async handleNewDocument() {
new NewDocumentModal(this.app, async (kind: EventKind, title: string) => { new NewDocumentModal(this.app, async (kind: EventKind, title: string) => {
try { try {
console.error(`[Scriptorium] Creating new document: kind=${kind}, title=${title}`); log(`Creating new document: kind=${kind}, title=${title}`);
process.stderr.write(`[Scriptorium] Creating new document: kind=${kind}, title=${title}\n`);
// Ensure folder structure exists // Ensure folder structure exists
const folderPath = await this.ensureNostrNotesFolder(kind); const folderPath = await ensureNostrNotesFolder(this.app, kind);
// Sanitize filename from title // Sanitize filename from title
const sanitizedTitle = this.sanitizeFilename(title); const sanitizedTitle = this.sanitizeFilename(title);
@ -448,33 +186,22 @@ export default class ScriptoriumPlugin extends Plugin {
let file: TFile; let file: TFile;
try { try {
console.error(`[Scriptorium] Creating file: ${filePath}`); log(`Creating file: ${filePath}`);
process.stderr.write(`[Scriptorium] Creating file: ${filePath}\n`);
file = await this.app.vault.create(filePath, content); file = await this.app.vault.create(filePath, content);
console.error(`[Scriptorium] File created successfully: ${file.path}`); log(`File created successfully: ${file.path}`);
process.stderr.write(`[Scriptorium] File created successfully: ${file.path}\n`);
// Verify file was actually created // Verify file was actually created
const verifyFile = this.app.vault.getAbstractFileByPath(filePath); const verifyFile = this.app.vault.getAbstractFileByPath(filePath);
if (!verifyFile || !(verifyFile instanceof TFile)) { if (!verifyFile || !(verifyFile instanceof TFile)) {
const msg = `Error: File ${filename} was not created properly`; const msg = `Error: File ${filename} was not created properly`;
console.error(`[Scriptorium] ${msg}`); log(msg);
process.stderr.write(`[Scriptorium] ${msg}\n`);
new Notice(msg); new Notice(msg);
safeConsoleError(`File creation verification failed for ${filePath}`); logError("File creation verification failed", { filePath });
return; return;
} }
} catch (error: any) { } 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"; logError("Error creating file", error);
console.error(`[Scriptorium] Error creating file: ${safeMessage}`); showErrorNotice("Error creating file", error);
process.stderr.write(`[Scriptorium] Error creating file: ${safeMessage}\n`);
if (error?.stack) {
console.error(`[Scriptorium] Stack trace:`, error.stack);
process.stderr.write(`[Scriptorium] Stack trace: ${error.stack}\n`);
}
new Notice(`Error creating file: ${safeMessage}`);
safeConsoleError("Error creating file:", error);
safeConsoleError("File path was:", filePath);
return; return;
} }
@ -489,16 +216,14 @@ export default class ScriptoriumPlugin extends Plugin {
// Write metadata with all placeholders (title will be included if set) // Write metadata with all placeholders (title will be included if set)
await writeMetadata(file, metadata, this.app); await writeMetadata(file, metadata, this.app);
} catch (error: any) { } 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"; showErrorNotice("Error creating metadata", error);
new Notice(`Error creating metadata: ${safeMessage}`);
safeConsoleError("Error creating metadata:", error); safeConsoleError("Error creating metadata:", error);
// Continue anyway - file was created // Continue anyway - file was created
} }
// For .adoc files, ensure file is visible in explorer but don't auto-open // For .adoc files, ensure file is visible in explorer but don't auto-open
if (file.extension === "adoc" || file.extension === "asciidoc") { if (file.extension === "adoc" || file.extension === "asciidoc") {
console.error(`[Scriptorium] AsciiDoc file created: ${file.path}`); log(`AsciiDoc file created: ${file.path}`);
process.stderr.write(`[Scriptorium] AsciiDoc file created: ${file.path}\n`);
// File should be visible in Obsidian's file explorer automatically // File should be visible in Obsidian's file explorer automatically
// since we used vault.create(). The file explorer will refresh automatically. // since we used vault.create(). The file explorer will refresh automatically.
@ -508,35 +233,24 @@ export default class ScriptoriumPlugin extends Plugin {
} else { } else {
// Open the new file in Obsidian workspace (use active leaf or create new) // Open the new file in Obsidian workspace (use active leaf or create new)
// Use a small delay to ensure file is fully created before opening // Use a small delay to ensure file is fully created before opening
console.error(`[Scriptorium] Waiting before opening file...`); log("Waiting before opening file...");
process.stderr.write(`[Scriptorium] Waiting before opening file...\n`);
await new Promise(resolve => setTimeout(resolve, 200)); await new Promise(resolve => setTimeout(resolve, 200));
try { try {
console.error(`[Scriptorium] Attempting to open file: ${file.path} (extension: ${file.extension})`); log(`Attempting to open file: ${file.path} (extension: ${file.extension})`);
process.stderr.write(`[Scriptorium] Attempting to open file: ${file.path} (extension: ${file.extension})\n`);
const leaf = this.app.workspace.getMostRecentLeaf(); const leaf = this.app.workspace.getMostRecentLeaf();
if (leaf && leaf.view) { if (leaf && leaf.view) {
await leaf.openFile(file, { active: true }); await leaf.openFile(file, { active: true });
console.error(`[Scriptorium] File opened successfully in existing leaf`); log("File opened successfully in existing leaf");
process.stderr.write(`[Scriptorium] File opened successfully in existing leaf\n`);
} else { } else {
// Fallback: open in new leaf // Fallback: open in new leaf
const newLeaf = this.app.workspace.getLeaf("tab"); const newLeaf = this.app.workspace.getLeaf("tab");
await newLeaf.openFile(file, { active: true }); await newLeaf.openFile(file, { active: true });
console.error(`[Scriptorium] File opened successfully in new leaf`); log("File opened successfully in new leaf");
process.stderr.write(`[Scriptorium] File opened successfully in new leaf\n`);
} }
} catch (error: any) { } catch (error: any) {
const errorMsg = error?.message || String(error); logError("Error opening file", error);
const stackTrace = error?.stack || "";
console.error(`[Scriptorium] Error opening file: ${errorMsg}`);
process.stderr.write(`[Scriptorium] Error opening file: ${errorMsg}\n`);
if (stackTrace) {
console.error(`[Scriptorium] Stack trace:`, stackTrace);
process.stderr.write(`[Scriptorium] Stack trace: ${stackTrace}\n`);
}
safeConsoleError("Error opening file:", error); safeConsoleError("Error opening file:", error);
// File was created, just couldn't open it - show a notice // File was created, just couldn't open it - show a notice
new Notice(`File created but couldn't open: ${file.name}`); new Notice(`File created but couldn't open: ${file.name}`);
@ -545,8 +259,7 @@ export default class ScriptoriumPlugin extends Plugin {
new Notice(`Created ${filename} in ${folderPath}`); new Notice(`Created ${filename} in ${folderPath}`);
} catch (error: any) { } 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"; showErrorNotice("Error creating document", error);
new Notice(`Error creating document: ${safeMessage}`);
safeConsoleError("Error creating document:", error); safeConsoleError("Error creating document:", error);
} }
}).open(); }).open();

16
src/metadataManager.ts

@ -1,6 +1,7 @@
import { TFile } from "obsidian"; import { TFile } from "obsidian";
import { EventKind, EventMetadata } from "./types"; import { EventKind, EventMetadata } from "./types";
import { safeConsoleError } from "./utils/security"; import { safeConsoleError } from "./utils/security";
import { isMarkdownFile, isAsciiDocFile } from "./utils/fileExtensions";
/** /**
* Tag definitions with descriptions for each event kind * Tag definitions with descriptions for each event kind
@ -44,7 +45,7 @@ const TAG_DEFINITIONS: Record<EventKind, TagDefinition[]> = {
{ key: "image", description: "Cover image URL", required: false }, { key: "image", description: "Cover image URL", required: false },
{ key: "auto_update", description: "Auto-update: yes, ask, or no", required: false }, { key: "auto_update", description: "Auto-update: yes, ask, or no", required: false },
{ key: "topics", description: "Comma-separated topics (e.g., 'bitcoin, nostr')", required: false }, { key: "topics", description: "Comma-separated topics (e.g., 'bitcoin, nostr')", required: false },
{ key: "collection_id", description: "NKBIP-08 collection identifier (C tag)", required: false }, { key: "collection_id", description: "NKBIP-08 collection identifier (C tag) - Optional: compendium, digest, or library of related books (e.g., 'bible', 'goethe-complete-works', 'encyclopedia-britannica')", required: false },
{ key: "version_tag", description: "NKBIP-08 version identifier (e.g., kjv, drb)", required: false }, { key: "version_tag", description: "NKBIP-08 version identifier (e.g., kjv, drb)", required: false },
], ],
30041: [ 30041: [
@ -55,6 +56,7 @@ const TAG_DEFINITIONS: Record<EventKind, TagDefinition[]> = {
{ key: "topics", description: "Comma-separated topics (e.g., 'bitcoin, nostr')", required: false }, { key: "topics", description: "Comma-separated topics (e.g., 'bitcoin, nostr')", required: false },
// Note: NKBIP-08 tags (collection_id, title_id, chapter_id, section_id, version_tag) // Note: NKBIP-08 tags (collection_id, title_id, chapter_id, section_id, version_tag)
// are only used when 30041 is nested under 30040, not for stand-alone 30041 events // are only used when 30041 is nested under 30040, not for stand-alone 30041 events
// collection_id is inherited from root 30040 if present
], ],
30817: [ 30817: [
{ key: "title", description: "Wiki page title (required)", required: true }, { key: "title", description: "Wiki page title (required)", required: true },
@ -269,7 +271,7 @@ export async function readMetadata(
try { try {
const content = await app.vault.read(file); const content = await app.vault.read(file);
if (file.extension === "md" || file.extension === "markdown") { if (isMarkdownFile(file)) {
const { metadata } = parseMarkdownFrontmatter(content); const { metadata } = parseMarkdownFrontmatter(content);
if (Object.keys(metadata).length === 0) { if (Object.keys(metadata).length === 0) {
return null; return null;
@ -277,7 +279,7 @@ export async function readMetadata(
const kind = (metadata.kind as EventKind) || 1; const kind = (metadata.kind as EventKind) || 1;
const filtered = filterPlaceholders(metadata, kind); const filtered = filterPlaceholders(metadata, kind);
return filtered as EventMetadata; return filtered as EventMetadata;
} else if (file.extension === "adoc" || file.extension === "asciidoc") { } else if (isAsciiDocFile(file)) {
const { metadata } = parseAsciiDocAttributes(content); const { metadata } = parseAsciiDocAttributes(content);
if (Object.keys(metadata).length === 0) { if (Object.keys(metadata).length === 0) {
return null; return null;
@ -299,10 +301,10 @@ export async function readMetadata(
* For AsciiDoc, keeps the title header but removes attribute lines * For AsciiDoc, keeps the title header but removes attribute lines
*/ */
export function stripMetadataFromContent(file: TFile, content: string): string { export function stripMetadataFromContent(file: TFile, content: string): string {
if (file.extension === "md" || file.extension === "markdown") { if (isMarkdownFile(file)) {
const { body } = parseMarkdownFrontmatter(content); const { body } = parseMarkdownFrontmatter(content);
return body; return body;
} else if (file.extension === "adoc" || file.extension === "asciidoc") { } else if (isAsciiDocFile(file)) {
const lines = content.split("\n"); const lines = content.split("\n");
const result: string[] = []; const result: string[] = [];
let foundTitle = false; let foundTitle = false;
@ -351,12 +353,12 @@ export async function writeMetadata(
try { try {
const currentContent = await app.vault.read(file); const currentContent = await app.vault.read(file);
if (file.extension === "md" || file.extension === "markdown") { if (isMarkdownFile(file)) {
const { body } = parseMarkdownFrontmatter(currentContent); const { body } = parseMarkdownFrontmatter(currentContent);
const frontmatter = formatMarkdownFrontmatter(metadata); const frontmatter = formatMarkdownFrontmatter(metadata);
const newContent = frontmatter ? `---\n${frontmatter}---\n${body}` : body; const newContent = frontmatter ? `---\n${frontmatter}---\n${body}` : body;
await app.vault.modify(file, newContent); await app.vault.modify(file, newContent);
} else if (file.extension === "adoc" || file.extension === "asciidoc") { } else if (isAsciiDocFile(file)) {
// For AsciiDoc, we need to preserve the title if it exists in the body // For AsciiDoc, we need to preserve the title if it exists in the body
// and remove old attributes // and remove old attributes
const { body } = parseAsciiDocAttributes(currentContent); const { body } = parseAsciiDocAttributes(currentContent);

15
src/nostr/eventBuilder.ts

@ -1,6 +1,7 @@
import { finalizeEvent, getEventHash, getPublicKey, nip19 } from "nostr-tools"; import { finalizeEvent, getEventHash, getPublicKey, nip19 } from "nostr-tools";
import { EventKind, EventMetadata, SignedEvent, Kind30041Metadata } from "../types"; import { EventKind, EventMetadata, SignedEvent, Kind30041Metadata, Kind30040Metadata } from "../types";
import { sanitizeString } from "../utils/security"; import { sanitizeString } from "../utils/security";
import { addNKBIP08TagsTo30040, addNKBIP08TagsTo30041 } from "./nkbip08Tags";
/** /**
* Normalize secret key from bech32 nsec or hex format to hex * Normalize secret key from bech32 nsec or hex format to hex
@ -145,8 +146,10 @@ export function buildTagsFromMetadata(
metadata.topics.forEach((topic) => tags.push(["t", topic])); metadata.topics.forEach((topic) => tags.push(["t", topic]));
} }
// NKBIP-08 tags // NKBIP-08 tags
if (metadata.collection_id) tags.push(["C", metadata.collection_id]); // Note: For structured documents, NKBIP-08 tags are added in eventManager.ts
if (metadata.version_tag) tags.push(["v", metadata.version_tag]); // with proper book/chapter identification. For simple 30040 events, treat as book.
const meta30040 = metadata as Kind30040Metadata;
addNKBIP08TagsTo30040(tags, meta30040, true, false, undefined, meta30040); // Simple 30040 is a book, use itself as root
// Additional tags // Additional tags
if (metadata.additional_tags) { if (metadata.additional_tags) {
metadata.additional_tags.forEach((tag) => tags.push(tag)); metadata.additional_tags.forEach((tag) => tags.push(tag));
@ -179,11 +182,7 @@ export function buildTagsFromMetadata(
} }
// NKBIP-08 tags (only for nested 30041 under 30040) // NKBIP-08 tags (only for nested 30041 under 30040)
if (meta30041.collection_id) tags.push(["C", meta30041.collection_id]); addNKBIP08TagsTo30041(tags, meta30041);
if (meta30041.title_id) tags.push(["T", meta30041.title_id]);
if (meta30041.chapter_id) tags.push(["c", meta30041.chapter_id]);
if (meta30041.section_id) tags.push(["s", meta30041.section_id]);
if (meta30041.version_tag) tags.push(["v", meta30041.version_tag]);
break; break;
case 30817: case 30817:

319
src/nostr/nkbip08Tags.ts

@ -0,0 +1,319 @@
import { Kind30040Metadata, Kind30041Metadata, EventMetadata } from "../types";
/**
* NKBIP-08 tag names
*/
export const NKBIP08_TAGS = {
COLLECTION: "C",
TITLE: "T",
CHAPTER: "c",
SECTION: "s",
VERSION: "v",
} as const;
/**
* Normalize tag values according to NKBIP-08 (NIP-54 rules):
* - Remove quotes (single and double)
* - Convert any non-letter non-number character to a hyphen
* - Convert all letters to lowercase
* - Numbers are preserved (not converted to hyphens)
* - Collapse multiple hyphens to single hyphen
* - Trim leading/trailing hyphens
*
* IMPORTANT: This handles hierarchical paths with colons (e.g., "part-1:question-2:article-3")
* by converting colons to hyphens, resulting in "part-1-question-2-article-3" as per NKBIP-08 spec.
*/
export function normalizeNKBIP08TagValue(text: string): string {
if (!text) {
return "";
}
// Remove quotes (single and double)
let normalized = text.trim().replace(/^["']|["']$/g, "");
// Normalize: lowercase, convert non-letter non-number to hyphen
// Per NKBIP-08: "Section identifiers cannot contain colons in tag values.
// Hierarchical paths with colons MUST be normalized: colons → hyphens"
let result = "";
for (let i = 0; i < normalized.length; i++) {
const char = normalized[i];
if ((char >= "a" && char <= "z") || (char >= "A" && char <= "Z") || (char >= "0" && char <= "9")) {
result += char.toLowerCase();
} else {
// Non-alphanumeric (including colons) becomes hyphen (but don't add consecutive hyphens)
if (result && result[result.length - 1] !== "-") {
result += "-";
}
}
}
// Collapse multiple hyphens
result = result.replace(/-+/g, "-");
// Trim leading/trailing hyphens
result = result.replace(/^-+|-+$/g, "");
return result;
}
/**
* NKBIP-08 tag metadata for 30040 (publication index)
*/
export interface NKBIP08_30040Tags {
collection_id?: string; // C tag (optional - for compendiums, digests, libraries)
version_tag?: string; // v tag
}
/**
* NKBIP-08 tag metadata for 30041 (publication content) when nested under 30040
*/
export interface NKBIP08_30041Tags {
collection_id?: string; // C tag (inherited from root 30040)
title_id?: string; // T tag (derived from root 30040 title)
chapter_id?: string; // c tag (from chapter title)
section_id?: string; // s tag (from section title, only if not a chapter)
version_tag?: string; // v tag (inherited from parent 30040)
}
/**
* Extract NKBIP-08 tags from a 30040 metadata object
*/
export function extractNKBIP08TagsFrom30040(
metadata: Kind30040Metadata
): NKBIP08_30040Tags {
return {
collection_id: metadata.collection_id,
version_tag: metadata.version_tag,
};
}
/**
* Build NKBIP-08 tags for a nested 30041 event under a 30040 parent
*
* Per NKBIP-08 spec:
* - C tag (collection_id): Inherited from root 30040 (optional - for compendiums, digests, libraries)
* - T tag (title_id): Normalized book title (from root 30040, not just immediate parent)
* - c tag (chapter_id): Normalized chapter identifier
* - If 30041 is directly under root (isChapter=true): from 30041's own title
* - If 30041 is under a chapter (isChapter=false): from parent 30040's title
* - s tag (section_id): Normalized section identifier (from 30041 node's title)
* - Only added if isChapter=false (i.e., this is a section, not a chapter)
* - v tag (version_tag): Inherited from parent 30040
*
* @param parentMetadata - The parent 30040 metadata (source of inheritance)
* @param rootMetadata - The root 30040 metadata (source of collection_id)
* @param bookTitle - The book title (root 30040 title) for T tag
* @param chapterTitle - The chapter title (parent 30040 title if nested, or 30041 title if direct child)
* @param sectionTitle - The section title (30041 node title)
* @param isChapter - Whether this 30041 is directly under root (making it a chapter, not a section)
* @returns NKBIP-08 tags for the 30041 event
*/
export function buildNKBIP08TagsFor30041(
parentMetadata: Kind30040Metadata,
rootMetadata: Kind30040Metadata,
bookTitle: string,
chapterTitle: string,
sectionTitle: string,
isChapter: boolean = false
): NKBIP08_30041Tags {
return {
// C tag: Inherited from root 30040 (optional - for compendiums, digests, libraries)
collection_id: rootMetadata.collection_id ? normalizeNKBIP08TagValue(rootMetadata.collection_id) : undefined,
// Inherit from parent 30040
version_tag: parentMetadata.version_tag ? normalizeNKBIP08TagValue(parentMetadata.version_tag) : undefined,
// T tag: Normalized book title (from root 30040)
title_id: bookTitle ? normalizeNKBIP08TagValue(bookTitle) : undefined,
// c tag: If this is a chapter (direct child of root), use its own title; otherwise use parent's title
chapter_id: chapterTitle ? normalizeNKBIP08TagValue(chapterTitle) : undefined,
// s tag: Only add if this is NOT a chapter (i.e., it's a section under a chapter)
section_id: isChapter ? undefined : (sectionTitle ? normalizeNKBIP08TagValue(sectionTitle) : undefined),
};
}
/**
* Merge NKBIP-08 tags for nested 30040 events
* Child 30040 inherits from parent 30040 if parent values exist, otherwise uses own values
* All values are normalized per NKBIP-08 spec
*
* @param parentMetadata - Parent 30040 metadata (optional)
* @param childMetadata - Child 30040 metadata
* @returns Merged NKBIP-08 tags (normalized)
*/
export function mergeNKBIP08TagsFor30040(
parentMetadata: Kind30040Metadata | undefined,
childMetadata: Kind30040Metadata,
rootMetadata?: Kind30040Metadata
): NKBIP08_30040Tags {
// Collection ID is inherited from root (if present), not from parent
const collectionId = rootMetadata?.collection_id || childMetadata.collection_id;
const versionTag = parentMetadata?.version_tag || childMetadata.version_tag;
return {
collection_id: collectionId ? normalizeNKBIP08TagValue(collectionId) : undefined,
version_tag: versionTag ? normalizeNKBIP08TagValue(versionTag) : undefined,
};
}
/**
* Apply NKBIP-08 tags to a 30041 metadata object
* Used when building nested 30041 events under 30040
*
* @param metadata - The 30041 metadata to update
* @param nkbip08Tags - The NKBIP-08 tags to apply
* @returns Updated metadata with NKBIP-08 tags
*/
export function applyNKBIP08TagsTo30041(
metadata: Kind30041Metadata,
nkbip08Tags: NKBIP08_30041Tags
): Kind30041Metadata {
return {
...metadata,
collection_id: nkbip08Tags.collection_id,
title_id: nkbip08Tags.title_id,
chapter_id: nkbip08Tags.chapter_id,
section_id: nkbip08Tags.section_id,
version_tag: nkbip08Tags.version_tag,
};
}
/**
* Apply NKBIP-08 tags to a 30040 metadata object
* Used when building nested 30040 events
*
* @param metadata - The 30040 metadata to update
* @param nkbip08Tags - The NKBIP-08 tags to apply
* @returns Updated metadata with NKBIP-08 tags
*/
export function applyNKBIP08TagsTo30040(
metadata: Kind30040Metadata,
nkbip08Tags: NKBIP08_30040Tags
): Kind30040Metadata {
return {
...metadata,
collection_id: nkbip08Tags.collection_id,
version_tag: nkbip08Tags.version_tag,
};
}
/**
* Add NKBIP-08 tags to a tags array for a 30040 event
*
* Per NKBIP-08 spec:
* - C tag (collection_id): Optional, normalized (for compendiums, digests, libraries - inherited from root)
* - T tag (title_id): MANDATORY for book/title events, also added to chapters (inherited from book)
* - c tag (chapter_id): Optional, for chapter index events, normalized
* - v tag (version_tag): Optional, normalized
*
* @param tags - The tags array to add to
* @param metadata - The 30040 metadata containing NKBIP-08 tag values
* @param isBook - Whether this is a book/title event (requires T tag)
* @param isChapter - Whether this is a chapter event (requires c tag and inherits T tag)
* @param bookTitle - The book title for T tag (used for chapters to inherit from book)
*/
export function addNKBIP08TagsTo30040(
tags: string[][],
metadata: Kind30040Metadata,
isBook: boolean = false,
isChapter: boolean = false,
bookTitle?: string,
rootMetadata?: Kind30040Metadata
): void {
// C tag (collection) - optional, inherited from root if present
if (rootMetadata?.collection_id) {
const normalized = normalizeNKBIP08TagValue(rootMetadata.collection_id);
if (normalized) {
tags.push([NKBIP08_TAGS.COLLECTION, normalized]);
}
} else if (metadata.collection_id) {
// Fallback: use own collection_id if root not provided (for root itself)
const normalized = normalizeNKBIP08TagValue(metadata.collection_id);
if (normalized) {
tags.push([NKBIP08_TAGS.COLLECTION, normalized]);
}
}
// T tag (title) - MANDATORY for book/title events per NKBIP-08 spec
// Also added to chapters (inherited from book)
if (isBook && metadata.title) {
const normalized = normalizeNKBIP08TagValue(metadata.title);
if (normalized) {
tags.push([NKBIP08_TAGS.TITLE, normalized]);
}
} else if (isChapter && bookTitle) {
// Chapter inherits T tag from book
const normalized = normalizeNKBIP08TagValue(bookTitle);
if (normalized) {
tags.push([NKBIP08_TAGS.TITLE, normalized]);
}
}
// c tag (chapter) - for chapter index events
if (isChapter && metadata.title) {
const normalized = normalizeNKBIP08TagValue(metadata.title);
if (normalized) {
tags.push([NKBIP08_TAGS.CHAPTER, normalized]);
}
}
// v tag (version) - optional, add if present
if (metadata.version_tag) {
const normalized = normalizeNKBIP08TagValue(metadata.version_tag);
if (normalized) {
tags.push([NKBIP08_TAGS.VERSION, normalized]);
}
}
}
/**
* Add NKBIP-08 tags to a tags array for a 30041 event
* Only adds tags if they are present (for nested 30041 under 30040)
* Stand-alone 30041 events don't have NKBIP-08 tags
*
* Per NKBIP-08 spec:
* - C tag (collection_id): Optional, normalized (inherited from root 30040)
* - T tag (title_id): MANDATORY for nested 30041, normalized
* - c tag (chapter_id): Optional, normalized
* - s tag (section_id): Optional, normalized
* - v tag (version_tag): Optional, normalized
*
* @param tags - The tags array to add to
* @param metadata - The 30041 metadata containing NKBIP-08 tag values
*/
export function addNKBIP08TagsTo30041(
tags: string[][],
metadata: Kind30041Metadata
): void {
// Only add NKBIP-08 tags if they exist (indicating this is a nested 30041)
// All tag values are already normalized when stored in metadata
if (metadata.collection_id) {
tags.push([NKBIP08_TAGS.COLLECTION, metadata.collection_id]);
}
if (metadata.title_id) {
tags.push([NKBIP08_TAGS.TITLE, metadata.title_id]);
}
if (metadata.chapter_id) {
tags.push([NKBIP08_TAGS.CHAPTER, metadata.chapter_id]);
}
if (metadata.section_id) {
tags.push([NKBIP08_TAGS.SECTION, metadata.section_id]);
}
if (metadata.version_tag) {
tags.push([NKBIP08_TAGS.VERSION, metadata.version_tag]);
}
}
/**
* Check if a 30041 event has NKBIP-08 tags (indicating it's nested under 30040)
*
* @param metadata - The 30041 metadata to check
* @returns True if the event has NKBIP-08 tags
*/
export function hasNKBIP08Tags(metadata: Kind30041Metadata): boolean {
return !!(
metadata.collection_id ||
metadata.title_id ||
metadata.chapter_id ||
metadata.section_id ||
metadata.version_tag
);
}

7
src/nostr/relayClient.ts

@ -2,6 +2,7 @@ import { Relay } from "nostr-tools";
import { SignedEvent, PublishingResult } from "../types"; import { SignedEvent, PublishingResult } from "../types";
import { ensureAuthenticated, handleAuthRequiredError } from "./authHandler"; import { ensureAuthenticated, handleAuthRequiredError } from "./authHandler";
import { safeConsoleError } from "../utils/security"; import { safeConsoleError } from "../utils/security";
import { sanitizeErrorMessage } from "../utils/errorHandling";
import { deduplicateRelayUrls, normalizeRelayUrl } from "../relayManager"; import { deduplicateRelayUrls, normalizeRelayUrl } from "../relayManager";
/** /**
@ -66,24 +67,22 @@ export async function publishEventToRelay(
}; };
} catch (error: any) { } catch (error: any) {
relay.close(); relay.close();
const safeMessage = error?.message ? String(error.message).replace(/nsec1[a-z0-9]{58,}/gi, "[REDACTED]").replace(/[0-9a-f]{64}/gi, "[REDACTED]") : "Publish failed";
return { return {
eventId: event.id, eventId: event.id,
relay: relayUrl, relay: relayUrl,
success: false, success: false,
message: safeMessage, message: sanitizeErrorMessage(error) || "Publish failed",
}; };
} }
} catch (error: any) { } catch (error: any) {
if (relay) { if (relay) {
relay.close(); relay.close();
} }
const safeMessage = error?.message ? String(error.message).replace(/nsec1[a-z0-9]{58,}/gi, "[REDACTED]").replace(/[0-9a-f]{64}/gi, "[REDACTED]") : "Failed to connect to relay";
return { return {
eventId: event.id, eventId: event.id,
relay: relayUrl, relay: relayUrl,
success: false, success: false,
message: safeMessage, message: sanitizeErrorMessage(error) || "Failed to connect to relay",
}; };
} }
} }

4
src/types.ts

@ -80,7 +80,7 @@ export interface Kind30040Metadata extends BaseMetadata {
derivative_pubkey?: string; derivative_pubkey?: string;
additional_tags?: string[][]; // custom tags additional_tags?: string[][]; // custom tags
// NKBIP-08 tags // NKBIP-08 tags
collection_id?: string; // C tag collection_id?: string; // C tag (optional - for compendiums, digests, libraries of related books)
version_tag?: string; // v tag version_tag?: string; // v tag
} }
@ -95,7 +95,7 @@ export interface Kind30041Metadata extends BaseMetadata {
published_at?: string; published_at?: string;
topics?: string[]; // t tags topics?: string[]; // t tags
// NKBIP-08 tags (only for nested 30041 under 30040) // NKBIP-08 tags (only for nested 30041 under 30040)
collection_id?: string; // C tag collection_id?: string; // C tag (inherited from root 30040)
title_id?: string; // T tag title_id?: string; // T tag
chapter_id?: string; // c tag chapter_id?: string; // c tag
section_id?: string; // s tag section_id?: string; // s tag

15
src/ui/metadataModal.ts

@ -233,10 +233,10 @@ export class MetadataModal extends Modal {
new Setting(container) new Setting(container)
.setName("Collection ID") .setName("Collection ID")
.setDesc("NKBIP-08 collection identifier (C tag)") .setDesc("NKBIP-08 collection identifier (C tag) - Optional: compendium, digest, or library of related books (e.g., 'bible', 'goethe-complete-works', 'encyclopedia-britannica'). If set, will be inherited by all events in the hierarchy.")
.addText((text) => { .addText((text) => {
text.setValue(meta.collection_id || "") text.setValue(meta.collection_id || "")
.setPlaceholder("collection-id") .setPlaceholder("e.g., bible, goethe-complete-works")
.onChange((value) => { .onChange((value) => {
meta.collection_id = value; meta.collection_id = value;
}); });
@ -257,17 +257,6 @@ export class MetadataModal extends Modal {
private render30041Fields(container: HTMLElement) { private render30041Fields(container: HTMLElement) {
const meta = this.metadata as any; 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) new Setting(container)
.setName("Title ID") .setName("Title ID")
.setDesc("NKBIP-08 title identifier (T tag)") .setDesc("NKBIP-08 title identifier (T tag)")

27
src/utils/console.ts

@ -0,0 +1,27 @@
/**
* Centralized console logging utilities
* All logging goes through stderr for Obsidian plugin debugging
*/
/**
* Log a message to stderr (Obsidian console)
*/
export function log(message: string): void {
console.error(`[Scriptorium] ${message}`);
if (typeof process !== "undefined" && process.stderr) {
process.stderr.write(`[Scriptorium] ${message}\n`);
}
}
/**
* Log an error with optional stack trace
*/
export function logError(message: string, error?: any): void {
const errorMsg = error?.message || String(error || "");
const stackTrace = error?.stack || "";
log(`Error: ${message} - ${errorMsg}`);
if (stackTrace) {
log(`Stack trace: ${stackTrace}`);
}
}

22
src/utils/errorHandling.ts

@ -0,0 +1,22 @@
import { Notice } from "obsidian";
import { sanitizeString } from "./security";
/**
* Sanitize error message for display (removes private keys)
*/
export function sanitizeErrorMessage(error: any): string {
if (!error) return "Unknown error";
const message = error?.message || String(error);
return sanitizeString(message)
.replace(/nsec1[a-z0-9]{58,}/gi, "[REDACTED]")
.replace(/[0-9a-f]{64}/gi, "[REDACTED]");
}
/**
* Show a notice with sanitized error message
*/
export function showErrorNotice(message: string, error?: any): void {
const safeMessage = error ? sanitizeErrorMessage(error) : message;
new Notice(safeMessage);
}

50
src/utils/eventKind.ts

@ -0,0 +1,50 @@
import { TFile } from "obsidian";
import { EventKind } from "../types";
import { isAsciiDocFile, isMarkdownFile } from "./fileExtensions";
import { isAsciiDocDocument } from "../asciidocParser";
/**
* Determine event kind from file extension and content
*/
export function determineEventKind(
file: TFile,
content: string,
defaultKind: EventKind,
metadataKind?: EventKind
): EventKind {
if (isAsciiDocFile(file)) {
if (isAsciiDocDocument(content)) {
return 30040;
}
return 30818;
}
if (isMarkdownFile(file)) {
return metadataKind || defaultKind;
}
return defaultKind;
}
/**
* Get folder name for an event kind
*/
export function getFolderNameForKind(kind: EventKind): string {
const folderMap: Record<EventKind, string> = {
1: "kind-1-notes",
11: "kind-11-threads",
30023: "kind-30023-articles",
30040: "kind-30040-publications",
30041: "kind-30041-chapters",
30817: "kind-30817-wiki-md",
30818: "kind-30818-wiki-adoc",
};
return folderMap[kind];
}
/**
* Check if event kind requires a title
*/
export function requiresTitle(kind: EventKind): boolean {
return kind !== 1;
}

25
src/utils/fileExtensions.ts

@ -0,0 +1,25 @@
import { TFile } from "obsidian";
import { EventKind } from "../types";
/**
* Check if file is a Markdown file
*/
export function isMarkdownFile(file: TFile): boolean {
return file.extension === "md" || file.extension === "markdown";
}
/**
* Check if file is an AsciiDoc file
*/
export function isAsciiDocFile(file: TFile): boolean {
return file.extension === "adoc" || file.extension === "asciidoc";
}
/**
* Get file extension type
*/
export function getFileType(file: TFile): "markdown" | "asciidoc" | "unknown" {
if (isMarkdownFile(file)) return "markdown";
if (isAsciiDocFile(file)) return "asciidoc";
return "unknown";
}
Loading…
Cancel
Save