Branch data Line data Source code
1 : : #include "SettingsDialog.h"
2 : :
3 : : #include <QCheckBox>
4 : : #include <QColorDialog>
5 : : #include <QComboBox>
6 : : #include <QCryptographicHash>
7 : : #include <QFile>
8 : : #include <QHBoxLayout>
9 : : #include <QHeaderView>
10 : : #include <QInputDialog>
11 : : #include <QLabel>
12 : : #include <QLineEdit>
13 : : #include <QListWidget>
14 : : #include <QLoggingCategory>
15 : : #include <QMenu>
16 : : #include <QMessageBox>
17 : : #include <QPushButton>
18 : : #include <QSettings>
19 : : #include <QSignalBlocker>
20 : : #include <QShortcut>
21 : : #include <QSpinBox>
22 : : #include <QTabWidget>
23 : : #include <QTableWidget>
24 : : #include <QTimer>
25 : : #include <QUuid>
26 : : #include <QVBoxLayout>
27 : :
28 : : #include "data/AccountConfig.h"
29 : : #include "ui/ThemeManager.h"
30 : : #include "ui/PlainTextMessageBox.h"
31 : : #include "data/CalendarStore.h"
32 : : #include "data/ContactStore.h"
33 : : #include "data/DavCredentials.h"
34 : : #include "data/MailCache.h"
35 : : #include "data/SettingsSyncModels.h"
36 : : #include "service/CardDavClient.h"
37 : : #include "service/CalDavClient.h"
38 : : #include "service/NextcloudAuth.h"
39 : : #include "service/ImapService.h"
40 : : #include "ui/AccountFormWidget.h"
41 : : #include <QEvent>
42 : : #include <QFrame>
43 : : #include <QGroupBox>
44 : : #include <QScrollArea>
45 : :
46 : : #include <memory>
47 : :
48 [ + + + - : 67 : Q_LOGGING_CATEGORY(lcSettings, "mailjd.settings")
+ - - - ]
49 : :
50 : 1 : static QString cardDavContactIdentity(const QString &accountId,
51 : : const QString &bookPath,
52 : : const Contact &contact) {
53 [ + - + - ]: 2 : const QString material = accountId + QLatin1Char('\n') + bookPath +
54 [ + - + - ]: 3 : QLatin1Char('\n') + contact.cardDavUid +
55 [ + - + - : 3 : QLatin1Char('\n') + contact.email.toLower();
+ - ]
56 : 2 : return QStringLiteral("v2:%1:%2").arg(
57 : : accountId,
58 [ + - ]: 2 : QString::fromLatin1(
59 [ + - + - ]: 2 : QCryptographicHash::hash(material.toUtf8(),
60 : : QCryptographicHash::Sha256)
61 [ + - + - ]: 5 : .toHex()));
62 : 1 : }
63 : :
64 : 81 : SettingsDialog::SettingsDialog(QWidget *parent)
65 [ + - + - ]: 81 : : QDialog(parent), m_configDir(AccountConfigLoader::defaultConfigDir()) {
66 : : // Modal test seams: real dialogs by default (unit tests override).
67 : 0 : m_promptText = [this](const QString &title, const QString &label,
68 : : const QString &initial, bool *ok) {
69 : 0 : return QInputDialog::getText(this, title, label, QLineEdit::Normal,
70 [ # # ]: 0 : initial, ok);
71 : 81 : };
72 : 162 : m_confirm = [this](const QString &title, const QString &text) {
73 : 0 : return PlainTextMessageBox::question(this, title, text);
74 : 81 : };
75 : 162 : m_warn = [this](const QString &title, const QString &text) {
76 : 0 : PlainTextMessageBox::warning(this, title, text);
77 : 81 : };
78 [ + - ]: 81 : setupUi();
79 [ + - ]: 81 : setupShortcuts();
80 [ + - ]: 81 : loadAccountList();
81 : 81 : }
82 : :
83 : 8 : void SettingsDialog::setConfigDir(const QString &dir) {
84 : 8 : m_configDir = dir;
85 : 8 : loadAccountList();
86 : 8 : }
87 : :
88 : 81 : void SettingsDialog::setupUi() {
89 [ + - + - ]: 81 : setWindowTitle(tr("Settings"));
90 [ + - ]: 81 : setMinimumSize(700, 550);
91 [ + - ]: 81 : resize(800, 600);
92 : :
93 [ + - + - : 81 : auto *mainLayout = new QVBoxLayout(this);
- + - - ]
94 : :
95 : : // Tab widget
96 [ + - + - : 81 : m_tabs = new QTabWidget(this);
- + - - ]
97 : :
98 : : // === Tab: Accounts ===
99 [ + - + - : 81 : auto *accountsTab = new QWidget(this);
- + - - ]
100 [ + - + - : 81 : auto *accountsLayout = new QHBoxLayout(accountsTab);
- + - - ]
101 : :
102 : : // Left: account list + buttons
103 [ + - + - : 81 : auto *leftPanel = new QVBoxLayout();
- + - - ]
104 [ + - + - : 81 : m_accountList = new QListWidget(this);
- + - - ]
105 [ + - ]: 81 : m_accountList->setMinimumWidth(180);
106 [ + - ]: 81 : m_accountList->setMaximumWidth(250);
107 [ + - ]: 81 : leftPanel->addWidget(m_accountList);
108 : :
109 [ + - + - : 81 : auto *listButtons = new QHBoxLayout();
- + - - ]
110 [ + - + - : 81 : m_addButton = new QPushButton(tr("Add"), this);
+ - - + -
- ]
111 [ + - + - : 81 : m_deleteButton = new QPushButton(tr("Delete"), this);
+ - - + -
- ]
112 [ + - ]: 81 : m_deleteButton->setEnabled(false);
113 [ + - ]: 81 : listButtons->addWidget(m_addButton);
114 [ + - ]: 81 : listButtons->addWidget(m_deleteButton);
115 [ + - ]: 81 : leftPanel->addLayout(listButtons);
116 : :
117 [ + - ]: 81 : accountsLayout->addLayout(leftPanel);
118 : :
119 : : // Right: account form
120 [ + - + - : 81 : m_accountForm = new AccountFormWidget(this);
- + - - ]
121 [ + - ]: 81 : m_accountForm->setEnabled(false); // Disabled until account selected
122 [ + - ]: 81 : accountsLayout->addWidget(m_accountForm, 1);
123 : :
124 [ + - + - ]: 81 : m_tabs->addTab(accountsTab, tr("Accounts"));
125 : :
126 : : // === Tab: General ===
127 [ + - + - : 81 : auto *generalTab = new QWidget(this);
- + - - ]
128 [ + - + - : 81 : auto *generalLayout = new QVBoxLayout(generalTab);
- + - - ]
129 : :
130 : : // 67.B2: Appearance — three-way theme choice, applies live and is
131 : : // persisted by ThemeManager (QSettings appearance/theme).
132 [ + - + - : 81 : auto *appearanceLayout = new QHBoxLayout();
- + - - ]
133 [ + - + - : 81 : appearanceLayout->addWidget(new QLabel(tr("Theme:"), this));
+ - + - -
+ - - ]
134 [ + - + - : 81 : m_themeCombo = new QComboBox(this);
- + - - ]
135 [ + - + - ]: 162 : m_themeCombo->addItem(tr("Light"), QStringLiteral("light"));
136 [ + - + - ]: 162 : m_themeCombo->addItem(tr("Dark"), QStringLiteral("dark"));
137 [ + - + - ]: 162 : m_themeCombo->addItem(tr("System"), QStringLiteral("system"));
138 : : {
139 [ + - ]: 81 : QSettings s;
140 [ + - ]: 243 : const QString mode = s.value(QStringLiteral("appearance/theme"),
141 [ + - ]: 243 : QStringLiteral("system")).toString();
142 [ + - ]: 81 : int idx = m_themeCombo->findData(mode);
143 [ + - ]: 81 : if (idx >= 0)
144 [ + - ]: 81 : m_themeCombo->setCurrentIndex(idx);
145 : 81 : }
146 [ + - ]: 81 : connect(m_themeCombo, &QComboBox::currentIndexChanged, this, [this](int) {
147 [ + - + - ]: 8 : ThemeManager::instance().setMode(ThemeManager::modeFromString(
148 [ + - + - ]: 8 : m_themeCombo->currentData().toString()));
149 : 4 : });
150 [ + - ]: 81 : appearanceLayout->addWidget(m_themeCombo);
151 [ + - ]: 81 : appearanceLayout->addStretch();
152 [ + - ]: 81 : generalLayout->addLayout(appearanceLayout);
153 : :
154 : : // Default view mode
155 [ + - + - : 81 : auto *viewModeLayout = new QHBoxLayout();
- + - - ]
156 [ + - + - : 81 : viewModeLayout->addWidget(new QLabel(tr("Default View:"), this));
+ - + - -
+ - - ]
157 [ + - + - : 81 : m_defaultViewCombo = new QComboBox(this);
- + - - ]
158 [ + - + - ]: 162 : m_defaultViewCombo->addItem(tr("Text"), QStringLiteral("text"));
159 [ + - + - ]: 162 : m_defaultViewCombo->addItem(tr("HTML"), QStringLiteral("html"));
160 [ + - ]: 81 : viewModeLayout->addWidget(m_defaultViewCombo);
161 [ + - ]: 81 : viewModeLayout->addStretch();
162 [ + - ]: 81 : generalLayout->addLayout(viewModeLayout);
163 : :
164 : : // External content policy
165 [ + - + - : 81 : auto *extContentLayout = new QHBoxLayout();
- + - - ]
166 [ + - + - : 81 : extContentLayout->addWidget(new QLabel(tr("External Content:"), this));
+ - + - -
+ - - ]
167 [ + - + - : 81 : m_externalContentCombo = new QComboBox(this);
- + - - ]
168 [ + - + - ]: 162 : m_externalContentCombo->addItem(tr("Block"), QStringLiteral("block"));
169 [ + - + - ]: 162 : m_externalContentCombo->addItem(tr("Load"), QStringLiteral("load"));
170 [ + - + - ]: 162 : m_externalContentCombo->addItem(tr("Ask"), QStringLiteral("ask"));
171 [ + - ]: 81 : extContentLayout->addWidget(m_externalContentCombo);
172 [ + - ]: 81 : extContentLayout->addStretch();
173 [ + - ]: 81 : generalLayout->addLayout(extContentLayout);
174 : :
175 : : // T-306: Language selection
176 [ + - + - : 81 : auto *langLayout = new QHBoxLayout();
- + - - ]
177 [ + - + - : 81 : langLayout->addWidget(new QLabel(tr("Language:"), this));
+ - + - -
+ - - ]
178 [ + - + - : 81 : m_languageCombo = new QComboBox(this);
- + - - ]
179 [ + - + - ]: 162 : m_languageCombo->addItem(tr("Auto (System)"), QStringLiteral("auto"));
180 [ + - ]: 243 : m_languageCombo->addItem(QStringLiteral("English"), QStringLiteral("en"));
181 [ + - ]: 243 : m_languageCombo->addItem(QStringLiteral("Deutsch"), QStringLiteral("de"));
182 [ + - ]: 81 : langLayout->addWidget(m_languageCombo);
183 [ + - ]: 81 : langLayout->addStretch();
184 [ + - ]: 81 : generalLayout->addLayout(langLayout);
185 : :
186 : : // Network payload budget. The IMAP layer enforces this independently too;
187 : : // exposing it here lets users deliberately trade larger-message support for
188 : : // memory usage without falling back to an unbounded fetch.
189 [ + - + - : 81 : auto *downloadGroup = new QGroupBox(tr("Message Downloads"));
+ - - + -
- ]
190 [ + - + - : 81 : auto *downloadLayout = new QHBoxLayout(downloadGroup);
- + - - ]
191 [ + - + - : 81 : downloadLayout->addWidget(new QLabel(tr("Maximum message size:"), this));
+ - + - -
+ - - ]
192 [ + - + - : 81 : m_maxMessageSizeSpin = new QSpinBox(this);
- + - - ]
193 [ + - ]: 81 : m_maxMessageSizeSpin->setRange(ImapService::MinMaxMessageSizeMiB,
194 : : ImapService::MaxMaxMessageSizeMiB);
195 [ + - + - ]: 81 : m_maxMessageSizeSpin->setSuffix(tr(" MiB"));
196 [ + - ]: 81 : QSettings downloadSettings;
197 [ + - + - ]: 81 : m_maxMessageSizeSpin->setValue(
198 : : downloadSettings
199 [ + - ]: 243 : .value(QStringLiteral("network/maxMessageSizeMiB"),
200 : : ImapService::DefaultMaxMessageSizeMiB)
201 : : .toInt());
202 [ + - ]: 81 : m_maxMessageSizeSpin->setToolTip(
203 [ + - ]: 162 : tr("Messages above this size are not downloaded or cached."));
204 [ + - ]: 81 : downloadLayout->addWidget(m_maxMessageSizeSpin);
205 [ + - ]: 81 : downloadLayout->addStretch();
206 [ + - ]: 81 : generalLayout->addWidget(downloadGroup);
207 : :
208 : : // Sprint 49: Tray settings
209 [ + - + - : 81 : auto *trayGroup = new QGroupBox(tr("System Tray"));
+ - - + -
- ]
210 [ + - + - : 81 : auto *trayLayout = new QVBoxLayout(trayGroup);
- + - - ]
211 : :
212 : : auto *closeToTrayCheck =
213 [ + - + - : 81 : new QCheckBox(tr("Close to tray instead of quitting"));
+ - - + -
- ]
214 [ + - ]: 81 : QSettings traySettings;
215 : 162 : closeToTrayCheck->setChecked(
216 [ + - + - : 162 : traySettings.value("tray/closeToTray", true).toBool());
+ - ]
217 [ + - ]: 81 : connect(closeToTrayCheck, &QCheckBox::toggled, this, [](bool checked) {
218 [ + - ]: 2 : QSettings s;
219 [ + - ]: 4 : s.setValue("tray/closeToTray", checked);
220 : 2 : });
221 [ + - ]: 81 : trayLayout->addWidget(closeToTrayCheck);
222 : :
223 [ + - + - : 81 : auto *startMinimizedCheck = new QCheckBox(tr("Start minimized to tray"));
+ - - + -
- ]
224 : 162 : startMinimizedCheck->setChecked(
225 [ + - + - : 162 : traySettings.value("tray/startMinimized", false).toBool());
+ - ]
226 [ + - ]: 81 : connect(startMinimizedCheck, &QCheckBox::toggled, this, [](bool checked) {
227 [ + - ]: 2 : QSettings s;
228 [ + - ]: 4 : s.setValue("tray/startMinimized", checked);
229 : 2 : });
230 [ + - ]: 81 : trayLayout->addWidget(startMinimizedCheck);
231 : :
232 [ + - ]: 81 : generalLayout->addWidget(trayGroup);
233 : :
234 : : // Sprint 49: Notification settings
235 [ + - + - : 81 : auto *notifGroup = new QGroupBox(tr("Notifications"));
+ - - + -
- ]
236 [ + - + - : 81 : auto *notifLayout = new QVBoxLayout(notifGroup);
- + - - ]
237 : :
238 : : auto *notifEnabledCheck =
239 [ + - + - : 81 : new QCheckBox(tr("Show desktop notifications for new mail"));
+ - - + -
- ]
240 : 162 : notifEnabledCheck->setChecked(
241 [ + - + - : 162 : traySettings.value("notifications/enabled", true).toBool());
+ - ]
242 [ + - ]: 81 : connect(notifEnabledCheck, &QCheckBox::toggled, this, [](bool checked) {
243 [ + - ]: 2 : QSettings s;
244 [ + - ]: 4 : s.setValue("notifications/enabled", checked);
245 : 2 : });
246 [ + - ]: 81 : notifLayout->addWidget(notifEnabledCheck);
247 : :
248 [ + - ]: 81 : generalLayout->addWidget(notifGroup);
249 : :
250 [ + - ]: 81 : generalLayout->addStretch();
251 : :
252 : : // Load current settings values
253 [ + - ]: 81 : QSettings settings;
254 : : QString currentViewMode =
255 [ + - + - : 162 : settings.value("view/defaultMode", "text").toString();
+ - ]
256 [ + - ]: 81 : int viewIdx = m_defaultViewCombo->findData(currentViewMode);
257 [ + - ]: 81 : if (viewIdx >= 0)
258 [ + - ]: 81 : m_defaultViewCombo->setCurrentIndex(viewIdx);
259 : :
260 : : QString currentExtContent =
261 [ + - + - : 162 : settings.value("view/externalContent", "block").toString();
+ - ]
262 [ + - ]: 81 : int extIdx = m_externalContentCombo->findData(currentExtContent);
263 [ + - ]: 81 : if (extIdx >= 0)
264 [ + - ]: 81 : m_externalContentCombo->setCurrentIndex(extIdx);
265 : :
266 : : // T-306: Load language setting
267 [ + - + - : 162 : QString currentLang = settings.value("i18n/language", "auto").toString();
+ - ]
268 [ + - ]: 81 : int langIdx = m_languageCombo->findData(currentLang);
269 [ + - ]: 81 : if (langIdx >= 0)
270 [ + - ]: 81 : m_languageCombo->setCurrentIndex(langIdx);
271 : :
272 [ + - + - ]: 81 : m_tabs->addTab(generalTab, tr("General"));
273 : :
274 : : // === Tab: Whitelist (T-122) ===
275 [ + - + - : 81 : auto *whitelistTab = new QWidget(this);
- + - - ]
276 [ + - + - : 81 : auto *wlLayout = new QVBoxLayout(whitelistTab);
- + - - ]
277 : :
278 [ + - - - ]: 162 : wlLayout->addWidget(new QLabel(
279 : 81 : tr("Automatically load external content for the following senders/domains:"),
280 [ + - + - : 162 : this));
+ - - + ]
281 : :
282 [ + - + - : 81 : m_whitelistTable = new QTableWidget(0, 3, this);
- + - - ]
283 [ + - + + : 324 : m_whitelistTable->setHorizontalHeaderLabels(
- - ]
284 : : {tr("Type"), tr("Value"), tr("Added")});
285 [ + - + - ]: 81 : m_whitelistTable->horizontalHeader()->setStretchLastSection(true);
286 [ + - ]: 81 : m_whitelistTable->setSelectionBehavior(QAbstractItemView::SelectRows);
287 [ + - ]: 81 : m_whitelistTable->setEditTriggers(QAbstractItemView::NoEditTriggers);
288 [ + - ]: 81 : wlLayout->addWidget(m_whitelistTable, 1);
289 : :
290 [ + - + - : 81 : auto *wlAddLayout = new QHBoxLayout();
- + - - ]
291 [ + - + - : 81 : m_whitelistTypeCombo = new QComboBox(this);
- + - - ]
292 [ + - + - : 81 : m_whitelistTypeCombo->addItem(tr("Sender"), "sender");
+ - ]
293 [ + - + - : 81 : m_whitelistTypeCombo->addItem(tr("Domain"), "domain");
+ - ]
294 [ + - ]: 81 : wlAddLayout->addWidget(m_whitelistTypeCombo);
295 : :
296 [ + - + - : 81 : m_whitelistValueEdit = new QLineEdit(this);
- + - - ]
297 [ + - ]: 81 : m_whitelistValueEdit->setPlaceholderText(
298 [ + - ]: 162 : tr("e.g. user@example.com or example.com"));
299 [ + - ]: 81 : wlAddLayout->addWidget(m_whitelistValueEdit, 1);
300 : :
301 [ + - + - : 81 : auto *wlAddBtn = new QPushButton(tr("Add"), this);
+ - - + -
- ]
302 [ + - ]: 81 : connect(wlAddBtn, &QPushButton::clicked, this, [this]() {
303 [ + + ]: 4 : if (!m_cache) return;
304 [ + - + - ]: 2 : QString type = m_whitelistTypeCombo->currentData().toString();
305 [ + - + - ]: 2 : QString value = m_whitelistValueEdit->text().trimmed();
306 [ + + ]: 2 : if (value.isEmpty()) return;
307 [ + - ]: 1 : m_cache->addWhitelistEntry(type, value);
308 [ + - ]: 1 : m_whitelistValueEdit->clear();
309 [ + - ]: 1 : loadWhitelistTable();
310 [ + + + + ]: 3 : });
311 [ + - ]: 81 : wlAddLayout->addWidget(wlAddBtn);
312 [ + - ]: 81 : wlLayout->addLayout(wlAddLayout);
313 : :
314 [ + - + - : 81 : auto *wlRemoveBtn = new QPushButton(tr("Remove Selected"), this);
+ - - + -
- ]
315 [ + - ]: 81 : connect(wlRemoveBtn, &QPushButton::clicked, this, [this]() {
316 [ + + ]: 2 : if (!m_cache) return;
317 [ + - + - ]: 1 : auto selected = m_whitelistTable->selectionModel()->selectedRows();
318 [ + - + - : 2 : for (const auto &idx : selected) {
+ + ]
319 [ + - ]: 1 : qint64 id = m_whitelistTable->item(idx.row(), 0)
320 [ + - + - ]: 1 : ->data(Qt::UserRole).toLongLong();
321 [ + - ]: 1 : m_cache->removeWhitelistEntry(id);
322 : : }
323 [ + - ]: 1 : loadWhitelistTable();
324 : 1 : });
325 [ + - ]: 81 : wlLayout->addWidget(wlRemoveBtn);
326 : :
327 [ + - + - ]: 81 : m_tabs->addTab(whitelistTab, tr("Whitelist"));
328 : :
329 : : // === Tab: Kontakte (multi-server CardDAV + CalDAV, shared accounts) ===
330 : : // Sprint 73: the tab content is wrapped in a QScrollArea and split into
331 : : // three QGroupBox sections (DAV Accounts, Address Books, Calendars) so the
332 : : // shared account model is visible and the layout stays usable at the
333 : : // minimum dialog size. The server URL column stretches and the lists have
334 : : // a one-row minimum instead of a hard cap that truncated content.
335 [ + - + - : 81 : auto *contactsTab = new QWidget(this);
- + - - ]
336 [ + - + - : 81 : auto *tabOuter = new QVBoxLayout(contactsTab);
- + - - ]
337 [ + - ]: 81 : tabOuter->setContentsMargins(0, 0, 0, 0);
338 : :
339 [ + - + - : 81 : auto *davScroll = new QScrollArea(this);
- + - - ]
340 [ + - ]: 81 : davScroll->setWidgetResizable(true);
341 [ + - ]: 81 : davScroll->setFrameShape(QFrame::NoFrame);
342 [ + - + - : 81 : auto *davContent = new QWidget(davScroll);
- + - - ]
343 [ + - + - : 81 : auto *ctLayout = new QVBoxLayout(davContent);
- + - - ]
344 [ + - ]: 81 : ctLayout->setContentsMargins(0, 0, 0, 0);
345 : :
346 : : // -- Section: DAV Accounts --
347 [ + - + - : 81 : auto *accountsGroup = new QGroupBox(tr("DAV Accounts"), davContent);
+ - - + -
- ]
348 [ + - + - : 81 : auto *davAccountsLayout = new QVBoxLayout(accountsGroup);
- + - - ]
349 [ + - + - : 81 : m_cdAccountTable = new QTableWidget(0, 2, accountsGroup);
- + - - ]
350 [ + - + + : 243 : m_cdAccountTable->setHorizontalHeaderLabels(
- - ]
351 : : {tr("Server URL"), tr("Username")});
352 : : // Server URL is the long, important column -> Stretch. Username is short
353 : : // and bounded -> ResizeToContents with a minimum so it stays readable.
354 [ + - + - ]: 81 : m_cdAccountTable->horizontalHeader()->setSectionResizeMode(
355 : : 0, QHeaderView::Stretch);
356 [ + - + - ]: 81 : m_cdAccountTable->horizontalHeader()->setSectionResizeMode(
357 : : 1, QHeaderView::ResizeToContents);
358 [ + - + - ]: 81 : m_cdAccountTable->horizontalHeader()->setMinimumSectionSize(120);
359 [ + - ]: 81 : m_cdAccountTable->setSelectionBehavior(QAbstractItemView::SelectRows);
360 [ + - ]: 81 : m_cdAccountTable->setSelectionMode(QAbstractItemView::SingleSelection);
361 [ + - ]: 81 : m_cdAccountTable->setEditTriggers(QAbstractItemView::NoEditTriggers);
362 : : // At least one full row + header must stay visible; no hard maximum.
363 : : // verticalHeader()->defaultSectionSize() returns the configured row height
364 : : // even before any rows exist, so this works during construction.
365 [ + - + - ]: 81 : const int cdRowH = m_cdAccountTable->verticalHeader()->defaultSectionSize();
366 : 162 : m_cdAccountTable->setMinimumHeight(
367 [ + - ]: 81 : m_cdAccountTable->horizontalHeader()->height() + cdRowH +
368 [ + - + - ]: 81 : 2 * m_cdAccountTable->frameWidth() + 6);
369 [ + - ]: 81 : davAccountsLayout->addWidget(m_cdAccountTable);
370 : :
371 [ + - + - : 81 : auto *accBtnRow = new QHBoxLayout();
- + - - ]
372 [ + - + - : 81 : m_cdAddBtn = new QPushButton(tr("Add…"), accountsGroup);
+ - - + -
- ]
373 [ + - + - : 81 : m_cdRemoveBtn = new QPushButton(tr("Remove"), accountsGroup);
+ - - + -
- ]
374 [ + - ]: 81 : m_cdRemoveBtn->setEnabled(false);
375 : : // Sprint 73: authorize/re-authorize an existing DAV account in place.
376 : : // Required for accounts restored by settings sync, which carry metadata
377 : : // but no local keyring secret.
378 [ + - + - : 81 : m_cdAuthorizeBtn = new QPushButton(tr("Authorize…"), accountsGroup);
+ - - + -
- ]
379 [ + - ]: 81 : m_cdAuthorizeBtn->setEnabled(false);
380 [ + - ]: 81 : accBtnRow->addWidget(m_cdAddBtn);
381 [ + - ]: 81 : accBtnRow->addWidget(m_cdRemoveBtn);
382 [ + - ]: 81 : accBtnRow->addWidget(m_cdAuthorizeBtn);
383 [ + - ]: 81 : accBtnRow->addStretch();
384 [ + - ]: 81 : davAccountsLayout->addLayout(accBtnRow);
385 : :
386 : : // Sprint 73: explain the shared DAV account model so users understand how
387 : : // to configure multiple CardDAV/CalDAV servers.
388 : : auto *davHelp = new QLabel(
389 [ + - ]: 81 : tr("Each DAV account can provide address books, calendars, or both. "
390 : : "Add another DAV account for another CardDAV/CalDAV server."),
391 [ + - + - : 162 : accountsGroup);
- + - - ]
392 [ + - ]: 81 : davHelp->setWordWrap(true);
393 [ + - ]: 81 : davHelp->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed);
394 [ + - ]: 81 : davAccountsLayout->addWidget(davHelp);
395 [ + - ]: 81 : ctLayout->addWidget(accountsGroup);
396 : :
397 : : // -- Section: Address Books --
398 [ + - + - : 81 : auto *booksGroup = new QGroupBox(tr("Address Books"), davContent);
+ - - + -
- ]
399 [ + - + - : 81 : auto *booksLayout = new QVBoxLayout(booksGroup);
- + - - ]
400 [ + - + - : 81 : m_cdBookList = new QListWidget(booksGroup);
- + - - ]
401 : : // Keep at least one full row visible without an artificial cap.
402 [ + - ]: 81 : m_cdBookList->setMinimumHeight(
403 [ + - - + ]: 81 : m_cdBookList->sizeHintForRow(0) > 0
404 [ # # # # ]: 0 : ? m_cdBookList->sizeHintForRow(0) + 2 * m_cdBookList->frameWidth() + 6
405 : : : 48);
406 [ + - ]: 81 : booksLayout->addWidget(m_cdBookList);
407 : :
408 : : // -- Discover + Sync buttons --
409 [ + - + - : 81 : auto *actionRow = new QHBoxLayout();
- + - - ]
410 [ + - + - : 81 : m_cdDiscoverBtn = new QPushButton(tr("Discover Address Books"), booksGroup);
+ - - + -
- ]
411 [ + - ]: 81 : m_cdDiscoverBtn->setEnabled(false);
412 [ + - + - : 81 : m_cdSyncBtn = new QPushButton(tr("Sync Now"), booksGroup);
+ - - + -
- ]
413 [ + - ]: 81 : m_cdSyncBtn->setEnabled(false);
414 [ + - ]: 81 : actionRow->addWidget(m_cdDiscoverBtn);
415 [ + - ]: 81 : actionRow->addWidget(m_cdSyncBtn);
416 [ + - ]: 81 : actionRow->addStretch();
417 [ + - ]: 81 : booksLayout->addLayout(actionRow);
418 : :
419 : : // -- Status + auto-sync interval --
420 [ + - + - : 81 : auto *statusRow = new QHBoxLayout();
- + - - ]
421 [ + - + - : 81 : m_cdStatusLabel = new QLabel(booksGroup);
- + - - ]
422 [ + - ]: 81 : m_cdStatusLabel->setWordWrap(true);
423 : : // SEC-2026-07-21-16: server-controlled strings (login names, DAV errors)
424 : : // flow into this label; force plain text to prevent UI spoofing.
425 [ + - ]: 81 : m_cdStatusLabel->setTextFormat(Qt::PlainText);
426 [ + - ]: 81 : statusRow->addWidget(m_cdStatusLabel, 1);
427 [ + - + - : 81 : statusRow->addWidget(new QLabel(tr("Auto-Sync:"), booksGroup));
+ - + - -
+ - - ]
428 [ + - + - : 81 : m_cdIntervalCombo = new QComboBox(booksGroup);
- + - - ]
429 [ + - + - ]: 81 : m_cdIntervalCombo->addItem(tr("Off"), 0);
430 [ + - + - ]: 81 : m_cdIntervalCombo->addItem(tr("15 Minutes"), 15);
431 [ + - + - ]: 81 : m_cdIntervalCombo->addItem(tr("1 Hour"), 60);
432 [ + - + - ]: 81 : m_cdIntervalCombo->addItem(tr("6 Hours"), 360);
433 [ + - + - ]: 81 : m_cdIntervalCombo->addItem(tr("12 Hours"), 720);
434 [ + - + - ]: 81 : m_cdIntervalCombo->addItem(tr("24 Hours"), 1440);
435 [ + - ]: 81 : statusRow->addWidget(m_cdIntervalCombo);
436 [ + - ]: 81 : booksLayout->addLayout(statusRow);
437 [ + - ]: 81 : ctLayout->addWidget(booksGroup);
438 : :
439 : : // Load saved settings
440 [ + - ]: 81 : loadCdAccounts();
441 [ + - ]: 81 : updateCdAccountTable();
442 : : {
443 [ + - ]: 81 : QSettings s;
444 [ + - + - ]: 162 : int interval = s.value("carddav/syncIntervalMin", 0).toInt();
445 [ + - ]: 81 : int idx = m_cdIntervalCombo->findData(interval);
446 [ + - ]: 81 : if (idx >= 0)
447 [ + - ]: 81 : m_cdIntervalCombo->setCurrentIndex(idx);
448 : 81 : }
449 : :
450 : : // Auth instance
451 [ + - + - : 81 : m_ncAuth = new NextcloudAuth(this);
- + - - ]
452 : :
453 : : // -- Connections --
454 : 81 : connect(m_cdAccountTable, &QTableWidget::currentCellChanged, this,
455 [ + - ]: 106 : [this](int row, int, int, int) { onCdAccountSelected(row); });
456 : 81 : connect(m_cdAddBtn, &QPushButton::clicked, this,
457 [ + - ]: 81 : [this]() { addCdAccount(); });
458 : 81 : connect(m_cdRemoveBtn, &QPushButton::clicked, this,
459 [ + - ]: 81 : [this]() { removeCdAccount(); });
460 : 81 : connect(m_cdAuthorizeBtn, &QPushButton::clicked, this,
461 [ + - ]: 81 : [this]() { authorizeCdAccount(); });
462 : 81 : connect(m_cdDiscoverBtn, &QPushButton::clicked, this,
463 [ + - ]: 81 : [this]() { discoverBooks(); });
464 : 81 : connect(m_cdSyncBtn, &QPushButton::clicked, this,
465 [ + - ]: 81 : [this]() { syncAllAccounts(); });
466 : :
467 : : // Sprint 73: resolve the authorized account by stable ID instead of the
468 : : // current row. Browser login is asynchronous, so m_cdCurrentIdx may point
469 : : // at a different account by the time loginSuccess fires. Capturing the
470 : : // pending account ID guarantees credentials land on the right account and
471 : : // keeps synced caldav/configs references valid.
472 : 81 : connect(m_ncAuth, &NextcloudAuth::loginSuccess, this,
473 [ + - ]: 81 : [this](const QString &server, const QString &loginName,
474 : : const QString &appPassword) {
475 : 5 : const QString pendingId = m_pendingDavAuthAccountId;
476 : 5 : m_pendingDavAuthAccountId.clear();
477 [ + - ]: 5 : m_cdAddBtn->setEnabled(true);
478 [ + + ]: 5 : if (pendingId.isEmpty())
479 : 1 : return;
480 [ + - ]: 4 : const int idx = findCdAccountIndexById(pendingId);
481 [ - + ]: 4 : if (idx < 0)
482 : 0 : return;
483 : 4 : auto &acc = m_cdAccounts[idx];
484 : 4 : const CdAccount previousAccount = acc;
485 : 4 : const QString previousServer = acc.serverUrl;
486 : 4 : const QString previousUsername = acc.username;
487 : 4 : acc.serverUrl = server;
488 : 4 : acc.username = loginName;
489 : 4 : acc.password = appPassword;
490 : 4 : acc.requiresLocalAuthorization = false;
491 [ + - - + ]: 4 : if (!saveCdAccounts()) {
492 : 0 : QString cleanupError;
493 : 0 : DavCredentials::deletePasswordBlocking(
494 [ # # ]: 0 : acc.id, acc.serverUrl, acc.username, &cleanupError);
495 : 0 : acc = previousAccount;
496 [ # # ]: 0 : m_cdStatusLabel->setText(
497 [ # # ]: 0 : tr("Login succeeded, but the credentials could not be "
498 : : "stored."));
499 : 0 : return;
500 : 0 : }
501 [ + - ]: 4 : if (DavCredentials::accountName(acc.id, previousServer,
502 : 4 : previousUsername) !=
503 [ + + ]: 4 : DavCredentials::accountName(acc.id, acc.serverUrl,
504 [ + - ]: 8 : acc.username)) {
505 : 2 : QString cleanupError;
506 : 6 : if (!DavCredentials::deletePasswordBlocking(
507 [ + - ]: 2 : acc.id, previousServer, previousUsername,
508 [ + + - + : 2 : &cleanupError) && !previousServer.isEmpty()) {
- + ]
509 [ # # # # : 0 : qCWarning(lcSettings)
# # ]
510 [ # # ]: 0 : << "Failed to remove prior endpoint-bound DAV secret for"
511 [ # # # # : 0 : << acc.id << ":" << cleanupError;
# # ]
512 : : }
513 : 2 : }
514 [ + - ]: 4 : updateCdAccountTable();
515 [ + - ]: 4 : m_cdAccountTable->setCurrentCell(idx, 0);
516 [ + - ]: 4 : m_cdStatusLabel->setText(
517 [ + - + - ]: 12 : tr("Logged in as %1").arg(loginName));
518 : : // Re-evaluate action state for the freshly authorized account.
519 [ + - ]: 4 : onCdAccountSelected(idx);
520 [ + - + - : 5 : });
+ - + + ]
521 : :
522 : 81 : connect(m_ncAuth, &NextcloudAuth::loginFailed, this,
523 [ + - ]: 81 : [this](const QString &error) {
524 : 1 : m_pendingDavAuthAccountId.clear();
525 [ + - ]: 1 : m_cdStatusLabel->setText(
526 [ + - + - ]: 3 : tr("Login failed: %1").arg(error));
527 : 1 : m_cdAddBtn->setEnabled(true);
528 : 1 : });
529 : :
530 : : // Save interval on change
531 : 81 : connect(m_cdIntervalCombo, &QComboBox::currentIndexChanged, this,
532 [ + - ]: 81 : [this]() {
533 [ + - ]: 2 : QSettings s;
534 [ + - + - ]: 4 : s.setValue("carddav/syncIntervalMin",
535 [ + - ]: 4 : m_cdIntervalCombo->currentData().toInt());
536 : 2 : });
537 : :
538 : : // Save book selection on check/uncheck
539 : 81 : connect(m_cdBookList, &QListWidget::itemChanged, this,
540 [ + - ]: 81 : [this](QListWidgetItem *item) {
541 [ + - - + ]: 4 : if (m_cdCurrentIdx < 0 ||
542 [ - + ]: 2 : m_cdCurrentIdx >= static_cast<int>(m_cdAccounts.size()))
543 : 0 : return;
544 : 2 : auto &acc = m_cdAccounts[m_cdCurrentIdx];
545 : 2 : const QStringList previousSelection = acc.selectedBooks;
546 [ + - + - ]: 2 : QString path = item->data(Qt::UserRole).toString();
547 [ + - + + ]: 2 : if (item->checkState() == Qt::Checked) {
548 [ + - ]: 1 : if (!acc.selectedBooks.contains(path))
549 [ + - ]: 1 : acc.selectedBooks.append(path);
550 : : } else {
551 [ + - ]: 1 : acc.selectedBooks.removeAll(path);
552 : : }
553 [ + - - + ]: 2 : if (!saveCdAccounts()) {
554 : 0 : acc.selectedBooks = previousSelection;
555 : 0 : QSignalBlocker blocker(m_cdBookList);
556 [ # # # # ]: 0 : item->setCheckState(previousSelection.contains(path)
557 : : ? Qt::Checked
558 : : : Qt::Unchecked);
559 : 0 : }
560 : 2 : });
561 : : // Auto-select first CardDAV account to show address books immediately
562 [ + - + + ]: 81 : if (m_cdAccountTable->rowCount() > 0) {
563 [ + - ]: 16 : m_cdAccountTable->setCurrentCell(0, 0);
564 [ + - ]: 16 : onCdAccountSelected(0);
565 : : }
566 : :
567 : : // -- Section: Calendars (shared DAV account selector) --
568 [ + - + - : 81 : auto *calendarsGroup = new QGroupBox(tr("Calendars"), davContent);
+ - - + -
- ]
569 [ + - + - : 81 : auto *calendarsLayout = new QVBoxLayout(calendarsGroup);
- + - - ]
570 : :
571 : : // Account selector (DAV accounts). Empty-state text is set after the combo
572 : : // is populated below.
573 [ + - + - : 81 : auto *calAccRow = new QHBoxLayout();
- + - - ]
574 [ + - + - : 81 : calAccRow->addWidget(new QLabel(tr("Account:"), calendarsGroup));
+ - + - -
+ - - ]
575 [ + - + - : 81 : m_calAccountCombo = new QComboBox(calendarsGroup);
- + - - ]
576 [ + - ]: 81 : calAccRow->addWidget(m_calAccountCombo, 1);
577 [ + - ]: 81 : calendarsLayout->addLayout(calAccRow);
578 : :
579 : : // Calendar list with checkboxes
580 [ + - + - : 81 : m_calCalendarList = new QListWidget(calendarsGroup);
- + - - ]
581 : : // At least one full row visible; no hard maximum that truncates content.
582 [ + - ]: 81 : m_calCalendarList->setMinimumHeight(
583 [ + - - + ]: 81 : m_calCalendarList->sizeHintForRow(0) > 0
584 [ # # ]: 0 : ? m_calCalendarList->sizeHintForRow(0) +
585 [ # # ]: 0 : 2 * m_calCalendarList->frameWidth() + 6
586 : : : 48);
587 [ + - ]: 81 : calendarsLayout->addWidget(m_calCalendarList);
588 : :
589 : : // Discover + Sync buttons
590 [ + - + - : 81 : auto *calBtnRow = new QHBoxLayout();
- + - - ]
591 [ + - + - : 81 : m_calDiscoverBtn = new QPushButton(tr("Discover Calendars"), calendarsGroup);
+ - - + -
- ]
592 [ + - + - : 81 : m_calSyncBtn = new QPushButton(tr("Sync Now"), calendarsGroup);
+ - - + -
- ]
593 [ + - ]: 81 : m_calDiscoverBtn->setEnabled(false);
594 [ + - ]: 81 : m_calSyncBtn->setEnabled(false);
595 [ + - ]: 81 : calBtnRow->addWidget(m_calDiscoverBtn);
596 [ + - ]: 81 : calBtnRow->addWidget(m_calSyncBtn);
597 [ + - ]: 81 : calBtnRow->addStretch();
598 [ + - ]: 81 : calendarsLayout->addLayout(calBtnRow);
599 : :
600 : : // Status + sync interval
601 [ + - + - : 81 : auto *calStatusRow = new QHBoxLayout();
- + - - ]
602 [ + - + - : 81 : m_calStatusLabel = new QLabel(calendarsGroup);
- + - - ]
603 [ + - ]: 81 : m_calStatusLabel->setWordWrap(true);
604 : : // SEC-2026-07-21-16: server-controlled strings (discovery errors containing
605 : : // HTTP Reason-Phrase) flow into this label; force plain text.
606 [ + - ]: 81 : m_calStatusLabel->setTextFormat(Qt::PlainText);
607 [ + - ]: 81 : calStatusRow->addWidget(m_calStatusLabel, 1);
608 [ + - - - ]: 162 : calStatusRow->addWidget(
609 [ + - ]: 162 : new QLabel(tr("CalDAV sync interval (all DAV accounts):"),
610 [ + - + - : 243 : calendarsGroup));
- + ]
611 [ + - + - : 81 : m_calIntervalCombo = new QComboBox(calendarsGroup);
- + - - ]
612 [ + - + - ]: 81 : m_calIntervalCombo->addItem(tr("Off"), 0);
613 [ + - + - ]: 81 : m_calIntervalCombo->addItem(tr("5 Minutes"), 5);
614 [ + - + - ]: 81 : m_calIntervalCombo->addItem(tr("15 Minutes"), 15);
615 [ + - + - ]: 81 : m_calIntervalCombo->addItem(tr("30 Minutes"), 30);
616 [ + - + - ]: 81 : m_calIntervalCombo->addItem(tr("1 Hour"), 60);
617 [ + - ]: 81 : calStatusRow->addWidget(m_calIntervalCombo);
618 [ + - ]: 81 : calendarsLayout->addLayout(calStatusRow);
619 [ + - ]: 81 : ctLayout->addWidget(calendarsGroup);
620 : :
621 : : // Sprint 73: single trailing stretch at the bottom of the DAV tab. The
622 : : // previous code inserted a stretch between the CardDAV and CalDAV
623 : : // sections, which pushed the calendar area out of view.
624 [ + - ]: 81 : ctLayout->addStretch();
625 : :
626 : : // Populate calendar account combo from DAV accounts
627 [ + + ]: 98 : for (const auto &acc : m_cdAccounts) {
628 [ + - ]: 34 : m_calAccountCombo->addItem(
629 [ + - + - ]: 51 : acc.serverUrl + QStringLiteral(" (") + acc.username +
630 [ + - ]: 51 : QStringLiteral(")"),
631 : 17 : acc.id);
632 : : }
633 [ + - + + ]: 81 : if (m_calAccountCombo->count() == 0) {
634 [ + - ]: 130 : m_calAccountCombo->addItem(
635 [ + - ]: 195 : tr("No DAV account — add one above"), QString());
636 : : }
637 : :
638 : : // Load saved CalDAV configs
639 [ + - ]: 81 : loadCalConfigs();
640 : : {
641 [ + - ]: 81 : QSettings s;
642 : : int interval =
643 [ + - + - ]: 162 : s.value(QStringLiteral("caldav/syncIntervalMin"), 15).toInt();
644 [ + - ]: 81 : int idx = m_calIntervalCombo->findData(interval);
645 [ + - ]: 81 : if (idx >= 0)
646 [ + - ]: 81 : m_calIntervalCombo->setCurrentIndex(idx);
647 : 81 : }
648 : :
649 : : // Calendar connections
650 [ + - ]: 81 : connect(m_calAccountCombo,
651 : : qOverload<int>(&QComboBox::currentIndexChanged), this,
652 : 82 : [this](int idx) { onCalAccountSelected(idx); });
653 : 81 : connect(m_calDiscoverBtn, &QPushButton::clicked, this,
654 [ + - ]: 81 : [this]() { discoverCalendars(); });
655 : 81 : connect(m_calSyncBtn, &QPushButton::clicked, this,
656 [ + - ]: 81 : [this]() { syncCalendars(); });
657 [ + - ]: 81 : connect(m_calIntervalCombo,
658 : : qOverload<int>(&QComboBox::currentIndexChanged), this,
659 : 81 : [this]() {
660 [ + - ]: 3 : QSettings s;
661 [ + - + - ]: 6 : s.setValue(QStringLiteral("caldav/syncIntervalMin"),
662 [ + - ]: 6 : m_calIntervalCombo->currentData().toInt());
663 : 3 : });
664 : 81 : connect(m_calCalendarList, &QListWidget::itemChanged, this,
665 [ + - ]: 81 : [this](QListWidgetItem *item) {
666 [ + - + - ]: 5 : QString accId = m_calAccountCombo->currentData().toString();
667 [ + - ]: 5 : int cfgIdx = findCalConfigForAccount(accId);
668 [ + + ]: 5 : if (cfgIdx < 0)
669 : 3 : return;
670 : 2 : auto &cfg = m_calConfigs[cfgIdx];
671 [ + - + - ]: 2 : QString path = item->data(Qt::UserRole).toString();
672 [ + - + + ]: 2 : if (item->checkState() == Qt::Checked) {
673 [ + - ]: 1 : if (!cfg.selectedCalendars.contains(path))
674 [ + - ]: 1 : cfg.selectedCalendars.append(path);
675 : : } else {
676 [ + - ]: 1 : cfg.selectedCalendars.removeAll(path);
677 : : }
678 [ + - ]: 2 : saveCalConfigs();
679 [ + + ]: 5 : });
680 : :
681 : : // Auto-select first calendar account only when a real DAV account exists
682 : : // (skip the placeholder empty-state entry whose account id is empty).
683 [ + - + - ]: 162 : if (m_calAccountCombo->count() > 0 &&
684 [ + - + - : 162 : !m_calAccountCombo->itemData(0).toString().isEmpty()) {
+ + + - +
- + + - -
- - ]
685 [ + - ]: 16 : m_calDiscoverBtn->setEnabled(true);
686 [ + - ]: 16 : m_calSyncBtn->setEnabled(true);
687 [ + - ]: 16 : onCalAccountSelected(0);
688 : : }
689 : :
690 : : // Finalize the scrollable DAV tab.
691 [ + - ]: 81 : davScroll->setWidget(davContent);
692 [ + - ]: 81 : tabOuter->addWidget(davScroll);
693 [ + - + - ]: 81 : m_tabs->addTab(contactsTab, tr("DAV accounts"));
694 : :
695 : : // === Tab: Synchronisation (T-315) ===
696 [ + - + - : 81 : auto *syncTab = new QWidget(this);
- + - - ]
697 [ + - + - : 81 : auto *syncLayout = new QVBoxLayout(syncTab);
- + - - ]
698 : :
699 [ + - + - : 81 : m_syncEnabledCheck = new QCheckBox(tr("Synchronize settings"), this);
+ - - + -
- ]
700 [ + - ]: 81 : syncLayout->addWidget(m_syncEnabledCheck);
701 : :
702 [ + - + - : 81 : auto *folderLayout = new QHBoxLayout();
- + - - ]
703 [ + - + - : 81 : folderLayout->addWidget(new QLabel(tr("Folder name:"), this));
+ - + - -
+ - - ]
704 [ + - + - : 81 : m_syncFolderEdit = new QLineEdit(this);
- + - - ]
705 [ + - ]: 162 : m_syncFolderEdit->setPlaceholderText(QStringLiteral("MailJD-Settings"));
706 [ + - ]: 81 : folderLayout->addWidget(m_syncFolderEdit);
707 [ + - ]: 81 : folderLayout->addStretch();
708 [ + - ]: 81 : syncLayout->addLayout(folderLayout);
709 : :
710 : : // Separator
711 [ + - + - : 81 : auto *sepFrame = new QFrame(this);
- + - - ]
712 [ + - ]: 81 : sepFrame->setFrameShape(QFrame::HLine);
713 [ + - ]: 81 : sepFrame->setFrameShadow(QFrame::Sunken);
714 [ + - ]: 81 : syncLayout->addWidget(sepFrame);
715 : :
716 [ + - - - ]: 162 : syncLayout->addWidget(
717 [ + - + - : 162 : new QLabel(tr("Settings to synchronize:"), this));
+ - - + ]
718 : :
719 [ + - + - : 81 : m_syncCatIcons = new QCheckBox(tr("Folder icons"), this);
+ - - + -
- ]
720 [ + - + - : 81 : m_syncCatColors = new QCheckBox(tr("Folder colors"), this);
+ - - + -
- ]
721 [ + - + - : 81 : m_syncCatCalColors = new QCheckBox(tr("Calendar colors"), this);
+ - - + -
- ]
722 [ + - + - : 81 : m_syncCatHidden = new QCheckBox(tr("Hidden folders"), this);
+ - - + -
- ]
723 [ + - + - : 81 : m_syncCatWhitelist = new QCheckBox(tr("External content whitelist"), this);
+ - - + -
- ]
724 [ + - ]: 81 : m_syncCatWhitelist->setChecked(false);
725 [ + - ]: 81 : m_syncCatWhitelist->setEnabled(false);
726 [ + - ]: 81 : m_syncCatWhitelist->setToolTip(
727 [ + - ]: 162 : tr("Privacy-sensitive external-content permissions stay on this "
728 : : "device."));
729 [ + - + - : 81 : m_syncCatDav = new QCheckBox(tr("DAV configuration (CardDAV + CalDAV)"), this);
+ - - + -
- ]
730 [ + - + - : 81 : m_syncCatGeneral = new QCheckBox(tr("General settings"), this);
+ - - + -
- ]
731 [ + - ]: 81 : syncLayout->addWidget(m_syncCatIcons);
732 [ + - ]: 81 : syncLayout->addWidget(m_syncCatColors);
733 [ + - ]: 81 : syncLayout->addWidget(m_syncCatCalColors);
734 [ + - ]: 81 : syncLayout->addWidget(m_syncCatHidden);
735 [ + - ]: 81 : syncLayout->addWidget(m_syncCatWhitelist);
736 [ + - ]: 81 : syncLayout->addWidget(m_syncCatDav);
737 [ + - ]: 81 : syncLayout->addWidget(m_syncCatGeneral);
738 : :
739 : : // Separator
740 [ + - + - : 81 : auto *sepFrame2 = new QFrame(this);
- + - - ]
741 [ + - ]: 81 : sepFrame2->setFrameShape(QFrame::HLine);
742 [ + - ]: 81 : sepFrame2->setFrameShadow(QFrame::Sunken);
743 [ + - ]: 81 : syncLayout->addWidget(sepFrame2);
744 : :
745 [ + - + - : 81 : m_syncStatusLabel = new QLabel(this);
- + - - ]
746 [ + - ]: 81 : syncLayout->addWidget(m_syncStatusLabel);
747 : :
748 [ + - + - : 81 : auto *syncBtnLayout = new QHBoxLayout();
- + - - ]
749 [ + - + - : 81 : m_syncNowBtn = new QPushButton(tr("Sync now"), this);
+ - - + -
- ]
750 [ + - + - : 81 : m_syncResetBtn = new QPushButton(tr("Reset sync data"), this);
+ - - + -
- ]
751 [ + - ]: 81 : syncBtnLayout->addWidget(m_syncNowBtn);
752 [ + - ]: 81 : syncBtnLayout->addWidget(m_syncResetBtn);
753 [ + - ]: 81 : syncBtnLayout->addStretch();
754 [ + - ]: 81 : syncLayout->addLayout(syncBtnLayout);
755 : :
756 [ + - ]: 81 : syncLayout->addStretch();
757 : :
758 : : // Load sync settings
759 : : {
760 [ + - ]: 81 : QSettings ss;
761 : 162 : m_syncEnabledCheck->setChecked(
762 [ + - + - : 162 : ss.value(QStringLiteral("sync/enabled"), false).toBool());
+ - ]
763 [ + - ]: 162 : m_syncFolderEdit->setText(
764 [ + - ]: 243 : ss.value(QStringLiteral("sync/folder"),
765 : 162 : QStringLiteral("MailJD-Settings"))
766 [ + - ]: 162 : .toString());
767 : : QStringList cats =
768 [ + - ]: 243 : ss.value(QStringLiteral("sync/categories"),
769 [ + - ]: 162 : SyncPayload::allCategories())
770 [ + - ]: 81 : .toStringList();
771 [ + - ]: 162 : m_syncCatIcons->setChecked(cats.contains(QStringLiteral("folderIcons")));
772 [ + - ]: 162 : m_syncCatColors->setChecked(cats.contains(QStringLiteral("folderColors")));
773 [ + - ]: 162 : m_syncCatHidden->setChecked(
774 : 162 : cats.contains(QStringLiteral("hiddenFolders")));
775 [ + - ]: 81 : m_syncCatWhitelist->setChecked(false);
776 [ + - ]: 162 : m_syncCatCalColors->setChecked(
777 : 162 : cats.contains(QStringLiteral("calendarColors")));
778 [ + - ]: 81 : m_syncCatDav->setChecked(
779 [ + - + - : 165 : cats.contains(QStringLiteral("davAccounts")) ||
- - - - ]
780 [ + + - + : 249 : cats.contains(QStringLiteral("carddavAccounts")) ||
+ + + - -
- - - ]
781 [ + + + + : 84 : cats.contains(QStringLiteral("caldavAccounts")));
+ + - - -
- ]
782 [ + - ]: 162 : m_syncCatGeneral->setChecked(cats.contains(QStringLiteral("general")));
783 : :
784 : : QString lastSync =
785 [ + - + - ]: 81 : ss.value(QStringLiteral("sync/lastSync")).toString();
786 [ + + ]: 81 : if (!lastSync.isEmpty()) {
787 [ + - ]: 3 : QDateTime dt = QDateTime::fromString(lastSync, Qt::ISODate);
788 [ + - ]: 3 : m_syncStatusLabel->setText(
789 [ + - + - : 9 : tr("Last sync: %1").arg(dt.toLocalTime().toString()));
+ - + - ]
790 : 3 : } else {
791 [ + - + - ]: 78 : m_syncStatusLabel->setText(tr("Not yet synchronized"));
792 : : }
793 : 81 : }
794 : :
795 : : // Enable/disable category checkboxes based on sync toggle
796 : 85 : auto updateSyncWidgets = [this]() {
797 : 85 : bool enabled = m_syncEnabledCheck->isChecked();
798 : 85 : m_syncFolderEdit->setEnabled(enabled);
799 : 85 : m_syncCatIcons->setEnabled(enabled);
800 : 85 : m_syncCatColors->setEnabled(enabled);
801 : 85 : m_syncCatHidden->setEnabled(enabled);
802 : 85 : m_syncCatWhitelist->setEnabled(false);
803 : 85 : m_syncCatCalColors->setEnabled(enabled);
804 : 85 : m_syncCatDav->setEnabled(enabled);
805 : 85 : m_syncCatGeneral->setEnabled(enabled);
806 : 85 : m_syncNowBtn->setEnabled(enabled);
807 : 85 : m_syncResetBtn->setEnabled(enabled);
808 : 166 : };
809 [ + - ]: 81 : connect(m_syncEnabledCheck, &QCheckBox::toggled, this, updateSyncWidgets);
810 [ + - ]: 81 : updateSyncWidgets(); // Apply initial state
811 : :
812 [ + - ]: 81 : connect(m_syncNowBtn, &QPushButton::clicked, this, [this]() {
813 [ + - + - ]: 3 : m_syncStatusLabel->setText(tr("Synchronizing…"));
814 : 3 : m_syncNowBtn->setEnabled(false);
815 : : // Re-enable after a short delay (async operation)
816 [ + - ]: 3 : QTimer::singleShot(3000, this, [this]() {
817 [ + - ]: 2 : if (m_syncNowBtn)
818 : 2 : m_syncNowBtn->setEnabled(m_syncEnabledCheck->isChecked());
819 [ + - ]: 2 : if (m_syncStatusLabel) {
820 [ + - ]: 2 : QSettings s;
821 : : QString lastSync =
822 [ + - + - ]: 2 : s.value(QStringLiteral("sync/lastSync")).toString();
823 [ + + ]: 2 : if (!lastSync.isEmpty()) {
824 [ + - ]: 1 : QDateTime dt = QDateTime::fromString(lastSync, Qt::ISODate);
825 [ + - ]: 1 : m_syncStatusLabel->setText(
826 [ + - + - : 3 : tr("Last sync: %1").arg(dt.toLocalTime().toString()));
+ - + - ]
827 : 1 : } else {
828 [ + - + - ]: 1 : m_syncStatusLabel->setText(tr("Sync completed"));
829 : : }
830 : 2 : }
831 : 2 : });
832 : 3 : emit syncRequested();
833 : 3 : });
834 : 81 : connect(m_syncResetBtn, &QPushButton::clicked, this,
835 [ + - ]: 81 : &SettingsDialog::syncResetRequested);
836 : :
837 [ + - + - ]: 81 : m_tabs->addTab(syncTab, tr("Sync"));
838 : :
839 [ + - ]: 81 : mainLayout->addWidget(m_tabs);
840 : :
841 : : // Bottom buttons
842 [ + - + - : 81 : auto *buttonLayout = new QHBoxLayout();
- + - - ]
843 [ + - ]: 81 : buttonLayout->addStretch();
844 [ + - + - : 81 : m_cancelButton = new QPushButton(tr("Cancel"), this);
+ - - + -
- ]
845 [ + - + - : 81 : m_saveButton = new QPushButton(tr("Save"), this);
+ - - + -
- ]
846 [ + - ]: 81 : m_saveButton->setDefault(true);
847 [ + - ]: 81 : buttonLayout->addWidget(m_cancelButton);
848 [ + - ]: 81 : buttonLayout->addWidget(m_saveButton);
849 [ + - ]: 81 : mainLayout->addLayout(buttonLayout);
850 : :
851 : : // Connections
852 : 81 : connect(m_accountList, &QListWidget::currentRowChanged, this,
853 [ + - ]: 81 : &SettingsDialog::onAccountSelected);
854 : 81 : connect(m_addButton, &QPushButton::clicked, this,
855 [ + - ]: 81 : &SettingsDialog::addAccount);
856 : 81 : connect(m_deleteButton, &QPushButton::clicked, this,
857 [ + - ]: 81 : &SettingsDialog::deleteAccount);
858 [ + - ]: 81 : connect(m_saveButton, &QPushButton::clicked, this, &SettingsDialog::saveAll);
859 [ + - ]: 81 : connect(m_cancelButton, &QPushButton::clicked, this, &SettingsDialog::reject);
860 : :
861 : : // Track form changes
862 [ + - ]: 81 : connect(m_accountForm, &AccountFormWidget::formChanged, this, [this]() {
863 : 4 : m_dirty = true;
864 : : // Sync current form values back to the accounts vector
865 [ + - + - ]: 8 : if (m_currentIndex >= 0 &&
866 [ + - ]: 4 : m_currentIndex < static_cast<int>(m_accounts.size())) {
867 [ + - ]: 4 : auto editedAccount = m_accountForm->config();
868 : 4 : editedAccount.id = m_accounts[m_currentIndex].id;
869 : : editedAccount.legacyKeyringName =
870 : 4 : m_accounts[m_currentIndex].legacyKeyringName;
871 : 4 : m_accounts[m_currentIndex] = editedAccount;
872 : : // Update list item text
873 : 4 : m_accountList->item(m_currentIndex)
874 [ + - + - ]: 8 : ->setText(m_accounts[m_currentIndex].name.isEmpty()
875 [ - + - - ]: 8 : ? "(Neuer Account)"
876 : 4 : : m_accounts[m_currentIndex].name);
877 : 4 : }
878 : 4 : });
879 [ + - + - : 243 : }
+ - + - +
- + - + -
- - - - -
- - - ]
880 : :
881 : 81 : void SettingsDialog::setupShortcuts() {
882 : : // Ctrl+N = Add account
883 [ + - + - : 81 : auto *addShortcut = new QShortcut(QKeySequence("Ctrl+N"), this);
+ - - + -
- ]
884 : 81 : connect(addShortcut, &QShortcut::activated, this,
885 [ + - ]: 81 : &SettingsDialog::addAccount);
886 : :
887 : : // Delete = Delete selected account
888 [ + - - + : 81 : auto *delShortcut = new QShortcut(QKeySequence::Delete, this);
- - ]
889 [ + - ]: 81 : connect(delShortcut, &QShortcut::activated, this, [this]() {
890 [ # # ]: 0 : if (m_deleteButton->isEnabled()) {
891 : 0 : deleteAccount();
892 : : }
893 : 0 : });
894 : 81 : }
895 : :
896 : 91 : void SettingsDialog::loadAccountList() {
897 : 91 : m_accountList->clear();
898 [ + - ]: 91 : m_accounts = AccountConfigLoader::loadAll(m_configDir);
899 : 91 : m_currentIndex = -1;
900 : :
901 [ + + ]: 134 : for (const auto &acc : m_accounts) {
902 [ + - ]: 43 : m_accountList->addItem(acc.name);
903 : : }
904 : :
905 [ + + ]: 91 : if (!m_accounts.empty()) {
906 : 41 : m_accountList->setCurrentRow(0);
907 : : } else {
908 : 50 : m_accountForm->clear();
909 : 50 : m_accountForm->setEnabled(false);
910 : 50 : m_deleteButton->setEnabled(false);
911 : : }
912 : :
913 : 91 : m_dirty = false;
914 : 91 : m_accountsChanged = false;
915 : :
916 : : // Snapshot for change detection in saveAll()
917 : 91 : m_originalAccounts = m_accounts;
918 : 91 : }
919 : :
920 : 62 : void SettingsDialog::onAccountSelected(int row) {
921 [ + + + + : 62 : if (row < 0 || row >= static_cast<int>(m_accounts.size())) {
+ + ]
922 : 14 : m_accountForm->clear();
923 : 14 : m_accountForm->setEnabled(false);
924 : 14 : m_deleteButton->setEnabled(false);
925 : 14 : m_currentIndex = -1;
926 : 14 : return;
927 : : }
928 : :
929 : 48 : m_currentIndex = row;
930 : 48 : m_accountForm->setConfig(m_accounts[row]);
931 : 48 : m_accountForm->setEnabled(true);
932 : 48 : m_deleteButton->setEnabled(true);
933 : : }
934 : :
935 : 6 : void SettingsDialog::addAccount() {
936 [ + - ]: 6 : AccountConfig newAcc;
937 [ + - ]: 6 : newAcc.name = "";
938 : 6 : newAcc.imap.port = 993;
939 [ + - ]: 6 : newAcc.imap.security = "ssl";
940 : 6 : newAcc.smtp.port = 587;
941 [ + - ]: 6 : newAcc.smtp.security = "starttls";
942 : :
943 [ + - ]: 6 : m_accounts.push_back(newAcc);
944 [ + - + - ]: 6 : m_accountList->addItem(tr("(New Account)"));
945 [ + - + - ]: 6 : m_accountList->setCurrentRow(m_accountList->count() - 1);
946 : :
947 : 6 : m_dirty = true;
948 : 6 : m_accountsChanged = true;
949 [ + - ]: 6 : m_accountForm->clear();
950 [ + - ]: 6 : m_accountForm->setEnabled(true);
951 : : // Focus the name field
952 [ + - ]: 6 : m_accountForm->setFocus();
953 : 6 : }
954 : :
955 : 2 : void SettingsDialog::deleteAccount() {
956 [ + - - + ]: 4 : if (m_currentIndex < 0 ||
957 [ - + ]: 2 : m_currentIndex >= static_cast<int>(m_accounts.size())) {
958 : 1 : return;
959 : : }
960 : :
961 : 2 : auto name = m_accounts[m_currentIndex].name;
962 [ + - + - ]: 2 : auto displayName = name.isEmpty() ? tr("(New Account)") : name;
963 : :
964 [ + - + - ]: 2 : if (!m_confirm(tr("Delete Account"),
965 [ + - + - : 6 : tr("Really delete account \"%1\"?").arg(displayName))) {
+ + ]
966 : 1 : return;
967 : : }
968 : :
969 : : // Delete from disk if it was previously saved
970 [ - + ]: 1 : if (!name.isEmpty()) {
971 [ # # ]: 0 : AccountConfigLoader::remove(name, m_configDir);
972 : : }
973 : :
974 [ + - ]: 1 : m_accounts.erase(m_accounts.begin() + m_currentIndex);
975 [ + - + - ]: 1 : delete m_accountList->takeItem(m_currentIndex);
976 : :
977 : 1 : m_dirty = true;
978 : 1 : m_accountsChanged = true;
979 : :
980 [ + - ]: 1 : if (m_accounts.empty()) {
981 : 1 : m_currentIndex = -1;
982 [ + - ]: 1 : m_accountForm->clear();
983 [ + - ]: 1 : m_accountForm->setEnabled(false);
984 [ + - ]: 1 : m_deleteButton->setEnabled(false);
985 : : }
986 [ + + + + ]: 3 : }
987 : :
988 : 5 : void SettingsDialog::saveAll() {
989 : : // Sync current form values
990 [ + + + + ]: 6 : if (m_currentIndex >= 0 &&
991 [ + - ]: 1 : m_currentIndex < static_cast<int>(m_accounts.size())) {
992 [ + - ]: 1 : auto editedAccount = m_accountForm->config();
993 : 1 : editedAccount.id = m_accounts[m_currentIndex].id;
994 : : editedAccount.legacyKeyringName =
995 : 1 : m_accounts[m_currentIndex].legacyKeyringName;
996 : 1 : m_accounts[m_currentIndex] = editedAccount;
997 : 1 : }
998 : :
999 : : // Validate all accounts
1000 [ + + ]: 8 : for (size_t i = 0; i < m_accounts.size(); ++i) {
1001 [ + - ]: 4 : auto errors = AccountConfigLoader::validate(m_accounts[i]);
1002 [ + + ]: 4 : if (!errors.isEmpty()) {
1003 [ + - ]: 1 : m_accountList->setCurrentRow(static_cast<int>(i));
1004 [ + - + - ]: 1 : m_warn(tr("Validation Error"),
1005 [ + - ]: 1 : tr("Account \"%1\":\n%2")
1006 [ + - + - : 2 : .arg(m_accounts[i].name.isEmpty() ? tr("(New Account)")
+ - ]
1007 : 0 : : m_accounts[i].name)
1008 [ + - + - : 2 : .arg(errors.join("\n")));
+ - ]
1009 : 1 : return;
1010 : : }
1011 : :
1012 [ - + ]: 3 : for (size_t j = 0; j < i; ++j) {
1013 : : const bool duplicateName =
1014 : 0 : m_accounts[i].name.compare(m_accounts[j].name,
1015 : 0 : Qt::CaseInsensitive) == 0;
1016 : : const bool duplicateSlug =
1017 [ # # ]: 0 : AccountConfigLoader::slugify(m_accounts[i].name) ==
1018 [ # # ]: 0 : AccountConfigLoader::slugify(m_accounts[j].name);
1019 [ # # # # ]: 0 : if (duplicateName || duplicateSlug) {
1020 [ # # ]: 0 : m_accountList->setCurrentRow(static_cast<int>(i));
1021 [ # # # # ]: 0 : m_warn(tr("Validation Error"),
1022 [ # # ]: 0 : tr("Account names must have unique display names and file "
1023 : : "names. \"%1\" conflicts with \"%2\".")
1024 [ # # ]: 0 : .arg(m_accounts[i].name, m_accounts[j].name));
1025 : 0 : return;
1026 : : }
1027 : : }
1028 : :
1029 : : const QString uniquenessError =
1030 [ + - ]: 3 : AccountConfigLoader::checkNameUniqueness(m_accounts[i], m_configDir);
1031 [ - + ]: 3 : if (!uniquenessError.isEmpty()) {
1032 [ # # ]: 0 : m_accountList->setCurrentRow(static_cast<int>(i));
1033 [ # # # # ]: 0 : m_warn(tr("Validation Error"), uniquenessError);
1034 : 0 : return;
1035 : : }
1036 [ + - + + ]: 4 : }
1037 : :
1038 : : // Save each account
1039 : 4 : bool allSaved = true;
1040 [ + + ]: 7 : for (auto &acc : m_accounts) {
1041 [ + - - + ]: 3 : if (!AccountConfigLoader::save(acc, m_configDir)) {
1042 [ # # # # : 0 : qCWarning(lcSettings) << "Failed to save account:" << acc.name;
# # # # #
# ]
1043 : 0 : allSaved = false;
1044 : : }
1045 : : }
1046 : :
1047 [ + - ]: 4 : if (allSaved) {
1048 : : // Save general settings
1049 [ + - ]: 4 : QSettings settings;
1050 [ + - ]: 8 : settings.setValue("view/defaultMode",
1051 [ + - + - ]: 8 : m_defaultViewCombo->currentData().toString());
1052 [ + - ]: 8 : settings.setValue("view/externalContent",
1053 [ + - + - ]: 8 : m_externalContentCombo->currentData().toString());
1054 [ + - ]: 8 : settings.setValue(QStringLiteral("network/maxMessageSizeMiB"),
1055 [ + - ]: 4 : m_maxMessageSizeSpin->value());
1056 : :
1057 : : // T-306: Save language and notify for live switching
1058 [ + - + - ]: 4 : QString newLang = m_languageCombo->currentData().toString();
1059 [ + - + - : 8 : QString oldLang = settings.value("i18n/language", "auto").toString();
+ - ]
1060 [ + - ]: 8 : settings.setValue("i18n/language", newLang);
1061 [ - + ]: 4 : if (newLang != oldLang)
1062 [ # # ]: 0 : emit languageChangeRequested(newLang);
1063 : :
1064 : 4 : m_dirty = false;
1065 [ + - ]: 4 : m_accountForm->resetModified();
1066 : :
1067 : : // Detect if account data actually changed vs only view settings
1068 [ + - ]: 4 : if (!m_accountsChanged) {
1069 : : // Check if accounts were modified compared to snapshot
1070 [ + + ]: 4 : if (m_accounts.size() != m_originalAccounts.size()) {
1071 : 1 : m_accountsChanged = true;
1072 : : } else {
1073 [ + + ]: 4 : for (size_t i = 0; i < m_accounts.size(); ++i) {
1074 : 2 : const auto &a = m_accounts[i];
1075 : 2 : const auto &b = m_originalAccounts[i];
1076 [ + - ]: 4 : if (a.name != b.name || a.email != b.email ||
1077 [ + + + - ]: 2 : a.imap.host != b.imap.host || a.imap.port != b.imap.port ||
1078 [ + - ]: 1 : a.imap.security != b.imap.security ||
1079 [ + - ]: 1 : a.imap.username != b.imap.username ||
1080 [ + - ]: 1 : a.imap.password != b.imap.password ||
1081 [ + - + - ]: 1 : a.smtp.host != b.smtp.host || a.smtp.port != b.smtp.port ||
1082 [ + - ]: 1 : a.smtp.security != b.smtp.security ||
1083 [ + - + - : 5 : a.smtp.username != b.smtp.username ||
+ + ]
1084 [ - + ]: 1 : a.smtp.password != b.smtp.password) {
1085 : 1 : m_accountsChanged = true;
1086 : 1 : break;
1087 : : }
1088 : : }
1089 : : }
1090 : : }
1091 : :
1092 [ + - + - : 8 : qCInfo(lcSettings) << "Settings saved. Accounts changed:"
+ - + + ]
1093 [ + - ]: 4 : << m_accountsChanged;
1094 : :
1095 : : // T-315: Save sync settings
1096 [ + - ]: 4 : if (m_syncEnabledCheck) {
1097 [ + - ]: 4 : settings.setValue(QStringLiteral("sync/enabled"),
1098 [ + - ]: 4 : m_syncEnabledCheck->isChecked());
1099 [ + - ]: 8 : settings.setValue(
1100 : 8 : QStringLiteral("sync/folder"),
1101 [ + - + - ]: 8 : m_syncFolderEdit->text().trimmed().isEmpty()
1102 [ - + + - : 16 : ? QStringLiteral("MailJD-Settings")
- - ]
1103 [ + - + - : 8 : : m_syncFolderEdit->text().trimmed());
- + - - ]
1104 : :
1105 : 4 : QStringList cats;
1106 [ + - + - ]: 4 : if (m_syncCatIcons->isChecked())
1107 [ + - ]: 4 : cats << QStringLiteral("folderIcons");
1108 [ + - + - ]: 4 : if (m_syncCatColors->isChecked())
1109 [ + - ]: 4 : cats << QStringLiteral("folderColors");
1110 [ + - + - ]: 4 : if (m_syncCatHidden->isChecked())
1111 [ + - ]: 4 : cats << QStringLiteral("hiddenFolders");
1112 [ + - + - ]: 4 : if (m_syncCatCalColors->isChecked())
1113 [ + - ]: 4 : cats << QStringLiteral("calendarColors");
1114 [ + - + - ]: 4 : if (m_syncCatDav->isChecked())
1115 [ + - ]: 4 : cats << QStringLiteral("davAccounts");
1116 [ + - + - ]: 4 : if (m_syncCatGeneral->isChecked())
1117 [ + - ]: 4 : cats << QStringLiteral("general");
1118 [ + - ]: 8 : settings.setValue(QStringLiteral("sync/categories"), cats);
1119 : :
1120 : : // Generate clientId on first enable
1121 [ + - + - : 8 : if (m_syncEnabledCheck->isChecked() &&
+ + ]
1122 [ + - + - : 8 : settings.value(QStringLiteral("sync/clientId")).toString().isEmpty()) {
+ - + - +
- + - + +
- - - - -
- - - ]
1123 [ + - ]: 2 : settings.setValue(QStringLiteral("sync/clientId"),
1124 [ + - + - ]: 2 : QUuid::createUuid().toString(QUuid::WithoutBraces));
1125 : : }
1126 : :
1127 [ + - ]: 4 : emit syncSettingsChanged();
1128 : 4 : }
1129 : :
1130 [ + - ]: 4 : accept();
1131 : 4 : } else {
1132 [ # # # # : 0 : m_warn(tr("Error"), tr("Not all accounts could be saved."));
# # ]
1133 : : }
1134 : : }
1135 : :
1136 : 5 : bool SettingsDialog::hasUnsavedChanges() const {
1137 [ + + + - : 5 : return m_dirty || (m_accountForm && m_accountForm->isModified());
- + ]
1138 : : }
1139 : :
1140 : 2 : void SettingsDialog::reject() {
1141 [ + + ]: 2 : if (hasUnsavedChanges()) {
1142 [ + - + - ]: 1 : if (!m_confirm(tr("Unsaved Changes"),
1143 [ + - + - ]: 2 : tr("There are unsaved changes. Really close?"))) {
1144 : 1 : return;
1145 : : }
1146 : : }
1147 : 1 : QDialog::reject();
1148 : : }
1149 : :
1150 : : // T-122: Whitelist tab support
1151 : 7 : void SettingsDialog::setCache(MailCache *cache) {
1152 : 7 : m_cache = cache;
1153 : 7 : loadWhitelistTable();
1154 : 7 : }
1155 : :
1156 : 9 : void SettingsDialog::loadWhitelistTable() {
1157 [ + - ]: 9 : m_whitelistTable->setRowCount(0);
1158 [ - + ]: 9 : if (!m_cache) return;
1159 : :
1160 [ + - ]: 9 : auto entries = m_cache->whitelistEntries();
1161 [ + - ]: 9 : m_whitelistTable->setRowCount(entries.size());
1162 : :
1163 [ + + ]: 12 : for (int i = 0; i < entries.size(); ++i) {
1164 [ + - ]: 3 : const auto &e = entries[i];
1165 : : auto *typeItem = new QTableWidgetItem(
1166 [ + - + + : 3 : e.type == "sender" ? tr("Sender") : tr("Domain"));
+ - + - +
- - + -
- ]
1167 [ + - ]: 3 : typeItem->setData(Qt::UserRole, e.id);
1168 [ + - ]: 3 : m_whitelistTable->setItem(i, 0, typeItem);
1169 [ + - + - : 3 : m_whitelistTable->setItem(i, 1, new QTableWidgetItem(e.value));
+ - - + -
- ]
1170 [ + - + - : 3 : m_whitelistTable->setItem(i, 2, new QTableWidgetItem(e.createdAt));
+ - - + -
- ]
1171 : : }
1172 : 9 : }
1173 : :
1174 : 6 : void SettingsDialog::setContactStore(ContactStore *store) {
1175 : 6 : m_contactStore = store;
1176 : 6 : }
1177 : :
1178 : 4 : void SettingsDialog::setCalendarStore(CalendarStore *store) {
1179 : 4 : m_calendarStore = store;
1180 : : // Re-populate calendar list now that we have the store for color lookups
1181 : : // (constructor runs onCalAccountSelected before setCalendarStore is called)
1182 [ + - + - : 4 : if (m_calAccountCombo && m_calAccountCombo->count() > 0)
+ - ]
1183 : 4 : onCalAccountSelected(m_calAccountCombo->currentIndex());
1184 : 4 : }
1185 : :
1186 : : // ═══════════════════════════════════════════════════════
1187 : : // Multi-server CardDAV helpers
1188 : : // ═══════════════════════════════════════════════════════
1189 : :
1190 : 81 : void SettingsDialog::loadCdAccounts() {
1191 : 81 : m_cdAccounts.clear();
1192 [ + - ]: 81 : QSettings s;
1193 [ + - ]: 81 : int count = s.beginReadArray("carddav/accounts");
1194 [ + + ]: 96 : for (int i = 0; i < count; ++i) {
1195 [ + - ]: 15 : s.setArrayIndex(i);
1196 : 15 : CdAccount acc;
1197 [ + - + - ]: 15 : acc.id = s.value("id").toString();
1198 [ - + ]: 15 : if (acc.id.isEmpty()) {
1199 [ # # # # ]: 0 : acc.id = QUuid::createUuid().toString(QUuid::WithoutBraces);
1200 [ # # ]: 0 : s.setValue(QStringLiteral("id"), acc.id);
1201 : : }
1202 [ + - + - ]: 15 : acc.serverUrl = s.value("serverUrl").toString();
1203 [ + - + - ]: 15 : acc.username = s.value("username").toString();
1204 : 15 : acc.requiresLocalAuthorization =
1205 [ + - + - ]: 30 : s.value(QStringLiteral("requiresLocalAuthorization"), false).toBool();
1206 [ + - + - ]: 15 : const QString plaintextPassword = s.value("password").toString();
1207 [ + + + - : 15 : if (!plaintextPassword.isEmpty() && !acc.requiresLocalAuthorization) {
+ + ]
1208 : 2 : QString keyringError;
1209 [ + - ]: 2 : if (DavCredentials::writePasswordBlocking(
1210 : : acc.id, acc.serverUrl, acc.username,
1211 [ + - + + ]: 4 : plaintextPassword.toUtf8(), &keyringError)) {
1212 : 1 : acc.password = plaintextPassword;
1213 [ + - ]: 1 : s.remove(QStringLiteral("password"));
1214 : : } else {
1215 : 1 : acc.password = plaintextPassword;
1216 [ + - + - : 2 : qCWarning(lcSettings)
+ + ]
1217 [ + - ]: 1 : << "Failed to migrate DAV password to keyring for"
1218 [ + - + - : 1 : << acc.id << ":" << keyringError;
+ - ]
1219 : : }
1220 [ + - ]: 15 : } else if (!acc.requiresLocalAuthorization) {
1221 [ + - ]: 13 : acc.password = cdPasswordForAccount(acc);
1222 : : }
1223 [ + - + - ]: 15 : acc.selectedBooks = s.value("selectedBooks").toStringList();
1224 : : // Load discoveredBooks as interleaved path/name pairs
1225 [ + - + - ]: 15 : QStringList dbPairs = s.value("discoveredBookPairs").toStringList();
1226 [ + + ]: 17 : for (int j = 0; j + 1 < dbPairs.size(); j += 2)
1227 [ + - + - : 2 : acc.discoveredBooks.insert(dbPairs[j], dbPairs[j + 1]);
+ - ]
1228 [ + - ]: 15 : m_cdAccounts.push_back(acc);
1229 : 15 : }
1230 [ + - ]: 81 : s.endArray();
1231 [ + - ]: 81 : s.sync();
1232 [ + - + - : 81 : if (!QFile::setPermissions(s.fileName(),
+ + ]
1233 : : QFileDevice::ReadOwner | QFileDevice::WriteOwner)) {
1234 [ + - + - : 36 : qCWarning(lcSettings) << "Failed to restrict settings permissions for"
+ - + + ]
1235 [ + - + - ]: 18 : << s.fileName();
1236 : : }
1237 : :
1238 : : // Migrate legacy single-server settings if present
1239 [ + + ]: 81 : if (m_cdAccounts.empty()) {
1240 [ + - + - ]: 67 : QString legacy = s.value("carddav/serverUrl").toString();
1241 [ + + ]: 67 : if (!legacy.isEmpty()) {
1242 : 2 : CdAccount acc;
1243 [ + - + - ]: 2 : acc.id = QUuid::createUuid().toString(QUuid::WithoutBraces);
1244 : 2 : acc.serverUrl = legacy;
1245 [ + - + - ]: 2 : acc.username = s.value("carddav/username").toString();
1246 [ + - + - ]: 2 : const QString legacyPassword = s.value("carddav/password").toString();
1247 [ + - ]: 2 : if (!legacyPassword.isEmpty()) {
1248 : 2 : QString keyringError;
1249 [ + - ]: 2 : if (DavCredentials::writePasswordBlocking(
1250 : : acc.id, acc.serverUrl, acc.username,
1251 [ + - + + ]: 4 : legacyPassword.toUtf8(), &keyringError)) {
1252 : 1 : acc.password = legacyPassword;
1253 [ + - ]: 1 : m_cdAccounts.push_back(acc);
1254 [ + - + - ]: 1 : if (saveCdAccounts()) {
1255 [ + - ]: 1 : s.remove("carddav/serverUrl");
1256 [ + - ]: 1 : s.remove("carddav/username");
1257 [ + - ]: 1 : s.remove("carddav/password");
1258 : : }
1259 : : } else {
1260 : 1 : acc.password = legacyPassword;
1261 [ + - + - : 2 : qCWarning(lcSettings)
+ + ]
1262 [ + - ]: 1 : << "Failed to migrate legacy DAV password to keyring:"
1263 [ + - ]: 1 : << keyringError;
1264 [ + - ]: 1 : m_cdAccounts.push_back(acc);
1265 : : }
1266 : 2 : } else {
1267 [ # # ]: 0 : m_cdAccounts.push_back(acc);
1268 [ # # # # ]: 0 : if (saveCdAccounts()) {
1269 [ # # ]: 0 : s.remove("carddav/serverUrl");
1270 [ # # ]: 0 : s.remove("carddav/username");
1271 [ # # ]: 0 : s.remove("carddav/password");
1272 : : }
1273 : : }
1274 : 2 : }
1275 : 67 : }
1276 : 81 : }
1277 : :
1278 : 13 : bool SettingsDialog::saveCdAccounts() {
1279 : : // Commit secrets before metadata. A failed keyring write leaves the
1280 : : // existing QSettings array untouched, so a newly entered password cannot
1281 : : // disappear behind an apparently successful dialog save.
1282 [ + + ]: 25 : for (const auto &acc : m_cdAccounts) {
1283 [ + + ]: 13 : if (acc.password.isEmpty())
1284 : 4 : continue;
1285 : 9 : QString keyringError;
1286 : 27 : if (!DavCredentials::writePasswordBlocking(
1287 [ + - + - : 9 : acc.id, acc.serverUrl, acc.username, acc.password.toUtf8(),
+ + ]
1288 : : &keyringError)) {
1289 [ + - + - : 2 : qCWarning(lcSettings) << "Refusing to save DAV settings after keyring"
+ + ]
1290 [ + - ]: 1 : " write failed for"
1291 [ + - + - : 1 : << acc.id << ":" << keyringError;
+ - ]
1292 [ + - + - ]: 1 : m_warn(tr("Credential Storage Error"),
1293 [ + - ]: 2 : tr("Could not securely store the password for DAV account "
1294 : : "\"%1\":\n%2\n\nNo DAV settings were saved.")
1295 [ + - ]: 2 : .arg(acc.serverUrl, keyringError));
1296 : 1 : return false;
1297 : : }
1298 [ + + ]: 9 : }
1299 : :
1300 [ + - ]: 12 : QSettings s;
1301 [ + - ]: 12 : s.remove(QStringLiteral("carddav/accounts"));
1302 [ + - ]: 24 : s.beginWriteArray("carddav/accounts", static_cast<int>(m_cdAccounts.size()));
1303 [ + + ]: 24 : for (int i = 0; i < static_cast<int>(m_cdAccounts.size()); ++i) {
1304 [ + - ]: 12 : s.setArrayIndex(i);
1305 : 12 : const auto &acc = m_cdAccounts[i];
1306 [ + - ]: 24 : s.setValue("id", acc.id);
1307 [ + - ]: 24 : s.setValue("serverUrl", acc.serverUrl);
1308 [ + - ]: 24 : s.setValue("username", acc.username);
1309 [ + - ]: 12 : s.remove(QStringLiteral("password"));
1310 [ - + ]: 12 : if (acc.requiresLocalAuthorization) {
1311 [ # # ]: 0 : s.setValue(QStringLiteral("requiresLocalAuthorization"), true);
1312 : : } else {
1313 [ + - ]: 12 : s.remove(QStringLiteral("requiresLocalAuthorization"));
1314 : : }
1315 [ + - ]: 24 : s.setValue("selectedBooks", acc.selectedBooks);
1316 : : // Save discoveredBooks as interleaved path/name pairs
1317 : 12 : QStringList dbPairs;
1318 [ + - ]: 12 : for (auto it = acc.discoveredBooks.constBegin();
1319 [ + - + + ]: 17 : it != acc.discoveredBooks.constEnd(); ++it) {
1320 [ + - ]: 5 : dbPairs.append(it.key());
1321 [ + - ]: 5 : dbPairs.append(it.value());
1322 : : }
1323 [ + - ]: 24 : s.setValue("discoveredBookPairs", dbPairs);
1324 : 12 : }
1325 [ + - ]: 12 : s.endArray();
1326 [ + - ]: 12 : s.sync();
1327 [ + - - + ]: 12 : if (s.status() != QSettings::NoError) {
1328 [ # # # # : 0 : qCWarning(lcSettings) << "Failed to persist DAV account settings:"
# # # # ]
1329 [ # # # # ]: 0 : << s.status();
1330 [ # # # # ]: 0 : m_warn(tr("Settings Error"),
1331 [ # # ]: 0 : tr("The DAV account settings could not be written."));
1332 : 0 : return false;
1333 : : }
1334 : : // SEC-2026-07-21-24: Harden the INI file permissions to 0600. When the
1335 : : // keyring migration fails, the DAV plaintext password is intentionally
1336 : : // retained in QSettings as fail-safe; the default umask-dependent perms
1337 : : // (typically 0644) make it world-readable on multi-user systems.
1338 [ + - + - : 12 : if (!QFile::setPermissions(s.fileName(),
- + ]
1339 : : QFileDevice::ReadOwner | QFileDevice::WriteOwner)) {
1340 [ # # # # : 0 : qCWarning(lcSettings) << "Failed to restrict settings permissions for"
# # # # ]
1341 [ # # # # ]: 0 : << s.fileName();
1342 [ # # # # ]: 0 : m_warn(tr("Settings Error"),
1343 [ # # ]: 0 : tr("The settings file permissions could not be secured."));
1344 : 0 : return false;
1345 : : }
1346 : 12 : return true;
1347 : 12 : }
1348 : :
1349 : 79 : QString SettingsDialog::cdPasswordForAccount(const CdAccount &account) const {
1350 [ - + ]: 79 : if (account.requiresLocalAuthorization)
1351 : 0 : return {};
1352 [ + + ]: 79 : if (!account.password.isEmpty())
1353 : 32 : return account.password;
1354 : :
1355 : 47 : QString keyringError;
1356 : : const QByteArray password = DavCredentials::readPasswordBlocking(
1357 [ + - ]: 47 : account.id, account.serverUrl, account.username, &keyringError);
1358 [ + + + - : 47 : if (password.isEmpty() && !keyringError.isEmpty()) {
+ + ]
1359 [ + - + - : 80 : qCWarning(lcSettings)
+ + ]
1360 [ + - ]: 40 : << "Failed to read DAV password from keyring for"
1361 [ + - + - : 40 : << account.id << ":" << keyringError;
+ - ]
1362 : : }
1363 [ + - ]: 47 : return QString::fromUtf8(password);
1364 : 47 : }
1365 : :
1366 : 60 : bool SettingsDialog::cdAccountHasLocalCredentials(
1367 : : const CdAccount &account) const {
1368 [ + + + + : 60 : if (account.serverUrl.isEmpty() || account.username.isEmpty())
+ + ]
1369 : 6 : return false;
1370 [ + - ]: 54 : return !cdPasswordForAccount(account).isEmpty();
1371 : : }
1372 : :
1373 : 6 : int SettingsDialog::findCdAccountIndexById(const QString &accountId) const {
1374 [ + - ]: 7 : for (int i = 0; i < static_cast<int>(m_cdAccounts.size()); ++i) {
1375 [ + + ]: 7 : if (m_cdAccounts[i].id == accountId)
1376 : 6 : return i;
1377 : : }
1378 : 0 : return -1;
1379 : : }
1380 : :
1381 : 94 : void SettingsDialog::updateCdAccountTable() {
1382 : : // Block signals to prevent cascading currentCellChanged during rebuild
1383 : 94 : m_cdAccountTable->blockSignals(true);
1384 : 94 : m_cdAccountTable->setRowCount(0);
1385 [ + + ]: 124 : for (int i = 0; i < static_cast<int>(m_cdAccounts.size()); ++i) {
1386 : 30 : const auto &acc = m_cdAccounts[i];
1387 : 30 : m_cdAccountTable->insertRow(i);
1388 [ + - - - ]: 30 : m_cdAccountTable->setItem(
1389 : 30 : i, 0, new QTableWidgetItem(acc.serverUrl.isEmpty()
1390 [ + + + - : 60 : ? tr("(New)") : acc.serverUrl));
+ - - + ]
1391 [ + - - - ]: 30 : m_cdAccountTable->setItem(
1392 : 30 : i, 1, new QTableWidgetItem(acc.username.isEmpty()
1393 [ + + + - : 63 : ? QStringLiteral("\u2014") : acc.username));
+ + - + -
- ]
1394 : : }
1395 : 94 : m_cdAccountTable->blockSignals(false);
1396 : 94 : m_cdSyncBtn->setEnabled(!m_cdAccounts.empty());
1397 : 94 : }
1398 : :
1399 : 56 : void SettingsDialog::onCdAccountSelected(int row) {
1400 : 56 : m_cdCurrentIdx = row;
1401 : : // Block signals to prevent itemChanged during population
1402 : 56 : m_cdBookList->blockSignals(true);
1403 : 56 : m_cdBookList->clear();
1404 : 56 : m_cdBookList->blockSignals(false);
1405 : 56 : m_cdRemoveBtn->setEnabled(row >= 0);
1406 : :
1407 [ + + - + : 56 : if (row < 0 || row >= static_cast<int>(m_cdAccounts.size())) {
+ + ]
1408 : 2 : m_cdDiscoverBtn->setEnabled(false);
1409 [ + - ]: 2 : if (m_cdAuthorizeBtn)
1410 : 2 : m_cdAuthorizeBtn->setEnabled(false);
1411 : 2 : return;
1412 : : }
1413 : :
1414 : 54 : const auto &acc = m_cdAccounts[row];
1415 : :
1416 : : // Sprint 73: derive action state from metadata + local secret instead of
1417 : : // username alone. A synced account carries metadata but no keyring entry,
1418 : : // so it must be locally authorized before discovery/sync can run.
1419 [ + + + + ]: 54 : const bool hasMetadata = !acc.username.isEmpty() && !acc.serverUrl.isEmpty();
1420 : 54 : const bool hasLocalSecret = cdAccountHasLocalCredentials(acc);
1421 : 54 : m_cdDiscoverBtn->setEnabled(hasMetadata);
1422 [ + - ]: 54 : if (m_cdAuthorizeBtn)
1423 : 54 : m_cdAuthorizeBtn->setEnabled(hasMetadata);
1424 : :
1425 [ + + + + ]: 54 : if (hasMetadata && !hasLocalSecret) {
1426 [ + - + - ]: 25 : m_cdStatusLabel->setText(tr(
1427 : : "This DAV account was synchronized without credentials. Authorize it "
1428 : : "on this device before discovery or sync."));
1429 [ + + ]: 29 : } else if (acc.username.isEmpty()) {
1430 [ + - + - ]: 3 : m_cdStatusLabel->setText(tr("Please log in first."));
1431 : : }
1432 : :
1433 : : // Populate book list from ALL discovered books (with names)
1434 : 54 : m_cdBookList->blockSignals(true);
1435 [ + - ]: 54 : for (auto it = acc.discoveredBooks.constBegin();
1436 [ + - + + ]: 62 : it != acc.discoveredBooks.constEnd(); ++it) {
1437 : 8 : QString path = it.key();
1438 : 8 : QString displayName = it.value();
1439 : : auto *item = new QListWidgetItem(
1440 [ + - - + : 8 : displayName.isEmpty() ? path : displayName, m_cdBookList);
+ - - + -
- ]
1441 [ + - ]: 8 : item->setFlags(item->flags() | Qt::ItemIsUserCheckable);
1442 [ + + + - ]: 8 : item->setCheckState(acc.selectedBooks.contains(path)
1443 : : ? Qt::Checked : Qt::Unchecked);
1444 [ + - ]: 8 : item->setData(Qt::UserRole, path);
1445 [ + - ]: 8 : item->setToolTip(path);
1446 : 8 : }
1447 : 54 : m_cdBookList->blockSignals(false);
1448 : : }
1449 : :
1450 : 2 : void SettingsDialog::addCdAccount() {
1451 : 2 : CdAccount acc;
1452 [ + - + - ]: 2 : acc.id = QUuid::createUuid().toString(QUuid::WithoutBraces);
1453 [ + - ]: 2 : m_cdAccounts.push_back(acc);
1454 [ + - ]: 2 : updateCdAccountTable();
1455 : :
1456 : 2 : int row = static_cast<int>(m_cdAccounts.size()) - 1;
1457 [ + - ]: 2 : m_cdAccountTable->setCurrentCell(row, 0);
1458 : 2 : m_cdCurrentIdx = row;
1459 : :
1460 : : // Ask for server URL via input dialog
1461 : 2 : bool ok = false;
1462 : : QString url = m_promptText(
1463 [ + - ]: 4 : tr("New DAV Server"),
1464 [ + - + - ]: 6 : tr("Server URL (e.g. https://cloud.example.com):"), QString(), &ok);
1465 [ + + + - : 2 : if (!ok || url.trimmed().isEmpty()) {
- + + + +
+ - - ]
1466 : : // Remove the placeholder entry
1467 : 1 : m_cdAccounts.pop_back();
1468 [ + - ]: 1 : updateCdAccountTable();
1469 : 1 : return;
1470 : : }
1471 : :
1472 [ + - ]: 1 : acc.serverUrl = url.trimmed();
1473 : 1 : m_cdAccounts.back().serverUrl = acc.serverUrl;
1474 [ + - - + ]: 1 : if (!saveCdAccounts()) {
1475 : 0 : m_cdAccounts.pop_back();
1476 [ # # ]: 0 : updateCdAccountTable();
1477 : 0 : return;
1478 : : }
1479 [ + - ]: 1 : updateCdAccountTable();
1480 [ + - ]: 1 : m_cdAccountTable->setCurrentCell(row, 0);
1481 : :
1482 : : // Start Nextcloud login
1483 [ + - + - ]: 1 : m_cdStatusLabel->setText(tr("Waiting for login in browser\u2026"));
1484 [ + - ]: 1 : m_cdAddBtn->setEnabled(false);
1485 : 1 : m_pendingDavAuthAccountId = acc.id;
1486 [ + - ]: 1 : m_ncAuth->startLogin(acc.serverUrl);
1487 [ + + + + ]: 3 : }
1488 : :
1489 : 3 : void SettingsDialog::authorizeCdAccount() {
1490 [ + - - + ]: 6 : if (m_cdCurrentIdx < 0 ||
1491 [ - + ]: 3 : m_cdCurrentIdx >= static_cast<int>(m_cdAccounts.size()))
1492 : 1 : return;
1493 : :
1494 : 3 : CdAccount acc = m_cdAccounts[m_cdCurrentIdx];
1495 : :
1496 : : // A synced account may arrive without a server URL; ask for it in place
1497 : : // rather than forcing a delete + re-add.
1498 [ + + ]: 3 : if (acc.serverUrl.isEmpty()) {
1499 : 1 : bool ok = false;
1500 : : QString url = m_promptText(
1501 [ + - ]: 2 : tr("Authorize DAV Account"),
1502 : 1 : tr("Server URL (e.g. https://cloud.example.com):"),
1503 [ + - + - ]: 2 : acc.serverUrl, &ok);
1504 [ - + - - : 1 : if (!ok || url.trimmed().isEmpty())
- - - + +
- - - ]
1505 : 1 : return;
1506 [ # # ]: 0 : acc.serverUrl = url.trimmed();
1507 : 0 : const CdAccount previous = m_cdAccounts[m_cdCurrentIdx];
1508 : 0 : m_cdAccounts[m_cdCurrentIdx].serverUrl = acc.serverUrl;
1509 [ # # ]: 0 : if (DavCredentials::accountName(previous.id, previous.serverUrl,
1510 : 0 : previous.username) !=
1511 [ # # # # ]: 0 : DavCredentials::accountName(previous.id, acc.serverUrl,
1512 : 0 : previous.username)) {
1513 : 0 : m_cdAccounts[m_cdCurrentIdx].password.clear();
1514 : 0 : m_cdAccounts[m_cdCurrentIdx].requiresLocalAuthorization = true;
1515 : : }
1516 [ # # # # ]: 0 : if (!saveCdAccounts()) {
1517 : 0 : m_cdAccounts[m_cdCurrentIdx] = previous;
1518 : 0 : return;
1519 : : }
1520 [ # # ]: 0 : if (m_cdAccounts[m_cdCurrentIdx].requiresLocalAuthorization) {
1521 : 0 : QString cleanupError;
1522 [ # # ]: 0 : if (!DavCredentials::deletePasswordBlocking(
1523 : : previous.id, previous.serverUrl, previous.username,
1524 [ # # # # : 0 : &cleanupError) && !previous.serverUrl.isEmpty()) {
# # ]
1525 [ # # # # : 0 : qCWarning(lcSettings)
# # ]
1526 [ # # ]: 0 : << "Failed to remove prior endpoint-bound DAV secret for"
1527 [ # # # # : 0 : << previous.id << ":" << cleanupError;
# # ]
1528 : : }
1529 : 0 : }
1530 [ # # ]: 0 : updateCdAccountTable();
1531 [ - - - + ]: 1 : }
1532 : :
1533 : : // Capture the stable account ID so loginSuccess writes credentials to the
1534 : : // right account even if the user changes the selection during the async
1535 : : // browser login flow. The account ID stays stable, preserving any synced
1536 : : // caldav/configs references and selected books/calendars.
1537 : 2 : m_pendingDavAuthAccountId = acc.id;
1538 [ + - + - ]: 2 : m_cdStatusLabel->setText(tr("Waiting for login in browser\u2026"));
1539 [ + - ]: 2 : m_cdAddBtn->setEnabled(false);
1540 [ + - ]: 2 : m_ncAuth->startLogin(acc.serverUrl);
1541 [ + + ]: 3 : }
1542 : :
1543 : 2 : void SettingsDialog::removeCdAccount() {
1544 [ + - - + ]: 4 : if (m_cdCurrentIdx < 0 ||
1545 [ - + ]: 2 : m_cdCurrentIdx >= static_cast<int>(m_cdAccounts.size()))
1546 : 1 : return;
1547 : :
1548 [ + - + - ]: 2 : if (!m_confirm(tr("Remove Server"),
1549 [ + - ]: 2 : tr("Really remove server \"%1\"?")
1550 [ + - + + ]: 4 : .arg(m_cdAccounts[m_cdCurrentIdx].serverUrl)))
1551 : 1 : return;
1552 : :
1553 : 1 : const auto account = m_cdAccounts[m_cdCurrentIdx];
1554 : 1 : const int removedIndex = m_cdCurrentIdx;
1555 [ + - ]: 1 : m_cdAccounts.erase(m_cdAccounts.begin() + removedIndex);
1556 [ + - - + ]: 1 : if (!saveCdAccounts()) {
1557 [ # # ]: 0 : m_cdAccounts.insert(m_cdAccounts.begin() + removedIndex, account);
1558 : 0 : return;
1559 : : }
1560 : :
1561 : 1 : QString keyringError;
1562 [ + - - + ]: 1 : if (!DavCredentials::deletePasswordBlocking(
1563 : : account.id, account.serverUrl, account.username, &keyringError)) {
1564 [ # # # # : 0 : qCWarning(lcSettings) << "Failed to delete orphaned DAV keyring secret for"
# # # # ]
1565 [ # # # # : 0 : << account.id << ":" << keyringError;
# # ]
1566 : : }
1567 [ + - ]: 1 : updateCdAccountTable();
1568 [ + - ]: 1 : m_cdBookList->clear();
1569 : 1 : m_cdCurrentIdx = -1;
1570 [ + - ]: 1 : }
1571 : :
1572 : 4 : void SettingsDialog::discoverBooks() {
1573 [ + - - + ]: 8 : if (m_cdCurrentIdx < 0 ||
1574 [ - + ]: 4 : m_cdCurrentIdx >= static_cast<int>(m_cdAccounts.size()))
1575 : 2 : return;
1576 : 4 : const auto &acc = m_cdAccounts[m_cdCurrentIdx];
1577 : :
1578 : : // Distinguish the missing-credential cases so the user sees a clear,
1579 : : // actionable message instead of a generic network error.
1580 : : // - Metadata incomplete (no URL/username) → ask to log in first.
1581 : : // - Metadata present but no local keyring secret → this is a synced
1582 : : // account that needs local authorization. Do NOT start CardDavClient.
1583 [ + - + + : 4 : if (acc.serverUrl.isEmpty() || acc.username.isEmpty()) {
+ + ]
1584 [ + - + - ]: 1 : m_cdStatusLabel->setText(tr("Please log in first."));
1585 : 1 : return;
1586 : : }
1587 : :
1588 [ + - ]: 3 : const QString password = cdPasswordForAccount(acc);
1589 [ + + ]: 3 : if (password.isEmpty()) {
1590 [ + - + - ]: 1 : m_cdStatusLabel->setText(tr(
1591 : : "This DAV account was synchronized without credentials. Authorize it "
1592 : : "on this device before discovery or sync."));
1593 [ + - ]: 1 : if (m_cdAuthorizeBtn)
1594 [ + - ]: 1 : m_cdAuthorizeBtn->setEnabled(true);
1595 : 1 : return;
1596 : : }
1597 : :
1598 [ + - + - ]: 2 : m_cdStatusLabel->setText(tr("Searching address books\u2026"));
1599 [ + - ]: 2 : m_cdDiscoverBtn->setEnabled(false);
1600 : :
1601 [ + - + - : 2 : auto *client = new CardDavClient(acc.serverUrl, acc.username, password, this);
- + - - ]
1602 [ + - ]: 2 : if (m_testNam)
1603 [ + - ]: 2 : client->setNetworkAccessManager(m_testNam);
1604 : 2 : int idx = m_cdCurrentIdx; // capture by value
1605 : :
1606 : 2 : connect(client, &CardDavClient::addressBooksDiscovered, this,
1607 [ + - ]: 2 : [this, client, idx](const QList<AddressBookInfo> &books) {
1608 [ + - ]: 1 : m_cdBookList->clear();
1609 [ + - - + : 1 : if (idx < 0 || idx >= static_cast<int>(m_cdAccounts.size())) {
- + ]
1610 [ # # ]: 0 : client->deleteLater();
1611 : 0 : return;
1612 : : }
1613 : 1 : auto &accRef = m_cdAccounts[idx];
1614 : : // Save discovered books with display names
1615 : : const QMap<QString, QString> previousBooks =
1616 : 1 : accRef.discoveredBooks;
1617 [ + - ]: 1 : accRef.discoveredBooks.clear();
1618 [ + + ]: 2 : for (const auto &book : books)
1619 [ + - ]: 1 : accRef.discoveredBooks.insert(book.path, book.displayName);
1620 [ + - - + ]: 1 : if (!saveCdAccounts()) {
1621 : 0 : accRef.discoveredBooks = previousBooks;
1622 [ # # ]: 0 : m_cdDiscoverBtn->setEnabled(true);
1623 [ # # ]: 0 : client->deleteLater();
1624 : 0 : return;
1625 : : }
1626 : :
1627 : 1 : const auto &selected = accRef.selectedBooks;
1628 : : // Block itemChanged during population
1629 : 1 : m_cdBookList->blockSignals(true);
1630 [ + + ]: 2 : for (const auto &book : books) {
1631 : : auto *item = new QListWidgetItem(
1632 : 1 : book.displayName.isEmpty() ? book.path : book.displayName,
1633 [ + - - + : 1 : m_cdBookList);
+ - - + -
- ]
1634 [ + - ]: 1 : item->setFlags(item->flags() | Qt::ItemIsUserCheckable);
1635 [ - + + - ]: 1 : item->setCheckState(selected.contains(book.path)
1636 : : ? Qt::Checked : Qt::Unchecked);
1637 [ + - ]: 1 : item->setData(Qt::UserRole, book.path);
1638 [ + - ]: 1 : item->setToolTip(book.path);
1639 : : }
1640 : 1 : m_cdBookList->blockSignals(false);
1641 [ + - ]: 2 : m_cdStatusLabel->setText(
1642 [ + - ]: 3 : QStringLiteral("%1 Adressbücher gefunden.").arg(books.size()));
1643 [ + - ]: 1 : m_cdDiscoverBtn->setEnabled(true);
1644 [ + - ]: 1 : client->deleteLater();
1645 [ + - ]: 1 : });
1646 : :
1647 : 2 : connect(client, &CardDavClient::syncFailed, this,
1648 [ + - ]: 2 : [this, client](const QString &error) {
1649 [ + - ]: 1 : m_cdStatusLabel->setText(
1650 [ + - + - ]: 3 : tr("Error: %1").arg(error));
1651 : 1 : m_cdDiscoverBtn->setEnabled(true);
1652 : 1 : client->deleteLater();
1653 : 1 : });
1654 : :
1655 [ + - ]: 2 : client->discoverAddressBooks();
1656 [ + + ]: 3 : }
1657 : :
1658 : 6 : void SettingsDialog::syncAllAccounts() {
1659 [ + + ]: 6 : if (m_cdAccounts.empty()) {
1660 [ + - + - ]: 1 : m_cdStatusLabel->setText(tr("No servers configured."));
1661 : 3 : return;
1662 : : }
1663 : :
1664 [ + - ]: 5 : m_cdSyncBtn->setEnabled(false);
1665 [ + - + - ]: 5 : m_cdStatusLabel->setText(tr("Synchronizing\u2026"));
1666 : :
1667 : : struct SyncTarget {
1668 : : QString accountId;
1669 : : QString serverUrl;
1670 : : QString username;
1671 : : QString password;
1672 : : QString bookPath;
1673 : : };
1674 : :
1675 : 5 : std::vector<SyncTarget> targets;
1676 : 5 : bool incompleteSync = false;
1677 : 5 : int missingCredentialSkips = 0;
1678 [ + + ]: 10 : for (const auto &acc : m_cdAccounts) {
1679 [ + + ]: 5 : if (acc.selectedBooks.isEmpty())
1680 : 2 : continue;
1681 [ + - - + : 4 : if (acc.serverUrl.isEmpty() || acc.username.isEmpty()) {
- + ]
1682 : 0 : incompleteSync = true;
1683 : 0 : continue;
1684 : : }
1685 : :
1686 [ + - ]: 4 : const QString password = cdPasswordForAccount(acc);
1687 [ + + ]: 4 : if (password.isEmpty()) {
1688 : 1 : incompleteSync = true;
1689 : 1 : ++missingCredentialSkips;
1690 : : // Log the reason with account ID + server URL only; never the secret.
1691 [ + - + - : 2 : qCWarning(lcSettings)
+ + ]
1692 [ + - ]: 1 : << "Skipping CardDAV account without local credentials; needs"
1693 [ + - + - ]: 1 : << "local authorization: id=" << acc.id
1694 [ + - + - ]: 1 : << "server=" << acc.serverUrl;
1695 : 1 : continue;
1696 : 1 : }
1697 : :
1698 [ + + ]: 6 : for (const auto &bookPath : acc.selectedBooks) {
1699 : 3 : targets.push_back(
1700 : 9 : {acc.id, acc.serverUrl, acc.username, password, bookPath});
1701 : : }
1702 [ + + ]: 4 : }
1703 [ + + ]: 5 : if (targets.empty()) {
1704 : : // Distinguish "authorize the synced account" from "you have not selected
1705 : : // any books yet". When all selected accounts are skipped because they
1706 : : // lack a local secret, point the user at the authorize action.
1707 [ + + ]: 2 : if (missingCredentialSkips > 0) {
1708 [ + - + - ]: 1 : m_cdStatusLabel->setText(tr(
1709 : : "Selected address books belong to a DAV account that was "
1710 : : "synchronized without credentials. Authorize it on this device "
1711 : : "before sync."));
1712 : : } else {
1713 [ + - ]: 1 : m_cdStatusLabel->setText(
1714 [ + - ]: 2 : tr("No address books selected. Please discover and select first."));
1715 : : }
1716 [ + - ]: 2 : m_cdSyncBtn->setEnabled(true);
1717 : 2 : return;
1718 : : }
1719 : :
1720 : : struct SyncState {
1721 : : int remaining = 0;
1722 : : int totalContacts = 0;
1723 : : int totalSkipped = 0;
1724 : : bool hadErrors = false;
1725 : : bool incomplete = false;
1726 : : QStringList activeUids;
1727 : : };
1728 : :
1729 : : auto state = std::make_shared<SyncState>(
1730 : 6 : SyncState{static_cast<int>(targets.size()), 0, 0, false,
1731 [ + - ]: 3 : incompleteSync, {}});
1732 : :
1733 : 3 : auto finishSync = [this, state]() {
1734 : 3 : --state->remaining;
1735 [ - + ]: 3 : if (state->remaining > 0)
1736 : 0 : return;
1737 : :
1738 [ + + - + : 3 : if (state->hadErrors || state->incomplete) {
+ + ]
1739 [ + - ]: 1 : m_cdStatusLabel->setText(
1740 [ + - ]: 1 : tr("%1 contacts synchronized (some errors).")
1741 [ + - ]: 2 : .arg(state->totalContacts));
1742 : : } else {
1743 [ + + ]: 2 : if (m_contactStore) {
1744 [ + - ]: 1 : state->activeUids.removeDuplicates();
1745 : : // SEC-2026-07-21-17: don't allow empty-list deletion — a valid-but-
1746 : : // empty 207 response must not wipe the local contact cache.
1747 [ + - ]: 1 : m_contactStore->removeStaleCardDavContacts(state->activeUids,
1748 : : false);
1749 : : }
1750 : 4 : QString msg = QStringLiteral(
1751 [ + - ]: 2 : "%1 contacts synchronized").arg(state->totalContacts);
1752 [ + + ]: 2 : if (state->totalSkipped > 0)
1753 : 2 : msg += QStringLiteral(
1754 [ + - + - ]: 2 : " (%1 without email skipped)").arg(state->totalSkipped);
1755 [ + - ]: 2 : msg += QLatin1Char('.');
1756 [ + - ]: 2 : m_cdStatusLabel->setText(msg);
1757 : 2 : }
1758 : :
1759 : 3 : m_cdSyncBtn->setEnabled(true);
1760 : 3 : };
1761 : :
1762 [ + + ]: 6 : for (const auto &target : targets) {
1763 : : auto *client = new CardDavClient(
1764 [ + - + - : 3 : target.serverUrl, target.username, target.password, this);
- + - - ]
1765 [ + - ]: 3 : if (m_testNam)
1766 [ + - ]: 3 : client->setNetworkAccessManager(m_testNam);
1767 : :
1768 : 3 : connect(client, &CardDavClient::contactsSynced, this,
1769 [ + - - - : 6 : [this, client, state, target, finishSync](
- - ]
1770 : : const QList<Contact> &contacts, int skippedNoEmail) {
1771 [ + + ]: 2 : if (m_contactStore) {
1772 [ + + ]: 2 : for (const auto &c : contacts) {
1773 : 1 : Contact scoped = c;
1774 : : scoped.cardDavUid =
1775 : 1 : cardDavContactIdentity(target.accountId,
1776 [ + - ]: 1 : target.bookPath, c);
1777 [ + - ]: 1 : m_contactStore->upsertCardDavContact(scoped);
1778 [ + - ]: 1 : state->activeUids.append(scoped.cardDavUid);
1779 : 1 : }
1780 : : }
1781 : 2 : state->totalContacts += contacts.size();
1782 : 2 : state->totalSkipped += skippedNoEmail;
1783 : 2 : finishSync();
1784 : 2 : client->deleteLater();
1785 : 2 : });
1786 : :
1787 : 3 : connect(client, &CardDavClient::syncFailed, this,
1788 [ + - - - ]: 6 : [this, client, state, finishSync](
1789 : : const QString &error) {
1790 [ + - + - : 2 : qCWarning(lcSettings) << "Sync failed:" << error;
+ - + - +
+ ]
1791 : 1 : state->hadErrors = true;
1792 : 1 : finishSync();
1793 : 1 : client->deleteLater();
1794 : 1 : });
1795 : :
1796 [ + - ]: 3 : client->syncAddressBook(target.bookPath);
1797 : : }
1798 [ + - + + : 8 : }
- - - - -
- - - ]
1799 : :
1800 : : // T-304: Runtime language switching
1801 : 142 : void SettingsDialog::changeEvent(QEvent *event) {
1802 [ + + ]: 142 : if (event->type() == QEvent::LanguageChange)
1803 : 5 : retranslateUi();
1804 : 142 : QDialog::changeEvent(event);
1805 : 142 : }
1806 : :
1807 : 5 : void SettingsDialog::retranslateUi() {
1808 [ + - + - ]: 5 : setWindowTitle(tr("Settings"));
1809 [ + - + - ]: 5 : m_tabs->setTabText(0, tr("Accounts"));
1810 [ + - + - ]: 5 : m_tabs->setTabText(1, tr("General"));
1811 [ + - + - ]: 5 : m_tabs->setTabText(2, tr("Whitelist"));
1812 [ + - + - ]: 5 : m_tabs->setTabText(3, tr("DAV accounts"));
1813 [ + - + - ]: 5 : m_tabs->setTabText(4, tr("Sync"));
1814 [ + - + - ]: 5 : m_saveButton->setText(tr("Save"));
1815 [ + - + - ]: 5 : m_cancelButton->setText(tr("Cancel"));
1816 [ + - + - ]: 5 : m_addButton->setText(tr("Add"));
1817 [ + - + - ]: 5 : m_deleteButton->setText(tr("Delete"));
1818 : : // Sync tab
1819 [ + - ]: 5 : if (m_syncEnabledCheck)
1820 [ + - + - ]: 5 : m_syncEnabledCheck->setText(tr("Synchronize settings"));
1821 [ + - ]: 5 : if (m_syncNowBtn)
1822 [ + - + - ]: 5 : m_syncNowBtn->setText(tr("Sync now"));
1823 [ + - ]: 5 : if (m_syncResetBtn)
1824 [ + - + - ]: 5 : m_syncResetBtn->setText(tr("Reset sync data"));
1825 : 5 : }
1826 : :
1827 : : // ═══════════════════════════════════════════════════════
1828 : : // T-335: Kalender tab helpers (Sprint 32)
1829 : : // ═══════════════════════════════════════════════════════
1830 : :
1831 : 84 : void SettingsDialog::loadCalConfigs() {
1832 : 84 : m_calConfigs.clear();
1833 [ + - ]: 84 : QSettings s;
1834 [ + - ]: 84 : int count = s.beginReadArray(QStringLiteral("caldav/configs"));
1835 [ + + ]: 97 : for (int i = 0; i < count; ++i) {
1836 [ + - ]: 13 : s.setArrayIndex(i);
1837 : 13 : CalConfig cfg;
1838 : : cfg.carddavAccountId =
1839 [ + - + - ]: 13 : s.value(QStringLiteral("carddavAccountId")).toString();
1840 : : cfg.selectedCalendars =
1841 [ + - + - ]: 13 : s.value(QStringLiteral("selectedCalendars")).toStringList();
1842 : : cfg.readOnlyCalendars =
1843 [ + - + - ]: 13 : s.value(QStringLiteral("readOnlyCalendars")).toStringList();
1844 : 13 : cfg.syncIntervalMinutes =
1845 [ + - + - ]: 26 : s.value(QStringLiteral("syncIntervalMin"), 15).toInt();
1846 : : // Load discovered calendar pairs
1847 : : QStringList pairs =
1848 [ + - + - ]: 13 : s.value(QStringLiteral("discoveredCalPairs")).toStringList();
1849 [ + + ]: 24 : for (int j = 0; j + 1 < pairs.size(); j += 2)
1850 [ + - + - : 11 : cfg.discoveredCalendars.insert(pairs[j], pairs[j + 1]);
+ - ]
1851 [ + - ]: 13 : m_calConfigs.push_back(cfg);
1852 : 13 : }
1853 [ + - ]: 84 : s.endArray();
1854 : 84 : }
1855 : :
1856 : 27 : void SettingsDialog::saveCalConfigs() {
1857 [ + - ]: 27 : QSettings s;
1858 [ + - ]: 54 : s.beginWriteArray(QStringLiteral("caldav/configs"),
1859 : 27 : static_cast<int>(m_calConfigs.size()));
1860 [ + + ]: 55 : for (int i = 0; i < static_cast<int>(m_calConfigs.size()); ++i) {
1861 [ + - ]: 28 : s.setArrayIndex(i);
1862 : 28 : const auto &cfg = m_calConfigs[i];
1863 [ + - ]: 56 : s.setValue(QStringLiteral("carddavAccountId"), cfg.carddavAccountId);
1864 [ + - ]: 56 : s.setValue(QStringLiteral("selectedCalendars"), cfg.selectedCalendars);
1865 [ + - ]: 56 : s.setValue(QStringLiteral("readOnlyCalendars"), cfg.readOnlyCalendars);
1866 [ + - ]: 56 : s.setValue(QStringLiteral("syncIntervalMin"), cfg.syncIntervalMinutes);
1867 : 28 : QStringList pairs;
1868 [ + - ]: 28 : for (auto it = cfg.discoveredCalendars.cbegin();
1869 [ + - + + ]: 35 : it != cfg.discoveredCalendars.cend(); ++it) {
1870 [ + - + - ]: 7 : pairs << it.key() << it.value();
1871 : : }
1872 [ + - ]: 56 : s.setValue(QStringLiteral("discoveredCalPairs"), pairs);
1873 : 28 : }
1874 [ + - ]: 27 : s.endArray();
1875 : 27 : }
1876 : :
1877 : 46 : int SettingsDialog::findCalConfigForAccount(const QString &accountId) const {
1878 [ + + ]: 53 : for (int i = 0; i < static_cast<int>(m_calConfigs.size()); ++i) {
1879 [ + + ]: 28 : if (m_calConfigs[i].carddavAccountId == accountId)
1880 : 21 : return i;
1881 : : }
1882 : 25 : return -1;
1883 : : }
1884 : :
1885 : 34 : void SettingsDialog::onCalAccountSelected(int index) {
1886 [ + - ]: 34 : m_calCalendarList->clear();
1887 [ + - + - : 34 : if (index < 0 || index >= m_calAccountCombo->count())
- + - + ]
1888 : 0 : return;
1889 : :
1890 [ + - + - ]: 34 : QString accId = m_calAccountCombo->itemData(index).toString();
1891 [ + - ]: 34 : int cfgIdx = findCalConfigForAccount(accId);
1892 : :
1893 : : // Auto-create config if none exists
1894 [ + + ]: 34 : if (cfgIdx < 0) {
1895 : 21 : CalConfig newCfg;
1896 : 21 : newCfg.carddavAccountId = accId;
1897 [ + - ]: 21 : m_calConfigs.push_back(newCfg);
1898 : 21 : cfgIdx = static_cast<int>(m_calConfigs.size()) - 1;
1899 [ + - ]: 21 : saveCalConfigs();
1900 : 21 : }
1901 : :
1902 : 34 : const auto &cfg = m_calConfigs[cfgIdx];
1903 : :
1904 : : // Fallback color palette (shared, 67.B3: ThemeManager owns all colors)
1905 [ + - ]: 34 : const QStringList palette = ThemeManager::calendarPalette();
1906 : :
1907 : : // Populate calendar list from discovered calendars
1908 : 34 : m_calCalendarList->blockSignals(true);
1909 [ + - ]: 34 : for (auto it = cfg.discoveredCalendars.cbegin();
1910 [ + - + + ]: 43 : it != cfg.discoveredCalendars.cend(); ++it) {
1911 [ + - + - : 9 : auto *item = new QListWidgetItem(it.value(), m_calCalendarList);
- + - - ]
1912 [ + - ]: 9 : item->setData(Qt::UserRole, it.key());
1913 [ + - ]: 9 : item->setFlags(item->flags() | Qt::ItemIsUserCheckable);
1914 [ + + + - ]: 9 : item->setCheckState(cfg.selectedCalendars.contains(it.key())
1915 : : ? Qt::Checked
1916 : : : Qt::Unchecked);
1917 : :
1918 : : // Color swatch icon
1919 : 9 : QString colorStr;
1920 [ + + ]: 9 : if (m_calendarStore)
1921 [ + - ]: 2 : colorStr = m_calendarStore->calendarColor(it.key());
1922 : 9 : QColor color = colorStr.isEmpty()
1923 [ + + ]: 9 : ? QColor(palette.at(qHash(it.key()) % palette.size()))
1924 : 9 : : QColor(colorStr);
1925 [ + - ]: 9 : QPixmap px(14, 14);
1926 [ + - ]: 9 : px.fill(color);
1927 [ + - + - ]: 9 : item->setIcon(QIcon(px));
1928 : 9 : }
1929 : 34 : m_calCalendarList->blockSignals(false);
1930 : :
1931 : : // Double-click → color edit
1932 [ + - + - ]: 34 : m_calCalendarList->disconnect(SIGNAL(itemDoubleClicked(QListWidgetItem*)));
1933 : 34 : connect(m_calCalendarList, &QListWidget::itemDoubleClicked, this,
1934 [ + - ]: 34 : [this](QListWidgetItem *item) {
1935 [ - + ]: 2 : if (!m_calendarStore) return;
1936 [ + - + - ]: 2 : QString path = item->data(Qt::UserRole).toString();
1937 [ + - + - : 2 : QColor current = item->icon().pixmap(14, 14).toImage().pixelColor(7, 7);
+ - + - ]
1938 [ + - ]: 4 : QColor chosen = QColorDialog::getColor(current, this,
1939 [ + - ]: 4 : tr("Choose calendar color"));
1940 [ + + ]: 2 : if (chosen.isValid()) {
1941 [ + - + - ]: 1 : m_calendarStore->setCalendarColor(path, chosen.name());
1942 [ + - ]: 1 : QPixmap px(14, 14);
1943 [ + - ]: 1 : px.fill(chosen);
1944 [ + - + - ]: 1 : item->setIcon(QIcon(px));
1945 : 1 : }
1946 : 2 : });
1947 : :
1948 : : // Context menu → edit / reset color
1949 [ + - ]: 34 : m_calCalendarList->setContextMenuPolicy(Qt::CustomContextMenu);
1950 [ + - + - ]: 34 : m_calCalendarList->disconnect(SIGNAL(customContextMenuRequested(QPoint)));
1951 : 34 : connect(m_calCalendarList, &QListWidget::customContextMenuRequested, this,
1952 [ + - ]: 34 : [this](const QPoint &pos) {
1953 [ + - ]: 3 : auto *item = m_calCalendarList->itemAt(pos);
1954 [ + - - + ]: 4 : if (!item || !m_calendarStore) return;
1955 [ + - + - ]: 3 : QString path = item->data(Qt::UserRole).toString();
1956 : : // Sprint 75: QListWidget is a QAbstractScrollArea; the
1957 : : // customContextMenuRequested signal delivers viewport-local
1958 : : // coordinates. Map via viewport()->mapToGlobal() (not the
1959 : : // widget map) so the menu opens under the cursor rather
1960 : : // than offset by the 1px frame.
1961 : : const QPoint globalPos =
1962 [ + - + - ]: 3 : m_calCalendarList->viewport()->mapToGlobal(pos);
1963 : :
1964 : : // Sprint 75: opt-in test seam. Record the exec coordinate
1965 : : // and return without showing the menu — keeps unit tests
1966 : : // deterministic without relying on QMenu's post-adjustment
1967 : : // screen geometry.
1968 [ + + ]: 3 : if (m_interceptCalMenuExec) {
1969 : 1 : m_lastCalMenuExecGlobalPos = globalPos;
1970 : 1 : return;
1971 : : }
1972 : :
1973 [ + - ]: 2 : QMenu menu;
1974 [ + - + - ]: 2 : menu.addAction(tr("Choose color…"), [this, item, path]() {
1975 [ + - + - : 2 : QColor current = item->icon().pixmap(14, 14).toImage().pixelColor(7, 7);
+ - + - ]
1976 [ + - ]: 4 : QColor chosen = QColorDialog::getColor(current, this,
1977 [ + - ]: 4 : tr("Choose calendar color"));
1978 [ + + ]: 2 : if (chosen.isValid()) {
1979 [ + - + - ]: 1 : m_calendarStore->setCalendarColor(path, chosen.name());
1980 [ + - ]: 1 : QPixmap px(14, 14);
1981 [ + - ]: 1 : px.fill(chosen);
1982 [ + - + - ]: 1 : item->setIcon(QIcon(px));
1983 : 1 : }
1984 : 2 : });
1985 [ + - + - ]: 2 : menu.addAction(tr("Reset to server color"), [this, item, path]() {
1986 [ + - ]: 1 : m_calendarStore->setCalendarColor(path, QString());
1987 : : // Revert to palette fallback (shared palette, 67.B3)
1988 [ + - ]: 1 : const QStringList palette = ThemeManager::calendarPalette();
1989 : 1 : QColor fallback(palette.at(qHash(path) % palette.size()));
1990 [ + - ]: 1 : QPixmap px(14, 14);
1991 [ + - ]: 1 : px.fill(fallback);
1992 [ + - + - ]: 1 : item->setIcon(QIcon(px));
1993 : 1 : });
1994 [ + - ]: 2 : menu.exec(globalPos);
1995 [ + + ]: 3 : });
1996 : :
1997 [ + - ]: 34 : m_calDiscoverBtn->setEnabled(true);
1998 [ + - ]: 34 : m_calSyncBtn->setEnabled(!cfg.selectedCalendars.isEmpty());
1999 : :
2000 : : // Sprint 73: when the selected DAV account has no discovered calendars yet,
2001 : : // tell the user that discovery runs per account. This makes the multi-server
2002 : : // model obvious: each DAV account needs its own calendar discovery.
2003 [ + - + + ]: 34 : if (cfg.discoveredCalendars.isEmpty()) {
2004 [ + - ]: 26 : m_calStatusLabel->setText(
2005 [ + - ]: 52 : tr("No calendars discovered yet for this DAV account. "
2006 : : "Click Discover Calendars."));
2007 : : }
2008 : 34 : }
2009 : :
2010 : 7 : void SettingsDialog::discoverCalendars() {
2011 : : // No DAV account configured at all.
2012 [ + - + - : 7 : if (m_calAccountCombo->count() == 0 || m_calAccountCombo->currentIndex() < 0) {
+ - - + -
+ ]
2013 [ # # # # ]: 0 : m_calStatusLabel->setText(tr("No DAV account configured."));
2014 : 4 : return;
2015 : : }
2016 : :
2017 [ + - + - ]: 7 : QString accId = m_calAccountCombo->currentData().toString();
2018 : :
2019 : : // Find the selected DAV account.
2020 : 7 : const CdAccount *accPtr = nullptr;
2021 [ + + ]: 7 : for (const auto &acc : m_cdAccounts) {
2022 [ + - ]: 5 : if (acc.id == accId) {
2023 : 5 : accPtr = &acc;
2024 : 5 : break;
2025 : : }
2026 : : }
2027 [ + + ]: 7 : if (!accPtr) {
2028 [ + - + - ]: 2 : m_calStatusLabel->setText(tr("No DAV account configured."));
2029 : 2 : return;
2030 : : }
2031 : :
2032 : : // Metadata incomplete: account exists but was never logged in.
2033 [ + - - + : 5 : if (accPtr->serverUrl.isEmpty() || accPtr->username.isEmpty()) {
- + ]
2034 [ # # # # ]: 0 : m_calStatusLabel->setText(tr("Please log in first."));
2035 : 0 : return;
2036 : : }
2037 : :
2038 : : // Metadata present but no local keyring secret: synced account needs local
2039 : : // authorization. Do NOT start CalDavClient.
2040 [ + - ]: 5 : const QString password = cdPasswordForAccount(*accPtr);
2041 [ + + ]: 5 : if (password.isEmpty()) {
2042 [ + - + - ]: 2 : m_calStatusLabel->setText(tr(
2043 : : "This DAV account was synchronized without credentials. Authorize it "
2044 : : "on this device before discovery or sync."));
2045 : 2 : return;
2046 : : }
2047 : :
2048 [ + - + - ]: 3 : m_calStatusLabel->setText(tr("Discovering calendars…"));
2049 [ + - ]: 3 : m_calDiscoverBtn->setEnabled(false);
2050 : :
2051 [ + - + - : 3 : auto *client = new CalDavClient(accPtr->serverUrl, accPtr->username, password, this);
- + - - ]
2052 [ + + ]: 3 : if (m_testNam)
2053 [ + - ]: 1 : client->setNetworkAccessManager(m_testNam);
2054 : :
2055 : 3 : connect(client, &CalDavClient::calendarsDiscovered, this,
2056 [ + - - - ]: 6 : [this, accId, client](const QList<CalendarInfo> &calendars) {
2057 : 2 : int cfgIdx = findCalConfigForAccount(accId);
2058 [ - + ]: 2 : if (cfgIdx < 0) {
2059 : 0 : CalConfig newCfg;
2060 : 0 : newCfg.carddavAccountId = accId;
2061 [ # # ]: 0 : m_calConfigs.push_back(newCfg);
2062 : 0 : cfgIdx = static_cast<int>(m_calConfigs.size()) - 1;
2063 : 0 : }
2064 : 2 : auto &cfg = m_calConfigs[cfgIdx];
2065 : 2 : cfg.discoveredCalendars.clear();
2066 [ + + ]: 4 : for (const auto &cal : calendars)
2067 [ + - ]: 2 : cfg.discoveredCalendars.insert(cal.path, cal.displayName);
2068 : 2 : saveCalConfigs();
2069 : :
2070 [ + - ]: 2 : m_calStatusLabel->setText(
2071 [ + - + - ]: 6 : tr("Found %1 calendar(s).").arg(calendars.size()));
2072 : 2 : m_calDiscoverBtn->setEnabled(true);
2073 : :
2074 : : // Refresh list
2075 : 2 : onCalAccountSelected(m_calAccountCombo->currentIndex());
2076 : :
2077 : 2 : client->deleteLater();
2078 : 2 : });
2079 : :
2080 : 3 : connect(client, &CalDavClient::syncFailed, this,
2081 [ + - ]: 3 : [this, client](const QString &error) {
2082 [ + - + - : 2 : m_calStatusLabel->setText(tr("Discovery failed: %1").arg(error));
+ - ]
2083 : 1 : m_calDiscoverBtn->setEnabled(true);
2084 : 1 : client->deleteLater();
2085 : 1 : });
2086 : :
2087 [ + - ]: 3 : client->discoverCalendars();
2088 [ + + + + ]: 9 : }
2089 : :
2090 : 1 : void SettingsDialog::syncCalendars() {
2091 [ + - + - ]: 1 : m_calStatusLabel->setText(tr("Syncing calendars…"));
2092 : 1 : m_calSyncBtn->setEnabled(false);
2093 : 1 : emit calendarSyncRequested();
2094 : :
2095 [ + - ]: 1 : QTimer::singleShot(1000, this, [this]() {
2096 [ + - + - ]: 1 : m_calStatusLabel->setText(tr("Sync triggered. Check main window."));
2097 : 1 : m_calSyncBtn->setEnabled(true);
2098 : 1 : });
2099 : 1 : }
|