From 552bb3a7c82fc7472ab8d3a5a0fd32fd9ea72909 Mon Sep 17 00:00:00 2001 From: "Dirk-Jan C. Binnema" Date: Sun, 27 Jul 2025 09:17:55 +0300 Subject: [PATCH] message: add support for labels + tests Labels are strings associated with messages, which can be used for searching them. --- lib/message/meson.build | 3 +- lib/message/mu-document.hh | 7 +- lib/message/mu-fields.hh | 22 ++- lib/message/mu-labels.cc | 244 ++++++++++++++++++++++++++++++++++ lib/message/mu-labels.hh | 88 ++++++++++++ lib/message/mu-message.cc | 7 + lib/message/mu-message.hh | 23 +++- lib/message/tests/meson.build | 7 + 8 files changed, 390 insertions(+), 11 deletions(-) create mode 100644 lib/message/mu-labels.cc create mode 100644 lib/message/mu-labels.hh diff --git a/lib/message/meson.build b/lib/message/meson.build index 006bb189..31052b83 100644 --- a/lib/message/meson.build +++ b/lib/message/meson.build @@ -1,4 +1,4 @@ -## Copyright (C) 2022-2024 Dirk-Jan C. Binnema +## Copyright (C) 2022-2025 Dirk-Jan C. Binnema ## ## This program is free software; you can redistribute it and/or modify ## it under the terms of the GNU General Public License as published by @@ -25,6 +25,7 @@ lib_mu_message=static_library( 'mu-document.cc', 'mu-fields.cc', 'mu-flags.cc', + 'mu-labels.cc', 'mu-priority.cc', 'mu-mime-object.cc', ], diff --git a/lib/message/mu-document.hh b/lib/message/mu-document.hh index 5119044c..97025628 100644 --- a/lib/message/mu-document.hh +++ b/lib/message/mu-document.hh @@ -1,4 +1,4 @@ -/** Copyright (C) 2022-2023 Dirk-Jan C. Binnema +/** Copyright (C) 2022-2025 Dirk-Jan C. Binnema ** ** This program is free software; you can redistribute it and/or modify it ** under the terms of the GNU General Public License as published by the @@ -28,6 +28,7 @@ #include "mu-priority.hh" #include "mu-flags.hh" #include "mu-contact.hh" +#include "mu-labels.hh" #include #include @@ -102,7 +103,6 @@ public: */ void add(Field::Id field_id, const std::vector& vals); - /** * Add message-contacts to the document, if non-empty * @@ -139,12 +139,13 @@ public: /** - * Add message flags to the document + * Add message flags to the document * * @param flags mesage flags. */ void add(Flags flags); + /** * Remove values and terms for some field. * diff --git a/lib/message/mu-fields.hh b/lib/message/mu-fields.hh index 62c79e02..e17f2711 100644 --- a/lib/message/mu-fields.hh +++ b/lib/message/mu-fields.hh @@ -1,5 +1,5 @@ /* -** Copyright (C) 2022-2024 Dirk-Jan C. Binnema +** Copyright (C) 2022-2025 Dirk-Jan C. Binnema ** ** This program is free software; you can redistribute it and/or modify it ** under the terms of the GNU General Public License as published by the @@ -65,6 +65,10 @@ struct Field { Tags, /**< Message Tags */ ThreadId, /**< Thread Id */ To, /**< To: recipient */ + + // XXX: re-order when we update the db-schema. + Labels, /**< Labels */ + // _count_ /**< Number of Ids */ }; @@ -462,6 +466,19 @@ static constexpr std::array Field::Flag::NormalTerm | Field::Flag::PhrasableTerm, }, + { + Field::Id::Labels, + Field::Type::StringList, + "labels", "label", + "Message label(s)", + "label:projectx", + 'q', + Field::Flag::BooleanTerm | + Field::Flag::Value | + Field::Flag::IncludeInSexp +, + }, + }}; /* @@ -476,8 +493,7 @@ static constexpr std::array * @return ref of the message field. */ constexpr const Field& -field_from_id(Field::Id id) -{ +field_from_id(Field::Id id) { return Fields.at(static_cast(id)); } diff --git a/lib/message/mu-labels.cc b/lib/message/mu-labels.cc new file mode 100644 index 00000000..3f800eba --- /dev/null +++ b/lib/message/mu-labels.cc @@ -0,0 +1,244 @@ +/* +** Copyright (C) 2025 Dirk-Jan C. Binnema +** +** This program is free software; you can redistribute it and/or modify it +** under the terms of the GNU General Public License as published by the +** Free Software Foundation; either version 3, or (at your option) any +** later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program; if not, write to the Free Software Foundation, +** Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +** +*/ + + +#include "mu-labels.hh" +#include +#include + +using namespace Mu; +using namespace Mu::Labels; + + +Result +Mu::Labels::validate_label(const std::string &label) +{ + if (label.empty()) + return Err(Error{Error::Code::InvalidArgument, + "labels cannot be empty"}); + else if (!g_utf8_validate(label.c_str(), label.size(), {})) // perhpps put hex in err str? + return Err(Error{Error::Code::InvalidArgument, + "labels must be valid UTF-8"}); + + const auto cstr{label.c_str()}; + + // labels must be at least two characters and not start with a + // dash. these limitations are there to avoid confusion with + // command-line parameters. + if (cstr[0] == '-' || cstr[0] == '+') + return Err(Error{Error::Code::InvalidArgument, + "labels cannot start with '+' or '-' ({})", label}); + + for (auto cur = cstr; cur && *cur; cur = g_utf8_next_char(cur)) { + + const gunichar uc = g_utf8_get_char(cur); + if (g_unichar_isalnum(uc)) + continue; // alphanum is okay + + // almost all non-ctrl ascii is allowed _except_ =,<,>,$,[] + if (uc > ' ' && uc <= '~') { + switch (uc) { + case '"': + case '/': + case '\\': + case '*': + case '$': + return Err(Error{Error::Code::InvalidArgument, + "illegal character '{}' in label ({})", uc, label}); + default: + break; + } + } else + return Err(Error{Error::Code::InvalidArgument, + "illegal non alpha-numeric character '{}' in label ({})", + uc, label}); + } + + return Ok(); +} + +Result +Mu::Labels::parse_delta_label(const std::string &expr) +{ + if (expr.size() < 1) + return Err(Error{Error::Code::InvalidArgument, + "empty labels are invalid"}); + const auto cstr{expr.c_str()}; + + // first char; either '+' or '-' + if (cstr[0] != '+' && cstr[0] != '-') + return Err(Error{Error::Code::InvalidArgument, + "invalid label expression '{}'; " + "must start with '+' or '-'", + expr}); + Delta delta{cstr[0] == '+' ? Delta::Add : Delta::Remove}; + std::string label{expr.substr(1)}; + + if (const auto res = validate_label(label); !res) + return Err(res.error()); + + return Ok(DeltaLabel{std::move(delta), std::move(label)}); +} + +std::pair +Mu::Labels::updated_labels(const LabelVec& labels, const DeltaLabelVec& deltas) +{ + // quite complicated! + + // First, the delta; put in a set for uniqueness; and use a special + // comparison operator so "add" and "remove" deltas are considered "the same" + // for the set; then fill the set from the end of the deltas vec to the begining, + // so "the last one wins", as we want. + const auto cmp_delta_label=[](const DeltaLabel& dl1, const DeltaLabel& dl2) { + return dl1.second < dl2.second; + }; + // only one change per label, last one wins + std::set working_deltas{ + deltas.rbegin(), deltas.rend() + }; + + // working set of lables; we start with _all_ (uniquified) + std::set working_labels{labels.begin(), labels.end()}; + + // keep track of the deltas that actually changed something (ie. + // removing a non-existing label or adding an already existing one is + // not a change.) + DeltaLabelVec effective_deltas; + + // now check each of our "workin deltas", apply on the working_labels, and + // if they changed anything, add to 'effectivc_deltas + for (auto& delta: working_deltas) { + switch (delta.first) { + case Delta::Add: + // add to the _effective_ deltas if the element wasn't + // there before. + if (working_labels.emplace(delta.second).second) + effective_deltas.emplace_back(std::move(delta)); + break; + case Delta::Remove: + // add to the _effective_ deltas if the element was + // actually removed. + if (working_labels.erase(delta.second) > 0U) + effective_deltas.emplace_back(std::move(delta)); + break; + default: + // can't have Neutral here. + throw std::runtime_error("invalid delta"); + } + } + + + return {{ working_labels.begin(), working_labels.end()}, effective_deltas}; +} + + + +#ifdef BUILD_TESTS + +#include "utils/mu-test-utils.hh" + +static void +test_parse_delta_label() +{ + + { + const auto expr = parse_delta_label("+foo"); + assert_valid_result(expr); + g_assert_true(expr->first == Delta::Add); + assert_equal(expr->second, "foo"); + } + + + { + const auto expr = parse_delta_label("-bar@cuux"); + assert_valid_result(expr); + g_assert_true(expr->first == Delta::Remove); + assert_equal(expr->second, "bar@cuux"); + } + + g_assert_false(!!parse_delta_label("ravenking")); + g_assert_false(!!parse_delta_label("+norrell strange")); + g_assert_false(!!parse_delta_label("-😨")); +} + +static void +test_validate_label() +{ + g_assert_true(!!validate_label("ravenking")); + g_assert_true(!!validate_label("@raven+king")); + g_assert_true(!!validate_label("operation:mindcrime")); + + g_assert_false(!!validate_label("norrell strange")); + g_assert_false(!!validate_label("😨")); + g_assert_false(!!validate_label("")); + g_assert_false(!!validate_label("+")); + g_assert_false(!!validate_label("-")); +} + +static void +test_updated_labels() +{ + const auto assert_eq=[](const LabelVec& labels, const DeltaLabelVec& deltas, + const LabelVec& exp_labels, const DeltaLabelVec& exp_deltas) { + + const auto& [res_labels, res_deltas] = updated_labels(labels, deltas); + + assert_equal_seq_str(res_labels, exp_labels); + g_assert_cmpuint(res_deltas.size(), ==, exp_deltas.size()); + for (size_t i{}; i != res_deltas.size(); ++i) { + g_assert_true(res_deltas[i].first == exp_deltas[i].first); + assert_equal(res_deltas[i].second, exp_deltas[i].second); + } + }; + + const auto delta_labels = [](std::initializer_list strs)->DeltaLabelVec { + DeltaLabelVec deltas; + std::transform(strs.begin(), strs.end(), std::back_inserter(deltas), + [](auto str) { + const auto res = parse_delta_label(str); + assert_valid_result(res); + return *res; + }); + return deltas; + }; + + assert_eq({"foo", "bar", "cuux"}, delta_labels({"+fnorb", "+bar", "-bar", "+bar", "-cuux"}), + {"bar", "fnorb", "foo"}, delta_labels({"-cuux", "+fnorb"})); + + assert_eq({}, delta_labels({"-fnorb", "-fnorb", "+whiteward", "+altesia", "+fnorb"}), + {"altesia", "fnorb", "whiteward"}, delta_labels({"+altesia", "+fnorb", "+whiteward"})); + + + assert_eq({"piranesi", "hyperion", "mordor", "piranesi"}, delta_labels({}), + {"hyperion", "mordor", "piranesi"}, delta_labels({})); +} + + +int +main(int argc, char* argv[]) +{ + mu_test_init(&argc, &argv); + + g_test_add_func("/message/labels/parse-delta-label", test_parse_delta_label); + g_test_add_func("/message/labels/validate-label", test_validate_label); + g_test_add_func("/message/labels/updated-labels", test_updated_labels); + + return g_test_run(); +} +#endif /*BUILD_TESTS*/ diff --git a/lib/message/mu-labels.hh b/lib/message/mu-labels.hh new file mode 100644 index 00000000..7971b96e --- /dev/null +++ b/lib/message/mu-labels.hh @@ -0,0 +1,88 @@ +/* +** Copyright (C) 2025 Dirk-Jan C. Binnema +** +** This program is free software; you can redistribute it and/or modify it +** under the terms of the GNU General Public License as published by the +** Free Software Foundation; either version 3, or (at your option) any +** later version. +** +** This program is distributed in the hope that it will be useful, +** but WITHOUT ANY WARRANTY; without even the implied warranty of +** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +** GNU General Public License for more details. +** +** You should have received a copy of the GNU General Public License +** along with this program; if not, write to the Free Software Foundation, +** Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +** +*/ + +#ifndef MU_LABELS_HH +#define MU_LABELS_HH + +#include + +#include +#include +#include + +namespace Mu { +namespace Labels { + +using LabelVec = std::vector; + +enum struct Delta { Add='+', Remove='-'}; +using DeltaLabel = std::pair; +using DeltaLabelVec = std::vector; + +/** + * Parse a label expression, i.e., a label prefixed with '+' or '-' + * + * This also validates the label, as per valid_label() + * + * @param expr expression + * + * @return a result with either a DeltaLabel or an error + */ +Result parse_delta_label(const std::string& expr); + +/** + * Is the label (without +/- prefix) valid? + * + * @param label some label + * + * @return either Ok or some error + */ +Result validate_label(const std::string &label); + +/** + * Apply deltas to labels and return the result as well as the + * effective changes. + * + * The deltas are handled in order; 'last one wins', hence: + * { +foo, -foo } ==> no foo in the result + * and + * { -foo, +foo } ==> foo in results + * + * The result labels do not contain duplicates. Order is not necessarily + * maintained. + * + * The result is a pair, the first element is LabelVec with the results + * as explained. + * + * The second is a DeltaVec with the _effective_ changes; this the input + * DeltaVec but without any +