matthew-matvei/freeman

View on GitHub
src/common/managers/DirectoryManager.ts

Summary

Maintainability
A
3 hrs
Test Coverage
import log from "electron-log";
import fs from "fs";
import { inject, injectable } from "inversify";
import os from "os";
import path from "path";
import trash from "trash";

import DirectoryError from "errors/DirectoryError";
import TYPES from "ioc/types";
import { IAttributesManager, IDirectoryManager } from "managers";
import { IDirectoryItem, IListDirectoryOptions } from "models";
import { DirectorySorter } from "objects";
import { ItemType } from "types";
import Utils from "Utils";
import { IFileSystemWrapper } from "wrappers";

/** Provides methods for reading, writing and creating files and folders. */
@injectable()
class DirectoryManager implements IDirectoryManager {

    /** A watcher that observes changes to a directory. */
    private watcher?: fs.FSWatcher;

    /** A wrapper that abstracts file system operations. */
    private fileSystemWrapper: IFileSystemWrapper;

    /** A manager that handles directory item attributes on Windows. */
    private attributesManager: IAttributesManager;

    /**
     * Initialises a new instance of the DirectoryManager class.
     *
     * @param fileSystemWrapper - a wrapper that abstracts file system operations
     * @param attributesManager - a manager that handles directory item attributes on Windows
     */
    public constructor(
        @inject(TYPES.IFileSystemWrapper) fileSystemWrapper: IFileSystemWrapper,
        @inject(TYPES.IAttributesManager) attributesManager: IAttributesManager
    ) {
        this.fileSystemWrapper = fileSystemWrapper;
        this.attributesManager = attributesManager;
    }

    /** @inheritDoc */
    public async listDirectory(
        filePath: string,
        options: IListDirectoryOptions
    ): Promise<IDirectoryItem[]> {

        if (!(await this.isDirectory(filePath))) {
            throw new DirectoryError("Cannot call listDirectory on a non-directory item", filePath);
        }

        const filterCondition = options.filterCondition ? options.filterCondition :
            (item: IDirectoryItem) => true;
        const sort = options.sort ? options.sort :
            DirectorySorter.sortByTypeThenAlphaNumery;

        let fileList;

        try {
            fileList = await this.fileSystemWrapper.readdirAsync(filePath);
        } catch {
            throw new DirectoryError("Could not list items in directory", filePath);
        }

        const files = await Promise.all(fileList.map(async fileName => {
            const fullPath = path.join(filePath, fileName);
            let fileStats: fs.Stats;

            try {
                fileStats = await this.fileSystemWrapper.lstatAsync(fullPath);
            } catch {
                return {
                    accessible: false,
                    isDirectory: true,
                    isHidden: await this.isHidden(fullPath, options.hideUnixStyleHiddenItems),
                    name: fileName,
                    path: fullPath
                } as IDirectoryItem;
            }

            return {
                accessible: await this.isAccessible(fullPath),
                created: fileStats.ctime,
                isDirectory: fileStats.isDirectory(),
                isHidden: await this.isHidden(fullPath, options.hideUnixStyleHiddenItems),
                lastModified: fileStats.mtime,
                name: fileName,
                path: fullPath,
                size: !fileStats.isDirectory() && fileStats.size || undefined
            } as IDirectoryItem;
        }));

        return sort(files).filter(filterCondition);
    }

    /** @inheritDoc */
    public async createItem(itemName: string, itemPath: string, itemType: ItemType): Promise<void> {
        const fullItemName = path.join(itemPath, itemName);

        if (itemType === "folder") {
            try {
                await this.fileSystemWrapper.mkdirAsync(fullItemName);
            } catch {
                throw new DirectoryError("Could not create directory", fullItemName);
            }
        } else {
            try {
                await this.fileSystemWrapper.writeFileAsync(fullItemName, "");
            } catch {
                throw new DirectoryError("Could not create file", fullItemName);
            }
        }
    }

    /** @inheritDoc */
    public async renameItem(oldName: string, newName: string, itemPath: string): Promise<void> {
        if (oldName === newName) {
            return;
        }

        const oldNameFull = path.join(itemPath, oldName);
        const newNameFull = path.join(itemPath, newName);

        try {
            await this.fileSystemWrapper.renameAsync(oldNameFull, newNameFull);
        } catch {
            throw new DirectoryError("Could not rename item", oldNameFull, newNameFull);
        }
    }

    /** @inheritDoc */
    public async deleteItems(itemsToDelete: IDirectoryItem[]): Promise<void> {
        const itemDeletions = itemsToDelete.map(async item => {
            await this.deleteItem(item.path, Utils.parseItemType(item));
        });

        await Promise.all(itemDeletions);
    }

    /** @inheritDoc */
    public async sendItemsToTrash(itemsToTrash: IDirectoryItem[]): Promise<void> {
        const itemSoftDeletions = itemsToTrash.map(async item => {
            await DirectoryManager.sendItemToTrash(item.path);
        });

        await Promise.all(itemSoftDeletions);
    }

    /** @inheritDoc */
    public async copyItems(itemsToCopy: IDirectoryItem[], destinationDirectory: string): Promise<void> {
        const itemCopies = itemsToCopy.map(async item => {
            await this.copyItem(item.path, destinationDirectory);
        });

        await Promise.all(itemCopies);
    }

    /** @inheritDoc */
    public async moveItems(itemsToMove: IDirectoryItem[], destinationDirectory: string): Promise<void> {
        const itemMoves = itemsToMove.map(async item => {
            await this.moveItem(item.path, destinationDirectory, Utils.parseItemType(item));
        });

        await Promise.all(itemMoves);
    }

    /** @inheritDoc */
    public readFileSync(filePath: string): string {
        return fs.readFileSync(filePath, "utf-8");
    }

    /** @inheritDoc */
    public startWatching(pathToWatch: string, listener: () => void) {
        this.watcher = fs.watch(pathToWatch, listener);
    }

    /** @inheritDoc */
    public stopWatching() {
        this.watcher && this.watcher.close();
    }

    /**
     * Returns whether a given file or folder is hidden.
     *
     * @param pathToItem the path to the file or folder
     * @param hideUnixStyleHiddenItems whether Unix-style hidden items should be hidden on Windows
     *
     * @returns whether the file at pathToItem is hidden
     */
    private async isHidden(pathToItem: string, hideUnixStyleHiddenItems: boolean): Promise<boolean> {
        if (!pathToItem) {
            throw new ReferenceError("pathToItem must contain characters");
        }

        const itemName = path.basename(pathToItem);

        if (os.platform() === "linux") {
            return itemName.startsWith(".");
        } else if (os.platform() === "win32") {
            return this.handleWindowsIshidden(pathToItem, hideUnixStyleHiddenItems);
        }

        log.warn("Only linux and win32 platforms currently supported");

        return false;
    }

    /**
     * Handles returning whether a given file or folder is hidden on Windows.
     *
     * @param pathToItem the path to the file or folder
     * @param hideUnixStyleHiddenItems whether Unix-style hidden items should be hidden on Windows
     *
     * @returns whether the file at pathToItem is hidden
     */
    private async handleWindowsIshidden(pathToItem: string, hideUnixStyleHiddenItems: boolean): Promise<boolean> {
        const itemName = path.basename(pathToItem);

        if (hideUnixStyleHiddenItems && itemName.startsWith(".")) {
            return true;
        }

        try {
            const attributes = await this.attributesManager.getAttributesAsync(pathToItem);

            return attributes.hidden;
        } catch (error) {
            log.warn(
                "Could not determine attributes, defaulting path to not hidden",
                pathToItem,
                error);

            return false;
        }
    }

    /**
     * Returns whether the user has at least read access to the directory at pathToItem.
     *
     * @param pathToItem the path to test accessibility
     *
     * @returns whether the user has at least read access to the directory at pathToItem
     */
    private async isAccessible(pathToItem: string): Promise<boolean> {
        try {
            await this.fileSystemWrapper.accessAsync(pathToItem, fs.constants.R_OK);

            return true;
        } catch {
            return false;
        }
    }

    /**
     * Copies an item at itemPath to the destinationDirectory.
     *
     * @param itemPath the full path to the source item
     * @param destinationDirectory the directory to copy the item to
     */
    private async copyItem(itemPath: string, destinationDirectory: string): Promise<void> {
        const fileName = path.basename(itemPath);
        const destinationFileName = path.join(destinationDirectory, fileName);

        try {
            await this.fileSystemWrapper.copyAsync(itemPath, destinationFileName);
        } catch {
            throw new DirectoryError("Failed to copy item", itemPath, destinationFileName);
        }
    }

    /**
     * Moves an item at itemPath to the destinationDirectory. This involves deleting
     * permanently the source file.
     *
     * @param itemPath the full path to the source item
     * @param destinationDirectory the directory to move the item to
     * @param itemType the type of the source item
     */
    private async moveItem(itemPath: string, destinationDirectory: string, itemType: ItemType): Promise<void> {
        try {
            await this.copyItem(itemPath, destinationDirectory);
            await this.deleteItem(itemPath, itemType);
        } catch {
            throw new DirectoryError("Failed to copy item", itemPath, destinationDirectory);
        }
    }

    /**
     * Deletes the item of itemType at itemPath.
     *
     * @param itemPath the full path to the item to be deleted
     * @param itemType the type of the item to be deleted
     */
    private async deleteItem(itemPath: string, itemType: ItemType): Promise<void> {
        if (itemType === "folder") {
            try {
                await this.fileSystemWrapper.rmdirAsync(itemPath);
            } catch {
                throw new DirectoryError("Cannot remove folder", itemPath);
            }
        } else {
            try {
                await this.fileSystemWrapper.unlinkAsync(itemPath);
            } catch {
                throw new DirectoryError("Cannot remove file", itemPath);
            }
        }
    }

    /**
     * Returns whether the file at the given path is a directory.
     *
     * @param pathToItem the path to the file
     *
     * @returns whether the file is a directory
     */
    private async isDirectory(pathToItem: string): Promise<boolean> {
        const stats = await this.fileSystemWrapper.lstatAsync(pathToItem);

        return stats.isDirectory();
    }

    /**
     * Sends the item at itemPath to the system-dependent trash.
     *
     * @param itemPath the path to the file
     */
    private static async sendItemToTrash(itemPath: string): Promise<void> {
        try {
            await trash([itemPath], { glob: false });
        } catch {
            throw new DirectoryError("Could not send item to trash", itemPath);
        }
    }
}

export default DirectoryManager;