previewCache.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380
  1. // IndexedDB cache for preview images - matches original implementation
  2. import { apiClient } from './apiClient'
  3. const PREVIEW_CACHE_DB_NAME = 'dune_weaver_previews'
  4. const PREVIEW_CACHE_DB_VERSION = 1
  5. const PREVIEW_CACHE_STORE_NAME = 'previews'
  6. const MAX_CACHE_SIZE_MB = 200
  7. const MAX_CACHE_SIZE_BYTES = MAX_CACHE_SIZE_MB * 1024 * 1024
  8. interface PreviewData {
  9. image_data: string
  10. first_coordinate: { x: number; y: number } | null
  11. last_coordinate: { x: number; y: number } | null
  12. error?: string
  13. }
  14. interface CacheEntry {
  15. pattern: string
  16. data: PreviewData
  17. size: number
  18. lastAccessed: number
  19. created: number
  20. }
  21. let previewCacheDB: IDBDatabase | null = null
  22. // In-memory cache for faster access during session
  23. const memoryCache = new Map<string, PreviewData>()
  24. const MAX_MEMORY_CACHE_SIZE = 100
  25. // Initialize IndexedDB
  26. export async function initPreviewCacheDB(): Promise<IDBDatabase> {
  27. if (previewCacheDB) return previewCacheDB
  28. return new Promise((resolve, reject) => {
  29. const request = indexedDB.open(PREVIEW_CACHE_DB_NAME, PREVIEW_CACHE_DB_VERSION)
  30. request.onerror = () => {
  31. console.error('Failed to open preview cache database')
  32. reject(request.error)
  33. }
  34. request.onsuccess = () => {
  35. previewCacheDB = request.result
  36. console.debug('Preview cache database opened successfully')
  37. resolve(previewCacheDB)
  38. }
  39. request.onupgradeneeded = (event) => {
  40. const db = (event.target as IDBOpenDBRequest).result
  41. // Create object store for preview cache
  42. const store = db.createObjectStore(PREVIEW_CACHE_STORE_NAME, { keyPath: 'pattern' })
  43. store.createIndex('lastAccessed', 'lastAccessed', { unique: false })
  44. store.createIndex('size', 'size', { unique: false })
  45. console.debug('Preview cache database schema created')
  46. }
  47. })
  48. }
  49. // Get preview from cache (memory first, then IndexedDB)
  50. export async function getPreviewFromCache(pattern: string): Promise<PreviewData | null> {
  51. // Check memory cache first
  52. if (memoryCache.has(pattern)) {
  53. return memoryCache.get(pattern)!
  54. }
  55. // Check IndexedDB
  56. try {
  57. if (!previewCacheDB) await initPreviewCacheDB()
  58. const transaction = previewCacheDB!.transaction([PREVIEW_CACHE_STORE_NAME], 'readwrite')
  59. const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME)
  60. return new Promise((resolve, reject) => {
  61. const request = store.get(pattern)
  62. request.onsuccess = () => {
  63. const result = request.result as CacheEntry | undefined
  64. if (result) {
  65. // Update last accessed time
  66. result.lastAccessed = Date.now()
  67. store.put(result)
  68. // Add to memory cache
  69. addToMemoryCache(pattern, result.data)
  70. resolve(result.data)
  71. } else {
  72. resolve(null)
  73. }
  74. }
  75. request.onerror = () => reject(request.error)
  76. })
  77. } catch (error) {
  78. console.warn(`Error getting preview from cache: ${error}`)
  79. return null
  80. }
  81. }
  82. // Add to memory cache with size limit
  83. function addToMemoryCache(pattern: string, data: PreviewData) {
  84. if (memoryCache.size >= MAX_MEMORY_CACHE_SIZE) {
  85. // Remove oldest entry (first key)
  86. const oldestKey = memoryCache.keys().next().value
  87. if (oldestKey) {
  88. memoryCache.delete(oldestKey)
  89. }
  90. }
  91. memoryCache.set(pattern, data)
  92. }
  93. // Save preview to IndexedDB cache with size management
  94. export async function savePreviewToCache(pattern: string, previewData: PreviewData): Promise<void> {
  95. try {
  96. if (!previewData || !previewData.image_data) {
  97. console.warn(`Invalid preview data for ${pattern}, skipping cache save`)
  98. return
  99. }
  100. if (!previewCacheDB) await initPreviewCacheDB()
  101. // Add to memory cache
  102. addToMemoryCache(pattern, previewData)
  103. // Calculate size from base64 data
  104. const size = Math.ceil((previewData.image_data.length * 3) / 4)
  105. // Check if we need to free up space
  106. await managePreviewCacheSize(size)
  107. const cacheEntry: CacheEntry = {
  108. pattern: pattern,
  109. data: previewData,
  110. size: size,
  111. lastAccessed: Date.now(),
  112. created: Date.now(),
  113. }
  114. const transaction = previewCacheDB!.transaction([PREVIEW_CACHE_STORE_NAME], 'readwrite')
  115. const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME)
  116. return new Promise((resolve, reject) => {
  117. const request = store.put(cacheEntry)
  118. request.onsuccess = () => {
  119. console.debug(`Preview cached for ${pattern} (${(size / 1024).toFixed(1)}KB)`)
  120. resolve()
  121. }
  122. request.onerror = () => reject(request.error)
  123. })
  124. } catch (error) {
  125. console.warn(`Error saving preview to cache: ${error}`)
  126. }
  127. }
  128. // Get current cache size
  129. async function getPreviewCacheSize(): Promise<number> {
  130. try {
  131. if (!previewCacheDB) return 0
  132. const transaction = previewCacheDB.transaction([PREVIEW_CACHE_STORE_NAME], 'readonly')
  133. const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME)
  134. return new Promise((resolve, reject) => {
  135. const request = store.getAll()
  136. request.onsuccess = () => {
  137. const totalSize = request.result.reduce(
  138. (sum: number, entry: CacheEntry) => sum + (entry.size || 0),
  139. 0
  140. )
  141. resolve(totalSize)
  142. }
  143. request.onerror = () => reject(request.error)
  144. })
  145. } catch (error) {
  146. console.warn(`Error getting cache size: ${error}`)
  147. return 0
  148. }
  149. }
  150. // Manage cache size by removing least recently used items (LRU eviction)
  151. async function managePreviewCacheSize(newItemSize: number): Promise<void> {
  152. try {
  153. const currentSize = await getPreviewCacheSize()
  154. if (currentSize + newItemSize <= MAX_CACHE_SIZE_BYTES) {
  155. return // No cleanup needed
  156. }
  157. console.debug(
  158. `Cache size would exceed limit (${((currentSize + newItemSize) / 1024 / 1024).toFixed(1)}MB), cleaning up...`
  159. )
  160. const transaction = previewCacheDB!.transaction([PREVIEW_CACHE_STORE_NAME], 'readwrite')
  161. const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME)
  162. const index = store.index('lastAccessed')
  163. // Get all entries sorted by last accessed (oldest first)
  164. const entries = await new Promise<CacheEntry[]>((resolve, reject) => {
  165. const request = index.getAll()
  166. request.onsuccess = () => resolve(request.result)
  167. request.onerror = () => reject(request.error)
  168. })
  169. // Sort by last accessed time (oldest first)
  170. entries.sort((a, b) => a.lastAccessed - b.lastAccessed)
  171. let freedSpace = 0
  172. const targetSpace = newItemSize + MAX_CACHE_SIZE_BYTES * 0.1 // Free 10% extra buffer
  173. for (const entry of entries) {
  174. if (freedSpace >= targetSpace) break
  175. await new Promise<void>((resolve, reject) => {
  176. const deleteRequest = store.delete(entry.pattern)
  177. deleteRequest.onsuccess = () => {
  178. freedSpace += entry.size
  179. // Also remove from memory cache
  180. memoryCache.delete(entry.pattern)
  181. console.debug(
  182. `Evicted cached preview for ${entry.pattern} (${(entry.size / 1024).toFixed(1)}KB)`
  183. )
  184. resolve()
  185. }
  186. deleteRequest.onerror = () => reject(deleteRequest.error)
  187. })
  188. }
  189. console.debug(`Freed ${(freedSpace / 1024 / 1024).toFixed(1)}MB from preview cache`)
  190. } catch (error) {
  191. console.warn(`Error managing cache size: ${error}`)
  192. }
  193. }
  194. // Clear a specific pattern from cache
  195. export async function clearPatternFromCache(pattern: string): Promise<void> {
  196. try {
  197. // Remove from memory cache
  198. memoryCache.delete(pattern)
  199. if (!previewCacheDB) await initPreviewCacheDB()
  200. const transaction = previewCacheDB!.transaction([PREVIEW_CACHE_STORE_NAME], 'readwrite')
  201. const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME)
  202. await new Promise<void>((resolve, reject) => {
  203. const deleteRequest = store.delete(pattern)
  204. deleteRequest.onsuccess = () => {
  205. console.debug(`Cleared ${pattern} from cache`)
  206. resolve()
  207. }
  208. deleteRequest.onerror = () => reject(deleteRequest.error)
  209. })
  210. } catch (error) {
  211. console.warn(`Error clearing pattern from cache: ${error}`)
  212. }
  213. }
  214. // Get multiple previews from cache (batch operation)
  215. export async function getPreviewsFromCache(
  216. patterns: string[]
  217. ): Promise<Map<string, PreviewData>> {
  218. const results = new Map<string, PreviewData>()
  219. const uncachedPatterns: string[] = []
  220. // Check memory cache first
  221. for (const pattern of patterns) {
  222. if (memoryCache.has(pattern)) {
  223. results.set(pattern, memoryCache.get(pattern)!)
  224. } else {
  225. uncachedPatterns.push(pattern)
  226. }
  227. }
  228. // Check IndexedDB for remaining patterns
  229. if (uncachedPatterns.length > 0 && previewCacheDB) {
  230. try {
  231. const transaction = previewCacheDB.transaction([PREVIEW_CACHE_STORE_NAME], 'readwrite')
  232. const store = transaction.objectStore(PREVIEW_CACHE_STORE_NAME)
  233. await Promise.all(
  234. uncachedPatterns.map(
  235. (pattern) =>
  236. new Promise<void>((resolve) => {
  237. const request = store.get(pattern)
  238. request.onsuccess = () => {
  239. const result = request.result as CacheEntry | undefined
  240. if (result) {
  241. // Update last accessed time
  242. result.lastAccessed = Date.now()
  243. store.put(result)
  244. // Add to results and memory cache
  245. results.set(pattern, result.data)
  246. addToMemoryCache(pattern, result.data)
  247. }
  248. resolve()
  249. }
  250. request.onerror = () => resolve()
  251. })
  252. )
  253. )
  254. } catch (error) {
  255. console.warn(`Error batch getting from cache: ${error}`)
  256. }
  257. }
  258. return results
  259. }
  260. // Shared function to cache all previews - used by both BrowsePage and Layout modal
  261. export interface CacheAllProgress {
  262. completed: number
  263. total: number
  264. done: boolean
  265. }
  266. export async function cacheAllPreviews(
  267. onProgress: (progress: CacheAllProgress) => void
  268. ): Promise<{ success: boolean; cached: number }> {
  269. const BATCH_SIZE = 10
  270. try {
  271. await initPreviewCacheDB()
  272. // Fetch all patterns
  273. const patterns: { path: string }[] = await apiClient.get('/list_theta_rho_files_with_metadata')
  274. const allPaths = patterns.map((p) => p.path)
  275. // Check which patterns are already cached
  276. const cachedPreviews = await getPreviewsFromCache(allPaths)
  277. const uncachedPatterns = allPaths.filter((path) => !cachedPreviews.has(path))
  278. if (uncachedPatterns.length === 0) {
  279. onProgress({ completed: patterns.length, total: patterns.length, done: true })
  280. return { success: true, cached: 0 }
  281. }
  282. onProgress({ completed: 0, total: uncachedPatterns.length, done: false })
  283. const totalBatches = Math.ceil(uncachedPatterns.length / BATCH_SIZE)
  284. for (let i = 0; i < totalBatches; i++) {
  285. const batchStart = i * BATCH_SIZE
  286. const batchEnd = Math.min(batchStart + BATCH_SIZE, uncachedPatterns.length)
  287. const batchPatterns = uncachedPatterns.slice(batchStart, batchEnd)
  288. try {
  289. const results = await apiClient.post<Record<string, PreviewData>>('/preview_thr_batch', { file_names: batchPatterns })
  290. for (const [path, data] of Object.entries(results)) {
  291. if (data && !data.error) {
  292. await savePreviewToCache(path, data)
  293. }
  294. }
  295. } catch {
  296. // Continue even if batch fails
  297. }
  298. onProgress({ completed: batchEnd, total: uncachedPatterns.length, done: false })
  299. // Small delay between batches
  300. if (i + 1 < totalBatches) {
  301. await new Promise((resolve) => setTimeout(resolve, 100))
  302. }
  303. }
  304. onProgress({ completed: uncachedPatterns.length, total: uncachedPatterns.length, done: true })
  305. return { success: true, cached: uncachedPatterns.length }
  306. } catch (error) {
  307. console.error('Error caching previews:', error)
  308. return { success: false, cached: 0 }
  309. }
  310. }