chromium/ash/webui/camera_app_ui/resources/js/models/file_system_access_entry.ts

// Copyright 2020 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 '../assert.js';
import {AsyncJobWithResultQueue} from '../async_job_queue.js';
import {isFileSystemDirectoryHandle, isFileSystemFileHandle} from '../util.js';

import {AsyncWriter} from './async_writer.js';

/**
 * The file entry implementation for SWA.
 */
export class FileAccessEntry {
  constructor(
      private readonly handle: FileSystemFileHandle,
      private readonly parent: DirectoryAccessEntryImpl|null = null) {}

  /**
   * Returns the File object which the entry points to.
   */
  async file(): Promise<File> {
    return this.handle.getFile();
  }

  /**
   * Writes |blob| data into the file.
   *
   * @return The promise is resolved once the write operation is
   *     completed.
   */
  async write(blob: Blob): Promise<void> {
    const writer = await this.handle.createWritable();
    await writer.write(blob);
    await writer.close();
  }

  async getWriter(): Promise<AsyncWriter> {
    const writer = await this.handle.createWritable();
    // TODO(crbug.com/980846): We should write files in-place so that even the
    // app is accidentally closed or hit any unexpected exceptions, the captured
    // video will not be dropped entirely.
    return new AsyncWriter({
      write: (blob) => writer.write(blob),
      seek: (offset) => writer.seek(offset),
      close: () => writer.close(),
    });
  }

  /**
   * Gets the timestamp of the last modification time of the file.
   *
   * @return The number of milliseconds since the Unix epoch in UTC.
   */
  async getLastModificationTime(): Promise<number> {
    const file = await this.file();
    return file.lastModified;
  }

  /**
   * @throws Thrown when trying to delete file with no parent directory.
   */
  async remove(): Promise<void> {
    if (this.parent === null) {
      throw new Error('Failed to delete file due to no parent directory');
    }
    return this.parent.removeEntry(this.name);
  }

  /**
   * Moves the file to given directory |dir| and given |name|.
   */
  async moveTo(dir: DirectoryAccessEntry, name: string): Promise<void> {
    const dirHandle = await dir.getHandle();
    await this.handle.move(dirHandle, name);
  }

  get name(): string {
    return this.handle.name;
  }
}

/**
 * Guards from name collision when creating files.
 */
const createFileJobs = new AsyncJobWithResultQueue();

/**
 * The abstract interface for the directory entry.
 */
export interface DirectoryAccessEntry {
  readonly name: string;

  getHandle(): Promise<FileSystemDirectoryHandle>;

  getFiles(): Promise<FileAccessEntry[]>;

  getDirectories(): Promise<DirectoryAccessEntry[]>;

  getFile(name: string): Promise<FileAccessEntry|null>;

  /**
   * Checks if file or directory with the target |name| exists.
   */
  exists(name: string): Promise<boolean>;

  /**
   * Create the file given by its |name|. If there is already a file with same
   * name, it will try to use a name with index as suffix.
   * (e.g. IMG.png => IMG (1).png).
   */
  createFile(name: string): Promise<FileAccessEntry>;

  /**
   * Gets the directory given by its |name|. If the directory is not found,
   * creates one if |createIfNotExist| is true.
   * TODO(crbug.com/1127587): Split this method to getDirectory() and
   * createDirectory().
   */
  getDirectory({name, createIfNotExist}:
                   {name: string, createIfNotExist: boolean}):
      Promise<DirectoryAccessEntry|null>;

  /**
   * Removes file by given |name| from the directory.
   */
  removeEntry(name: string): Promise<void>;
}

/**
 * The directory entry implementation for SWA.
 */
export class DirectoryAccessEntryImpl implements DirectoryAccessEntry {
  constructor(private readonly handle: FileSystemDirectoryHandle) {}

  get name(): string {
    return this.handle.name;
  }

  getHandle(): Promise<FileSystemDirectoryHandle> {
    return Promise.resolve(this.handle);
  }

  async getFiles(): Promise<FileAccessEntry[]> {
    const results = [];
    for await (const handle of this.handle.values()) {
      if (isFileSystemFileHandle(handle)) {
        results.push(new FileAccessEntry(handle, this));
      }
    }
    return results;
  }

  async getDirectories(): Promise<DirectoryAccessEntry[]> {
    const results = [];
    for await (const handle of this.handle.values()) {
      if (isFileSystemDirectoryHandle(handle)) {
        results.push(new DirectoryAccessEntryImpl(handle));
      }
    }
    return results;
  }

  async getFile(name: string): Promise<FileAccessEntry|null> {
    const handle = await this.handle.getFileHandle(name, {create: false});
    return new FileAccessEntry(handle, this);
  }

  async exists(name: string): Promise<boolean> {
    try {
      await this.getFile(name);
      return true;
    } catch (e) {
      assert(e instanceof Error);
      // File doesn't exist.
      if (e.name === 'NotFoundError') {
        return false;
      }
      // Directory with same name exists.
      if (e.name === 'TypeMismatchError') {
        return true;
      }
      throw e;
    }
  }

  async createFile(name: string): Promise<FileAccessEntry> {
    return createFileJobs.push(async () => {
      let uniqueName = name;
      for (let i = 0; await this.exists(uniqueName);) {
        uniqueName = name.replace(/^(.*?)(?=\.)/, `$& (${++i})`);
      }
      const handle =
          await this.handle.getFileHandle(uniqueName, {create: true});
      return new FileAccessEntry(handle, this);
    });
  }

  async getDirectory({name, createIfNotExist}:
                         {name: string, createIfNotExist: boolean}):
      Promise<DirectoryAccessEntry|null> {
    try {
      const handle = await this.handle.getDirectoryHandle(
          name, {create: createIfNotExist});
      assert(handle !== null);
      return new DirectoryAccessEntryImpl(handle);
    } catch (error) {
      if (!createIfNotExist && error instanceof Error &&
          error.name === 'NotFoundError') {
        return null;
      }
      throw error;
    }
  }

  async removeEntry(name: string): Promise<void> {
    if (await this.exists(name)) {
      await this.handle.removeEntry(name);
    }
  }
}