controller/ThreadBuilder.h
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #pragma once | ||
| 2 | |||
| 3 | #include <QList> | ||
| 4 | #include <QString> | ||
| 5 | #include <memory> | ||
| 6 | #include <vector> | ||
| 7 | #include <functional> | ||
| 8 | |||
| 9 | #include "data/Models.h" | ||
| 10 | |||
| 11 | // A node in the mail thread tree. | ||
| 12 | // Children are owned via unique_ptr; root nodes are owned by the caller. | ||
| 13 | // T-79.B1/H7: the node owns its MailHeader by value. A pointer into the | ||
| 14 | // source QList shared its (implicitly shared) buffer with MailListModel — | ||
| 15 | // the first detach made the tree paint stale data and a later reallocation | ||
| 16 | // turned every node pointer into a use-after-free. | ||
| 17 | struct ThreadNode { | ||
| 18 | MailHeader header; | ||
| 19 | ThreadNode *parent = nullptr; | ||
| 20 | std::vector<std::unique_ptr<ThreadNode>> children; | ||
| 21 | int depth = 0; | ||
| 22 | |||
| 23 | // Presentation metadata populated by MailThreadModel after the tree is built. | ||
| 24 | int rowInParent = -1; | ||
| 25 | int cachedDescendants = 0; | ||
| 26 | int cachedUnreadDescendants = 0; | ||
| 27 | |||
| 28 | 424651 | ~ThreadNode() = default; | |
| 29 | |||
| 30 | // Newest date in this subtree (for sorting root nodes). | ||
| 31 | 225983 | QDateTime newestDate() const { | |
| 32 | 225983 | QDateTime newest = header.date; | |
| 33 |
2/2✓ Branch 22 → 5 taken 187587 times.
✓ Branch 22 → 23 taken 225983 times.
|
413570 | for (const auto &child : children) { |
| 34 |
1/1✓ Branch 7 → 8 taken 187587 times.
|
187587 | QDateTime childDate = child->newestDate(); |
| 35 |
9/11✓ Branch 8 → 9 taken 187587 times.
✓ Branch 9 → 10 taken 187587 times.
✗ Branch 9 → 16 not taken.
✓ Branch 10 → 11 taken 187587 times.
✓ Branch 11 → 12 taken 187587 times.
✗ Branch 11 → 15 not taken.
✓ Branch 12 → 13 taken 187587 times.
✓ Branch 14 → 15 taken 159572 times.
✓ Branch 14 → 16 taken 28015 times.
✓ Branch 17 → 18 taken 159572 times.
✓ Branch 17 → 19 taken 28015 times.
|
187587 | if (childDate.isValid() && (!newest.isValid() || childDate > newest)) |
| 36 | 159572 | newest = childDate; | |
| 37 | 187587 | } | |
| 38 | 225983 | return newest; | |
| 39 | ✗ | } | |
| 40 | |||
| 41 | // Total count of descendants (not including self). | ||
| 42 | 6101 | int descendantCount() const { | |
| 43 | 6101 | int count = 0; | |
| 44 |
2/2✓ Branch 9 → 4 taken 6035 times.
✓ Branch 9 → 10 taken 6101 times.
|
12136 | for (const auto &child : children) { |
| 45 |
1/1✓ Branch 6 → 7 taken 6035 times.
|
6035 | count += 1 + child->descendantCount(); |
| 46 | } | ||
| 47 | 6101 | return count; | |
| 48 | } | ||
| 49 | |||
| 50 | // T-547: Check if any descendant has unseen mail. | ||
| 51 | 2007 | bool hasUnseenDescendant() const { | |
| 52 |
2/2✓ Branch 15 → 4 taken 2005 times.
✓ Branch 15 → 16 taken 2003 times.
|
4008 | for (const auto &child : children) { |
| 53 |
2/2✓ Branch 7 → 8 taken 3 times.
✓ Branch 7 → 9 taken 2002 times.
|
2005 | if (!child->header.isSeen()) |
| 54 | 4 | return true; | |
| 55 |
3/3✓ Branch 10 → 11 taken 2002 times.
✓ Branch 11 → 12 taken 1 time.
✓ Branch 11 → 13 taken 2001 times.
|
2002 | if (child->hasUnseenDescendant()) |
| 56 | 1 | return true; | |
| 57 | } | ||
| 58 | 2003 | return false; | |
| 59 | } | ||
| 60 | |||
| 61 | // T-547: Count unseen descendants (not including self). | ||
| 62 | 2011 | int unseenDescendantCount() const { | |
| 63 | 2011 | int count = 0; | |
| 64 |
2/2✓ Branch 13 → 4 taken 2007 times.
✓ Branch 13 → 14 taken 2011 times.
|
4018 | for (const auto &child : children) { |
| 65 |
2/2✓ Branch 7 → 8 taken 6 times.
✓ Branch 7 → 9 taken 2001 times.
|
2007 | if (!child->header.isSeen()) |
| 66 | 6 | ++count; | |
| 67 |
1/1✓ Branch 10 → 11 taken 2007 times.
|
2007 | count += child->unseenDescendantCount(); |
| 68 | } | ||
| 69 | 2011 | return count; | |
| 70 | } | ||
| 71 | }; | ||
| 72 | |||
| 73 | // Builds a deterministic references-first thread tree from a flat list of | ||
| 74 | // MailHeaders. Duplicate/missing IDs, cycles, depth, and conservative subject | ||
| 75 | // fallback have stable policies independent of input order. | ||
| 76 | class ThreadBuilder { | ||
| 77 | public: | ||
| 78 | struct FallbackCandidate { | ||
| 79 | QString subject; | ||
| 80 | QString participant; | ||
| 81 | qint64 date = 0; | ||
| 82 | bool reply = false; | ||
| 83 | }; | ||
| 84 | // Maximum number of parent edges accepted for one message. Links beyond | ||
| 85 | // this depth are detached into another root so every message stays visible. | ||
| 86 | static constexpr int MaximumDepth = 64; | ||
| 87 | static constexpr qint64 FallbackWindowSeconds = 30LL * 24 * 60 * 60; | ||
| 88 | |||
| 89 | // Build thread tree from flat header list. | ||
| 90 | // The returned unique_ptrs own the root-level ThreadNodes (and their | ||
| 91 | // children). Each node holds its own copy of the MailHeader, so 'headers' | ||
| 92 | // may be modified or destroyed after this call. | ||
| 93 | static std::vector<std::unique_ptr<ThreadNode>> | ||
| 94 | buildThreads(const QList<MailHeader> &headers, | ||
| 95 | const std::function<bool()> &cancelled = {}); | ||
| 96 | |||
| 97 | // Exact Message-ID/References dependency keys for component discovery. | ||
| 98 | // Subject fallback uses participant/date candidate rows separately. | ||
| 99 | static QStringList dependencyKeys(const MailHeader &header); | ||
| 100 | |||
| 101 | // Candidate rows use the same normalization, address parser and prefix | ||
| 102 | // classification as buildThreads. A root/reply pair can only participate | ||
| 103 | // in subject fallback when subject and participant match and its dates are | ||
| 104 | // ordered within 30 days. This may include roots with resolved references; | ||
| 105 | // the builder remains the final authority on whether a link is made. | ||
| 106 | static QList<FallbackCandidate> fallbackCandidates(const MailHeader &header); | ||
| 107 | }; | ||
| 108 |