aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/config.cpp157
-rw-r--r--src/config.h46
-rw-r--r--src/mainwindow.cpp87
-rw-r--r--src/mainwindow.h26
4 files changed, 283 insertions, 33 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())
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/src/mainwindow.cpp b/src/mainwindow.cpp
index c6df95c..bf1a33b 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -1717,12 +1717,31 @@ void MainWindow::buildSavedQueryRow(QWidget *parent, QVBoxLayout *layout)
auto *box = new QHBoxLayout(row);
box->setContentsMargins(0, 0, 0, 0);
- // Sent is an ordinary row here, not a hardcoded button beside the others.
- // It is still GENERATED, so its query is composed from the accounts' `sent`
- // keys at click time and correcting a folder name stays a config edit and
- // nothing else; what changed is that the entry can now be reordered,
- // renamed, unpinned or removed like every other, instead of being the one
- // control on the row the user did not own.
+ // The built-in filters come first, in their own fixed order, and they are
+ // not saved queries: they are shipped, they are not in queries.json, and
+ // the user cannot edit or delete them (item 93). They are what the row is
+ // FOR; the pinned saved queries below them are the transitional half that
+ // item 94 removes.
+ for (const SavedQuery &filter : Config::builtinFilters()) {
+ // Sent with no account configuring a sent folder finds nothing by
+ // construction. Hidden rather than present and empty, which is what the
+ // hardcoded Sent button did and is worth keeping: a control that always
+ // returns nothing reads as broken rather than as absent.
+ if (m_config.resolvedQuery(filter, QString())
+ == Config::matchNothingQuery())
+ continue;
+
+ auto *button = new QPushButton(filter.name, row);
+ // A stable object name per filter, so a test finds the button without
+ // depending on the label, which is translated.
+ button->setObjectName(filter.generated + QStringLiteral("Button"));
+ connect(button, &QPushButton::clicked, this,
+ [this, filter]() { runFilter(filter); });
+ box->addWidget(button);
+ }
+
+ // The user's own saved queries. A pinned one is still a button, beside the
+ // filters, until item 94 makes the menu their only home.
QList<SavedQuery> unpinned;
for (const SavedQuery &saved : m_config.savedQueries()) {
// A generator whose accounts configure nothing produces a button that
@@ -1736,10 +1755,9 @@ void MainWindow::buildSavedQueryRow(QWidget *parent, QVBoxLayout *layout)
continue;
}
auto *button = new QPushButton(saved.name, row);
- // The generated entries keep a stable object name so a test can find
- // the sent button without depending on what the user renamed it to.
- if (saved.generated == QStringLiteral("sent"))
- button->setObjectName(QStringLiteral("sentButton"));
+ // No object name here any more. "sentButton" now belongs to the BUILT-IN
+ // Sent filter, and a migrated Sent entry claiming it too would give two
+ // buttons one name, so findChild() would return whichever came first.
connect(button, &QPushButton::clicked, this,
[this, saved]() { runSavedQuery(saved); });
addSavedQueryActions(button, saved);
@@ -1761,12 +1779,29 @@ void MainWindow::buildSavedQueryRow(QWidget *parent, QVBoxLayout *layout)
auto *menu = new QMenu(menuButton);
for (const SavedQuery &saved : unpinned) {
QAction *action = menu->addAction(saved.name);
- connect(action, &QAction::triggered, this,
- [this, saved]() { runSavedQuery(saved); });
+
// A menu entry has no context menu of its own, so its own submenu
- // carries the same three actions; an unpinned query would
- // otherwise be the one thing that cannot be edited or deleted.
+ // carries the same actions; an unpinned query would otherwise be
+ // the one thing that cannot be edited or deleted.
auto *entryMenu = new QMenu(menu);
+
+ // Running the query is an item INSIDE that submenu, and must be:
+ // Qt does not emit triggered for an action that owns a menu, so a
+ // connection on `action` itself never fires and clicking the entry
+ // only opens the submenu. That shipped, and went unnoticed while
+ // the menu was the rarely-used half and the user's queries were
+ // pinned buttons. Item 93 moved every query into the menu, and item
+ // 94 makes it their only home.
+ auto *run = new QAction(tr("Run"), entryMenu);
+ run->setObjectName(QStringLiteral("runQuery"));
+ connect(run, &QAction::triggered, this,
+ [this, saved]() { runSavedQuery(saved); });
+ entryMenu->addAction(run);
+
+ auto *runSeparator = new QAction(entryMenu);
+ runSeparator->setSeparator(true);
+ entryMenu->addAction(runSeparator);
+
addSavedQueryActions(entryMenu, saved);
action->setMenu(entryMenu);
}
@@ -1928,6 +1963,22 @@ void MainWindow::runSavedQuery(const SavedQuery &saved)
runQuery(saved.flat ? FlatResult::Yes : FlatResult::No);
}
+void MainWindow::runFilter(const SavedQuery &filter)
+{
+ // The account box is READ and never written. That is the whole difference
+ // from runSavedQuery(), and it is item 90's defect: a filter narrows what
+ // the user is already looking at, so the dropdown is its input rather than
+ // something it resets on the way past.
+ const QString accountKey = m_accountBox->currentData().toString();
+
+ // Resolved here, in the account's scope, and put in the bar so what ran is
+ // visible and editable. runQuery() is told not to scope it again.
+ m_queryEdit->setText(m_config.resolvedQuery(filter, accountKey));
+
+ runQuery(filter.flat ? FlatResult::Yes : FlatResult::No,
+ AccountScope::AlreadyScoped);
+}
+
void MainWindow::saveCurrentQuery()
{
const QString query = m_queryEdit->text().trimmed();
@@ -2007,7 +2058,7 @@ void MainWindow::rebuildSavedQueryRow()
}
}
-void MainWindow::runQuery(FlatResult flat)
+void MainWindow::runQuery(FlatResult flat, AccountScope scope)
{
// Set on EVERY run, not only when Yes. This is the line that stops flat
// mode leaking: any query that is not the Sent button restores the tree,
@@ -2017,8 +2068,12 @@ void MainWindow::runQuery(FlatResult flat)
QString query = m_queryEdit->text().trimmed();
+ // A built-in filter arrives already resolved in the selected account's
+ // scope, because a generator has to be asked for the account's own query
+ // rather than have its all-accounts query wrapped. Scoping again here would
+ // put path:"work/Sent/**" inside path:"work/**".
const QString accountKey = m_accountBox->currentData().toString();
- if (!accountKey.isEmpty())
+ if (scope == AccountScope::Apply && !accountKey.isEmpty())
query = m_config.account(accountKey).scopedQuery(query);
if (query.isEmpty())
diff --git a/src/mainwindow.h b/src/mainwindow.h
index 80c7d2b..bb1ba75 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -274,12 +274,23 @@ public:
enum class FlatResult { No, Yes };
Q_ENUM(FlatResult)
+ /// Whether runQuery() applies the selected account's scope to the bar text.
+ ///
+ /// Apply is right for anything the user typed or a saved query put there.
+ /// AlreadyScoped is for a built-in filter, whose text was resolved through
+ /// Config::resolvedQuery(query, accountKey) and already carries the scope:
+ /// scoping it a second time would wrap path:"work/Sent/**" in
+ /// path:"work/**", which is the double scope item 93 exists to avoid.
+ enum class AccountScope { Apply, AlreadyScoped };
+ Q_ENUM(AccountScope)
+
private:
/// The real query runner. Kept off the slot list deliberately: a slot with
/// a defaulted argument does not satisfy QObject::connect, which matches
/// signal and slot arity at compile time, so the zero-argument slot below
/// is what widgets connect to.
- void runQuery(FlatResult flat);
+ void runQuery(FlatResult flat,
+ AccountScope scope = AccountScope::Apply);
/// Builds the row of saved-query buttons, the overflow menu and Sent.
///
@@ -295,6 +306,19 @@ private:
/// twice. Setting the dropdown also shows the user what scope they are in.
void runSavedQuery(const SavedQuery &saved);
+ /// Runs a built-in filter in whatever account scope is currently selected.
+ ///
+ /// The opposite of runSavedQuery() in the one way that matters: it does NOT
+ /// touch the account box. A filter narrows what the user is already looking
+ /// at, so the dropdown is its input rather than something it overwrites,
+ /// which is item 90's defect and item 93's design.
+ ///
+ /// The query text is resolved here rather than left to runQuery()'s own
+ /// scoping, because a generator must be asked for the account's own query:
+ /// Sent wrapped in a scope double-scopes and works only by accident of
+ /// path: being hierarchical. See Config::resolvedQuery(query, accountKey).
+ void runFilter(const SavedQuery &filter);
+
/// Names the current query and stores it in queries.json.
void saveCurrentQuery();