aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-12 11:53:46 +0200
committerDanilo M. <danix@danix.xyz>2026-08-12 11:53:46 +0200
commit0afd62360ffa7f28bd24b7a37ddbe53456f94aa9 (patch)
tree2776c369fd4ae12ec6f7e593ae6667a61617bdba /src
parent01ecb8c4e3bbf8176ca9d4085734ed2ec0774c80 (diff)
downloadqtmaildir-0afd62360ffa7f28bd24b7a37ddbe53456f94aa9.tar.gz
qtmaildir-0afd62360ffa7f28bd24b7a37ddbe53456f94aa9.zip
feat(rules): a dialog to view and edit the tagging rules
Edits land on a working copy and reach the file only on Save. The dialog never opens a notmuch database of its own: it publishes the queries it wants counted and MainWindow runs them through the worker, because the worker owns the only handle. Two departures from the drafted version, both of which lost edits. QPlainTextEdit has no editingFinished, so the note reached the working copy only for whichever row was current at Save; it is driven from textChanged instead, with the selection handler blocking the signal so loading a rule cannot write itself back over the one now current. And reloadList()'s setCurrentItem emits currentItemChanged, so New and Copy repopulated the form from m_working before the pending edit had been flushed into it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'src')
-rw-r--r--src/CMakeLists.txt1
-rw-r--r--src/tagrulesdialog.cpp341
-rw-r--r--src/tagrulesdialog.h98
3 files changed, 440 insertions, 0 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 4df9adf..926f6a9 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -13,6 +13,7 @@ add_library(qtmaildir_lib STATIC
tagcolors.cpp
tagdialog.cpp
tagrules.cpp
+ tagrulesdialog.cpp
tagstrip.cpp
threadlistmodel.cpp
threadlistview.cpp
diff --git a/src/tagrulesdialog.cpp b/src/tagrulesdialog.cpp
new file mode 100644
index 0000000..40ee66a
--- /dev/null
+++ b/src/tagrulesdialog.cpp
@@ -0,0 +1,341 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#include "tagrulesdialog.h"
+
+#include <QCheckBox>
+#include <QDialogButtonBox>
+#include <QFormLayout>
+#include <QHBoxLayout>
+#include <QHeaderView>
+#include <QLabel>
+#include <QLineEdit>
+#include <QMessageBox>
+#include <QPlainTextEdit>
+#include <QPushButton>
+#include <QSpinBox>
+#include <QTreeWidget>
+#include <QVBoxLayout>
+
+namespace {
+
+/// Columns of the rule list.
+enum Column { ColumnEnabled = 0, ColumnStage, ColumnId, ColumnTags,
+ ColumnCount };
+
+QStringList splitTags(const QString &text)
+{
+ QStringList out;
+ const QStringList parts = text.split(QLatin1Char(','));
+ for (const QString &part : parts) {
+ const QString trimmed = part.trimmed();
+ if (!trimmed.isEmpty())
+ out.append(trimmed);
+ }
+ return out;
+}
+
+} // namespace
+
+TagRulesDialog::TagRulesDialog(QWidget *parent)
+ : QDialog(parent)
+{
+ setWindowTitle(tr("Tagging rules"));
+ resize(760, 520);
+
+ m_rules.load();
+ m_working = m_rules.rules();
+
+ auto *layout = new QVBoxLayout(this);
+
+ auto *intro = new QLabel(
+ tr("Rules tag mail as it arrives, applied by the notmuch post-new "
+ "hook. Editing them here changes what the next sync does; it does "
+ "not retag mail you already have."),
+ this);
+ intro->setWordWrap(true);
+ layout->addWidget(intro);
+
+ m_warningLabel = new QLabel(this);
+ m_warningLabel->setWordWrap(true);
+ m_warningLabel->setVisible(false);
+ layout->addWidget(m_warningLabel);
+
+ m_list = new QTreeWidget(this);
+ m_list->setColumnCount(ColumnCount + 1);
+ m_list->setHeaderLabels({ tr("On"), tr("Stage"), tr("Rule"), tr("Tags"),
+ tr("Matches") });
+ m_list->setRootIsDecorated(false);
+ m_list->setUniformRowHeights(true);
+ layout->addWidget(m_list, 1);
+
+ auto *form = new QFormLayout;
+ m_id = new QLineEdit(this);
+ m_stage = new QSpinBox(this);
+ m_stage->setRange(0, 999);
+ m_enabled = new QCheckBox(tr("Applied on every sync"), this);
+ m_add = new QLineEdit(this);
+ m_add->setPlaceholderText(tr("comma separated"));
+ m_remove = new QLineEdit(this);
+ m_remove->setPlaceholderText(tr("comma separated"));
+ m_query = new QLineEdit(this);
+ // Query syntax is wire format, never translated. Only the prose is.
+ m_query->setPlaceholderText(QStringLiteral("from:sender@example.com"));
+ m_note = new QPlainTextEdit(this);
+ m_note->setMaximumHeight(70);
+
+ form->addRow(tr("Name"), m_id);
+ form->addRow(tr("Stage"), m_stage);
+ form->addRow(QString(), m_enabled);
+ form->addRow(tr("Add tags"), m_add);
+ form->addRow(tr("Remove tags"), m_remove);
+ form->addRow(tr("Query"), m_query);
+ form->addRow(tr("Note"), m_note);
+ layout->addLayout(form);
+
+ auto *buttons = new QHBoxLayout;
+ auto *addButton = new QPushButton(tr("&New"), this);
+ auto *copyButton = new QPushButton(tr("&Copy"), this);
+ auto *deleteButton = new QPushButton(tr("&Delete"), this);
+ auto *refreshButton = new QPushButton(tr("Count &matches"), this);
+ refreshButton->setToolTip(
+ tr("Count the messages each rule's query matches across all mail. "
+ "This does not tag anything."));
+ buttons->addWidget(addButton);
+ buttons->addWidget(copyButton);
+ buttons->addWidget(deleteButton);
+ buttons->addStretch();
+ buttons->addWidget(refreshButton);
+ layout->addLayout(buttons);
+
+ auto *box = new QDialogButtonBox(QDialogButtonBox::Save
+ | QDialogButtonBox::Cancel,
+ this);
+ m_saveButton = box->button(QDialogButtonBox::Save);
+ layout->addWidget(box);
+
+ connect(m_list, &QTreeWidget::currentItemChanged,
+ this, &TagRulesDialog::onSelectionChanged);
+ connect(addButton, &QPushButton::clicked,
+ this, &TagRulesDialog::onAddRule);
+ connect(copyButton, &QPushButton::clicked,
+ this, &TagRulesDialog::onCopyRule);
+ connect(deleteButton, &QPushButton::clicked,
+ this, &TagRulesDialog::onDeleteRule);
+ connect(refreshButton, &QPushButton::clicked,
+ this, &TagRulesDialog::countsRequested);
+ connect(box, &QDialogButtonBox::accepted,
+ this, &TagRulesDialog::onSave);
+ connect(box, &QDialogButtonBox::rejected, this, &QDialog::reject);
+
+ // Edits land on the working copy as they are made, so switching rows does
+ // not silently discard what was typed.
+ connect(m_id, &QLineEdit::editingFinished,
+ this, &TagRulesDialog::applyEditsToCurrentRule);
+ connect(m_add, &QLineEdit::editingFinished,
+ this, &TagRulesDialog::applyEditsToCurrentRule);
+ connect(m_remove, &QLineEdit::editingFinished,
+ this, &TagRulesDialog::applyEditsToCurrentRule);
+ connect(m_query, &QLineEdit::editingFinished,
+ this, &TagRulesDialog::applyEditsToCurrentRule);
+ connect(m_stage, &QSpinBox::editingFinished,
+ this, &TagRulesDialog::applyEditsToCurrentRule);
+ connect(m_enabled, &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
+ // would reach the working copy only for whichever row happened to be
+ // current when Save was pressed, so a note typed on one rule and then
+ // followed by a click on another would be silently discarded.
+ connect(m_note, &QPlainTextEdit::textChanged,
+ this, &TagRulesDialog::applyEditsToCurrentRule);
+
+ reloadList();
+ showWarnings();
+}
+
+void TagRulesDialog::showWarnings()
+{
+ const QStringList warnings = m_rules.warnings();
+ if (warnings.isEmpty()) {
+ m_warningLabel->setVisible(false);
+ return;
+ }
+ m_warningLabel->setText(
+ tr("%n rule(s) could not be read and were skipped: %1", "",
+ warnings.size()).arg(warnings.join(QStringLiteral("; "))));
+ m_warningLabel->setVisible(true);
+}
+
+void TagRulesDialog::fillItem(QTreeWidgetItem *item, const TagRule &rule) const
+{
+ item->setCheckState(ColumnEnabled,
+ rule.enabled ? Qt::Checked : Qt::Unchecked);
+ item->setText(ColumnStage, QString::number(rule.stage));
+ item->setText(ColumnId, rule.id);
+
+ QStringList tags;
+ for (const QString &tag : rule.add)
+ tags.append(QStringLiteral("+") + tag);
+ for (const QString &tag : rule.remove)
+ tags.append(QStringLiteral("-") + tag);
+ item->setText(ColumnTags, tags.join(QStringLiteral(" ")));
+}
+
+void TagRulesDialog::reloadList()
+{
+ // Guards the setCurrentItem below. Selecting a row emits
+ // currentItemChanged, and onSelectionChanged() would load that rule into
+ // the form; the counts column is also rebuilt from nothing here, so any
+ // count already shown is dropped rather than left against the wrong row.
+ m_reloading = true;
+ m_list->clear();
+ for (const TagRule &rule : m_working) {
+ auto *item = new QTreeWidgetItem(m_list);
+ fillItem(item, rule);
+ }
+ m_list->resizeColumnToContents(ColumnEnabled);
+ m_list->resizeColumnToContents(ColumnStage);
+ m_reloading = false;
+
+ if (!m_working.isEmpty())
+ m_list->setCurrentItem(m_list->topLevelItem(0));
+}
+
+int TagRulesDialog::currentIndex() const
+{
+ return m_list->currentItem()
+ ? m_list->indexOfTopLevelItem(m_list->currentItem())
+ : -1;
+}
+
+void TagRulesDialog::onSelectionChanged()
+{
+ const int index = currentIndex();
+ if (index < 0 || index >= m_working.size())
+ return;
+ const TagRule &rule = m_working.at(index);
+
+ // The note's textChanged fires from setPlainText below, which would then
+ // write the rule just loaded back over the rule now current. Harmless when
+ // they are the same rule, destructive when the selection is what changed.
+ const QSignalBlocker blockNote(m_note);
+
+ m_id->setText(rule.id);
+ m_stage->setValue(rule.stage);
+ m_enabled->setChecked(rule.enabled);
+ m_add->setText(rule.add.join(QStringLiteral(", ")));
+ m_remove->setText(rule.remove.join(QStringLiteral(", ")));
+ m_query->setText(rule.query);
+ m_note->setPlainText(rule.note);
+}
+
+void TagRulesDialog::applyEditsToCurrentRule()
+{
+ if (m_reloading)
+ return;
+
+ const int index = currentIndex();
+ if (index < 0 || index >= m_working.size())
+ return;
+
+ TagRule &rule = m_working[index];
+ rule.id = m_id->text().trimmed();
+ rule.stage = m_stage->value();
+ rule.enabled = m_enabled->isChecked();
+ rule.add = splitTags(m_add->text());
+ rule.remove = splitTags(m_remove->text());
+ rule.query = m_query->text().trimmed();
+ rule.note = m_note->toPlainText();
+
+ fillItem(m_list->topLevelItem(index), rule);
+}
+
+void TagRulesDialog::onAddRule()
+{
+ // Flushed first: reloadList() rebuilds every row from m_working, so an
+ // edit still sitting in the form would be painted away by its own reload.
+ applyEditsToCurrentRule();
+
+ TagRule rule;
+ rule.id = QStringLiteral("new-rule");
+ rule.query = QStringLiteral("from:sender@example.com");
+ rule.add = { QStringLiteral("tag") };
+ m_working.append(rule);
+ reloadList();
+ m_list->setCurrentItem(m_list->topLevelItem(m_working.size() - 1));
+}
+
+void TagRulesDialog::onCopyRule()
+{
+ applyEditsToCurrentRule();
+
+ const int index = currentIndex();
+ if (index < 0 || index >= m_working.size())
+ return;
+ TagRule copy = m_working.at(index);
+ copy.id += QStringLiteral("-copy");
+ m_working.insert(index + 1, copy);
+ reloadList();
+ m_list->setCurrentItem(m_list->topLevelItem(index + 1));
+}
+
+void TagRulesDialog::onDeleteRule()
+{
+ const int index = currentIndex();
+ if (index < 0 || index >= m_working.size())
+ return;
+ m_working.removeAt(index);
+ reloadList();
+}
+
+QStringList TagRulesDialog::countQueries() const
+{
+ QStringList queries;
+ for (const TagRule &rule : m_working)
+ queries.append(rule.query);
+ return queries;
+}
+
+void TagRulesDialog::setCounts(const QVector<int> &counts)
+{
+ for (int i = 0; i < counts.size() && i < m_list->topLevelItemCount(); ++i) {
+ // -1 is the worker's "this query could not be run", which for a rule
+ // means the query is malformed. Saying so beats showing a number.
+ m_list->topLevelItem(i)->setText(
+ ColumnCount,
+ counts.at(i) < 0 ? tr("invalid")
+ : QString::number(counts.at(i)));
+ }
+ m_list->resizeColumnToContents(ColumnCount);
+}
+
+void TagRulesDialog::onSave()
+{
+ applyEditsToCurrentRule();
+
+ m_rules.setRules(m_working);
+ if (!m_rules.save()) {
+ QMessageBox::warning(this, tr("Tagging rules"),
+ tr("Could not write %1.")
+ .arg(TagRules::defaultPath()));
+ return;
+ }
+ accept();
+}
diff --git a/src/tagrulesdialog.h b/src/tagrulesdialog.h
new file mode 100644
index 0000000..0350bd5
--- /dev/null
+++ b/src/tagrulesdialog.h
@@ -0,0 +1,98 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#pragma once
+
+#include <QDialog>
+
+#include "tagrules.h"
+
+class QCheckBox;
+class QLabel;
+class QLineEdit;
+class QPlainTextEdit;
+class QPushButton;
+class QSpinBox;
+class QTreeWidget;
+class QTreeWidgetItem;
+
+/// 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.
+class TagRulesDialog : public QDialog
+{
+ Q_OBJECT
+
+public:
+ explicit TagRulesDialog(QWidget *parent = nullptr);
+
+ /// The queries whose message counts the dialog wants, in the order its
+ /// rows appear. MainWindow hands these to the worker; the dialog never
+ /// touches the database itself, because NotmuchWorker owns the only
+ /// handle and notmuch permits one per process.
+ QStringList countQueries() const;
+
+signals:
+ /// Asks the owner to run countQueries() through the worker.
+ void countsRequested();
+
+public slots:
+ /// Corpus counts, positionally paired with countQueries().
+ void setCounts(const QVector<int> &counts);
+
+private slots:
+ void onSelectionChanged();
+ void onAddRule();
+ void onCopyRule();
+ void onDeleteRule();
+ void applyEditsToCurrentRule();
+ void onSave();
+
+private:
+ void reloadList();
+ void showWarnings();
+ int currentIndex() const;
+
+ /// Writes one rule's summary onto its row. Shared by reloadList() and
+ /// applyEditsToCurrentRule() so the two cannot render a rule differently.
+ void fillItem(QTreeWidgetItem *item, const TagRule &rule) const;
+
+ TagRules m_rules;
+ QList<TagRule> m_working; ///< Edited copy; written only on Save.
+
+ /// True while reloadList() is repopulating the tree. The row it selects
+ /// afterwards emits currentItemChanged, and onSelectionChanged() would
+ /// then load a rule into the form while the form still holds the previous
+ /// row's text, which applyEditsToCurrentRule() has no chance to flush.
+ bool m_reloading = false;
+
+ QTreeWidget *m_list = nullptr;
+ QLineEdit *m_id = nullptr;
+ QLineEdit *m_add = nullptr;
+ QLineEdit *m_remove = nullptr;
+ QLineEdit *m_query = nullptr;
+ QPlainTextEdit *m_note = nullptr;
+ QSpinBox *m_stage = nullptr;
+ QCheckBox *m_enabled = nullptr;
+ QLabel *m_warningLabel = nullptr;
+ QPushButton *m_saveButton = nullptr;
+};