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

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

import {assert} from 'chrome://resources/js/assert.js';

import type {EntryLocation} from '../../background/js/entry_location_impl.js';
import type {VolumeManager} from '../../background/js/volume_manager.js';
import {ArrayDataModel} from '../../common/js/array_data_model.js';
import {compareLabel, compareName} from '../../common/js/entry_utils.js';
import type {FileExtensionType} from '../../common/js/file_type.js';
import {getType, isImage, isRaw} from '../../common/js/file_type.js';
import type {FilesAppEntry} from '../../common/js/files_app_entry_types.js';
import {getRecentDateBucket, getTranslationKeyForDateBucket} from '../../common/js/recent_date_bucket.js';
import {collator, str, strf} from '../../common/js/translations.js';

import type {MetadataItem} from './metadata/metadata_item.js';
import type {MetadataModel} from './metadata/metadata_model.js';

export const GROUP_BY_FIELD_MODIFICATION_TIME = 'modificationTime';
export const GROUP_BY_FIELD_DIRECTORY = 'isDirectory';

const FIELDS_SUPPORT_GROUP_BY = new Set([
  GROUP_BY_FIELD_MODIFICATION_TIME,
  GROUP_BY_FIELD_DIRECTORY,
]);

type UniversalEntry = FilesAppEntry|Entry;

/**
 * Currently we only support group by modificationTime or isDirectory, so the
 * group value can only be one of them.
 */
export type GroupValue = chrome.fileManagerPrivate.RecentDateBucket|boolean;

/**
 * This represents a group header.
 */
export interface GroupHeader {
  /** The start index of this group. */
  startIndex: number;
  /** The end index of this group. */
  endIndex: number;
  /** The actual group value. */
  group: (GroupValue|undefined);
  /** The group label. */
  label: string;
}

/**
 * This represents the snapshot of a groupBy result.
 */
interface GroupBySnapshot {
  /** When groupBy is calculated, what the sort order is. */
  sortDirection: string;
  /** The groupBy results, each item is a `GroupHeader` type. */
  groups: GroupHeader[];
}

/**
 * File list.
 */
export class FileListModel extends ArrayDataModel<UniversalEntry> {
  /**
   * Whether this file list is sorted in descending order.
   */
  private isDescendingOrder_: boolean = false;

  /**
   * The number of folders in the list.
   */
  private numFolders_: number = 0;

  /**
   * The number of files in the list.
   */
  private numFiles_: number = 0;

  /**
   * The number of image files in the list.
   */
  private numImageFiles_: number = 0;

  /**
   * Whether to use modificationByMeTime as "Last Modified" time.
   */
  private useModificationByMeTime_: boolean = false;

  /**
   * The volume manager.
   */
  private volumeManager_: null|VolumeManager = null;

  /**
   * Used to get the label for entries when
   * sorting by label.
   */
  private locationInfo_: null|EntryLocation = null;

  hasGroupHeadingBeforeSort: boolean = false;

  /**
   * The field to do group by on.
   */
  private groupByField_: string|null = null;

  /**
   * The key is the field name which is used by groupBy. The value is a
   * object with type GroupBySnapshot.
   *
   */
  private groupBySnapshot_: Record<string, GroupBySnapshot> =
      Array.from(FIELDS_SUPPORT_GROUP_BY)
          .reduce((acc: Record<string, GroupBySnapshot>, field) => {
            acc[field] = {
              sortDirection: 'asc',
              groups: [],
            };
            return acc;
          }, {});

  constructor(private readonly metadataModel_: MetadataModel) {
    super([]);

    // Initialize compare functions.
    this.setCompareFunction('name', this.compareName_.bind(this));
    this.setCompareFunction('modificationTime', this.compareMtime_.bind(this));
    this.setCompareFunction('size', this.compareSize_.bind(this));
    this.setCompareFunction('type', this.compareType_.bind(this));
  }

  /**
   * @param fileType Type object returned by getType().
   * @return Localized string representation of file type.
   */
  static getFileTypeString(fileType: FileExtensionType): string {
    // Partitions on removable volumes are treated separately, they don't
    // have translatable names.
    if (fileType.type === 'partition') {
      return fileType.subtype;
    }
    if (fileType.subtype) {
      return strf(fileType.translationKey, fileType.subtype);
    } else {
      return str(fileType.translationKey);
    }
  }

  /**
   * Sorts data model according to given field and direction and dispatches
   * sorted event.
   * @param field Sort field.
   * @param direction Sort direction.
   */
  override sort(field: string, direction: string) {
    this.hasGroupHeadingBeforeSort = this.shouldShowGroupHeading();
    this.isDescendingOrder_ = direction === 'desc';
    ArrayDataModel.prototype.sort.call(this, field, direction);
  }

  /**
   * Removes and adds items to the model.
   *
   * The implementation is similar to ArrayDataModel.splice(), but this
   * has a Files app specific optimization, which sorts only the new items and
   * merge sorted lists.
   * Note that this implementation assumes that the list is always sorted.
   *
   * @param index The index of the item to update.
   * @param deleteCount The number of items to remove.
   * @param args The items to add.
   * @return An array with the removed items.
   */
  override splice(
      index: number, deleteCount: number,
      ...args: UniversalEntry[]): UniversalEntry[] {
    const insertPos = Math.max(0, Math.min(index, this.indexes_.length));
    deleteCount = Math.min(deleteCount, this.indexes_.length - insertPos);

    for (let i = insertPos; i < insertPos + deleteCount; i++) {
      this.onRemoveEntryFromList_(this.array_[this.indexes_[i]!]!);
    }
    for (const arg of args) {
      this.onAddEntryToList_(arg);
    }

    // Prepare a comparison function to sort the list.
    let comp = null;
    if (this.sortStatus.field && this.compareFunctions_) {
      const compareFunction = this.compareFunctions_[this.sortStatus.field];
      if (compareFunction) {
        const dirMultiplier = this.sortStatus.direction === 'desc' ? -1 : 1;
        comp = (a: UniversalEntry, b: UniversalEntry) => {
          return compareFunction(a, b) * dirMultiplier;
        };
      }
    }

    // Store the given new items in |newItems| and sort it before marge them to
    // the existing list.
    const newItems = [];
    for (const arg of args) {
      newItems.push(arg);
    }
    if (comp) {
      newItems.sort(comp);
    }

    // Creating a list of existing items.
    // This doesn't include items which should be deleted by this splice() call.
    const deletedItems: UniversalEntry[] = [];
    const currentItems: UniversalEntry[] = [];
    for (let i = 0; i < this.indexes_.length; i++) {
      const item = this.array_[this.indexes_[i]!]!;
      if (insertPos <= i && i < insertPos + deleteCount) {
        deletedItems.push(item);
      } else {
        currentItems.push(item);
      }
    }

    // Initialize splice permutation with -1s.
    // Values of undeleted items will be filled in following merge step.
    const permutation = new Array(this.indexes_.length);
    for (let i = 0; i < permutation.length; i++) {
      permutation[i] = -1;
    }

    // Merge the list of existing item and the list of new items.
    this.indexes_ = [];
    this.array_ = [];
    let p = 0;
    let q = 0;
    while (p < currentItems.length || q < newItems.length) {
      const currentIndex = p + q;
      this.indexes_.push(currentIndex);
      // Determine which should be inserted to the resulting list earlier, the
      // smallest item of unused current items or the smallest item of unused
      // new items.
      let shouldPushCurrentItem;
      if (q === newItems.length) {
        shouldPushCurrentItem = true;
      } else if (p === currentItems.length) {
        shouldPushCurrentItem = false;
      } else {
        if (comp) {
          shouldPushCurrentItem = comp(currentItems[p]!, newItems[q]!) <= 0;
        } else {
          // If the comparator is not defined, new items should be inserted to
          // the insertion position. That is, the current items before insertion
          // position should be pushed to the resulting list earlier.
          shouldPushCurrentItem = p < insertPos;
        }
      }
      if (shouldPushCurrentItem) {
        this.array_.push(currentItems[p]!);
        if (p < insertPos) {
          permutation[p] = currentIndex;
        } else {
          permutation[p + deleteCount] = currentIndex;
        }
        p++;
      } else {
        this.array_.push(newItems[q]!);
        q++;
      }
    }

    // Calculate the index property of splice event.
    // If no item is inserted, it is simply the insertion/deletion position.
    // If at least one item is inserted, it should be the resulting index of the
    // item which is inserted first.
    let spliceIndex = insertPos;
    if (args.length > 0) {
      for (let i = 0; i < this.indexes_.length; i++) {
        if (this.array_[this.indexes_[i]!] === args[0]) {
          spliceIndex = i;
          break;
        }
      }
    }

    // Dispatch permute/splice event.
    this.dispatchPermutedEvent_(permutation);
    // TODO(arv): Maybe unify splice and change events?
    const spliceEvent = new CustomEvent('splice', {
      detail: {
        removed: deletedItems,
        added: args,
        index: spliceIndex,
      },
    });
    this.dispatchEvent(spliceEvent);

    this.updateGroupBySnapshot_();

    return deletedItems;
  }

  /**
   */
  override replaceItem(oldItem: UniversalEntry, newItem: UniversalEntry) {
    this.onRemoveEntryFromList_(oldItem);
    this.onAddEntryToList_(newItem);

    super.replaceItem(oldItem, newItem);
  }

  /**
   * Returns the number of files in this file list.
   * @return The number of files.
   */
  getFileCount(): number {
    return this.numFiles_;
  }

  /**
   * Returns the number of folders in this file list.
   * @return The number of folders.
   */
  getFolderCount(): number {
    return this.numFolders_;
  }

  /**
   * Sets whether to use modificationByMeTime as "Last Modified" time.
   */
  setUseModificationByMeTime(useModificationByMeTime: boolean) {
    this.useModificationByMeTime_ = useModificationByMeTime;
  }

  /**
   * Updates the statistics about contents when new entry is about to be added.
   * @param entry Entry of the new item.
   */
  private onAddEntryToList_(entry: UniversalEntry) {
    if (entry.isDirectory) {
      this.numFolders_++;
    } else {
      this.numFiles_++;
    }

    const mimeType =
        this.metadataModel_.getCache([entry], ['contentMimeType'])[0]
            ?.contentMimeType;
    if (isImage(entry, mimeType) || isRaw(entry, mimeType)) {
      this.numImageFiles_++;
    }
  }

  /**
   * Updates the statistics about contents when an entry is about to be removed.
   * @param entry Entry of the item to be removed.
   */
  private onRemoveEntryFromList_(entry: UniversalEntry) {
    if (entry.isDirectory) {
      this.numFolders_--;
    } else {
      this.numFiles_--;
    }

    const mimeType =
        this.metadataModel_.getCache([entry], ['contentMimeType'])[0]
            ?.contentMimeType;
    if (isImage(entry, mimeType) || isRaw(entry, mimeType)) {
      this.numImageFiles_--;
    }
  }

  /**
   * Compares entries by name.
   * @param a First entry.
   * @param b Second entry.
   * @return Compare result.
   */
  private compareName_(a: UniversalEntry, b: UniversalEntry): number {
    // Directories always precede files.
    if (a.isDirectory !== b.isDirectory) {
      return a.isDirectory === this.isDescendingOrder_ ? 1 : -1;
    }

    return compareName(a, b);
  }

  /**
   * Compares entries by label (i18n name).
   * @param a First entry.
   * @param b Second entry.
   * @return Compare result.
   */
  private compareLabel_(a: UniversalEntry, b: UniversalEntry): number {
    // Set locationInfo once because we only compare within the same volume.
    if (!this.locationInfo_ && this.volumeManager_) {
      this.locationInfo_ = this.volumeManager_.getLocationInfo(a);
    }

    // Directories always precede files.
    if (a.isDirectory !== b.isDirectory) {
      return a.isDirectory === this.isDescendingOrder_ ? 1 : -1;
    }

    return compareLabel(this.locationInfo_!, a, b);
  }

  /**
   * Compares entries by mtime first, then by name.
   * @param a First entry.
   * @param b Second entry.
   * @return Compare result.
   */
  private compareMtime_(a: UniversalEntry, b: UniversalEntry): number {
    // Directories always precede files.
    if (a.isDirectory !== b.isDirectory) {
      return a.isDirectory === this.isDescendingOrder_ ? 1 : -1;
    }

    const properties = this.metadataModel_.getCache(
        [a, b], ['modificationTime', 'modificationByMeTime']);
    const aTime = this.getMtime_(properties[0]!);
    const bTime = this.getMtime_(properties[1]!);

    if (aTime > bTime) {
      return 1;
    }

    if (aTime < bTime) {
      return -1;
    }

    return compareName(a, b);
  }

  /**
   * Returns the modification time from a properties object.
   * "Modification time" can be modificationTime or modificationByMeTime
   * depending on this.useModificationByMeTime_.
   * @param properties Properties object.
   * @return Modification time.
   */
  private getMtime_(properties: MetadataItem): number|Date {
    if (this.useModificationByMeTime_) {
      return properties.modificationByMeTime || properties.modificationTime ||
          0;
    }
    return properties.modificationTime || 0;
  }

  /**
   * Compares entries by size first, then by name.
   * @param a First entry.
   * @param b Second entry.
   * @return Compare result.
   */
  private compareSize_(a: UniversalEntry, b: UniversalEntry): number {
    // Directories always precede files.
    if (a.isDirectory !== b.isDirectory) {
      return a.isDirectory === this.isDescendingOrder_ ? 1 : -1;
    }

    const properties = this.metadataModel_.getCache([a, b], ['size']);
    const aSize = properties[0]!.size || 0;
    const bSize = properties[1]!.size || 0;

    return aSize !== bSize ? aSize - bSize : compareName(a, b);
  }

  /**
   * Compares entries by type first, then by subtype and then by name.
   * @param a First entry.
   * @param b Second entry.
   * @return Compare result.
   */
  private compareType_(a: UniversalEntry, b: UniversalEntry): number {
    // Directories always precede files.
    if (a.isDirectory !== b.isDirectory) {
      return a.isDirectory === this.isDescendingOrder_ ? 1 : -1;
    }

    const properties =
        this.metadataModel_.getCache([a, b], ['contentMimeType']);
    const aType = FileListModel.getFileTypeString(
        getType(a, properties[0]!.contentMimeType));
    const bType = FileListModel.getFileTypeString(
        getType(b, properties[1]!.contentMimeType));

    const result = collator.compare(aType, bType);
    return result !== 0 ? result : compareName(a, b);
  }

  initNewDirContents(volumeManager: VolumeManager) {
    this.volumeManager_ = volumeManager;
    // Clear the location info, it's reset by compareLabel_ when needed.
    this.locationInfo_ = null;
    // Initialize compare function based on Labels.
    this.setCompareFunction('name', this.compareLabel_.bind(this));
  }

  get groupByField(): string|null {
    return this.groupByField_;
  }

  /**
   * @param field the field to group by.
   */
  set groupByField(field: string|null) {
    this.groupByField_ = field;
    if (!field || this.groupBySnapshot_[field]?.groups.length === 0) {
      this.updateGroupBySnapshot_();
    }
  }

  /**
   * Should the current list model show group heading or not.
   */
  shouldShowGroupHeading(): boolean {
    if (!this.groupByField_) {
      return false;
    }
    // GroupBy modification time is only valid when the current sort field is
    // modification time.
    if (this.groupByField_ === GROUP_BY_FIELD_MODIFICATION_TIME) {
      return this.sortStatus.field === this.groupByField_;
    }
    return FIELDS_SUPPORT_GROUP_BY.has(this.groupByField_);
  }

  /**
   * @param item Item in the file list model.
   * @param now Timestamp represents now.
   */
  private getGroupForModificationTime_(item: UniversalEntry, now: number):
      chrome.fileManagerPrivate.RecentDateBucket {
    const properties = this.metadataModel_.getCache(
        [item], ['modificationTime', 'modificationByMeTime']);
    return getRecentDateBucket(
        new Date(this.getMtime_(properties[0]!)), new Date(now));
  }

  /**
   * @param item Item in the file list model.
   */
  private getGroupForDirectory_(item: UniversalEntry): boolean {
    return item.isDirectory;
  }

  private getGroupLabel_(value: GroupValue|undefined): string {
    switch (this.groupByField_) {
      case GROUP_BY_FIELD_MODIFICATION_TIME:
        const dateBucket = value as chrome.fileManagerPrivate.RecentDateBucket;
        return str(getTranslationKeyForDateBucket(dateBucket));
      case GROUP_BY_FIELD_DIRECTORY:
        const isDirectory = value as boolean;
        return isDirectory ? str('GRID_VIEW_FOLDERS_TITLE') :
                             str('GRID_VIEW_FILES_TITLE');
      default:
        return '';
    }
  }

  /**
   * Update the GroupBy snapshot by the existing sort field.
   */
  private updateGroupBySnapshot_() {
    if (!this.shouldShowGroupHeading()) {
      return;
    }
    assert(this.groupByField_);
    const snapshot: GroupBySnapshot =
        this.groupBySnapshot_[this.groupByField_!]!;
    assert(snapshot);
    snapshot.sortDirection = this.sortStatus.direction!;
    snapshot.groups = [];

    const now = Date.now();
    let prevItemGroup = null;
    for (let i = 0; i < this.length; i++) {
      const item = this.item(i)!;
      let curItemGroup;
      if (this.groupByField_ === GROUP_BY_FIELD_MODIFICATION_TIME) {
        curItemGroup = this.getGroupForModificationTime_(item, now);
      } else if (this.groupByField_ === GROUP_BY_FIELD_DIRECTORY) {
        curItemGroup = this.getGroupForDirectory_(item);
      }
      if (prevItemGroup !== curItemGroup) {
        if (i > 0) {
          snapshot.groups[snapshot.groups.length - 1]!.endIndex = i - 1;
        }
        snapshot.groups.push({
          startIndex: i,
          endIndex: -1,
          group: curItemGroup,
          label: this.getGroupLabel_(curItemGroup),
        });
      }
      prevItemGroup = curItemGroup;
    }
    if (snapshot.groups.length > 0) {
      // The last element is always the end of the last group.
      snapshot.groups[snapshot.groups.length - 1]!.endIndex = this.length - 1;
    }
  }

  /**
   * Refresh the group by data, e.g. when date modified changes due to
   * timezone change.
   */
  refreshGroupBySnapshot() {
    if (this.groupByField_ === GROUP_BY_FIELD_MODIFICATION_TIME) {
      this.updateGroupBySnapshot_();
    }
  }

  /**
   * Return the groupBy snapshot.
   */
  getGroupBySnapshot(): GroupHeader[] {
    if (!this.shouldShowGroupHeading()) {
      return [];
    }
    assert(this.groupByField_);
    const snapshot = this.groupBySnapshot_[this.groupByField_!]!;
    if (this.groupByField_ === GROUP_BY_FIELD_MODIFICATION_TIME) {
      if (this.sortStatus.direction === snapshot.sortDirection) {
        return snapshot.groups;
      }
      // Why are we calculating reverse order data in the snapshot instead
      // of calculating it inside sort() function? It's because redraw can
      // happen before sort() finishes, if we generate reverse order data
      // at the end of sort(), that might be too late for redraw.
      const reversedGroups = Array.from(snapshot.groups);
      reversedGroups.reverse();
      return reversedGroups.map(group => {
        return {
          startIndex: this.length - 1 - group.endIndex,
          endIndex: this.length - 1 - group.startIndex,
          group: group.group,
          label: group.label,
        };
      });
    }
    // Grid view Folders/Files group order never changes, e.g. Folders group
    // always shows first, and then Files group.
    if (this.groupByField_ === GROUP_BY_FIELD_DIRECTORY) {
      return snapshot.groups;
    }
    return [];
  }
}