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:
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