/* ** Copyright (C) 2025 Dirk-Jan C. Binnema ** ** This program is free software; you can redistribute it and/or modify it ** under the terms of the GNU General Public License as published by the ** Free Software Foundation; either version 3, or (at your option) any ** later version. ** ** This program is distributed in the hope that it will be useful, ** but WITHOUT ANY WARRANTY; without even the implied warranty of ** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ** GNU General Public License for more details. ** ** You should have received a copy of the GNU General Public License ** along with this program; if not, write to the Free Software Foundation, ** Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. ** */ #include "mu-cmd.hh" #include #include #include "mu-store.hh" #include "message/mu-message.hh" #include "message/mu-labels.hh" using namespace Mu; using namespace Labels; static Result label_update(Mu::Store& store, const Options& opts) { // First get our list of parse delta-label, and ensure they // are valid. DeltaLabelVec deltas{}; for (auto&& delta_label : opts.label.delta_labels) { if (const auto res = parse_delta_label(delta_label); !res) return Err(Error{Error::Code::InvalidArgument, "invalid delta-label '{}': {}", delta_label, res.error().what()}); else deltas.emplace_back(std::move(*res)); } if (!opts.label.query) return Err(Error{Error::Code::Query, "missing query"}); // now run queru and apply the deltas to each. const auto query{*opts.label.query}; auto results{store.run_query(query)}; if (!results) return Err(Error{Error::Code::Query, "failed to run query '{}': {}", query, *results.error().what()}); // seems we got some results... let's apply to each size_t n{}; const auto labelstr{join(opts.label.delta_labels, " ")}; for (auto&& result : *results) { if (auto &&msg{result.message()}; msg) { if (opts.label.dry_run || opts.verbose) mu_println("labels: apply {} to {}", labelstr, msg->path()); if (!opts.label.dry_run) { store.update_labels(*msg, deltas); } ++n; } } if (opts.verbose || opts.label.dry_run) mu_println("labels: {}updated {} message(s)", opts.label.dry_run ? "would have " : "", n); return Ok(); } static Result label_clear(Mu::Store& store, const Options& opts) { if (!opts.label.query) return Err(Error{Error::Code::Query, "missing query"}); const auto query{*opts.label.query}; auto results{store.run_query(query)}; if (!results) return Err(Error{Error::Code::Query, "failed to run query '{}': {}", query, *results.error().what()}); size_t n{}; for (auto&& result : *results) { if (auto &&msg{result.message()}; msg) { if (opts.label.dry_run || opts.verbose) mu_println("labels: clear all from {}", msg->path()); if (!opts.label.dry_run) { store.clear_labels(*msg); } ++n; } } if (opts.verbose || opts.label.dry_run) mu_println("labels: {}cleared {} message(s)", opts.label.dry_run ? "would have " : "", n); return Ok(); } static Result label_list(const Mu::Store& store, const Options& opts) { const auto label_map{store.label_map()}; for (const auto& [label, n]: label_map) mu_println("{}: {}", label, n); return Ok(); } constexpr std::string_view path_key = "path:"; constexpr std::string_view message_id_key = "message-id:"; constexpr std::string_view labels_key = "labels:"; static Result label_export(const Mu::Store& store, const Options& opts) { const auto now_t{::time({})}; const auto now_tm{::localtime(&now_t)}; const auto now{mu_format("{:%F-%T}", *now_tm)}; const auto fname = opts.label.file.value_or( mu_format("mu-export-{}.txt", now)); auto output{std::ofstream{fname, std::ios::out}}; if (!output.good()) return Err(Error{Error::Code::File, "failed to open '{}' for writing", fname}); const auto query{opts.label.query.value_or("")}; auto results{store.run_query(query)}; if (!results) return Err(Error{Error::Code::Query, "failed to run query '{}': {}", query, *results.error().what()}); mu_println(output, ";; version:0 @ {}\n", now); for (auto&& result : *results) { if (auto &&msg{result.message()}; msg) { if (const auto labels{msg->labels()}; !labels.empty()) { mu_print(output, "{}{}\n" "{}{}\n" "{}{}\n\n", path_key, msg->path(), message_id_key, msg->message_id(), labels_key, join(labels,',')); } } } if (!opts.quiet) mu_println("written {}", fname); return Ok(); } static void log_import(const Options& opts, const std::string& msg, bool is_err=false) { if (is_err) mu_debug("{}", msg); else mu_warning("{}", msg); if (is_err && !opts.quiet) mu_printerrln("{}", msg); else if (opts.verbose) mu_println("{}", msg); } static void log_import_err(const Options& opts, const std::string& msg) { log_import(opts, msg, true); } static Result log_import_get_matching(Mu::Store& store, const std::string& query, int max=1) { if (auto qres = store.run_query(query, {}, {}, max); !qres) return Err(std::move(qres.error())); else if (qres->empty()) return Err(Error{Error::Code::Query, "no matching messages for {}", query}); else return Ok(std::move(*qres)); } static void import_labels_for_message(Mu::Store& store, const Options& opts, const std::string& path, const std::string& msgid, const std::vector labels) { Labels::DeltaLabelVec delta_labels{}; std::transform(labels.begin(), labels.end(), std::back_inserter(delta_labels), [](const auto& label) { return DeltaLabel{Delta::Add, label}; }); const auto qres = [&]->Result{ // plan A: match by path if (auto qres_a{log_import_get_matching(store, "path:" + path)}; !qres_a) { log_import_err(opts, mu_format("failed to find by path: {}; try with message-id", qres_a.error().what())); // plan B: try the message-id return log_import_get_matching(store, "msgid:" + msgid, -1/*all matching*/); } else return qres_a; }(); // neither plan a or b worked? we have to give up... if (!qres) { log_import_err(opts, qres.error().what()); return; } // we have match(es)! for (auto&& item: *qres) { auto msg{*item.message()}; if (opts.label.dry_run ) mu_println("labels: would apply label '{}' to {}", join(labels, ","), path); else if (const auto res = store.update_labels(msg, delta_labels); !res) log_import_err(opts, mu_format("failed to update labels for {}: {}", msg.path(), res.error().what())); else log_import(opts, mu_format("applied labels {} to {}", join(labels, ","), path)); } } static Result label_import(Mu::Store& store, const Options& opts) { // sanity check, should be caught during arg parsing if (!opts.label.file) return Err(Error{Error::Code::InvalidArgument, "missing input file"}); auto input{std::ifstream{*opts.label.file, std::ios::in}}; if (!input.good()) return Err(Error{Error::Code::File, "failed to open '{}' for reading", *opts.label.file}); std::string line; std::string current_path, current_msgid; std::vector current_labels; while (std::getline(input, line)) { if (line.find(path_key) == 0) current_path = line.substr(path_key.length()); else if (line.find(message_id_key) == 0) current_msgid = line.substr(message_id_key.length()); else if (line.find(labels_key) == 0) { current_labels = split(line.substr(labels_key.length()), ','); if (!current_labels.empty()) import_labels_for_message(store, opts, current_path, current_msgid, current_labels); current_path.clear(); current_msgid.clear(); current_labels.clear(); } // ignore anything else. } return Ok(); } Result Mu::mu_cmd_label(Mu::Store &store, const Options &opts) { switch (opts.label.sub) { case Options::Label::Sub::List: return label_list(store, opts); case Options::Label::Sub::Update: return label_update(store, opts); case Options::Label::Sub::Clear: return label_clear(store, opts); case Options::Label::Sub::Export: return label_export(store, opts); case Options::Label::Sub::Import: return label_import(store, opts); default: return Err(Error{Error::Code::Internal, "invalid sub-command"}); } } #ifdef BUILD_TESTS /* * Tests. * */ #include #include "utils/mu-test-utils.hh" static std::string test_mu_home; static void test_mu_label_update() { { const auto res = run_command({MU_PROGRAM, "label", "update", "subject:abc", "--labels", "+foo,-bar", "--muhome", test_mu_home}); assert_valid_result(res); g_assert_cmpuint(res->exit_code,==,0); } { const auto res = run_command({MU_PROGRAM, "find", "label:foo", "--muhome", test_mu_home,}); assert_valid_result(res); g_assert_cmpuint(res->exit_code,==,0); g_assert_cmpuint(count_nl(res->standard_out), ==, 2); } { const auto res = run_command({MU_PROGRAM, "find", "label:bar", "--muhome", test_mu_home,}); assert_valid_result(res); g_assert_cmpuint(res->exit_code,==,2/*not found*/); } { const auto res = run_command({MU_PROGRAM, "label", "update", "subject:abc", "--labels", "-foo,+bar", "--muhome", test_mu_home}); assert_valid_result(res); g_assert_cmpuint(res->exit_code,==,0); } { const auto res = run_command({MU_PROGRAM, "find", "label:foo", "--muhome", test_mu_home,}); assert_valid_result(res); g_assert_cmpuint(res->exit_code,==,2/*not found*/); } { const auto res = run_command({MU_PROGRAM, "find", "label:bar", "--muhome", test_mu_home}); assert_valid_result(res); g_assert_cmpuint(res->exit_code,==,0); g_assert_cmpuint(count_nl(res->standard_out), ==, 2); } } static void test_mu_label_clear() { { const auto res = run_command({MU_PROGRAM, "label", "update", "subject:abc", "--labels", "+foo", "--muhome", test_mu_home}); assert_valid_result(res); g_assert_cmpuint(res->exit_code,==,0); } { const auto res = run_command({MU_PROGRAM, "find", "label:foo", "--muhome", test_mu_home}); assert_valid_result(res); g_assert_cmpuint(res->exit_code,==,0); g_assert_cmpuint(count_nl(res->standard_out), ==, 2); } { const auto res = run_command({MU_PROGRAM, "label", "clear", "subject:abc", "--muhome", test_mu_home}); assert_valid_result(res); g_assert_cmpuint(res->exit_code,==,0); } { const auto res = run_command({MU_PROGRAM, "find", "label:foo", "--muhome", test_mu_home}); assert_valid_result(res); g_assert_cmpuint(res->exit_code,==,2/*not found*/); g_assert_cmpuint(count_nl(res->standard_out), ==, 0); } } static void test_mu_label_list() { { const auto res = run_command({MU_PROGRAM, "label", "update", "subject:abc", "--labels", "+foo,-bar,+cuux,+fnorb", "--muhome", test_mu_home}); assert_valid_result(res); g_assert_cmpuint(res->exit_code,==,0); } { const auto res = run_command({MU_PROGRAM, "label", "update", "subject:abc", "--labels", "-cuux", "--muhome", test_mu_home}); assert_valid_result(res); g_assert_cmpuint(res->exit_code,==,0); } { const auto res = run_command({MU_PROGRAM, "label", "list", "--muhome", test_mu_home}); assert_valid_result(res); g_assert_cmpuint(res->exit_code,==,0); g_assert_cmpuint(count_nl(res->standard_out), ==, 2); // foo & fnorb } } static void test_mu_label_export_import() { TempDir temp_dir{}; const auto exportfile{join_paths(temp_dir.path(), "export.txt")}; // ensure there are some labels (from previous test) { const auto res = run_command({MU_PROGRAM, "label", "list", "--muhome", test_mu_home}); assert_valid_result(res); g_assert_cmpuint(res->exit_code,==,0); g_assert_cmpuint(count_nl(res->standard_out), ==, 2); // foo & fnorb } // export the current labels; they're from the previous test // fnorb,foo { const auto res = run_command({MU_PROGRAM, "label", "export", exportfile, "--muhome", test_mu_home}); assert_valid_result(res); g_assert_cmpuint(res->exit_code,==,0); } // now, re-init / index the store { auto res = run_command({MU_PROGRAM, "--quiet", "init", "--muhome", test_mu_home, "--reinit"}); assert_valid_result(res); auto res2 = run_command({MU_PROGRAM, "--quiet", "index", "--muhome", test_mu_home}); assert_valid_result(res2); } // ensure the labels are gone. { const auto res = run_command({MU_PROGRAM, "label", "list", "--muhome", test_mu_home}); assert_valid_result(res); g_assert_cmpuint(res->exit_code,==,0); g_assert_cmpuint(count_nl(res->standard_out), ==, 0); } // import the labels { const auto res = run_command({MU_PROGRAM, "label", "import", exportfile, "--muhome", test_mu_home}); assert_valid_result(res); g_assert_cmpuint(res->exit_code,==,0); } // ensure the label are back { const auto res = run_command({MU_PROGRAM, "label", "list", "--muhome", test_mu_home}); assert_valid_result(res); g_assert_cmpuint(res->exit_code,==,0); g_assert_cmpuint(count_nl(res->standard_out), ==, 2); } } int main(int argc, char* argv[]) { mu_test_init(&argc, &argv); TempDir temp_dir{}; { test_mu_home = temp_dir.path(); auto res1 = run_command({MU_PROGRAM, "--quiet", "init", "--muhome", test_mu_home, "--maildir" , MU_TESTMAILDIR2}); assert_valid_result(res1); auto res2 = run_command({MU_PROGRAM, "--quiet", "index", "--muhome", test_mu_home}); assert_valid_result(res2); } g_test_add_func("/cmd/label/update", test_mu_label_update); g_test_add_func("/cmd/label/clear", test_mu_label_clear); g_test_add_func("/cmd/label/list", test_mu_label_list); g_test_add_func("/cmd/label/export-import", test_mu_label_export_import); return g_test_run(); } #endif /*BUILD_TESTS*/