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