diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-02 17:21:47 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-02 17:21:47 +0200 |
| commit | b61ce428d81871fbbc6261f274f20785db9d1c38 (patch) | |
| tree | b1ebcf1da244a31ec1c991be42d0062d776ba1b4 | |
| parent | db1eb37b26f9ac14be4e1a4e5edbf5b6e77f8db7 (diff) | |
| download | qtmaildir-b61ce428d81871fbbc6261f274f20785db9d1c38.tar.gz qtmaildir-b61ce428d81871fbbc6261f274f20785db9d1c38.zip | |
feat: add Config with account, query, and sync parsing
Accounts use [account.work] rather than [account/work]: QSettings' INI
backend treats "/" as its own hierarchical group separator, so a literal
slash in a section header parses as a nested group and trips
QSettings::FormatError, silently breaking childGroups() enumeration. A
dot carries no such meaning and keeps the format flat.
Saved-query order is alphabetical (QSettings::childKeys() sorts), not
file order; documented in code and tests rather than left to a false
assumption.
| -rw-r--r-- | docs/superpowers/plans/2026-08-02-qtmaildir-v1.md | 12 | ||||
| -rw-r--r-- | docs/superpowers/specs/2026-08-02-qtmaildir-design.md | 4 | ||||
| -rw-r--r-- | src/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | src/config.cpp | 89 | ||||
| -rw-r--r-- | src/config.h | 62 | ||||
| -rw-r--r-- | tests/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | tests/test_config.cpp | 132 |
7 files changed, 293 insertions, 8 deletions
diff --git a/docs/superpowers/plans/2026-08-02-qtmaildir-v1.md b/docs/superpowers/plans/2026-08-02-qtmaildir-v1.md index 2defcc5..b264986 100644 --- a/docs/superpowers/plans/2026-08-02-qtmaildir-v1.md +++ b/docs/superpowers/plans/2026-08-02-qtmaildir-v1.md @@ -544,13 +544,13 @@ void TestConfig::parsesAccounts() { QTemporaryDir dir; const QString path = writeIni(dir, QStringLiteral( - "[account/work]\n" + "[account.work]\n" "name=Test User\n" "address=user@example.org\n" "maildir=work-mail\n" "drafts=Drafts\n" "\n" - "[account/personal]\n" + "[account.personal]\n" "name=Test User\n" "address=me@example.net\n" "maildir=personal\n" @@ -610,7 +610,7 @@ void TestConfig::accountWithoutMaildirIsRejected() { QTemporaryDir dir; const QString path = writeIni(dir, QStringLiteral( - "[account/broken]\n" + "[account.broken]\n" "name=No Maildir\n" "address=x@example.org\n" )); @@ -666,7 +666,7 @@ Expected: FAIL, `config.h: No such file or directory`. /// identity. struct Account { - QString key; ///< INI group suffix, e.g. "work" from [account/work]. + QString key; ///< INI group suffix, e.g. "work" from [account.work]. QString name; QString address; QString maildir; ///< Relative to notmuch's database.path. @@ -762,11 +762,11 @@ void Config::load(const QString &path) } for (const QString &group : settings.childGroups()) { - if (!group.startsWith(QStringLiteral("account/"))) + if (!group.startsWith(QStringLiteral("account."))) continue; Account account; - account.key = group.mid(QStringLiteral("account/").size()); + account.key = group.mid(QStringLiteral("account.").size()); settings.beginGroup(group); account.name = settings.value(QStringLiteral("name")).toString(); diff --git a/docs/superpowers/specs/2026-08-02-qtmaildir-design.md b/docs/superpowers/specs/2026-08-02-qtmaildir-design.md index b92c939..a2b4955 100644 --- a/docs/superpowers/specs/2026-08-02-qtmaildir-design.md +++ b/docs/superpowers/specs/2026-08-02-qtmaildir-design.md @@ -205,13 +205,13 @@ identity. [sync] command = /home/danix/bin/mailsync.sh -[account/privateemail-danix] +[account.privateemail-danix] name = Danilo M. address = danix@danix.xyz maildir = privateemail-danix ; relative to notmuch's database.path drafts = Drafts -[account/gmail] +[account.gmail] name = Danilo M. address = <the gmail address> maildir = gmail diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index aeb425f..d1ed29f 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,5 +1,6 @@ add_library(qtmaildir_lib STATIC keymap.cpp + config.cpp ) target_include_directories(qtmaildir_lib diff --git a/src/config.cpp b/src/config.cpp new file mode 100644 index 0000000..b12130d --- /dev/null +++ b/src/config.cpp @@ -0,0 +1,89 @@ +#include "config.h" + +#include <QFileInfo> +#include <QSettings> +#include <QStandardPaths> + +QString Account::scopedQuery(const QString &query) const +{ + const QString prefix = QStringLiteral("path:\"%1/**\"").arg(maildir); + if (query.trimmed().isEmpty()) + return prefix; + return QStringLiteral("%1 and (%2)").arg(prefix, query); +} + +QString Config::defaultPath() +{ + const QString base = + QStandardPaths::writableLocation(QStandardPaths::ConfigLocation); + return base + QStringLiteral("/qtmaildir/qtmaildir.conf"); +} + +void Config::load(const QString &path) +{ + QSettings settings(path, QSettings::IniFormat); + + m_notmuchConfig = + settings.value(QStringLiteral("general/notmuch_config")).toString(); + + m_syncCommand = settings.value(QStringLiteral("sync/command")).toString(); + if (m_syncCommand.isEmpty()) { + m_warnings.append(QStringLiteral( + "No sync command configured ([sync] command); syncing is disabled.")); + } else if (!QFileInfo::exists(m_syncCommand.split(QLatin1Char(' ')).first())) { + m_warnings.append( + QStringLiteral("Sync command '%1' does not exist; syncing is disabled.") + .arg(m_syncCommand)); + m_syncCommand.clear(); + } + + // Account groups are written as [account.work], [account.personal], etc. + // A dot, not a slash, separates the "account" namespace from the key: + // QSettings' INI backend treats "/" as its own hierarchical group + // separator, so a literal "[account/work]" section header would be + // parsed as a *nested* group "work" inside a group "account" (and trips + // a QSettings::FormatError besides), not as a single flat group named + // "account/work". "." carries no such meaning to QSettings, so + // childGroups() here returns "account.work" and "account.personal" as + // plain top-level entries and status() stays NoError. + for (const QString &group : settings.childGroups()) { + if (!group.startsWith(QStringLiteral("account."))) + continue; + + Account account; + account.key = group.mid(QStringLiteral("account.").size()); + + settings.beginGroup(group); + account.name = settings.value(QStringLiteral("name")).toString(); + account.address = settings.value(QStringLiteral("address")).toString(); + account.maildir = settings.value(QStringLiteral("maildir")).toString(); + account.drafts = settings.value(QStringLiteral("drafts")).toString(); + settings.endGroup(); + + if (!account.isValid()) { + m_warnings.append( + QStringLiteral("Account '%1' has no maildir; ignoring it.") + .arg(account.key)); + continue; + } + m_accounts.append(account); + } + + settings.beginGroup(QStringLiteral("queries")); + // QSettings::childKeys() returns keys in alphabetical order, not file + // order, so the saved-query button order in the UI is alphabetical too. + // A hand-rolled parser would be needed to preserve file order; not + // needed in v1. + for (const QString &name : settings.childKeys()) + m_savedQueries.append({ name, settings.value(name).toString() }); + settings.endGroup(); +} + +Account Config::account(const QString &key) const +{ + for (const Account &a : m_accounts) { + if (a.key == key) + return a; + } + return {}; +} diff --git a/src/config.h b/src/config.h new file mode 100644 index 0000000..f037db5 --- /dev/null +++ b/src/config.h @@ -0,0 +1,62 @@ +#pragma once + +#include <QList> +#include <QString> +#include <QStringList> + +/// One mail account. notmuch has no concept of accounts; it sees a single flat +/// tree. An account is therefore a path prefix within that tree plus an +/// identity. +struct Account +{ + QString key; ///< INI group suffix, e.g. "work" from [account/work]. + QString name; + QString address; + QString maildir; ///< Relative to notmuch's database.path. + QString drafts; ///< Unused in v1; send is v2. + + bool isValid() const { return !key.isEmpty() && !maildir.isEmpty(); } + + /// Restricts a notmuch query to this account's subtree. + QString scopedQuery(const QString &query) const; +}; + +struct SavedQuery +{ + QString name; + QString query; +}; + +/// Reads ~/.config/qtmaildir/qtmaildir.conf. +/// +/// The Maildir path is deliberately NOT configurable here: notmuch already +/// stores it as database.path and libnotmuch reads it. Duplicating it would +/// allow the GUI to index a different tree than the CLI. +class Config +{ +public: + /// Path used when load() is called with no argument. + static QString defaultPath(); + + void load(const QString &path); + + QList<Account> accounts() const { return m_accounts; } + Account account(const QString &key) const; + QList<SavedQuery> savedQueries() const { return m_savedQueries; } + + /// Empty when unset; the caller disables the Sync button in that case. + QString syncCommand() const { return m_syncCommand; } + + /// Optional alternate notmuch config file. Empty means "let notmuch decide". + QString notmuchConfig() const { return m_notmuchConfig; } + + /// Non-fatal problems, shown once in a startup banner. + QStringList warnings() const { return m_warnings; } + +private: + QList<Account> m_accounts; + QList<SavedQuery> m_savedQueries; + QString m_syncCommand; + QString m_notmuchConfig; + QStringList m_warnings; +}; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d4f0d3f..bec7621 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -6,3 +6,4 @@ function(add_qtmaildir_test name) endfunction() add_qtmaildir_test(keymap) +add_qtmaildir_test(config) diff --git a/tests/test_config.cpp b/tests/test_config.cpp new file mode 100644 index 0000000..0caa93c --- /dev/null +++ b/tests/test_config.cpp @@ -0,0 +1,132 @@ +#include <QtTest> +#include <QTemporaryDir> +#include <QSettings> +#include "config.h" + +class TestConfig : public QObject +{ + Q_OBJECT +private slots: + void parsesAccounts(); + void parsesSavedQueries(); + void missingSyncCommandIsEmpty(); + void accountWithoutMaildirIsRejected(); + void scopedQueryWrapsCorrectly(); +}; + +static QString writeIni(const QTemporaryDir &dir, const QString &body) +{ + const QString path = dir.filePath(QStringLiteral("qtmaildir.conf")); + QFile f(path); + f.open(QIODevice::WriteOnly | QIODevice::Text); + f.write(body.toUtf8()); + f.close(); + return path; +} + +void TestConfig::parsesAccounts() +{ + QTemporaryDir dir; + const QString path = writeIni(dir, QStringLiteral( + "[account.work]\n" + "name=Test User\n" + "address=user@example.org\n" + "maildir=work-mail\n" + "drafts=Drafts\n" + "\n" + "[account.personal]\n" + "name=Test User\n" + "address=me@example.net\n" + "maildir=personal\n" + )); + + Config config; + config.load(path); + + QCOMPARE(config.accounts().size(), 2); + + const Account work = config.account(QStringLiteral("work")); + QCOMPARE(work.key, QStringLiteral("work")); + QCOMPARE(work.name, QStringLiteral("Test User")); + QCOMPARE(work.address, QStringLiteral("user@example.org")); + QCOMPARE(work.maildir, QStringLiteral("work-mail")); + QCOMPARE(work.drafts, QStringLiteral("Drafts")); + + // drafts is optional in v1 (send is v2). + const Account personal = config.account(QStringLiteral("personal")); + QVERIFY(personal.drafts.isEmpty()); + QVERIFY(personal.isValid()); +} + +void TestConfig::parsesSavedQueries() +{ + QTemporaryDir dir; + const QString path = writeIni(dir, QStringLiteral( + "[queries]\n" + "Inbox=tag:inbox\n" + "Unread=tag:unread\n" + )); + + Config config; + config.load(path); + + const QList<SavedQuery> queries = config.savedQueries(); + QCOMPARE(queries.size(), 2); + // QSettings::childKeys() returns keys alphabetically, not in file order, + // so the UI button order is alphabetical. This assertion happens to hold + // either way since "Inbox" < "Unread", but the ordering guarantee is + // alphabetical, not "follows the file". + QCOMPARE(queries.at(0).name, QStringLiteral("Inbox")); + QCOMPARE(queries.at(0).query, QStringLiteral("tag:inbox")); +} + +void TestConfig::missingSyncCommandIsEmpty() +{ + QTemporaryDir dir; + const QString path = writeIni(dir, QStringLiteral("[general]\n")); + + Config config; + config.load(path); + + QVERIFY(config.syncCommand().isEmpty()); + // The UI uses this to disable the Sync button with a tooltip. + QVERIFY(!config.warnings().isEmpty()); +} + +void TestConfig::accountWithoutMaildirIsRejected() +{ + QTemporaryDir dir; + const QString path = writeIni(dir, QStringLiteral( + "[sync]\n" + "command=/bin/true\n" + "\n" + "[account.broken]\n" + "name=No Maildir\n" + "address=x@example.org\n" + )); + + Config config; + config.load(path); + + // Rejected, reported, and not offered to the user as a scope. + QCOMPARE(config.accounts().size(), 0); + QCOMPARE(config.warnings().size(), 1); + QVERIFY(config.warnings().first().contains(QStringLiteral("broken"))); +} + +void TestConfig::scopedQueryWrapsCorrectly() +{ + Account account; + account.key = QStringLiteral("work"); + account.maildir = QStringLiteral("work-mail"); + + QCOMPARE(account.scopedQuery(QStringLiteral("tag:inbox")), + QStringLiteral("path:\"work-mail/**\" and (tag:inbox)")); + + // An empty query still scopes to the account rather than matching nothing. + QCOMPARE(account.scopedQuery(QString()), + QStringLiteral("path:\"work-mail/**\"")); +} + +QTEST_MAIN(TestConfig) +#include "test_config.moc" |
