UNPKG

mmir-tooling

Version:
595 lines (513 loc) 25.2 kB
import { SettingsBuildOptions , SettingsType , SettingsBuildEntry , SettingsBuildEntryMultiple , RuntimeConfiguration , AsyncGramarExecEntry , DirectoriesInfo , ResourceConfig , BuildAppConfig , SettingsOptions } from '../index.d'; import { Grammar , SpeechConfig } from 'mmir-lib'; import path from 'path'; import fs from 'fs-extra'; import _ from 'lodash'; import promise from '../utils/promise'; import fileUtils from '../utils/filepath-utils'; import appConfigUtils from '../utils/module-config-init'; import directoriesUtil from './directories-utils'; import optionUtils from './option-utils'; import logUtils from '../utils/log-utils'; const log = logUtils.log; const warn = logUtils.warn; import defaultSettingsConfiguration from '../defaultValues/settings/configuration'; import defaultSettingsDictionary from '../defaultValues/settings/dictionary'; import defaultSettingsGrammar from '../defaultValues/settings/grammar'; import defaultSettingsSpeech from '../defaultValues/settings/speech'; const ALL_SPEECH_CONFIGS_TYPE = 'speech-all'; const CONFIG_IGNORE_GRAMMAR_FILES = 'ignoreGrammarFiles'; const CONFIG_GRAMMAR_ASYNC_EXEC = 'grammarAsyncExecMode'; const CONFIG_GRAMMAR_DISABLE_STRICT_MODE = 'grammarDisableStrictCompileMode'; /** * scan for * * * configuration.[json | js] * * <id>/dictionary.[json | js] * * <id>/grammar.[json | js] * * <id>/speech.[json | js] * * NOTE: if .js file, it MUST be a CommonJS module that exports the settings object as its only/default export, i.e. ~ "module.exports = settingsObject;"; * any dynamic code is evaluated at compile-time, i.e. the exported settings-object must not contain dynamic content * * @param {[type]} dir [description] * @param {[type]} list [description] * @param {[type]} options [description] * @return {[type]} [description] */ function readDir(dir: string, list: SettingsBuildEntry[], options: SettingsOptions): void { var files = fs.readdirSync(dir); var dirs = []; // log('read dir "'+dir+'" -> ', files); files.forEach(function(p){ var absPath = path.join(dir, p); if(fileUtils.isDirectory(absPath)){ dirs.push(absPath); return false; } else if(/(configuration|dictionary|grammar|speech)\.js(on)?$/i.test(absPath)){ let id: string, type: SettingsType; if(isSettingsType('configuration', absPath)){ type = 'configuration'; } else { type = getTypeFrom(absPath); id = getIdFor(absPath); } let isAdd = true; let isInline = true; let isForce: boolean;//default for force: undefined if(options){ isInline = !/file/i.test(options.include); if(options[type] === false){ isAdd = false; } else if(options[type] && (!id || options[type][id])){ var conf = id? options[type][id] : options[type]; isAdd = !conf.exclude; isInline = typeof conf.include !== 'undefined'? !/file/i.test(conf.include) : isInline; isForce = conf.force; } } if(isAdd){ if(type !== 'configuration' && contains(list, type, id)){ warn('ERROR settings-utils: encountered multiple entries for '+type+' setting for ID '+id+', ignoring '+absPath); } else { var normalized = fileUtils.normalizePath(absPath); var fileType = getFileType(normalized); list.push({ type: type, id: id, file: normalized, fileType: fileType, value: isInline? readSettingsFile(normalized, fileType) : void(0), include: isInline? 'inline' : 'file', force: isForce }); } } } }); // log('read sub-dirs -> ', dirs); var size = dirs.length; if(size > 0){ for(var i = 0; i < size; ++i){ readDir(dirs[i], list, options); } } } // function addFromOptions(settings, list, appRootDir){ // //TODO // } function isSettingsType(type: SettingsType, filePath: string): boolean { return new RegExp('^' + type + '\.json$', 'i').test(path.basename(filePath)); } function getTypeFrom(settingsFilePath: string): SettingsType { return path.basename(settingsFilePath).replace(/\.js(on)?$/, '') as SettingsType; } function getIdFor(settingsFilePath: string): string { return path.basename(path.dirname(settingsFilePath)); } function getFileType(filePath: string): 'js' | 'json' { return /\.js$/i.test(filePath)? 'js' : 'json'; } /** * read settings file as JSON or "plain" CommonJS module and return PlainObject * * @param {string} filePath the filePath * @param {'json' | 'js'} [fileType] OPTIONAL if omitted, will be derived from filePath (DEFAULT: 'json') * @param {boolean} [async] OPTIONAL (positional argument!) if settings file should be read async (which will return Promise) * @return {{[field: string]: any} | Promise<{[field: string]: any}>} the settings object (or if async, a Promise that resolves to the settings object) */ function readSettingsFile(filePath: string, fileType?: 'json' | 'js', async?: boolean): any { fileType = fileType || getFileType(filePath); if(fileType === 'js'){ return !async? requireJson(filePath) : new promise(function(resolve){resolve(requireJson(filePath))}); } else { return !async? readJsonSync(filePath) : readJsonAsync(filePath); } } //TODO wrap try/catch -> print webpack-error function requireJson(filePath: string): any { try{ return require(filePath); } catch (err){ logUtils.warn('cannot load module contents as JSON-like object from ', filePath); throw err; } } function readJsonSync(filePath: string): any { // log('reading ', filePath);//DEBU var buffer = fs.readFileSync(filePath); return binToJsonObj(buffer, filePath); } async function readJsonAsync(filePath: string): Promise<any> { // log('reading ', filePath);//DEBU return new promise(function(resolve, reject){ fs.readFile(filePath, function(err, buffer){ if(err){ return reject(err); } resolve(binToJsonObj(buffer, filePath)); }); }); } //TODO wrap try/catch -> print webpack-error function binToJsonObj(buffer: Buffer, filePath: string): any { var enc = detectByteOrder(buffer); var content = buffer.toString(enc); // var content = toUtfString(buffer, enc);// buffer.toString(enc); content = removeBom(content); // log('encoding '+enc+' -> ', JSON.stringify(content));//DEBU try{ return JSON.parse(content); } catch (err){ logUtils.warn('cannot parse file contents as JSON from ', filePath); throw err; } } function detectByteOrder(buffer: Buffer): BufferEncoding { //from https://docs.microsoft.com/en-us/windows/desktop/Intl/using-byte-order-marks: // // Byte order mark Description // EF BB BF UTF-8 // FF FE UTF-16, little endian // FE FF UTF-16, big endian // FF FE 00 00 UTF-32, little endian // 00 00 FE FF UTF-32, big-endian if(buffer[0] === 239 /*EF*/ && buffer[1] === 187 /*BB*/ && buffer[2] === 191 /*BF*/){ return 'utf8'; // } else if(buffer[0] === 255 /*FF*/ && buffer[1] === 254 /*FE*/ && buffer[2] === 0 /*00*/ && buffer[3] === 0 /*00*/){ // return 'utf32le'; // } else if(buffer[0] === 0 /*00*/ && buffer[1] === 0 /*00*/ && buffer[2] === 254 /*FE*/ && buffer[3] === 255 /*FF*/){ // return 'utf32be'; } else if(buffer[0] === 255 /*FF*/ && buffer[1] === 254 /*FE*/){ return 'utf16le'; } else if(buffer[0] === 254 /*FE*/ && buffer[1] === 255 /*FF*/){ return 'utf16be' as BufferEncoding; } //try utf8 anyway... return 'utf8' } // function toUtfString(buffer, enc){ // var penc = enc.replace('utf', 'utf-'); // // var Iconv = require('iconv').Iconv; // // var iconv = new Iconv(penc, 'UTF-8'); // // return iconv.convert(buffer).toString(); // import iconv from 'iconv-lite'; import { WebpackAppConfig } from '../index-webpack.d'; // return iconv.decode(buffer, enc); // } function removeBom(content: string): string { // log('remove BOM? -> ', content.codePointAt(0), content.codePointAt(1), content.codePointAt(2), content.codePointAt(3), content.codePointAt(4));//DEBU if(content.codePointAt(0) === 65279 /*FEFF*/ || content.codePointAt(0) === 65534 /*FFFE*/){ content = content.substring(1); // log('removed BOM!');//DEBU } return content; } /** * HELPER load all files for settings-entry * @param {SettingsEntry} s NOTE s.file MUST be an Array! */ function doLoadAllFilesFor(s: SettingsBuildEntryMultiple){ if(!s.value){ warn('WARN settings-utils: forced merging for "'+s.id+'" ('+s.type+') with multiple file resources: content not loaded yet, loading file content and merging now...'); if(Array.isArray(s.file)){ var content = {}; s.file.forEach(function(f){ _.merge(content, readSettingsFile(f)); }); s.value = content; } else { warn('WARN settings-utils: could not merge files for "'+s.id+'" ('+s.type+') since there is no file list: ', s.file); } } else { log('settings-utils: multiple file resources for "'+s.id+'" ('+s.type+') already merged to ', s.value); } } function contains(list: SettingsBuildEntry[], settingsType: SettingsType, settingsId: string): boolean { return list.findIndex(function(item){ return item.id === settingsId && item.type === settingsType; }) !== -1; } /** * check if value is contained in list * * @param {Array<string|&{id: string}>} list * @param {string|&{id: string}} value * @return {boolean} */ function containsEntry(list: Array<string | &{id: string}>, value: string | &{id: string}): boolean { var id = typeof value === 'string'? value : value.id; return list.findIndex(function(item){ return ((typeof item === 'string'? item : item.id) === id); }) !== -1; } function normalizeConfigurations(settingsList: SettingsBuildEntry[]): void { var c: SettingsBuildEntry, conf: SettingsBuildEntryMultiple; for(var i = 0, size = settingsList.length; i < size; ++i){ c = settingsList[i]; if(c.type === 'configuration'){ if(conf){ // log("INFO settings-utils: encountered multiple configuration.json definition: merging configuration, some values may get overwritten");//DEBU //if "include" was set to "file", the file contents have not been loaded yet if(!conf.value){ conf.value = readSettingsFile(Array.isArray(conf.file)? conf.file[0] : conf.file, conf.fileType); } if(!c.value){ c.value = readSettingsFile(c.file, c.fileType); } //merge configuration values: _.merge(conf.value, c.value); //"merge" file-fields: if(!conf.file){ conf.file = []; } else if(!_.isArray(conf.file)){ conf.file = [conf.file]; } if(c.file){ conf.file.push(c.file) } //remove merged configuration from list & adjust i & size settingsList.splice(i--, 1); --size; } else { conf = c; } } } } /** * HELPER create a non-file settings entry (i.e. not loaded from a file) * * @param {SettingsType} type the type of settings object, e.g. "speech" or "configuration" * @param {Object} value the actual settings-data (JSON-like object) * @param {String} [id] if more than 1 settings-entry for this type can exist, its ID * @return {SettingsEntry} the settings-entry */ function createSettingsEntryFor(type: SettingsType, value: any, id?: string): SettingsBuildEntry { return {type: type, file: 'settings://'+type+'/options' + (id? '/'+id : ''), include: void(0), value: value, id: id}; } function getConfiguration(settingsList: SettingsBuildEntry[]): SettingsBuildEntryMultiple | undefined { return settingsList.find(function(item){ return item.type === 'configuration'}); } function getSettings(settingsList: SettingsBuildEntry[], type: SettingsType): SettingsBuildEntry[] { return settingsList.filter(function(item){ return item.type === type}); } /** * HELPER create default for settings type * * @param {SettingsType} type the type of settings object, e.g. "speech" or "configuration" * @param {String} id if more than 1 settings-entry for this type can exist, its ID * @return {any} the (default) settings value for id */ function createDefaultSettingsFor(type: SettingsType | string, id: string): RuntimeConfiguration | Grammar | {[dictKey: string]: string} | SpeechConfig { switch(type){ case 'configuration': return defaultSettingsConfiguration.getDefault(); case 'dictionary': return defaultSettingsDictionary.getDefault(id); case 'grammar': return defaultSettingsGrammar.getDefault(id); case 'speech': return defaultSettingsSpeech.getDefault(id); } warn("WARN settings-utils.createDefaultSettingsFor(): encountered unknown settings type "+type+" (id: "+id+"), using empty object as default value"); return {}; } function toAliasId(settings: SettingsBuildEntry): string { return 'mmirf/settings/' + settings.type + (settings.id? '/' + settings.id : '');//FIXME formalize IDs for loading views in webpack (?) } function addToConfigList(runtimeConfiguration: RuntimeConfiguration, configListName: string, entry: string | {id: string, [field: string]: any}): void { //NOTE: special treatment for value TRUE: // in case a config-list is set to TRUE, it means that // it should be interpreted, as if the config-list contains // all possible values, e.g. for "ignoreGrammarFiles" -> do ignore ALL grammar files if(runtimeConfiguration[configListName] === true){ return; } var exists = false; if(!runtimeConfiguration[configListName]){ runtimeConfiguration[configListName] = []; } else { exists = containsEntry(runtimeConfiguration[configListName], entry); } if(!exists){ runtimeConfiguration[configListName].push(entry); } } /** * check if settings entry should be excluded * * @param {SettingsType} settingsType * @param {Array<SettingsType>|RegExp} [excludeTypePattern] if not specified, always returns FALSE * @return {Boolean} TRUE if settings should be excluded */ function isExclude(settingsType: SettingsType, excludeTypePattern: Array<SettingsType> | RegExp): boolean { if(!excludeTypePattern){ return false; } if(Array.isArray(excludeTypePattern) ){ return containsEntry(excludeTypePattern, settingsType); } return excludeTypePattern.test(settingsType); } export = { setGrammarIgnored: function(runtimeConfiguration: RuntimeConfiguration, grammarId: string): void { addToConfigList(runtimeConfiguration, CONFIG_IGNORE_GRAMMAR_FILES, grammarId); }, setGrammarAsyncExec: function(runtimeConfiguration: RuntimeConfiguration, grammarIdOrEntry: string | AsyncGramarExecEntry): void { addToConfigList(runtimeConfiguration, CONFIG_GRAMMAR_ASYNC_EXEC, grammarIdOrEntry); }, /** * parse for JSON settings files * * @param {SetttingsOptions} options the settings-options with field options.path: * options.path: {String} the directory to parse for JSON settings * options.configuration: {Boolean | SettingsEntryOptions} options for the configuration.json (or .js) entry * options.dictionary: {Boolean | {[id: String]: SettingsEntryOptions}} options-map for the dictionary.json (or .js) entries where id is (usually) the language code * options.grammar: {Boolean | {[id: String]: SettingsEntryOptions}} options-map for the grammar.json (or .js) entries where id is (usually) the language code * options.speech: {Boolean | {[id: String]: SettingsEntryOptions}} options-map for the speech.json (or .js) entries where id is (usually) the language code * where: * if Boolean: if FALSE, the corresponding JSON settings are excluded/ignored * if SettingsEntryOptions: * SettingsEntryOptions.exclude: {Boolean} if TRUE the JSON setting for the corresponding ID will be excluded/ignored * @param {String} appRootDir the root directory of the app (against which relative paths will be resolved) * @param {Array<SettingsEntry>} [settingsList] OPTIONAL * @return {Array<SettingsEntry>} list of setting entries: * { * type: 'configuration' | 'dictionary' | 'grammar' | 'speech', * file: String, * id: String | undefined * } */ jsonSettingsFromDir: function(options: SettingsOptions | SettingsBuildOptions | false, appRootDir: string, settingsList?: SettingsBuildEntry[]): SettingsBuildEntry[] { var dir = options && options.path; if(dir && !path.isAbsolute(dir)){ dir = path.resolve(appRootDir, dir); } var list = settingsList || []; if(dir){ readDir(dir, list, options as SettingsOptions); } return list; }, // jsonSettingsFromOptions: function(options, appRootDir, settingsList){ // // var list = settingsList || []; // addFromOptions(options, list, appRootDir); // // return list; // }, createSettingsEntryFor: createSettingsEntryFor, createDefaultSettingsFor: createDefaultSettingsFor, normalizeConfigurations: normalizeConfigurations, getConfiguration: getConfiguration, getSettingsFor: getSettings, /** load settings file * NOTE: if updating s.value with the loaded data, need to update s.include and s.file accordingly! */ loadSettingsFrom: readSettingsFile, getFileType: getFileType, getAllSpeechConfigsType: function(): 'speech-all' { return ALL_SPEECH_CONFIGS_TYPE; }, /** * apply the "global" options from `options` or default values to the entries * from `settingsList` if its corresponding options-field is not explicitly specified. * * @param {SetttingsOptions} options the settings options * @param {{Array<SettingsEntry>}} settingsList * @return {{Array<SettingsEntry>}} */ applyDefaultOptions: function(options: SettingsBuildOptions, settingsList: SettingsBuildEntry[]): SettingsBuildEntry[] { settingsList.forEach(function(g){ [ {name: 'include', defaultValue: 'inline'}, {name: 'force', defaultValue: false}, ].forEach(function(fieldInfo){ optionUtils.applySetting(fieldInfo.name, g, options, fieldInfo.defaultValue); }); }); return settingsList; }, addSettingsToAppConfig: function(settings: SettingsBuildEntry[], appConfig: BuildAppConfig | WebpackAppConfig, directories: DirectoriesInfo, _resources: ResourceConfig, runtimeConfig: RuntimeConfiguration, settingsOptions: SettingsOptions | SettingsBuildOptions | false, ignoreMissingDictionaries?: boolean){ if(!settings || settings.length < 1){ return; } //get speech-config settings that should be applied to speech-configs const iall = settings.findIndex(function(s){ return s.type === ALL_SPEECH_CONFIGS_TYPE; }); let allSpeechSettings: SettingsBuildEntry; if(iall !== -1){ allSpeechSettings = settings[iall]; //remove from settings list (will be merged into each speech-config, see below) settings.splice(iall, 1); } const regExpExcludeType = settingsOptions && settingsOptions.excludeTypePattern; const dicts = ignoreMissingDictionaries? null : new Map(); settings.forEach(function(s){ if(isExclude(s.type, regExpExcludeType)){ return; } var aliasId = toAliasId(s); if(s.include === 'file' && _.isArray(s.file)){ doLoadAllFilesFor(s); } log(' adding setting ('+s.include+') for '+s.type+' as ', aliasId);//DEBUG if(s.type === 'configuration'){ if(!_.isEqual(s.value, runtimeConfig)){ warn("WARN settings-utils: encountered multiple configuration.json definitions when applying to app-config: merging configuration, some values may get overwritten..."); _.merge(s.value, runtimeConfig); } //NOTE configuration.json entry is mandatory, i.e. already set in directories // directoriesUtil.addConfiguration(directories, aliasId); } else if(s.type === 'dictionary'){ dicts && dicts.set(s.id, s); directoriesUtil.addDictionary(directories, aliasId) } else if(s.type === 'grammar'){ directoriesUtil.addJsonGrammar(directories, aliasId) } else if(s.type === 'speech'){ if(allSpeechSettings){ if(s.include === 'file'){ if(!s.value){ s.value = readSettingsFile(s.file, s.fileType); log(" settings-utils: applying 'speech-all' settings: did load file resource for "+s.id+": ", s.value); } } //NOTE the speech-all settings are treat "weak", i.e. they should not overwrite other setting // (1) merge into a temporary speech-config, where s.value overwrites speech-all settings if necessary // (2) "apply" temporary speech-config to s var temp = _.merge({}, allSpeechSettings.value, s.value); _.merge(s.value, temp); } directoriesUtil.addSpeechConfig(directories, aliasId) } else if(s.type !== ALL_SPEECH_CONFIGS_TYPE) { warn("WARN settings-utils: encountered multiple unknown settings definitions when applying to app-config: ", s); } if(s.include === 'file'){ appConfigUtils.addIncludeModule(appConfig, aliasId, s.file); } else { appConfigUtils.addAppSettings(appConfig, aliasId, s.value); } }); //ensure that for each language there is a (possibly empty) dictionary if(!ignoreMissingDictionaries && !isExclude('dictionary', regExpExcludeType)){ var languages = directoriesUtil.getLanguages(directories); var missing = []; languages.forEach(function(l){ var dict = dicts.get(l); if(!dict && settingsOptions && settingsOptions.dictionary !== false){ var dictEntry = createSettingsEntryFor('dictionary', createDefaultSettingsFor('dictionary', l), l); missing.push(dictEntry); settings.push(dictEntry) } }); if(missing.length > 0){ // log("INFO settings-utils: adding missing dictionaries for : ", missing); this.addSettingsToAppConfig(missing, appConfig, directories, _resources, runtimeConfig, settingsOptions, true); } } }, isExcludeType: isExclude, configEntryIgnoreGrammar: CONFIG_IGNORE_GRAMMAR_FILES, configEntryAsyncExecGrammar: CONFIG_GRAMMAR_ASYNC_EXEC, configEntryDisableGrammarStrictMode: CONFIG_GRAMMAR_DISABLE_STRICT_MODE };