blob: 415cf93f5c40da1129ea0f86c1c7099d2acf0195 [file] [log] [blame]
// Copyright (c) 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.
/**
* The drive mount path used in the storage. It must be '/drive'.
* @type {string}
*/
var STORED_DRIVE_MOUNT_PATH = '/drive';
/**
* Model for the folder shortcuts. This object is cr.ui.ArrayDataModel-like
* object with additional methods for the folder shortcut feature.
* This uses chrome.storage as backend. Items are always sorted by URL.
*
* @param {VolumeManagerWrapper} volumeManager Volume manager instance.
* @constructor
* @extends {cr.EventTarget}
*/
function FolderShortcutsDataModel(volumeManager) {
this.volumeManager_ = volumeManager;
this.array_ = [];
this.pendingPaths_ = {}; // Hash map for easier deleting.
this.unresolvablePaths_ = {};
this.lastDriveRootURL_ = null;
// Queue to serialize resolving entries.
this.queue_ = new AsyncUtil.Queue();
this.queue_.run(
this.volumeManager_.ensureInitialized.bind(this.volumeManager_));
// Load the shortcuts. Runs within the queue.
this.load_();
// Listening for changes in the storage.
chrome.storage.onChanged.addListener(function(changes, namespace) {
if (!(FolderShortcutsDataModel.NAME in changes) || namespace !== 'sync')
return;
this.reload_(); // Runs within the queue.
}.bind(this));
// If the volume info list is changed, then shortcuts have to be reloaded.
this.volumeManager_.volumeInfoList.addEventListener(
'permuted', this.reload_.bind(this));
// If the drive status has changed, then shortcuts have to be re-resolved.
this.volumeManager_.addEventListener(
'drive-connection-changed', this.reload_.bind(this));
}
/**
* Key name in chrome.storage. The array are stored with this name.
* @type {string}
* @const
*/
FolderShortcutsDataModel.NAME = 'folder-shortcuts-list';
FolderShortcutsDataModel.prototype = {
__proto__: cr.EventTarget.prototype,
/**
* @return {number} Number of elements in the array.
*/
get length() {
return this.array_.length;
},
/**
* Remembers the Drive volume's root URL used for conversions between virtual
* paths and URLs.
* @private
*/
rememberLastDriveURL_: function() {
if (this.lastDriveRootURL_)
return;
var volumeInfo = this.volumeManager_.getCurrentProfileVolumeInfo(
VolumeManagerCommon.VolumeType.DRIVE);
if (volumeInfo)
this.lastDriveRootURL_ = volumeInfo.fileSystem.root.toURL();
},
/**
* Resolves Entries from a list of stored virtual paths. Runs within a queue.
* @param {Array.<string>} list List of virtual paths.
* @private
*/
processEntries_: function(list) {
this.queue_.run(function(callback) {
this.pendingPaths_ = {};
this.unresolvablePaths_ = {};
list.forEach(function(path) {
this.pendingPaths_[path] = true;
}, this);
callback();
}.bind(this));
this.queue_.run(function(queueCallback) {
var volumeInfo = this.volumeManager_.getCurrentProfileVolumeInfo(
VolumeManagerCommon.VolumeType.DRIVE);
var changed = false;
var resolvedURLs = {};
this.rememberLastDriveURL_(); // Required for conversions.
var onResolveSuccess = function(path, entry) {
if (path in this.pendingPaths_)
delete this.pendingPaths_[path];
if (path in this.unresolvablePaths_) {
changed = true;
delete this.unresolvablePaths_[path];
}
if (!this.exists(entry)) {
changed = true;
this.addInternal_(entry);
}
resolvedURLs[entry.toURL()] = true;
}.bind(this);
var onResolveFailure = function(path, url) {
if (path in this.pendingPaths_)
delete this.pendingPaths_[path];
var existingIndex = this.getIndexByURL_(url);
if (existingIndex !== -1) {
changed = true;
this.removeInternal_(this.item(existingIndex));
}
// Remove the shortcut on error, only if Drive is fully online.
// Only then we can be sure, that the error means that the directory
// does not exist anymore.
if (!volumeInfo ||
this.volumeManager_.getDriveConnectionState().type !==
VolumeManagerCommon.DriveConnectionType.ONLINE) {
if (!this.unresolvablePaths_[path]) {
changed = true;
this.unresolvablePaths_[path] = true;
}
}
// Not adding to the model nor to the |unresolvablePaths_| means
// that it will be removed from the storage permanently after the
// next call to save_().
}.bind(this);
// Resolve the items all at once, in parallel.
var group = new AsyncUtil.Group();
list.forEach(function(path) {
group.add(function(path, callback) {
var url =
this.lastDriveRootURL_ && this.convertStoredPathToUrl_(path);
if (url && volumeInfo) {
webkitResolveLocalFileSystemURL(
url,
function(entry) {
onResolveSuccess(path, entry);
callback();
},
function() {
onResolveFailure(path, url);
callback();
});
} else {
onResolveFailure(path, url);
callback();
}
}.bind(this, path));
}, this);
// Save the model after finishing.
group.run(function() {
// Remove all of those old entries, which were resolved by this method.
var index = 0;
while (index < this.length) {
var entry = this.item(index);
if (!resolvedURLs[entry.toURL()]) {
this.removeInternal_(entry);
changed = true;
} else {
index++;
}
}
// If something changed, then save.
if (changed)
this.save_();
queueCallback();
}.bind(this));
}.bind(this));
},
/**
* Initializes the model and loads the shortcuts.
* @private
*/
load_: function() {
this.queue_.run(function(callback) {
chrome.storage.sync.get(FolderShortcutsDataModel.NAME, function(value) {
var shortcutPaths = value[FolderShortcutsDataModel.NAME] || [];
// Record metrics.
metrics.recordSmallCount('FolderShortcut.Count', shortcutPaths.length);
// Resolve and add the entries to the model.
this.processEntries_(shortcutPaths); // Runs within a queue.
callback();
}.bind(this));
}.bind(this));
},
/**
* Reloads the model and loads the shortcuts.
* @private
*/
reload_: function(ev) {
var shortcutPaths;
this.queue_.run(function(callback) {
chrome.storage.sync.get(FolderShortcutsDataModel.NAME, function(value) {
var shortcutPaths = value[FolderShortcutsDataModel.NAME] || [];
this.processEntries_(shortcutPaths); // Runs within a queue.
callback();
}.bind(this));
}.bind(this));
},
/**
* Returns the entries in the given range as a new array instance. The
* arguments and return value are compatible with Array.slice().
*
* @param {number} begin Where to start the selection.
* @param {number=} opt_end Where to end the selection.
* @return {Array.<Entry>} Entries in the selected range.
*/
slice: function(begin, opt_end) {
return this.array_.slice(begin, opt_end);
},
/**
* @param {number} index Index of the element to be retrieved.
* @return {Entry} The value of the |index|-th element.
*/
item: function(index) {
return this.array_[index];
},
/**
* @param {string} value URL of the entry to be found.
* @return {number} Index of the element with the specified |value|.
* @private
*/
getIndexByURL_: function(value) {
for (var i = 0; i < this.length; i++) {
// Same item check: must be exact match.
if (this.array_[i].toURL() === value)
return i;
}
return -1;
},
/**
* @param {Entry} value Value of the element to be retrieved.
* @return {number} Index of the element with the specified |value|.
*/
getIndex: function(value) {
for (var i = 0; i < this.length; i++) {
// Same item check: must be exact match.
if (util.isSameEntry(this.array_[i], value))
return i;
}
return -1;
},
/**
* Compares 2 entries and returns a number indicating one entry comes before
* or after or is the same as the other entry in sort order.
*
* @param {Entry} a First entry.
* @param {Entry} b Second entry.
* @return {number} Returns -1, if |a| < |b|. Returns 0, if |a| === |b|.
* Otherwise, returns 1.
*/
compare: function(a, b) {
return util.comparePath(a, b);
},
/**
* Adds the given item to the array. If there were already same item in the
* list, return the index of the existing item without adding a duplicate
* item.
*
* @param {Entry} value Value to be added into the array.
* @return {number} Index in the list which the element added to.
*/
add: function(value) {
var result = this.addInternal_(value);
metrics.recordUserAction('FolderShortcut.Add');
this.save_();
return result;
},
/**
* Adds the given item to the array. If there were already same item in the
* list, return the index of the existing item without adding a duplicate
* item.
*
* @param {Entry} value Value to be added into the array.
* @return {number} Index in the list which the element added to.
* @private
*/
addInternal_: function(value) {
this.rememberLastDriveURL_(); // Required for saving.
var oldArray = this.array_.slice(0); // Shallow copy.
var addedIndex = -1;
for (var i = 0; i < this.length; i++) {
// Same item check: must be exact match.
if (util.isSameEntry(this.array_[i], value))
return i;
// Since the array is sorted, new item will be added just before the first
// larger item.
if (this.compare(this.array_[i], value) >= 0) {
this.array_.splice(i, 0, value);
addedIndex = i;
break;
}
}
// If value is not added yet, add it at the last.
if (addedIndex == -1) {
this.array_.push(value);
addedIndex = this.length;
}
this.firePermutedEvent_(
this.calculatePermutation_(oldArray, this.array_));
return addedIndex;
},
/**
* Removes the given item from the array.
* @param {Entry} value Value to be removed from the array.
* @return {number} Index in the list which the element removed from.
*/
remove: function(value) {
var result = this.removeInternal_(value);
if (result !== -1) {
this.save_();
metrics.recordUserAction('FolderShortcut.Remove');
}
return result;
},
/**
* Removes the given item from the array.
*
* @param {Entry} value Value to be removed from the array.
* @return {number} Index in the list which the element removed from.
* @private
*/
removeInternal_: function(value) {
var removedIndex = -1;
var oldArray = this.array_.slice(0); // Shallow copy.
for (var i = 0; i < this.length; i++) {
// Same item check: must be exact match.
if (util.isSameEntry(this.array_[i], value)) {
this.array_.splice(i, 1);
removedIndex = i;
break;
}
}
if (removedIndex !== -1) {
this.firePermutedEvent_(
this.calculatePermutation_(oldArray, this.array_));
return removedIndex;
}
// No item is removed.
return -1;
},
/**
* @param {Entry} entry Entry to be checked.
* @return {boolean} True if the given |entry| exists in the array. False
* otherwise.
*/
exists: function(entry) {
var index = this.getIndex(entry);
return (index >= 0);
},
/**
* Saves the current array to chrome.storage.
* @private
*/
save_: function() {
this.rememberLastDriveURL_();
if (!this.lastDriveRootURL_)
return;
// TODO(mtomasz): Migrate to URL.
var paths = this.array_.
map(function(entry) { return entry.toURL(); }).
map(this.convertUrlToStoredPath_.bind(this)).
concat(Object.keys(this.pendingPaths_)).
concat(Object.keys(this.unresolvablePaths_));
var prefs = {};
prefs[FolderShortcutsDataModel.NAME] = paths;
chrome.storage.sync.set(prefs, function() {});
},
/**
* Creates a permutation array for 'permuted' event, which is compatible with
* a permutation array used in cr/ui/array_data_model.js.
*
* @param {Array.<Entry>} oldArray Previous array before changing.
* @param {Array.<Entry>} newArray New array after changing.
* @return {Array.<number>} Created permutation array.
* @private
*/
calculatePermutation_: function(oldArray, newArray) {
var oldIndex = 0; // Index of oldArray.
var newIndex = 0; // Index of newArray.
// Note that both new and old arrays are sorted.
var permutation = [];
for (; oldIndex < oldArray.length; oldIndex++) {
if (newIndex >= newArray.length) {
// oldArray[oldIndex] is deleted, which is not in the new array.
permutation[oldIndex] = -1;
continue;
}
while (newIndex < newArray.length) {
// Unchanged item, which exists in both new and old array. But the
// index may be changed.
if (util.isSameEntry(oldArray[oldIndex], newArray[newIndex])) {
permutation[oldIndex] = newIndex;
newIndex++;
break;
}
// oldArray[oldIndex] is deleted, which is not in the new array.
if (this.compare(oldArray[oldIndex], newArray[newIndex]) < 0) {
permutation[oldIndex] = -1;
break;
}
// In the case of this.compare(oldArray[oldIndex]) > 0:
// newArray[newIndex] is added, which is not in the old array.
newIndex++;
}
}
return permutation;
},
/**
* Fires a 'permuted' event, which is compatible with cr.ui.ArrayDataModel.
* @param {Array.<number>} permutation Permutation array.
*/
firePermutedEvent_: function(permutation) {
var permutedEvent = new Event('permuted');
permutedEvent.newLength = this.length;
permutedEvent.permutation = permutation;
this.dispatchEvent(permutedEvent);
// Note: This model only fires 'permuted' event, because:
// 1) 'change' event is not necessary to fire since it is covered by
// 'permuted' event.
// 2) 'splice' and 'sorted' events are not implemented. These events are
// not used in NavigationListModel. We have to implement them when
// necessary.
},
/**
* Called externally when one of the items is not found on the filesystem.
* @param {Entry} entry The entry which is not found.
*/
onItemNotFoundError: function(entry) {
// If Drive is online, then delete the shortcut permanently. Otherwise,
// delete from model and add to |unresolvablePaths_|.
if (this.volumeManager_.getDriveConnectionState().type !==
VolumeManagerCommon.DriveConnectionType.ONLINE) {
var path = this.convertUrlToStoredPath_(entry.toURL());
// TODO(mtomasz): Add support for multi-profile.
this.unresolvablePaths_[path] = true;
}
this.removeInternal_(entry);
this.save_();
},
/**
* Converts the given "stored path" to the URL.
*
* This conversion is necessary because the shortcuts are not stored with
* stored-formatted mount paths for compatibility. See http://crbug.com/336155
* for detail.
*
* @param {string} path Path in Drive with the stored drive mount path.
* @return {?string} URL of the given path.
* @private
*/
convertStoredPathToUrl_: function(path) {
if (path.indexOf(STORED_DRIVE_MOUNT_PATH + '/') !== 0) {
console.warn(path + ' is neither a drive mount path nor a stored path.');
return null;
}
return this.lastDriveRootURL_ + encodeURIComponent(
path.substr(STORED_DRIVE_MOUNT_PATH.length));
},
/**
* Converts the URL to the stored-formatted path.
*
* See the comment of convertStoredPathToUrl_() for further information.
*
* @param {string} url URL of the directory in Drive.
* @return {?string} Path with the stored drive mount path.
* @private
*/
convertUrlToStoredPath_: function(url) {
// Root URLs contain a trailing slash.
if (url.indexOf(this.lastDriveRootURL_) !== 0) {
console.warn(url + ' is not a drive URL.');
return null;
}
return STORED_DRIVE_MOUNT_PATH + '/' + decodeURIComponent(
url.substr(this.lastDriveRootURL_.length));
},
};