summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/config.cpp121
-rw-r--r--src/config.h46
-rw-r--r--tests/test_config.cpp136
3 files changed, 302 insertions, 1 deletions
diff --git a/src/config.cpp b/src/config.cpp
index ae26555..0b65053 100644
--- a/src/config.cpp
+++ b/src/config.cpp
@@ -57,7 +57,24 @@ constexpr int kQueriesFormatVersion = 1;
/// button that silently finds nothing. Adding one here needs no format bump:
/// an older build keeps the row and reports it, which is why an unknown
/// generator is a problem rather than a reason to drop the entry.
-const QStringList kQueryGenerators = { QStringLiteral("sent") };
+const QStringList kQueryGenerators = { QStringLiteral("unread"),
+ QStringLiteral("inbox"),
+ QStringLiteral("flagged"),
+ QStringLiteral("sent") };
+
+/// The tag a generator matches, for the three filters that are a plain tag
+/// query. Empty for "sent", which composes from each account's folder instead
+/// and is handled separately.
+QString generatorTag(const QString &generator)
+{
+ if (generator == QStringLiteral("unread"))
+ return QStringLiteral("unread");
+ if (generator == QStringLiteral("inbox"))
+ return QStringLiteral("inbox");
+ if (generator == QStringLiteral("flagged"))
+ return QStringLiteral("flagged");
+ return QString();
+}
} // namespace
@@ -652,6 +669,108 @@ QString Config::resolvedQuery(const SavedQuery &query) const
return scope.scopedQuery(query.query);
}
+bool Config::isKnownGenerator(const QString &generator)
+{
+ return kQueryGenerators.contains(generator);
+}
+
+QString Config::matchNothingQuery()
+{
+ // notmuch reads an EMPTY query as "match everything", so a generator with
+ // nothing to match must say so explicitly. `tag:` and its negation cannot
+ // both hold, and the tag name is irrelevant: what matters is that this
+ // parses and matches nothing. A malformed string would not do, since
+ // notmuch accepts almost anything and matches nothing quietly, which is the
+ // same result reached by luck rather than by contract.
+ return QStringLiteral("tag:unread and not tag:unread");
+}
+
+QList<SavedQuery> Config::builtinFilters()
+{
+ // Left to right on the query row. Fixed rather than configurable: item 94
+ // removes the mixed row entirely once these are confirmed, so a settings
+ // surface for the order would be built and deleted inside two items.
+ QList<SavedQuery> filters;
+ for (const QString &generator : kQueryGenerators)
+ filters.append(builtinFilter(generator));
+ return filters;
+}
+
+SavedQuery Config::builtinFilter(const QString &generator)
+{
+ if (!isKnownGenerator(generator))
+ return {};
+
+ SavedQuery filter;
+ filter.generated = generator;
+
+ // Translated, because these are the labels on the buttons. The GENERATOR
+ // name is not: it is stored in queries.json and matched against a closed
+ // set, so translating it would make a file written in one locale unreadable
+ // in another.
+ if (generator == QStringLiteral("unread")) {
+ filter.name = tr("Unread");
+ } else if (generator == QStringLiteral("inbox")) {
+ filter.name = tr("Inbox");
+ } else if (generator == QStringLiteral("flagged")) {
+ filter.name = tr("Flagged");
+ } else if (generator == QStringLiteral("sent")) {
+ filter.name = tr("Sent");
+ // Messages rather than threads, and the only filter that sets this. A
+ // thread would fold the user's sent message back into the conversation
+ // it belongs to, which is item 63's finding.
+ filter.flat = true;
+ }
+
+ return filter;
+}
+
+QString Config::resolvedQuery(const SavedQuery &query,
+ const QString &accountKey) const
+{
+ // An ordinary saved query is a DESTINATION: it states its own scope and
+ // ignores the dropdown, which is the behaviour item 90 leaves alone. Only a
+ // generated filter composes.
+ if (!query.isGenerated())
+ return resolvedQuery(query);
+
+ if (!isKnownGenerator(query.generated))
+ return QString();
+
+ if (accountKey.isEmpty()) {
+ // Across every account, which for the tag filters is the bare query and
+ // for Sent is the union of the accounts' folders.
+ if (query.generated == QStringLiteral("sent")) {
+ const QString all = allSentQuery();
+ return all.isEmpty() ? matchNothingQuery() : all;
+ }
+ return QStringLiteral("tag:%1").arg(generatorTag(query.generated));
+ }
+
+ const Account scope = account(accountKey);
+ if (!scope.isValid())
+ return resolvedQuery(query, QString());
+
+ if (query.generated == QStringLiteral("sent")) {
+ // The account's OWN sent query, never the all-accounts one wrapped in
+ // this account's path. Wrapping gives
+ // path:"a/**" and (path:"a/Sent/**" or path:"b/Sent/**")
+ // which returns the right rows because path: is hierarchical, and is
+ // still wrong: it double-scopes and works by accident of the syntax.
+ const QString sent = scope.sentQuery();
+ // Empty when the account configures no sent folder, which is a real
+ // case and not a misconfiguration. Returned as-is it would mean "match
+ // everything", so a button labelled Sent would show the whole Maildir.
+ return sent.isEmpty() ? matchNothingQuery() : sent;
+ }
+
+ // A tag filter carries no path of its own, so scoping is exactly what
+ // scopedQuery() does. Its parentheses are load-bearing: `path:... and a or
+ // b` binds as `(path:... and a) or b`.
+ return scope.scopedQuery(
+ QStringLiteral("tag:%1").arg(generatorTag(query.generated)));
+}
+
SavedQuery Config::startupSavedQuery() const
{
if (m_savedQueries.isEmpty())
diff --git a/src/config.h b/src/config.h
index b9ee9d6..3946b40 100644
--- a/src/config.h
+++ b/src/config.h
@@ -19,6 +19,7 @@
#pragma once
#include <QColor>
+#include <QCoreApplication>
#include <QJsonObject>
#include <QList>
#include <QString>
@@ -155,6 +156,11 @@ struct SavedQuery
/// allow the GUI to index a different tree than the CLI.
class Config
{
+ // Not a QObject: this class is a value holder read from every thread. The
+ // macro gives it tr() for the built-in filters' NAMES, which are the labels
+ // on the query row's buttons and therefore user-facing.
+ Q_DECLARE_TR_FUNCTIONS(Config)
+
public:
/// Path used when load() is called with no argument.
static QString defaultPath();
@@ -190,6 +196,46 @@ public:
/// than a scope built from an empty maildir, which would be path:"/**".
QString resolvedQuery(const SavedQuery &query) const;
+ /// The query as it should be run in one account's scope, or across all of
+ /// them when `accountKey` is empty.
+ ///
+ /// This is what makes a built-in filter COMPOSE with the account dropdown
+ /// rather than fight it (item 93). A generator is asked for the account's
+ /// own query, never handed its all-accounts query to wrap: wrapping gives
+ /// path:"a/**" and (path:"a/Sent/**" or path:"b/Sent/**")
+ /// which returns the right rows only because path: is hierarchical, and
+ /// says something other than what is meant.
+ ///
+ /// An ordinary saved query ignores `accountKey` and keeps resolving through
+ /// its OWN stored account, which is the behaviour item 90 leaves alone: a
+ /// saved query is a destination and states its own scope.
+ QString resolvedQuery(const SavedQuery &query,
+ const QString &accountKey) const;
+
+ /// The built-in filters, in the order they appear on the query row.
+ ///
+ /// Shipped rather than stored: these are not the user's saved queries and
+ /// are not in queries.json at all. The row used to be whatever the user had
+ /// pinned, which is how it drifted (item 93).
+ static QList<SavedQuery> builtinFilters();
+
+ /// One built-in filter by generator name, or a default-constructed
+ /// SavedQuery when the name is not one.
+ static SavedQuery builtinFilter(const QString &generator);
+
+ /// Whether `generator` is one this build knows how to resolve.
+ ///
+ /// A closed set, so a typo is reported on load rather than producing a
+ /// button that silently finds nothing.
+ static bool isKnownGenerator(const QString &generator);
+
+ /// A query that deliberately matches no message.
+ ///
+ /// Needed because an EMPTY query means "match everything" to notmuch, so a
+ /// generator with nothing to match cannot simply return one: Sent under an
+ /// account that configures no sent folder would show the entire Maildir.
+ static QString matchNothingQuery();
+
/// Empty when unset; the caller disables the Sync button in that case.
QString syncCommand() const { return m_syncCommand; }
diff --git a/tests/test_config.cpp b/tests/test_config.cpp
index 3b094ba..895495d 100644
--- a/tests/test_config.cpp
+++ b/tests/test_config.cpp
@@ -95,6 +95,12 @@ private slots:
void allSentQueryIsEmptyWhenNoAccountHasOne();
void allSentQuerySkipsAccountsWithoutTheKey();
void allSentQueryJoinsEveryConfiguredAccount();
+ void everyBuiltinFilterIsAKnownGenerator();
+ void aFilterAcrossAllAccountsIsTheUnscopedQuery();
+ void aTagFilterScopedToAnAccountCarriesThatAccountsPath();
+ void sentScopedToAnAccountIsThatAccountsSentFolderAlone();
+ void sentScopedToAnAccountWithNoSentFolderMatchesNothing();
+ void aFilterKeepsItsViewMode();
void draftsQueryIsEmptyWithoutTheKey();
void draftsQuerySurvivesABracketedPath();
void allDraftsQuerySkipsAccountsWithoutTheKey();
@@ -1020,6 +1026,136 @@ void TestConfig::allSentQueryJoinsEveryConfiguredAccount()
QCOMPARE(all.count(QStringLiteral(" or ")), 1);
}
+/// Two accounts, one with a sent folder and one without. The second is the
+/// case that matters most: folderQuery() returns empty for an unset folder and
+/// an empty query means "match everything" to notmuch, so a filter that falls
+/// back to it silently shows the whole Maildir.
+static QString writeTwoAccounts(const QTemporaryDir &dir)
+{
+ return writeIni(dir, QStringLiteral(
+ "[account.work]\n"
+ "maildir=work\n"
+ "sent=Sent\n"
+ "\n"
+ "[account.personal]\n"
+ "maildir=personal\n"));
+}
+
+void TestConfig::everyBuiltinFilterIsAKnownGenerator()
+{
+ // The guard for every case below. A filter whose generator is not in the
+ // closed set loads with a reported problem and resolves to an empty query,
+ // which means "match everything": the assertions that follow would then be
+ // measuring a typo rather than the design.
+ Config config;
+ const QList<SavedQuery> filters = config.builtinFilters();
+
+ QCOMPARE(filters.size(), 4);
+
+ QStringList names;
+ for (const SavedQuery &filter : filters) {
+ QVERIFY2(filter.isGenerated(),
+ qPrintable(QStringLiteral("filter '%1' stores a query instead "
+ "of naming a generator")
+ .arg(filter.name)));
+ QVERIFY2(Config::isKnownGenerator(filter.generated),
+ qPrintable(QStringLiteral("filter '%1' names the unknown "
+ "generator '%2'")
+ .arg(filter.name, filter.generated)));
+ names.append(filter.name);
+ }
+
+ // The order is the row's order, left to right, and is fixed rather than
+ // configurable: item 94 removes the mixed row entirely, so a settings
+ // surface for this would be built and deleted inside two items.
+ QCOMPARE(names, (QStringList{ QStringLiteral("Unread"),
+ QStringLiteral("Inbox"),
+ QStringLiteral("Flagged"),
+ QStringLiteral("Sent") }));
+}
+
+void TestConfig::aFilterAcrossAllAccountsIsTheUnscopedQuery()
+{
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeTwoAccounts(dir));
+
+ // An empty account key is "All accounts", which is what the dropdown holds
+ // by default.
+ const SavedQuery unread = config.builtinFilter(QStringLiteral("unread"));
+ QCOMPARE(config.resolvedQuery(unread, QString()),
+ QStringLiteral("tag:unread"));
+}
+
+void TestConfig::aTagFilterScopedToAnAccountCarriesThatAccountsPath()
+{
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeTwoAccounts(dir));
+
+ // A tag filter has no path of its own, so scoping it is exactly what
+ // Account::scopedQuery() does and nothing more is needed.
+ const SavedQuery unread = config.builtinFilter(QStringLiteral("unread"));
+ QCOMPARE(config.resolvedQuery(unread, QStringLiteral("work")),
+ QStringLiteral("path:\"work/**\" and (tag:unread)"));
+}
+
+void TestConfig::sentScopedToAnAccountIsThatAccountsSentFolderAlone()
+{
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeTwoAccounts(dir));
+
+ const SavedQuery sent = config.builtinFilter(QStringLiteral("sent"));
+ const QString scoped = config.resolvedQuery(sent, QStringLiteral("work"));
+
+ // The whole point of a per-account generator. Wrapping the all-accounts
+ // query instead would give
+ // path:"work/**" and (path:"work/Sent/**" or path:"personal/Sent/**")
+ // which returns the RIGHT ROWS, because path: is hierarchical and the
+ // personal half cannot match inside work. It is still wrong to build: it
+ // double-scopes and works by accident of the path syntax rather than by
+ // saying what is meant. A row-count assertion passes against it, which is
+ // why this asserts on the string.
+ QCOMPARE(scoped, QStringLiteral("path:\"work/Sent/**\""));
+ QVERIFY2(!scoped.contains(QStringLiteral("personal")),
+ "another account's sent folder leaked into a scoped Sent filter");
+ QCOMPARE(scoped.count(QStringLiteral("path:")), 1);
+}
+
+void TestConfig::sentScopedToAnAccountWithNoSentFolderMatchesNothing()
+{
+ QTemporaryDir dir;
+ Config config;
+ config.load(writeTwoAccounts(dir));
+
+ // `personal` configures no sent folder, so folderQuery() gives an empty
+ // string. Returned as-is that is "match everything" to notmuch, so Sent
+ // under this account would show the entire Maildir: the worst possible
+ // answer for a button labelled Sent.
+ const SavedQuery sent = config.builtinFilter(QStringLiteral("sent"));
+ const QString scoped =
+ config.resolvedQuery(sent, QStringLiteral("personal"));
+
+ QVERIFY2(!scoped.isEmpty(),
+ "an account with no sent folder resolved to an empty query, "
+ "which notmuch reads as 'match everything'");
+ QCOMPARE(scoped, Config::matchNothingQuery());
+}
+
+void TestConfig::aFilterKeepsItsViewMode()
+{
+ Config config;
+
+ // Sent lists MESSAGES, the other three list threads. Not a detail to
+ // unify: a thread would fold the user's sent message back into the
+ // conversation it belongs to, which is item 63's finding.
+ QVERIFY(config.builtinFilter(QStringLiteral("sent")).flat);
+ QVERIFY(!config.builtinFilter(QStringLiteral("unread")).flat);
+ QVERIFY(!config.builtinFilter(QStringLiteral("inbox")).flat);
+ QVERIFY(!config.builtinFilter(QStringLiteral("flagged")).flat);
+}
+
void TestConfig::draftsQueryIsEmptyWithoutTheKey()
{
// Optional for the same reason `sent` is, and more often absent: an