//////////////////////////////////////////////////////////////////////////////// // // Copyright 2015 - 2023, Thomas Lauf, Paul Beckingham, Federico Hernandez. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included // in all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS // OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL // THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. // // https://www.opensource.org/licenses/mit-license.php // //////////////////////////////////////////////////////////////////////////////// #include #include #include #include #include #include #include #include #include #include #include //////////////////////////////////////////////////////////////////////////////// Rules::Rules () { // Load the default values. _settings = { {"confirmation", "on"}, {"debug", "off"}, {"verbose", "on"}, // 'day' report. {"reports.day.hours", "all"}, {"reports.day.axis", "internal"}, {"reports.day.lines", "2"}, {"reports.day.spacing", "1"}, {"reports.day.month", "no"}, {"reports.day.week", "no"}, {"reports.day.day", "yes"}, {"reports.day.weekday", "yes"}, {"reports.day.totals", "no"}, {"reports.day.summary", "yes"}, {"reports.day.holidays", "no"}, {"reports.day.cell", "15"}, // 'week' report. {"reports.week.hours", "all"}, {"reports.week.lines", "1"}, {"reports.week.spacing", "1"}, {"reports.week.month", "no"}, {"reports.week.week", "yes"}, {"reports.week.day", "yes"}, {"reports.week.weekday", "yes"}, {"reports.week.totals", "yes"}, {"reports.week.summary", "yes"}, {"reports.week.holidays", "yes"}, {"reports.week.cell", "15"}, // 'month' report. {"reports.month.hours", "all"}, {"reports.month.lines", "1"}, {"reports.month.spacing", "1"}, {"reports.month.month", "yes"}, {"reports.month.week", "yes"}, {"reports.month.day", "yes"}, {"reports.month.weekday", "yes"}, {"reports.month.totals", "yes"}, {"reports.month.summary", "yes"}, {"reports.month.holidays", "yes"}, {"reports.month.cell", "15"}, // 'summary' report. {"reports.summary.holidays", "yes"}, // Enough of a theme to make the charts work. {"theme.description", "Built-in default"}, {"theme.colors.exclusion", "gray8 on gray4"}, {"theme.colors.today", "white"}, {"theme.colors.holiday", "gray4"}, {"theme.colors.label", "gray4"}, // Options for the journal / undo file. {"journal.size", "-1"}, }; } //////////////////////////////////////////////////////////////////////////////// // Nested files are supported, with the following construct: // import /absolute/path/to/file void Rules::load (const std::string& file, int nest /* = 1 */) { if (nest > 10) throw std::string ("Rules files may only be nested to 10 levels."); if (nest == 1) { Path originalFile (file); _original_file = originalFile._data; if (! originalFile.exists ()) throw std::string ("ERROR: Configuration file not found."); if (! originalFile.readable ()) throw std::string ("ERROR: Configuration file cannot be read (insufficient privileges)."); } // Read the file, then parse the contents. std::string contents; try { AtomicFile::read (file, contents); if (contents.length ()) { parse (contents, nest); } } catch (...) { } } //////////////////////////////////////////////////////////////////////////////// std::string Rules::file () const { return _original_file; } //////////////////////////////////////////////////////////////////////////////// bool Rules::has (const std::string& key) const { return _settings.find (key) != _settings.end (); } //////////////////////////////////////////////////////////////////////////////// // Return the configuration value given the specified key. std::string Rules::get (const std::string &key, const std::string &defaultValue) const { auto found = _settings.find (key); if (found != _settings.end ()) { return found->second; } return defaultValue; } //////////////////////////////////////////////////////////////////////////////// int Rules::getInteger (const std::string& key, int defaultValue) const { auto found = _settings.find (key); if (found != _settings.end ()) { int value = strtoimax (found->second.c_str (), nullptr, 10); // Invalid values are handled. ERANGE errors are simply capped by // strtoimax, which is desired behavior. // Note that not all platforms behave alike, and the EINVAL is not // necessarily returned. if (value == 0 && (errno == EINVAL || found->second != "0")) throw format ("Invalid integer value for '{1}': '{2}'", key, found->second); return value; } return defaultValue; } //////////////////////////////////////////////////////////////////////////////// double Rules::getReal (const std::string& key) const { auto found = _settings.find (key); if (found != _settings.end ()) return strtod (found->second.c_str (), nullptr); return 0.0; } //////////////////////////////////////////////////////////////////////////////// bool Rules::getBoolean (const std::string& key, bool defaultValue) const { auto found = _settings.find (key); if (found != _settings.end ()) { auto value = lowerCase (found->second); if (value == "true" || value == "1" || value == "y" || value == "yes" || value == "on") { return true; } return false; } return defaultValue; } //////////////////////////////////////////////////////////////////////////////// void Rules::set (const std::string& key, const int value) { _settings[key] = format (value); } //////////////////////////////////////////////////////////////////////////////// void Rules::set (const std::string& key, const double value) { _settings[key] = format (value, 1, 8); } //////////////////////////////////////////////////////////////////////////////// void Rules::set (const std::string& key, const std::string& value) { _settings[key] = value; } //////////////////////////////////////////////////////////////////////////////// // Provide a vector of all configuration keys. If a stem is provided, only // return matching keys. std::vector Rules::all (const std::string& stem) const { std::vector items; for (const auto& it : _settings) if (stem.empty () || it.first.find (stem) == 0) items.push_back (it.first); return items; } //////////////////////////////////////////////////////////////////////////////// bool Rules::isRuleType (const std::string& type) const { if (std::find (_rule_types.begin (), _rule_types.end (), type) != _rule_types.end ()) return true; return false; } //////////////////////////////////////////////////////////////////////////////// std::string Rules::dump () const { std::stringstream out; out << "Rules\n" << " _original_file " << _original_file << '\n'; out << " Settings\n"; for (const auto& item : _settings) out << " " << item.first << "=" << item.second << '\n'; return out.str (); } //////////////////////////////////////////////////////////////////////////////// void Rules::parse (const std::string& input, int nest /* = 1 */) { bool inRule = false; std::string ruleDef; // Remove comments from input. for (auto& line : split (input, '\n')) { auto comment = line.find ("#"); if (comment != std::string::npos) { line.resize (comment); line = rtrim (line); } // Tokenize the line for convenience, but capture the indentation level. // tokens. auto indent = line.find_first_not_of (' '); auto tokens = Lexer::tokenize (line); if (! tokens.empty ()) { if (inRule) { if (indent == 0) { inRule = false; parseRule (ruleDef); ruleDef = ""; } else { ruleDef += line + '\n'; } } // Note: this should NOT be an 'else' to the above 'if (inRule)', because // there are lines where both blocks need to run. if (! inRule) { auto firstWord = std::get <0> (tokens[0]); // Top-level rule definition: // 'define' // ... // if (firstWord == "define") { if (! ruleDef.empty ()) { parseRule (ruleDef); ruleDef = ""; } inRule = true; ruleDef = line + '\n'; } // Top-level import: // 'import' else if (firstWord == "import" && tokens.size () == 2 && std::get <1> (tokens[1]) == Lexer::Type::path) { File imported (std::get <0> (tokens[1])); if (! imported.is_absolute ()) throw format ("Can only import files with absolute paths, not '{1}'.", imported._data); if (! imported.readable ()) throw format ("Could not read imported file '{1}'.", imported._data); load (imported._data, nest + 1); } // Top-level settings: // '=' else if (tokens.size () >= 3 && std::get <0> (tokens[1]) == "=") { // If this line is an assignment, then tokenizing it is a mistake, so // use the raw data from 'line'. auto equals = line.find ('='); assert (equals != std::string::npos); set (trim (line.substr (indent, equals - indent)), trim (line.substr (equals + 1))); } // Top-level settings, with no value: // '=' else if (tokens.size () == 2 && std::get <0> (tokens[1]) == "=") { set (firstWord, ""); } // Admit defeat. else throw format ("Unrecognized construct: {1}", line); } } } if (! ruleDef.empty ()) parseRule (ruleDef); } //////////////////////////////////////////////////////////////////////////////// void Rules::parseRule (const std::string& input) { // Break the rule def into lines. auto lines = split (input, '\n'); // Tokenize the first line. std::vector tokens; std::string token; Lexer::Type type; Lexer lexer (lines[0]); while (lexer.token (token, type)) tokens.push_back (token); // Based on the tokens of the first line, determine which rule type needs to // be parsed. if (tokens.size () >= 2 && tokens[0] == "define") { if (tokens.size () >= 2 && isRuleType (tokens[1].substr (0, tokens[1].length () - 1))) parseRuleSettings (lines); // Error. else throw format ("Unrecognized rule type '{1}'", join (" ", tokens)); } } //////////////////////////////////////////////////////////////////////////////// void Rules::parseRuleSettings ( const std::vector & lines) { std::vector hierarchy; std::vector indents; indents.push_back (0); for (const auto& line : lines) { auto indent = getIndentation (line); auto tokens = tokenizeLine (line); auto group = parseGroup (tokens); // Capture increased indentation. if (indent > indents.back ()) indents.push_back (indent); // If indent decreased. else if (indent < indents.back ()) { while (!indents.empty () && indent != indents.back ()) { indents.pop_back (); hierarchy.pop_back (); } // Spot raggedy-ass indentation. if (indent != indents.back ()) throw std::string ("Syntax error in rule: mismatched indent."); } // Descend. if (! group.empty ()) hierarchy.push_back (group); // Settings. if (tokens.size () >= 3 && tokens[1] == "=") { auto name = join (".", hierarchy) + "." + tokens[0]; auto equals = line.find ('='); if (equals == std::string::npos) throw format ("Syntax error in rule: missing '=' in line '{1}'.", line); auto value = Lexer::dequote (trim (line.substr (equals + 1))); set (name, value); } } // Should arrive here with indents and hierarchy in their original state. if (indents.size () != 1 || indents[0] != 0) throw std::string ("Syntax error - indentation is not right."); } //////////////////////////////////////////////////////////////////////////////// unsigned int Rules::getIndentation (const std::string& line) { auto indent = line.find_first_not_of (' '); if (indent == std::string::npos) indent = 0; return indent; } //////////////////////////////////////////////////////////////////////////////// // Tokenize the line. This loses whitespace information and token types. std::vector Rules::tokenizeLine (const std::string& line) { std::vector tokens; std::string token; Lexer::Type type; Lexer lexer (line); while (lexer.token (token, type)) tokens.push_back (token); return tokens; } //////////////////////////////////////////////////////////////////////////////// // {"one","two:"} --> "two" // {"one two", ":"} --> "one two" // {"one"} --> "" std::string Rules::parseGroup (const std::vector & tokens) { auto count = tokens.size (); if (count) { auto last = tokens.back (); if (count >= 2 && last == ":") return tokens[count - 2]; else if (count >= 1 && last[last.length () - 1] == ':') return last.substr (0, last.length () - 1); } return ""; } //////////////////////////////////////////////////////////////////////////////// // Note that because this function does not recurse with includes, it therefore // only sees the top-level settings. This has the desirable effect of adding as // an override any setting which resides in an imported file. bool Rules::setConfigVariable ( Journal& journal, const Rules& rules, std::string name, std::string value, bool confirmation /* = false */) { // Read config file as lines of text. std::vector lines; File::read (rules.file (), lines); bool change = false; if (rules.has (name)) { // No change. if (rules.get (name) == value) return false; // If there is a non-comment line containing the entry in flattened form: // a.b.c = value bool found = false; for (auto& line : lines) { auto comment = line.find ('#'); auto pos = line.find (name); if (pos != std::string::npos && (comment == std::string::npos || comment > pos)) { found = true; // Modify value if (! confirmation || confirm (format ("Are you sure you want to change the value of '{1}' from '{2}' to '{3}'?", name, rules.get (name), value))) { auto before = line; line = line.substr (0, pos) + name + " = " + value; journal.recordConfigAction (before, line); change = true; } } } // If it was not found, then retry in hierarchical form∴ // a: // b: // c = value if (! found) { auto leaf = split (name, '.').back () + ":"; for (auto& line : lines) { auto comment = line.find ('#'); auto pos = line.find (leaf); if (pos != std::string::npos && (comment == std::string::npos || comment > pos)) { found = true; // Remove name if (! confirmation || confirm (format ("Are you sure you want to change the value of '{1}' from '{2}' to '{3}'?", name, rules.get (name), value))) { auto before = line; line = line.substr (0, pos) + leaf + " " + value; journal.recordConfigAction (before, line); change = true; } } } } if (! found) { // Remove name if (! confirmation || confirm (format ("Are you sure you want to change the value of '{1}' from '{2}' to '{3}'?", name, rules.get (name), value))) { // Add blank line required by rules. if (lines.empty () || lines.back ().empty ()) lines.emplace_back (""); // Add new line. lines.push_back (name + " = " + json::encode (value)); journal.recordConfigAction ("", lines.back ()); change = true; } } } else { if (! confirmation || confirm (format ("Are you sure you want to add '{1}' with a value of '{2}'?", name, value))) { // TODO Ideally, this would locate an existing hierarchy and insert the // new leaf/value properly. But that's non-trivial. // Add blank line required by rules. if (lines.empty () || lines.back ().empty ()) lines.emplace_back (""); // Add new line. lines.push_back (name + " = " + json::encode (value)); journal.recordConfigAction ("", lines.back ()); change = true; } } if (change) { AtomicFile::write (rules.file (), lines); } return change; } //////////////////////////////////////////////////////////////////////////////// // Removes lines from configuration but leaves comments intact. // // Return codes: // 0 - found and removed // 1 - found and not removed // 2 - not found int Rules::unsetConfigVariable ( Journal& journal, const Rules& rules, std::string name, bool confirmation /* = false */) { // Setting not found. if (! rules.has (name)) return 2; // Read config file as lines of text. std::vector lines; AtomicFile::read (rules.file (), lines); // If there is a non-comment line containing the entry in flattened form: // a.b.c = value bool found = false; bool change = false; for (auto& line : lines) { auto comment = line.find ('#'); auto pos = line.find (name); if (pos != std::string::npos && (comment == std::string::npos || comment > pos)) { found = true; // Remove name if (! confirmation || confirm (format ("Are you sure you want to remove '{1}'?", name))) { journal.recordConfigAction (line, ""); line = ""; change = true; } } } // If it was not found, then retry in hierarchical form∴ // a: // b: // c = value if (! found) { auto leaf = split (name, '.').back () + ":"; for (auto& line : lines) { auto comment = line.find ('#'); auto pos = line.find (leaf); if (pos != std::string::npos && (comment == std::string::npos || comment > pos)) { found = true; // Remove name if (! confirmation || confirm (format ("Are you sure you want to remove '{1}'?", name))) { line = ""; change = true; } } } } if (change) { AtomicFile::write (rules.file (), lines); } if (change && found) return 0; else if (found) return 1; return 2; }