diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | src/tagrules.cpp | 229 | ||||
| -rw-r--r-- | src/tagrules.h | 87 |
3 files changed, 317 insertions, 0 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 6f5101e..4df9adf 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -12,6 +12,7 @@ add_library(qtmaildir_lib STATIC tagchip.cpp tagcolors.cpp tagdialog.cpp + tagrules.cpp tagstrip.cpp threadlistmodel.cpp threadlistview.cpp diff --git a/src/tagrules.cpp b/src/tagrules.cpp new file mode 100644 index 0000000..39cc529 --- /dev/null +++ b/src/tagrules.cpp @@ -0,0 +1,229 @@ +/* + * 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 "tagrules.h" + +#include <QCoreApplication> +#include <QDir> +#include <QFile> +#include <QFileInfo> +#include <QJsonArray> +#include <QJsonDocument> +#include <QJsonParseError> +#include <QRegularExpression> +#include <QSaveFile> +#include <algorithm> + +namespace { + +constexpr int kFormatVersion = 1; +constexpr int kDefaultStage = 50; + +/// Fields this version understands. Anything else is preserved. +bool isKnownKey(const QString &key) +{ + static const QStringList known{ + QStringLiteral("id"), QStringLiteral("stage"), + QStringLiteral("enabled"), QStringLiteral("add"), + QStringLiteral("remove"), QStringLiteral("query"), + QStringLiteral("note"), + }; + return known.contains(key); +} + +QStringList stringsOf(const QJsonValue &value) +{ + QStringList out; + const QJsonArray array = value.toArray(); + for (const QJsonValue &entry : array) { + const QString text = entry.toString().trimmed(); + if (!text.isEmpty()) + out.append(text); + } + return out; +} + +} // namespace + +QString TagRules::defaultPath() +{ + // Not QStandardPaths::ConfigLocation: that appends the organization and + // application names, which would put the file under qtmaildir's own + // directory. mailctl reads this path too, so it must be tool-neutral. + QString base = qEnvironmentVariable("XDG_CONFIG_HOME"); + if (base.isEmpty()) + base = QDir::homePath() + QStringLiteral("/.config"); + return base + QStringLiteral("/mailrules/rules.json"); +} + +void TagRules::load(const QString &path) +{ + const QString target = path.isEmpty() ? defaultPath() : path; + + m_rules.clear(); + m_warnings.clear(); + m_unknown = QJsonObject(); + m_missing = false; + + QFile file(target); + if (!file.exists()) { + m_missing = true; + return; + } + + if (!file.open(QIODevice::ReadOnly)) { + m_warnings.append( + QObject::tr("Cannot read %1: %2").arg(target, file.errorString())); + return; + } + + QJsonParseError error{}; + const QJsonDocument document = + QJsonDocument::fromJson(file.readAll(), &error); + if (error.error != QJsonParseError::NoError || !document.isObject()) { + m_warnings.append(QObject::tr("Cannot read %1: %2") + .arg(target, error.errorString())); + return; + } + + const QJsonObject root = document.object(); + + const int version = root.value(QStringLiteral("version")) + .toInt(kFormatVersion); + if (version != kFormatVersion) { + m_warnings.append( + QObject::tr("%1 uses format version %2, newer than this version " + "understands (%3); refusing to guess") + .arg(target).arg(version).arg(kFormatVersion)); + return; + } + + for (auto it = root.begin(); it != root.end(); ++it) { + if (it.key() != QStringLiteral("version") + && it.key() != QStringLiteral("rules")) { + m_unknown.insert(it.key(), it.value()); + } + } + + // An id is a handle: a UI selects on it and a diff tracks it. + static const QRegularExpression idPattern( + QStringLiteral("^[a-z0-9][a-z0-9-]*$")); + + QStringList seen; + const QJsonArray array = root.value(QStringLiteral("rules")).toArray(); + for (int index = 0; index < array.size(); ++index) { + const QJsonObject object = array.at(index).toObject(); + const QString where = QObject::tr("rule #%1").arg(index + 1); + + TagRule rule; + rule.id = object.value(QStringLiteral("id")).toString(); + if (!idPattern.match(rule.id).hasMatch()) { + m_warnings.append( + QObject::tr("%1: id '%2' is missing or not lowercase letters, " + "digits and dashes; dropped") + .arg(where, rule.id)); + continue; + } + + if (seen.contains(rule.id)) { + m_warnings.append(QObject::tr("Rule '%1': duplicate id; keeping " + "the first").arg(rule.id)); + continue; + } + + rule.query = object.value(QStringLiteral("query")).toString().trimmed(); + if (rule.query.isEmpty()) { + m_warnings.append( + QObject::tr("Rule '%1': no query; dropped").arg(rule.id)); + continue; + } + + rule.add = stringsOf(object.value(QStringLiteral("add"))); + rule.remove = stringsOf(object.value(QStringLiteral("remove"))); + if (rule.add.isEmpty() && rule.remove.isEmpty()) { + m_warnings.append(QObject::tr("Rule '%1': adds and removes " + "nothing; dropped").arg(rule.id)); + continue; + } + + rule.stage = object.value(QStringLiteral("stage")).toInt(kDefaultStage); + rule.enabled = object.value(QStringLiteral("enabled")).toBool(true); + rule.note = object.value(QStringLiteral("note")).toString(); + + for (auto it = object.begin(); it != object.end(); ++it) { + if (!isKnownKey(it.key())) + rule.unknown.insert(it.key(), it.value()); + } + + seen.append(rule.id); + m_rules.append(rule); + } +} + +bool TagRules::save(const QString &path) const +{ + const QString target = path.isEmpty() ? defaultPath() : path; + + QDir().mkpath(QFileInfo(target).absolutePath()); + + QJsonArray array; + for (const TagRule &rule : m_rules) { + QJsonObject object; + object.insert(QStringLiteral("id"), rule.id); + object.insert(QStringLiteral("stage"), rule.stage); + object.insert(QStringLiteral("enabled"), rule.enabled); + object.insert(QStringLiteral("add"), + QJsonArray::fromStringList(rule.add)); + object.insert(QStringLiteral("remove"), + QJsonArray::fromStringList(rule.remove)); + object.insert(QStringLiteral("query"), rule.query); + object.insert(QStringLiteral("note"), rule.note); + for (auto it = rule.unknown.begin(); it != rule.unknown.end(); ++it) + object.insert(it.key(), it.value()); + array.append(object); + } + + QJsonObject root = m_unknown; + root.insert(QStringLiteral("version"), kFormatVersion); + root.insert(QStringLiteral("rules"), array); + + // QSaveFile writes a temporary and renames on commit, which is the same + // atomicity mailrules.py gets from os.replace. The hook must never read a + // half-written file. + QSaveFile file(target); + if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) + return false; + file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); + return file.commit(); +} + +QList<TagRule> TagRules::ordered() const +{ + QList<TagRule> out; + for (const TagRule &rule : m_rules) { + if (rule.enabled) + out.append(rule); + } + // stable_sort, not sort: ties must keep file order, which is the tie-break + // the format promises and what lets a user sequence rules within a stage. + std::stable_sort(out.begin(), out.end(), + [](const TagRule &a, const TagRule &b) { + return a.stage < b.stage; + }); + return out; +} diff --git a/src/tagrules.h b/src/tagrules.h new file mode 100644 index 0000000..8f75b38 --- /dev/null +++ b/src/tagrules.h @@ -0,0 +1,87 @@ +/* + * 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 <QJsonObject> +#include <QList> +#include <QString> +#include <QStringList> + +/// One auto-tagging rule, as stored in ~/.config/mailrules/rules.json. +/// +/// A rule carries NO scope. The notmuch post-new hook supplies `tag:new`, a +/// dry run supplies nothing and counts against the whole corpus. That split is +/// what lets one rule answer both "what would this tag on arrival" and "what +/// does this match in all my mail". +struct TagRule +{ + QString id; ///< Stable handle, [a-z0-9-]. Never the tag name: tags + ///< contain '/' and can be renamed. + QString query; ///< notmuch query, unscoped. + QString note; ///< Why the rule is shaped this way. Shown in the dialog. + QStringList add; + QStringList remove; + int stage = 50; ///< Ascending. Account tags 10, topic rules 50. + bool enabled = true; + + /// Fields this version of qtmaildir does not understand, kept verbatim and + /// written back on save. Without this, one save from here silently strips + /// whatever a newer mailctl wrote, and the shared format would belong to + /// whichever tool saved last. + QJsonObject unknown; +}; + +/// Reads and writes the shared rule store. +/// +/// Degrades rather than refusing, exactly as Config does: a malformed rule is +/// dropped with a warning and the rest still load, because one typo must not +/// cost every rule. qtmaildir must never fail to open because of this file. +class TagRules +{ +public: + /// $XDG_CONFIG_HOME/mailrules/rules.json, or ~/.config/... as fallback. + /// Deliberately not under qtmaildir's own config directory: mailctl reads + /// the same file and neither tool owns it. + static QString defaultPath(); + + /// Replaces the current contents. Never throws; see warnings(). + void load(const QString &path = QString()); + + /// Atomic: QSaveFile writes a temporary and renames, so the hook can never + /// read a partial file. Returns false if the write failed. + bool save(const QString &path = QString()) const; + + QList<TagRule> rules() const { return m_rules; } + void setRules(const QList<TagRule> &rules) { m_rules = rules; } + + /// Enabled rules in execution order: stage ascending, ties in file order. + QList<TagRule> ordered() const; + + QStringList warnings() const { return m_warnings; } + + /// No file yet, as distinct from a file that would not load. A fresh + /// install is not an error and must not be reported as one. + bool missing() const { return m_missing; } + +private: + QList<TagRule> m_rules; + QStringList m_warnings; + QJsonObject m_unknown; ///< Unrecognised top-level keys. + bool m_missing = false; +}; |
