MailJD nbsp;·nbsp; Test Dashboard nbsp;·nbsp; Coverage
LCOV - code coverage report
Current view: top level - service - CalDavClient.cpp (source / functions) Coverage Total Hit
Test: MailJD Coverage (Unit + E2E) Lines: 97.8 % 956 935
Test Date: 2026-07-27 17:53:44 Functions: 100.0 % 46 46
Legend: Lines:     hit not hit
Branches: + taken - not taken # not executed
Branches: 61.2 % 2572 1573

             Branch data     Line data    Source code
       1                 :             : #include "CalDavClient.h"
       2                 :             : 
       3                 :             : #include <QDomDocument>
       4                 :             : #include <QLoggingCategory>
       5                 :             : #include <QNetworkAccessManager>
       6                 :             : #include <QNetworkReply>
       7                 :             : #include <QNetworkRequest>
       8                 :             : #include <QRegularExpression>
       9                 :             : #include <QTimeZone>
      10                 :             : #include <QTimer>
      11                 :             : 
      12                 :             : #include "DavNetworkLimits.h"
      13                 :             : #include "DavXmlHelper.h"
      14                 :             : 
      15   [ +  +  +  -  :         140 : Q_LOGGING_CATEGORY(lcCalDav, "mailjd.caldav")
             +  -  -  - ]
      16                 :             : 
      17                 :             : using namespace DavXmlHelper;
      18                 :             : 
      19                 :          69 : CalDavClient::CalDavClient(const QString &serverUrl, const QString &username,
      20                 :          69 :                            const QString &password, QObject *parent)
      21                 :          69 :     : QObject(parent), m_serverUrl(serverUrl), m_username(username),
      22                 :         138 :       m_password(password) {
      23   [ +  -  +  + ]:          69 :   if (m_serverUrl.endsWith('/'))
      24         [ +  - ]:           5 :     m_serverUrl.chop(1);
      25   [ +  -  +  -  :          69 :   m_nam = new QNetworkAccessManager(this);
             -  +  -  - ]
      26         [ +  - ]:          69 :   m_nam->setRedirectPolicy(QNetworkRequest::SameOriginRedirectPolicy);
      27                 :             : 
      28                 :             :   // T-79.F1/H8: inactivity watchdog. Started at construction so an idle
      29                 :             :   // client (caller never issued a request) still releases itself; rearmed
      30                 :             :   // on every request and every finished reply.
      31   [ +  -  +  -  :          69 :   m_inactivityTimer = new QTimer(this);
             -  +  -  - ]
      32         [ +  - ]:          69 :   m_inactivityTimer->setSingleShot(true);
      33         [ +  - ]:          69 :   m_inactivityTimer->setInterval(kInactivityTimeoutMs);
      34                 :          69 :   connect(m_inactivityTimer, &QTimer::timeout, this,
      35         [ +  - ]:          69 :           &CalDavClient::onInactivityTimeout);
      36         [ +  - ]:          69 :   m_inactivityTimer->start();
      37                 :          69 : }
      38                 :             : 
      39                 :             : // T-79.F1/H8: register a dispatched reply for lifetime tracking. The
      40                 :             : // decrement runs as a QUEUED handler so it always executes after the
      41                 :             : // reply's direct business handler — result signals (e.g.
      42                 :             : // calendarsDiscovered) synchronously start follow-up requests, which must
      43                 :             : // be counted before the zero check runs.
      44                 :          86 : void CalDavClient::trackReply(QNetworkReply *reply) {
      45                 :          86 :   ++m_pendingReplies;
      46         [ +  - ]:          86 :   m_trackedReplies.insert(reply);
      47                 :          86 :   m_inactivityTimer->start();
      48                 :          86 :   connect(
      49                 :             :       reply, &QNetworkReply::finished, this,
      50         [ +  - ]:          86 :       [this, reply]() {
      51                 :          35 :         m_trackedReplies.remove(reply);
      52                 :          35 :         m_inactivityTimer->start();
      53         [ +  + ]:          35 :         if (--m_pendingReplies == 0) {
      54                 :           3 :           m_inactivityTimer->stop();
      55                 :           3 :           emit allRequestsFinished();
      56                 :             :         }
      57                 :          35 :       },
      58                 :             :       Qt::QueuedConnection);
      59                 :          86 : }
      60                 :             : 
      61                 :           1 : void CalDavClient::onInactivityTimeout() {
      62         [ -  + ]:           1 :   if (m_pendingReplies <= 0) {
      63                 :             :     // Idle client that never got (or has finished) its work — release it.
      64         [ #  # ]:           0 :     emit allRequestsFinished();
      65                 :           0 :     return;
      66                 :             :   }
      67   [ +  -  +  -  :           2 :   qCWarning(lcCalDav) << "CalDAV inactivity timeout with" << m_pendingReplies
          +  -  +  -  +  
                      + ]
      68         [ +  - ]:           1 :                       << "outstanding replies — aborting";
      69                 :           1 :   m_pendingReplies = 0;
      70                 :           1 :   const auto replies = m_trackedReplies;
      71                 :           1 :   m_trackedReplies.clear();
      72         [ +  + ]:           2 :   for (QNetworkReply *reply : replies) {
      73         [ +  - ]:           1 :     reply->disconnect(this); // no late business/tracking callbacks
      74         [ +  - ]:           1 :     reply->abort();
      75         [ +  - ]:           1 :     reply->deleteLater();
      76                 :             :   }
      77                 :             :   // Both signals so sync callers surface the error and write callers can
      78                 :             :   // roll back their optimistic local change.
      79         [ +  - ]:           1 :   emit syncFailed(QStringLiteral("CalDAV request timed out"));
      80         [ +  - ]:           1 :   emit writeFailed(QStringLiteral("CalDAV request timed out"));
      81         [ +  - ]:           1 :   emit allRequestsFinished();
      82                 :           1 : }
      83                 :             : 
      84                 :          46 : void CalDavClient::setNetworkAccessManager(QNetworkAccessManager *nam) {
      85   [ +  +  +  -  :          46 :   if (m_nam && m_nam->parent() == this)
                   +  + ]
      86         [ +  - ]:          45 :     delete m_nam;
      87                 :          46 :   m_nam = nam;
      88         [ +  + ]:          46 :   if (m_nam) {
      89                 :          45 :     m_nam->setParent(this);
      90                 :          45 :     m_nam->setRedirectPolicy(QNetworkRequest::SameOriginRedirectPolicy);
      91                 :             :   }
      92                 :          46 : }
      93                 :             : 
      94                 :          92 : QByteArray CalDavClient::authHeader() const {
      95                 :             :   // No credentials → no auth header (e.g. testing with auth-free server)
      96         [ +  + ]:          92 :   if (m_username.isEmpty())
      97                 :           6 :     return {};
      98                 :             :   // T-504: Refuse to send credentials over unencrypted connections
      99         [ +  - ]:          86 :   QUrl serverUrl(m_serverUrl);
     100   [ +  -  +  -  :          86 :   if (serverUrl.scheme().toLower() == QLatin1String("http")) {
                   +  + ]
     101   [ +  -  +  -  :          22 :     qCWarning(lcCalDav) << "Refusing Basic Auth over HTTP — use HTTPS";
             +  -  +  + ]
     102                 :          11 :     return {};
     103                 :             :   }
     104                 :             :   return "Basic " +
     105                 :           0 :          QByteArray(
     106   [ +  -  +  -  :         225 :              (m_username + QStringLiteral(":") + m_password).toUtf8())
                   +  - ]
     107   [ +  -  +  - ]:         150 :              .toBase64();
     108                 :          86 : }
     109                 :             : 
     110                 :          38 : bool CalDavClient::isValidEtag(const QString &etag) {
     111         [ +  - ]:          38 :   const QByteArray bytes = etag.toUtf8();
     112                 :          38 :   qsizetype offset = 0;
     113                 :             :   // If-Match uses strong comparison, so weak entity-tags can never match.
     114         [ +  + ]:          38 :   if (bytes.startsWith("W/"))
     115                 :           1 :     return false;
     116   [ +  +  +  +  :          66 :   if (bytes.size() < offset + 2 || bytes.at(offset) != '"' ||
                   +  + ]
     117   [ +  -  +  + ]:          29 :       bytes.back() != '"') {
     118                 :          10 :     return false;
     119                 :             :   }
     120                 :             : 
     121                 :             :   // RFC 9110 section 8.8.3: entity-tag = [ weak ] opaque-tag,
     122                 :             :   // etagc = %x21 / %x23-7E / obs-text. Validate the encoded field value,
     123                 :             :   // because that is what QNetworkRequest receives.
     124         [ +  + ]:         134 :   for (qsizetype i = offset + 1; i < bytes.size() - 1; ++i) {
     125                 :         110 :     const auto value = static_cast<unsigned char>(bytes.at(i));
     126   [ +  +  +  +  :         110 :     if (value != 0x21 && !(value >= 0x23 && value <= 0x7E) && value < 0x80)
             +  +  +  + ]
     127                 :           3 :       return false;
     128                 :             :   }
     129                 :          24 :   return true;
     130                 :          38 : }
     131                 :             : 
     132                 :         208 : static int effectivePort(const QUrl &url) {
     133                 :         208 :   const int port = url.port();
     134         [ +  + ]:         208 :   if (port >= 0)
     135                 :          25 :     return port;
     136         [ +  - ]:         549 :   return url.scheme().compare(QStringLiteral("https"), Qt::CaseInsensitive) == 0
     137         [ +  + ]:         183 :              ? 443
     138                 :         183 :              : 80;
     139                 :             : }
     140                 :             : 
     141                 :         123 : QString CalDavClient::resolveDavUrl(const QString &href) const {
     142         [ +  - ]:         123 :   QUrl server(m_serverUrl);
     143                 :         123 :   QUrl base = server;
     144   [ +  -  +  -  :         123 :   if (!base.path().endsWith(QLatin1Char('/')))
                   +  + ]
     145   [ +  -  +  -  :         244 :     base.setPath(base.path() + QLatin1Char('/'));
                   +  - ]
     146                 :             : 
     147         [ +  - ]:         123 :   QUrl candidate(href);
     148   [ +  -  +  +  :         123 :   QUrl resolved = candidate.isRelative() ? base.resolved(candidate) : candidate;
                   +  - ]
     149         [ +  + ]:         122 :   if (!resolved.isValid() ||
     150   [ +  -  +  -  :         485 :       resolved.scheme().compare(server.scheme(), Qt::CaseInsensitive) != 0 ||
          +  +  +  +  +  
             +  -  -  -  
                      - ]
     151   [ +  -  +  -  :         363 :       resolved.host().compare(server.host(), Qt::CaseInsensitive) != 0 ||
          +  +  +  +  -  
                -  -  - ]
     152   [ +  -  +  -  :         104 :       effectivePort(resolved) != effectivePort(server) ||
                   +  + ]
     153   [ +  -  +  +  :         346 :       !resolved.userName().isEmpty() || !resolved.password().isEmpty()) {
          +  -  +  +  +  
          -  +  +  +  +  
          +  +  +  +  -  
                -  -  - ]
     154   [ +  -  +  -  :          46 :     qCWarning(lcCalDav) << "Rejected cross-origin DAV URL:" << href;
          +  -  +  -  +  
                      + ]
     155                 :          23 :     return {};
     156                 :             :   }
     157                 :             : 
     158         [ +  - ]:         100 :   return resolved.toString();
     159                 :         123 : }
     160                 :             : 
     161                 :             : // ═══════════════════════════════════════════════════════
     162                 :             : // Calendar Discovery (PROPFIND)
     163                 :             : // ═══════════════════════════════════════════════════════
     164                 :             : 
     165                 :          16 : void CalDavClient::discoverCalendars() {
     166                 :             :   // T-505: URL-encode username to prevent path traversal
     167                 :             :   QString url =
     168                 :          16 :       m_serverUrl +
     169                 :          32 :       QStringLiteral("/remote.php/dav/calendars/%1/")
     170   [ +  -  +  -  :          16 :           .arg(QString::fromUtf8(QUrl::toPercentEncoding(m_username)));
             +  -  +  - ]
     171                 :             : 
     172   [ +  -  +  - ]:          16 :   QNetworkRequest request{QUrl(url)};
     173   [ +  -  +  -  :          16 :   request.setRawHeader("Authorization", authHeader());
                   +  - ]
     174   [ +  -  +  -  :          16 :   request.setRawHeader("Depth", "1");
                   +  - ]
     175   [ +  -  +  -  :          16 :   request.setRawHeader("Content-Type", "application/xml; charset=utf-8");
                   +  - ]
     176                 :             : 
     177                 :             :   QByteArray body =
     178                 :             :       "<?xml version=\"1.0\" encoding=\"utf-8\" ?>"
     179                 :             :       "<d:propfind xmlns:d=\"DAV:\" "
     180                 :             :       "  xmlns:cs=\"http://calendarserver.org/ns/\" "
     181                 :             :       "  xmlns:apple=\"http://apple.com/ns/ical/\">"
     182                 :             :       "  <d:prop>"
     183                 :             :       "    <d:displayname/>"
     184                 :             :       "    <d:resourcetype/>"
     185                 :             :       "    <cs:getctag/>"
     186                 :             :       "    <apple:calendar-color/>"
     187                 :             :       "  </d:prop>"
     188         [ +  - ]:          16 :       "</d:propfind>";
     189                 :             : 
     190   [ +  -  +  - ]:          16 :   auto *reply = m_nam->sendCustomRequest(request, "PROPFIND", body);
     191         [ +  - ]:          16 :   DavNetworkLimits::apply(reply);
     192         [ +  - ]:          16 :   trackReply(reply); // T-79.F1: lifetime follows request completion
     193         [ +  - ]:          16 :   connect(reply, &QNetworkReply::finished, this, [this, reply]() {
     194                 :          12 :     onDiscoverReply(reply);
     195                 :          12 :   });
     196                 :          16 : }
     197                 :             : 
     198                 :          12 : void CalDavClient::onDiscoverReply(QNetworkReply *reply) {
     199         [ +  - ]:          12 :   reply->deleteLater();
     200                 :             : 
     201         [ +  - ]:          12 :   const QString limitError = DavNetworkLimits::failureReason(reply);
     202         [ +  + ]:          12 :   if (!limitError.isEmpty()) {
     203   [ +  -  +  -  :           2 :     qCWarning(lcCalDav) << "PROPFIND failed:" << limitError;
          +  -  +  -  +  
                      + ]
     204         [ +  - ]:           1 :     emit syncFailed(limitError);
     205                 :           1 :     return;
     206                 :             :   }
     207                 :             : 
     208   [ +  -  +  + ]:          11 :   if (reply->error() != QNetworkReply::NoError) {
     209   [ +  -  +  -  :           4 :     qCWarning(lcCalDav) << "PROPFIND failed:" << reply->errorString();
          +  -  +  -  +  
                -  +  + ]
     210   [ +  -  +  - ]:           2 :     emit syncFailed(reply->errorString());
     211                 :           2 :     return;
     212                 :             :   }
     213                 :             : 
     214         [ +  - ]:           9 :   QByteArray data = reply->readAll();
     215   [ +  -  +  -  :          18 :   qCDebug(lcCalDav) << "PROPFIND response:" << data.left(2000);
          +  -  +  -  +  
                -  +  + ]
     216         [ +  - ]:           9 :   QDomDocument doc;
     217                 :             :   // T-511: Check XML parse result
     218   [ +  -  +  + ]:           9 :   if (!doc.setContent(data)) {
     219   [ +  -  +  -  :           2 :     qCWarning(lcCalDav) << "Failed to parse XML response";
             +  -  +  + ]
     220         [ +  - ]:           1 :     emit syncFailed(QStringLiteral("Invalid XML response"));
     221                 :           1 :     return;
     222                 :             :   }
     223                 :             : 
     224                 :           8 :   QList<CalendarInfo> calendars;
     225                 :             :   QDomNodeList responses =
     226         [ +  - ]:           8 :       findElementsByLocalNameDoc(doc, QStringLiteral("response"));
     227                 :             : 
     228   [ +  -  +  + ]:          18 :   for (int i = 0; i < responses.count(); ++i) {
     229   [ +  -  +  - ]:          10 :     QDomElement resp = responses.at(i).toElement();
     230                 :             : 
     231                 :             :     // Check for calendar resourcetype
     232                 :          10 :     bool isCalendar = false;
     233                 :             :     QDomNodeList resourceTypes =
     234         [ +  - ]:          10 :         findElementsByLocalName(resp, QStringLiteral("resourcetype"));
     235   [ +  -  +  + ]:          13 :     for (int j = 0; j < resourceTypes.count(); ++j) {
     236                 :          10 :       QString rtXml;
     237         [ +  - ]:          10 :       QTextStream ts(&rtXml);
     238   [ +  -  +  - ]:          10 :       resourceTypes.at(j).save(ts, 0);
     239   [ +  -  +  + ]:          10 :       if (rtXml.contains(QStringLiteral("calendar"),
     240                 :             :                          Qt::CaseInsensitive)) {
     241                 :           7 :         isCalendar = true;
     242                 :           7 :         break;
     243                 :             :       }
     244   [ +  +  +  + ]:          17 :     }
     245                 :             : 
     246         [ +  + ]:          10 :     if (!isCalendar)
     247                 :           3 :       continue;
     248                 :             : 
     249                 :             :     // Extract href
     250                 :           7 :     QString href;
     251                 :             :     QDomNodeList hrefs =
     252         [ +  - ]:           7 :         findElementsByLocalName(resp, QStringLiteral("href"));
     253   [ +  -  +  + ]:           7 :     if (!hrefs.isEmpty())
     254   [ +  -  +  -  :           6 :       href = hrefs.at(0).toElement().text();
                   +  - ]
     255                 :             : 
     256                 :             :     // Extract displayname
     257                 :           7 :     QString displayName;
     258                 :             :     QDomNodeList nameNodes =
     259         [ +  - ]:           7 :         findElementsByLocalName(resp, QStringLiteral("displayname"));
     260   [ +  -  +  + ]:           7 :     if (!nameNodes.isEmpty())
     261   [ +  -  +  -  :           4 :       displayName = nameNodes.at(0).toElement().text().trimmed();
             +  -  +  - ]
     262                 :             : 
     263                 :             :     // Fallback: name from path
     264         [ +  + ]:           7 :     if (displayName.isEmpty()) {
     265                 :           4 :       displayName = href;
     266   [ +  -  +  + ]:           4 :       if (displayName.endsWith(QLatin1Char('/')))
     267         [ +  - ]:           2 :         displayName.chop(1);
     268                 :             :       displayName =
     269         [ +  - ]:           4 :           displayName.mid(displayName.lastIndexOf(QLatin1Char('/')) + 1);
     270                 :             :     }
     271                 :             : 
     272                 :             :     // Extract calendar-color (namespace-agnostic via localName())
     273                 :           7 :     QString color;
     274                 :             :     QDomElement colorEl =
     275         [ +  - ]:           7 :         findFirstElementByLocalName(resp, QStringLiteral("calendar-color"));
     276   [ +  -  +  + ]:           7 :     if (!colorEl.isNull()) {
     277   [ +  -  +  - ]:           4 :       color = colorEl.text().trimmed();
     278                 :             :       // Nextcloud sometimes returns #RRGGBBAA — strip alpha
     279   [ +  +  +  -  :           4 :       if (color.length() == 9 && color.startsWith('#'))
             +  -  +  + ]
     280         [ +  - ]:           1 :         color = color.left(7);
     281                 :             :     }
     282                 :             : 
     283                 :             :     // Extract CTag
     284                 :           7 :     QString ctag;
     285                 :             :     QDomNodeList ctagNodes =
     286         [ +  - ]:           7 :         findElementsByLocalName(resp, QStringLiteral("getctag"));
     287   [ +  -  +  + ]:           7 :     if (!ctagNodes.isEmpty())
     288   [ +  -  +  -  :           3 :       ctag = ctagNodes.at(0).toElement().text().trimmed();
             +  -  +  - ]
     289                 :             : 
     290         [ +  + ]:           7 :     if (!href.isEmpty()) {
     291                 :           6 :       CalendarInfo cal;
     292                 :           6 :       cal.path = href;
     293                 :           6 :       cal.displayName = displayName;
     294                 :           6 :       cal.color = color;
     295                 :           6 :       cal.ctag = ctag;
     296         [ +  - ]:           6 :       calendars.append(cal);
     297   [ +  -  +  -  :          12 :       qCDebug(lcCalDav) << "Calendar:" << displayName << "at" << href
          +  -  +  -  +  
             -  +  -  +  
                      + ]
     298   [ +  -  +  - ]:           6 :                          << "color:" << color;
     299                 :           6 :     }
     300   [ +  +  +  + ]:          13 :   }
     301                 :             : 
     302   [ +  -  +  -  :          16 :   qCInfo(lcCalDav) << "Discovered" << calendars.size() << "calendars";
          +  -  +  -  +  
                -  +  + ]
     303         [ +  - ]:           8 :   emit calendarsDiscovered(calendars);
     304   [ +  +  +  +  :          14 : }
                   +  + ]
     305                 :             : 
     306                 :             : // ═══════════════════════════════════════════════════════
     307                 :             : // Event Sync (REPORT calendar-query VEVENT)
     308                 :             : // ═══════════════════════════════════════════════════════
     309                 :             : 
     310                 :          24 : void CalDavClient::syncCalendar(const QString &calendarPath) {
     311         [ +  - ]:          24 :   QString url = resolveDavUrl(calendarPath);
     312         [ +  + ]:          24 :   if (url.isEmpty()) {
     313         [ +  - ]:           6 :     emit syncFailed(QStringLiteral("Cross-origin URL rejected"));
     314                 :           6 :     return;
     315                 :             :   }
     316                 :             : 
     317   [ +  -  +  - ]:          18 :   QNetworkRequest request{QUrl(url)};
     318   [ +  -  +  -  :          18 :   request.setRawHeader("Authorization", authHeader());
                   +  - ]
     319   [ +  -  +  -  :          18 :   request.setRawHeader("Depth", "1");
                   +  - ]
     320   [ +  -  +  -  :          18 :   request.setRawHeader("Content-Type", "application/xml; charset=utf-8");
                   +  - ]
     321                 :             : 
     322                 :             :   QByteArray body =
     323                 :             :       "<?xml version=\"1.0\" encoding=\"utf-8\" ?>"
     324                 :             :       "<c:calendar-query xmlns:d=\"DAV:\" "
     325                 :             :       "  xmlns:c=\"urn:ietf:params:xml:ns:caldav\">"
     326                 :             :       "  <d:prop>"
     327                 :             :       "    <d:getetag/>"
     328                 :             :       "    <c:calendar-data/>"
     329                 :             :       "  </d:prop>"
     330                 :             :       "  <c:filter>"
     331                 :             :       "    <c:comp-filter name=\"VCALENDAR\">"
     332                 :             :       "      <c:comp-filter name=\"VEVENT\"/>"
     333                 :             :       "    </c:comp-filter>"
     334                 :             :       "  </c:filter>"
     335         [ +  - ]:          18 :       "</c:calendar-query>";
     336                 :             : 
     337   [ +  -  +  - ]:          18 :   auto *reply = m_nam->sendCustomRequest(request, "REPORT", body);
     338         [ +  - ]:          18 :   DavNetworkLimits::apply(reply);
     339         [ +  - ]:          18 :   trackReply(reply); // T-79.F1: lifetime follows request completion
     340         [ +  - ]:          18 :   connect(reply, &QNetworkReply::finished, this,
     341                 :          36 :           [this, reply, calendarPath]() {
     342                 :          16 :             onSyncEventsReply(reply, calendarPath);
     343                 :          16 :           });
     344         [ +  + ]:          24 : }
     345                 :             : 
     346                 :          16 : void CalDavClient::onSyncEventsReply(QNetworkReply *reply,
     347                 :             :                                      const QString &calendarPath) {
     348         [ +  - ]:          16 :   reply->deleteLater();
     349                 :             : 
     350         [ +  - ]:          16 :   const QString limitError = DavNetworkLimits::failureReason(reply);
     351         [ +  + ]:          16 :   if (!limitError.isEmpty()) {
     352   [ +  -  +  -  :           2 :     qCWarning(lcCalDav) << "REPORT (events) failed:" << limitError;
          +  -  +  -  +  
                      + ]
     353         [ +  - ]:           1 :     emit syncFailed(limitError);
     354                 :           1 :     return;
     355                 :             :   }
     356                 :             : 
     357   [ +  -  +  + ]:          15 :   if (reply->error() != QNetworkReply::NoError) {
     358   [ +  -  +  -  :           2 :     qCWarning(lcCalDav) << "REPORT (events) failed:"
             +  -  +  + ]
     359   [ +  -  +  - ]:           1 :                          << reply->errorString();
     360   [ +  -  +  - ]:           1 :     emit syncFailed(reply->errorString());
     361                 :           1 :     return;
     362                 :             :   }
     363                 :             : 
     364         [ +  - ]:          14 :   QByteArray data = reply->readAll();
     365   [ +  -  +  -  :          28 :   qCDebug(lcCalDav) << "REPORT (events) response:" << data.size()
          +  -  +  -  +  
                      + ]
     366         [ +  - ]:          14 :                      << "bytes";
     367                 :             : 
     368                 :          14 :   QList<CalendarEvent> events;
     369                 :          14 :   QString parseError;
     370   [ +  -  +  + ]:          14 :   if (!parseICalEvents(data, &events, &parseError)) {
     371   [ +  -  +  -  :           6 :     qCWarning(lcCalDav) << "REPORT (events) parse failed:" << parseError;
          +  -  +  -  +  
                      + ]
     372         [ +  - ]:           3 :     emit syncFailed(parseError);
     373                 :           3 :     return;
     374                 :             :   }
     375                 :             : 
     376                 :             :   // Assign calendarPath to each event
     377   [ +  -  +  -  :          21 :   for (auto &ev : events) {
                   +  + ]
     378                 :          11 :     ev.calendarPath = calendarPath;
     379         [ +  + ]:          11 :     if (!ev.resourceHref.isEmpty()) {
     380         [ +  - ]:          10 :       const QString resolvedHref = resolveDavUrl(ev.resourceHref);
     381         [ +  + ]:          10 :       if (resolvedHref.isEmpty()) {
     382         [ +  - ]:           1 :         emit syncFailed(QStringLiteral("Cross-origin resource href rejected"));
     383                 :           1 :         return;
     384                 :             :       }
     385                 :           9 :       ev.resourceHref = resolvedHref;
     386         [ +  + ]:          10 :     }
     387                 :             :   }
     388                 :             : 
     389   [ +  -  +  -  :          20 :   qCInfo(lcCalDav) << "Synced" << events.size() << "events from"
          +  -  +  -  +  
                -  +  + ]
     390         [ +  - ]:          10 :                     << calendarPath;
     391         [ +  - ]:          10 :   emit eventsSynced(calendarPath, events);
     392   [ +  +  +  +  :          28 : }
             +  +  +  + ]
     393                 :             : 
     394                 :             : // ═══════════════════════════════════════════════════════
     395                 :             : // Task Sync (REPORT calendar-query VTODO)
     396                 :             : // ═══════════════════════════════════════════════════════
     397                 :             : 
     398                 :          14 : void CalDavClient::syncTasks(const QString &calendarPath) {
     399         [ +  - ]:          14 :   QString url = resolveDavUrl(calendarPath);
     400         [ +  + ]:          14 :   if (url.isEmpty()) {
     401         [ +  - ]:           2 :     emit syncFailed(QStringLiteral("Cross-origin URL rejected"));
     402                 :           2 :     return;
     403                 :             :   }
     404                 :             : 
     405   [ +  -  +  - ]:          12 :   QNetworkRequest request{QUrl(url)};
     406   [ +  -  +  -  :          12 :   request.setRawHeader("Authorization", authHeader());
                   +  - ]
     407   [ +  -  +  -  :          12 :   request.setRawHeader("Depth", "1");
                   +  - ]
     408   [ +  -  +  -  :          12 :   request.setRawHeader("Content-Type", "application/xml; charset=utf-8");
                   +  - ]
     409                 :             : 
     410                 :             :   QByteArray body =
     411                 :             :       "<?xml version=\"1.0\" encoding=\"utf-8\" ?>"
     412                 :             :       "<c:calendar-query xmlns:d=\"DAV:\" "
     413                 :             :       "  xmlns:c=\"urn:ietf:params:xml:ns:caldav\">"
     414                 :             :       "  <d:prop>"
     415                 :             :       "    <d:getetag/>"
     416                 :             :       "    <c:calendar-data/>"
     417                 :             :       "  </d:prop>"
     418                 :             :       "  <c:filter>"
     419                 :             :       "    <c:comp-filter name=\"VCALENDAR\">"
     420                 :             :       "      <c:comp-filter name=\"VTODO\"/>"
     421                 :             :       "    </c:comp-filter>"
     422                 :             :       "  </c:filter>"
     423         [ +  - ]:          12 :       "</c:calendar-query>";
     424                 :             : 
     425   [ +  -  +  - ]:          12 :   auto *reply = m_nam->sendCustomRequest(request, "REPORT", body);
     426         [ +  - ]:          12 :   DavNetworkLimits::apply(reply);
     427         [ +  - ]:          12 :   trackReply(reply); // T-79.F1: lifetime follows request completion
     428         [ +  - ]:          12 :   connect(reply, &QNetworkReply::finished, this,
     429                 :          24 :           [this, reply, calendarPath]() {
     430                 :          11 :             onSyncTasksReply(reply, calendarPath);
     431                 :          11 :           });
     432         [ +  + ]:          14 : }
     433                 :             : 
     434                 :          11 : void CalDavClient::onSyncTasksReply(QNetworkReply *reply,
     435                 :             :                                     const QString &calendarPath) {
     436         [ +  - ]:          11 :   reply->deleteLater();
     437                 :             : 
     438         [ +  - ]:          11 :   const QString limitError = DavNetworkLimits::failureReason(reply);
     439         [ +  + ]:          11 :   if (!limitError.isEmpty()) {
     440   [ +  -  +  -  :           2 :     qCWarning(lcCalDav) << "REPORT (tasks) failed:" << limitError;
          +  -  +  -  +  
                      + ]
     441         [ +  - ]:           1 :     emit syncFailed(limitError);
     442                 :           1 :     return;
     443                 :             :   }
     444                 :             : 
     445   [ +  -  +  + ]:          10 :   if (reply->error() != QNetworkReply::NoError) {
     446   [ +  -  +  -  :           2 :     qCWarning(lcCalDav) << "REPORT (tasks) failed:"
             +  -  +  + ]
     447   [ +  -  +  - ]:           1 :                          << reply->errorString();
     448   [ +  -  +  - ]:           1 :     emit syncFailed(reply->errorString());
     449                 :           1 :     return;
     450                 :             :   }
     451                 :             : 
     452         [ +  - ]:           9 :   QByteArray data = reply->readAll();
     453                 :           9 :   QList<CalendarTask> tasks;
     454                 :           9 :   QString parseError;
     455   [ +  -  +  + ]:           9 :   if (!parseICalTasks(data, &tasks, &parseError)) {
     456   [ +  -  +  -  :           4 :     qCWarning(lcCalDav) << "REPORT (tasks) parse failed:" << parseError;
          +  -  +  -  +  
                      + ]
     457         [ +  - ]:           2 :     emit syncFailed(parseError);
     458                 :           2 :     return;
     459                 :             :   }
     460                 :             : 
     461   [ +  -  +  -  :          16 :   for (auto &t : tasks) {
                   +  + ]
     462                 :          10 :     t.calendarPath = calendarPath;
     463         [ +  + ]:          10 :     if (!t.resourceHref.isEmpty()) {
     464         [ +  - ]:           9 :       const QString resolvedHref = resolveDavUrl(t.resourceHref);
     465         [ +  + ]:           9 :       if (resolvedHref.isEmpty()) {
     466         [ +  - ]:           1 :         emit syncFailed(QStringLiteral("Cross-origin resource href rejected"));
     467                 :           1 :         return;
     468                 :             :       }
     469                 :           8 :       t.resourceHref = resolvedHref;
     470         [ +  + ]:           9 :     }
     471                 :             :   }
     472                 :             : 
     473   [ +  -  +  -  :          12 :   qCInfo(lcCalDav) << "Synced" << tasks.size() << "tasks from"
          +  -  +  -  +  
                -  +  + ]
     474         [ +  - ]:           6 :                     << calendarPath;
     475         [ +  - ]:           6 :   emit tasksSynced(calendarPath, tasks);
     476   [ +  +  +  +  :          20 : }
             +  +  +  + ]
     477                 :             : 
     478                 :             : // ═══════════════════════════════════════════════════════
     479                 :             : // iCal Parsing (RFC 5545)
     480                 :             : // ═══════════════════════════════════════════════════════
     481                 :             : 
     482                 :             : // T-79.C1/H5: single inverse of escapeICalText() (RFC 5545 §3.3.11).
     483                 :             : // One pass so "\\n" yields a literal backslash + n, not a newline.
     484                 :         127 : static QString unescapeICalText(const QString &text) {
     485                 :         127 :   QString result;
     486         [ +  - ]:         127 :   result.reserve(text.size());
     487         [ +  + ]:         723 :   for (int i = 0; i < text.size(); ++i) {
     488                 :         596 :     const QChar c = text.at(i);
     489   [ +  +  +  -  :         596 :     if (c == QLatin1Char('\\') && i + 1 < text.size()) {
                   +  + ]
     490                 :          17 :       const QChar next = text.at(i + 1);
     491   [ +  +  -  +  :          17 :       if (next == QLatin1Char('n') || next == QLatin1Char('N')) {
                   +  + ]
     492         [ +  - ]:           4 :         result.append(QLatin1Char('\n'));
     493                 :           4 :         ++i;
     494                 :          17 :         continue;
     495                 :             :       }
     496   [ +  +  +  +  :          17 :       if (next == QLatin1Char('\\') || next == QLatin1Char(',') ||
                   +  - ]
     497         [ +  - ]:          17 :           next == QLatin1Char(';')) {
     498         [ +  - ]:          13 :         result.append(next);
     499                 :          13 :         ++i;
     500                 :          13 :         continue;
     501                 :             :       }
     502                 :             :     }
     503         [ +  - ]:         579 :     result.append(c);
     504                 :             :   }
     505                 :         127 :   return result;
     506                 :           0 : }
     507                 :             : 
     508                 :             : // Helper: parse a single iCal component block into key-value pairs
     509                 :             : static QMap<QString, QString>
     510                 :          48 : parseICalBlock(const QString &block) {
     511                 :          48 :   QMap<QString, QString> props;
     512                 :             : 
     513                 :             :   // Unfold continuation lines (RFC 5545 §3.1)
     514                 :             :   static QRegularExpression foldingRegex(
     515   [ +  +  +  -  :          54 :       QStringLiteral("\\r?\\n[ \\t]"));
             +  -  -  - ]
     516                 :          48 :   QString unfolded = block;
     517         [ +  - ]:          48 :   unfolded.replace(foldingRegex, QString());
     518                 :             : 
     519                 :             :   // Normalize line endings
     520         [ +  - ]:          96 :   unfolded.replace(QStringLiteral("\r\n"), QStringLiteral("\n"));
     521         [ +  - ]:          48 :   unfolded.replace(QLatin1Char('\r'), QLatin1Char('\n'));
     522                 :             : 
     523         [ +  - ]:          48 :   const auto lines = unfolded.split(QLatin1Char('\n'));
     524                 :             :   // T-79.C1/H4: BEGIN:/END: nesting depth. Lines of nested sub-components
     525                 :             :   // (e.g. VALARM) must be ignored — a DISPLAY alarm's DESCRIPTION would
     526                 :             :   // otherwise overwrite the event's own description.
     527                 :          48 :   int nestedDepth = 0;
     528         [ +  + ]:         292 :   for (const QString &line : lines) {
     529         [ +  + ]:         244 :     if (line.isEmpty())
     530                 :          58 :       continue;
     531                 :         195 :     int colonPos = line.indexOf(QLatin1Char(':'));
     532         [ +  + ]:         195 :     if (colonPos < 0)
     533                 :           1 :       continue;
     534                 :             : 
     535         [ +  - ]:         194 :     QString rawKey = line.left(colonPos);
     536         [ +  - ]:         194 :     QString key = rawKey.toUpper();
     537         [ +  - ]:         194 :     QString value = line.mid(colonPos + 1);
     538                 :             : 
     539                 :             :     // Strip parameters from key (e.g. "DTSTART;VALUE=DATE" → "DTSTART")
     540                 :             :     // But preserve the full key for VALUE=DATE detection
     541                 :         194 :     QString baseKey = key;
     542                 :         194 :     int semiPos = baseKey.indexOf(QLatin1Char(';'));
     543         [ +  + ]:         194 :     if (semiPos > 0)
     544         [ +  - ]:          21 :       baseKey = baseKey.left(semiPos);
     545                 :             : 
     546         [ +  + ]:         194 :     if (baseKey == QStringLiteral("BEGIN")) {
     547                 :           1 :       ++nestedDepth;
     548                 :           1 :       continue;
     549                 :             :     }
     550         [ +  + ]:         193 :     if (baseKey == QStringLiteral("END")) {
     551         [ +  - ]:           1 :       if (nestedDepth > 0)
     552                 :           1 :         --nestedDepth;
     553                 :           1 :       continue;
     554                 :             :     }
     555         [ +  + ]:         192 :     if (nestedDepth > 0)
     556                 :           4 :       continue; // property belongs to a nested sub-component
     557                 :             : 
     558                 :             :     // T-79.C1/H3: EXDATE may appear on multiple lines — accumulate the
     559                 :             :     // (already comma-separated) values instead of overwriting.
     560         [ +  + ]:         188 :     if (baseKey == QStringLiteral("EXDATE")) {
     561         [ +  - ]:           2 :       const QString existing = props.value(baseKey);
     562         [ +  - ]:           4 :       props.insert(baseKey, existing.isEmpty()
     563   [ +  +  +  - ]:           5 :                                 ? value
     564   [ +  -  +  +  :           3 :                                 : existing + QLatin1Char(',') + value);
                   -  - ]
     565                 :           2 :       continue;
     566                 :           2 :     }
     567                 :             : 
     568                 :             :     // For date properties and ORGANIZER, store both the base key and params
     569   [ +  +  +  -  :         523 :     if (baseKey == QStringLiteral("DTSTART") ||
                   +  + ]
     570   [ +  +  +  +  :         472 :         baseKey == QStringLiteral("DTEND") ||
                   +  - ]
     571   [ +  +  +  +  :         453 :         baseKey == QStringLiteral("DUE") ||
                   +  + ]
     572   [ +  +  +  +  :         448 :         baseKey == QStringLiteral("COMPLETED") ||
                   +  + ]
     573   [ +  +  +  +  :         839 :         baseKey == QStringLiteral("LAST-MODIFIED") ||
             +  +  +  + ]
     574   [ +  +  +  +  :         312 :         baseKey == QStringLiteral("ORGANIZER")) {
                   +  + ]
     575         [ +  - ]:          65 :       props.insert(baseKey, value);
     576         [ +  + ]:          65 :       if (key != baseKey)
     577   [ +  -  +  - ]:          20 :         props.insert(baseKey + QStringLiteral("_PARAMS"),
     578         [ +  - ]:          40 :                      rawKey.mid(semiPos + 1));
     579                 :             :     } else {
     580         [ +  - ]:         121 :       props.insert(baseKey, value);
     581                 :             :     }
     582   [ +  +  +  +  :         218 :   }
             +  +  +  + ]
     583                 :          48 :   return props;
     584                 :          48 : }
     585                 :             : 
     586                 :           7 : static QString iCalParamValue(const QString &params, const QString &name) {
     587                 :          14 :   for (const auto &param : params.split(QLatin1Char(';'),
     588   [ +  -  +  -  :          16 :                                         Qt::SkipEmptyParts)) {
             +  -  +  - ]
     589                 :           9 :     const int eq = param.indexOf(QLatin1Char('='));
     590         [ +  + ]:           9 :     if (eq <= 0)
     591                 :           1 :       continue;
     592   [ +  -  +  -  :           8 :     if (param.left(eq).trimmed().compare(name, Qt::CaseInsensitive) == 0)
                   +  + ]
     593   [ +  -  +  - ]:           7 :       return param.mid(eq + 1).trimmed();
     594         [ -  + ]:           7 :   }
     595                 :           0 :   return {};
     596                 :             : }
     597                 :             : 
     598                 :             : // Helper: parse iCal date/time string
     599                 :          63 : static QDateTime parseICalDateTime(const QString &value,
     600                 :             :                                    const QString &params) {
     601                 :             :   // Check for VALUE=DATE (all-day event)
     602                 :             :   // The caller should set allDay based on the presence of VALUE=DATE
     603         [ +  + ]:          63 :   if (value.length() == 8) {
     604                 :             :     // Pure date: 20260301
     605         [ +  - ]:          11 :     QDate d = QDate::fromString(value, QStringLiteral("yyyyMMdd"));
     606   [ +  -  +  -  :          32 :     return d.isValid() ? QDateTime(d, QTime(0, 0), QTimeZone::utc())
                   -  - ]
     607   [ +  +  +  -  :          21 :                        : QDateTime();
             +  -  +  + ]
     608                 :             :   }
     609                 :             : 
     610                 :             :   // DateTime with Z suffix: 20260301T100000Z
     611   [ +  -  +  + ]:          52 :   if (value.endsWith(QLatin1Char('Z'))) {
     612         [ +  - ]:          45 :     QString stripped = value.left(value.length() - 1);
     613                 :             :     // T-401/Bug 8: tag the wall-clock time as UTC directly — NOT .toUTC()
     614                 :             :     // which would double-shift (fromString creates LocalTime, toUTC shifts
     615                 :             :     // it again). setTimeZone(QTimeZone::utc()) replaces the deprecated
     616                 :             :     // setTimeSpec(Qt::UTC) with the identical reinterpretation semantics.
     617                 :             :     QDateTime dt =
     618         [ +  - ]:          45 :         QDateTime::fromString(stripped, QStringLiteral("yyyyMMddTHHmmss"));
     619   [ +  -  +  - ]:          45 :     dt.setTimeZone(QTimeZone::utc());
     620                 :          45 :     return dt;
     621                 :          45 :   }
     622                 :             : 
     623                 :             :   // DateTime without Z (local time): 20260301T100000
     624                 :             :   QDateTime dt =
     625         [ +  - ]:           7 :       QDateTime::fromString(value, QStringLiteral("yyyyMMddTHHmmss"));
     626                 :             : 
     627                 :             :   // Check TZID in params
     628         [ +  - ]:           7 :   const QString tzid = iCalParamValue(params, QStringLiteral("TZID"));
     629         [ +  - ]:           7 :   if (!tzid.isEmpty()) {
     630   [ +  -  +  - ]:           7 :     QTimeZone zone(tzid.toUtf8());
     631   [ +  -  +  + ]:           7 :     if (zone.isValid())
     632   [ +  -  +  -  :           5 :       return QDateTime(dt.date(), dt.time(), zone);
                   +  - ]
     633   [ +  -  +  -  :           4 :     qCWarning(lcCalDav) << "Unknown TZID in iCalendar response:" << tzid;
          +  -  +  -  +  
                      + ]
     634         [ +  + ]:           7 :   }
     635                 :             : 
     636                 :           2 :   return dt;
     637                 :           7 : }
     638                 :             : 
     639                 :          46 : static bool validateICalendarText(const QString &icalText,
     640                 :             :                                   const QString &componentName,
     641                 :             :                                   QString *error) {
     642         [ +  - ]:          46 :   const QString trimmed = icalText.trimmed();
     643         [ -  + ]:          46 :   if (trimmed.isEmpty())
     644                 :           0 :     return true;
     645                 :             : 
     646   [ +  -  +  +  :         182 :   if (!trimmed.contains(QStringLiteral("BEGIN:VCALENDAR")) ||
          +  -  +  +  -  
                -  -  - ]
     647   [ +  -  +  +  :          90 :       !trimmed.contains(QStringLiteral("END:VCALENDAR"))) {
          +  +  +  +  +  
             -  -  -  -  
                      - ]
     648         [ +  - ]:           3 :     if (error)
     649                 :           3 :       *error = QStringLiteral("Invalid iCalendar response");
     650                 :           3 :     return false;
     651                 :             :   }
     652                 :             : 
     653         [ +  - ]:          86 :   const QString beginComponent = QStringLiteral("BEGIN:%1").arg(componentName);
     654         [ +  - ]:          86 :   const QString endComponent = QStringLiteral("END:%1").arg(componentName);
     655   [ +  -  +  +  :          43 :   if (trimmed.contains(beginComponent) && !trimmed.contains(endComponent)) {
          +  -  +  +  +  
                      + ]
     656         [ +  - ]:           2 :     if (error)
     657                 :           4 :       *error = QStringLiteral("Invalid iCalendar %1 component")
     658         [ +  - ]:           4 :                    .arg(componentName);
     659                 :           2 :     return false;
     660                 :             :   }
     661                 :             : 
     662                 :          41 :   return true;
     663                 :          46 : }
     664                 :             : 
     665                 :             : QList<CalendarEvent>
     666                 :          18 : CalDavClient::parseICalEvents(const QByteArray &xmlData) {
     667                 :          18 :   QList<CalendarEvent> events;
     668                 :          18 :   QString error;
     669         [ +  - ]:          18 :   parseICalEvents(xmlData, &events, &error);
     670                 :          18 :   return events;
     671                 :          18 : }
     672                 :             : 
     673                 :          37 : bool CalDavClient::parseICalEvents(const QByteArray &xmlData,
     674                 :             :                                    QList<CalendarEvent> *events,
     675                 :             :                                    QString *error) {
     676         [ +  - ]:          37 :   if (events)
     677         [ +  - ]:          37 :     events->clear();
     678         [ -  + ]:          37 :   if (!events) {
     679         [ #  # ]:           0 :     if (error)
     680                 :           0 :       *error = QStringLiteral("Invalid parser output");
     681                 :           0 :     return false;
     682                 :             :   }
     683                 :             : 
     684                 :             :   // Parse the multistatus XML to extract calendar-data
     685         [ +  - ]:          37 :   QDomDocument doc;
     686                 :             :   // T-511: Check XML parse result
     687   [ +  -  +  + ]:          37 :   if (!doc.setContent(xmlData)) {
     688   [ +  -  +  -  :           4 :     qCWarning(lcCalDav) << "Failed to parse XML response";
             +  -  +  + ]
     689         [ +  - ]:           2 :     if (error)
     690                 :           2 :       *error = QStringLiteral("Invalid XML response");
     691                 :           2 :     return false;
     692                 :             :   }
     693                 :             : 
     694                 :             :   QDomNodeList responses =
     695         [ +  - ]:          35 :       findElementsByLocalNameDoc(doc, QStringLiteral("response"));
     696                 :             : 
     697   [ +  -  +  + ]:          69 :   for (int i = 0; i < responses.count(); ++i) {
     698   [ +  -  +  - ]:          37 :     QDomElement resp = responses.at(i).toElement();
     699                 :             : 
     700                 :          37 :     QString href;
     701                 :             :     QDomNodeList hrefNodes =
     702         [ +  - ]:          37 :         findElementsByLocalName(resp, QStringLiteral("href"));
     703   [ +  -  +  + ]:          37 :     if (!hrefNodes.isEmpty())
     704   [ +  -  +  -  :          33 :       href = hrefNodes.at(0).toElement().text().trimmed();
             +  -  +  - ]
     705                 :             : 
     706                 :             :     // Get etag
     707                 :          37 :     QString etag;
     708                 :             :     QDomNodeList etagNodes =
     709         [ +  - ]:          37 :         findElementsByLocalName(resp, QStringLiteral("getetag"));
     710   [ +  -  +  + ]:          37 :     if (!etagNodes.isEmpty())
     711   [ +  -  +  -  :          34 :       etag = etagNodes.at(0).toElement().text();
                   +  - ]
     712                 :             : 
     713                 :             :     // Get calendar-data
     714                 :          37 :     QString icalText;
     715                 :             :     QDomNodeList dataNodes =
     716         [ +  - ]:          37 :         findElementsByLocalName(resp, QStringLiteral("calendar-data"));
     717   [ +  -  +  + ]:          37 :     if (dataNodes.isEmpty())
     718                 :           1 :       continue;
     719   [ +  -  +  -  :          36 :     icalText = dataNodes.at(0).toElement().text();
                   +  - ]
     720         [ +  + ]:          36 :     if (icalText.isEmpty())
     721                 :           2 :       continue;
     722   [ +  -  +  + ]:          34 :     if (!validateICalendarText(icalText, QStringLiteral("VEVENT"), error))
     723                 :           3 :       return false;
     724                 :             : 
     725                 :             :     // Find VEVENT blocks
     726                 :             :     static QRegularExpression veventRegex(
     727                 :          12 :         QStringLiteral("BEGIN:VEVENT\\s*\\n(.*?)END:VEVENT"),
     728   [ +  +  +  -  :          43 :         QRegularExpression::DotMatchesEverythingOption);
             +  -  -  - ]
     729                 :             : 
     730         [ +  - ]:          31 :     auto it = veventRegex.globalMatch(icalText);
     731   [ +  -  +  + ]:          65 :     while (it.hasNext()) {
     732         [ +  - ]:          34 :       auto match = it.next();
     733         [ +  - ]:          34 :       QString block = match.captured(1);
     734         [ +  - ]:          34 :       auto props = parseICalBlock(block);
     735                 :             : 
     736                 :             :       // T-79.C1/H3: VEVENT blocks carrying RECURRENCE-ID are exceptions of
     737                 :             :       // a recurring series. Upserting them would clobber the master row
     738                 :             :       // (UNIQUE(calendar_id, uid), last block wins) — skip them and keep
     739                 :             :       // the master series intact.
     740   [ +  -  +  + ]:          34 :       if (props.contains(QStringLiteral("RECURRENCE-ID"))) {
     741   [ +  -  +  -  :           2 :         qCInfo(lcCalDav) << "Skipping RECURRENCE-ID exception VEVENT for UID"
             +  -  +  + ]
     742   [ +  -  +  -  :           2 :                          << props.value(QStringLiteral("UID")).trimmed();
                   +  - ]
     743                 :           1 :         continue;
     744                 :           1 :       }
     745                 :             : 
     746                 :          33 :       CalendarEvent ev;
     747   [ +  -  +  - ]:          66 :       ev.uid = props.value(QStringLiteral("UID")).trimmed();
     748                 :             :       // T-79.C1/H5: unescape TEXT values once, centrally (display sites
     749                 :             :       // must not unescape again).
     750                 :             :       ev.summary =
     751   [ +  -  +  -  :          66 :           unescapeICalText(props.value(QStringLiteral("SUMMARY")).trimmed());
                   +  - ]
     752         [ +  - ]:          66 :       ev.description = unescapeICalText(
     753   [ +  -  +  - ]:         132 :           props.value(QStringLiteral("DESCRIPTION")).trimmed());
     754                 :             :       ev.location =
     755   [ +  -  +  -  :          66 :           unescapeICalText(props.value(QStringLiteral("LOCATION")).trimmed());
                   +  - ]
     756   [ +  -  +  - ]:          66 :       ev.rrule = props.value(QStringLiteral("RRULE")).trimmed();
     757                 :          33 :       ev.etag = etag;
     758                 :          33 :       ev.resourceHref = href;
     759                 :             : 
     760                 :             :       // T-79.C1/H3: EXDATE — occurrences deleted from the series.
     761                 :             :       // parseICalBlock accumulates multiple lines comma-separated.
     762         [ +  - ]:          66 :       const QString exdateVal = props.value(QStringLiteral("EXDATE"));
     763                 :          33 :       for (const QString &part :
     764   [ +  -  +  -  :          69 :            exdateVal.split(QLatin1Char(','), Qt::SkipEmptyParts)) {
             +  -  +  + ]
     765   [ +  -  +  - ]:           3 :         QDateTime exdate = parseICalDateTime(part.trimmed(), QString());
     766   [ +  -  +  - ]:           3 :         if (exdate.isValid())
     767         [ +  - ]:           3 :           ev.exdates.append(exdate);
     768                 :          36 :       }
     769                 :             : 
     770                 :             :       // Parse dates
     771                 :             :       QString dtStartParams =
     772         [ +  - ]:          66 :           props.value(QStringLiteral("DTSTART_PARAMS"));
     773                 :             :       QString dtStartVal =
     774   [ +  -  +  - ]:          66 :           props.value(QStringLiteral("DTSTART")).trimmed();
     775         [ +  - ]:          33 :       ev.dtStart = parseICalDateTime(dtStartVal, dtStartParams);
     776                 :             : 
     777                 :             :       // Detect all-day
     778   [ +  -  +  +  :          96 :       ev.allDay = dtStartParams.contains(QStringLiteral("VALUE=DATE")) ||
          +  +  +  -  +  
             -  -  -  -  
                      - ]
     779                 :          30 :                   dtStartVal.length() == 8;
     780                 :             : 
     781                 :             :       QString dtEndParams =
     782         [ +  - ]:          66 :           props.value(QStringLiteral("DTEND_PARAMS"));
     783                 :             :       QString dtEndVal =
     784   [ +  -  +  - ]:          66 :           props.value(QStringLiteral("DTEND")).trimmed();
     785         [ +  + ]:          33 :       if (!dtEndVal.isEmpty())
     786         [ +  - ]:          16 :         ev.dtEnd = parseICalDateTime(dtEndVal, dtEndParams);
     787                 :             : 
     788                 :             :       // DURATION as fallback for DTEND
     789   [ +  -  +  +  :          50 :       if (!ev.dtEnd.isValid() && props.contains(QStringLiteral("DURATION"))) {
          +  -  +  +  +  
          +  +  +  +  +  
             -  -  -  - ]
     790                 :             :         // T-524: Full iCal DURATION parser — supports P, D, T, H, M, W
     791                 :             :         // Formats: P1DT2H30M, PT1H, P1W, P1D, PT30M, etc.
     792   [ +  -  +  - ]:           8 :         QString dur = props.value(QStringLiteral("DURATION")).trimmed();
     793   [ +  +  +  - ]:           6 :         static thread_local QRegularExpression weekRe(QStringLiteral("(\\d+)W"));
     794   [ +  +  +  - ]:           6 :         static thread_local QRegularExpression dayRe(QStringLiteral("(\\d+)D"));
     795   [ +  +  +  - ]:           6 :         static thread_local QRegularExpression hourRe(QStringLiteral("(\\d+)H"));
     796   [ +  +  +  - ]:           6 :         static thread_local QRegularExpression minRe(QStringLiteral("(\\d+)M"));
     797                 :             : 
     798                 :           4 :         qint64 totalSecs = 0;
     799         [ +  - ]:           4 :         auto wm = weekRe.match(dur);
     800   [ +  -  +  +  :           4 :         if (wm.hasMatch()) totalSecs += wm.captured(1).toLongLong() * 7 * 86400;
             +  -  +  - ]
     801         [ +  - ]:           4 :         auto dm = dayRe.match(dur);
     802   [ +  -  +  +  :           4 :         if (dm.hasMatch()) totalSecs += dm.captured(1).toLongLong() * 86400;
             +  -  +  - ]
     803         [ +  - ]:           4 :         auto hm = hourRe.match(dur);
     804   [ +  -  +  +  :           4 :         if (hm.hasMatch()) totalSecs += hm.captured(1).toLongLong() * 3600;
             +  -  +  - ]
     805         [ +  - ]:           4 :         auto mm = minRe.match(dur);
     806   [ +  -  +  +  :           4 :         if (mm.hasMatch()) totalSecs += mm.captured(1).toLongLong() * 60;
             +  -  +  - ]
     807                 :             : 
     808         [ +  + ]:           4 :         if (totalSecs > 0)
     809         [ +  - ]:           3 :           ev.dtEnd = ev.dtStart.addSecs(totalSecs);
     810                 :           4 :       }
     811                 :             : 
     812                 :             :       // LAST-MODIFIED
     813                 :             :       QString lastModVal =
     814   [ +  -  +  - ]:          66 :           props.value(QStringLiteral("LAST-MODIFIED")).trimmed();
     815         [ +  + ]:          33 :       if (!lastModVal.isEmpty())
     816         [ +  - ]:           2 :         ev.lastModified = parseICalDateTime(lastModVal, QString());
     817                 :             : 
     818         [ +  + ]:          33 :       if (!ev.uid.isEmpty()) {
     819         [ +  - ]:          31 :         events->append(ev);
     820                 :             :       } else {
     821                 :             :         // T-79.C2/L12: one malformed component must not abort the whole
     822                 :             :         // calendar's sync — skip it and keep the remaining items.
     823   [ +  -  +  -  :           4 :         qCWarning(lcCalDav) << "Skipping VEVENT without UID (href:" << href
          +  -  +  -  +  
                      + ]
     824         [ +  - ]:           2 :                             << ")";
     825                 :             :       }
     826   [ +  +  +  +  :          36 :     }
                   +  + ]
     827   [ +  +  +  +  :          73 :   }
          +  +  +  +  +  
          +  +  +  +  +  
          +  +  +  +  +  
                   +  + ]
     828                 :             : 
     829                 :          32 :   return true;
     830                 :          37 : }
     831                 :             : 
     832                 :             : QList<CalendarTask>
     833                 :           3 : CalDavClient::parseICalTasks(const QByteArray &xmlData) {
     834                 :           3 :   QList<CalendarTask> tasks;
     835                 :           3 :   QString error;
     836         [ +  - ]:           3 :   parseICalTasks(xmlData, &tasks, &error);
     837                 :           3 :   return tasks;
     838                 :           3 : }
     839                 :             : 
     840                 :          13 : bool CalDavClient::parseICalTasks(const QByteArray &xmlData,
     841                 :             :                                   QList<CalendarTask> *tasks,
     842                 :             :                                   QString *error) {
     843         [ +  - ]:          13 :   if (tasks)
     844         [ +  - ]:          13 :     tasks->clear();
     845         [ -  + ]:          13 :   if (!tasks) {
     846         [ #  # ]:           0 :     if (error)
     847                 :           0 :       *error = QStringLiteral("Invalid parser output");
     848                 :           0 :     return false;
     849                 :             :   }
     850                 :             : 
     851         [ +  - ]:          13 :   QDomDocument doc;
     852                 :             :   // T-511: Check XML parse result
     853   [ +  -  +  + ]:          13 :   if (!doc.setContent(xmlData)) {
     854   [ +  -  +  -  :           2 :     qCWarning(lcCalDav) << "Failed to parse XML response";
             +  -  +  + ]
     855         [ +  - ]:           1 :     if (error)
     856                 :           1 :       *error = QStringLiteral("Invalid XML response");
     857                 :           1 :     return false;
     858                 :             :   }
     859                 :             : 
     860                 :             :   QDomNodeList responses =
     861         [ +  - ]:          12 :       findElementsByLocalNameDoc(doc, QStringLiteral("response"));
     862                 :             : 
     863   [ +  -  +  + ]:          22 :   for (int i = 0; i < responses.count(); ++i) {
     864   [ +  -  +  - ]:          12 :     QDomElement resp = responses.at(i).toElement();
     865                 :             : 
     866                 :          12 :     QString href;
     867                 :             :     QDomNodeList hrefNodes =
     868         [ +  - ]:          12 :         findElementsByLocalName(resp, QStringLiteral("href"));
     869   [ +  -  +  + ]:          12 :     if (!hrefNodes.isEmpty())
     870   [ +  -  +  -  :           9 :       href = hrefNodes.at(0).toElement().text().trimmed();
             +  -  +  - ]
     871                 :             : 
     872                 :          12 :     QString etag;
     873                 :             :     QDomNodeList etagNodes =
     874         [ +  - ]:          12 :         findElementsByLocalName(resp, QStringLiteral("getetag"));
     875   [ +  -  +  + ]:          12 :     if (!etagNodes.isEmpty())
     876   [ +  -  +  -  :          10 :       etag = etagNodes.at(0).toElement().text();
                   +  - ]
     877                 :             : 
     878                 :          12 :     QString icalText;
     879                 :             :     QDomNodeList dataNodes =
     880         [ +  - ]:          12 :         findElementsByLocalName(resp, QStringLiteral("calendar-data"));
     881   [ +  -  -  + ]:          12 :     if (dataNodes.isEmpty())
     882                 :           0 :       continue;
     883   [ +  -  +  -  :          12 :     icalText = dataNodes.at(0).toElement().text();
                   +  - ]
     884         [ -  + ]:          12 :     if (icalText.isEmpty())
     885                 :           0 :       continue;
     886   [ +  -  +  + ]:          12 :     if (!validateICalendarText(icalText, QStringLiteral("VTODO"), error))
     887                 :           2 :       return false;
     888                 :             : 
     889                 :             :     // Find VTODO blocks
     890                 :             :     static QRegularExpression vtodoRegex(
     891                 :           8 :         QStringLiteral("BEGIN:VTODO\\s*\\n(.*?)END:VTODO"),
     892   [ +  +  +  -  :          18 :         QRegularExpression::DotMatchesEverythingOption);
             +  -  -  - ]
     893                 :             : 
     894         [ +  - ]:          10 :     auto it = vtodoRegex.globalMatch(icalText);
     895   [ +  -  +  + ]:          24 :     while (it.hasNext()) {
     896         [ +  - ]:          14 :       auto match = it.next();
     897         [ +  - ]:          14 :       QString block = match.captured(1);
     898         [ +  - ]:          14 :       auto props = parseICalBlock(block);
     899                 :             : 
     900                 :          14 :       CalendarTask task;
     901   [ +  -  +  - ]:          28 :       task.uid = props.value(QStringLiteral("UID")).trimmed();
     902                 :             :       // T-79.C1/H5: unescape TEXT values once, centrally.
     903                 :             :       task.summary =
     904   [ +  -  +  -  :          28 :           unescapeICalText(props.value(QStringLiteral("SUMMARY")).trimmed());
                   +  - ]
     905         [ +  - ]:          28 :       task.description = unescapeICalText(
     906   [ +  -  +  - ]:          56 :           props.value(QStringLiteral("DESCRIPTION")).trimmed());
     907                 :          14 :       task.etag = etag;
     908                 :          14 :       task.resourceHref = href;
     909                 :             : 
     910                 :             :       // Status
     911                 :             :       task.status =
     912         [ +  - ]:          42 :           props.value(QStringLiteral("STATUS"), QStringLiteral("NEEDS-ACTION"))
     913         [ +  - ]:          28 :               .trimmed()
     914         [ +  - ]:          14 :               .toUpper();
     915                 :             : 
     916                 :             :       // Priority (0-9)
     917                 :             :       QString priStr =
     918   [ +  -  +  - ]:          28 :           props.value(QStringLiteral("PRIORITY")).trimmed();
     919         [ +  + ]:          14 :       if (!priStr.isEmpty())
     920         [ +  - ]:           3 :         task.priority = priStr.toInt();
     921                 :             : 
     922                 :             :       // Percent-complete
     923                 :             :       QString pctStr =
     924   [ +  -  +  - ]:          28 :           props.value(QStringLiteral("PERCENT-COMPLETE")).trimmed();
     925         [ +  + ]:          14 :       if (!pctStr.isEmpty())
     926         [ +  - ]:           3 :         task.percentComplete = pctStr.toInt();
     927                 :             : 
     928                 :             :       // Due date
     929   [ +  -  +  - ]:          28 :       QString dueVal = props.value(QStringLiteral("DUE")).trimmed();
     930         [ +  - ]:          28 :       QString dueParams = props.value(QStringLiteral("DUE_PARAMS"));
     931         [ +  + ]:          14 :       if (!dueVal.isEmpty())
     932         [ +  - ]:           3 :         task.due = parseICalDateTime(dueVal, dueParams);
     933                 :             : 
     934                 :             :       // Completed date
     935                 :             :       QString compVal =
     936   [ +  -  +  - ]:          28 :           props.value(QStringLiteral("COMPLETED")).trimmed();
     937         [ +  + ]:          14 :       if (!compVal.isEmpty())
     938         [ +  - ]:           2 :         task.completedAt = parseICalDateTime(compVal, QString());
     939                 :             : 
     940                 :             :       // LAST-MODIFIED
     941                 :             :       QString lastModVal =
     942   [ +  -  +  - ]:          28 :           props.value(QStringLiteral("LAST-MODIFIED")).trimmed();
     943         [ +  + ]:          14 :       if (!lastModVal.isEmpty())
     944         [ +  - ]:           2 :         task.lastModified = parseICalDateTime(lastModVal, QString());
     945                 :             : 
     946                 :             :       // Start date (Sprint 37 – T-452)
     947                 :             :       QString dtStartVal =
     948   [ +  -  +  - ]:          28 :           props.value(QStringLiteral("DTSTART")).trimmed();
     949                 :             :       QString dtStartParams =
     950         [ +  - ]:          28 :           props.value(QStringLiteral("DTSTART_PARAMS"));
     951         [ +  + ]:          14 :       if (!dtStartVal.isEmpty())
     952         [ +  - ]:           1 :         task.dtStart = parseICalDateTime(dtStartVal, dtStartParams);
     953                 :             : 
     954                 :             :       // Created date (Sprint 37 – T-452)
     955                 :             :       QString createdVal =
     956   [ +  -  +  - ]:          28 :           props.value(QStringLiteral("CREATED")).trimmed();
     957         [ +  + ]:          14 :       if (!createdVal.isEmpty())
     958         [ +  - ]:           1 :         task.created = parseICalDateTime(createdVal, QString());
     959                 :             : 
     960                 :             :       // Organizer (Sprint 37 – T-452)
     961                 :             :       // Format: ORGANIZER;CN="John Doe":mailto:john@example.com
     962                 :             :       QString orgVal =
     963   [ +  -  +  - ]:          28 :           props.value(QStringLiteral("ORGANIZER")).trimmed();
     964         [ +  + ]:          14 :       if (!orgVal.isEmpty()) {
     965                 :             :         QString orgParams =
     966         [ +  - ]:          10 :             props.value(QStringLiteral("ORGANIZER_PARAMS"));
     967         [ +  + ]:           9 :         if (!orgParams.isEmpty() &&
     968   [ +  -  +  +  :           9 :             orgParams.contains(QStringLiteral("CN="))) {
          +  +  +  +  +  
             +  -  -  -  
                      - ]
     969                 :             :           // Extract CN value
     970                 :             :           static QRegularExpression cnRe(
     971   [ +  +  +  -  :           4 :               QStringLiteral("CN=\"?([^\";]+)\"?"));
             +  -  -  - ]
     972         [ +  - ]:           3 :           auto m = cnRe.match(orgParams);
     973   [ +  -  +  +  :           3 :           task.organizer = m.hasMatch() ? m.captured(1) : orgVal;
                   +  - ]
     974                 :           3 :         } else {
     975                 :             :           // Fallback: strip mailto: prefix
     976                 :           2 :           task.organizer = orgVal;
     977         [ +  - ]:           2 :           task.organizer.remove(
     978                 :           4 :               QStringLiteral("mailto:"), Qt::CaseInsensitive);
     979                 :             :         }
     980                 :           5 :       }
     981                 :             : 
     982         [ +  + ]:          14 :       if (!task.uid.isEmpty()) {
     983         [ +  - ]:          13 :         tasks->append(task);
     984                 :             :       } else {
     985                 :             :         // T-79.C2/L12: skip the malformed component, keep the rest.
     986   [ +  -  +  -  :           2 :         qCWarning(lcCalDav) << "Skipping VTODO without UID (href:" << href
          +  -  +  -  +  
                      + ]
     987         [ +  - ]:           1 :                             << ")";
     988                 :             :       }
     989                 :          14 :     }
     990   [ +  -  +  +  :          24 :   }
          -  +  +  -  +  
          +  -  +  +  -  
          +  +  -  +  +  
                   -  + ]
     991                 :             : 
     992                 :          10 :   return true;
     993                 :          13 : }
     994                 :             : 
     995                 :             : // ═══════════════════════════════════════════════════════
     996                 :             : // Sprint 39 – T-530: Write API (iCal serialization + PUT/DELETE)
     997                 :             : // ═══════════════════════════════════════════════════════
     998                 :             : 
     999                 :         138 : static QByteArray escapeICalText(const QString &text) {
    1000                 :         138 :   QString escaped = text;
    1001         [ +  - ]:         138 :   escaped.replace(QLatin1Char('\\'), QStringLiteral("\\\\"));
    1002         [ +  - ]:         138 :   escaped.replace(QLatin1Char(';'), QStringLiteral("\\;"));
    1003         [ +  - ]:         138 :   escaped.replace(QLatin1Char(','), QStringLiteral("\\,"));
    1004         [ +  - ]:         138 :   escaped.replace(QLatin1Char('\n'), QStringLiteral("\\n"));
    1005         [ +  - ]:         138 :   escaped.remove(QLatin1Char('\r'));
    1006         [ +  - ]:         276 :   return escaped.toUtf8();
    1007                 :         138 : }
    1008                 :             : 
    1009                 :          65 : static QByteArray formatICalDate(const QDateTime &dt, bool allDay) {
    1010         [ +  + ]:          65 :   if (allDay)
    1011   [ +  -  +  -  :          22 :     return dt.date().toString(QStringLiteral("yyyyMMdd")).toUtf8();
                   +  - ]
    1012                 :             :   // Always emit UTC
    1013         [ +  - ]:          54 :   return dt.toUTC()
    1014         [ +  - ]:         108 :       .toString(QStringLiteral("yyyyMMdd'T'HHmmss'Z'"))
    1015         [ +  - ]:          54 :       .toUtf8();
    1016                 :             : }
    1017                 :             : 
    1018                 :          30 : QByteArray CalDavClient::eventToICalendar(const CalendarEvent &event) {
    1019                 :          30 :   QByteArray ical;
    1020         [ +  - ]:          30 :   ical += "BEGIN:VCALENDAR\r\n";
    1021         [ +  - ]:          30 :   ical += "VERSION:2.0\r\n";
    1022         [ +  - ]:          30 :   ical += "PRODID:-//MailJD//CalDAV Client//EN\r\n";
    1023         [ +  - ]:          30 :   ical += "BEGIN:VEVENT\r\n";
    1024   [ +  -  +  -  :          30 :   ical += "UID:" + escapeICalText(event.uid) + "\r\n";
             +  -  +  - ]
    1025                 :             : 
    1026   [ +  -  +  + ]:          30 :   if (event.dtStart.isValid()) {
    1027         [ +  + ]:          28 :     if (event.allDay) {
    1028   [ +  -  +  -  :           4 :       ical += "DTSTART;VALUE=DATE:" + formatICalDate(event.dtStart, true) + "\r\n";
             +  -  +  - ]
    1029                 :             :     } else {
    1030   [ +  -  +  -  :          24 :       ical += "DTSTART:" + formatICalDate(event.dtStart, false) + "\r\n";
             +  -  +  - ]
    1031                 :             :     }
    1032                 :             :   }
    1033                 :             : 
    1034   [ +  -  +  + ]:          30 :   if (event.dtEnd.isValid()) {
    1035         [ +  + ]:          27 :     if (event.allDay) {
    1036   [ +  -  +  -  :           4 :       ical += "DTEND;VALUE=DATE:" + formatICalDate(event.dtEnd, true) + "\r\n";
             +  -  +  - ]
    1037                 :             :     } else {
    1038   [ +  -  +  -  :          23 :       ical += "DTEND:" + formatICalDate(event.dtEnd, false) + "\r\n";
             +  -  +  - ]
    1039                 :             :     }
    1040                 :             :   }
    1041                 :             : 
    1042         [ +  + ]:          30 :   if (!event.summary.isEmpty())
    1043   [ +  -  +  -  :          28 :     ical += "SUMMARY:" + escapeICalText(event.summary) + "\r\n";
             +  -  +  - ]
    1044                 :             : 
    1045         [ +  + ]:          30 :   if (!event.description.isEmpty())
    1046   [ +  -  +  -  :           8 :     ical += "DESCRIPTION:" + escapeICalText(event.description) + "\r\n";
             +  -  +  - ]
    1047                 :             : 
    1048         [ +  + ]:          30 :   if (!event.location.isEmpty())
    1049   [ +  -  +  -  :           5 :     ical += "LOCATION:" + escapeICalText(event.location) + "\r\n";
             +  -  +  - ]
    1050                 :             : 
    1051         [ +  + ]:          30 :   if (!event.rrule.isEmpty()) {
    1052                 :             :     // T-610/SEC-09: RRULE uses semicolons as structural delimiters,
    1053                 :             :     // so escapeICalText() would break it. Only strip CR/LF for injection prevention.
    1054                 :           3 :     QString safeRrule = event.rrule;
    1055         [ +  - ]:           3 :     safeRrule.remove('\r');
    1056         [ +  - ]:           3 :     safeRrule.remove('\n');
    1057   [ +  -  +  -  :           3 :     ical += "RRULE:" + safeRrule.toUtf8() + "\r\n";
             +  -  +  - ]
    1058                 :           3 :   }
    1059                 :             : 
    1060         [ +  - ]:          30 :   ical += "END:VEVENT\r\n";
    1061         [ +  - ]:          30 :   ical += "END:VCALENDAR\r\n";
    1062                 :          30 :   return ical;
    1063                 :           0 : }
    1064                 :             : 
    1065                 :          24 : QByteArray CalDavClient::taskToICalendar(const CalendarTask &task) {
    1066                 :          24 :   QByteArray ical;
    1067         [ +  - ]:          24 :   ical += "BEGIN:VCALENDAR\r\n";
    1068         [ +  - ]:          24 :   ical += "VERSION:2.0\r\n";
    1069         [ +  - ]:          24 :   ical += "PRODID:-//MailJD//CalDAV Client//EN\r\n";
    1070         [ +  - ]:          24 :   ical += "BEGIN:VTODO\r\n";
    1071   [ +  -  +  -  :          24 :   ical += "UID:" + escapeICalText(task.uid) + "\r\n";
             +  -  +  - ]
    1072                 :             : 
    1073         [ +  + ]:          24 :   if (!task.summary.isEmpty())
    1074   [ +  -  +  -  :          22 :     ical += "SUMMARY:" + escapeICalText(task.summary) + "\r\n";
             +  -  +  - ]
    1075                 :             : 
    1076         [ +  + ]:          24 :   if (!task.description.isEmpty())
    1077   [ +  -  +  -  :           4 :     ical += "DESCRIPTION:" + escapeICalText(task.description) + "\r\n";
             +  -  +  - ]
    1078                 :             : 
    1079         [ +  + ]:          24 :   if (!task.status.isEmpty())
    1080   [ +  -  +  -  :          17 :     ical += "STATUS:" + escapeICalText(task.status) + "\r\n";
             +  -  +  - ]
    1081                 :             : 
    1082         [ +  + ]:          24 :   if (task.priority > 0)
    1083   [ +  -  +  -  :           5 :     ical += "PRIORITY:" + QByteArray::number(task.priority) + "\r\n";
             +  -  +  - ]
    1084                 :             : 
    1085         [ +  + ]:          24 :   if (task.percentComplete > 0)
    1086   [ +  -  +  -  :           4 :     ical += "PERCENT-COMPLETE:" + QByteArray::number(task.percentComplete) + "\r\n";
             +  -  +  - ]
    1087                 :             : 
    1088   [ +  -  +  + ]:          24 :   if (task.due.isValid()) {
    1089                 :             :     // Tasks typically use VALUE=DATE for all-day dues
    1090   [ +  -  +  -  :           6 :     if (task.due.time() == QTime(0, 0)) {
                   +  + ]
    1091   [ +  -  +  -  :           2 :       ical += "DUE;VALUE=DATE:" + formatICalDate(task.due, true) + "\r\n";
             +  -  +  - ]
    1092                 :             :     } else {
    1093   [ +  -  +  -  :           4 :       ical += "DUE:" + formatICalDate(task.due, false) + "\r\n";
             +  -  +  - ]
    1094                 :             :     }
    1095                 :             :   }
    1096                 :             : 
    1097   [ +  -  +  + ]:          24 :   if (task.dtStart.isValid()) {
    1098   [ +  -  +  -  :           2 :     if (task.dtStart.time() == QTime(0, 0)) {
                   +  + ]
    1099   [ +  -  +  -  :           1 :       ical += "DTSTART;VALUE=DATE:" + formatICalDate(task.dtStart, true) + "\r\n";
             +  -  +  - ]
    1100                 :             :     } else {
    1101   [ +  -  +  -  :           1 :       ical += "DTSTART:" + formatICalDate(task.dtStart, false) + "\r\n";
             +  -  +  - ]
    1102                 :             :     }
    1103                 :             :   }
    1104                 :             : 
    1105   [ +  -  +  + ]:          24 :   if (task.completedAt.isValid())
    1106   [ +  -  +  -  :           2 :     ical += "COMPLETED:" + formatICalDate(task.completedAt, false) + "\r\n";
             +  -  +  - ]
    1107                 :             : 
    1108         [ +  - ]:          24 :   ical += "END:VTODO\r\n";
    1109         [ +  - ]:          24 :   ical += "END:VCALENDAR\r\n";
    1110                 :          24 :   return ical;
    1111                 :           0 : }
    1112                 :             : 
    1113                 :          47 : QString CalDavClient::resourceUrl(const QString &calendarPath,
    1114                 :             :                                   const QString &uid) const {
    1115         [ +  - ]:          47 :   QString basePath = resolveDavUrl(calendarPath);
    1116         [ +  + ]:          47 :   if (basePath.isEmpty())
    1117                 :           7 :     return {};
    1118   [ +  -  +  + ]:          40 :   if (!basePath.endsWith(QLatin1Char('/')))
    1119         [ +  - ]:           3 :     basePath += QLatin1Char('/');
    1120   [ +  -  +  -  :          80 :   return basePath + QString::fromUtf8(QUrl::toPercentEncoding(uid)) +
                   +  - ]
    1121         [ +  - ]:         120 :          QStringLiteral(".ics");
    1122                 :          47 : }
    1123                 :             : 
    1124                 :          33 : QString CalDavClient::resourceUrlForExistingResource(
    1125                 :             :     const QString &resourceHref, const QString &calendarPath,
    1126                 :             :     const QString &uid) const {
    1127         [ +  + ]:          33 :   if (!resourceHref.isEmpty())
    1128                 :          11 :     return resolveDavUrl(resourceHref);
    1129                 :          22 :   return resourceUrl(calendarPath, uid);
    1130                 :             : }
    1131                 :             : 
    1132                 :          12 : void CalDavClient::createEvent(const QString &calendarPath,
    1133                 :             :                                const CalendarEvent &event) {
    1134         [ +  - ]:          12 :   QString url = resourceUrl(calendarPath, event.uid);
    1135         [ +  + ]:          12 :   if (url.isEmpty()) {
    1136         [ +  - ]:           1 :     emit writeFailed(QStringLiteral("Invalid calendar path for event creation"));
    1137                 :           1 :     return;
    1138                 :             :   }
    1139                 :             : 
    1140   [ +  -  +  - ]:          11 :   QNetworkRequest request{QUrl(url)};
    1141   [ +  -  +  -  :          11 :   request.setRawHeader("Authorization", authHeader());
                   +  - ]
    1142   [ +  -  +  -  :          11 :   request.setRawHeader("Content-Type", "text/calendar; charset=utf-8");
                   +  - ]
    1143   [ +  -  +  -  :          11 :   request.setRawHeader("If-None-Match", "*"); // Only create, don't overwrite
                   +  - ]
    1144                 :             : 
    1145         [ +  - ]:          11 :   QByteArray body = eventToICalendar(event);
    1146         [ +  - ]:          11 :   auto *reply = m_nam->put(request, body);
    1147         [ +  - ]:          11 :   DavNetworkLimits::apply(reply);
    1148         [ +  - ]:          11 :   trackReply(reply); // T-79.F1: lifetime follows request completion
    1149                 :             : 
    1150                 :          11 :   connect(reply, &QNetworkReply::finished, this,
    1151   [ +  -  -  - ]:          22 :           [this, reply, event, url]() {
    1152         [ +  - ]:           7 :             reply->deleteLater();
    1153         [ +  - ]:           7 :             const QString limitError = DavNetworkLimits::failureReason(reply);
    1154         [ +  + ]:           7 :             if (!limitError.isEmpty()) {
    1155         [ +  - ]:           1 :               emit writeFailed(limitError);
    1156                 :           1 :               return;
    1157                 :             :             }
    1158         [ +  - ]:           6 :             int status = reply->attribute(
    1159         [ +  - ]:           6 :                 QNetworkRequest::HttpStatusCodeAttribute).toInt();
    1160   [ +  +  +  + ]:           6 :             if (status == 201 || status == 204) {
    1161                 :             :               // Update etag from response
    1162                 :           4 :               CalendarEvent saved = event;
    1163         [ +  - ]:           8 :               QByteArray newEtag = reply->rawHeader("ETag");
    1164         [ +  + ]:           4 :               if (!newEtag.isEmpty())
    1165         [ +  - ]:           3 :                 saved.etag = QString::fromUtf8(newEtag);
    1166         [ +  - ]:           8 :               const QByteArray location = reply->rawHeader("Location");
    1167                 :           4 :               const QString savedHref = location.isEmpty()
    1168                 :           6 :                                             ? url
    1169   [ +  +  +  -  :           4 :                                             : resolveDavUrl(QString::fromUtf8(location));
          +  -  +  +  -  
                      - ]
    1170         [ +  + ]:           4 :               if (!savedHref.isEmpty())
    1171                 :           3 :                 saved.resourceHref = savedHref;
    1172   [ +  -  +  -  :           8 :               qCDebug(lcCalDav) << "Event created:" << saved.uid;
          +  -  +  -  +  
                      + ]
    1173         [ +  - ]:           4 :               emit eventSaved(saved);
    1174                 :           4 :             } else {
    1175                 :           4 :               QString err = QStringLiteral("Create event failed (HTTP %1): %2")
    1176   [ +  -  +  -  :           4 :                   .arg(status).arg(QString::fromUtf8(reply->readAll()));
             +  -  +  - ]
    1177   [ +  -  +  -  :           4 :               qCWarning(lcCalDav) << err;
             +  -  +  + ]
    1178         [ +  - ]:           2 :               emit writeFailed(err);
    1179                 :           2 :             }
    1180         [ +  + ]:           7 :           });
    1181         [ +  + ]:          12 : }
    1182                 :             : 
    1183                 :          10 : void CalDavClient::updateEvent(const CalendarEvent &event) {
    1184                 :          10 :   QString url = resourceUrlForExistingResource(event.resourceHref,
    1185                 :          10 :                                                event.calendarPath,
    1186         [ +  - ]:          10 :                                                event.uid);
    1187         [ +  + ]:          10 :   if (url.isEmpty()) {
    1188         [ +  - ]:           1 :     emit writeFailed(QStringLiteral("Invalid calendar path for event update"));
    1189                 :           1 :     return;
    1190                 :             :   }
    1191                 :             : 
    1192   [ +  -  +  - ]:           9 :   QNetworkRequest request{QUrl(url)};
    1193   [ +  -  +  -  :           9 :   request.setRawHeader("Authorization", authHeader());
                   +  - ]
    1194   [ +  -  +  -  :           9 :   request.setRawHeader("Content-Type", "text/calendar; charset=utf-8");
                   +  - ]
    1195   [ +  -  +  + ]:           9 :   if (!isValidEtag(event.etag)) {
    1196   [ +  -  +  -  :           6 :     qCWarning(lcCalDav) << "Rejecting PUT without a valid RFC entity-tag";
             +  -  +  + ]
    1197         [ +  - ]:           3 :     emit writeFailed(QStringLiteral("Invalid or missing server ETag — write "
    1198                 :             :                                     "refused to preserve concurrency protection"));
    1199                 :           3 :     return;
    1200                 :             :   }
    1201   [ +  -  +  -  :           6 :   request.setRawHeader("If-Match", event.etag.toUtf8());
                   +  - ]
    1202                 :             : 
    1203         [ +  - ]:           6 :   QByteArray body = eventToICalendar(event);
    1204         [ +  - ]:           6 :   auto *reply = m_nam->put(request, body);
    1205         [ +  - ]:           6 :   DavNetworkLimits::apply(reply);
    1206         [ +  - ]:           6 :   trackReply(reply); // T-79.F1: lifetime follows request completion
    1207                 :             : 
    1208         [ +  - ]:           6 :   connect(reply, &QNetworkReply::finished, this,
    1209                 :          12 :           [this, reply, event]() {
    1210         [ +  - ]:           6 :             reply->deleteLater();
    1211         [ +  - ]:           6 :             const QString limitError = DavNetworkLimits::failureReason(reply);
    1212         [ +  + ]:           6 :             if (!limitError.isEmpty()) {
    1213         [ +  - ]:           1 :               emit writeFailed(limitError);
    1214                 :           1 :               return;
    1215                 :             :             }
    1216         [ +  - ]:           5 :             int status = reply->attribute(
    1217         [ +  - ]:           5 :                 QNetworkRequest::HttpStatusCodeAttribute).toInt();
    1218   [ +  +  +  + ]:           5 :             if (status == 200 || status == 204) {
    1219                 :           2 :               CalendarEvent saved = event;
    1220         [ +  - ]:           4 :               QByteArray newEtag = reply->rawHeader("ETag");
    1221         [ +  - ]:           2 :               if (!newEtag.isEmpty())
    1222         [ +  - ]:           2 :                 saved.etag = QString::fromUtf8(newEtag);
    1223   [ +  -  +  -  :           4 :               qCDebug(lcCalDav) << "Event updated:" << saved.uid;
          +  -  +  -  +  
                      + ]
    1224         [ +  - ]:           2 :               emit eventSaved(saved);
    1225         [ +  + ]:           5 :             } else if (status == 412) {
    1226         [ +  - ]:           2 :               emit writeFailed(QStringLiteral(
    1227                 :             :                   "Conflict: event was modified on server (ETag mismatch)"));
    1228                 :             :             } else {
    1229                 :           4 :               QString err = QStringLiteral("Update event failed (HTTP %1): %2")
    1230   [ +  -  +  -  :           4 :                   .arg(status).arg(QString::fromUtf8(reply->readAll()));
             +  -  +  - ]
    1231   [ +  -  +  -  :           4 :               qCWarning(lcCalDav) << err;
             +  -  +  + ]
    1232         [ +  - ]:           2 :               emit writeFailed(err);
    1233                 :           2 :             }
    1234         [ +  + ]:           6 :           });
    1235   [ +  +  +  + ]:          13 : }
    1236                 :             : 
    1237                 :           7 : void CalDavClient::deleteEvent(const CalendarEvent &event) {
    1238                 :           7 :   QString url = resourceUrlForExistingResource(event.resourceHref,
    1239                 :           7 :                                                event.calendarPath,
    1240         [ +  - ]:           7 :                                                event.uid);
    1241         [ +  + ]:           7 :   if (url.isEmpty()) {
    1242         [ +  - ]:           1 :     emit writeFailed(QStringLiteral("Invalid calendar path for event deletion"));
    1243                 :           1 :     return;
    1244                 :             :   }
    1245                 :             : 
    1246   [ +  -  +  - ]:           6 :   QNetworkRequest request{QUrl(url)};
    1247   [ +  -  +  -  :           6 :   request.setRawHeader("Authorization", authHeader());
                   +  - ]
    1248   [ +  -  +  + ]:           6 :   if (!isValidEtag(event.etag)) {
    1249   [ +  -  +  -  :           2 :     qCWarning(lcCalDav) << "Rejecting DELETE without a valid RFC entity-tag";
             +  -  +  + ]
    1250         [ +  - ]:           1 :     emit writeFailed(QStringLiteral("Invalid or missing server ETag — delete "
    1251                 :             :                                     "refused to preserve concurrency protection"));
    1252                 :           1 :     return;
    1253                 :             :   }
    1254   [ +  -  +  -  :           5 :   request.setRawHeader("If-Match", event.etag.toUtf8());
                   +  - ]
    1255                 :             : 
    1256         [ +  - ]:           5 :   auto *reply = m_nam->deleteResource(request);
    1257         [ +  - ]:           5 :   DavNetworkLimits::apply(reply);
    1258         [ +  - ]:           5 :   trackReply(reply); // T-79.F1: lifetime follows request completion
    1259                 :             : 
    1260         [ +  - ]:           5 :   connect(reply, &QNetworkReply::finished, this,
    1261                 :          10 :           [this, reply, uid = event.uid]() {
    1262         [ +  - ]:           5 :             reply->deleteLater();
    1263         [ +  - ]:           5 :             const QString limitError = DavNetworkLimits::failureReason(reply);
    1264         [ +  + ]:           5 :             if (!limitError.isEmpty()) {
    1265         [ +  - ]:           1 :               emit writeFailed(limitError);
    1266                 :           1 :               return;
    1267                 :             :             }
    1268         [ +  - ]:           4 :             int status = reply->attribute(
    1269         [ +  - ]:           4 :                 QNetworkRequest::HttpStatusCodeAttribute).toInt();
    1270   [ +  +  +  + ]:           4 :             if (status == 200 || status == 204) {
    1271   [ +  -  +  -  :           4 :               qCDebug(lcCalDav) << "Event deleted:" << uid;
          +  -  +  -  +  
                      + ]
    1272         [ +  - ]:           2 :               emit eventDeleted(uid);
    1273         [ +  + ]:           4 :             } else if (status == 412) {
    1274         [ +  - ]:           2 :               emit writeFailed(QStringLiteral(
    1275                 :             :                   "Conflict: event was modified on server (ETag mismatch)"));
    1276                 :             :             } else {
    1277                 :           2 :               QString err = QStringLiteral("Delete event failed (HTTP %1)")
    1278         [ +  - ]:           1 :                   .arg(status);
    1279   [ +  -  +  -  :           2 :               qCWarning(lcCalDav) << err;
             +  -  +  + ]
    1280         [ +  - ]:           1 :               emit writeFailed(err);
    1281                 :           1 :             }
    1282         [ +  + ]:           5 :           });
    1283   [ +  +  +  + ]:           8 : }
    1284                 :             : 
    1285                 :           9 : void CalDavClient::createTask(const QString &calendarPath,
    1286                 :             :                               const CalendarTask &task) {
    1287         [ +  - ]:           9 :   QString url = resourceUrl(calendarPath, task.uid);
    1288         [ +  + ]:           9 :   if (url.isEmpty()) {
    1289         [ +  - ]:           1 :     emit writeFailed(QStringLiteral("Invalid calendar path for task creation"));
    1290                 :           1 :     return;
    1291                 :             :   }
    1292                 :             : 
    1293   [ +  -  +  - ]:           8 :   QNetworkRequest request{QUrl(url)};
    1294   [ +  -  +  -  :           8 :   request.setRawHeader("Authorization", authHeader());
                   +  - ]
    1295   [ +  -  +  -  :           8 :   request.setRawHeader("Content-Type", "text/calendar; charset=utf-8");
                   +  - ]
    1296   [ +  -  +  -  :           8 :   request.setRawHeader("If-None-Match", "*");
                   +  - ]
    1297                 :             : 
    1298         [ +  - ]:           8 :   QByteArray body = taskToICalendar(task);
    1299         [ +  - ]:           8 :   auto *reply = m_nam->put(request, body);
    1300         [ +  - ]:           8 :   DavNetworkLimits::apply(reply);
    1301         [ +  - ]:           8 :   trackReply(reply); // T-79.F1: lifetime follows request completion
    1302                 :             : 
    1303                 :           8 :   connect(reply, &QNetworkReply::finished, this,
    1304   [ +  -  -  - ]:          16 :           [this, reply, task, url]() {
    1305         [ +  - ]:           6 :             reply->deleteLater();
    1306         [ +  - ]:           6 :             const QString limitError = DavNetworkLimits::failureReason(reply);
    1307         [ +  + ]:           6 :             if (!limitError.isEmpty()) {
    1308         [ +  - ]:           1 :               emit writeFailed(limitError);
    1309                 :           1 :               return;
    1310                 :             :             }
    1311         [ +  - ]:           5 :             int status = reply->attribute(
    1312         [ +  - ]:           5 :                 QNetworkRequest::HttpStatusCodeAttribute).toInt();
    1313   [ +  +  +  + ]:           5 :             if (status == 201 || status == 204) {
    1314                 :           3 :               CalendarTask saved = task;
    1315         [ +  - ]:           6 :               QByteArray newEtag = reply->rawHeader("ETag");
    1316         [ +  + ]:           3 :               if (!newEtag.isEmpty())
    1317         [ +  - ]:           2 :                 saved.etag = QString::fromUtf8(newEtag);
    1318         [ +  - ]:           6 :               const QByteArray location = reply->rawHeader("Location");
    1319                 :           3 :               const QString savedHref = location.isEmpty()
    1320                 :           4 :                                             ? url
    1321   [ +  +  +  -  :           3 :                                             : resolveDavUrl(QString::fromUtf8(location));
          +  -  +  +  -  
                      - ]
    1322         [ +  + ]:           3 :               if (!savedHref.isEmpty())
    1323                 :           2 :                 saved.resourceHref = savedHref;
    1324   [ +  -  +  -  :           6 :               qCDebug(lcCalDav) << "Task created:" << saved.uid;
          +  -  +  -  +  
                      + ]
    1325         [ +  - ]:           3 :               emit taskSaved(saved);
    1326                 :           3 :             } else {
    1327                 :           4 :               QString err = QStringLiteral("Create task failed (HTTP %1): %2")
    1328   [ +  -  +  -  :           4 :                   .arg(status).arg(QString::fromUtf8(reply->readAll()));
             +  -  +  - ]
    1329   [ +  -  +  -  :           4 :               qCWarning(lcCalDav) << err;
             +  -  +  + ]
    1330         [ +  - ]:           2 :               emit writeFailed(err);
    1331                 :           2 :             }
    1332         [ +  + ]:           6 :           });
    1333         [ +  + ]:           9 : }
    1334                 :             : 
    1335                 :           7 : void CalDavClient::updateTask(const CalendarTask &task) {
    1336                 :           7 :   QString url = resourceUrlForExistingResource(task.resourceHref,
    1337                 :           7 :                                                task.calendarPath,
    1338         [ +  - ]:           7 :                                                task.uid);
    1339         [ +  + ]:           7 :   if (url.isEmpty()) {
    1340         [ +  - ]:           1 :     emit writeFailed(QStringLiteral("Invalid calendar path for task update"));
    1341                 :           1 :     return;
    1342                 :             :   }
    1343                 :             : 
    1344   [ +  -  +  - ]:           6 :   QNetworkRequest request{QUrl(url)};
    1345   [ +  -  +  -  :           6 :   request.setRawHeader("Authorization", authHeader());
                   +  - ]
    1346   [ +  -  +  -  :           6 :   request.setRawHeader("Content-Type", "text/calendar; charset=utf-8");
                   +  - ]
    1347   [ +  -  +  + ]:           6 :   if (!isValidEtag(task.etag)) {
    1348   [ +  -  +  -  :           2 :     qCWarning(lcCalDav) << "Rejecting task PUT without a valid RFC entity-tag";
             +  -  +  + ]
    1349         [ +  - ]:           1 :     emit writeFailed(QStringLiteral("Invalid or missing server ETag — write "
    1350                 :             :                                     "refused to preserve concurrency protection"));
    1351                 :           1 :     return;
    1352                 :             :   }
    1353   [ +  -  +  -  :           5 :   request.setRawHeader("If-Match", task.etag.toUtf8());
                   +  - ]
    1354                 :             : 
    1355         [ +  - ]:           5 :   QByteArray body = taskToICalendar(task);
    1356         [ +  - ]:           5 :   auto *reply = m_nam->put(request, body);
    1357         [ +  - ]:           5 :   DavNetworkLimits::apply(reply);
    1358         [ +  - ]:           5 :   trackReply(reply); // T-79.F1: lifetime follows request completion
    1359                 :             : 
    1360         [ +  - ]:           5 :   connect(reply, &QNetworkReply::finished, this,
    1361                 :          10 :           [this, reply, task]() {
    1362         [ +  - ]:           5 :             reply->deleteLater();
    1363         [ +  - ]:           5 :             const QString limitError = DavNetworkLimits::failureReason(reply);
    1364         [ +  + ]:           5 :             if (!limitError.isEmpty()) {
    1365         [ +  - ]:           1 :               emit writeFailed(limitError);
    1366                 :           1 :               return;
    1367                 :             :             }
    1368         [ +  - ]:           4 :             int status = reply->attribute(
    1369         [ +  - ]:           4 :                 QNetworkRequest::HttpStatusCodeAttribute).toInt();
    1370   [ +  +  +  + ]:           4 :             if (status == 200 || status == 204) {
    1371                 :           3 :               CalendarTask saved = task;
    1372         [ +  - ]:           6 :               QByteArray newEtag = reply->rawHeader("ETag");
    1373         [ +  + ]:           3 :               if (!newEtag.isEmpty())
    1374         [ +  - ]:           2 :                 saved.etag = QString::fromUtf8(newEtag);
    1375   [ +  -  +  -  :           6 :               qCDebug(lcCalDav) << "Task updated:" << saved.uid;
          +  -  +  -  +  
                      + ]
    1376         [ +  - ]:           3 :               emit taskSaved(saved);
    1377         [ +  - ]:           4 :             } else if (status == 412) {
    1378         [ +  - ]:           2 :               emit writeFailed(QStringLiteral(
    1379                 :             :                   "Conflict: task was modified on server (ETag mismatch)"));
    1380                 :             :             } else {
    1381                 :           0 :               QString err = QStringLiteral("Update task failed (HTTP %1): %2")
    1382   [ #  #  #  #  :           0 :                   .arg(status).arg(QString::fromUtf8(reply->readAll()));
             #  #  #  # ]
    1383   [ #  #  #  #  :           0 :               qCWarning(lcCalDav) << err;
             #  #  #  # ]
    1384         [ #  # ]:           0 :               emit writeFailed(err);
    1385                 :           0 :             }
    1386         [ +  + ]:           5 :           });
    1387   [ +  +  +  + ]:           8 : }
    1388                 :             : 
    1389                 :           7 : void CalDavClient::deleteTask(const CalendarTask &task) {
    1390                 :           7 :   QString url = resourceUrlForExistingResource(task.resourceHref,
    1391                 :           7 :                                                task.calendarPath,
    1392         [ +  - ]:           7 :                                                task.uid);
    1393         [ +  + ]:           7 :   if (url.isEmpty()) {
    1394         [ +  - ]:           1 :     emit writeFailed(QStringLiteral("Invalid calendar path for task deletion"));
    1395                 :           1 :     return;
    1396                 :             :   }
    1397                 :             : 
    1398   [ +  -  +  - ]:           6 :   QNetworkRequest request{QUrl(url)};
    1399   [ +  -  +  -  :           6 :   request.setRawHeader("Authorization", authHeader());
                   +  - ]
    1400   [ +  -  +  + ]:           6 :   if (!isValidEtag(task.etag)) {
    1401   [ +  -  +  -  :           2 :     qCWarning(lcCalDav) << "Rejecting task DELETE without a valid RFC entity-tag";
             +  -  +  + ]
    1402         [ +  - ]:           1 :     emit writeFailed(QStringLiteral("Invalid or missing server ETag — delete "
    1403                 :             :                                     "refused to preserve concurrency protection"));
    1404                 :           1 :     return;
    1405                 :             :   }
    1406   [ +  -  +  -  :           5 :   request.setRawHeader("If-Match", task.etag.toUtf8());
                   +  - ]
    1407                 :             : 
    1408         [ +  - ]:           5 :   auto *reply = m_nam->deleteResource(request);
    1409         [ +  - ]:           5 :   DavNetworkLimits::apply(reply);
    1410         [ +  - ]:           5 :   trackReply(reply); // T-79.F1: lifetime follows request completion
    1411                 :             : 
    1412         [ +  - ]:           5 :   connect(reply, &QNetworkReply::finished, this,
    1413                 :          10 :           [this, reply, uid = task.uid]() {
    1414         [ +  - ]:           5 :             reply->deleteLater();
    1415         [ +  - ]:           5 :             const QString limitError = DavNetworkLimits::failureReason(reply);
    1416         [ +  + ]:           5 :             if (!limitError.isEmpty()) {
    1417         [ +  - ]:           1 :               emit writeFailed(limitError);
    1418                 :           1 :               return;
    1419                 :             :             }
    1420         [ +  - ]:           4 :             int status = reply->attribute(
    1421         [ +  - ]:           4 :                 QNetworkRequest::HttpStatusCodeAttribute).toInt();
    1422   [ +  +  +  + ]:           4 :             if (status == 200 || status == 204) {
    1423   [ +  -  +  -  :           4 :               qCDebug(lcCalDav) << "Task deleted:" << uid;
          +  -  +  -  +  
                      + ]
    1424         [ +  - ]:           2 :               emit taskDeleted(uid);
    1425         [ +  + ]:           4 :             } else if (status == 412) {
    1426         [ +  - ]:           2 :               emit writeFailed(QStringLiteral(
    1427                 :             :                   "Conflict: task was modified on server (ETag mismatch)"));
    1428                 :             :             } else {
    1429                 :           2 :               QString err = QStringLiteral("Delete task failed (HTTP %1)")
    1430         [ +  - ]:           1 :                   .arg(status);
    1431   [ +  -  +  -  :           2 :               qCWarning(lcCalDav) << err;
             +  -  +  + ]
    1432         [ +  - ]:           1 :               emit writeFailed(err);
    1433                 :           1 :             }
    1434         [ +  + ]:           5 :           });
    1435   [ +  +  +  + ]:           8 : }
        

Generated by: LCOV version 2.0-1