modules/FileBasedStoreLoader.js

/**
 * UnityBase file-system based virtual store **select**. Able to load files & transform it content to {@link TubCachedData} format.
 *
 * Good sample of usage can be found in `ubm_form.loadAllForm`
 *
 * For work with data, loaded by FileBasedStoreLoader you can use {@link LocalDataStore} class.
 * @module FileBasedStoreLoader
 */

"use strict";
module.exports = FileBasedStoreLoader;
/**
 * @example

    var loader = new FileBasedStoreLoader({
         entity: me.entity,
           foldersConfig: folders,
           fileMask: /-fm\.def$/,
           onBeforeRowAdd: postProcessing
        });
    resultDataCache = loader.load();

 * @class
 * @param {Object}    config
 * @param {TubEntity} config.entity
 * @param {Array.<{path: string}>} config.foldersConfig   Array of folder configuration to scan for files.
 *                                              Necessary param is path - path to folder. You can also pass additional information
 *                                              for use in  `onBeforeRowAdd` and `onNewFolder` callbacks.
 *                                              Currently processed root folder accessible from FileBasedStoreLoader.processingRootFolder
 * @param {Boolean}   config.zipToArray     Transform result from array of object to array-of-array representation. Default true
 * @param {Boolean}   config.uniqueID       Result data must contain ID attribute and values must be unique. Default true.
 * @param {RegExp}    [config.fileMask]     Regular expression to filter folder files. Each fileName (without path) will be tested by this regExp
 * @param {String}    [config.attributeRegExpString] String representation of regular expression to found attribute and it value in input content.
 *                                                   Default is '^\\/\\/@(\\w+)\\s"(.*?)"' what mean find all string like: //@attribute "value"
 *                                                   You can pass empty string to disable attribute parsing by regExp and do it manually in `onBeforeRowAdd` handler.
 * @param {Function}  [config.onBeforeRowAdd] Callback called for each row BEFORE it added to store. In case it return false row not added.
 *                          Called with args (this: FileBasedStoreLoader, fullFilePath: string, fileContent: string, oneRow: Object);
 * @param {Function}  [config.onNewFolder] Callback called for each new folder in case of recursive folder.
 *                          In case callback return false or not defined - folder not processed.
 *                          Called with args (this: FileBasedStoreLoader, fullFolderPath: string, recursionLevel: integer);
 */
function FileBasedStoreLoader(config) {
    var entityAttributes = config.entity.attributes,
        i, l;

    /**
     * Configuration
     * @type {Object}
     */
    this.config = _.clone(config);
    if (!Array.isArray(config.foldersConfig)){
        throw new Error('config.foldersConfig must be array');
    }
    if (config.attributeRegExpString !== '') {
        this.config.attributeRegExpString = config.attributeRegExpString || FileBasedStoreLoader.JSON_ATTRIBURE_REGEXP;
    }

    if (!this.config.hasOwnProperty('uniqueID')){ this.config.uniqueID = true; }
    if (!this.config.hasOwnProperty('zipToArray')){ this.config.zipToArray = true; }

    /**
     * Entity attributes array
     * @type {Array.<Object>}
     * @readonly
     */
    this.attributes = [];
    for (i = 0, l = entityAttributes.count; i < l; i++) {
        this.attributes.push({
            name: entityAttributes.items[i].name,
            dataType: entityAttributes.items[i].dataType,
            defaultValue: entityAttributes.items[i].defaultValue,
            defaultView: entityAttributes.items[i].defaultView
        });
    }
    /**
     * Is `mStore.simpleAudit` enabled for current entity (exist `mi_modifyDate` attribute)
     * @type {Boolean}
     * @readonly
     */
    this.haveModifyDate = Boolean(_.find(this.attributes, {name: 'mi_modifyDate'}));
    /**
     * Is `mStore.simpleAudit` enabled for current entity (exist `mi_createDate` attribute)
     * @type {Boolean}
     * @readonly
     */
    this.haveCreateDate = Boolean(_.find(this.attributes, {name: 'mi_createDate'}));

    /**
     * Currently processed root folder
     * @type {*}
     * @readonly
     */
    this.processingRootFolder = null;
}

FileBasedStoreLoader.JSON_ATTRIBURE_REGEXP = '^\\/\\/@(\\w+)\\s"(.*?)"';
FileBasedStoreLoader.XML_ATTRIBURE_REGEXP = '<!--@(\\w+)\\s*"(.+)"\\s*-->';

/**
 * Perform actual loading.
 * @return {TubCachedData}
 */
FileBasedStoreLoader.prototype.load = function(){
    var me = this,
        result, lds;

    /**
     * Array of Object representing dirty result
     * @type {Array.<Object>}
     * @protected
     */
    this.resultCollection = [];
    me.config.foldersConfig.forEach(function(folderConfig){
        me.processingRootFolder = folderConfig;
        me.parseFolder(folderConfig.path, 0);
    });
    // transformation to array=of=array
    if (me.config.zipToArray){
        result = {
            data: [],
            fields: [],
            rowCount: 0
        };
        result.fields = _.flatten(me.attributes, 'name');
        lds = require('LocalDataStore');
        result.data = lds.arrayOfObjectsToSelectResult(me.resultCollection, result.fields);
        result.rowCount = result.data.length;
    } else {
        result = me.resultCollection;
    }
    me.resultCollection = [];
    return result;
};

/**
 * @method parseFolder
 * @protected
 * @param {String} folderPath Folder to parse
 * @param {Number} recursionLevel current level of folder recursion
 */
FileBasedStoreLoader.prototype.parseFolder = function(folderPath, recursionLevel){
    var
        me = this,
        fs = require('fs'),
        config = me.config,
        folderFiles;

    if (!fs.existsSync(folderPath)) {
        return;
    }

    if (config.onNewFolder) {
        if (config.onNewFolder(me, folderPath, recursionLevel) === false) return;
    }
    folderFiles = fs.readdirSync(folderPath);

    folderFiles.forEach(function(fileName){
        var oneRow,
            fullPath = folderPath + fileName,
            stat = fs.statSync(fullPath),
            newFolderCheck, content, canAdd;

        if (stat.isDirectory()){
            if (config.onNewFolder){
                newFolderCheck = config.onNewFolder(me, folderPath + fileName, recursionLevel + 1);
                if (newFolderCheck !== false){
                    me.parseFolder(fullPath + '\\', recursionLevel + 1);
                }
            }
        } else if (!me.config.fileMask || me.config.fileMask.test(fileName)) { // filtration by mask
            content = fs.readFileSync(fullPath);
            oneRow = me.extractAttributesValues(content);

            if (me.haveModifyDate) {
                oneRow['mi_modifyDate'] = stat.mtime;
            }
            if (me.haveCreateDate) {
                oneRow['mi_createDate'] = stat.ctime;
            }
            canAdd = me.config.onBeforeRowAdd ? me.config.onBeforeRowAdd(me, fullPath, content, oneRow) : true;
            //check unique ID
            if (canAdd && config.uniqueID) {
                if (!oneRow.ID) {
                    console.error('Parameter ID not set. File "%" ignored', fullPath);
                    canAdd = false
                } else if (_.find(me.resultCollection, {ID: oneRow.ID})) {
                    console.error('Record with ID "' + oneRow.ID + '" already exist. File ignored ', fullPath);
                    canAdd = false
                }
            }
            if (canAdd) {
                me.resultCollection.push(oneRow);
            }
        }
    });
};

/**
 * Extract attribute values from content using regular expression passed in the config.attributeRegExpString.
 *
 * Convert values from string representation to JS data type using entity attribute dataType information
 *
 * Add default values for missed attributes
 *
 * @private
 * @param {String} content
 * @result {Object} dictionary looking like {attrbuteName: "value"}
 */
FileBasedStoreLoader.prototype.extractAttributesValues = function(content){
    var me = this,
        regexp = me.config.attributeRegExpString ? new RegExp(me.config.attributeRegExpString, 'gm') : false,
        attrVal, result = {};

    //extraction block
    if (regexp !== false) {
        attrVal = regexp.exec(content);
        while (attrVal !== null) {
            result[attrVal[1]] = attrVal[2];
            attrVal = regexp.exec(content);
        }
    }
    //default block
    me.attributes.forEach(function(attribute){
        if (attribute.defaultValue !== '' && !result[attribute.name]){
            result[attribute.name] = attribute.defaultValue;
        }
    });
    //transformation block
    _.forEach(result, function(value, attribute) {
        var attr = _.find(me.attributes, {name: attribute}),
            toType;
        if (!attr){ return; }
        toType = attr.dataType;
        switch (+toType) {
            case TubAttrDataType.Int:
            case TubAttrDataType.BigInt:
            case TubAttrDataType.ID:
            case TubAttrDataType.Float:
            case TubAttrDataType.Currency:
            case TubAttrDataType.Entity:
            case TubAttrDataType.TimeLog:
                result[attribute] = +value;
                break;
            case TubAttrDataType.Boolean:
                result[attribute] = (value === true) || (value === 'true') || (value === '1');
                break;
            case TubAttrDataType.DateTime:
                result[attribute] = _.isDate(value) ? value : new Date(value);
                break;
            case TubAttrDataType.Unknown:
            case TubAttrDataType.String:
            case TubAttrDataType.Text:
            case TubAttrDataType.Many:
            case TubAttrDataType.Document:
            case TubAttrDataType.Enum:
            case TubAttrDataType.BLOB:
                break; // just to be sure we handle all types
            default:
                throw "Unknown attribute type " + toType;
        }
    });
    return result;
};