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:
2025-10-17 00:16:14 -04:00
parent e6cdd6d90a
commit 4e399e00f8
7 changed files with 489 additions and 26 deletions

View File

@@ -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}` }],

View File

@@ -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}` }]

View File

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

View File

@@ -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
View 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);
}
}