Phase 5 Complete: Advanced Read Operations with Excalidraw Support

- Enhanced read_note tool with frontmatter parsing options
  - parseFrontmatter option to separate frontmatter from content
  - withFrontmatter and withContent options for flexible responses
  - Returns structured ParsedNote JSON when parsing enabled
  - Backward compatible (default behavior unchanged)

- New read_excalidraw tool for Excalidraw file metadata
  - Detects compressed-json format (Excalidraw's actual format)
  - Returns elementCount, hasCompressedData, metadata fields
  - Handles compressed (base64) and uncompressed formats
  - Preview text extraction from Text Elements section
  - Optional full compressed data inclusion

- New frontmatter-utils.ts for YAML parsing
  - Uses Obsidian's built-in parseYaml
  - Extracts and parses frontmatter
  - Handles edge cases (no frontmatter, malformed YAML)
  - Excalidraw metadata parsing with compression detection

- Enhanced type definitions with JSDoc comments
  - ParsedNote interface for structured note data
  - ExcalidrawMetadata interface with detailed field docs
  - Clear documentation of all fields and their purposes

- Comprehensive documentation
  - IMPLEMENTATION_NOTES_PHASE5.md - Implementation details
  - EXCALIDRAW_FIX_SUMMARY.md - Bug fix documentation
  - EXCALIDRAW_TESTING_GUIDE.md - Testing instructions
  - Updated CHANGELOG.md with all changes
  - Updated ROADMAP.md marking Phase 5 complete

- Known limitation documented
  - elementCount returns 0 for compressed files (expected)
  - Decompression would require pako library (not included)
  - hasCompressedData correctly identifies compressed files
  - Preview text still available without decompression
  - Added to roadmap as future enhancement

All manual tests passed. Phase 5 complete and production-ready.
This commit is contained in:
2025-10-16 23:47:19 -04:00
parent aff7c6bd0a
commit 7e5a6a8c3c
8 changed files with 970 additions and 438 deletions

View File

@@ -16,13 +16,25 @@ export class ToolRegistry {
return [
{
name: "read_note",
description: "Read the content of a file from the Obsidian vault. Use this to read the contents of a specific note or file. Path must be vault-relative (no leading slash) and include the file extension. Use list_notes() first if you're unsure of the exact path. This only works on files, not folders.",
description: "Read the content of a file from the Obsidian vault with optional frontmatter parsing. Use this to read the contents of a specific note or file. Path must be vault-relative (no leading slash) and include the file extension. Use list() first if you're unsure of the exact path. This only works on files, not folders. By default returns raw content. Set parseFrontmatter to true to get structured data with separated frontmatter and content.",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Vault-relative path to the file (e.g., 'folder/note.md' or 'daily/2024-10-16.md'). Must include file extension. Paths are case-sensitive on macOS/Linux. Do not use leading or trailing slashes."
},
withFrontmatter: {
type: "boolean",
description: "If true (default), include frontmatter in the response when parseFrontmatter is true. Only applies when parseFrontmatter is true."
},
withContent: {
type: "boolean",
description: "If true (default), include full content in the response. Set to false to get only metadata when parseFrontmatter is true."
},
parseFrontmatter: {
type: "boolean",
description: "If true, parse and separate frontmatter from content, returning structured JSON. If false (default), return raw file content as plain text. Use true when you need to work with frontmatter separately."
}
},
required: ["path"]
@@ -175,6 +187,28 @@ export class ToolRegistry {
},
required: ["path"]
}
},
{
name: "read_excalidraw",
description: "Read an Excalidraw drawing file with specialized metadata extraction. Returns structured ExcalidrawMetadata JSON object. ALWAYS RETURNED FIELDS: 'path' (string: file path), 'isExcalidraw' (boolean: true if valid Excalidraw file), 'elementCount' (number: count of drawing elements - NOTE: returns 0 for compressed files which is most Excalidraw files, only uncompressed files return actual count), 'hasCompressedData' (boolean: true if drawing uses compressed format), 'metadata' (object: contains appState, version, and compressed flag). CONDITIONAL FIELDS: 'preview' (string: text elements from Text Elements section, included when includePreview=true which is default), 'compressedData' (string: full file content including compressed drawing data, included only when includeCompressed=true). Gracefully handles non-Excalidraw files by returning isExcalidraw=false with helpful message. Use this for .excalidraw.md files to get drawing information. Most files use compressed format so elementCount will be 0 but hasCompressedData will be true.",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Vault-relative path to the Excalidraw file (e.g., 'drawings/diagram.excalidraw.md'). Paths are case-sensitive on macOS/Linux. Do not use leading or trailing slashes."
},
includeCompressed: {
type: "boolean",
description: "If true, include the full compressed drawing data in 'compressedData' field. Default: false. Warning: can be very large for complex drawings with embedded images. Set to true only when you need the complete drawing JSON data for processing or export."
},
includePreview: {
type: "boolean",
description: "If true (default), include preview text in 'preview' field extracted from the drawing's text elements section. Set to false to omit preview and reduce response size. Useful for getting a text summary of the drawing without the full data."
}
},
required: ["path"]
}
}
];
}
@@ -183,7 +217,11 @@ export class ToolRegistry {
try {
switch (name) {
case "read_note":
return await this.noteTools.readNote(args.path);
return await this.noteTools.readNote(args.path, {
withFrontmatter: args.withFrontmatter,
withContent: args.withContent,
parseFrontmatter: args.parseFrontmatter
});
case "create_note":
return await this.noteTools.createNote(args.path, args.content, args.createParents ?? false);
case "update_note":
@@ -209,6 +247,11 @@ export class ToolRegistry {
return await this.vaultTools.stat(args.path);
case "exists":
return await this.vaultTools.exists(args.path);
case "read_excalidraw":
return await this.noteTools.readExcalidraw(args.path, {
includeCompressed: args.includeCompressed,
includePreview: args.includePreview
});
default:
return {
content: [{ type: "text", text: `Unknown tool: ${name}` }],

View File

@@ -1,12 +1,25 @@
import { App, TFile } from 'obsidian';
import { CallToolResult } from '../types/mcp-types';
import { CallToolResult, ParsedNote, ExcalidrawMetadata } from '../types/mcp-types';
import { PathUtils } from '../utils/path-utils';
import { ErrorMessages } from '../utils/error-messages';
import { FrontmatterUtils } from '../utils/frontmatter-utils';
export class NoteTools {
constructor(private app: App) {}
async readNote(path: string): Promise<CallToolResult> {
async readNote(
path: string,
options?: {
withFrontmatter?: boolean;
withContent?: boolean;
parseFrontmatter?: boolean;
}
): Promise<CallToolResult> {
// Default options
const withFrontmatter = options?.withFrontmatter ?? true;
const withContent = options?.withContent ?? true;
const parseFrontmatter = options?.parseFrontmatter ?? false;
// Validate path
if (!path || path.trim() === '') {
return {
@@ -42,8 +55,36 @@ export class NoteTools {
try {
const content = await this.app.vault.read(file);
// If no special options, return simple content
if (!parseFrontmatter) {
return {
content: [{ type: "text", text: content }]
};
}
// Parse frontmatter if requested
const extracted = FrontmatterUtils.extractFrontmatter(content);
const result: ParsedNote = {
path: file.path,
hasFrontmatter: extracted.hasFrontmatter,
content: withContent ? content : ''
};
// Include frontmatter if requested
if (withFrontmatter && extracted.hasFrontmatter) {
result.frontmatter = extracted.frontmatter;
result.parsedFrontmatter = extracted.parsedFrontmatter || undefined;
}
// Include content without frontmatter if parsing
if (withContent && extracted.hasFrontmatter) {
result.contentWithoutFrontmatter = extracted.contentWithoutFrontmatter;
}
return {
content: [{ type: "text", text: content }]
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
};
} catch (error) {
return {
@@ -247,4 +288,107 @@ export class NoteTools {
};
}
}
async readExcalidraw(
path: string,
options?: {
includeCompressed?: boolean;
includePreview?: boolean;
}
): Promise<CallToolResult> {
// Default options
const includeCompressed = options?.includeCompressed ?? false;
const includePreview = options?.includePreview ?? true;
// Validate path
if (!path || path.trim() === '') {
return {
content: [{ type: "text", text: ErrorMessages.emptyPath() }],
isError: true
};
}
if (!PathUtils.isValidVaultPath(path)) {
return {
content: [{ type: "text", text: ErrorMessages.invalidPath(path) }],
isError: true
};
}
// Resolve file using path utilities
const file = PathUtils.resolveFile(this.app, path);
if (!file) {
// Check if it's a folder instead
if (PathUtils.folderExists(this.app, path)) {
return {
content: [{ type: "text", text: ErrorMessages.notAFile(path) }],
isError: true
};
}
return {
content: [{ type: "text", text: ErrorMessages.fileNotFound(path) }],
isError: true
};
}
try {
const content = await this.app.vault.read(file);
// Parse Excalidraw metadata (gracefully handles malformed files)
const metadata = FrontmatterUtils.parseExcalidrawMetadata(content);
if (!metadata.isExcalidraw) {
// Return structured response for non-Excalidraw files
const result: ExcalidrawMetadata = {
path: file.path,
isExcalidraw: false
};
return {
content: [{
type: "text",
text: JSON.stringify({
...result,
message: `File is not an Excalidraw drawing. The file does not contain Excalidraw plugin markers. Use read_note instead for regular markdown files.`
}, null, 2)
}]
};
}
// Build result with all core metadata fields (always returned)
const result: ExcalidrawMetadata = {
path: file.path,
isExcalidraw: metadata.isExcalidraw,
elementCount: metadata.elementCount, // Number of drawing elements
hasCompressedData: metadata.hasCompressedData, // Boolean for embedded images
metadata: metadata.metadata // Object with appState and version
};
// Include preview if requested (extract text elements)
if (includePreview) {
// Extract text before the Drawing section
const drawingIndex = content.indexOf('## Drawing');
if (drawingIndex > 0) {
const previewText = content.substring(0, drawingIndex).trim();
// Remove the "# Text Elements" header if present
result.preview = previewText.replace(/^#\s*Text Elements\s*\n+/, '').trim();
}
}
// Include compressed data if requested (full content)
if (includeCompressed) {
result.compressedData = content;
}
return {
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
};
} catch (error) {
return {
content: [{ type: "text", text: ErrorMessages.operationFailed('read excalidraw', path, (error as Error).message) }],
isError: true
};
}
}
}