aboutsummaryrefslogtreecommitdiffstats
path: root/src/config.cpp
blob: 1a8f3225282b4962a1cda2416f5eeb3d05423fdb (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
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 "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::addProblem(const QString &message)
{
    m_warnings.append(message);
    m_problems.append(message);
}

void Config::addNotice(const QString &message)
{
    m_warnings.append(message);
}

void Config::load(const QString &path)
{
    QSettings settings(path, QSettings::IniFormat);

    // Keys of [general] are read WITHOUT the "general/" prefix. QSettings'
    // INI backend treats a section literally named [general] as its own
    // fallback section and strips the prefix, so "general/notmuch_config"
    // never matches anything, in any section arrangement (verified on
    // Qt 6.11). The file still reads as [general] to the user; only the
    // lookup differs. Same family of trap as the [account.work] dot and the
    // childKeys() ordering already documented in CLAUDE.md.
    m_notmuchConfig =
        settings.value(QStringLiteral("notmuch_config")).toString();

    // Absent is fine and silent: the default is 1.0. Present but unparseable
    // is a problem, since the user asked for something and is not getting it.
    // The range check lives in MessageView::clampZoom(), the one place that
    // knows what the web view can render.
    // Empty is treated as unset rather than as "a query named nothing".
    const QString startup =
        settings.value(QStringLiteral("startup_query")).toString().trimmed();
    if (!startup.isEmpty()) {
        m_startupQuery = startup;
        m_startupQueryWasSet = true;
    }

    const QVariant zoom = settings.value(QStringLiteral("message_zoom"));
    if (zoom.isValid()) {
        bool ok = false;
        const double value = zoom.toString().toDouble(&ok);
        if (ok) {
            m_messageZoom = value;
        } else {
            addProblem(QStringLiteral("Message zoom '%1' is not a number; "
                                      "using the default.")
                           .arg(zoom.toString()));
        }
    }

    // A [general] key, so no prefix, per the note at the top of load().
    m_completionOnFocus =
        settings.value(QStringLiteral("completion_on_focus"), false).toBool();

    // [completion] is an ordinary section, so this one DOES take its prefix.
    // ',' separates entries and '|' separates a value from its description:
    // two different characters because QSettings splits comma lists itself,
    // so a description holding a comma would otherwise become two entries.
    // Neither character is legal in a mimetype.
    const QStringList rawMimetypes =
        settings.value(QStringLiteral("completion/extra_mimetypes")).toStringList();
    for (const QString &raw : rawMimetypes) {
        const QString entry = raw.trimmed();
        if (entry.isEmpty())
            continue;

        const int bar = entry.indexOf(QLatin1Char('|'));
        const QString value = (bar < 0 ? entry : entry.left(bar)).trimmed();
        const QString description =
            (bar < 0 ? QString() : entry.mid(bar + 1)).trimmed();

        // Skip only the bad entry: one typo must not cost the user the rest
        // of the list, and the built-ins are appended to regardless.
        if (value.isEmpty()) {
            addProblem(QStringLiteral("[completion] extra_mimetypes: entry '%1' "
                                      "has no mimetype; ignoring it.")
                           .arg(entry));
            continue;
        }
        m_extraMimetypes.append({ value, description });
    }

    m_syncCommand = settings.value(QStringLiteral("sync/command")).toString();
    if (m_syncCommand.isEmpty()) {
        // Not a problem: sync is optional, and nothing the user asked for is
        // being ignored. A modal here would fire on every launch.
        addNotice(QStringLiteral(
            "No sync command configured ([sync] command); syncing is disabled."));
    } else if (!QFileInfo::exists(m_syncCommand.split(QLatin1Char(' ')).first())) {
        addProblem(
            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();

        // Both optional, and both describe this account's chip in the thread
        // list. An account tag is a different taxonomy from a functional one,
        // saying which mailbox a thread arrived in rather than what state it
        // is in, so these live here rather than in [tagcolors].
        account.label = settings.value(QStringLiteral("label")).toString();

        const QString colour = settings.value(QStringLiteral("color")).toString();
        if (!colour.isEmpty()) {
            account.color = QColor(colour);
            if (!account.color.isValid()) {
                addProblem(
                    QStringLiteral("Account '%1' has an unparseable color '%2'; "
                                   "using a generated one.")
                        .arg(account.key, colour));
            }
        }
        settings.endGroup();

        if (!account.isValid()) {
            addProblem(
                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();

    // Checked here rather than where startup_query is read: [queries] is not
    // parsed until now. Only a name the user actually wrote is worth a
    // problem; the built-in default naming a query they never created is not
    // something they got wrong.
    if (m_startupQueryWasSet && !m_savedQueries.isEmpty()
        && startupSavedQuery().name.compare(m_startupQuery,
                                            Qt::CaseInsensitive) != 0) {
        addProblem(QStringLiteral("Startup query '%1' is not a saved query; "
                                  "opening '%2' instead.")
                       .arg(m_startupQuery, startupSavedQuery().name));
    }
}

SavedQuery Config::startupSavedQuery() const
{
    if (m_savedQueries.isEmpty())
        return {};

    for (const SavedQuery &query : m_savedQueries) {
        if (query.name.compare(m_startupQuery, Qt::CaseInsensitive) == 0)
            return query;
    }

    // Named a query that does not exist. Not worth a warning: the default is
    // a name the user never wrote, so an install with no [queries] Unread
    // entry would warn on every launch about a key it never set.
    return m_savedQueries.first();
}

Account Config::account(const QString &key) const
{
    for (const Account &a : m_accounts) {
        if (a.key == key)
            return a;
    }
    return {};
}