uri-handler-service.ts
1 import { App, Notice, Plugin } from 'obsidian'; 2 import { RadicleService } from './radicle-service'; 3 import { GitDreamNodeService } from './git-dreamnode-service'; 4 import { DreamSongRelationshipService } from './dreamsong-relationship-service'; 5 import { useInterBrainStore } from '../store/interbrain-store'; 6 import { DreamNode } from '../types/dreamnode'; 7 8 /** 9 * URI Handler Service 10 * 11 * Registers and handles custom Obsidian URI protocols for deep linking. 12 * Enables one-click DreamNode cloning from email links. 13 */ 14 export class URIHandlerService { 15 private app: App; 16 private plugin: Plugin; 17 private radicleService: RadicleService; 18 private dreamNodeService: GitDreamNodeService; 19 20 constructor(app: App, plugin: Plugin, radicleService: RadicleService, dreamNodeService: GitDreamNodeService) { 21 this.app = app; 22 this.plugin = plugin; 23 this.radicleService = radicleService; 24 this.dreamNodeService = dreamNodeService; 25 } 26 27 /** 28 * Register all URI handlers 29 */ 30 registerHandlers(): void { 31 try { 32 // Register single node clone handler 33 // Format: obsidian://interbrain-clone?vault=<vault>&uuid=<dreamUUID> 34 this.plugin.registerObsidianProtocolHandler( 35 'interbrain-clone', 36 this.handleSingleNodeClone.bind(this) 37 ); 38 console.log(`🔗 [URIHandler] Registered: obsidian://interbrain-clone`); 39 40 // Register batch clone handler 41 // Format: obsidian://interbrain-clone-batch?vault=<vault>&uuids=<uuid1,uuid2,uuid3> 42 this.plugin.registerObsidianProtocolHandler( 43 'interbrain-clone-batch', 44 this.handleBatchNodeClone.bind(this) 45 ); 46 console.log(`🔗 [URIHandler] Registered: obsidian://interbrain-clone-batch`); 47 } catch (error) { 48 console.error('Failed to register URI handlers:', error); 49 console.warn(`⚠️ [URIHandler] Deep links will not be functional`); 50 } 51 } 52 53 /** 54 * Handle single DreamNode clone URI with collaboration handshake 55 * Format: obsidian://interbrain-clone?id=<radicleId or uuid>&senderDid=<did>&senderName=<name> 56 * Or: obsidian://interbrain-clone?repo=<github.com/user/repo> 57 */ 58 private async handleSingleNodeClone(params: Record<string, string>): Promise<'success' | 'skipped' | 'error'> { 59 try { 60 let id = params.id || params.uuid; // Support both 'id' (new) and 'uuid' (legacy) 61 const repo = params.repo; // GitHub repository path 62 const senderDid = params.senderDid ? decodeURIComponent(params.senderDid) : undefined; 63 const senderName = params.senderName ? decodeURIComponent(params.senderName) : undefined; 64 65 console.log(`🔗 [URIHandler] Single clone request:`, { id, repo, senderDid, senderName }); 66 67 // Check for GitHub repository 68 if (repo) { 69 const result = await this.cloneFromGitHub(repo); 70 71 // If clone successful OR already exists, and we have sender info, create/link Dreamer node 72 // (but for single links, we still select the cloned node, not the Dreamer) 73 if ((result === 'success' || result === 'skipped') && senderDid && senderName) { 74 await this.handleCollaborationHandshake(repo, senderDid, senderName); 75 } 76 77 // FINAL STEP: Trigger lightweight plugin reload with cloned node as target 78 if (result === 'success' || result === 'skipped') { 79 // Single clone links should ALWAYS select the cloned node (not Dreamer) 80 const clonedNode = await this.findNodeByIdentifier(repo); 81 const targetNodeUUID = clonedNode?.id; 82 83 if (targetNodeUUID) { 84 console.log(`🔄 [URIHandler] Storing target UUID for reload (cloned node): ${targetNodeUUID}`); 85 (globalThis as any).__interbrainReloadTargetUUID = targetNodeUUID; 86 } 87 88 console.log(`🔄 [URIHandler] Triggering plugin reload to finalize single clone...`); 89 const plugins = (this.app as any).plugins; 90 await plugins.disablePlugin('interbrain'); 91 await plugins.enablePlugin('interbrain'); 92 console.log(`✅ [URIHandler] Plugin reload complete - single clone finalized`); 93 } 94 95 return result; 96 } 97 98 // Check for Radicle/UUID identifier 99 if (!id) { 100 new Notice('Invalid clone link: missing node identifier or repository'); 101 console.error(`❌ [URIHandler] Single clone missing identifier parameter`); 102 return 'error'; 103 } 104 105 // URL decode the ID (handles %3A -> : conversion) 106 id = decodeURIComponent(id); 107 console.log(`🔗 [URIHandler] Decoded ID: ${id}`); 108 109 // Determine if this is a Radicle ID or UUID 110 const isRadicleId = id.startsWith('rad:'); 111 112 if (isRadicleId) { 113 // Clone from Radicle network with collaboration handshake 114 const result = await this.cloneFromRadicle(id); 115 116 // If clone successful OR already exists, and we have sender info, create/link Dreamer node 117 // (but for single links, we still select the cloned node, not the Dreamer) 118 if ((result === 'success' || result === 'skipped') && senderDid && senderName) { 119 await this.handleCollaborationHandshake(id, senderDid, senderName); 120 } 121 122 // FINAL STEP: Trigger lightweight plugin reload with cloned node as target 123 if (result === 'success' || result === 'skipped') { 124 // Single clone links should ALWAYS select the cloned node (not Dreamer) 125 const clonedNode = await this.findNodeByIdentifier(id); 126 const targetNodeUUID = clonedNode?.id; 127 128 if (targetNodeUUID) { 129 console.log(`🔄 [URIHandler] Storing target UUID for reload (cloned node): ${targetNodeUUID}`); 130 (globalThis as any).__interbrainReloadTargetUUID = targetNodeUUID; 131 } 132 133 console.log(`🔄 [URIHandler] Triggering plugin reload to finalize single clone...`); 134 const plugins = (this.app as any).plugins; 135 await plugins.disablePlugin('interbrain'); 136 await plugins.enablePlugin('interbrain'); 137 console.log(`✅ [URIHandler] Plugin reload complete - single clone finalized`); 138 } 139 140 return result; 141 } else { 142 // Legacy UUID fallback (for Windows users) 143 new Notice(`UUID-based links not yet implemented. Please ask sender to share via Radicle.`); 144 console.warn(`⚠️ [URIHandler] UUID-based clone not implemented: ${id}`); 145 return 'error'; 146 } 147 148 } catch (error) { 149 console.error('Failed to handle clone link:', error); 150 new Notice(`Failed to handle clone link: ${error instanceof Error ? error.message : 'Unknown error'}`); 151 return 'error'; 152 } 153 } 154 155 /** 156 * Handle batch DreamNode clone URI with collaboration handshake 157 * Format: obsidian://interbrain-clone-batch?ids=<id1,id2,id3>&senderDid=<did>&senderName=<name> 158 * Examples: 159 * - Pure Radicle: ids=rad:z1234,rad:z5678 160 * - Pure GitHub: ids=github.com/user/repo1,github.com/user/repo2 161 * - Mixed: ids=rad:z1234,github.com/user/repo,uuid-fallback 162 */ 163 private async handleBatchNodeClone(params: Record<string, string>): Promise<void> { 164 try { 165 const ids = params.ids || params.uuids; // Support both 'ids' (new) and 'uuids' (legacy) 166 const senderDid = params.senderDid ? decodeURIComponent(params.senderDid) : undefined; 167 const senderName = params.senderName ? decodeURIComponent(params.senderName) : undefined; 168 169 console.log(`🔗 [URIHandler] Batch clone request:`, { ids, senderDid, senderName }); 170 171 if (!ids) { 172 new Notice('Invalid batch clone link: missing node identifiers'); 173 console.error(`❌ [URIHandler] Batch clone missing identifiers parameter`); 174 return; 175 } 176 177 const identifiers = ids.split(',').map(u => u.trim()).filter(Boolean); 178 179 if (identifiers.length === 0) { 180 new Notice('Invalid batch clone link: no valid identifiers'); 181 return; 182 } 183 184 // Classify each identifier 185 const classified = identifiers.map(id => ({ 186 raw: id, 187 type: this.classifyIdentifier(id) 188 })); 189 190 // Show progress notification 191 const notice = new Notice(`Cloning ${identifiers.length} DreamNodes in parallel...`, 0); 192 193 // PARALLELIZED: Clone all nodes simultaneously 194 const clonePromises = classified.map(async ({ raw, type }) => { 195 try { 196 // Determine clone method based on type 197 let result: 'success' | 'skipped' | 'error'; 198 if (type === 'github') { 199 result = await this.cloneFromGitHub(raw, true); // silent=true 200 } else if (type === 'radicle') { 201 result = await this.cloneFromRadicle(raw, true); // silent=true 202 } else { 203 console.warn(`⚠️ [URIHandler] UUID-based clone not implemented: ${raw}`); 204 return { result: 'error', identifier: raw, type }; 205 } 206 207 return { result, identifier: raw, type }; 208 } catch (error) { 209 console.error(`❌ [URIHandler] Failed to clone ${type} identifier "${raw}":`, error); 210 return { result: 'error' as const, identifier: raw, type }; 211 } 212 }); 213 214 // Wait for all clones to complete 215 const results = await Promise.all(clonePromises); 216 217 // Count results 218 let successCount = 0; 219 let skipCount = 0; 220 let errorCount = 0; 221 222 results.forEach(({ result }) => { 223 if (result === 'success') successCount++; 224 else if (result === 'skipped') skipCount++; 225 else errorCount++; 226 }); 227 228 notice.hide(); 229 230 // Show comprehensive summary 231 const parts: string[] = []; 232 if (successCount > 0) parts.push(`${successCount} cloned`); 233 if (skipCount > 0) parts.push(`${skipCount} already existed`); 234 if (errorCount > 0) parts.push(`${errorCount} failed`); 235 236 const summary = parts.join(', '); 237 new Notice(`✅ Batch clone complete: ${summary}`); 238 239 // If we have sender info, handle collaboration handshake ONCE at the end 240 if (senderDid && senderName) { 241 // CRITICAL: Scan vault FIRST to ensure all cloned nodes are in the store 242 // before trying to link them (prevents dangling reference cleanup from removing them) 243 console.log(`🔄 [URIHandler] Scanning vault to register ${successCount + skipCount} cloned nodes...`); 244 await this.dreamNodeService.scanVault(); 245 246 // Find or create the Dreamer node 247 const dreamerNode = await this.findOrCreateDreamerNode(senderDid, senderName); 248 await new Promise(resolve => setTimeout(resolve, 200)); 249 250 // Now link all successfully cloned nodes to the Dreamer node 251 // (nodes are now guaranteed to exist in the store) 252 for (const { result, identifier } of results) { 253 if (result === 'success' || result === 'skipped') { 254 try { 255 const clonedNode = await this.findNodeByIdentifier(identifier); 256 if (clonedNode) { 257 await this.linkNodes(clonedNode, dreamerNode); 258 } else { 259 console.warn(`⚠️ [URIHandler] Node not found after vault scan: ${identifier}`); 260 } 261 } catch (linkError) { 262 console.error(`❌ [URIHandler] Failed to link ${identifier}:`, linkError); 263 } 264 } 265 } 266 267 // FINAL UI REFRESH: Run comprehensive refresh and select the Dreamer node 268 try { 269 console.log(`🔄 [URIHandler] Running comprehensive refresh (DreamSong relationships + constellation)...`); 270 271 const relationshipService = new DreamSongRelationshipService(this.plugin); 272 const scanResult = await relationshipService.scanVaultForDreamSongRelationships(); 273 274 if (scanResult.success) { 275 const canvasAPI = (globalThis as any).__interbrainCanvas; 276 if (canvasAPI?.applyConstellationLayout) { 277 await canvasAPI.applyConstellationLayout(); 278 279 // CRITICAL: Select the Dreamer node (not the cloned nodes) 280 const store = useInterBrainStore.getState(); 281 store.setSelectedNode(dreamerNode); 282 store.setSpatialLayout('liminal-web'); 283 284 console.log(`✅ [URIHandler] Batch clone complete - Dreamer node selected with all relationships visible`); 285 } 286 } 287 } catch (refreshError) { 288 console.error(`❌ [URIHandler] UI refresh failed (non-critical):`, refreshError); 289 } 290 291 // FINAL STEP: Trigger lightweight plugin reload with explicit Dreamer node UUID 292 if (dreamerNode?.id) { 293 console.log(`🔄 [URIHandler] Storing Dreamer node UUID for reload: ${dreamerNode.id}`); 294 (globalThis as any).__interbrainReloadTargetUUID = dreamerNode.id; 295 } 296 297 console.log(`🔄 [URIHandler] Triggering plugin reload to finalize batch clone...`); 298 const plugins = (this.app as any).plugins; 299 await plugins.disablePlugin('interbrain'); 300 await plugins.enablePlugin('interbrain'); 301 console.log(`✅ [URIHandler] Plugin reload complete - batch clone finalized`); 302 } 303 304 } catch (error) { 305 console.error('Failed to handle batch clone link:', error); 306 new Notice(`Failed to handle batch clone: ${error instanceof Error ? error.message : 'Unknown error'}`); 307 } 308 } 309 310 /** 311 * Classify identifier type for universal batch clone support 312 */ 313 private classifyIdentifier(id: string): 'radicle' | 'github' | 'uuid' { 314 if (id.startsWith('rad:')) return 'radicle'; 315 if (id.includes('github.com/')) return 'github'; 316 return 'uuid'; 317 } 318 319 /** 320 * Auto-focus a node after clone or when clicking already-cloned link 321 * Extracted helper to reuse for both new clones and existing nodes 322 */ 323 private async autoFocusNode(repoName: string, silent: boolean = false): Promise<void> { 324 // Find the node by repo name 325 const allNodes = await this.dreamNodeService.list(); 326 const targetNode = allNodes.find((node: any) => node.repoPath === repoName); 327 328 if (!targetNode) { 329 console.warn(`⚠️ [URIHandler] Could not find node with repoPath: ${repoName}`); 330 return; 331 } 332 333 // Set selected node in store FIRST (prevents "no selectedNode available" warning) 334 const store = useInterBrainStore.getState(); 335 store.setSelectedNode(targetNode); 336 337 // Check if DreamSpace is open and has focus API 338 const canvasAPI = (globalThis as any).__interbrainCanvas; 339 if (!canvasAPI?.focusOnNode) { 340 return; 341 } 342 343 // Focus on the node (triggers liminal-web layout transition) 344 const success = canvasAPI.focusOnNode(targetNode.id); 345 if (success && !silent) { 346 new Notice(`🎯 Node focused in DreamSpace!`); 347 } else if (!success) { 348 console.warn(`⚠️ [URIHandler] Failed to focus on "${repoName}"`); 349 } 350 } 351 352 /** 353 * Index a newly cloned node for semantic search 354 * Extracted helper to reuse for both Radicle and GitHub clones 355 */ 356 private async indexNewNode(repoName: string): Promise<void> { 357 try { 358 // Find the node by repo name 359 const allNodes = await this.dreamNodeService.list(); 360 const targetNode = allNodes.find((node: any) => node.repoPath === repoName); 361 362 if (!targetNode) { 363 console.warn(`⚠️ [URIHandler] Could not find node for indexing: ${repoName}`); 364 return; 365 } 366 367 // Index the node using semantic search service 368 const { indexingService } = await import('../features/semantic-search/services/indexing-service'); 369 await indexingService.indexNode(targetNode); 370 371 } catch (error) { 372 console.error(`❌ [URIHandler] Failed to index node (non-critical):`, error); 373 // Don't fail the clone operation if indexing fails 374 } 375 } 376 377 /** 378 * Normalize repository name to human-readable title 379 * Uses the same logic as DreamNodeMigrationService.normalizeToHumanReadable() 380 * 381 * Handles: 382 * - PascalCase: "ThunderstormGenerator" → "Thunderstorm Generator" 383 * - kebab-case: "thunderstorm-generator" → "Thunderstorm Generator" 384 * - snake_case: "thunderstorm_generator" → "Thunderstorm Generator" 385 * - Mixed: "Thunderstorm-Generator-UPDATED" → "Thunderstorm Generator Updated" 386 */ 387 private async normalizeRepoNameToTitle(repoName: string): Promise<string> { 388 const { isPascalCase, pascalCaseToTitle } = await import('../utils/title-sanitization'); 389 390 // If repo name contains hyphens, underscores, or periods as separators 391 if (/[-_.]+/.test(repoName)) { 392 // Replace separators with spaces and normalize 393 return repoName 394 .split(/[-_.]+/) // Split on hyphens, underscores, periods 395 .filter(word => word.length > 0) 396 .map(word => { 397 // Capitalize first letter, lowercase rest (proper title case) 398 const cleaned = word.trim(); 399 if (cleaned.length === 0) return ''; 400 return cleaned.charAt(0).toUpperCase() + cleaned.slice(1).toLowerCase(); 401 }) 402 .join(' ') 403 .trim(); 404 } 405 406 // If repo name is pure PascalCase (no separators), convert to spaced format 407 if (isPascalCase(repoName)) { 408 return pascalCaseToTitle(repoName); 409 } 410 411 // Already human-readable with spaces, return as-is 412 return repoName; 413 } 414 415 /** 416 * Clone a DreamNode from Radicle network 417 * Public method to allow reuse by CoherenceBeaconService and other features 418 */ 419 public async cloneFromRadicle(radicleId: string, silent: boolean = false): Promise<'success' | 'skipped' | 'error'> { 420 try { 421 // Get vault path 422 const adapter = this.app.vault.adapter as any; 423 const vaultPath = adapter.basePath || ''; 424 425 if (!vaultPath) { 426 throw new Error('Could not determine vault path'); 427 } 428 429 // Clone the repository (handles duplicate detection internally) 430 if (!silent) { 431 new Notice(`Cloning from Radicle network...`, 3000); 432 } 433 434 const cloneResult = await this.radicleService.clone(radicleId, vaultPath); 435 let finalRepoName = cloneResult.repoName; 436 437 // Strip UUID suffix from directory name if present (backend uses it for uniqueness) 438 // Format: "Name-abc1234" → "Name" 439 if (!cloneResult.alreadyExisted) { 440 const cleanName = cloneResult.repoName.replace(/-[a-f0-9]{7}$/i, ''); 441 if (cleanName !== cloneResult.repoName) { 442 const path = require('path'); 443 const fs = require('fs').promises; 444 const oldPath = path.join(vaultPath, cloneResult.repoName); 445 const newPath = path.join(vaultPath, cleanName); 446 447 console.log(`URIHandler: Renaming ${cloneResult.repoName} → ${cleanName}...`); 448 await fs.rename(oldPath, newPath); 449 finalRepoName = cleanName; 450 console.log(`URIHandler: ✓ Renamed to clean PascalCase name`); 451 452 // Initialize submodules if any 453 const execAsync = require('util').promisify(require('child_process').exec); 454 try { 455 await execAsync('git submodule update --init --recursive', { cwd: newPath }); 456 console.log(`URIHandler: ✓ Submodules initialized`); 457 } catch (subErr) { 458 console.warn(`URIHandler: No submodules or init failed:`, subErr); 459 } 460 } 461 } 462 463 // Check if repo already existed - if so, skip refresh but still focus 464 if (cloneResult.alreadyExisted) { 465 if (!silent) { 466 new Notice(`📌 DreamNode "${finalRepoName}" already cloned!`); 467 // Auto-focus the existing node (only when not in batch mode) 468 await this.autoFocusNode(finalRepoName, silent); 469 } 470 471 return 'skipped'; // Already have it, no refresh needed 472 } 473 474 if (!silent) { 475 new Notice(`✅ Cloned "${finalRepoName}" successfully!`); 476 } 477 478 // CRITICAL: Write Radicle ID to .udd file for future lookups 479 try { 480 const fs = require('fs').promises; 481 const path = require('path'); 482 const uddPath = path.join(vaultPath, finalRepoName, '.udd'); 483 const uddContent = await fs.readFile(uddPath, 'utf-8'); 484 const udd = JSON.parse(uddContent); 485 486 if (!udd.radicleId) { 487 udd.radicleId = radicleId; 488 await fs.writeFile(uddPath, JSON.stringify(udd, null, 2), 'utf-8'); 489 console.log(`✅ [URIHandler] Saved Radicle ID to .udd: ${radicleId}`); 490 } 491 } catch (uddError) { 492 console.warn(`⚠️ [URIHandler] Could not save Radicle ID to .udd:`, uddError); 493 } 494 495 // AUTO-REFRESH: Make the newly cloned node appear immediately (skip in batch mode) 496 if (!silent) { 497 try { 498 // Step 1: Rescan vault to detect the new DreamNode 499 await this.dreamNodeService.scanVault(); 500 501 // Step 2: Index the newly cloned node for semantic search 502 await this.indexNewNode(finalRepoName); 503 504 // Step 3: Rescan DreamSong relationships 505 const relationshipService = new DreamSongRelationshipService(this.plugin); 506 const scanResult = await relationshipService.scanVaultForDreamSongRelationships(); 507 508 if (scanResult.success) { 509 // Step 4: Apply constellation layout if DreamSpace is open 510 const canvasAPI = (globalThis as any).__interbrainCanvas; 511 if (canvasAPI?.applyConstellationLayout) { 512 await canvasAPI.applyConstellationLayout(); 513 514 // Step 5: Auto-focus the newly cloned node 515 await this.autoFocusNode(finalRepoName, silent); 516 } 517 } else { 518 console.warn(`⚠️ [URIHandler] Relationship scan failed:`, scanResult.error); 519 } 520 521 } catch (refreshError) { 522 console.error(`❌ [URIHandler] Auto-refresh failed (non-critical):`, refreshError); 523 // Don't fail the clone operation if refresh fails 524 } 525 } 526 527 return 'success'; 528 529 } catch (error) { 530 console.error(`❌ [URIHandler] Clone failed for ${radicleId}:`, error); 531 532 if (!silent) { 533 const errorMsg = error instanceof Error ? error.message : 'Unknown error'; 534 new Notice(`Failed to clone: ${errorMsg}`); 535 } 536 537 return 'error'; 538 } 539 } 540 541 /** 542 * Clone a DreamNode from GitHub 543 */ 544 private async cloneFromGitHub(repoPath: string, silent: boolean = false): Promise<'success' | 'skipped' | 'error'> { 545 try { 546 // Get vault path 547 const adapter = this.app.vault.adapter as any; 548 const vaultPath = adapter.basePath || ''; 549 550 if (!vaultPath) { 551 throw new Error('Could not determine vault path'); 552 } 553 554 // Extract repo name from path (e.g., "github.com/user/dreamnode-uuid" → "dreamnode-uuid") 555 const match = repoPath.match(/github\.com\/[^/]+\/([^/\s]+)/); 556 if (!match) { 557 throw new Error(`Invalid GitHub repository path: ${repoPath}`); 558 } 559 560 const repoName = match[1].replace(/\.git$/, ''); 561 const destinationPath = require('path').join(vaultPath, repoName); 562 563 // Check if already exists 564 const fs = require('fs'); 565 if (fs.existsSync(destinationPath)) { 566 if (!silent) { 567 new Notice(`📌 DreamNode "${repoName}" already cloned!`); 568 // Auto-focus the existing node (only when not in batch mode) 569 await this.autoFocusNode(repoName, silent); 570 } 571 return 'skipped'; 572 } 573 574 // Show progress 575 if (!silent) { 576 new Notice(`Cloning from GitHub...`, 3000); 577 } 578 579 // Import GitHub service and clone 580 const { githubService } = await import('../features/github-sharing/GitHubService'); 581 const githubUrl = `https://${repoPath}`; 582 await githubService.clone(githubUrl, destinationPath); 583 584 // AUTO-INITIALIZE: Create .udd file for InterBrain compatibility 585 try { 586 const path = require('path'); 587 const fsPromises = require('fs').promises; 588 const uddPath = path.join(destinationPath, '.udd'); 589 590 // Check if .udd already exists (shouldn't happen, but be safe) 591 if (!fs.existsSync(uddPath)) { 592 // Generate UUID for this DreamNode (using Node.js built-in) 593 const crypto = require('crypto'); 594 const uuid = crypto.randomUUID(); 595 596 // Derive human-readable title from repo name using established naming schema 597 // Uses the same normalization logic as DreamNodeMigrationService 598 // Handles kebab-case, snake_case, PascalCase → "Human Readable Title" 599 const title = await this.normalizeRepoNameToTitle(repoName); 600 601 // Create minimal .udd structure 602 const udd = { 603 uuid, 604 title, 605 type: 'dream', 606 dreamTalk: '', 607 liminalWebRelationships: [], 608 submodules: [], 609 supermodules: [], 610 githubRepoUrl: githubUrl // Preserve GitHub URL for fallback broadcasts 611 }; 612 613 // Write .udd file asynchronously to ensure completion before scanVault() 614 await fsPromises.writeFile(uddPath, JSON.stringify(udd, null, 2), 'utf8'); 615 } 616 } catch (uddError) { 617 console.error(`❌ [URIHandler] Failed to create .udd file (non-critical):`, uddError); 618 // Don't fail the clone operation if .udd creation fails 619 } 620 621 if (!silent) { 622 new Notice(`✅ Cloned "${repoName}" successfully!`); 623 } 624 625 // AUTO-REFRESH: Make the newly cloned node appear immediately (skip in batch mode) 626 if (!silent) { 627 try { 628 // Step 1: Rescan vault to detect the new DreamNode 629 await this.dreamNodeService.scanVault(); 630 631 // Step 2: Index the newly cloned node for semantic search 632 await this.indexNewNode(repoName); 633 634 // Step 3: Rescan DreamSong relationships 635 const relationshipService = new DreamSongRelationshipService(this.plugin); 636 const scanResult = await relationshipService.scanVaultForDreamSongRelationships(); 637 638 if (scanResult.success) { 639 // Step 4: Apply constellation layout if DreamSpace is open 640 const canvasAPI = (globalThis as any).__interbrainCanvas; 641 if (canvasAPI?.applyConstellationLayout) { 642 await canvasAPI.applyConstellationLayout(); 643 644 // Step 5: Auto-focus the newly cloned node 645 await this.autoFocusNode(repoName, silent); 646 } 647 } else { 648 console.warn(`⚠️ [URIHandler] Relationship scan failed:`, scanResult.error); 649 } 650 651 } catch (refreshError) { 652 console.error(`❌ [URIHandler] Auto-refresh failed (non-critical):`, refreshError); 653 // Don't fail the clone operation if refresh fails 654 } 655 } 656 657 return 'success'; 658 659 } catch (error) { 660 console.error(`❌ [URIHandler] GitHub clone failed for ${repoPath}:`, error); 661 662 if (!silent) { 663 const errorMsg = error instanceof Error ? error.message : 'Unknown error'; 664 new Notice(`Failed to clone from GitHub: ${errorMsg}`); 665 } 666 667 return 'error'; 668 } 669 } 670 671 /** 672 * Handle collaboration handshake: create Dreamer node for sender and link cloned node 673 * @param clonedNodeIdentifier Radicle ID or GitHub URL of the cloned node 674 * @param senderDid Sender's Radicle DID 675 * @param senderName Sender's human-readable name 676 */ 677 private async handleCollaborationHandshake( 678 clonedNodeIdentifier: string, 679 senderDid: string, 680 senderName: string 681 ): Promise<DreamNode | undefined> { 682 try { 683 console.log(`🤝 [URIHandler] Starting collaboration handshake for ${senderName} (${senderDid})...`); 684 685 // Step 1: Find or create Dreamer node for sender 686 const dreamerNode = await this.findOrCreateDreamerNode(senderDid, senderName); 687 688 // Wait a bit to ensure all file operations are complete 689 await new Promise(resolve => setTimeout(resolve, 200)); 690 691 // Step 2: Find the cloned node by identifier (Radicle ID or GitHub URL) 692 const clonedNode = await this.findNodeByIdentifier(clonedNodeIdentifier); 693 if (!clonedNode) { 694 console.warn(`⚠️ [URIHandler] Could not find cloned node with identifier: ${clonedNodeIdentifier}`); 695 return; 696 } 697 698 // Step 3: Link cloned node to Dreamer node (add relationship) 699 await this.linkNodes(clonedNode, dreamerNode); 700 701 console.log(`✅ [URIHandler] Collaboration handshake complete: "${clonedNode.name}" linked to "${dreamerNode.name}"`); 702 703 // Step 4: Refresh UI to show new relationship immediately 704 try { 705 // Rescan vault to detect the new Dreamer node (if created) 706 await this.dreamNodeService.scanVault(); 707 708 // Rescan relationships to update constellation 709 const relationshipService = new DreamSongRelationshipService(this.plugin); 710 const scanResult = await relationshipService.scanVaultForDreamSongRelationships(); 711 712 if (scanResult.success) { 713 // Apply constellation layout to show new relationship 714 const canvasAPI = (globalThis as any).__interbrainCanvas; 715 if (canvasAPI?.applyConstellationLayout) { 716 await canvasAPI.applyConstellationLayout(); 717 console.log(`✅ [URIHandler] UI refreshed - relationship now visible`); 718 } 719 } 720 } catch (refreshError) { 721 console.error(`❌ [URIHandler] UI refresh failed (non-critical):`, refreshError); 722 } 723 724 return dreamerNode; 725 726 } catch (error) { 727 console.error(`❌ [URIHandler] Collaboration handshake failed:`, error); 728 // Don't fail the whole operation if handshake fails 729 return undefined; 730 } 731 } 732 733 /** 734 * Find existing Dreamer node by DID, or create new one 735 */ 736 private async findOrCreateDreamerNode(did: string, name: string): Promise<any> { 737 // Search for existing Dreamer node with this DID 738 const allNodes = await this.dreamNodeService.list(); 739 const existingDreamer = allNodes.find((node: any) => { 740 return node.type === 'dreamer' && node.radicleId === did; 741 }); 742 743 if (existingDreamer) { 744 console.log(`👤 [URIHandler] Found existing Dreamer node: "${existingDreamer.name}"`); 745 746 // Ensure UUID is populated (store object might not have it) 747 if (!existingDreamer.uuid) { 748 const fs = require('fs').promises; 749 const path = require('path'); 750 try { 751 const uddPath = path.join(this.app.vault.adapter.basePath, existingDreamer.repoPath, '.udd'); 752 const uddContent = await fs.readFile(uddPath, 'utf-8'); 753 const udd = JSON.parse(uddContent); 754 existingDreamer.uuid = udd.uuid; 755 console.log(`✅ [URIHandler] Populated UUID for existing Dreamer: ${existingDreamer.uuid}`); 756 } catch (error) { 757 console.warn(`⚠️ [URIHandler] Could not read UUID for existing Dreamer:`, error); 758 } 759 } 760 761 return existingDreamer; 762 } 763 764 // Create new Dreamer node 765 console.log(`👤 [URIHandler] Creating new Dreamer node for ${name}...`); 766 767 const newDreamer = await this.dreamNodeService.create(name, 'dreamer'); 768 769 // Wait for .udd file to be created by pre-commit hook 770 const uddPath = require('path').join(this.app.vault.adapter.basePath, newDreamer.repoPath, '.udd'); 771 const fs = require('fs').promises; 772 773 try { 774 // Retry loop: wait for pre-commit hook to move .udd file 775 let retries = 10; 776 let uddContent = null; 777 while (retries > 0) { 778 try { 779 uddContent = await fs.readFile(uddPath, 'utf-8'); 780 break; // Success! 781 } catch (error) { 782 if (retries === 1) throw error; // Last attempt failed 783 await new Promise(resolve => setTimeout(resolve, 100)); // Wait 100ms 784 retries--; 785 } 786 } 787 788 if (!uddContent) { 789 throw new Error('Failed to read .udd file after retries'); 790 } 791 792 const udd = JSON.parse(uddContent); 793 udd.radicleId = did; 794 await fs.writeFile(uddPath, JSON.stringify(udd, null, 2), 'utf-8'); 795 console.log(`✅ [URIHandler] Saved DID to Dreamer node: ${did}`); 796 797 // CRITICAL: Populate UUID from .udd file (store object doesn't have it) 798 newDreamer.uuid = udd.uuid; 799 console.log(`✅ [URIHandler] Dreamer node UUID: ${newDreamer.uuid}`); 800 } catch (error) { 801 console.warn(`⚠️ [URIHandler] Could not save DID to .udd file:`, error); 802 } 803 804 return newDreamer; 805 } 806 807 /** 808 * Find node by identifier (Radicle ID or GitHub URL) 809 */ 810 private async findNodeByIdentifier(identifier: string): Promise<any> { 811 const allNodes = await this.dreamNodeService.list(); 812 813 // Determine if identifier is Radicle ID or GitHub URL 814 const isRadicleId = identifier.startsWith('rad:'); 815 const isGitHubUrl = identifier.includes('github.com/'); 816 817 // Normalize GitHub URL if needed (remove protocol, .git suffix) 818 const normalizedGitHubUrl = isGitHubUrl 819 ? identifier.replace(/^https?:\/\//, '').replace(/\.git$/, '') 820 : null; 821 822 const fs = require('fs').promises; 823 const path = require('path'); 824 825 for (const node of allNodes) { 826 try { 827 const uddPath = path.join(this.app.vault.adapter.basePath, node.repoPath, '.udd'); 828 const uddContent = await fs.readFile(uddPath, 'utf-8'); 829 const udd = JSON.parse(uddContent); 830 831 // Check Radicle ID 832 if (isRadicleId && udd.radicleId === identifier) { 833 console.log(`🔍 [URIHandler] Found node by Radicle ID: "${node.name}"`); 834 node.uuid = udd.uuid; 835 return node; 836 } 837 838 // Check GitHub URL 839 if (isGitHubUrl && udd.githubRepoUrl) { 840 const normalizedUddUrl = udd.githubRepoUrl.replace(/^https?:\/\//, '').replace(/\.git$/, ''); 841 if (normalizedUddUrl === normalizedGitHubUrl) { 842 console.log(`🔍 [URIHandler] Found node by GitHub URL: "${node.name}"`); 843 node.uuid = udd.uuid; 844 return node; 845 } 846 } 847 } catch { 848 // Skip nodes without .udd or invalid JSON 849 } 850 } 851 852 console.warn(`⚠️ [URIHandler] No node found with identifier: ${identifier}`); 853 return null; 854 } 855 856 /** 857 * Link two nodes by adding relationship 858 */ 859 private async linkNodes(sourceNode: any, targetNode: any): Promise<void> { 860 try { 861 // CRITICAL VALIDATION: Ensure both nodes have UUIDs 862 if (!sourceNode.uuid) { 863 console.error(`❌ [URIHandler] Source node "${sourceNode.name}" has no UUID!`); 864 throw new Error(`Cannot link node without UUID: ${sourceNode.name}`); 865 } 866 if (!targetNode.uuid) { 867 console.error(`❌ [URIHandler] Target node "${targetNode.name}" has no UUID!`); 868 throw new Error(`Cannot link node without UUID: ${targetNode.name}`); 869 } 870 871 const fs = require('fs').promises; 872 const path = require('path'); 873 const adapter = this.app.vault.adapter as any; 874 const vaultPath = adapter.basePath || ''; 875 876 // Add bidirectional relationship by updating .udd files 877 // Source -> Target 878 const sourceUddPath = path.join(vaultPath, sourceNode.repoPath, '.udd'); 879 const sourceUddContent = await fs.readFile(sourceUddPath, 'utf-8'); 880 const sourceUdd = JSON.parse(sourceUddContent); 881 882 if (!sourceUdd.liminalWebRelationships) { 883 sourceUdd.liminalWebRelationships = []; 884 } 885 886 // Add relationship if not already present 887 if (!sourceUdd.liminalWebRelationships.includes(targetNode.uuid)) { 888 sourceUdd.liminalWebRelationships.push(targetNode.uuid); 889 await fs.writeFile(sourceUddPath, JSON.stringify(sourceUdd, null, 2), 'utf-8'); 890 console.log(`🔗 [URIHandler] Added relationship: "${sourceNode.name}" -> "${targetNode.name}"`); 891 } 892 893 // Target -> Source 894 const targetUddPath = path.join(vaultPath, targetNode.repoPath, '.udd'); 895 const targetUddContent = await fs.readFile(targetUddPath, 'utf-8'); 896 const targetUdd = JSON.parse(targetUddContent); 897 898 if (!targetUdd.liminalWebRelationships) { 899 targetUdd.liminalWebRelationships = []; 900 } 901 902 // Add relationship if not already present 903 if (!targetUdd.liminalWebRelationships.includes(sourceNode.uuid)) { 904 targetUdd.liminalWebRelationships.push(sourceNode.uuid); 905 await fs.writeFile(targetUddPath, JSON.stringify(targetUdd, null, 2), 'utf-8'); 906 console.log(`🔗 [URIHandler] Added relationship: "${targetNode.name}" -> "${sourceNode.name}"`); 907 } 908 909 console.log(`✅ [URIHandler] Linked "${sourceNode.name}" <-> "${targetNode.name}"`); 910 } catch (error) { 911 console.error(`❌ [URIHandler] Failed to link nodes:`, error); 912 throw error; 913 } 914 } 915 916 /** 917 * Generate deep link URL for single DreamNode with collaboration handshake 918 * @param vaultName The Obsidian vault name (unused, kept for API compatibility) 919 * @param identifier Radicle ID (preferred) or UUID (fallback) 920 * @param senderDid Optional sender's Radicle DID for peer following 921 * @param senderName Optional sender's human-readable name for Dreamer node creation 922 */ 923 static generateSingleNodeLink(vaultName: string, identifier: string, senderDid?: string, senderName?: string): string { 924 // Don't encode colons in Radicle IDs - they're part of the protocol 925 // rad:z... should stay as rad:z..., not rad%3Az... 926 const encodedIdentifier = identifier.startsWith('rad:') 927 ? identifier // Keep Radicle ID as-is 928 : encodeURIComponent(identifier); // Encode other identifiers (UUIDs) 929 930 let uri = `obsidian://interbrain-clone?id=${encodedIdentifier}`; 931 932 // Add collaboration handshake parameters if provided 933 if (senderDid) { 934 uri += `&senderDid=${encodeURIComponent(senderDid)}`; 935 } 936 if (senderName) { 937 uri += `&senderName=${encodeURIComponent(senderName)}`; 938 } 939 940 return uri; 941 } 942 943 /** 944 * Generate deep link URL for GitHub clone 945 * @param vaultName The Obsidian vault name (unused, kept for API compatibility) 946 * @param githubRepoUrl GitHub repository URL (e.g., "https://github.com/user/repo" or "github.com/user/repo") 947 */ 948 static generateGitHubCloneLink(vaultName: string, githubRepoUrl: string): string { 949 // Extract clean repo path: github.com/user/repo 950 const repoPath = githubRepoUrl 951 .replace(/^https?:\/\//, '') // Remove protocol 952 .replace(/\.git$/, ''); // Remove .git suffix 953 954 // Return clean URI without encoding (slashes must remain unencoded) 955 return `obsidian://interbrain-clone?repo=${repoPath}`; 956 } 957 958 /** 959 * Generate deep link URL for batch clone with collaboration handshake 960 * @param vaultName The Obsidian vault name (unused, kept for API compatibility) 961 * @param identifiers Array of identifiers (can be Radicle IDs, GitHub URLs, or UUIDs) 962 * @param senderDid Optional sender's Radicle DID for peer following 963 * @param senderName Optional sender's human-readable name for Dreamer node creation 964 */ 965 static generateBatchNodeLink(vaultName: string, identifiers: string[], senderDid?: string, senderName?: string): string { 966 const encodedIdentifiers = encodeURIComponent(identifiers.join(',')); 967 let uri = `obsidian://interbrain-clone-batch?ids=${encodedIdentifiers}`; 968 969 // Add collaboration handshake parameters if provided 970 if (senderDid) { 971 uri += `&senderDid=${encodeURIComponent(senderDid)}`; 972 } 973 if (senderName) { 974 uri += `&senderName=${encodeURIComponent(senderName)}`; 975 } 976 977 return uri; 978 } 979 } 980 981 // Singleton instance 982 let _uriHandlerService: URIHandlerService | null = null; 983 984 export function initializeURIHandlerService(app: App, plugin: Plugin, radicleService: RadicleService, dreamNodeService: GitDreamNodeService): void { 985 _uriHandlerService = new URIHandlerService(app, plugin, radicleService, dreamNodeService); 986 _uriHandlerService.registerHandlers(); 987 console.log(`🔗 [URIHandler] Service initialized`); 988 } 989 990 export function getURIHandlerService(): URIHandlerService { 991 if (!_uriHandlerService) { 992 throw new Error('URIHandlerService not initialized. Call initializeURIHandlerService() first.'); 993 } 994 return _uriHandlerService; 995 }