wkdhkr/dedupper

View on GitHub
src/services/fs/FileService.js

Summary

Maintainability
D
2 days
Test Coverage
// @flow
import waitOn from "wait-on";
import { exec } from "child-process-promise";
import sleep from "await-sleep";
import mv from "mv";
import path from "path";
import mkdirp from "mkdirp";
import { copyFile, writeFile, symlink, unlink, pathExists } from "fs-extra";
import deleteEmpty from "delete-empty";
import recursiveReadDir from "recursive-readdir";
import pify from "pify";
import trash from "trash";

import { Logger } from "log4js";
import AttributeService from "./AttributeService";
import FileCacheService from "./FileCacheService";
import ContentsService from "./contents/ContentsService";
import FileNameMarkHelper from "../../helpers/FileNameMarkHelper";
import {
  TYPE_IMAGE,
  TYPE_DEDUPPER_CACHE,
  TYPE_DEDUPPER_LOCK
} from "../../types/ClassifyTypes";
import { STATE_ERASED } from "../../types/FileStates";
import {
  DELETE_MODE_MOVE,
  DELETE_MODE_ERASE
} from "../../types/DeleteModeTypes";
import type { Config, FileInfo } from "../../types";

const copyFileAsync: (string, string) => Promise<void> = pify(copyFile);
const mvAsync: (string, string) => Promise<void> = pify(mv);
const mkdirAsync: string => Promise<void> = pify(mkdirp);

export default class FileService {
  log: typeof Logger;

  config: Config;

  as: AttributeService;

  cs: ContentsService;

  fcs: FileCacheService;

  getSourcePath: (targetPath?: string) => string;

  getDestPath: (targetPath?: string) => Promise<string>;

  getDirPath: (targetPath?: string) => string;

  getFileName: (targetPath?: string) => string;

  isDirectory: (targetPath?: string) => boolean;

  isDeadLink: (targetPath?: string) => Promise<boolean>;

  isArchive: (targetPath?: string) => boolean;

  isLibraryPlace: (targetPath?: string) => boolean;

  cleanCacheFile: (targetPath?: string, force?: boolean) => Promise<void>;

  constructor(config: Config) {
    this.log = config.getLogger(this);
    this.config = config;
    this.as = new AttributeService(config);
    this.cs = new ContentsService(config, this.as);
    this.fcs = new FileCacheService(config, this.as);
    this.getSourcePath = this.as.getSourcePath;
    this.getDirPath = this.as.getDirPath;
    this.getDestPath = this.as.getDestPath;
    this.getFileName = (...args) => this.as.getFileName(...args);
    this.isDirectory = this.as.isDirectory;
    this.isDeadLink = this.as.isDeadLink;
    this.isArchive = this.as.isArchive;
    this.isLibraryPlace = (...args) => this.as.isLibraryPlace(...args);
    this.cleanCacheFile = this.fcs.clean;
  }

  pathExists: (targetPath: string) => Promise<empty> = async (
    targetPath: string
  ) => pathExists(targetPath);

  write: (targetPath: string, content: any) => Promise<void> = async (
    targetPath: string,
    content: any
  ): Promise<void> => writeFile(targetPath, content);

  extractArchive: () => Promise<void> = async () => {
    const execCommand = [
      this.config.archiveExtractCommand,
      JSON.stringify(this.getSourcePath())
    ].join(" ");
    if (!this.config.dryrun) {
      await exec(execCommand);
    }
  };

  wait: (targetPath?: string) => Promise<void> = (
    targetPath?: string
  ): Promise<void> =>
    new Promise((resolve, reject) => {
      waitOn(
        {
          resources: [path.resolve(targetPath || this.getSourcePath())],
          timeout: 60000
        },
        err => {
          if (err) {
            reject();
            return;
          }
          resolve();
        }
      );
    });

  createSymLink: (from: string, to: string) => Promise<void> = async (
    from: string,
    to: string
  ): Promise<void> => {
    if (this.config.dryrun) {
      return;
    }
    if (await pathExists(to)) {
      this.log.warn(`symlink exists already: path = ${to}`);
      return;
    }
    symlink(path.resolve(from), to);
  };

  unlink: (targetPath?: string) => Promise<void> = async (
    targetPath?: string
  ): Promise<void> => {
    const finalTargetPath = this.getSourcePath(targetPath);
    this.log.debug(`unlink: path = ${finalTargetPath}`);
    if (this.config.dryrun) {
      return;
    }
    await unlink(finalTargetPath);
    await this.waitDelete(finalTargetPath);
  };

  prepareDir: (targetPath: string, force?: boolean) => Promise<void> = async (
    targetPath: string,
    force: boolean = false
  ): Promise<void> => {
    if (await this.isDirectory(targetPath)) {
      return;
    }
    this.log.debug(`mkdir: path = ${targetPath}`);
    if (!this.config.dryrun || force) {
      await mkdirAsync(targetPath);
    }
  };

  collectFilePaths: (targetPath?: string) => Promise<Array<string>> = async (
    targetPath?: string
  ): Promise<string[]> => recursiveReadDir(targetPath || this.getSourcePath());

  async isMinorFile(targetPath: string): Promise<boolean> {
    const stats = await this.as.getStat(targetPath);
    if (stats.isSymbolicLink()) {
      return true;
    }
    if (
      [TYPE_DEDUPPER_CACHE, TYPE_DEDUPPER_LOCK].includes(
        this.as.detectClassifyType(targetPath)
      )
    ) {
      return true;
    }
    return false;
  }

  async delete(targetPath?: string, isRetry: boolean = false) {
    const finalTargetPath = this.getSourcePath(targetPath);
    if (!(await pathExists(finalTargetPath))) {
      return;
    }
    try {
      const isMinorFile = await this.isMinorFile(finalTargetPath);
      if (isMinorFile === false && !isRetry) {
        this.log.warn(`delete file/dir: path = ${finalTargetPath}`);
      }

      if (!this.config.dryrun) {
        if (isMinorFile) {
          await this.unlink(finalTargetPath);
          return;
        }
        await this.handleDelete(finalTargetPath);
      }
    } catch (e) {
      // retry. avoid EBUSY error
      // this.log.warn(e);
      if (await pathExists(finalTargetPath)) {
        await sleep(200);
        await this.delete(finalTargetPath);
      }
    }
  }

  waitDelete: (targetPath: string) => Promise<void> = async (
    targetPath: string
  ): Promise<void> => {
    let i = 0;
    // eslint-disable-next-line no-await-in-loop
    while (await pathExists(targetPath)) {
      // eslint-disable-next-line no-await-in-loop
      await sleep(200);
      i += 1;
      if (i === 60 * 5) {
        throw new Error(`wait delete timeout path = ${targetPath}`);
      }
    }
  };

  handleDelete: (targetPath: string) => Promise<void> = async (
    targetPath: string
  ): Promise<void> => {
    this.log.info(`delete path = ${targetPath}`);
    if (this.config.deleteMode === DELETE_MODE_MOVE) {
      const parsedPath = path.parse(targetPath);
      const destPath = path.join(
        parsedPath.root,
        // TODO: config
        ".trash",
        parsedPath.dir.replace(parsedPath.root, ""),
        parsedPath.base
      );
      // TODO: move to rename function
      await this.prepareDir(this.as.getDirPath(destPath));
      await this.rename(targetPath, destPath);
    } else if (this.config.deleteMode === DELETE_MODE_ERASE) {
      await this.unlink(targetPath);
    } else {
      await trash([targetPath], { glob: false });
    }
    await this.waitDelete(targetPath);
  };

  // eslint-disable-next-line complexity
  async rename(
    from: string,
    to?: string,
    isRetry: boolean = false,
    isCopy: boolean = false
  ) {
    const finalFrom = to ? from : this.getSourcePath();
    const finalTo = to || from;
    if (finalFrom === finalTo) {
      return;
    }
    if (!isRetry) {
      this.log.info(`rename file: from = ${finalFrom}, to = ${finalTo}`);
    }
    if (this.config.dryrun) {
      return;
    }
    try {
      if (isCopy) {
        await copyFileAsync(finalFrom, finalTo);
        await this.delete(finalFrom);
      } else {
        await mvAsync(finalFrom, finalTo);
      }
      return;
    } catch (e) {
      // retry. avoid EBUSY error
      if (e.code !== "EBUSY") {
        this.log.warn(e);
      }
      if (await pathExists(finalFrom)) {
        await sleep(2000);
        if (e.code === "UNKNOWN") {
          /*
          // hmm, try copy and delete.
          this.log.warn(
            `detect UNKNOWN error in rename. try copy & delete. path = ${finalFrom}`
          );
          await this.rename(finalFrom, finalTo, true, true);
          */
          throw e;
        }
        await this.rename(finalFrom, finalTo, true);

        return;
      }
      throw e;
    }
  }

  createDedupperLock: (dirPath: string) => Promise<void> = async (
    dirPath: string
  ): Promise<void> =>
    this.as.touchHide(path.join(dirPath, `${process.pid}.dplock`));

  async deleteEmptyDirectory(targetPath?: string) {
    if (!this.config.dryrun) {
      try {
        const GARBAGE_REGEX = /(?:Thumbs\.db|\.DS_Store|.*\.dpcache)$/i;
        const isGarbageFile = (file, regex = GARBAGE_REGEX) => regex.test(file);
        const filter = (file, regex) => !isGarbageFile(file, regex);
        await sleep(3000);
        const deletedDirs = await pify(deleteEmpty)(
          targetPath || this.as.getSourcePath(),
          { verbose: false, filter }
        );
        (deletedDirs || []).forEach(d =>
          this.log.info(`delete empty dir: path = ${d}`)
        );
      } catch (e) {
        this.log.warn(e);
      }
    }
  }

  fillInsertFileInfo: (fileInfo: FileInfo) => Promise<FileInfo> = async (
    fileInfo: FileInfo
  ): Promise<FileInfo> => {
    let filledInfo = fileInfo;
    const isImageHashNeeded =
      fileInfo.state !== STATE_ERASED && fileInfo.type === TYPE_IMAGE;
    if (isImageHashNeeded && !fileInfo.p_hash) {
      const pHash = await this.cs.calculatePHash();
      if (!pHash) {
        throw new Error(`cannot fill pHash = ${fileInfo.from_path}`);
      }
      filledInfo = {
        ...filledInfo,
        p_hash: pHash
      };
      await this.fcs.write(filledInfo);
    }
    if (isImageHashNeeded && !fileInfo.d_hash) {
      const dHash = await this.cs.calculateDHash();
      if (!dHash) {
        throw new Error(`cannot fill dHash path = ${fileInfo.from_path}`);
      }
      filledInfo = {
        ...filledInfo,
        d_hash: dHash
      };
      await this.fcs.write(filledInfo);
    }
    return filledInfo;
  };

  fillCachedFileInfo: (cachedFileInfo: FileInfo) => Promise<FileInfo> = async (
    cachedFileInfo: FileInfo
  ): Promise<FileInfo> => {
    if (
      cachedFileInfo.type === TYPE_IMAGE &&
      !cachedFileInfo.d_hash &&
      this.config.pHash
    ) {
      const filledInfo = {
        ...cachedFileInfo,
        d_hash: await this.cs.calculateDHash(cachedFileInfo.from_path)
      };
      await this.fcs.write(filledInfo);
      return filledInfo;
    }
    return cachedFileInfo;
  };

  collectFileInfo: () => Promise<FileInfo> = async (): Promise<FileInfo> => {
    const cachedFileInfo = await this.fcs.load();
    if (cachedFileInfo) {
      return this.fillCachedFileInfo(cachedFileInfo);
    }
    const fileInfo = await Promise.all([
      this.config.pHash ? this.cs.calculatePHash() : Promise.resolve(),
      this.config.pHash ? this.cs.calculateDHash() : Promise.resolve(),
      (this.cs.readInfo(): any),
      this.as.getFileStat(),
      this.getDestPath(),
      this.as.getName()
    ]).then(([pHash, dHash, info, { size, birthtime }, destPath, name]) => ({
      p_hash: pHash,
      d_hash: dHash,
      name,
      type: this.as.detectClassifyType(),
      from_path: this.as.getSourcePath(),
      to_path: destPath,
      timestamp: birthtime.getTime(),
      size,
      state: this.as.getState(),
      ...info
    }));

    await this.fcs.write(fileInfo);
    return fileInfo;
  };

  async getFinalDestPath(
    priorDestPath?: string,
    isReplace?: boolean
  ): Promise<string> {
    let destPath = priorDestPath || (await this.getDestPath());
    if (this.config.manual) {
      destPath = FileNameMarkHelper.strip(this.getSourcePath());
    } else if (!isReplace) {
      let i = 1;
      const originalDestPath = destPath;
      // eslint-disable-next-line no-await-in-loop
      while (await pathExists(destPath)) {
        const parsedPath = this.as.getParsedPath(originalDestPath);
        destPath = path.join(
          parsedPath.dir,
          `${parsedPath.name}_${i}${parsedPath.ext}`
        );
        i += 1;
      }
    }
    return destPath;
  }

  async moveToLibrary(
    priorDestPath?: string,
    isReplace?: boolean
  ): Promise<string> {
    const destPath = await this.getFinalDestPath(priorDestPath, isReplace);
    if (isReplace && (await pathExists(destPath))) {
      // avoid overwrite, use recyclebin
      await this.delete(destPath);
    }
    await this.prepareDir(this.as.getDirPath(destPath));
    await this.rename(this.as.getSourcePath(), destPath);
    return destPath;
  }
}