feat: Phase 7 - Waypoint Support
- Add get_folder_waypoint tool to extract waypoint blocks from folder notes - Add is_folder_note tool to detect folder notes by basename or waypoint markers - Implement waypoint edit protection in update_note to prevent corruption - Create waypoint-utils.ts with helper functions for waypoint operations - Add FolderWaypointResult and FolderNoteResult types - Update ROADMAP.md and CHANGELOG.md with Phase 7 completion - All manual tests passing
This commit is contained in:
@@ -256,6 +256,34 @@ export class ToolRegistry {
|
||||
},
|
||||
required: ["path"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "get_folder_waypoint",
|
||||
description: "Get Waypoint block from a folder note. Waypoint blocks (%% Begin Waypoint %% ... %% End Waypoint %%) are auto-generated by the Waypoint plugin to create folder indexes. Returns structured JSON with waypoint presence, line range, extracted wikilinks, and raw content. Use this to inspect folder note navigation structures without parsing the entire file.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Vault-relative path to the folder note (e.g., 'projects/projects.md' or 'daily/daily.md'). Paths are case-sensitive on macOS/Linux. Do not use leading or trailing slashes."
|
||||
}
|
||||
},
|
||||
required: ["path"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "is_folder_note",
|
||||
description: "Check if a note is a folder note. A folder note is identified by either having the same basename as its parent folder OR containing Waypoint markers. Returns structured JSON with boolean result, detection reason (basename_match, waypoint_marker, both, or none), and folder path. Use this to identify navigation/index notes in your vault structure.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Vault-relative path to the note to check (e.g., 'projects/projects.md'). Paths are case-sensitive on macOS/Linux. Do not use leading or trailing slashes."
|
||||
}
|
||||
},
|
||||
required: ["path"]
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -311,6 +339,10 @@ export class ToolRegistry {
|
||||
includeCompressed: args.includeCompressed,
|
||||
includePreview: args.includePreview
|
||||
});
|
||||
case "get_folder_waypoint":
|
||||
return await this.vaultTools.getFolderWaypoint(args.path);
|
||||
case "is_folder_note":
|
||||
return await this.vaultTools.isFolderNote(args.path);
|
||||
default:
|
||||
return {
|
||||
content: [{ type: "text", text: `Unknown tool: ${name}` }],
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CallToolResult, ParsedNote, ExcalidrawMetadata } from '../types/mcp-typ
|
||||
import { PathUtils } from '../utils/path-utils';
|
||||
import { ErrorMessages } from '../utils/error-messages';
|
||||
import { FrontmatterUtils } from '../utils/frontmatter-utils';
|
||||
import { WaypointUtils } from '../utils/waypoint-utils';
|
||||
|
||||
export class NoteTools {
|
||||
constructor(private app: App) {}
|
||||
@@ -230,6 +231,28 @@ export class NoteTools {
|
||||
}
|
||||
|
||||
try {
|
||||
// Check for waypoint edit protection
|
||||
const currentContent = await this.app.vault.read(file);
|
||||
const waypointCheck = WaypointUtils.wouldAffectWaypoint(currentContent, content);
|
||||
|
||||
if (waypointCheck.affected) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `Cannot update note: This would modify a Waypoint block.\n\n` +
|
||||
`Waypoint blocks (%% Begin Waypoint %% ... %% End Waypoint %%) are auto-generated ` +
|
||||
`by the Waypoint plugin and should not be manually edited.\n\n` +
|
||||
`Waypoint location: lines ${waypointCheck.waypointRange?.start}-${waypointCheck.waypointRange?.end}\n\n` +
|
||||
`Troubleshooting tips:\n` +
|
||||
`• Use get_folder_waypoint() to view the current waypoint content\n` +
|
||||
`• Edit content outside the waypoint block\n` +
|
||||
`• Let the Waypoint plugin regenerate the block automatically\n` +
|
||||
`• If you need to force this edit, the waypoint will need to be regenerated`
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
await this.app.vault.modify(file, content);
|
||||
return {
|
||||
content: [{ type: "text", text: `Note updated successfully: ${file.path}` }]
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { App, TFile, TFolder } from 'obsidian';
|
||||
import { CallToolResult, FileMetadata, DirectoryMetadata, VaultInfo, SearchResult, SearchMatch, StatResult, ExistsResult, ListResult, FileMetadataWithFrontmatter, FrontmatterSummary, WaypointSearchResult } from '../types/mcp-types';
|
||||
import { CallToolResult, FileMetadata, DirectoryMetadata, VaultInfo, SearchResult, SearchMatch, StatResult, ExistsResult, ListResult, FileMetadataWithFrontmatter, FrontmatterSummary, WaypointSearchResult, FolderWaypointResult, FolderNoteResult } from '../types/mcp-types';
|
||||
import { PathUtils } from '../utils/path-utils';
|
||||
import { ErrorMessages } from '../utils/error-messages';
|
||||
import { GlobUtils } from '../utils/glob-utils';
|
||||
import { SearchUtils } from '../utils/search-utils';
|
||||
import { WaypointUtils } from '../utils/waypoint-utils';
|
||||
|
||||
export class VaultTools {
|
||||
constructor(private app: App) {}
|
||||
@@ -596,4 +597,96 @@ export class VaultTools {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async getFolderWaypoint(path: string): Promise<CallToolResult> {
|
||||
try {
|
||||
// Normalize and validate path
|
||||
const normalizedPath = PathUtils.normalizePath(path);
|
||||
|
||||
// Resolve file
|
||||
const file = PathUtils.resolveFile(this.app, normalizedPath);
|
||||
if (!file) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: ErrorMessages.fileNotFound(normalizedPath)
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
// Read file content
|
||||
const content = await this.app.vault.read(file);
|
||||
|
||||
// Extract waypoint block
|
||||
const waypointBlock = WaypointUtils.extractWaypointBlock(content);
|
||||
|
||||
const result: FolderWaypointResult = {
|
||||
path: file.path,
|
||||
hasWaypoint: waypointBlock.hasWaypoint,
|
||||
waypointRange: waypointBlock.waypointRange,
|
||||
links: waypointBlock.links,
|
||||
rawContent: waypointBlock.rawContent
|
||||
};
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify(result, null, 2)
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `Get folder waypoint error: ${(error as Error).message}`
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async isFolderNote(path: string): Promise<CallToolResult> {
|
||||
try {
|
||||
// Normalize and validate path
|
||||
const normalizedPath = PathUtils.normalizePath(path);
|
||||
|
||||
// Resolve file
|
||||
const file = PathUtils.resolveFile(this.app, normalizedPath);
|
||||
if (!file) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: ErrorMessages.fileNotFound(normalizedPath)
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
// Check if it's a folder note
|
||||
const folderNoteInfo = await WaypointUtils.isFolderNote(this.app, file);
|
||||
|
||||
const result: FolderNoteResult = {
|
||||
path: file.path,
|
||||
isFolderNote: folderNoteInfo.isFolderNote,
|
||||
reason: folderNoteInfo.reason,
|
||||
folderPath: folderNoteInfo.folderPath
|
||||
};
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify(result, null, 2)
|
||||
}]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: `Is folder note error: ${(error as Error).message}`
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,22 @@ export interface WaypointSearchResult {
|
||||
filesSearched: number;
|
||||
}
|
||||
|
||||
// Phase 7: Waypoint Support Types
|
||||
export interface FolderWaypointResult {
|
||||
path: string;
|
||||
hasWaypoint: boolean;
|
||||
waypointRange?: { start: number; end: number };
|
||||
links?: string[];
|
||||
rawContent?: string;
|
||||
}
|
||||
|
||||
export interface FolderNoteResult {
|
||||
path: string;
|
||||
isFolderNote: boolean;
|
||||
reason: 'basename_match' | 'waypoint_marker' | 'both' | 'none';
|
||||
folderPath?: string;
|
||||
}
|
||||
|
||||
// Phase 3: Discovery Endpoint Types
|
||||
export interface StatResult {
|
||||
path: string;
|
||||
|
||||
191
src/utils/waypoint-utils.ts
Normal file
191
src/utils/waypoint-utils.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { App, TFile } from 'obsidian';
|
||||
|
||||
/**
|
||||
* Waypoint block information
|
||||
*/
|
||||
export interface WaypointBlock {
|
||||
hasWaypoint: boolean;
|
||||
waypointRange?: { start: number; end: number };
|
||||
links?: string[];
|
||||
rawContent?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Folder note detection result
|
||||
*/
|
||||
export interface FolderNoteInfo {
|
||||
isFolderNote: boolean;
|
||||
reason: 'basename_match' | 'waypoint_marker' | 'both' | 'none';
|
||||
folderPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utilities for working with Waypoint plugin markers
|
||||
*/
|
||||
export class WaypointUtils {
|
||||
private static readonly WAYPOINT_START = /%% Begin Waypoint %%/;
|
||||
private static readonly WAYPOINT_END = /%% End Waypoint %%/;
|
||||
private static readonly LINK_PATTERN = /\[\[([^\]]+)\]\]/g;
|
||||
|
||||
/**
|
||||
* Extract waypoint block from file content
|
||||
*/
|
||||
static extractWaypointBlock(content: string): WaypointBlock {
|
||||
const lines = content.split('\n');
|
||||
let inWaypoint = false;
|
||||
let waypointStart = -1;
|
||||
let waypointContent: string[] = [];
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
if (this.WAYPOINT_START.test(line)) {
|
||||
inWaypoint = true;
|
||||
waypointStart = i + 1; // 1-indexed, line after marker
|
||||
waypointContent = [];
|
||||
} else if (this.WAYPOINT_END.test(line) && inWaypoint) {
|
||||
// Extract links from waypoint content
|
||||
const contentStr = waypointContent.join('\n');
|
||||
const links: string[] = [];
|
||||
let linkMatch: RegExpExecArray | null;
|
||||
|
||||
// Reset lastIndex for global regex
|
||||
this.LINK_PATTERN.lastIndex = 0;
|
||||
|
||||
while ((linkMatch = this.LINK_PATTERN.exec(contentStr)) !== null) {
|
||||
links.push(linkMatch[1]);
|
||||
}
|
||||
|
||||
return {
|
||||
hasWaypoint: true,
|
||||
waypointRange: {
|
||||
start: waypointStart,
|
||||
end: i + 1 // 1-indexed, line of end marker
|
||||
},
|
||||
links,
|
||||
rawContent: contentStr
|
||||
};
|
||||
} else if (inWaypoint) {
|
||||
waypointContent.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// No waypoint found or unclosed waypoint
|
||||
return { hasWaypoint: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content contains a waypoint block
|
||||
*/
|
||||
static hasWaypointMarker(content: string): boolean {
|
||||
return this.WAYPOINT_START.test(content) && this.WAYPOINT_END.test(content);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is a folder note
|
||||
* A folder note is a note that:
|
||||
* 1. Has the same basename as its parent folder, OR
|
||||
* 2. Contains waypoint markers
|
||||
*/
|
||||
static async isFolderNote(app: App, file: TFile): Promise<FolderNoteInfo> {
|
||||
const basename = file.basename;
|
||||
const parentFolder = file.parent;
|
||||
|
||||
// Check basename match
|
||||
const basenameMatch = parentFolder && parentFolder.name === basename;
|
||||
|
||||
// Check for waypoint markers
|
||||
let hasWaypoint = false;
|
||||
try {
|
||||
const content = await app.vault.read(file);
|
||||
hasWaypoint = this.hasWaypointMarker(content);
|
||||
} catch (error) {
|
||||
// If we can't read the file, we can't check for waypoints
|
||||
console.error(`Failed to read file ${file.path}:`, error);
|
||||
}
|
||||
|
||||
// Determine result
|
||||
let reason: 'basename_match' | 'waypoint_marker' | 'both' | 'none';
|
||||
if (basenameMatch && hasWaypoint) {
|
||||
reason = 'both';
|
||||
} else if (basenameMatch) {
|
||||
reason = 'basename_match';
|
||||
} else if (hasWaypoint) {
|
||||
reason = 'waypoint_marker';
|
||||
} else {
|
||||
reason = 'none';
|
||||
}
|
||||
|
||||
return {
|
||||
isFolderNote: basenameMatch || hasWaypoint,
|
||||
reason,
|
||||
folderPath: parentFolder?.path
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an edit would affect a waypoint block
|
||||
* Returns true if the edit should be blocked
|
||||
*/
|
||||
static wouldAffectWaypoint(
|
||||
content: string,
|
||||
newContent: string
|
||||
): { affected: boolean; waypointRange?: { start: number; end: number } } {
|
||||
const waypointBlock = this.extractWaypointBlock(content);
|
||||
|
||||
if (!waypointBlock.hasWaypoint) {
|
||||
return { affected: false };
|
||||
}
|
||||
|
||||
// Check if the waypoint block still exists in the new content
|
||||
const newWaypointBlock = this.extractWaypointBlock(newContent);
|
||||
|
||||
if (!newWaypointBlock.hasWaypoint) {
|
||||
// Waypoint was removed
|
||||
return {
|
||||
affected: true,
|
||||
waypointRange: waypointBlock.waypointRange
|
||||
};
|
||||
}
|
||||
|
||||
// Check if waypoint content changed
|
||||
if (waypointBlock.rawContent !== newWaypointBlock.rawContent) {
|
||||
return {
|
||||
affected: true,
|
||||
waypointRange: waypointBlock.waypointRange
|
||||
};
|
||||
}
|
||||
|
||||
// Check if waypoint range changed (lines were added/removed before it)
|
||||
if (
|
||||
waypointBlock.waypointRange!.start !== newWaypointBlock.waypointRange!.start ||
|
||||
waypointBlock.waypointRange!.end !== newWaypointBlock.waypointRange!.end
|
||||
) {
|
||||
// This is acceptable - waypoint content is the same, just moved
|
||||
return { affected: false };
|
||||
}
|
||||
|
||||
return { affected: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the parent folder path for a file path
|
||||
*/
|
||||
static getParentFolderPath(filePath: string): string | null {
|
||||
const lastSlash = filePath.lastIndexOf('/');
|
||||
if (lastSlash === -1) {
|
||||
return null; // File is in root
|
||||
}
|
||||
return filePath.substring(0, lastSlash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the basename without extension
|
||||
*/
|
||||
static getBasename(filePath: string): string {
|
||||
const lastSlash = filePath.lastIndexOf('/');
|
||||
const filename = lastSlash === -1 ? filePath : filePath.substring(lastSlash + 1);
|
||||
const lastDot = filename.lastIndexOf('.');
|
||||
return lastDot === -1 ? filename : filename.substring(0, lastDot);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user