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