MailJD nbsp;·nbsp; Test Dashboard nbsp;·nbsp; Coverage
LCOV - code coverage report
Current view: top level - app - SearchQuery.cpp (source / functions) Coverage Total Hit
Test: MailJD Coverage (Unit + E2E) Lines: 100.0 % 189 189
Test Date: 2026-06-21 21:10:19 Functions: 100.0 % 6 6
Legend: Lines:     hit not hit
Branches: + taken - not taken # not executed
Branches: 69.2 % 496 343

             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 : }
        

Generated by: LCOV version 2.0-1