UNPKG

gpii-universal

Version:

Cross platform, core components of the GPII personalization infrastructure.

573 lines (517 loc) 22.9 kB
/* * INI File reader/writer * * Copyright 2018 Raising the Floor - International * * Licensed under the New BSD license. You may not use this file except in * compliance with this License. * * The R&D leading to these results received funding from the * Department of Education - Grant H421A150005 (GPII-APCP). However, * these results do not necessarily represent the policy of the * Department of Education, and you should not assume endorsement by the * Federal Government. * * You may obtain a copy of the License at * https://github.com/GPII/universal/blob/master/LICENSE.txt */ "use strict"; var fluid = require("infusion"), gpii = fluid.registerNamespace("gpii"); var fs = require("fs"), os = require("os"), XRegExp = require("xregexp"); gpii.iniFile = fluid.registerNamespace("gpii.iniFile"); var pattern = fs.readFileSync(__dirname + "/ini.regex"); gpii.iniFile.regex = new XRegExp(pattern, "gmsx"); /** * Options for writing ini files with gpii.iniFile.write(). * * @typedef {Object} iniFile.WriteOptions * * @property {String} keyValueDelimiter - Text which separates keys and values, for new values. [default: "="]. * @property {Boolean} keepUndefined - true to keep items and sections that are not in data. Use to define values * without reading the file beforehand. * @property {String} multilineStyle - How new multi-line values are written: * "'''" or '"""': Surround the value with 3x single or double quotes (default). * "indent": Indent the additional lines. * "escape": Use an escaped n (\n). * anything else: Wrap the value with the given value. * @property {String} quote - For new values, quote them "always", "never", "strings" (for only strings), or * "spaces" (if the value starts or ends with a space) [default: "spaces"]. * @property {String} quoteChar - The quote character for new values when quoting. [default: " (double quote)]. * @property {String} eol - The file's end of line character(s). [default: auto-detect] */ /** * Callback for iniFile.parse(), invoked at the start of a new section. * * @callback iniFile.sectionBegin * @param {Object} state The state object that was passed to the parse function. * @param {Array<String>} sectionPath The path to the new section object. * @return {String} The text to add before the new section, or fluid.NO_VALUE to remove the section. Ignored when * reading */ /** * Callback for iniFile.parse(), invoked at the end of a section, after all of its sub-sections have been parsed. * * @callback iniFile.sectionEnd * @param {Object} state The state object that was passed to the parse function. * @param {Array<String>} sectionPath The path to the section object. * @param {Boolean} eof true if the end of section is the end of the file (rather than just before another section). * @return {String} The text to add at the end of the section. Ignored when reading. */ /** * Callback for iniFile.parse(), called when a value has been parsed. * * @callback iniFile.gotValue * @param {String} sectionPath The path of the section. * @param {String} key The value name. * @param {String} value The value. * @param {Boolean} quoted true if the value was quoted. * @return {String} The new value, fluid.NO_VALUE to remove the value, or undefined if it's unchanged. Ignored when * reading. */ /** * Parse an INI file, invoking call backs at interesting points: start/end of a section, and on a key=value. * * If options.write=true, the return values of the callbacks will be used to modify the content, the entirety of which * is then returned by this function. * * It uses the regular expression in ./ini.regex to perform the actual parsing. * * @param {String} content The INI file content. * @param {Object} handlers Object containing the callbacks. * @param {iniFile.sectionBegin} handlers.sectionBegin - called at the start of a new section, returning a string to * write before the section line, if options.write=true. * @param {iniFile.sectionEnd} handlers.sectionEnd - called at the end of a section, after all of its sub-sections * have been seen. Returning a string to write at the end of the section, if options.write=true. * @param {iniFile.gotValue} handlers.gotValue - called when a value has been parsed. If options.write=true, return the * text to replace the content of the value, undefined for no update, or fluid.NO_VALUE to remove the key=value pair. * @param {Object} options Parser options. * @param {Boolean} options.write True if writing. * @param {Object} options.state An object to pass to the handler callbacks. * @return {Object|String} The data from the INI file, or the new file content if options.write=true. */ gpii.iniFile.parse = function (content, handlers, options) { options = Object.assign({}, options); var sectionPath = []; if (handlers.sectionBegin) { handlers.sectionBegin(options.state, sectionPath); } var regexFunction = options.write ? XRegExp.replace : XRegExp.forEach; var output = regexFunction(content, gpii.iniFile.regex, function (match) { return gpii.iniFile.parse.processMatch(match, sectionPath, handlers, options); }); var append = gpii.iniFile.parse.endSection(sectionPath, 0, handlers.sectionEnd, true, options); if (options.write && append) { // If the content doesn't end with a new line, then add one before the new addition. var hasEol = output.endsWith(options.state.options.eol); if (!hasEol) { output += options.state.options.eol; if (append.endsWith(options.state.options.eol)) { append = append.trimRight(); } } output += append; } return options.write && output; }; /** * Called during parsing when a [section] has ended, either due to the start of another section, or at the end of file. * Adjust the current path to match the new depth, calling the sectionEnd callback for each level. * * @param {Array<String>} sectionPath Path of the section that's ending. * @param {Number} depth The new section path depth. * @param {iniFile.sectionEnd} sectionEnd The end of section callback * @param {Boolean} eof true if the end of section is the end of the file (rather than just before another section). * @param {Object} options Parser options. * @param {Boolean} options.write True if writing. * @param {Object} options.state An object to pass to the handler callbacks. * @return {String} If writing, the text to append to the section. */ gpii.iniFile.parse.endSection = function (sectionPath, depth, sectionEnd, eof, options) { var result = ""; // Go back up the stack (if required) while (depth <= sectionPath.length) { // At this point, the sub-sections for the current section are done. if (sectionEnd) { var endContent = sectionEnd(options.state, sectionPath, eof); if (options.write && endContent) { result += endContent; } } if (sectionPath.length === 0) { break; } sectionPath.pop(); } return result; }; /** * Processes a match from the INI parser. * * @param {Object} match The match from the regular expression. * @param {String} match.section The section name, if matching a section header, otherwise undefined. * @param {String} match.sectionCount The depth of the section (the [ characters). * @param {String} match.key Name of the value. * @param {String} match.prefix Everything up to the value (key and surrounding whitespace). * @param {String} match.indent The indentation whitespace. * @param {String} match.value The value, if unquoted. * @param {String} match.value_ml_indent Multi-line value, if indented. * @param {String} match.value_ml_quote Multi-line value, if quoted. * @param {String} match.value_quote The value, if quoted. * @param {String} match.qqq The quotes used for value_ml_quote * @param {String} match.q The quotes used for value_quote * @param {String} match.suffix Everything after the value, up to and including the newline. * @param {Array<String>} sectionPath The path of the current section. * @param {Object} handlers Parser callbacks. * @param {Object} options INI file options. * @return {String} The replacement string, if writing. */ gpii.iniFile.parse.processMatch = function (match, sectionPath, handlers, options) { var result = options.write && match.toString(); if (match.section) { // The number of ['s in the section line. var depth = match.sectionCount.length; if (depth > sectionPath.length + 1) { fluid.log("INI file parse error: Current section is not a direct descendant of the previous section"); } var endContent = gpii.iniFile.parse.endSection(sectionPath, depth, handlers.sectionEnd, false, options); if (options.write) { result = endContent + result; } sectionPath.push(match.section); if (handlers.sectionBegin) { var prepend = handlers.sectionBegin(options.state, sectionPath); if (options.write && prepend) { result = prepend + result; } } } else { // It's some type of key=value pair. var value = fluid.find( [match.value, match.value_ml_indent, match.value_ml_quote, match.value_quote], fluid.identity); if (match.value_quote) { value = value.replace("\\\"", "\""); } else if (match.value_ml_indent || match.value_ml_quote) { value = value.replace(/\n[^\S\n]+/g, "\n"); } if (handlers.gotValue) { var quoted = match.value_ml_quote !== undefined || match.value_quote !== undefined; var newValue = handlers.gotValue(options.state, sectionPath, match.key, value, quoted); if (options.write) { if (newValue === fluid.NO_VALUE) { result = ""; } else if (newValue !== undefined) { var quote = (match.qqq || match.q || ""); if (match.value_ml_indent) { newValue = newValue.replace(/\n/g, "\n" + match.indent + " ".repeat(4)); } result = match.prefix + quote + newValue + quote + match.suffix; } } } } return result; }; /** * Stringify a value - converts it to a string as returned by JSON.stringify. * @param {Mixed} value The value. * @return {String} The stringified version of value. */ gpii.iniFile.stringify = function (value) { if (value === null || value === undefined) { return ""; } else { return typeof(value) === "string" ? value : JSON.stringify(value); } }; /** * Generates the text of a new key=value pair, or a new section and its values and sub-sections if value is an * object. * * @param {String} key The name of it. * @param {String|Number|Boolean} value The value. * @param {Array<String>} path The path of the containing section. * @param {iniFile.WriteOptions} options INI file output options. * @return {Array<String>} Array of lines for the new value or sub-section. */ gpii.iniFile.writeValue = function (key, value, path, options) { var output = []; if (fluid.isPlainObject(value)) { // The section header. output.push(""); output.push("[".repeat(path.length + 1) + key + "]".repeat(path.length + 1)); // Section items. fluid.each(Object.keys(value), function (subkey) { output.push.apply(output, gpii.iniFile.writeValue(subkey, value[subkey], path.concat(key), options)); }); } else { var newValue = gpii.iniFile.stringify(value); if (newValue.indexOf("\n") >= 0) { // A multi-line value. switch (options.multilineStyle) { case "indent": // eol is added at the end to ensure the next line isn't included in the value if it happens to be // at the same indentation as the value. newValue = newValue.replace(/\n/g, options.eol + " ") + options.eol; break; case "escape": newValue = newValue.replace(/\n/g, "\\n"); break; case "\"\"\"": case "'''": default: newValue = options.multilineStyle + newValue + options.multilineStyle; break; } } else { var quote = (options.quote === "always" || (options.quote === "string" && typeof(s) === "string")); quote = quote || (options.quote === "spaces" && /^\s|\s$/.test(newValue)); if (quote) { newValue = options.quoteChar + newValue + options.quoteChar; } } output.push(key + options.keyValueDelimiter + newValue); } return output; }; /** * Get the values that have not been written to for the current section (that is, the new values). * * @param {iniFile.WriteState} state The parser state. * @param {Array<String>} sectionPath The path to the section object. * @param {Boolean} subSections true to return only sub-sections (objects), otherwise return values. * @return {Array<Object>} The key-value pairs that have yet to be written. */ gpii.iniFile.getUnwrittenValues = function (state, sectionPath, subSections) { var items = []; var section = fluid.get(state.data, sectionPath); if (section) { var allKeys = Object.keys(section); var path = sectionPath.length ? sectionPath.join(state.pathSep) + state.pathSep : ""; fluid.each(allKeys, function (key) { if (!state.valuesWritten[path + key]) { var value = section[key]; if (fluid.isPlainObject(value) === !!subSections) { items.push({ key: key, value: value }); } } }); } return items; }; /** * Parser state used for INI file writing. * @typedef {Object} iniFile.WriteState * @property {Object} data The data being written. * @property {Object} currentSection The object of the section currently being dealt with. * @property {Array<String>} currentPath Path of the current section. * @property {String} currentPathString Path of the current section, as a string. * @property {String} pathSep Use to separate path segments, while allowing '.' (dot) in the section/key names. * @property {Object} valuesWritten A hash-map of the value paths that have been written, or where already there. * @property {iniFile.WriteOptions} options The INI file writing options */ /** * Writes an object to existing INI file content. * * @param {String} input The ini file content to update (can be empty). * @param {Object} data The new settings data. * @param {iniFile.WriteOptions} options INI file output options. * @return {String} The new ini file content. */ gpii.iniFile.write = function (input, data, options) { var state = { data: data, // The object of the section currently being dealt with. currentSection: null, // Path of the current section. currentPath: [], currentPathString: "", // Use to separate path segments, while allowing '.' (dot) in the section/key names. pathSep: "][", // A hash-map of the value paths that have been written, or where already there. valuesWritten: {}, options: Object.assign({ keyValueDelimiter: "=", keepUndefined: false, eol: undefined, quoteChar: "\"", quote: "spaces", multilineStyle: "\"\"\"" }, options) }; if (state.options.eol === undefined) { var m = /\r\n|[\r\n]/.exec(input); state.options.eol = m && m[0] || os.EOL; } return gpii.iniFile.parse(input, gpii.iniFile.write, {state: state, write: true}); }; /** * Called at the start of a new section. * Output unwritten values to the current section (before the new one). * * @param {iniFile.WriteState} state The parser state. * @param {Array<String>} sectionPath The path to the new section object. * @return {String} The text to add before the new section, or fluid.NO_VALUE to remove the section. */ gpii.iniFile.write.sectionBegin = function (state, sectionPath) { var output = []; if (sectionPath.length > 0) { // Add the values to the previous section that haven't already been added. fluid.each(gpii.iniFile.getUnwrittenValues(state, state.currentPath, false), function (tuple) { output.push.apply(output, gpii.iniFile.writeValue(tuple.key, tuple.value, sectionPath, state.options)); }); } state.currentPath = sectionPath.slice(); state.currentSection = fluid.get(state.data, state.currentPath); state.currentPathString = state.currentPath.join(state.pathSep); state.valuesWritten[state.currentPathString] = true; return output.length === 0 ? "" : (output.join(state.options.eol) + state.options.eol); }; /** * Called at the end of a section, after all of its sub-sections have been parsed. * Output the unwritten sub-sections of the section. * * @param {iniFile.WriteState} state The parser state. * @param {Array<String>} sectionPath The path to the section object. * @param {Boolean} eof true if the end of section is the end of the file (rather than just before another section). * @return {String} The text to add at the end of the section. */ gpii.iniFile.write.sectionEnd = function (state, sectionPath, eof) { var output = []; if (eof || sectionPath.length === 0 && state.currentPathString === "") { // Special case where at the end of a file with no sections, where sectionBegin wouldn't have been // called. var content = gpii.iniFile.write.sectionBegin(state, ["dummy"]); if (content && content.length > 0) { output.push(content.trim()); } } // Add the sub-sections to the section that haven't already been added. fluid.each(gpii.iniFile.getUnwrittenValues(state, sectionPath, true), function (tuple) { output.push.apply(output, gpii.iniFile.writeValue(tuple.key, tuple.value, sectionPath, state.options)); }); state.valuesWritten[state.currentPathString] = true; return output.length === 0 ? "" : (output.join(state.options.eol) + state.options.eol); }; /** * Called when a value has been parsed. Return the updated value, if required. * * @param {iniFile.WriteState} state The parser state. * @param {String} sectionPath The path of the section. * @param {String} key The value name. * @param {String} value The value. * @return {String} The new value, fluid.NO_VALUE to remove the value, or undefined if it's unchanged. */ gpii.iniFile.write.gotValue = function (state, sectionPath, key, value) { var result; if (state.currentSection && state.currentSection.hasOwnProperty(key)) { var newValue = gpii.iniFile.stringify(state.currentSection[key]); var p = (state.currentPathString ? state.currentPathString + state.pathSep : "") + key; state.valuesWritten[p] = true; if (value !== newValue) { result = newValue; } } else if (!state.options.keepUndefined) { result = fluid.NO_VALUE; } return result; }; /** * Reads INI file content, returning the parsed data as an object. * * @param {String} content The ini file content. * @param {Object} options Options: * @param {Boolean} options.strings true to always return strings, otherwise try to return numbers and booleans for * unquoted values, where appropriate. * @param {Boolean} options.firstDuplicate true to use the first occurrence of a value with a duplicate name, otherwise * the last is used. [default: false] * * @return {Object} The parsed ini file data. */ gpii.iniFile.read = function (content, options) { var state = { result: {}, options: Object.assign({ firstDuplicate: false }, options) }; gpii.iniFile.parse(content, gpii.iniFile.read, {state:state}); return state.result; }; /** * Called at the start of a new section. Create an empty object for it. * @param {Object} state The parser state. * @param {Array<String>} sectionPath The path to the new section object. */ gpii.iniFile.read.sectionBegin = function (state, sectionPath) { var existing = sectionPath.length && fluid.get(state.result, sectionPath); if (existing) { if (fluid.isPlainObject(existing)) { fluid.log("iniFile - Warning: Merging sections with duplicate name: ", sectionPath.join(".")); } else { fluid.log("iniFile - Warning: Overwriting key=value with section of the same name: ", sectionPath.join(".")); fluid.set(state.result, sectionPath, {}); } } }; /** * Called when a value has been parsed. * * @param {Object} state The parser state. * @param {String} sectionPath The path of the section. * @param {String} key The value name. * @param {String} value The value. * @param {Boolean} quoted true if the value was quoted. */ gpii.iniFile.read.gotValue = function (state, sectionPath, key, value, quoted) { // Ignore the value if it's already set, if only setting first duplicates. var set = !state.options.firstDuplicate || (fluid.get(state.result, sectionPath.concat(key), value) === undefined); if (!state.options.strings && !quoted) { // Try to convert it to a non-string value. if (/^([0-9]+|true|false|null)$/.test(value)) { value = JSON.parse(value); } } if (set) { fluid.set(state.result, sectionPath.concat(key), value); } }; /** * Wrapper for read(), that reads from the given file. * @param {String} path The ini file. * @param {Object} options Options for read(). * @return {Object} The result of read(). */ gpii.iniFile.readFile = function (path, options) { var content; try { content = fs.readFileSync(path, "utf8"); } catch (e) { content = ""; } return gpii.iniFile.read(content, options); }; /** * Wrapper for write(), that reads the initial data from the given file. * * @param {String} path The ini file. * @param {Object} data The new settings data. * @param {Object} options Options for write(). * @return {Object} The result of write(). */ gpii.iniFile.writeFromFile = function (path, data, options) { var content; try { content = fs.readFileSync(path, "utf8"); } catch (e) { content = ""; } return gpii.iniFile.write(content, data, options); };