Branch data Line data Source code
1 : : #include "ThreadBuilder.h"
2 : :
3 : : #include <QHash>
4 : : #include <QLoggingCategory>
5 : : #include <QRegularExpression>
6 : :
7 : : #include <algorithm>
8 : :
9 [ + + + - : 73 : Q_LOGGING_CATEGORY(lcThreadBuilder, "mailjd.threading")
+ - - - ]
10 : :
11 : : // T-79.B1/M9: true if 'candidate' or any of its ancestors is 'node'.
12 : : // Linking node under candidate would then close a parent cycle — both
13 : : // mails would end up unreachable from the roots (invisible + leaked).
14 : 38 : static bool wouldCreateCycle(const ThreadNode *node,
15 : : const ThreadNode *candidate) {
16 [ + + ]: 78 : for (const ThreadNode *p = candidate; p; p = p->parent) {
17 [ + + ]: 44 : if (p == node)
18 : 4 : return true;
19 : : }
20 : 34 : return false;
21 : : }
22 : :
23 : : std::vector<std::unique_ptr<ThreadNode>>
24 : 80 : ThreadBuilder::buildThreads(const QList<MailHeader> &headers) {
25 [ + + ]: 80 : if (headers.isEmpty())
26 : 7 : return {};
27 : :
28 : : // Step 1: Build messageId → ThreadNode index
29 : : // All nodes are created as unique_ptr and stored in allNodes.
30 : : // Raw pointers in nodeById are non-owning references for linking.
31 : 73 : QHash<QString, ThreadNode *> nodeById;
32 : 73 : std::vector<std::unique_ptr<ThreadNode>> allNodes;
33 : :
34 [ + + ]: 275 : for (int i = 0; i < headers.size(); ++i) {
35 [ + - ]: 202 : auto node = std::make_unique<ThreadNode>();
36 : 202 : node->header = headers[i]; // T-79.B1/H7: node owns its header by value
37 : 202 : auto *raw = node.get();
38 [ + - ]: 202 : allNodes.push_back(std::move(node));
39 : :
40 [ + + ]: 202 : if (!headers[i].messageId.isEmpty()) {
41 [ + - ]: 174 : nodeById.insert(headers[i].messageId, raw);
42 : : }
43 : 202 : }
44 : :
45 : : // Step 2: Link parents via In-Reply-To, then References fallback.
46 : : // T-79.B1/M9: refuse links that would close a cycle (covers the old
47 : : // self-reference guard too) — forged mutual In-Reply-To/References
48 : : // otherwise made both mails vanish from the thread view.
49 [ + + ]: 275 : for (auto &node : allNodes) {
50 : : // Try In-Reply-To first (most reliable)
51 [ + + ]: 202 : if (!node->header.inReplyTo.isEmpty()) {
52 : 22 : auto *parent = nodeById.value(node->header.inReplyTo, nullptr);
53 [ + + + + : 22 : if (parent && !wouldCreateCycle(node.get(), parent)) {
+ + ]
54 : 19 : node->parent = parent;
55 : : // Transfer ownership: move from allNodes to parent->children later
56 : 19 : continue; // linked via In-Reply-To
57 : : }
58 : : }
59 : : // T-432: Fallback — try References header (last entry = direct parent)
60 [ + + ]: 183 : if (!node->header.references.isEmpty()) {
61 [ + + ]: 14 : for (int r = node->header.references.size() - 1; r >= 0; --r) {
62 : : auto *refParent =
63 [ + - ]: 12 : nodeById.value(node->header.references[r], nullptr);
64 [ + + + + : 12 : if (refParent && !wouldCreateCycle(node.get(), refParent)) {
+ + ]
65 : 8 : node->parent = refParent;
66 : 8 : break;
67 : : }
68 : : }
69 : : }
70 : : }
71 : :
72 : : // Step 3: Subject-based fallback for orphaned replies
73 : : // Group by normalized subject → if a Re:/Fwd: mail has no parent,
74 : : // try to attach it to the oldest non-reply mail with the same subject.
75 : 73 : QHash<QString, ThreadNode *> subjectRoots;
76 [ + + ]: 275 : for (auto &node : allNodes) {
77 [ + + ]: 202 : if (node->parent)
78 : 28 : continue; // already threaded
79 : :
80 [ + - ]: 175 : QString normSubject = normalizeSubject(node->header.subject);
81 [ + + ]: 175 : if (normSubject.isEmpty())
82 : 1 : continue;
83 : :
84 [ + - ]: 174 : bool isReply = (normSubject != node->header.subject.trimmed());
85 [ + + ]: 174 : if (!isReply) {
86 : : // This is a root-level subject (not a reply)
87 [ + + ]: 164 : if (!subjectRoots.contains(normSubject)) {
88 [ + - ]: 161 : subjectRoots.insert(normSubject, node.get());
89 : : }
90 : : }
91 [ + + ]: 175 : }
92 : :
93 : : // Now attach orphaned replies to subject roots
94 [ + + ]: 275 : for (auto &node : allNodes) {
95 [ + + ]: 202 : if (node->parent)
96 : 27 : continue;
97 : :
98 [ + - ]: 175 : QString normSubject = normalizeSubject(node->header.subject);
99 [ + - ]: 175 : bool isReply = (normSubject != node->header.subject.trimmed());
100 [ + + + + : 175 : if (isReply && subjectRoots.contains(normSubject)) {
+ + ]
101 : 7 : auto *root = subjectRoots.value(normSubject);
102 [ + - ]: 7 : if (!wouldCreateCycle(node.get(), root)) {
103 : 7 : node->parent = root;
104 : : }
105 : : }
106 : 175 : }
107 : :
108 : : // Step 4: Transfer children ownership from allNodes to parent->children
109 : : // and collect root nodes.
110 : : // We must iterate with index because we move elements out of allNodes.
111 : 73 : std::vector<std::unique_ptr<ThreadNode>> roots;
112 [ + + ]: 275 : for (size_t i = 0; i < allNodes.size(); ++i) {
113 [ + + ]: 202 : if (allNodes[i]->parent) {
114 [ + - ]: 34 : allNodes[i]->parent->children.push_back(std::move(allNodes[i]));
115 : : } else {
116 [ + - ]: 168 : roots.push_back(std::move(allNodes[i]));
117 : : }
118 : : }
119 : :
120 : : // Set depths recursively
121 : 348 : std::function<void(ThreadNode *, int)> setDepths = [&](ThreadNode *node,
122 : : int depth) {
123 : 202 : node->depth = depth;
124 [ + + ]: 236 : for (auto &child : node->children) {
125 [ + - ]: 34 : setDepths(child.get(), depth + 1);
126 : : }
127 : 275 : };
128 [ + + ]: 241 : for (auto &root : roots) {
129 [ + - ]: 168 : setDepths(root.get(), 0);
130 : : }
131 : :
132 : : // Step 5: Sort children by date (oldest first)
133 : 348 : std::function<void(ThreadNode *)> sortChildren = [&](ThreadNode *node) {
134 : 202 : std::sort(node->children.begin(), node->children.end(),
135 : 15 : [](const std::unique_ptr<ThreadNode> &a,
136 : : const std::unique_ptr<ThreadNode> &b) {
137 : 15 : return a->header.date < b->header.date;
138 : : });
139 [ + + ]: 236 : for (auto &child : node->children) {
140 [ + - ]: 34 : sortChildren(child.get());
141 : : }
142 : 275 : };
143 [ + + ]: 241 : for (auto &root : roots) {
144 [ + - ]: 168 : sortChildren(root.get());
145 : : }
146 : :
147 : : // Step 6: Sort root nodes by newest descendant date (DESC)
148 [ + - ]: 73 : std::sort(roots.begin(), roots.end(),
149 : 177 : [](const std::unique_ptr<ThreadNode> &a,
150 : : const std::unique_ptr<ThreadNode> &b) {
151 [ + - + - : 177 : return a->newestDate() > b->newestDate();
+ - ]
152 : : });
153 : :
154 [ + - + - : 146 : qCInfo(lcThreadBuilder) << "Built" << roots.size() << "threads from"
+ - + - +
- + + ]
155 [ + - + - ]: 73 : << headers.size() << "headers";
156 : 73 : return roots;
157 : 73 : }
158 : :
159 : 350 : QString ThreadBuilder::normalizeSubject(const QString &subject) {
160 : : // Strip Re:/Fwd:/AW:/WG: prefixes (case-insensitive, repeating)
161 : : static QRegularExpression prefixRx(
162 : : R"(^(?:\s*(?:Re|Fwd|Fw|AW|WG)\s*(?:\[\d+\])?\s*:\s*)+)",
163 [ + + + - : 350 : QRegularExpression::CaseInsensitiveOption);
+ - + - -
- ]
164 : 350 : QString result = subject;
165 [ + - ]: 350 : result.replace(prefixRx, QString());
166 [ + - ]: 700 : return result.trimmed();
167 : 350 : }
|