diff options
| author | Danilo M. <danix@danix.xyz> | 2026-10-31 12:08:42 +0100 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-10-31 12:08:42 +0100 |
| commit | b1f6660b9f1471659ead81cf527d13d93dd57146 (patch) | |
| tree | 34ec1d186839c12a7288ad99c1f22f25b43b9d6f /src/mainwindow.cpp | |
| parent | 3474936404df92ac08534c010aed94f97e6fca15 (diff) | |
| download | qtmaildir-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/mainwindow.cpp')
| -rw-r--r-- | src/mainwindow.cpp | 141 |
1 files changed, 138 insertions, 3 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)); |
