/ src / utils / imageStore.ts
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  }