mu: add 'label' command + manpage + tests
Add a label command and document it.
This commit is contained in:
@ -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',
|
||||
|
||||
647
mu/mu-cmd-count.cc
Normal file
647
mu/mu-cmd-count.cc
Normal file
@ -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*/
|
||||
549
mu/mu-cmd-label.cc
Normal file
549
mu/mu-cmd-label.cc
Normal file
@ -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*/
|
||||
19
mu/mu-cmd.cc
19
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)
|
||||
{
|
||||
@ -160,6 +171,14 @@ Mu::mu_cmd_execute(const Options& opts) try {
|
||||
case Options::SubCommand::Move:
|
||||
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
|
||||
*/
|
||||
|
||||
10
mu/mu-cmd.hh
10
mu/mu-cmd.hh
@ -116,6 +116,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
|
||||
*
|
||||
|
||||
@ -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
|
||||
*/
|
||||
@ -494,6 +500,78 @@ sub_init(CLI::App& sub, Options& opts)
|
||||
->excludes("--support-ngrams");
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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,
|
||||
@ -200,6 +201,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
|
||||
*/
|
||||
|
||||
@ -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',
|
||||
|
||||
Reference in New Issue
Block a user