imageStore.ts
1 import { mkdir, open } from 'fs/promises' 2 import { join } from 'path' 3 import { getSessionId } from '../bootstrap/state.js' 4 import type { PastedContent } from './config.js' 5 import { logForDebugging } from './debug.js' 6 import { getClaudeConfigHomeDir } from './envUtils.js' 7 import { getFsImplementation } from './fsOperations.js' 8 9 const IMAGE_STORE_DIR = 'image-cache' 10 const MAX_STORED_IMAGE_PATHS = 200 11 12 // In-memory cache of stored image paths 13 const storedImagePaths = new Map<number, string>() 14 15 /** 16 * Get the image store directory for the current session. 17 */ 18 function getImageStoreDir(): string { 19 return join(getClaudeConfigHomeDir(), IMAGE_STORE_DIR, getSessionId()) 20 } 21 22 /** 23 * Ensure the image store directory exists. 24 */ 25 async function ensureImageStoreDir(): Promise<void> { 26 const dir = getImageStoreDir() 27 await mkdir(dir, { recursive: true }) 28 } 29 30 /** 31 * Get the file path for an image by ID. 32 */ 33 function getImagePath(imageId: number, mediaType: string): string { 34 const extension = mediaType.split('/')[1] || 'png' 35 return join(getImageStoreDir(), `${imageId}.${extension}`) 36 } 37 38 /** 39 * Cache the image path immediately (fast, no file I/O). 40 */ 41 export function cacheImagePath(content: PastedContent): string | null { 42 if (content.type !== 'image') { 43 return null 44 } 45 const imagePath = getImagePath(content.id, content.mediaType || 'image/png') 46 evictOldestIfAtCap() 47 storedImagePaths.set(content.id, imagePath) 48 return imagePath 49 } 50 51 /** 52 * Store an image from pastedContents to disk. 53 */ 54 export async function storeImage( 55 content: PastedContent, 56 ): Promise<string | null> { 57 if (content.type !== 'image') { 58 return null 59 } 60 61 try { 62 await ensureImageStoreDir() 63 const imagePath = getImagePath(content.id, content.mediaType || 'image/png') 64 const fh = await open(imagePath, 'w', 0o600) 65 try { 66 await fh.writeFile(content.content, { encoding: 'base64' }) 67 await fh.datasync() 68 } finally { 69 await fh.close() 70 } 71 evictOldestIfAtCap() 72 storedImagePaths.set(content.id, imagePath) 73 logForDebugging(`Stored image ${content.id} to ${imagePath}`) 74 return imagePath 75 } catch (error) { 76 logForDebugging(`Failed to store image: ${error}`) 77 return null 78 } 79 } 80 81 /** 82 * Store all images from pastedContents to disk. 83 */ 84 export async function storeImages( 85 pastedContents: Record<number, PastedContent>, 86 ): Promise<Map<number, string>> { 87 const pathMap = new Map<number, string>() 88 89 for (const [id, content] of Object.entries(pastedContents)) { 90 if (content.type === 'image') { 91 const path = await storeImage(content) 92 if (path) { 93 pathMap.set(Number(id), path) 94 } 95 } 96 } 97 98 return pathMap 99 } 100 101 /** 102 * Get the file path for a stored image by ID. 103 */ 104 export function getStoredImagePath(imageId: number): string | null { 105 return storedImagePaths.get(imageId) ?? null 106 } 107 108 /** 109 * Clear the in-memory cache of stored image paths. 110 */ 111 export function clearStoredImagePaths(): void { 112 storedImagePaths.clear() 113 } 114 115 function evictOldestIfAtCap(): void { 116 while (storedImagePaths.size >= MAX_STORED_IMAGE_PATHS) { 117 const oldest = storedImagePaths.keys().next().value 118 if (oldest !== undefined) { 119 storedImagePaths.delete(oldest) 120 } else { 121 break 122 } 123 } 124 } 125 126 /** 127 * Clean up old image cache directories from previous sessions. 128 */ 129 export async function cleanupOldImageCaches(): Promise<void> { 130 const fsImpl = getFsImplementation() 131 const baseDir = join(getClaudeConfigHomeDir(), IMAGE_STORE_DIR) 132 const currentSessionId = getSessionId() 133 134 try { 135 let sessionDirs 136 try { 137 sessionDirs = await fsImpl.readdir(baseDir) 138 } catch { 139 return 140 } 141 142 for (const sessionDir of sessionDirs) { 143 if (sessionDir.name === currentSessionId) { 144 continue 145 } 146 147 const sessionPath = join(baseDir, sessionDir.name) 148 try { 149 await fsImpl.rm(sessionPath, { recursive: true, force: true }) 150 logForDebugging(`Cleaned up old image cache: ${sessionPath}`) 151 } catch { 152 // Ignore errors for individual directories 153 } 154 } 155 156 try { 157 const remaining = await fsImpl.readdir(baseDir) 158 if (remaining.length === 0) { 159 await fsImpl.rmdir(baseDir) 160 } 161 } catch { 162 // Ignore 163 } 164 } catch { 165 // Ignore errors reading base directory 166 } 167 }