diff options
| author | Dirk-Jan C. Binnema <djcb@djcbsoftware.nl> | 2025-07-27 09:17:55 +0300 |
|---|---|---|
| committer | Dirk-Jan C. Binnema <djcb@djcbsoftware.nl> | 2025-08-15 21:02:24 +0300 |
| commit | 552bb3a7c82fc7472ab8d3a5a0fd32fd9ea72909 (patch) | |
| tree | cca09a480b86affefd272bd0b4b2076f031659fe | |
| parent | 34d3bf2e28138f31faaed55ba98c3088449c9fdf (diff) | |
message: add support for labels + tests
Labels are strings associated with messages, which can be used for searching
them.
| -rw-r--r-- | lib/message/meson.build | 3 | ||||
| -rw-r--r-- | lib/message/mu-document.hh | 7 | ||||
| -rw-r--r-- | lib/message/mu-fields.hh | 22 | ||||
| -rw-r--r-- | lib/message/mu-labels.cc | 244 | ||||
| -rw-r--r-- | lib/message/mu-labels.hh | 88 | ||||
| -rw-r--r-- | lib/message/mu-message.cc | 7 | ||||
| -rw-r--r-- | lib/message/mu-message.hh | 23 | ||||
| -rw-r--r-- | lib/message/tests/meson.build | 7 |
8 files changed, 390 insertions, 11 deletions
diff --git a/lib/message/meson.build b/lib/message/meson.build index 006bb18..31052b8 100644 --- a/lib/message/meson.build +++ b/lib/message/meson.build @@ -1,4 +1,4 @@ -## Copyright (C) 2022-2024 Dirk-Jan C. Binnema <djcb@djcbsoftware.nl> +## Copyright (C) 2022-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 @@ -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 5119044..9702562 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 <djcb@djcbsoftware.nl> +/** Copyright (C) 2022-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 @@ -28,6 +28,7 @@ #include "mu-priority.hh" #include "mu-flags.hh" #include "mu-contact.hh" +#include "mu-labels.hh" #include <utils/mu-option.hh> #include <utils/mu-sexp.hh> @@ -102,7 +103,6 @@ public: */ void add(Field::Id field_id, const std::vector<std::string>& 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 62c79e0..e17f271 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 <djcb@djcbsoftware.nl> +** Copyright (C) 2022-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 @@ -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, Field::id_size()> 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<Field, Field::id_size()> * @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<size_t>(id)); } diff --git a/lib/message/mu-labels.cc b/lib/message/mu-labels.cc new file mode 100644 index 0000000..3f800eb --- /dev/null +++ b/lib/message/mu-labels.cc @@ -0,0 +1,244 @@ +/* +** 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-labels.hh" +#include <set> +#include <algorithm> + +using namespace Mu; +using namespace Mu::Labels; + + +Result<void> +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<DeltaLabel> +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<LabelVec, DeltaLabelVec> +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<DeltaLabel, decltype(cmp_delta_label)> working_deltas{ + deltas.rbegin(), deltas.rend() + }; + + // working set of lables; we start with _all_ (uniquified) + std::set<std::string> 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<std::string> 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 0000000..7971b96 --- /dev/null +++ b/lib/message/mu-labels.hh @@ -0,0 +1,88 @@ +/* +** 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. +** +*/ + +#ifndef MU_LABELS_HH +#define MU_LABELS_HH + +#include <utils/mu-result.hh> + +#include <string> +#include <vector> +#include <utility> + +namespace Mu { +namespace Labels { + +using LabelVec = std::vector<std::string>; + +enum struct Delta { Add='+', Remove='-'}; +using DeltaLabel = std::pair<Delta, std::string>; +using DeltaLabelVec = std::vector<DeltaLabel>; + +/** + * 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<DeltaLabel> parse_delta_label(const std::string& expr); + +/** + * Is the label (without +/- prefix) valid? + * + * @param label some label + * + * @return either Ok or some error + */ +Result<void> 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 +<label> if label was already in labels; and without + * -<label> if <label> was not in labels. + * + * @param labels existing labels + * @param deltas deltas for these labels + * + * @return updated labels + */ +std::pair<LabelVec, DeltaLabelVec> updated_labels(const LabelVec& labels, const DeltaLabelVec& delta); + +} // Labels +} // Mu + + +#endif /*MU_LABELS_HH*/ diff --git a/lib/message/mu-message.cc b/lib/message/mu-message.cc index 339abd1..2d7c17f 100644 --- a/lib/message/mu-message.cc +++ b/lib/message/mu-message.cc @@ -240,6 +240,13 @@ Message::set_flags(Flags flags) priv_->doc.add(flags); } +void +Message::set_labels(const Labels::LabelVec& labels) +{ + priv_->doc.remove(Field::Id::Labels); + priv_->doc.add(Field::Id::Labels, labels); +} + bool Message::load_mime_message(bool reload) const { diff --git a/lib/message/mu-message.hh b/lib/message/mu-message.hh index c6b6917..0202558 100644 --- a/lib/message/mu-message.hh +++ b/lib/message/mu-message.hh @@ -31,6 +31,7 @@ #include "mu-priority.hh" #include "mu-flags.hh" #include "mu-fields.hh" +#include "mu-labels.hh" #include "mu-document.hh" #include "mu-message-part.hh" #include "mu-message-file.hh" @@ -342,15 +343,29 @@ public: } /** - * get the list of tags (ie., X-Label) + * Get the labels for this message + * + * @return a list with the tags for this msg. Don't modify/free + */ + Labels::LabelVec labels() const { + return document().string_vec_value(Field::Id::Labels); + } + + /** + * Set the labels for this message, removing the existing ones. * - * @param msg a valid MuMsg + * @param labels the new labels + */ + void set_labels(const Labels::LabelVec& labels); + + + /** + * get the list of tags (ie., X-Label) * * @return a list with the tags for this msg. Don't modify/free */ std::vector<std::string> tags() const { - return document() - .string_vec_value(Field::Id::Tags); + return document().string_vec_value(Field::Id::Tags); } /* diff --git a/lib/message/tests/meson.build b/lib/message/tests/meson.build index 880a188..7dfbdd4 100644 --- a/lib/message/tests/meson.build +++ b/lib/message/tests/meson.build @@ -42,6 +42,13 @@ test('test-fields', cpp_args: ['-DBUILD_TESTS'], dependencies: [glib_dep, gmime_dep, lib_mu_message_dep])) +test('test-labels', + executable('test-labels', + '../mu-labels.cc', + install: false, + cpp_args: ['-DBUILD_TESTS'], + dependencies: [glib_dep, lib_mu_utils_dep])) + test('test-flags', executable('test-flags', '../mu-flags.cc', |
