summaryrefslogtreecommitdiffstats
path: root/src/config.cpp
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-15 11:13:28 +0200
committerDanilo M. <danix@danix.xyz>2026-08-15 11:13:28 +0200
commit4757cc5780de1f7a7478cf07948f5659913d31b5 (patch)
tree80a0366d74cb98b5499abeb9209a689d8b834292 /src/config.cpp
parente6e18849bd5aa49dbdd299982d39a92e7360d0aa (diff)
parent8ea004e67427693139ddd55384c7963d856650f0 (diff)
downloadqtmaildir-4757cc5780de1f7a7478cf07948f5659913d31b5.tar.gz
qtmaildir-4757cc5780de1f7a7478cf07948f5659913d31b5.zip
Merge branch 'builtin-filters': four built-in query filters
Items 93, 95 and 90. The query row starts with Unread, Inbox, Flagged and Sent, shipped by the application rather than pinned by the user, and they compose with the account dropdown instead of resetting it. Item 90 is absorbed: the button that cleared the account selection is no longer a saved query at all. Item 95 was found by hand testing this and is not caused by it: no entry in the saved query overflow menu had ever been runnable, because Qt emits no triggered for an action that owns a submenu.
Diffstat (limited to 'src/config.cpp')
-rw-r--r--src/config.cpp157
1 files changed, 141 insertions, 16 deletions
diff --git a/src/config.cpp b/src/config.cpp
index ae26555..ece1fb9 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
@@ -465,22 +482,16 @@ void Config::loadSavedQueries(const QString &configPath, QSettings &settings)
}
settings.endGroup();
- // Sent was a hardcoded button beside the saved queries and becomes an
- // ordinary row here, so it can be reordered, renamed, unpinned or
- // removed like any other. It stays GENERATED, so it still follows the
- // accounts. Appended last, where the button already sat.
+ // Sent is NOT migrated into queries.json any more. It used to become an
+ // ordinary saved query here, so the hardcoded button could be
+ // reordered, renamed or removed; item 93 makes it one of four built-in
+ // filters instead, which are shipped rather than stored. Migrating it
+ // as well would put two Sent buttons on the row, one of them the user's
+ // to edit and one not.
//
- // Only when an account actually configures a sent folder: the button
- // was hidden entirely otherwise, and migrating a row that always finds
- // nothing would be worse than what it replaces.
- if (!allSentQuery().isEmpty()) {
- SavedQuery sent;
- sent.name = QStringLiteral("Sent");
- sent.generated = QStringLiteral("sent");
- sent.pinned = true;
- sent.flat = true;
- m_savedQueries.append(sent);
- }
+ // Nothing is lost: the built-in Sent resolves through the same
+ // generator, so it still follows the accounts, and it now composes with
+ // the account dropdown rather than resetting it.
// Order is alphabetical here because childKeys() is genuinely all the
// INI knows. The user reorders once and it sticks from then on.
@@ -568,6 +579,18 @@ void Config::loadSavedQueries(const QString &configPath, QSettings &settings)
continue;
}
+ // A stored entry naming a generator now duplicates a BUILT-IN filter of
+ // the same name, since item 93 ships all four rather than storing them.
+ // 0.19.0 migrated the hardcoded Sent button into exactly such an entry,
+ // so every existing install has one.
+ //
+ // Unpinned, never dropped: the row would otherwise carry two Sent
+ // buttons, one the user's to edit and one not. Deleting it would be
+ // data loss on a file whose readers are supposed to preserve what they
+ // do not own, and an unpin is reversible from the UI.
+ if (query.isGenerated() && isKnownGenerator(query.generated))
+ query.pinned = false;
+
for (auto it = object.begin(); it != object.end(); ++it) {
static const QStringList known = {
QStringLiteral("name"), QStringLiteral("query"),
@@ -652,6 +675,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())