chromium/ui/file_manager/file_manager/foreground/js/metadata/metadata_model.ts

// Copyright 2015 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 {entriesToURLs} from '../../../common/js/entry_utils.js';
import type {FilesAppEntry} from '../../../common/js/files_app_entry_types.js';
import {MetadataStats} from '../../../common/js/shared_types.js';
import {getStore} from '../../../state/store.js';

import {ContentMetadataProvider} from './content_metadata_provider.js';
import {DlpMetadataProvider} from './dlp_metadata_provider.js';
import {ExternalMetadataProvider} from './external_metadata_provider.js';
import {FileSystemMetadataProvider} from './file_system_metadata_provider.js';
import {MetadataCacheSet, type MetadataModelMap} from './metadata_cache_set.js';
import {MetadataItem, type MetadataKey} from './metadata_item.js';
import type {MetadataProvider} from './metadata_provider.js';
import {MultiMetadataProvider} from './multi_metadata_provider.js';

export {MetadataStats} from '../../../common/js/shared_types.js';

export class MetadataModel {
  private cache_ = new MetadataCacheSet();
  private callbackRequests_: MetadataProviderCallbackRequest[] = [];

  /** Record stats about Metadata when in tests. */
  private stats_: MetadataStats|null =
      window.IN_TEST ? new MetadataStats() : null;

  constructor(private rawProvider_: MetadataProvider) {}

  static create(volumeManager: VolumeManager): MetadataModel {
    return new MetadataModel(new MultiMetadataProvider(
        new FileSystemMetadataProvider(), new ExternalMetadataProvider(),
        new ContentMetadataProvider(), new DlpMetadataProvider(),
        volumeManager));
  }

  getProvider(): MetadataProvider {
    return this.rawProvider_;
  }

  /**
   * Obtains metadata for entries.
   * @param entries Entries.
   * @param names Metadata property names to be obtained.
   */
  get(entries: Array<Entry|FilesAppEntry>,
      names: readonly MetadataKey[]): Promise<MetadataItem[]> {
    this.rawProvider_.checkPropertyNames(names);

    // Check if the results are cached or not.
    if (this.cache_.hasFreshCache(entries, names)) {
      if (this.stats_) {
        this.stats_.fromCache += entries.length;
      }
      return Promise.resolve(this.getCache(entries, names));
    }

    if (this.stats_) {
      this.stats_.fullFetch += entries.length;
    }

    // The LRU cache may be cached out when the callback is completed.
    // To hold cached values, create snapshot of the cache for entries.
    const requestId = this.cache_.generateRequestId();
    const snapshot = this.cache_.createSnapshot(entries);
    const requests = snapshot.createRequests(entries, names);
    snapshot.startRequests(requestId, requests);
    this.cache_.startRequests(requestId, requests);

    // Register callback.
    const promise = new Promise<MetadataItem[]>(fulfill => {
      this.callbackRequests_.push(new MetadataProviderCallbackRequest(
          entries, names, snapshot, fulfill));
    });

    // If the requests are not empty, call the requests.
    if (requests.length) {
      this.rawProvider_.get(requests).then(list => {
        // Obtain requested entries and ensure all the requested properties are
        // contained in the result.
        const requestedEntries: Array<Entry|FilesAppEntry> = [];
        for (const [i, request] of requests.entries()) {
          requestedEntries.push(request.entry);
          for (const name of request.names) {
            if (!(name in list[i]!)) {
              list[i]![name] = undefined;
            }
          }
        }

        // Store cache.
        this.cache_.storeProperties(requestId, requestedEntries, list, names);

        // Invoke callbacks and remove the ones that were successful.
        this.callbackRequests_.filter(
            request =>
                !request.storeProperties(requestId, requestedEntries, list));
      });
    }

    return promise;
  }

  /**
   * Updates the metadata of the given fileUrls with the provided values for
   * each specified metadata name.
   * @param fileUrls FileURLs to have their metadata updated
   * @param names Metadata property names to be updated.
   * @param values Contains an array for each file, where the array contains a
   *     new value for each metadata property passed in `names`.
   */
  update(
      fileUrls: string[], names: MetadataKey[],
      values: Array<Array<string|number|boolean>>) {
    const {allEntries} = getStore().getState();

    // Only update corresponding entries that are available in the store.
    const itemsToUpdate = [];
    const entriesToUpdate = [];
    for (const [i, url] of fileUrls.entries()) {
      const entry = allEntries[url]?.entry;
      if (!entry) {
        continue;
      }
      entriesToUpdate.push(entry);
      const item = new MetadataItem();
      // TODO(austinct): Change the function call signature and update all
      // callers to allow this statement to be well typed without the type
      // assertions.
      names.forEach((key, j) => (item[key] as any) = values[i]![j]);
      itemsToUpdate.push(item);
    }

    if (entriesToUpdate.length === 0) {
      return;
    }

    this.cache_.invalidate(
        this.cache_.generateRequestId(), entriesToUpdate, names);
    this.cache_.storeProperties(
        this.cache_.generateRequestId(), entriesToUpdate, itemsToUpdate, names);
  }

  /**
   * Obtains metadata cache for entries.
   * @param entries Entries.
   * @param names Metadata property names to be obtained.
   */
  getCache(entries: Array<Entry|FilesAppEntry>, names: MetadataKey[]):
      MetadataItem[] {
    // Check if the property name is correct or not.
    this.rawProvider_.checkPropertyNames(names);
    return this.cache_.get(entries, names);
  }

  /**
   * Obtains metadata cache for file URLs.
   * @param urls File URLs.
   * @param names Metadata property names to be obtained.
   */
  getCacheByUrls(urls: string[], names: MetadataKey[]): MetadataItem[] {
    // Check if the property name is correct or not.
    this.rawProvider_.checkPropertyNames(names);
    return this.cache_.getByUrls(urls, names);
  }

  /**
   * Clears old metadata for newly created entries.
   */
  notifyEntriesCreated(entries: Array<Entry|FilesAppEntry>) {
    this.cache_.clear(entriesToURLs(entries));
    if (this.stats_) {
      this.stats_.clearCacheCount += entries.length;
    }
  }

  /**
   * Clears metadata for deleted entries.
   * @param urls Note it is not an entry list because we cannot
   *     obtain entries after removing them from the file system.
   */
  notifyEntriesRemoved(urls: string[]) {
    this.cache_.clear(urls);
    if (this.stats_) {
      this.stats_.clearCacheCount += urls.length;
    }
  }

  /**
   * Invalidates metadata for updated entries.
   */
  notifyEntriesChanged(entries: Array<Entry|FilesAppEntry>) {
    this.cache_.invalidate(this.cache_.generateRequestId(), entries);
    if (this.stats_) {
      this.stats_.invalidateCount += entries.length;
    }
  }

  /**
   * Clears all cache.
   */
  clearAllCache() {
    this.cache_.clearAll();
    if (this.stats_) {
      this.stats_.clearAllCount++;
    }
  }

  getStats(): MetadataStats|null {
    return this.stats_;
  }

  /** Adds event listener to internal cache object. */
  addEventListener<K extends keyof MetadataModelMap>(
      type: K, listener: (event: MetadataModelMap[K]) => void): void {
    this.cache_.addEventListener(type, listener);
  }

  /** Removes event listener from internal cache object. */
  removeEventListener<K extends keyof MetadataModelMap>(
      type: K, listener: (event: MetadataModelMap[K]) => void): void {
    this.cache_.removeEventListener(type, listener);
  }
}

class MetadataProviderCallbackRequest {
  constructor(
      private entries_: Array<Entry|FilesAppEntry>,
      private names_: MetadataKey[], private cache_: MetadataCacheSet,
      private fulfill_: (items: MetadataItem[]) => void) {}

  /**
   * Stores properties to snapshot cache of the callback request.
   * If all the requested property are served, it invokes the callback.
   * @return Whether the callback is invoked or not.
   */
  storeProperties(
      requestId: number, entries: Array<Entry|FilesAppEntry>,
      objects: MetadataItem[]): boolean {
    this.cache_.storeProperties(requestId, entries, objects, this.names_);
    if (this.cache_.hasFreshCache(this.entries_, this.names_)) {
      this.fulfill_(this.cache_.get(this.entries_, this.names_));
      return true;
    }
    return false;
  }
}