Fix search results being clipped in app list.
[chromium-blink-merge.git] / ui / file_manager / image_loader / cache.js
blobb9eb698238af5005e1869d3ba16a4987fbfb938e
1 // Copyright 2013 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
5 /**
6  * Persistent cache storing images in an indexed database on the hard disk.
7  * @constructor
8  */
9 function Cache() {
10   /**
11    * IndexedDB database handle.
12    * @type {IDBDatabase}
13    * @private
14    */
15   this.db_ = null;
18 /**
19  * Cache database name.
20  * @type {string}
21  * @const
22  */
23 Cache.DB_NAME = 'image-loader';
25 /**
26  * Cache database version.
27  * @type {number}
28  * @const
29  */
30 Cache.DB_VERSION = 12;
32 /**
33  * Memory limit for images data in bytes.
34  *
35  * @const
36  * @type {number}
37  */
38 Cache.MEMORY_LIMIT = 250 * 1024 * 1024;  // 250 MB.
40 /**
41  * Minimal amount of memory freed per eviction. Used to limit number of
42  * evictions which are expensive.
43  *
44  * @const
45  * @type {number}
46  */
47 Cache.EVICTION_CHUNK_SIZE = 50 * 1024 * 1024;  // 50 MB.
49 /**
50  * Creates a cache key.
51  *
52  * @param {Object} request Request options.
53  * @return {?string} Cache key. It may be null if the cache does not support
54  *     |request|. e.g. Data URI.
55  */
56 Cache.createKey = function(request) {
57   if (/^data:/i.test(request.url))
58     return null;
59   return JSON.stringify({
60     url: request.url,
61     scale: request.scale,
62     width: request.width,
63     height: request.height,
64     maxWidth: request.maxWidth,
65     maxHeight: request.maxHeight});
68 /**
69  * Initializes the cache database.
70  * @param {function()} callback Completion callback.
71  */
72 Cache.prototype.initialize = function(callback) {
73   // Establish a connection to the database or (re)create it if not available
74   // or not up to date. After changing the database's schema, increment
75   // Cache.DB_VERSION to force database recreating.
76   var openRequest = window.indexedDB.open(Cache.DB_NAME, Cache.DB_VERSION);
78   openRequest.onsuccess = function(e) {
79     this.db_ = e.target.result;
80     callback();
81   }.bind(this);
83   openRequest.onerror = callback;
85   openRequest.onupgradeneeded = function(e) {
86     console.info('Cache database creating or upgrading.');
87     var db = e.target.result;
88     if (db.objectStoreNames.contains('metadata'))
89       db.deleteObjectStore('metadata');
90     if (db.objectStoreNames.contains('data'))
91       db.deleteObjectStore('data');
92     if (db.objectStoreNames.contains('settings'))
93       db.deleteObjectStore('settings');
94     db.createObjectStore('metadata', {keyPath: 'key'});
95     db.createObjectStore('data', {keyPath: 'key'});
96     db.createObjectStore('settings', {keyPath: 'key'});
97   };
101  * Sets size of the cache.
103  * @param {number} size Size in bytes.
104  * @param {IDBTransaction=} opt_transaction Transaction to be reused. If not
105  *     provided, then a new one is created.
106  * @private
107  */
108 Cache.prototype.setCacheSize_ = function(size, opt_transaction) {
109   var transaction = opt_transaction ||
110       this.db_.transaction(['settings'], 'readwrite');
111   var settingsStore = transaction.objectStore('settings');
113   settingsStore.put({key: 'size', value: size});  // Update asynchronously.
117  * Fetches current size of the cache.
119  * @param {function(number)} onSuccess Callback to return the size.
120  * @param {function()} onFailure Failure callback.
121  * @param {IDBTransaction=} opt_transaction Transaction to be reused. If not
122  *     provided, then a new one is created.
123  * @private
124  */
125 Cache.prototype.fetchCacheSize_ = function(
126     onSuccess, onFailure, opt_transaction) {
127   var transaction = opt_transaction ||
128       this.db_.transaction(['settings', 'metadata', 'data'], 'readwrite');
129   var settingsStore = transaction.objectStore('settings');
130   var sizeRequest = settingsStore.get('size');
132   sizeRequest.onsuccess = function(e) {
133     if (e.target.result)
134       onSuccess(e.target.result.value);
135     else
136       onSuccess(0);
137   };
139   sizeRequest.onerror = function() {
140     console.error('Failed to fetch size from the database.');
141     onFailure();
142   };
146  * Evicts the least used elements in cache to make space for a new image and
147  * updates size of the cache taking into account the upcoming item.
149  * @param {number} size Requested size.
150  * @param {function()} onSuccess Success callback.
151  * @param {function()} onFailure Failure callback.
152  * @param {IDBTransaction=} opt_transaction Transaction to be reused. If not
153  *     provided, then a new one is created.
154  * @private
155  */
156 Cache.prototype.evictCache_ = function(
157     size, onSuccess, onFailure, opt_transaction) {
158   var transaction = opt_transaction ||
159       this.db_.transaction(['settings', 'metadata', 'data'], 'readwrite');
161   // Check if the requested size is smaller than the cache size.
162   if (size > Cache.MEMORY_LIMIT) {
163     onFailure();
164     return;
165   }
167   var onCacheSize = function(cacheSize) {
168     if (size < Cache.MEMORY_LIMIT - cacheSize) {
169       // Enough space, no need to evict.
170       this.setCacheSize_(cacheSize + size, transaction);
171       onSuccess();
172       return;
173     }
175     var bytesToEvict = Math.max(size, Cache.EVICTION_CHUNK_SIZE);
177     // Fetch all metadata.
178     var metadataEntries = [];
179     var metadataStore = transaction.objectStore('metadata');
180     var dataStore = transaction.objectStore('data');
182     var onEntriesFetched = function() {
183       metadataEntries.sort(function(a, b) {
184         return b.lastLoadTimestamp - a.lastLoadTimestamp;
185       });
187       var totalEvicted = 0;
188       while (bytesToEvict > 0) {
189         var entry = metadataEntries.pop();
190         totalEvicted += entry.size;
191         bytesToEvict -= entry.size;
192         metadataStore.delete(entry.key);  // Remove asynchronously.
193         dataStore.delete(entry.key);  // Remove asynchronously.
194       }
196       this.setCacheSize_(cacheSize - totalEvicted + size, transaction);
197     }.bind(this);
199     metadataStore.openCursor().onsuccess = function(e) {
200       var cursor = e.target.result;
201       if (cursor) {
202         metadataEntries.push(cursor.value);
203         cursor.continue();
204       } else {
205         onEntriesFetched();
206       }
207     };
208   }.bind(this);
210   this.fetchCacheSize_(onCacheSize, onFailure, transaction);
214  * Saves an image in the cache.
216  * @param {string} key Cache key.
217  * @param {string} data Image data.
218  * @param {number} width Image width.
219  * @param {number} height Image height.
220  * @param {number} timestamp Last modification timestamp. Used to detect
221  *     if the cache entry becomes out of date.
222  */
223 Cache.prototype.saveImage = function(key, data, width, height, timestamp) {
224   if (!this.db_) {
225     console.warn('Cache database not available.');
226     return;
227   }
229   var onNotFoundInCache = function() {
230     var metadataEntry = {
231       key: key,
232       timestamp: timestamp,
233       width: width,
234       height: height,
235       size: data.length,
236       lastLoadTimestamp: Date.now()};
237     var dataEntry = {key: key, data: data};
239     var transaction = this.db_.transaction(['settings', 'metadata', 'data'],
240                                            'readwrite');
241     var metadataStore = transaction.objectStore('metadata');
242     var dataStore = transaction.objectStore('data');
244     var onCacheEvicted = function() {
245       metadataStore.put(metadataEntry);  // Add asynchronously.
246       dataStore.put(dataEntry);  // Add asynchronously.
247     };
249     // Make sure there is enough space in the cache.
250     this.evictCache_(data.length, onCacheEvicted, function() {}, transaction);
251   }.bind(this);
253   // Check if the image is already in cache. If not, then save it to cache.
254   this.loadImage(key, timestamp, function() {}, onNotFoundInCache);
258  * Loads an image from the cache (if available) or returns null.
260  * @param {string} key Cache key.
261  * @param {number} timestamp Last modification timestamp. If different
262  *     that the one in cache, then the entry will be invalidated.
263  * @param {function(string, number, number)} onSuccess Success callback with
264  *     the image's data, width, height.
265  * @param {function()} onFailure Failure callback.
266  */
267 Cache.prototype.loadImage = function(key, timestamp, onSuccess, onFailure) {
268   if (!this.db_) {
269     console.warn('Cache database not available.');
270     onFailure();
271     return;
272   }
274   var transaction = this.db_.transaction(['settings', 'metadata', 'data'],
275                                          'readwrite');
276   var metadataStore = transaction.objectStore('metadata');
277   var dataStore = transaction.objectStore('data');
278   var metadataRequest = metadataStore.get(key);
279   var dataRequest = dataStore.get(key);
281   var metadataEntry = null;
282   var metadataReceived = false;
283   var dataEntry = null;
284   var dataReceived = false;
286   var onPartialSuccess = function() {
287     // Check if all sub-requests have finished.
288     if (!metadataReceived || !dataReceived)
289       return;
291     // Check if both entries are available or both unavailable.
292     if (!!metadataEntry != !!dataEntry) {
293       console.warn('Inconsistent cache database.');
294       onFailure();
295       return;
296     }
298     // Process the responses.
299     if (!metadataEntry) {
300       // The image not found.
301       onFailure();
302     } else if (metadataEntry.timestamp != timestamp) {
303       // The image is not up to date, so remove it.
304       this.removeImage(key, function() {}, function() {}, transaction);
305       onFailure();
306     } else {
307       // The image is available. Update the last load time and return the
308       // image data.
309       metadataEntry.lastLoadTimestamp = Date.now();
310       metadataStore.put(metadataEntry);  // Added asynchronously.
311       onSuccess(dataEntry.data, metadataEntry.width, metadataEntry.height);
312     }
313   }.bind(this);
315   metadataRequest.onsuccess = function(e) {
316     if (e.target.result)
317       metadataEntry = e.target.result;
318     metadataReceived = true;
319     onPartialSuccess();
320   };
322   dataRequest.onsuccess = function(e) {
323     if (e.target.result)
324       dataEntry = e.target.result;
325     dataReceived = true;
326     onPartialSuccess();
327   };
329   metadataRequest.onerror = function() {
330     console.error('Failed to fetch metadata from the database.');
331     metadataReceived = true;
332     onPartialSuccess();
333   };
335   dataRequest.onerror = function() {
336     console.error('Failed to fetch image data from the database.');
337     dataReceived = true;
338     onPartialSuccess();
339   };
343  * Removes the image from the cache.
345  * @param {string} key Cache key.
346  * @param {function()=} opt_onSuccess Success callback.
347  * @param {function()=} opt_onFailure Failure callback.
348  * @param {IDBTransaction=} opt_transaction Transaction to be reused. If not
349  *     provided, then a new one is created.
350  */
351 Cache.prototype.removeImage = function(
352     key, opt_onSuccess, opt_onFailure, opt_transaction) {
353   if (!this.db_) {
354     console.warn('Cache database not available.');
355     return;
356   }
358   var transaction = opt_transaction ||
359       this.db_.transaction(['settings', 'metadata', 'data'], 'readwrite');
360   var metadataStore = transaction.objectStore('metadata');
361   var dataStore = transaction.objectStore('data');
363   var cacheSize = null;
364   var cacheSizeReceived = false;
365   var metadataEntry = null;
366   var metadataReceived = false;
368   var onPartialSuccess = function() {
369     if (!cacheSizeReceived || !metadataReceived)
370       return;
372     // If either cache size or metadata entry is not available, then it is
373     // an error.
374     if (cacheSize === null || !metadataEntry) {
375       if (opt_onFailure)
376         opt_onFailure();
377       return;
378     }
380     if (opt_onSuccess)
381       opt_onSuccess();
383     this.setCacheSize_(cacheSize - metadataEntry.size, transaction);
384     metadataStore.delete(key);  // Delete asynchronously.
385     dataStore.delete(key);  // Delete asynchronously.
386   }.bind(this);
388   var onCacheSizeFailure = function() {
389     cacheSizeReceived = true;
390   };
392   var onCacheSizeSuccess = function(result) {
393     cacheSize = result;
394     cacheSizeReceived = true;
395     onPartialSuccess();
396   };
398   // Fetch the current cache size.
399   this.fetchCacheSize_(onCacheSizeSuccess, onCacheSizeFailure, transaction);
401   // Receive image's metadata.
402   var metadataRequest = metadataStore.get(key);
404   metadataRequest.onsuccess = function(e) {
405     if (e.target.result)
406       metadataEntry = e.target.result;
407     metadataReceived = true;
408     onPartialSuccess();
409   };
411   metadataRequest.onerror = function() {
412     console.error('Failed to remove an image.');
413     metadataReceived = true;
414     onPartialSuccess();
415   };