Branch data Line data Source code
1 : : #include "FolderSubscriptionDialog.h"
2 : :
3 : : #include <QDialogButtonBox>
4 : : #include <QDir>
5 : : #include <QFile>
6 : : #include <QHBoxLayout>
7 : : #include <QJsonArray>
8 : : #include <QJsonDocument>
9 : : #include <QJsonObject>
10 : : #include <QLabel>
11 : : #include <QListWidget>
12 : : #include <QLoggingCategory>
13 : : #include <QPushButton>
14 : : #include <QSaveFile>
15 : : #include <QTreeWidget>
16 : : #include <QVBoxLayout>
17 : : #include <QEvent>
18 : :
19 [ + + + - : 93 : Q_LOGGING_CATEGORY(lcSubscription, "mailjd.subscription")
+ - - - ]
20 : :
21 : : // ═══════════════════════════════════════════════════════
22 : : // FolderSubscriptionDialog
23 : : // ═══════════════════════════════════════════════════════
24 : :
25 : : static const QString SUBSCRIPTIONS_FILENAME =
26 : : QStringLiteral("subscriptions.json");
27 : :
28 : : namespace {
29 : :
30 : 39 : QJsonObject loadSubscriptionObject(const QString &filePath) {
31 [ + - ]: 39 : QFile readFile(filePath);
32 [ + - + + ]: 39 : if (!readFile.open(QIODevice::ReadOnly))
33 [ + - ]: 10 : return {};
34 : :
35 [ + - + - ]: 29 : auto doc = QJsonDocument::fromJson(readFile.readAll());
36 [ + - + - : 29 : return doc.isObject() ? doc.object() : QJsonObject{};
+ - - - ]
37 : 39 : }
38 : :
39 : 39 : bool writeSubscriptionObject(const QString &filePath,
40 : : const QJsonObject &object) {
41 [ + - ]: 39 : QSaveFile file(filePath);
42 [ + - - + ]: 39 : if (!file.open(QIODevice::WriteOnly)) {
43 [ # # # # : 0 : qCWarning(lcSubscription) << "Failed to write" << filePath
# # # # #
# ]
44 [ # # # # ]: 0 : << file.errorString();
45 : 0 : return false;
46 : : }
47 : :
48 [ + - + - ]: 39 : const QByteArray data = QJsonDocument(object).toJson(QJsonDocument::Indented);
49 [ + - - + ]: 39 : if (file.write(data) != data.size()) {
50 [ # # # # : 0 : qCWarning(lcSubscription) << "Failed to write complete subscriptions file"
# # # # ]
51 [ # # # # : 0 : << filePath << file.errorString();
# # ]
52 [ # # ]: 0 : file.cancelWriting();
53 : 0 : return false;
54 : : }
55 : :
56 [ + - - + ]: 39 : if (!file.commit()) {
57 [ # # # # : 0 : qCWarning(lcSubscription) << "Failed to commit subscriptions file"
# # # # ]
58 [ # # # # : 0 : << filePath << file.errorString();
# # ]
59 : 0 : return false;
60 : : }
61 : :
62 : 39 : return true;
63 : 39 : }
64 : :
65 : : } // namespace
66 : :
67 : 13 : FolderSubscriptionDialog::FolderSubscriptionDialog(
68 : : const QStringList &allFolders, const QStringList &subscribedFolders,
69 : 13 : const QStringList &hiddenFolders, QWidget *parent)
70 [ + - ]: 13 : : QDialog(parent), m_hidden(hiddenFolders) {
71 [ + - + - ]: 13 : setWindowTitle(tr("Folder Subscriptions"));
72 [ + - ]: 13 : setMinimumSize(400, 500);
73 : :
74 [ + - + - : 13 : auto *layout = new QVBoxLayout(this);
- + - - ]
75 : :
76 : : auto *label = new QLabel(
77 : 26 : QStringLiteral("Abonnierte Ordner werden regelmäßig auf neue "
78 : : "Nachrichten geprüft:"),
79 [ + - + - : 39 : this);
- + - - ]
80 [ + - ]: 13 : label->setWordWrap(true);
81 [ + - ]: 13 : layout->addWidget(label);
82 : :
83 : : // T-068: Select All / Select None buttons
84 [ + - + - : 13 : auto *buttonRow = new QHBoxLayout();
- + - - ]
85 [ + - + - : 26 : auto *selectAll = new QPushButton(QStringLiteral("Alle auswählen"), this);
- + - - ]
86 [ + - + - : 26 : auto *selectNone = new QPushButton(QStringLiteral("Keine auswählen"), this);
- + - - ]
87 : 13 : connect(selectAll, &QPushButton::clicked, this,
88 [ + - ]: 13 : [this]() { setCheckAll(Qt::Checked); });
89 : 13 : connect(selectNone, &QPushButton::clicked, this,
90 [ + - ]: 13 : [this]() { setCheckAll(Qt::Unchecked); });
91 [ + - ]: 13 : buttonRow->addWidget(selectAll);
92 [ + - ]: 13 : buttonRow->addWidget(selectNone);
93 [ + - ]: 13 : buttonRow->addStretch();
94 [ + - ]: 13 : layout->addLayout(buttonRow);
95 : :
96 [ + - + - : 13 : m_tree = new QTreeWidget(this);
- + - - ]
97 [ + - ]: 13 : m_tree->setHeaderHidden(true);
98 [ + - ]: 13 : m_tree->setRootIsDecorated(true); // T-068
99 [ + - ]: 13 : layout->addWidget(m_tree);
100 : :
101 [ + - ]: 13 : buildTree(allFolders, subscribedFolders);
102 : :
103 : : // T-077: Hidden folders section
104 [ + + ]: 13 : if (!hiddenFolders.isEmpty()) {
105 : : auto *hiddenLabel = new QLabel(
106 [ + - + - : 10 : QStringLiteral("Ausgeblendete Ordner:"), this);
- + - - ]
107 [ + - ]: 5 : layout->addWidget(hiddenLabel);
108 : :
109 [ + - + - : 5 : m_hiddenList = new QListWidget(this);
- + - - ]
110 [ + - ]: 5 : m_hiddenList->setMaximumHeight(120);
111 [ + + ]: 13 : for (const auto &h : hiddenFolders) {
112 [ + - ]: 8 : m_hiddenList->addItem(h);
113 : : }
114 [ + - ]: 5 : layout->addWidget(m_hiddenList);
115 : :
116 : : auto *unhideBtn =
117 [ + - + - : 10 : new QPushButton(QStringLiteral("Einblenden"), this);
- + - - ]
118 [ + - ]: 5 : connect(unhideBtn, &QPushButton::clicked, this, [this]() {
119 [ + - ]: 2 : auto *item = m_hiddenList->currentItem();
120 [ + + ]: 2 : if (!item)
121 : 1 : return;
122 [ + - ]: 1 : QString path = item->text();
123 [ + - ]: 1 : m_hidden.removeAll(path);
124 [ + - + - : 1 : delete m_hiddenList->takeItem(m_hiddenList->row(item));
+ - ]
125 : 1 : });
126 [ + - + - : 5 : auto *unhideRow = new QHBoxLayout();
- + - - ]
127 [ + - ]: 5 : unhideRow->addWidget(unhideBtn);
128 [ + - ]: 5 : unhideRow->addStretch();
129 [ + - ]: 5 : layout->addLayout(unhideRow);
130 : : }
131 : :
132 : : auto *buttons =
133 : : new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel,
134 [ + - + - : 13 : this);
- + - - ]
135 [ + - ]: 13 : connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
136 [ + - ]: 13 : connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
137 [ + - ]: 13 : layout->addWidget(buttons);
138 : 13 : }
139 : :
140 : 13 : void FolderSubscriptionDialog::buildTree(const QStringList &allFolders,
141 : : const QStringList &subscribed) {
142 [ + - ]: 13 : QSet<QString> subscribedSet(subscribed.begin(), subscribed.end());
143 : :
144 : : // T-068: Build hierarchical tree.
145 : : // We need to detect the delimiter for each folder path.
146 : : // Common delimiters: "." and "/"
147 : 13 : QMap<QString, QTreeWidgetItem *> pathToItem;
148 : :
149 : : // Sort by path length so parents are created before children
150 : 13 : QStringList sorted = allFolders;
151 [ + - + - : 13 : std::sort(sorted.begin(), sorted.end(),
+ - ]
152 : 115 : [](const QString &a, const QString &b) {
153 : 115 : return a.length() < b.length();
154 : : });
155 : :
156 [ + - + - : 71 : for (const auto &folder : sorted) {
+ + ]
157 : : // Detect delimiter (prefer / over .)
158 : 58 : QChar delim = '.';
159 [ + - + + ]: 58 : if (folder.contains('/')) {
160 : 5 : delim = '/';
161 : : }
162 : :
163 : : // Extract display name (last segment)
164 [ + - ]: 58 : QString displayName = folder.section(delim, -1);
165 [ - + ]: 58 : if (displayName.isEmpty())
166 : 0 : displayName = folder;
167 : :
168 : : // Find or create parent
169 [ + - ]: 58 : QString parentPath = folder.section(delim, 0, -2);
170 : 58 : QTreeWidgetItem *parentItem = nullptr;
171 [ + + + - : 58 : if (!parentPath.isEmpty() && parentPath != folder) {
+ + ]
172 [ + - ]: 12 : auto it = pathToItem.find(parentPath);
173 [ + - + + ]: 12 : if (it != pathToItem.end()) {
174 : 11 : parentItem = it.value();
175 : : } else {
176 : : // Create intermediate parent
177 [ + - ]: 1 : QString parentName = parentPath.section(delim, -1);
178 [ - + ]: 1 : if (parentName.isEmpty())
179 : 0 : parentName = parentPath;
180 [ + - + - : 1 : auto *intermediate = new QTreeWidgetItem(m_tree);
- + - - ]
181 [ + - ]: 1 : intermediate->setText(0, parentName);
182 [ + - ]: 1 : intermediate->setData(0, Qt::UserRole, parentPath);
183 [ + - + - ]: 1 : intermediate->setFlags(intermediate->flags() | Qt::ItemIsUserCheckable);
184 [ + - ]: 1 : intermediate->setCheckState(0, Qt::Unchecked);
185 [ + - ]: 1 : pathToItem[parentPath] = intermediate;
186 : 1 : parentItem = intermediate;
187 : 1 : }
188 : : }
189 : :
190 [ + + + - : 174 : auto *item = parentItem ? new QTreeWidgetItem(parentItem)
+ - - + +
+ + - - -
- - - - -
- ]
191 [ + - + - : 104 : : new QTreeWidgetItem(m_tree);
+ + ]
192 [ + - ]: 58 : item->setText(0, displayName);
193 [ + - ]: 58 : item->setData(0, Qt::UserRole, folder); // store full path
194 [ + - + - ]: 58 : item->setFlags(item->flags() | Qt::ItemIsUserCheckable);
195 [ + + + - ]: 58 : item->setCheckState(0, subscribedSet.contains(folder) ? Qt::Checked
196 : : : Qt::Unchecked);
197 [ + - ]: 58 : pathToItem[folder] = item;
198 : 58 : }
199 : :
200 [ + - ]: 13 : m_tree->expandAll();
201 : :
202 : : // T-204: Propagate check state between parent and child items
203 : 13 : connect(m_tree, &QTreeWidget::itemChanged, this,
204 [ + - ]: 13 : [this](QTreeWidgetItem *item, int column) {
205 [ - + ]: 15 : if (column != 0)
206 : 0 : return;
207 : :
208 : : // Block signals to avoid recursive triggers
209 : 15 : m_tree->blockSignals(true);
210 : :
211 [ + - ]: 15 : auto state = item->checkState(0);
212 : :
213 : : // Propagate to children: set all children to same state
214 : 15 : std::function<void(QTreeWidgetItem *, Qt::CheckState)> setChildren;
215 : 45 : setChildren = [&setChildren](QTreeWidgetItem *parent,
216 : : Qt::CheckState cs) {
217 [ - + ]: 15 : for (int i = 0; i < parent->childCount(); ++i) {
218 : 0 : auto *child = parent->child(i);
219 : 0 : child->setCheckState(0, cs);
220 : 0 : setChildren(child, cs);
221 : : }
222 : 15 : };
223 : :
224 [ + - ]: 15 : if (state != Qt::PartiallyChecked) {
225 [ + - ]: 15 : setChildren(item, state);
226 : : }
227 : :
228 : : // Update parent state based on children
229 : 15 : std::function<void(QTreeWidgetItem *)> updateParent;
230 : 49 : updateParent = [&updateParent](QTreeWidgetItem *child) {
231 : 19 : auto *parent = child->parent();
232 [ + + ]: 19 : if (!parent)
233 : 15 : return;
234 : 4 : int checked = 0, unchecked = 0;
235 [ + + ]: 12 : for (int i = 0; i < parent->childCount(); ++i) {
236 : 8 : auto cs = parent->child(i)->checkState(0);
237 [ + + ]: 8 : if (cs == Qt::Checked)
238 : 4 : ++checked;
239 [ + - ]: 4 : else if (cs == Qt::Unchecked)
240 : 4 : ++unchecked;
241 : : }
242 : 4 : int total = parent->childCount();
243 [ + + ]: 4 : if (checked == total)
244 : 1 : parent->setCheckState(0, Qt::Checked);
245 [ + + ]: 3 : else if (unchecked == total)
246 : 1 : parent->setCheckState(0, Qt::Unchecked);
247 : : else
248 : 2 : parent->setCheckState(0, Qt::PartiallyChecked);
249 : 4 : updateParent(parent);
250 : 15 : };
251 [ + - ]: 15 : updateParent(item);
252 : :
253 : 15 : m_tree->blockSignals(false);
254 : 15 : });
255 : 13 : }
256 : :
257 : 12 : QStringList FolderSubscriptionDialog::subscribedFolders() const {
258 : 12 : QStringList result;
259 [ + - + + ]: 71 : for (int i = 0; i < m_tree->topLevelItemCount(); ++i) {
260 [ + - + - ]: 59 : collectChecked(m_tree->topLevelItem(i), result);
261 : : }
262 : 12 : return result;
263 : 0 : }
264 : :
265 : 72 : void FolderSubscriptionDialog::collectChecked(QTreeWidgetItem *item,
266 : : QStringList &result) const {
267 [ + + ]: 72 : if (item->checkState(0) == Qt::Checked) {
268 [ + - + - ]: 33 : QString path = item->data(0, Qt::UserRole).toString();
269 [ + - ]: 33 : if (!path.isEmpty()) {
270 [ + - ]: 33 : result.append(path);
271 : : }
272 : 33 : }
273 [ + + ]: 85 : for (int i = 0; i < item->childCount(); ++i) {
274 : 13 : collectChecked(item->child(i), result);
275 : : }
276 : 72 : }
277 : :
278 : 2 : void FolderSubscriptionDialog::setCheckAll(Qt::CheckState state) {
279 : 2 : std::function<void(QTreeWidgetItem *)> setAll;
280 : 14 : setAll = [&](QTreeWidgetItem *item) {
281 : 10 : item->setCheckState(0, state);
282 [ - + ]: 10 : for (int i = 0; i < item->childCount(); ++i) {
283 : 0 : setAll(item->child(i));
284 : : }
285 : 2 : };
286 [ + - + + ]: 12 : for (int i = 0; i < m_tree->topLevelItemCount(); ++i) {
287 [ + - + - ]: 10 : setAll(m_tree->topLevelItem(i));
288 : : }
289 : 2 : }
290 : :
291 : : // ── Persistence ──
292 : :
293 : : QStringList
294 : 30 : FolderSubscriptionDialog::loadSubscriptions(const QString &configDir) {
295 : : QString filePath =
296 [ + - + - ]: 30 : QDir(configDir).filePath(SUBSCRIPTIONS_FILENAME);
297 [ + - ]: 30 : QFile file(filePath);
298 [ + - + + ]: 30 : if (!file.exists()) {
299 [ + - + - : 16 : qCInfo(lcSubscription) << "No subscriptions file found at" << filePath;
+ - + - +
+ ]
300 : 8 : return {};
301 : : }
302 : :
303 [ + - - + ]: 22 : if (!file.open(QIODevice::ReadOnly)) {
304 [ # # # # : 0 : qCWarning(lcSubscription) << "Failed to open" << filePath;
# # # # #
# ]
305 : 0 : return {};
306 : : }
307 : :
308 [ + - + - ]: 22 : auto doc = QJsonDocument::fromJson(file.readAll());
309 [ + - - + ]: 22 : if (!doc.isObject()) {
310 [ # # # # : 0 : qCWarning(lcSubscription) << "Invalid subscriptions JSON";
# # # # ]
311 : 0 : return {};
312 : : }
313 : :
314 : 22 : QStringList result;
315 [ + - + - : 22 : auto arr = doc.object().value("subscribed").toArray();
+ - + - ]
316 [ + - + - : 85 : for (const auto &val : arr) {
+ + ]
317 [ + - + - ]: 63 : result.append(val.toString());
318 : : }
319 : :
320 [ + - + - : 44 : qCInfo(lcSubscription) << "Loaded" << result.size() << "subscriptions from"
+ - + - +
- + + ]
321 [ + - ]: 22 : << filePath;
322 : 22 : return result;
323 : 30 : }
324 : :
325 : 21 : void FolderSubscriptionDialog::saveSubscriptions(const QString &configDir,
326 : : const QStringList &folders) {
327 [ + - ]: 21 : QDir dir(configDir);
328 [ + - + + ]: 21 : if (!dir.exists()) {
329 [ + - + - : 2 : if (!dir.mkpath(".")) {
+ + ]
330 [ + - + - : 2 : qCWarning(lcSubscription) << "Failed to create config directory"
+ - + + ]
331 [ + - ]: 1 : << configDir;
332 : 1 : return;
333 : : }
334 : : }
335 : :
336 [ + - ]: 20 : QString filePath = dir.filePath(SUBSCRIPTIONS_FILENAME);
337 : :
338 : : // Load existing JSON to preserve "hidden" key
339 [ + - ]: 20 : QJsonObject obj = loadSubscriptionObject(filePath);
340 : :
341 [ + - ]: 20 : QJsonArray arr;
342 [ + + ]: 67 : for (const auto &f : folders) {
343 [ + - + - ]: 47 : arr.append(f);
344 : : }
345 [ + - + - : 20 : obj["subscribed"] = arr;
+ - + - ]
346 : :
347 [ + - + - ]: 20 : if (writeSubscriptionObject(filePath, obj))
348 [ + - + - : 40 : qCInfo(lcSubscription) << "Saved" << folders.size() << "subscriptions to"
+ - + - +
- + + ]
349 [ + - ]: 20 : << filePath;
350 [ + + ]: 21 : }
351 : :
352 : : // T-069: Hidden folder persistence
353 : :
354 : : QStringList
355 : 38 : FolderSubscriptionDialog::loadHidden(const QString &configDir) {
356 [ + - + - ]: 38 : QString filePath = QDir(configDir).filePath(SUBSCRIPTIONS_FILENAME);
357 [ + - ]: 38 : QFile file(filePath);
358 [ + - + + : 38 : if (!file.exists() || !file.open(QIODevice::ReadOnly))
+ - - + +
+ ]
359 : 16 : return {};
360 : :
361 [ + - + - ]: 22 : auto doc = QJsonDocument::fromJson(file.readAll());
362 [ + - - + ]: 22 : if (!doc.isObject())
363 : 0 : return {};
364 : :
365 : 22 : QStringList result;
366 [ + - + - : 22 : auto arr = doc.object().value("hidden").toArray();
+ - + - ]
367 [ + - + - : 39 : for (const auto &val : arr) {
+ + ]
368 [ + - + - ]: 17 : result.append(val.toString());
369 : : }
370 : :
371 [ + - + - : 44 : qCInfo(lcSubscription) << "Loaded" << result.size() << "hidden folders";
+ - + - +
- + + ]
372 : 22 : return result;
373 : 38 : }
374 : :
375 : 20 : void FolderSubscriptionDialog::saveHidden(const QString &configDir,
376 : : const QStringList &folders) {
377 [ + - ]: 20 : QDir dir(configDir);
378 [ + - + + ]: 20 : if (!dir.exists()) {
379 [ + - + - : 1 : if (!dir.mkpath(".")) {
+ - ]
380 [ + - + - : 2 : qCWarning(lcSubscription) << "Failed to create config directory"
+ - + + ]
381 [ + - ]: 1 : << configDir;
382 : 1 : return;
383 : : }
384 : : }
385 : :
386 [ + - ]: 19 : QString filePath = dir.filePath(SUBSCRIPTIONS_FILENAME);
387 : :
388 : : // Load existing JSON to preserve "subscribed" key
389 [ + - ]: 19 : QJsonObject obj = loadSubscriptionObject(filePath);
390 : :
391 [ + - ]: 19 : QJsonArray arr;
392 [ + + ]: 34 : for (const auto &f : folders) {
393 [ + - + - ]: 15 : arr.append(f);
394 : : }
395 [ + - + - : 19 : obj["hidden"] = arr;
+ - + - ]
396 : :
397 [ + - + - ]: 19 : if (writeSubscriptionObject(filePath, obj))
398 [ + - + - : 38 : qCInfo(lcSubscription) << "Saved" << folders.size() << "hidden folders to"
+ - + - +
- + + ]
399 [ + - ]: 19 : << filePath;
400 [ + + ]: 20 : }
401 : :
402 : : // T-304: Runtime language switching
403 : 26 : void FolderSubscriptionDialog::changeEvent(QEvent *event) {
404 [ - + ]: 26 : if (event->type() == QEvent::LanguageChange)
405 : 0 : retranslateUi();
406 : 26 : QDialog::changeEvent(event);
407 : 26 : }
408 : :
409 : 0 : void FolderSubscriptionDialog::retranslateUi() {
410 [ # # # # ]: 0 : setWindowTitle(tr("Folder Subscriptions"));
411 : 0 : }
|