/ src / hooks / useDreamSongData.ts
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  }