/ src / services / uri-handler-service.ts
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  }