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: 98.4 % 838 825
Test Date: 2026-06-21 21:10:19 Functions: 100.0 % 41 41
Legend: Lines:     hit not hit
Branches: + taken - not taken # not executed
Branches: 62.1 % 2344 1455

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

Generated by: LCOV version 2.0-1