uri-handler-service.ts
1 import { App, Notice, Plugin } from 'obsidian'; 2 import { RadicleService } from '../social-resonance-filter/services/radicle-service'; 3 import { GitDreamNodeService } from '../dreamnode/services/git-dreamnode-service'; 4 import { UDDService } from '../dreamnode/services/udd-service'; 5 import { DreamSongRelationshipService } from '../dreamweaving/services/dreamsong-relationship-service'; 6 import { useInterBrainStore } from '../../core/store/interbrain-store'; 7 import { DreamNode } from '../dreamnode'; 8 9 /** 10 * URI Handler Service 11 * 12 * Registers and handles custom Obsidian URI protocols for deep linking. 13 * Enables one-click DreamNode cloning from email links. 14 */ 15 export class URIHandlerService { 16 private app: App; 17 private plugin: Plugin; 18 private radicleService: RadicleService; 19 private dreamNodeService: GitDreamNodeService; 20 21 constructor(app: App, plugin: Plugin, radicleService: RadicleService, dreamNodeService: GitDreamNodeService) { 22 this.app = app; 23 this.plugin = plugin; 24 this.radicleService = radicleService; 25 this.dreamNodeService = dreamNodeService; 26 } 27 28 /** 29 * Register all URI handlers 30 */ 31 registerHandlers(): void { 32 try { 33 // Clone handler: obsidian://interbrain-clone?ids=<id1,id2,...>&senderDid=<did>&senderName=<name> 34 this.plugin.registerObsidianProtocolHandler( 35 'interbrain-clone', 36 this.handleClone.bind(this) 37 ); 38 39 // Update contact handler: obsidian://interbrain-update-contact?did=<did>&uuid=<uuid>&name=<name>&email=<email> 40 this.plugin.registerObsidianProtocolHandler( 41 'interbrain-update-contact', 42 this.handleUpdateContact.bind(this) 43 ); 44 // Universal command handler: obsidian://interbrain?command=<command>&uuid=<uuid> 45 this.plugin.registerObsidianProtocolHandler( 46 'interbrain', 47 this.handleCommand.bind(this) 48 ); 49 } catch (error) { 50 console.error('[URIHandler] Failed to register handlers:', error); 51 } 52 } 53 54 /** 55 * Universal command handler for external tools (e.g. Alfred) 56 * Format: obsidian://interbrain?command=<command>&uuid=<uuid> 57 * 58 * If uuid is provided, selects the DreamNode before executing the command. 59 */ 60 private async handleCommand(params: Record<string, string>): Promise<void> { 61 try { 62 const command = params.command; 63 if (!command) { 64 new Notice('Invalid InterBrain URI: missing command parameter'); 65 return; 66 } 67 68 const uuid = params.uuid; 69 70 // If a UUID is provided, select the node first 71 if (uuid) { 72 const store = useInterBrainStore.getState(); 73 const nodeData = store.dreamNodes.get(uuid); 74 if (nodeData) { 75 store.setSelectedNode(nodeData.node); 76 store.requestNavigation({ type: 'liminal-web-focus', nodeId: nodeData.node.id }); 77 } else { 78 console.warn(`[URIHandler] DreamNode not found for UUID: ${uuid}`); 79 new Notice(`DreamNode not found: ${uuid.slice(0, 8)}...`); 80 return; 81 } 82 } 83 84 // Execute the command via Obsidian's command palette 85 const commandId = `interbrain:${command}`; 86 const executed = (this.app as any).commands.executeCommandById(commandId); 87 if (!executed) { 88 new Notice(`Unknown InterBrain command: ${command}`); 89 } 90 } catch (error) { 91 console.error('[URIHandler] Failed to handle command:', error); 92 new Notice(`Failed to execute command: ${error instanceof Error ? error.message : 'Unknown error'}`); 93 } 94 } 95 96 /** 97 * Unified clone handler - handles both single and batch clones 98 * Format: obsidian://interbrain-clone?ids=<id1,id2,...>&senderDid=<did>&senderName=<name> 99 * 100 * Selection logic: 101 * - Single ID (ids=rad:z1234): Selects the cloned Dream node 102 * - Multiple IDs (ids=rad:z1234,rad:z5678): Selects the Dreamer node 103 */ 104 private async handleClone(params: Record<string, string>): Promise<void> { 105 try { 106 const ids = params.ids || params.id || params.uuid || params.uuids; // Support legacy formats 107 const senderDid = params.senderDid ? decodeURIComponent(params.senderDid) : undefined; 108 const senderName = params.senderName ? decodeURIComponent(params.senderName) : undefined; 109 110 if (!ids) { 111 new Notice('Invalid clone link: missing node identifiers'); 112 return; 113 } 114 115 const identifiers = ids.split(',').map(u => u.trim()).filter(Boolean); 116 117 if (identifiers.length === 0) { 118 new Notice('Invalid clone link: no valid identifiers'); 119 return; 120 } 121 122 const isSingleClone = identifiers.length === 1; 123 124 // Classify each identifier 125 const classified = identifiers.map(id => ({ 126 raw: id, 127 type: this.classifyIdentifier(id) 128 })); 129 130 // Show progress notification 131 const notice = new Notice(`Cloning ${identifiers.length} DreamNode${identifiers.length > 1 ? 's' : ''} in parallel...`, 0); 132 133 // PARALLELIZED: Clone all nodes simultaneously 134 const clonePromises = classified.map(async ({ raw, type }) => { 135 try { 136 // Determine clone method based on type 137 let result: 'success' | 'skipped' | 'error'; 138 if (type === 'github') { 139 result = await this.cloneFromGitHub(raw, true); // silent=true 140 } else if (type === 'radicle') { 141 // Pass senderDid as peerNid for direct P2P clone 142 const cloneResult = await this.cloneFromRadicle(raw, true, senderDid); 143 result = cloneResult.status; 144 } else { 145 console.warn(`⚠️ [URIHandler] UUID-based clone not implemented: ${raw}`); 146 return { result: 'error', identifier: raw, type }; 147 } 148 149 return { result, identifier: raw, type }; 150 } catch (error) { 151 console.error(`❌ [URIHandler] Failed to clone ${type} identifier "${raw}":`, error); 152 return { result: 'error' as const, identifier: raw, type }; 153 } 154 }); 155 156 // Wait for all clones to complete 157 const results = await Promise.all(clonePromises); 158 159 // Count results 160 let successCount = 0; 161 let skipCount = 0; 162 let errorCount = 0; 163 164 results.forEach(({ result }) => { 165 if (result === 'success') successCount++; 166 else if (result === 'skipped') skipCount++; 167 else errorCount++; 168 }); 169 170 notice.hide(); 171 172 // Show comprehensive summary with appropriate icon 173 const parts: string[] = []; 174 if (successCount > 0) parts.push(`${successCount} cloned`); 175 if (skipCount > 0) parts.push(`${skipCount} already existed`); 176 if (errorCount > 0) parts.push(`${errorCount} failed`); 177 178 const summary = parts.join(', '); 179 180 if (errorCount > 0 && successCount === 0 && skipCount === 0) { 181 // All failed - show error with explanation 182 new Notice( 183 `❌ Clone failed. The DreamNode may not be available on the network yet. ` + 184 `The sender should ensure they've shared the link after the node is synced.`, 185 10000 186 ); 187 } else if (errorCount > 0) { 188 // Partial failure 189 new Notice(`⚠️ Clone partial: ${summary}. Some nodes may need the sender to sync first.`, 8000); 190 } else { 191 // All success or skipped 192 new Notice(`✅ Clone complete: ${summary}`); 193 } 194 195 // Determine if anything actually changed (new clones vs all already existed) 196 const allNodesAlreadyExisted = successCount === 0 && skipCount > 0; 197 198 // If we have sender info, handle collaboration handshake 199 if (senderDid && senderName) { 200 if (allNodesAlreadyExisted) { 201 // Fast path: nodes exist, ensure Dreamer + relationships 202 203 // Get current store state (nodes already loaded) 204 const store = useInterBrainStore.getState(); 205 206 // Convert Map to array for searching 207 const nodesArray = Array.from(store.dreamNodes.values()).map(nodeData => nodeData.node); 208 209 // Check if Dreamer node exists 210 let dreamerNode = nodesArray.find((n: any) => n.type === 'dreamer' && n.did === senderDid); 211 212 // Extract senderEmail from params if available 213 const senderEmail = params.senderEmail ? decodeURIComponent(params.senderEmail) : undefined; 214 215 if (!dreamerNode) { 216 // Create Dreamer node and link relationships 217 await this.dreamNodeService.scanVault(); 218 dreamerNode = await this.findOrCreateDreamerNode(senderDid, senderName, senderEmail); 219 await new Promise(resolve => setTimeout(resolve, 200)); 220 221 // Link all cloned nodes to the Dreamer 222 for (const identifier of identifiers) { 223 try { 224 const clonedNode = await this.findNodeByIdentifier(identifier); 225 if (clonedNode) { 226 await this.linkNodes(clonedNode, dreamerNode); 227 } 228 } catch { 229 // Non-critical link failure 230 } 231 } 232 233 // Sync Radicle peer relationships 234 try { 235 await (this.app as any).commands.executeCommandById('interbrain:sync-radicle-peer-following'); 236 } catch { 237 // Non-critical sync failure 238 } 239 } else { 240 // Dreamer exists - DreamNode.id is already the UUID 241 (dreamerNode as any).uuid = dreamerNode.id; 242 243 // Check if all cloned nodes are linked to this Dreamer 244 let missingLinks = false; 245 for (const identifier of identifiers) { 246 const clonedNode = await this.findNodeByIdentifier(identifier); 247 if (clonedNode && !dreamerNode.liminalWebConnections?.includes(clonedNode.id)) { 248 missingLinks = true; 249 await this.linkNodes(clonedNode, dreamerNode); 250 } 251 } 252 253 if (missingLinks) { 254 await this.dreamNodeService.scanVault(); 255 256 try { 257 await (this.app as any).commands.executeCommandById('interbrain:sync-radicle-peer-following'); 258 } catch { 259 // Non-critical 260 } 261 } 262 } 263 264 // Select the appropriate target node 265 let targetNode: any; 266 if (isSingleClone) { 267 // Single clone: Select the cloned Dream node 268 targetNode = nodesArray.find(n => { 269 if (identifiers[0].startsWith('rad:')) { 270 return n.radicleId === identifiers[0]; 271 } 272 if (identifiers[0].includes('github.com/')) { 273 return n.githubRepoUrl?.includes(identifiers[0]); 274 } 275 return false; 276 }); 277 } else { 278 // Batch clone: Select the Dreamer node 279 targetNode = dreamerNode; 280 } 281 282 if (targetNode) { 283 store.setSelectedNode(targetNode); 284 } else { 285 await (this.app as any).commands.executeCommandById('interbrain:refresh-plugin'); 286 } 287 288 } else { 289 // Full path: New nodes cloned - run complete workflow 290 await this.dreamNodeService.scanVault(); 291 292 const senderEmail = params.senderEmail ? decodeURIComponent(params.senderEmail) : undefined; 293 const dreamerNode = await this.findOrCreateDreamerNode(senderDid, senderName, senderEmail); 294 await new Promise(resolve => setTimeout(resolve, 200)); 295 296 // Link all successfully cloned nodes to the Dreamer node 297 for (const { result, identifier } of results) { 298 if (result === 'success' || result === 'skipped') { 299 try { 300 const clonedNode = await this.findNodeByIdentifier(identifier); 301 if (clonedNode) { 302 await this.linkNodes(clonedNode, dreamerNode); 303 } 304 } catch { 305 // Non-critical link failure 306 } 307 } 308 } 309 310 // Sync Radicle peer relationships 311 try { 312 await (this.app as any).commands.executeCommandById('interbrain:sync-radicle-peer-following'); 313 } catch { 314 // Non-critical sync failure 315 } 316 317 // Refresh UI with smart selection 318 try { 319 let targetUUID: string | undefined; 320 if (isSingleClone) { 321 const clonedNode = await this.findNodeByIdentifier(identifiers[0]); 322 targetUUID = clonedNode?.id; 323 } else { 324 targetUUID = dreamerNode.id; 325 } 326 327 if (targetUUID) { 328 (globalThis as any).__interbrainReloadTargetUUID = targetUUID; 329 } 330 await (this.app as any).commands.executeCommandById('interbrain:refresh-plugin'); 331 } catch { 332 // Non-critical refresh failure 333 } 334 } 335 } 336 337 } catch (error) { 338 console.error('Failed to handle clone link:', error); 339 new Notice(`Failed to handle clone: ${error instanceof Error ? error.message : 'Unknown error'}`); 340 } 341 } 342 343 /** 344 * Handle contact update (DID backpropagation from Bob → Alice) 345 * Format: obsidian://interbrain-update-contact?did=<did>&uuid=<dreamer-uuid>&name=<name>&email=<email> 346 * 347 * This enables the collaboration handshake completion: 348 * 1. Alice shares with Bob → Bob installs → Bob gets Alice's DID 349 * 2. Bob shares DID back to Alice via this URI 350 * 3. Alice's Dreamer node for Bob gets updated with his DID 351 * 4. Sync command auto-triggers → mutual delegation established 352 */ 353 private async handleUpdateContact(params: Record<string, string>): Promise<void> { 354 try { 355 const did = params.did ? decodeURIComponent(params.did) : undefined; 356 const uuid = params.uuid ? decodeURIComponent(params.uuid) : undefined; 357 const name = params.name ? decodeURIComponent(params.name) : undefined; 358 const email = params.email ? decodeURIComponent(params.email) : undefined; 359 360 if (!did) { 361 new Notice('Invalid update link: missing DID'); 362 return; 363 } 364 365 if (!uuid) { 366 new Notice('Invalid update link: missing Dreamer UUID'); 367 return; 368 } 369 370 // Find the Dreamer node by UUID 371 const allNodes = await this.dreamNodeService.list(); 372 const dreamerNode = allNodes.find((node: any) => node.id === uuid && node.type === 'dreamer'); 373 374 if (!dreamerNode) { 375 new Notice(`Dreamer node not found (UUID: ${uuid.slice(0, 8)}...)`); 376 return; 377 } 378 379 // Update the Dreamer node with new contact info 380 const updates: Partial<DreamNode> = { did }; 381 if (name) updates.name = name; 382 if (email) updates.email = email; 383 384 await this.dreamNodeService.update(uuid, updates); 385 new Notice(`Contact updated: ${name || dreamerNode.name}'s DID received`); 386 387 // Auto-trigger sync for mutual delegation 388 try { 389 const executed = (this.plugin.app as any).commands.executeCommandById('interbrain:sync-radicle-peer-following'); 390 if (executed) { 391 new Notice('Collaboration setup complete! Syncing peer configuration...'); 392 } else { 393 new Notice('Contact updated. Run "Sync Radicle Peer Following" to complete setup.'); 394 } 395 } catch { 396 new Notice('Contact updated, but auto-sync failed. Run "Sync Radicle Peer Following" manually.'); 397 } 398 399 // Refresh UI 400 await this.dreamNodeService.scanVault(); 401 402 } catch (error) { 403 console.error('[URIHandler] Failed to handle update contact:', error); 404 new Notice(`Failed to update contact: ${error instanceof Error ? error.message : 'Unknown error'}`); 405 } 406 } 407 408 /** 409 * Classify identifier type for universal clone support 410 */ 411 private classifyIdentifier(id: string): 'radicle' | 'github' | 'uuid' { 412 if (id.startsWith('rad:')) return 'radicle'; 413 if (id.includes('github.com/')) return 'github'; 414 return 'uuid'; 415 } 416 417 /** 418 * Auto-focus a node after clone 419 */ 420 private async autoFocusNode(repoName: string, silent: boolean = false): Promise<void> { 421 const allNodes = await this.dreamNodeService.list(); 422 const targetNode = allNodes.find((node: any) => node.repoPath === repoName); 423 424 if (!targetNode) return; 425 426 const store = useInterBrainStore.getState(); 427 store.setSelectedNode(targetNode); 428 store.requestNavigation({ type: 'liminal-web-focus', nodeId: targetNode.id }); 429 430 if (!silent) { 431 new Notice(`Node focused in DreamSpace!`); 432 } 433 } 434 435 /** 436 * Index a newly cloned node for semantic search 437 */ 438 private async indexNewNode(repoName: string): Promise<void> { 439 try { 440 const allNodes = await this.dreamNodeService.list(); 441 const targetNode = allNodes.find((node: any) => node.repoPath === repoName); 442 443 if (!targetNode) return; 444 445 const { indexingService } = await import('../semantic-search/services/indexing-service'); 446 await indexingService.indexNode(targetNode); 447 } catch { 448 // Non-critical - don't fail clone 449 } 450 } 451 452 /** 453 * Normalize repository name to human-readable title 454 * Uses the same logic as DreamNodeMigrationService.normalizeToHumanReadable() 455 * 456 * Handles: 457 * - PascalCase: "ThunderstormGenerator" → "Thunderstorm Generator" 458 * - kebab-case: "thunderstorm-generator" → "Thunderstorm Generator" 459 * - snake_case: "thunderstorm_generator" → "Thunderstorm Generator" 460 * - Mixed: "Thunderstorm-Generator-UPDATED" → "Thunderstorm Generator Updated" 461 */ 462 private async normalizeRepoNameToTitle(repoName: string): Promise<string> { 463 const { isPascalCase, pascalCaseToTitle } = await import('../dreamnode/utils/title-sanitization'); 464 465 // If repo name contains hyphens, underscores, or periods as separators 466 if (/[-_.]+/.test(repoName)) { 467 // Replace separators with spaces and normalize 468 return repoName 469 .split(/[-_.]+/) // Split on hyphens, underscores, periods 470 .filter(word => word.length > 0) 471 .map(word => { 472 // Capitalize first letter, lowercase rest (proper title case) 473 const cleaned = word.trim(); 474 if (cleaned.length === 0) return ''; 475 return cleaned.charAt(0).toUpperCase() + cleaned.slice(1).toLowerCase(); 476 }) 477 .join(' ') 478 .trim(); 479 } 480 481 // If repo name is pure PascalCase (no separators), convert to spaced format 482 if (isPascalCase(repoName)) { 483 return pascalCaseToTitle(repoName); 484 } 485 486 // Already human-readable with spaces, return as-is 487 return repoName; 488 } 489 490 /** 491 * Ensure Radicle node is running before clone operations 492 * @returns Passphrase string, empty if node running, or null if not configured 493 */ 494 private async ensureRadicleNodeRunning(): Promise<string | null> { 495 const { PassphraseManager } = await import('../social-resonance-filter/services/passphrase-manager'); 496 const { UIService } = await import('../../core/services/ui-service'); 497 498 const uiService = new UIService(this.app); 499 const passphraseManager = new PassphraseManager(uiService, this.plugin); 500 const passphrase = await passphraseManager.getPassphrase(); 501 502 if (passphrase === null) { 503 new Notice('Please configure your Radicle passphrase in settings and try again'); 504 return null; 505 } 506 507 if (passphrase === '') { 508 return ''; // Node already running 509 } 510 511 // Ensure Radicle CLI is available before starting node 512 const isAvailable = await this.radicleService.isAvailable(); 513 if (!isAvailable) { 514 throw new Error('Radicle CLI not available'); 515 } 516 517 // Start node 518 try { 519 await (this.radicleService as any).startNode(passphrase); 520 new Notice('Radicle node started'); 521 return passphrase; 522 } catch (error) { 523 console.error('[URIHandler] Failed to start Radicle node:', error); 524 new Notice(`Failed to start Radicle node: ${error instanceof Error ? error.message : 'Unknown error'}`); 525 throw error; 526 } 527 } 528 529 /** 530 * Clone a DreamNode from Radicle network 531 * Public method to allow reuse by CoherenceBeaconService and other features 532 */ 533 /** 534 * Clone a DreamNode from Radicle network 535 * @param radicleId The Radicle ID (RID) of the repo to clone 536 * @param silent If true, don't show notices 537 * @param peerNid Optional peer Node ID for direct P2P clone (bypasses routing table) 538 */ 539 public async cloneFromRadicle(radicleId: string, silent: boolean = false, peerNid?: string): Promise<{ status: 'success' | 'skipped' | 'error'; repoName?: string }> { 540 try { 541 const adapter = this.app.vault.adapter as any; 542 const vaultPath = adapter.basePath || ''; 543 544 if (!vaultPath) { 545 throw new Error('Could not determine vault path'); 546 } 547 548 // Ensure Radicle node is running before attempting clone 549 const passphrase = await this.ensureRadicleNodeRunning(); 550 if (passphrase === null) { 551 throw new Error('Radicle node requires passphrase to start. Operation cancelled.'); 552 } 553 554 if (!silent) { 555 new Notice(`Cloning from Radicle network...`, 3000); 556 } 557 558 // RadicleService.clone() handles: clone, directory rename, submodule init, .udd update 559 // Pass peerNid for direct P2P clone (--seed flag) 560 const cloneResult = await this.radicleService.clone(radicleId, vaultPath, passphrase, peerNid); 561 562 if (cloneResult.alreadyExisted) { 563 console.log(`[URIHandler] Radicle ID ${radicleId} already exists as "${cloneResult.repoName}"`); 564 if (!silent) { 565 new Notice(`DreamNode "${cloneResult.repoName}" already cloned!`); 566 await this.autoFocusNode(cloneResult.repoName, silent); 567 } 568 return { status: 'skipped', repoName: cloneResult.repoName }; 569 } 570 571 if (!silent) { 572 new Notice(`Cloned "${cloneResult.repoName}" successfully!`); 573 } 574 575 // Auto-refresh: Make the newly cloned node appear immediately 576 if (!silent) { 577 try { 578 await this.dreamNodeService.scanVault(); 579 await this.indexNewNode(cloneResult.repoName); 580 581 const relationshipService = new DreamSongRelationshipService(this.plugin); 582 const scanResult = await relationshipService.scanVaultForDreamSongRelationships(); 583 584 if (scanResult.success) { 585 const store = useInterBrainStore.getState(); 586 store.requestNavigation({ type: 'applyLayout' }); 587 setTimeout(() => this.autoFocusNode(cloneResult.repoName, silent), 100); 588 } 589 } catch { 590 // Non-critical - node was cloned successfully 591 } 592 } 593 594 return { status: 'success', repoName: cloneResult.repoName }; 595 596 } catch (error) { 597 // Handle network propagation delays gracefully (SEED-RELAYED MODE) 598 // This happens when the sender hasn't announced to seeds yet, 599 // or seeds haven't propagated the repo information 600 if (error instanceof Error && error.message === 'NETWORK_PROPAGATION_DELAY') { 601 if (!silent) { 602 new Notice( 603 'DreamNode not yet available on network. The sender may need to sync their node, or network propagation is in progress. Please try again in a moment.', 604 10000 605 ); 606 } 607 return { status: 'error' }; 608 } 609 610 console.error(`[URIHandler] Clone failed for ${radicleId}:`, error); 611 612 if (!silent) { 613 const errorMsg = error instanceof Error ? error.message : 'Unknown error'; 614 new Notice(`Failed to clone: ${errorMsg}`); 615 } 616 617 return { status: 'error' }; 618 } 619 } 620 621 /** 622 * Clone a DreamNode from GitHub 623 */ 624 private async cloneFromGitHub(repoPath: string, silent: boolean = false): Promise<'success' | 'skipped' | 'error'> { 625 try { 626 const adapter = this.app.vault.adapter as any; 627 const vaultPath = adapter.basePath || ''; 628 629 if (!vaultPath) { 630 throw new Error('Could not determine vault path'); 631 } 632 633 // Extract repo name from path 634 const match = repoPath.match(/github\.com\/[^/]+\/([^/\s]+)/); 635 if (!match) { 636 throw new Error(`Invalid GitHub repository path: ${repoPath}`); 637 } 638 639 const repoName = match[1].replace(/\.git$/, ''); 640 const destinationPath = `${vaultPath}/${repoName}`; 641 642 // Check if already exists - use Obsidian vault API 643 if (await this.app.vault.adapter.exists(repoName)) { 644 if (!silent) { 645 new Notice(`DreamNode "${repoName}" already cloned!`); 646 await this.autoFocusNode(repoName, silent); 647 } 648 return 'skipped'; 649 } 650 651 if (!silent) { 652 new Notice(`Cloning from GitHub...`, 3000); 653 } 654 655 // Clone via GitHub service 656 const { githubService } = await import('../github-publishing/services/github-service'); 657 const githubUrl = `https://${repoPath}`; 658 await githubService.clone(githubUrl, destinationPath); 659 660 // Create .udd file for InterBrain compatibility using UDDService 661 try { 662 if (!UDDService.uddExists(destinationPath)) { 663 // Use Web Crypto API for UUID generation (available in Electron) 664 const uuid = globalThis.crypto.randomUUID(); 665 const title = await this.normalizeRepoNameToTitle(repoName); 666 667 await UDDService.createUDD(destinationPath, { 668 uuid, 669 title, 670 type: 'dream', 671 dreamTalk: '' 672 }); 673 674 // Add GitHub URL to the UDD (createUDD doesn't support this field) 675 const udd = await UDDService.readUDD(destinationPath); 676 (udd as any).githubRepoUrl = githubUrl; 677 await UDDService.writeUDD(destinationPath, udd); 678 } 679 } catch { 680 // Non-critical - clone succeeded 681 } 682 683 if (!silent) { 684 new Notice(`Cloned "${repoName}" successfully!`); 685 } 686 687 // Auto-refresh 688 if (!silent) { 689 try { 690 await this.dreamNodeService.scanVault(); 691 await this.indexNewNode(repoName); 692 693 const relationshipService = new DreamSongRelationshipService(this.plugin); 694 const scanResult = await relationshipService.scanVaultForDreamSongRelationships(); 695 696 if (scanResult.success) { 697 const store = useInterBrainStore.getState(); 698 store.requestNavigation({ type: 'applyLayout' }); 699 setTimeout(() => this.autoFocusNode(repoName, silent), 100); 700 } 701 } catch { 702 // Non-critical 703 } 704 } 705 706 return 'success'; 707 708 } catch (error) { 709 console.error(`[URIHandler] GitHub clone failed for ${repoPath}:`, error); 710 711 if (!silent) { 712 const errorMsg = error instanceof Error ? error.message : 'Unknown error'; 713 new Notice(`Failed to clone from GitHub: ${errorMsg}`); 714 } 715 716 return 'error'; 717 } 718 } 719 720 /** 721 * Handle collaboration handshake: create Dreamer node for sender and link cloned node 722 */ 723 private async handleCollaborationHandshake( 724 clonedNodeIdentifier: string, 725 senderDid: string, 726 senderName: string 727 ): Promise<DreamNode | undefined> { 728 try { 729 const dreamerNode = await this.findOrCreateDreamerNode(senderDid, senderName); 730 await new Promise(resolve => setTimeout(resolve, 200)); 731 732 const clonedNode = await this.findNodeByIdentifier(clonedNodeIdentifier); 733 if (!clonedNode) return; 734 735 await this.linkNodes(clonedNode, dreamerNode); 736 737 // Refresh UI 738 try { 739 await this.dreamNodeService.scanVault(); 740 const relationshipService = new DreamSongRelationshipService(this.plugin); 741 const scanResult = await relationshipService.scanVaultForDreamSongRelationships(); 742 743 if (scanResult.success) { 744 const store = useInterBrainStore.getState(); 745 store.requestNavigation({ type: 'applyLayout' }); 746 } 747 } catch { 748 // Non-critical 749 } 750 751 return dreamerNode; 752 } catch { 753 return undefined; 754 } 755 } 756 757 /** 758 * Find existing Dreamer node by DID, or create new one 759 */ 760 private async findOrCreateDreamerNode(did: string, name: string, email?: string): Promise<any> { 761 // Search for existing Dreamer node with this DID 762 const allNodes = await this.dreamNodeService.list(); 763 const existingDreamer = allNodes.find((node: any) => { 764 return node.type === 'dreamer' && node.did === did; 765 }); 766 767 if (existingDreamer) { 768 // DreamNode.id is already the UUID from .udd - just alias it for compatibility 769 (existingDreamer as any).uuid = existingDreamer.id; 770 return existingDreamer; 771 } 772 773 // Create new Dreamer node with DID metadata using standard creation flow 774 const metadata: any = { did }; 775 if (email) { 776 metadata.email = email; 777 } 778 const newDreamer = await this.dreamNodeService.create(name, 'dreamer', undefined, undefined, undefined, metadata); 779 780 // Wait for creation to complete 781 await new Promise(resolve => setTimeout(resolve, 500)); 782 783 // DreamNode.id is already the UUID - just alias it for compatibility 784 (newDreamer as any).uuid = newDreamer.id; 785 786 return newDreamer; 787 } 788 789 /** 790 * Find node by identifier (Radicle ID or GitHub URL) 791 * Uses DreamNode properties from store (populated from .udd during vault scan) 792 */ 793 private async findNodeByIdentifier(identifier: string): Promise<any> { 794 const allNodes = await this.dreamNodeService.list(); 795 796 // Determine if identifier is Radicle ID or GitHub URL 797 const isRadicleId = identifier.startsWith('rad:'); 798 const isGitHubUrl = identifier.includes('github.com/'); 799 800 // Normalize GitHub URL if needed (remove protocol, .git suffix) 801 const normalizedGitHubUrl = isGitHubUrl 802 ? identifier.replace(/^https?:\/\//, '').replace(/\.git$/, '') 803 : null; 804 805 for (const node of allNodes) { 806 // Check Radicle ID (available on DreamNode from vault scan) 807 if (isRadicleId && node.radicleId === identifier) { 808 (node as any).uuid = node.id; 809 return node; 810 } 811 812 // Check GitHub URL (available on DreamNode from vault scan) 813 if (isGitHubUrl && node.githubRepoUrl) { 814 const normalizedUddUrl = node.githubRepoUrl.replace(/^https?:\/\//, '').replace(/\.git$/, ''); 815 if (normalizedUddUrl === normalizedGitHubUrl) { 816 (node as any).uuid = node.id; 817 return node; 818 } 819 } 820 } 821 822 return null; 823 } 824 825 /** 826 * Link two nodes by adding relationship 827 * Delegates to GitDreamNodeService.addRelationship for proper bidirectional handling 828 */ 829 private async linkNodes(sourceNode: any, targetNode: any): Promise<void> { 830 // Use node.id (which is the UUID) - the uuid alias is just for backward compat 831 const sourceId = sourceNode.uuid || sourceNode.id; 832 const targetId = targetNode.uuid || targetNode.id; 833 834 if (!sourceId || !targetId) { 835 throw new Error(`Cannot link nodes without IDs`); 836 } 837 838 // Delegate to GitDreamNodeService which handles bidirectional relationships 839 // and liminal-web.json updates for Dreamer nodes 840 await this.dreamNodeService.addRelationship(sourceId, targetId); 841 } 842 843 /** 844 * Generate deep link URL for single DreamNode with collaboration handshake 845 * @param vaultName The Obsidian vault name (unused, kept for API compatibility) 846 * @param identifier Radicle ID (preferred) or UUID (fallback) 847 * @param senderDid Optional sender's Radicle DID for peer following 848 * @param senderName Optional sender's human-readable name for Dreamer node creation 849 * @param senderEmail Optional sender's email address for contact info 850 */ 851 static generateSingleNodeLink(vaultName: string, identifier: string, senderDid?: string, senderName?: string, senderEmail?: string): string { 852 // Unified schema: Use ?ids= for both single and batch clones 853 // Don't encode colons in Radicle IDs - they're part of the protocol 854 // rad:z... should stay as rad:z..., not rad%3Az... 855 const encodedIdentifier = identifier.startsWith('rad:') 856 ? identifier // Keep Radicle ID as-is 857 : encodeURIComponent(identifier); // Encode other identifiers (UUIDs) 858 859 let uri = `obsidian://interbrain-clone?ids=${encodedIdentifier}`; 860 861 // Add collaboration handshake parameters if provided 862 if (senderDid) { 863 uri += `&senderDid=${encodeURIComponent(senderDid)}`; 864 } 865 if (senderName) { 866 uri += `&senderName=${encodeURIComponent(senderName)}`; 867 } 868 if (senderEmail) { 869 uri += `&senderEmail=${encodeURIComponent(senderEmail)}`; 870 } 871 872 return uri; 873 } 874 875 /** 876 * Generate deep link URL for GitHub clone (uses unified ?ids= schema) 877 * @param vaultName The Obsidian vault name (unused, kept for API compatibility) 878 * @param githubRepoUrl GitHub repository URL (e.g., "https://github.com/user/repo" or "github.com/user/repo") 879 */ 880 static generateGitHubCloneLink(vaultName: string, githubRepoUrl: string): string { 881 // Extract clean repo path: github.com/user/repo 882 const repoPath = githubRepoUrl 883 .replace(/^https?:\/\//, '') // Remove protocol 884 .replace(/\.git$/, ''); // Remove .git suffix 885 886 // Use unified ?ids= schema (not ?repo=) 887 return `obsidian://interbrain-clone?ids=${repoPath}`; 888 } 889 890 /** 891 * Generate deep link URL for batch clone with collaboration handshake 892 * @param vaultName The Obsidian vault name (unused, kept for API compatibility) 893 * @param identifiers Array of identifiers (can be Radicle IDs, GitHub URLs, or UUIDs) 894 * @param senderDid Optional sender's Radicle DID for peer following 895 * @param senderName Optional sender's human-readable name for Dreamer node creation 896 */ 897 static generateBatchNodeLink(vaultName: string, identifiers: string[], senderDid?: string, senderName?: string, senderEmail?: string): string { 898 // Unified schema: Use ?ids= with comma-separated list 899 const encodedIdentifiers = encodeURIComponent(identifiers.join(',')); 900 let uri = `obsidian://interbrain-clone?ids=${encodedIdentifiers}`; 901 902 // Add collaboration handshake parameters if provided 903 if (senderDid) { 904 uri += `&senderDid=${encodeURIComponent(senderDid)}`; 905 } 906 if (senderName) { 907 uri += `&senderName=${encodeURIComponent(senderName)}`; 908 } 909 if (senderEmail) { 910 uri += `&senderEmail=${encodeURIComponent(senderEmail)}`; 911 } 912 913 return uri; 914 } 915 916 /** 917 * Generate update-contact URI for DID backpropagation 918 * @param did Sender's Radicle DID 919 * @param dreamerUuid UUID of the recipient's Dreamer node (for the sender) 920 * @param name Optional sender's name 921 * @param email Optional sender's email 922 * 923 * Example: Bob installs InterBrain and wants to share his DID with Alice 924 * - did: Bob's newly created Radicle DID 925 * - dreamerUuid: Alice's UUID for her Dreamer node representing Bob 926 * - name: "Bob" (optional, for display) 927 * - email: "bob@example.com" (optional, for additional contact info) 928 */ 929 static generateUpdateContactLink(did: string, dreamerUuid: string, name?: string, email?: string): string { 930 let uri = `obsidian://interbrain-update-contact?did=${encodeURIComponent(did)}&uuid=${encodeURIComponent(dreamerUuid)}`; 931 932 if (name) { 933 uri += `&name=${encodeURIComponent(name)}`; 934 } 935 if (email) { 936 uri += `&email=${encodeURIComponent(email)}`; 937 } 938 939 return uri; 940 } 941 } 942 943 // Singleton instance 944 let _uriHandlerService: URIHandlerService | null = null; 945 946 export function initializeURIHandlerService(app: App, plugin: Plugin, radicleService: RadicleService, dreamNodeService: GitDreamNodeService): void { 947 _uriHandlerService = new URIHandlerService(app, plugin, radicleService, dreamNodeService); 948 _uriHandlerService.registerHandlers(); 949 } 950 951 export function getURIHandlerService(): URIHandlerService { 952 if (!_uriHandlerService) { 953 throw new Error('URIHandlerService not initialized. Call initializeURIHandlerService() first.'); 954 } 955 return _uriHandlerService; 956 }