<commit_before>// Copyright (c) 2012 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.

'use strict';

if (chrome.extension) {
  var getContentWindows = function() {
    return chrome.extension.getViews();
  };
}

/**
 * @constructor
 */
function FileCopyManager() {
  this.copyTasks_ = [];
  this.deleteTasks_ = [];
  this.cancelObservers_ = [];
  this.cancelRequested_ = false;
  this.cancelCallback_ = null;
  this.unloadTimeout_ = null;

  window.addEventListener('error', function(e) {
    this.log_('Unhandled error: ', e.message, e.filename + ':' + e.lineno);
  }.bind(this));
}

var fileCopyManagerInstance = null;

/**
 * Get FileCopyManager instance. In case is hasn't been initialized, a new
 * instance is created.
 *
 * @param {DirectoryEntry} root Root entry.
 * @return {FileCopyManager} A FileCopyManager instance.
 */
FileCopyManager.getInstance = function(root) {
  if (fileCopyManagerInstance === null) {
    fileCopyManagerInstance = new FileCopyManager(root);
  }
  return fileCopyManagerInstance;
};

/**
 * A record of a queued copy operation.
 *
 * Multiple copy operations may be queued at any given time.  Additional
 * Tasks may be added while the queue is being serviced.  Though a
 * cancel operation cancels everything in the queue.
 *
 * @param {DirectoryEntry} targetDirEntry Target directory.
 * @param {DirectoryEntry=} opt_zipBaseDirEntry Base directory dealt as a root
 *     in ZIP archive.
 * @constructor
 */
FileCopyManager.Task = function(targetDirEntry, opt_zipBaseDirEntry) {
  this.targetDirEntry = targetDirEntry;
  this.zipBaseDirEntry = opt_zipBaseDirEntry;
  this.originalEntries = null;

  this.pendingDirectories = [];
  this.pendingFiles = [];
  this.pendingBytes = 0;

  this.completedDirectories = [];
  this.completedFiles = [];
  this.completedBytes = 0;

  this.deleteAfterCopy = false;
  this.move = false;
  this.zip = false;
  this.sourceOnDrive = false;
  this.targetOnDrive = false;

  // If directory already exists, we try to make a copy named 'dir (X)',
  // where X is a number. When we do this, all subsequent copies from
  // inside the subtree should be mapped to the new directory name.
  // For example, if 'dir' was copied as 'dir (1)', then 'dir\file.txt' should
  // become 'dir (1)\file.txt'.
  this.renamedDirectories_ = [];
};

/**
 * @param {Array.<Entry>} entries Entries.
 * @param {function} callback When entries resolved.
 */
FileCopyManager.Task.prototype.setEntries = function(entries, callback) {
  var self = this;

  var onEntriesRecursed = function(result) {
    self.pendingDirectories = result.dirEntries;
    self.pendingFiles = result.fileEntries;
    self.pendingBytes = result.fileBytes;
    callback();
  };

  this.originalEntries = entries;
  // When moving directories, FileEntry.moveTo() is used if both source
  // and target are on Drive. There is no need to recurse into directories.
  var recurse = !this.move;
  util.recurseAndResolveEntries(entries, recurse, onEntriesRecursed);
};

/**
 * @return {Entry} Next entry.
 */
FileCopyManager.Task.prototype.getNextEntry = function() {
  // We should keep the file in pending list and remove it after complete.
  // Otherwise, if we try to get status in the middle of copying. The returned
  // status is wrong (miss count the pasting item in totalItems).
  if (this.pendingDirectories.length) {
    this.pendingDirectories[0].inProgress = true;
    return this.pendingDirectories[0];
  }

  if (this.pendingFiles.length) {
    this.pendingFiles[0].inProgress = true;
    return this.pendingFiles[0];
  }

  return null;
};

/**
 * @param {Entry} entry Entry.
 * @param {number} size Bytes completed.
 */
FileCopyManager.Task.prototype.markEntryComplete = function(entry, size) {
  // It is probably not safe to directly remove the first entry in pending list.
  // We need to check if the removed entry (srcEntry) corresponding to the added
  // entry (target entry).
  if (entry.isDirectory && this.pendingDirectories &&
      this.pendingDirectories[0].inProgress) {
    this.completedDirectories.push(entry);
    this.pendingDirectories.shift();
  } else if (this.pendingFiles && this.pendingFiles[0].inProgress) {
    this.completedFiles.push(entry);
    this.completedBytes += size;
    this.pendingBytes -= size;
    this.pendingFiles.shift();
  } else {
    throw new Error('Try to remove a source entry which is not correspond to' +
                    ' the finished target entry');
  }
};

/**
 * Updates copy progress status for the entry.
 *
 * @param {Entry} entry Entry which is being coppied.
 * @param {number} size Number of bytes that has been copied since last update.
 */
FileCopyManager.Task.prototype.updateFileCopyProgress = function(entry, size) {
  if (entry.isFile && this.pendingFiles && this.pendingFiles[0].inProgress) {
    this.completedBytes += size;
    this.pendingBytes -= size;
  }
};

/**
 * @param {string} fromName Old name.
 * @param {string} toName New name.
 */
FileCopyManager.Task.prototype.registerRename = function(fromName, toName) {
  this.renamedDirectories_.push({from: fromName + '/', to: toName + '/'});
};

/**
 * @param {string} path A path.
 * @return {string} Path after renames.
 */
FileCopyManager.Task.prototype.applyRenames = function(path) {
  // Directories are processed in pre-order, so we will store only the first
  // renaming point:
  // x   -> x (1)    -- new directory created.
  // x\y -> x (1)\y  -- no more renames inside the new directory, so
  //                    this one will not be stored.
  // x\y\a.txt       -- only one rename will be applied.
  for (var index = 0; index < this.renamedDirectories_.length; ++index) {
    var rename = this.renamedDirectories_[index];
    if (path.indexOf(rename.from) == 0) {
      path = rename.to + path.substr(rename.from.length);
    }
  }
  return path;
};

/**
 * Error class used to report problems with a copy operation.
 *
 * @param {string} reason Error type.
 * @param {Object} data Additional data.
 * @constructor
 */
FileCopyManager.Error = function(reason, data) {
  this.reason = reason;
  this.code = FileCopyManager.Error[reason];
  this.data = data;
};

/** @const */
FileCopyManager.Error.CANCELLED = 0;
/** @const */
FileCopyManager.Error.UNEXPECTED_SOURCE_FILE = 1;
/** @const */
FileCopyManager.Error.TARGET_EXISTS = 2;
/** @const */
FileCopyManager.Error.FILESYSTEM_ERROR = 3;


// FileCopyManager methods.

/**
 * Initializes the filesystem if it is not done yet.
 * @param {function()} callback Completion callback.
 */
FileCopyManager.prototype.initialize = function(callback) {
  // Already initialized.
  if (this.root_) {
    callback();
    return;
  }
  chrome.fileBrowserPrivate.requestLocalFileSystem(function(filesystem) {
    this.root_ = filesystem.root;
    callback();
  }.bind(this));
};

/**
 * Called before a new method is run in the manager. Prepares the manager's
 * state for running a new method.
 */
FileCopyManager.prototype.willRunNewMethod = function() {
  // Cancel any pending close actions so the file copy manager doesn't go away.
  if (this.unloadTimeout_)
    clearTimeout(this.unloadTimeout_);
  this.unloadTimeout_ = null;
};

/**
 * @return {Object} Status object.
 */
FileCopyManager.prototype.getStatus = function() {
  var rv = {
    pendingItems: 0,  // Files + Directories
    pendingFiles: 0,
    pendingDirectories: 0,
    pendingBytes: 0,

    completedItems: 0,  // Files + Directories
    completedFiles: 0,
    completedDirectories: 0,
    completedBytes: 0,

    percentage: NaN,
    pendingCopies: 0,
    pendingMoves: 0,
    pendingZips: 0,
    filename: ''  // In case pendingItems == 1
  };

  var pendingFile = null;

  for (var i = 0; i < this.copyTasks_.length; i++) {
    var task = this.copyTasks_[i];
    var pendingFiles = task.pendingFiles.length;
    var pendingDirectories = task.pendingDirectories.length;
    rv.pendingFiles += pendingFiles;
    rv.pendingDirectories += pendingDirectories;
    rv.pendingBytes += task.pendingBytes;

    rv.completedFiles += task.completedFiles.length;
    rv.completedDirectories += task.completedDirectories.length;
    rv.completedBytes += task.completedBytes;

    if (task.zip) {
      rv.pendingZips += pendingFiles + pendingDirectories;
    } else if (task.move || task.deleteAfterCopy) {
      rv.pendingMoves += pendingFiles + pendingDirectories;
    } else {
      rv.pendingCopies += pendingFiles + pendingDirectories;
    }

    if (task.pendingFiles.length === 1)
      pendingFile = task.pendingFiles[0];

    if (task.pendingDirectories.length === 1)
      pendingFile = task.pendingDirectories[0];

  }
  rv.pendingItems = rv.pendingFiles + rv.pendingDirectories;
  rv.completedItems = rv.completedFiles + rv.completedDirectories;

  rv.totalFiles = rv.pendingFiles + rv.completedFiles;
  rv.totalDirectories = rv.pendingDirectories + rv.completedDirectories;
  rv.totalItems = rv.pendingItems + rv.completedItems;
  rv.totalBytes = rv.pendingBytes + rv.completedBytes;

  rv.percentage = rv.completedBytes / rv.totalBytes;
  if (rv.pendingItems === 1)
    rv.filename = pendingFile.name;

  return rv;
};

/**
 * Send an event to all the FileManager windows.
 *
 * @param {string} eventName Event name.
 * @param {Object} eventArgs An object with arbitrary event parameters.
 * @private
 */
FileCopyManager.prototype.sendEvent_ = function(eventName, eventArgs) {
  if (this.cancelRequested_)
    return;  // Swallow events until cancellation complete.

  eventArgs.status = this.getStatus();

  var windows = getContentWindows();
  for (var i = 0; i < windows.length; i++) {
    var w = windows[i];
    if (w.FileCopyManagerWrapper)
      w.FileCopyManagerWrapper.getInstance().onEvent(eventName, eventArgs);
  }
};

/**
 * Says if there are any tasks in the queue.
 * @return {boolean} True, if there are any tasks.
 */
FileCopyManager.prototype.hasQueuedTasks = function() {
  return this.copyTasks_.length > 0 || this.deleteTasks_.length > 0;
};

/**
 * Unloads the host page in 5 secs of idleing. Need to be called
 * each time this.copyTasks_.length or this.deleteTasks_.length
 * changed.
 *
 * @private
 */
FileCopyManager.prototype.maybeScheduleCloseBackgroundPage_ = function() {
  if (!this.hasQueuedTasks()) {
    if (this.unloadTimeout_ === null) {
      this.unloadTimeout_ = setTimeout(
          util.platform.v2() ? maybeCloseBackgroundPage : close, 5000);
    }
  } else if (this.unloadTimeout_) {
    clearTimeout(this.unloadTimeout_);
    this.unloadTimeout_ = null;
  }
};

/**
 * Reports an error on all of the active Files.app's windows.
 * @private
 */
FileCopyManager.prototype.log_ = function() {
  var windows = getContentWindows();
  for (var i = 0; i < windows.length; i++) {
    windows[i].console.error.apply(windows[i].console, arguments);
  }
};

/**
 * Dispatch a simple copy-progress event with reason and optional err data.
 *
 * @param {string} reason Event type.
 * @param {FileCopyManager.Error=} opt_err Error.
 * @private
 */
FileCopyManager.prototype.sendProgressEvent_ = function(reason, opt_err) {
  var event = {};
  event.reason = reason;
  if (opt_err)
    event.error = opt_err;
  this.sendEvent_('copy-progress', event);
};

/**
 * Dispatch an event of file operation completion (allows to update the UI).
 *
 * @private
 * @param {string} reason Completed file operation: 'movied|copied|deleted'.
 * @param {Array.<Entry>} affectedEntries deleted ot created entries.
 */
FileCopyManager.prototype.sendOperationEvent_ = function(reason,
                                                         affectedEntries) {
  var event = {};
  event.reason = reason;
  event.affectedEntries = affectedEntries;
  this.sendEvent_('copy-operation-complete', event);
};

/**
 * Completely clear out the copy queue, either because we encountered an error
 * or completed successfully.
 *
 * @private
 */
FileCopyManager.prototype.resetQueue_ = function() {
  for (var i = 0; i < this.cancelObservers_.length; i++)
    this.cancelObservers_[i]();

  this.copyTasks_ = [];
  this.cancelObservers_ = [];
  this.maybeScheduleCloseBackgroundPage_();
};

/**
 * Request that the current copy queue be abandoned.
 *
 * @param {function=} opt_callback On cancel.
 */
FileCopyManager.prototype.requestCancel = function(opt_callback) {
  this.cancelRequested_ = true;
  if (this.cancelCallback_)
    this.cancelCallback_();
  if (opt_callback)
    this.cancelObservers_.push(opt_callback);

  // If there is any active task it will eventually call maybeCancel_.
  // Otherwise call it right now.
  if (this.copyTasks_.length == 0)
    this.doCancel_();
};

/**
 * Perform the bookkeeping required to cancel.
 *
 * @private
 */
FileCopyManager.prototype.doCancel_ = function() {
  this.resetQueue_();
  this.cancelRequested_ = false;
  this.sendProgressEvent_('CANCELLED');
};

/**
 * Used internally to check if a cancel has been requested, and handle
 * it if so.
 *
 * @return {boolean} If canceled.
 * @private
 */
FileCopyManager.prototype.maybeCancel_ = function() {
  if (!this.cancelRequested_)
    return false;

  this.doCancel_();
  return true;
};

/**
 * Kick off pasting.
 *
 * @param {Array.<string>} files Pathes of source files.
 * @param {Array.<string>} directories Pathes of source directories.
 * @param {boolean} isCut If the source items are removed from original
 *     location.
 * @param {boolean} isOnDrive If the source items are on Google Drive.
 * @param {string} targetPath Target path.
 * @param {boolean} targetOnDrive If target is on Drive.
 */
FileCopyManager.prototype.paste = function(files, directories, isCut, isOnDrive,
                                           targetPath, targetOnDrive) {
  var self = this;
  var entries = [];

  var onPathError = function(err) {
    self.sendProgressEvent_('ERROR',
                            new FileCopyManager.Error('FILESYSTEM_ERROR', err));
  };

  var onTargetEntryFound = function(targetEntry) {
    self.queueCopy_(targetEntry,
                    entries,
                    isCut,
                    isOnDrive,
                    targetOnDrive);
  };

  var onComplete = function() {
    self.root_.getDirectory(targetPath, {},
                            onTargetEntryFound, onPathError);
  };

  var added = 0;
  var onEntryFound = function(entry) {
    // When getDirectories/getFiles finish, they call addEntry with null.
    // We don't want to add null to our entries.
    if (entry != null) {
      entries.push(entry);
      added++;
      if (added == total)
        onComplete();
    }
  };

  var entryFilterFunc = function(entry) {
    if (entry == '') {
      return false;
    } else if (isCut && entry.replace(/\/[^\/]+$/, '') == targetPath) {
      // Moving to the same directory is a redundant operation
      return false;
    } else {
      return true;
    }
  };
  directories = directories ? directories.filter(entryFilterFunc) : [];
  files = files ? files.filter(entryFilterFunc) : [];

  var total = directories.length + files.length;
  if (total == 0)
    return;

  util.getDirectories(self.root_, {create: false}, directories, onEntryFound,
                      onPathError);
  util.getFiles(self.root_, {create: false}, files, onEntryFound,
                onPathError);
};

/**
 * Checks if source and target are on the same root.
 *
 * @param {DirectoryEntry} sourceEntry An entry from the source.
 * @param {DirectoryEntry} targetDirEntry Directory entry for the target.
 * @param {boolean} targetOnDrive If target is on Drive.
 * @return {boolean} Whether source and target dir are on the same root.
 */
FileCopyManager.prototype.isOnSameRoot = function(sourceEntry,
                                                  targetDirEntry,
                                                  targetOnDrive) {
  return PathUtil.getRootPath(sourceEntry.fullPath) ==
         PathUtil.getRootPath(targetDirEntry.fullPath);
};

/**
 * Initiate a file copy.
 *
 * @param {DirectoryEntry} targetDirEntry Target directory.
 * @param {Array.<Entry>} entries Entries to copy.
 * @param {boolean} deleteAfterCopy In case of move.
 * @param {boolean} sourceOnDrive Source directory on Drive.
 * @param {boolean} targetOnDrive Target directory on Drive.
 * @return {FileCopyManager.Task} Copy task.
 * @private
 */
FileCopyManager.prototype.queueCopy_ = function(targetDirEntry,
                                                entries,
                                                deleteAfterCopy,
                                                sourceOnDrive,
                                                targetOnDrive) {
  var self = this;
  // When copying files, null can be specified as source directory.
  var copyTask = new FileCopyManager.Task(targetDirEntry);
  if (deleteAfterCopy) {
    // |sourecDirEntry| may be null, so let's check the root for the first of
    // the entries scheduled to be copied.
    if (this.isOnSameRoot(entries[0], targetDirEntry)) {
      copyTask.move = true;
    } else {
      copyTask.deleteAfterCopy = true;
    }
  }
  copyTask.sourceOnDrive = sourceOnDrive;
  copyTask.targetOnDrive = targetOnDrive;
  copyTask.setEntries(entries, function() {
    self.copyTasks_.push(copyTask);
    self.maybeScheduleCloseBackgroundPage_();
    if (self.copyTasks_.length == 1) {
      // Assume self.cancelRequested_ == false.
      // This moved us from 0 to 1 active tasks, let the servicing begin!
      self.serviceAllTasks_();
    } else {
      // Force to update the progress of butter bar when there are new tasks
      // coming while servicing current task.
      self.sendProgressEvent_('PROGRESS');
    }
  });

  return copyTask;
};

/**
 * Service all pending tasks, as well as any that might appear during the
 * copy.
 *
 * @private
 */
FileCopyManager.prototype.serviceAllTasks_ = function() {
  var self = this;

  var onTaskError = function(err) {
    if (self.maybeCancel_())
      return;
    self.sendProgressEvent_('ERROR', err);
    self.resetQueue_();
  };

  var onTaskSuccess = function(task) {
    if (self.maybeCancel_())
      return;
    if (!self.copyTasks_.length) {
      // All tasks have been serviced, clean up and exit.
      self.sendProgressEvent_('SUCCESS');
      self.resetQueue_();
      return;
    }

    // We want to dispatch a PROGRESS event when there are more tasks to serve
    // right after one task finished in the queue. We treat all tasks as one
    // big task logically, so there is only one BEGIN/SUCCESS event pair for
    // these continuous tasks.
    self.sendProgressEvent_('PROGRESS');

    self.serviceNextTask_(onTaskSuccess, onTaskError);
  };

  // If the queue size is 1 after pushing our task, it was empty before,
  // so we need to kick off queue processing and dispatch BEGIN event.

  this.sendProgressEvent_('BEGIN');
  this.serviceNextTask_(onTaskSuccess, onTaskError);
};

/**
 * Service all entries in the next copy task.
 *
 * @param {function} successCallback On success.
 * @param {function} errorCallback On error.
 * @private
 */
FileCopyManager.prototype.serviceNextTask_ = function(
    successCallback, errorCallback) {
  var self = this;
  var task = this.copyTasks_[0];

  var onFilesystemError = function(err) {
    errorCallback(new FileCopyManager.Error('FILESYSTEM_ERROR', err));
  };

  var onTaskComplete = function() {
    self.copyTasks_.shift();
    self.maybeScheduleCloseBackgroundPage_();
    successCallback(task);
  };

  var deleteOriginals = function() {
    var count = task.originalEntries.length;

    var onEntryDeleted = function(entry) {
      self.sendOperationEvent_('deleted', [entry]);
      count--;
      if (!count)
        onTaskComplete();
    };

    for (var i = 0; i < task.originalEntries.length; i++) {
      var entry = task.originalEntries[i];
      util.removeFileOrDirectory(
          entry, onEntryDeleted.bind(self, entry), onFilesystemError);
    }
  };

  var onEntryServiced = function(targetEntry, size) {
    // We should not dispatch a PROGRESS event when there is no pending items
    // in the task.
    if (task.pendingDirectories.length + task.pendingFiles.length == 0) {
      if (task.deleteAfterCopy) {
        deleteOriginals();
      } else {
        onTaskComplete();
      }
      return;
    }

    self.sendProgressEvent_('PROGRESS');

    // We yield a few ms between copies to give the browser a chance to service
    // events (like perhaps the user clicking to cancel the copy, for example).
    setTimeout(function() {
      self.serviceNextTaskEntry_(task, onEntryServiced, errorCallback);
    }, 10);
  };

  if (!task.zip)
    this.serviceNextTaskEntry_(task, onEntryServiced, errorCallback);
  else
    this.serviceZipTask_(task, onTaskComplete, errorCallback);
};

/**
 * Service the next entry in a given task.
 * TODO(olege): Refactor this method into a separate class.
 *
 * @param {FileManager.Task} task A task.
 * @param {function} successCallback On success.
 * @param {function} errorCallback On error.
 * @private
 */
FileCopyManager.prototype.serviceNextTaskEntry_ = function(
    task, successCallback, errorCallback) {
  if (this.maybeCancel_())
    return;

  var self = this;
  var sourceEntry = task.getNextEntry();

  if (!sourceEntry) {
    // All entries in this task have been copied.
    successCallback(null);
    return;
  }

  // |sourceEntry.originalSourcePath| is set in util.recurseAndResolveEntries.
  var sourcePath = sourceEntry.originalSourcePath;
  if (sourceEntry.fullPath.substr(0, sourcePath.length) != sourcePath) {
    // We found an entry in the list that is not relative to the base source
    // path, something is wrong.
    onError('UNEXPECTED_SOURCE_FILE', sourceEntry.fullPath);
    return;
  }

  var targetDirEntry = task.targetDirEntry;
  var originalPath = sourceEntry.fullPath.substr(sourcePath.length + 1);

  originalPath = task.applyRenames(originalPath);

  var targetRelativePrefix = originalPath;
  var targetExt = '';
  var index = targetRelativePrefix.lastIndexOf('.');
  if (index != -1) {
    targetExt = targetRelativePrefix.substr(index);
    targetRelativePrefix = targetRelativePrefix.substr(0, index);
  }

  // If file already exists, we try to make a copy named 'file (1).ext'.
  // If file is already named 'file (X).ext', we go with 'file (X+1).ext'.
  // If new name is still occupied, we increase the number up to 10 times.
  var copyNumber = 0;
  var match = /^(.*?)(?: \((\d+)\))?$/.exec(targetRelativePrefix);
  if (match && match[2]) {
    copyNumber = parseInt(match[2], 10);
    targetRelativePrefix = match[1];
  }

  var targetRelativePath = '';
  var renameTries = 0;
  var firstExistingEntry = null;

  var onCopyCompleteBase = function(entry, size) {
    task.markEntryComplete(entry, size);
    successCallback(entry, size);
  };

  var onCopyComplete = function(entry, size) {
    self.sendOperationEvent_('copied', [entry]);
    onCopyCompleteBase(entry, size);
  };

  var onCopyProgress = function(entry, size) {
    task.updateFileCopyProgress(entry, size);
    self.sendProgressEvent_('PROGRESS');
  };

  var onError = function(reason, data) {
    self.log_('serviceNextTaskEntry error: ' + reason + ':', data);
    errorCallback(new FileCopyManager.Error(reason, data));
  };

  var onFilesystemCopyComplete = function(sourceEntry, targetEntry) {
    // TODO(benchan): We currently do not know the size of data being
    // copied by FileEntry.copyTo(), so task.completedBytes will not be
    // increased. We will address this issue once we need to use
    // task.completedBytes to track the progress.
    self.sendOperationEvent_('copied', [sourceEntry, targetEntry]);
    onCopyCompleteBase(targetEntry, 0);
  };

  var onFilesystemMoveComplete = function(sourceEntry, targetEntry) {
    self.sendOperationEvent_('moved', [sourceEntry, targetEntry]);
    onCopyCompleteBase(targetEntry, 0);
  };

  var onFilesystemError = function(err) {
    onError('FILESYSTEM_ERROR', err);
  };

  var onTargetExists = function(existingEntry) {
    if (!firstExistingEntry)
      firstExistingEntry = existingEntry;
    renameTries++;
    if (renameTries < 10) {
      copyNumber++;
      tryNextCopy();
    } else {
      onError('TARGET_EXISTS', firstExistingEntry);
    }
  };

  /**
   * Resolves the immediate parent directory entry and the file name of a
   * given path, where the path is specified by a directory (not necessarily
   * the immediate parent) and a path (not necessarily the file name) related
   * to that directory. For instance,
   *   Given:
   *     |dirEntry| = DirectoryEntry('/root/dir1')
   *     |relativePath| = 'dir2/file'
   *
   *   Return:
   *     |parentDirEntry| = DirectoryEntry('/root/dir1/dir2')
   *     |fileName| = 'file'
   *
   * @param {DirectoryEntry} dirEntry A directory entry.
   * @param {string} relativePath A path relative to |dirEntry|.
   * @param {function(Entry,string)} successCallback A callback for returning
   *     the |parentDirEntry| and |fileName| upon success.
   * @param {function(FileError)} errorCallback An error callback when there is
   *     an error getting |parentDirEntry|.
   */
  var resolveDirAndBaseName = function(dirEntry, relativePath,
                                 successCallback, errorCallback) {
    // |intermediatePath| contains the intermediate path components
    // that are appended to |dirEntry| to form |parentDirEntry|.
    var intermediatePath = '';
    var fileName = relativePath;

    // Extract the file name component from |relativePath|.
    var index = relativePath.lastIndexOf('/');
    if (index != -1) {
      intermediatePath = relativePath.substr(0, index);
      fileName = relativePath.substr(index + 1);
    }

    if (intermediatePath == '') {
      successCallback(dirEntry, fileName);
    } else {
      dirEntry.getDirectory(intermediatePath,
                            {create: false},
                            function(entry) {
                              successCallback(entry, fileName);
                            },
                            errorCallback);
    }
  };

  var onTargetNotResolved = function(err) {
    // We expect to be unable to resolve the target file, since we're going
    // to create it during the copy.  However, if the resolve fails with
    // anything other than NOT_FOUND, that's trouble.
    if (err.code != FileError.NOT_FOUND_ERR)
      return onError('FILESYSTEM_ERROR', err);

    if (task.move) {
      resolveDirAndBaseName(
          targetDirEntry, targetRelativePath,
          function(dirEntry, fileName) {
            sourceEntry.moveTo(dirEntry, fileName,
                               onFilesystemMoveComplete.bind(self, sourceEntry),
                               onFilesystemError);
          },
          onFilesystemError);
      return;
    }

    // TODO(benchan): drive::FileSystem has not implemented directory copy,
    // and thus we only call FileEntry.copyTo() for files. Revisit this
    // code when drive::FileSystem supports directory copy.
    if (sourceEntry.isFile && (task.sourceOnDrive || task.targetOnDrive)) {
      var sourceFileUrl = sourceEntry.toURL();
      var targetFileUrl = targetDirEntry.toURL() + '/' +
                          encodeURIComponent(targetRelativePath);
      var sourceFilePath = util.extractFilePath(sourceFileUrl);
      var targetFilePath = util.extractFilePath(targetFileUrl);
      var transferedBytes = 0;

      var onStartTransfer = function() {
        chrome.fileBrowserPrivate.onFileTransfersUpdated.addListener(
            onFileTransfersUpdated);
      };

      var onFailTransfer = function(err) {
        chrome.fileBrowserPrivate.onFileTransfersUpdated.removeListener(
            onFileTransfersUpdated);

        self.log_('Error copying ' + sourceFileUrl + ' to ' + targetFileUrl);
        onFilesystemError(err);
      };

      var onSuccessTransfer = function(targetEntry) {
        chrome.fileBrowserPrivate.onFileTransfersUpdated.removeListener(
            onFileTransfersUpdated);

        targetEntry.getMetadata(function(metadata) {
          if (metadata.size > transferedBytes)
            onCopyProgress(sourceEntry, metadata.size - transferedBytes);
          onFilesystemCopyComplete(sourceEntry, targetEntry);
        });
      };

      var downTransfer = 0;
      var onFileTransfersUpdated = function(statusList) {
        for (var i = 0; i < statusList.length; i++) {
          var s = statusList[i];
          // Comparing urls is unreliable, since they may use different
          // url encoding schemes (eg. rfc2396 vs. rfc3986).
          var filePath = util.extractFilePath(s.fileUrl);
          if (filePath == sourceFilePath || filePath == targetFilePath) {
            var processed = s.processed;

            // It becomes tricky when both the sides are on Drive.
            // Currently, it is implemented by download followed by upload.
            // Note, however, download will not happen if the file is cached.
            if (task.sourceOnDrive && task.targetOnDrive) {
              if (filePath == sourceFilePath) {
                // Download transfer is detected. Let's halve the progress.
                downTransfer = processed = (s.processed >> 1);
              } else {
                // If download transfer has been detected, the upload transfer
                // is stacked on top of it after halving. Otherwise, just use
                // the upload transfer as-is.
                processed = downTransfer > 0 ?
                   downTransfer + (s.processed >> 1) : s.processed;
              }
            }

            if (processed > transferedBytes) {
              onCopyProgress(sourceEntry, processed - transferedBytes);
              transferedBytes = processed;
            }
          }
        }
      };

      if (task.sourceOnDrive && task.targetOnDrive) {
        resolveDirAndBaseName(
            targetDirEntry, targetRelativePath,
            function(dirEntry, fileName) {
              onStartTransfer();
              sourceEntry.copyTo(dirEntry, fileName, onSuccessTransfer,
                                                     onFailTransfer);
            },
            onFilesystemError);
        return;
      }

      var onFileTransferCompleted = function() {
        self.cancelCallback_ = null;
        if (chrome.runtime.lastError) {
          onFailTransfer({
            code: chrome.runtime.lastError.message,
            toDrive: task.targetOnDrive,
            sourceFileUrl: sourceFileUrl
          });
        } else {
          targetDirEntry.getFile(targetRelativePath, {}, onSuccessTransfer,
                                                         onFailTransfer);
        }
      };

      self.cancelCallback_ = function() {
        self.cancelCallback_ = null;
        chrome.fileBrowserPrivate.onFileTransfersUpdated.removeListener(
            onFileTransfersUpdated);
        if (task.sourceOnDrive) {
          chrome.fileBrowserPrivate.cancelFileTransfers([sourceFileUrl],
                                                        function() {});
        } else {
          chrome.fileBrowserPrivate.cancelFileTransfers([targetFileUrl],
                                                        function() {});
        }
      };

      // TODO(benchan): Until drive::FileSystem supports FileWriter, we use the
      // transferFile API to copy files into or out from a drive file system.
      onStartTransfer();
      chrome.fileBrowserPrivate.transferFile(
          sourceFileUrl, targetFileUrl, onFileTransferCompleted);
      return;
    }

    if (sourceEntry.isDirectory) {
      targetDirEntry.getDirectory(
          targetRelativePath,
          {create: true, exclusive: true},
          function(targetEntry) {
            if (targetRelativePath != originalPath) {
              task.registerRename(originalPath, targetRelativePath);
            }
            onCopyComplete(targetEntry);
          },
          util.flog('Error getting dir: ' + targetRelativePath,
                    onFilesystemError));
    } else {
      targetDirEntry.getFile(
          targetRelativePath,
          {create: true, exclusive: true},
          function(targetEntry) {
            self.copyEntry_(sourceEntry, targetEntry,
                            onCopyProgress, onCopyComplete, onError);
          },
          util.flog('Error getting file: ' + targetRelativePath,
                    onFilesystemError));
    }
  };

  var tryNextCopy = function() {
    targetRelativePath = targetRelativePrefix;
    if (copyNumber > 0) {
      targetRelativePath += ' (' + copyNumber + ')';
    }
    targetRelativePath += targetExt;

    // Check to see if the target exists.  This kicks off the rest of the copy
    // if the target is not found, or raises an error if it does.
    util.resolvePath(targetDirEntry, targetRelativePath, onTargetExists,
                     onTargetNotResolved);
  };

  tryNextCopy();
};

/**
 * Service a zip file creation task.
 *
 * @param {FileManager.Task} task A task.
 * @param {function} completeCallback On complete.
 * @param {function} errorCallback On error.
 * @private
 */
FileCopyManager.prototype.serviceZipTask_ = function(task, completeCallback,
                                                     errorCallback) {
  var self = this;
  var dirURL = task.zipBaseDirEntry.toURL();
  var selectionURLs = [];
  for (var i = 0; i < task.pendingDirectories.length; i++)
    selectionURLs.push(task.pendingDirectories[i].toURL());
  for (var i = 0; i < task.pendingFiles.length; i++)
    selectionURLs.push(task.pendingFiles[i].toURL());

  var destName = 'Archive';
  if (task.originalEntries.length == 1) {
    var entryPath = task.originalEntries[0].fullPath;
    var i = entryPath.lastIndexOf('/');
    var basename = (i < 0) ? entryPath : entryPath.substr(i + 1);
    i = basename.lastIndexOf('.');
    destName = ((i < 0) ? basename : basename.substr(0, i));
  }

  var copyNumber = 0;
  var firstExistingEntry = null;
  var destPath = destName + '.zip';

  var onError = function(reason, data) {
    self.log_('serviceZipTask error: ' + reason + ':', data);
    errorCallback(new FileCopyManager.Error(reason, data));
  };

  var onTargetExists = function(existingEntry) {
    if (copyNumber < 10) {
      if (!firstExistingEntry)
        firstExistingEntry = existingEntry;
      copyNumber++;
      tryZipSelection();
    } else {
      onError('TARGET_EXISTS', firstExistingEntry);
    }
  };

  var onTargetNotResolved = function() {
    var onZipSelectionComplete = function(success) {
      if (success) {
        self.sendProgressEvent_('SUCCESS');
      } else {
        self.sendProgressEvent_('ERROR',
            new FileCopyManager.Error('FILESYSTEM_ERROR', ''));
      }
      completeCallback(task);
    };

    self.sendProgressEvent_('PROGRESS');
    chrome.fileBrowserPrivate.zipSelection(dirURL, selectionURLs, destPath,
        onZipSelectionComplete);
  };

  var tryZipSelection = function() {
    if (copyNumber > 0)
      destPath = destName + ' (' + copyNumber + ').zip';

    // Check if the target exists. This kicks off the rest of the zip file
    // creation if the target is not found, or raises an error if it does.
    util.resolvePath(task.targetDirEntry, destPath, onTargetExists,
                     onTargetNotResolved);
  };

  tryZipSelection();
};

/**
 * Copy the contents of sourceEntry into targetEntry.
 *
 * @private
 * @param {Entry} sourceEntry entry that will be copied.
 * @param {Entry} targetEntry entry to which sourceEntry will be copied.
 * @param {function(Entry, number)} progressCallback function that will be
 *     called when a part of the source entry is copied. It takes |targetEntry|
 *     and size of the last copied chunk as parameters.
 * @param {function(Entry, number)} successCallback function that will be called
 *     the copy operation finishes. It takes |targetEntry| and size of the last
 *     (not previously reported) copied chunk as parameters.
 * @param {function(string, object)} errorCallback function that will be called
 *     if an error is encountered. Takes error type and additional error data
 *     as parameters.
 */
FileCopyManager.prototype.copyEntry_ = function(sourceEntry,
                                                targetEntry,
                                                progressCallback,
                                                successCallback,
                                                errorCallback) {
  if (this.maybeCancel_())
    return;

  var self = this;

  var onSourceFileFound = function(file) {
    var onWriterCreated = function(writer) {
      var reportedProgress = 0;
      writer.onerror = function(progress) {
        errorCallback('FILESYSTEM_ERROR', writer.error);
      };

      writer.onprogress = function(progress) {
        if (self.maybeCancel_()) {
          // If the copy was cancelled, we should abort the operation.
          writer.abort();
          return;
        }
        // |progress.loaded| will contain total amount of data copied by now.
        // |progressCallback| expects data amount delta from the last progress
        // update.
        progressCallback(targetEntry, progress.loaded - reportedProgress);
        reportedProgress = progress.loaded;
      };

      writer.onwriteend = function() {
        sourceEntry.getMetadata(function(metadata) {
          chrome.fileBrowserPrivate.setLastModified(targetEntry.toURL(),
              '' + Math.round(metadata.modificationTime.getTime() / 1000));
          successCallback(targetEntry, file.size - reportedProgress);
        });
      };

      writer.write(file);
    };

    targetEntry.createWriter(onWriterCreated, errorCallback);
  };

  sourceEntry.file(onSourceFileFound, errorCallback);
};

/**
 * Timeout before files are really deleted (to allow undo).
 */
FileCopyManager.DELETE_TIMEOUT = 30 * 1000;

/**
 * Schedules the files deletion.
 *
 * @param {Array.<Entry>} entries The entries.
 */
FileCopyManager.prototype.deleteEntries = function(entries) {
  var task = { entries: entries };
  this.deleteTasks_.push(task);
  this.maybeScheduleCloseBackgroundPage_();
  if (this.deleteTasks_.length == 1)
    this.serviceAllDeleteTasks_();
};

/**
 * Service all pending delete tasks, as well as any that might appear during the
 * deletion.
 *
 * @private
 */
FileCopyManager.prototype.serviceAllDeleteTasks_ = function() {
  var self = this;

  var onTaskSuccess = function(task) {
    self.deleteTasks_.shift();
    if (!self.deleteTasks_.length) {
      // All tasks have been serviced, clean up and exit.
      self.sendDeleteEvent_(task, 'SUCCESS');
      self.maybeScheduleCloseBackgroundPage_();
      return;
    }

    // We want to dispatch a PROGRESS event when there are more tasks to serve
    // right after one task finished in the queue. We treat all tasks as one
    // big task logically, so there is only one BEGIN/SUCCESS event pair for
    // these continuous tasks.
    self.sendDeleteEvent_(self.deleteTasks_[0], 'PROGRESS');
    self.serviceDeleteTask_(self.deleteTasks_[0], onTaskSuccess);
  };

  // If the queue size is 1 after pushing our task, it was empty before,
  // so we need to kick off queue processing and dispatch BEGIN event.
  this.sendDeleteEvent_(this.deleteTasks_[0], 'BEGIN');
  this.serviceDeleteTask_(this.deleteTasks_[0], onTaskSuccess);
};

/**
 * Performs the deletion.
 *
 * @param {Object} task The delete task (see deleteEntries function).
 * @param {function(Object)} onComplete Completion callback with the task
 *     as an argument.
 * @private
 */
FileCopyManager.prototype.serviceDeleteTask_ = function(
    task, onComplete) {
  var downcount = task.entries.length;

  var onEntryComplete = function() {
    if (--downcount == 0)
      onComplete(task);
  }.bind(this);

  if (downcount == 0)
    onComplete(task);

  for (var i = 0; i < task.entries.length; i++) {
    var entry = task.entries[i];
    util.removeFileOrDirectory(
        entry,
        onEntryComplete,
        onEntryComplete); // We ignore error, because we can't do anything here.
  }
};

/**
 * Send a 'delete' event to listeners.
 *
 * @param {Object} task The delete task (see deleteEntries function).
 * @param {string} reason Event reason.
 * @private
 */
FileCopyManager.prototype.sendDeleteEvent_ = function(task, reason) {
  this.sendEvent_('delete', {
    reason: reason,
    urls: task.entries.map(function(e) {
      return util.makeFilesystemUrl(e.fullPath);
    })
  });
};

/**
 * Creates a zip file for the selection of files.
 *
 * @param {Entry} dirEntry the directory containing the selection.
 * @param {boolean} isOnDrive If directory is on Drive.
 * @param {Array.<Entry>} selectionEntries the selected entries.
 */
FileCopyManager.prototype.zipSelection = function(dirEntry, isOnDrive,
                                                  selectionEntries) {
  var self = this;
  var zipTask = new FileCopyManager.Task(dirEntry, dirEntry);
  zipTask.zip = true;
  zipTask.sourceOnDrive = isOnDrive;
  zipTask.targetOnDrive = isOnDrive;
  zipTask.setEntries(selectionEntries, function() {
    // TODO: per-entry zip progress update with accurate byte count.
    // For now just set pendingBytes to zero so that the progress bar is full.
    zipTask.pendingBytes = 0;
    self.copyTasks_.push(zipTask);
    if (self.copyTasks_.length == 1) {
      // Assume self.cancelRequested_ == false.
      // This moved us from 0 to 1 active tasks, let the servicing begin!
      self.serviceAllTasks_();
    } else {
      // Force to update the progress of butter bar when there are new tasks
      // coming while servicing current task.
      self.sendProgressEvent_('PROGRESS');
    }
  });
};
<commit_msg>Files.app: Fixed a bug of moving multiple files.<commit_after>// Copyright (c) 2012 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.

'use strict';

if (chrome.extension) {
  var getContentWindows = function() {
    return chrome.extension.getViews();
  };
}

/**
 * @constructor
 */
function FileCopyManager() {
  this.copyTasks_ = [];
  this.deleteTasks_ = [];
  this.cancelObservers_ = [];
  this.cancelRequested_ = false;
  this.cancelCallback_ = null;
  this.unloadTimeout_ = null;

  window.addEventListener('error', function(e) {
    this.log_('Unhandled error: ', e.message, e.filename + ':' + e.lineno);
  }.bind(this));
}

var fileCopyManagerInstance = null;

/**
 * Get FileCopyManager instance. In case is hasn't been initialized, a new
 * instance is created.
 *
 * @param {DirectoryEntry} root Root entry.
 * @return {FileCopyManager} A FileCopyManager instance.
 */
FileCopyManager.getInstance = function(root) {
  if (fileCopyManagerInstance === null) {
    fileCopyManagerInstance = new FileCopyManager(root);
  }
  return fileCopyManagerInstance;
};

/**
 * A record of a queued copy operation.
 *
 * Multiple copy operations may be queued at any given time.  Additional
 * Tasks may be added while the queue is being serviced.  Though a
 * cancel operation cancels everything in the queue.
 *
 * @param {DirectoryEntry} targetDirEntry Target directory.
 * @param {DirectoryEntry=} opt_zipBaseDirEntry Base directory dealt as a root
 *     in ZIP archive.
 * @constructor
 */
FileCopyManager.Task = function(targetDirEntry, opt_zipBaseDirEntry) {
  this.targetDirEntry = targetDirEntry;
  this.zipBaseDirEntry = opt_zipBaseDirEntry;
  this.originalEntries = null;

  this.pendingDirectories = [];
  this.pendingFiles = [];
  this.pendingBytes = 0;

  this.completedDirectories = [];
  this.completedFiles = [];
  this.completedBytes = 0;

  this.deleteAfterCopy = false;
  this.move = false;
  this.zip = false;
  this.sourceOnDrive = false;
  this.targetOnDrive = false;

  // If directory already exists, we try to make a copy named 'dir (X)',
  // where X is a number. When we do this, all subsequent copies from
  // inside the subtree should be mapped to the new directory name.
  // For example, if 'dir' was copied as 'dir (1)', then 'dir\file.txt' should
  // become 'dir (1)\file.txt'.
  this.renamedDirectories_ = [];
};

/**
 * @param {Array.<Entry>} entries Entries.
 * @param {function} callback When entries resolved.
 */
FileCopyManager.Task.prototype.setEntries = function(entries, callback) {
  var self = this;

  var onEntriesRecursed = function(result) {
    // Deeper directory is moved earier.
    self.pendingDirectories = result.dirEntries.sort(
        function(a, b) { return a.fullPath < b.fullPath; });
    self.pendingFiles = result.fileEntries;
    self.pendingBytes = result.fileBytes;
    callback();
  };

  this.originalEntries = entries;
  // When moving directories, FileEntry.moveTo() is used if both source
  // and target are on Drive. There is no need to recurse into directories.
  var recurse = !this.move;
  util.recurseAndResolveEntries(entries, recurse, onEntriesRecursed);
};

/**
 * @return {Entry} Next entry.
 */
FileCopyManager.Task.prototype.getNextEntry = function() {
  // We should keep the file in pending list and remove it after complete.
  // Otherwise, if we try to get status in the middle of copying. The returned
  // status is wrong (miss count the pasting item in totalItems).
  if (this.pendingFiles.length) {
    this.pendingFiles[0].inProgress = true;
    return this.pendingFiles[0];
  }
  if (this.pendingDirectories.length) {
    this.pendingDirectories[0].inProgress = true;
    return this.pendingDirectories[0];
  }
  return null;
};

/**
 * @param {Entry} entry Entry.
 * @param {number} size Bytes completed.
 */
FileCopyManager.Task.prototype.markEntryComplete = function(entry, size) {
  // It is probably not safe to directly remove the first entry in pending list.
  // We need to check if the removed entry (srcEntry) corresponding to the added
  // entry (target entry).
  if (entry.isDirectory && this.pendingDirectories &&
      this.pendingDirectories[0].inProgress) {
    this.completedDirectories.push(entry);
    this.pendingDirectories.shift();
  } else if (this.pendingFiles && this.pendingFiles[0].inProgress) {
    this.completedFiles.push(entry);
    this.completedBytes += size;
    this.pendingBytes -= size;
    this.pendingFiles.shift();
  } else {
    throw new Error('Try to remove a source entry which is not correspond to' +
                    ' the finished target entry');
  }
};

/**
 * Updates copy progress status for the entry.
 *
 * @param {Entry} entry Entry which is being coppied.
 * @param {number} size Number of bytes that has been copied since last update.
 */
FileCopyManager.Task.prototype.updateFileCopyProgress = function(entry, size) {
  if (entry.isFile && this.pendingFiles && this.pendingFiles[0].inProgress) {
    this.completedBytes += size;
    this.pendingBytes -= size;
  }
};

/**
 * @param {string} fromName Old name.
 * @param {string} toName New name.
 */
FileCopyManager.Task.prototype.registerRename = function(fromName, toName) {
  this.renamedDirectories_.push({from: fromName + '/', to: toName + '/'});
};

/**
 * @param {string} path A path.
 * @return {string} Path after renames.
 */
FileCopyManager.Task.prototype.applyRenames = function(path) {
  // Directories are processed in pre-order, so we will store only the first
  // renaming point:
  // x   -> x (1)    -- new directory created.
  // x\y -> x (1)\y  -- no more renames inside the new directory, so
  //                    this one will not be stored.
  // x\y\a.txt       -- only one rename will be applied.
  for (var index = 0; index < this.renamedDirectories_.length; ++index) {
    var rename = this.renamedDirectories_[index];
    if (path.indexOf(rename.from) == 0) {
      path = rename.to + path.substr(rename.from.length);
    }
  }
  return path;
};

/**
 * Error class used to report problems with a copy operation.
 *
 * @param {string} reason Error type.
 * @param {Object} data Additional data.
 * @constructor
 */
FileCopyManager.Error = function(reason, data) {
  this.reason = reason;
  this.code = FileCopyManager.Error[reason];
  this.data = data;
};

/** @const */
FileCopyManager.Error.CANCELLED = 0;
/** @const */
FileCopyManager.Error.UNEXPECTED_SOURCE_FILE = 1;
/** @const */
FileCopyManager.Error.TARGET_EXISTS = 2;
/** @const */
FileCopyManager.Error.FILESYSTEM_ERROR = 3;


// FileCopyManager methods.

/**
 * Initializes the filesystem if it is not done yet.
 * @param {function()} callback Completion callback.
 */
FileCopyManager.prototype.initialize = function(callback) {
  // Already initialized.
  if (this.root_) {
    callback();
    return;
  }
  chrome.fileBrowserPrivate.requestLocalFileSystem(function(filesystem) {
    this.root_ = filesystem.root;
    callback();
  }.bind(this));
};

/**
 * Called before a new method is run in the manager. Prepares the manager's
 * state for running a new method.
 */
FileCopyManager.prototype.willRunNewMethod = function() {
  // Cancel any pending close actions so the file copy manager doesn't go away.
  if (this.unloadTimeout_)
    clearTimeout(this.unloadTimeout_);
  this.unloadTimeout_ = null;
};

/**
 * @return {Object} Status object.
 */
FileCopyManager.prototype.getStatus = function() {
  var rv = {
    pendingItems: 0,  // Files + Directories
    pendingFiles: 0,
    pendingDirectories: 0,
    pendingBytes: 0,

    completedItems: 0,  // Files + Directories
    completedFiles: 0,
    completedDirectories: 0,
    completedBytes: 0,

    percentage: NaN,
    pendingCopies: 0,
    pendingMoves: 0,
    pendingZips: 0,
    filename: ''  // In case pendingItems == 1
  };

  var pendingFile = null;

  for (var i = 0; i < this.copyTasks_.length; i++) {
    var task = this.copyTasks_[i];
    var pendingFiles = task.pendingFiles.length;
    var pendingDirectories = task.pendingDirectories.length;
    rv.pendingFiles += pendingFiles;
    rv.pendingDirectories += pendingDirectories;
    rv.pendingBytes += task.pendingBytes;

    rv.completedFiles += task.completedFiles.length;
    rv.completedDirectories += task.completedDirectories.length;
    rv.completedBytes += task.completedBytes;

    if (task.zip) {
      rv.pendingZips += pendingFiles + pendingDirectories;
    } else if (task.move || task.deleteAfterCopy) {
      rv.pendingMoves += pendingFiles + pendingDirectories;
    } else {
      rv.pendingCopies += pendingFiles + pendingDirectories;
    }

    if (task.pendingFiles.length === 1)
      pendingFile = task.pendingFiles[0];

    if (task.pendingDirectories.length === 1)
      pendingFile = task.pendingDirectories[0];

  }
  rv.pendingItems = rv.pendingFiles + rv.pendingDirectories;
  rv.completedItems = rv.completedFiles + rv.completedDirectories;

  rv.totalFiles = rv.pendingFiles + rv.completedFiles;
  rv.totalDirectories = rv.pendingDirectories + rv.completedDirectories;
  rv.totalItems = rv.pendingItems + rv.completedItems;
  rv.totalBytes = rv.pendingBytes + rv.completedBytes;

  rv.percentage = rv.completedBytes / rv.totalBytes;
  if (rv.pendingItems === 1)
    rv.filename = pendingFile.name;

  return rv;
};

/**
 * Send an event to all the FileManager windows.
 *
 * @param {string} eventName Event name.
 * @param {Object} eventArgs An object with arbitrary event parameters.
 * @private
 */
FileCopyManager.prototype.sendEvent_ = function(eventName, eventArgs) {
  if (this.cancelRequested_)
    return;  // Swallow events until cancellation complete.

  eventArgs.status = this.getStatus();

  var windows = getContentWindows();
  for (var i = 0; i < windows.length; i++) {
    var w = windows[i];
    if (w.FileCopyManagerWrapper)
      w.FileCopyManagerWrapper.getInstance().onEvent(eventName, eventArgs);
  }
};

/**
 * Says if there are any tasks in the queue.
 * @return {boolean} True, if there are any tasks.
 */
FileCopyManager.prototype.hasQueuedTasks = function() {
  return this.copyTasks_.length > 0 || this.deleteTasks_.length > 0;
};

/**
 * Unloads the host page in 5 secs of idleing. Need to be called
 * each time this.copyTasks_.length or this.deleteTasks_.length
 * changed.
 *
 * @private
 */
FileCopyManager.prototype.maybeScheduleCloseBackgroundPage_ = function() {
  if (!this.hasQueuedTasks()) {
    if (this.unloadTimeout_ === null) {
      this.unloadTimeout_ = setTimeout(
          util.platform.v2() ? maybeCloseBackgroundPage : close, 5000);
    }
  } else if (this.unloadTimeout_) {
    clearTimeout(this.unloadTimeout_);
    this.unloadTimeout_ = null;
  }
};

/**
 * Reports an error on all of the active Files.app's windows.
 * @private
 */
FileCopyManager.prototype.log_ = function() {
  var windows = getContentWindows();
  for (var i = 0; i < windows.length; i++) {
    windows[i].console.error.apply(windows[i].console, arguments);
  }
};

/**
 * Dispatch a simple copy-progress event with reason and optional err data.
 *
 * @param {string} reason Event type.
 * @param {FileCopyManager.Error=} opt_err Error.
 * @private
 */
FileCopyManager.prototype.sendProgressEvent_ = function(reason, opt_err) {
  var event = {};
  event.reason = reason;
  if (opt_err)
    event.error = opt_err;
  this.sendEvent_('copy-progress', event);
};

/**
 * Dispatch an event of file operation completion (allows to update the UI).
 *
 * @private
 * @param {string} reason Completed file operation: 'movied|copied|deleted'.
 * @param {Array.<Entry>} affectedEntries deleted ot created entries.
 */
FileCopyManager.prototype.sendOperationEvent_ = function(reason,
                                                         affectedEntries) {
  var event = {};
  event.reason = reason;
  event.affectedEntries = affectedEntries;
  this.sendEvent_('copy-operation-complete', event);
};

/**
 * Completely clear out the copy queue, either because we encountered an error
 * or completed successfully.
 *
 * @private
 */
FileCopyManager.prototype.resetQueue_ = function() {
  for (var i = 0; i < this.cancelObservers_.length; i++)
    this.cancelObservers_[i]();

  this.copyTasks_ = [];
  this.cancelObservers_ = [];
  this.maybeScheduleCloseBackgroundPage_();
};

/**
 * Request that the current copy queue be abandoned.
 *
 * @param {function=} opt_callback On cancel.
 */
FileCopyManager.prototype.requestCancel = function(opt_callback) {
  this.cancelRequested_ = true;
  if (this.cancelCallback_)
    this.cancelCallback_();
  if (opt_callback)
    this.cancelObservers_.push(opt_callback);

  // If there is any active task it will eventually call maybeCancel_.
  // Otherwise call it right now.
  if (this.copyTasks_.length == 0)
    this.doCancel_();
};

/**
 * Perform the bookkeeping required to cancel.
 *
 * @private
 */
FileCopyManager.prototype.doCancel_ = function() {
  this.resetQueue_();
  this.cancelRequested_ = false;
  this.sendProgressEvent_('CANCELLED');
};

/**
 * Used internally to check if a cancel has been requested, and handle
 * it if so.
 *
 * @return {boolean} If canceled.
 * @private
 */
FileCopyManager.prototype.maybeCancel_ = function() {
  if (!this.cancelRequested_)
    return false;

  this.doCancel_();
  return true;
};

/**
 * Kick off pasting.
 *
 * @param {Array.<string>} files Pathes of source files.
 * @param {Array.<string>} directories Pathes of source directories.
 * @param {boolean} isCut If the source items are removed from original
 *     location.
 * @param {boolean} isOnDrive If the source items are on Google Drive.
 * @param {string} targetPath Target path.
 * @param {boolean} targetOnDrive If target is on Drive.
 */
FileCopyManager.prototype.paste = function(files, directories, isCut, isOnDrive,
                                           targetPath, targetOnDrive) {
  var self = this;
  var entries = [];

  var onPathError = function(err) {
    self.sendProgressEvent_('ERROR',
                            new FileCopyManager.Error('FILESYSTEM_ERROR', err));
  };

  var onTargetEntryFound = function(targetEntry) {
    self.queueCopy_(targetEntry,
                    entries,
                    isCut,
                    isOnDrive,
                    targetOnDrive);
  };

  var onComplete = function() {
    self.root_.getDirectory(targetPath, {},
                            onTargetEntryFound, onPathError);
  };

  var added = 0;
  var onEntryFound = function(entry) {
    // When getDirectories/getFiles finish, they call addEntry with null.
    // We don't want to add null to our entries.
    if (entry != null) {
      entries.push(entry);
      added++;
      if (added == total)
        onComplete();
    }
  };

  var entryFilterFunc = function(entry) {
    if (entry == '') {
      return false;
    } else if (isCut && entry.replace(/\/[^\/]+$/, '') == targetPath) {
      // Moving to the same directory is a redundant operation
      return false;
    } else {
      return true;
    }
  };
  directories = directories ? directories.filter(entryFilterFunc) : [];
  files = files ? files.filter(entryFilterFunc) : [];

  var total = directories.length + files.length;
  if (total == 0)
    return;

  util.getDirectories(self.root_, {create: false}, directories, onEntryFound,
                      onPathError);
  util.getFiles(self.root_, {create: false}, files, onEntryFound,
                onPathError);
};

/**
 * Checks if source and target are on the same root.
 *
 * @param {DirectoryEntry} sourceEntry An entry from the source.
 * @param {DirectoryEntry} targetDirEntry Directory entry for the target.
 * @param {boolean} targetOnDrive If target is on Drive.
 * @return {boolean} Whether source and target dir are on the same root.
 */
FileCopyManager.prototype.isOnSameRoot = function(sourceEntry,
                                                  targetDirEntry,
                                                  targetOnDrive) {
  return PathUtil.getRootPath(sourceEntry.fullPath) ==
         PathUtil.getRootPath(targetDirEntry.fullPath);
};

/**
 * Initiate a file copy.
 *
 * @param {DirectoryEntry} targetDirEntry Target directory.
 * @param {Array.<Entry>} entries Entries to copy.
 * @param {boolean} deleteAfterCopy In case of move.
 * @param {boolean} sourceOnDrive Source directory on Drive.
 * @param {boolean} targetOnDrive Target directory on Drive.
 * @return {FileCopyManager.Task} Copy task.
 * @private
 */
FileCopyManager.prototype.queueCopy_ = function(targetDirEntry,
                                                entries,
                                                deleteAfterCopy,
                                                sourceOnDrive,
                                                targetOnDrive) {
  var self = this;
  // When copying files, null can be specified as source directory.
  var copyTask = new FileCopyManager.Task(targetDirEntry);
  if (deleteAfterCopy) {
    // |sourecDirEntry| may be null, so let's check the root for the first of
    // the entries scheduled to be copied.
    if (this.isOnSameRoot(entries[0], targetDirEntry)) {
      copyTask.move = true;
    } else {
      copyTask.deleteAfterCopy = true;
    }
  }
  copyTask.sourceOnDrive = sourceOnDrive;
  copyTask.targetOnDrive = targetOnDrive;
  copyTask.setEntries(entries, function() {
    self.copyTasks_.push(copyTask);
    self.maybeScheduleCloseBackgroundPage_();
    if (self.copyTasks_.length == 1) {
      // Assume self.cancelRequested_ == false.
      // This moved us from 0 to 1 active tasks, let the servicing begin!
      self.serviceAllTasks_();
    } else {
      // Force to update the progress of butter bar when there are new tasks
      // coming while servicing current task.
      self.sendProgressEvent_('PROGRESS');
    }
  });

  return copyTask;
};

/**
 * Service all pending tasks, as well as any that might appear during the
 * copy.
 *
 * @private
 */
FileCopyManager.prototype.serviceAllTasks_ = function() {
  var self = this;

  var onTaskError = function(err) {
    if (self.maybeCancel_())
      return;
    self.sendProgressEvent_('ERROR', err);
    self.resetQueue_();
  };

  var onTaskSuccess = function(task) {
    if (self.maybeCancel_())
      return;
    if (!self.copyTasks_.length) {
      // All tasks have been serviced, clean up and exit.
      self.sendProgressEvent_('SUCCESS');
      self.resetQueue_();
      return;
    }

    // We want to dispatch a PROGRESS event when there are more tasks to serve
    // right after one task finished in the queue. We treat all tasks as one
    // big task logically, so there is only one BEGIN/SUCCESS event pair for
    // these continuous tasks.
    self.sendProgressEvent_('PROGRESS');

    self.serviceNextTask_(onTaskSuccess, onTaskError);
  };

  // If the queue size is 1 after pushing our task, it was empty before,
  // so we need to kick off queue processing and dispatch BEGIN event.

  this.sendProgressEvent_('BEGIN');
  this.serviceNextTask_(onTaskSuccess, onTaskError);
};

/**
 * Service all entries in the next copy task.
 *
 * @param {function} successCallback On success.
 * @param {function} errorCallback On error.
 * @private
 */
FileCopyManager.prototype.serviceNextTask_ = function(
    successCallback, errorCallback) {
  var self = this;
  var task = this.copyTasks_[0];

  var onFilesystemError = function(err) {
    errorCallback(new FileCopyManager.Error('FILESYSTEM_ERROR', err));
  };

  var onTaskComplete = function() {
    self.copyTasks_.shift();
    self.maybeScheduleCloseBackgroundPage_();
    successCallback(task);
  };

  var deleteOriginals = function() {
    var count = task.originalEntries.length;

    var onEntryDeleted = function(entry) {
      self.sendOperationEvent_('deleted', [entry]);
      count--;
      if (!count)
        onTaskComplete();
    };

    for (var i = 0; i < task.originalEntries.length; i++) {
      var entry = task.originalEntries[i];
      util.removeFileOrDirectory(
          entry, onEntryDeleted.bind(self, entry), onFilesystemError);
    }
  };

  var onEntryServiced = function(targetEntry, size) {
    // We should not dispatch a PROGRESS event when there is no pending items
    // in the task.
    if (task.pendingDirectories.length + task.pendingFiles.length == 0) {
      if (task.deleteAfterCopy) {
        deleteOriginals();
      } else {
        onTaskComplete();
      }
      return;
    }

    self.sendProgressEvent_('PROGRESS');

    // We yield a few ms between copies to give the browser a chance to service
    // events (like perhaps the user clicking to cancel the copy, for example).
    setTimeout(function() {
      self.serviceNextTaskEntry_(task, onEntryServiced, errorCallback);
    }, 10);
  };

  if (!task.zip)
    this.serviceNextTaskEntry_(task, onEntryServiced, errorCallback);
  else
    this.serviceZipTask_(task, onTaskComplete, errorCallback);
};

/**
 * Service the next entry in a given task.
 * TODO(olege): Refactor this method into a separate class.
 *
 * @param {FileManager.Task} task A task.
 * @param {function} successCallback On success.
 * @param {function} errorCallback On error.
 * @private
 */
FileCopyManager.prototype.serviceNextTaskEntry_ = function(
    task, successCallback, errorCallback) {
  if (this.maybeCancel_())
    return;

  var self = this;
  var sourceEntry = task.getNextEntry();

  if (!sourceEntry) {
    // All entries in this task have been copied.
    successCallback(null);
    return;
  }

  // |sourceEntry.originalSourcePath| is set in util.recurseAndResolveEntries.
  var sourcePath = sourceEntry.originalSourcePath;
  if (sourceEntry.fullPath.substr(0, sourcePath.length) != sourcePath) {
    // We found an entry in the list that is not relative to the base source
    // path, something is wrong.
    onError('UNEXPECTED_SOURCE_FILE', sourceEntry.fullPath);
    return;
  }

  var targetDirEntry = task.targetDirEntry;
  var originalPath = sourceEntry.fullPath.substr(sourcePath.length + 1);

  originalPath = task.applyRenames(originalPath);

  var targetRelativePrefix = originalPath;
  var targetExt = '';
  var index = targetRelativePrefix.lastIndexOf('.');
  if (index != -1) {
    targetExt = targetRelativePrefix.substr(index);
    targetRelativePrefix = targetRelativePrefix.substr(0, index);
  }

  // If file already exists, we try to make a copy named 'file (1).ext'.
  // If file is already named 'file (X).ext', we go with 'file (X+1).ext'.
  // If new name is still occupied, we increase the number up to 10 times.
  var copyNumber = 0;
  var match = /^(.*?)(?: \((\d+)\))?$/.exec(targetRelativePrefix);
  if (match && match[2]) {
    copyNumber = parseInt(match[2], 10);
    targetRelativePrefix = match[1];
  }

  var targetRelativePath = '';
  var renameTries = 0;
  var firstExistingEntry = null;

  var onCopyCompleteBase = function(entry, size) {
    task.markEntryComplete(entry, size);
    successCallback(entry, size);
  };

  var onCopyComplete = function(entry, size) {
    self.sendOperationEvent_('copied', [entry]);
    onCopyCompleteBase(entry, size);
  };

  var onCopyProgress = function(entry, size) {
    task.updateFileCopyProgress(entry, size);
    self.sendProgressEvent_('PROGRESS');
  };

  var onError = function(reason, data) {
    self.log_('serviceNextTaskEntry error: ' + reason + ':', data);
    errorCallback(new FileCopyManager.Error(reason, data));
  };

  var onFilesystemCopyComplete = function(sourceEntry, targetEntry) {
    // TODO(benchan): We currently do not know the size of data being
    // copied by FileEntry.copyTo(), so task.completedBytes will not be
    // increased. We will address this issue once we need to use
    // task.completedBytes to track the progress.
    self.sendOperationEvent_('copied', [sourceEntry, targetEntry]);
    onCopyCompleteBase(targetEntry, 0);
  };

  var onFilesystemMoveComplete = function(sourceEntry, targetEntry) {
    self.sendOperationEvent_('moved', [sourceEntry, targetEntry]);
    onCopyCompleteBase(targetEntry, 0);
  };

  var onFilesystemError = function(err) {
    onError('FILESYSTEM_ERROR', err);
  };

  var onTargetExists = function(existingEntry) {
    if (!firstExistingEntry)
      firstExistingEntry = existingEntry;
    renameTries++;
    if (renameTries < 10) {
      copyNumber++;
      tryNextCopy();
    } else {
      onError('TARGET_EXISTS', firstExistingEntry);
    }
  };

  /**
   * Resolves the immediate parent directory entry and the file name of a
   * given path, where the path is specified by a directory (not necessarily
   * the immediate parent) and a path (not necessarily the file name) related
   * to that directory. For instance,
   *   Given:
   *     |dirEntry| = DirectoryEntry('/root/dir1')
   *     |relativePath| = 'dir2/file'
   *
   *   Return:
   *     |parentDirEntry| = DirectoryEntry('/root/dir1/dir2')
   *     |fileName| = 'file'
   *
   * @param {DirectoryEntry} dirEntry A directory entry.
   * @param {string} relativePath A path relative to |dirEntry|.
   * @param {function(Entry,string)} successCallback A callback for returning
   *     the |parentDirEntry| and |fileName| upon success.
   * @param {function(FileError)} errorCallback An error callback when there is
   *     an error getting |parentDirEntry|.
   */
  var resolveDirAndBaseName = function(dirEntry, relativePath,
                                 successCallback, errorCallback) {
    // |intermediatePath| contains the intermediate path components
    // that are appended to |dirEntry| to form |parentDirEntry|.
    var intermediatePath = '';
    var fileName = relativePath;

    // Extract the file name component from |relativePath|.
    var index = relativePath.lastIndexOf('/');
    if (index != -1) {
      intermediatePath = relativePath.substr(0, index);
      fileName = relativePath.substr(index + 1);
    }

    if (intermediatePath == '') {
      successCallback(dirEntry, fileName);
    } else {
      dirEntry.getDirectory(intermediatePath,
                            {create: false},
                            function(entry) {
                              successCallback(entry, fileName);
                            },
                            errorCallback);
    }
  };

  var onTargetNotResolved = function(err) {
    // We expect to be unable to resolve the target file, since we're going
    // to create it during the copy.  However, if the resolve fails with
    // anything other than NOT_FOUND, that's trouble.
    if (err.code != FileError.NOT_FOUND_ERR)
      return onError('FILESYSTEM_ERROR', err);

    if (task.move) {
      resolveDirAndBaseName(
          targetDirEntry, targetRelativePath,
          function(dirEntry, fileName) {
            sourceEntry.moveTo(dirEntry, fileName,
                               onFilesystemMoveComplete.bind(self, sourceEntry),
                               onFilesystemError);
          },
          onFilesystemError);
      return;
    }

    // TODO(benchan): drive::FileSystem has not implemented directory copy,
    // and thus we only call FileEntry.copyTo() for files. Revisit this
    // code when drive::FileSystem supports directory copy.
    if (sourceEntry.isFile && (task.sourceOnDrive || task.targetOnDrive)) {
      var sourceFileUrl = sourceEntry.toURL();
      var targetFileUrl = targetDirEntry.toURL() + '/' +
                          encodeURIComponent(targetRelativePath);
      var sourceFilePath = util.extractFilePath(sourceFileUrl);
      var targetFilePath = util.extractFilePath(targetFileUrl);
      var transferedBytes = 0;

      var onStartTransfer = function() {
        chrome.fileBrowserPrivate.onFileTransfersUpdated.addListener(
            onFileTransfersUpdated);
      };

      var onFailTransfer = function(err) {
        chrome.fileBrowserPrivate.onFileTransfersUpdated.removeListener(
            onFileTransfersUpdated);

        self.log_('Error copying ' + sourceFileUrl + ' to ' + targetFileUrl);
        onFilesystemError(err);
      };

      var onSuccessTransfer = function(targetEntry) {
        chrome.fileBrowserPrivate.onFileTransfersUpdated.removeListener(
            onFileTransfersUpdated);

        targetEntry.getMetadata(function(metadata) {
          if (metadata.size > transferedBytes)
            onCopyProgress(sourceEntry, metadata.size - transferedBytes);
          onFilesystemCopyComplete(sourceEntry, targetEntry);
        });
      };

      var downTransfer = 0;
      var onFileTransfersUpdated = function(statusList) {
        for (var i = 0; i < statusList.length; i++) {
          var s = statusList[i];
          // Comparing urls is unreliable, since they may use different
          // url encoding schemes (eg. rfc2396 vs. rfc3986).
          var filePath = util.extractFilePath(s.fileUrl);
          if (filePath == sourceFilePath || filePath == targetFilePath) {
            var processed = s.processed;

            // It becomes tricky when both the sides are on Drive.
            // Currently, it is implemented by download followed by upload.
            // Note, however, download will not happen if the file is cached.
            if (task.sourceOnDrive && task.targetOnDrive) {
              if (filePath == sourceFilePath) {
                // Download transfer is detected. Let's halve the progress.
                downTransfer = processed = (s.processed >> 1);
              } else {
                // If download transfer has been detected, the upload transfer
                // is stacked on top of it after halving. Otherwise, just use
                // the upload transfer as-is.
                processed = downTransfer > 0 ?
                   downTransfer + (s.processed >> 1) : s.processed;
              }
            }

            if (processed > transferedBytes) {
              onCopyProgress(sourceEntry, processed - transferedBytes);
              transferedBytes = processed;
            }
          }
        }
      };

      if (task.sourceOnDrive && task.targetOnDrive) {
        resolveDirAndBaseName(
            targetDirEntry, targetRelativePath,
            function(dirEntry, fileName) {
              onStartTransfer();
              sourceEntry.copyTo(dirEntry, fileName, onSuccessTransfer,
                                                     onFailTransfer);
            },
            onFilesystemError);
        return;
      }

      var onFileTransferCompleted = function() {
        self.cancelCallback_ = null;
        if (chrome.runtime.lastError) {
          onFailTransfer({
            code: chrome.runtime.lastError.message,
            toDrive: task.targetOnDrive,
            sourceFileUrl: sourceFileUrl
          });
        } else {
          targetDirEntry.getFile(targetRelativePath, {}, onSuccessTransfer,
                                                         onFailTransfer);
        }
      };

      self.cancelCallback_ = function() {
        self.cancelCallback_ = null;
        chrome.fileBrowserPrivate.onFileTransfersUpdated.removeListener(
            onFileTransfersUpdated);
        if (task.sourceOnDrive) {
          chrome.fileBrowserPrivate.cancelFileTransfers([sourceFileUrl],
                                                        function() {});
        } else {
          chrome.fileBrowserPrivate.cancelFileTransfers([targetFileUrl],
                                                        function() {});
        }
      };

      // TODO(benchan): Until drive::FileSystem supports FileWriter, we use the
      // transferFile API to copy files into or out from a drive file system.
      onStartTransfer();
      chrome.fileBrowserPrivate.transferFile(
          sourceFileUrl, targetFileUrl, onFileTransferCompleted);
      return;
    }

    if (sourceEntry.isDirectory) {
      targetDirEntry.getDirectory(
          targetRelativePath,
          {create: true, exclusive: true},
          function(targetEntry) {
            if (targetRelativePath != originalPath) {
              task.registerRename(originalPath, targetRelativePath);
            }
            onCopyComplete(targetEntry);
          },
          util.flog('Error getting dir: ' + targetRelativePath,
                    onFilesystemError));
    } else {
      targetDirEntry.getFile(
          targetRelativePath,
          {create: true, exclusive: true},
          function(targetEntry) {
            self.copyEntry_(sourceEntry, targetEntry,
                            onCopyProgress, onCopyComplete, onError);
          },
          util.flog('Error getting file: ' + targetRelativePath,
                    onFilesystemError));
    }
  };

  var tryNextCopy = function() {
    targetRelativePath = targetRelativePrefix;
    if (copyNumber > 0) {
      targetRelativePath += ' (' + copyNumber + ')';
    }
    targetRelativePath += targetExt;

    // Check to see if the target exists.  This kicks off the rest of the copy
    // if the target is not found, or raises an error if it does.
    util.resolvePath(targetDirEntry, targetRelativePath, onTargetExists,
                     onTargetNotResolved);
  };

  tryNextCopy();
};

/**
 * Service a zip file creation task.
 *
 * @param {FileManager.Task} task A task.
 * @param {function} completeCallback On complete.
 * @param {function} errorCallback On error.
 * @private
 */
FileCopyManager.prototype.serviceZipTask_ = function(task, completeCallback,
                                                     errorCallback) {
  var self = this;
  var dirURL = task.zipBaseDirEntry.toURL();
  var selectionURLs = [];
  for (var i = 0; i < task.pendingDirectories.length; i++)
    selectionURLs.push(task.pendingDirectories[i].toURL());
  for (var i = 0; i < task.pendingFiles.length; i++)
    selectionURLs.push(task.pendingFiles[i].toURL());

  var destName = 'Archive';
  if (task.originalEntries.length == 1) {
    var entryPath = task.originalEntries[0].fullPath;
    var i = entryPath.lastIndexOf('/');
    var basename = (i < 0) ? entryPath : entryPath.substr(i + 1);
    i = basename.lastIndexOf('.');
    destName = ((i < 0) ? basename : basename.substr(0, i));
  }

  var copyNumber = 0;
  var firstExistingEntry = null;
  var destPath = destName + '.zip';

  var onError = function(reason, data) {
    self.log_('serviceZipTask error: ' + reason + ':', data);
    errorCallback(new FileCopyManager.Error(reason, data));
  };

  var onTargetExists = function(existingEntry) {
    if (copyNumber < 10) {
      if (!firstExistingEntry)
        firstExistingEntry = existingEntry;
      copyNumber++;
      tryZipSelection();
    } else {
      onError('TARGET_EXISTS', firstExistingEntry);
    }
  };

  var onTargetNotResolved = function() {
    var onZipSelectionComplete = function(success) {
      if (success) {
        self.sendProgressEvent_('SUCCESS');
      } else {
        self.sendProgressEvent_('ERROR',
            new FileCopyManager.Error('FILESYSTEM_ERROR', ''));
      }
      completeCallback(task);
    };

    self.sendProgressEvent_('PROGRESS');
    chrome.fileBrowserPrivate.zipSelection(dirURL, selectionURLs, destPath,
        onZipSelectionComplete);
  };

  var tryZipSelection = function() {
    if (copyNumber > 0)
      destPath = destName + ' (' + copyNumber + ').zip';

    // Check if the target exists. This kicks off the rest of the zip file
    // creation if the target is not found, or raises an error if it does.
    util.resolvePath(task.targetDirEntry, destPath, onTargetExists,
                     onTargetNotResolved);
  };

  tryZipSelection();
};

/**
 * Copy the contents of sourceEntry into targetEntry.
 *
 * @private
 * @param {Entry} sourceEntry entry that will be copied.
 * @param {Entry} targetEntry entry to which sourceEntry will be copied.
 * @param {function(Entry, number)} progressCallback function that will be
 *     called when a part of the source entry is copied. It takes |targetEntry|
 *     and size of the last copied chunk as parameters.
 * @param {function(Entry, number)} successCallback function that will be called
 *     the copy operation finishes. It takes |targetEntry| and size of the last
 *     (not previously reported) copied chunk as parameters.
 * @param {function(string, object)} errorCallback function that will be called
 *     if an error is encountered. Takes error type and additional error data
 *     as parameters.
 */
FileCopyManager.prototype.copyEntry_ = function(sourceEntry,
                                                targetEntry,
                                                progressCallback,
                                                successCallback,
                                                errorCallback) {
  if (this.maybeCancel_())
    return;

  var self = this;

  var onSourceFileFound = function(file) {
    var onWriterCreated = function(writer) {
      var reportedProgress = 0;
      writer.onerror = function(progress) {
        errorCallback('FILESYSTEM_ERROR', writer.error);
      };

      writer.onprogress = function(progress) {
        if (self.maybeCancel_()) {
          // If the copy was cancelled, we should abort the operation.
          writer.abort();
          return;
        }
        // |progress.loaded| will contain total amount of data copied by now.
        // |progressCallback| expects data amount delta from the last progress
        // update.
        progressCallback(targetEntry, progress.loaded - reportedProgress);
        reportedProgress = progress.loaded;
      };

      writer.onwriteend = function() {
        sourceEntry.getMetadata(function(metadata) {
          chrome.fileBrowserPrivate.setLastModified(targetEntry.toURL(),
              '' + Math.round(metadata.modificationTime.getTime() / 1000));
          successCallback(targetEntry, file.size - reportedProgress);
        });
      };

      writer.write(file);
    };

    targetEntry.createWriter(onWriterCreated, errorCallback);
  };

  sourceEntry.file(onSourceFileFound, errorCallback);
};

/**
 * Timeout before files are really deleted (to allow undo).
 */
FileCopyManager.DELETE_TIMEOUT = 30 * 1000;

/**
 * Schedules the files deletion.
 *
 * @param {Array.<Entry>} entries The entries.
 */
FileCopyManager.prototype.deleteEntries = function(entries) {
  var task = { entries: entries };
  this.deleteTasks_.push(task);
  this.maybeScheduleCloseBackgroundPage_();
  if (this.deleteTasks_.length == 1)
    this.serviceAllDeleteTasks_();
};

/**
 * Service all pending delete tasks, as well as any that might appear during the
 * deletion.
 *
 * @private
 */
FileCopyManager.prototype.serviceAllDeleteTasks_ = function() {
  var self = this;

  var onTaskSuccess = function(task) {
    self.deleteTasks_.shift();
    if (!self.deleteTasks_.length) {
      // All tasks have been serviced, clean up and exit.
      self.sendDeleteEvent_(task, 'SUCCESS');
      self.maybeScheduleCloseBackgroundPage_();
      return;
    }

    // We want to dispatch a PROGRESS event when there are more tasks to serve
    // right after one task finished in the queue. We treat all tasks as one
    // big task logically, so there is only one BEGIN/SUCCESS event pair for
    // these continuous tasks.
    self.sendDeleteEvent_(self.deleteTasks_[0], 'PROGRESS');
    self.serviceDeleteTask_(self.deleteTasks_[0], onTaskSuccess);
  };

  // If the queue size is 1 after pushing our task, it was empty before,
  // so we need to kick off queue processing and dispatch BEGIN event.
  this.sendDeleteEvent_(this.deleteTasks_[0], 'BEGIN');
  this.serviceDeleteTask_(this.deleteTasks_[0], onTaskSuccess);
};

/**
 * Performs the deletion.
 *
 * @param {Object} task The delete task (see deleteEntries function).
 * @param {function(Object)} onComplete Completion callback with the task
 *     as an argument.
 * @private
 */
FileCopyManager.prototype.serviceDeleteTask_ = function(
    task, onComplete) {
  var downcount = task.entries.length;

  var onEntryComplete = function() {
    if (--downcount == 0)
      onComplete(task);
  }.bind(this);

  if (downcount == 0)
    onComplete(task);

  for (var i = 0; i < task.entries.length; i++) {
    var entry = task.entries[i];
    util.removeFileOrDirectory(
        entry,
        onEntryComplete,
        onEntryComplete); // We ignore error, because we can't do anything here.
  }
};

/**
 * Send a 'delete' event to listeners.
 *
 * @param {Object} task The delete task (see deleteEntries function).
 * @param {string} reason Event reason.
 * @private
 */
FileCopyManager.prototype.sendDeleteEvent_ = function(task, reason) {
  this.sendEvent_('delete', {
    reason: reason,
    urls: task.entries.map(function(e) {
      return util.makeFilesystemUrl(e.fullPath);
    })
  });
};

/**
 * Creates a zip file for the selection of files.
 *
 * @param {Entry} dirEntry the directory containing the selection.
 * @param {boolean} isOnDrive If directory is on Drive.
 * @param {Array.<Entry>} selectionEntries the selected entries.
 */
FileCopyManager.prototype.zipSelection = function(dirEntry, isOnDrive,
                                                  selectionEntries) {
  var self = this;
  var zipTask = new FileCopyManager.Task(dirEntry, dirEntry);
  zipTask.zip = true;
  zipTask.sourceOnDrive = isOnDrive;
  zipTask.targetOnDrive = isOnDrive;
  zipTask.setEntries(selectionEntries, function() {
    // TODO: per-entry zip progress update with accurate byte count.
    // For now just set pendingBytes to zero so that the progress bar is full.
    zipTask.pendingBytes = 0;
    self.copyTasks_.push(zipTask);
    if (self.copyTasks_.length == 1) {
      // Assume self.cancelRequested_ == false.
      // This moved us from 0 to 1 active tasks, let the servicing begin!
      self.serviceAllTasks_();
    } else {
      // Force to update the progress of butter bar when there are new tasks
      // coming while servicing current task.
      self.sendProgressEvent_('PROGRESS');
    }
  });
};

{'fraction_non_alphanumeric': 0.10783377119656078, 'fraction_numerical': 0.003391449725340339, 'mean_word_length': 2.804679691049523, 'pattern_counts': {'":': 0, '<': 46, '<?xml version=': 0, '>': 43, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 82, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '2767397', 'n_tokens_mistral': 23790, 'n_tokens_neox': 23092, 'n_words': 6653}