chromium/ui/file_manager/file_manager/foreground/js/folder_shortcuts_data_model.ts

// Copyright 2013 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import {getPreferences} from '../../common/js/api.js';
import type {PermutationEvent} from '../../common/js/array_data_model.js';
import {AsyncQueue, Group} from '../../common/js/async_util.js';
import {comparePath, isSameEntry} from '../../common/js/entry_utils.js';
import type {FilesAppEntry} from '../../common/js/files_app_entry_types.js';
import {type CustomEventMap, FilesEventTarget} from '../../common/js/files_event_target.js';
import type {FilteredVolumeManager} from '../../common/js/filtered_volume_manager.js';
import {recordSmallCount, recordUserAction} from '../../common/js/metrics.js';
import {VolumeType} from '../../common/js/volume_manager_types.js';
import {addFolderShortcut, refreshFolderShortcut, removeFolderShortcut} from '../../state/ducks/folder_shortcuts.js';
import {getStore} from '../../state/store.js';

/**
 * The drive mount path used in the persisted storage. It must be '/drive'.
 */
const STORED_DRIVE_MOUNT_PATH = '/drive';

interface FolderShortcutsDataModelEventMap extends CustomEventMap {
  'permuted': PermutationEvent;
}

/**
 * Model for the folder shortcuts. This object is ArrayDataModel-like
 * object with additional methods for the folder shortcut feature.
 *
 * Items are always sorted by URL.
 */
export class FolderShortcutsDataModel extends
    FilesEventTarget<FolderShortcutsDataModelEventMap> {
  private array_: Array<Entry|FilesAppEntry> = [];
  private pendingPaths_ = new Set<string>();  // Hash map for easier deleting.
  private unresolvablePaths_ = new Set<string>();
  private lastDriveRootURL_: string|null = null;

  private store_ = getStore();
  private queue_ = new AsyncQueue();

  /**
   * @param volumeManager Volume manager instance.
   */
  constructor(private volumeManager_: FilteredVolumeManager) {
    super();

    // Queue to serialize resolving entries.
    this.queue_.run(
        this.volumeManager_.ensureInitialized.bind(this.volumeManager_));

    // Load the shortcuts. Runs within the queue.
    this.load_();

    // The list of folder shortcuts is persisted in the preferences.
    chrome.fileManagerPrivate.onPreferencesChanged.addListener(
        this.reload_.bind(this));

    // If the volume info list is changed, then shortcuts have to be reloaded.
    this.volumeManager_.volumeInfoList.addEventListener(
        'permuted', this.reload_.bind(this));

    // If the drive status has changed, then shortcuts have to be re-resolved.
    this.volumeManager_.addEventListener(
        'drive-connection-changed', this.reload_.bind(this));
  }

  /**
   * @return Number of elements in the array.
   */
  get length(): number {
    return this.array_.length;
  }

  /**
   * Remembers the Drive volume's root URL used for conversions between virtual
   * paths and URLs.
   */
  private rememberLastDriveUrl_() {
    if (this.lastDriveRootURL_) {
      return;
    }
    const volumeInfo =
        this.volumeManager_.getCurrentProfileVolumeInfo(VolumeType.DRIVE);
    if (volumeInfo) {
      this.lastDriveRootURL_ = volumeInfo.fileSystem.root.toURL();
    }
  }

  /**
   * Resolves Entries from a list of stored virtual paths. Runs within a queue.
   * @param list List of virtual paths.
   */
  private processEntries_(list: string[]) {
    this.queue_.run(callback => {
      this.pendingPaths_ = new Set<string>();
      this.unresolvablePaths_ = new Set<string>();
      list.forEach(path => {
        this.pendingPaths_.add(path);
      }, this);
      callback();
    });

    this.queue_.run(queueCallback => {
      const volumeInfo =
          this.volumeManager_.getCurrentProfileVolumeInfo(VolumeType.DRIVE);
      let changed = false;
      const resolvedURLs = new Set<string>();
      this.rememberLastDriveUrl_();  // Required for conversions.

      const onResolveSuccess = (path: string, entry: Entry) => {
        if (this.pendingPaths_.has(path)) {
          this.pendingPaths_.delete(path);
        }
        if (this.unresolvablePaths_.has(path)) {
          changed = true;
          this.unresolvablePaths_.delete(path);
        }
        if (!this.exists(entry)) {
          changed = true;
          this.addInternal_(entry);
        }
        resolvedURLs.add(entry.toURL());
      };

      const onResolveFailure = (path: string, url: string|null) => {
        if (this.pendingPaths_.has(path)) {
          this.pendingPaths_.delete(path);
        }
        const existingIndex = this.getIndexByUrl_(url || '');
        if (existingIndex !== -1) {
          changed = true;
          this.removeInternal_(this.item(existingIndex)!);
        }
        // Remove the shortcut on error, only if Drive is fully online.
        // Only then we can be sure, that the error means that the directory
        // does not exist anymore.
        if (!volumeInfo ||
            this.volumeManager_.getDriveConnectionState().type !==
                chrome.fileManagerPrivate.DriveConnectionStateType.ONLINE) {
          if (!this.unresolvablePaths_.has(path)) {
            changed = true;
            this.unresolvablePaths_.add(path);
          }
        }
        // Not adding to the model nor to the |unresolvablePaths_| means
        // that it will be removed from the persistent storage permanently after
        // the next call to save_().
      };

      // Resolve the items all at once, in parallel.
      const group = new Group();
      list.forEach(path => {
        group.add((callback) => {
          const url =
              this.lastDriveRootURL_ && this.convertStoredPathToUrl_(path);
          if (url && volumeInfo) {
            window.webkitResolveLocalFileSystemURL(
                url,
                entry => {
                  onResolveSuccess(path, entry);
                  callback();
                },
                () => {
                  onResolveFailure(path, url);
                  callback();
                });
          } else {
            onResolveFailure(path, url);
            callback();
          }
        });
      });

      // Save the model after finishing.
      group.run(() => {
        // Remove all of those old entries, which were resolved by this method.
        let index = 0;
        while (index < this.length) {
          const entry = this.item(index)!;
          if (!resolvedURLs.has(entry.toURL())) {
            this.removeInternal_(entry);
            changed = true;
          } else {
            index++;
          }
        }
        // If something changed, then save.
        if (changed) {
          this.store_.dispatch(refreshFolderShortcut({entries: this.array_}));
          this.save_();
        }
        queueCallback();
      });
    });
  }

  /**
   * Initializes the model and loads the shortcuts.
   */
  private async load_() {
    this.queue_.run(async (callback) => {
      try {
        const shortcutPaths = await this.getPersistedShortcutPaths_();
        // Record metrics.
        recordSmallCount('FolderShortcut.Count', shortcutPaths.length);

        // Resolve and add the entries to the model.
        this.processEntries_(shortcutPaths);  // Runs within a queue.
      } finally {
        callback();
      }
    });
  }

  /**
   * Fetches the shortcut paths from the persistent storage (preferences) it
   * migrates from the legacy storage.chrome.sync if needed.
   *
   */
  private async getPersistedShortcutPaths_(): Promise<string[]> {
    const prefs = await getPreferences();
    if (prefs.folderShortcuts && prefs.folderShortcuts.length) {
      return prefs.folderShortcuts;
    }

    return [];
  }

  /**
   * Reloads the model and loads the shortcuts.
   */
  private reload_() {
    this.queue_.run(async (callback) => {
      try {
        const shortcutPaths = await this.getPersistedShortcutPaths_();
        this.processEntries_(shortcutPaths);  // Runs within a queue.
      } finally {
        callback();
      }
    });
  }

  /**
   * Returns the entries in the given range as a new array instance. The
   * arguments and return value are compatible with Array.slice().
   *
   * @param begin Where to start the selection.
   * @param end Where to end the selection.
   * @return Entries in the selected range.
   */
  slice(begin: number, end?: number): Array<Entry|FilesAppEntry> {
    return this.array_.slice(begin, end);
  }

  /**
   * @param index Index of the element to be retrieved.
   * @return The value of the |index|-th element.
   */
  item(index: number): Entry|FilesAppEntry|undefined {
    return this.array_[index];
  }

  /**
   * @param value URL of the entry to be found.
   * @return Index of the element with the specified |value|.
   */
  private getIndexByUrl_(value: string): number {
    for (let i = 0; i < this.length; i++) {
      // Same item check: must be exact match.
      if (this.array_[i]!.toURL() === value) {
        return i;
      }
    }
    return -1;
  }

  /**
   * @param value Value of the element to be retrieved.
   * @return Index of the element with the specified |value|.
   */
  getIndex(value: Entry|FilesAppEntry): number {
    for (let i = 0; i < this.length; i++) {
      // Same item check: must be exact match.
      if (isSameEntry(this.array_[i], value)) {
        return i;
      }
    }
    return -1;
  }

  /**
   * Compares 2 entries and returns a number indicating one entry comes before
   * or after or is the same as the other entry in sort order.
   *
   * @param a First entry.
   * @param b Second entry.
   * @return Returns -1, if |a| < |b|. Returns 0, if |a| === |b|.
   *     Otherwise, returns 1.
   */
  compare(a: Entry|FilesAppEntry, b: Entry|FilesAppEntry): number {
    return comparePath(a, b);
  }

  /**
   * Adds the given item to the array. If there were already same item in the
   * list, return the index of the existing item without adding a duplicate
   * item.
   *
   * @param value Value to be added into the array.
   * @return Index in the list which the element added to.
   */
  add(value: Entry|FilesAppEntry): number {
    const result = this.addInternal_(value);
    this.store_.dispatch(addFolderShortcut({entry: value}));
    recordUserAction('FolderShortcut.Add');
    this.save_();
    return result;
  }

  /**
   * Adds the given item to the array. If there were already same item in the
   * list, return the index of the existing item without adding a duplicate
   * item.
   *
   * @param value Value to be added into the array.
   * @return Index in the list which the element added to.
   */
  private addInternal_(value: Entry|FilesAppEntry): number {
    this.rememberLastDriveUrl_();  // Required for saving.

    const oldArray = this.array_.slice(0);  // Shallow copy.
    let addedIndex = -1;
    for (let i = 0; i < this.length; i++) {
      // Same item check: must be exact match.
      if (isSameEntry(this.array_[i], value)) {
        return i;
      }

      // Since the array is sorted, new item will be added just before the first
      // larger item.
      if (this.compare(this.array_[i]!, value) >= 0) {
        this.array_.splice(i, 0, value);
        addedIndex = i;
        break;
      }
    }
    // If value is not added yet, add it at the last.
    if (addedIndex === -1) {
      this.array_.push(value);
      addedIndex = this.length;
    }

    this.firePermutedEvent_(this.calculatePermutation_(oldArray, this.array_));
    return addedIndex;
  }

  /**
   * Removes the given item from the array.
   * @param value Value to be removed from the array.
   * @return Index in the list which the element removed from.
   */
  remove(value: Entry|FilesAppEntry): number {
    const result = this.removeInternal_(value);
    if (result !== -1) {
      this.store_.dispatch(removeFolderShortcut({key: value.toURL()}));
      this.save_();
      recordUserAction('FolderShortcut.Remove');
    }
    return result;
  }

  /**
   * Removes the given item from the array.
   *
   * @param value Value to be removed from the array.
   * @return Index in the list which the element removed from.
   */
  private removeInternal_(value: Entry|FilesAppEntry): number {
    let removedIndex = -1;
    const oldArray = this.array_.slice(0);  // Shallow copy.
    for (let i = 0; i < this.length; i++) {
      // Same item check: must be exact match.
      if (isSameEntry(this.array_[i], value)) {
        this.array_.splice(i, 1);
        removedIndex = i;
        break;
      }
    }

    if (removedIndex !== -1) {
      this.firePermutedEvent_(
          this.calculatePermutation_(oldArray, this.array_));
      return removedIndex;
    }

    // No item is removed.
    return -1;
  }

  /**
   * @param entry Entry to be checked.
   * @return True if the given |entry| exists in the array. False otherwise.
   */
  exists(entry: Entry|FilesAppEntry): boolean {
    const index = this.getIndex(entry);
    return (index >= 0);
  }

  /**
   * Saves the current array to the persistent storage (Chrome prefs).
   */
  private save_() {
    this.rememberLastDriveUrl_();
    if (!this.lastDriveRootURL_) {
      return;
    }

    // TODO(mtomasz): Migrate to URL.
    const paths = this.array_
                      .map(entry => {
                        return entry.toURL();
                      })
                      .map(this.convertUrlToStoredPath_.bind(this))
                      .filter((path): path is string => !!path)
                      .concat(...this.pendingPaths_)
                      .concat(...this.unresolvablePaths_);

    const prefs = {folderShortcuts: paths};
    chrome.fileManagerPrivate.setPreferences(prefs);
  }

  /**
   * Creates a permutation array for 'permuted' event, which is compatible with
   * a permutation array used in cr/ui/array_data_model.js.
   *
   * @param oldArray Previous array before changing.
   * @param newArray New array after changing.
   * @return Created permutation array.
   */
  private calculatePermutation_(
      oldArray: Array<Entry|FilesAppEntry>,
      newArray: Array<Entry|FilesAppEntry>): number[] {
    let oldIndex = 0;  // Index of oldArray.
    let newIndex = 0;  // Index of newArray.

    // Note that both new and old arrays are sorted.
    const permutation = [];
    for (; oldIndex < oldArray.length; oldIndex++) {
      if (newIndex >= newArray.length) {
        // oldArray[oldIndex] is deleted, which is not in the new array.
        permutation[oldIndex] = -1;
        continue;
      }

      while (newIndex < newArray.length) {
        // Unchanged item, which exists in both new and old array. But the
        // index may be changed.
        if (isSameEntry(oldArray[oldIndex], newArray[newIndex])) {
          permutation[oldIndex] = newIndex;
          newIndex++;
          break;
        }

        // oldArray[oldIndex] is deleted, which is not in the new array.
        if (this.compare(oldArray[oldIndex]!, newArray[newIndex]!) < 0) {
          permutation[oldIndex] = -1;
          break;
        }

        // In the case of this.compare(oldArray[oldIndex]) > 0:
        // newArray[newIndex] is added, which is not in the old array.
        newIndex++;
      }
    }
    return permutation;
  }

  /**
   * Fires a 'permuted' event, which is compatible with ArrayDataModel.
   * @param permutation Permutation array.
   */
  private firePermutedEvent_(permutation: number[]) {
    const permutedEvent =
        new CustomEvent(
            'permuted', {detail: {newLength: this.length, permutation}}) as
        PermutationEvent;
    this.dispatchEvent(permutedEvent);

    // Note: This model only fires 'permuted' event, because:
    // 1) 'change' event is not necessary to fire since it is covered by
    //    'permuted' event.
    // 2) 'splice' and 'sorted' events are not implemented. We have to implement
    // them when necessary.
  }

  /**
   * Called externally when one of the items is not found on the filesystem.
   * @param entry The entry which is not found.
   */
  onItemNotFoundError(entry: Entry) {
    // If Drive is online, then delete the shortcut permanently. Otherwise,
    // delete from model and add to |unresolvablePaths_|.
    if (this.volumeManager_.getDriveConnectionState().type !==
        chrome.fileManagerPrivate.DriveConnectionStateType.ONLINE) {
      const path = this.convertUrlToStoredPath_(entry.toURL());
      // TODO(mtomasz): Add support for multi-profile.
      if (path) {
        this.unresolvablePaths_.add(path);
      }
    }
    this.removeInternal_(entry);
    this.save_();
  }

  /**
   * Converts the given "stored path" to the URL.
   *
   * This conversion is necessary because the shortcuts are not stored with
   * stored-formatted mount paths for compatibility. See http://crbug.com/336155
   * for detail.
   *
   * @param path Path in Drive with the stored drive mount path.
   * @return URL of the given path.
   */
  private convertStoredPathToUrl_(path: string): string|null {
    if (path.indexOf(STORED_DRIVE_MOUNT_PATH + '/') !== 0) {
      console.warn(path + ' is neither a drive mount path nor a stored path.');
      return null;
    }
    return this.lastDriveRootURL_ +
        encodeURIComponent(path.substr(STORED_DRIVE_MOUNT_PATH.length));
  }

  /**
   * Converts the URL to the stored-formatted path.
   *
   * See the comment of convertStoredPathToUrl_() for further information.
   *
   * @param url URL of the directory in Drive.
   * @return Path with the stored drive mount path.
   */
  private convertUrlToStoredPath_(url: string): null|string {
    // Root URLs contain a trailing slash.
    if (!this.lastDriveRootURL_ || url.indexOf(this.lastDriveRootURL_) !== 0) {
      console.warn(url + ' is not a drive URL.');
      return null;
    }

    return STORED_DRIVE_MOUNT_PATH + '/' +
        decodeURIComponent(url.substr(this.lastDriveRootURL_.length));
  }
}