| // Copyright 2013 The Chromium Authors. All rights reserved. |
| // 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 |
| */ |
| 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 = 12; |
| |
| /** |
| * 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. |
| |
| /** |
| * Creates a cache key. |
| * |
| * @param {Object} request Request options. |
| * @return {?string} Cache key. It may be null if the cache does not support |
| * |request|. e.g. Data URI. |
| */ |
| ImageCache.createKey = function(request) { |
| if (/^data:/i.test(request.url)) |
| return null; |
| return JSON.stringify({ |
| url: request.url, |
| scale: request.scale, |
| width: request.width, |
| height: request.height, |
| maxWidth: request.maxWidth, |
| maxHeight: request.maxHeight}); |
| }; |
| |
| /** |
| * 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. |
| var 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.'); |
| var 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) { |
| var transaction = opt_transaction || |
| this.db_.transaction(['settings'], 'readwrite'); |
| var 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) { |
| var transaction = opt_transaction || |
| this.db_.transaction(['settings', 'metadata', 'data'], 'readwrite'); |
| var settingsStore = transaction.objectStore('settings'); |
| var sizeRequest = settingsStore.get('size'); |
| |
| sizeRequest.onsuccess = function(e) { |
| if (e.target.result) |
| onSuccess(e.target.result.value); |
| else |
| onSuccess(0); |
| }; |
| |
| sizeRequest.onerror = function() { |
| console.error('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) { |
| var 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; |
| } |
| |
| var onCacheSize = function(cacheSize) { |
| if (size < ImageCache.MEMORY_LIMIT - cacheSize) { |
| // Enough space, no need to evict. |
| this.setCacheSize_(cacheSize + size, transaction); |
| onSuccess(); |
| return; |
| } |
| |
| var bytesToEvict = Math.max(size, ImageCache.EVICTION_CHUNK_SIZE); |
| |
| // Fetch all metadata. |
| var metadataEntries = []; |
| var metadataStore = transaction.objectStore('metadata'); |
| var dataStore = transaction.objectStore('data'); |
| |
| var onEntriesFetched = function() { |
| metadataEntries.sort(function(a, b) { |
| return b.lastLoadTimestamp - a.lastLoadTimestamp; |
| }); |
| |
| var totalEvicted = 0; |
| while (bytesToEvict > 0) { |
| var 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) { |
| var 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 {string} data Image data. |
| * @param {number} width Image width. |
| * @param {number} height Image height. |
| * @param {number} timestamp Last modification timestamp. Used to detect |
| * if the cache entry becomes out of date. |
| */ |
| ImageCache.prototype.saveImage = function(key, data, width, height, timestamp) { |
| if (!this.db_) { |
| console.warn('Cache database not available.'); |
| return; |
| } |
| |
| var onNotFoundInCache = function() { |
| var metadataEntry = { |
| key: key, |
| timestamp: timestamp, |
| width: width, |
| height: height, |
| size: data.length, |
| lastLoadTimestamp: Date.now()}; |
| var dataEntry = {key: key, data: data}; |
| |
| var transaction = this.db_.transaction(['settings', 'metadata', 'data'], |
| 'readwrite'); |
| var metadataStore = transaction.objectStore('metadata'); |
| var dataStore = transaction.objectStore('data'); |
| |
| var 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 (if available) or returns null. |
| * |
| * @param {string} key Cache key. |
| * @param {number} timestamp Last modification timestamp. If different |
| * that the one in cache, then the entry will be invalidated. |
| * @param {function(string, number, number)} onSuccess Success callback with |
| * the image's data, width, height. |
| * @param {function()} onFailure Failure callback. |
| */ |
| ImageCache.prototype.loadImage = function( |
| key, timestamp, onSuccess, onFailure) { |
| if (!this.db_) { |
| console.warn('Cache database not available.'); |
| onFailure(); |
| return; |
| } |
| |
| var transaction = this.db_.transaction(['settings', 'metadata', 'data'], |
| 'readwrite'); |
| var metadataStore = transaction.objectStore('metadata'); |
| var dataStore = transaction.objectStore('data'); |
| var metadataRequest = metadataStore.get(key); |
| var dataRequest = dataStore.get(key); |
| |
| var metadataEntry = null; |
| var metadataReceived = false; |
| var dataEntry = null; |
| var dataReceived = false; |
| |
| var 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(dataEntry.data, metadataEntry.width, metadataEntry.height); |
| } |
| }.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.error('Failed to fetch metadata from the database.'); |
| metadataReceived = true; |
| onPartialSuccess(); |
| }; |
| |
| dataRequest.onerror = function() { |
| console.error('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; |
| } |
| |
| var transaction = opt_transaction || |
| this.db_.transaction(['settings', 'metadata', 'data'], 'readwrite'); |
| var metadataStore = transaction.objectStore('metadata'); |
| var dataStore = transaction.objectStore('data'); |
| |
| var cacheSize = null; |
| var cacheSizeReceived = false; |
| var metadataEntry = null; |
| var metadataReceived = false; |
| |
| var 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); |
| |
| var onCacheSizeFailure = function() { |
| cacheSizeReceived = true; |
| }; |
| |
| var onCacheSizeSuccess = function(result) { |
| cacheSize = result; |
| cacheSizeReceived = true; |
| onPartialSuccess(); |
| }; |
| |
| // Fetch the current cache size. |
| this.fetchCacheSize_(onCacheSizeSuccess, onCacheSizeFailure, transaction); |
| |
| // Receive image's metadata. |
| var metadataRequest = metadataStore.get(key); |
| |
| metadataRequest.onsuccess = function(e) { |
| if (e.target.result) |
| metadataEntry = e.target.result; |
| metadataReceived = true; |
| onPartialSuccess(); |
| }; |
| |
| metadataRequest.onerror = function() { |
| console.error('Failed to remove an image.'); |
| metadataReceived = true; |
| onPartialSuccess(); |
| }; |
| }; |