diff options
| -rw-r--r-- | src/keymap.cpp | 6 | ||||
| -rw-r--r-- | src/mainwindow.cpp | 49 | ||||
| -rw-r--r-- | src/mainwindow.h | 7 | ||||
| -rw-r--r-- | tests/test_mainwindow.cpp | 55 |
4 files changed, 117 insertions, 0 deletions
diff --git a/src/keymap.cpp b/src/keymap.cpp index 098a85d..d62ba3e 100644 --- a/src/keymap.cpp +++ b/src/keymap.cpp @@ -33,6 +33,12 @@ QStringList KeyMap::knownActions() QStringLiteral("delete"), QStringLiteral("restore"), QStringLiteral("cleanup_stranded"), + // Item 103's spam sibling: it reports mail tagged `spam` that is not in + // any account's spam folder, and moves nothing. No default binding, + // matching cleanup_stranded: a view-replacing report is reached from a + // menu, and since item 132 an unbound action is menu-reachable rather + // than broken. + QStringLiteral("cleanup_stranded_spam"), // Item 118. No default binding, deliberately: this is the one action // that destroys mail with no undo, and a chord is how it would be run // by accident. Menu only, which item 132 made a legitimate choice. diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 9198839..476ff18 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1762,6 +1762,18 @@ void MainWindow::registerActions() [this]() { showStrandedDeletedMail(); }); + // The spam half of the same cleanup, beside its Delete sibling. Its + // mnemonic is on "&Check" rather than the brief's `s&pam`: Alt+P is + // already Re&ply and Alt+S is Mark &spam (and Find &stranded), and no + // letter of "Find stranded spam" is free in the Message menu. Menu only, + // like cleanup_stranded: it replaces the whole view like a filter does, + // and it moves nothing. + addAction(QStringLiteral("cleanup_stranded_spam"), + tr("&Check for stranded spam"), + tr("Show mail tagged spam that is not in a spam folder"), + [this]() { + showStrandedSpamMail(); + }); // The ONE irreversible action in this application, and the only one that // asks before it runs (item 118). CLAUDE.md rules out confirmation // dialogs for mutations because every mutation pushes its inverse onto @@ -2076,6 +2088,12 @@ const QHash<QString, QPair<QString, QString>> kThemeIcons = { // nothing, so an icon from the delete family would promise the one // thing it deliberately does not do. { QStringLiteral("cleanup_stranded"), { QStringLiteral("system-search"), QString() } }, + // The spam cleanup shares `system-search` for the same reason it shares + // the action's shape: both report what they find and move nothing. It is + // a Message-menu-only entry that always carries its text and never + // reaches the main toolbar, so it is named in noTwoActionsShareAnIcon()'s + // exception list, exactly as reply_no_quote and empty_spam are. + { QStringLiteral("cleanup_stranded_spam"), { QStringLiteral("system-search"), QString() } }, { QStringLiteral("empty_trash"), { QStringLiteral("edit-delete-shred"), QString() } }, // Item 185. Distinct from empty_trash's, because both reach the trash // bar and there the icon IS the control: two buttons that destroy @@ -2213,6 +2231,7 @@ void MainWindow::buildMenus() // It replaces the whole view like a filter does, so a sixth button beside // the five filters would read as one of them. messageMenu->addAction(m_actions.value(QStringLiteral("cleanup_stranded"))); + messageMenu->addAction(m_actions.value(QStringLiteral("cleanup_stranded_spam"))); messageMenu->addAction(m_actions.value(QStringLiteral("empty_trash"))); messageMenu->addAction(m_actions.value(QStringLiteral("empty_spam"))); messageMenu->addAction(m_actions.value(QStringLiteral("tag_rules"))); @@ -7102,6 +7121,36 @@ void MainWindow::showStrandedDeletedMail() "Select what should go and press Delete.")); } +void MainWindow::showStrandedSpamMail() +{ + // Not scoped to the selected account, deliberately, for the same reason + // showStrandedDeletedMail() is not: the stranded mail belongs to no view, + // and the user wants to see all of it at once. The account dropdown can + // still narrow it by hand afterwards. + const QString spam = m_config.allSpamQuery(); + + // No account configures a spam folder: everything tagged `spam` is by + // definition stranded, since there is nowhere for it to have gone. An + // empty exclusion must never be written as `not ()`, which notmuch parses + // without complaint and matches nothing, reporting a clean database. + const QString query = + spam.isEmpty() + ? QStringLiteral("tag:spam") + : QStringLiteral("tag:spam and not (%1)").arg(spam); + + // Into the bar, like a filter: what ran is visible and editable, and + // AlreadyScoped stops runQuery() wrapping it in the selected account's + // path, which would hide every other account's stranded mail. + m_queryEdit->setText(query); + runQuery(FlatResult::No, AccountScope::AlreadyScoped); + + // After runQuery(), which sets "Searching...": set before it, this would + // be overwritten and the user would be told nothing about what they are + // looking at. + m_statusLabel->setText(tr("Mail tagged spam but not in a spam folder. " + "Select what should go and press Mark spam.")); +} + void MainWindow::restoreSelected(bool fallbackToInbox) { const QModelIndexList rows = diff --git a/src/mainwindow.h b/src/mainwindow.h index ab65f1d..a7fbf26 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -1314,6 +1314,13 @@ private: /// without moving. void showStrandedDeletedMail(); + /// The spam half of the same cleanup: runs the query that finds mail + /// tagged `spam` whose file is not inside any account's spam folder. Like + /// its Delete sibling it REPORTS and moves nothing, and it is repeatable + /// for the same reason: mail reaches this state again whenever another + /// client tags without moving. + void showStrandedSpamMail(); + /// Asks the worker what is in the trash. The answer arrives at /// onThreadMessagesResolved() tagged `empty_trash` and goes to /// confirmAndPurge(): the count in the dialog has to be what will actually diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp index d581933..dfce9c3 100644 --- a/tests/test_mainwindow.cpp +++ b/tests/test_mainwindow.cpp @@ -531,6 +531,8 @@ private slots: void emptySpamMovesEachAccountsMailToItsOwnTrash(); void emptySpamRewritesTheOriginToTheSpamFolder(); void emptySpamRefusesAnUnconfiguredFolder(); + void theSpamCleanupQueryExcludesTheSpamFolder(); + void theSpamCleanupQueryWithoutASpamFolderIsJustTheTag(); // ComposeWindow, item 123. These need a window but no worker: the composer // never touches NotmuchWorker, it reads its context from the value struct @@ -8706,6 +8708,11 @@ void TestMainWindow::noTwoActionsShareAnIcon() // fails if it is ever put on the toolbar, so this is not a hiding // place. QStringLiteral("empty_spam"), + // Find stranded spam shares `cleanup_stranded`'s `system-search`, + // Task 7. Same property again: a Message-menu-only entry that always + // carries its text and never reaches the main toolbar, allowed for the + // same reason and caught here if it is ever put on the toolbar. + QStringLiteral("cleanup_stranded_spam"), }; const Config config; @@ -13675,6 +13682,54 @@ void TestMainWindow::theCleanupQueryExcludesMailAlreadyInTrash() QCOMPARE(notmuchCount(cfg, queryEdit->text()), 0); } +void TestMainWindow::theSpamCleanupQueryExcludesTheSpamFolder() +{ + // Task 7, mirroring theCleanupQueryExcludesMailAlreadyInTrash(). Properly + // spammed mail carries the tag AND sits in the spam folder, so without the + // exclusion this reports every message ever moved to spam. + WorkerBackedWindow backed; + QVERIFY2(backed.build(QStringLiteral("work"), QStringLiteral("work"), + QString(), QStringLiteral("Spam")), + qPrintable(backed.error())); + + MainWindow window(backed.config()); + auto *queryEdit = + window.findChild<QLineEdit *>(QStringLiteral("queryEdit")); + auto *cleanup = + window.findChild<QAction *>(QStringLiteral("cleanup_stranded_spam")); + QVERIFY(queryEdit); + QVERIFY2(cleanup, "there is no cleanup_stranded_spam action"); + + cleanup->trigger(); + + // The composed query, asserted whole: the exclusion has to wrap the + // account's own spam path, and the tag has to be there. + QCOMPARE(queryEdit->text(), + QStringLiteral("tag:spam and not (path:\"work/Spam/**\")")); +} + +void TestMainWindow::theSpamCleanupQueryWithoutASpamFolderIsJustTheTag() +{ + // The other branch of the same composition. An empty exclusion must never + // be written as `not ()`: notmuch parses that happily and matches nothing, + // so an account with no spam folder would report a clean database. + WorkerBackedWindow backed; + QVERIFY2(backed.build(QStringLiteral("acct"), QStringLiteral("acct")), + qPrintable(backed.error())); + + MainWindow window(backed.config()); + auto *queryEdit = + window.findChild<QLineEdit *>(QStringLiteral("queryEdit")); + auto *cleanup = + window.findChild<QAction *>(QStringLiteral("cleanup_stranded_spam")); + QVERIFY(queryEdit); + QVERIFY2(cleanup, "there is no cleanup_stranded_spam action"); + + cleanup->trigger(); + + QCOMPARE(queryEdit->text(), QStringLiteral("tag:spam")); +} + void TestMainWindow::aMoveThatRelocatesNothingWritesNoTag() { // The spec's ordering bullet, at the UI level: a failed rename must leave |
