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 ¶ms, const QString &name) {
454 : 14 : for (const auto ¶m : 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 ¶ms) {
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 : }
|