nw-skeleton

Source: app-wrapper/js/helper/appOperationHelper.js

/**
 * @fileOverview AppOperationHelper class file
 * @author Dino Ivankov <dinoivankov@gmail.com>
 * @version 1.3.1
 */

const _ = require('lodash');
const AppBaseClass = require('../lib/appBase').AppBaseClass;

var _appWrapper;
var appState;

/**
 * AppOperationHelper class - handles and manages app operations
 *
 * @class
 * @extends {appWrapper.AppBaseClass}
 * @memberof appWrapper.helpers
 * @property {Number}   operationStartTime              Timestamp of last operation start
 * @property {Number}   lastTimeCalculation             Timestamp of last time calculation
 * @property {Number}   lastTimeValue                   Last time value
 * @property {Number}   timeCalculationDelay            Minimum delay between calculations
 * @property {Number}   minPercentComplete              Minimum percent complete before time calculation
 * @property {Number}   lastCalculationPercent          Last calculated percent value
 * @property {string}   progressNotificationId          Id of progress desktop notification
 * @property {boolean}  progressNotificationCreated     Flag to indicate whether progress notification was created
 * @property {boolean}  progressNotificationProgress    Percent complete of progress desktop notification
 */
class AppOperationHelper extends AppBaseClass {

    /**
     * Creates AppOperationHelper instance
     *
     * @constructor
     * @return {AppOperationHelper}              Instance of AppOperationHelper class
     */
    constructor() {
        super();

        _appWrapper = window.getAppWrapper();
        appState = _appWrapper.getAppState();

        this.timeouts = {
            appStatusChangingTimeout: null,
            cancellingTimeout: null,
            cancelCountdown: null,
        };

        this.intervals = {
            cancellingCheck: null,
            cancelCountdown: null,
        };

        this.boundMethods = {
            cancelAndClose: null,
            cancelAndReload: null,
            stopCancelAndExit: null,
            cancelProgressDone: null,
            cancelOperationComplete: null,
        };

        this.operationStartTime = null;
        this.lastTimeCalculation = null;
        this.lastTimeValue = 0;
        this.timeCalculationDelay = 0.3;
        this.minPercentComplete = 0.5;

        this.lastCalculationPercent = 0;

        this.progressNotificationId = null;
        this.progressNotificationCreated = false;
        this.progressNotificationProgress = null;

        return this;
    }

    /**
     * Starts the operation
     *
     * @param  {string}     operationText       Operation text
     * @param  {boolean}    cancelable          Flag to indicate whether operation is cancelable
     * @param  {boolean}    appBusy             Flag to indicate whether to set appBusy status
     * @param  {boolean}    useProgress         Flag to indicate whether to use progress bar
     * @param  {string}     progressText        Progress bar tet
     * @param  {boolean}    preventAnimation    Flag to indicate whether to prevent animations
     * @param  {boolean}    notify              Flag to indicate whether to notify user when operation is finished
     * @return {string}                         App operation ID
     */
    operationStart(operationText, cancelable, appBusy, useProgress, progressText, preventAnimation, notify){
        if (appState.appOperation.operationActive){
            this.log('Can\'t start another operation, one is already in progress', 'warning', []);
            return;
        }
        if (!operationText){
            operationText = '';
        }
        if (!cancelable){
            cancelable = false;
        }
        if (preventAnimation){
            appState.progressData.animated = false;
        } else {
            appState.progressData.animated = true;
        }

        if (!notify){
            notify = false;
        }

        let operationActive = true;
        let cancelling = false;
        let cancelled = false;
        let utilHelper = _appWrapper.getHelper('util');
        let operationId = utilHelper.getRandomString(10);
        let operationStartTimestamp = parseInt((+ new Date()) / 1000, 10);

        let hideLiveInfo = appState.appOperation.hideLiveInfo;
        let hideProgressBar = appState.appOperation.hideProgressBar;

        appState.appOperation = {
            operationText,
            useProgress,
            progressText,
            appBusy,
            cancelable,
            cancelling,
            cancelled,
            operationActive,
            operationStartTimestamp,
            operationId,
            notify,
            hideLiveInfo,
            hideProgressBar
        };

        if (_.isUndefined(appBusy)){
            appBusy = true;
        }

        appState.status.appStatusChanging = true;
        appState.appOperation.operationVisible = true;
        _appWrapper.setAppStatus(appBusy);

        clearTimeout(this.timeouts.appStatusChangingTimeout);
        if (useProgress){
            this.startProgress(100, appState.appOperation.progressText);
        }
        return operationId;
    }

    /**
     * Updates current operation
     *
     * @param  {Number} completed    Number of sub-operations completed
     * @param  {Number} total        Total number of sub-operations
     * @param  {string} progressText Progress bar text
     * @return {undefined}
     */
    operationUpdate(completed, total, progressText){
        if (!progressText){
            progressText = appState.appOperation.progressText;
        }
        if (appState.appOperation.useProgress){
            this.updateProgress(completed, total, progressText);
        }
    }

    /**
     * Finishes current operation
     *
     * @param  {string} operationText   Operation text to display
     * @param  {Number} timeoutDuration Duration in milliseconds until hiding appOperation text
     * @return {undefined}
     */
    operationFinish(operationText, timeoutDuration){
        let originalText = appState.appOperation.operationText;
        if (operationText){
            appState.appOperation.operationText = operationText;
        }

        // let appBusy = appState.appOperation.appBusy ? false : appState.status.appBusy;

        if (!timeoutDuration){
            timeoutDuration = 2000;
        }

        appState.progressData.inProgress = false;
        appState.appOperation.operationActive = false;

        // _appWrapper.setAppStatus(appBusy, 'success');
        //
        _appWrapper.emit('appOperation:progressDone');
        if (appState.appOperation.notify && !appState.status.windowFocused){
            let duration = _appWrapper.getHelper('format').formatDurationCustom(parseInt((+ new Date()) / 1000, 10) - appState.appOperation.operationStartTimestamp);
            this.addDesktopNotification('Operation "{1}" completed.', [originalText], false, {
                message: this.translate('Duration: {1} seconds', null, [duration]),
            }, {
                onClicked: () => {
                    // console.log('onclick');
                    // console.log(_appWrapper.windowManager.window);
                    _appWrapper.windowManager.window.focus();
                },
                onClosed: () => {
                    // console.log('onclose');
                    // console.log(_appWrapper.windowManager.window);
                    _appWrapper.windowManager.window.focus();
                }
            });
        }

        clearTimeout(this.timeouts.appStatusChangingTimeout);

        this.timeouts.appStatusChangingTimeout = setTimeout(() => {
            appState.status.appStatusChanging = false;
            if (appState.appOperation.useProgress){
                this.clearProgress();
            }
            this.resetOperationData();
            _appWrapper.emit('appOperation:finish');
            _appWrapper.setAppStatus(false);
        }, timeoutDuration);
        appState.progressData.animated = true;
    }

    /**
     * Cancels current operation
     *
     * @async
     * @param  {Event} e    Event that triggered the method
     * @return {boolean}    Cancelling result
     */
    async operationCancel (e) {
        if (e && e.preventDefault && _.isFunction(e.preventDefault)){
            e.preventDefault();
        }
        if (!appState.appOperation.cancelable){
            return;
        }
        appState.appOperation.cancelling = true;
        appState.appOperation.operationText = this.translate('Cancelling...');
        let returnPromise;
        let resolveReference;
        returnPromise = new Promise((resolve) => {
            resolveReference = resolve;
        });
        this.intervals.cancellingCheck = setInterval( () => {
            let cancelled = this.isOperationCancelled();
            if (cancelled){
                clearInterval(this.intervals.cancellingCheck);
                appState.appOperation.cancelling = false;
                appState.appOperation.cancelled = true;
                _appWrapper.emit('appOperation:cancelled');
                resolveReference(cancelled);
            }
        }, 100);
        this.timeouts.cancellingTimeout = setTimeout( () => {
            clearInterval(this.intervals.cancellingCheck);
            clearTimeout(this.timeouts.cancellingTimeout);
            _appWrapper.emit('appOperation:cancelTimedOut');
            resolveReference(false);
        }, this.getConfig('cancelOperationTimeout'));
        return returnPromise;
    }

    /**
     * Start operation progress
     *
     * @param  {Number} total           Total number of sub-operations
     * @param  {string} progressText    Progress text
     * @return {undefined}
     */
    startProgress (total, progressText) {
        appState.progressData.inProgress = true;
        this.updateProgress(0, total, progressText);
    }

    /**
     * Update operation progress
     *
     * @async
     * @param  {Number} completed       Number of sub-operations completed
     * @param  {Number} total           Total number of sub-operations
     * @param  {string} progressText    Progress bar text
     * @return {undefined}
     */
    async updateProgress (completed, total, progressText) {
        let pd = appState.progressData;
        if (!pd.inProgress){
            this.log('Trying to update progress while appState.progressData.inProgress is false', 'info', []);
            return;
        }
        if (!this.operationStartTime){
            this.operationStartTime = (+ new Date()) / 1000;
        }
        if (completed > total){
            completed = total;
        }
        if (completed < 0){
            completed = 0;
        }

        let percentComplete = Math.ceil((completed / total) * 100);

        //TODO fix notification

        // let appNotificationsHelper = _appWrapper.getHelper('appNotifications');
        // if (appState.appOperation.notify && !appState.status.windowFocused){
        //     let notifOptions = {
        //         imageUrl: '',
        //         type: 'progress',
        //         progress: percentComplete,
        //         requireInteraction: true,
        //         message: ' '
        //     };
        //     if (!this.progressNotificationId && !this.progressNotificationCreated){
        //         this.progressNotificationCreated = true;
        //         this.progressNotificationProgress = percentComplete;
        //         notifOptions.progress = percentComplete;
        //         this.progressNotificationId = await this.addDesktopNotification(appState.appOperation.operationText, [], true, notifOptions);
        //     } else if (this.progressNotificationCreated && this.progressNotificationId){
        //         if (this.progressNotificationProgress != percentComplete){
        //             this.progressNotificationProgress = percentComplete;
        //             notifOptions.progress = percentComplete;
        //             appNotificationsHelper.updateDesktopNotification(this.progressNotificationId, notifOptions);
        //         }
        //     }
        // }

        let remainingTime = this.calculateTime(percentComplete);
        percentComplete = parseInt(percentComplete);
        if (progressText){
            pd.operationText = progressText;
        }
        let formattedDuration = this.translate('calculating');
        if (percentComplete >= this.minPercentComplete){
            formattedDuration = _appWrapper.getHelper('format').formatDuration(remainingTime);
        }
        pd.percentComplete = percentComplete + '%';
        if (percentComplete < 100){
            pd.percentComplete += ' (ETA: ' + formattedDuration + ')';
        }
        pd.percentNumber = percentComplete;
        pd.currentStep = completed;
        pd.totalSteps = total;
        pd.styleObject = {
            width: percentComplete + '%'
        };
    }

    /**
     * Clears operation progress
     *
     * @return {undefined}
     */
    clearProgress () {
        appState.progressData = {
            animated: true,
            inProgress: false,
            percentComplete: 0,
            percentNumber: 0,
            currentStep: 0,
            totalSteps: 0,
            operationText: '',
            progressBarClass: '',
            styleObject: {
                width: '0%'
            }
        };
        this.operationStartTime = null;
        this.lastTimeCalculation = null;
        this.lastTimeValue = 0;
        this.lastCalculationPercent = 0;
    }

    /**
     * Old method to calculate remaining time
     *
     * @param  {Number} percent Current percent
     * @return {Number}         Remaining seconds
     */
    calculateTimeOld(percent){
        let currentTime = (+ new Date()) / 1000;
        let remainingTime = null;
        if (percent && percent > this.minPercentComplete && (!this.lastTimeValue || (currentTime - this.lastTimeCalculation > this.timeCalculationDelay))){
            let remaining = 100 - percent;
            this.lastTimeCalculation = currentTime;
            let elapsedTime = currentTime - this.operationStartTime;
            let timePerPercent = elapsedTime / percent;
            remainingTime = remaining * timePerPercent;
            this.lastTimeValue = remainingTime;
        } else {
            remainingTime = this.lastTimeValue;
        }
        return remainingTime;
    }

    /**
     * Method to calculate remaining time
     *
     * @param  {Number} percent Current percent
     * @return {Number}         Remaining seconds
     */
    calculateTime(percent){
        let currentTime = (+ new Date()) / 1000;
        let remainingTime = null;
        let change = percent - this.lastCalculationPercent;
        if (this.lastTimeCalculation){
            if (change > 0 && percent && percent > this.minPercentComplete && (!this.lastTimeValue || (currentTime - this.lastTimeCalculation > this.timeCalculationDelay))){
                let remaining = 100 - percent;
                let elapsedSinceLastCalculation = currentTime - this.lastTimeCalculation;
                let timePerPercent = elapsedSinceLastCalculation / change;
                remainingTime = remaining * timePerPercent;
                this.lastTimeCalculation = currentTime;
                this.lastTimeValue = remainingTime;
            } else {
                remainingTime = this.lastTimeValue;
            }
        } else {
            this.lastTimeCalculation = currentTime;
        }
        this.lastCalculationPercent = percent;
        return remainingTime;
    }

    /**
     * Checks whether operation can continue
     *
     * @return {boolean} True if operation can continue, false otherwise
     */
    canOperationContinue () {
        return appState.appOperation.operationActive && !appState.appOperation.cancelled && !appState.appOperation.cancelling;
    }

    /**
     * Checks whether operation is active by its ID
     *
     * @param {string}  operationId     Operation ID to check for
     * @return {boolean}                True if operation is active, false otherwise
     */
    isOperationActive (operationId) {
        let active = appState.appOperation.operationActive || appState.appOperation.cancelling;
        if (operationId){
            active = active && operationId == appState.appOperation.operationId;
        }
        return active;
    }

    /**
     * Checks whether operation is active by its text
     *
     * @param {string}  operationText   Operation text to check for
     * @return {boolean}                True if operation is active, false otherwise
     */
    isOperationTextActive (operationText) {
        let active = appState.appOperation.operationActive || appState.appOperation.cancelling;
        if (operationText){
            active = active && operationText == appState.appOperation.operationText;
        }
        return active;
    }

    /**
     * Checks whether operation is cancelled
     *
     * @return {boolean} True if operation is cancelled, false otherwise
     */
    isOperationCancelled () {
        return !appState.appOperation.operationActive || appState.appOperation.cancelled;
    }

    /**
     * Resets all operation data
     *
     * @param  {Object} data Operation data to set
     * @return {undefined}
     */
    resetOperationData (data) {
        if (!data){
            data = {};
        }
        appState.appOperation = _.extend({
            operationText: '',
            useProgress: false,
            progressText: '',
            appBusy: false,
            cancelable: false,
            cancelling: false,
            cancelled: false,
            operationActive: false,
            operationVisible: false,
            operationStartTimestamp: false,
            operationId: '',
            notify: false,
            hideLiveInfo: false,
            hideProgressBar: false,
        }, data);
    }

    /**
     * Shows cancel operation modal for reload or close events
     *
     * @async
     * @param  {boolean} reloading Flag to indicate whether window is reloading or closing
     * @return {undefined}
     */
    async showCancelModal (reloading) {
        let modalHelper = _appWrapper.getHelper('modal');
        let modalOptions = {};
        modalOptions.reloading = reloading ? true : false;
        modalOptions.closing = reloading ? false : true;
        if (modalOptions.closing){
            appState.preventClose = true;
        }
        modalOptions.cancelable = appState.appOperation.cancelable;

        appState.modalData.currentModal = modalHelper.getModalObject('cancelAndExitModal', modalOptions);
        let cm = appState.modalData.currentModal;
        cm.onCancel = this.boundMethods.stopCancelAndExit;
        _appWrapper.once('appOperation:finish', this.boundMethods.cancelOperationComplete);
        _appWrapper.once('appOperation:progressDone', this.boundMethods.cancelProgressDone);
        if (appState.appOperation.cancelable){
            cm.title = this.translate('Are you sure?');
            if (cm.reloading){
                await modalHelper.queryModal(this.boundMethods.cancelAndReload, this.boundMethods.stopCancelAndExit);
            } else {
                await modalHelper.queryModal(this.boundMethods.cancelAndClose, this.boundMethods.stopCancelAndExit);
            }
            return;
        } else {
            cm.title = this.translate('Operation in progress');
            cm.showCancelButton = false;
            cm.confirmButtonText = this.translate('Ok');
            cm.autoCloseTime = 10000;
            await modalHelper.queryModal(this.boundMethods.stopCancelAndExit, this.boundMethods.stopCancelAndExit);
            this.stopCancelAndExit();
        }
    }

    /**
     * Triggered when progress is done while cancel modal is opened
     *
     * @async
     * @return {undefined}
     */
    async cancelProgressDone () {
        let cm = appState.modalData.currentModal;
        cm.showCancelButton = false;
        cm.showConfirmButton = false;
        cm.hideProgress = true;
        cm.success = true;
        cm.title = this.translate('Operation finished');
    }

    /**
     * Triggered when operation is finished while cancel modal is opened
     *
     * @async
     * @return {undefined}
     */
    async cancelOperationComplete () {
        let cm = _.cloneDeep(appState.modalData.currentModal);
        _appWrapper.getHelper('modal').closeCurrentModal();
        if (cm.reloading){
            _appWrapper.beforeUnload();
        } else {
            _appWrapper.onWindowClose();
        }
    }

    /**
     * Cancels current action and closes window
     *
     * @async
     * @param  {boolean} result Operation result
     * @return {undefined}
     */
    async cancelAndClose (result) {
        await this.cancelAndExit(result, () => {_appWrapper.windowManager.closeWindowForce();});
    }

    /**
     * Cancels current action and reloads window
     *
     * @async
     * @param  {boolean} result Operation result
     * @return {undefined}
     */
    async cancelAndReload (result) {
        await this.cancelAndExit(result, () => {_appWrapper.windowManager.reloadWindow(null, true);});
    }

    /**
     * Shows modal for cancelling and exits if operation finishes while modal is opened
     *
     * @async
     * @param  {boolean} result Operation result
     * @param  {Function} callback Eventual callback to call
     * @return {undefined}
     */
    async cancelAndExit (result, callback) {
        _appWrapper.removeAllListeners('appOperation:finish');
        let modalHelper = _appWrapper.getHelper('modal');
        let success = result;
        let cm = appState.modalData.currentModal;
        if (success){
            cm.title = this.translate('Please wait while operation is cancelled.');
            cm.body = '';
            cm.showCancelButton = false;
            cm.showConfirmButton = false;
            cm.remainingTime = appState.config.cancelOperationTimeout;
            clearInterval(this.intervals.cancelCountdown);
            this.intervals.cancelCountdown = setInterval( () => {
                if (cm.remainingTime >= 1000){
                    cm.remainingTime = cm.remainingTime - 1000;
                } else {
                    cm.remainingTime = 0;
                }
            }, 1000);
            success = await this.operationCancel();
            cm.remainingTime = 0;
            clearInterval(this.intervals.cancelCountdown);
            if (success){
                appState.preventClose = false;
                modalHelper.closeCurrentModal(true);
                await _appWrapper.cleanup();
                if (!appState.isDebugWindow){
                    appState.appError.error = false;
                    callback();
                    return;
                }
            }
        }
        if (!success){
            cm.title = this.translate('Problem cancelling operation');
            cm.fail = true;
            cm.success = false;
            cm.showCancelButton = false;
            cm.showConfirmButton = true;
            cm.confirmButtonText = this.translate('Ok');
            cm.hideProgress = true;
            cm.autoCloseTime = 10000;
            modalHelper.autoCloseModal();
        }
    }

    /**
     * Clears timeouts and intervals used by cancelAndExit method
     *
     * @async
     * @return {undefined}
     */
    async stopCancelAndExit(){
        let modalHelper = _appWrapper.getHelper('modal');
        _appWrapper.removeAllListeners('appOperation:finish');
        _appWrapper.removeAllListeners('appOperation:progressDone');
        modalHelper.resetModalActions();
        if (!appState.modalData.currentModal.busy){
            modalHelper.closeCurrentModal();
        }
    }
}

exports.AppOperationHelper = AppOperationHelper;