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 }