Branch data Line data Source code
1 : : #include "MailListModel.h"
2 : :
3 : : #include <QColor>
4 : : #include <QDataStream>
5 : : #include <QFont>
6 : : #include <QIODevice>
7 : : #include <QLocale>
8 : : #include <QLoggingCategory>
9 : :
10 : : #include "service/ImapResponseParser.h"
11 : : #include "ui/ThemeManager.h"
12 : :
13 [ # # # # : 0 : Q_LOGGING_CATEGORY(lcMailModel, "mailjd.mailmodel")
# # # # ]
14 : :
15 : 215 : MailListModel::MailListModel(QObject *parent) : QAbstractTableModel(parent) {}
16 : :
17 : 294952 : int MailListModel::rowCount(const QModelIndex &parent) const {
18 [ + + ]: 294952 : return parent.isValid() ? 0 : m_headers.size();
19 : : }
20 : :
21 : 292773 : int MailListModel::columnCount(const QModelIndex &parent) const {
22 [ + + ]: 292773 : return parent.isValid() ? 0 : ColumnCount;
23 : : }
24 : :
25 : 209506 : QVariant MailListModel::data(const QModelIndex &index, int role) const {
26 [ + + + + : 209506 : if (!index.isValid() || index.row() >= m_headers.size())
+ + ]
27 : 2 : return {};
28 : :
29 : 209504 : const auto &h = m_headers.at(index.row());
30 : :
31 [ + + ]: 209504 : if (role == Qt::DisplayRole) {
32 [ + + + + : 30120 : switch (index.column()) {
+ + + - ]
33 : 4277 : case Star:
34 [ + + + + : 8554 : return h.isFlagged() ? QStringLiteral("★") : QStringLiteral("☆");
+ + ]
35 : 4269 : case Attachment:
36 : 4269 : return {}; // painted by dedicated delegate
37 : 7023 : case Subject: {
38 [ + + + - ]: 7023 : QString subj = h.subject.isEmpty() ? tr("(No Subject)") : h.subject;
39 : : // T-79.E5/M16: search-mode folder badge is prepended for display
40 : : // only — MailHeader::subject stays clean for replies/forwards.
41 : 7023 : const QString badge = m_folderBadges.value(h.folderId);
42 [ + + ]: 7023 : if (!badge.isEmpty())
43 [ + - + - ]: 260 : subj.prepend(QStringLiteral("[%1] ").arg(badge));
44 [ + + + - ]: 7025 : if (h.isAnswered()) subj.prepend(QStringLiteral("↩ "));
45 : 7023 : return subj;
46 : 7023 : }
47 : 1631 : case Suggestion: {
48 : 1631 : auto it = m_suggestionCache.constFind(MailKey{h.folderId, h.uid});
49 [ + + ]: 1631 : return it != m_suggestionCache.constEnd() ? it->text : QString();
50 : : }
51 : 4380 : case From:
52 : 4380 : return h.from;
53 : 4271 : case Date:
54 [ + - ]: 4271 : return formatDate(h.date);
55 : 4269 : case Size:
56 [ + - ]: 4269 : return formatSize(h.size);
57 : : }
58 : : }
59 : :
60 : : // Star column: gold for flagged, light gray for unflagged
61 [ + + + + : 179384 : if (role == Qt::ForegroundRole && index.column() == Star) {
+ + ]
62 : 4274 : auto &tm = ThemeManager::instance();
63 [ + + + - : 12822 : return h.isFlagged() ? QColor(tm.color(QStringLiteral("@star_active")))
+ + + + -
- - - -
- ]
64 [ + - + - : 8527 : : QColor(tm.color(QStringLiteral("@star_inactive")));
+ + + + +
+ + + - -
- - - - ]
65 : : }
66 : :
67 : : // T-232: Suggestion column color based on confidence (shared palette)
68 [ + + + + : 175110 : if (role == Qt::ForegroundRole && index.column() == Suggestion) {
+ + ]
69 : 1630 : auto it = m_suggestionCache.constFind(MailKey{h.folderId, h.uid});
70 [ + + + - ]: 1635 : return ThemeManager::confidenceColor(
71 [ + - ]: 4895 : it != m_suggestionCache.constEnd() ? it->confidence : 0.0);
72 : : }
73 : :
74 : : // Tooltip for Star column
75 [ + + + + : 173480 : if (role == Qt::ToolTipRole && index.column() == Star) {
+ + ]
76 [ + + + - : 2 : return h.isFlagged() ? tr("Remove Flag") : tr("Flag");
+ - ]
77 : : }
78 : :
79 : : // Tooltip for Subject column: show full subject on hover
80 [ + + + - : 173478 : if (role == Qt::ToolTipRole && index.column() == Subject) {
+ + ]
81 : 1 : return h.subject;
82 : : }
83 : :
84 : : // Tooltip for Attachment column
85 [ - + - - : 173477 : if (role == Qt::ToolTipRole && index.column() == Attachment) {
- + ]
86 [ # # # # ]: 0 : return h.hasAttachments ? tr("Has attachment") : QString();
87 : : }
88 : :
89 : : // 67.B4: unread = medium weight (paired with the accent unread dot;
90 : : // replaces the former bold-only signal) — applied to all columns
91 [ + + + + : 173477 : if (role == Qt::FontRole && !h.isSeen()) {
+ + ]
92 [ + - ]: 15193 : QFont font;
93 [ + - ]: 15193 : font.setWeight(QFont::DemiBold);
94 [ + - ]: 15193 : return font;
95 : 15193 : }
96 : :
97 : : // Text alignment: Star column centered
98 [ + + + + : 158284 : if (role == Qt::TextAlignmentRole && index.column() == Star) {
+ + ]
99 : 4271 : return static_cast<int>(Qt::AlignCenter);
100 : : }
101 : :
102 : : // Sort role: return raw values for proper sorting
103 [ + + ]: 154013 : if (role == SortRole) {
104 [ + + + + : 3415 : switch (index.column()) {
+ ]
105 : 8 : case Star:
106 [ + + ]: 8 : return h.isFlagged() ? 1 : 0;
107 : 3393 : case Date:
108 : 3393 : return h.date;
109 : 3 : case Size:
110 : 3 : return h.size;
111 : 4 : case Suggestion: {
112 : 4 : auto it = m_suggestionCache.constFind(MailKey{h.folderId, h.uid});
113 [ + + ]: 4 : return it != m_suggestionCache.constEnd() ? it->confidence : 0.0;
114 : : }
115 : 7 : default:
116 : 7 : return data(index, Qt::DisplayRole);
117 : : }
118 : : }
119 : :
120 : : // Custom FlagsRole: return the raw flags bitmask (for filtering)
121 [ + + ]: 150598 : if (role == FlagsRole) {
122 : 4094 : return h.flags;
123 : : }
124 : :
125 : : // HasAttachmentsRole: from MailHeader (populated by cache/body parser)
126 [ + + ]: 146504 : if (role == HasAttachmentsRole) {
127 : 2484 : return h.hasAttachments;
128 : : }
129 : :
130 : : // LabelsRole: IMAP keywords parsed from FLAGS
131 : : // Filter internal keywords (e.g. NonJunk) at display time for cached data
132 [ + + ]: 144020 : if (role == LabelsRole) {
133 : 2491 : QStringList filtered;
134 [ + + ]: 2531 : for (const auto &label : h.labels) {
135 [ + - + + ]: 40 : if (!ImapResponseParser::isInternalKeyword(label)) {
136 [ + - ]: 37 : filtered.append(label);
137 : : }
138 : : }
139 : 2491 : return filtered;
140 : 2491 : }
141 : :
142 [ + + ]: 141529 : if (role == FolderBadgeRole)
143 : 1 : return m_folderBadges.value(h.folderId);
144 : :
145 : 141528 : return {};
146 : : }
147 : :
148 : 40671 : QVariant MailListModel::headerData(int section, Qt::Orientation orientation,
149 : : int role) const {
150 [ + + + + ]: 40671 : if (orientation != Qt::Horizontal || role != Qt::DisplayRole)
151 : 32309 : return {};
152 : :
153 [ + + + + : 8362 : switch (section) {
+ + + + ]
154 : 1392 : case Star:
155 : 1392 : return QStringLiteral(""); // narrow column, no text header
156 : 1390 : case Attachment:
157 : 1390 : return QStringLiteral(""); // icon-only column
158 : 1391 : case Subject:
159 [ + - ]: 1391 : return tr("Subject");
160 : 28 : case Suggestion:
161 : 28 : return QStringLiteral("→"); // arrow symbol
162 : 1387 : case From:
163 [ + - ]: 1387 : return tr("From");
164 : 1387 : case Date:
165 [ + - ]: 1387 : return tr("Date");
166 : 1386 : case Size:
167 [ + - ]: 1386 : return tr("Size");
168 : : }
169 : 1 : return {};
170 : : }
171 : :
172 : : // Sprint 76 (T-76.B3): models do not receive LanguageChange events, so the
173 : : // language-switch path (MainWindow) calls this to make views re-read the
174 : : // translated headers via headerData().
175 : 3 : void MailListModel::retranslateUi() {
176 [ + - + - ]: 3 : if (columnCount() > 0)
177 [ + - + - ]: 3 : emit headerDataChanged(Qt::Horizontal, 0, columnCount() - 1);
178 : 3 : }
179 : :
180 : : // ═══════════════════════════════════════════════════════
181 : : // Drag & Drop (T-102)
182 : : // ═══════════════════════════════════════════════════════
183 : :
184 : 237218 : Qt::ItemFlags MailListModel::flags(const QModelIndex &index) const {
185 [ + - ]: 237218 : auto defaultFlags = QAbstractTableModel::flags(index);
186 [ + + ]: 237218 : if (index.isValid())
187 : 237217 : return defaultFlags | Qt::ItemIsDragEnabled;
188 : 1 : return defaultFlags;
189 : : }
190 : :
191 : 3 : QStringList MailListModel::mimeTypes() const {
192 : 0 : return {QStringLiteral("application/x-mailjd-mail-ids"),
193 [ + + - - ]: 9 : QStringLiteral("application/x-mailjd-uids")};
194 [ + - - - : 9 : }
- - ]
195 : :
196 : 5 : QMimeData *MailListModel::mimeData(const QModelIndexList &indexes) const {
197 : : // Collect unique mail identities from the selected rows. UIDs are only
198 : : // unique within a folder, so drag payloads must carry the source folderId.
199 : 5 : QList<MailIdentity> mails;
200 : 5 : QSet<qint64> uidSet;
201 : 5 : QSet<QString> identitySet;
202 [ + + ]: 14 : for (const auto &idx : indexes) {
203 [ + + + + : 9 : if (!idx.isValid() || idx.row() >= m_headers.size())
+ + ]
204 : 3 : continue;
205 : 7 : const auto &header = m_headers.at(idx.row());
206 : : const QString key =
207 [ + - + - ]: 21 : QStringLiteral("%1:%2").arg(header.folderId).arg(header.uid);
208 [ + + ]: 7 : if (identitySet.contains(key))
209 : 1 : continue;
210 [ + - ]: 6 : identitySet.insert(key);
211 [ + - ]: 6 : mails.append({QString(), header.folderId, header.uid});
212 [ + - ]: 6 : uidSet.insert(header.uid);
213 [ + + ]: 7 : }
214 : :
215 : 5 : QByteArray identityEncoded;
216 [ + - ]: 5 : QDataStream identityStream(&identityEncoded, QIODevice::WriteOnly);
217 [ + - + - : 11 : for (const auto &mail : mails)
+ + ]
218 [ + - + - : 6 : identityStream << mail.account << mail.folderId << mail.uid;
+ - ]
219 : :
220 : 5 : QByteArray encoded;
221 [ + - ]: 5 : QDataStream stream(&encoded, QIODevice::WriteOnly);
222 [ + - + - : 10 : for (qint64 uid : uidSet)
+ + ]
223 [ + - ]: 5 : stream << uid;
224 : :
225 [ + - + - : 5 : auto *mimeData = new QMimeData;
- + - - ]
226 [ + - ]: 10 : mimeData->setData(QStringLiteral("application/x-mailjd-mail-ids"),
227 : : identityEncoded);
228 [ + - ]: 10 : mimeData->setData(QStringLiteral("application/x-mailjd-uids"), encoded);
229 : :
230 : : // Set human-readable text for drag tooltip
231 [ + - ]: 5 : mimeData->setText(
232 [ + - + - ]: 15 : QString("%1 Mail(s)").arg(mails.size()));
233 : :
234 : 5 : return mimeData;
235 : 5 : }
236 : :
237 : 3 : Qt::DropActions MailListModel::supportedDragActions() const {
238 : 3 : return Qt::MoveAction;
239 : : }
240 : :
241 : 254 : void MailListModel::setHeaders(const QList<MailHeader> &headers) {
242 : 254 : beginResetModel();
243 : 254 : m_headers = headers;
244 : 254 : m_suggestionCache.clear(); // T-232
245 : 254 : rebuildUidIndex();
246 : 254 : endResetModel();
247 : 254 : }
248 : :
249 : 37 : void MailListModel::appendHeaders(const QList<MailHeader> &headers) {
250 [ + + ]: 37 : if (headers.isEmpty())
251 : 1 : return;
252 : :
253 [ + - ]: 36 : beginInsertRows({}, m_headers.size(), m_headers.size() + headers.size() - 1);
254 : 36 : int baseRow = m_headers.size();
255 : 36 : m_headers.append(headers);
256 : : // T-514: extend composite UID index for new rows
257 [ + + ]: 198 : for (int i = 0; i < headers.size(); ++i) {
258 : 162 : const auto &hdr = headers.at(i);
259 [ + - ]: 162 : m_uidIndex.insert(MailKey{hdr.folderId, hdr.uid}, baseRow + i);
260 : : }
261 : : // T-116: update unread count for new headers
262 [ + + ]: 198 : for (const auto &h : headers) {
263 [ + + ]: 162 : if (!h.isSeen())
264 : 154 : ++m_unreadCount;
265 : : }
266 : 36 : endInsertRows();
267 : : }
268 : :
269 : 6 : void MailListModel::clear() {
270 : 6 : beginResetModel();
271 : 6 : m_headers.clear();
272 : 6 : m_uidIndex.clear();
273 : 6 : m_unreadCount = 0;
274 : 6 : m_suggestionCache.clear(); // T-232
275 : 6 : endResetModel();
276 : 6 : }
277 : :
278 : 161 : void MailListModel::updateFlags(qint64 uid, quint32 newFlags, qint64 folderId) {
279 [ + - ]: 161 : int row = rowForUid(uid, folderId);
280 [ + + ]: 161 : if (row < 0)
281 : 11 : return;
282 : :
283 : : // T-116: adjust unread count based on Seen flag transition
284 : 150 : bool wasSeen = m_headers.at(row).isSeen();
285 : : // T-261: Preserve labels (IMAP keywords) — they live outside the bitmask
286 : : // and must not be lost when the flag bitmask is updated during sync.
287 : 150 : QStringList savedLabels = m_headers.at(row).labels;
288 [ + - ]: 150 : m_headers[row].flags = newFlags;
289 [ + - ]: 150 : m_headers[row].labels = savedLabels;
290 : 150 : bool nowSeen = m_headers.at(row).isSeen();
291 [ + + + + ]: 150 : if (wasSeen && !nowSeen)
292 : 15 : ++m_unreadCount;
293 [ + + + + ]: 135 : else if (!wasSeen && nowSeen)
294 : 54 : --m_unreadCount;
295 : :
296 [ + - ]: 150 : auto topLeft = index(row, 0);
297 [ + - ]: 150 : auto bottomRight = index(row, ColumnCount - 1);
298 [ + - + - ]: 150 : emit dataChanged(topLeft, bottomRight,
299 : : {Qt::FontRole, Qt::DisplayRole, Qt::DecorationRole,
300 : : FlagsRole});
301 : 150 : }
302 : :
303 : 3 : void MailListModel::setHasAttachments(qint64 uid, qint64 folderId, bool has) {
304 : 3 : int row = rowForUid(uid, folderId);
305 [ + + ]: 3 : if (row < 0)
306 : 1 : return;
307 [ - + ]: 2 : if (m_headers.at(row).hasAttachments == has)
308 : 0 : return;
309 : 2 : m_headers[row].hasAttachments = has;
310 [ + - + - : 2 : emit dataChanged(index(row, 0), index(row, ColumnCount - 1),
+ - + - ]
311 : : {HasAttachmentsRole});
312 : : }
313 : :
314 : 49 : void MailListModel::removeByUid(qint64 uid, qint64 folderId) {
315 : 49 : int row = rowForUid(uid, folderId);
316 [ + + ]: 49 : if (row < 0)
317 : 9 : return;
318 : :
319 : : // T-116: adjust unread count if removed mail was unread
320 [ + + ]: 40 : if (!m_headers.at(row).isSeen())
321 : 27 : --m_unreadCount;
322 : :
323 [ + - ]: 40 : beginRemoveRows({}, row, row);
324 : 40 : m_headers.removeAt(row);
325 : :
326 : : // T-115: rebuild index from scratch (rows shift after remove)
327 : : // Cost: O(n), but removeByUid is rare compared to rowForUid
328 : 40 : rebuildUidIndex();
329 : :
330 : 40 : endRemoveRows();
331 : : }
332 : :
333 : : // T-525/Cx8: Batch remove — single reset instead of per-UID rebuild
334 : 5 : void MailListModel::removeByUids(const QList<qint64> &uids, qint64 folderId) {
335 [ + + ]: 5 : if (uids.isEmpty())
336 : 2 : return;
337 : :
338 [ + - ]: 3 : QSet<qint64> removeSet(uids.begin(), uids.end());
339 [ + - ]: 3 : beginResetModel();
340 [ + - + - : 3 : m_headers.erase(
+ - + - +
- ]
341 : : std::remove_if(m_headers.begin(), m_headers.end(),
342 : 11 : [&removeSet, folderId](const MailHeader &h) {
343 [ + + + + ]: 21 : return h.folderId == folderId &&
344 : 21 : removeSet.contains(h.uid);
345 : : }),
346 : : m_headers.end());
347 [ + - ]: 3 : rebuildUidIndex();
348 [ + - ]: 3 : endResetModel();
349 : 3 : }
350 : :
351 : 1839 : const MailHeader *MailListModel::headerAt(int row) const {
352 [ + + + + : 1839 : if (row < 0 || row >= m_headers.size())
+ + ]
353 : 3 : return nullptr;
354 : 1836 : return &m_headers.at(row);
355 : : }
356 : :
357 : 41 : MailHeader *MailListModel::mutableHeaderAt(int row) {
358 [ + + + + : 41 : if (row < 0 || row >= m_headers.size())
+ + ]
359 : 2 : return nullptr;
360 : 39 : return &m_headers[row];
361 : : }
362 : :
363 : 130 : qint64 MailListModel::uidAt(int row) const {
364 : 130 : auto *h = headerAt(row);
365 [ + + ]: 130 : return h ? h->uid : -1;
366 : : }
367 : :
368 : 6012 : int MailListModel::rowForUid(qint64 uid, qint64 folderId) const {
369 : : // T-514: O(1) composite hash lookup
370 : 6012 : auto it = m_uidIndex.constFind(MailKey{folderId, uid});
371 [ + + ]: 6012 : return it != m_uidIndex.constEnd() ? it.value() : -1;
372 : : }
373 : :
374 : 185 : int MailListModel::unreadCount() const {
375 : : // T-116: O(1) cached count
376 : 185 : return m_unreadCount;
377 : : }
378 : :
379 : 297 : void MailListModel::rebuildUidIndex() {
380 : 297 : m_uidIndex.clear();
381 : 297 : m_uidIndex.reserve(m_headers.size());
382 : 297 : m_unreadCount = 0;
383 [ + + ]: 156348 : for (int i = 0; i < m_headers.size(); ++i) {
384 : 156051 : const auto &h = m_headers.at(i);
385 [ + - ]: 156051 : m_uidIndex.insert(MailKey{h.folderId, h.uid}, i);
386 [ + + ]: 156051 : if (!h.isSeen())
387 : 78241 : ++m_unreadCount;
388 : : }
389 : 297 : }
390 : :
391 : : // 67.B4: humanized date column (DESIGN.md "MailList date format"):
392 : : // today "14:32" · yesterday "Yesterday" · this week "Mon" ·
393 : : // this year "12 May" · older "12.05.2024"
394 : 4403 : QString MailListModel::formatDate(const QDateTime &dt) {
395 [ + - + + ]: 4403 : if (!dt.isValid())
396 : 2 : return {};
397 : :
398 [ + - ]: 4401 : const QDate today = QDate::currentDate();
399 [ + - ]: 4401 : const QDate date = dt.date();
400 : :
401 [ + + ]: 4401 : if (date == today)
402 [ + - + - ]: 3814 : return dt.time().toString(QStringLiteral("HH:mm"));
403 [ + - + + ]: 2494 : if (date == today.addDays(-1))
404 [ + - ]: 4 : return tr("Yesterday");
405 [ + - + + : 2490 : if (date > today.addDays(-7) && date < today)
+ + + + ]
406 [ + - + - : 3 : return QLocale().dayName(date.dayOfWeek(), QLocale::ShortFormat);
+ - ]
407 [ + - + - : 2487 : if (date.year() == today.year())
+ + ]
408 [ + - + - ]: 4962 : return QLocale().toString(date, QStringLiteral("d. MMM"));
409 [ + - ]: 6 : return date.toString(QStringLiteral("dd.MM.yyyy"));
410 : : }
411 : :
412 : 4269 : QString MailListModel::formatSize(qint64 bytes) {
413 [ + + ]: 4269 : if (bytes < 1024)
414 [ + - + - ]: 4261 : return QString::number(bytes) + " B";
415 [ + + ]: 8 : if (bytes < 1024 * 1024)
416 [ + - + - ]: 5 : return QString::number(bytes / 1024.0, 'f', 1) + " KB";
417 [ + - + - ]: 3 : return QString::number(bytes / (1024.0 * 1024.0), 'f', 1) + " MB";
418 : : }
419 : :
420 : : // T-232: Set suggestion text + confidence for a specific UID
421 : 12 : void MailListModel::setSuggestion(qint64 uid, qint64 folderId,
422 : : const QString &text,
423 : : double confidence) {
424 [ + - ]: 12 : m_suggestionCache[MailKey{folderId, uid}] = {text, confidence};
425 : 12 : int row = rowForUid(uid, folderId);
426 [ + + ]: 12 : if (row >= 0) {
427 [ + - ]: 10 : auto idx = index(row, Suggestion);
428 [ + - + - ]: 10 : emit dataChanged(idx, idx, {Qt::DisplayRole, Qt::ForegroundRole, SortRole});
429 : : }
430 [ - - ]: 24 : }
431 : :
432 : : // T-232: Clear all cached suggestions
433 : 7 : void MailListModel::clearSuggestions() {
434 [ + + ]: 7 : if (m_suggestionCache.isEmpty())
435 : 3 : return;
436 : 4 : m_suggestionCache.clear();
437 [ + + ]: 4 : if (!m_headers.isEmpty()) {
438 [ + - + - : 9 : emit dataChanged(index(0, Suggestion),
+ - ]
439 [ + - ]: 6 : index(m_headers.size() - 1, Suggestion),
440 : : {Qt::DisplayRole, Qt::ForegroundRole, SortRole});
441 : : }
442 : : }
443 : :
444 : : // T-79.E5/M16: register a search-mode folder badge (display-only prefix).
445 : 69 : void MailListModel::setFolderBadge(qint64 folderId, const QString &folderName) {
446 : 69 : m_folderBadges.insert(folderId, folderName);
447 : 69 : }
448 : :
449 : 21 : void MailListModel::clearFolderBadges() {
450 [ + + ]: 21 : if (m_folderBadges.isEmpty())
451 : 2 : return;
452 : 19 : m_folderBadges.clear();
453 [ + - ]: 19 : if (!m_headers.isEmpty()) {
454 [ + - + - : 19 : emit dataChanged(index(0, Subject), index(m_headers.size() - 1, Subject),
+ - + - ]
455 : : {Qt::DisplayRole});
456 : : }
457 : : }
|