1// Copyright (c) 2012 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'use strict'; 6 7document.addEventListener('DOMContentLoaded', function() { 8 PhotoImport.load(); 9}); 10 11/** 12 * The main Photo App object. 13 * @param {HTMLElement} dom Container. 14 * @param {FileSystem} filesystem Local file system. 15 * @param {Object} params Parameters. 16 * @constructor 17 */ 18function PhotoImport(dom, filesystem, params) { 19 this.filesystem_ = filesystem; 20 this.dom_ = dom; 21 this.document_ = this.dom_.ownerDocument; 22 this.metadataCache_ = params.metadataCache; 23 this.volumeManager_ = new VolumeManager(); 24 this.copyManager_ = FileCopyManagerWrapper.getInstance(this.filesystem_.root); 25 this.mediaFilesList_ = null; 26 this.destination_ = null; 27 this.myPhotosDirectory_ = null; 28 this.parentWindowId_ = params.parentWindowId; 29 30 this.initDom_(); 31 this.initMyPhotos_(); 32 this.loadSource_(params.source); 33} 34 35PhotoImport.prototype = { __proto__: cr.EventTarget.prototype }; 36 37/** 38 * Single item width. 39 * Keep in sync with .grid-item rule in photo_import.css. 40 */ 41PhotoImport.ITEM_WIDTH = 164 + 8; 42 43/** 44 * Number of tries in creating a destination directory. 45 */ 46PhotoImport.CREATE_DESTINATION_TRIES = 100; 47 48/** 49 * Loads app in the document body. 50 * @param {FileSystem=} opt_filesystem Local file system. 51 * @param {Object=} opt_params Parameters. 52 */ 53PhotoImport.load = function(opt_filesystem, opt_params) { 54 ImageUtil.metrics = metrics; 55 56 var hash = location.hash ? location.hash.substr(1) : ''; 57 var query = location.search ? location.search.substr(1) : ''; 58 var params = opt_params || {}; 59 if (!params.source) params.source = hash; 60 if (!params.parentWindowId && query) params.parentWindowId = query; 61 if (!params.metadataCache) params.metadataCache = MetadataCache.createFull(); 62 63 function onFilesystem(filesystem) { 64 var dom = document.querySelector('.photo-import'); 65 new PhotoImport(dom, filesystem, params); 66 } 67 68 var api = chrome.fileBrowserPrivate || window.top.chrome.fileBrowserPrivate; 69 api.getStrings(function(strings) { 70 loadTimeData.data = strings; 71 72 if (opt_filesystem) { 73 onFilesystem(opt_filesystem); 74 } else { 75 api.requestFileSystem(onFilesystem); 76 } 77 }); 78}; 79 80/** 81 * One-time initialization of dom elements. 82 * @private 83 */ 84PhotoImport.prototype.initDom_ = function() { 85 this.dom_.setAttribute('loading', ''); 86 this.dom_.ownerDocument.defaultView.addEventListener( 87 'resize', this.onResize_.bind(this)); 88 89 this.spinner_ = this.dom_.querySelector('.spinner'); 90 91 this.document_.querySelector('title').textContent = 92 loadTimeData.getString('PHOTO_IMPORT_TITLE'); 93 this.dom_.querySelector('.caption').textContent = 94 loadTimeData.getString('PHOTO_IMPORT_CAPTION'); 95 this.selectAllNone_ = this.dom_.querySelector('.select'); 96 this.selectAllNone_.addEventListener('click', 97 this.onSelectAllNone_.bind(this)); 98 99 this.dom_.querySelector('label[for=delete-after-checkbox]').textContent = 100 loadTimeData.getString('PHOTO_IMPORT_DELETE_AFTER'); 101 this.selectedCount_ = this.dom_.querySelector('.selected-count'); 102 103 this.importButton_ = this.dom_.querySelector('button.import'); 104 this.importButton_.textContent = 105 loadTimeData.getString('PHOTO_IMPORT_IMPORT_BUTTON'); 106 this.importButton_.addEventListener('click', this.onImportClick_.bind(this)); 107 108 this.cancelButton_ = this.dom_.querySelector('button.cancel'); 109 this.cancelButton_.textContent = str('CANCEL_LABEL'); 110 this.cancelButton_.addEventListener('click', this.onCancelClick_.bind(this)); 111 112 this.grid_ = this.dom_.querySelector('grid'); 113 cr.ui.Grid.decorate(this.grid_); 114 this.grid_.itemConstructor = GridItem.bind(null, this); 115 this.fileList_ = new cr.ui.ArrayDataModel([]); 116 this.grid_.selectionModel = new cr.ui.ListSelectionModel(); 117 this.grid_.dataModel = this.fileList_; 118 this.grid_.selectionModel.addEventListener('change', 119 this.onSelectionChanged_.bind(this)); 120 this.onSelectionChanged_(); 121 122 this.importingDialog_ = new ImportingDialog(this.dom_, this.copyManager_, 123 this.metadataCache_, this.parentWindowId_); 124 125 var dialogs = cr.ui.dialogs; 126 dialogs.BaseDialog.OK_LABEL = str('OK_LABEL'); 127 dialogs.BaseDialog.CANCEL_LABEL = str('CANCEL_LABEL'); 128 this.alert_ = new dialogs.AlertDialog(this.dom_); 129}; 130 131/** 132 * One-time initialization of the My Photos directory. 133 * @private 134 */ 135PhotoImport.prototype.initMyPhotos_ = function() { 136 var onError = this.onError_.bind( 137 this, loadTimeData.getString('PHOTO_IMPORT_DRIVE_ERROR')); 138 139 var onDirectory = function(dir) { 140 // This may enable the import button, so check that. 141 this.myPhotosDirectory_ = dir; 142 this.onSelectionChanged_(); 143 }.bind(this); 144 145 var onMounted = function() { 146 var dir = PathUtil.join( 147 RootDirectory.DRIVE, 148 loadTimeData.getString('PHOTO_IMPORT_MY_PHOTOS_DIRECTORY_NAME')); 149 util.getOrCreateDirectory(this.filesystem_.root, dir, onDirectory, onError); 150 }.bind(this); 151 152 if (this.volumeManager_.isMounted(RootDirectory.DRIVE)) { 153 onMounted(); 154 } else { 155 this.volumeManager_.mountDrive(onMounted, onError); 156 } 157}; 158 159/** 160 * Creates the destination directory. 161 * @param {function} onSuccess Callback on success. 162 * @private 163 */ 164PhotoImport.prototype.createDestination_ = function(onSuccess) { 165 var onError = this.onError_.bind( 166 this, loadTimeData.getString('PHOTO_IMPORT_DESTINATION_ERROR')); 167 168 var dateFormatter = Intl.DateTimeFormat( 169 [] /* default locale */, 170 {year: 'numeric', month: 'short', day: 'numeric'}); 171 172 var baseName = PathUtil.join( 173 RootDirectory.DRIVE, 174 loadTimeData.getString('PHOTO_IMPORT_MY_PHOTOS_DIRECTORY_NAME'), 175 dateFormatter.format(new Date())); 176 177 var createDirectory = function(directoryName) { 178 this.filesystem_.root.getDirectory( 179 directoryName, 180 { create: true }, 181 function(dir) { 182 this.destination_ = dir; 183 onSuccess(); 184 }.bind(this), 185 onError); 186 }; 187 188 // Try to create a directory: Name, Name (2), Name (3)... 189 var tryNext = function(tryNumber) { 190 if (tryNumber > PhotoImport.CREATE_DESTINATION_TRIES) { 191 console.error('Too many directories with the same base name exist.'); 192 onError(); 193 return; 194 } 195 var directoryName = baseName; 196 if (tryNumber > 1) 197 directoryName += ' (' + (tryNumber) + ')'; 198 this.filesystem_.root.getDirectory( 199 directoryName, 200 { create: false }, 201 tryNext.bind(this, tryNumber + 1), 202 createDirectory.bind(this, directoryName)); 203 }.bind(this); 204 205 tryNext(1); 206}; 207 208 209/** 210 * Load the source contents. 211 * @param {string} source Path to source. 212 * @private 213 */ 214PhotoImport.prototype.loadSource_ = function(source) { 215 var onTraversed = function(results) { 216 this.dom_.removeAttribute('loading'); 217 this.mediaFilesList_ = results.filter(FileType.isImageOrVideo); 218 this.fillGrid_(); 219 }.bind(this); 220 221 var onEntry = function(entry) { 222 util.traverseTree(entry, onTraversed, 0 /* infinite depth */, 223 FileType.isVisible); 224 }.bind(this); 225 226 var onError = this.onError_.bind( 227 this, loadTimeData.getString('PHOTO_IMPORT_SOURCE_ERROR')); 228 229 util.resolvePath(this.filesystem_.root, source, onEntry, onError); 230}; 231 232/** 233 * Renders files into grid. 234 * @private 235 */ 236PhotoImport.prototype.fillGrid_ = function() { 237 if (!this.mediaFilesList_) return; 238 this.fileList_.splice(0, this.fileList_.length); 239 this.fileList_.push.apply(this.fileList_, this.mediaFilesList_); 240}; 241 242/** 243 * Creates groups for files based on modification date. 244 * @param {Array.<Entry>} files File list. 245 * @param {Object} filesystem Filesystem metadata. 246 * @return {Array.<Object>} List of grouped items. 247 * @private 248 */ 249PhotoImport.prototype.createGroups_ = function(files, filesystem) { 250 var dateFormatter = Intl.DateTimeFormat( 251 [] /* default locale */, 252 {year: 'numeric', month: 'short', day: 'numeric'}); 253 254 var columns = this.grid_.columns; 255 256 var unknownGroup = { 257 type: 'group', 258 date: 0, 259 title: loadTimeData.getString('PHOTO_IMPORT_UNKNOWN_DATE'), 260 items: [] 261 }; 262 263 var groupsMap = {}; 264 265 for (var index = 0; index < files.length; index++) { 266 var props = filesystem[index]; 267 var item = { type: 'entry', entry: files[index] }; 268 269 if (!props || !props.modificationTime) { 270 item.group = unknownGroup; 271 unknownGroup.items.push(item); 272 continue; 273 } 274 275 var date = new Date(props.modificationTime); 276 date.setHours(0); 277 date.setMinutes(0); 278 date.setSeconds(0); 279 date.setMilliseconds(0); 280 281 var time = date.getTime(); 282 if (!(time in groupsMap)) { 283 groupsMap[time] = { 284 type: 'group', 285 date: date, 286 title: dateFormatter.format(date), 287 items: [] 288 }; 289 } 290 291 var group = groupsMap[time]; 292 group.items.push(item); 293 item.group = group; 294 } 295 296 var groups = []; 297 for (var time in groupsMap) { 298 if (groupsMap.hasOwnProperty(time)) { 299 groups.push(groupsMap[time]); 300 } 301 } 302 if (unknownGroup.items.length > 0) 303 groups.push(unknownGroup); 304 305 groups.sort(function(a, b) { 306 return b.date.getTime() - a.date.getTime(); 307 }); 308 309 var list = []; 310 for (var index = 0; index < groups.length; index++) { 311 var group = groups[index]; 312 313 list.push(group); 314 for (var t = 1; t < columns; t++) { 315 list.push({ type: 'empty' }); 316 } 317 318 for (var j = 0; j < group.items.length; j++) { 319 list.push(group.items[j]); 320 } 321 322 var count = group.items.length; 323 while (count % columns != 0) { 324 list.push({ type: 'empty' }); 325 count++; 326 } 327 } 328 329 return list; 330}; 331 332/** 333 * Decorates grid item. 334 * @param {HTMLLIElement} li The list item. 335 * @param {FileEntry} entry The file entry. 336 * @private 337 */ 338PhotoImport.prototype.decorateGridItem_ = function(li, entry) { 339 li.className = 'grid-item'; 340 li.entry = entry; 341 342 var frame = this.document_.createElement('div'); 343 frame.className = 'grid-frame'; 344 li.appendChild(frame); 345 346 var box = this.document_.createElement('div'); 347 box.className = 'img-container'; 348 this.metadataCache_.get(entry, 'thumbnail|filesystem', 349 function(metadata) { 350 new ThumbnailLoader(entry.toURL(), 351 ThumbnailLoader.LoaderType.IMAGE, 352 metadata). 353 load(box, ThumbnailLoader.FillMode.FIT, 354 ThumbnailLoader.OptimizationMode.DISCARD_DETACHED); 355 }); 356 frame.appendChild(box); 357 358 var check = this.document_.createElement('div'); 359 check.className = 'check'; 360 li.appendChild(check); 361}; 362 363/** 364 * Handles the 'pick all/none' action. 365 * @private 366 */ 367PhotoImport.prototype.onSelectAllNone_ = function() { 368 var sm = this.grid_.selectionModel; 369 if (sm.selectedIndexes.length == this.fileList_.length) { 370 sm.unselectAll(); 371 } else { 372 sm.selectAll(); 373 } 374}; 375 376/** 377 * Show error message. 378 * @param {string} message Error message. 379 * @private 380 */ 381PhotoImport.prototype.onError_ = function(message) { 382 this.importingDialog_.hide(function() { 383 this.alert_.show(message, 384 function() { 385 window.close(); 386 }); 387 }.bind(this)); 388}; 389 390/** 391 * Resize event handler. 392 * @private 393 */ 394PhotoImport.prototype.onResize_ = function() { 395 var g = this.grid_; 396 g.startBatchUpdates(); 397 setTimeout(function() { 398 g.columns = 0; 399 g.redraw(); 400 g.endBatchUpdates(); 401 }, 0); 402}; 403 404/** 405 * @return {Array.<Object>} The list of selected entries. 406 * @private 407 */ 408PhotoImport.prototype.getSelectedItems_ = function() { 409 var indexes = this.grid_.selectionModel.selectedIndexes; 410 var list = []; 411 for (var i = 0; i < indexes.length; i++) { 412 list.push(this.fileList_.item(indexes[i])); 413 } 414 return list; 415}; 416 417/** 418 * Event handler for picked items change. 419 * @private 420 */ 421PhotoImport.prototype.onSelectionChanged_ = function() { 422 var count = this.grid_.selectionModel.selectedIndexes.length; 423 this.selectedCount_.textContent = count == 0 ? '' : 424 count == 1 ? loadTimeData.getString('PHOTO_IMPORT_ONE_SELECTED') : 425 loadTimeData.getStringF('PHOTO_IMPORT_MANY_SELECTED', count); 426 this.importButton_.disabled = count == 0 || this.myPhotosDirectory_ == null; 427 this.selectAllNone_.textContent = loadTimeData.getString( 428 count == this.fileList_.length && count > 0 ? 429 'PHOTO_IMPORT_SELECT_NONE' : 'PHOTO_IMPORT_SELECT_ALL'); 430}; 431 432/** 433 * Event handler for import button click. 434 * @param {Event} event The event. 435 * @private 436 */ 437PhotoImport.prototype.onImportClick_ = function(event) { 438 var entries = this.getSelectedItems_(); 439 var move = this.dom_.querySelector('#delete-after-checkbox').checked; 440 this.importingDialog_.show(entries, move); 441 442 this.createDestination_(function() { 443 var percentage = Math.round(entries.length / this.fileList_.length * 100); 444 metrics.recordMediumCount('PhotoImport.ImportCount', entries.length); 445 metrics.recordSmallCount('PhotoImport.ImportPercentage', percentage); 446 447 this.importingDialog_.start(this.destination_); 448 }.bind(this)); 449}; 450 451/** 452 * Click event handler for the cancel button. 453 * @param {Event} event The event. 454 * @private 455 */ 456PhotoImport.prototype.onCancelClick_ = function(event) { 457 window.close(); 458}; 459 460/** 461 * Item in the grid. 462 * @param {PhotoImport} app Application instance. 463 * @param {Entry} entry File entry. 464 * @constructor 465 */ 466function GridItem(app, entry) { 467 var li = app.document_.createElement('li'); 468 li.__proto__ = GridItem.prototype; 469 app.decorateGridItem_(li, entry); 470 return li; 471} 472 473GridItem.prototype = { 474 __proto__: cr.ui.ListItem.prototype, 475 get label() {}, 476 set label(value) {} 477}; 478 479/** 480 * Creates a selection controller that is to be used with grid. 481 * @param {cr.ui.ListSelectionModel} selectionModel The selection model to 482 * interact with. 483 * @param {cr.ui.Grid} grid The grid to interact with. 484 * @constructor 485 * @extends {!cr.ui.ListSelectionController} 486 */ 487function GridSelectionController(selectionModel, grid) { 488 this.selectionModel_ = selectionModel; 489 this.grid_ = grid; 490} 491 492/** 493 * Extends cr.ui.ListSelectionController. 494 */ 495GridSelectionController.prototype.__proto__ = 496 cr.ui.ListSelectionController.prototype; 497 498/** @override */ 499GridSelectionController.prototype.getIndexBelow = function(index) { 500 if (index == this.getLastIndex()) { 501 return -1; 502 } 503 504 var dm = this.grid_.dataModel; 505 var columns = this.grid_.columns; 506 var min = (Math.floor(index / columns) + 1) * columns; 507 508 for (var row = 1; true; row++) { 509 var end = index + columns * row; 510 var start = Math.max(min, index + columns * (row - 1)); 511 if (start > dm.length) break; 512 513 for (var i = end; i > start; i--) { 514 if (i < dm.length && dm.item(i).type == 'entry') 515 return i; 516 } 517 } 518 519 return this.getLastIndex(); 520}; 521 522/** @override */ 523GridSelectionController.prototype.getIndexAbove = function(index) { 524 if (index == this.getFirstIndex()) { 525 return -1; 526 } 527 528 var dm = this.grid_.dataModel; 529 index -= this.grid_.columns; 530 while (index >= 0 && dm.item(index).type != 'entry') { 531 index--; 532 } 533 534 return index < 0 ? this.getFirstIndex() : index; 535}; 536 537/** @override */ 538GridSelectionController.prototype.getIndexBefore = function(index) { 539 var dm = this.grid_.dataModel; 540 index--; 541 while (index >= 0 && dm.item(index).type != 'entry') { 542 index--; 543 } 544 return index; 545}; 546 547/** @override */ 548GridSelectionController.prototype.getIndexAfter = function(index) { 549 var dm = this.grid_.dataModel; 550 index++; 551 while (index < dm.length && dm.item(index).type != 'entry') { 552 index++; 553 } 554 return index == dm.length ? -1 : index; 555}; 556 557/** @override */ 558GridSelectionController.prototype.getFirstIndex = function() { 559 var dm = this.grid_.dataModel; 560 for (var index = 0; index < dm.length; index++) { 561 if (dm.item(index).type == 'entry') 562 return index; 563 } 564 return -1; 565}; 566 567/** @override */ 568GridSelectionController.prototype.getLastIndex = function() { 569 var dm = this.grid_.dataModel; 570 for (var index = dm.length - 1; index >= 0; index--) { 571 if (dm.item(index).type == 'entry') 572 return index; 573 } 574 return -1; 575}; 576