Branch data Line data Source code
1 : : #include "SearchQuery.h"
2 : :
3 : : #include <QDate>
4 : : #include <QDateTime>
5 : : #include <QStringList>
6 : :
7 : : using Tri = MailCache::SearchFilter::Tri;
8 : :
9 : : namespace {
10 : :
11 : : // Split the input into tokens, honoring double quotes so a prefix value can
12 : : // contain spaces (subject:"offene Rechnung" → one token "subject:offene
13 : : // Rechnung"). Quote characters are dropped; spaces inside quotes are kept.
14 : 225 : QStringList tokenize(const QString &input) {
15 : 225 : QStringList tokens;
16 : 225 : QString cur;
17 : 225 : bool inQuotes = false;
18 : 225 : bool hasToken = false;
19 [ + + ]: 3121 : for (const QChar ch : input) {
20 [ + + ]: 2896 : if (ch == QLatin1Char('"')) {
21 : 31 : inQuotes = !inQuotes;
22 : 31 : hasToken = true; // an empty quoted string is still a (empty) token start
23 : 31 : continue;
24 : : }
25 [ + + + + : 2865 : if (ch.isSpace() && !inQuotes) {
+ + ]
26 [ + + ]: 138 : if (hasToken) {
27 [ + - ]: 130 : tokens.append(cur);
28 : 130 : cur.clear();
29 : 130 : hasToken = false;
30 : : }
31 : 138 : continue;
32 : : }
33 [ + - ]: 2727 : cur.append(ch);
34 : 2727 : hasToken = true;
35 : : }
36 [ + + ]: 225 : if (hasToken)
37 [ + - ]: 209 : tokens.append(cur);
38 : 225 : return tokens;
39 : 225 : }
40 : :
41 : : // Parse a "date:" value into an inclusive [from, to] range. Supports presets
42 : : // (heute/gestern/woche/monat/jahr), ISO dates (year/month/day) and "A..B"
43 : : // ranges with open ends.
44 : 34 : QPair<QDateTime, QDateTime> parseDateValue(const QString &val) {
45 : 34 : QDateTime from, to;
46 [ + - ]: 34 : const QDate today = QDate::currentDate();
47 : :
48 [ + - ]: 34 : const int dotdot = val.indexOf(QStringLiteral(".."));
49 [ + + ]: 34 : if (dotdot >= 0) {
50 [ + - ]: 17 : const QString a = val.left(dotdot);
51 [ + - ]: 17 : const QString b = val.mid(dotdot + 2);
52 [ + + ]: 17 : if (!a.isEmpty()) {
53 [ + - ]: 14 : QDate d = QDate::fromString(a, Qt::ISODate);
54 [ + - + + : 14 : if (!d.isValid() && a.length() == 7) // "2025-02"
+ + + + ]
55 [ + - + - ]: 2 : d = QDate::fromString(a + "-01", Qt::ISODate);
56 [ + - + + : 14 : if (!d.isValid() && a.length() == 4) // "2025"
+ + + + ]
57 [ + - + - ]: 3 : d = QDate(a.toInt(), 1, 1);
58 [ + - + + ]: 14 : if (d.isValid())
59 [ + - + - ]: 11 : from = QDateTime(d, QTime(0, 0));
60 : : }
61 [ + + ]: 17 : if (!b.isEmpty()) {
62 [ + - ]: 13 : QDate d = QDate::fromString(b, Qt::ISODate);
63 [ + - + + : 13 : if (!d.isValid() && b.length() == 7)
+ + + + ]
64 [ + - + - : 3 : d = QDate::fromString(b + "-01", Qt::ISODate).addMonths(1).addDays(-1);
+ - + - ]
65 [ + - + + : 13 : if (!d.isValid() && b.length() == 4)
+ + + + ]
66 [ + - + - ]: 2 : d = QDate(b.toInt(), 12, 31);
67 [ + - + + ]: 13 : if (d.isValid())
68 [ + - + - ]: 9 : to = QDateTime(d, QTime(23, 59, 59));
69 : : }
70 : 17 : return {from, to};
71 : 17 : }
72 : :
73 [ + + ]: 17 : if (val == QStringLiteral("heute")) {
74 [ + - + - ]: 2 : from = QDateTime(today, QTime(0, 0));
75 [ + - + - ]: 2 : to = QDateTime(today, QTime(23, 59, 59));
76 [ + + ]: 15 : } else if (val == QStringLiteral("gestern")) {
77 [ + - ]: 1 : const QDate y = today.addDays(-1);
78 [ + - + - ]: 1 : from = QDateTime(y, QTime(0, 0));
79 [ + - + - ]: 1 : to = QDateTime(y, QTime(23, 59, 59));
80 [ + + ]: 14 : } else if (val == QStringLiteral("woche")) {
81 [ + - + - : 1 : from = QDateTime(today.addDays(-7), QTime(0, 0));
+ - ]
82 [ + - + - ]: 1 : to = QDateTime(today, QTime(23, 59, 59));
83 [ + + ]: 13 : } else if (val == QStringLiteral("monat")) {
84 [ + - + - : 1 : from = QDateTime(today.addDays(-30), QTime(0, 0));
+ - ]
85 [ + - + - ]: 1 : to = QDateTime(today, QTime(23, 59, 59));
86 [ + + ]: 12 : } else if (val == QStringLiteral("jahr")) {
87 [ + - + - : 1 : from = QDateTime(today.addYears(-1), QTime(0, 0));
+ - ]
88 [ + - + - ]: 1 : to = QDateTime(today, QTime(23, 59, 59));
89 : : } else {
90 [ + - ]: 11 : QDate d = QDate::fromString(val, Qt::ISODate);
91 [ + - + + ]: 11 : if (d.isValid()) {
92 [ + - + - ]: 2 : from = QDateTime(d, QTime(0, 0));
93 [ + - + - ]: 2 : to = QDateTime(d, QTime(23, 59, 59));
94 [ + + ]: 9 : } else if (val.length() == 7) { // "2025-02"
95 [ + - + - ]: 3 : const QDate start = QDate::fromString(val + "-01", Qt::ISODate);
96 [ + - + + ]: 3 : if (start.isValid()) {
97 [ + - + - ]: 2 : from = QDateTime(start, QTime(0, 0));
98 [ + - + - : 2 : to = QDateTime(start.addMonths(1).addDays(-1), QTime(23, 59, 59));
+ - + - ]
99 : : }
100 [ + + ]: 6 : } else if (val.length() == 4) { // "2025"
101 [ + - ]: 4 : const int year = val.toInt();
102 [ + + + + ]: 4 : if (year > 1900 && year < 2100) {
103 [ + - + - : 1 : from = QDateTime(QDate(year, 1, 1), QTime(0, 0));
+ - ]
104 [ + - + - : 1 : to = QDateTime(QDate(year, 12, 31), QTime(23, 59, 59));
+ - ]
105 : : }
106 : : }
107 : : }
108 : 17 : return {from, to};
109 : 34 : }
110 : :
111 : : // Quote a value for serialization if it contains whitespace (or is empty), so
112 : : // it survives tokenization on the way back in.
113 : 40 : QString quoteIfNeeded(const QString &value) {
114 : 40 : bool needsQuote = value.isEmpty();
115 [ + + ]: 251 : for (const QChar ch : value) {
116 [ + + ]: 222 : if (ch.isSpace()) {
117 : 11 : needsQuote = true;
118 : 11 : break;
119 : : }
120 : : }
121 [ + + ]: 40 : if (!needsQuote)
122 : 28 : return value;
123 [ + - + - ]: 24 : return QLatin1Char('"') + value + QLatin1Char('"');
124 : : }
125 : :
126 : 2488 : bool startsWithCi(const QString &s, const QString &prefix) {
127 : 2488 : return s.startsWith(prefix, Qt::CaseInsensitive);
128 : : }
129 : :
130 : : } // namespace
131 : :
132 : 225 : QString parseSearchQuery(const QString &input,
133 : : MailCache::SearchFilter &filter) {
134 : 225 : QStringList remaining;
135 [ + - ]: 225 : const QStringList tokens = tokenize(input);
136 : :
137 [ + + ]: 564 : for (const QString &token : tokens) {
138 [ + - + + ]: 339 : if (startsWithCi(token, QStringLiteral("folder:"))) {
139 : : // Sprint 60 (Q1): repeatable — collect every folder: token (OR-semantics).
140 [ + - ]: 16 : const QString v = token.mid(7);
141 [ + + ]: 16 : if (!v.isEmpty())
142 [ + - ]: 15 : filter.folderPatterns.append(v);
143 [ + - + + ]: 339 : } else if (startsWithCi(token, QStringLiteral("date:"))) {
144 [ + - + - : 34 : auto [from, to] = parseDateValue(token.mid(5).toLower());
+ - ]
145 : 34 : filter.dateFrom = from;
146 : 34 : filter.dateTo = to;
147 [ + - + + ]: 323 : } else if (startsWithCi(token, QStringLiteral("from:"))) {
148 [ + - ]: 9 : filter.fromFilter = token.mid(5);
149 [ + - + + ]: 280 : } else if (startsWithCi(token, QStringLiteral("to:"))) {
150 [ + - ]: 7 : filter.toFilter = token.mid(3);
151 [ + - + + ]: 273 : } else if (startsWithCi(token, QStringLiteral("subject:"))) {
152 [ + - ]: 9 : filter.subjectFilter = token.mid(8);
153 [ + - + + ]: 264 : } else if (startsWithCi(token, QStringLiteral("tag:"))) {
154 [ + - ]: 10 : const QString v = token.mid(4);
155 [ + + ]: 10 : if (!v.isEmpty())
156 [ + - ]: 9 : filter.tags.append(v);
157 [ + - + + ]: 264 : } else if (startsWithCi(token, QStringLiteral("label:"))) {
158 [ + - ]: 3 : const QString v = token.mid(6);
159 [ + + ]: 3 : if (!v.isEmpty())
160 [ + - ]: 2 : filter.tags.append(v);
161 [ + - + + ]: 254 : } else if (startsWithCi(token, QStringLiteral("is:"))) {
162 [ + - + - ]: 36 : const QString v = token.mid(3).toLower();
163 [ + + ]: 36 : if (v == QStringLiteral("unread"))
164 : 13 : filter.unread = Tri::Yes;
165 [ + + ]: 23 : else if (v == QStringLiteral("read"))
166 : 2 : filter.unread = Tri::No;
167 [ + + + + : 61 : else if (v == QStringLiteral("starred") || v == QStringLiteral("flagged"))
+ + + + +
- + - +
+ ]
168 : 5 : filter.flagged = Tri::Yes;
169 [ + + + + : 62 : else if (v == QStringLiteral("unstarred") ||
+ - + + ]
170 [ + + + + : 30 : v == QStringLiteral("unflagged"))
+ - ]
171 : 7 : filter.flagged = Tri::No;
172 [ + + ]: 9 : else if (v == QStringLiteral("answered"))
173 : 4 : filter.answered = Tri::Yes;
174 [ + + ]: 5 : else if (v == QStringLiteral("unanswered"))
175 : 2 : filter.answered = Tri::No;
176 : : else
177 [ + - ]: 3 : remaining.append(token); // unknown is: value ⇒ keep as free text
178 [ + - + + ]: 251 : } else if (startsWithCi(token, QStringLiteral("has:"))) {
179 [ + - + - ]: 12 : const QString v = token.mid(4).toLower();
180 [ + + + + : 44 : if (v == QStringLiteral("attachment") ||
+ - + + ]
181 [ + + + + : 20 : v == QStringLiteral("attachments"))
+ - ]
182 : 5 : filter.hasAttachment = Tri::Yes;
183 [ + + + + : 24 : else if (v == QStringLiteral("noattachment") ||
+ - + + ]
184 [ + + + + : 10 : v == QStringLiteral("noattachments"))
+ - ]
185 : 5 : filter.hasAttachment = Tri::No;
186 : : else
187 [ + - ]: 2 : remaining.append(token); // unknown has: value ⇒ keep as free text
188 : 12 : } else {
189 [ + - ]: 203 : remaining.append(token);
190 : : }
191 : : }
192 : :
193 [ + - ]: 450 : return remaining.join(QLatin1Char(' '));
194 : 225 : }
195 : :
196 : 232 : QString buildSearchQuery(const QString &freeText,
197 : : const MailCache::SearchFilter &filter) {
198 : 232 : QStringList parts;
199 : :
200 : : // Free text first so parseSearchQuery() returns it verbatim as the remainder.
201 [ + - ]: 232 : const QString trimmedText = freeText.trimmed();
202 [ + + ]: 232 : if (!trimmedText.isEmpty())
203 [ + - ]: 151 : parts.append(trimmedText);
204 : :
205 : : // Sprint 60 (Q2): one folder: token per pattern, order preserved so the
206 : : // parse(build(...)) round-trip yields the same list.
207 [ + + ]: 244 : for (const QString &p : filter.folderPatterns)
208 [ + + ]: 12 : if (!p.isEmpty())
209 [ + - + - : 22 : parts.append(QStringLiteral("folder:") + quoteIfNeeded(p));
+ - ]
210 [ + + ]: 232 : if (!filter.fromFilter.isEmpty())
211 [ + - + - : 14 : parts.append(QStringLiteral("from:") + quoteIfNeeded(filter.fromFilter));
+ - ]
212 [ + + ]: 232 : if (!filter.toFilter.isEmpty())
213 [ + - + - : 12 : parts.append(QStringLiteral("to:") + quoteIfNeeded(filter.toFilter));
+ - ]
214 [ + + ]: 232 : if (!filter.subjectFilter.isEmpty())
215 [ + - ]: 18 : parts.append(QStringLiteral("subject:") +
216 [ + - + - ]: 27 : quoteIfNeeded(filter.subjectFilter));
217 : :
218 : : // Date as an explicit ISO range; open ends become "A.." / "..B".
219 [ + - + + : 232 : if (filter.dateFrom.isValid() || filter.dateTo.isValid()) {
+ - + + +
+ ]
220 [ + - ]: 6 : const QString a = filter.dateFrom.isValid()
221 [ + + + - ]: 6 : ? filter.dateFrom.date().toString(Qt::ISODate)
222 [ + - ]: 6 : : QString();
223 [ + - ]: 6 : const QString b = filter.dateTo.isValid()
224 [ + + + - ]: 12 : ? filter.dateTo.date().toString(Qt::ISODate)
225 [ + - ]: 6 : : QString();
226 [ + - + - : 12 : parts.append(QStringLiteral("date:") + a + QStringLiteral("..") + b);
+ - + - ]
227 : 6 : }
228 : :
229 [ + + ]: 239 : for (const QString &tag : filter.tags)
230 [ + - + - : 14 : parts.append(QStringLiteral("tag:") + quoteIfNeeded(tag));
+ - ]
231 : :
232 [ + + ]: 232 : if (filter.unread == Tri::Yes)
233 [ + - ]: 15 : parts.append(QStringLiteral("is:unread"));
234 [ + + ]: 217 : else if (filter.unread == Tri::No)
235 [ + - ]: 1 : parts.append(QStringLiteral("is:read"));
236 [ + + ]: 232 : if (filter.flagged == Tri::Yes)
237 [ + - ]: 2 : parts.append(QStringLiteral("is:flagged"));
238 [ + + ]: 230 : else if (filter.flagged == Tri::No)
239 [ + - ]: 4 : parts.append(QStringLiteral("is:unflagged"));
240 [ + + ]: 232 : if (filter.answered == Tri::Yes)
241 [ + - ]: 3 : parts.append(QStringLiteral("is:answered"));
242 [ + + ]: 229 : else if (filter.answered == Tri::No)
243 [ + - ]: 2 : parts.append(QStringLiteral("is:unanswered"));
244 [ + + ]: 232 : if (filter.hasAttachment == Tri::Yes)
245 [ + - ]: 5 : parts.append(QStringLiteral("has:attachment"));
246 [ + + ]: 227 : else if (filter.hasAttachment == Tri::No)
247 [ + - ]: 3 : parts.append(QStringLiteral("has:noattachment"));
248 : :
249 [ + - ]: 464 : return parts.join(QLatin1Char(' '));
250 : 232 : }
|