diff options
| author | Dirk-Jan C. Binnema <djcb@djcbsoftware.nl> | 2025-07-27 09:24:57 +0300 |
|---|---|---|
| committer | Dirk-Jan C. Binnema <djcb@djcbsoftware.nl> | 2025-08-15 21:03:19 +0300 |
| commit | 46fa4f2aa224daa806141c33e618feda2265ce92 (patch) | |
| tree | e728be4ef0f5f37d7ed30589385c6b82aaf8a06f | |
| parent | 1e628dfcaba241a1d9dadda91a9546eca32806d0 (diff) | |
mu: add 'label' command + manpage + tests
Add a label command and document it.
| -rw-r--r-- | lib/message/mu-labels.cc | 17 | ||||
| -rw-r--r-- | man/meson.build | 1 | ||||
| -rw-r--r-- | man/mu-label.1.org | 156 | ||||
| -rw-r--r-- | mu/meson.build | 1 | ||||
| -rw-r--r-- | mu/mu-cmd-count.cc | 647 | ||||
| -rw-r--r-- | mu/mu-cmd-label.cc | 549 | ||||
| -rw-r--r-- | mu/mu-cmd.cc | 19 | ||||
| -rw-r--r-- | mu/mu-cmd.hh | 10 | ||||
| -rw-r--r-- | mu/mu-options.cc | 95 | ||||
| -rw-r--r-- | mu/mu-options.hh | 26 | ||||
| -rw-r--r-- | mu/tests/meson.build | 7 |
11 files changed, 1510 insertions, 18 deletions
diff --git a/lib/message/mu-labels.cc b/lib/message/mu-labels.cc index 3f800eb..13a789b 100644 --- a/lib/message/mu-labels.cc +++ b/lib/message/mu-labels.cc @@ -20,12 +20,10 @@ #include "mu-labels.hh" #include <set> -#include <algorithm> using namespace Mu; using namespace Mu::Labels; - Result<void> Mu::Labels::validate_label(const std::string &label) { @@ -55,19 +53,24 @@ Mu::Labels::validate_label(const std::string &label) if (uc > ' ' && uc <= '~') { switch (uc) { case '"': + case ',': case '/': case '\\': case '*': case '$': return Err(Error{Error::Code::InvalidArgument, - "illegal character '{}' in label ({})", uc, label}); + "illegal character '{}' in label '{}'", uc, label}); default: break; } - } else + } else if (::isprint(uc)) + return Err(Error{Error::Code::InvalidArgument, + "illegal non alpha-numeric character '{}' in label '{}'", + static_cast<char>(uc), label}); + else return Err(Error{Error::Code::InvalidArgument, - "illegal non alpha-numeric character '{}' in label ({})", - uc, label}); + "illegal non alpha-numeric character {:#x} in label '{}'", + uc, label}); } return Ok(); @@ -156,7 +159,6 @@ Mu::Labels::updated_labels(const LabelVec& labels, const DeltaLabelVec& deltas) static void test_parse_delta_label() { - { const auto expr = parse_delta_label("+foo"); assert_valid_result(expr); @@ -164,7 +166,6 @@ test_parse_delta_label() assert_equal(expr->second, "foo"); } - { const auto expr = parse_delta_label("-bar@cuux"); assert_valid_result(expr); diff --git a/man/meson.build b/man/meson.build index f48677b..1034655 100644 --- a/man/meson.build +++ b/man/meson.build @@ -55,6 +55,7 @@ man_orgs = [ 'mu-index.1.org', 'mu-info.1.org', 'mu-init.1.org', + 'mu-label.1.org', 'mu-mkdir.1.org', 'mu-move.1.org', 'mu-query.7.org', diff --git a/man/mu-label.1.org b/man/mu-label.1.org new file mode 100644 index 0000000..aab82b9 --- /dev/null +++ b/man/mu-label.1.org @@ -0,0 +1,156 @@ +#+TITLE: MU LABEL +#+MAN_CLASS_OPTIONS: :section-id "@SECTION_ID@" :date "@MAN_DATE@" +#+include: macros.inc + +* NAME + +mu-label - attach labels to messages. Export labels to file, and import them. + +* SYNOPSIS + +*mu* [_COMMON-OPTIONS_] *label* update [UPDATE-OPTIONS] "<query>" --labels [{+,-}<label>]... + +*mu* [_COMMON-OPTIONS_] *label* list [LIST-OPTIONS] + +mu [_COMMON-OPTIONS_] *label* clear [CLEAR-OPTIONS] "<query>" + +mu [_COMMON-OPTIONS_] *label* export [<file>] + +mu [_COMMON-OPTIONS_] *label* import <file> + +* DESCRIPTION + +*mu label* is the sub-command for adding and removing and listing message labels. + +A label is a string associated with a message. + +*mu label* has five sub-commands (i.e., 'sub-sub-commands'): + +- *update* for changing the labels for the messages matching some query +- *clear* for clearing all labels from messages matching some query +- *list* to list all labels that are in use in the store +- *export* to write the label information to a file +- *import* the read label information from a file + +Unlike other message metadata stored by *mu*, labels _cannot_ be restored by +re-indexing messages. Hence, to avoid loosing label information, you must *export* +it before re-indexing, and *import* afterwards. + +* UPDATE OPTIONS + +The *update* command changes the labels for messages that match some query. Make sure the query is recognized as a single parameter, i.e., quote it when using from a shell. + +** --labels + + a comma-separated list of labels, each prefixed with either *+* to add that + label, or *-* to remove it. See *VALID LABELS*. + +** --dry-run + + only print what /would/ change, but do not change anything + +#+include: "muhome.inc" :minlevel 2 + +* CLEAR OPTIONS + +The *clear* command removes all labels from messages that match some query. Make +sure the query is recognized as a single parameter, i.e., quote it when using +from a shell. + +** --dry-run + + only print what would change, but do not change anything + +#+include: "muhome.inc" :minlevel 2 + +* LIST OPTIONS + +The *list* command lists all the labels that are currently in use in the store. + + +* EXPORT OPTIONS + +The *export* command outputs /all/ labels in the store to a file, so you can *import* +it later. The command takes a path to a file as its argument. + +See *EXPORT FORMAT* below for details about the format. + +If no file is specified, *mu* creates one for you, in the current directory. + +* IMPORT OPTIONS + +The *import* command is for restoring the labels from a file created through +*export* earlier. The command takes a path to a file as its argument. + +See *EXPORT FORMAT* below for details on the format. + +** --dry-run + + only print what would change, but do not change anything + +#+include: "muhome.inc" :minlevel 2 + +* VALID LABELS + +*mu* does not wish to limit your creativity, but nevertheless puts a few +restrictions on what is accepted as a label: + +- a *valid label character* is either a UTF-8 encoded alphanumeric character, or + any ASCII character that is not a control-character and is not one of ' ' + (SPC), ',', '"', '/', '\' '*', '$'. +- a *valid label* consists of one or more valid label characters, the first of + which must *not* be either '+' or '-' + +Hence, some valid labels are: ~project-x~, ~capybara~, ~fnorb~, while some _invalid_ +ones are: ~holiday plan~ and ~+fancy$/dinner~. + +* EXPORT FORMAT + +The formats for import/export are UTF-8 encoded text. The first line starts with +~;;~ and some internal data. Empty lines are ignored. + +Each entry represents the tags for a message. It consists of three lines. The +first starts with ~path:~ followed by the file-system path to the message. The +second line starts with ~message-id:~ followed by the message-id for the message. +Finally, the third line starts with ~tag:~ followed by space-separated tags for +the message + +With this, the labels for a message can be restored. + +#+begin_example +path:/home/user/Maildir/inbox/cur/1720645394.99f64f5d81f42ba4.hyperion:2,S +message-id:669338009127192q7821feh1t826d0c4c90bd8fdf@mail.gmail.com +labels:foo,bar,cuux +#+end_example + +Note, the ~message-id:~ is only used if the message cannot be found at ~path:. +This adds some tolerance for the case where the precise file-system positions +have changed since the labels were exported. The upshot of that is that if there +are _duplicate_ messages (messages with the same message-id), the tags are applied +to all of them. + +#+include: "exit-code.inc" :minlevel 1 + +* EXAMPLES + +Some examples. For the query parameter, make sure to quote the query to ensure +it is recognized as a single parameter. + +Remove the label "planet" and add the label "dwarf-planet" to all messages that +have "pluto" in their subject: +#+begin_export +$ mu label update "subject:pluto" --labels -planet,+dwarf-planet +#+end_export + + +Clear all labels from messages with the label "boring": +#+begin_export +$ mu label clear "label:boring" +#+end_export + +#+include: "prefooter.inc" :minlevel 1 + +* SEE ALSO + +{{{man-link(mu-query,1)}}}, +{{{man-link(mu-find,1)}}}, diff --git a/mu/meson.build b/mu/meson.build index ee2460d..f785796 100644 --- a/mu/meson.build +++ b/mu/meson.build @@ -25,6 +25,7 @@ mu = executable( 'mu-cmd-info.cc', 'mu-cmd-init.cc', 'mu-cmd-index.cc', + 'mu-cmd-label.cc', 'mu-cmd-mkdir.cc', 'mu-cmd-move.cc', 'mu-cmd-remove.cc', diff --git a/mu/mu-cmd-count.cc b/mu/mu-cmd-count.cc new file mode 100644 index 0000000..8a8165f --- /dev/null +++ b/mu/mu-cmd-count.cc @@ -0,0 +1,647 @@ + /* + ** Copyright (C) 2024 Dirk-Jan C. Binnema <djcb@djcbsoftware.nl> + ** + ** 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 "config.h" + +#include <array> + +#include <unistd.h> +#include <stdio.h> +#include <string.h> +#include <errno.h> +#include <stdlib.h> +#include <signal.h> +#include <sys/wait.h> + +#include "message/mu-message.hh" +#include "mu-maildir.hh" +#include "mu-query-match-deciders.hh" +#include "mu-query.hh" +#include "mu-query-macros.hh" +#include "mu-query-parser.hh" +#include "message/mu-message.hh" + +#include "utils/mu-option.hh" + +#include "mu-cmd.hh" +#include "utils/mu-utils.hh" + +using namespace Mu; + +static Result<size_t> +count_query(const Store& store, const Options& opts) +{ + if (opts.count.query.empty()) + return Err(Error::Code::InvalidArgument, + "missing query"); + + auto&& query{join(opts.count.query, " ")}; + + return Ok(store.count_query(query)); +} + +static Result<std::string> +get_query(const Store& store, const Options& opts) +{ + if (opts.find.bookmark.empty() && opts.find.query.empty()) + return Err(Error::Code::InvalidArgument, + "neither bookmark nor query"); + + std::string bookmark; + if (!opts.find.bookmark.empty()) { + const auto res = resolve_bookmark(store, opts); + if (!res) + return Err(std::move(res.error())); + bookmark = res.value() + " "; + } + + auto&& query{join(opts.find.query, " ")}; + return Ok(bookmark + query); +} + +static Result<void> +prepare_links(const Options& opts) +{ + /* note, mu_maildir_mkdir simply ignores whatever part of the + * mail dir already exists */ + if (auto&& res = maildir_mkdir(opts.find.linksdir, 0700, true); !res) + return Err(std::move(res.error())); + + if (!opts.find.clearlinks) + return Ok(); + + if (auto&& res = maildir_clear_links(opts.find.linksdir); !res) + return Err(std::move(res.error())); + + return Ok(); +} + +static Result<void> +output_link(const Option<Message>& msg, const OutputInfo& info, const Options& opts) +{ + if (info.header) + return prepare_links(opts); + else if (info.footer) + return Ok(); + + /* during test, do not create "unique names" (i.e., names with path + * hashes), so we get a predictable result */ + const auto unique_names{!g_getenv("MU_TEST")&&!g_test_initialized()}; + + if (auto&& res = maildir_link(msg->path(), opts.find.linksdir, unique_names); !res) + return Err(std::move(res.error())); + + return Ok(); +} + +static void +ansi_color_maybe(Field::Id field_id, bool color) +{ + const char* ansi; + + if (!color) + return; /* nothing to do */ + + switch (field_id) { + case Field::Id::From: ansi = MU_COLOR_CYAN; break; + + case Field::Id::To: + case Field::Id::Cc: + case Field::Id::Bcc: ansi = MU_COLOR_BLUE; break; + case Field::Id::Subject: ansi = MU_COLOR_GREEN; break; + case Field::Id::Date: ansi = MU_COLOR_MAGENTA; break; + + default: + if (field_from_id(field_id).type != Field::Type::String) + ansi = MU_COLOR_YELLOW; + else + ansi = MU_COLOR_RED; + } + + fputs(ansi, stdout); +} + +static void +ansi_reset_maybe(Field::Id field_id, bool color) +{ + if (!color) + return; /* nothing to do */ + + fputs(MU_COLOR_DEFAULT, stdout); +} + +static std::string +display_field(const Message& msg, Field::Id field_id) +{ + switch (field_from_id(field_id).type) { + case Field::Type::String: + return msg.document().string_value(field_id); + case Field::Type::Integer: + if (field_id == Field::Id::Priority) { + return to_string(msg.priority()); + } else if (field_id == Field::Id::Flags) { + return to_string(msg.flags()); + } else /* as string */ + return msg.document().string_value(field_id); + case Field::Type::TimeT: + return mu_format("{:%c}", + mu_time(msg.document().integer_value(field_id))); + case Field::Type::ByteSize: + return to_string(msg.document().integer_value(field_id)); + case Field::Type::StringList: + return join(msg.document().string_vec_value(field_id), ','); + case Field::Type::ContactList: + return to_string(msg.document().contacts_value(field_id)); + default: + g_return_val_if_reached(""); + return ""; + } +} + +static void +print_summary(const Message& msg, const Options& opts) +{ + const auto body{msg.body_text()}; + if (!body) + return; + + const auto summ{summarize(body->c_str(), opts.find.summary_len.value_or(0))}; + + mu_print("Summary: "); + fputs_encoded(summ, stdout); + mu_println(""); +} + +static void +thread_indent(const QueryMatch& info, const Options& opts) +{ + const auto is_root{any_of(info.flags & QueryMatch::Flags::Root)}; + const auto first_child{any_of(info.flags & QueryMatch::Flags::First)}; + const auto last_child{any_of(info.flags & QueryMatch::Flags::Last)}; + const auto empty_parent{any_of(info.flags & QueryMatch::Flags::Orphan)}; + const auto is_dup{any_of(info.flags & QueryMatch::Flags::Duplicate)}; + // const auto is_related{any_of(info.flags & QueryMatch::Flags::Related)}; + + /* indent */ + if (opts.debug) { + ::fputs(info.thread_path.c_str(), stdout); + ::fputs(" ", stdout); + } else + for (auto i = info.thread_level; i > 1; --i) + ::fputs(" ", stdout); + + if (!is_root) { + if (first_child) + ::fputs("\\", stdout); + else if (last_child) + ::fputs("/", stdout); + else + ::fputs(" ", stdout); + ::fputs(empty_parent ? "*> " : is_dup ? "=> " + : "-> ", + stdout); + } +} + +static void +output_plain_fields(const Message& msg, const std::string& fields, + bool color, bool threads) +{ + size_t nonempty{}; + + for (auto&& k: fields) { + const auto field_opt{field_from_shortcut(k)}; + if (!field_opt || (!field_opt->is_value() && !field_opt->is_contact())) + nonempty += printf("%c", k); + + else { + ansi_color_maybe(field_opt->id, color); + nonempty += fputs_encoded( + display_field(msg, field_opt->id), stdout); + ansi_reset_maybe(field_opt->id, color); + } + } + + if (nonempty) + fputs("\n", stdout); +} + +static Result<void> +output_plain(const Option<Message>& msg, const OutputInfo& info, + const Options& opts) +{ + if (!msg) + return Ok(); + + /* we reuse the color (whatever that may be) + * for message-priority for threads, too */ + ansi_color_maybe(Field::Id::Priority, !opts.nocolor); + if (opts.find.threads && info.match_info) + thread_indent(*info.match_info, opts); + + output_plain_fields(*msg, opts.find.fields, !opts.nocolor, opts.find.threads); + + if (opts.view.summary_len) + print_summary(*msg, opts); + + return Ok(); +} + +static Result<void> +output_sexp(const Option<Message>& msg, const OutputInfo& info, const Options& opts) +{ + if (msg) { + if (const auto sexp{msg->sexp()}; !sexp.empty()) + fputs(sexp.to_string().c_str(), stdout); + else + fputs(msg->sexp().to_string().c_str(), stdout); + fputs("\n", stdout); + } + + return Ok(); +} + +static Result<void> +output_json(const Option<Message>& msg, const OutputInfo& info, const Options& opts) +{ + if (info.header) { + mu_println("["); + return Ok(); + } + + if (info.footer) { + mu_println("]"); + return Ok(); + } + + if (!msg) + return Ok(); + + mu_println("{}{}", msg->sexp().to_json_string(), info.last ? "" : ","); + + return Ok(); +} + +static void +print_attr_xml(const std::string& elm, const std::string& str) +{ + if (str.empty()) + return; /* empty: don't include */ + + auto&& esc{to_string_opt_gchar(g_markup_escape_text(str.c_str(), -1))}; + mu_println("\t\t<{}>{}</{}>", elm, esc.value_or(""), elm); +} + +static Result<void> +output_xml(const Option<Message>& msg, const OutputInfo& info, const Options& opts) +{ + if (info.header) { + mu_println("<?xml version=\"1.0\" encoding=\"UTF-8\" ?>"); + mu_println("<messages>"); + return Ok(); + } + + if (info.footer) { + mu_println("</messages>"); + return Ok(); + } + + mu_println("\t<message>"); + print_attr_xml("from", to_string(msg->from())); + print_attr_xml("to", to_string(msg->to())); + print_attr_xml("cc", to_string(msg->cc())); + print_attr_xml("subject", msg->subject()); + mu_println("\t\t<date>{}</date>", (unsigned)msg->date()); + mu_println("\t\t<size>{}</size>", (unsigned)msg->size()); + print_attr_xml("msgid", msg->message_id()); + print_attr_xml("path", msg->path()); + print_attr_xml("maildir", msg->maildir()); + mu_println("\t</message>"); + + return Ok(); +} + +static OutputFunc +get_output_func(const Options& opts) +{ + if (!opts.find.exec.empty()) + return exec_cmd; + + switch (opts.find.format) { + case Format::Links: + return output_link; + case Format::Plain: + return output_plain; + case Format::Xml: + return output_xml; + case Format::Sexp: + return output_sexp; + case Format::Json: + return output_json; + default: + throw Error(Error::Code::Internal, + "invalid format {}", + static_cast<size_t>(opts.find.format)); + } +} + +static Result<void> +output_query_results(const QueryResults& qres, const Options& opts) +{ + GError* err{}; + const auto output_func{get_output_func(opts)}; + if (!output_func) + return Err(Error::Code::Query, &err, "failed to find output function"); + + if (auto&& res = output_func(Nothing, FirstOutput, opts); !res) + return Err(std::move(res.error())); + + size_t n{0}; + for (auto&& item : qres) { + n++; + auto msg{item.message()}; + if (!msg) + continue; + + if (msg->changed() < opts.find.after.value_or(0)) + continue; + + if (auto&& res = output_func(msg, + {item.doc_id(), + false, + false, + n == qres.size(), /* last? */ + item.query_match()}, + opts); !res) + return Err(std::move(res.error())); + } + + if (auto&& res{output_func(Nothing, LastOutput, opts)}; !res) + return Err(std::move(res.error())); + else + return Ok(); +} + +static Result<void> +process_store_query(const Store& store, const std::string& expr, const Options& opts) +{ + auto qres{run_query(store, expr, opts)}; + if (!qres) + return Err(qres.error()); + + if (qres->empty()) + return Err(Error::Code::NoMatches, "no matches for search expression"); + + return output_query_results(*qres, opts); +} + +Result<void> +Mu::mu_cmd_find(const Store& store, const Options& opts) +{ + auto expr{get_query(store, opts)}; + if (!expr) + return Err(expr.error()); + + if (opts.find.analyze) + return analyze_query_expr(store, *expr, opts); + else + return process_store_query(store, *expr, opts); +} + + + +#ifdef BUILD_TESTS +/* + * Tests. + * + */ + +#include "utils/mu-test-utils.hh" + + +/* tests for the command line interface, uses testdir2 */ + +static std::string test_mu_home; + +auto count_nl(const std::string& s)->size_t { + size_t n{}; + for (auto&& c: s) + if (c == '\n') + ++n; + return n; +} + +static size_t +search_func(const std::string& expr, size_t expected) +{ + auto res = run_command({MU_PROGRAM, "find", "--muhome", test_mu_home, expr}); + assert_valid_result(res); + + /* we expect zero lines of error output if there is a match; otherwise + * there should be one line 'No matches found' */ + if (res->exit_code != 0) { + g_assert_cmpuint(res->exit_code, ==, 2); // no match + g_assert_true(res->standard_out.empty()); + g_assert_cmpuint(count_nl(res->standard_err), ==, 1); + return 0; + } + + return count_nl(res->standard_out); +} + +#define search(Q,EXP) do { \ + g_assert_cmpuint(search_func(Q, EXP), ==, EXP); \ +} while(0) + + +static void +test_mu_find_empty_query(void) +{ + search("\"\"", 14); +} + +static void +test_mu_find_01(void) +{ + search("f:john fruit", 1); + search("f:soc@example.com", 1); + search("t:alki@example.com", 1); + search("t:alcibiades", 1); + search("http emacs", 1); + search("f:soc@example.com OR f:john", 2); + search("f:soc@example.com OR f:john OR t:edmond", 3); + search("t:julius", 1); + search("s:dude", 1); + search("t:dantès", 1); +} + +/* index testdir2, and make sure it adds two documents */ +static void +test_mu_find_02(void) +{ + search("bull", 1); + search("g:x", 0); + search("flag:encrypted", 0); + search("flag:attach", 1); + + search("i:3BE9E6535E0D852173@emss35m06.us.lmco.com", 1); +} + +static void +test_mu_find_file(void) +{ + search("file:sittingbull.jpg", 1); + search("file:custer.jpg", 1); + search("file:custer.*", 1); + search("j:sit*", 1); +} + +static void +test_mu_find_mime(void) +{ + search("mime:image/jpeg", 1); + search("mime:text/plain", 14); + search("y:text*", 14); + search("y:image*", 1); + search("mime:message/rfc822", 2); +} + +static void +test_mu_find_text_in_rfc822(void) +{ + search("embed:dancing", 1); + search("e:curious", 1); + search("embed:with", 2); + search("e:karjala", 0); + search("embed:navigation", 1); +} + +static void +test_mu_find_maildir_special(void) +{ + search("\"maildir:/wOm_bàT\"", 3); + search("\"maildir:/wOm*\"", 3); + search("\"maildir:/wOm_*\"", 3); + search("\"maildir:wom_bat\"", 0); + search("\"maildir:/wombat\"", 0); + search("subject:atoms", 1); + search("\"maildir:/wom_bat\" subject:atoms", 1); +} + + +/* some more tests */ + +static void +test_mu_find_wrong_muhome() +{ + auto res = run_command({MU_PROGRAM, "find", "--muhome", + join_paths("/foo", "bar", "nonexistent"), "f:socrates"}); + assert_valid_result(res); + g_assert_cmpuint(res->exit_code,==,1); // general error + g_assert_cmpuint(count_nl(res->standard_err), >, 1); +} + +static void +test_mu_find_links(void) +{ + TempDir temp_dir; + + { + auto res = run_command({MU_PROGRAM, "find", "--muhome", test_mu_home, + "--format", "links", "--linksdir", temp_dir.path(), + "mime:message/rfc822"}); + assert_valid_result(res); + g_assert_cmpuint(res->exit_code,==,0); + g_assert_cmpuint(count_nl(res->standard_out),==,0); + g_assert_cmpuint(count_nl(res->standard_err),==,0); + } + + + /* furthermore, two symlinks should be there */ + const auto f1{mu_format("{}/cur/rfc822.1", temp_dir)}; + const auto f2{mu_format("{}/cur/rfc822.2", temp_dir)}; + + g_assert_cmpuint(determine_dtype(f1.c_str(), true), ==, DT_LNK); + g_assert_cmpuint(determine_dtype(f2.c_str(), true), ==, DT_LNK); + + /* now we try again, we should get a line of error output, + * when we find the first target file already exists */ + { + auto res = run_command({MU_PROGRAM, "find", "--muhome", test_mu_home, + "--format", "links", "--linksdir", temp_dir.path(), + "mime:message/rfc822"}); + assert_valid_result(res); + g_assert_cmpuint(res->exit_code,==,1); + g_assert_cmpuint(count_nl(res->standard_out),==,0); + g_assert_cmpuint(count_nl(res->standard_err),==,1); + } + + /* now we try again with --clearlinks, and the we should be + * back to 0 errors */ + { + auto res = run_command({MU_PROGRAM, "find", "--muhome", test_mu_home, + "--format", "links", "--clearlinks", "--linksdir", temp_dir.path(), + "mime:message/rfc822"}); + assert_valid_result(res); + g_assert_cmpuint(res->exit_code,==,0); + g_assert_cmpuint(count_nl(res->standard_out),==,0); + g_assert_cmpuint(count_nl(res->standard_err),==,0); + } + + g_assert_cmpuint(determine_dtype(f1.c_str(), true), ==, DT_LNK); + g_assert_cmpuint(determine_dtype(f2.c_str(), true), ==, DT_LNK); +} + +/* some more tests */ + +int +main(int argc, char* argv[]) +{ + mu_test_init(&argc, &argv); + + if (!set_en_us_utf8_locale()) + return 0; /* don't error out... */ + + 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/find/empty-query", test_mu_find_empty_query); + g_test_add_func("/cmd/find/01", test_mu_find_01); + g_test_add_func("/cmd/find/02", test_mu_find_02); + g_test_add_func("/cmd/find/file", test_mu_find_file); + g_test_add_func("/cmd/find/mime", test_mu_find_mime); + g_test_add_func("/cmd/find/links", test_mu_find_links); + g_test_add_func("/cmd/find/text-in-rfc822", test_mu_find_text_in_rfc822); + g_test_add_func("/cmd/find/wrong-muhome", test_mu_find_wrong_muhome); + g_test_add_func("/cmd/find/maildir-special", test_mu_find_maildir_special); + + return g_test_run(); +} + +#endif /*BUILD_TESTS*/ diff --git a/mu/mu-cmd-label.cc b/mu/mu-cmd-label.cc new file mode 100644 index 0000000..cb0131f --- /dev/null +++ b/mu/mu-cmd-label.cc @@ -0,0 +1,549 @@ +/* +** Copyright (C) 2025 Dirk-Jan C. Binnema <djcb@djcbsoftware.nl> +** +** 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 <algorithm> +#include <string_view> + +#include "mu-store.hh" +#include "message/mu-message.hh" +#include "message/mu-labels.hh" + +using namespace Mu; +using namespace Labels; + + + +static Result<void> +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<void> +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<void> +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<void> +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<QueryResults> +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<std::string> 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<QueryResults>{ + // 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<void> +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<std::string> 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<void> +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 <config.h> +#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*/ diff --git a/mu/mu-cmd.cc b/mu/mu-cmd.cc index ce7d41d..ecb02ba 100644 --- a/mu/mu-cmd.cc +++ b/mu/mu-cmd.cc @@ -102,6 +102,17 @@ with_readonly_store(const ReadOnlyStoreFunc& func, const Options& opts) return func(store.value(), opts); } +static Result<void> // overloading does not work. +with_readonly_store2(const WritableStoreFunc& func, const Options& opts) +{ + auto store{Store::make(opts.runtime_path(RuntimePath::XapianDb))}; + if (!store) + return Err(store.error()); + + return func(store.value(), opts); +} + + static Result<void> with_writable_store(const WritableStoreFunc func, const Options& opts) { @@ -161,6 +172,14 @@ Mu::mu_cmd_execute(const Options& opts) try { return with_writable_store(mu_cmd_move, opts); /* + * read-only _or_ writable store + */ + case Options::SubCommand::Label: + if (opts.label.read_only) + return with_readonly_store2(mu_cmd_label, opts); + else + return with_writable_store(mu_cmd_label, opts); + /* * commands instantiate store themselves */ case Options::SubCommand::Index: diff --git a/mu/mu-cmd.hh b/mu/mu-cmd.hh index 9b8740e..35fcfcc 100644 --- a/mu/mu-cmd.hh +++ b/mu/mu-cmd.hh @@ -117,6 +117,16 @@ Result<void> mu_cmd_info(const Mu::Store& store, const Options& opts); Result<void> mu_cmd_init(const Options& opts); /** + * execute the 'label' command + * + * @param store message store object. + * @param opts configuration options + * + * @return Ok() or some error + */ +Result<void> mu_cmd_label(Store& store, const Options& opts); + +/** * execute the 'mkdir' command * * @param opts configuration options diff --git a/mu/mu-options.cc b/mu/mu-options.cc index 9161028..1a11bdb 100644 --- a/mu/mu-options.cc +++ b/mu/mu-options.cc @@ -57,13 +57,10 @@ using namespace Mu; - /* * helpers */ - - /** * array of associated pair elements -- like an alist * but based on std::array and thus can be constexpr @@ -217,6 +214,15 @@ sub_crypto(CLI::App& sub, T& opts) "Attempt to decrypt"); } +static void add_muhome_option(CLI::App& sub, Options& opts) +{ + sub.add_option("--muhome", + opts.muhome, "Specify alternative mu directory") + ->envname("MUHOME") + ->type_name("<dir>") + ->transform(ExpandPath, "expand muhome path"); +} + /* * subcommands */ @@ -495,6 +501,78 @@ sub_init(CLI::App& sub, Options& opts) } static void +sub_label(CLI::App& sub, Options& opts) +{ + sub.require_subcommand(1); + + // update + auto update{sub.add_subcommand("update", "update labels")}; + update->add_option("--labels", opts.label.delta_labels, + "One or more comma-separated +label,-label") + ->delimiter(',') + ->type_name("<delta-label>") + ->required(); + update->add_flag("-n,--dry-run", opts.label.dry_run, + "Output what would change without changing anything"); + update->add_option("query", opts.label.query, "Query for messages to update") + ->required(); + add_muhome_option(*update, opts); + + // clear + auto clear = sub.add_subcommand("clear", "clear all labels from matched messages"); + clear ->add_option("query", opts.label.query, "Query for messages to clear of labels") + ->required(); + clear->add_flag("-n,--dry-run", opts.label.dry_run, + "Output what would change without changing anything"); + add_muhome_option(*clear, opts); + + // list + [[maybe_unused]] auto list = sub.add_subcommand("list", "list labels in the store"); + add_muhome_option(*list, opts); + + // export + [[maybe_unused]] auto exportsub = sub.add_subcommand("export", "export labels to a file"); + add_muhome_option(*exportsub, opts); + exportsub->add_option("output", opts.label.file, "File to export labels to") + ->type_name("<file>"); + + // import + auto importsub = sub.add_subcommand("import", "import labels from a file"); + importsub->add_flag("-n,--dry-run", opts.label.dry_run, + "Output what would change without changing anything"); + importsub->add_option("input", opts.label.file, "File with labels to import") + ->required() + ->type_name("<file>"); + add_muhome_option(*importsub, opts); + + // XXX: it'd be nice to make "update" the default command, such that + // mu label foo --labels +a,-b + // would be interpreted as + // mu label update foo --labels +a,-b + // but no succeeded yet; CLI11 treats 'foo' as unknown sub-command + + sub.final_callback([&](){ + if (sub.got_subcommand("list")) { + opts.label.sub = Options::Label::Sub::List; + opts.label.read_only = true; + } else if (sub.got_subcommand("clear")) { + opts.label.sub = Options::Label::Sub::Clear; + opts.label.read_only = opts.label.dry_run; + } else if (sub.got_subcommand("update")){ + opts.label.sub = Options::Label::Sub::Update; + opts.label.read_only = opts.label.dry_run; + } else if (sub.got_subcommand("export")){ + opts.label.sub = Options::Label::Sub::Export; + opts.label.read_only = true; + } else if (sub.got_subcommand("import")){ + opts.label.sub = Options::Label::Sub::Import; + opts.label.read_only = opts.label.dry_run; + } + }); +} + + +static void sub_mkdir(CLI::App& sub, Options& opts) { sub.add_option("--mode", opts.mkdir.mode, "Set the access mode (octal)") @@ -665,6 +743,10 @@ AssocPairs<SubCommand, CommandInfo, Options::SubCommandNum> SubCommandInfos= {{ {Category::NeedsWritableStore, "init", "Initialize the database", sub_init } }, + { SubCommand::Label, + {Category::None, // note Store handled on sub-subcommmands + "label", "Add/remove labels", sub_label } + }, { SubCommand::Mkdir, {Category::None, "mkdir", "Create a new Maildir", sub_mkdir } @@ -839,11 +921,7 @@ There is NO WARRANTY, to the extent permitted by law. /* store commands get the '--muhome' parameter as well */ if (cat == Category::NeedsReadOnlyStore || cat == Category::NeedsWritableStore) - sub->add_option("--muhome", - opts.muhome, "Specify alternative mu directory") - ->envname("MUHOME") - ->type_name("<dir>") - ->transform(ExpandPath, "expand muhome path"); + add_muhome_option(*sub, opts); } /* add scripts (if supported) as semi-subcommands as well */ @@ -919,7 +997,6 @@ validate_subcommand_ids() return true; } - /* * tests... also build as runtime-tests, so we can get coverage info */ diff --git a/mu/mu-options.hh b/mu/mu-options.hh index 0b110c8..ead9ab9 100644 --- a/mu/mu-options.hh +++ b/mu/mu-options.hh @@ -62,7 +62,7 @@ struct Options { static bool default_no_color(); enum struct SubCommand { - Add, Cfind, Extract, Fields, Find, Help, Index,Info, Init, Mkdir, + Add, Cfind, Extract, Fields, Find, Help, Index,Info, Init, Label, Mkdir, Move, Remove, Scm, Script, Server, Verify, View, // <private> __count__ @@ -78,6 +78,7 @@ struct Options { SubCommand::Index, SubCommand::Info, SubCommand::Init, + SubCommand::Label, SubCommand::Mkdir, SubCommand::Move, SubCommand::Remove, @@ -201,6 +202,29 @@ struct Options { } init; /* + * Label + */ + struct Label { + OptString query; /**< Query for the messages to label */ + bool dry_run{}; /**< Merely print the messages that would be + * labeled without doing so */ + StringVec delta_labels; /**< labels to add (+) or remove (-) */ + bool read_only{}; /** do not require writable store */ + + OptString file; /** file for import/export */ + + enum struct Sub { // sub-subcommands + Update, // add/remove labels + Clear, // clear all labels + List, // list all labels in the store + Export, // export labels + Import, // import labels + }; + Sub sub; + } label; + + + /* * Mkdir */ struct Mkdir { diff --git a/mu/tests/meson.build b/mu/tests/meson.build index 5476af7..ed56405 100644 --- a/mu/tests/meson.build +++ b/mu/tests/meson.build @@ -66,6 +66,13 @@ test('test-cmd-init', build_by_default: false, cpp_args: ['-DBUILD_TESTS'], dependencies: [glib_dep, lib_mu_dep])) +test('test-cmd-label', + executable('test-cmd-label', + '../mu-cmd-label.cc', + install: false, + build_by_default: false, + cpp_args: ['-DBUILD_TESTS'], + dependencies: [glib_dep, lib_mu_dep])) test('test-cmd-mkdir', executable('test-cmd-mkdir', |
