@remit/calendar-service 0.0.21 → 1.0.1

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/dist/task.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ import type { CalendarCollectionItem, CalendarTaskItem, ICalendarTaskRepository, PutCalendarTaskInput } from "@remit/data-ports";
2
+ import ICAL from "ical.js";
3
+ import { type CalendarResult } from "./errors.js";
4
+ /** A task as a person describes it, before it is iCalendar. */
5
+ export interface CalendarTaskFields {
6
+ summary: string;
7
+ description: string;
8
+ /** ISO 8601 with a zone offset, or `YYYY-MM-DD` when `allDay`. */
9
+ due: string;
10
+ allDay: boolean;
11
+ /** IANA zone the due time is anchored in. `""` anchors it in UTC. */
12
+ timeZone: string;
13
+ completed: boolean;
14
+ }
15
+ interface ParsedTask {
16
+ component: ICAL.Component;
17
+ todo: ICAL.Component;
18
+ uid: string;
19
+ }
20
+ /** Reads a VCALENDAR carrying exactly one VTODO with a UID and a DUE. */
21
+ export declare const parseTaskCalendar: (icalData: string) => Promise<CalendarResult<ParsedTask>>;
22
+ /** Builds the VCALENDAR text for a new task. */
23
+ export declare const buildTaskCalendar: (fields: CalendarTaskFields, uid: string, now: Date) => CalendarResult<string>;
24
+ /** Rewrites a stored task with a patch. */
25
+ export declare const updateTaskCalendar: (icalData: string, patch: Partial<CalendarTaskFields>, collectionTimezone: string, now: Date) => Promise<CalendarResult<string>>;
26
+ /** Turns one task resource into the row that stores it. */
27
+ export declare const prepareCalendarTask: (collection: Pick<CalendarCollectionItem, "calendarId" | "timezone">, resourceName: string, icalData: string) => Promise<CalendarResult<PutCalendarTaskInput>>;
28
+ /** Every task due in a window, across the collections given, in due order. */
29
+ export declare const listTasksInWindow: (repository: Pick<ICalendarTaskRepository, "listByDueRange">, collections: readonly CalendarCollectionItem[], window: {
30
+ from: string;
31
+ to: string;
32
+ }) => Promise<CalendarTaskItem[]>;
33
+ export {};
package/dist/task.js ADDED
@@ -0,0 +1,153 @@
1
+ import ICAL from "ical.js";
2
+ import { CALENDAR_PRODID, readEventTime } from "./build.js";
3
+ import { calendarFailure } from "./errors.js";
4
+ import { computeEtag } from "./etag.js";
5
+ import { readCalendarComponent, serializeCalendar } from "./parse.js";
6
+ import { readString } from "./project.js";
7
+ import { resolveTime, tzidOf } from "./time.js";
8
+ /** Reads a VCALENDAR carrying exactly one VTODO with a UID and a DUE. */
9
+ export const parseTaskCalendar = async (icalData) => {
10
+ const read = await readCalendarComponent(icalData);
11
+ if (!read.ok)
12
+ return read;
13
+ const component = read.value;
14
+ if (component.name !== "vcalendar") {
15
+ return calendarFailure("NotACalendar", "expected a VCALENDAR");
16
+ }
17
+ const todos = component.getAllSubcomponents("vtodo");
18
+ if (todos.length !== 1 || component.getAllSubcomponents("vevent").length) {
19
+ return calendarFailure("UnsupportedComponent", "a task resource holds exactly one VTODO and no VEVENT");
20
+ }
21
+ const todo = todos[0];
22
+ const uid = todo.getFirstPropertyValue("uid");
23
+ if (typeof uid !== "string" || uid === "") {
24
+ return calendarFailure("MissingUid", "the VTODO declares no UID");
25
+ }
26
+ if (!(todo.getFirstPropertyValue("due") instanceof ICAL.Time)) {
27
+ return calendarFailure("MissingDtStart", "the VTODO declares no DUE");
28
+ }
29
+ return { ok: true, value: { component, todo, uid } };
30
+ };
31
+ const setText = (todo, name, value) => {
32
+ todo.removeAllProperties(name);
33
+ if (value !== "")
34
+ todo.addPropertyWithValue(name, value);
35
+ };
36
+ const currentFields = (todo, collectionTimezone) => {
37
+ const due = todo.getFirstPropertyValue("due");
38
+ const tzid = tzidOf(todo.getFirstProperty("due"));
39
+ const resolved = due instanceof ICAL.Time
40
+ ? resolveTime(due, tzid, collectionTimezone)
41
+ : null;
42
+ return {
43
+ summary: readString(todo, "summary"),
44
+ description: readString(todo, "description"),
45
+ due: resolved
46
+ ? resolved.isDate
47
+ ? resolved.isoOffset.slice(0, 10)
48
+ : resolved.isoOffset
49
+ : "",
50
+ allDay: resolved?.isDate ?? false,
51
+ timeZone: tzid,
52
+ completed: readString(todo, "status").toUpperCase() === "COMPLETED",
53
+ };
54
+ };
55
+ /**
56
+ * Writes a patch onto a VTODO. The due fields are one fact between them, so
57
+ * touching any rewrites DUE from the values the task would then have.
58
+ */
59
+ const applyTaskFields = (todo, patch, collectionTimezone, now) => {
60
+ if (patch.summary !== undefined)
61
+ setText(todo, "summary", patch.summary);
62
+ if (patch.description !== undefined) {
63
+ setText(todo, "description", patch.description);
64
+ }
65
+ if (patch.completed !== undefined) {
66
+ setText(todo, "status", patch.completed ? "COMPLETED" : "NEEDS-ACTION");
67
+ todo.removeAllProperties("completed");
68
+ if (patch.completed) {
69
+ todo.addPropertyWithValue("completed", ICAL.Time.fromJSDate(now, true));
70
+ }
71
+ }
72
+ const touchesDue = patch.due !== undefined ||
73
+ patch.allDay !== undefined ||
74
+ patch.timeZone !== undefined;
75
+ if (!touchesDue)
76
+ return { ok: true, value: null };
77
+ const current = todo.hasProperty("due")
78
+ ? currentFields(todo, collectionTimezone)
79
+ : { due: "", allDay: false, timeZone: "" };
80
+ const allDay = patch.allDay ?? current.allDay;
81
+ const timeZone = patch.timeZone ?? current.timeZone;
82
+ const due = readEventTime(patch.due ?? current.due, allDay, timeZone);
83
+ if (!due.ok)
84
+ return due;
85
+ todo.removeAllProperties("due");
86
+ const property = new ICAL.Property("due");
87
+ todo.addProperty(property);
88
+ property.setValue(due.value);
89
+ if (!due.value.isDate && timeZone !== "") {
90
+ property.setParameter("tzid", timeZone);
91
+ }
92
+ return { ok: true, value: null };
93
+ };
94
+ /** Builds the VCALENDAR text for a new task. */
95
+ export const buildTaskCalendar = (fields, uid, now) => {
96
+ const calendar = new ICAL.Component("vcalendar");
97
+ calendar.addPropertyWithValue("version", "2.0");
98
+ calendar.addPropertyWithValue("prodid", CALENDAR_PRODID);
99
+ const todo = new ICAL.Component("vtodo");
100
+ calendar.addSubcomponent(todo);
101
+ todo.addPropertyWithValue("uid", uid);
102
+ todo.addPropertyWithValue("dtstamp", ICAL.Time.fromJSDate(now, true));
103
+ const applied = applyTaskFields(todo, fields, fields.timeZone, now);
104
+ if (!applied.ok)
105
+ return applied;
106
+ return { ok: true, value: serializeCalendar(calendar) };
107
+ };
108
+ /** Rewrites a stored task with a patch. */
109
+ export const updateTaskCalendar = async (icalData, patch, collectionTimezone, now) => {
110
+ const parsed = await parseTaskCalendar(icalData);
111
+ if (!parsed.ok)
112
+ return parsed;
113
+ const { todo, component } = parsed.value;
114
+ const applied = applyTaskFields(todo, patch, collectionTimezone, now);
115
+ if (!applied.ok)
116
+ return applied;
117
+ todo.removeAllProperties("dtstamp");
118
+ todo.addPropertyWithValue("dtstamp", ICAL.Time.fromJSDate(now, true));
119
+ return { ok: true, value: serializeCalendar(component) };
120
+ };
121
+ /** Turns one task resource into the row that stores it. */
122
+ export const prepareCalendarTask = async (collection, resourceName, icalData) => {
123
+ const parsed = await parseTaskCalendar(icalData);
124
+ if (!parsed.ok)
125
+ return parsed;
126
+ const { todo, uid } = parsed.value;
127
+ const fields = currentFields(todo, collection.timezone);
128
+ const due = resolveTime(todo.getFirstPropertyValue("due"), tzidOf(todo.getFirstProperty("due")), collection.timezone);
129
+ return {
130
+ ok: true,
131
+ value: {
132
+ calendarId: collection.calendarId,
133
+ resourceName,
134
+ icalUid: uid,
135
+ icalData,
136
+ etag: computeEtag(icalData),
137
+ summary: fields.summary,
138
+ description: fields.description,
139
+ due: due.isoOffset,
140
+ dueAt: due.isoUtc,
141
+ allDay: due.isDate,
142
+ completed: fields.completed,
143
+ },
144
+ };
145
+ };
146
+ /** Every task due in a window, across the collections given, in due order. */
147
+ export const listTasksInWindow = async (repository, collections, window) => {
148
+ const tasks = [];
149
+ for (const collection of collections) {
150
+ tasks.push(...(await repository.listByDueRange(collection.calendarId, window.from, window.to)));
151
+ }
152
+ return tasks.sort((left, right) => left.dueAt.localeCompare(right.dueAt));
153
+ };
package/dist/window.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { CalendarParticipation as CalendarParticipationValue } from "@remit/api-openapi-types";
1
2
  import type { CalendarCollectionItem, CalendarObjectItem, ICalendarEventIndexRepository, ICalendarObjectRepository } from "@remit/data-ports";
2
3
  /**
3
4
  * How far before the window occurrences are read, so one that started earlier
@@ -30,6 +31,8 @@ export interface CalendarInstance {
30
31
  zoneCertainty: CalendarObjectItem["zoneCertainty"];
31
32
  etag: string;
32
33
  hasRecurrence: boolean;
34
+ /** Where the reader stands on this occurrence, read from the bytes it came from. */
35
+ participation: CalendarParticipationValue;
33
36
  }
34
37
  export interface CalendarWindowRepositories {
35
38
  calendarObject: Pick<ICalendarObjectRepository, "find" | "listIncompleteExpansions">;
@@ -49,7 +52,7 @@ export interface CalendarWindow {
49
52
  * written to serve the read. A series is served from one source or the other,
50
53
  * never merged from both, so an occurrence cannot appear twice.
51
54
  */
52
- export declare const listCalendarInstances: (repositories: CalendarWindowRepositories, collections: CalendarCollectionItem[], window: CalendarWindow) => Promise<CalendarInstance[]>;
55
+ export declare const listCalendarInstances: (repositories: CalendarWindowRepositories, collections: CalendarCollectionItem[], window: CalendarWindow, self: ReadonlySet<string>) => Promise<CalendarInstance[]>;
53
56
  /**
54
57
  * The occurrences of one resource in a window, read from that resource alone:
55
58
  * its own index rows where the index reaches the window, and a live expansion
@@ -57,7 +60,7 @@ export declare const listCalendarInstances: (repositories: CalendarWindowReposit
57
60
  */
58
61
  export declare const listObjectInstances: (repositories: {
59
62
  calendarEventIndex: Pick<ICalendarEventIndexRepository, "listForObject">;
60
- }, collection: CalendarCollectionItem, object: CalendarObjectItem, window: CalendarWindow) => Promise<CalendarInstance[]>;
63
+ }, collection: CalendarCollectionItem, object: CalendarObjectItem, window: CalendarWindow, self: ReadonlySet<string>) => Promise<CalendarInstance[]>;
61
64
  /** A stretch of time somebody is busy in, as instants. */
62
65
  export interface BusySpan {
63
66
  startMs: number;
@@ -65,8 +68,8 @@ export interface BusySpan {
65
68
  }
66
69
  /**
67
70
  * Whether an occurrence consumes free/busy time. A cancelled event is not
68
- * something to work around, and one marked TRANSP:TRANSPARENT was written
69
- * precisely to say so.
71
+ * something to work around, one the reader declined is not one they attend,
72
+ * and one marked TRANSP:TRANSPARENT was written precisely to say so.
70
73
  */
71
74
  export declare const isBusy: (instance: CalendarInstance) => boolean;
72
75
  /**
@@ -82,4 +85,4 @@ export declare const mergeBusySpans: (spans: BusySpan[]) => BusySpan[];
82
85
  * clipped to the window so a span never claims time outside what was asked
83
86
  * for.
84
87
  */
85
- export declare const listBusySpans: (repositories: CalendarWindowRepositories, collections: CalendarCollectionItem[], window: CalendarWindow) => Promise<BusySpan[]>;
88
+ export declare const listBusySpans: (repositories: CalendarWindowRepositories, collections: CalendarCollectionItem[], window: CalendarWindow, self: ReadonlySet<string>) => Promise<BusySpan[]>;
package/dist/window.js CHANGED
@@ -1,6 +1,7 @@
1
- import { CalendarEventStatus, CalendarTransparency } from "@remit/domain-enums";
1
+ import { CalendarEventStatus, CalendarParticipation, CalendarTransparency, } from "@remit/domain-enums";
2
2
  import { expandCalendarWindow } from "./expand.js";
3
3
  import { parseCalendar } from "./parse.js";
4
+ import { participationAtSlot } from "./participation.js";
4
5
  import { toOffsetIso, toUtcIso } from "./time.js";
5
6
  /**
6
7
  * How far before the window occurrences are read, so one that started earlier
@@ -15,7 +16,14 @@ import { toOffsetIso, toUtcIso } from "./time.js";
15
16
  */
16
17
  export const CALENDAR_WINDOW_LOOKBACK_DAYS = 31;
17
18
  const LOOKBACK_MS = CALENDAR_WINDOW_LOOKBACK_DAYS * 24 * 60 * 60 * 1000;
18
- const instanceOf = (collection, object, occurrence) => ({
19
+ const parsedOrThrow = async (object) => {
20
+ const parsed = await parseCalendar(object.icalData);
21
+ if (!parsed.ok) {
22
+ throw new Error(`stored calendar object ${object.calendarObjectId} no longer parses: ${parsed.error.message}`);
23
+ }
24
+ return parsed.value;
25
+ };
26
+ const instanceOf = (collection, object, parsed, self, occurrence) => ({
19
27
  calendarId: collection.calendarId,
20
28
  calendarObjectId: object.calendarObjectId,
21
29
  recurrenceId: occurrence.recurrenceId,
@@ -31,6 +39,7 @@ const instanceOf = (collection, object, occurrence) => ({
31
39
  zoneCertainty: object.zoneCertainty,
32
40
  etag: object.etag,
33
41
  hasRecurrence: object.hasRecurrence,
42
+ participation: participationAtSlot(parsed, collection.timezone, occurrence.recurrenceId, self),
34
43
  });
35
44
  /**
36
45
  * Every occurrence in a window, across the collections given.
@@ -40,7 +49,7 @@ const instanceOf = (collection, object, occurrence) => ({
40
49
  * written to serve the read. A series is served from one source or the other,
41
50
  * never merged from both, so an occurrence cannot appear twice.
42
51
  */
43
- export const listCalendarInstances = async (repositories, collections, window) => {
52
+ export const listCalendarInstances = async (repositories, collections, window, self) => {
44
53
  const fromMs = Date.parse(window.from);
45
54
  const toMs = Date.parse(window.to);
46
55
  const lookbackFrom = toUtcIso(fromMs - LOOKBACK_MS);
@@ -52,28 +61,30 @@ export const listCalendarInstances = async (repositories, collections, window) =
52
61
  const live = await repositories.calendarObject.listIncompleteExpansions(collection.calendarId, window.to);
53
62
  const liveIds = new Set(live.map((object) => object.calendarObjectId));
54
63
  const rows = await repositories.calendarEventIndex.listByStartRange(collection.calendarId, lookbackFrom, window.to);
55
- // One read per resource that actually appears in the window, cached across
56
- // its own occurrences.
64
+ // One read and one parse per resource that actually appears in the window,
65
+ // cached across its own occurrences. The parse is what the reader's own
66
+ // answer is read from.
57
67
  const byId = new Map();
58
68
  for (const row of rows) {
59
69
  if (liveIds.has(row.calendarObjectId))
60
70
  continue;
61
71
  if (!byId.has(row.calendarObjectId)) {
62
- byId.set(row.calendarObjectId, await repositories.calendarObject.find(collection.calendarId, row.calendarObjectId));
72
+ const object = await repositories.calendarObject.find(collection.calendarId, row.calendarObjectId);
73
+ byId.set(row.calendarObjectId, object ? { object, parsed: await parsedOrThrow(object) } : null);
63
74
  }
64
- const object = byId.get(row.calendarObjectId);
65
- if (!object)
75
+ const read = byId.get(row.calendarObjectId);
76
+ if (!read)
66
77
  continue;
67
- instances.push(instanceOf(collection, object, row));
78
+ instances.push(instanceOf(collection, read.object, read.parsed, self, row));
68
79
  }
69
80
  for (const object of live) {
70
- const parsed = await parseCalendar(object.icalData);
71
- if (!parsed.ok) {
72
- throw new Error(`stored calendar object ${object.calendarObjectId} no longer parses: ${parsed.error.message}`);
73
- }
74
- const occurrences = expandCalendarWindow(parsed.value, collection.timezone, { fromMs: fromMs - LOOKBACK_MS, toMs });
81
+ const parsed = await parsedOrThrow(object);
82
+ const occurrences = expandCalendarWindow(parsed, collection.timezone, {
83
+ fromMs: fromMs - LOOKBACK_MS,
84
+ toMs,
85
+ });
75
86
  for (const occurrence of occurrences) {
76
- instances.push(instanceOf(collection, object, occurrence));
87
+ instances.push(instanceOf(collection, object, parsed, self, occurrence));
77
88
  }
78
89
  }
79
90
  }
@@ -86,28 +97,22 @@ export const listCalendarInstances = async (repositories, collections, window) =
86
97
  * its own index rows where the index reaches the window, and a live expansion
87
98
  * of it where it does not.
88
99
  */
89
- export const listObjectInstances = async (repositories, collection, object, window) => {
100
+ export const listObjectInstances = async (repositories, collection, object, window, self) => {
90
101
  const fromMs = Date.parse(window.from);
91
102
  const toMs = Date.parse(window.to);
103
+ const parsed = await parsedOrThrow(object);
92
104
  const indexReaches = object.expandedThrough === "" || object.expandedThrough >= window.to;
93
105
  const occurrences = indexReaches
94
106
  ? await repositories.calendarEventIndex.listForObject(collection.calendarId, object.calendarObjectId)
95
- : await expandObject(object, collection.timezone, fromMs, toMs);
107
+ : expandCalendarWindow(parsed, collection.timezone, {
108
+ fromMs: fromMs - LOOKBACK_MS,
109
+ toMs,
110
+ });
96
111
  return occurrences
97
- .map((occurrence) => instanceOf(collection, object, occurrence))
112
+ .map((occurrence) => instanceOf(collection, object, parsed, self, occurrence))
98
113
  .filter((instance) => overlapsWindow(instance, fromMs, toMs))
99
114
  .sort((left, right) => left.startAt.localeCompare(right.startAt));
100
115
  };
101
- const expandObject = async (object, timezone, fromMs, toMs) => {
102
- const parsed = await parseCalendar(object.icalData);
103
- if (!parsed.ok) {
104
- throw new Error(`stored calendar object ${object.calendarObjectId} no longer parses: ${parsed.error.message}`);
105
- }
106
- return expandCalendarWindow(parsed.value, timezone, {
107
- fromMs: fromMs - LOOKBACK_MS,
108
- toMs,
109
- });
110
- };
111
116
  const overlapsWindow = (instance, fromMs, toMs) => {
112
117
  const startMs = Date.parse(instance.startAt);
113
118
  if (startMs >= toMs)
@@ -118,11 +123,12 @@ const overlapsWindow = (instance, fromMs, toMs) => {
118
123
  };
119
124
  /**
120
125
  * Whether an occurrence consumes free/busy time. A cancelled event is not
121
- * something to work around, and one marked TRANSP:TRANSPARENT was written
122
- * precisely to say so.
126
+ * something to work around, one the reader declined is not one they attend,
127
+ * and one marked TRANSP:TRANSPARENT was written precisely to say so.
123
128
  */
124
129
  export const isBusy = (instance) => instance.transparency === CalendarTransparency.Opaque &&
125
- instance.status !== CalendarEventStatus.Cancelled;
130
+ instance.status !== CalendarEventStatus.Cancelled &&
131
+ instance.participation !== CalendarParticipation.Declined;
126
132
  /**
127
133
  * Collapses overlapping and touching spans into the stretches they cover.
128
134
  *
@@ -150,10 +156,10 @@ export const mergeBusySpans = (spans) => {
150
156
  * clipped to the window so a span never claims time outside what was asked
151
157
  * for.
152
158
  */
153
- export const listBusySpans = async (repositories, collections, window) => {
159
+ export const listBusySpans = async (repositories, collections, window, self) => {
154
160
  const fromMs = Date.parse(window.from);
155
161
  const toMs = Date.parse(window.to);
156
- const instances = await listCalendarInstances(repositories, collections, window);
162
+ const instances = await listCalendarInstances(repositories, collections, window, self);
157
163
  return mergeBusySpans(instances.filter(isBusy).map((instance) => ({
158
164
  startMs: Math.max(Date.parse(instance.startAt), fromMs),
159
165
  endMs: Math.min(Date.parse(instance.endAt), toMs),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/calendar-service",
3
- "version": "0.0.21",
3
+ "version": "1.0.1",
4
4
  "description": "iCalendar (RFC 5545) parsing, projection and recurrence expansion over the calendar store",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",