git-dreamnode-service.ts
1 import { DreamNode, UDDFile, GitStatus } from '../types/dreamnode'; 2 import { useInterBrainStore, RealNodeData } from '../store/interbrain-store'; 3 import { Plugin } from 'obsidian'; 4 import { indexingService } from '../features/semantic-search/services/indexing-service'; 5 import { UrlMetadata, generateYouTubeIframe, generateMarkdownLink } from '../utils/url-utils'; 6 import { createLinkFileContent, getLinkFileName } from '../utils/link-file-utils'; 7 import { sanitizeTitleToPascalCase } from '../utils/title-sanitization'; 8 9 // Access Node.js modules directly in Electron context 10 11 const { exec } = require('child_process'); 12 const { promisify } = require('util'); 13 const fs = require('fs'); 14 const path = require('path'); 15 const crypto = require('crypto'); 16 17 18 const execAsync = promisify(exec); 19 const fsPromises = fs.promises; 20 21 // Type for accessing file system path from Obsidian vault adapter 22 interface VaultAdapter { 23 path?: string; 24 basePath?: string; 25 } 26 27 /** 28 * GitDreamNodeService - Real git-based DreamNode storage 29 * 30 * Provides DreamNode CRUD operations backed by actual git repositories. 31 * Uses the Zustand real store for UI performance while syncing with vault. 32 */ 33 export class GitDreamNodeService { 34 private plugin: Plugin; 35 private vaultPath: string; 36 private templatePath: string; 37 38 constructor(plugin: Plugin) { 39 this.plugin = plugin; 40 // Get vault file system path for Node.js fs operations 41 const adapter = plugin.app.vault.adapter as VaultAdapter; 42 43 // Try different ways to get the vault path 44 let vaultPath = ''; 45 if (typeof adapter.path === 'string') { 46 vaultPath = adapter.path; 47 } else if (typeof adapter.basePath === 'string') { 48 vaultPath = adapter.basePath; 49 } else if (adapter.path && typeof adapter.path === 'object') { 50 // Sometimes path is an object with properties 51 52 vaultPath = (adapter.path as any).path || (adapter.path as any).basePath || ''; 53 } 54 55 this.vaultPath = vaultPath; 56 57 // Template is packaged with the plugin 58 // Get the plugin directory path from Obsidian's plugin manifest 59 if (this.vaultPath) { 60 const pluginDir = path.join(this.vaultPath, '.obsidian', 'plugins', plugin.manifest.id); 61 this.templatePath = path.join(pluginDir, 'DreamNode-template'); 62 } else { 63 // Fallback - try to get plugin directory from plugin object 64 // @ts-ignore - accessing private plugin properties 65 const adapter = plugin.app?.vault?.adapter as { basePath?: string }; 66 const pluginDir = adapter?.basePath ? 67 path.join(adapter.basePath, '.obsidian', 'plugins', plugin.manifest.id) : 68 './DreamNode-template'; 69 this.templatePath = path.join(pluginDir, 'DreamNode-template'); 70 console.warn('GitDreamNodeService: Could not determine vault path, using fallback template path:', this.templatePath); 71 } 72 73 } 74 75 /** 76 * Create a new DreamNode with git repository 77 */ 78 async create( 79 title: string, 80 type: 'dream' | 'dreamer', 81 dreamTalk?: globalThis.File, 82 position?: [number, number, number], 83 additionalFiles?: globalThis.File[] 84 ): Promise<DreamNode> { 85 // Generate unique ID and repo path 86 const uuid = crypto.randomUUID(); 87 const repoName = this.sanitizeRepoName(title); 88 const repoPath = path.join(this.vaultPath, repoName); 89 90 // Calculate position if not provided 91 const nodePosition = position || this.calculateNewNodePosition(); 92 93 // Process dreamTalk media 94 let dreamTalkMedia: Array<{ 95 path: string; 96 absolutePath: string; 97 type: string; 98 data: string; 99 size: number; 100 }> = []; 101 102 if (dreamTalk) { 103 const dataUrl = await this.fileToDataUrl(dreamTalk); 104 dreamTalkMedia = [{ 105 path: dreamTalk.name, 106 absolutePath: path.join(repoPath, dreamTalk.name), 107 type: dreamTalk.type, 108 data: dataUrl, 109 size: dreamTalk.size 110 }]; 111 } 112 113 // Create DreamNode object 114 const node: DreamNode = { 115 id: uuid, 116 type, 117 name: title, 118 position: nodePosition, 119 dreamTalkMedia, 120 dreamSongContent: [], 121 liminalWebConnections: [], 122 repoPath: repoName, // Relative to vault 123 hasUnsavedChanges: false, 124 gitStatus: await this.checkGitStatus(repoPath), 125 email: undefined, 126 phone: undefined 127 }; 128 129 // Update store immediately for snappy UI 130 const store = useInterBrainStore.getState(); 131 const nodeData: RealNodeData = { 132 node, 133 fileHash: dreamTalk ? await this.calculateFileHash(dreamTalk) : undefined, 134 lastSynced: Date.now() 135 }; 136 store.updateRealNode(uuid, nodeData); 137 138 // Create git repository in parallel (non-blocking) 139 this.createGitRepository(repoPath, uuid, title, type, dreamTalk, additionalFiles) 140 .then(async () => { 141 // Index the new node after git repository is created 142 try { 143 await indexingService.indexNode(node); 144 console.log(`GitDreamNodeService: Indexed new node "${title}"`); 145 } catch (error) { 146 console.error('Failed to index new node:', error); 147 // Don't fail the creation if indexing fails 148 } 149 }) 150 .catch(async (error) => { 151 // Check if git repository actually exists (commit might have succeeded despite stderr) 152 try { 153 await execAsync('git rev-parse HEAD', { cwd: repoPath }); 154 console.log(`GitDreamNodeService: Repository created successfully (stderr from hook is normal)`); 155 } catch { 156 // Actually failed 157 console.error('Failed to create git repository:', error); 158 } 159 }); 160 161 console.log(`GitDreamNodeService: Created ${type} "${title}" with ID ${uuid}`); 162 return node; 163 } 164 165 /** 166 * Update an existing DreamNode 167 */ 168 async update(id: string, changes: Partial<DreamNode>): Promise<void> { 169 const store = useInterBrainStore.getState(); 170 const nodeData = store.realNodes.get(id); 171 172 if (!nodeData) { 173 throw new Error(`DreamNode with ID ${id} not found`); 174 } 175 176 const originalNode = nodeData.node; 177 let updatedNode = { ...originalNode, ...changes }; 178 179 // Handle folder renaming if name changed 180 if (changes.name && changes.name !== originalNode.name) { 181 const newRepoName = this.generateRepoName(changes.name, originalNode.type, id); 182 const oldRepoPath = path.join(this.vaultPath, originalNode.repoPath); 183 const newRepoPath = path.join(this.vaultPath, newRepoName); 184 185 // Only rename if the paths are actually different 186 if (oldRepoPath !== newRepoPath) { 187 // Check if target name already exists 188 if (await this.fileExists(newRepoPath)) { 189 throw new Error(`A DreamNode with the name "${changes.name}" already exists. Please choose a different name.`); 190 } 191 192 try { 193 // Check if source exists 194 if (!await this.fileExists(oldRepoPath)) { 195 console.warn(`GitDreamNodeService: Source folder doesn't exist: ${oldRepoPath}`); 196 // Just update the repoPath without renaming 197 updatedNode = { ...updatedNode, repoPath: newRepoName }; 198 } else { 199 // Rename the folder 200 await fsPromises.rename(oldRepoPath, newRepoPath); 201 202 // Update repoPath in the node 203 updatedNode = { ...updatedNode, repoPath: newRepoName }; 204 205 console.log(`GitDreamNodeService: Renamed folder from "${originalNode.repoPath}" to "${newRepoName}"`); 206 } 207 } catch (error) { 208 console.error(`Failed to rename folder for node ${id}:`, error); 209 throw new Error(`Failed to rename DreamNode folder: ${error instanceof Error ? error.message : 'Unknown error'}`); 210 } 211 } 212 } 213 214 // Update in store 215 store.updateRealNode(id, { 216 ...nodeData, 217 node: updatedNode, 218 lastSynced: Date.now() 219 }); 220 221 // If metadata changed, update .udd file and auto-commit 222 if (changes.name || changes.type || changes.dreamTalkMedia || changes.email !== undefined || changes.phone !== undefined) { 223 await this.updateUDDFile(updatedNode); 224 225 // Auto-commit changes if enabled (only for actual file changes, not position) 226 await this.autoCommitChanges(updatedNode, changes); 227 } 228 229 console.log(`GitDreamNodeService: Updated node ${id}`, changes); 230 } 231 232 /** 233 * Delete a DreamNode and its git repository 234 */ 235 async delete(id: string): Promise<void> { 236 const store = useInterBrainStore.getState(); 237 const nodeData = store.realNodes.get(id); 238 239 if (!nodeData) { 240 throw new Error(`DreamNode with ID ${id} not found`); 241 } 242 243 const nodeName = nodeData.node.name; 244 const repoPath = nodeData.node.repoPath; 245 const fullRepoPath = path.join(this.vaultPath, repoPath); 246 247 try { 248 // Delete the actual git repository from disk 249 console.log(`GitDreamNodeService: Deleting git repository at ${fullRepoPath}`); 250 251 // Use recursive removal to delete the entire directory 252 await fsPromises.rm(fullRepoPath, { recursive: true, force: true }); 253 254 console.log(`GitDreamNodeService: Successfully deleted git repository for ${nodeName}`); 255 } catch (error) { 256 console.error(`GitDreamNodeService: Failed to delete git repository for ${nodeName}:`, error); 257 throw new Error(`Failed to delete git repository: ${error instanceof Error ? error.message : 'Unknown error'}`); 258 } 259 260 // Remove from store only after successful deletion 261 store.deleteRealNode(id); 262 263 console.log(`GitDreamNodeService: Deleted node ${nodeName} (${id})`); 264 } 265 266 /** 267 * List all DreamNodes from store 268 */ 269 async list(): Promise<DreamNode[]> { 270 const store = useInterBrainStore.getState(); 271 return Array.from(store.realNodes.values()).map(data => data.node); 272 } 273 274 /** 275 * Get a specific DreamNode by ID 276 */ 277 async get(id: string): Promise<DreamNode | null> { 278 const store = useInterBrainStore.getState(); 279 const nodeData = store.realNodes.get(id); 280 return nodeData ? nodeData.node : null; 281 } 282 283 /** 284 * Scan vault for DreamNode repositories and sync with store 285 */ 286 async scanVault(): Promise<{ added: number; updated: number; removed: number }> { 287 const stats = { added: 0, updated: 0, removed: 0 }; 288 289 try { 290 console.log('[VaultScan] Starting batch scan...'); 291 292 // Get all root-level directories 293 const entries = await fsPromises.readdir(this.vaultPath, { withFileTypes: true }); 294 const directories = entries.filter((entry: { isDirectory(): boolean }) => entry.isDirectory()); 295 296 // Track found nodes for removal detection 297 const foundNodeIds = new Set<string>(); 298 299 // Batch collection - don't update store until all nodes are processed 300 const nodesToAdd: Array<{ dirPath: string; udd: UDDFile; dirName: string }> = []; 301 const nodesToUpdate: Array<{ existingData: RealNodeData; dirPath: string; udd: UDDFile; dirName: string }> = []; 302 303 // Check each directory 304 for (const dir of directories) { 305 const dirPath = path.join(this.vaultPath, dir.name); 306 307 try { 308 // Check if it's a valid DreamNode (has .git and .udd) 309 const isValid = await this.isValidDreamNode(dirPath); 310 if (!isValid) continue; 311 312 // Read UDD file 313 const uddPath = path.join(dirPath, '.udd'); 314 const uddContent = await fsPromises.readFile(uddPath, 'utf-8'); 315 316 let udd: UDDFile; 317 try { 318 udd = JSON.parse(uddContent); 319 } catch (parseError) { 320 console.error(`⚠️ [VaultScan] Invalid JSON in ${dir.name}/.udd:`, parseError); 321 console.error(`⚠️ [VaultScan] File content preview:\n${uddContent.substring(0, 500)}`); 322 // Skip this node but continue scanning others 323 continue; 324 } 325 326 foundNodeIds.add(udd.uuid); 327 328 // Check if node exists in store 329 const store = useInterBrainStore.getState(); 330 const existingData = store.realNodes.get(udd.uuid); 331 332 if (!existingData) { 333 // Queue for batch add 334 nodesToAdd.push({ dirPath, udd, dirName: dir.name }); 335 } else { 336 // Queue for batch update 337 nodesToUpdate.push({ existingData, dirPath, udd, dirName: dir.name }); 338 } 339 } catch (error) { 340 // Log error for this specific node but continue scanning others 341 console.error(`⚠️ [VaultScan] Error processing ${dir.name}:`, error); 342 continue; 343 } 344 } 345 346 // Now process all batched operations - build complete Map without triggering re-renders 347 console.log(`[VaultScan] Processing ${nodesToAdd.length} adds, ${nodesToUpdate.length} updates`); 348 349 const store = useInterBrainStore.getState(); 350 const newRealNodes = new Map(store.realNodes); // Clone existing map 351 352 // Process all new nodes IN PARALLEL for speed (disk I/O can happen concurrently) 353 const addPromises = nodesToAdd.map(async ({ dirPath, udd, dirName }) => { 354 const nodeData = await this.buildNodeDataFromVault(dirPath, udd, dirName); 355 if (nodeData) { 356 return { uuid: udd.uuid, nodeData }; 357 } 358 return null; 359 }); 360 361 const addResults = await Promise.all(addPromises); 362 for (const result of addResults) { 363 if (result) { 364 newRealNodes.set(result.uuid, result.nodeData); 365 stats.added++; 366 } 367 } 368 369 // Process all updates IN PARALLEL for speed 370 const updatePromises = nodesToUpdate.map(async ({ existingData, dirPath, udd, dirName }) => { 371 const nodeData = await this.buildNodeDataFromVault(dirPath, udd, dirName); 372 if (nodeData) { 373 // Check if actually changed before counting as update 374 const changed = JSON.stringify(existingData.node) !== JSON.stringify(nodeData.node); 375 if (changed) { 376 return { uuid: udd.uuid, nodeData }; 377 } 378 } 379 return null; 380 }); 381 382 const updateResults = await Promise.all(updatePromises); 383 for (const result of updateResults) { 384 if (result) { 385 newRealNodes.set(result.uuid, result.nodeData); 386 stats.updated++; 387 } 388 } 389 390 // Remove nodes that no longer exist in vault 391 for (const [id] of store.realNodes) { 392 if (!foundNodeIds.has(id)) { 393 newRealNodes.delete(id); 394 stats.removed++; 395 } 396 } 397 398 // Extract and persist lightweight metadata for instant startup 399 const nodeMetadata = new Map<string, { name: string; type: string; uuid: string }>(); 400 for (const [id, data] of newRealNodes) { 401 nodeMetadata.set(id, { 402 name: data.node.name, 403 type: data.node.type, 404 uuid: data.node.id 405 }); 406 } 407 408 // Single store update - triggers only ONE React re-render 409 store.setRealNodes(newRealNodes); 410 store.setNodeMetadata(nodeMetadata); 411 412 // CRITICAL: Defer media loading to give React time to render placeholders first 413 setTimeout(() => { 414 import('./media-loading-service').then(({ getMediaLoadingService }) => { 415 try { 416 const mediaLoadingService = getMediaLoadingService(); 417 mediaLoadingService.loadAllNodesByDistance(); 418 } catch (error) { 419 console.warn('[VaultScan] Failed to start media loading:', error); 420 } 421 }); 422 }, 50); // 50ms delay to let React render 423 424 } catch (error) { 425 console.error('Vault scan error:', error); 426 } 427 428 return stats; 429 } 430 431 /** 432 * Create git repository with template 433 */ 434 private async createGitRepository( 435 repoPath: string, 436 uuid: string, 437 title: string, 438 type: 'dream' | 'dreamer', 439 dreamTalk?: globalThis.File, 440 additionalFiles?: globalThis.File[] 441 ): Promise<void> { 442 try { 443 // Create directory 444 await fsPromises.mkdir(repoPath, { recursive: true }); 445 446 // Initialize git with template 447 console.log(`GitDreamNodeService: Initializing git with template: ${this.templatePath}`); 448 const initResult = await execAsync(`git init --template="${this.templatePath}" "${repoPath}"`); 449 console.log(`GitDreamNodeService: Git init result:`, initResult); 450 451 // Make sure hooks are executable 452 const hooksDir = path.join(repoPath, '.git', 'hooks'); 453 if (await this.fileExists(hooksDir)) { 454 await execAsync(`chmod +x "${path.join(hooksDir, 'pre-commit')}"`, { cwd: repoPath }); 455 console.log(`GitDreamNodeService: Made pre-commit hook executable`); 456 } 457 458 // Write dreamTalk file if provided 459 let dreamTalkPath = ''; 460 if (dreamTalk) { 461 dreamTalkPath = path.join(repoPath, dreamTalk.name); 462 const buffer = await dreamTalk.arrayBuffer(); 463 await fsPromises.writeFile(dreamTalkPath, globalThis.Buffer.from(buffer)); 464 } 465 466 // Write additional files 467 if (additionalFiles) { 468 for (const file of additionalFiles) { 469 const filePath = path.join(repoPath, file.name); 470 const buffer = await file.arrayBuffer(); 471 await fsPromises.writeFile(filePath, globalThis.Buffer.from(buffer)); 472 } 473 } 474 475 // Replace placeholders in template files (while still in .git directory) 476 await this.replacePlaceholders(repoPath, { 477 uuid, 478 title, 479 type, 480 dreamTalk: dreamTalkPath ? dreamTalk!.name : '' 481 }); 482 483 // Move template files from .git/ to working directory 484 // (This is what the pre-commit hook used to do, but doing it here prevents timing issues) 485 console.log(`GitDreamNodeService: Moving template files to working directory`); 486 const gitDir = path.join(repoPath, '.git'); 487 488 // Move .udd file 489 const uddSource = path.join(gitDir, 'udd'); 490 const uddDest = path.join(repoPath, '.udd'); 491 if (await this.fileExists(uddSource)) { 492 await fsPromises.rename(uddSource, uddDest); 493 console.log(`GitDreamNodeService: Moved .git/udd to .udd`); 494 } 495 496 // Move README.md 497 const readmeSource = path.join(gitDir, 'README.md'); 498 const readmeDest = path.join(repoPath, 'README.md'); 499 if (await this.fileExists(readmeSource)) { 500 await fsPromises.rename(readmeSource, readmeDest); 501 console.log(`GitDreamNodeService: Moved .git/README.md to README.md`); 502 } 503 504 // Move LICENSE 505 const licenseSource = path.join(gitDir, 'LICENSE'); 506 const licenseDest = path.join(repoPath, 'LICENSE'); 507 if (await this.fileExists(licenseSource)) { 508 await fsPromises.rename(licenseSource, licenseDest); 509 console.log(`GitDreamNodeService: Moved .git/LICENSE to LICENSE`); 510 } 511 512 // Make initial commit 513 console.log(`GitDreamNodeService: Starting git operations in ${repoPath}`); 514 515 // Add all files 516 const addResult = await execAsync('git add -A', { cwd: repoPath }); 517 console.log(`GitDreamNodeService: Git add result:`, addResult); 518 519 // Make the initial commit (this triggers the pre-commit hook) 520 // Escape the title to handle quotes and special characters 521 const escapedTitle = title.replace(/"/g, '\\"'); 522 try { 523 const commitResult = await execAsync(`git commit -m "Initialize DreamNode: ${escapedTitle}"`, { cwd: repoPath }); 524 console.log(`GitDreamNodeService: Git commit result:`, commitResult); 525 } catch (commitError: any) { 526 // Pre-commit hook outputs to stderr which causes exec to throw even on success 527 // Check if commit actually succeeded by verifying HEAD exists 528 try { 529 const headResult = await execAsync('git rev-parse HEAD', { cwd: repoPath }); 530 console.log(`[GitDreamNodeService] ✅ Commit verified successful despite stderr - HEAD exists: ${headResult.stdout.trim()}`); 531 // Commit succeeded - don't rethrow, continue normally 532 } catch (verifyError) { 533 // Commit actually failed - HEAD doesn't exist 534 console.error(`[GitDreamNodeService] ❌ Commit failed - HEAD verification failed:`, verifyError); 535 throw commitError; 536 } 537 } 538 539 console.log(`GitDreamNodeService: Git repository created successfully at ${repoPath}`); 540 } catch (error: any) { 541 // Don't log error if repository was actually created successfully 542 // (This can happen if earlier operations like git init had stderr output) 543 try { 544 await execAsync('git rev-parse HEAD', { cwd: repoPath }); 545 console.log(`[GitDreamNodeService] ✅ Repository exists despite error - operation succeeded`); 546 return; // Success - don't throw 547 } catch { 548 // Repository doesn't exist - this is a real error 549 console.error('Failed to create git repository:', error); 550 throw error; 551 } 552 } 553 } 554 555 /** 556 * Replace template placeholders in files 557 */ 558 private async replacePlaceholders( 559 repoPath: string, 560 values: { 561 uuid: string; 562 title: string; 563 type: string; 564 dreamTalk: string; 565 } 566 ): Promise<void> { 567 // Update the udd file while it's still in the .git directory 568 // The pre-commit hook will move it to .udd in the working directory 569 const uddPath = path.join(repoPath, '.git', 'udd'); 570 console.log(`GitDreamNodeService: Updating template file at ${uddPath}`); 571 572 let uddContent = await fsPromises.readFile(uddPath, 'utf-8'); 573 574 uddContent = uddContent 575 .replace('TEMPLATE_UUID_PLACEHOLDER', values.uuid) 576 .replace('TEMPLATE_TITLE_PLACEHOLDER', values.title) 577 .replace('"type": "dream"', `"type": "${values.type}"`) 578 .replace('TEMPLATE_DREAMTALK_PLACEHOLDER', values.dreamTalk); 579 580 await fsPromises.writeFile(uddPath, uddContent); 581 console.log(`GitDreamNodeService: Updated template metadata`); 582 583 // Update README.md (also in .git directory initially) 584 const readmePath = path.join(repoPath, '.git', 'README.md'); 585 if (await this.fileExists(readmePath)) { 586 let readmeContent = await fsPromises.readFile(readmePath, 'utf-8'); 587 readmeContent = readmeContent.replace(/TEMPLATE_TITLE_PLACEHOLDER/g, values.title); 588 await fsPromises.writeFile(readmePath, readmeContent); 589 console.log(`GitDreamNodeService: Updated README.md template`); 590 } 591 } 592 593 /** 594 * Check if a directory is a valid DreamNode 595 */ 596 private async isValidDreamNode(dirPath: string): Promise<boolean> { 597 try { 598 // Check for .git directory 599 const gitPath = path.join(dirPath, '.git'); 600 const gitExists = await this.fileExists(gitPath); 601 602 // Check for .udd file 603 const uddPath = path.join(dirPath, '.udd'); 604 const uddExists = await this.fileExists(uddPath); 605 606 return gitExists && uddExists; 607 } catch { 608 return false; 609 } 610 } 611 612 /** 613 * Build node data from vault without updating store (for batching) 614 */ 615 private async buildNodeDataFromVault(dirPath: string, udd: UDDFile, repoName: string): Promise<RealNodeData | null> { 616 // Load dreamTalk media if specified 617 // IMPORTANT: If file temporarily doesn't exist, preserve existing dreamTalkMedia from store 618 const store = useInterBrainStore.getState(); 619 const existingData = store.realNodes.get(udd.uuid); 620 let dreamTalkMedia: Array<{ 621 path: string; 622 absolutePath: string; 623 type: string; 624 data: string; 625 size: number; 626 }> = existingData?.node.dreamTalkMedia || []; // Preserve existing if we have it 627 628 if (udd.dreamTalk) { 629 const mediaPath = path.join(dirPath, udd.dreamTalk); 630 if (await this.fileExists(mediaPath)) { 631 const stats = await fsPromises.stat(mediaPath); 632 const mimeType = this.getMimeType(udd.dreamTalk); 633 // Skip loading media data during vault scan - will lazy load via MediaLoadingService 634 635 dreamTalkMedia = [{ 636 path: udd.dreamTalk, 637 absolutePath: mediaPath, 638 type: mimeType, 639 data: '', // Empty - lazy load on demand 640 size: stats.size 641 }]; 642 } 643 // If file doesn't exist but udd.dreamTalk is set, keep existing dreamTalkMedia 644 // This prevents flickering when file system is temporarily inaccessible 645 } 646 647 // Use cached constellation position if available, otherwise random 648 const cachedPosition = store.constellationData.positions?.get(udd.uuid); 649 650 const node: DreamNode = { 651 id: udd.uuid, 652 type: udd.type, 653 name: udd.title, 654 position: cachedPosition || this.calculateNewNodePosition(), 655 dreamTalkMedia, 656 dreamSongContent: [], 657 liminalWebConnections: udd.liminalWebRelationships || [], 658 repoPath: repoName, 659 hasUnsavedChanges: false, 660 email: udd.email, 661 phone: udd.phone, 662 radicleId: udd.radicleId, 663 githubRepoUrl: udd.githubRepoUrl, 664 githubPagesUrl: udd.githubPagesUrl 665 }; 666 667 // Calculate file hash if needed 668 let fileHash: string | undefined; 669 if (dreamTalkMedia.length > 0 && udd.dreamTalk) { 670 const mediaPath = path.join(dirPath, udd.dreamTalk); 671 fileHash = await this.calculateFileHashFromPath(mediaPath); 672 } 673 674 // Return node data without updating store 675 return { 676 node, 677 fileHash, 678 lastSynced: Date.now() 679 }; 680 } 681 682 /** 683 * Add node from vault to store (legacy method - use buildNodeDataFromVault for batching) 684 */ 685 private async addNodeFromVault(dirPath: string, udd: UDDFile, repoName: string): Promise<void> { 686 const nodeData = await this.buildNodeDataFromVault(dirPath, udd, repoName); 687 if (nodeData) { 688 const store = useInterBrainStore.getState(); 689 store.updateRealNode(udd.uuid, nodeData); 690 } 691 } 692 693 /** 694 * Update node from vault if changed 695 */ 696 private async updateNodeFromVault( 697 existingData: RealNodeData, 698 dirPath: string, 699 udd: UDDFile, 700 repoName: string 701 ): Promise<boolean> { 702 let updated = false; 703 const node = { ...existingData.node }; 704 705 // CRITICAL: Sync repoPath with actual directory name (handles Radicle clone renames) 706 if (node.repoPath !== repoName) { 707 console.log(`📁 [GitDreamNodeService] Syncing repoPath: "${node.repoPath}" → "${repoName}"`); 708 node.repoPath = repoName; 709 updated = true; 710 } 711 712 // CRITICAL: Sync display name with .udd title (human-readable) 713 // .udd file is source of truth for display names, NOT the folder name 714 // Folder names are PascalCase for compatibility, but display uses human-readable titles 715 if (node.name !== udd.title) { 716 console.log(`✏️ [GitDreamNodeService] Syncing display name from .udd: "${node.name}" → "${udd.title}"`); 717 node.name = udd.title; 718 updated = true; 719 } 720 721 // Check metadata changes (type, contact fields, radicleId, and GitHub URLs - name synced from .udd) 722 if (node.type !== udd.type || node.email !== udd.email || node.phone !== udd.phone || 723 node.radicleId !== udd.radicleId || node.githubRepoUrl !== udd.githubRepoUrl || 724 node.githubPagesUrl !== udd.githubPagesUrl) { 725 node.type = udd.type; 726 node.email = udd.email; 727 node.phone = udd.phone; 728 node.radicleId = udd.radicleId; 729 node.githubRepoUrl = udd.githubRepoUrl; 730 node.githubPagesUrl = udd.githubPagesUrl; 731 updated = true; 732 } 733 734 // Check dreamTalk changes 735 if (udd.dreamTalk) { 736 const mediaPath = path.join(dirPath, udd.dreamTalk); 737 if (await this.fileExists(mediaPath)) { 738 const newHash = await this.calculateFileHashFromPath(mediaPath); 739 740 if (newHash !== existingData.fileHash) { 741 // File changed - reload metadata only (data will lazy load) 742 const stats = await fsPromises.stat(mediaPath); 743 const mimeType = this.getMimeType(udd.dreamTalk); 744 // Skip loading media data - will lazy load via MediaLoadingService 745 746 node.dreamTalkMedia = [{ 747 path: udd.dreamTalk, 748 absolutePath: mediaPath, 749 type: mimeType, 750 data: '', // Empty - lazy load on demand 751 size: stats.size 752 }]; 753 754 existingData.fileHash = newHash; 755 updated = true; 756 } 757 } 758 } 759 760 // Update store if changed 761 if (updated) { 762 const store = useInterBrainStore.getState(); 763 store.updateRealNode(node.id, { 764 node, 765 fileHash: existingData.fileHash, 766 lastSynced: Date.now() 767 }); 768 769 // Write updated metadata back to .udd file (keeps file system in sync) 770 await this.updateUDDFile(node); 771 console.log(`💾 [GitDreamNodeService] Updated .udd file for ${node.name}`); 772 } 773 774 return updated; 775 } 776 777 /** 778 * Update .udd file with node data 779 */ 780 private async updateUDDFile(node: DreamNode): Promise<void> { 781 const uddPath = path.join(this.vaultPath, node.repoPath, '.udd'); 782 783 const udd: UDDFile = { 784 uuid: node.id, 785 title: node.name, 786 type: node.type, 787 dreamTalk: node.dreamTalkMedia.length > 0 ? node.dreamTalkMedia[0].path : '', 788 liminalWebRelationships: node.liminalWebConnections || [], 789 submodules: [], 790 supermodules: [] 791 }; 792 793 // Include contact fields only for dreamer nodes 794 if (node.type === 'dreamer') { 795 if (node.email) udd.email = node.email; 796 if (node.phone) udd.phone = node.phone; 797 } 798 799 // CRITICAL: Preserve radicleId field if it exists 800 if (node.radicleId) { 801 udd.radicleId = node.radicleId; 802 } 803 804 // CRITICAL: Preserve GitHub URLs if they exist 805 if (node.githubRepoUrl) { 806 udd.githubRepoUrl = node.githubRepoUrl; 807 } 808 if (node.githubPagesUrl) { 809 udd.githubPagesUrl = node.githubPagesUrl; 810 } 811 812 await fsPromises.writeFile(uddPath, JSON.stringify(udd, null, 2)); 813 } 814 815 /** 816 * Helper utilities 817 */ 818 private async fileExists(path: string): Promise<boolean> { 819 try { 820 await fsPromises.access(path); 821 return true; 822 } catch { 823 return false; 824 } 825 } 826 827 /** 828 * Sanitize title to PascalCase for folder names 829 * Uses unified sanitization utility for consistency across all layers 830 */ 831 private sanitizeRepoName(title: string): string { 832 return sanitizeTitleToPascalCase(title); 833 } 834 835 private generateRepoName(title: string, _type: string, _nodeId: string): string { 836 const sanitized = this.sanitizeRepoName(title); 837 // For updates, we can use the same sanitization approach 838 // If we need uniqueness, we could append a short hash of nodeId 839 return sanitized; 840 } 841 842 843 /** 844 * Auto-commit changes if they are significant 845 */ 846 private async autoCommitChanges(node: DreamNode, changes: Partial<DreamNode>): Promise<void> { 847 try { 848 const repoPath = path.join(this.vaultPath, node.repoPath); 849 850 // Check if there are any changes to commit 851 const statusResult = await execAsync('git status --porcelain', { cwd: repoPath }); 852 if (statusResult.stdout.trim().length === 0) { 853 return; // No changes to commit 854 } 855 856 // Create commit message based on changes 857 const changeTypes = []; 858 if (changes.name) changeTypes.push(`rename to "${changes.name}"`); 859 if (changes.type) changeTypes.push(`change type to ${changes.type}`); 860 if (changes.dreamTalkMedia) changeTypes.push('update media'); 861 862 const commitMessage = changeTypes.length > 0 863 ? `Update DreamNode: ${changeTypes.join(', ')}` 864 : 'Update DreamNode metadata'; 865 866 // Stage and commit changes 867 await execAsync('git add -A', { cwd: repoPath }); 868 // Escape the commit message to handle quotes and special characters 869 const escapedMessage = commitMessage.replace(/"/g, '\\"'); 870 await execAsync(`git commit -m "${escapedMessage}"`, { cwd: repoPath }); 871 872 console.log(`GitDreamNodeService: Auto-committed changes for ${node.name}: ${commitMessage}`); 873 874 // Refresh git status after commit 875 const newGitStatus = await this.checkGitStatus(node.repoPath); 876 877 // Update store with new git status 878 const store = useInterBrainStore.getState(); 879 const nodeData = store.realNodes.get(node.id); 880 if (nodeData) { 881 store.updateRealNode(node.id, { 882 ...nodeData, 883 node: { ...node, gitStatus: newGitStatus }, 884 lastSynced: Date.now() 885 }); 886 } 887 888 } catch (error) { 889 console.error(`Failed to auto-commit changes for node ${node.id}:`, error); 890 // Don't throw - auto-commit failure shouldn't break the update 891 } 892 } 893 894 private calculateNewNodePosition(): [number, number, number] { 895 const sphereRadius = 5000; 896 const theta = Math.random() * Math.PI * 2; 897 const phi = Math.acos(2 * Math.random() - 1); 898 899 const x = sphereRadius * Math.sin(phi) * Math.cos(theta); 900 const y = sphereRadius * Math.sin(phi) * Math.sin(theta); 901 const z = sphereRadius * Math.cos(phi); 902 903 return [x, y, z]; 904 } 905 906 private async fileToDataUrl(file: globalThis.File): Promise<string> { 907 // .link files contain JSON metadata and should be read as text, not data URLs 908 if (file.name.toLowerCase().endsWith('.link')) { 909 return new Promise((resolve, reject) => { 910 const reader = new globalThis.FileReader(); 911 reader.onload = () => resolve(reader.result as string); 912 reader.onerror = reject; 913 reader.readAsText(file); 914 }); 915 } 916 917 // Regular media files get converted to data URLs 918 return new Promise((resolve, reject) => { 919 const reader = new globalThis.FileReader(); 920 reader.onload = () => resolve(reader.result as string); 921 reader.onerror = reject; 922 reader.readAsDataURL(file); 923 }); 924 } 925 926 private async filePathToDataUrl(filePath: string): Promise<string> { 927 // .link files contain JSON metadata and should be read as text, not data URLs 928 if (filePath.toLowerCase().endsWith('.link')) { 929 return await fsPromises.readFile(filePath, 'utf-8'); 930 } 931 932 // Regular media files get converted to data URLs 933 const buffer = await fsPromises.readFile(filePath); 934 const mimeType = this.getMimeType(filePath); 935 return `data:${mimeType};base64,${buffer.toString('base64')}`; 936 } 937 938 private getMimeType(filename: string): string { 939 const ext = path.extname(filename).toLowerCase(); 940 const mimeTypes: Record<string, string> = { 941 '.png': 'image/png', 942 '.jpg': 'image/jpeg', 943 '.jpeg': 'image/jpeg', 944 '.gif': 'image/gif', 945 '.webp': 'image/webp', 946 '.mp4': 'video/mp4', 947 '.webm': 'video/webm', 948 '.pdf': 'application/pdf' 949 }; 950 return mimeTypes[ext] || 'application/octet-stream'; 951 } 952 953 private async calculateFileHash(file: globalThis.File): Promise<string> { 954 const buffer = await file.arrayBuffer(); 955 const hash = crypto.createHash('sha256'); 956 hash.update(globalThis.Buffer.from(buffer)); 957 return hash.digest('hex'); 958 } 959 960 private async calculateFileHashFromPath(filePath: string): Promise<string> { 961 const buffer = await fsPromises.readFile(filePath); 962 const hash = crypto.createHash('sha256'); 963 hash.update(buffer); 964 return hash.digest('hex'); 965 } 966 967 /** 968 * Add files to an existing DreamNode 969 */ 970 async addFilesToNode(nodeId: string, files: globalThis.File[]): Promise<void> { 971 const store = useInterBrainStore.getState(); 972 const nodeData = store.realNodes.get(nodeId); 973 974 if (!nodeData) { 975 throw new Error(`DreamNode with ID ${nodeId} not found`); 976 } 977 978 const node = nodeData.node; 979 const repoPath = path.join(this.vaultPath, node.repoPath); 980 981 // Separate media and other files 982 const mediaFiles = files.filter(f => this.isMediaFile(f)); 983 const otherFiles = files.filter(f => !this.isMediaFile(f)); 984 985 // Update dreamTalk if media provided 986 if (mediaFiles.length > 0) { 987 const primaryMedia = mediaFiles[0]; 988 const dataUrl = await this.fileToDataUrl(primaryMedia); 989 990 node.dreamTalkMedia = [{ 991 path: primaryMedia.name, 992 absolutePath: path.join(repoPath, primaryMedia.name), 993 type: primaryMedia.type, 994 data: dataUrl, 995 size: primaryMedia.size 996 }]; 997 998 // Write file to disk 999 const buffer = await primaryMedia.arrayBuffer(); 1000 await fsPromises.writeFile( 1001 path.join(repoPath, primaryMedia.name), 1002 globalThis.Buffer.from(buffer) 1003 ); 1004 1005 // Update file hash 1006 nodeData.fileHash = await this.calculateFileHash(primaryMedia); 1007 } 1008 1009 // Write other files 1010 for (const file of otherFiles) { 1011 const buffer = await file.arrayBuffer(); 1012 await fsPromises.writeFile( 1013 path.join(repoPath, file.name), 1014 globalThis.Buffer.from(buffer) 1015 ); 1016 } 1017 1018 // Update store 1019 store.updateRealNode(nodeId, { 1020 ...nodeData, 1021 node, 1022 lastSynced: Date.now() 1023 }); 1024 1025 // Update .udd file 1026 await this.updateUDDFile(node); 1027 1028 console.log(`GitDreamNodeService: Added ${files.length} files to ${nodeId}`); 1029 } 1030 1031 private isMediaFile(file: globalThis.File): boolean { 1032 const validTypes = [ 1033 'image/png', 1034 'image/jpeg', 1035 'image/jpg', 1036 'image/gif', 1037 'image/webp', 1038 'video/mp4', 1039 'video/webm', 1040 'audio/mp3', 1041 'audio/wav', 1042 'audio/ogg', 1043 'application/pdf', 1044 // .link files appear as text/plain or application/octet-stream depending on system 1045 'text/plain', 1046 'application/octet-stream' 1047 ]; 1048 1049 // Also check file extension for .link files since MIME detection is unreliable 1050 const fileName = file.name.toLowerCase(); 1051 if (fileName.endsWith('.link')) { 1052 return true; 1053 } 1054 1055 return validTypes.includes(file.type); 1056 } 1057 1058 /** 1059 * Reset all data (clears store but not disk) 1060 */ 1061 reset(): void { 1062 const store = useInterBrainStore.getState(); 1063 store.setRealNodes(new Map()); 1064 console.log('GitDreamNodeService: Reset store data'); 1065 } 1066 1067 /** 1068 * Refresh git status for all nodes (implements IDreamNodeService) 1069 */ 1070 async refreshGitStatus(): Promise<{ updated: number; errors: number }> { 1071 return await this.refreshAllGitStatus(); 1072 } 1073 1074 /** 1075 * Internal method to refresh git status for all nodes 1076 */ 1077 private async refreshAllGitStatus(): Promise<{ updated: number; errors: number }> { 1078 const store = useInterBrainStore.getState(); 1079 const nodes = Array.from(store.realNodes.entries()); 1080 1081 let updated = 0; 1082 let errors = 0; 1083 1084 console.log(`GitDreamNodeService: Refreshing git status for ${nodes.length} nodes...`); 1085 1086 for (const [nodeId, nodeData] of nodes) { 1087 try { 1088 const newGitStatus = await this.checkGitStatus(nodeData.node.repoPath); 1089 const oldGitStatus = nodeData.node.gitStatus; 1090 1091 // Check if git status actually changed 1092 const statusChanged = !oldGitStatus || 1093 oldGitStatus.hasUncommittedChanges !== newGitStatus.hasUncommittedChanges || 1094 oldGitStatus.hasStashedChanges !== newGitStatus.hasStashedChanges || 1095 oldGitStatus.hasUnpushedChanges !== newGitStatus.hasUnpushedChanges; 1096 1097 // Check if commit hash changed (new commit detected) 1098 const oldCommitHash = oldGitStatus?.details?.commitHash; 1099 const newCommitHash = newGitStatus.details?.commitHash; 1100 const commitChanged = oldCommitHash && newCommitHash && oldCommitHash !== newCommitHash; 1101 1102 if (statusChanged || commitChanged) { 1103 // Update the node with new git status 1104 const updatedNode = { 1105 ...nodeData.node, 1106 gitStatus: newGitStatus 1107 }; 1108 1109 store.updateRealNode(nodeId, { 1110 ...nodeData, 1111 node: updatedNode, 1112 lastSynced: Date.now() 1113 }); 1114 1115 updated++; 1116 console.log(`GitDreamNodeService: Updated git status for ${updatedNode.name}: uncommitted=${newGitStatus.hasUncommittedChanges}, stashed=${newGitStatus.hasStashedChanges}, unpushed=${newGitStatus.hasUnpushedChanges}`); 1117 1118 // Trigger re-indexing if commit changed (meaningful content change) 1119 if (commitChanged && !newGitStatus.hasUncommittedChanges) { 1120 // Only re-index if the node is clean (committed changes) 1121 try { 1122 await indexingService.indexNode(updatedNode); 1123 console.log(`GitDreamNodeService: Re-indexed node "${updatedNode.name}" after commit change`); 1124 } catch (error) { 1125 console.error(`Failed to re-index node ${updatedNode.name}:`, error); 1126 } 1127 } 1128 } 1129 } catch (error) { 1130 console.error(`GitDreamNodeService: Failed to refresh git status for node ${nodeId}:`, error); 1131 errors++; 1132 } 1133 } 1134 1135 console.log(`GitDreamNodeService: Git status refresh complete. Updated: ${updated}, Errors: ${errors}`); 1136 return { updated, errors }; 1137 } 1138 1139 /** 1140 * Get statistics 1141 */ 1142 getStats() { 1143 const store = useInterBrainStore.getState(); 1144 const nodes = Array.from(store.realNodes.values()).map(d => d.node); 1145 1146 return { 1147 totalNodes: nodes.length, 1148 dreamNodes: nodes.filter(n => n.type === 'dream').length, 1149 dreamerNodes: nodes.filter(n => n.type === 'dreamer').length, 1150 nodesWithMedia: nodes.filter(n => n.dreamTalkMedia.length > 0).length 1151 }; 1152 } 1153 1154 /** 1155 * Check git status for a repository 1156 */ 1157 private async checkGitStatus(repoPath: string): Promise<GitStatus> { 1158 try { 1159 const fullPath = path.join(this.vaultPath, repoPath); 1160 1161 // Check if git repository exists 1162 const gitDir = path.join(fullPath, '.git'); 1163 if (!await this.fileExists(gitDir)) { 1164 // No git repo yet, return clean state 1165 return { 1166 hasUncommittedChanges: false, 1167 hasStashedChanges: false, 1168 hasUnpushedChanges: false, 1169 lastChecked: Date.now() 1170 }; 1171 } 1172 1173 // Get current commit hash 1174 let commitHash: string | undefined; 1175 try { 1176 const hashResult = await execAsync('git rev-parse HEAD', { cwd: fullPath }); 1177 commitHash = hashResult.stdout.trim(); 1178 } catch { 1179 // No commits yet 1180 console.log(`GitDreamNodeService: No commits yet in ${repoPath}`); 1181 } 1182 1183 // Check for uncommitted changes 1184 const statusResult = await execAsync('git status --porcelain', { cwd: fullPath }); 1185 const hasUncommittedChanges = statusResult.stdout.trim().length > 0; 1186 1187 // Check for stashed changes 1188 const stashResult = await execAsync('git stash list', { cwd: fullPath }); 1189 const hasStashedChanges = stashResult.stdout.trim().length > 0; 1190 1191 // Check for unpushed commits (ahead of remote) using git status 1192 let hasUnpushedChanges = false; 1193 let aheadCount = 0; 1194 try { 1195 // Use git status --porcelain=v1 --branch to get ahead/behind info 1196 const statusBranchResult = await execAsync('git status --porcelain=v1 --branch', { cwd: fullPath }); 1197 const branchLine = statusBranchResult.stdout.split('\n')[0]; 1198 1199 // Look for "ahead N" in the branch line 1200 // Format: "## branch...origin/branch [ahead N, behind M]" or "## branch...origin/branch [ahead N]" 1201 const aheadMatch = branchLine.match(/\[ahead (\d+)/); 1202 if (aheadMatch) { 1203 aheadCount = parseInt(aheadMatch[1], 10); 1204 hasUnpushedChanges = aheadCount > 0; 1205 console.log(`GitDreamNodeService: Found ${aheadCount} unpushed commits in ${repoPath}`); 1206 } else { 1207 console.log(`GitDreamNodeService: No ahead commits detected in ${repoPath}, branch line: ${branchLine}`); 1208 } 1209 } catch (error) { 1210 // No upstream or git error, assume no unpushed commits 1211 console.log(`GitDreamNodeService: Git status error for ${repoPath}:`, error instanceof Error ? error.message : 'Unknown error'); 1212 } 1213 1214 // Count different types of changes for details 1215 let details; 1216 if (hasUncommittedChanges || hasStashedChanges || hasUnpushedChanges || commitHash) { 1217 const statusLines = statusResult.stdout.trim().split('\n').filter((line: string) => line.length > 0); 1218 const staged = statusLines.filter((line: string) => line.charAt(0) !== ' ' && line.charAt(0) !== '?').length; 1219 const unstaged = statusLines.filter((line: string) => line.charAt(1) !== ' ').length; 1220 const untracked = statusLines.filter((line: string) => line.startsWith('??')).length; 1221 const stashCount = hasStashedChanges ? stashResult.stdout.trim().split('\n').length : 0; 1222 1223 details = { staged, unstaged, untracked, stashCount, aheadCount, commitHash }; 1224 } 1225 1226 return { 1227 hasUncommittedChanges, 1228 hasStashedChanges, 1229 hasUnpushedChanges, 1230 lastChecked: Date.now(), 1231 details 1232 }; 1233 1234 } catch (error) { 1235 console.warn(`Failed to check git status for ${repoPath}:`, error); 1236 // Return clean state on error 1237 return { 1238 hasUncommittedChanges: false, 1239 hasStashedChanges: false, 1240 hasUnpushedChanges: false, 1241 lastChecked: Date.now() 1242 }; 1243 } 1244 } 1245 1246 // Relationship management methods 1247 1248 /** 1249 * Update relationships for a node (bidirectional) 1250 * This method enforces bidirectionality by using atomic add/remove operations 1251 */ 1252 async updateRelationships(nodeId: string, relationshipIds: string[]): Promise<void> { 1253 const store = useInterBrainStore.getState(); 1254 const nodeData = store.realNodes.get(nodeId); 1255 1256 if (!nodeData) { 1257 throw new Error(`DreamNode with ID ${nodeId} not found`); 1258 } 1259 1260 const node = nodeData.node; 1261 1262 // Get current relationships 1263 const currentRelationships = new Set(node.liminalWebConnections || []); 1264 const newRelationships = new Set(relationshipIds); 1265 1266 // Find added and removed relationships 1267 const added = relationshipIds.filter(id => !currentRelationships.has(id)); 1268 const removed = Array.from(currentRelationships).filter(id => !newRelationships.has(id)); 1269 1270 // Use atomic operations for each change to ensure bidirectionality 1271 for (const addedId of added) { 1272 await this.addRelationship(nodeId, addedId); 1273 } 1274 1275 for (const removedId of removed) { 1276 await this.removeRelationship(nodeId, removedId); 1277 } 1278 1279 console.log(`GitDreamNodeService: Updated relationships for ${nodeId}:`, { 1280 added: added.length, 1281 removed: removed.length, 1282 total: relationshipIds.length 1283 }); 1284 } 1285 1286 /** 1287 * Get relationships for a node 1288 */ 1289 async getRelationships(nodeId: string): Promise<string[]> { 1290 const store = useInterBrainStore.getState(); 1291 const nodeData = store.realNodes.get(nodeId); 1292 1293 if (!nodeData) { 1294 throw new Error(`DreamNode with ID ${nodeId} not found`); 1295 } 1296 1297 return nodeData.node.liminalWebConnections || []; 1298 } 1299 1300 /** 1301 * Add a single relationship (bidirectional) 1302 * This method enforces bidirectionality by updating BOTH nodes atomically 1303 */ 1304 async addRelationship(nodeId: string, relatedNodeId: string): Promise<void> { 1305 const store = useInterBrainStore.getState(); 1306 const nodeData = store.realNodes.get(nodeId); 1307 const relatedNodeData = store.realNodes.get(relatedNodeId); 1308 1309 if (!nodeData) { 1310 throw new Error(`DreamNode with ID ${nodeId} not found`); 1311 } 1312 1313 if (!relatedNodeData) { 1314 throw new Error(`Related DreamNode with ID ${relatedNodeId} not found`); 1315 } 1316 1317 // Update both nodes' relationships atomically 1318 const node = nodeData.node; 1319 const relatedNode = relatedNodeData.node; 1320 1321 // Add relationship in both directions 1322 const relationships = new Set(node.liminalWebConnections || []); 1323 relationships.add(relatedNodeId); 1324 node.liminalWebConnections = Array.from(relationships); 1325 1326 const relatedRelationships = new Set(relatedNode.liminalWebConnections || []); 1327 relatedRelationships.add(nodeId); 1328 relatedNode.liminalWebConnections = Array.from(relatedRelationships); 1329 1330 // Update both nodes in store 1331 store.updateRealNode(nodeId, { 1332 ...nodeData, 1333 node, 1334 lastSynced: Date.now() 1335 }); 1336 1337 store.updateRealNode(relatedNodeId, { 1338 ...relatedNodeData, 1339 node: relatedNode, 1340 lastSynced: Date.now() 1341 }); 1342 1343 // Update both .udd files 1344 await Promise.all([ 1345 this.updateUDDFile(node), 1346 this.updateUDDFile(relatedNode) 1347 ]); 1348 1349 console.log(`GitDreamNodeService: Added bidirectional relationship ${nodeId} <-> ${relatedNodeId}`); 1350 } 1351 1352 /** 1353 * Remove a single relationship (bidirectional) 1354 * This method enforces bidirectionality by updating BOTH nodes atomically 1355 */ 1356 async removeRelationship(nodeId: string, relatedNodeId: string): Promise<void> { 1357 const store = useInterBrainStore.getState(); 1358 const nodeData = store.realNodes.get(nodeId); 1359 const relatedNodeData = store.realNodes.get(relatedNodeId); 1360 1361 if (!nodeData) { 1362 throw new Error(`DreamNode with ID ${nodeId} not found`); 1363 } 1364 1365 if (!relatedNodeData) { 1366 console.warn(`Related DreamNode with ID ${relatedNodeId} not found - removing one-way relationship only`); 1367 // Still remove from the first node even if related node is missing 1368 const node = nodeData.node; 1369 const relationships = new Set(node.liminalWebConnections || []); 1370 relationships.delete(relatedNodeId); 1371 node.liminalWebConnections = Array.from(relationships); 1372 1373 store.updateRealNode(nodeId, { 1374 ...nodeData, 1375 node, 1376 lastSynced: Date.now() 1377 }); 1378 1379 await this.updateUDDFile(node); 1380 console.log(`GitDreamNodeService: Removed one-way relationship ${nodeId} -> ${relatedNodeId}`); 1381 return; 1382 } 1383 1384 // Update both nodes' relationships atomically 1385 const node = nodeData.node; 1386 const relatedNode = relatedNodeData.node; 1387 1388 // Remove relationship in both directions 1389 const relationships = new Set(node.liminalWebConnections || []); 1390 relationships.delete(relatedNodeId); 1391 node.liminalWebConnections = Array.from(relationships); 1392 1393 const relatedRelationships = new Set(relatedNode.liminalWebConnections || []); 1394 relatedRelationships.delete(nodeId); 1395 relatedNode.liminalWebConnections = Array.from(relatedRelationships); 1396 1397 // Update both nodes in store 1398 store.updateRealNode(nodeId, { 1399 ...nodeData, 1400 node, 1401 lastSynced: Date.now() 1402 }); 1403 1404 store.updateRealNode(relatedNodeId, { 1405 ...relatedNodeData, 1406 node: relatedNode, 1407 lastSynced: Date.now() 1408 }); 1409 1410 // Update both .udd files 1411 await Promise.all([ 1412 this.updateUDDFile(node), 1413 this.updateUDDFile(relatedNode) 1414 ]); 1415 1416 console.log(`GitDreamNodeService: Removed bidirectional relationship ${nodeId} <-> ${relatedNodeId}`); 1417 } 1418 1419 /** 1420 * Create a DreamNode from URL metadata 1421 */ 1422 async createFromUrl( 1423 title: string, 1424 type: 'dream' | 'dreamer', 1425 urlMetadata: UrlMetadata, 1426 position?: [number, number, number] 1427 ): Promise<DreamNode> { 1428 // Use provided position or calculate random position 1429 const nodePosition = position 1430 ? position // Position is already calculated in world coordinates 1431 : this.calculateNewNodePosition(); 1432 1433 // Create node using existing create method without files 1434 const node = await this.create(title, type, undefined, nodePosition); 1435 1436 // Generate .link file name and content 1437 const linkFileName = getLinkFileName(urlMetadata, title); 1438 const linkFileContent = createLinkFileContent(urlMetadata, title); 1439 1440 console.log(`🔗 [GitDreamNodeService] Creating link file:`, { 1441 linkFileName, 1442 linkFilePath: path.join(this.vaultPath, node.repoPath, linkFileName), 1443 contentLength: linkFileContent.length, 1444 contentPreview: linkFileContent.substring(0, 100) 1445 }); 1446 1447 // Write .link file to repository 1448 const linkFilePath = path.join(this.vaultPath, node.repoPath, linkFileName); 1449 await fsPromises.writeFile(linkFilePath, linkFileContent); 1450 1451 console.log(`🔗 [GitDreamNodeService] Link file written successfully:`, linkFilePath); 1452 1453 // Update dreamTalk media to reference the .link file 1454 node.dreamTalkMedia = [{ 1455 path: linkFileName, 1456 absolutePath: linkFilePath, 1457 type: urlMetadata.type, 1458 data: linkFileContent, // Store link metadata as data 1459 size: linkFileContent.length 1460 }]; 1461 1462 // Create README content with URL 1463 const readmeContent = this.createUrlReadmeContent(urlMetadata, title); 1464 await this.writeReadmeFile(node.repoPath, readmeContent); 1465 1466 // Update .udd file with .link file path 1467 await this.updateUDDFile(node); 1468 1469 console.log(`GitDreamNodeService: Created ${type} "${title}" from URL (${urlMetadata.type})`); 1470 console.log(`GitDreamNodeService: Created .link file: ${linkFileName}`); 1471 console.log(`GitDreamNodeService: URL: ${urlMetadata.url}`); 1472 return node; 1473 } 1474 1475 /** 1476 * Add URL to an existing DreamNode 1477 */ 1478 async addUrlToNode(nodeId: string, urlMetadata: UrlMetadata): Promise<void> { 1479 const store = useInterBrainStore.getState(); 1480 const nodeData = store.realNodes.get(nodeId); 1481 1482 if (!nodeData) { 1483 throw new Error(`DreamNode with ID ${nodeId} not found`); 1484 } 1485 1486 const node = nodeData.node; 1487 1488 // Generate .link file name and content 1489 const linkFileName = getLinkFileName(urlMetadata, node.name); 1490 const linkFileContent = createLinkFileContent(urlMetadata, node.name); 1491 1492 console.log(`🔗 [GitDreamNodeService] Adding link file to existing node:`, { 1493 linkFileName, 1494 linkFilePath: path.join(this.vaultPath, node.repoPath, linkFileName), 1495 contentLength: linkFileContent.length, 1496 contentPreview: linkFileContent.substring(0, 100) 1497 }); 1498 1499 // Write .link file to repository 1500 const linkFilePath = path.join(this.vaultPath, node.repoPath, linkFileName); 1501 await fsPromises.writeFile(linkFilePath, linkFileContent); 1502 1503 console.log(`🔗 [GitDreamNodeService] Link file added successfully:`, linkFilePath); 1504 1505 // Add .link file as additional dreamTalk media 1506 const linkMedia = { 1507 path: linkFileName, 1508 absolutePath: linkFilePath, 1509 type: urlMetadata.type, 1510 data: linkFileContent, 1511 size: linkFileContent.length 1512 }; 1513 1514 node.dreamTalkMedia.push(linkMedia); 1515 1516 // Append URL content to README 1517 const urlContent = this.createUrlReadmeContent(urlMetadata); 1518 const readmePath = path.join(this.vaultPath, node.repoPath, 'README.md'); 1519 1520 try { 1521 // Read existing README content 1522 const existingContent = await fsPromises.readFile(readmePath, 'utf8'); 1523 const newContent = existingContent + '\n\n' + urlContent; 1524 await fsPromises.writeFile(readmePath, newContent); 1525 } catch (error) { 1526 console.warn(`Failed to update README for node ${nodeId}:`, error); 1527 // Create new README if it doesn't exist 1528 await this.writeReadmeFile(node.repoPath, urlContent); 1529 } 1530 1531 // Update .udd file 1532 await this.updateUDDFile(node); 1533 1534 // Update store 1535 store.updateRealNode(nodeId, { 1536 ...nodeData, 1537 node, 1538 lastSynced: Date.now() 1539 }); 1540 1541 console.log(`GitDreamNodeService: Added URL (${urlMetadata.type}) to node ${nodeId}: ${urlMetadata.url}`); 1542 console.log(`GitDreamNodeService: Created .link file: ${linkFileName}`); 1543 } 1544 1545 /** 1546 * Create README content for URLs 1547 */ 1548 private createUrlReadmeContent(urlMetadata: UrlMetadata, title?: string): string { 1549 let content = ''; 1550 1551 if (title) { 1552 content += `# ${title}\n\n`; 1553 } 1554 1555 if (urlMetadata.type === 'youtube' && urlMetadata.videoId) { 1556 // Add YouTube iframe embed for Obsidian 1557 content += generateYouTubeIframe(urlMetadata.videoId, 560, 315); 1558 content += '\n\n'; 1559 1560 // Add markdown link as backup 1561 content += `[${urlMetadata.title || 'YouTube Video'}](${urlMetadata.url})`; 1562 } else { 1563 // For other URLs, add as markdown link 1564 content += generateMarkdownLink(urlMetadata.url, urlMetadata.title); 1565 } 1566 1567 return content; 1568 } 1569 1570 /** 1571 * Write README.md file to node repository 1572 */ 1573 private async writeReadmeFile(repoPath: string, content: string): Promise<void> { 1574 const readmePath = path.join(this.vaultPath, repoPath, 'README.md'); 1575 await fsPromises.writeFile(readmePath, content); 1576 } 1577 }