| // Copyright 2013 The Chromium Authors | 
 | // Use of this source code is governed by a BSD-style license that can be | 
 | // found in the LICENSE file. | 
 |  | 
 | /** | 
 |  * Persistent cache storing images in an indexed database on the hard disk. | 
 |  * @constructor | 
 |  */ | 
 | export function ImageCache() { | 
 |   /** | 
 |    * IndexedDB database handle. | 
 |    * @type {IDBDatabase} | 
 |    * @private | 
 |    */ | 
 |   this.db_ = null; | 
 | } | 
 |  | 
 | /** | 
 |  * Cache database name. | 
 |  * @type {string} | 
 |  * @const | 
 |  */ | 
 | ImageCache.DB_NAME = 'image-loader'; | 
 |  | 
 | /** | 
 |  * Cache database version. | 
 |  * @type {number} | 
 |  * @const | 
 |  */ | 
 | ImageCache.DB_VERSION = 15; | 
 |  | 
 | /** | 
 |  * Memory limit for images data in bytes. | 
 |  * | 
 |  * @const | 
 |  * @type {number} | 
 |  */ | 
 | ImageCache.MEMORY_LIMIT = 250 * 1024 * 1024;  // 250 MB. | 
 |  | 
 | /** | 
 |  * Minimal amount of memory freed per eviction. Used to limit number of | 
 |  * evictions which are expensive. | 
 |  * | 
 |  * @const | 
 |  * @type {number} | 
 |  */ | 
 | ImageCache.EVICTION_CHUNK_SIZE = 50 * 1024 * 1024;  // 50 MB. | 
 |  | 
 | /** | 
 |  * Initializes the cache database. | 
 |  * @param {function()} callback Completion callback. | 
 |  */ | 
 | ImageCache.prototype.initialize = function(callback) { | 
 |   // Establish a connection to the database or (re)create it if not available | 
 |   // or not up to date. After changing the database's schema, increment | 
 |   // ImageCache.DB_VERSION to force database recreating. | 
 |   const openRequest = | 
 |       window.indexedDB.open(ImageCache.DB_NAME, ImageCache.DB_VERSION); | 
 |  | 
 |   openRequest.onsuccess = function(e) { | 
 |     this.db_ = e.target.result; | 
 |     callback(); | 
 |   }.bind(this); | 
 |  | 
 |   openRequest.onerror = callback; | 
 |  | 
 |   openRequest.onupgradeneeded = function(e) { | 
 |     console.info('Cache database creating or upgrading.'); | 
 |     const db = e.target.result; | 
 |     if (db.objectStoreNames.contains('metadata')) { | 
 |       db.deleteObjectStore('metadata'); | 
 |     } | 
 |     if (db.objectStoreNames.contains('data')) { | 
 |       db.deleteObjectStore('data'); | 
 |     } | 
 |     if (db.objectStoreNames.contains('settings')) { | 
 |       db.deleteObjectStore('settings'); | 
 |     } | 
 |     db.createObjectStore('metadata', {keyPath: 'key'}); | 
 |     db.createObjectStore('data', {keyPath: 'key'}); | 
 |     db.createObjectStore('settings', {keyPath: 'key'}); | 
 |   }; | 
 | }; | 
 |  | 
 | /** | 
 |  * Sets size of the cache. | 
 |  * | 
 |  * @param {number} size Size in bytes. | 
 |  * @param {IDBTransaction=} opt_transaction Transaction to be reused. If not | 
 |  *     provided, then a new one is created. | 
 |  * @private | 
 |  */ | 
 | ImageCache.prototype.setCacheSize_ = function(size, opt_transaction) { | 
 |   const transaction = | 
 |       opt_transaction || this.db_.transaction(['settings'], 'readwrite'); | 
 |   const settingsStore = transaction.objectStore('settings'); | 
 |  | 
 |   settingsStore.put({key: 'size', value: size});  // Update asynchronously. | 
 | }; | 
 |  | 
 | /** | 
 |  * Fetches current size of the cache. | 
 |  * | 
 |  * @param {function(number)} onSuccess Callback to return the size. | 
 |  * @param {function()} onFailure Failure callback. | 
 |  * @param {IDBTransaction=} opt_transaction Transaction to be reused. If not | 
 |  *     provided, then a new one is created. | 
 |  * @private | 
 |  */ | 
 | ImageCache.prototype.fetchCacheSize_ = function( | 
 |     onSuccess, onFailure, opt_transaction) { | 
 |   const transaction = opt_transaction || | 
 |       this.db_.transaction(['settings', 'metadata', 'data'], 'readwrite'); | 
 |   const settingsStore = transaction.objectStore('settings'); | 
 |   const sizeRequest = settingsStore.get('size'); | 
 |  | 
 |   sizeRequest.onsuccess = function(e) { | 
 |     if (e.target.result) { | 
 |       onSuccess(e.target.result.value); | 
 |     } else { | 
 |       onSuccess(0); | 
 |     } | 
 |   }; | 
 |  | 
 |   sizeRequest.onerror = function() { | 
 |     console.warn('Failed to fetch size from the database.'); | 
 |     onFailure(); | 
 |   }; | 
 | }; | 
 |  | 
 | /** | 
 |  * Evicts the least used elements in cache to make space for a new image and | 
 |  * updates size of the cache taking into account the upcoming item. | 
 |  * | 
 |  * @param {number} size Requested size. | 
 |  * @param {function()} onSuccess Success callback. | 
 |  * @param {function()} onFailure Failure callback. | 
 |  * @param {IDBTransaction=} opt_transaction Transaction to be reused. If not | 
 |  *     provided, then a new one is created. | 
 |  * @private | 
 |  */ | 
 | ImageCache.prototype.evictCache_ = function( | 
 |     size, onSuccess, onFailure, opt_transaction) { | 
 |   const transaction = opt_transaction || | 
 |       this.db_.transaction(['settings', 'metadata', 'data'], 'readwrite'); | 
 |  | 
 |   // Check if the requested size is smaller than the cache size. | 
 |   if (size > ImageCache.MEMORY_LIMIT) { | 
 |     onFailure(); | 
 |     return; | 
 |   } | 
 |  | 
 |   const onCacheSize = function(cacheSize) { | 
 |     if (size < ImageCache.MEMORY_LIMIT - cacheSize) { | 
 |       // Enough space, no need to evict. | 
 |       this.setCacheSize_(cacheSize + size, transaction); | 
 |       onSuccess(); | 
 |       return; | 
 |     } | 
 |  | 
 |     let bytesToEvict = Math.max(size, ImageCache.EVICTION_CHUNK_SIZE); | 
 |  | 
 |     // Fetch all metadata. | 
 |     const metadataEntries = []; | 
 |     const metadataStore = transaction.objectStore('metadata'); | 
 |     const dataStore = transaction.objectStore('data'); | 
 |  | 
 |     const onEntriesFetched = function() { | 
 |       metadataEntries.sort(function(a, b) { | 
 |         return b.lastLoadTimestamp - a.lastLoadTimestamp; | 
 |       }); | 
 |  | 
 |       let totalEvicted = 0; | 
 |       while (bytesToEvict > 0) { | 
 |         const entry = metadataEntries.pop(); | 
 |         totalEvicted += entry.size; | 
 |         bytesToEvict -= entry.size; | 
 |         metadataStore.delete(entry.key);  // Remove asynchronously. | 
 |         dataStore.delete(entry.key);  // Remove asynchronously. | 
 |       } | 
 |  | 
 |       this.setCacheSize_(cacheSize - totalEvicted + size, transaction); | 
 |     }.bind(this); | 
 |  | 
 |     metadataStore.openCursor().onsuccess = function(e) { | 
 |       const cursor = e.target.result; | 
 |       if (cursor) { | 
 |         metadataEntries.push(cursor.value); | 
 |         cursor.continue(); | 
 |       } else { | 
 |         onEntriesFetched(); | 
 |       } | 
 |     }; | 
 |   }.bind(this); | 
 |  | 
 |   this.fetchCacheSize_(onCacheSize, onFailure, transaction); | 
 | }; | 
 |  | 
 | /** | 
 |  * Saves an image in the cache. | 
 |  * | 
 |  * @param {string} key Cache key. | 
 |  * @param {number} timestamp Last modification timestamp. Used to detect | 
 |  *     if the image cache entry is out of date. | 
 |  * @param {number} width Image width. | 
 |  * @param {number} height Image height. | 
 |  * @param {?string} ifd Image ifd, null if none. | 
 |  * @param {string} data Image data. | 
 |  */ | 
 | ImageCache.prototype.saveImage = function( | 
 |     key, timestamp, width, height, ifd, data) { | 
 |   if (!this.db_) { | 
 |     console.warn('Cache database not available.'); | 
 |     return; | 
 |   } | 
 |  | 
 |   const onNotFoundInCache = function() { | 
 |     const metadataEntry = { | 
 |       key: key, | 
 |       timestamp: timestamp, | 
 |       width: width, | 
 |       height: height, | 
 |       ifd: ifd, | 
 |       size: data.length, | 
 |       lastLoadTimestamp: Date.now(), | 
 |     }; | 
 |  | 
 |     const dataEntry = {key: key, data: data}; | 
 |  | 
 |     const transaction = | 
 |         this.db_.transaction(['settings', 'metadata', 'data'], 'readwrite'); | 
 |     const metadataStore = transaction.objectStore('metadata'); | 
 |     const dataStore = transaction.objectStore('data'); | 
 |  | 
 |     const onCacheEvicted = function() { | 
 |       metadataStore.put(metadataEntry);  // Add asynchronously. | 
 |       dataStore.put(dataEntry);  // Add asynchronously. | 
 |     }; | 
 |  | 
 |     // Make sure there is enough space in the cache. | 
 |     this.evictCache_(data.length, onCacheEvicted, function() {}, transaction); | 
 |   }.bind(this); | 
 |  | 
 |   // Check if the image is already in cache. If not, then save it to cache. | 
 |   this.loadImage(key, timestamp, function() {}, onNotFoundInCache); | 
 | }; | 
 |  | 
 | /** | 
 |  * Loads an image from the cache. | 
 |  * | 
 |  * @param {string} key Cache key. | 
 |  * @param {number} timestamp Last modification timestamp. If different | 
 |  *     than the one in cache, then the entry will be invalidated. | 
 |  * @param {function(number, number, ?string, string)} onSuccess Success | 
 |  *     callback with the image width, height, ?ifd, and data. | 
 |  * @param {function()} onFailure Failure callback. | 
 |  */ | 
 | ImageCache.prototype.loadImage = function( | 
 |     key, timestamp, onSuccess, onFailure) { | 
 |   if (!this.db_) { | 
 |     console.warn('Cache database not available.'); | 
 |     onFailure(); | 
 |     return; | 
 |   } | 
 |  | 
 |   const transaction = | 
 |       this.db_.transaction(['settings', 'metadata', 'data'], 'readwrite'); | 
 |   const metadataStore = transaction.objectStore('metadata'); | 
 |   const dataStore = transaction.objectStore('data'); | 
 |   const metadataRequest = metadataStore.get(key); | 
 |   const dataRequest = dataStore.get(key); | 
 |  | 
 |   let metadataEntry = null; | 
 |   let metadataReceived = false; | 
 |   let dataEntry = null; | 
 |   let dataReceived = false; | 
 |  | 
 |   const onPartialSuccess = function() { | 
 |     // Check if all sub-requests have finished. | 
 |     if (!metadataReceived || !dataReceived) { | 
 |       return; | 
 |     } | 
 |  | 
 |     // Check if both entries are available or both unavailable. | 
 |     if (!!metadataEntry != !!dataEntry) { | 
 |       console.warn('Inconsistent cache database.'); | 
 |       onFailure(); | 
 |       return; | 
 |     } | 
 |  | 
 |     // Process the responses. | 
 |     if (!metadataEntry) { | 
 |       // The image not found. | 
 |       onFailure(); | 
 |     } else if (metadataEntry.timestamp != timestamp) { | 
 |       // The image is not up to date, so remove it. | 
 |       this.removeImage(key, function() {}, function() {}, transaction); | 
 |       onFailure(); | 
 |     } else { | 
 |       // The image is available. Update the last load time and return the | 
 |       // image data. | 
 |       metadataEntry.lastLoadTimestamp = Date.now(); | 
 |       metadataStore.put(metadataEntry);  // Added asynchronously. | 
 |       onSuccess( | 
 |           metadataEntry.width, metadataEntry.height, metadataEntry.ifd, | 
 |           dataEntry.data); | 
 |     } | 
 |   }.bind(this); | 
 |  | 
 |   metadataRequest.onsuccess = function(e) { | 
 |     if (e.target.result) { | 
 |       metadataEntry = e.target.result; | 
 |     } | 
 |     metadataReceived = true; | 
 |     onPartialSuccess(); | 
 |   }; | 
 |  | 
 |   dataRequest.onsuccess = function(e) { | 
 |     if (e.target.result) { | 
 |       dataEntry = e.target.result; | 
 |     } | 
 |     dataReceived = true; | 
 |     onPartialSuccess(); | 
 |   }; | 
 |  | 
 |   metadataRequest.onerror = function() { | 
 |     console.warn('Failed to fetch metadata from the database.'); | 
 |     metadataReceived = true; | 
 |     onPartialSuccess(); | 
 |   }; | 
 |  | 
 |   dataRequest.onerror = function() { | 
 |     console.warn('Failed to fetch image data from the database.'); | 
 |     dataReceived = true; | 
 |     onPartialSuccess(); | 
 |   }; | 
 | }; | 
 |  | 
 | /** | 
 |  * Removes the image from the cache. | 
 |  * | 
 |  * @param {string} key Cache key. | 
 |  * @param {function()=} opt_onSuccess Success callback. | 
 |  * @param {function()=} opt_onFailure Failure callback. | 
 |  * @param {IDBTransaction=} opt_transaction Transaction to be reused. If not | 
 |  *     provided, then a new one is created. | 
 |  */ | 
 | ImageCache.prototype.removeImage = function( | 
 |     key, opt_onSuccess, opt_onFailure, opt_transaction) { | 
 |   if (!this.db_) { | 
 |     console.warn('Cache database not available.'); | 
 |     return; | 
 |   } | 
 |  | 
 |   const transaction = opt_transaction || | 
 |       this.db_.transaction(['settings', 'metadata', 'data'], 'readwrite'); | 
 |   const metadataStore = transaction.objectStore('metadata'); | 
 |   const dataStore = transaction.objectStore('data'); | 
 |  | 
 |   let cacheSize = null; | 
 |   let cacheSizeReceived = false; | 
 |   let metadataEntry = null; | 
 |   let metadataReceived = false; | 
 |  | 
 |   const onPartialSuccess = function() { | 
 |     if (!cacheSizeReceived || !metadataReceived) { | 
 |       return; | 
 |     } | 
 |  | 
 |     // If either cache size or metadata entry is not available, then it is | 
 |     // an error. | 
 |     if (cacheSize === null || !metadataEntry) { | 
 |       if (opt_onFailure) { | 
 |         opt_onFailure(); | 
 |       } | 
 |       return; | 
 |     } | 
 |  | 
 |     if (opt_onSuccess) { | 
 |       opt_onSuccess(); | 
 |     } | 
 |  | 
 |     this.setCacheSize_(cacheSize - metadataEntry.size, transaction); | 
 |     metadataStore.delete(key);  // Delete asynchronously. | 
 |     dataStore.delete(key);  // Delete asynchronously. | 
 |   }.bind(this); | 
 |  | 
 |   const onCacheSizeFailure = function() { | 
 |     cacheSizeReceived = true; | 
 |   }; | 
 |  | 
 |   const onCacheSizeSuccess = function(result) { | 
 |     cacheSize = result; | 
 |     cacheSizeReceived = true; | 
 |     onPartialSuccess(); | 
 |   }; | 
 |  | 
 |   // Fetch the current cache size. | 
 |   this.fetchCacheSize_(onCacheSizeSuccess, onCacheSizeFailure, transaction); | 
 |  | 
 |   // Receive image's metadata. | 
 |   const metadataRequest = metadataStore.get(key); | 
 |  | 
 |   metadataRequest.onsuccess = function(e) { | 
 |     if (e.target.result) { | 
 |       metadataEntry = e.target.result; | 
 |     } | 
 |     metadataReceived = true; | 
 |     onPartialSuccess(); | 
 |   }; | 
 |  | 
 |   metadataRequest.onerror = function() { | 
 |     console.warn('Failed to remove an image.'); | 
 |     metadataReceived = true; | 
 |     onPartialSuccess(); | 
 |   }; | 
 | }; |