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