app/core/CalendarOutbox.h
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #pragma once | ||
| 2 | |||
| 3 | #include <QHash> | ||
| 4 | #include <QObject> | ||
| 5 | #include <QSet> | ||
| 6 | #include <QString> | ||
| 7 | #include <QTimer> | ||
| 8 | #include <functional> | ||
| 9 | #include <optional> | ||
| 10 | |||
| 11 | #include "data/CalendarModels.h" | ||
| 12 | |||
| 13 | class CalendarStore; | ||
| 14 | class CalDavClient; | ||
| 15 | struct DavResponse; | ||
| 16 | |||
| 17 | // Event write-back through a persistent outbox (Sprint 93 S93.1.6, | ||
| 18 | // CAL-CONFLICT). Replaces the rollback-on-failure contract for events: | ||
| 19 | // | ||
| 20 | // 1. Local first: the store row changes at once and the DAV resource | ||
| 21 | // (account, calendar, UID) gets one coalesced outbox entry ("put" or | ||
| 22 | // "delete") that remembers the server ETag the change is based on. | ||
| 23 | // 2. flush() pushes due entries: PUT with If-Match (If-None-Match for new | ||
| 24 | // resources) of the current local rows patched onto the stored | ||
| 25 | // resource, or DELETE with If-Match. | ||
| 26 | // 3. Success adopts the pushed body as the new base and removes the entry | ||
| 27 | // (unless a newer local change bumped its revision meanwhile). | ||
| 28 | // 4. Transport failures and 5xx keep the edit and retry with backoff | ||
| 29 | // (30 s … 30 min) on the retry timer, the sync timer and reconnects. | ||
| 30 | // 5. 412: the server version is fetched and merged on property level | ||
| 31 | // (ICalMerge). Non-overlapping changes are pushed automatically; an | ||
| 32 | // overlap marks the entry "conflict" with the server version kept for | ||
| 33 | // resolveConflict() ("mine" / "server" / "both"). | ||
| 34 | // | ||
| 35 | // Display: rows of a resource with an entry carry syncState "pending" or | ||
| 36 | // "conflict". Tasks share this contract (Sprint 94). | ||
| 37 | class CalendarOutbox : public QObject { | ||
| 38 | 275 | Q_OBJECT | |
| 39 | public: | ||
| 40 | struct Endpoint { | ||
| 41 | QString serverUrl; | ||
| 42 | QString username; | ||
| 43 | QByteArray password; | ||
| 44 | }; | ||
| 45 | struct Deps { | ||
| 46 | CalendarStore *store = nullptr; | ||
| 47 | std::function<std::optional<Endpoint>(const QString &accountId)> endpoint; | ||
| 48 | // Whether the account has a server configured, without reading its | ||
| 49 | // password (the keyring read may run a nested event loop). Empty: the | ||
| 50 | // endpoint answers. | ||
| 51 | std::function<bool(const QString &accountId)> hasEndpoint; | ||
| 52 | std::function<CalDavClient *(const QString &url, const QString &user, const QString &password)> makeClient; | ||
| 53 | std::function<bool()> networkAllowed; | ||
| 54 | std::function<qint64()> now; // epoch seconds (tests inject a clock) | ||
| 55 | // Sprint 96 S96.3.7: the own push registration URL of a collection | ||
| 56 | // ("" = none); sent as Push-Dont-Notify with every write so MailJD is | ||
| 57 | // not notified about its own change (other devices still are). | ||
| 58 | std::function<QString(const QString &accountId, const QString &calendarPath)> pushDontNotify; | ||
| 59 | }; | ||
| 60 | explicit CalendarOutbox(Deps deps, QObject *parent = nullptr); | ||
| 61 | |||
| 62 | // Writes one row (master or exception) locally and queues its resource. | ||
| 63 | bool saveEvent(const CalendarEvent &event); | ||
| 64 | bool saveTask(const CalendarTask &task); | ||
| 65 | bool removeTask(const QString &uid, const QString &accountId, const QString &calendarPath); | ||
| 66 | bool moveTask(const CalendarTask &source, const CalendarTask &target); | ||
| 67 | // Removes the series (all rows of the UID) and queues the deletion. | ||
| 68 | bool removeSeries(const QString &uid, const QString &accountId, const QString &calendarPath); | ||
| 69 | // Replaces every row of a resource with `rows` (undo; empty = remove). | ||
| 70 | bool restoreSeries(const QString &uid, const QString &accountId, const QString &calendarPath, | ||
| 71 | const QList<CalendarEvent> &rows); | ||
| 72 | // F-52 (SPRINT-104): occurrence-scoped writes through the series engine. | ||
| 73 | // An invitation that carries only RECURRENCE-ID rows merges them into | ||
| 74 | // the stored series (the master row and its other exceptions survive); | ||
| 75 | // a cancellation removes exactly that occurrence and adds its date to | ||
| 76 | // the master's EXDATE list (RFC 5545). Both return false when no master | ||
| 77 | // is stored to merge into. | ||
| 78 | bool applyOccurrenceUpdate(const QString &uid, const QString &accountId, | ||
| 79 | const QString &calendarPath, | ||
| 80 | const QList<CalendarEvent> &exceptionRows); | ||
| 81 | bool applyOccurrenceCancel(const QString &uid, const QString &accountId, | ||
| 82 | const QString &calendarPath, | ||
| 83 | const QDateTime &recurrenceId); | ||
| 84 | // choice: "mine" (overwrite the server), "server" (take the server | ||
| 85 | // version), "both" (take the server version and keep mine as a copy). | ||
| 86 | bool resolveConflict(const QString &uid, const QString &accountId, const QString &calendarPath, | ||
| 87 | const QString &choice); | ||
| 88 | // F108-12: the ways out of a write the server refuses ("failed", or | ||
| 89 | // pending with retries): try it again now, or drop the local change and | ||
| 90 | // take the server's version (none: the item goes) — the dropped version | ||
| 91 | // is kept in the conflict history. | ||
| 92 | bool retryWrite(const QString &uid, const QString &accountId, const QString &calendarPath); | ||
| 93 | bool discardLocalChange(const QString &uid, const QString &accountId, const QString &calendarPath); | ||
| 94 | // Pushes due entries; `ignoreBackoff` also pushes entries waiting for | ||
| 95 | // their next retry ("Retry now"). | ||
| 96 | void flush(bool ignoreBackoff = false); | ||
| 97 | // The flush after a local write, one event-loop turn later and coalesced. | ||
| 98 | // A local write runs inside UI handlers (a task row's check); the | ||
| 99 | // credential read of a flush may spin a nested event loop that would | ||
| 100 | // deliver queued model updates under them and destroy the row whose | ||
| 101 | // handler is running (user report 2026-10-13: a double-click aborted). | ||
| 102 | void requestFlush(); | ||
| 103 | 42 | void beginBatch() { ++m_batchDepth; } | |
| 104 | void endBatch(); | ||
| 105 | bool hasEntry(const QString &uid, const QString &accountId, const QString &calendarPath) const; | ||
| 106 | static qint64 backoffSeconds(int attempts); | ||
| 107 | |||
| 108 | // ─── Sprint 107: the conflict resolver (CalendarOutboxResolve.cpp) ─── | ||
| 109 | // The three versions of a conflicted resource for the resolver's plan. | ||
| 110 | struct ConflictInputs { | ||
| 111 | bool valid = false; | ||
| 112 | QString base; // the last server version (a moved task: its source's) | ||
| 113 | QString local; // what this computer would push now | ||
| 114 | QString server; // the server version ("" = deleted there) | ||
| 115 | QString serverEtag; // identity of the shown server version (D-9) | ||
| 116 | QString componentType; // "VEVENT" or "VTODO" | ||
| 117 | QString operation; // "put" or "delete" (deleted here) | ||
| 118 | QStringList fields; // CalendarOutboxEntry::conflictFields | ||
| 119 | QString movedFrom; // D-22: the calendar path the task was moved from | ||
| 120 | }; | ||
| 121 | ConflictInputs conflictInputs(const QString &uid, const QString &accountId, const QString &calendarPath) const; | ||
| 122 | // S107.3.1/3.2: writes a resolution. The resolved resource replaces the | ||
| 123 | // local rows, the server version becomes the merge base and the entry is | ||
| 124 | // pushed against `serverEtag`. False (nothing written) when the conflict | ||
| 125 | // changed meanwhile (another server version, D-9) or the text cannot be | ||
| 126 | // read. `keptSide` ("combined", "local", "server") goes to the history. | ||
| 127 | bool resolveWith(const QString &uid, const QString &accountId, const QString &calendarPath, | ||
| 128 | const QString &resolved, const QString &serverEtag, const QString &keptSide); | ||
| 129 | // S107.3.6: fetches the server version again; a newer one replaces the | ||
| 130 | // shown one (or merges cleanly). Emits storeChanged. | ||
| 131 | void refreshConflict(const QString &uid, const QString &accountId, const QString &calendarPath); | ||
| 132 | // S107.3.7 (D-21): restores the version a resolution replaced as a new | ||
| 133 | // local edit (outbox put) and removes the history entry. | ||
| 134 | bool restoreFromHistory(qint64 historyId); | ||
| 135 | |||
| 136 | signals: | ||
| 137 | void storeChanged(); | ||
| 138 | void statusMessage(const QString &message); | ||
| 139 | |||
| 140 | private: | ||
| 141 | void flushOnce(bool ignoreBackoff); | ||
| 142 | void push(const CalendarOutboxEntry &entry, const Endpoint &endpoint); | ||
| 143 | void onPutFinished(const CalendarOutboxEntry &entry, const QByteArray &body, const DavResponse &response, | ||
| 144 | const Endpoint &endpoint); | ||
| 145 | void onDeleteFinished(const CalendarOutboxEntry &entry, const DavResponse &response, | ||
| 146 | const Endpoint &endpoint); | ||
| 147 | void fetchServerVersion(const CalendarOutboxEntry &entry, const QString &url, const QByteArray &ours, | ||
| 148 | const Endpoint &endpoint); | ||
| 149 | // F108-12: a 404 on writing a resource the server had — deleted, or a | ||
| 150 | // write the server refuses at that address (Nextcloud in a shared | ||
| 151 | // calendar)? A GET decides. | ||
| 152 | void confirmDeletedOnServer(const CalendarOutboxEntry &entry, const QString &url, const Endpoint &endpoint); | ||
| 153 | void onServerVersion(const CalendarOutboxEntry &entry, const QByteArray &ours, const DavResponse &response); | ||
| 154 | void mergeServerVersion(const CalendarOutboxEntry &entry, const QByteArray &ours, const QString &theirs, | ||
| 155 | const QString &etag); | ||
| 156 | QByteArray localBody(const CalendarOutboxEntry &entry) const; | ||
| 157 | // D-22: the source DELETE of a task move that met a changed source. | ||
| 158 | bool isMoveSource(const CalendarOutboxEntry &entry) const; | ||
| 159 | void mergeMovedSource(const CalendarOutboxEntry &entry, const QString &theirs, const QString &etag); | ||
| 160 | bool replaceRows(const CalendarOutboxEntry &entry, const QString &ical, const QString &etag); | ||
| 161 | void recordHistory(const CalendarOutboxEntry &entry, const QString &keptSide, const QString &losingText); | ||
| 162 | bool keepBothTask(const CalendarOutboxEntry &entry); | ||
| 163 | void retryLater(const CalendarOutboxEntry &entry, const QString &error, bool permanent = false); | ||
| 164 | void markConflict(const CalendarOutboxEntry &entry, const QString &serverIcal, const QString &serverEtag, | ||
| 165 | const QStringList &fields); | ||
| 166 | bool adoptServerVersion(const CalendarOutboxEntry &entry, const QString &ical, const QString &etag); | ||
| 167 | void finishInFlight(const CalendarOutboxEntry &entry); | ||
| 168 | void scheduleRetry(); | ||
| 169 | CalDavClient *clientFor(const Endpoint &endpoint); | ||
| 170 | static QString key(const QString &accountId, const QString &calendarPath, const QString &uid); | ||
| 171 | |||
| 172 | void publishChange(); | ||
| 173 | int m_batchDepth = 0; | ||
| 174 | bool m_flushing = false; // S106-159: no re-entrant flush | ||
| 175 | bool m_flushAgain = false; | ||
| 176 | bool m_flushAgainIgnoringBackoff = false; | ||
| 177 | bool m_batchChanged = false; | ||
| 178 | bool m_flushRequested = false; | ||
| 179 | Deps m_deps; | ||
| 180 | QSet<QString> m_inFlight; | ||
| 181 | QSet<QString> m_refreshAgain; // S107.3.6: a check asked for while one runs | ||
| 182 | QTimer m_retryTimer; | ||
| 183 | }; | ||
| 184 |