From a5e008457cef18f229596aa4a6545bb078935e8a Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Wed, 30 Jul 2025 11:49:26 +0100 Subject: [PATCH] feat: Add heading refactoring command with comprehensive link updates - Implements refactor-headings command to update heading text across files - Automatically updates anchor links when headings change - Supports custom slug generation with configurable options - Includes recursive directory processing and cross-file reference updates - Comprehensive test suite with 15 test cases covering all functionality - Dry-run support for safe preview of changes - Detailed reporting of heading changes and link updates Closes #31 --- src/cli.ts | 47 ++ src/commands/refactor-headings.test.ts | 556 +++++++++++++++++ src/commands/refactor-headings.ts | 524 ++++++++++++++++ src/generated/ajv-validators.ts | 670 +++++++++++---------- src/generated/api-routes.ts | 799 ++++++++++++------------- src/generated/mcp-tools.ts | 333 ++++++----- 6 files changed, 2039 insertions(+), 890 deletions(-) create mode 100644 src/commands/refactor-headings.test.ts create mode 100644 src/commands/refactor-headings.ts diff --git a/src/cli.ts b/src/cli.ts index f8b9b08..2ee0643 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -9,6 +9,7 @@ import { moveCommand } from './commands/move.js'; import { splitCommand } from './commands/split.js'; import { tocCommand } from './commands/toc.js'; import { validateCommand } from './commands/validate.js'; +import { refactorHeadingsCommand } from './commands/refactor-headings.js'; const program = new Command(); @@ -309,4 +310,50 @@ Output Options: ) .action(validateCommand); +program + .command('refactor-headings') + .description('Refactor markdown headings and update all affected links') + .argument('[files...]', 'Markdown files to process (supports globs, defaults to current directory)') + .option('--old-heading ', 'Original heading text to find and replace') + .option('--new-heading ', 'New heading text to replace with') + .option('-r, --recursive', 'Process directories recursively') + .option('--max-depth ', 'Maximum depth to traverse subdirectories', parseInt) + .option('--no-update-cross-references', 'Skip updating cross-file references') + .option('-d, --dry-run', 'Show what would be changed without making changes') + .option('-v, --verbose', 'Show detailed output with processing information') + .option('--json', 'Output results in JSON format') + .addHelpText( + 'after', + ` +Examples: + $ markmv refactor-headings docs/ --old-heading "API Reference" --new-heading "API Documentation" --recursive + $ markmv refactor-headings README.md --old-heading "Getting Started" --new-heading "Quick Start Guide" + $ markmv refactor-headings **/*.md --old-heading "Installation" --new-heading "Setup" --dry-run + $ markmv refactor-headings . --old-heading "Configuration" --new-heading "Settings" --verbose + $ markmv refactor-headings docs/ --old-heading "Usage" --new-heading "How to Use" --no-update-cross-references + +Features: + šŸ“ Updates heading text in place + šŸ”— Automatically updates anchor links (#old-slug → #new-slug) + 🌐 Updates cross-file heading references + šŸ”„ Maintains link integrity across the entire project + šŸ” Dry-run support for safe preview + šŸ“Š Comprehensive change reporting + ⚔ Leverages existing TocGenerator for consistent slug generation + +The command will: +1. Find all instances of the specified old heading text +2. Replace them with the new heading text +3. Generate old and new anchor slugs automatically +4. Update all anchor links that reference the old slug +5. Update cross-file references (unless --no-update-cross-references is used) +6. Provide detailed reporting of all changes made + +Slug Generation: +Headings are converted to URL-friendly anchor slugs using the same algorithm +as the toc command: lowercase, special characters become hyphens, spaces +become hyphens, multiple hyphens collapsed to single hyphens.` + ) + .action(refactorHeadingsCommand); + program.parse(); diff --git a/src/commands/refactor-headings.test.ts b/src/commands/refactor-headings.test.ts new file mode 100644 index 0000000..4a069c5 --- /dev/null +++ b/src/commands/refactor-headings.test.ts @@ -0,0 +1,556 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { readFile, writeFile, mkdir, rm } from 'fs/promises'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { refactorHeadings, formatRefactorHeadingsResults } from './refactor-headings.js'; +import type { RefactorHeadingsOperationOptions } from './refactor-headings.js'; + +describe('refactorHeadings', () => { + let testDir: string; + let testFile1: string; + let testFile2: string; + + beforeEach(async () => { + // Create real temporary directory for testing + testDir = join(tmpdir(), `markmv-test-${Date.now()}`); + await mkdir(testDir, { recursive: true }); + + testFile1 = join(testDir, 'file1.md'); + testFile2 = join(testDir, 'file2.md'); + }); + + afterEach(async () => { + // Clean up temporary directory + try { + await rm(testDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe('basic heading refactoring', () => { + it('should refactor heading text and generate correct slugs', async () => { + const testContent = `# Main Title + +## Getting Started + +This is some content. + +### Advanced Usage + +More content here. + +## Getting Started + +Duplicate heading for testing. +`; + + const expectedContent = `# Main Title + +## Quick Start Guide + +This is some content. + +### Advanced Usage + +More content here. + +## Quick Start Guide + +Duplicate heading for testing. +`; + + await writeFile(testFile1, testContent, 'utf-8'); + + const options: RefactorHeadingsOperationOptions = { + oldHeading: 'Getting Started', + newHeading: 'Quick Start Guide', + dryRun: false, + verbose: false, + }; + + const result = await refactorHeadings([testFile1], options); + + expect(result.success).toBe(true); + expect(result.filesProcessed).toBe(1); + expect(result.headingsChanged).toBe(2); + expect(result.headingChanges).toHaveLength(2); + + // Verify heading changes + expect(result.headingChanges[0]).toEqual({ + filePath: testFile1, + line: 3, + oldText: 'Getting Started', + newText: 'Quick Start Guide', + oldSlug: 'getting-started', + newSlug: 'quick-start-guide', + level: 2, + }); + + expect(result.headingChanges[1]).toEqual({ + filePath: testFile1, + line: 11, + oldText: 'Getting Started', + newText: 'Quick Start Guide', + oldSlug: 'getting-started', + newSlug: 'quick-start-guide', + level: 2, + }); + + // Check file was actually modified + const modifiedContent = await readFile(testFile1, 'utf-8'); + expect(modifiedContent).toBe(expectedContent); + }); + + it('should handle headings with different levels', async () => { + const testContent = `# Installation + +## Installation + +### Installation Steps + +#### Installation Notes +`; + + await writeFile(testFile1, testContent, 'utf-8'); + + const options: RefactorHeadingsOperationOptions = { + oldHeading: 'Installation', + newHeading: 'Setup', + dryRun: false, + verbose: false, + }; + + const result = await refactorHeadings([testFile1], options); + + expect(result.headingsChanged).toBe(2); // Only exact text matches: "Installation" and "Installation" + expect(result.headingChanges.map(c => c.level)).toEqual([1, 2]); + }); + + it('should skip files with no matching headings', async () => { + const testContent = `# Different Title + +## Other Section + +Content here. +`; + + await writeFile(testFile1, testContent, 'utf-8'); + + const options: RefactorHeadingsOperationOptions = { + oldHeading: 'Non-existent Heading', + newHeading: 'New Heading', + dryRun: false, + verbose: false, + }; + + const result = await refactorHeadings([testFile1], options); + + expect(result.filesProcessed).toBe(1); + expect(result.headingsChanged).toBe(0); + expect(result.headingChanges).toHaveLength(0); + + // File should not be modified + const unchangedContent = await readFile(testFile1, 'utf-8'); + expect(unchangedContent).toBe(testContent); + }); + }); + + describe('anchor link updating', () => { + it('should update anchor links that reference changed headings', async () => { + const testContent = `# Documentation + +## Getting Started + +See the [installation guide](#getting-started) for details. + +Jump to [Getting Started](#getting-started) section. + +Check out [other section](#other-section). + +## Other Section + +More content. +`; + + await writeFile(testFile1, testContent, 'utf-8'); + + const options: RefactorHeadingsOperationOptions = { + oldHeading: 'Getting Started', + newHeading: 'Quick Start Guide', + updateCrossReferences: true, + dryRun: false, + verbose: false, + }; + + const result = await refactorHeadings([testFile1], options); + + expect(result.headingsChanged).toBe(1); + expect(result.linksUpdated).toBe(2); + expect(result.linkUpdates).toHaveLength(2); + + // Verify link updates + expect(result.linkUpdates[0]).toEqual({ + filePath: testFile1, + line: 5, + oldLink: '#getting-started', + newLink: '#quick-start-guide', + linkType: 'anchor', + }); + + expect(result.linkUpdates[1]).toEqual({ + filePath: testFile1, + line: 7, + oldLink: '#getting-started', + newLink: '#quick-start-guide', + linkType: 'anchor', + }); + + // Check that links were actually updated in the file + const modifiedContent = await readFile(testFile1, 'utf-8'); + expect(modifiedContent).toContain('[installation guide](#quick-start-guide)'); + expect(modifiedContent).toContain('[Getting Started](#quick-start-guide)'); + expect(modifiedContent).toContain('[other section](#other-section)'); // Should remain unchanged + }); + + it('should not update anchor links when updateCrossReferences is false', async () => { + const testContent = `# Documentation + +## Getting Started + +See the [installation guide](#getting-started) for details. +`; + + await writeFile(testFile1, testContent, 'utf-8'); + + const options: RefactorHeadingsOperationOptions = { + oldHeading: 'Getting Started', + newHeading: 'Quick Start Guide', + updateCrossReferences: false, + dryRun: false, + verbose: false, + }; + + const result = await refactorHeadings([testFile1], options); + + expect(result.headingsChanged).toBe(1); + expect(result.linksUpdated).toBe(0); + expect(result.linkUpdates).toHaveLength(0); + + // Link should not be updated + const modifiedContent = await readFile(testFile1, 'utf-8'); + expect(modifiedContent).toContain('[installation guide](#getting-started)'); + }); + }); + + describe('dry run mode', () => { + it('should not write files in dry run mode', async () => { + const testContent = `# Main Title + +## Getting Started + +Content here. +`; + + await writeFile(testFile1, testContent, 'utf-8'); + + const options: RefactorHeadingsOperationOptions = { + oldHeading: 'Getting Started', + newHeading: 'Quick Start Guide', + dryRun: true, + verbose: false, + }; + + const result = await refactorHeadings([testFile1], options); + + expect(result.headingsChanged).toBe(1); + + // File should not be modified + const unchangedContent = await readFile(testFile1, 'utf-8'); + expect(unchangedContent).toBe(testContent); + }); + + it('should still detect and report changes in dry run mode', async () => { + const testContent = `# Documentation + +## Getting Started + +See [getting started](#getting-started) guide. +`; + + await writeFile(testFile1, testContent, 'utf-8'); + + const options: RefactorHeadingsOperationOptions = { + oldHeading: 'Getting Started', + newHeading: 'Quick Start Guide', + updateCrossReferences: true, + dryRun: true, + verbose: false, + }; + + const result = await refactorHeadings([testFile1], options); + + expect(result.headingsChanged).toBe(1); + expect(result.linksUpdated).toBe(1); + expect(result.headingChanges).toHaveLength(1); + expect(result.linkUpdates).toHaveLength(1); + + // File should not be modified + const unchangedContent = await readFile(testFile1, 'utf-8'); + expect(unchangedContent).toBe(testContent); + }); + }); + + describe('multiple files processing', () => { + it('should process multiple files correctly', async () => { + const file1Content = `# File 1 + +## Getting Started + +Content in file 1. +`; + + const file2Content = `# File 2 + +## Getting Started + +Content in file 2. + +See [getting started](#getting-started) for more. +`; + + await writeFile(testFile1, file1Content, 'utf-8'); + await writeFile(testFile2, file2Content, 'utf-8'); + + const options: RefactorHeadingsOperationOptions = { + oldHeading: 'Getting Started', + newHeading: 'Quick Start Guide', + updateCrossReferences: true, + dryRun: false, + verbose: false, + }; + + const result = await refactorHeadings([testFile1, testFile2], options); + + expect(result.filesProcessed).toBe(2); + expect(result.headingsChanged).toBe(2); // One heading in each file + expect(result.linksUpdated).toBe(1); // One link in file2 + }); + }); + + describe('error handling', () => { + it('should handle file read errors gracefully', async () => { + const nonexistentFile = join(testDir, 'nonexistent.md'); + + const options: RefactorHeadingsOperationOptions = { + oldHeading: 'Getting Started', + newHeading: 'Quick Start Guide', + dryRun: false, + verbose: false, + }; + + const result = await refactorHeadings([nonexistentFile], options); + + expect(result.filesProcessed).toBe(0); // No files were actually processed + expect(result.fileErrors).toHaveLength(1); + expect(result.fileErrors[0].file).toBe(nonexistentFile); + expect(result.fileErrors[0].error).toContain('Failed to resolve file pattern'); + expect(result.success).toBe(false); + }); + }); + + describe('custom slug generation', () => { + it('should use custom slugify function when provided', async () => { + const testContent = `## Getting Started\n\nContent.`; + await writeFile(testFile1, testContent, 'utf-8'); + + const options: RefactorHeadingsOperationOptions = { + oldHeading: 'Getting Started', + newHeading: 'Quick Start Guide', + slugify: (text: string) => text.toLowerCase().replace(/\s+/g, '_'), + dryRun: false, + verbose: false, + }; + + const result = await refactorHeadings([testFile1], options); + + expect(result.headingChanges[0].oldSlug).toBe('getting_started'); + expect(result.headingChanges[0].newSlug).toBe('quick_start_guide'); + }); + }); + + describe('regex escaping', () => { + it('should handle special characters in heading text', async () => { + const testContent = `## [API] Reference (v1.0) + +Content here. +`; + + const expectedContent = `## [New API] Reference (v2.0) + +Content here. +`; + + await writeFile(testFile1, testContent, 'utf-8'); + + const options: RefactorHeadingsOperationOptions = { + oldHeading: '[API] Reference (v1.0)', + newHeading: '[New API] Reference (v2.0)', + dryRun: false, + verbose: false, + }; + + const result = await refactorHeadings([testFile1], options); + + expect(result.headingsChanged).toBe(1); + + const modifiedContent = await readFile(testFile1, 'utf-8'); + expect(modifiedContent).toBe(expectedContent); + }); + }); +}); + +describe('formatRefactorHeadingsResults', () => { + it('should format results with heading changes and link updates', () => { + const result = { + success: true, + filesProcessed: 2, + headingsChanged: 3, + linksUpdated: 2, + headingChanges: [ + { + filePath: 'file1.md', + line: 5, + oldText: 'Getting Started', + newText: 'Quick Start Guide', + oldSlug: 'getting-started', + newSlug: 'quick-start-guide', + level: 2, + }, + { + filePath: 'file2.md', + line: 10, + oldText: 'Getting Started', + newText: 'Quick Start Guide', + oldSlug: 'getting-started', + newSlug: 'quick-start-guide', + level: 3, + }, + ], + linkUpdates: [ + { + filePath: 'file1.md', + line: 15, + oldLink: '#getting-started', + newLink: '#quick-start-guide', + linkType: 'anchor' as const, + }, + ], + fileErrors: [], + processingTime: 1500, + }; + + const options: RefactorHeadingsOperationOptions = { + oldHeading: 'Getting Started', + newHeading: 'Quick Start Guide', + dryRun: false, + verbose: false, + }; + + const formatted = formatRefactorHeadingsResults(result, options); + + expect(formatted).toContain('šŸ”§ Heading Refactoring Results'); + expect(formatted).toContain('Files processed: 2'); + expect(formatted).toContain('Headings changed: 3'); + expect(formatted).toContain('Links updated: 2'); + expect(formatted).toContain('Processing time: 1500ms'); + expect(formatted).toContain('šŸ“ Heading Changes:'); + expect(formatted).toContain('šŸ”— Link Updates:'); + expect(formatted).toContain('file1.md (line 5)'); + expect(formatted).toContain('## Getting Started'); + expect(formatted).toContain('## Quick Start Guide'); + expect(formatted).toContain('#getting-started → #quick-start-guide'); + }); + + it('should show dry run indicator when in dry run mode', () => { + const result = { + success: true, + filesProcessed: 1, + headingsChanged: 1, + linksUpdated: 0, + headingChanges: [], + linkUpdates: [], + fileErrors: [], + processingTime: 500, + }; + + const options: RefactorHeadingsOperationOptions = { + oldHeading: 'Old', + newHeading: 'New', + dryRun: true, + verbose: false, + }; + + const formatted = formatRefactorHeadingsResults(result, options); + + expect(formatted).toContain('šŸ” Dry run - no files were actually modified'); + }); + + it('should show errors when present', () => { + const result = { + success: false, + filesProcessed: 1, + headingsChanged: 0, + linksUpdated: 0, + headingChanges: [], + linkUpdates: [], + fileErrors: [ + { file: 'error.md', error: 'File not found' }, + { file: 'error2.md', error: 'Permission denied' }, + ], + processingTime: 100, + }; + + const options: RefactorHeadingsOperationOptions = { + oldHeading: 'Old', + newHeading: 'New', + dryRun: false, + verbose: false, + }; + + const formatted = formatRefactorHeadingsResults(result, options); + + expect(formatted).toContain('šŸ’„ Errors:'); + expect(formatted).toContain('šŸ’„ error.md: File not found'); + expect(formatted).toContain('šŸ’„ error2.md: Permission denied'); + }); + + it('should handle empty results gracefully', () => { + const result = { + success: true, + filesProcessed: 0, + headingsChanged: 0, + linksUpdated: 0, + headingChanges: [], + linkUpdates: [], + fileErrors: [], + processingTime: 0, + }; + + const options: RefactorHeadingsOperationOptions = { + oldHeading: 'Old', + newHeading: 'New', + dryRun: false, + verbose: false, + }; + + const formatted = formatRefactorHeadingsResults(result, options); + + expect(formatted).toContain('šŸ”§ Heading Refactoring Results'); + expect(formatted).toContain('Files processed: 0'); + expect(formatted).toContain('Headings changed: 0'); + expect(formatted).toContain('Links updated: 0'); + expect(formatted).not.toContain('šŸ“ Heading Changes:'); + expect(formatted).not.toContain('šŸ”— Link Updates:'); + }); +}); \ No newline at end of file diff --git a/src/commands/refactor-headings.ts b/src/commands/refactor-headings.ts new file mode 100644 index 0000000..fb69705 --- /dev/null +++ b/src/commands/refactor-headings.ts @@ -0,0 +1,524 @@ +import { glob } from 'glob'; +import { statSync } from 'fs'; +import { posix } from 'path'; +import { readFile, writeFile } from 'fs/promises'; +import { TocGenerator } from '../utils/toc-generator.js'; +import { LinkParser } from '../core/link-parser.js'; +import type { OperationOptions } from '../types/operations.js'; + +/** + * Configuration options for heading refactoring operations. + * + * Controls how heading changes are detected and how affected links are updated. + * + * @category Commands + */ +export interface RefactorHeadingsOperationOptions extends OperationOptions { + /** The original heading text to find and replace */ + oldHeading: string; + /** The new heading text to replace with */ + newHeading: string; + /** Process directories recursively */ + recursive?: boolean; + /** Maximum depth to traverse subdirectories */ + maxDepth?: number; + /** Custom slug generator function */ + slugify?: (text: string) => string; + /** Update cross-file references */ + updateCrossReferences?: boolean; +} + +/** + * CLI-specific options for the refactor-headings command. + * + * @category Commands + */ +export interface RefactorHeadingsCliOptions extends RefactorHeadingsOperationOptions { + /** Output results in JSON format */ + json?: boolean; +} + +/** + * Details about a heading change operation. + * + * @category Commands + */ +export interface HeadingChange { + /** File containing the heading */ + filePath: string; + /** Line number of the heading */ + line: number; + /** Original heading text */ + oldText: string; + /** New heading text */ + newText: string; + /** Original slug */ + oldSlug: string; + /** New slug */ + newSlug: string; + /** Heading level */ + level: number; +} + +/** + * Details about a link update operation. + * + * @category Commands + */ +export interface LinkUpdate { + /** File containing the link */ + filePath: string; + /** Line number of the link */ + line?: number; + /** Original link text/href */ + oldLink: string; + /** Updated link text/href */ + newLink: string; + /** Type of link updated */ + linkType: 'anchor' | 'reference'; +} + +/** + * Result of a heading refactoring operation. + * + * @category Commands + */ +export interface RefactorHeadingsResult { + /** Whether the operation completed successfully */ + success: boolean; + /** Number of files processed */ + filesProcessed: number; + /** Number of headings changed */ + headingsChanged: number; + /** Number of links updated */ + linksUpdated: number; + /** Detailed heading changes */ + headingChanges: HeadingChange[]; + /** Detailed link updates */ + linkUpdates: LinkUpdate[]; + /** Files that had processing errors */ + fileErrors: Array<{ file: string; error: string }>; + /** Processing time in milliseconds */ + processingTime: number; +} + +/** + * Default configuration for heading refactoring. + */ +const DEFAULT_REFACTOR_HEADINGS_OPTIONS: Partial = { + dryRun: false, + verbose: false, + recursive: false, + updateCrossReferences: true, +}; + +/** + * Refactors headings in markdown files and updates all affected links. + * + * This command finds all instances of a specified heading and updates them to new text, + * while automatically updating all anchor links and cross-file references that point + * to those headings. + * + * Features: + * - Updates heading text in place + * - Automatically updates anchor links (#old-slug → #new-slug) + * - Updates cross-file heading references + * - Maintains link integrity across the entire project + * - Supports custom slug generation + * - Dry-run support for safe preview + * - Comprehensive change reporting + * + * @example + * ```typescript + * // Basic heading refactoring + * const result = await refactorHeadings(['docs/'], { + * oldHeading: 'API Reference', + * newHeading: 'API Documentation', + * recursive: true + * }); + * + * // With custom slug generation + * const result = await refactorHeadings(['README.md'], { + * oldHeading: 'Getting Started', + * newHeading: 'Quick Start Guide', + * slugify: (text) => text.toLowerCase().replace(/\s+/g, '_') + * }); + * ``` + * + * @param files - Array of file paths or glob patterns to process + * @param options - Configuration options for the refactoring operation + * @returns Promise resolving to detailed results of the refactoring operation + * + * @group Commands + */ +export async function refactorHeadings( + files: string[], + options: RefactorHeadingsOperationOptions +): Promise { + const startTime = Date.now(); + const mergedOptions = { ...DEFAULT_REFACTOR_HEADINGS_OPTIONS, ...options }; + + if (mergedOptions.verbose) { + console.log('šŸ”§ Starting heading refactoring...'); + console.log(`šŸ“‹ Configuration: + - Old heading: "${options.oldHeading}" + - New heading: "${options.newHeading}" + - Recursive: ${mergedOptions.recursive} + - Update cross-references: ${mergedOptions.updateCrossReferences} + - Dry run: ${mergedOptions.dryRun}`); + } + + // Initialize result structure + const result: RefactorHeadingsResult = { + success: true, + filesProcessed: 0, + headingsChanged: 0, + linksUpdated: 0, + headingChanges: [], + linkUpdates: [], + fileErrors: [], + processingTime: 0, + }; + + // Resolve file patterns + const resolvedFiles = new Set(); + for (const filePattern of files) { + try { + if (statSync(filePattern).isDirectory()) { + const dirPattern = mergedOptions.recursive + ? posix.join(filePattern, '**/*.md') + : posix.join(filePattern, '*.md'); + + const globOptions: Parameters[1] = { + ignore: ['node_modules/**', '.git/**'] + }; + if (mergedOptions.maxDepth !== undefined) { + globOptions.maxDepth = mergedOptions.maxDepth; + } + + const matches = await glob(dirPattern, globOptions); + matches.forEach(file => resolvedFiles.add(file.toString())); + } else if (filePattern.includes('*')) { + const globOptions: Parameters[1] = { + ignore: ['node_modules/**', '.git/**'] + }; + if (mergedOptions.maxDepth !== undefined) { + globOptions.maxDepth = mergedOptions.maxDepth; + } + + const matches = await glob(filePattern, globOptions); + matches.forEach(file => resolvedFiles.add(file.toString())); + } else { + resolvedFiles.add(filePattern); + } + } catch (error) { + result.fileErrors.push({ + file: filePattern, + error: `Failed to resolve file pattern: ${error instanceof Error ? error.message : String(error)}` + }); + } + } + + const fileList = Array.from(resolvedFiles); + result.filesProcessed = fileList.length; + + if (mergedOptions.verbose) { + console.log(`šŸ“ Found ${fileList.length} markdown files to process`); + } + + // Initialize generators and parsers + const tocGenerator = new TocGenerator(); + const linkParser = new LinkParser(); + // Note: LinkRefactorer could be used for more advanced link updates in the future + + // Generate old and new slugs + const slugify = mergedOptions.slugify || tocGenerator['defaultSlugify'].bind(tocGenerator); + const oldSlug = slugify(options.oldHeading); + const newSlug = slugify(options.newHeading); + + if (mergedOptions.verbose) { + console.log(`šŸ”— Slug mapping: "${oldSlug}" → "${newSlug}"`); + } + + // Step 1: Find and update headings in all files + for (const filePath of fileList) { + try { + const content = await readFile(filePath, 'utf-8'); + const tocResult = await tocGenerator.generateToc(content); + + // Find headings that match the old heading text + const matchingHeadings = tocResult.headings.filter( + heading => heading.text.trim() === options.oldHeading.trim() + ); + + if (matchingHeadings.length === 0) { + continue; // No matching headings in this file + } + + if (mergedOptions.verbose) { + console.log(`\nšŸ“„ ${filePath}: found ${matchingHeadings.length} matching headings`); + } + + // Update headings in content + let updatedContent = content; + const headingChanges: HeadingChange[] = []; + + for (const heading of matchingHeadings) { + // Use custom slugify if provided, otherwise use the heading's existing slug + const actualOldSlug = mergedOptions.slugify ? slugify(heading.text) : heading.slug; + + // Create heading change record + const headingChange: HeadingChange = { + filePath, + line: heading.line, + oldText: heading.text, + newText: options.newHeading, + oldSlug: actualOldSlug, + newSlug, + level: heading.level, + }; + + headingChanges.push(headingChange); + result.headingChanges.push(headingChange); + + // Replace the heading text in content + const headingRegex = new RegExp( + `^(#{${heading.level}}\\s+)${escapeRegExp(heading.text.trim())}(\\s*)$`, + 'gm' + ); + + updatedContent = updatedContent.replace(headingRegex, `$1${options.newHeading}$2`); + } + + result.headingsChanged += headingChanges.length; + + // Write updated content if not dry run + if (!mergedOptions.dryRun && headingChanges.length > 0) { + await writeFile(filePath, updatedContent, 'utf-8'); + } + + } catch (error) { + result.fileErrors.push({ + file: filePath, + error: `Failed to process headings: ${error instanceof Error ? error.message : String(error)}` + }); + } + } + + // Step 2: Update anchor links and cross-references if requested + if (mergedOptions.updateCrossReferences && oldSlug !== newSlug) { + if (mergedOptions.verbose) { + console.log(`\nšŸ” Searching for anchor links to update: #${oldSlug} → #${newSlug}`); + } + + for (const filePath of fileList) { + try { + const parseResult = await linkParser.parseFile(filePath); + + // Find anchor links that reference the old slug + const anchorLinks = parseResult.links.filter( + link => link.type === 'anchor' && link.href === `#${oldSlug}` + ); + + if (anchorLinks.length === 0) { + continue; // No matching anchor links in this file + } + + if (mergedOptions.verbose) { + console.log(`šŸ“„ ${filePath}: found ${anchorLinks.length} anchor links to update`); + } + + // Update anchor links + const content = await readFile(filePath, 'utf-8'); + let updatedContent = content; + + for (const link of anchorLinks) { + const linkUpdate: LinkUpdate = { + filePath, + line: link.line, + oldLink: `#${oldSlug}`, + newLink: `#${newSlug}`, + linkType: 'anchor', + }; + + result.linkUpdates.push(linkUpdate); + + // Replace the anchor link + const oldLinkPattern = new RegExp(`#${escapeRegExp(oldSlug)}(?![\\w-])`, 'g'); + updatedContent = updatedContent.replace(oldLinkPattern, `#${newSlug}`); + } + + result.linksUpdated += anchorLinks.length; + + // Write updated content if not dry run + if (!mergedOptions.dryRun && anchorLinks.length > 0) { + await writeFile(filePath, updatedContent, 'utf-8'); + } + + } catch (error) { + result.fileErrors.push({ + file: filePath, + error: `Failed to update links: ${error instanceof Error ? error.message : String(error)}` + }); + } + } + } + + result.processingTime = Date.now() - startTime; + + // Set success to false if there were any errors + if (result.fileErrors.length > 0) { + result.success = false; + } + + if (mergedOptions.verbose) { + console.log(`\nāœ… Refactoring completed in ${result.processingTime}ms`); + console.log(`šŸ“Š Summary: ${result.headingsChanged} headings changed, ${result.linksUpdated} links updated`); + + if (mergedOptions.dryRun) { + console.log(`šŸ” Dry run - no files were actually modified`); + } + } + + return result; +} + +/** + * Command handler for the refactor-headings CLI command. + */ +export async function refactorHeadingsCommand( + files: string[] = ['.'], + options: RefactorHeadingsCliOptions +): Promise { + try { + if (!options.oldHeading || !options.newHeading) { + console.error('šŸ’„ Error: Both --old-heading and --new-heading are required'); + process.exit(1); + } + + if (options.oldHeading === options.newHeading) { + console.error('šŸ’„ Error: Old heading and new heading cannot be the same'); + process.exit(1); + } + + // Parse CLI options into RefactorHeadingsOperationOptions + const operationOptions: RefactorHeadingsOperationOptions = { + dryRun: options.dryRun || false, + verbose: options.verbose || false, + oldHeading: options.oldHeading, + newHeading: options.newHeading, + recursive: options.recursive || false, + maxDepth: options.maxDepth, + updateCrossReferences: options.updateCrossReferences !== false, // Default to true + }; + + // Run the refactor-headings operation + const result = await refactorHeadings(files, operationOptions); + + // Format and display results + if (options.json) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(formatRefactorHeadingsResults(result, operationOptions)); + } + + // Exit with error code if there were errors + if (result.fileErrors.length > 0) { + process.exit(1); + } + + } catch (error) { + console.error('šŸ’„ Error running refactor-headings command:'); + console.error(error instanceof Error ? error.message : String(error)); + + if (options.verbose && error instanceof Error && error.stack) { + console.error('\nStack trace:'); + console.error(error.stack); + } + + process.exit(1); + } +} + +/** + * Formats the refactor-headings results for display. + */ +export function formatRefactorHeadingsResults( + result: RefactorHeadingsResult, + options: RefactorHeadingsOperationOptions +): string { + const lines: string[] = []; + + lines.push('šŸ”§ Heading Refactoring Results'); + lines.push(''.padEnd(50, '=')); + lines.push(''); + + // Summary + lines.push(`šŸ“Š Summary:`); + lines.push(` Files processed: ${result.filesProcessed}`); + lines.push(` Headings changed: ${result.headingsChanged}`); + lines.push(` Links updated: ${result.linksUpdated}`); + lines.push(` Processing time: ${result.processingTime}ms`); + + if (options.dryRun) { + lines.push(` šŸ” Dry run - no files were actually modified`); + } + + lines.push(''); + + // Show heading changes + if (result.headingChanges.length > 0) { + lines.push('šŸ“ Heading Changes:'); + lines.push(''.padEnd(30, '-')); + + result.headingChanges.forEach(change => { + lines.push(`\nšŸ“„ ${change.filePath} (line ${change.line}):`); + lines.push(` ${'#'.repeat(change.level)} ${change.oldText}`); + lines.push(` ↓`); + lines.push(` ${'#'.repeat(change.level)} ${change.newText}`); + lines.push(` Slug: ${change.oldSlug} → ${change.newSlug}`); + }); + } + + // Show link updates + if (result.linkUpdates.length > 0) { + lines.push('\nšŸ”— Link Updates:'); + lines.push(''.padEnd(30, '-')); + + const linksByFile = result.linkUpdates.reduce((acc, update) => { + if (!acc[update.filePath]) { + acc[update.filePath] = []; + } + acc[update.filePath].push(update); + return acc; + }, {} satisfies Record); + + Object.entries(linksByFile).forEach(([file, updates]) => { + lines.push(`\nšŸ“„ ${file}:`); + updates.forEach(update => { + const lineInfo = update.line ? ` (line ${update.line})` : ''; + lines.push(` šŸ”— ${update.oldLink} → ${update.newLink}${lineInfo}`); + }); + }); + } + + // Show errors if any + if (result.fileErrors.length > 0) { + lines.push('\nšŸ’„ Errors:'); + lines.push(''.padEnd(30, '-')); + result.fileErrors.forEach(error => { + lines.push(` šŸ’„ ${error.file}: ${error.error}`); + }); + } + + return lines.join('\n'); +} + +/** + * Escapes special regex characters in a string. + */ +function escapeRegExp(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export { DEFAULT_REFACTOR_HEADINGS_OPTIONS }; \ No newline at end of file diff --git a/src/generated/ajv-validators.ts b/src/generated/ajv-validators.ts index 60eb2df..59cb9ae 100644 --- a/src/generated/ajv-validators.ts +++ b/src/generated/ajv-validators.ts @@ -1,431 +1,447 @@ /** * Auto-generated AJV validators for markmv API methods - * + * * DO NOT EDIT MANUALLY - This file is auto-generated */ import Ajv from 'ajv'; -const ajv = new Ajv({ - allErrors: true, +const ajv = new Ajv({ + allErrors: true, verbose: true, - strict: false, + strict: false }); // Schema definitions export const schemas = { - $schema: 'http://json-schema.org/draft-07/schema#', - title: 'markmv API Schemas', - description: 'Auto-generated schemas for markmv methods with @group annotations', - definitions: { - moveFile: { - title: 'moveFile', - description: 'Move a single markdown file and update all references', - type: 'object', - properties: { - input: { - type: 'object', - properties: { - sourcePath: { - type: 'string', - description: 'Source file path', + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "markmv API Schemas", + "description": "Auto-generated schemas for markmv methods with @group annotations", + "definitions": { + "moveFile": { + "title": "moveFile", + "description": "Move a single markdown file and update all references", + "type": "object", + "properties": { + "input": { + "type": "object", + "properties": { + "sourcePath": { + "type": "string", + "description": "Source file path" }, - destinationPath: { - type: 'string', - description: 'Destination file path', + "destinationPath": { + "type": "string", + "description": "Destination file path" }, - options: { - type: 'object', - properties: { - dryRun: { - type: 'boolean', - description: 'Show changes without executing', + "options": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "description": "Show changes without executing" }, - verbose: { - type: 'boolean', - description: 'Show detailed output', + "verbose": { + "type": "boolean", + "description": "Show detailed output" }, - force: { - type: 'boolean', - description: 'Force operation even if conflicts exist', - }, - createDirectories: { - type: 'boolean', - description: 'Create missing directories', + "force": { + "type": "boolean", + "description": "Force operation even if conflicts exist" }, + "createDirectories": { + "type": "boolean", + "description": "Create missing directories" + } }, - additionalProperties: false, - }, + "additionalProperties": false + } }, - required: ['sourcePath', 'destinationPath'], - additionalProperties: false, + "required": [ + "sourcePath", + "destinationPath" + ], + "additionalProperties": false }, - output: { - type: 'object', - properties: { - success: { - type: 'boolean', + "output": { + "type": "object", + "properties": { + "success": { + "type": "boolean" }, - modifiedFiles: { - type: 'array', - items: { - type: 'string', - }, + "modifiedFiles": { + "type": "array", + "items": { + "type": "string" + } }, - createdFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - deletedFiles: { - type: 'array', - items: { - type: 'string', - }, + "createdFiles": { + "type": "array", + "items": { + "type": "string" + } }, - errors: { - type: 'array', - items: { - type: 'string', - }, + "deletedFiles": { + "type": "array", + "items": { + "type": "string" + } }, - warnings: { - type: 'array', - items: { - type: 'string', - }, + "errors": { + "type": "array", + "items": { + "type": "string" + } }, - changes: { - type: 'array', - items: { - type: 'object', - }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } }, + "changes": { + "type": "array", + "items": { + "type": "object" + } + } }, - required: [ - 'success', - 'modifiedFiles', - 'createdFiles', - 'deletedFiles', - 'errors', - 'warnings', - 'changes', + "required": [ + "success", + "modifiedFiles", + "createdFiles", + "deletedFiles", + "errors", + "warnings", + "changes" ], - additionalProperties: false, - }, + "additionalProperties": false + } }, - additionalProperties: false, - 'x-group': 'Core API', - 'x-examples': [ - 'markmv move old.md new.md', - 'markmv move docs/old.md archive/renamed.md --dry-run', - ], + "additionalProperties": false, + "x-group": "Core API", + "x-examples": [ + "markmv move old.md new.md", + "markmv move docs/old.md archive/renamed.md --dry-run" + ] }, - moveFiles: { - title: 'moveFiles', - description: 'Move multiple markdown files and update all references', - type: 'object', - properties: { - input: { - type: 'object', - properties: { - moves: { - type: 'array', - description: 'Array of source/destination pairs', - items: { - type: 'object', - properties: { - source: { - type: 'string', - }, - destination: { - type: 'string', + "moveFiles": { + "title": "moveFiles", + "description": "Move multiple markdown files and update all references", + "type": "object", + "properties": { + "input": { + "type": "object", + "properties": { + "moves": { + "type": "array", + "description": "Array of source/destination pairs", + "items": { + "type": "object", + "properties": { + "source": { + "type": "string" }, + "destination": { + "type": "string" + } }, - required: ['source', 'destination'], - additionalProperties: false, - }, + "required": [ + "source", + "destination" + ], + "additionalProperties": false + } }, - options: { - type: 'object', - properties: { - dryRun: { - type: 'boolean', - description: 'Show changes without executing', - }, - verbose: { - type: 'boolean', - description: 'Show detailed output', + "options": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "description": "Show changes without executing" }, - force: { - type: 'boolean', - description: 'Force operation even if conflicts exist', + "verbose": { + "type": "boolean", + "description": "Show detailed output" }, - createDirectories: { - type: 'boolean', - description: 'Create missing directories', + "force": { + "type": "boolean", + "description": "Force operation even if conflicts exist" }, + "createDirectories": { + "type": "boolean", + "description": "Create missing directories" + } }, - additionalProperties: false, - }, + "additionalProperties": false + } }, - required: ['moves'], - additionalProperties: false, + "required": [ + "moves" + ], + "additionalProperties": false }, - output: { - type: 'object', - properties: { - success: { - type: 'boolean', + "output": { + "type": "object", + "properties": { + "success": { + "type": "boolean" }, - modifiedFiles: { - type: 'array', - items: { - type: 'string', - }, + "modifiedFiles": { + "type": "array", + "items": { + "type": "string" + } }, - createdFiles: { - type: 'array', - items: { - type: 'string', - }, + "createdFiles": { + "type": "array", + "items": { + "type": "string" + } }, - deletedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - errors: { - type: 'array', - items: { - type: 'string', - }, + "deletedFiles": { + "type": "array", + "items": { + "type": "string" + } }, - warnings: { - type: 'array', - items: { - type: 'string', - }, + "errors": { + "type": "array", + "items": { + "type": "string" + } }, - changes: { - type: 'array', - items: { - type: 'object', - }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } }, + "changes": { + "type": "array", + "items": { + "type": "object" + } + } }, - required: [ - 'success', - 'modifiedFiles', - 'createdFiles', - 'deletedFiles', - 'errors', - 'warnings', - 'changes', + "required": [ + "success", + "modifiedFiles", + "createdFiles", + "deletedFiles", + "errors", + "warnings", + "changes" ], - additionalProperties: false, - }, + "additionalProperties": false + } }, - additionalProperties: false, - 'x-group': 'Core API', - 'x-examples': ['markmv move-files --batch file1.md:new1.md file2.md:new2.md'], + "additionalProperties": false, + "x-group": "Core API", + "x-examples": [ + "markmv move-files --batch file1.md:new1.md file2.md:new2.md" + ] }, - validateOperation: { - title: 'validateOperation', - description: 'Validate the result of a previous operation for broken links', - type: 'object', - properties: { - input: { - type: 'object', - properties: { - result: { - type: 'object', - description: 'Operation result to validate', - properties: { - success: { - type: 'boolean', + "validateOperation": { + "title": "validateOperation", + "description": "Validate the result of a previous operation for broken links", + "type": "object", + "properties": { + "input": { + "type": "object", + "properties": { + "result": { + "type": "object", + "description": "Operation result to validate", + "properties": { + "success": { + "type": "boolean" }, - modifiedFiles: { - type: 'array', - items: { - type: 'string', - }, + "modifiedFiles": { + "type": "array", + "items": { + "type": "string" + } }, - createdFiles: { - type: 'array', - items: { - type: 'string', - }, + "createdFiles": { + "type": "array", + "items": { + "type": "string" + } }, - deletedFiles: { - type: 'array', - items: { - type: 'string', - }, + "deletedFiles": { + "type": "array", + "items": { + "type": "string" + } }, - errors: { - type: 'array', - items: { - type: 'string', - }, + "errors": { + "type": "array", + "items": { + "type": "string" + } }, - warnings: { - type: 'array', - items: { - type: 'string', - }, - }, - changes: { - type: 'array', - items: { - type: 'object', - }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } }, + "changes": { + "type": "array", + "items": { + "type": "object" + } + } }, - required: [ - 'success', - 'modifiedFiles', - 'createdFiles', - 'deletedFiles', - 'errors', - 'warnings', - 'changes', + "required": [ + "success", + "modifiedFiles", + "createdFiles", + "deletedFiles", + "errors", + "warnings", + "changes" ], - additionalProperties: false, - }, + "additionalProperties": false + } }, - required: ['result'], - additionalProperties: false, + "required": [ + "result" + ], + "additionalProperties": false }, - output: { - type: 'object', - properties: { - valid: { - type: 'boolean', - }, - brokenLinks: { - type: 'number', + "output": { + "type": "object", + "properties": { + "valid": { + "type": "boolean" }, - errors: { - type: 'array', - items: { - type: 'string', - }, + "brokenLinks": { + "type": "number" }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + } }, - required: ['valid', 'brokenLinks', 'errors'], - additionalProperties: false, - }, + "required": [ + "valid", + "brokenLinks", + "errors" + ], + "additionalProperties": false + } }, - additionalProperties: false, - 'x-group': 'Core API', - 'x-examples': [], + "additionalProperties": false, + "x-group": "Core API", + "x-examples": [] }, - testAutoExposure: { - title: 'testAutoExposure', - description: 'Test function to demonstrate auto-exposure pattern', - type: 'object', - properties: { - input: { - type: 'object', - properties: { - input: { - type: 'string', - description: 'The input message to echo', - }, + "testAutoExposure": { + "title": "testAutoExposure", + "description": "Test function to demonstrate auto-exposure pattern", + "type": "object", + "properties": { + "input": { + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "The input message to echo" + } }, - required: ['input'], - additionalProperties: false, + "required": [ + "input" + ], + "additionalProperties": false }, - output: { - type: 'object', - properties: { - message: { - type: 'string', + "output": { + "type": "object", + "properties": { + "message": { + "type": "string" }, - timestamp: { - type: 'string', - }, - success: { - type: 'boolean', + "timestamp": { + "type": "string" }, + "success": { + "type": "boolean" + } }, - required: ['message', 'timestamp', 'success'], - additionalProperties: false, - }, + "required": [ + "message", + "timestamp", + "success" + ], + "additionalProperties": false + } }, - additionalProperties: false, - 'x-group': 'Testing', - 'x-examples': ['markmv test "Hello World"'], - }, - }, + "additionalProperties": false, + "x-group": "Testing", + "x-examples": [ + "markmv test \"Hello World\"" + ] + } + } }; // Compiled validators export const validators = { moveFile: { input: ajv.compile(schemas.definitions.moveFile.properties.input), - output: ajv.compile(schemas.definitions.moveFile.properties.output), + output: ajv.compile(schemas.definitions.moveFile.properties.output) }, moveFiles: { input: ajv.compile(schemas.definitions.moveFiles.properties.input), - output: ajv.compile(schemas.definitions.moveFiles.properties.output), + output: ajv.compile(schemas.definitions.moveFiles.properties.output) }, validateOperation: { input: ajv.compile(schemas.definitions.validateOperation.properties.input), - output: ajv.compile(schemas.definitions.validateOperation.properties.output), + output: ajv.compile(schemas.definitions.validateOperation.properties.output) }, testAutoExposure: { input: ajv.compile(schemas.definitions.testAutoExposure.properties.input), - output: ajv.compile(schemas.definitions.testAutoExposure.properties.output), - }, + output: ajv.compile(schemas.definitions.testAutoExposure.properties.output) + } }; -/** Validate input for a specific method */ -export function validateInput( - methodName: string, - data: unknown -): { valid: boolean; errors: string[] } { +/** + * Validate input for a specific method + */ +export function validateInput(methodName: string, data: unknown): { valid: boolean; errors: string[] } { const validator = validators[methodName as keyof typeof validators]?.input; if (!validator) { return { valid: false, errors: [`Unknown method: ${methodName}`] }; } - + const valid = validator(data); - return valid - ? { valid, errors: [] } - : { - valid, - errors: validator.errors?.map((err) => `${err.instancePath} ${err.message}`) ?? [ - 'Validation failed', - ], - }; + return valid ? { valid, errors: [] } : { + valid, + errors: validator.errors?.map(err => `${err.instancePath} ${err.message}`) ?? ['Validation failed'] + }; } -/** Validate output for a specific method */ -export function validateOutput( - methodName: string, - data: unknown -): { valid: boolean; errors: string[] } { +/** + * Validate output for a specific method + */ +export function validateOutput(methodName: string, data: unknown): { valid: boolean; errors: string[] } { const validator = validators[methodName as keyof typeof validators]?.output; if (!validator) { return { valid: false, errors: [`Unknown method: ${methodName}`] }; } - + const valid = validator(data); - return valid - ? { valid, errors: [] } - : { - valid, - errors: validator.errors?.map((err) => `${err.instancePath} ${err.message}`) ?? [ - 'Validation failed', - ], - }; + return valid ? { valid, errors: [] } : { + valid, + errors: validator.errors?.map(err => `${err.instancePath} ${err.message}`) ?? ['Validation failed'] + }; } -/** Get list of available methods */ +/** + * Get list of available methods + */ export function getAvailableMethods(): string[] { return Object.keys(validators); } diff --git a/src/generated/api-routes.ts b/src/generated/api-routes.ts index af31fbf..4bb1c6f 100644 --- a/src/generated/api-routes.ts +++ b/src/generated/api-routes.ts @@ -1,6 +1,6 @@ /** * Auto-generated REST API route definitions for markmv API methods - * + * * DO NOT EDIT MANUALLY - This file is auto-generated */ @@ -11,11 +11,7 @@ import type { FileOperations } from '../core/file-operations.js'; export interface ApiRoute { path: string; method: 'GET' | 'POST' | 'PUT' | 'DELETE'; - handler: ( - req: IncomingMessage, - res: ServerResponse, - markmvInstance: FileOperations - ) => Promise; + handler: (req: IncomingMessage, res: ServerResponse, markmvInstance: FileOperations) => Promise; description: string; inputSchema: object; outputSchema: object; @@ -27,568 +23,557 @@ export const autoGeneratedApiRoutes: ApiRoute[] = [ path: '/api/move-file', method: 'POST', handler: createmoveFileHandler, - description: 'Move a single markdown file and update all references', + description: "Move a single markdown file and update all references", inputSchema: { - type: 'object', - properties: { - sourcePath: { - type: 'string', - description: 'Source file path', - }, - destinationPath: { - type: 'string', - description: 'Destination file path', - }, - options: { - type: 'object', - properties: { - dryRun: { - type: 'boolean', - description: 'Show changes without executing', + "type": "object", + "properties": { + "sourcePath": { + "type": "string", + "description": "Source file path" }, - verbose: { - type: 'boolean', - description: 'Show detailed output', + "destinationPath": { + "type": "string", + "description": "Destination file path" }, - force: { - type: 'boolean', - description: 'Force operation even if conflicts exist', - }, - createDirectories: { - type: 'boolean', - description: 'Create missing directories', - }, - }, - additionalProperties: false, - }, + "options": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "description": "Show changes without executing" + }, + "verbose": { + "type": "boolean", + "description": "Show detailed output" + }, + "force": { + "type": "boolean", + "description": "Force operation even if conflicts exist" + }, + "createDirectories": { + "type": "boolean", + "description": "Create missing directories" + } + }, + "additionalProperties": false + } }, - required: ['sourcePath', 'destinationPath'], - additionalProperties: false, - }, + "required": [ + "sourcePath", + "destinationPath" + ], + "additionalProperties": false +}, outputSchema: { - type: 'object', - properties: { - success: { - type: 'boolean', - }, - modifiedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - createdFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - deletedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - errors: { - type: 'array', - items: { - type: 'string', - }, - }, - warnings: { - type: 'array', - items: { - type: 'string', - }, - }, - changes: { - type: 'array', - items: { - type: 'object', - }, - }, + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "modifiedFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "createdFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "deletedFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "changes": { + "type": "array", + "items": { + "type": "object" + } + } }, - required: [ - 'success', - 'modifiedFiles', - 'createdFiles', - 'deletedFiles', - 'errors', - 'warnings', - 'changes', + "required": [ + "success", + "modifiedFiles", + "createdFiles", + "deletedFiles", + "errors", + "warnings", + "changes" ], - additionalProperties: false, - }, + "additionalProperties": false +} }, { path: '/api/move-files', method: 'POST', handler: createmoveFilesHandler, - description: 'Move multiple markdown files and update all references', + description: "Move multiple markdown files and update all references", inputSchema: { - type: 'object', - properties: { - moves: { - type: 'array', - description: 'Array of source/destination pairs', - items: { - type: 'object', - properties: { - source: { - type: 'string', - }, - destination: { - type: 'string', - }, + "type": "object", + "properties": { + "moves": { + "type": "array", + "description": "Array of source/destination pairs", + "items": { + "type": "object", + "properties": { + "source": { + "type": "string" + }, + "destination": { + "type": "string" + } + }, + "required": [ + "source", + "destination" + ], + "additionalProperties": false + } + }, + "options": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "description": "Show changes without executing" + }, + "verbose": { + "type": "boolean", + "description": "Show detailed output" + }, + "force": { + "type": "boolean", + "description": "Force operation even if conflicts exist" + }, + "createDirectories": { + "type": "boolean", + "description": "Create missing directories" + } + }, + "additionalProperties": false + } + }, + "required": [ + "moves" + ], + "additionalProperties": false +}, + outputSchema: { + "type": "object", + "properties": { + "success": { + "type": "boolean" }, - required: ['source', 'destination'], - additionalProperties: false, - }, - }, - options: { - type: 'object', - properties: { - dryRun: { - type: 'boolean', - description: 'Show changes without executing', + "modifiedFiles": { + "type": "array", + "items": { + "type": "string" + } }, - verbose: { - type: 'boolean', - description: 'Show detailed output', + "createdFiles": { + "type": "array", + "items": { + "type": "string" + } }, - force: { - type: 'boolean', - description: 'Force operation even if conflicts exist', + "deletedFiles": { + "type": "array", + "items": { + "type": "string" + } }, - createDirectories: { - type: 'boolean', - description: 'Create missing directories', + "errors": { + "type": "array", + "items": { + "type": "string" + } }, - }, - additionalProperties: false, - }, - }, - required: ['moves'], - additionalProperties: false, - }, - outputSchema: { - type: 'object', - properties: { - success: { - type: 'boolean', - }, - modifiedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - createdFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - deletedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - errors: { - type: 'array', - items: { - type: 'string', - }, - }, - warnings: { - type: 'array', - items: { - type: 'string', - }, - }, - changes: { - type: 'array', - items: { - type: 'object', - }, - }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "changes": { + "type": "array", + "items": { + "type": "object" + } + } }, - required: [ - 'success', - 'modifiedFiles', - 'createdFiles', - 'deletedFiles', - 'errors', - 'warnings', - 'changes', + "required": [ + "success", + "modifiedFiles", + "createdFiles", + "deletedFiles", + "errors", + "warnings", + "changes" ], - additionalProperties: false, - }, + "additionalProperties": false +} }, { path: '/api/validate-operation', method: 'POST', handler: createvalidateOperationHandler, - description: 'Validate the result of a previous operation for broken links', + description: "Validate the result of a previous operation for broken links", inputSchema: { - type: 'object', - properties: { - result: { - type: 'object', - description: 'Operation result to validate', - properties: { - success: { - type: 'boolean', - }, - modifiedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - createdFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - deletedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - errors: { - type: 'array', - items: { - type: 'string', - }, - }, - warnings: { - type: 'array', - items: { - type: 'string', - }, - }, - changes: { - type: 'array', - items: { - type: 'object', - }, - }, - }, - required: [ - 'success', - 'modifiedFiles', - 'createdFiles', - 'deletedFiles', - 'errors', - 'warnings', - 'changes', - ], - additionalProperties: false, - }, + "type": "object", + "properties": { + "result": { + "type": "object", + "description": "Operation result to validate", + "properties": { + "success": { + "type": "boolean" + }, + "modifiedFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "createdFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "deletedFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "changes": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "required": [ + "success", + "modifiedFiles", + "createdFiles", + "deletedFiles", + "errors", + "warnings", + "changes" + ], + "additionalProperties": false + } }, - required: ['result'], - additionalProperties: false, - }, + "required": [ + "result" + ], + "additionalProperties": false +}, outputSchema: { - type: 'object', - properties: { - valid: { - type: 'boolean', - }, - brokenLinks: { - type: 'number', - }, - errors: { - type: 'array', - items: { - type: 'string', - }, - }, + "type": "object", + "properties": { + "valid": { + "type": "boolean" + }, + "brokenLinks": { + "type": "number" + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + } }, - required: ['valid', 'brokenLinks', 'errors'], - additionalProperties: false, - }, + "required": [ + "valid", + "brokenLinks", + "errors" + ], + "additionalProperties": false +} }, { path: '/api/test-auto-exposure', method: 'POST', handler: createtestAutoExposureHandler, - description: 'Test function to demonstrate auto-exposure pattern', + description: "Test function to demonstrate auto-exposure pattern", inputSchema: { - type: 'object', - properties: { - input: { - type: 'string', - description: 'The input message to echo', - }, + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "The input message to echo" + } }, - required: ['input'], - additionalProperties: false, - }, + "required": [ + "input" + ], + "additionalProperties": false +}, outputSchema: { - type: 'object', - properties: { - message: { - type: 'string', - }, - timestamp: { - type: 'string', - }, - success: { - type: 'boolean', - }, + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "timestamp": { + "type": "string" + }, + "success": { + "type": "boolean" + } }, - required: ['message', 'timestamp', 'success'], - additionalProperties: false, - }, - }, + "required": [ + "message", + "timestamp", + "success" + ], + "additionalProperties": false +} + } ]; // These handler functions will be created dynamically by the API server // They are placeholders for the auto-generated route definitions export async function createmoveFileHandler( - req: IncomingMessage, + req: IncomingMessage, res: ServerResponse, markmvInstance: FileOperations ): Promise { try { // Parse request body const body = await parseRequestBody(req); - + // Validate input const inputValidation = validateInput('moveFile', body); if (!inputValidation.valid) { res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Validation failed', - details: inputValidation.errors, - }) - ); + res.end(JSON.stringify({ + error: 'Validation failed', + details: inputValidation.errors + })); return; } - + // Route to appropriate method based on methodName let result: unknown; if (typeof body !== 'object' || body === null || Array.isArray(body)) { throw new Error('Invalid request body'); } - + const bodyObj = body as Record; + const sourcePath = bodyObj.sourcePath; - const destinationPath = bodyObj.destinationPath; + const destinationPath = bodyObj.destinationPath; const options = bodyObj.options || {}; - - if ( - typeof sourcePath === 'string' && - typeof destinationPath === 'string' && - typeof options === 'object' && - options !== null && - !Array.isArray(options) - ) { - result = await markmvInstance.moveFile( - sourcePath, - destinationPath, - options as Record - ); + + if (typeof sourcePath === 'string' && typeof destinationPath === 'string' && + (typeof options === 'object' && options !== null && !Array.isArray(options))) { + result = await markmvInstance.moveFile(sourcePath, destinationPath, options as Record); } else { throw new Error('Invalid parameters for moveFile'); } - + // Validate output const outputValidation = validateOutput('moveFile', result); if (!outputValidation.valid) { console.warn('Output validation failed for moveFile:', outputValidation.errors); } - + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); } catch (error) { res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Internal server error', - message: error instanceof Error ? error.message : String(error), - }) - ); + res.end(JSON.stringify({ + error: 'Internal server error', + message: error instanceof Error ? error.message : String(error) + })); } } export async function createmoveFilesHandler( - req: IncomingMessage, + req: IncomingMessage, res: ServerResponse, markmvInstance: FileOperations ): Promise { try { // Parse request body const body = await parseRequestBody(req); - + // Validate input const inputValidation = validateInput('moveFiles', body); if (!inputValidation.valid) { res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Validation failed', - details: inputValidation.errors, - }) - ); + res.end(JSON.stringify({ + error: 'Validation failed', + details: inputValidation.errors + })); return; } - + // Route to appropriate method based on methodName let result: unknown; if (typeof body !== 'object' || body === null || Array.isArray(body)) { throw new Error('Invalid request body'); } - + const bodyObj = body as Record; + const moves = bodyObj.moves; const options = bodyObj.options || {}; - - if ( - Array.isArray(moves) && - typeof options === 'object' && - options !== null && - !Array.isArray(options) - ) { + + if (Array.isArray(moves) && + (typeof options === 'object' && options !== null && !Array.isArray(options))) { result = await markmvInstance.moveFiles(moves, options as Record); } else { throw new Error('Invalid parameters for moveFiles'); } - + // Validate output const outputValidation = validateOutput('moveFiles', result); if (!outputValidation.valid) { console.warn('Output validation failed for moveFiles:', outputValidation.errors); } - + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); } catch (error) { res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Internal server error', - message: error instanceof Error ? error.message : String(error), - }) - ); + res.end(JSON.stringify({ + error: 'Internal server error', + message: error instanceof Error ? error.message : String(error) + })); } } export async function createvalidateOperationHandler( - req: IncomingMessage, + req: IncomingMessage, res: ServerResponse, markmvInstance: FileOperations ): Promise { try { // Parse request body const body = await parseRequestBody(req); - + // Validate input const inputValidation = validateInput('validateOperation', body); if (!inputValidation.valid) { res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Validation failed', - details: inputValidation.errors, - }) - ); + res.end(JSON.stringify({ + error: 'Validation failed', + details: inputValidation.errors + })); return; } - + // Route to appropriate method based on methodName let result: unknown; if (typeof body !== 'object' || body === null || Array.isArray(body)) { throw new Error('Invalid request body'); } - + const bodyObj = body as Record; + const operationResult = bodyObj.result; - - if ( - typeof operationResult === 'object' && - operationResult !== null && - !Array.isArray(operationResult) - ) { + + if (typeof operationResult === 'object' && operationResult !== null && !Array.isArray(operationResult)) { // Type guard to ensure operationResult has required OperationResult properties const opResult = operationResult as Record; - if ( - typeof opResult.success === 'boolean' && - Array.isArray(opResult.modifiedFiles) && - Array.isArray(opResult.createdFiles) && - Array.isArray(opResult.deletedFiles) && - Array.isArray(opResult.errors) && - Array.isArray(opResult.warnings) && - Array.isArray(opResult.changes) - ) { - result = await markmvInstance.validateOperation( - opResult as unknown as import('../types/operations.js').OperationResult - ); + if (typeof opResult.success === 'boolean' && + Array.isArray(opResult.modifiedFiles) && + Array.isArray(opResult.createdFiles) && + Array.isArray(opResult.deletedFiles) && + Array.isArray(opResult.errors) && + Array.isArray(opResult.warnings) && + Array.isArray(opResult.changes)) { + result = await markmvInstance.validateOperation(opResult as unknown as import('../types/operations.js').OperationResult); } else { throw new Error('Invalid OperationResult structure'); } } else { throw new Error('Invalid parameters for validateOperation'); } - + // Validate output const outputValidation = validateOutput('validateOperation', result); if (!outputValidation.valid) { console.warn('Output validation failed for validateOperation:', outputValidation.errors); } - + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); } catch (error) { res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Internal server error', - message: error instanceof Error ? error.message : String(error), - }) - ); + res.end(JSON.stringify({ + error: 'Internal server error', + message: error instanceof Error ? error.message : String(error) + })); } } export async function createtestAutoExposureHandler( - req: IncomingMessage, + req: IncomingMessage, res: ServerResponse, _markmvInstance: FileOperations ): Promise { try { // Parse request body const body = await parseRequestBody(req); - + // Validate input const inputValidation = validateInput('testAutoExposure', body); if (!inputValidation.valid) { res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Validation failed', - details: inputValidation.errors, - }) - ); + res.end(JSON.stringify({ + error: 'Validation failed', + details: inputValidation.errors + })); return; } - + // Route to appropriate method based on methodName let result: unknown; if (typeof body !== 'object' || body === null || Array.isArray(body)) { throw new Error('Invalid request body'); } - + const bodyObj = body as Record; + const input = bodyObj.input; - + if (typeof input === 'string') { // Import and call the standalone function const { testAutoExposure } = await import('../index.js'); @@ -596,32 +581,32 @@ export async function createtestAutoExposureHandler( } else { throw new Error('Invalid parameters for testAutoExposure'); } - + // Validate output const outputValidation = validateOutput('testAutoExposure', result); if (!outputValidation.valid) { console.warn('Output validation failed for testAutoExposure:', outputValidation.errors); } - + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(result)); } catch (error) { res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - error: 'Internal server error', - message: error instanceof Error ? error.message : String(error), - }) - ); + res.end(JSON.stringify({ + error: 'Internal server error', + message: error instanceof Error ? error.message : String(error) + })); } } -/** Helper functions */ +/** + * Helper functions + */ async function parseRequestBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { let body = ''; - req.on('data', (chunk) => { + req.on('data', chunk => { body += chunk.toString(); }); req.on('end', () => { @@ -635,12 +620,16 @@ async function parseRequestBody(req: IncomingMessage): Promise { }); } -/** Get API route by path */ +/** + * Get API route by path + */ export function getApiRouteByPath(path: string): ApiRoute | undefined { - return autoGeneratedApiRoutes.find((route) => route.path === path); + return autoGeneratedApiRoutes.find(route => route.path === path); } -/** Get all API route paths */ +/** + * Get all API route paths + */ export function getApiRoutePaths(): string[] { - return autoGeneratedApiRoutes.map((route) => route.path); + return autoGeneratedApiRoutes.map(route => route.path); } diff --git a/src/generated/mcp-tools.ts b/src/generated/mcp-tools.ts index fb176e5..764ff58 100644 --- a/src/generated/mcp-tools.ts +++ b/src/generated/mcp-tools.ts @@ -1,6 +1,6 @@ /** * Auto-generated MCP tool definitions for markmv API methods - * + * * DO NOT EDIT MANUALLY - This file is auto-generated */ @@ -10,184 +10,201 @@ import type { Tool } from '@modelcontextprotocol/sdk/types.js'; export const autoGeneratedMcpTools: Tool[] = [ { name: 'move_file', - description: 'Move a single markdown file and update all references', + description: "Move a single markdown file and update all references", inputSchema: { - type: 'object', - properties: { - sourcePath: { - type: 'string', - description: 'Source file path', - }, - destinationPath: { - type: 'string', - description: 'Destination file path', - }, - options: { - type: 'object', - properties: { - dryRun: { - type: 'boolean', - description: 'Show changes without executing', - }, - verbose: { - type: 'boolean', - description: 'Show detailed output', - }, - force: { - type: 'boolean', - description: 'Force operation even if conflicts exist', - }, - createDirectories: { - type: 'boolean', - description: 'Create missing directories', - }, - }, - additionalProperties: false, - }, + "type": "object", + "properties": { + "sourcePath": { + "type": "string", + "description": "Source file path" + }, + "destinationPath": { + "type": "string", + "description": "Destination file path" + }, + "options": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "description": "Show changes without executing" + }, + "verbose": { + "type": "boolean", + "description": "Show detailed output" + }, + "force": { + "type": "boolean", + "description": "Force operation even if conflicts exist" + }, + "createDirectories": { + "type": "boolean", + "description": "Create missing directories" + } + }, + "additionalProperties": false + } }, - required: ['sourcePath', 'destinationPath'], - additionalProperties: false, - }, + "required": [ + "sourcePath", + "destinationPath" + ], + "additionalProperties": false +} }, { name: 'move_files', - description: 'Move multiple markdown files and update all references', + description: "Move multiple markdown files and update all references", inputSchema: { - type: 'object', - properties: { - moves: { - type: 'array', - description: 'Array of source/destination pairs', - items: { - type: 'object', - properties: { - source: { - type: 'string', - }, - destination: { - type: 'string', - }, - }, - required: ['source', 'destination'], - additionalProperties: false, - }, - }, - options: { - type: 'object', - properties: { - dryRun: { - type: 'boolean', - description: 'Show changes without executing', - }, - verbose: { - type: 'boolean', - description: 'Show detailed output', - }, - force: { - type: 'boolean', - description: 'Force operation even if conflicts exist', - }, - createDirectories: { - type: 'boolean', - description: 'Create missing directories', - }, - }, - additionalProperties: false, - }, + "type": "object", + "properties": { + "moves": { + "type": "array", + "description": "Array of source/destination pairs", + "items": { + "type": "object", + "properties": { + "source": { + "type": "string" + }, + "destination": { + "type": "string" + } + }, + "required": [ + "source", + "destination" + ], + "additionalProperties": false + } + }, + "options": { + "type": "object", + "properties": { + "dryRun": { + "type": "boolean", + "description": "Show changes without executing" + }, + "verbose": { + "type": "boolean", + "description": "Show detailed output" + }, + "force": { + "type": "boolean", + "description": "Force operation even if conflicts exist" + }, + "createDirectories": { + "type": "boolean", + "description": "Create missing directories" + } + }, + "additionalProperties": false + } }, - required: ['moves'], - additionalProperties: false, - }, + "required": [ + "moves" + ], + "additionalProperties": false +} }, { name: 'validate_operation', - description: 'Validate the result of a previous operation for broken links', + description: "Validate the result of a previous operation for broken links", inputSchema: { - type: 'object', - properties: { - result: { - type: 'object', - description: 'Operation result to validate', - properties: { - success: { - type: 'boolean', - }, - modifiedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - createdFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - deletedFiles: { - type: 'array', - items: { - type: 'string', - }, - }, - errors: { - type: 'array', - items: { - type: 'string', - }, - }, - warnings: { - type: 'array', - items: { - type: 'string', - }, - }, - changes: { - type: 'array', - items: { - type: 'object', - }, - }, - }, - required: [ - 'success', - 'modifiedFiles', - 'createdFiles', - 'deletedFiles', - 'errors', - 'warnings', - 'changes', - ], - additionalProperties: false, - }, + "type": "object", + "properties": { + "result": { + "type": "object", + "description": "Operation result to validate", + "properties": { + "success": { + "type": "boolean" + }, + "modifiedFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "createdFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "deletedFiles": { + "type": "array", + "items": { + "type": "string" + } + }, + "errors": { + "type": "array", + "items": { + "type": "string" + } + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + } + }, + "changes": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "required": [ + "success", + "modifiedFiles", + "createdFiles", + "deletedFiles", + "errors", + "warnings", + "changes" + ], + "additionalProperties": false + } }, - required: ['result'], - additionalProperties: false, - }, + "required": [ + "result" + ], + "additionalProperties": false +} }, { name: 'test_auto_exposure', - description: 'Test function to demonstrate auto-exposure pattern', + description: "Test function to demonstrate auto-exposure pattern", inputSchema: { - type: 'object', - properties: { - input: { - type: 'string', - description: 'The input message to echo', - }, + "type": "object", + "properties": { + "input": { + "type": "string", + "description": "The input message to echo" + } }, - required: ['input'], - additionalProperties: false, - }, - }, + "required": [ + "input" + ], + "additionalProperties": false +} + } ]; -/** Get MCP tool by name */ +/** + * Get MCP tool by name + */ export function getMcpToolByName(name: string): Tool | undefined { - return autoGeneratedMcpTools.find((tool) => tool.name === name); + return autoGeneratedMcpTools.find(tool => tool.name === name); } -/** Get all MCP tool names */ +/** + * Get all MCP tool names + */ export function getMcpToolNames(): string[] { - return autoGeneratedMcpTools.map((tool) => tool.name); + return autoGeneratedMcpTools.map(tool => tool.name); } +