summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-14 12:42:38 +0200
committerDanilo M. <danix@danix.xyz>2026-08-14 12:42:38 +0200
commit441b93a75b9941d395edfe4b65ed0af2da9e0021 (patch)
tree7690c00dd355ad75fc59484f2cda2bf185c7b87e
parente876e509b0770a243725b63ea55c9ccf3e41b1bf (diff)
downloadqtmaildir-441b93a75b9941d395edfe4b65ed0af2da9e0021.tar.gz
qtmaildir-441b93a75b9941d395edfe4b65ed0af2da9e0021.zip
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.
-rw-r--r--src/CMakeLists.txt1
-rw-r--r--src/searchterm.cpp93
-rw-r--r--src/searchterm.h94
-rw-r--r--tests/CMakeLists.txt1
-rw-r--r--tests/test_searchterm.cpp146
5 files changed, 335 insertions, 0 deletions
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. <danix@danix.xyz>
+ *
+ * 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 <algorithm>
+
+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. <danix@danix.xyz>
+ *
+ * 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 <QDate>
+#include <QList>
+#include <QString>
+
+/// 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. <danix@danix.xyz>
+ *
+ * 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 <QtTest>
+
+#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 <foo@example.org>")),
+ QStringLiteral("from:\"Foo <foo@example.org>\""));
+}
+
+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"