useDreamSongData.ts
1 /** 2 * useDreamSongData - React Hook for DreamSong State Management 3 * 4 * Layer 2 of the three-layer DreamSong architecture. 5 * Minimal React state management with hash-based change detection. 6 */ 7 8 import { useState, useEffect, useMemo } from 'react'; 9 import { TFile } from 'obsidian'; 10 import { DreamSongBlock } from '../types/dreamsong'; 11 import { DreamNode } from '../types/dreamnode'; 12 import { CanvasParserService } from '../services/canvas-parser-service'; 13 import { VaultService } from '../services/vault-service'; 14 import { parseAndResolveCanvas, generateCanvasStructureHash, hashesEqual } from '../services/dreamsong'; 15 import { serviceManager } from '../services/service-manager'; 16 17 interface UseDreamSongDataOptions { 18 canvasParser: CanvasParserService; 19 vaultService: VaultService; 20 dreamNode?: DreamNode; // Optional: for checking Songline features (perspectives/conversations) 21 } 22 23 interface DreamSongDataResult { 24 blocks: DreamSongBlock[]; 25 hasContent: boolean; 26 isLoading: boolean; 27 error: string | null; 28 hash: string | null; 29 } 30 31 /** 32 * Custom hook for managing DreamSong data with hash-based change detection 33 * 34 * This replaces the complex caching system with React's built-in optimization. 35 * Only re-renders when the structural hash actually changes. 36 */ 37 export function useDreamSongData( 38 canvasPath: string, 39 dreamNodePath: string, 40 options: UseDreamSongDataOptions, 41 sourceDreamNodeId?: string 42 ): DreamSongDataResult { 43 const { canvasParser, vaultService, dreamNode } = options; 44 45 // Minimal state - only what's necessary for the UI 46 const [hash, setHash] = useState<string | null>(null); 47 const [blocks, setBlocks] = useState<DreamSongBlock[]>([]); 48 const [isLoading, setIsLoading] = useState(false); 49 const [error, setError] = useState<string | null>(null); 50 51 // Songline feature detection 52 const [hasPerspectives, setHasPerspectives] = useState(false); 53 const [hasConversations, setHasConversations] = useState(false); 54 55 // Memoized parsing function to prevent unnecessary re-runs 56 const parseCanvas = useMemo(() => { 57 return async () => { 58 if (!canvasPath) return; 59 60 setIsLoading(true); 61 setError(null); 62 63 try { 64 // Check if canvas exists 65 const canvasExists = await vaultService.fileExists(canvasPath); 66 67 if (canvasExists) { 68 // Parse canvas content 69 const canvasData = await canvasParser.parseCanvas(canvasPath); 70 71 // Generate hash first to check if we need to do expensive parsing 72 const newHash = generateCanvasStructureHash(canvasData); 73 74 // Compare with current hash - only update if changed 75 if (hashesEqual(hash, newHash)) { 76 console.log(`⚡ DreamSong hash unchanged: ${newHash}`); 77 setIsLoading(false); 78 return; 79 } 80 81 // Hash changed - do full parse and resolve 82 const result = await parseAndResolveCanvas(canvasData, dreamNodePath, vaultService, sourceDreamNodeId); 83 84 setBlocks(result.blocks); 85 setHash(result.hash); 86 87 } else { 88 // No canvas - empty blocks (README now handled as separate section in UI) 89 setBlocks([]); 90 setHash(null); 91 } 92 93 } catch (err) { 94 console.error('Error parsing DreamSong:', err); 95 setError(err instanceof Error ? err.message : 'Unknown parsing error'); 96 setBlocks([]); 97 setHash(null); 98 } finally { 99 setIsLoading(false); 100 } 101 }; 102 }, [canvasPath, dreamNodePath, canvasParser, vaultService, hash]); 103 104 // Effect for parsing when dependencies change 105 useEffect(() => { 106 parseCanvas(); 107 }, [parseCanvas]); 108 109 // Effect for checking Songline features (perspectives/conversations) 110 useEffect(() => { 111 if (!dreamNode || !vaultService) { 112 setHasPerspectives(false); 113 setHasConversations(false); 114 return; 115 } 116 117 const checkSonglineFeatures = async () => { 118 const fs = require('fs').promises; 119 const path = require('path'); 120 121 try { 122 // Get vault base path 123 const vaultBasePath = vaultService.getVaultPath(); 124 if (!vaultBasePath) { 125 console.warn('[Songline] Vault path not available'); 126 setHasPerspectives(false); 127 setHasConversations(false); 128 return; 129 } 130 const absoluteRepoPath = path.join(vaultBasePath, dreamNode.repoPath); 131 132 // Check for perspectives (DreamNodes only) 133 if (dreamNode.type !== 'dreamer') { 134 try { 135 const perspectivesPath = path.join(absoluteRepoPath, 'perspectives.json'); 136 const content = await fs.readFile(perspectivesPath, 'utf-8'); 137 const perspectivesFile = JSON.parse(content); 138 const hasPerspectivesContent = perspectivesFile.perspectives?.length > 0; 139 setHasPerspectives(hasPerspectivesContent); 140 } catch { 141 setHasPerspectives(false); 142 } 143 } else { 144 setHasPerspectives(false); 145 } 146 147 // Check for conversations (DreamerNodes only) 148 if (dreamNode.type === 'dreamer') { 149 try { 150 const conversationsDir = path.join(absoluteRepoPath, 'conversations'); 151 await fs.access(conversationsDir); 152 const files = await fs.readdir(conversationsDir); 153 const audioFiles = files.filter((f: string) => f.endsWith('.mp3') || f.endsWith('.wav')); 154 const hasConversationsContent = audioFiles.length > 0; 155 setHasConversations(hasConversationsContent); 156 } catch { 157 setHasConversations(false); 158 } 159 } else { 160 setHasConversations(false); 161 } 162 } catch (error) { 163 console.error('Error checking Songline features:', error); 164 setHasPerspectives(false); 165 setHasConversations(false); 166 } 167 }; 168 169 checkSonglineFeatures(); 170 }, [dreamNode, vaultService]); 171 172 // Effect for real-time file change detection 173 useEffect(() => { 174 // Get Obsidian app instance for file watching 175 const app = serviceManager.getApp(); 176 if (!app || !canvasPath) { 177 return; 178 } 179 180 const vault = app.vault; 181 182 // Handler for file modification events 183 const handleFileChange = (file: TFile) => { 184 // Check if the changed file is our canvas 185 if (file.path === canvasPath) { 186 // Use a small delay to ensure file write is complete 187 globalThis.setTimeout(() => { 188 parseCanvas(); 189 }, 100); 190 } 191 }; 192 193 // Listen for file modifications 194 // Note: Obsidian's event system types may be incomplete - using 'any' for event listeners 195 vault.on('modify', handleFileChange as any); 196 197 // Also listen for file creation (in case canvas was created after component mount) 198 vault.on('create', handleFileChange as any); 199 200 201 // Cleanup listener on unmount or path change 202 return () => { 203 vault.off('modify', handleFileChange as any); 204 vault.off('create', handleFileChange as any); 205 }; 206 }, [canvasPath, parseCanvas]); 207 208 // Derived state - DreamSong should show if ANY of these conditions are met: 209 // 1. Canvas/README has content (blocks.length > 0) 210 // 2. DreamNode has perspectives 211 // 3. DreamerNode has conversations 212 const hasContent = blocks.length > 0 || hasPerspectives || hasConversations; 213 214 return { 215 blocks, 216 hasContent, 217 isLoading, 218 error, 219 hash 220 }; 221 } 222 223 /** 224 * Simplified version for when you only need to check if content exists 225 * without resolving media paths (more performant) 226 */ 227 export function useDreamSongExists( 228 canvasPath: string, 229 vaultService: VaultService 230 ): { exists: boolean; isLoading: boolean } { 231 const [exists, setExists] = useState(false); 232 const [isLoading, setIsLoading] = useState(false); 233 234 useEffect(() => { 235 if (!canvasPath) { 236 setExists(false); 237 return; 238 } 239 240 setIsLoading(true); 241 242 vaultService.fileExists(canvasPath) 243 .then(setExists) 244 .catch(() => setExists(false)) 245 .finally(() => setIsLoading(false)); 246 }, [canvasPath, vaultService]); 247 248 return { exists, isLoading }; 249 }