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 }