@remit/calendar-service 0.0.18 → 0.0.19
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/accept.d.ts +71 -0
- package/dist/accept.js +159 -0
- package/dist/build.d.ts +79 -0
- package/dist/build.js +196 -0
- package/dist/correct.d.ts +4 -0
- package/dist/correct.js +22 -0
- package/dist/errors.d.ts +56 -0
- package/dist/errors.js +1 -0
- package/dist/etag.d.ts +9 -0
- package/{src/etag.ts → dist/etag.js} +1 -3
- package/dist/expand.d.ts +73 -0
- package/dist/expand.js +188 -0
- package/dist/feed.d.ts +81 -0
- package/dist/feed.js +157 -0
- package/dist/fixtures.d.ts +19 -0
- package/dist/fixtures.js +37 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +15 -0
- package/dist/memory-store.d.ts +105 -0
- package/dist/memory-store.js +319 -0
- package/dist/parse.d.ts +39 -0
- package/dist/parse.js +87 -0
- package/dist/project.d.ts +30 -0
- package/dist/project.js +57 -0
- package/dist/put.d.ts +52 -0
- package/dist/put.js +78 -0
- package/dist/reopen.d.ts +14 -0
- package/dist/reopen.js +92 -0
- package/dist/scope.d.ts +70 -0
- package/dist/scope.js +687 -0
- package/dist/subscribe.d.ts +96 -0
- package/dist/subscribe.js +298 -0
- package/dist/suggest.d.ts +74 -0
- package/dist/suggest.js +138 -0
- package/dist/time.d.ts +76 -0
- package/dist/time.js +164 -0
- package/dist/window.d.ts +85 -0
- package/dist/window.js +161 -0
- package/package.json +40 -35
- package/src/accept.test.ts +0 -403
- package/src/accept.ts +0 -250
- package/src/build.test.ts +0 -113
- package/src/build.ts +0 -311
- package/src/correct.test.ts +0 -173
- package/src/correct.ts +0 -40
- package/src/errors.ts +0 -62
- package/src/etag.test.ts +0 -54
- package/src/expand.test.ts +0 -361
- package/src/expand.ts +0 -303
- package/src/feed.test.ts +0 -334
- package/src/feed.ts +0 -203
- package/src/feeds/google-secret-address.ics +0 -99
- package/src/fixtures.ts +0 -52
- package/src/index.ts +0 -128
- package/src/memory-store.ts +0 -485
- package/src/parse.test.ts +0 -264
- package/src/parse.ts +0 -138
- package/src/project.test.ts +0 -247
- package/src/project.ts +0 -122
- package/src/put.test.ts +0 -318
- package/src/put.ts +0 -133
- package/src/reopen.test.ts +0 -529
- package/src/reopen.ts +0 -199
- package/src/scope.test.ts +0 -1421
- package/src/scope.ts +0 -1003
- package/src/subscribe.test.ts +0 -582
- package/src/subscribe.ts +0 -433
- package/src/suggest.test.ts +0 -376
- package/src/suggest.ts +0 -244
- package/src/time.ts +0 -260
- package/src/window.test.ts +0 -648
- package/src/window.ts +0 -281
- package/tsconfig.json +0 -8
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { CalendarRepositories, CalendarWrite, ICalendarStore, ICalendarSuggestionRepository } from "@remit/data-ports";
|
|
2
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
3
|
+
export declare class MissingRow extends NotFoundError {
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* The calendar store over plain maps. A commit applies its writes in order;
|
|
7
|
+
* that they stand or fall together on a real database is proven in
|
|
8
|
+
* drizzle-service's calendar-put.sqlite.test.ts.
|
|
9
|
+
*/
|
|
10
|
+
export declare class MemoryCalendarStore implements ICalendarStore {
|
|
11
|
+
readonly collections: Map<string, {
|
|
12
|
+
displayName: string;
|
|
13
|
+
accountConfigId: string;
|
|
14
|
+
createdAt: number;
|
|
15
|
+
updatedAt: number;
|
|
16
|
+
source: "Default" | "UserCreated" | "MailDerived" | "Subscribed";
|
|
17
|
+
color: "Cal1" | "Cal2" | "Cal3" | "Cal4" | "Cal5" | "Cal6";
|
|
18
|
+
calendarId: string;
|
|
19
|
+
urlSegment: string;
|
|
20
|
+
componentSet: "VeventOnly";
|
|
21
|
+
timezone: string;
|
|
22
|
+
syncSequence: number;
|
|
23
|
+
subscriptionUrl: string;
|
|
24
|
+
subscriptionEnabled: boolean;
|
|
25
|
+
subscriptionCheckedAt: number;
|
|
26
|
+
subscriptionFetchedAt: number;
|
|
27
|
+
subscriptionError: string;
|
|
28
|
+
}>;
|
|
29
|
+
readonly objects: Map<string, {
|
|
30
|
+
status: "Confirmed" | "Tentative" | "Cancelled";
|
|
31
|
+
createdAt: number;
|
|
32
|
+
updatedAt: number;
|
|
33
|
+
summary: string;
|
|
34
|
+
calendarId: string;
|
|
35
|
+
syncSequence: number;
|
|
36
|
+
etag: string;
|
|
37
|
+
icalData: string;
|
|
38
|
+
calendarObjectId: string;
|
|
39
|
+
icalUid: string;
|
|
40
|
+
allDay: boolean;
|
|
41
|
+
transparency: "Opaque" | "Transparent";
|
|
42
|
+
zoneCertainty: "Local" | "Explicit" | "Ambiguous";
|
|
43
|
+
hasRecurrence: boolean;
|
|
44
|
+
resourceName: string;
|
|
45
|
+
sequence: number;
|
|
46
|
+
dtStart: string;
|
|
47
|
+
dtEnd: string;
|
|
48
|
+
expandedThrough: string;
|
|
49
|
+
}>;
|
|
50
|
+
readonly occurrences: Map<string, {
|
|
51
|
+
status: "Confirmed" | "Tentative" | "Cancelled";
|
|
52
|
+
createdAt: number;
|
|
53
|
+
updatedAt: number;
|
|
54
|
+
summary: string;
|
|
55
|
+
calendarId: string;
|
|
56
|
+
calendarObjectId: string;
|
|
57
|
+
recurrenceId: string;
|
|
58
|
+
allDay: boolean;
|
|
59
|
+
transparency: "Opaque" | "Transparent";
|
|
60
|
+
startAt: string;
|
|
61
|
+
endAt: string;
|
|
62
|
+
}[]>;
|
|
63
|
+
readonly suggestions: Map<string, {
|
|
64
|
+
accountConfigId: string;
|
|
65
|
+
state: "Pending" | "Tentative" | "Accepted" | "Declined" | "Dismissed" | "Superseded";
|
|
66
|
+
createdAt: number;
|
|
67
|
+
updatedAt: number;
|
|
68
|
+
source: "IcalendarPart" | "IcalendarAttachment" | "TextHeuristic";
|
|
69
|
+
summary: string;
|
|
70
|
+
icalData: string;
|
|
71
|
+
icalUid: string;
|
|
72
|
+
allDay: boolean;
|
|
73
|
+
zoneCertainty: "Local" | "Explicit" | "Ambiguous";
|
|
74
|
+
location: string;
|
|
75
|
+
sequence: number;
|
|
76
|
+
dtStart: string;
|
|
77
|
+
dtEnd: string;
|
|
78
|
+
messageId: string;
|
|
79
|
+
bodyPartId: string;
|
|
80
|
+
suggestionId: string;
|
|
81
|
+
method: "None" | "Request" | "Reply" | "Cancel" | "Counter" | "Publish";
|
|
82
|
+
endsAtUtc: string;
|
|
83
|
+
organizer: string;
|
|
84
|
+
acceptedCalendarObjectId: string;
|
|
85
|
+
supersededByMessageId: string;
|
|
86
|
+
}>;
|
|
87
|
+
readonly feedTokens: Map<string, {
|
|
88
|
+
accountConfigId: string;
|
|
89
|
+
createdAt: number;
|
|
90
|
+
updatedAt: number;
|
|
91
|
+
calendarId: string;
|
|
92
|
+
rotatedAt: number;
|
|
93
|
+
feedTokenId: string;
|
|
94
|
+
tokenHash: string;
|
|
95
|
+
}>;
|
|
96
|
+
private readonly collectionRepo;
|
|
97
|
+
private readonly objectRepo;
|
|
98
|
+
private readonly eventIndexRepo;
|
|
99
|
+
private readonly suggestionRepo;
|
|
100
|
+
private readonly feedTokenRepo;
|
|
101
|
+
get calendarSuggestion(): ICalendarSuggestionRepository;
|
|
102
|
+
get repos(): CalendarRepositories;
|
|
103
|
+
commit(writes: readonly CalendarWrite[]): Promise<void>;
|
|
104
|
+
private apply;
|
|
105
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { NotFoundError } from "@remit/data-ports/errors";
|
|
2
|
+
import { deriveCalendarFeedTokenId, deriveCalendarId, deriveCalendarObjectId, deriveCalendarSuggestionId, normalizeCalendarUrlSegment, } from "@remit/data-ports/id";
|
|
3
|
+
import { CalendarSuggestionState } from "@remit/domain-enums";
|
|
4
|
+
// The class the port's conformance suite requires a repository to throw for an
|
|
5
|
+
// absent row, so a caller that branches on it — the calendar feed answers 404 to
|
|
6
|
+
// a token whose collection is gone — behaves the same against this double as
|
|
7
|
+
// against a real one.
|
|
8
|
+
export class MissingRow extends NotFoundError {
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The calendar store over plain maps. A commit applies its writes in order;
|
|
12
|
+
* that they stand or fall together on a real database is proven in
|
|
13
|
+
* drizzle-service's calendar-put.sqlite.test.ts.
|
|
14
|
+
*/
|
|
15
|
+
export class MemoryCalendarStore {
|
|
16
|
+
collections = new Map();
|
|
17
|
+
objects = new Map();
|
|
18
|
+
occurrences = new Map();
|
|
19
|
+
suggestions = new Map();
|
|
20
|
+
feedTokens = new Map();
|
|
21
|
+
collectionRepo = {
|
|
22
|
+
create: async (input) => {
|
|
23
|
+
const urlSegment = normalizeCalendarUrlSegment(input.urlSegment);
|
|
24
|
+
const calendarId = deriveCalendarId(input.accountConfigId, urlSegment);
|
|
25
|
+
const existing = this.collections.get(calendarId);
|
|
26
|
+
if (existing)
|
|
27
|
+
return existing;
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
const collection = {
|
|
30
|
+
calendarId,
|
|
31
|
+
accountConfigId: input.accountConfigId,
|
|
32
|
+
urlSegment,
|
|
33
|
+
displayName: input.displayName,
|
|
34
|
+
color: input.color ?? "Cal1",
|
|
35
|
+
componentSet: input.componentSet ?? "VeventOnly",
|
|
36
|
+
source: input.source ?? "UserCreated",
|
|
37
|
+
timezone: input.timezone ?? "",
|
|
38
|
+
syncSequence: 0,
|
|
39
|
+
subscriptionUrl: input.subscriptionUrl ?? "",
|
|
40
|
+
subscriptionEnabled: input.subscriptionEnabled ?? false,
|
|
41
|
+
subscriptionCheckedAt: 0,
|
|
42
|
+
subscriptionFetchedAt: 0,
|
|
43
|
+
subscriptionError: "",
|
|
44
|
+
createdAt: now,
|
|
45
|
+
updatedAt: now,
|
|
46
|
+
};
|
|
47
|
+
this.collections.set(calendarId, collection);
|
|
48
|
+
return collection;
|
|
49
|
+
},
|
|
50
|
+
get: async (_accountConfigId, calendarId) => {
|
|
51
|
+
const collection = this.collections.get(calendarId);
|
|
52
|
+
if (!collection)
|
|
53
|
+
throw new MissingRow(calendarId);
|
|
54
|
+
return collection;
|
|
55
|
+
},
|
|
56
|
+
update: async (_accountConfigId, calendarId, input) => {
|
|
57
|
+
const collection = this.collections.get(calendarId);
|
|
58
|
+
if (!collection)
|
|
59
|
+
throw new MissingRow(calendarId);
|
|
60
|
+
const updated = { ...collection, ...input };
|
|
61
|
+
this.collections.set(calendarId, updated);
|
|
62
|
+
return updated;
|
|
63
|
+
},
|
|
64
|
+
delete: async (_accountConfigId, calendarId) => {
|
|
65
|
+
this.collections.delete(calendarId);
|
|
66
|
+
},
|
|
67
|
+
listByAccountConfig: async (accountConfigId) => [...this.collections.values()].filter((collection) => collection.accountConfigId === accountConfigId),
|
|
68
|
+
createExclusive: async (input) => {
|
|
69
|
+
const calendarId = deriveCalendarId(input.accountConfigId, input.urlSegment);
|
|
70
|
+
if (this.collections.has(calendarId))
|
|
71
|
+
return null;
|
|
72
|
+
return this.collectionRepo.create(input);
|
|
73
|
+
},
|
|
74
|
+
listEnabledSubscriptions: async () => [...this.collections.values()].filter((collection) => collection.source === "Subscribed" && collection.subscriptionEnabled),
|
|
75
|
+
findByUrlSegment: async (accountConfigId, urlSegment) => this.collections.get(deriveCalendarId(accountConfigId, urlSegment)) ??
|
|
76
|
+
null,
|
|
77
|
+
bumpSyncSequence: async (_accountConfigId, calendarId) => {
|
|
78
|
+
const collection = this.collections.get(calendarId);
|
|
79
|
+
if (!collection)
|
|
80
|
+
throw new MissingRow(calendarId);
|
|
81
|
+
const bumped = {
|
|
82
|
+
...collection,
|
|
83
|
+
syncSequence: collection.syncSequence + 1,
|
|
84
|
+
updatedAt: Date.now(),
|
|
85
|
+
};
|
|
86
|
+
this.collections.set(calendarId, bumped);
|
|
87
|
+
return bumped.syncSequence;
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
objectRepo = {
|
|
91
|
+
put: async (input) => {
|
|
92
|
+
const calendarObjectId = deriveCalendarObjectId(input.calendarId, input.resourceName);
|
|
93
|
+
const now = Date.now();
|
|
94
|
+
const object = {
|
|
95
|
+
...input,
|
|
96
|
+
calendarObjectId,
|
|
97
|
+
createdAt: this.objects.get(calendarObjectId)?.createdAt ?? now,
|
|
98
|
+
updatedAt: now,
|
|
99
|
+
};
|
|
100
|
+
this.objects.set(calendarObjectId, object);
|
|
101
|
+
return object;
|
|
102
|
+
},
|
|
103
|
+
listIncompleteExpansions: async (calendarId, instant) => [...this.objects.values()].filter((object) => object.calendarId === calendarId &&
|
|
104
|
+
object.expandedThrough !== "" &&
|
|
105
|
+
object.expandedThrough < instant),
|
|
106
|
+
find: async (_calendarId, calendarObjectId) => this.objects.get(calendarObjectId) ?? null,
|
|
107
|
+
get: async (_calendarId, calendarObjectId) => {
|
|
108
|
+
const object = this.objects.get(calendarObjectId);
|
|
109
|
+
if (!object)
|
|
110
|
+
throw new MissingRow(calendarObjectId);
|
|
111
|
+
return object;
|
|
112
|
+
},
|
|
113
|
+
delete: async (_calendarId, calendarObjectId) => {
|
|
114
|
+
this.objects.delete(calendarObjectId);
|
|
115
|
+
},
|
|
116
|
+
findByResourceName: async (calendarId, resourceName) => this.objects.get(deriveCalendarObjectId(calendarId, resourceName)) ??
|
|
117
|
+
null,
|
|
118
|
+
findByUid: async (calendarId, icalUid) => [...this.objects.values()].find((object) => object.calendarId === calendarId && object.icalUid === icalUid) ?? null,
|
|
119
|
+
listByUidPrefix: async (calendarId, prefix) => [...this.objects.values()].filter((object) => object.calendarId === calendarId && object.icalUid.startsWith(prefix)),
|
|
120
|
+
listByCalendar: async (calendarId) => [...this.objects.values()].filter((object) => object.calendarId === calendarId),
|
|
121
|
+
listChangedSince: async (calendarId, syncSequence) => [...this.objects.values()]
|
|
122
|
+
.filter((object) => object.calendarId === calendarId &&
|
|
123
|
+
object.syncSequence > syncSequence)
|
|
124
|
+
.sort((left, right) => left.syncSequence - right.syncSequence),
|
|
125
|
+
};
|
|
126
|
+
eventIndexRepo = {
|
|
127
|
+
replaceForObject: async (calendarId, calendarObjectId, occurrences) => {
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
this.occurrences.set(calendarObjectId, occurrences.map((occurrence) => ({
|
|
130
|
+
...occurrence,
|
|
131
|
+
calendarId,
|
|
132
|
+
calendarObjectId,
|
|
133
|
+
createdAt: now,
|
|
134
|
+
updatedAt: now,
|
|
135
|
+
})));
|
|
136
|
+
},
|
|
137
|
+
deleteForObject: async (_calendarId, calendarObjectId) => {
|
|
138
|
+
this.occurrences.delete(calendarObjectId);
|
|
139
|
+
},
|
|
140
|
+
listForObject: async (_calendarId, calendarObjectId) => this.occurrences.get(calendarObjectId) ?? [],
|
|
141
|
+
listByStartRange: async (calendarId, startAt, endAt) => [...this.occurrences.values()]
|
|
142
|
+
.flat()
|
|
143
|
+
.filter((row) => row.calendarId === calendarId &&
|
|
144
|
+
row.startAt >= startAt &&
|
|
145
|
+
row.startAt < endAt)
|
|
146
|
+
.sort((left, right) => left.startAt.localeCompare(right.startAt)),
|
|
147
|
+
};
|
|
148
|
+
suggestionRepo = {
|
|
149
|
+
put: async (input) => {
|
|
150
|
+
const suggestionId = deriveCalendarSuggestionId(input.messageId, input.bodyPartId, input.icalUid);
|
|
151
|
+
const now = Date.now();
|
|
152
|
+
const existing = this.suggestions.get(suggestionId);
|
|
153
|
+
const suggestion = {
|
|
154
|
+
...input,
|
|
155
|
+
suggestionId,
|
|
156
|
+
state: existing?.state ?? CalendarSuggestionState.Pending,
|
|
157
|
+
acceptedCalendarObjectId: existing?.acceptedCalendarObjectId ?? "",
|
|
158
|
+
supersededByMessageId: existing?.supersededByMessageId ?? "",
|
|
159
|
+
createdAt: existing?.createdAt ?? now,
|
|
160
|
+
updatedAt: now,
|
|
161
|
+
};
|
|
162
|
+
this.suggestions.set(suggestionId, suggestion);
|
|
163
|
+
return suggestion;
|
|
164
|
+
},
|
|
165
|
+
get: async (accountConfigId, suggestionId) => {
|
|
166
|
+
const suggestion = this.suggestions.get(suggestionId);
|
|
167
|
+
if (!suggestion || suggestion.accountConfigId !== accountConfigId) {
|
|
168
|
+
throw new MissingRow(suggestionId);
|
|
169
|
+
}
|
|
170
|
+
return suggestion;
|
|
171
|
+
},
|
|
172
|
+
listByAcceptedCalendarObjects: async (accountConfigId, calendarObjectIds) => [...this.suggestions.values()].filter((suggestion) => suggestion.accountConfigId === accountConfigId &&
|
|
173
|
+
calendarObjectIds.includes(suggestion.acceptedCalendarObjectId)),
|
|
174
|
+
listByMessage: async (accountConfigId, messageId) => [...this.suggestions.values()].filter((suggestion) => suggestion.accountConfigId === accountConfigId &&
|
|
175
|
+
suggestion.messageId === messageId),
|
|
176
|
+
listByState: async (accountConfigId, state, options) => ({
|
|
177
|
+
items: [...this.suggestions.values()].filter((suggestion) => suggestion.accountConfigId === accountConfigId &&
|
|
178
|
+
suggestion.state === state &&
|
|
179
|
+
suggestion.endsAtUtc >= options.endsAfter),
|
|
180
|
+
continuationToken: undefined,
|
|
181
|
+
}),
|
|
182
|
+
settle: async (accountConfigId, suggestionId, input) => {
|
|
183
|
+
const suggestion = this.suggestions.get(suggestionId);
|
|
184
|
+
if (!suggestion || suggestion.accountConfigId !== accountConfigId) {
|
|
185
|
+
throw new MissingRow(suggestionId);
|
|
186
|
+
}
|
|
187
|
+
const settled = { ...suggestion, ...input, updatedAt: Date.now() };
|
|
188
|
+
this.suggestions.set(suggestionId, settled);
|
|
189
|
+
return settled;
|
|
190
|
+
},
|
|
191
|
+
supersedeIfPending: async (accountConfigId, suggestionId, supersededByMessageId) => {
|
|
192
|
+
const suggestion = this.suggestions.get(suggestionId);
|
|
193
|
+
if (!suggestion ||
|
|
194
|
+
suggestion.accountConfigId !== accountConfigId ||
|
|
195
|
+
suggestion.state !== CalendarSuggestionState.Pending) {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
const retired = {
|
|
199
|
+
...suggestion,
|
|
200
|
+
state: CalendarSuggestionState.Superseded,
|
|
201
|
+
acceptedCalendarObjectId: "",
|
|
202
|
+
supersededByMessageId,
|
|
203
|
+
updatedAt: Date.now(),
|
|
204
|
+
};
|
|
205
|
+
this.suggestions.set(suggestionId, retired);
|
|
206
|
+
return retired;
|
|
207
|
+
},
|
|
208
|
+
repointSuperseded: async (accountConfigId, suggestionId, supersededByMessageId) => {
|
|
209
|
+
const suggestion = this.suggestions.get(suggestionId);
|
|
210
|
+
if (!suggestion ||
|
|
211
|
+
suggestion.accountConfigId !== accountConfigId ||
|
|
212
|
+
suggestion.state !== CalendarSuggestionState.Superseded) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
const repointed = {
|
|
216
|
+
...suggestion,
|
|
217
|
+
supersededByMessageId,
|
|
218
|
+
updatedAt: Date.now(),
|
|
219
|
+
};
|
|
220
|
+
this.suggestions.set(suggestionId, repointed);
|
|
221
|
+
return repointed;
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
feedTokenRepo = {
|
|
225
|
+
put: async (input) => {
|
|
226
|
+
const feedTokenId = deriveCalendarFeedTokenId(input.calendarId);
|
|
227
|
+
const now = Date.now();
|
|
228
|
+
const existing = this.feedTokens.get(feedTokenId);
|
|
229
|
+
const token = {
|
|
230
|
+
feedTokenId,
|
|
231
|
+
accountConfigId: input.accountConfigId,
|
|
232
|
+
calendarId: input.calendarId,
|
|
233
|
+
tokenHash: input.tokenHash,
|
|
234
|
+
createdAt: existing?.createdAt ?? now,
|
|
235
|
+
rotatedAt: existing ? now : 0,
|
|
236
|
+
updatedAt: now,
|
|
237
|
+
};
|
|
238
|
+
this.feedTokens.set(feedTokenId, token);
|
|
239
|
+
return token;
|
|
240
|
+
},
|
|
241
|
+
findByCalendar: async (accountConfigId, calendarId) => {
|
|
242
|
+
const token = this.feedTokens.get(deriveCalendarFeedTokenId(calendarId));
|
|
243
|
+
if (!token || token.accountConfigId !== accountConfigId)
|
|
244
|
+
return null;
|
|
245
|
+
return token;
|
|
246
|
+
},
|
|
247
|
+
findByTokenHash: async (tokenHash) => [...this.feedTokens.values()].find((token) => token.tokenHash === tokenHash) ?? null,
|
|
248
|
+
delete: async (accountConfigId, calendarId) => {
|
|
249
|
+
const feedTokenId = deriveCalendarFeedTokenId(calendarId);
|
|
250
|
+
const token = this.feedTokens.get(feedTokenId);
|
|
251
|
+
if (!token || token.accountConfigId !== accountConfigId)
|
|
252
|
+
return;
|
|
253
|
+
this.feedTokens.delete(feedTokenId);
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
get calendarSuggestion() {
|
|
257
|
+
return this.suggestionRepo;
|
|
258
|
+
}
|
|
259
|
+
get repos() {
|
|
260
|
+
return {
|
|
261
|
+
calendarCollection: this.collectionRepo,
|
|
262
|
+
calendarObject: this.objectRepo,
|
|
263
|
+
calendarEventIndex: this.eventIndexRepo,
|
|
264
|
+
calendarSuggestion: this.suggestionRepo,
|
|
265
|
+
calendarFeedToken: this.feedTokenRepo,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
async commit(writes) {
|
|
269
|
+
for (const write of writes)
|
|
270
|
+
await this.apply(write);
|
|
271
|
+
}
|
|
272
|
+
async apply(write) {
|
|
273
|
+
switch (write.kind) {
|
|
274
|
+
case "PutObject": {
|
|
275
|
+
const { calendarId } = write.object;
|
|
276
|
+
const syncSequence = await this.collectionRepo.bumpSyncSequence(write.accountConfigId, calendarId);
|
|
277
|
+
const object = await this.objectRepo.put({
|
|
278
|
+
...write.object,
|
|
279
|
+
syncSequence,
|
|
280
|
+
});
|
|
281
|
+
await this.eventIndexRepo.replaceForObject(calendarId, object.calendarObjectId, write.occurrences);
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
case "ReprojectObject": {
|
|
285
|
+
const { calendarId, resourceName } = write.object;
|
|
286
|
+
const stored = await this.objectRepo.findByResourceName(calendarId, resourceName);
|
|
287
|
+
if (stored === null || stored.etag !== write.readEtag)
|
|
288
|
+
return;
|
|
289
|
+
await this.apply({
|
|
290
|
+
kind: "PutObject",
|
|
291
|
+
accountConfigId: write.accountConfigId,
|
|
292
|
+
object: write.object,
|
|
293
|
+
occurrences: write.occurrences,
|
|
294
|
+
});
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
case "DeleteObject":
|
|
298
|
+
await this.eventIndexRepo.deleteForObject(write.calendarId, write.calendarObjectId);
|
|
299
|
+
await this.objectRepo.delete(write.calendarId, write.calendarObjectId);
|
|
300
|
+
await this.collectionRepo.bumpSyncSequence(write.accountConfigId, write.calendarId);
|
|
301
|
+
return;
|
|
302
|
+
case "SettleSuggestion":
|
|
303
|
+
await this.suggestionRepo.settle(write.accountConfigId, write.suggestionId, write.settle);
|
|
304
|
+
return;
|
|
305
|
+
case "UpdateCollection":
|
|
306
|
+
await this.collectionRepo.update(write.accountConfigId, write.calendarId, write.patch);
|
|
307
|
+
return;
|
|
308
|
+
case "DeleteCollection": {
|
|
309
|
+
for (const object of await this.objectRepo.listByCalendar(write.calendarId)) {
|
|
310
|
+
await this.eventIndexRepo.deleteForObject(write.calendarId, object.calendarObjectId);
|
|
311
|
+
await this.objectRepo.delete(write.calendarId, object.calendarObjectId);
|
|
312
|
+
}
|
|
313
|
+
await this.feedTokenRepo.delete(write.accountConfigId, write.calendarId);
|
|
314
|
+
await this.collectionRepo.delete(write.accountConfigId, write.calendarId);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
package/dist/parse.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import ICAL from "ical.js";
|
|
2
|
+
import { type CalendarResult } from "./errors.js";
|
|
3
|
+
export interface ParsedCalendar {
|
|
4
|
+
/** The VCALENDAR, with every property and component the input carried. */
|
|
5
|
+
component: ICAL.Component;
|
|
6
|
+
/** The VEVENT without a RECURRENCE-ID. */
|
|
7
|
+
master: ICAL.Component;
|
|
8
|
+
/** The VEVENTs carrying a RECURRENCE-ID, in document order. */
|
|
9
|
+
overrides: ICAL.Component[];
|
|
10
|
+
uid: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Serializes a VCALENDAR back to iCalendar text.
|
|
14
|
+
*
|
|
15
|
+
* Lossless by construction: ical.js keeps every property it parsed, including
|
|
16
|
+
* ones it has no design rule for, so an X- property or a whole unknown
|
|
17
|
+
* component survives the round trip. The output is always CRLF (RFC 5545 3.1),
|
|
18
|
+
* whatever the input used.
|
|
19
|
+
*
|
|
20
|
+
* The write path does not call this. A resource is stored as the bytes it
|
|
21
|
+
* arrived in — LF-only ones included — because the etag is a digest of exactly
|
|
22
|
+
* what the writer sent; running a stored resource through here would rewrite
|
|
23
|
+
* its line endings, refold its long lines, and move the tag of a resource
|
|
24
|
+
* nobody edited. This is for building a VCALENDAR, not for keeping one.
|
|
25
|
+
*/
|
|
26
|
+
export declare const serializeCalendar: (component: ICAL.Component) => string;
|
|
27
|
+
/**
|
|
28
|
+
* ical.js's parser in a promise, so unreadable bytes arrive as a rejection to
|
|
29
|
+
* branch on rather than a synchronous throw.
|
|
30
|
+
*/
|
|
31
|
+
export declare const readCalendarComponent: (icalData: string) => Promise<CalendarResult<ICAL.Component>>;
|
|
32
|
+
/**
|
|
33
|
+
* Reads and validates a VCALENDAR carrying exactly one event.
|
|
34
|
+
*
|
|
35
|
+
* Every refusal is a returned value. The input is client-supplied bytes, so a
|
|
36
|
+
* malformed resource is an ordinary outcome of this function rather than a
|
|
37
|
+
* fault.
|
|
38
|
+
*/
|
|
39
|
+
export declare const parseCalendar: (icalData: string) => Promise<CalendarResult<ParsedCalendar>>;
|
package/dist/parse.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import ICAL from "ical.js";
|
|
2
|
+
import { calendarFailure } from "./errors.js";
|
|
3
|
+
/** Components a VEVENT-only collection accepts at the top level of a resource. */
|
|
4
|
+
const ACCEPTED_COMPONENTS = new Set(["vevent", "vtimezone"]);
|
|
5
|
+
/**
|
|
6
|
+
* Serializes a VCALENDAR back to iCalendar text.
|
|
7
|
+
*
|
|
8
|
+
* Lossless by construction: ical.js keeps every property it parsed, including
|
|
9
|
+
* ones it has no design rule for, so an X- property or a whole unknown
|
|
10
|
+
* component survives the round trip. The output is always CRLF (RFC 5545 3.1),
|
|
11
|
+
* whatever the input used.
|
|
12
|
+
*
|
|
13
|
+
* The write path does not call this. A resource is stored as the bytes it
|
|
14
|
+
* arrived in — LF-only ones included — because the etag is a digest of exactly
|
|
15
|
+
* what the writer sent; running a stored resource through here would rewrite
|
|
16
|
+
* its line endings, refold its long lines, and move the tag of a resource
|
|
17
|
+
* nobody edited. This is for building a VCALENDAR, not for keeping one.
|
|
18
|
+
*/
|
|
19
|
+
export const serializeCalendar = (component) => component.toString();
|
|
20
|
+
/**
|
|
21
|
+
* ical.js's parser in a promise, so unreadable bytes arrive as a rejection to
|
|
22
|
+
* branch on rather than a synchronous throw.
|
|
23
|
+
*/
|
|
24
|
+
export const readCalendarComponent = (icalData) => new Promise((resolve) => {
|
|
25
|
+
resolve(new ICAL.Component(ICAL.parse(icalData)));
|
|
26
|
+
}).then((component) => ({ ok: true, value: component }), (error) => calendarFailure("MalformedIcalendar", error instanceof Error ? error.message : "unreadable iCalendar data"));
|
|
27
|
+
/**
|
|
28
|
+
* Reads and validates a VCALENDAR carrying exactly one event.
|
|
29
|
+
*
|
|
30
|
+
* Every refusal is a returned value. The input is client-supplied bytes, so a
|
|
31
|
+
* malformed resource is an ordinary outcome of this function rather than a
|
|
32
|
+
* fault.
|
|
33
|
+
*/
|
|
34
|
+
export const parseCalendar = async (icalData) => {
|
|
35
|
+
const read = await readCalendarComponent(icalData);
|
|
36
|
+
if (!read.ok)
|
|
37
|
+
return read;
|
|
38
|
+
const component = read.value;
|
|
39
|
+
if (component.name !== "vcalendar") {
|
|
40
|
+
// A document with nothing in it names nothing: ical.js reads empty bytes
|
|
41
|
+
// as a component with no name at all, and a suggestion read out of prose
|
|
42
|
+
// carries exactly that. Naming what arrived is for a document that has a
|
|
43
|
+
// root to name.
|
|
44
|
+
return calendarFailure("NotACalendar", component.name
|
|
45
|
+
? `expected a VCALENDAR, found ${component.name.toUpperCase()}`
|
|
46
|
+
: "expected a VCALENDAR, found nothing");
|
|
47
|
+
}
|
|
48
|
+
const unsupported = component
|
|
49
|
+
.getAllSubcomponents()
|
|
50
|
+
.map((child) => child.name)
|
|
51
|
+
.find((name) => !ACCEPTED_COMPONENTS.has(name));
|
|
52
|
+
if (unsupported) {
|
|
53
|
+
return calendarFailure("UnsupportedComponent", `this calendar stores VEVENT only, and the resource carries a ${unsupported.toUpperCase()}`);
|
|
54
|
+
}
|
|
55
|
+
const events = component.getAllSubcomponents("vevent");
|
|
56
|
+
if (events.length === 0) {
|
|
57
|
+
return calendarFailure("NoEvent", "the resource carries no VEVENT");
|
|
58
|
+
}
|
|
59
|
+
const masters = events.filter((event) => !event.hasProperty("recurrence-id"));
|
|
60
|
+
if (masters.length === 0) {
|
|
61
|
+
return calendarFailure("NoMasterEvent", "every VEVENT carries a RECURRENCE-ID, so there is no event for them to override");
|
|
62
|
+
}
|
|
63
|
+
if (masters.length > 1) {
|
|
64
|
+
return calendarFailure("MultipleMasterEvents", `one resource holds one event, and this one holds ${masters.length}`);
|
|
65
|
+
}
|
|
66
|
+
const master = masters[0];
|
|
67
|
+
const uid = master.getFirstPropertyValue("uid");
|
|
68
|
+
if (typeof uid !== "string" || uid === "") {
|
|
69
|
+
return calendarFailure("MissingUid", "the VEVENT declares no UID");
|
|
70
|
+
}
|
|
71
|
+
const mismatched = events.find((event) => event.getFirstPropertyValue("uid") !== uid);
|
|
72
|
+
if (mismatched) {
|
|
73
|
+
return calendarFailure("MismatchedUid", "the VEVENTs in this resource declare different UIDs");
|
|
74
|
+
}
|
|
75
|
+
if (!master.hasProperty("dtstart")) {
|
|
76
|
+
return calendarFailure("MissingDtStart", "the VEVENT declares no DTSTART");
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
ok: true,
|
|
80
|
+
value: {
|
|
81
|
+
component,
|
|
82
|
+
master,
|
|
83
|
+
overrides: events.filter((event) => event.hasProperty("recurrence-id")),
|
|
84
|
+
uid,
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { CalendarObjectItem } from "@remit/data-ports";
|
|
2
|
+
import ICAL from "ical.js";
|
|
3
|
+
import { type CalendarResult } from "./errors.js";
|
|
4
|
+
import type { ParsedCalendar } from "./parse.js";
|
|
5
|
+
/**
|
|
6
|
+
* The columns `CalendarObject` carries beside its bytes. Every one of them is
|
|
7
|
+
* read out of the VEVENT here and nowhere else, so the row can only ever
|
|
8
|
+
* describe the `icalData` it was written with.
|
|
9
|
+
*/
|
|
10
|
+
export type CalendarObjectProjection = Pick<CalendarObjectItem, "icalUid" | "summary" | "dtStart" | "dtEnd" | "allDay" | "zoneCertainty" | "status" | "transparency" | "hasRecurrence" | "sequence">;
|
|
11
|
+
export declare const readString: (component: ICAL.Component, name: string) => string;
|
|
12
|
+
/**
|
|
13
|
+
* The fields that describe one VEVENT rather than the series it belongs to.
|
|
14
|
+
*
|
|
15
|
+
* Read per occurrence as well as per resource: an override VEVENT carries its
|
|
16
|
+
* own summary, status and transparency, and a range read that took them from
|
|
17
|
+
* the master would draw the old title over an edited instance and count a
|
|
18
|
+
* cancelled one as busy time.
|
|
19
|
+
*/
|
|
20
|
+
export type CalendarEventDisplay = Pick<CalendarObjectItem, "summary" | "status" | "transparency">;
|
|
21
|
+
export declare const projectEventDisplay: (component: ICAL.Component) => CalendarEventDisplay;
|
|
22
|
+
/**
|
|
23
|
+
* Whether the resource expands to more than its master instance — an RRULE, an
|
|
24
|
+
* RDATE, or an override VEVENT. The overrides count: a client that edits one
|
|
25
|
+
* instance of a series and then deletes the rule leaves a resource with nothing
|
|
26
|
+
* but a master and its exceptions, and every one of those is still an
|
|
27
|
+
* occurrence somebody has to see.
|
|
28
|
+
*/
|
|
29
|
+
export declare const hasRecurrence: (calendar: ParsedCalendar) => boolean;
|
|
30
|
+
export declare const projectCalendar: (calendar: ParsedCalendar, collectionTimezone: string) => CalendarResult<CalendarObjectProjection>;
|
package/dist/project.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { CalendarEventStatus, CalendarTransparency } from "@remit/domain-enums";
|
|
2
|
+
import ICAL from "ical.js";
|
|
3
|
+
import { calendarFailure } from "./errors.js";
|
|
4
|
+
import { resolveTime, tzidOf } from "./time.js";
|
|
5
|
+
const STATUS_BY_ICAL = {
|
|
6
|
+
CONFIRMED: CalendarEventStatus.Confirmed,
|
|
7
|
+
TENTATIVE: CalendarEventStatus.Tentative,
|
|
8
|
+
CANCELLED: CalendarEventStatus.Cancelled,
|
|
9
|
+
};
|
|
10
|
+
const TRANSPARENCY_BY_ICAL = {
|
|
11
|
+
OPAQUE: CalendarTransparency.Opaque,
|
|
12
|
+
TRANSPARENT: CalendarTransparency.Transparent,
|
|
13
|
+
};
|
|
14
|
+
export const readString = (component, name) => {
|
|
15
|
+
const value = component.getFirstPropertyValue(name);
|
|
16
|
+
return typeof value === "string" ? value : "";
|
|
17
|
+
};
|
|
18
|
+
export const projectEventDisplay = (component) => ({
|
|
19
|
+
summary: readString(component, "summary"),
|
|
20
|
+
status: STATUS_BY_ICAL[readString(component, "status").toUpperCase()] ??
|
|
21
|
+
CalendarEventStatus.Confirmed,
|
|
22
|
+
transparency: TRANSPARENCY_BY_ICAL[readString(component, "transp").toUpperCase()] ??
|
|
23
|
+
CalendarTransparency.Opaque,
|
|
24
|
+
});
|
|
25
|
+
/**
|
|
26
|
+
* Whether the resource expands to more than its master instance — an RRULE, an
|
|
27
|
+
* RDATE, or an override VEVENT. The overrides count: a client that edits one
|
|
28
|
+
* instance of a series and then deletes the rule leaves a resource with nothing
|
|
29
|
+
* but a master and its exceptions, and every one of those is still an
|
|
30
|
+
* occurrence somebody has to see.
|
|
31
|
+
*/
|
|
32
|
+
export const hasRecurrence = (calendar) => calendar.master.hasProperty("rrule") ||
|
|
33
|
+
calendar.master.hasProperty("rdate") ||
|
|
34
|
+
calendar.overrides.length > 0;
|
|
35
|
+
export const projectCalendar = (calendar, collectionTimezone) => {
|
|
36
|
+
const event = new ICAL.Event(calendar.master);
|
|
37
|
+
const start = resolveTime(event.startDate, tzidOf(calendar.master.getFirstProperty("dtstart")), collectionTimezone);
|
|
38
|
+
const end = resolveTime(event.endDate, tzidOf(calendar.master.getFirstProperty("dtend") ??
|
|
39
|
+
calendar.master.getFirstProperty("dtstart")), collectionTimezone);
|
|
40
|
+
if (end.instantMs < start.instantMs) {
|
|
41
|
+
return calendarFailure("BackwardsEnd", `the event ends at ${end.isoUtc}, before it starts at ${start.isoUtc}`);
|
|
42
|
+
}
|
|
43
|
+
const sequence = calendar.master.getFirstPropertyValue("sequence");
|
|
44
|
+
return {
|
|
45
|
+
ok: true,
|
|
46
|
+
value: {
|
|
47
|
+
icalUid: calendar.uid,
|
|
48
|
+
dtStart: start.isoOffset,
|
|
49
|
+
dtEnd: end.isoOffset,
|
|
50
|
+
allDay: start.isDate,
|
|
51
|
+
zoneCertainty: start.certainty,
|
|
52
|
+
...projectEventDisplay(calendar.master),
|
|
53
|
+
hasRecurrence: hasRecurrence(calendar),
|
|
54
|
+
sequence: typeof sequence === "number" ? sequence : 0,
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
};
|