/ src / services / dreamnode-migration-service.ts
dreamnode-migration-service.ts
  1  /**
  2   * DreamNode Migration Service
  3   *
  4   * Handles migration of DreamNode folder names from old naming conventions to PascalCase.
  5   * This includes:
  6   * - Renaming folders on file system
  7   * - Updating .udd titles to human-readable format
  8   * - Updating git submodule paths (.git file gitdir references)
  9   * - Updating parent .gitmodules files
 10   * - Updating DreamSong.canvas file paths (parent refs + submodule to standalone)
 11   * - Renaming GitHub repositories (if published)
 12   * - Updating git remote URLs
 13   *
 14   * Architecture Safety:
 15   * - All UUIDs remain unchanged (relationships unaffected)
 16   * - Migration is idempotent - can be run multiple times safely
 17   * - Only file system paths change, not data integrity
 18   */
 19  
 20  import { Plugin } from 'obsidian';
 21  import { sanitizeTitleToPascalCase, isPascalCase, pascalCaseToTitle } from '../utils/title-sanitization';
 22  import { useInterBrainStore } from '../store/interbrain-store';
 23  
 24  const { exec } = require('child_process');
 25  const { promisify } = require('util');
 26  const fs = require('fs');
 27  const path = require('path');
 28  
 29  const execAsync = promisify(exec);
 30  const fsPromises = fs.promises;
 31  
 32  interface VaultAdapter {
 33    path?: string;
 34    basePath?: string;
 35  }
 36  
 37  export interface MigrationResult {
 38    success: boolean;
 39    oldPath: string;
 40    newPath: string;
 41    changes: string[];
 42    errors: string[];
 43  }
 44  
 45  export class DreamNodeMigrationService {
 46    private plugin: Plugin;
 47    private vaultPath: string;
 48  
 49    constructor(plugin: Plugin) {
 50      this.plugin = plugin;
 51  
 52      // Get vault path
 53      const adapter = plugin.app.vault.adapter as VaultAdapter;
 54      let vaultPath = '';
 55      if (typeof adapter.path === 'string') {
 56        vaultPath = adapter.path;
 57      } else if (typeof adapter.basePath === 'string') {
 58        vaultPath = adapter.basePath;
 59      }
 60      this.vaultPath = vaultPath;
 61    }
 62  
 63    /**
 64     * Normalize title to human-readable format with spaces
 65     *
 66     * Handles:
 67     * - PascalCase: "ThunderstormGenerator" → "Thunderstorm Generator"
 68     * - kebab-case: "thunderstorm-generator" → "Thunderstorm Generator"
 69     * - snake_case: "thunderstorm_generator" → "Thunderstorm Generator"
 70     * - Mixed: "Thunderstorm-Generator-UPDATED" → "Thunderstorm Generator Updated"
 71     * - Already human: "Thunderstorm Generator" → "Thunderstorm Generator" (no change)
 72     */
 73    private normalizeToHumanReadable(title: string): string {
 74      // If title contains hyphens, underscores, or periods as separators
 75      if (/[-_.]+/.test(title)) {
 76        // Replace separators with spaces and normalize
 77        return title
 78          .split(/[-_.]+/)                    // Split on hyphens, underscores, periods
 79          .filter(word => word.length > 0)
 80          .map(word => {
 81            // Capitalize first letter, lowercase rest (proper title case)
 82            const cleaned = word.trim();
 83            if (cleaned.length === 0) return '';
 84            return cleaned.charAt(0).toUpperCase() + cleaned.slice(1).toLowerCase();
 85          })
 86          .join(' ')
 87          .trim();
 88      }
 89  
 90      // If title is pure PascalCase (no separators), convert to spaced format
 91      if (isPascalCase(title)) {
 92        return pascalCaseToTitle(title);
 93      }
 94  
 95      // Already human-readable with spaces, return as-is
 96      return title;
 97    }
 98  
 99    /**
100     * Migrate a single DreamNode to PascalCase naming
101     */
102    async migrateSingleNode(nodeId: string): Promise<MigrationResult> {
103      const changes: string[] = [];
104      const errors: string[] = [];
105  
106      try {
107        // Get node from store
108        const store = useInterBrainStore.getState();
109        const nodeData = store.realNodes.get(nodeId);
110  
111        if (!nodeData) {
112          return {
113            success: false,
114            oldPath: '',
115            newPath: '',
116            changes: [],
117            errors: [`Node ${nodeId} not found in store`]
118          };
119        }
120  
121        const node = nodeData.node;
122        const oldFolderName = node.repoPath;
123        const oldFullPath = path.join(this.vaultPath, oldFolderName);
124  
125        // Read .udd to get title
126        const uddPath = path.join(oldFullPath, '.udd');
127        if (!fs.existsSync(uddPath)) {
128          return {
129            success: false,
130            oldPath: oldFullPath,
131            newPath: '',
132            changes: [],
133            errors: ['.udd file not found']
134          };
135        }
136  
137        const uddContent = await fsPromises.readFile(uddPath, 'utf-8');
138        const udd = JSON.parse(uddContent);
139  
140        // Step 1: Normalize .udd title to human-readable format if needed
141        let titleUpdated = false;
142        const humanTitle = this.normalizeToHumanReadable(udd.title);
143        if (humanTitle !== udd.title) {
144          console.log(`DreamNodeMigration: Converting title to human-readable: "${udd.title}" → "${humanTitle}"`);
145          udd.title = humanTitle;
146          titleUpdated = true;
147        }
148  
149        // Step 2: Generate new PascalCase folder name from (now human-readable) title
150        const newFolderName = sanitizeTitleToPascalCase(udd.title);
151        const newFullPath = path.join(this.vaultPath, newFolderName);
152  
153        // Check if already PascalCase (no folder rename needed)
154        if (oldFolderName === newFolderName) {
155          // Folder is correct, but we may have updated the title
156          if (titleUpdated) {
157            // Write updated .udd with human-readable title
158            await fsPromises.writeFile(uddPath, JSON.stringify(udd, null, 2));
159            changes.push(`Updated .udd title: "${pascalCaseToTitle(oldFolderName)}" (human-readable)`);
160  
161            // Commit the .udd update
162            try {
163              await execAsync(
164                'git add .udd && git commit --no-verify -m "Convert title to human-readable format" || true',
165                { cwd: oldFullPath }
166              );
167              changes.push('Committed .udd title update');
168            } catch (error) {
169              errors.push(`Failed to commit .udd update: ${error instanceof Error ? error.message : String(error)}`);
170            }
171  
172            // Update store with new title
173            node.name = udd.title;
174            const store = useInterBrainStore.getState();
175            store.updateRealNode(nodeId, {
176              ...nodeData,
177              node,
178              lastSynced: Date.now()
179            });
180            changes.push('Updated node name in store');
181  
182            return {
183              success: errors.length === 0,
184              oldPath: oldFullPath,
185              newPath: newFullPath,
186              changes,
187              errors
188            };
189          }
190  
191          // Nothing to do
192          return {
193            success: true,
194            oldPath: oldFullPath,
195            newPath: newFullPath,
196            changes: ['Already using PascalCase naming - no migration needed'],
197            errors: []
198          };
199        }
200  
201        // Check if target already exists
202        if (fs.existsSync(newFullPath)) {
203          return {
204            success: false,
205            oldPath: oldFullPath,
206            newPath: newFullPath,
207            changes: [],
208            errors: [`Target path already exists: ${newFolderName}`]
209          };
210        }
211  
212        // Check if this is a clean repo (no uncommitted changes)
213        try {
214          const statusResult = await execAsync('git status --porcelain', { cwd: oldFullPath });
215          if (statusResult.stdout.trim().length > 0) {
216            return {
217              success: false,
218              oldPath: oldFullPath,
219              newPath: newFullPath,
220              changes: [],
221              errors: ['Repository has uncommitted changes - commit or stash first']
222            };
223          }
224        } catch (error) {
225          errors.push(`Failed to check git status: ${error instanceof Error ? error.message : String(error)}`);
226        }
227  
228        // Step 1: Check if this node is a submodule (has .git file pointing to parent)
229        const gitPath = path.join(oldFullPath, '.git');
230        const isSubmodule = fs.existsSync(gitPath) && fs.statSync(gitPath).isFile();
231  
232        if (isSubmodule) {
233          // Read .git file to get gitdir path
234          const gitFile = await fsPromises.readFile(gitPath, 'utf-8');
235          const gitdirMatch = gitFile.match(/gitdir:\s*(.+)/);
236  
237          if (gitdirMatch) {
238            const oldGitdir = gitdirMatch[1].trim();
239            // Extract parent path and update to new folder name
240            // Old: ../.git/modules/OldName
241            // New: ../.git/modules/NewName
242            const gitdirParts = oldGitdir.split('/');
243            gitdirParts[gitdirParts.length - 1] = newFolderName;
244            const newGitdir = gitdirParts.join('/');
245  
246            changes.push(`Updated .git file gitdir: ${oldGitdir} → ${newGitdir}`);
247  
248            // We'll update this after the rename
249          }
250        }
251  
252        // Step 2: Rename folder
253        await fsPromises.rename(oldFullPath, newFullPath);
254        changes.push(`Renamed folder: ${oldFolderName} → ${newFolderName}`);
255  
256        // Step 3: Update .git file if submodule
257        if (isSubmodule) {
258          const newGitPath = path.join(newFullPath, '.git');
259          const gitFile = await fsPromises.readFile(newGitPath, 'utf-8');
260          const gitdirMatch = gitFile.match(/gitdir:\s*(.+)/);
261  
262          if (gitdirMatch) {
263            const oldGitdir = gitdirMatch[1].trim();
264            const gitdirParts = oldGitdir.split('/');
265            gitdirParts[gitdirParts.length - 1] = newFolderName;
266            const newGitdir = gitdirParts.join('/');
267  
268            const newGitFileContent = gitFile.replace(oldGitdir, newGitdir);
269            await fsPromises.writeFile(newGitPath, newGitFileContent);
270            changes.push(`Updated .git file with new gitdir path`);
271          }
272        }
273  
274        // Step 3.5: Write updated .udd file (with human-readable title if it was fixed)
275        if (titleUpdated) {
276          const newUddPath = path.join(newFullPath, '.udd');
277          await fsPromises.writeFile(newUddPath, JSON.stringify(udd, null, 2));
278          changes.push(`Updated .udd title: "${udd.title}" (human-readable)`);
279  
280          // Commit the .udd update
281          try {
282            await execAsync(
283              'git add .udd && git commit --no-verify -m "Convert title to human-readable format" || true',
284              { cwd: newFullPath }
285            );
286            changes.push('Committed .udd title update');
287          } catch (error) {
288            errors.push(`Failed to commit .udd update: ${error instanceof Error ? error.message : String(error)}`);
289          }
290        }
291  
292        // Step 4: Find and update parent .gitmodules files
293        try {
294          const parentUpdates = await this.updateParentGitmodules(oldFolderName, newFolderName);
295          changes.push(...parentUpdates);
296        } catch (error) {
297          errors.push(`Failed to update parent .gitmodules: ${error instanceof Error ? error.message : String(error)}`);
298        }
299  
300        // Step 5: Rename GitHub repository if published
301        if (udd.githubRepoUrl) {
302          try {
303            const githubRename = await this.renameGitHubRepo(udd.githubRepoUrl, newFolderName, newFullPath);
304            changes.push(...githubRename.changes);
305            errors.push(...githubRename.errors);
306          } catch (error) {
307            errors.push(`Failed to rename GitHub repo: ${error instanceof Error ? error.message : String(error)}`);
308          }
309        }
310  
311        // Step 5.5: Update canvas file paths (if DreamSong.canvas exists)
312        try {
313          const canvasUpdates = await this.updateCanvasPathsForMigration(oldFolderName, newFolderName, newFullPath);
314          changes.push(...canvasUpdates);
315        } catch (error) {
316          errors.push(`Failed to update canvas paths: ${error instanceof Error ? error.message : String(error)}`);
317        }
318  
319        // Step 6: Update store with new folder path AND human-readable title
320        node.repoPath = newFolderName;
321        node.name = udd.title;  // Use human-readable title, not PascalCase folder name
322        store.updateRealNode(nodeId, {
323          ...nodeData,
324          node,
325          lastSynced: Date.now()
326        });
327        changes.push('Updated Zustand store repoPath and name');
328  
329        return {
330          success: errors.length === 0,
331          oldPath: oldFullPath,
332          newPath: newFullPath,
333          changes,
334          errors
335        };
336  
337      } catch (error) {
338        errors.push(`Migration failed: ${error instanceof Error ? error.message : String(error)}`);
339        return {
340          success: false,
341          oldPath: '',
342          newPath: '',
343          changes,
344          errors
345        };
346      }
347    }
348  
349    /**
350     * Find and update parent .gitmodules files
351     */
352    private async updateParentGitmodules(oldPath: string, newPath: string): Promise<string[]> {
353      const changes: string[] = [];
354  
355      try {
356        // Search all directories in vault for .gitmodules files
357        const entries = await fsPromises.readdir(this.vaultPath, { withFileTypes: true });
358        const directories = entries.filter((e: any) => e.isDirectory());
359  
360        for (const dir of directories) {
361          const gitmodulesPath = path.join(this.vaultPath, dir.name, '.gitmodules');
362  
363          if (fs.existsSync(gitmodulesPath)) {
364            let content = await fsPromises.readFile(gitmodulesPath, 'utf-8');
365            const originalContent = content;
366  
367            // Update path field
368            content = content.replace(
369              new RegExp(`(path\\s*=\\s*)${oldPath}`, 'g'),
370              `$1${newPath}`
371            );
372  
373            // Write back if changed
374            if (content !== originalContent) {
375              await fsPromises.writeFile(gitmodulesPath, content);
376              changes.push(`Updated .gitmodules in ${dir.name}`);
377  
378              // Auto-commit the change
379              try {
380                await execAsync(
381                  `git add .gitmodules && git commit --no-verify -m "Update submodule path: ${oldPath} → ${newPath}" || true`,
382                  { cwd: path.join(this.vaultPath, dir.name) }
383                );
384                changes.push(`Committed .gitmodules change in ${dir.name}`);
385              } catch {
386                // Non-fatal - continue
387                changes.push(`Note: Could not auto-commit in ${dir.name}`);
388              }
389            }
390          }
391        }
392      } catch (error) {
393        throw new Error(`Failed to update parent .gitmodules: ${error instanceof Error ? error.message : String(error)}`);
394      }
395  
396      return changes;
397    }
398  
399    /**
400     * Rename GitHub repository using gh CLI
401     */
402    private async renameGitHubRepo(
403      repoUrl: string,
404      newName: string,
405      localPath: string
406    ): Promise<{ changes: string[]; errors: string[] }> {
407      const changes: string[] = [];
408      const errors: string[] = [];
409  
410      try {
411        // Extract owner/repo from URL
412        const match = repoUrl.match(/github\.com\/([^/]+)\/([^/\s]+)/);
413        if (!match) {
414          errors.push(`Invalid GitHub URL format: ${repoUrl}`);
415          return { changes, errors };
416        }
417  
418        const [, owner, oldRepoName] = match;
419        const cleanOldName = oldRepoName.replace(/\.git$/, '');
420  
421        // Try to detect gh CLI path
422        let ghPath = 'gh';
423        try {
424          const pathsToTry = ['/opt/homebrew/bin/gh', '/usr/local/bin/gh', 'gh'];
425          for (const testPath of pathsToTry) {
426            try {
427              await execAsync(`${testPath} --version`);
428              ghPath = testPath;
429              break;
430            } catch {
431              continue;
432            }
433          }
434        } catch {
435          errors.push('GitHub CLI not found - cannot rename repository');
436          return { changes, errors };
437        }
438  
439        // Rename repository
440        try {
441          await execAsync(`"${ghPath}" repo rename ${newName} --repo ${owner}/${cleanOldName} --yes`);
442          changes.push(`Renamed GitHub repo: ${cleanOldName} → ${newName}`);
443        } catch (error) {
444          errors.push(`Failed to rename GitHub repo: ${error instanceof Error ? error.message : String(error)}`);
445          return { changes, errors };
446        }
447  
448        // Update git remote URL
449        const newUrl = `https://github.com/${owner}/${newName}.git`;
450        try {
451          await execAsync(`git remote set-url github ${newUrl}`, { cwd: localPath });
452          changes.push(`Updated git remote URL: ${newUrl}`);
453        } catch (error) {
454          errors.push(`Failed to update git remote: ${error instanceof Error ? error.message : String(error)}`);
455        }
456  
457        // Update .udd file with new GitHub URL
458        try {
459          const uddPath = path.join(localPath, '.udd');
460          const uddContent = await fsPromises.readFile(uddPath, 'utf-8');
461          const udd = JSON.parse(uddContent);
462  
463          udd.githubRepoUrl = newUrl;
464          if (udd.githubPagesUrl) {
465            udd.githubPagesUrl = `https://${owner}.github.io/${newName}`;
466          }
467  
468          await fsPromises.writeFile(uddPath, JSON.stringify(udd, null, 2));
469          changes.push('Updated .udd with new GitHub URLs');
470  
471          // Commit .udd update
472          await execAsync(
473            'git add .udd && git commit --no-verify -m "Update GitHub URLs after repo rename" || true',
474            { cwd: localPath }
475          );
476          changes.push('Committed .udd update');
477        } catch (error) {
478          errors.push(`Failed to update .udd: ${error instanceof Error ? error.message : String(error)}`);
479        }
480  
481      } catch (error) {
482        errors.push(`GitHub rename failed: ${error instanceof Error ? error.message : String(error)}`);
483      }
484  
485      return { changes, errors };
486    }
487  
488    /**
489     * Update canvas file paths after migration
490     *
491     * Handles two types of path updates:
492     * 1. Parent folder rename: Holofractal-Universe → HolofractalUniverse
493     * 2. Submodule to standalone: Parent/Submodule/file.png → Submodule/file.png
494     */
495    private async updateCanvasPathsForMigration(
496      oldFolderName: string,
497      newFolderName: string,
498      nodePath: string
499    ): Promise<string[]> {
500      const changes: string[] = [];
501  
502      try {
503        // Check if DreamSong.canvas exists
504        const canvasPath = path.join(nodePath, 'DreamSong.canvas');
505        if (!fs.existsSync(canvasPath)) {
506          return changes; // No canvas file, nothing to update
507        }
508  
509        // Read canvas file
510        const canvasContent = await fsPromises.readFile(canvasPath, 'utf-8');
511        let canvas = JSON.parse(canvasContent);
512        let pathsUpdated = 0;
513  
514        // Process each node in the canvas
515        if (canvas.nodes && Array.isArray(canvas.nodes)) {
516          for (const node of canvas.nodes) {
517            if (node.type === 'file' && node.file) {
518              const originalPath = node.file;
519              let newPath = originalPath;
520  
521              // Pattern 1: Update old parent folder name to new name
522              // E.g., "Holofractal-Universe/..." → "HolofractalUniverse/..."
523              if (originalPath.startsWith(oldFolderName + '/')) {
524                newPath = originalPath.replace(oldFolderName + '/', newFolderName + '/');
525                changes.push(`Updated parent reference: ${originalPath} → ${newPath}`);
526              }
527  
528              // Pattern 2: Remove submodule prefix (convert submodule paths to standalone)
529              // E.g., "HolofractalUniverse/ThunderstormGenerator/file.png" → "ThunderstormGenerator/file.png"
530              // This handles cases where files were in submodules but submodules were removed
531              const pathParts = newPath.split('/');
532              if (pathParts.length >= 3) {
533                // Check if the middle part is a DreamNode (has .udd file at vault root)
534                const potentialNodeName = pathParts[1];
535                const potentialNodePath = path.join(this.vaultPath, potentialNodeName);
536                const potentialUddPath = path.join(potentialNodePath, '.udd');
537  
538                if (fs.existsSync(potentialUddPath)) {
539                  // This is a submodule reference - convert to standalone node path
540                  const standaloneNodePath = pathParts.slice(1).join('/');
541                  if (standaloneNodePath !== newPath) {
542                    newPath = standaloneNodePath;
543                    changes.push(`Converted submodule to standalone: ${originalPath} → ${newPath}`);
544                  }
545                }
546              }
547  
548              // Update the node's file path if it changed
549              if (newPath !== originalPath) {
550                node.file = newPath;
551                pathsUpdated++;
552              }
553            }
554          }
555        }
556  
557        // Write updated canvas if any paths changed
558        if (pathsUpdated > 0) {
559          await fsPromises.writeFile(canvasPath, JSON.stringify(canvas, null, 2));
560          changes.push(`Updated ${pathsUpdated} file path(s) in DreamSong.canvas`);
561  
562          // Commit the canvas update
563          try {
564            await execAsync(
565              'git add DreamSong.canvas && git commit --no-verify -m "Update canvas paths after migration" || true',
566              { cwd: nodePath }
567            );
568            changes.push('Committed canvas path updates');
569          } catch {
570            // Non-fatal - continue
571            changes.push('Note: Could not auto-commit canvas updates');
572          }
573        }
574  
575      } catch (error) {
576        // If canvas doesn't exist or parse fails, that's okay
577        if (error instanceof Error && !error.message.includes('ENOENT')) {
578          throw error;
579        }
580      }
581  
582      return changes;
583    }
584  
585    /**
586     * Migrate all DreamNodes in vault (parallelized for speed)
587     */
588    async migrateAllNodes(): Promise<{ total: number; succeeded: number; failed: number; results: MigrationResult[] }> {
589      const store = useInterBrainStore.getState();
590      const allNodes = Array.from(store.realNodes.keys());
591  
592      // Execute all migrations in parallel using Promise.all()
593      const results = await Promise.all(
594        allNodes.map(nodeId => this.migrateSingleNode(nodeId))
595      );
596  
597      // Count successes and failures
598      const succeeded = results.filter(r => r.success).length;
599      const failed = results.filter(r => !r.success).length;
600  
601      return {
602        total: allNodes.length,
603        succeeded,
604        failed,
605        results
606      };
607    }
608  
609    /**
610     * Audit and fix ALL canvas file paths in the entire vault
611     *
612     * Scans every DreamSong.canvas file and fixes paths that don't follow PascalCase conventions:
613     * - Converts kebab-case folder names to PascalCase
614     * - Removes submodule prefixes (converts to standalone paths)
615     * - Ensures all path components match actual folder names
616     *
617     * This is idempotent - safe to run multiple times.
618     */
619    async auditAllCanvasPaths(): Promise<{
620      total: number;
621      fixed: number;
622      pathsUpdated: number;
623      errors: string[];
624    }> {
625      const errors: string[] = [];
626      let totalCanvas = 0;
627      let fixedCanvas = 0;
628      let totalPathsUpdated = 0;
629  
630      try {
631        // Get all directories in vault
632        const entries = await fsPromises.readdir(this.vaultPath, { withFileTypes: true });
633        const directories = entries.filter((e: any) => e.isDirectory());
634  
635        console.log(`CanvasAudit: Scanning ${directories.length} directories for DreamSong.canvas files...`);
636  
637        for (const dir of directories) {
638          const canvasPath = path.join(this.vaultPath, dir.name, 'DreamSong.canvas');
639  
640          if (!fs.existsSync(canvasPath)) {
641            continue; // No canvas file in this directory
642          }
643  
644          totalCanvas++;
645          console.log(`CanvasAudit: Processing ${dir.name}/DreamSong.canvas...`);
646  
647          try {
648            // Read and parse canvas
649            const canvasContent = await fsPromises.readFile(canvasPath, 'utf-8');
650            let canvas = JSON.parse(canvasContent);
651            let pathsUpdatedInCanvas = 0;
652  
653            console.log(`  CanvasAudit: Found ${canvas.nodes?.length || 0} nodes in canvas`);
654  
655            // Process each file node
656            if (canvas.nodes && Array.isArray(canvas.nodes)) {
657              for (const node of canvas.nodes) {
658                if (node.type === 'file' && node.file) {
659                  const originalPath = node.file;
660                  console.log(`  CanvasAudit: Checking path: ${originalPath}`);
661                  const fixedPath = await this.fixCanvasPath(originalPath);
662  
663                  if (fixedPath !== originalPath) {
664                    console.log(`  CanvasAudit: ✅ Fixed path: ${originalPath} → ${fixedPath}`);
665                    node.file = fixedPath;
666                    pathsUpdatedInCanvas++;
667                    totalPathsUpdated++;
668                  } else {
669                    console.log(`  CanvasAudit: ✓ Path already correct: ${originalPath}`);
670                  }
671                }
672              }
673            }
674  
675            // Write back if any paths changed
676            if (pathsUpdatedInCanvas > 0) {
677              await fsPromises.writeFile(canvasPath, JSON.stringify(canvas, null, 2));
678              fixedCanvas++;
679              console.log(`  CanvasAudit: Updated ${pathsUpdatedInCanvas} path(s) in ${dir.name}/DreamSong.canvas`);
680  
681              // Auto-commit changes
682              try {
683                await execAsync(
684                  'git add DreamSong.canvas && git commit --no-verify -m "Fix canvas paths to match PascalCase naming" || true',
685                  { cwd: path.join(this.vaultPath, dir.name) }
686                );
687                console.log(`  CanvasAudit: Committed changes to ${dir.name}`);
688              } catch {
689                console.log(`  CanvasAudit: Note - Could not auto-commit in ${dir.name}`);
690              }
691            }
692  
693          } catch (error) {
694            const errorMsg = `Failed to process canvas in ${dir.name}: ${error instanceof Error ? error.message : String(error)}`;
695            errors.push(errorMsg);
696            console.error(`  CanvasAudit: ${errorMsg}`);
697          }
698        }
699  
700        console.log(`CanvasAudit: Complete. Scanned ${totalCanvas} canvas files, fixed ${fixedCanvas}, updated ${totalPathsUpdated} paths.`);
701  
702      } catch (error) {
703        const errorMsg = `Canvas audit failed: ${error instanceof Error ? error.message : String(error)}`;
704        errors.push(errorMsg);
705        console.error(errorMsg);
706      }
707  
708      return {
709        total: totalCanvas,
710        fixed: fixedCanvas,
711        pathsUpdated: totalPathsUpdated,
712        errors
713      };
714    }
715  
716    /**
717     * Fix a single canvas file path to match current naming conventions
718     *
719     * Simply checks each path component and converts kebab-case to actual folder name.
720     * Does NOT remove path components - preserves the full path structure.
721     *
722     * Example: "9-11/JellifiedSteel/file.jpg" → "911/JellifiedSteel/file.jpg"
723     */
724    private async fixCanvasPath(originalPath: string): Promise<string> {
725      const pathParts = originalPath.split('/');
726      const fixedParts: string[] = [];
727  
728      console.log(`    CanvasAudit: Analyzing path parts:`, pathParts);
729  
730      for (let i = 0; i < pathParts.length; i++) {
731        const part = pathParts[i];
732  
733        // Last part is filename - keep as-is
734        if (i === pathParts.length - 1) {
735          console.log(`      Part[${i}]: "${part}" (filename - keeping as-is)`);
736          fixedParts.push(part);
737          continue;
738        }
739  
740        // Check if this part exists as a valid folder
741        const potentialNodePath = path.join(this.vaultPath, part);
742        console.log(`      Part[${i}]: "${part}" - checking if exists at: ${potentialNodePath}`);
743  
744        if (fs.existsSync(potentialNodePath)) {
745          // Folder exists with this name - keep it as-is
746          console.log(`      ✓ Folder exists as-is: ${part}`);
747          fixedParts.push(part);
748          continue;
749        }
750  
751        // Folder doesn't exist - try to find the actual folder name
752        console.log(`      ✗ Folder "${part}" doesn't exist, trying alternatives...`);
753  
754        // Try removing hyphens/underscores (9-11 → 911, my-node → mynode)
755        const withoutSeparators = part.replace(/[-_]/g, '');
756        const withoutSeparatorsPath = path.join(this.vaultPath, withoutSeparators);
757        console.log(`      Trying without separators: "${withoutSeparators}" at ${withoutSeparatorsPath}`);
758  
759        if (fs.existsSync(withoutSeparatorsPath)) {
760          // Found it without separators!
761          console.log(`      ✅ Found folder without separators: ${part} → ${withoutSeparators}`);
762          fixedParts.push(withoutSeparators);
763          continue;
764        }
765  
766        // Try PascalCase conversion
767        const pascalCasePart = sanitizeTitleToPascalCase(part);
768        const pascalCasePath = path.join(this.vaultPath, pascalCasePart);
769        console.log(`      Trying PascalCase: "${pascalCasePart}" at ${pascalCasePath}`);
770  
771        if (fs.existsSync(pascalCasePath)) {
772          // Found it with PascalCase conversion!
773          console.log(`      ✅ Found folder with PascalCase: ${part} → ${pascalCasePart}`);
774          fixedParts.push(pascalCasePart);
775          continue;
776        }
777  
778        // Couldn't find matching folder - keep original
779        console.log(`      ⚠️ Could not find matching folder for: ${part} - keeping original`);
780        fixedParts.push(part);
781      }
782  
783      const result = fixedParts.join('/');
784      console.log(`    CanvasAudit: Result: ${originalPath} → ${result}`);
785      return result;
786    }
787  }