gpii-universal
Version:
Cross platform, core components of the GPII personalization infrastructure.
573 lines (517 loc) • 22.9 kB
JavaScript
/*
* 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);
};