Files
obsidian-mcp-server/src/utils/frontmatter-utils.ts
Bill 48e429d59e fix: remove console.error from graceful error handlers
Removed console.error calls from error handlers that gracefully skip
problematic files and continue processing. These handlers catch errors
when reading or parsing files but successfully return fallback values,
so logging errors creates unnecessary noise during testing and deployment.

Changes:
- vault-tools.ts: Remove console.error from search and frontmatter extraction
- search-utils.ts: Remove console.error from file search handlers
- waypoint-utils.ts: Remove console.error from file read handler
- frontmatter-utils.ts: Remove console.error from YAML and Excalidraw parsing

Test updates:
- Remove test assertions checking for console.error calls since these
  are no longer emitted by graceful error handlers

All 709 tests pass with no console noise during error handling.
2025-10-26 12:44:00 -04:00

362 lines
10 KiB
TypeScript

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
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');
}
/**
* Serialize frontmatter object to YAML string with delimiters
* Returns the complete frontmatter block including --- delimiters
*/
static serializeFrontmatter(data: Record<string, any>): string {
if (!data || Object.keys(data).length === 0) {
return '';
}
const lines: string[] = ['---'];
for (const [key, value] of Object.entries(data)) {
if (value === undefined || value === null) {
continue;
}
// Handle different value types
if (Array.isArray(value)) {
// Array format
if (value.length === 0) {
lines.push(`${key}: []`);
} else {
lines.push(`${key}:`);
for (const item of value) {
const itemStr = typeof item === 'string' ? item : JSON.stringify(item);
lines.push(` - ${itemStr}`);
}
}
} else if (typeof value === 'object') {
// Object format (nested)
lines.push(`${key}:`);
for (const [subKey, subValue] of Object.entries(value)) {
const subValueStr = typeof subValue === 'string' ? subValue : JSON.stringify(subValue);
lines.push(` ${subKey}: ${subValueStr}`);
}
} else if (typeof value === 'string') {
// String - check if needs quoting
const needsQuotes = value.includes(':') || value.includes('#') ||
value.includes('[') || value.includes(']') ||
value.includes('{') || value.includes('}') ||
value.includes('|') || value.includes('>') ||
value.startsWith(' ') || value.endsWith(' ');
if (needsQuotes) {
// Escape quotes in the string
const escaped = value.replace(/"/g, '\\"');
lines.push(`${key}: "${escaped}"`);
} else {
lines.push(`${key}: ${value}`);
}
} else if (typeof value === 'number' || typeof value === 'boolean') {
// Number or boolean - direct serialization
lines.push(`${key}: ${value}`);
} else {
// Fallback to JSON stringification
lines.push(`${key}: ${JSON.stringify(value)}`);
}
}
lines.push('---');
return lines.join('\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 (one or more characters)
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]*?)```/, // One or more chars for language
/```\s*\n([\s\S]*?)```/ // No language specifier
];
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 {
// Validate base64 encoding (will throw on invalid data)
// This validates the compressed data is at least well-formed
/* istanbul ignore else - Buffer.from fallback for non-Node/browser environments without atob (Jest/Node always has atob) */
if (typeof atob !== 'undefined') {
// atob throws on invalid base64, unlike Buffer.from
atob(trimmedJson);
} else if (typeof Buffer !== 'undefined') {
// Buffer.from doesn't throw, but we keep it for completeness
Buffer.from(trimmedJson, 'base64');
}
// 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"');
return {
isExcalidraw: isExcalidraw,
elementCount: isExcalidraw ? 0 : undefined,
hasCompressedData: isExcalidraw ? false : undefined,
metadata: isExcalidraw ? {} : undefined
};
}
}
}