From 441b93a75b9941d395edfe4b65ed0af2da9e0021 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Fri, 14 Aug 2026 12:42:38 +0200 Subject: feat(search): build notmuch terms for the right-click actions One place for the query grammar behind every search surface, with no widget involved so it is tested without a painter or a web engine. extend() parenthesises both sides. The query bar may hold a hand-written disjunction, and 'a or b AND c' binds as 'a or (b AND c)', which widens a search meant to narrow it and reports nothing. --- src/CMakeLists.txt | 1 + src/searchterm.cpp | 93 +++++++++++++++++++++++++++++ src/searchterm.h | 94 +++++++++++++++++++++++++++++ tests/CMakeLists.txt | 1 + tests/test_searchterm.cpp | 146 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 335 insertions(+) create mode 100644 src/searchterm.cpp create mode 100644 src/searchterm.h create mode 100644 tests/test_searchterm.cpp diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 0945f65..382f3b8 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -25,6 +25,7 @@ add_library(qtmaildir_lib STATIC mainwindow.cpp querycompleter.cpp rulequery.cpp + searchterm.cpp ) target_include_directories(qtmaildir_lib diff --git a/src/searchterm.cpp b/src/searchterm.cpp new file mode 100644 index 0000000..acc64c6 --- /dev/null +++ b/src/searchterm.cpp @@ -0,0 +1,93 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2 as + * published by the Free Software Foundation. + * + * 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 "searchterm.h" + +#include + +namespace SearchTerm { + +QString quote(const QString &value) +{ + // simplified() collapses every run of whitespace, newlines and tabs + // included, and trims the ends. A selection spanning paragraphs arrives + // full of newlines, which would otherwise reach the query bar verbatim. + QString cleaned = value.simplified(); + if (cleaned.isEmpty()) + return {}; + + if (cleaned.size() > kMaxValueLength) + cleaned.truncate(kMaxValueLength); + + // Backslashes first: escaping the quotes first would then escape the + // backslashes this step adds, doubling them. + cleaned.replace(QLatin1Char('\\'), QStringLiteral("\\\\")); + cleaned.replace(QLatin1Char('"'), QStringLiteral("\\\"")); + + return QLatin1Char('"') + cleaned + QLatin1Char('"'); +} + +QString field(const QString &name, const QString &value) +{ + const QString quoted = quote(value); + if (quoted.isEmpty()) + return {}; + return name + QLatin1Char(':') + quoted; +} + +QString onDate(const QDate &day) +{ + if (!day.isValid()) + return {}; + const QString text = day.toString(QStringLiteral("yyyy-MM-dd")); + return QStringLiteral("date:%1..%1").arg(text); +} + +QString tag(const QString &name) +{ + const QString trimmed = name.simplified(); + if (trimmed.isEmpty()) + return {}; + + // A tag is a token from a vocabulary the user chose, so it reads better + // unquoted in the bar they are about to edit. Quoted only when it holds + // something that would not survive. + const bool needsQuoting = + std::any_of(trimmed.cbegin(), trimmed.cend(), [](QChar ch) { + return !(ch.isLetterOrNumber() || ch == QLatin1Char('-') + || ch == QLatin1Char('_') || ch == QLatin1Char('.') + || ch == QLatin1Char('/')); + }); + + return QStringLiteral("tag:") + (needsQuoting ? quote(trimmed) : trimmed); +} + +QString extend(const QString &existing, const QString &addition) +{ + const QString left = existing.trimmed(); + const QString right = addition.trimmed(); + + if (right.isEmpty()) + return left; + if (left.isEmpty()) + return right; + + return QStringLiteral("(%1) AND (%2)").arg(left, right); +} + +} // namespace SearchTerm diff --git a/src/searchterm.h b/src/searchterm.h new file mode 100644 index 0000000..d596676 --- /dev/null +++ b/src/searchterm.h @@ -0,0 +1,94 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2 as + * published by the Free Software Foundation. + * + * 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. + */ + +#pragma once + +#include +#include +#include + +/// Builds the notmuch query strings behind the right-click search actions. +/// +/// Free functions and no widget, so the whole query grammar is testable +/// without a painter, a web engine or a window. Every surface that offers a +/// search goes through here, which is what stops five surfaces growing five +/// slightly different quoting rules. +/// +/// **notmuch rejects almost nothing.** `from:((((` parses cleanly and matches +/// zero, so a malformed query produces an empty result rather than an error +/// the user could act on. Correctness here cannot be checked by asking notmuch; +/// it is checked against the constructed string. +namespace SearchTerm { + +/// Longest quoted value. A selection longer than this is a mis-drag rather +/// than a search, and the query bar is an editable line the user has to be +/// able to read. +inline constexpr int kMaxValueLength = 200; + +/// Quotes an arbitrary value for use as a notmuch term. +/// +/// Whitespace and newlines collapse to single spaces, embedded quotes are +/// escaped, the value is capped at kMaxValueLength, and an empty or +/// whitespace-only value yields an EMPTY STRING rather than `""`. Callers +/// test for empty to decide whether to offer a menu entry at all. +QString quote(const QString &value); + +/// `field:"value"`, or empty when the value is empty. +/// +/// The field name is a notmuch keyword and is never translated: it is wire +/// format, not user-facing text. +QString field(const QString &name, const QString &value); + +/// `date:YYYY-MM-DD..YYYY-MM-DD` for a single day, empty for an invalid date. +/// +/// The day twice rather than the day and its successor: notmuch's range is +/// inclusive at both ends, so the naive `..next-day` form silently includes a +/// second day of mail. +QString onDate(const QDate &day); + +/// `tag:name`, quoted only when the name needs it. +QString tag(const QString &name); + +/// Narrows `existing` by `addition`, as `(existing) AND (addition)`. +/// +/// **Both sides are parenthesised and that is load-bearing.** The query bar +/// may hold a hand-written disjunction, and `a or b AND c` binds as +/// `a or (b AND c)`: the result WIDENS a search the user asked to narrow, and +/// nothing reports an error. The same trap is why the post-new hook +/// parenthesises a rule's query before scoping it with `tag:new`. +/// +/// An empty `existing` yields `addition` alone rather than `() AND (x)`, which +/// matches nothing; an empty `addition` leaves `existing` untouched. +QString extend(const QString &existing, const QString &addition); + +} // namespace SearchTerm + +/// One entry a context menu can offer: a finished query and the text naming it. +/// +/// Carried rather than rebuilt at menu-construction time, so the value a menu +/// entry searches for is the value the pane extracted, with no second parse of +/// anything already rendered. +struct SearchOffer +{ + /// Shown in the menu. Already translated, and elides a long value: the + /// query keeps the full one. + QString label; + + /// The finished notmuch query. Never empty in a constructed offer. + QString query; +}; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e5142b6..5b0b85b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -54,3 +54,4 @@ add_qtmaildir_test(querycompleter) add_qtmaildir_test(tagdialog) add_qtmaildir_test(tagrules) add_qtmaildir_test(rulequery) +add_qtmaildir_test(searchterm) diff --git a/tests/test_searchterm.cpp b/tests/test_searchterm.cpp new file mode 100644 index 0000000..e0bdb60 --- /dev/null +++ b/tests/test_searchterm.cpp @@ -0,0 +1,146 @@ +/* + * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs + * Copyright (C) 2026 Danilo M. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2 as + * published by the Free Software Foundation. + * + * 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 + +#include "searchterm.h" + +/// The query grammar of the right-click search actions. +/// +/// Asserted on the CONSTRUCTED STRING throughout, never on a query notmuch +/// refuses: notmuch's parser rejects almost nothing, so `from:((((` parses +/// cleanly and matches zero. A test expecting a failure would pass against +/// correct code and against broken code alike. +class TestSearchTerm : public QObject +{ + Q_OBJECT +private slots: + void quotesAPlainValue(); + void escapesEmbeddedQuotes(); + void collapsesWhitespaceAndNewlines(); + void rejectsEmptyAndWhitespaceOnly(); + void capsAnOverlongSelection(); + void buildsAFieldTerm(); + void omitsAFieldWithNoValue(); + void buildsADateRangeForOneDay(); + void tagIsNotQuoted(); + void extendParenthesisesBothSides(); + void extendOntoAnEmptyQueryIsAReplace(); +}; + +void TestSearchTerm::quotesAPlainValue() +{ + QCOMPARE(SearchTerm::quote(QStringLiteral("Quarterly report")), + QStringLiteral("\"Quarterly report\"")); +} + +void TestSearchTerm::escapesEmbeddedQuotes() +{ + // A selection is arbitrary prose and can carry a quote. Unescaped, it ends + // the quoted string early and the rest becomes stray query syntax, which + // notmuch accepts and matches nothing on. + QCOMPARE(SearchTerm::quote(QStringLiteral("say \"hello\" now")), + QStringLiteral("\"say \\\"hello\\\" now\"")); +} + +void TestSearchTerm::collapsesWhitespaceAndNewlines() +{ + QCOMPARE(SearchTerm::quote(QStringLiteral(" two\n\nlines\there ")), + QStringLiteral("\"two lines here\"")); +} + +void TestSearchTerm::rejectsEmptyAndWhitespaceOnly() +{ + // An empty term must yield an empty string, which is what every caller + // tests to decide whether to offer a menu entry at all. + QVERIFY(SearchTerm::quote(QString()).isEmpty()); + QVERIFY(SearchTerm::quote(QStringLiteral(" \n\t ")).isEmpty()); +} + +void TestSearchTerm::capsAnOverlongSelection() +{ + // A multi-kilobyte selection is a mis-drag, not a query. + const QString huge(5000, QLatin1Char('x')); + const QString term = SearchTerm::quote(huge); + QVERIFY2(term.size() < 300, + qPrintable(QStringLiteral("term was %1 chars").arg(term.size()))); + QVERIFY(term.startsWith(QStringLiteral("\"xxx"))); + QVERIFY(term.endsWith(QLatin1Char('"'))); +} + +void TestSearchTerm::buildsAFieldTerm() +{ + QCOMPARE(SearchTerm::field(QStringLiteral("from"), + QStringLiteral("Foo ")), + QStringLiteral("from:\"Foo \"")); +} + +void TestSearchTerm::omitsAFieldWithNoValue() +{ + // A message with no Cc must not offer cc:"" , which parses cleanly and + // matches nothing, so the entry would look enabled and do nothing. + QVERIFY(SearchTerm::field(QStringLiteral("cc"), QString()).isEmpty()); +} + +void TestSearchTerm::buildsADateRangeForOneDay() +{ + // notmuch's date: range is inclusive at both ends, so one day is the day + // named twice rather than the day and its successor. + const QDate day(2026, 8, 14); + QCOMPARE(SearchTerm::onDate(day), + QStringLiteral("date:2026-08-14..2026-08-14")); + QVERIFY(SearchTerm::onDate(QDate()).isEmpty()); +} + +void TestSearchTerm::tagIsNotQuoted() +{ + // A tag name is a token from a known vocabulary, not prose. Quoting one + // is not wrong but reads badly in the bar, and the user edits that text. + QCOMPARE(SearchTerm::tag(QStringLiteral("inbox")), + QStringLiteral("tag:inbox")); + // A tag containing a space is the exception and does need quoting. + QCOMPARE(SearchTerm::tag(QStringLiteral("to do")), + QStringLiteral("tag:\"to do\"")); + QVERIFY(SearchTerm::tag(QString()).isEmpty()); +} + +void TestSearchTerm::extendParenthesisesBothSides() +{ + // THE case this exists for. The bar may hold a hand-written disjunction, + // and `a or b AND c` binds as `a or (b AND c)`: the result WIDENS a search + // the user asked to narrow. Both sides are wrapped so neither can rebind. + QCOMPARE(SearchTerm::extend(QStringLiteral("tag:inbox or tag:flagged"), + QStringLiteral("from:foo@example.org")), + QStringLiteral("(tag:inbox or tag:flagged) AND (from:foo@example.org)")); +} + +void TestSearchTerm::extendOntoAnEmptyQueryIsAReplace() +{ + // Rather than "() AND (x)", which matches nothing. + QCOMPARE(SearchTerm::extend(QString(), QStringLiteral("tag:inbox")), + QStringLiteral("tag:inbox")); + QCOMPARE(SearchTerm::extend(QStringLiteral(" "), + QStringLiteral("tag:inbox")), + QStringLiteral("tag:inbox")); + // And an empty new term leaves the existing query alone. + QCOMPARE(SearchTerm::extend(QStringLiteral("tag:inbox"), QString()), + QStringLiteral("tag:inbox")); +} + +QTEST_MAIN(TestSearchTerm) +#include "test_searchterm.moc" -- cgit v1.2.3