chromium/ui/file_manager/file_manager/foreground/js/metadata_box_controller.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 type {VolumeManager} from '../../background/js/volume_manager.js';
import {isDirectoryEntry, isNativeEntry, isSameEntry, unwrapEntry} from '../../common/js/entry_utils.js';
import {getType} from '../../common/js/file_type.js';
import type {FilesAppDirEntry, FilesAppEntry} from '../../common/js/files_app_entry_types.js';
import {strf} from '../../common/js/translations.js';
import type {TrashEntry} from '../../common/js/trash.js';
import type {FilesMetadataBox} from '../elements/files_metadata_box.js';
import {type RawIfd} from '../elements/files_metadata_box.js';
import type {FilesQuickView} from '../elements/files_quick_view.js';

import type {MetadataItem} from './metadata/metadata_item.js';
import {type MetadataKey} from './metadata/metadata_item.js';
import type {MetadataModel} from './metadata/metadata_model.js';
import {PathComponent} from './path_component.js';
import type {QuickViewModel} from './quick_view_model.js';
import type {FileMetadataFormatter} from './ui/file_metadata_formatter.js';

function isTrashEntry(entry: Entry|FilesAppEntry): entry is TrashEntry {
  return 'restoreEntry' in entry;
}

/**
 * Controller of metadata box.
 * This should be initialized with |init| method.
 */
export class MetadataBoxController {
  private metadataBox_: FilesMetadataBox|null = null;

  private quickView_: FilesQuickView|null = null;

  private previousEntry_?: Entry|FilesAppEntry;

  private isDirectorySizeLoading_ = false;

  private onDirectorySizeLoaded_:
      ((entry: DirectoryEntry|FilesAppDirEntry) => void)|null = null;

  constructor(
      private metadataModel_: MetadataModel,
      private quickViewModel_: QuickViewModel,
      private fileMetadataFormatter_: FileMetadataFormatter,
      private volumeManager_: VolumeManager) {}

  /**
   * Initialize the controller with quick view which will be lazily loaded.
   */
  init(quickView: FilesQuickView) {
    this.quickView_ = quickView;

    this.fileMetadataFormatter_.addEventListener(
        'date-time-format-changed', this.updateView_.bind(this));

    this.quickView_.addEventListener(
        'metadata-box-active-changed', this.updateView_.bind(this));

    this.quickViewModel_.addEventListener(
        'selected-entry-changed', this.updateView_.bind(this));

    this.metadataBox_ = this.quickView_.getFilesMetadataBox();
    this.metadataBox_.clear(false);

    this.metadataBox_.setAttribute('files-ng', '');
  }

  /**
   * Update the view of metadata box.
   */
  private updateView_(event: Event) {
    if (!this.quickView_?.metadataBoxActive) {
      return;
    }

    const entry = this.quickViewModel_.getSelectedEntry()!;
    const sameEntry = isSameEntry(entry, this.previousEntry_);
    this.previousEntry_ = entry;

    if (!entry) {
      this.metadataBox?.clear(false);
      return;
    }

    if (event.type === 'date-time-format-changed') {
      // Update the displayed entry modificationTime format only, and return.
      this.metadataModel_.get([entry], ['modificationTime'])
          .then(this.updateModificationTime_.bind(this, entry));
      return;
    }

    // Do not clear isSizeLoading and size fields when the entry is not changed.
    this.metadataBox.clear(sameEntry);

    const metadata = [
      ...GENERAL_METADATA_NAMES,
      'alternateUrl',
      'externalFileUrl',
      'hosted',
    ] as const;
    this.metadataModel_.get([entry], metadata)
        .then(this.onGeneralMetadataLoaded_.bind(this, entry, sameEntry));
  }

  /**
   * Accessor to get a guaranteed `FilesMetadataBox`.
   */
  private get metadataBox(): FilesMetadataBox {
    return this.metadataBox_!;
  }

  /**
   * Updates the metadata box with general and file-specific metadata.
   *
   * @param isSameEntry if the entry is not changed from the last time.
   */
  private onGeneralMetadataLoaded_(
      entry: Entry|FilesAppEntry, isSameEntry: boolean, items: MetadataItem[]) {
    const type = getType(entry).type;
    const item = items[0];

    if (isDirectoryEntry(entry)) {
      this.setDirectorySize_(entry, isSameEntry);
    } else if (item?.size) {
      this.metadataBox.size =
          this.fileMetadataFormatter_.formatSize(item.size, item.hosted, true);
      this.metadataBox.metadataRendered('size');
    }

    if (isTrashEntry(entry)) {
      this.metadataBox.originalLocation =
          this.getFileLocationLabel_(entry.restoreEntry);
      this.metadataBox.metadataRendered('originalLocation');
    }

    this.updateModificationTime_(entry, items);

    if (!entry.isDirectory) {
      // Extra metadata types for local video media.
      let media: readonly MetadataKey[] = [];

      let sniffMimeType: MetadataKey = 'mediaMimeType';
      if (item?.externalFileUrl || item?.alternateUrl) {
        sniffMimeType = 'contentMimeType';
      } else if (type === 'video') {
        media = EXTRA_METADATA_NAMES;
      }

      this.metadataModel_.get([entry], [sniffMimeType, ...media])
          .then(items => {
            let mimeType = items[0] &&
                    items[0][sniffMimeType as keyof MetadataItem] as string ||
                '';
            const newType = getType(entry, mimeType);
            if (newType.encrypted) {
              mimeType =
                  strf('METADATA_BOX_ENCRYPTED', newType.originalMimeType);
            }
            this.metadataBox.mediaMimeType = mimeType;
            this.metadataBox.metadataRendered('mime');
            this.metadataBox.fileLocation = this.getFileLocationLabel_(entry);
            this.metadataBox.metadataRendered('location');
          });
    }

    if (['image', 'video', 'audio'].includes(type)) {
      if (item?.externalFileUrl || item?.alternateUrl) {
        this.metadataModel_.get([entry], ['imageHeight', 'imageWidth'])
            .then(items => {
              this.metadataBox.imageWidth = items[0]?.imageWidth || 0;
              this.metadataBox.imageHeight = items[0]?.imageHeight || 0;
              this.metadataBox.setFileTypeInfo(type);
              this.metadataBox.metadataRendered('meta');
            });
      } else {
        const data = EXTRA_METADATA_NAMES;
        this.metadataModel_.get([entry], data).then(items => {
          const item = items[0]!;
          this.metadataBox.setProperties({
            ifd: item.ifd || null,
            imageHeight: item.imageHeight || 0,
            imageWidth: item.imageWidth || 0,
            mediaAlbum: item.mediaAlbum || '',
            mediaArtist: item.mediaArtist || '',
            mediaDuration: item.mediaDuration || 0,
            mediaGenre: item.mediaGenre || '',
            mediaTitle: item.mediaTitle || '',
            mediaTrack: item.mediaTrack || '',
            mediaYearRecorded: item.mediaYearRecorded || '',
          });
          this.metadataBox.setFileTypeInfo(type);
          this.metadataBox.metadataRendered('meta');
        });
      }
    } else if (type === 'raw') {
      this.metadataModel_.get([entry], ['ifd']).then(items => {
        const raw: RawIfd|null = items[0]?.ifd ? items[0].ifd as RawIfd : null;
        this.metadataBox.ifd = raw ? {raw} : undefined;
        this.metadataBox.imageWidth = raw?.width || 0;
        this.metadataBox.imageHeight = raw?.height || 0;
        this.metadataBox.setFileTypeInfo('image');
        this.metadataBox.metadataRendered('meta');
      });
    }
  }

  /**
   * Updates the metadata box modificationTime.
   */
  private updateModificationTime_(
      _: Entry|FilesAppEntry, items: MetadataItem[]) {
    const item = items[0];

    this.metadataBox.modificationTime =
        this.fileMetadataFormatter_.formatModDate(item?.modificationTime);
  }

  /**
   * Set a current directory's size in metadata box.
   *
   * A loading animation is shown while fetching the directory size. However, it
   * won't show if there is no size value. Use a dummy value ' ' in that case.
   *
   * To avoid flooding the OS system with chrome.getDirectorySize requests, if a
   * previous request is active, store the new request and return. Only the most
   * recent new request is stored. When the active request returns, it calls the
   * stored request instead of updating the size field.
   *
   * `isSameEntry` is True if the entry is not changed from the last time. False
   * enables the loading animation.
   */
  private setDirectorySize_(
      entry: DirectoryEntry|FilesAppDirEntry, sameEntry: boolean) {
    if (!isDirectoryEntry(entry)) {
      return;
    }
    const directoryEntry = unwrapEntry(entry);
    if (!isNativeEntry(directoryEntry)) {
      const typeName = ('typeName' in directoryEntry) ?
          directoryEntry.typeName :
          'no typeName';
      console.warn('Supplied directory is not a native type:', typeName);
      return;
    }

    if (this.metadataBox.size === '') {
      this.metadataBox.size = ' ';  // Provide a dummy size value.
    }

    if (this.isDirectorySizeLoading_) {
      if (!sameEntry) {
        this.metadataBox.isSizeLoading = true;
      }

      // Store the new setDirectorySize_ request and return.
      this.onDirectorySizeLoaded_ = lastEntry => {
        this.setDirectorySize_(entry, isSameEntry(entry, lastEntry));
      };
      return;
    }

    this.metadataBox.isSizeLoading = !sameEntry;

    this.isDirectorySizeLoading_ = true;
    chrome.fileManagerPrivate.getDirectorySize(
        directoryEntry as DirectoryEntry, (size: number|undefined) => {
          this.isDirectorySizeLoading_ = false;

          if (this.onDirectorySizeLoaded_) {
            setTimeout(this.onDirectorySizeLoaded_.bind(null, entry));
            this.onDirectorySizeLoaded_ = null;
            return;
          }

          if (this.quickViewModel_.getSelectedEntry() !== entry) {
            return;
          }

          if (chrome.runtime.lastError) {
            console.warn(chrome.runtime.lastError);
            size = undefined;
          }

          this.metadataBox.size =
              this.fileMetadataFormatter_.formatSize(size, true, true);
          this.metadataBox.isSizeLoading = false;
          this.metadataBox.metadataRendered('size');
        });
  }

  /**
   * Returns a label to display the file's location.
   */
  private getFileLocationLabel_(entry: Entry|FilesAppEntry) {
    const components =
        PathComponent.computeComponentsFromEntry(entry, this.volumeManager_);
    return components.map(c => c.name).join('/');
  }
}

export const GENERAL_METADATA_NAMES = [
  'size',
  'modificationTime',
] as const;

export const EXTRA_METADATA_NAMES = [
  'ifd',
  'imageHeight',
  'imageWidth',
  'mediaAlbum',
  'mediaArtist',
  'mediaDuration',
  'mediaGenre',
  'mediaTitle',
  'mediaTrack',
  'mediaYearRecorded',
] as const;