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
};
}
}
}

View File

@@ -140,3 +140,38 @@ export interface ListResult {
hasMore: boolean;
nextCursor?: string;
}
// Phase 5: Advanced Read Operations Types
export interface ParsedNote {
path: string;
hasFrontmatter: boolean;
frontmatter?: string;
parsedFrontmatter?: Record<string, any>;
content: string;
contentWithoutFrontmatter?: string;
}
/**
* Excalidraw drawing file metadata
* Returned by read_excalidraw tool
*/
export interface ExcalidrawMetadata {
/** File path */
path: string;
/** True if file is a valid Excalidraw drawing */
isExcalidraw: boolean;
/** Number of drawing elements (shapes, text, etc.) */
elementCount?: number;
/** True if drawing contains compressed/embedded image data */
hasCompressedData?: boolean;
/** Drawing metadata including appState and version */
metadata?: {
appState?: Record<string, any>;
version?: number;
[key: string]: any;
};
/** Preview text extracted from text elements section (when includePreview=true) */
preview?: string;
/** Full compressed drawing data (when includeCompressed=true) */
compressedData?: string;
}

View File

@@ -0,0 +1,290 @@
import { parseYaml } from 'obsidian';
/**
* Utility class for parsing and extracting frontmatter from markdown files
*/
export class FrontmatterUtils {
/**
* Extract frontmatter from markdown content
* Returns the frontmatter block, content without frontmatter, and parsed YAML
*/
static extractFrontmatter(content: string): {
hasFrontmatter: boolean;
frontmatter: string;
parsedFrontmatter: Record<string, any> | null;
content: string;
contentWithoutFrontmatter: string;
} {
// Check if content starts with frontmatter delimiter
if (!content.startsWith('---\n') && !content.startsWith('---\r\n')) {
return {
hasFrontmatter: false,
frontmatter: '',
parsedFrontmatter: null,
content: content,
contentWithoutFrontmatter: content
};
}
// Find the closing delimiter
const lines = content.split('\n');
let endIndex = -1;
// Start from line 1 (skip the opening ---)
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (line === '---' || line === '...') {
endIndex = i;
break;
}
}
// If no closing delimiter found, treat as no frontmatter
if (endIndex === -1) {
return {
hasFrontmatter: false,
frontmatter: '',
parsedFrontmatter: null,
content: content,
contentWithoutFrontmatter: content
};
}
// Extract frontmatter (excluding delimiters)
const frontmatterLines = lines.slice(1, endIndex);
const frontmatter = frontmatterLines.join('\n');
// Extract content after frontmatter
const contentLines = lines.slice(endIndex + 1);
const contentWithoutFrontmatter = contentLines.join('\n');
// Parse YAML using Obsidian's built-in parser
let parsedFrontmatter: Record<string, any> | null = null;
try {
parsedFrontmatter = parseYaml(frontmatter) || {};
} catch (error) {
// If parsing fails, return null for parsed frontmatter
console.error('Failed to parse frontmatter:', error);
parsedFrontmatter = null;
}
return {
hasFrontmatter: true,
frontmatter: frontmatter,
parsedFrontmatter: parsedFrontmatter,
content: content,
contentWithoutFrontmatter: contentWithoutFrontmatter
};
}
/**
* Extract only the frontmatter summary (common fields)
* Useful for list operations without reading full content
*/
static extractFrontmatterSummary(parsedFrontmatter: Record<string, any> | null): {
title?: string;
tags?: string[];
aliases?: string[];
[key: string]: any;
} | null {
if (!parsedFrontmatter) {
return null;
}
const summary: Record<string, any> = {};
// Extract common fields
if (parsedFrontmatter.title) {
summary.title = parsedFrontmatter.title;
}
if (parsedFrontmatter.tags) {
// Normalize tags to array
if (Array.isArray(parsedFrontmatter.tags)) {
summary.tags = parsedFrontmatter.tags;
} else if (typeof parsedFrontmatter.tags === 'string') {
summary.tags = [parsedFrontmatter.tags];
}
}
if (parsedFrontmatter.aliases) {
// Normalize aliases to array
if (Array.isArray(parsedFrontmatter.aliases)) {
summary.aliases = parsedFrontmatter.aliases;
} else if (typeof parsedFrontmatter.aliases === 'string') {
summary.aliases = [parsedFrontmatter.aliases];
}
}
// Include any other top-level fields
for (const key in parsedFrontmatter) {
if (!['title', 'tags', 'aliases'].includes(key)) {
summary[key] = parsedFrontmatter[key];
}
}
return Object.keys(summary).length > 0 ? summary : null;
}
/**
* Check if content has frontmatter (quick check without parsing)
*/
static hasFrontmatter(content: string): boolean {
return content.startsWith('---\n') || content.startsWith('---\r\n');
}
/**
* Parse Excalidraw file metadata
* Excalidraw files are JSON with special structure
*/
static parseExcalidrawMetadata(content: string): {
isExcalidraw: boolean;
elementCount?: number;
hasCompressedData?: boolean;
metadata?: Record<string, any>;
} {
try {
// Excalidraw files are typically markdown with a code block containing JSON
// Format: # Text Elements\n\n<text content>\n\n## Drawing\n```json\n<excalidraw data>\n```
// Check if it's an Excalidraw file by looking for the plugin marker
const hasExcalidrawMarker = content.includes('excalidraw-plugin') ||
content.includes('"type":"excalidraw"');
if (!hasExcalidrawMarker) {
return { isExcalidraw: false };
}
// Try multiple approaches to extract JSON from code block
let jsonString: string | null = null;
// Approach 1: Look for code fence after "## Drawing" section
const drawingIndex = content.indexOf('## Drawing');
if (drawingIndex > 0) {
const afterDrawing = content.substring(drawingIndex);
// Pattern 1: ```compressed-json (Excalidraw's actual format)
let match = afterDrawing.match(/```compressed-json\s*\n([\s\S]*?)```/);
if (match) {
jsonString = match[1];
}
// Pattern 2: ```json
if (!jsonString) {
match = afterDrawing.match(/```json\s*\n([\s\S]*?)```/);
if (match) {
jsonString = match[1];
}
}
// Pattern 3: ``` with any language specifier
if (!jsonString) {
match = afterDrawing.match(/```[a-z-]*\s*\n([\s\S]*?)```/);
if (match) {
jsonString = match[1];
}
}
// Pattern 4: ``` (no language specifier)
if (!jsonString) {
match = afterDrawing.match(/```\s*\n([\s\S]*?)```/);
if (match) {
jsonString = match[1];
}
}
}
// Approach 2: If still no match, try searching entire content
if (!jsonString) {
// Look for any code fence with JSON-like content
const patterns = [
/```compressed-json\s*\n([\s\S]*?)```/,
/```json\s*\n([\s\S]*?)```/,
/```[a-z-]*\s*\n([\s\S]*?)```/,
/```\s*\n([\s\S]*?)```/
];
for (const pattern of patterns) {
const match = content.match(pattern);
if (match) {
jsonString = match[1];
break;
}
}
}
if (!jsonString) {
// Return with default values if JSON can't be extracted
return {
isExcalidraw: true,
elementCount: 0,
hasCompressedData: false,
metadata: {}
};
}
// Check if data is compressed (base64 encoded)
const trimmedJson = jsonString.trim();
let jsonData: any;
if (trimmedJson.startsWith('N4KAk') || !trimmedJson.startsWith('{')) {
// Data is compressed - try to decompress
try {
// Decompress using pako (if available) or return metadata indicating compression
// For now, we'll indicate it's compressed and provide limited metadata
return {
isExcalidraw: true,
elementCount: 0, // Can't count without decompression
hasCompressedData: true, // Definitely compressed
metadata: {
appState: {},
version: 2,
compressed: true // Indicate data is compressed
}
};
} catch (decompressError) {
// Decompression failed
return {
isExcalidraw: true,
elementCount: 0,
hasCompressedData: true,
metadata: { compressed: true }
};
}
}
// Parse the JSON (uncompressed format)
jsonData = JSON.parse(trimmedJson);
// Count elements
const elementCount = jsonData.elements ? jsonData.elements.length : 0;
// Check for compressed data (files or images)
const hasCompressedData = !!(jsonData.files && Object.keys(jsonData.files).length > 0);
return {
isExcalidraw: true,
elementCount: elementCount,
hasCompressedData: hasCompressedData,
metadata: {
appState: jsonData.appState || {},
version: jsonData.version || 2
}
};
} catch (error) {
// If parsing fails, return with default values
const isExcalidraw = content.includes('excalidraw-plugin') ||
content.includes('"type":"excalidraw"');
// Log error for debugging
console.error('Excalidraw parsing error:', error);
return {
isExcalidraw: isExcalidraw,
elementCount: isExcalidraw ? 0 : undefined,
hasCompressedData: isExcalidraw ? false : undefined,
metadata: isExcalidraw ? {} : undefined
};
}
}
}