emmercm/igir

View on GitHub
src/modules/directoryCleaner.ts

Summary

Maintainability
D
2 days
Test Coverage
import fs from 'node:fs';
import path from 'node:path';

import { Semaphore } from 'async-mutex';
import { isNotJunk } from 'junk';
import trash from 'trash';

import ProgressBar, { ProgressBarSymbol } from '../console/progressBar.js';
import Defaults from '../globals/defaults.js';
import ArrayPoly from '../polyfill/arrayPoly.js';
import fsPoly from '../polyfill/fsPoly.js';
import File from '../types/files/file.js';
import Options from '../types/options.js';
import Module from './module.js';

/**
 * Recycle any unknown files in the {@link OptionsProps.output} directory, if applicable.
 */
export default class DirectoryCleaner extends Module {
  private readonly options: Options;

  constructor(options: Options, progressBar: ProgressBar) {
    super(progressBar, DirectoryCleaner.name);
    this.options = options;
  }

  /**
   * Clean some directories, excluding some files.
   */
  async clean(dirsToClean: string[], filesToExclude: File[]): Promise<string[]> {
    // If nothing was written, then don't clean anything
    if (filesToExclude.length === 0) {
      this.progressBar.logTrace('no files were written, not cleaning output');
      return [];
    }

    this.progressBar.logTrace('cleaning files in output');
    this.progressBar.setSymbol(ProgressBarSymbol.FILE_SCANNING);
    this.progressBar.reset(0);

    // If there is nothing to clean, then don't do anything
    const filesToClean = await this.options.scanOutputFilesWithoutCleanExclusions(
      dirsToClean,
      filesToExclude,
      (increment) => {
        this.progressBar.incrementTotal(increment);
      },
    );
    if (filesToClean.length === 0) {
      this.progressBar.logDebug('no files to clean');
      return [];
    }

    this.progressBar.setSymbol(ProgressBarSymbol.RECYCLING);

    try {
      this.progressBar.logTrace(
        `cleaning ${filesToClean.length.toLocaleString()} file${filesToClean.length !== 1 ? 's' : ''}`,
      );
      this.progressBar.reset(filesToClean.length);
      if (this.options.getCleanDryRun()) {
        this.progressBar.logInfo(
          `paths skipped from cleaning (dry run):\n${filesToClean.map((filePath) => `  ${filePath}`).join('\n')}`,
        );
      } else {
        const cleanBackupDir = this.options.getCleanBackup();
        if (cleanBackupDir !== undefined) {
          await this.backupFiles(cleanBackupDir, filesToClean);
        } else {
          await this.trashOrDelete(filesToClean);
        }
      }
    } catch (error) {
      this.progressBar.logError(`failed to clean unmatched files: ${error}`);
      return [];
    }

    try {
      let emptyDirs = await DirectoryCleaner.getEmptyDirs(dirsToClean);
      while (emptyDirs.length > 0) {
        this.progressBar.reset(emptyDirs.length);
        this.progressBar.logTrace(
          `cleaning ${emptyDirs.length.toLocaleString()} empty director${emptyDirs.length !== 1 ? 'ies' : 'y'}`,
        );
        if (this.options.getCleanDryRun()) {
          this.progressBar.logInfo(
            `paths skipped from cleaning (dry run):\n${emptyDirs.map((filePath) => `  ${filePath}`).join('\n')}`,
          );
        } else {
          await this.trashOrDelete(emptyDirs);
        }
        // Deleting some empty directories could leave others newly empty
        emptyDirs = await DirectoryCleaner.getEmptyDirs(dirsToClean);
      }
    } catch (error) {
      this.progressBar.logError(`failed to clean empty directories: ${error}`);
    }

    this.progressBar.logTrace('done cleaning files in output');
    return filesToClean.sort();
  }

  private async trashOrDelete(filePaths: string[]): Promise<void> {
    // Prefer recycling...
    for (let i = 0; i < filePaths.length; i += Defaults.OUTPUT_CLEANER_BATCH_SIZE) {
      const filePathsChunk = filePaths.slice(i, i + Defaults.OUTPUT_CLEANER_BATCH_SIZE);
      this.progressBar.logInfo(
        `recycling cleaned path${filePathsChunk.length !== 1 ? 's' : ''}:\n${filePathsChunk.map((filePath) => `  ${filePath}`).join('\n')}`,
      );
      try {
        await trash(filePathsChunk);
      } catch (error) {
        this.progressBar.logWarn(
          `failed to recycle ${filePathsChunk.length} path${filePathsChunk.length !== 1 ? 's' : ''}: ${error}`,
        );
      }
      this.progressBar.update(i);
    }

    // ...but if that doesn't work, delete the leftovers
    const existSemaphore = new Semaphore(Defaults.OUTPUT_CLEANER_BATCH_SIZE);
    const existingFilePathsCheck = await Promise.all(
      filePaths.map(async (filePath) =>
        existSemaphore.runExclusive(async () => fsPoly.exists(filePath)),
      ),
    );
    const existingFilePaths = filePaths.filter((filePath, idx) => existingFilePathsCheck.at(idx));
    if (existingFilePaths.length > 0) {
      this.progressBar.setSymbol(ProgressBarSymbol.DELETING);
    }
    for (let i = 0; i < existingFilePaths.length; i += Defaults.OUTPUT_CLEANER_BATCH_SIZE) {
      const filePathsChunk = existingFilePaths.slice(i, i + Defaults.OUTPUT_CLEANER_BATCH_SIZE);
      this.progressBar.logInfo(
        `deleting cleaned path${filePathsChunk.length !== 1 ? 's' : ''}:\n${filePathsChunk.map((filePath) => `  ${filePath}`).join('\n')}`,
      );
      await Promise.all(
        filePathsChunk.map(async (filePath) => {
          try {
            await fsPoly.rm(filePath, { force: true });
          } catch (error) {
            this.progressBar.logError(`${filePath}: failed to delete: ${error}`);
          }
        }),
      );
    }
  }

  private async backupFiles(backupDir: string, filePaths: string[]): Promise<void> {
    const semaphore = new Semaphore(this.options.getWriterThreads());
    await Promise.all(
      filePaths.map(async (filePath) => {
        await semaphore.runExclusive(async () => {
          let backupPath = path.join(backupDir, path.basename(filePath));
          let increment = 0;
          while (await fsPoly.exists(backupPath)) {
            increment += 1;
            const { name, ext } = path.parse(filePath);
            backupPath = path.join(backupDir, `${name} (${increment})${ext}`);
          }

          this.progressBar.logInfo(`moving cleaned path: ${filePath} -> ${backupPath}`);
          const backupPathDir = path.dirname(backupPath);
          if (!(await fsPoly.exists(backupPathDir))) {
            await fsPoly.mkdir(backupPathDir, { recursive: true });
          }
          try {
            await fsPoly.mv(filePath, backupPath);
          } catch (error) {
            this.progressBar.logWarn(`failed to move ${filePath} -> ${backupPath}: ${error}`);
          }
          this.progressBar.incrementProgress();
        });
      }),
    );
  }

  private static async getEmptyDirs(dirsToClean: string | string[]): Promise<string[]> {
    if (Array.isArray(dirsToClean)) {
      return (
        await Promise.all(
          dirsToClean.map(async (dirToClean) => DirectoryCleaner.getEmptyDirs(dirToClean)),
        )
      )
        .flat()
        .reduce(ArrayPoly.reduceUnique(), []);
    }

    // Find all subdirectories and files in the directory
    const subPaths = (await fs.promises.readdir(dirsToClean))
      .filter((basename) => isNotJunk(basename))
      .map((basename) => path.join(dirsToClean, basename));

    // Categorize the subdirectories and files
    const subDirs: string[] = [];
    const subFiles: string[] = [];
    await Promise.all(
      subPaths.map(async (subPath) => {
        if (await fsPoly.isDirectory(subPath)) {
          subDirs.push(subPath);
        } else {
          subFiles.push(subPath);
        }
      }),
    );

    // If there are no subdirectories or files, this directory is empty
    if (subDirs.length === 0 && subFiles.length === 0) {
      return [dirsToClean];
    }

    // Otherwise, recurse and look for empty subdirectories
    return (await Promise.all(subDirs.map(async (subDir) => this.getEmptyDirs(subDir)))).flat();
  }
}