Class BlueMindImportWriter

java.lang.Object
org.exoplatform.caldav.client.bluemind.BlueMindImportWriter
All Implemented Interfaces:
CalendarObjectWriter

@Component public class BlueMindImportWriter extends Object implements CalendarObjectWriter
The BlueMind door: every write is an ICS import, every removal a REST delete with notifications off, and the version eXo records afterwards is the one the collection's own listing publishes (EXO-90307).

Which version, and why that one. The import answers a task, not an ETag, and the engine drives every later conditional write off the ETag it recorded. There is exactly one channel that value may come from: the Depth: 1 PROPFIND the verification pass lists the collection with (CalDavClient#listResourceEtags), because that pass compares the listing against the row and, once the content is confirmed equal, adopts the listing's value into the row (CaldavMirrorVerificationService#adoptVersion: "the version the server publishes means the one the collection listing publishes"). A precondition read from any other channel — a calendar-multiget REPORT, a GET, the PUT's own answer — would be compared against a row the pass has meanwhile rewritten in the listing's shape, and on BlueMind the shapes differ: the REPORT renders SyncTokens.getEtag(path, version), quoted base64 (CalendarMultigetExecutor.java:123), while the listing renders a child .ics through GetTag.fetch as the raw token bmdav_<lnum>_0 (GetTag.java:46-56, "unsupported ressource type", timestamp 0). Compared across channels, every update after the first pass would be refused for ever. So this door reads the precondition from the listing, records what the listing publishes after the write, and the row, the pass and the emulation speak one shape.

What that buys, and what it does not. On BlueMind the listed value of an object never moves, so the emulated If-Match refuses nothing a client changed — which is exactly what BlueMind's own CalDAV PUT does with a real If-Match: its DAV server reads only If-None-Match (PutProtocol.java:65, putQuery.setCreate(r.headers().contains("If-None-Match"))) and no If-Match reader exists anywhere under plugins/net.bluemind.dav.server. So the engine never relied on it there: the verification pass's content comparison and answer adoption are the guard on this server, through either door. What the listing does answer reliably is existence: an object absent from it is absent, which is what create-only needs. The check and the write are two calls, so a client writing between them is not refused; that is the race the repair path already accepts on every server.

Reading one object rather than the whole calendar. Read through the listing alone, every conditional write cost one Depth: 1 ETag-only PROPFIND of the whole collection before it and one after it; on BlueMind the destination is the main calendar, so every push, answer, exclusion or removal listed the user's entire calendar twice, and BlueMind logged one INFO line per child each time (GetTag.java:53-54). This door now asks two single-object questions instead, and asks the listing only when their answers are not conclusive:

  • Is the object there? — a one-href calendar-multiget REPORT asking getetag only (CalDavClient#multigetEtags). BlueMind answers one response per item its store returned (CalendarMultigetExecutor.java:82,114-130) and none for a uid it does not hold, so a response for the href is presence — taken only when its href is byte-equal to the one eXo sent: the server spells it as <REPORT path> + uid + ".ics" (:117), the construction the listing hashes from (DavStore.java:402), so equality means the Depth: 0 request path below hashes like the listing's child for this object, whatever the collection-wide verdict. Its version is the REPORT channel's, quoted base64 (:123), and is never recorded. No response is not taken as absence: a failed lookup answers the same empty document (:83-86), so absence is only ever the listing's word — which is why putObject(org.exoplatform.caldav.client.CalDavEndpoint, java.lang.String, java.lang.String), whose accepting answer is absence, reads the listing directly and asks nothing else. Both multiget shapes were observed on the rig on 2026-09-16 (bluemind-report-multiget-one-href-getetag.captured.xml: one response, "Ym1kYXZfMzk4MDk2NjI5Nl8x"; bluemind-report-multiget-missing-href.captured.xml: an empty multistatus).
  • Which version does the listing publish for it? — a Depth: 0 PROPFIND of getetag on the href (CalDavClient#readEtag). BlueMind resolves it through the same ds.from(path) (PropFindProtocol.java:69) and hashes dr.getPath() (GetTag.java:47,56, SyncTokens.java:42), so the token equals the listing's iff the request path equals containerPath + uid + ".ics" as addEvents builds it (DavStore.java:402) — a hypothesis about spelling, observed to hold on the rig on 2026-09-16 for one object of the main calendar (bluemind-propfind-object-depth0-getetag.captured.xml against bluemind-propfind-collection-depth1-getetag.captured.xml, the same bmdav_3980966296_0), and still verified live per collection below because one object on one server is an observation, not a proof. This read cannot answer existence either: the router assumes an .ics node exists whenever its path is well formed (MethodRouter.java:162-175, DavStore.java:474-502, the default "assume yes"), ResType.VSTUFF mints the node from the path with no lookup, and Depth: 0 adds only that node (DavStore.java:355-357); a missing object is answered 207 with a token — observed on the rig the same day on a probe href that did not exist (bluemind-propfind-missing-object-depth0.captured.xml, bmdav_3856992451_0). So it is asked only once presence is established.
Nothing here rests on the spelling hypothesis. The first time the Depth: 0 read answers on a collection, its token is compared with the Depth: 1 listing's for the same href, the way the pass compares (sameVersion(java.lang.String, java.lang.String)); the verdict is kept per collection for the life of the process (depthZeroAgreesWithListing) and stated once at INFO. Agreement makes later writes on that collection single-object reads only; disagreement keeps them on the listing, at today's cost. And a refusal is never pronounced on the single-object channel alone: when its version differs from the one the caller conditions on, the listing is read and its value compared instead (versionOf(org.exoplatform.caldav.client.CalDavEndpoint, java.lang.String, java.lang.String)); a listing that contradicts the token there revokes the verdict as well — so even a verdict that went stale can cost a listing, never a wrong 412 and never a recorded version of the wrong shape. Every fall-back is taken at DEBUG. Request count per conditional write on the settled path: two single-object requests before and two after, none listing the collection; a create still lists once, before the import.

Switching a server onto this door. A row written over CalDAV holds the PUT's ETag, another shape again. Its first conditional write through this door is refused once, the engine settles it as a conflict, and the next verification pass — finding the listing's value differs from the row — fetches, compares against what eXo renders now, repairs the edit in and adopts the listing's value. One refusal, self-healing, and only on the transition.

What is taken out of the document. LAST-MODIFIED, and only on this door. BlueMind's importer applies a series only when the file's LAST-MODIFIED is null, later than the stored one, or its EXDATEs changed (ICSImportTask.java:171-179; the file's value becomes updated at VEventServiceHelper.java:381-382); eXo renders it from the event's own modification date, which an answer does not move, so a copy rewritten to carry an answer would be silently dropped. Without the property the import always applies. BlueMind renders the stored object's LAST-MODIFIED from its own item timestamp (VEventServiceHelper.java:151) and IcsEquivalence ignores that property on every server, so the verification pass sees no drift.

A bounded quirk to know about. When an import changes the start or end of a series, BlueMind's merge empties the stored exceptions and EXDATEs before re-adding the file's (EventChangesMerge.java:111-113 — from source, not yet observed on a server); a second import of the same document restores them. The verification pass performs that second import when it compares the copy and finds it short of what eXo renders, and it compares only when the listing's value moved — which on BlueMind it does not — or in a settings round. Until the rig says otherwise, treat a date-changing edit of a series with exceptions as one to watch.

  • Constructor Details

    • BlueMindImportWriter

      @Autowired public BlueMindImportWriter(BlueMindCalendarImportClient importClient, CalDavClient calDavClient)
      The writer over the import API and the CalDAV client its reads use.
      Parameters:
      importClient - BlueMind's calendar REST API
      calDavClient - the CalDAV client, for the reads that learn whether an object is stored and under which listed version
  • Method Details

    • putObject

      public PutResult putObject(CalDavEndpoint endpoint, String href, String icsData)
      Creates one object; an object already at the href is answered 412.

      Create-only, emulated: an object the listing already carries at the href is answered 412 with its version, the way If-None-Match: * would. The listing is read directly here: the answer that lets a create proceed is absence, and absence is the listing's word alone.

      Specified by:
      putObject in interface CalendarObjectWriter
      Parameters:
      endpoint - the declared server
      href - the object's server-absolute path to create at
      icsData - the iCalendar text to store
      Returns:
      the status and, when known, the stored version
    • overwriteObject

      public PutResult overwriteObject(CalDavEndpoint endpoint, String href, String icsData)
      Writes one object over whatever is there, with no precondition at all.
      Specified by:
      overwriteObject in interface CalendarObjectWriter
      Parameters:
      endpoint - the declared server
      href - the object's server-absolute path
      icsData - the iCalendar text to store
      Returns:
      the status and, when known, the stored version; never a 412
    • updateObject

      public PutResult updateObject(CalDavEndpoint endpoint, String href, String icsData, String ifMatch)
      Replaces one existing object, only when its version is still the one the caller last read.

      Replace-if-unchanged, emulated: an object the listing does not carry, or carries under another version than ifMatch, is answered 412, the way If-Match would. Versions compare the way the verification pass compares them — quoting and a weak marker aside.

      Specified by:
      updateObject in interface CalendarObjectWriter
      Parameters:
      endpoint - the declared server
      href - the object's server-absolute path
      icsData - the iCalendar text to store
      ifMatch - the version the caller's own read answered, verbatim
      Returns:
      the status and, when known, the stored version; 412 when the object moved on or is gone
    • deleteObject

      public int deleteObject(CalDavEndpoint endpoint, String href, String ifMatch)
      Deletes one object, conditionally when a version is given.

      Conditional when a version is given, emulated the same way: gone is 404, another version is 412. The removal itself carries sendNotifications=false.

      Specified by:
      deleteObject in interface CalendarObjectWriter
      Parameters:
      endpoint - the declared server
      href - the object's server-absolute path
      ifMatch - the version to condition the removal on, or null to remove unconditionally
      Returns:
      200 or 204 removed, 404 or 410 already gone, 412 refused