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