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 @@ -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)
- **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
- **Two-step workflow**: Create/sign events separately from publishing to relays
- **Automatic relay management**: Fetch relay lists (kind 10002) with AUTH support
@ -111,8 +113,8 @@ Supported event kinds: **30040**, **30041**, **30818** @@ -111,8 +113,8 @@ Supported event kinds: **30040**, **30041**, **30818**
:author: Author Name
:type: book
:summary: Book description
:collection_id: my-collection
:version_tag: v1
:collection_id: bible
:version_tag: kjv
== Chapter 1
@ -123,8 +125,42 @@ Chapter content here... @@ -123,8 +125,42 @@ Chapter content here...
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.
### 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
| Kind | Format | Description | Title Required |
@ -140,7 +176,11 @@ When publishing, metadata is automatically stripped from content before creating @@ -140,7 +176,11 @@ When publishing, metadata is automatically stripped from content before creating
### Stand-alone vs Nested 30041
- **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
@ -167,12 +207,19 @@ All predefined metadata fields are shown in frontmatter/attributes with placehol @@ -167,12 +207,19 @@ All predefined metadata fields are shown in frontmatter/attributes with placehol
- `published_by` - Publisher
- `source` - Source URL
- `auto_update` - Auto-update behavior (yes, ask, no)
- `collection_id` - NKBIP-08 collection identifier
- `version_tag` - NKBIP-08 version 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 (v tag) - If set in root 30040, inherited by all events in the hierarchy
**30041 (Publication Content)**:
- Stand-alone: Same as 30023 (image, summary, published_at, topics)
- Nested: NKBIP-08 tags (collection_id, title_id, chapter_id, section_id, version_tag)
- **Stand-alone**: Same as 30023 (image, summary, published_at, topics)
- **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

3
src/asciidocParser.ts

@ -26,8 +26,9 @@ export function isAsciiDocDocument(content: string): boolean { @@ -26,8 +26,9 @@ export function isAsciiDocDocument(content: string): boolean {
/**
* 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();
if (!trimmed.startsWith("=")) {
return null;

255
src/asciidocValidator.ts

@ -0,0 +1,255 @@ @@ -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 @@ @@ -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 { @@ -11,11 +11,19 @@ import {
import {
createSignedEvent,
buildTagsFromMetadata,
normalizeDTag,
getPubkeyFromPrivkey,
} from "./nostr/eventBuilder";
import { parseAsciiDocStructure } from "./asciidocParser";
import { parseAsciiDocStructure, isAsciiDocDocument } from "./asciidocParser";
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)
@ -66,21 +74,52 @@ export async function buildAsciiDocEvents( @@ -66,21 +74,52 @@ export async function buildAsciiDocEvents(
const rootNode = header[0];
const structure: StructureNode[] = [rootNode];
// Track the root book title for T tag inheritance
const rootBookTitle = rootNode.title;
// 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) {
// 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,
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 event = createSignedEvent(30041, node.content || "", tags, privkey);
events.push(event);
@ -90,15 +129,16 @@ export async function buildAsciiDocEvents( @@ -90,15 +129,16 @@ export async function buildAsciiDocEvents(
const childEvents: Array<{ kind: number; dTag: string; eventId?: string }> = [];
// 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;
// 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 = {
...baseMetadata,
kind: 30040,
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
author: parentMetadata?.author || baseMetadata.author,
type: parentMetadata?.type || baseMetadata.type,
@ -111,9 +151,17 @@ export async function buildAsciiDocEvents( @@ -111,9 +151,17 @@ export async function buildAsciiDocEvents(
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) {
await buildEventsFromNode(child, mergedMetadata);
await buildEventsFromNode(child, finalMetadata, currentBookTitle, isRoot, currentRootMetadata);
// Find the event we just created for this child
const childEvent = events.find((e) => {
@ -131,15 +179,30 @@ export async function buildAsciiDocEvents( @@ -131,15 +179,30 @@ export async function buildAsciiDocEvents(
}
// Now build this index event with references to children
const tags = buildTagsFromMetadata(mergedMetadata, pubkey, childEvents);
const event = createSignedEvent(30040, "", tags, privkey);
// We need to manually add NKBIP-08 tags with proper book/chapter flags
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);
node.metadata = mergedMetadata;
node.metadata = finalMetadata;
}
}
// Build events starting from root
await buildEventsFromNode(rootNode, metadata as Kind30040Metadata);
// Build events starting from root (no parent, book title is root title, isParentRoot=false for root itself)
await buildEventsFromNode(rootNode, metadata as Kind30040Metadata, rootBookTitle, false, metadata as Kind30040Metadata);
// Sort events: indexes first, then content (for proper dependency order)
events.sort((a, b) => {
@ -162,8 +225,7 @@ export async function buildEvents( @@ -162,8 +225,7 @@ export async function buildEvents(
app: any
): Promise<EventCreationResult> {
// Check if this is an AsciiDoc document with structure
const isAsciiDoc = file.extension === "adoc" || file.extension === "asciidoc";
const hasStructure = isAsciiDoc && content.trim().startsWith("=") && !content.trim().startsWith("==");
const hasStructure = isAsciiDocFile(file) && isAsciiDocDocument(content);
if (hasStructure && (metadata.kind === 30040 || metadata.kind === 30041)) {
// Parse header title and merge with metadata

375
src/main.ts

@ -1,26 +1,26 @@ @@ -1,26 +1,26 @@
import { Plugin, TFile, TFolder, Notice } from "obsidian";
import { ScriptoriumSettings, EventKind, EventMetadata, DEFAULT_SETTINGS } from "./types";
import { Plugin, TFile, Notice } from "obsidian";
import { ScriptoriumSettings, EventKind, 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 { MetadataReminderModal } from "./ui/metadataReminderModal";
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";
import { writeMetadata, createDefaultMetadata } from "./metadataManager";
import { safeConsoleError } from "./utils/security";
import { showErrorNotice } from "./utils/errorHandling";
import { log, logError } from "./utils/console";
import { getFolderNameForKind } from "./utils/eventKind";
import {
getCurrentFile,
ensureNostrNotesFolder,
handleCreateEvents,
handlePreviewStructure,
handlePublishEvents,
handleEditMetadata,
} from "./commands/commandHandlers";
export default class ScriptoriumPlugin extends Plugin {
settings!: ScriptoriumSettings;
async onload() {
// Log to terminal console that started Obsidian
console.error("[Scriptorium] Plugin loading...");
process.stderr.write("[Scriptorium] Plugin loading...\n");
log("Plugin loading...");
try {
await this.loadSettings();
@ -28,10 +28,8 @@ export default class ScriptoriumPlugin extends Plugin { @@ -28,10 +28,8 @@ export default class ScriptoriumPlugin extends Plugin {
// Note: We don't register file extensions for .adoc or .asciidoc files
// Users should install the obsidian-asciidoc plugin for .adoc file support
console.error("[Scriptorium] Plugin loaded - file extensions not registered");
process.stderr.write("[Scriptorium] Plugin loaded - file extensions not registered\n");
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");
log("Plugin loaded - file extensions not registered");
log("Install obsidian-asciidoc plugin for .adoc file editing support");
// Add settings tab
this.addSettingTab(new ScriptoriumSettingTab(this.app, this));
@ -75,17 +73,9 @@ export default class ScriptoriumPlugin extends Plugin { @@ -75,17 +73,9 @@ export default class ScriptoriumPlugin extends Plugin {
// Status bar
this.addStatusBarItem().setText("Scriptorium");
console.error("[Scriptorium] Plugin loaded successfully");
process.stderr.write("[Scriptorium] Plugin loaded successfully\n");
log("Plugin loaded successfully");
} catch (error: any) {
const errorMsg = error?.message || String(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`);
}
logError("Error loading plugin", error);
safeConsoleError("Error loading plugin:", error);
}
}
@ -119,290 +109,38 @@ export default class ScriptoriumPlugin extends Plugin { @@ -119,290 +109,38 @@ export default class ScriptoriumPlugin extends Plugin {
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() {
const file = await this.getCurrentFile();
const file = await getCurrentFile(this.app);
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 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);
}
await handleCreateEvents(this.app, file, this.settings);
}
private async handlePreviewStructure() {
const file = await this.getCurrentFile();
const file = await getCurrentFile(this.app);
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);
}
await handlePreviewStructure(this.app, file);
}
private async handlePublishEvents() {
const file = await this.getCurrentFile();
const file = await getCurrentFile(this.app);
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);
}
await handlePublishEvents(this.app, file, this.settings);
}
private async handleEditMetadata() {
const file = await this.getCurrentFile();
const file = await getCurrentFile(this.app);
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);
}
await handleEditMetadata(this.app, file, this.settings.defaultEventKind);
}
private async handleNewDocument() {
new NewDocumentModal(this.app, async (kind: EventKind, title: string) => {
try {
console.error(`[Scriptorium] Creating new document: kind=${kind}, title=${title}`);
process.stderr.write(`[Scriptorium] Creating new document: kind=${kind}, title=${title}\n`);
log(`Creating new document: kind=${kind}, title=${title}`);
// Ensure folder structure exists
const folderPath = await this.ensureNostrNotesFolder(kind);
const folderPath = await ensureNostrNotesFolder(this.app, kind);
// Sanitize filename from title
const sanitizedTitle = this.sanitizeFilename(title);
@ -448,33 +186,22 @@ export default class ScriptoriumPlugin extends Plugin { @@ -448,33 +186,22 @@ export default class ScriptoriumPlugin extends Plugin {
let file: TFile;
try {
console.error(`[Scriptorium] Creating file: ${filePath}`);
process.stderr.write(`[Scriptorium] Creating file: ${filePath}\n`);
log(`Creating file: ${filePath}`);
file = await this.app.vault.create(filePath, content);
console.error(`[Scriptorium] File created successfully: ${file.path}`);
process.stderr.write(`[Scriptorium] File created successfully: ${file.path}\n`);
log(`File created successfully: ${file.path}`);
// Verify file was actually created
const verifyFile = this.app.vault.getAbstractFileByPath(filePath);
if (!verifyFile || !(verifyFile instanceof TFile)) {
const msg = `Error: File ${filename} was not created properly`;
console.error(`[Scriptorium] ${msg}`);
process.stderr.write(`[Scriptorium] ${msg}\n`);
log(msg);
new Notice(msg);
safeConsoleError(`File creation verification failed for ${filePath}`);
logError("File creation verification failed", { 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";
console.error(`[Scriptorium] Error creating file: ${safeMessage}`);
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);
logError("Error creating file", error);
showErrorNotice("Error creating file", error);
return;
}
@ -489,16 +216,14 @@ export default class ScriptoriumPlugin extends Plugin { @@ -489,16 +216,14 @@ export default class ScriptoriumPlugin extends Plugin {
// Write metadata with all placeholders (title will be included if set)
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}`);
showErrorNotice("Error creating metadata", error);
safeConsoleError("Error creating metadata:", error);
// Continue anyway - file was created
}
// For .adoc files, ensure file is visible in explorer but don't auto-open
if (file.extension === "adoc" || file.extension === "asciidoc") {
console.error(`[Scriptorium] AsciiDoc file created: ${file.path}`);
process.stderr.write(`[Scriptorium] AsciiDoc file created: ${file.path}\n`);
log(`AsciiDoc file created: ${file.path}`);
// File should be visible in Obsidian's file explorer automatically
// since we used vault.create(). The file explorer will refresh automatically.
@ -508,35 +233,24 @@ export default class ScriptoriumPlugin extends Plugin { @@ -508,35 +233,24 @@ export default class ScriptoriumPlugin extends Plugin {
} else {
// 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
console.error(`[Scriptorium] Waiting before opening file...`);
process.stderr.write(`[Scriptorium] Waiting before opening file...\n`);
log("Waiting before opening file...");
await new Promise(resolve => setTimeout(resolve, 200));
try {
console.error(`[Scriptorium] Attempting to open file: ${file.path} (extension: ${file.extension})`);
process.stderr.write(`[Scriptorium] Attempting to open file: ${file.path} (extension: ${file.extension})\n`);
log(`Attempting to open file: ${file.path} (extension: ${file.extension})`);
const leaf = this.app.workspace.getMostRecentLeaf();
if (leaf && leaf.view) {
await leaf.openFile(file, { active: true });
console.error(`[Scriptorium] File opened successfully in existing leaf`);
process.stderr.write(`[Scriptorium] File opened successfully in existing leaf\n`);
log("File opened successfully in existing leaf");
} else {
// Fallback: open in new leaf
const newLeaf = this.app.workspace.getLeaf("tab");
await newLeaf.openFile(file, { active: true });
console.error(`[Scriptorium] File opened successfully in new leaf`);
process.stderr.write(`[Scriptorium] File opened successfully in new leaf\n`);
log("File opened successfully in new leaf");
}
} catch (error: any) {
const errorMsg = error?.message || String(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`);
}
logError("Error opening file", error);
safeConsoleError("Error opening file:", error);
// File was created, just couldn't open it - show a notice
new Notice(`File created but couldn't open: ${file.name}`);
@ -545,8 +259,7 @@ export default class ScriptoriumPlugin extends Plugin { @@ -545,8 +259,7 @@ export default class ScriptoriumPlugin extends Plugin {
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}`);
showErrorNotice("Error creating document", error);
safeConsoleError("Error creating document:", error);
}
}).open();

16
src/metadataManager.ts

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
import { TFile } from "obsidian";
import { EventKind, EventMetadata } from "./types";
import { safeConsoleError } from "./utils/security";
import { isMarkdownFile, isAsciiDocFile } from "./utils/fileExtensions";
/**
* Tag definitions with descriptions for each event kind
@ -44,7 +45,7 @@ const TAG_DEFINITIONS: Record<EventKind, TagDefinition[]> = { @@ -44,7 +45,7 @@ const TAG_DEFINITIONS: Record<EventKind, TagDefinition[]> = {
{ key: "image", description: "Cover image URL", 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: "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 },
],
30041: [
@ -55,6 +56,7 @@ const TAG_DEFINITIONS: Record<EventKind, TagDefinition[]> = { @@ -55,6 +56,7 @@ const TAG_DEFINITIONS: Record<EventKind, TagDefinition[]> = {
{ 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)
// 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: [
{ key: "title", description: "Wiki page title (required)", required: true },
@ -269,7 +271,7 @@ export async function readMetadata( @@ -269,7 +271,7 @@ export async function readMetadata(
try {
const content = await app.vault.read(file);
if (file.extension === "md" || file.extension === "markdown") {
if (isMarkdownFile(file)) {
const { metadata } = parseMarkdownFrontmatter(content);
if (Object.keys(metadata).length === 0) {
return null;
@ -277,7 +279,7 @@ export async function readMetadata( @@ -277,7 +279,7 @@ export async function readMetadata(
const kind = (metadata.kind as EventKind) || 1;
const filtered = filterPlaceholders(metadata, kind);
return filtered as EventMetadata;
} else if (file.extension === "adoc" || file.extension === "asciidoc") {
} else if (isAsciiDocFile(file)) {
const { metadata } = parseAsciiDocAttributes(content);
if (Object.keys(metadata).length === 0) {
return null;
@ -299,10 +301,10 @@ export async function readMetadata( @@ -299,10 +301,10 @@ export async function readMetadata(
* For AsciiDoc, keeps the title header but removes attribute lines
*/
export function stripMetadataFromContent(file: TFile, content: string): string {
if (file.extension === "md" || file.extension === "markdown") {
if (isMarkdownFile(file)) {
const { body } = parseMarkdownFrontmatter(content);
return body;
} else if (file.extension === "adoc" || file.extension === "asciidoc") {
} else if (isAsciiDocFile(file)) {
const lines = content.split("\n");
const result: string[] = [];
let foundTitle = false;
@ -351,12 +353,12 @@ export async function writeMetadata( @@ -351,12 +353,12 @@ export async function writeMetadata(
try {
const currentContent = await app.vault.read(file);
if (file.extension === "md" || file.extension === "markdown") {
if (isMarkdownFile(file)) {
const { body } = parseMarkdownFrontmatter(currentContent);
const frontmatter = formatMarkdownFrontmatter(metadata);
const newContent = frontmatter ? `---\n${frontmatter}---\n${body}` : body;
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
// and remove old attributes
const { body } = parseAsciiDocAttributes(currentContent);

15
src/nostr/eventBuilder.ts

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
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 { addNKBIP08TagsTo30040, addNKBIP08TagsTo30041 } from "./nkbip08Tags";
/**
* Normalize secret key from bech32 nsec or hex format to hex
@ -145,8 +146,10 @@ export function buildTagsFromMetadata( @@ -145,8 +146,10 @@ export function buildTagsFromMetadata(
metadata.topics.forEach((topic) => tags.push(["t", topic]));
}
// NKBIP-08 tags
if (metadata.collection_id) tags.push(["C", metadata.collection_id]);
if (metadata.version_tag) tags.push(["v", metadata.version_tag]);
// Note: For structured documents, NKBIP-08 tags are added in eventManager.ts
// 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
if (metadata.additional_tags) {
metadata.additional_tags.forEach((tag) => tags.push(tag));
@ -179,11 +182,7 @@ export function buildTagsFromMetadata( @@ -179,11 +182,7 @@ export function buildTagsFromMetadata(
}
// NKBIP-08 tags (only for nested 30041 under 30040)
if (meta30041.collection_id) tags.push(["C", meta30041.collection_id]);
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]);
addNKBIP08TagsTo30041(tags, meta30041);
break;
case 30817:

319
src/nostr/nkbip08Tags.ts

@ -0,0 +1,319 @@ @@ -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"; @@ -2,6 +2,7 @@ import { Relay } from "nostr-tools";
import { SignedEvent, PublishingResult } from "../types";
import { ensureAuthenticated, handleAuthRequiredError } from "./authHandler";
import { safeConsoleError } from "../utils/security";
import { sanitizeErrorMessage } from "../utils/errorHandling";
import { deduplicateRelayUrls, normalizeRelayUrl } from "../relayManager";
/**
@ -66,24 +67,22 @@ export async function publishEventToRelay( @@ -66,24 +67,22 @@ export async function publishEventToRelay(
};
} catch (error: any) {
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 {
eventId: event.id,
relay: relayUrl,
success: false,
message: safeMessage,
message: sanitizeErrorMessage(error) || "Publish failed",
};
}
} catch (error: any) {
if (relay) {
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 {
eventId: event.id,
relay: relayUrl,
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 { @@ -80,7 +80,7 @@ export interface Kind30040Metadata extends BaseMetadata {
derivative_pubkey?: string;
additional_tags?: string[][]; // custom 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
}
@ -95,7 +95,7 @@ export interface Kind30041Metadata extends BaseMetadata { @@ -95,7 +95,7 @@ export interface Kind30041Metadata extends BaseMetadata {
published_at?: string;
topics?: string[]; // t tags
// 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
chapter_id?: string; // c tag
section_id?: string; // s tag

15
src/ui/metadataModal.ts

@ -233,10 +233,10 @@ export class MetadataModal extends Modal { @@ -233,10 +233,10 @@ export class MetadataModal extends Modal {
new Setting(container)
.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) => {
text.setValue(meta.collection_id || "")
.setPlaceholder("collection-id")
.setPlaceholder("e.g., bible, goethe-complete-works")
.onChange((value) => {
meta.collection_id = value;
});
@ -257,17 +257,6 @@ export class MetadataModal extends Modal { @@ -257,17 +257,6 @@ export class MetadataModal extends Modal {
private render30041Fields(container: HTMLElement) {
const meta = this.metadata as any;
new Setting(container)
.setName("Collection ID")
.setDesc("NKBIP-08 collection identifier (C tag)")
.addText((text) => {
text.setValue(meta.collection_id || "")
.setPlaceholder("collection-id")
.onChange((value) => {
meta.collection_id = value;
});
});
new Setting(container)
.setName("Title ID")
.setDesc("NKBIP-08 title identifier (T tag)")

27
src/utils/console.ts

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