/ src / features / dreamnode-editor / services / editor-service.ts
editor-service.ts
  1  /**
  2   * EditorService - Orchestrates DreamNode edit operations
  3   *
  4   * Coordinates save operations using the parent GitDreamNodeService,
  5   * handling the complexity of file deduplication and metadata updates.
  6   */
  7  
  8  import fs from 'fs';
  9  import { useInterBrainStore } from '../../../core/store/interbrain-store';
 10  import { serviceManager } from '../../../core/services/service-manager';
 11  import { DreamNode } from '../../dreamnode';
 12  
 13  /**
 14   * Save all pending edit mode changes
 15   *
 16   * Handles:
 17   * 1. New DreamTalk media file (with hash-based deduplication)
 18   * 2. Metadata changes (name, contact fields for dreamer)
 19   * 3. Relationship changes
 20   *
 21   * Note: Node type is immutable after creation - not saved here.
 22   */
 23  export async function saveEditModeChanges(): Promise<{ success: boolean; error?: string }> {
 24    const store = useInterBrainStore.getState();
 25    const { editMode } = store;
 26  
 27    if (!editMode.isActive || !editMode.editingNode) {
 28      return { success: false, error: 'No active edit session' };
 29    }
 30  
 31    try {
 32      const dreamNodeService = serviceManager.getActive();
 33      const editingNode = editMode.editingNode;
 34  
 35      // 1. Handle new DreamTalk media file if provided
 36      if (editMode.newDreamTalkFile) {
 37        await handleDreamTalkFileUpdate(editingNode, editMode.newDreamTalkFile);
 38      }
 39  
 40      // 2. Save metadata changes (type is immutable, not saved)
 41      const updates: Partial<DreamNode> = {
 42        name: editingNode.name
 43      };
 44  
 45      // Include contact info only for dreamer-type nodes
 46      if (editingNode.type === 'dreamer') {
 47        updates.email = editingNode.email;
 48        updates.phone = editingNode.phone;
 49        updates.did = editingNode.did;
 50      }
 51  
 52      await dreamNodeService.update(editingNode.id, updates);
 53  
 54      // 3. Save relationship changes
 55      await dreamNodeService.updateRelationships(
 56        editingNode.id,
 57        editMode.pendingRelationships
 58      );
 59  
 60      // 4. Update store state with confirmed relationships
 61      store.savePendingRelationships();
 62  
 63      // 5. Clear the new DreamTalk file from edit mode state
 64      if (editMode.newDreamTalkFile) {
 65        store.setEditModeNewDreamTalkFile(undefined);
 66      }
 67  
 68      return { success: true };
 69    } catch (error) {
 70      console.error('EditorService: Failed to save changes:', error);
 71      return {
 72        success: false,
 73        error: error instanceof Error ? error.message : 'Unknown error'
 74      };
 75    }
 76  }
 77  
 78  /**
 79   * Handle DreamTalk file update with smart deduplication
 80   *
 81   * Checks if the file is:
 82   * - An internal file (already in the repo) - just update metadata reference
 83   * - An external file that matches existing - skip copy, update reference
 84   * - A new external file - copy to repo and update reference
 85   */
 86  async function handleDreamTalkFileUpdate(
 87    editingNode: DreamNode,
 88    file: globalThis.File
 89  ): Promise<void> {
 90    const dreamNodeService = serviceManager.getActive();
 91    const vaultService = serviceManager.getVaultService();
 92  
 93    // Try to read the file - if it fails, it's likely an internal file already in the repo
 94    let fileIsReadable = false;
 95    let fileHash: string | null = null;
 96  
 97    try {
 98      const buffer = await file.arrayBuffer();
 99      fileIsReadable = true;
100      // Calculate hash of the dropped file
101      const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', buffer);
102      fileHash = Array.from(new Uint8Array(hashBuffer))
103        .map(b => b.toString(16).padStart(2, '0'))
104        .join('');
105    } catch {
106      // File not readable - it's an internal file reference
107      fileIsReadable = false;
108    }
109  
110    if (!fileIsReadable) {
111      // Internal file - just update the dreamTalk path in metadata, no file copy needed
112      await updateDreamTalkReference(editingNode, file, vaultService);
113      return;
114    }
115  
116    if (!fileHash || !vaultService) {
117      // Fallback to direct save
118      await dreamNodeService.addFilesToNode(editingNode.id, [file]);
119      return;
120    }
121  
122    // File is readable - check if it already exists in the repo by comparing hashes
123    const targetPath = `${editingNode.repoPath}/${file.name}`;
124    const existingFileExists = await vaultService.fileExists(targetPath);
125  
126    if (existingFileExists) {
127      // File exists - compare hashes
128      const fullPath = vaultService.getFullPath(targetPath);
129      const existingContent = fs.readFileSync(fullPath);
130      const existingHashBuffer = await globalThis.crypto.subtle.digest('SHA-256', existingContent);
131      const existingHash = Array.from(new Uint8Array(existingHashBuffer))
132        .map(b => b.toString(16).padStart(2, '0'))
133        .join('');
134  
135      if (existingHash === fileHash) {
136        // Same file - just update the metadata reference, no copy needed
137        await updateDreamTalkReference(editingNode, file, vaultService);
138        return;
139      }
140      // Different file with same name - will be replaced below
141    }
142  
143    // Copy file to repo
144    await dreamNodeService.addFilesToNode(editingNode.id, [file]);
145  }
146  
147  /**
148   * Update DreamTalk reference without copying file
149   * Used when the file already exists in the repo
150   */
151  async function updateDreamTalkReference(
152    editingNode: DreamNode,
153    file: globalThis.File,
154    vaultService: ReturnType<typeof serviceManager.getVaultService>
155  ): Promise<void> {
156    const dreamNodeService = serviceManager.getActive();
157    const targetPath = `${editingNode.repoPath}/${file.name}`;
158  
159    let dataUrl = '';
160    let fileSize = 0;
161  
162    if (vaultService) {
163      try {
164        dataUrl = await vaultService.readFileAsDataURL(targetPath);
165        const fullPath = vaultService.getFullPath(targetPath);
166        const stats = fs.statSync(fullPath);
167        fileSize = stats.size;
168      } catch (err) {
169        console.warn(`EditorService: Could not load file data for preview: ${err}`);
170      }
171    }
172  
173    const updates: Partial<DreamNode> = {
174      dreamTalkMedia: [{
175        path: file.name,
176        absolutePath: targetPath,
177        type: file.type || 'application/octet-stream',
178        data: dataUrl,
179        size: fileSize
180      }]
181    };
182  
183    await dreamNodeService.update(editingNode.id, updates);
184  }
185  
186  /**
187   * Get fresh node data after save
188   */
189  export async function getFreshNodeData(nodeId: string): Promise<DreamNode | null> {
190    const dreamNodeService = serviceManager.getActive();
191    return await dreamNodeService.get(nodeId);
192  }
193  
194  /**
195   * Exit edit mode and return to liminal-web layout
196   */
197  export function exitToLiminalWeb(): void {
198    const store = useInterBrainStore.getState();
199  
200    if (store.selectedNode) {
201      store.setSpatialLayout('liminal-web');
202    }
203  
204    store.exitEditMode();
205  }
206  
207  /**
208   * Cancel edit mode without saving
209   */
210  export function cancelEditMode(): void {
211    const store = useInterBrainStore.getState();
212  
213    // Change spatial layout BEFORE exiting edit mode
214    if (store.selectedNode) {
215      store.setSpatialLayout('liminal-web');
216    }
217  
218    // Exit edit mode - original data is preserved in store
219    store.exitEditMode();
220  }