diff options
| -rw-r--r-- | src/tagrules.h | 3 | ||||
| -rw-r--r-- | src/tagrulesdialog.cpp | 112 | ||||
| -rw-r--r-- | src/tagrulesdialog.h | 38 | ||||
| -rw-r--r-- | tests/test_tagrules.cpp | 100 |
4 files changed, 237 insertions, 16 deletions
diff --git a/src/tagrules.h b/src/tagrules.h index e2c8667..fadac2c 100644 --- a/src/tagrules.h +++ b/src/tagrules.h @@ -20,6 +20,7 @@ #include <QJsonObject> #include <QList> +#include <QMetaType> #include <QString> #include <QStringList> @@ -119,3 +120,5 @@ private: QJsonObject m_unknown; ///< Unrecognised top-level keys. bool m_missing = false; }; + +Q_DECLARE_METATYPE(TagRule) diff --git a/src/tagrulesdialog.cpp b/src/tagrulesdialog.cpp index e037e37..5f503ea 100644 --- a/src/tagrulesdialog.cpp +++ b/src/tagrulesdialog.cpp @@ -173,6 +173,13 @@ TagRulesDialog::TagRulesDialog(const TagRule &seed, QWidget *parent) form->addRow(QString(), m_enabled); form->addRow(tr("Add tags"), m_add); form->addRow(tr("Remove tags"), m_remove); + m_archive = new QCheckBox( + tr("Archive matching mail on arrival (removes inbox)"), this); + m_archive->setToolTip( + tr("Without this, the sync hook skips a rule that removes inbox, " + "since it runs unattended.")); + m_archive->setVisible(false); + form->addRow(QString(), m_archive); m_builder = new QWidget(this); auto *builderLayout = new QVBoxLayout(m_builder); builderLayout->setContentsMargins(0, 0, 0, 0); @@ -263,6 +270,13 @@ TagRulesDialog::TagRulesDialog(const TagRule &seed, QWidget *parent) tr("Run this rule's query in the main window, to see which mail it " "collects. This does not tag anything.")); buttons->addWidget(m_previewButton); + m_applyButton = new QPushButton(tr("&Apply to existing mail"), this); + m_applyButton->setToolTip( + tr("Save, then apply this rule to mail already in the index. Asks " + "first, naming how many messages would change. Undoable from the " + "main window.")); + m_applyButton->setEnabled(false); + buttons->addWidget(m_applyButton); buttons->addWidget(refreshButton); layout->addLayout(buttons); @@ -358,6 +372,13 @@ TagRulesDialog::TagRulesDialog(const TagRule &seed, QWidget *parent) this, &TagRulesDialog::applyEditsToCurrentRule); connect(m_enabled, &QCheckBox::toggled, this, &TagRulesDialog::applyEditsToCurrentRule); + connect(m_applyButton, &QPushButton::clicked, + this, &TagRulesDialog::onApply); + connect(m_remove, &QLineEdit::textChanged, this, [this](const QString &text) { + m_archive->setVisible(splitTags(text).contains(QStringLiteral("inbox"))); + }); + connect(m_archive, &QCheckBox::toggled, + this, &TagRulesDialog::applyEditsToCurrentRule); // QPlainTextEdit has no editingFinished: it is a multi-line editor, where // Return inserts a newline rather than committing. Without this the note @@ -545,18 +566,52 @@ void TagRulesDialog::setWarning(const QString &text) void TagRulesDialog::showWarnings() { + QStringList parts; const QStringList warnings = m_rules.warnings(); - if (warnings.isEmpty()) { - setWarning(QString()); - return; - } // Not "skipped" any more: a rule with a bad name is loaded repaired, and // saying it was skipped would send the user looking for something that is // in front of them. What is true of every warning here is that the file on // disk is not yet what the hook will run. - setWarning(tr("%n rule(s) in the file need attention: %1. Save to write " - "them back.", "", warnings.size()) - .arg(warnings.join(QStringLiteral("; ")))); + if (!warnings.isEmpty()) { + parts.append(tr("%n rule(s) in the file need attention: %1. Save to " + "write them back.", "", warnings.size()) + .arg(warnings.join(QStringLiteral("; ")))); + } + // One banner for both sources. The hook refusing a rule is the case + // CLAUDE.md asks the UI to say out loud rather than fail silently. + m_lastSkipWarning = hookSkipWarning(); + if (!m_lastSkipWarning.isEmpty()) + parts.append(m_lastSkipWarning); + setWarning(parts.join(QStringLiteral(" "))); +} + +QString TagRulesDialog::hookSkipWarning() const +{ + QStringList skipped; + for (const TagRule &rule : m_working) { + if (rule.enabled && TagRules::hookWouldSkip(rule)) + skipped.append(rule.id); + } + if (skipped.isEmpty()) + return {}; + return tr("The sync hook skips %n rule(s), which remove inbox or unread " + "on mail nobody has looked at: %1. Tick \"Archive matching " + "mail on arrival\" to let a rule remove inbox; unread is never " + "removed on arrival.", "", skipped.size()) + .arg(skipped.join(QStringLiteral(", "))); +} + +void TagRulesDialog::updateApplyButton() +{ + const int index = currentIndex(); + if (index < 0 || index >= m_working.size()) { + m_applyButton->setEnabled(false); + return; + } + const TagRule ¤t = m_working.at(index); + m_applyButton->setEnabled(current.enabled && !current.query.isEmpty() + && !(current.add.isEmpty() + && current.remove.isEmpty())); } void TagRulesDialog::fillItem(QTreeWidgetItem *item, const TagRule &rule) const @@ -644,6 +699,8 @@ void TagRulesDialog::onSelectionChanged() m_enabled->setChecked(rule.enabled); m_add->setText(rule.add.join(QStringLiteral(", "))); m_remove->setText(rule.remove.join(QStringLiteral(", "))); + m_archive->setChecked(rule.allowArchive); + m_archive->setVisible(rule.remove.contains(QStringLiteral("inbox"))); m_query->setText(rule.query); m_note->setPlainText(rule.note); @@ -666,6 +723,7 @@ void TagRulesDialog::onSelectionChanged() rebuildRows(m_loadedQuery); m_reloading = wasReloading; + updateApplyButton(); } void TagRulesDialog::applyEditsToCurrentRule() @@ -700,6 +758,10 @@ void TagRulesDialog::applyEditsToCurrentRule() rule.enabled = m_enabled->isChecked(); rule.add = splitTags(m_add->text()); rule.remove = splitTags(m_remove->text()); + // Cleared in the editor when inbox leaves the list, so a flag that does + // nothing never reaches the file (item 213). + rule.allowArchive = m_archive->isChecked() + && rule.remove.contains(QStringLiteral("inbox")); if (m_textMode->isChecked()) { rule.query = m_query->text().trimmed(); } else { @@ -713,6 +775,10 @@ void TagRulesDialog::applyEditsToCurrentRule() rule.note = m_note->toPlainText(); fillItem(m_list->topLevelItem(index), rule); + updateApplyButton(); + + if (hookSkipWarning() != m_lastSkipWarning) + showWarnings(); } void TagRulesDialog::onAddRule() @@ -808,7 +874,7 @@ void TagRulesDialog::setCounts(const QVector<int> &counts) } } -void TagRulesDialog::onSave() +bool TagRulesDialog::saveRules() { applyEditsToCurrentRule(); @@ -823,7 +889,7 @@ void TagRulesDialog::onSave() setWarning(tr("%n rule(s) cannot be saved as they are: %1", "", problems.size()) .arg(problems.join(QStringLiteral("; ")))); - return; + return false; } m_rules.setRules(m_working); @@ -831,9 +897,33 @@ void TagRulesDialog::onSave() QMessageBox::warning(this, tr("Tagging rules"), tr("Could not write %1.") .arg(TagRules::defaultPath())); - return; + return false; } - accept(); + return true; +} + +void TagRulesDialog::onSave() +{ + if (saveRules()) + accept(); +} + +void TagRulesDialog::onApply() +{ + // Saved first, decided with the user (item 212): the rule applied is the + // rule the hook runs, and a failed save stops here exactly as OK does. + // The dialog stays open; only OK closes it. + if (!saveRules()) + return; + const int index = currentIndex(); + if (index < 0 || index >= m_working.size()) + return; + emit applyRequested(m_working.at(index)); +} + +void TagRulesDialog::setArchiveForTest(bool on) +{ + m_archive->setChecked(on); // toggled -> applyEditsToCurrentRule } QString TagRulesDialog::queryLineForTest() const diff --git a/src/tagrulesdialog.h b/src/tagrulesdialog.h index 3bfb3d5..b43e79a 100644 --- a/src/tagrulesdialog.h +++ b/src/tagrulesdialog.h @@ -40,11 +40,10 @@ class QVBoxLayout; /// Views and edits the shared auto-tagging rules. /// -/// Does NOT apply rules to existing mail. The counts are shown so a rule can be -/// judged before it runs, but the only thing that tags mail is the notmuch -/// post-new hook. Backfill is deliberately out of this version: a rule that is -/// safe against arrivals is not automatically safe unscoped, and the -/// confirmation story for a bulk write does not exist yet. +/// Does not tag mail itself. The post-new hook applies rules to arriving mail; +/// applying one to existing mail (item 212) is asked of the owner through +/// applyRequested(), after the rule has been saved, and the owner confirms +/// and performs the write. class TagRulesDialog : public QDialog { Q_OBJECT @@ -95,6 +94,10 @@ public: void setRowValueForTest(int index, const QString &value); /// Runs the Save path without showing the dialog. void saveForTest() { onSave(); } + /// Runs the Apply path without a click. + void applyForTest() { onApply(); } + /// Ticks or clears the archive box, as a click would. + void setArchiveForTest(bool on); /// Types into the query field and toggles the mode, so the refusal path /// can be reached without a synthetic click. The refusal reports through @@ -198,6 +201,11 @@ signals: /// since `tag:new` is set only on mail that has just arrived. void previewRequested(const QString &query); + /// Asks the owner to apply one SAVED rule to existing mail (item 212). + /// Emitted only after the dialog has written rules.json, so the rule + /// applied is exactly the rule the hook will run. + void applyRequested(const TagRule &rule); + public slots: /// Corpus counts, positionally paired with countQueries(). void setCounts(const QVector<int> &counts); @@ -209,6 +217,7 @@ private slots: void onDeleteRule(); void applyEditsToCurrentRule(); void onSave(); + void onApply(); public slots: /// Saves the window's size and column widths on the way out. Overridden @@ -222,6 +231,17 @@ private: void saveUiState(); void showWarnings(); + /// Validates and writes the working rules. False, with the reason shown, + /// when nothing was written. Shared by Save and Apply so neither can + /// write what the other would refuse. + bool saveRules(); + + /// Names every enabled rule the post-new hook will skip, or empty. + QString hookSkipWarning() const; + + /// Enables Apply only for a rule worth applying. + void updateApplyButton(); + /// The only way the warning label is written. An empty string hides it. /// One route in so the icon and the red styling cannot drift between the /// load path, the save refusal and the text-mode notice. @@ -300,6 +320,14 @@ private: QCheckBox *m_enabled = nullptr; QLabel *m_warningLabel = nullptr; QPushButton *m_saveButton = nullptr; + /// Maps to TagRule::allowArchive; shown only while Remove tags holds inbox. + QCheckBox *m_archive = nullptr; + QPushButton *m_applyButton = nullptr; + + /// The skip sentence last shown, so an edit re-renders the banner only + /// when that sentence changed and a dismissed banner is not resurrected + /// by every keystroke. + QString m_lastSkipWarning; QRadioButton *m_matchAll = nullptr; QRadioButton *m_matchAny = nullptr; diff --git a/tests/test_tagrules.cpp b/tests/test_tagrules.cpp index 860d433..27496ce 100644 --- a/tests/test_tagrules.cpp +++ b/tests/test_tagrules.cpp @@ -35,6 +35,10 @@ class TestTagRules : public QObject Q_OBJECT private slots: + void applySavesFirstAndEmitsTheSavedRule(); + void applyEmitsNothingWhenTheSaveFails(); + void theArchiveBoxMapsToAllowArchive(); + void aRuleTheHookWillSkipIsNamedInTheWarning(); void allowArchiveRoundTripsAndIsOmittedWhenFalse(); void allowArchiveIsDroppedFromARuleThatKeepsInbox(); void hookWouldSkipMirrorsThePostNewRefusal(); @@ -1578,5 +1582,101 @@ void TestTagRules::hookWouldSkipMirrorsThePostNewRefusal() QVERIFY(!TagRules::hookWouldSkip(rule)); } +static QString writeDialogRules(const QTemporaryDir &home, const QByteArray &json) +{ + QDir().mkpath(home.filePath(QStringLiteral("mailrules"))); + const QString path = home.filePath(QStringLiteral("mailrules/rules.json")); + QFile out(path); + if (out.open(QIODevice::WriteOnly)) + out.write(json); + return path; +} + +void TestTagRules::applySavesFirstAndEmitsTheSavedRule() +{ + // Item 212, decided with the user: only what the hook would run is + // applied, so Apply writes the file before asking for anything. + QTemporaryDir home; + QVERIFY(home.isValid()); + qputenv("XDG_CONFIG_HOME", home.path().toUtf8()); + const QString path = writeDialogRules(home, R"({"version": 1, "rules": [ + {"id": "promo", "add": ["promo"], "query": "from:a@example.org"}]})"); + + TagRulesDialog dialog; + QSignalSpy spy(&dialog, &TagRulesDialog::applyRequested); + dialog.selectRuleForTest(0); + dialog.setTagsForTest(QStringLiteral("promo, sale")); // unsaved edit + dialog.applyForTest(); + + QCOMPARE(spy.size(), 1); + const TagRule emitted = spy.first().at(0).value<TagRule>(); + QCOMPARE(emitted.add, (QStringList{ QStringLiteral("promo"), + QStringLiteral("sale") })); + + TagRules onDisk; + onDisk.load(path); + QCOMPARE(onDisk.rules().at(0).add, emitted.add); +} + +void TestTagRules::applyEmitsNothingWhenTheSaveFails() +{ + QTemporaryDir home; + QVERIFY(home.isValid()); + qputenv("XDG_CONFIG_HOME", home.path().toUtf8()); + writeDialogRules(home, R"({"version": 1, "rules": [ + {"id": "promo", "add": ["promo"], "query": "from:a@example.org"}]})"); + + TagRulesDialog dialog; + QSignalSpy spy(&dialog, &TagRulesDialog::applyRequested); + dialog.selectRuleForTest(0); + dialog.setTagsForTest(QString()); // adds and removes nothing + dialog.applyForTest(); + + QVERIFY(spy.isEmpty()); + QVERIFY(!dialog.warningTextForTest().isEmpty()); +} + +void TestTagRules::theArchiveBoxMapsToAllowArchive() +{ + QTemporaryDir home; + QVERIFY(home.isValid()); + qputenv("XDG_CONFIG_HOME", home.path().toUtf8()); + const QString path = writeDialogRules(home, R"({"version": 1, "rules": [ + {"id": "promo", "add": ["promo"], "remove": ["inbox"], + "query": "from:a@example.org"}]})"); + + { + TagRulesDialog dialog; + dialog.selectRuleForTest(0); + dialog.setArchiveForTest(true); + dialog.saveForTest(); + } + TagRules onDisk; + onDisk.load(path); + QVERIFY(onDisk.rules().at(0).allowArchive); +} + +void TestTagRules::aRuleTheHookWillSkipIsNamedInTheWarning() +{ + // CLAUDE.md: a rule the hook will refuse must be flagged in the UI. + QTemporaryDir home; + QVERIFY(home.isValid()); + qputenv("XDG_CONFIG_HOME", home.path().toUtf8()); + writeDialogRules(home, R"({"version": 1, "rules": [ + {"id": "promo", "add": ["promo"], "remove": ["inbox"], + "query": "from:a@example.org"}, + {"id": "fine", "add": ["x"], "query": "from:b@example.org"}]})"); + + TagRulesDialog dialog; + QVERIFY2(dialog.warningTextForTest().contains(QStringLiteral("promo")), + qPrintable(dialog.warningTextForTest())); + QVERIFY(!dialog.warningTextForTest().contains(QStringLiteral("fine"))); + + dialog.selectRuleForTest(0); + dialog.setArchiveForTest(true); + QVERIFY2(!dialog.warningTextForTest().contains(QStringLiteral("promo")), + qPrintable(dialog.warningTextForTest())); +} + QTEST_MAIN(TestTagRules) #include "test_tagrules.moc" |
