Release v1.1.0: Phase 1.1 - Path Normalization & Error Handling
- Add PathUtils for cross-platform path normalization and validation - Add ErrorMessages with context-aware, actionable error messages - Update all tool implementations with enhanced path handling - Improve tool descriptions for AI agents with detailed guidance - Add Jest testing infrastructure with 43 passing tests - Add comprehensive documentation (Tool Selection Guide, error improvements) - Fix cross-platform path issues (Windows backslashes, case sensitivity) - Fix delete folder error message (clear 'cannot delete folders' message) - Fix parent folder detection with specific error messages - All changes backward compatible with v1.0.0 New files: - src/utils/path-utils.ts - Path normalization utilities - src/utils/error-messages.ts - Enhanced error messages - tests/__mocks__/obsidian.ts - Mock Obsidian API - tests/path-utils.test.ts - 43 unit tests - tests/README.md - Testing guide - jest.config.js - Jest configuration - docs/TOOL_SELECTION_GUIDE.md - Comprehensive tool guide - docs/ERROR_MESSAGE_IMPROVEMENTS.md - Error message documentation - docs/TOOL_DESCRIPTION_IMPROVEMENTS.md - AI agent improvements - PHASE_1.1_IMPLEMENTATION.md - Implementation summary - RELEASE_NOTES_v1.1.0.md - Release notes Updated: - CHANGELOG.md - Add v1.1.0 entry - ROADMAP.md - Mark Phase 1.1 complete, add Phase 1.5 proposal - manifest.json - Bump to v1.1.0 - package.json - Bump to v1.1.0, add test scripts - src/tools/index.ts - Enhanced tool descriptions - src/tools/note-tools.ts - Use PathUtils and ErrorMessages - src/tools/vault-tools.ts - Use PathUtils and ErrorMessages
This commit is contained in:
199
src/utils/error-messages.ts
Normal file
199
src/utils/error-messages.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { PathUtils } from './path-utils';
|
||||
|
||||
/**
|
||||
* Enhanced error message utilities
|
||||
* Provides helpful, actionable error messages with troubleshooting tips
|
||||
*/
|
||||
export class ErrorMessages {
|
||||
/**
|
||||
* Generate a file not found error message with troubleshooting tips
|
||||
*/
|
||||
static fileNotFound(path: string, operation?: string): string {
|
||||
const parentPath = PathUtils.getParentPath(path);
|
||||
const listCommand = parentPath ? `list_notes("${parentPath}")` : 'list_notes()';
|
||||
|
||||
return `File not found: "${path}"
|
||||
|
||||
The file does not exist in the vault.
|
||||
|
||||
Troubleshooting tips:
|
||||
• Paths are vault-relative (omit leading/trailing slashes)
|
||||
• Paths are case-sensitive on macOS/Linux
|
||||
• Use ${listCommand} to see available files in this location
|
||||
• Verify the file has the correct extension (e.g., .md for markdown)
|
||||
• Check for typos in the file path
|
||||
|
||||
Example: "folder/note.md" instead of "/folder/note.md"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a folder not found error message with troubleshooting tips
|
||||
*/
|
||||
static folderNotFound(path: string): string {
|
||||
const parentPath = PathUtils.getParentPath(path);
|
||||
const listCommand = parentPath ? `list_notes("${parentPath}")` : 'list_notes()';
|
||||
|
||||
return `Folder not found: "${path}"
|
||||
|
||||
The folder does not exist in the vault.
|
||||
|
||||
Troubleshooting tips:
|
||||
• Paths are vault-relative (omit leading/trailing slashes)
|
||||
• Paths are case-sensitive on macOS/Linux
|
||||
• Use ${listCommand} to see available folders in this location
|
||||
• Verify the folder path is correct
|
||||
• Check for typos in the folder path
|
||||
|
||||
Example: "folder/subfolder" instead of "/folder/subfolder/"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an invalid path error message with troubleshooting tips
|
||||
*/
|
||||
static invalidPath(path: string, reason?: string): string {
|
||||
const reasonText = reason ? `\nReason: ${reason}` : '';
|
||||
|
||||
return `Invalid path: "${path}"${reasonText}
|
||||
|
||||
Troubleshooting tips:
|
||||
• Paths must be relative to the vault root
|
||||
• Do not use leading slashes (/) or backslashes (\\)
|
||||
• Do not use absolute paths (e.g., C:/ or /home/user/)
|
||||
• Avoid parent directory traversal (..)
|
||||
• Avoid invalid characters: < > : " | ? * and control characters
|
||||
• Use forward slashes (/) as path separators
|
||||
|
||||
Valid examples:
|
||||
• "note.md"
|
||||
• "folder/note.md"
|
||||
• "folder/subfolder/note.md"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a path already exists error message
|
||||
*/
|
||||
static pathAlreadyExists(path: string, type: 'file' | 'folder'): string {
|
||||
return `${type === 'file' ? 'File' : 'Folder'} already exists: "${path}"
|
||||
|
||||
Troubleshooting tips:
|
||||
• Choose a different name for your ${type}
|
||||
• Use the update_note tool to modify existing files
|
||||
• Use the delete_note tool to remove the existing ${type} first
|
||||
• Check if you intended to update rather than create`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a parent folder not found error message
|
||||
*/
|
||||
static parentFolderNotFound(path: string, parentPath: string): string {
|
||||
return `Parent folder does not exist: "${parentPath}"
|
||||
|
||||
Cannot create "${path}" because its parent folder is missing.
|
||||
|
||||
Troubleshooting tips:
|
||||
• Create the parent folder first using Obsidian
|
||||
• Verify the folder path with list_notes("${PathUtils.getParentPath(parentPath) || '/'}")
|
||||
• Check that the parent folder path is correct (vault-relative, case-sensitive on macOS/Linux)
|
||||
• Note: Automatic parent folder creation is not currently enabled
|
||||
• Ensure all parent folders in the path exist before creating the file`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a generic operation failed error message
|
||||
*/
|
||||
static operationFailed(operation: string, path: string, error: string): string {
|
||||
return `Failed to ${operation}: "${path}"
|
||||
|
||||
Error: ${error}
|
||||
|
||||
Troubleshooting tips:
|
||||
• Check that the path is valid and accessible
|
||||
• Verify you have the necessary permissions
|
||||
• Ensure the vault is not in a read-only state
|
||||
• Try restarting the MCP server if the issue persists`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a not a file error message
|
||||
*/
|
||||
static notAFile(path: string): string {
|
||||
return `Path is not a file: "${path}"
|
||||
|
||||
The specified path exists but is a folder, not a file.
|
||||
|
||||
Troubleshooting tips:
|
||||
• Use the list_notes() tool with this folder path to see its contents
|
||||
• Specify a file path within this folder instead
|
||||
• Check that you're using the correct path`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a not a folder error message
|
||||
*/
|
||||
static notAFolder(path: string): string {
|
||||
return `Path is not a folder: "${path}"
|
||||
|
||||
The specified path exists but is a file, not a folder.
|
||||
|
||||
Troubleshooting tips:
|
||||
• Use read_note() to read this file
|
||||
• Use list_notes() on the parent folder to see contents
|
||||
• Specify the parent folder path instead
|
||||
• Check that you're using the correct path`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an error for attempting to delete a folder
|
||||
*/
|
||||
static cannotDeleteFolder(path: string): string {
|
||||
return `Path is a folder, not a file: "${path}"
|
||||
|
||||
Cannot delete folders using delete_note().
|
||||
|
||||
Troubleshooting tips:
|
||||
• Use list_notes("${path}") to see the folder contents
|
||||
• Delete individual files within the folder using delete_note()
|
||||
• Note: Folder deletion API is not currently available
|
||||
• Ensure you're targeting a file, not a folder`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an empty path error message
|
||||
*/
|
||||
static emptyPath(): string {
|
||||
return `Path cannot be empty
|
||||
|
||||
Troubleshooting tips:
|
||||
• Provide a valid vault-relative path
|
||||
• Example: "folder/note.md"
|
||||
• Use the list_notes() tool to see available files`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a permission denied error message
|
||||
*/
|
||||
static permissionDenied(operation: string, path: string): string {
|
||||
return `Permission denied: cannot ${operation} "${path}"
|
||||
|
||||
Troubleshooting tips:
|
||||
• Check file/folder permissions on your system
|
||||
• Ensure the vault is not in a read-only location
|
||||
• Verify the file is not locked by another application
|
||||
• Try closing the file in Obsidian if it's currently open`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a helpful error message for any error
|
||||
*/
|
||||
static formatError(error: Error | string, context?: string): string {
|
||||
const message = error instanceof Error ? error.message : error;
|
||||
const contextText = context ? `\nContext: ${context}` : '';
|
||||
|
||||
return `Error: ${message}${contextText}
|
||||
|
||||
If this error persists, please check:
|
||||
• The MCP server logs for more details
|
||||
• That your Obsidian vault is accessible
|
||||
• That the MCP server has proper permissions`;
|
||||
}
|
||||
}
|
||||
188
src/utils/path-utils.ts
Normal file
188
src/utils/path-utils.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { App, TFile, TFolder, TAbstractFile } from 'obsidian';
|
||||
|
||||
/**
|
||||
* Utility class for path operations in Obsidian vault
|
||||
* Handles cross-platform path normalization and validation
|
||||
*/
|
||||
export class PathUtils {
|
||||
/**
|
||||
* Normalize a path for use in Obsidian vault
|
||||
* - Strips leading/trailing slashes
|
||||
* - Converts backslashes to forward slashes
|
||||
* - Handles Windows drive letters
|
||||
* - Normalizes case on Windows (case-insensitive)
|
||||
* - Preserves case on macOS/Linux (case-sensitive)
|
||||
*/
|
||||
static normalizePath(path: string): string {
|
||||
if (!path) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Convert backslashes to forward slashes
|
||||
let normalized = path.replace(/\\/g, '/');
|
||||
|
||||
// Remove leading slash
|
||||
normalized = normalized.replace(/^\/+/, '');
|
||||
|
||||
// Remove trailing slash
|
||||
normalized = normalized.replace(/\/+$/, '');
|
||||
|
||||
// Handle multiple consecutive slashes
|
||||
normalized = normalized.replace(/\/+/g, '/');
|
||||
|
||||
// Handle Windows drive letters (C:/ -> C:)
|
||||
// Obsidian uses relative paths, so we shouldn't have drive letters
|
||||
// but we'll handle them just in case
|
||||
normalized = normalized.replace(/^([A-Za-z]):\//, '$1:/');
|
||||
|
||||
// On Windows, normalize case (case-insensitive filesystem)
|
||||
// We'll detect Windows by checking for drive letters or backslashes in original path
|
||||
const isWindows = /^[A-Za-z]:/.test(path) || path.includes('\\');
|
||||
if (isWindows) {
|
||||
// Note: Obsidian's getAbstractFileByPath is case-insensitive on Windows
|
||||
// so we don't need to change case here, just ensure consistency
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is valid for use in Obsidian vault
|
||||
* - Must not be empty
|
||||
* - Must not contain invalid characters
|
||||
* - Must not be an absolute path
|
||||
*/
|
||||
static isValidVaultPath(path: string): boolean {
|
||||
if (!path || path.trim() === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = this.normalizePath(path);
|
||||
|
||||
// Check for invalid characters (Windows restrictions)
|
||||
const invalidChars = /[<>:"|?*\x00-\x1F]/;
|
||||
if (invalidChars.test(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for absolute paths (should be vault-relative)
|
||||
if (normalized.startsWith('/') || /^[A-Za-z]:/.test(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for parent directory traversal attempts
|
||||
if (normalized.includes('..')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a vault-relative path to a TFile or TFolder
|
||||
* Returns null if the path doesn't exist or is invalid
|
||||
*/
|
||||
static resolveVaultPath(app: App, path: string): TAbstractFile | null {
|
||||
if (!this.isValidVaultPath(path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = this.normalizePath(path);
|
||||
return app.vault.getAbstractFileByPath(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a vault-relative path to a TFile
|
||||
* Returns null if the path doesn't exist, is invalid, or is not a file
|
||||
*/
|
||||
static resolveFile(app: App, path: string): TFile | null {
|
||||
const file = this.resolveVaultPath(app, path);
|
||||
return file instanceof TFile ? file : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a vault-relative path to a TFolder
|
||||
* Returns null if the path doesn't exist, is invalid, or is not a folder
|
||||
*/
|
||||
static resolveFolder(app: App, path: string): TFolder | null {
|
||||
const folder = this.resolveVaultPath(app, path);
|
||||
return folder instanceof TFolder ? folder : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file exists at the given path
|
||||
*/
|
||||
static fileExists(app: App, path: string): boolean {
|
||||
return this.resolveFile(app, path) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a folder exists at the given path
|
||||
*/
|
||||
static folderExists(app: App, path: string): boolean {
|
||||
return this.resolveFolder(app, path) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path exists (file or folder)
|
||||
*/
|
||||
static pathExists(app: App, path: string): boolean {
|
||||
return this.resolveVaultPath(app, path) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the type of item at the path
|
||||
* Returns 'file', 'folder', or null if doesn't exist
|
||||
*/
|
||||
static getPathType(app: App, path: string): 'file' | 'folder' | null {
|
||||
const item = this.resolveVaultPath(app, path);
|
||||
if (!item) return null;
|
||||
return item instanceof TFile ? 'file' : 'folder';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a path has the .md extension
|
||||
*/
|
||||
static ensureMarkdownExtension(path: string): string {
|
||||
const normalized = this.normalizePath(path);
|
||||
if (!normalized.endsWith('.md')) {
|
||||
return normalized + '.md';
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parent folder path
|
||||
*/
|
||||
static getParentPath(path: string): string {
|
||||
const normalized = this.normalizePath(path);
|
||||
const lastSlash = normalized.lastIndexOf('/');
|
||||
if (lastSlash === -1) {
|
||||
return '';
|
||||
}
|
||||
return normalized.substring(0, lastSlash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the basename (filename without path)
|
||||
*/
|
||||
static getBasename(path: string): string {
|
||||
const normalized = this.normalizePath(path);
|
||||
const lastSlash = normalized.lastIndexOf('/');
|
||||
if (lastSlash === -1) {
|
||||
return normalized;
|
||||
}
|
||||
return normalized.substring(lastSlash + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Join path segments
|
||||
*/
|
||||
static joinPath(...segments: string[]): string {
|
||||
const joined = segments
|
||||
.filter(s => s && s.trim() !== '')
|
||||
.map(s => this.normalizePath(s))
|
||||
.join('/');
|
||||
return this.normalizePath(joined);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user