aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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;
+};