ui/MailListModel.h
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #pragma once | ||
| 2 | |||
| 3 | #include <QAbstractTableModel> | ||
| 4 | #include <QHash> | ||
| 5 | #include <QList> | ||
| 6 | #include <QMimeData> | ||
| 7 | #include <QPair> | ||
| 8 | #include <functional> | ||
| 9 | #include <optional> | ||
| 10 | |||
| 11 | #include "data/MailSort.h" | ||
| 12 | #include "data/Models.h" | ||
| 13 | |||
| 14 | // MailListModel displays mail headers in a table format (Star, Subject, From, | ||
| 15 | // Date, Size). Designed for use with QSortFilterProxyModel for sorting. Unread | ||
| 16 | // (unseen) mails are rendered in bold (FontRole). Star column shows ⭐ for | ||
| 17 | // flagged mails. | ||
| 18 | class MailListModel : public QAbstractTableModel { | ||
| 19 | 21 | Q_OBJECT | |
| 20 | |||
| 21 | public: | ||
| 22 | // T-514: Composite identity key (folderId, uid) — UIDs are only unique per folder | ||
| 23 | using MailKey = QPair<qint64, qint64>; | ||
| 24 | // Column order: Star/status column first (narrow icon), then attachment | ||
| 25 | // indicator, then content. | ||
| 26 | enum Column { Star = 0, Attachment, Subject, Suggestion, From, Date, Size, ColumnCount }; | ||
| 27 | |||
| 28 | // Custom roles for filtering/sorting beyond Qt::UserRole | ||
| 29 | enum Role { | ||
| 30 | SortRole = Qt::UserRole, // Raw values for proper sorting | ||
| 31 | FlagsRole = Qt::UserRole + 1, // quint32 flags bitmask | ||
| 32 | HasAttachmentsRole = Qt::UserRole + 2, // bool: has attachments | ||
| 33 | LabelsRole = Qt::UserRole + 3, // QStringList: IMAP keywords/labels | ||
| 34 | // T-79.E5/M16: search-mode folder badge (display-only, per folderId) | ||
| 35 | FolderBadgeRole = Qt::UserRole + 4, | ||
| 36 | SuggestionConfRole = Qt::UserRole + 10, // double: suggestion confidence | ||
| 37 | }; | ||
| 38 | |||
| 39 | explicit MailListModel(QObject *parent = nullptr); | ||
| 40 | |||
| 41 | // QAbstractTableModel interface | ||
| 42 | int rowCount(const QModelIndex &parent = {}) const override; | ||
| 43 | int columnCount(const QModelIndex &parent = {}) const override; | ||
| 44 | bool canFetchMore(const QModelIndex &parent) const override; | ||
| 45 | void fetchMore(const QModelIndex &parent) override; | ||
| 46 | QVariant data(const QModelIndex &index, | ||
| 47 | int role = Qt::DisplayRole) const override; | ||
| 48 | QVariant headerData(int section, Qt::Orientation orientation, | ||
| 49 | int role = Qt::DisplayRole) const override; | ||
| 50 | |||
| 51 | // Sprint 76 (T-76.B3): re-emit header data so translated column headers | ||
| 52 | // (Subject/From/Date/Size/Has attachment) refresh on a live language switch. | ||
| 53 | void retranslateUi(); | ||
| 54 | |||
| 55 | // T-102: Drag & Drop support | ||
| 56 | Qt::ItemFlags flags(const QModelIndex &index) const override; | ||
| 57 | QStringList mimeTypes() const override; | ||
| 58 | QMimeData *mimeData(const QModelIndexList &indexes) const override; | ||
| 59 | Qt::DropActions supportedDragActions() const override; | ||
| 60 | |||
| 61 | // --- Data management --- | ||
| 62 | |||
| 63 | // Replace all headers (initial load from cache). | ||
| 64 | void setHeaders(const QList<MailHeader> &headers); | ||
| 65 | |||
| 66 | using PageLoader = | ||
| 67 | std::function<QList<MailHeader>(const MailHeader &, int)>; | ||
| 68 | struct PageCursor { | ||
| 69 | qint64 uid = 0; | ||
| 70 | QDateTime date; | ||
| 71 | // T-87.D1 random seek: forward pages anchored by a seek include the | ||
| 72 | // anchor uid itself (inclusive keyset) so the seek target is visible. | ||
| 73 | bool includeAnchor = false; | ||
| 74 | // F100-66: what a non-date sort keys on (MailSort::key). | ||
| 75 | QString from, to, subject; | ||
| 76 | qint64 size = 0; | ||
| 77 | quint32 flags = 0; | ||
| 78 | 5794 | PageCursor() = default; | |
| 79 | 1342 | PageCursor(const MailHeader &header) | |
| 80 | 2684 | : uid(header.uid), date(header.date), from(header.from), to(header.to), | |
| 81 | 2684 | subject(header.subject), size(header.size), flags(header.flags) {} | |
| 82 | }; | ||
| 83 | struct PageResult { | ||
| 84 | QList<MailHeader> headers; | ||
| 85 | PageCursor cursor; | ||
| 86 | QString error; | ||
| 87 | bool endOfData = false; | ||
| 88 | }; | ||
| 89 | using PageCallback = std::function<void(PageResult)>; | ||
| 90 | using AsyncPageLoader = std::function<void(const PageCursor &, int, PageCallback)>; | ||
| 91 | // Flat quick filter: the window about to be replaced by filtered pages is | ||
| 92 | // kept once, so clearing the filter restores it at once (as a diff) | ||
| 93 | // instead of showing an empty list until the unfiltered page is read. | ||
| 94 | void keepUnfilteredWindow(); | ||
| 95 | // `changed` (optional) reports whether mail data changed while the window | ||
| 96 | // was kept (new mail, flags, removals): only then is a catch-up read of | ||
| 97 | // the first page needed after the restore. | ||
| 98 | bool restoreUnfilteredWindow(bool *changed = nullptr); | ||
| 99 | 704 | void discardUnfilteredWindow() { m_unfilteredWindow.reset(); } | |
| 100 | void setAsyncPagedHeaders(const QList<MailHeader> &firstPage, int totalCount, | ||
| 101 | int totalUnread, AsyncPageLoader loader, | ||
| 102 | AsyncPageLoader previousLoader = {}); | ||
| 103 | void setPagedHeaders(const QList<MailHeader> &firstPage, int totalCount, | ||
| 104 | int totalUnread, PageLoader loader, PageLoader previousLoader = {}); | ||
| 105 | bool canFetchPrevious() const; | ||
| 106 | // F100-66: the order of the pages the loaders deliver; streamed rows are | ||
| 107 | // inserted at their position in it. Set before the pages of a new order. | ||
| 108 | 701 | void setSortOrder(const MailSortOrder &order) { m_sort = order; } | |
| 109 | const MailSortOrder &sortOrder() const { return m_sort; } | ||
| 110 | void fetchPrevious(std::function<void()> completed = {}); | ||
| 111 | // T-87.D1 random seek: anchor the retained window at the given uid (date | ||
| 112 | // unknown — the keyset falls back to a uid-only comparison) and load one | ||
| 113 | // window around it. No-op without loaders or while a page is pending. | ||
| 114 | void seekToUid(qint64 uid, std::function<void()> completed = {}); | ||
| 115 | void setAuthoritativeCounts(int totalCount, int totalUnread); | ||
| 116 | // The folder's unread total for mail outside the loaded window: a visual | ||
| 117 | // Seen commit on an unloaded row adjusts it by one, and settled cache | ||
| 118 | // counts (IDLE flag pushes, flag sync) replace it. Never below zero. | ||
| 119 | 10 | void adjustUnreadCount(int delta) { m_unreadCount = std::max(0, m_unreadCount + delta); } | |
| 120 | 12 | void setAuthoritativeUnread(int unread) { resetUnread(std::max(0, unread)); } | |
| 121 | // S106-184: changes whenever the unread total is set anew (a page, count | ||
| 122 | // or window reload) — a later correction of an earlier ±1 adjustment | ||
| 123 | // applies only while the basis it adjusted is still the current one. | ||
| 124 | 164 | quint64 unreadBasis() const { return m_unreadBasis; } | |
| 125 | |||
| 126 | // Append new headers (incremental sync from IMAP). | ||
| 127 | void appendHeaders(const QList<MailHeader> &headers); | ||
| 128 | // Keep the live IMAP stream within the retained-window row/byte budget. | ||
| 129 | // The caller owns canonical counts and reloads the cache page if a suffix | ||
| 130 | // could not be published. Other appendHeaders callers retain their semantics. | ||
| 131 | int appendStreamedHeaders(const QList<MailHeader> &headers); | ||
| 132 | |||
| 133 | // Clear all data. | ||
| 134 | void clear(); | ||
| 135 | |||
| 136 | // Update flags for a specific UID. Emits dataChanged for the row. | ||
| 137 | // T-514: folderId required for composite key lookup. | ||
| 138 | void updateFlags(qint64 uid, quint32 newFlags, qint64 folderId); | ||
| 139 | |||
| 140 | // T-71: Update attachment status for a row (called after storeAttachments). | ||
| 141 | void setHasAttachments(qint64 uid, qint64 folderId, bool has); | ||
| 142 | |||
| 143 | // Remove a mail by UID. Uses beginRemoveRows/endRemoveRows. | ||
| 144 | // T-514: folderId required for composite key lookup. | ||
| 145 | void removeByUid(qint64 uid, qint64 folderId); | ||
| 146 | |||
| 147 | // T-525/Cx8: Batch remove — single rebuild instead of O(N²) | ||
| 148 | // T-514: folderId required (all UIDs must be from the same folder). | ||
| 149 | void removeByUids(const QList<qint64> &uids, qint64 folderId); | ||
| 150 | |||
| 151 | // Get the header at a given row. Returns nullptr if out of range. | ||
| 152 | const MailHeader *headerAt(int row) const; | ||
| 153 | MailHeader *mutableHeaderAt(int row); | ||
| 154 | |||
| 155 | // Get the UID at a given row. Returns -1 if out of range. | ||
| 156 | qint64 uidAt(int row) const; | ||
| 157 | |||
| 158 | // Find the row index for a given UID. Returns -1 if not found. | ||
| 159 | // T-514: folderId required for composite key lookup. | ||
| 160 | int rowForUid(qint64 uid, qint64 folderId) const; | ||
| 161 | |||
| 162 | // P90.15: flags of a loaded row without any SQL. Engaged when the caller | ||
| 163 | // needs to decide a seen/unseen toggle from the loaded projection and | ||
| 164 | // commit the visual state before the durable journal write. | ||
| 165 | std::optional<quint32> flagsFor(qint64 uid, qint64 folderId) const; | ||
| 166 | |||
| 167 | // Count of unseen (unread) mails in the current model. | ||
| 168 | int unreadCount() const; | ||
| 169 | 43 | int totalHeaderCount() const { return m_totalHeaderCount; } | |
| 170 | // T-87.D1: conservative decoded-byte charge of the retained window. | ||
| 171 | qint64 retainedBytes() const { return m_retainedBytes; } | ||
| 172 | // Row-removal observers must distinguish window eviction from message deletion. | ||
| 173 | // True only while the retained-window removal signals are being delivered. | ||
| 174 | 151 | bool isEvictingRetainedRows() const { return m_evictingRetainedRows; } | |
| 175 | // Rows a filtered first page leaves out are hidden, not deleted: removal | ||
| 176 | // observers must not move a selection off them. Set by the controller only | ||
| 177 | // while it applies a page read with an active flat filter. | ||
| 178 | 147 | bool isHidingFilteredRows() const { return m_hidingFilteredRows; } | |
| 179 | 1404 | void setHidingFilteredRows(bool hiding) { m_hidingFilteredRows = hiding; } | |
| 180 | |||
| 181 | // Access the full header list for thread model population. | ||
| 182 | 23 | const QList<MailHeader> &allHeaders() const { return m_headers; } | |
| 183 | |||
| 184 | // T-87.D1 bounded retained window. Forward pages append older rows at the | ||
| 185 | // tail and evict the newest rows at the head; backward pages prepend newer | ||
| 186 | // rows at the head and evict the oldest rows at the tail. The retained set | ||
| 187 | // therefore always stays contiguous, both keyset cursors remain valid, and | ||
| 188 | // evicted ranges are re-fetched on demand when scrolling back. | ||
| 189 | static constexpr int MaximumRetainedRows = 1000; // 5 x PageSize | ||
| 190 | static constexpr int MaximumPageDiffMoves = 64; | ||
| 191 | // Liveness floor: never evict below this even when the byte budget is | ||
| 192 | // exceeded, so pathological fat headers cannot drain the visible window. | ||
| 193 | static constexpr int MinimumRetainedRows = 400; // 2 x PageSize | ||
| 194 | static constexpr qint64 MaximumRetainedBytes = 8LL * 1024 * 1024; | ||
| 195 | |||
| 196 | // T-232: Set/clear folder suggestion for a specific UID. | ||
| 197 | // T-514: folderId required for composite key lookup. | ||
| 198 | void setSuggestion(qint64 uid, qint64 folderId, const QString &text, double confidence); | ||
| 199 | void clearSuggestions(); | ||
| 200 | |||
| 201 | // T-79.E5/M16: search-mode folder badges. Register before populating the | ||
| 202 | // model with search results; clear when leaving search mode. | ||
| 203 | void setFolderBadge(qint64 folderId, const QString &folderName); | ||
| 204 | void clearFolderBadges(); | ||
| 205 | |||
| 206 | public: | ||
| 207 | // 67.B4: humanized date for the Date column (shared with | ||
| 208 | // MailThreadModel). Today "14:32" · yesterday "Yesterday" · this week | ||
| 209 | // "Mon" · this year "12. May" · older "12.05.2024". | ||
| 210 | static QString formatDate(const QDateTime &dt); | ||
| 211 | |||
| 212 | signals: | ||
| 213 | void pageLoadFailed(const QString &error); | ||
| 214 | // T-87.D1: emitted before the retained window drops rows [first,last] | ||
| 215 | // (head side when sliding down, tail side when sliding up) so selection | ||
| 216 | // owners can capture identities ahead of the removal. | ||
| 217 | void windowSlid(int first, int last); | ||
| 218 | |||
| 219 | private: | ||
| 220 | void insertStreamedHeaders(QList<MailHeader> batch); | ||
| 221 | bool applyPageDiff(const QList<MailHeader> &page); | ||
| 222 | void requestPage(bool previous, std::function<void()> completed = {}); | ||
| 223 | |||
| 224 | // T-87.D1: slide the retained window after a page insert. fetchedForward | ||
| 225 | // evicts from the head, fetchedBackward (previous) evicts from the tail. | ||
| 226 | void evictDistantRetainedRows(bool fetchedForward); | ||
| 227 | |||
| 228 | // Conservative decoded-size charge shared with the writer's batch budget. | ||
| 229 | static qint64 headerCharge(const MailHeader &header); | ||
| 230 | |||
| 231 | // Format size for display: "1.2 KB", "3.4 MB" | ||
| 232 | static QString formatSize(qint64 bytes); | ||
| 233 | |||
| 234 | // T-115: Rebuild the UID→row index from m_headers. | ||
| 235 | void rebuildUidIndex(); | ||
| 236 | |||
| 237 | QList<MailHeader> m_headers; | ||
| 238 | |||
| 239 | // T-514: O(1) (folderId,UID)→row lookup index. | ||
| 240 | // Maintained in sync with m_headers by all mutating methods. | ||
| 241 | QHash<MailKey, int> m_uidIndex; | ||
| 242 | |||
| 243 | // T-116: Cached unread (unseen) count — avoids O(n) scan on each query. | ||
| 244 | // Maintained by all mutating methods (setHeaders, appendHeaders, | ||
| 245 | // updateFlags, removeByUid, clear). | ||
| 246 | int m_unreadCount = 0; | ||
| 247 | quint64 m_unreadBasis = 0; | ||
| 248 | 1830 | void resetUnread(int unread) { m_unreadCount = unread; ++m_unreadBasis; } | |
| 249 | int m_totalHeaderCount = 0; | ||
| 250 | AsyncPageLoader m_pageLoader; | ||
| 251 | AsyncPageLoader m_previousPageLoader; | ||
| 252 | quint64 m_pageGeneration = 0; | ||
| 253 | bool m_pagePending = false; | ||
| 254 | bool m_pageFailed = false; | ||
| 255 | PageCursor m_previousPageCursor; | ||
| 256 | PageCursor m_pageCursor; | ||
| 257 | MailSortOrder m_sort; | ||
| 258 | qint64 m_retainedBytes = 0; | ||
| 259 | struct WindowState { | ||
| 260 | QList<MailHeader> headers; | ||
| 261 | int totalCount = 0; | ||
| 262 | int unreadCount = 0; | ||
| 263 | AsyncPageLoader pageLoader, previousPageLoader; | ||
| 264 | PageCursor pageCursor, previousPageCursor; | ||
| 265 | bool dataChanged = false; | ||
| 266 | }; | ||
| 267 | // Marks the kept unfiltered window as outdated (see restoreUnfilteredWindow). | ||
| 268 |
2/2✓ Branch 3 → 4 taken 2 times.
✓ Branch 3 → 6 taken 1055 times.
|
1057 | void markKeptWindowChanged() { if (m_unfilteredWindow) m_unfilteredWindow->dataChanged = true; } |
| 269 | std::optional<WindowState> m_unfilteredWindow; | ||
| 270 | bool m_evictingRetainedRows = false; | ||
| 271 | bool m_hidingFilteredRows = false; | ||
| 272 | static constexpr int PageSize = 200; | ||
| 273 | |||
| 274 | // T-232: Suggestion cache — (folderId,UID) → display text + confidence | ||
| 275 | struct SuggestionData { | ||
| 276 | QString text; // e.g. "→ Projekte 96%" | ||
| 277 | double confidence; // for color coding | ||
| 278 | }; | ||
| 279 | QHash<MailKey, SuggestionData> m_suggestionCache; | ||
| 280 | |||
| 281 | // T-79.E5/M16: search-mode folder badges (folderId → display name). | ||
| 282 | // Painted as a "[folder] " prefix in the Subject column; the underlying | ||
| 283 | // MailHeader::subject stays clean so replies/forwards never carry it. | ||
| 284 | QHash<qint64, QString> m_folderBadges; | ||
| 285 | }; | ||
| 286 |