aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-31 12:08:42 +0100
committerDanilo M. <danix@danix.xyz>2026-10-31 12:08:42 +0100
commitb1f6660b9f1471659ead81cf527d13d93dd57146 (patch)
tree34ec1d186839c12a7288ad99c1f22f25b43b9d6f /src
parent3474936404df92ac08534c010aed94f97e6fca15 (diff)
downloadqtmaildir-b1f6660b9f1471659ead81cf527d13d93dd57146.tar.gz
qtmaildir-b1f6660b9f1471659ead81cf527d13d93dd57146.zip
feat: apply a saved rule to existing mail, confirmed and undoable
The dialog's Apply reaches MainWindow, which counts the messages the rule would actually move, confirms with that count, then writes through the worker. The rule is written one tag at a time inside one undo macro, because the worker's effective set is per message: a single +a -b write would make the undo strip a tag from messages that already had it. Each undo entry is filled from its own confirmation, so it reverts exactly what moved. Refused while a sync holds the write lock, checked again after the confirmation closes. Item 212. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'src')
-rw-r--r--src/mainwindow.cpp141
-rw-r--r--src/mainwindow.h26
2 files changed, 163 insertions, 4 deletions
diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp
index 4377870..8b1c537 100644
--- a/src/mainwindow.cpp
+++ b/src/mainwindow.cpp
@@ -118,6 +118,21 @@ namespace {
/// Overridden only by setLocksPathForTesting(); "/proc/locks" in every real run.
QString g_locksPath = QStringLiteral("/proc/locks");
+/// A rule as single-tag operations: `+a`, `+b`, `-c`, each as (add, remove).
+/// Item 212: the worker's effective set is per MESSAGE, not per tag, so one
+/// `+promo -inbox` write reports a message that already had promo (it lost
+/// inbox) and its undo would strip promo from it. One operation per tag makes
+/// each effective set, and therefore each undo, exact.
+QList<QPair<QStringList, QStringList>> ruleOperations(const TagRule &rule)
+{
+ QList<QPair<QStringList, QStringList>> ops;
+ for (const QString &tag : rule.add)
+ ops.append({ { tag }, {} });
+ for (const QString &tag : rule.remove)
+ ops.append({ {}, { tag } });
+ return ops;
+}
+
} // namespace
/// Doc comment on the declaration. Separators and control characters are
@@ -2808,6 +2823,9 @@ void MainWindow::showTagRulesDialog(const TagRule &seed)
connect(dialog, &TagRulesDialog::previewRequested,
this, &MainWindow::onRulePreviewRequested);
+ connect(dialog, &TagRulesDialog::applyRequested,
+ this, &MainWindow::applyRuleToExistingMail);
+
connect(dialog, &TagRulesDialog::countsRequested, this, [this, dialog]() {
QMetaObject::invokeMethod(
m_worker, "requestMessageCounts", Qt::QueuedConnection,
@@ -2821,6 +2839,16 @@ void MainWindow::showTagRulesDialog(const TagRule &seed)
void MainWindow::onRuleCountsReady(const QVector<int> &counts,
quint64 generation)
{
+ // Item 212. Apply's count first: it shares this signal and the generation
+ // sequence, and needs no dialog, since the test and a closed dialog's
+ // last Apply both arrive here without one.
+ if (m_pendingApplyGeneration != 0
+ && generation == m_pendingApplyGeneration) {
+ m_pendingApplyGeneration = 0;
+ confirmRuleApply(counts);
+ return;
+ }
+
// Stale reply, or the dialog closed while the count was in flight. Both
// are ordinary rather than rare: counting every rule against a cold index
// takes seconds, which is long enough for the user to close the dialog or
@@ -2831,6 +2859,108 @@ void MainWindow::onRuleCountsReady(const QVector<int> &counts,
m_tagRulesDialog->setCounts(counts);
}
+void MainWindow::applyRuleToExistingMail(const TagRule &rule)
+{
+ if (!m_worker)
+ return;
+
+ // Refused rather than held: HeldEdit replays ids, and a backfill has none
+ // until the worker resolves the query. A deliberate deviation from the
+ // spec, recorded there.
+ if (aSyncHoldsTheWriteLock()) {
+ showTransientStatus(
+ tr("A sync is running; apply the rule again when it finishes."));
+ return;
+ }
+
+ // One count for the whole rule (what the confirmation names), then one per
+ // single-tag operation, in the order ruleOperations() lists them. The
+ // per-operation counts decide which writes are sent at all; see
+ // confirmRuleApply() for why the rule is split.
+ QStringList queries{ SearchTerm::changes(rule.query, rule.add, rule.remove) };
+ for (const auto &op : ruleOperations(rule))
+ queries.append(SearchTerm::changes(rule.query, op.first, op.second));
+
+ m_pendingApplyRule = rule;
+ m_pendingApplyGeneration = ++m_ruleCountGeneration;
+ QMetaObject::invokeMethod(
+ m_worker, "requestMessageCounts", Qt::QueuedConnection,
+ Q_ARG(QStringList, queries),
+ Q_ARG(quint64, m_pendingApplyGeneration));
+}
+
+void MainWindow::confirmRuleApply(const QVector<int> &counts)
+{
+ const TagRule rule = m_pendingApplyRule;
+ const int count = counts.value(0, 0);
+ if (count <= 0) {
+ showTransientStatus(
+ tr("Rule \"%1\" has nothing to change.").arg(rule.id));
+ return;
+ }
+
+ // The one confirmation for a reversible write, and why: the scope is the
+ // whole index, so the user cannot see what it will touch.
+ QStringList what;
+ if (!rule.add.isEmpty())
+ what.append(tr("Adds: %1.").arg(rule.add.join(QStringLiteral(", "))));
+ if (!rule.remove.isEmpty())
+ what.append(tr("Removes: %1.").arg(rule.remove.join(QStringLiteral(", "))));
+
+ QWidget *parent = m_tagRulesDialog ? static_cast<QWidget *>(m_tagRulesDialog)
+ : this;
+ QMessageBox box(parent);
+ box.setIcon(QMessageBox::Question);
+ box.setWindowTitle(tr("Apply rule"));
+ box.setText(tr("Apply rule \"%1\" to %n message(s)?", "", count).arg(rule.id));
+ box.setInformativeText(what.join(QStringLiteral(" "))
+ + QStringLiteral("\n")
+ + tr("You can undo this from the main window."));
+ box.setStandardButtons(QMessageBox::Yes | QMessageBox::Cancel);
+ box.setDefaultButton(QMessageBox::Cancel);
+ if (box.exec() != QMessageBox::Yes)
+ return;
+
+ // The box ran a nested event loop: a sync may have started meanwhile, and
+ // a read-write open would then block the worker behind its lock.
+ if (aSyncHoldsTheWriteLock()) {
+ showTransientStatus(
+ tr("A sync is running; apply the rule again when it finishes."));
+ return;
+ }
+
+ const QString description = tr("Apply rule \"%1\"").arg(rule.id);
+
+ // One write per single-tag operation, grouped so one Ctrl+Z reverts the
+ // whole rule (see ruleOperations() for why it is split). An operation
+ // whose count was 0 is NOT sent: applyTags() stays silent on an empty
+ // write, and an armed command that never hears back would steal the next
+ // write's confirmation. Commands are built with no ids; the worker
+ // resolves them and the confirmation fills each one (item 176).
+ const auto ops = ruleOperations(rule);
+ m_undoStack.beginMacro(description);
+ bool first = true;
+ for (int i = 0; i < ops.size(); ++i) {
+ if (counts.value(i + 1, 0) <= 0)
+ continue;
+ auto *command = new MessageTagCommand(this, {}, ops.at(i).first,
+ ops.at(i).second, description);
+ awaitTagConfirmation(command, first);
+ first = false;
+ QMetaObject::invokeMethod(
+ m_worker, "applyTagsToQuery", Qt::QueuedConnection,
+ Q_ARG(QString, rule.query), Q_ARG(QStringList, ops.at(i).first),
+ Q_ARG(QStringList, ops.at(i).second), Q_ARG(QString, description));
+ m_undoStack.push(command);
+ }
+ m_undoStack.endMacro();
+
+ // The rows a backfill touches are mostly not in the model, so the list is
+ // re-queried once the write has run rather than updated optimistically.
+ announceAction(tr("%1: %n message(s)", "", count).arg(description),
+ [this]() { refreshCurrentQuery(); });
+}
+
void MainWindow::showAbout()
{
QDialog dialog(this);
@@ -5877,11 +6007,14 @@ void MainWindow::onExternalSyncStateChanged(SyncMonitor::State state)
}
-void MainWindow::announceAction(const QString &text)
+void MainWindow::announceAction(const QString &text,
+ const std::function<void()> &onDone)
{
if (!aSyncHoldsTheWriteLock()) {
if (!m_worker) {
showTransientStatus(text);
+ if (onDone)
+ onDone();
return;
}
@@ -5899,8 +6032,10 @@ void MainWindow::announceAction(const QString &text)
m_statusTimer->stop();
m_statusLabel->setText(tr("%1, applying…").arg(text));
const quint64 ticket = ++m_announceTicket;
- QMetaObject::invokeMethod(m_worker, [this, text, ticket]() {
- QMetaObject::invokeMethod(this, [this, text, ticket]() {
+ QMetaObject::invokeMethod(m_worker, [this, text, ticket, onDone]() {
+ QMetaObject::invokeMethod(this, [this, text, ticket, onDone]() {
+ if (onDone)
+ onDone();
// A newer announcement, or an error, owns the label now.
if (ticket == m_announceTicket)
showTransientStatus(tr("%1, done").arg(text));
diff --git a/src/mainwindow.h b/src/mainwindow.h
index 45d9288..5c9e87b 100644
--- a/src/mainwindow.h
+++ b/src/mainwindow.h
@@ -160,6 +160,13 @@ public:
/// synthesising a click on a status-bar label.
Q_INVOKABLE void showPendingChanges();
+ /// Item 212. Counts what `rule` would change across the whole index,
+ /// confirms with that count, then writes through the worker with exact
+ /// undo. The rule has already been saved by the dialog. Refused while a
+ /// sync holds the write lock, since a backfill has no ids to hold until
+ /// the worker resolves its query.
+ void applyRuleToExistingMail(const TagRule &rule);
+
/// Whether the undo stack still holds anything. Exposed so a test can show
/// that a rejected write did not take unrelated history down with it.
bool canUndo() const { return m_undoStack.canUndo(); }
@@ -780,7 +787,13 @@ private slots:
/// describes state lasting until the sync ends, and a message that expired
/// would leave rows showing a tag the database has not got and no
/// explanation of why.
- void announceAction(const QString &text);
+ ///
+ /// `onDone`, when given, runs once the worker has reached the write: the
+ /// caller's follow-up (a refresh), not a label, so it runs whichever
+ /// announcement owns the label by then. Not run on the sync-held branch;
+ /// no caller passes one while a sync holds the lock.
+ void announceAction(const QString &text,
+ const std::function<void()> &onDone = {});
/// Reacts to a sync started outside this window, by cron or by hand.
///
@@ -845,6 +858,11 @@ private slots:
void onRuleCountsReady(const QVector<int> &counts, quint64 generation);
private:
+ /// The count for applyRuleToExistingMail() arrived: confirm, then write.
+ /// `counts` holds the whole rule's count first, then one per single-tag
+ /// operation in the order ruleOperations() lists them.
+ void confirmRuleApply(const QVector<int> &counts);
+
void buildUi();
/// Pushes the selected account's identity into the card delegate, so the
@@ -1825,6 +1843,12 @@ private:
/// counter.
quint64 m_ruleCountGeneration = 0;
+ /// The rule awaiting its change count, and the generation that count
+ /// will carry. Shares m_ruleCountGeneration's sequence, so a pending
+ /// Apply and the dialog's Count matches can never be confused.
+ TagRule m_pendingApplyRule;
+ quint64 m_pendingApplyGeneration = 0;
+
/// The generation of a REFRESH query, run after a sync to bring the list
/// up to date without disturbing it.
///