app/core/ContactOutbox.h
| Line | Branch | Exec | Source |
|---|---|---|---|
| 1 | #pragma once | ||
| 2 | |||
| 3 | #include <QObject> | ||
| 4 | #include <QSet> | ||
| 5 | #include <QString> | ||
| 6 | #include <QStringList> | ||
| 7 | #include <QTimer> | ||
| 8 | #include <functional> | ||
| 9 | #include <optional> | ||
| 10 | |||
| 11 | #include "data/ContactModels.h" | ||
| 12 | #include "service/DavResponse.h" | ||
| 13 | |||
| 14 | class ContactStore; | ||
| 15 | class CardDavClient; | ||
| 16 | |||
| 17 | // Contact write-back through a persistent outbox (Sprint 97 S97.1.4, | ||
| 18 | // CONTACT-OUTBOX, decision D-6) with the CalendarOutbox contract: | ||
| 19 | // | ||
| 20 | // 1. Local first: the card changes in the store at once (patched by | ||
| 21 | // VCardCodec, never rebuilt) and gets one coalesced outbox entry ("put" | ||
| 22 | // or "delete") that remembers the server ETag the change is based on. | ||
| 23 | // 2. flush() pushes due entries: PUT of the stored raw card with If-Match | ||
| 24 | // (If-None-Match for new cards, whose href is a fresh UUID), DELETE with | ||
| 25 | // If-Match. | ||
| 26 | // 3. Success adopts the pushed card as the new merge base. A PUT answer | ||
| 27 | // without ETag (SabreDAV changed the card while storing it) is followed | ||
| 28 | // by a GET that adopts the server's version (CB-13). | ||
| 29 | // 4. Transport errors and 5xx keep the edit and retry with backoff | ||
| 30 | // (30 s … 30 min); nothing is rolled back (CB-04). | ||
| 31 | // 5. 412: the server version is fetched and merged on property level | ||
| 32 | // (VCardMerge). Non-overlapping changes are pushed automatically; an | ||
| 33 | // overlap marks the entry "conflict" with the server version kept for | ||
| 34 | // resolveConflict() ("mine" / "server" / "both"). | ||
| 35 | // 6. A move is a create in the target book and a delete in the source | ||
| 36 | // that waits for the create (dependsOn): a failed step retries and | ||
| 37 | // never leaves the card in both books for good (CB-11). | ||
| 38 | // | ||
| 39 | // The local book ("On this computer") has no server: its changes are only | ||
| 40 | // local. Read-only books and unreadable cards refuse writes. | ||
| 41 | class ContactOutbox : public QObject { | ||
| 42 | 96 | Q_OBJECT | |
| 43 | public: | ||
| 44 | struct Endpoint { | ||
| 45 | QString serverUrl; | ||
| 46 | QString username; | ||
| 47 | QByteArray password; | ||
| 48 | }; | ||
| 49 | using EndpointCallback = std::function<void(std::optional<Endpoint>)>; | ||
| 50 | struct Deps { | ||
| 51 | ContactStore *store = nullptr; | ||
| 52 | // Asynchronous (keyring); called at most once per account and flush. | ||
| 53 | std::function<void(QObject *owner, const QString &accountId, EndpointCallback callback)> endpoint; | ||
| 54 | std::function<CardDavClient *(const QString &url, const QString &user, const QString &password)> makeClient; | ||
| 55 | std::function<bool()> networkAllowed; | ||
| 56 | std::function<qint64()> now; // epoch seconds (tests inject a clock) | ||
| 57 | // Sprint 96: own push registration of a book ("" = none) for the | ||
| 58 | // Push-Dont-Notify header of PUT/DELETE. | ||
| 59 | std::function<QString(const QString &accountId, const QString &bookPath)> pushDontNotify; | ||
| 60 | }; | ||
| 61 | explicit ContactOutbox(Deps deps, QObject *parent = nullptr); | ||
| 62 | |||
| 63 | // Each returns the card key ("<bookId>/<uid>") or an empty string with | ||
| 64 | // *error set (user-facing, no technical detail). | ||
| 65 | QString saveCard(qint64 bookId, const QString &uid, const ContactCard &edited, QString *error = nullptr); | ||
| 66 | QString createCard(qint64 bookId, const ContactCard &card, QString *error = nullptr); | ||
| 67 | // S97.5.8: an imported vCard. A 3.0/4.0 card with a UID is stored as | ||
| 68 | // written (lossless); a 2.1 card or one without UID is created from its | ||
| 69 | // parsed values. Returns the new key, "" with *error. | ||
| 70 | QString importRaw(qint64 bookId, const QString &raw, QString *error = nullptr); | ||
| 71 | QString moveCard(qint64 fromBookId, const QString &uid, qint64 toBookId, QString *error = nullptr); | ||
| 72 | // Replaces the raw card (undo of an edit, conflict "keep mine"). | ||
| 73 | bool replaceRaw(qint64 bookId, const QString &uid, const QString &raw); | ||
| 74 | // Removes the card; *removed receives it for undo (restoreCard). | ||
| 75 | bool removeCard(qint64 bookId, const QString &uid, StoredCard *removed = nullptr); | ||
| 76 | bool restoreCard(const StoredCard &card); | ||
| 77 | // choice: "mine" (overwrite the server), "server" (take the server | ||
| 78 | // version), "both" (take the server version and keep mine as a copy). | ||
| 79 | bool resolveConflict(qint64 bookId, const QString &uid, const QString &choice); | ||
| 80 | |||
| 81 | // ─── Sprint 107: the conflict resolver (ContactOutboxResolve.cpp) ─── | ||
| 82 | struct ConflictInputs { | ||
| 83 | bool valid = false; | ||
| 84 | QString base; // the last server version (a moved card: its source's) | ||
| 85 | QString local; // the card on this computer ("" = deleted here) | ||
| 86 | QString server; // the server version ("" = deleted there) | ||
| 87 | QString serverEtag; // identity of the shown server version (D-9) | ||
| 88 | QString operation; // "put" or "delete" | ||
| 89 | QStringList fields; // ContactOutboxEntry::conflictFields | ||
| 90 | qint64 movedFrom = 0; // D-22: the book the card was moved from | ||
| 91 | }; | ||
| 92 | ConflictInputs conflictInputs(qint64 bookId, const QString &uid) const; | ||
| 93 | // S107.3.3: writes a resolution (the resolved card replaces the local one, | ||
| 94 | // the server version becomes the merge base, pushed against | ||
| 95 | // `serverEtag`). False when the conflict changed meanwhile or the card | ||
| 96 | // cannot be read losslessly. | ||
| 97 | bool resolveWith(qint64 bookId, const QString &uid, const QString &resolved, const QString &serverEtag, | ||
| 98 | const QString &keptSide); | ||
| 99 | // S107.3.6: fetches the server version again (newer → reload or merge). | ||
| 100 | void refreshConflict(qint64 bookId, const QString &uid); | ||
| 101 | // S107.3.7 (D-21): restores the version a resolution replaced. | ||
| 102 | bool restoreFromHistory(qint64 historyId); | ||
| 103 | void flush(bool ignoreBackoff = false); | ||
| 104 | 20 | void beginBatch() { ++m_batchDepth; } | |
| 105 | void endBatch(); | ||
| 106 | static qint64 backoffSeconds(int attempts); | ||
| 107 | |||
| 108 | signals: | ||
| 109 | // Card keys whose stored card or sync state changed (per-card updates). | ||
| 110 | void cardsChanged(const QStringList &keys); | ||
| 111 | void statusMessage(const QString &message); | ||
| 112 | |||
| 113 | private: | ||
| 114 | std::optional<AddressBookInfo> writableBook(qint64 bookId, QString *error) const; | ||
| 115 | void pushAccount(const QString &accountId, const QList<ContactOutboxEntry> &entries, const Endpoint &endpoint); | ||
| 116 | void push(const ContactOutboxEntry &entry, CardDavClient *client); | ||
| 117 | void onPutFinished(const ContactOutboxEntry &entry, const QString &body, const DavResponse &response, | ||
| 118 | CardDavClient *client); | ||
| 119 | void adoptPushed(const ContactOutboxEntry &entry, const QString &body, const QString &href, const QString &etag, | ||
| 120 | const QString &serverRaw); | ||
| 121 | void onDeleteFinished(const ContactOutboxEntry &entry, const DavResponse &response, CardDavClient *client); | ||
| 122 | void onServerVersion(const ContactOutboxEntry &entry, const DavResponse &response); | ||
| 123 | void retryLater(const ContactOutboxEntry &entry, const QString &error, bool permanent = false); | ||
| 124 | void markConflict(const ContactOutboxEntry &entry, const QString &serverRaw, const QString &serverEtag, | ||
| 125 | const QStringList &fields); | ||
| 126 | bool adoptServerVersion(const ContactOutboxEntry &entry, const QString &raw, const QString &etag); | ||
| 127 | // D-22: the source delete of a move met a changed source card. | ||
| 128 | void mergeMovedCard(const ContactOutboxEntry &entry, const QString &theirs, const QString &etag); | ||
| 129 | void recordHistory(qint64 bookId, const QString &uid, const QString &keptSide, const QString &losingText); | ||
| 130 | std::optional<ContactOutboxEntry> moveSourceOf(const ContactOutboxEntry &target) const; | ||
| 131 | void finishInFlight(const ContactOutboxEntry &entry); | ||
| 132 | void scheduleRetry(); | ||
| 133 | void changed(const QStringList &keys); | ||
| 134 | static QString key(qint64 bookId, const QString &uid); | ||
| 135 | |||
| 136 | int m_batchDepth = 0; | ||
| 137 | QStringList m_batchKeys; | ||
| 138 | Deps m_deps; | ||
| 139 | QSet<QString> m_inFlight; | ||
| 140 | QSet<QString> m_resolving; // accounts whose credentials are being read | ||
| 141 | QSet<QString> m_refreshAgain; // S107.3.6: a check asked for while one runs | ||
| 142 | QTimer m_retryTimer; | ||
| 143 | }; | ||
| 144 |