summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDirk-Jan C. Binnema <djcb@djcbsoftware.nl>2025-07-27 09:24:57 +0300
committerDirk-Jan C. Binnema <djcb@djcbsoftware.nl>2025-08-15 21:03:19 +0300
commit46fa4f2aa224daa806141c33e618feda2265ce92 (patch)
treee728be4ef0f5f37d7ed30589385c6b82aaf8a06f
parent1e628dfcaba241a1d9dadda91a9546eca32806d0 (diff)
mu: add 'label' command + manpage + tests
Add a label command and document it.
-rw-r--r--lib/message/mu-labels.cc17
-rw-r--r--man/meson.build1
-rw-r--r--man/mu-label.1.org156
-rw-r--r--mu/meson.build1
-rw-r--r--mu/mu-cmd-count.cc647
-rw-r--r--mu/mu-cmd-label.cc549
-rw-r--r--mu/mu-cmd.cc19
-rw-r--r--mu/mu-cmd.hh10
-rw-r--r--mu/mu-options.cc95
-rw-r--r--mu/mu-options.hh26
-rw-r--r--mu/tests/meson.build7
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',