@sockethub/platform-caldav 1.0.0-beta.5 → 1.0.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -133,7 +133,10 @@ The collection contains calendar objects such as:
133
133
  ```
134
134
 
135
135
  Each returned item contains its resource `id`, `uid`, `etag`, and
136
- `updateSupported`. Keep the ETag for later update or delete requests. Use date
136
+ `updateSupported`, plus `created` and `lastModified` (UTC) when the resource
137
+ records them; `lastModified` falls back to `DTSTAMP`. These two are output
138
+ only and ignored on create and update. Keep the ETag for later update or
139
+ delete requests. Use date
137
140
  ranges for large calendars; responses larger than 10 MiB are rejected.
138
141
 
139
142
  ### Sync changes
@@ -185,6 +188,15 @@ When `truncated` is `true`, the server limited the result. The returned token
185
188
  covers exactly the changes delivered, so store it and sync again until
186
189
  `truncated` is `false`.
187
190
 
191
+ To track changes without downloading a calendar's history, start from the
192
+ current token instead of an initial sync. Send `"object": { "tokenOnly": true }`
193
+ to get the calendar's current `syncToken` with empty `items` and `removed`,
194
+ then `query` the date range you need, then `sync` with the token on later
195
+ passes. Read the token before the `query`, so nothing changed in between is
196
+ missed. Later syncs still report changes to any item in the calendar,
197
+ including old ones. A server that exposes no token returns
198
+ `caldav:sync-unsupported`.
199
+
188
200
  Calendar resources that hold neither an event nor a task, such as journals, are
189
201
  left out of `items`.
190
202
 
@@ -299,8 +311,12 @@ Update sends the complete event or task returned by `query`, including `id`,
299
311
  `uid`, and `etag`. Increment `sequence` when changing scheduling information.
300
312
  An update is rejected when `updateSupported` is false, which protects recurrence
301
313
  exceptions, exclusion dates, and multi-component resources from being lost.
302
- Properties outside Sockethub's item model, such as `CATEGORIES` and `X-`
303
- properties, are not preserved when an otherwise supported item is updated.
314
+ Update changes only the stored properties whose fields differ from the stored
315
+ item, plus `DTSTAMP` and `LAST-MODIFIED`. Everything else stays as it was,
316
+ including `CATEGORIES`, `X-` properties, `CREATED`, `VTIMEZONE` blocks, and
317
+ parameters on unchanged attendees and alarms. A field left out of the update
318
+ removes its property. A changed field is written from the item model, so
319
+ parameters the model does not carry are dropped from that property.
304
320
 
305
321
  ```json
306
322
  {
package/dist/dav.d.ts CHANGED
@@ -15,7 +15,12 @@ export declare class CalDavClient extends DavClient {
15
15
  * task in the calendar; with one, lists only what changed since then.
16
16
  */
17
17
  sync(calendar: CalendarDescription, syncToken?: string): Promise<CalendarSyncResult>;
18
- update(id: string, etag: string, body: string): Promise<{
18
+ /**
19
+ * Replace a resource if its ETag still matches. `body` is either the new
20
+ * resource or a function that builds it from the stored one, so changes
21
+ * can be applied without losing what the item model does not carry.
22
+ */
23
+ update(id: string, etag: string, body: string | ((stored: string) => string)): Promise<{
19
24
  id: string;
20
25
  etag?: string;
21
26
  }>;
package/dist/ical.d.ts CHANGED
@@ -1,7 +1,14 @@
1
1
  import type { CalendarItem, CalendarObjectInput } from "./types.js";
2
2
  /** Fold one iCalendar content line at 75 UTF-8 octets (74 after continuation). */
3
3
  export declare function foldLine(line: string): string;
4
- export declare function buildICalendar(input: CalendarObjectInput, now?: Date): {
4
+ /**
5
+ * Serialize an item as a single-component iCalendar resource. `created`
6
+ * stamps `CREATED` with `now`; pass it only for a new resource, so an update
7
+ * does not move the creation time.
8
+ */
9
+ export declare function buildICalendar(input: CalendarObjectInput, now?: Date, { created }?: {
10
+ created?: boolean;
11
+ }): {
5
12
  uid: string;
6
13
  body: string;
7
14
  };
package/dist/index.d.ts CHANGED
@@ -39,6 +39,11 @@ export default class CalDav implements PlatformInterface {
39
39
  * the result being complete. `caldav:sync-token-invalid` means start
40
40
  * over without a token; `caldav:sync-unsupported` means fall back to
41
41
  * `query`.
42
+ *
43
+ * With `object.tokenOnly: true`, returns the calendar's current token
44
+ * and no items, so a client can start tracking changes without
45
+ * downloading history: get the token first, then `query` the range it
46
+ * needs, then `sync` with the token on later passes.
42
47
  */
43
48
  sync(job: ActivityStream, credentials: CalDavCredentials, done: PlatformCallback): void;
44
49
  update(job: ActivityStream, credentials: CalDavCredentials, done: PlatformCallback): void;
package/dist/index.js CHANGED
@@ -66381,6 +66381,35 @@ class DavClient {
66381
66381
  }
66382
66382
  return this.readMultistatus(response);
66383
66383
  }
66384
+ async currentSyncToken(collection) {
66385
+ const response = await this.request(collection, {
66386
+ method: "PROPFIND",
66387
+ headers: {
66388
+ depth: "0",
66389
+ "content-type": "application/xml; charset=utf-8"
66390
+ },
66391
+ body: `<?xml version="1.0" encoding="utf-8"?>
66392
+ <d:propfind xmlns:d="DAV:"><d:prop><d:sync-token/></d:prop></d:propfind>`
66393
+ });
66394
+ if (response.status === 404)
66395
+ return rejectDavResponse(response, `${this.errorPrefix}:not-found`);
66396
+ if (response.status !== 207) {
66397
+ await response.body?.cancel().catch(() => {});
66398
+ throw new DavFailure(`${this.errorPrefix}:invalid-response`);
66399
+ }
66400
+ let responses;
66401
+ try {
66402
+ responses = asArray(parseDavXml(await response.text(), {
66403
+ parseTagValue: false
66404
+ }).multistatus?.response);
66405
+ } catch {
66406
+ throw new DavFailure(`${this.errorPrefix}:invalid-response`);
66407
+ }
66408
+ const token = successfulProps(responses[0] ?? {})["sync-token"];
66409
+ if (typeof token !== "string" || token.trim() === "")
66410
+ throw new DavFailure(`${this.errorPrefix}:sync-unsupported`);
66411
+ return token.trim();
66412
+ }
66384
66413
  async syncCollection(collection, prop, syncToken, namespaces = "") {
66385
66414
  const response = await this.request(collection, {
66386
66415
  method: "REPORT",
@@ -66819,7 +66848,7 @@ var ActivityStreamSchema = {
66819
66848
  var package_default = {
66820
66849
  name: "@sockethub/schemas",
66821
66850
  description: "JSON schema files for validating Sockethub Activity Streams",
66822
- version: "3.0.0-beta.5",
66851
+ version: "3.0.0-beta.6",
66823
66852
  type: "module",
66824
66853
  private: false,
66825
66854
  author: "Nick Jennings <nick@silverbucket.net>",
@@ -66889,7 +66918,7 @@ var package_default = {
66889
66918
  "ajv-formats": "^2.1.1",
66890
66919
  "ajv-formats-draft2019": "^1.6.1"
66891
66920
  },
66892
- gitHead: "cc4ffdd9714ebbd8372a88c4f2d795974e04029e",
66921
+ gitHead: "604f3da8850a19a4fd5f5e5dcec10bf283e9085e",
66893
66922
  types: "./dist/index.d.ts"
66894
66923
  };
66895
66924
 
@@ -68036,7 +68065,7 @@ function timeZoneRange(input, fallbackYear) {
68036
68065
  const last = years.length ? Math.max(...years) : fallbackYear;
68037
68066
  return [first - 1, Math.max(last + 1, first + 30)];
68038
68067
  }
68039
- function buildICalendar(input, now = new Date) {
68068
+ function buildICalendar(input, now = new Date, { created = false } = {}) {
68040
68069
  const uid = input.uid ?? `${randomUUID()}@sockethub`;
68041
68070
  if (/[\r\n%/\\]/.test(uid))
68042
68071
  throw new Error("invalid uid");
@@ -68055,6 +68084,8 @@ function buildICalendar(input, now = new Date) {
68055
68084
  `BEGIN:${component}`,
68056
68085
  `UID:${escapeText(uid)}`,
68057
68086
  `DTSTAMP:${utcDateTime(now.toISOString(), "now")}`,
68087
+ ...created ? [`CREATED:${utcDateTime(now.toISOString(), "now")}`] : [],
68088
+ `LAST-MODIFIED:${utcDateTime(now.toISOString(), "now")}`,
68058
68089
  `SUMMARY:${escapeText(input.name)}`
68059
68090
  ];
68060
68091
  if (input.content)
@@ -68101,6 +68132,13 @@ function parseDate(value) {
68101
68132
  return `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}T${value.slice(9, 11)}:${value.slice(11, 13)}:${value.slice(13, 15)}`;
68102
68133
  return value;
68103
68134
  }
68135
+ function utcTimestamp(value) {
68136
+ if (!value || !/^\d{8}T\d{6}Z$/.test(value))
68137
+ return;
68138
+ const iso = parseDate(value);
68139
+ const parsed = new Date(iso);
68140
+ return !Number.isNaN(parsed.getTime()) && parsed.toISOString().replace(".000Z", "Z") === iso ? iso : undefined;
68141
+ }
68104
68142
  var STATUSES = {
68105
68143
  event: ["tentative", "confirmed", "cancelled"],
68106
68144
  task: ["needs-action", "in-process", "completed", "cancelled"]
@@ -68176,6 +68214,13 @@ function parseICalendar(body, id, etag) {
68176
68214
  name: unescapeText(summary),
68177
68215
  updateSupported: isUpdateSupported(body)
68178
68216
  };
68217
+ const created = utcTimestamp(first("CREATED")?.value);
68218
+ const hasMethod = lines.slice(0, begin).some((line) => /^METHOD[;:]/i.test(line));
68219
+ const lastModified = utcTimestamp(first("LAST-MODIFIED")?.value) ?? (hasMethod ? undefined : utcTimestamp(first("DTSTAMP")?.value));
68220
+ if (created)
68221
+ item.created = created;
68222
+ if (lastModified)
68223
+ item.lastModified = lastModified;
68179
68224
  if (first("DESCRIPTION"))
68180
68225
  item.content = unescapeText(first("DESCRIPTION")?.value ?? "");
68181
68226
  if (first("LOCATION"))
@@ -68460,7 +68505,8 @@ class CalDavClient extends DavClient {
68460
68505
  return rejectDavResponse(current, "caldav:not-found");
68461
68506
  if (!current.ok)
68462
68507
  return rejectDavResponse(current, "caldav:update-failed");
68463
- if (!isUpdateSupported(await current.text()))
68508
+ const stored = await current.text();
68509
+ if (!isUpdateSupported(stored))
68464
68510
  throw new CalDavFailure("caldav:unsupported-update");
68465
68511
  const response = await this.request(resource, {
68466
68512
  method: "PUT",
@@ -68468,7 +68514,7 @@ class CalDavClient extends DavClient {
68468
68514
  "content-type": "text/calendar; charset=utf-8",
68469
68515
  "if-match": etag
68470
68516
  },
68471
- body
68517
+ body: typeof body === "function" ? body(stored) : body
68472
68518
  });
68473
68519
  if (response.status === 412)
68474
68520
  return rejectDavResponse(response, "caldav:conflict");
@@ -68678,11 +68724,126 @@ END:VCALENDAR\r
68678
68724
  skipped
68679
68725
  };
68680
68726
  }
68727
+
68728
+ // src/patch.ts
68729
+ var GROUPS = [
68730
+ { fields: ["name"], names: ["SUMMARY"] },
68731
+ { fields: ["content"], names: ["DESCRIPTION"] },
68732
+ { fields: ["location"], names: ["LOCATION"] },
68733
+ { fields: ["url"], names: ["URL"] },
68734
+ {
68735
+ fields: ["startTime", "endTime", "due", "allDay", "timeZone"],
68736
+ names: ["DTSTART", "DTEND", "DUE"]
68737
+ },
68738
+ { fields: ["status"], names: ["STATUS"] },
68739
+ { fields: ["completedTime"], names: ["COMPLETED"] },
68740
+ { fields: ["percentComplete"], names: ["PERCENT-COMPLETE"] },
68741
+ { fields: ["sequence"], names: ["SEQUENCE"] },
68742
+ { fields: ["recurrence"], names: ["RRULE"] },
68743
+ { fields: ["organizer"], names: ["ORGANIZER"] },
68744
+ { fields: ["attendees"], names: ["ATTENDEE"] },
68745
+ { fields: ["attachments"], names: ["ATTACH"] },
68746
+ { fields: ["reminders"], names: ["VALARM"] },
68747
+ { fields: [], names: ["DTSTAMP", "LAST-MODIFIED"] }
68748
+ ];
68749
+ function logicalLines(body) {
68750
+ const lines = [];
68751
+ for (const line of body.split(/\r?\n/)) {
68752
+ if (/^[ \t]/.test(line) && lines.length)
68753
+ lines[lines.length - 1].push(line);
68754
+ else
68755
+ lines.push([line]);
68756
+ }
68757
+ while (lines.length && lines[lines.length - 1].join("") === "")
68758
+ lines.pop();
68759
+ return lines;
68760
+ }
68761
+ var unfolded = (raw) => raw.map((line, index) => index ? line.slice(1) : line).join("");
68762
+ var propertyName = (raw) => (unfolded(raw).match(/^[^;:]*/)?.[0] ?? "").toUpperCase();
68763
+ function componentBounds(lines) {
68764
+ const begin = lines.findIndex((raw) => /^BEGIN:(?:VEVENT|VTODO)$/.test(unfolded(raw)));
68765
+ if (begin === -1)
68766
+ return;
68767
+ const name = unfolded(lines[begin]).slice("BEGIN:".length);
68768
+ const end = lines.findIndex((raw, index) => index > begin && unfolded(raw) === `END:${name}`);
68769
+ return end === -1 ? undefined : { begin, end, name };
68770
+ }
68771
+ function units(lines) {
68772
+ const result = [];
68773
+ let alarm;
68774
+ for (const raw of lines) {
68775
+ const text = unfolded(raw);
68776
+ if (alarm) {
68777
+ alarm.push(...raw);
68778
+ if (text === "END:VALARM") {
68779
+ result.push({ name: "VALARM", raw: alarm });
68780
+ alarm = undefined;
68781
+ }
68782
+ } else if (text === "BEGIN:VALARM")
68783
+ alarm = [...raw];
68784
+ else
68785
+ result.push({ name: propertyName(raw), raw });
68786
+ }
68787
+ if (alarm)
68788
+ result.push({ name: "", raw: alarm });
68789
+ return result;
68790
+ }
68791
+ function canonical(value) {
68792
+ return JSON.stringify(value, (_key, entry) => entry && typeof entry === "object" && !Array.isArray(entry) ? Object.fromEntries(Object.keys(entry).sort().map((key) => [key, entry[key]])) : entry);
68793
+ }
68794
+ function sameField(current, next, field) {
68795
+ if (field === "allDay")
68796
+ return current.allDay === true === (next.allDay === true);
68797
+ return canonical(current[field]) === canonical(next[field]);
68798
+ }
68799
+ function patchICalendar(stored, item, generated) {
68800
+ const current = parseICalendar(stored, "");
68801
+ if (current.type !== item.type)
68802
+ return { uid: item.uid ?? current.uid, body: generated };
68803
+ const lines = logicalLines(stored);
68804
+ const bounds = componentBounds(lines);
68805
+ const fresh = logicalLines(generated);
68806
+ const freshBounds = componentBounds(fresh);
68807
+ if (!bounds || !freshBounds)
68808
+ throw new Error("invalid calendar data");
68809
+ const freshUnits = units(fresh.slice(freshBounds.begin + 1, freshBounds.end));
68810
+ const replaced = new Set;
68811
+ for (const group of GROUPS)
68812
+ if (!group.fields.length || !group.fields.every((field) => sameField(current, item, field)))
68813
+ for (const name of group.names)
68814
+ replaced.add(name);
68815
+ const freshNames = new Set(freshUnits.map((unit) => unit.name));
68816
+ if (replaced.has("DTSTART") && (freshNames.has("DTEND") || freshNames.has("DUE")))
68817
+ replaced.add("DURATION");
68818
+ const kept = [];
68819
+ let insertAt;
68820
+ for (const unit of units(lines.slice(bounds.begin + 1, bounds.end))) {
68821
+ if (replaced.has(unit.name))
68822
+ insertAt ??= kept.length;
68823
+ else
68824
+ kept.push(unit);
68825
+ }
68826
+ const added = freshUnits.filter((unit) => replaced.has(unit.name));
68827
+ kept.splice(insertAt ?? kept.length, 0, ...added);
68828
+ const zone = item.timeZone;
68829
+ const freshZone = fresh.findIndex((raw) => unfolded(raw) === "BEGIN:VTIMEZONE");
68830
+ const zones = zone && replaced.has("DTSTART") && freshZone !== -1 && freshZone < freshBounds.begin && !lines.slice(0, bounds.begin).concat(lines.slice(bounds.end + 1)).some((raw) => unfolded(raw) === `TZID:${zone}`) ? fresh.slice(freshZone, freshBounds.begin) : [];
68831
+ const output = [
68832
+ ...lines.slice(0, bounds.begin),
68833
+ ...zones,
68834
+ lines[bounds.begin],
68835
+ ...kept.map((unit) => unit.raw),
68836
+ ...lines.slice(bounds.end)
68837
+ ].flat();
68838
+ return { uid: current.uid, body: `${output.join(`\r
68839
+ `)}\r
68840
+ ` };
68841
+ }
68681
68842
  // package.json
68682
68843
  var package_default2 = {
68683
68844
  name: "@sockethub/platform-caldav",
68684
68845
  description: "A Sockethub platform for synchronizing CalDAV calendars, events, and tasks",
68685
- version: "1.0.0-beta.5",
68846
+ version: "1.0.0-beta.6",
68686
68847
  type: "module",
68687
68848
  private: false,
68688
68849
  license: "LGPL-3.0+",
@@ -68716,16 +68877,16 @@ var package_default2 = {
68716
68877
  test: "bun test src/"
68717
68878
  },
68718
68879
  dependencies: {
68719
- "@sockethub/dav": "^1.0.0-beta.5",
68720
- "@sockethub/schemas": "^3.0.0-beta.5",
68721
- "@sockethub/util": "^1.0.0-beta.5",
68880
+ "@sockethub/dav": "^1.0.0-beta.6",
68881
+ "@sockethub/schemas": "^3.0.0-beta.6",
68882
+ "@sockethub/util": "^1.0.0-beta.6",
68722
68883
  undici: "^7.30.0"
68723
68884
  },
68724
68885
  devDependencies: {
68725
68886
  "@types/bun": "latest"
68726
68887
  },
68727
68888
  peerDependencies: {
68728
- "@sockethub/server": "^5.0.0-beta.5"
68889
+ "@sockethub/server": "^5.0.0-beta.6"
68729
68890
  },
68730
68891
  peerDependenciesMeta: {
68731
68892
  "@sockethub/server": {
@@ -68733,7 +68894,7 @@ var package_default2 = {
68733
68894
  }
68734
68895
  },
68735
68896
  types: "./dist/index.d.ts",
68736
- gitHead: "cc4ffdd9714ebbd8372a88c4f2d795974e04029e"
68897
+ gitHead: "604f3da8850a19a4fd5f5e5dcec10bf283e9085e"
68737
68898
  };
68738
68899
 
68739
68900
  // src/schema.ts
@@ -68863,7 +69024,9 @@ var shared = {
68863
69024
  attendees: { type: "array", maxItems: 1000, items: person },
68864
69025
  reminders: { type: "array", maxItems: 100, items: reminder },
68865
69026
  attachments: { type: "array", maxItems: 100, items: attachment },
68866
- updateSupported: { type: "boolean" }
69027
+ updateSupported: { type: "boolean" },
69028
+ created: { type: "string", format: "date-time" },
69029
+ lastModified: { type: "string", format: "date-time" }
68867
69030
  };
68868
69031
  var eventObject = {
68869
69032
  type: "object",
@@ -68934,7 +69097,8 @@ var syncToken = { type: "string", minLength: 1, maxLength: 4096 };
68934
69097
  var syncObject = {
68935
69098
  type: "object",
68936
69099
  additionalProperties: false,
68937
- properties: { syncToken }
69100
+ properties: { syncToken, tokenOnly: { const: true } },
69101
+ not: { required: ["syncToken", "tokenOnly"] }
68938
69102
  };
68939
69103
  var calendarItem2 = { oneOf: [eventObject, taskObject] };
68940
69104
  var mutationObject = {
@@ -68977,7 +69141,8 @@ var PlatformCalDavSchema = {
68977
69141
  taskObject,
68978
69142
  deleteObject,
68979
69143
  queryObject,
68980
- { ...syncObject, required: ["syncToken"] }
69144
+ { ...syncObject, required: ["syncToken"] },
69145
+ { ...syncObject, required: ["tokenOnly"] }
68981
69146
  ]
68982
69147
  }
68983
69148
  }
@@ -69286,7 +69451,9 @@ class CalDav {
69286
69451
  if (!target.components.includes(input.type)) {
69287
69452
  throw new CalDavFailure("caldav:unsupported-component");
69288
69453
  }
69289
- const calendarData = buildICalendar(input);
69454
+ const calendarData = buildICalendar(input, new Date, {
69455
+ created: true
69456
+ });
69290
69457
  const created = await client.create(target, calendarData.uid, calendarData.body);
69291
69458
  done(null, {
69292
69459
  "@context": CONTEXT,
@@ -69317,7 +69484,12 @@ class CalDav {
69317
69484
  sync(job, credentials, done) {
69318
69485
  const client = this.client(credentials);
69319
69486
  const input = job.object ?? {};
69320
- this.calendar(client, credentials, job.target?.id ?? "").then((calendar) => client.sync(calendar, input.syncToken)).then((result) => done(null, {
69487
+ this.calendar(client, credentials, job.target?.id ?? "").then((calendar) => input.tokenOnly ? client.currentSyncToken(new URL(calendar.id)).then((syncToken) => ({
69488
+ items: [],
69489
+ removed: [],
69490
+ syncToken,
69491
+ truncated: false
69492
+ })) : client.sync(calendar, input.syncToken)).then((result) => done(null, {
69321
69493
  "@context": CONTEXT,
69322
69494
  ...job.id ? { id: job.id } : {},
69323
69495
  type: "collection",
@@ -69332,7 +69504,12 @@ class CalDav {
69332
69504
  this.calendar(client, credentials, job.target?.id ?? "").then((calendar) => {
69333
69505
  this.assertResource(calendar.id, input.id ?? "");
69334
69506
  const generated = buildICalendar(input);
69335
- return client.update(input.id ?? "", input.etag ?? "", generated.body).then((updated) => ({ updated, uid: generated.uid }));
69507
+ let uid = generated.uid;
69508
+ return client.update(input.id ?? "", input.etag ?? "", (stored) => {
69509
+ const patched = patchICalendar(stored, input, generated.body);
69510
+ uid = patched.uid;
69511
+ return patched.body;
69512
+ }).then((updated) => ({ updated, uid }));
69336
69513
  }).then(({ updated, uid }) => done(null, this.mutationResponse(job, "update", updated.id, input.type, uid, updated.etag))).catch((error) => this.fail(job, error, done, credentials)).finally(() => client.close().catch(() => {}));
69337
69514
  }
69338
69515
  delete(job, credentials, done) {
@@ -69461,4 +69638,4 @@ export {
69461
69638
  foldLine
69462
69639
  };
69463
69640
 
69464
- //# debugId=16184EE35BE0342464756E2164756E21
69641
+ //# debugId=0BA1D0264BEB87B464756E2164756E21