Upstream version 7.35.144.0
[platform/framework/web/crosswalk.git] / src / chrome / browser / resources / file_manager / foreground / js / folder_shortcuts_data_model.js
1 // Copyright (c) 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.
4
5 /**
6  * The drive mount path used in the storage. It must be '/drive'.
7  * @type {string}
8  */
9 var STORED_DRIVE_MOUNT_PATH = '/drive';
10
11 /**
12  * Model for the folder shortcuts. This object is cr.ui.ArrayDataModel-like
13  * object with additional methods for the folder shortcut feature.
14  * This uses chrome.storage as backend. Items are always sorted by URL.
15  *
16  * @param {VolumeManagerWrapper} volumeManager Volume manager instance.
17  * @constructor
18  * @extends {cr.EventTarget}
19  */
20 function FolderShortcutsDataModel(volumeManager) {
21   this.volumeManager_ = volumeManager;
22   this.array_ = [];
23   this.pendingPaths_ = {};  // Hash map for easier deleting.
24   this.unresolvablePaths_ = {};
25   this.lastDriveRootURL_ = null;
26
27   // Queue to serialize resolving entries.
28   this.queue_ = new AsyncUtil.Queue();
29   this.queue_.run(this.volumeManager_.ensureInitialized.bind(this));
30
31   // Load the shortcuts. Runs within the queue.
32   this.load_();
33
34   // Listening for changes in the storage.
35   chrome.storage.onChanged.addListener(function(changes, namespace) {
36     if (!(FolderShortcutsDataModel.NAME in changes) || namespace !== 'sync')
37       return;
38     this.reload_();  // Runs within the queue.
39   }.bind(this));
40
41   // If the volume info list is changed, then shortcuts have to be reloaded.
42   this.volumeManager_.volumeInfoList.addEventListener(
43       'permuted', this.reload_.bind(this));
44
45   // If the drive status has changed, then shortcuts have to be re-resolved.
46   this.volumeManager_.addEventListener(
47       'drive-connection-changed', this.reload_.bind(this));
48 }
49
50 /**
51  * Key name in chrome.storage. The array are stored with this name.
52  * @type {string}
53  * @const
54  */
55 FolderShortcutsDataModel.NAME = 'folder-shortcuts-list';
56
57 FolderShortcutsDataModel.prototype = {
58   __proto__: cr.EventTarget.prototype,
59
60   /**
61    * @return {number} Number of elements in the array.
62    */
63   get length() {
64     return this.array_.length;
65   },
66
67   /**
68    * Remembers the Drive volume's root URL used for conversions between virtual
69    * paths and URLs.
70    * @private
71    */
72   rememberLastDriveURL_: function() {
73     if (this.lastDriveRootURL_)
74       return;
75     var volumeInfo = this.volumeManager_.getCurrentProfileVolumeInfo(
76         util.VolumeType.DRIVE);
77     if (volumeInfo)
78       this.lastDriveRootURL_ = volumeInfo.fileSystem.root.toURL();
79   },
80
81   /**
82    * Resolves Entries from a list of stored virtual paths. Runs within a queue.
83    * @param {Array.<string>} list List of virtual paths.
84    * @private
85    */
86   processEntries_: function(list) {
87     this.queue_.run(function(callback) {
88       this.pendingPaths_ = {};
89       this.unresolvablePaths_ = {};
90       list.forEach(function(path) {
91         this.pendingPaths_[path] = true;
92       }, this);
93       callback();
94     }.bind(this));
95
96     this.queue_.run(function(queueCallback) {
97       var volumeInfo = this.volumeManager_.getCurrentProfileVolumeInfo(
98           util.VolumeType.DRIVE);
99       var changed = false;
100       var resolvedURLs = {};
101       this.rememberLastDriveURL_();  // Required for conversions.
102
103       var onResolveSuccess = function(path, entry) {
104         if (path in this.pendingPaths_)
105           delete this.pendingPaths_[path];
106         if (path in this.unresolvablePaths_) {
107           changed = true;
108           delete this.unresolvablePaths_[path];
109         }
110         if (!this.exists(entry)) {
111           changed = true;
112           this.addInternal_(entry);
113         }
114         resolvedURLs[entry.toURL()] = true;
115       }.bind(this);
116
117       var onResolveFailure = function(path, url) {
118         if (path in this.pendingPaths_)
119           delete this.pendingPaths_[path];
120         var existingIndex = this.getIndexByURL_(url);
121         if (existingIndex !== -1) {
122           changed = true;
123           this.removeInternal_(this.item(existingIndex));
124         }
125         // Remove the shortcut on error, only if Drive is fully online.
126         // Only then we can be sure, that the error means that the directory
127         // does not exist anymore.
128         if (!volumeInfo ||
129             this.volumeManager_.getDriveConnectionState().type !==
130                 util.DriveConnectionType.ONLINE) {
131           if (!this.unresolvablePaths_[path]) {
132             changed = true;
133             this.unresolvablePaths_[path] = true;
134           }
135         }
136         // Not adding to the model nor to the |unresolvablePaths_| means
137         // that it will be removed from the storage permanently after the
138         // next call to save_().
139       }.bind(this);
140
141       // Resolve the items all at once, in parallel.
142       var group = new AsyncUtil.Group();
143       list.forEach(function(path) {
144         group.add(function(path, callback) {
145           var url =
146               this.lastDriveRootURL_ && this.convertStoredPathToUrl_(path);
147           if (url && volumeInfo) {
148             webkitResolveLocalFileSystemURL(
149                 url,
150                 function(entry) {
151                   onResolveSuccess(path, entry);
152                   callback();
153                 },
154                 function() {
155                   onResolveFailure(path, url);
156                   callback();
157                 });
158           } else {
159             onResolveFailure(path, url);
160             callback();
161           }
162         }.bind(this, path));
163       }, this);
164
165       // Save the model after finishing.
166       group.run(function() {
167         // Remove all of those old entries, which were resolved by this method.
168         var index = 0;
169         while (index < this.length) {
170           var entry = this.item(index);
171           if (!resolvedURLs[entry.toURL()]) {
172             this.removeInternal_(entry);
173             changed = true;
174           } else {
175             index++;
176           }
177         }
178         // If something changed, then save.
179         if (changed)
180           this.save_();
181         queueCallback();
182       }.bind(this));
183     }.bind(this));
184   },
185
186   /**
187    * Initializes the model and loads the shortcuts.
188    * @private
189    */
190   load_: function() {
191     this.queue_.run(function(callback) {
192       chrome.storage.sync.get(FolderShortcutsDataModel.NAME, function(value) {
193         var shortcutPaths = value[FolderShortcutsDataModel.NAME] || [];
194
195         // Record metrics.
196         metrics.recordSmallCount('FolderShortcut.Count', shortcutPaths.length);
197
198         // Resolve and add the entries to the model.
199         this.processEntries_(shortcutPaths);  // Runs within a queue.
200         callback();
201       }.bind(this));
202     }.bind(this));
203   },
204
205   /**
206    * Reloads the model and loads the shortcuts.
207    * @private
208    */
209   reload_: function(ev) {
210     var shortcutPaths;
211     this.queue_.run(function(callback) {
212       chrome.storage.sync.get(FolderShortcutsDataModel.NAME, function(value) {
213         var shortcutPaths = value[FolderShortcutsDataModel.NAME] || [];
214         this.processEntries_(shortcutPaths);  // Runs within a queue.
215         callback();
216       }.bind(this));
217     }.bind(this));
218   },
219
220   /**
221    * Returns the entries in the given range as a new array instance. The
222    * arguments and return value are compatible with Array.slice().
223    *
224    * @param {number} start Where to start the selection.
225    * @param {number=} opt_end Where to end the selection.
226    * @return {Array.<Entry>} Entries in the selected range.
227    */
228   slice: function(begin, opt_end) {
229     return this.array_.slice(begin, opt_end);
230   },
231
232   /**
233    * @param {number} index Index of the element to be retrieved.
234    * @return {Entry} The value of the |index|-th element.
235    */
236   item: function(index) {
237     return this.array_[index];
238   },
239
240   /**
241    * @param {string} value URL of the entry to be found.
242    * @return {number} Index of the element with the specified |value|.
243    * @private
244    */
245   getIndexByURL_: function(value) {
246     for (var i = 0; i < this.length; i++) {
247       // Same item check: must be exact match.
248       if (this.array_[i].toURL() === value)
249         return i;
250     }
251     return -1;
252   },
253
254   /**
255    * @param {Entry} value Value of the element to be retrieved.
256    * @return {number} Index of the element with the specified |value|.
257    */
258   getIndex: function(value) {
259     for (var i = 0; i < this.length; i++) {
260       // Same item check: must be exact match.
261       if (util.isSameEntry(this.array_[i], value))
262         return i;
263     }
264     return -1;
265   },
266
267   /**
268    * Compares 2 entries and returns a number indicating one entry comes before
269    * or after or is the same as the other entry in sort order.
270    *
271    * @param {Entry} a First entry.
272    * @param {Entry} b Second entry.
273    * @return {boolean} Returns -1, if |a| < |b|. Returns 0, if |a| === |b|.
274    *     Otherwise, returns 1.
275    */
276   compare: function(a, b) {
277     return a.toURL().localeCompare(
278         b.toURL(),
279         undefined,  // locale parameter, use default locale.
280         {usage: 'sort', numeric: true});
281   },
282
283   /**
284    * Adds the given item to the array. If there were already same item in the
285    * list, return the index of the existing item without adding a duplicate
286    * item.
287    *
288    * @param {Entry} value Value to be added into the array.
289    * @return {number} Index in the list which the element added to.
290    */
291   add: function(value) {
292     var result = this.addInternal_(value);
293     metrics.recordUserAction('FolderShortcut.Add');
294     this.save_();
295     return result;
296   },
297
298   /**
299    * Adds the given item to the array. If there were already same item in the
300    * list, return the index of the existing item without adding a duplicate
301    * item.
302    *
303    * @param {Entry} value Value to be added into the array.
304    * @return {number} Index in the list which the element added to.
305    * @private
306    */
307   addInternal_: function(value) {
308     this.rememberLastDriveURL_();  // Required for saving.
309
310     var oldArray = this.array_.slice(0);  // Shallow copy.
311     var addedIndex = -1;
312     for (var i = 0; i < this.length; i++) {
313       // Same item check: must be exact match.
314       if (util.isSameEntry(this.array_[i], value))
315         return i;
316
317       // Since the array is sorted, new item will be added just before the first
318       // larger item.
319       if (this.compare(this.array_[i], value) >= 0) {
320         this.array_.splice(i, 0, value);
321         addedIndex = i;
322         break;
323       }
324     }
325     // If value is not added yet, add it at the last.
326     if (addedIndex == -1) {
327       this.array_.push(value);
328       addedIndex = this.length;
329     }
330
331     this.firePermutedEvent_(
332         this.calculatePermutation_(oldArray, this.array_));
333     return addedIndex;
334   },
335
336   /**
337    * Removes the given item from the array.
338    * @param {Entry} value Value to be removed from the array.
339    * @return {number} Index in the list which the element removed from.
340    */
341   remove: function(value) {
342     var result = this.removeInternal_(value);
343     if (result !== -1) {
344       this.save_();
345       metrics.recordUserAction('FolderShortcut.Remove');
346     }
347     return result;
348   },
349
350   /**
351    * Removes the given item from the array.
352    *
353    * @param {Entry} value Value to be removed from the array.
354    * @return {number} Index in the list which the element removed from.
355    * @private
356    */
357   removeInternal_: function(value) {
358     var removedIndex = -1;
359     var oldArray = this.array_.slice(0);  // Shallow copy.
360     for (var i = 0; i < this.length; i++) {
361       // Same item check: must be exact match.
362       if (util.isSameEntry(this.array_[i], value)) {
363         this.array_.splice(i, 1);
364         removedIndex = i;
365         break;
366       }
367     }
368
369     if (removedIndex !== -1) {
370       this.firePermutedEvent_(
371           this.calculatePermutation_(oldArray, this.array_));
372       return removedIndex;
373     }
374
375     // No item is removed.
376     return -1;
377   },
378
379   /**
380    * @param {Entry} entry Entry to be checked.
381    * @return {boolean} True if the given |entry| exists in the array. False
382    *     otherwise.
383    */
384   exists: function(entry) {
385     var index = this.getIndex(entry);
386     return (index >= 0);
387   },
388
389   /**
390    * Saves the current array to chrome.storage.
391    * @private
392    */
393   save_: function() {
394     // The current implementation doesn't rely on sort order in prefs, however
395     // older versions do. Therefore, we need to sort the paths before saving.
396     // TODO(mtomasz): Remove sorting prefs after M-34 is stable.
397     // crbug.com/333148
398     var compareByPath = function(a, b) {
399       return a.localeCompare(
400           b,
401           undefined,  // locale parameter, use default locale.
402           {usage: 'sort', numeric: true});
403     };
404
405     this.rememberLastDriveURL_();
406     if (!this.lastDriveRootURL_)
407       return;
408
409     // TODO(mtomasz): Migrate to URL.
410     var paths = this.array_.
411                 map(function(entry) { return entry.toURL(); }).
412                 map(this.convertUrlToStoredPath_.bind(this)).
413                 concat(Object.keys(this.pendingPaths_)).
414                 concat(Object.keys(this.unresolvablePaths_)).
415                 sort(compareByPath);
416
417     var prefs = {};
418     prefs[FolderShortcutsDataModel.NAME] = paths;
419     chrome.storage.sync.set(prefs, function() {});
420   },
421
422   /**
423    * Creates a permutation array for 'permuted' event, which is compatible with
424    * a permutation array used in cr/ui/array_data_model.js.
425    *
426    * @param {array} oldArray Previous array before changing.
427    * @param {array} newArray New array after changing.
428    * @return {Array.<number>} Created permutation array.
429    * @private
430    */
431   calculatePermutation_: function(oldArray, newArray) {
432     var oldIndex = 0;  // Index of oldArray.
433     var newIndex = 0;  // Index of newArray.
434
435     // Note that both new and old arrays are sorted.
436     var permutation = [];
437     for (; oldIndex < oldArray.length; oldIndex++) {
438       if (newIndex >= newArray.length) {
439         // oldArray[oldIndex] is deleted, which is not in the new array.
440         permutation[oldIndex] = -1;
441         continue;
442       }
443
444       while (newIndex < newArray.length) {
445         // Unchanged item, which exists in both new and old array. But the
446         // index may be changed.
447         if (util.isSameEntry(oldArray[oldIndex], newArray[newIndex])) {
448           permutation[oldIndex] = newIndex;
449           newIndex++;
450           break;
451         }
452
453         // oldArray[oldIndex] is deleted, which is not in the new array.
454         if (this.compare(oldArray[oldIndex], newArray[newIndex]) < 0) {
455           permutation[oldIndex] = -1;
456           break;
457         }
458
459         // In the case of this.compare(oldArray[oldIndex]) > 0:
460         // newArray[newIndex] is added, which is not in the old array.
461         newIndex++;
462       }
463     }
464     return permutation;
465   },
466
467   /**
468    * Fires a 'permuted' event, which is compatible with cr.ui.ArrayDataModel.
469    * @param {Array.<number>} Permutation array.
470    */
471   firePermutedEvent_: function(permutation) {
472     var permutedEvent = new Event('permuted');
473     permutedEvent.newLength = this.length;
474     permutedEvent.permutation = permutation;
475     this.dispatchEvent(permutedEvent);
476
477     // Note: This model only fires 'permuted' event, because:
478     // 1) 'change' event is not necessary to fire since it is covered by
479     //    'permuted' event.
480     // 2) 'splice' and 'sorted' events are not implemented. These events are
481     //    not used in NavigationListModel. We have to implement them when
482     //    necessary.
483   },
484
485   /**
486    * Called externally when one of the items is not found on the filesystem.
487    * @param {Entry} entry The entry which is not found.
488    */
489   onItemNotFoundError: function(entry) {
490     // If Drive is online, then delete the shortcut permanently. Otherwise,
491     // delete from model and add to |unresolvablePaths_|.
492     if (this.volumeManager_.getDriveConnectionState().type !==
493         util.DriveConnectionType.ONLINE) {
494       var path = this.convertUrlToStoredPath_(entry.toURL());
495       // TODO(mtomasz): Add support for multi-profile.
496       this.unresolvablePaths_[path] = true;
497     }
498     this.removeInternal_(entry);
499     this.save_();
500   },
501
502   /**
503    * Converts the given "stored path" to the URL.
504    *
505    * This conversion is necessary because the shortcuts are not stored with
506    * stored-formatted mount paths for compatibility. See http://crbug.com/336155
507    * for detail.
508    *
509    * @param {string} path Path in Drive with the stored drive mount path.
510    * @return {string} URL of the given path.
511    * @private
512    */
513   convertStoredPathToUrl_: function(path) {
514     if (path.indexOf(STORED_DRIVE_MOUNT_PATH + '/') !== 0) {
515       console.warn(path + ' is neither a drive mount path nor a stored path.');
516       return null;
517     }
518     return this.lastDriveRootURL_ + encodeURIComponent(
519         path.substr(STORED_DRIVE_MOUNT_PATH.length));
520   },
521
522   /**
523    * Converts the URL to the stored-formatted path.
524    *
525    * See the comment of convertStoredPathToUrl_() for further information.
526    *
527    * @param {string} url URL of the directory in Drive.
528    * @return {string} Path with the stored drive mount path.
529    * @private
530    */
531   convertUrlToStoredPath_: function(url) {
532     // Root URLs contain a trailing slash.
533     if (url.indexOf(this.lastDriveRootURL_) !== 0) {
534       console.warn(url + ' is not a drive URL.');
535       return null;
536     }
537
538     return STORED_DRIVE_MOUNT_PATH + '/' + decodeURIComponent(
539         url.substr(this.lastDriveRootURL_.length));
540   },
541 };