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