@remit/web-client 0.0.231 → 0.0.233
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/package.json +1 -1
- package/src/components/calendar/AgendaStrip.stories.tsx +263 -0
- package/src/components/calendar/AgendaStrip.tsx +145 -0
- package/src/components/calendar/AgendaView.tsx +222 -0
- package/src/components/calendar/CalendarComposePane.stories.tsx +101 -11
- package/src/components/calendar/CalendarComposePane.tsx +83 -23
- package/src/components/calendar/CalendarComposeSeed.tsx +35 -0
- package/src/components/calendar/CalendarEventPane.stories.tsx +30 -1
- package/src/components/calendar/CalendarEventPane.tsx +28 -5
- package/src/components/calendar/CalendarViewPlaceholder.tsx +3 -4
- package/src/components/calendar/CalendarWorkspace.stories.tsx +34 -1
- package/src/components/calendar/CalendarWorkspace.tsx +71 -23
- package/src/components/calendar/OpenCalendarEvent.tsx +189 -7
- package/src/components/calendar/WriteCalendarEvent.tsx +94 -0
- package/src/components/calendar/agenda-address.render.test.ts +389 -0
- package/src/components/calendar/agenda-strip.render.test.ts +241 -0
- package/src/components/calendar/calendar-workspace.render.test.ts +13 -6
- package/src/components/calendar/calendar-write-flow.render.test.ts +356 -0
- package/src/hooks/calendar/agenda-window.test.ts +235 -0
- package/src/hooks/calendar/agenda-window.ts +183 -0
- package/src/hooks/calendar/calendar-reads.render.test.ts +265 -0
- package/src/hooks/calendar/calendar-writes.render.test.ts +305 -0
- package/src/hooks/calendar/draft.test.ts +241 -0
- package/src/hooks/calendar/draft.ts +168 -0
- package/src/hooks/calendar/index.ts +88 -0
- package/src/hooks/calendar/instance.ts +136 -0
- package/src/hooks/calendar/recurrence-rule.test.ts +92 -0
- package/src/hooks/calendar/recurrence-rule.ts +133 -0
- package/src/hooks/calendar/resource.test.ts +98 -0
- package/src/hooks/calendar/resource.ts +74 -0
- package/src/hooks/calendar/selection.ts +107 -0
- package/src/hooks/calendar/useCalendarEvent.ts +46 -0
- package/src/hooks/calendar/useCalendarEvents.ts +195 -0
- package/src/hooks/calendar/useCalendarFreeBusy.ts +80 -0
- package/src/hooks/calendar/useCalendarWrites.ts +174 -0
- package/src/hooks/calendar/useCalendars.ts +43 -0
- package/src/hooks/calendar/window.test.ts +148 -0
- package/src/hooks/calendar/window.ts +168 -0
- package/src/hooks/useCalendarData.ts +62 -40
- package/src/lib/calendar-density.ts +13 -1
- package/src/lib/calendar-route.test.ts +20 -1
- package/src/lib/calendar-route.ts +21 -7
- package/src/routes/calendar/$view.$date/new.tsx +2 -2
- package/src/routes/calendar/$view.$date.tsx +51 -15
- package/src/routing/calendar.ts +32 -4
- package/src/test-support/http.ts +8 -0
- package/src/lib/calendar-fixtures.ts +0 -207
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every calendar surface reads and writes, in one module.
|
|
3
|
+
*
|
|
4
|
+
* The grid, the reading pane and the day strip all ask the same questions, so
|
|
5
|
+
* they ask them through the same hooks: one window is fetched once however many
|
|
6
|
+
* of them are on screen, and a write invalidates what all of them are showing.
|
|
7
|
+
*/
|
|
8
|
+
export {
|
|
9
|
+
type AgendaRange,
|
|
10
|
+
busySpansByDate,
|
|
11
|
+
datesInRange,
|
|
12
|
+
extendRangeEnd,
|
|
13
|
+
extendRangeStart,
|
|
14
|
+
type FreeLookup,
|
|
15
|
+
freeStretchesByDate,
|
|
16
|
+
LEAD_IN,
|
|
17
|
+
LEAD_OUT,
|
|
18
|
+
PAGE,
|
|
19
|
+
rangeAround,
|
|
20
|
+
rangeCovering,
|
|
21
|
+
weekKeyOf,
|
|
22
|
+
weekWindowsOver,
|
|
23
|
+
} from "./agenda-window";
|
|
24
|
+
export {
|
|
25
|
+
type CreateInput,
|
|
26
|
+
createInputFromDraft,
|
|
27
|
+
type DraftRefusal,
|
|
28
|
+
draftFromEvent,
|
|
29
|
+
emptyDraft,
|
|
30
|
+
patchFromDrafts,
|
|
31
|
+
type UpdatePatch,
|
|
32
|
+
} from "./draft";
|
|
33
|
+
export {
|
|
34
|
+
type CalendarInstanceRef,
|
|
35
|
+
calendarInstanceId,
|
|
36
|
+
deviceTimeZone,
|
|
37
|
+
isDrawnInstance,
|
|
38
|
+
readCalendarInstanceId,
|
|
39
|
+
toCalendarDescriptor,
|
|
40
|
+
toCalendarEventData,
|
|
41
|
+
UNZONED_CALENDAR,
|
|
42
|
+
} from "./instance";
|
|
43
|
+
export { rruleFromText, textFromRrule } from "./recurrence-rule";
|
|
44
|
+
export {
|
|
45
|
+
type CalendarResourceText,
|
|
46
|
+
rruleFromIcalData,
|
|
47
|
+
textFromIcalData,
|
|
48
|
+
} from "./resource";
|
|
49
|
+
export {
|
|
50
|
+
type CalendarSelection,
|
|
51
|
+
selectCalendarIds,
|
|
52
|
+
useCalendarSelection,
|
|
53
|
+
useDrawnEvents,
|
|
54
|
+
} from "./selection";
|
|
55
|
+
export {
|
|
56
|
+
type CalendarEventResource,
|
|
57
|
+
useCalendarEvent,
|
|
58
|
+
} from "./useCalendarEvent";
|
|
59
|
+
export {
|
|
60
|
+
CALENDAR_WINDOW_STALE_TIME,
|
|
61
|
+
type CalendarEventWeeksResult,
|
|
62
|
+
type CalendarEventWindowRequest,
|
|
63
|
+
type CalendarEventWindowResult,
|
|
64
|
+
useCalendarEventWeeks,
|
|
65
|
+
useCalendarEventWindow,
|
|
66
|
+
usePrefetchAdjacentWindows,
|
|
67
|
+
} from "./useCalendarEvents";
|
|
68
|
+
export {
|
|
69
|
+
type CalendarFreeBusyResult,
|
|
70
|
+
useCalendarFreeBusyWeeks,
|
|
71
|
+
} from "./useCalendarFreeBusy";
|
|
72
|
+
export { type CalendarsResult, useCalendars } from "./useCalendars";
|
|
73
|
+
export {
|
|
74
|
+
type CalendarWriteOutcome,
|
|
75
|
+
type CalendarWrites,
|
|
76
|
+
type ScopedWrite,
|
|
77
|
+
useCalendarWrites,
|
|
78
|
+
useInvalidateCalendarReads,
|
|
79
|
+
} from "./useCalendarWrites";
|
|
80
|
+
export {
|
|
81
|
+
addDays,
|
|
82
|
+
type CalendarWindow,
|
|
83
|
+
calendarWindow,
|
|
84
|
+
calendarWindowOfDays,
|
|
85
|
+
isoAt,
|
|
86
|
+
isoAtInZone,
|
|
87
|
+
startOfDay,
|
|
88
|
+
} from "./window";
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The API's shapes, as the calendar kit draws them.
|
|
3
|
+
*
|
|
4
|
+
* One place does the translating, so the grid, the strip and the reading pane
|
|
5
|
+
* cannot disagree about what an occurrence is. Nothing here expands a
|
|
6
|
+
* recurrence: the server returns instances, and this reads them.
|
|
7
|
+
*/
|
|
8
|
+
import type {
|
|
9
|
+
RemitImapCalendarEventInstance,
|
|
10
|
+
RemitImapCalendarResponse,
|
|
11
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
12
|
+
import type {
|
|
13
|
+
CalendarColorId,
|
|
14
|
+
CalendarDescriptor,
|
|
15
|
+
CalendarEventData,
|
|
16
|
+
ZoneCertainty,
|
|
17
|
+
} from "@remit/ui";
|
|
18
|
+
|
|
19
|
+
/** The device's own clock, the only zone a browser can honestly draw in. */
|
|
20
|
+
export const deviceTimeZone = (): string =>
|
|
21
|
+
Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A collection that names no zone of its own, spelled the way the API spells
|
|
25
|
+
* it: an empty string, which both ends read as UTC.
|
|
26
|
+
*
|
|
27
|
+
* There is deliberately no substitute value. The server checks a TZID against
|
|
28
|
+
* `Intl.supportedValuesOf("timeZone")`, which is the canonical zone list and so
|
|
29
|
+
* holds no spelling of UTC at all — not `UTC`, not `Etc/UTC` — and refuses
|
|
30
|
+
* anything else. Sending nothing is what the API asks for, and it is the only
|
|
31
|
+
* thing it accepts.
|
|
32
|
+
*
|
|
33
|
+
* The device's zone is not the fallback either. The listing returns this
|
|
34
|
+
* collection's occurrences on its own clock, so answering with the reader's
|
|
35
|
+
* would rewrite every time they saved by the difference between the two.
|
|
36
|
+
*/
|
|
37
|
+
export const UNZONED_CALENDAR = "";
|
|
38
|
+
|
|
39
|
+
const COLORS: Record<string, CalendarColorId> = {
|
|
40
|
+
Cal1: "cal-1",
|
|
41
|
+
Cal2: "cal-2",
|
|
42
|
+
Cal3: "cal-3",
|
|
43
|
+
Cal4: "cal-4",
|
|
44
|
+
Cal5: "cal-5",
|
|
45
|
+
Cal6: "cal-6",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const ZONES: Record<string, ZoneCertainty> = {
|
|
49
|
+
Local: "local",
|
|
50
|
+
Explicit: "explicit",
|
|
51
|
+
Ambiguous: "ambiguous",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The address of one occurrence, as a single string, because the grid selects
|
|
56
|
+
* an event by one id. A series draws many occurrences from one resource, so the
|
|
57
|
+
* resource id alone would key every Monday standup the same and select all of
|
|
58
|
+
* them at once.
|
|
59
|
+
*/
|
|
60
|
+
const OCCURRENCE_SEPARATOR = "#";
|
|
61
|
+
|
|
62
|
+
export function calendarInstanceId(
|
|
63
|
+
calendarObjectId: string,
|
|
64
|
+
recurrenceId: string,
|
|
65
|
+
): string {
|
|
66
|
+
return recurrenceId === ""
|
|
67
|
+
? calendarObjectId
|
|
68
|
+
: `${calendarObjectId}${OCCURRENCE_SEPARATOR}${recurrenceId}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface CalendarInstanceRef {
|
|
72
|
+
calendarObjectId: string;
|
|
73
|
+
/** `""` on a resource that does not recur. */
|
|
74
|
+
recurrenceId: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function readCalendarInstanceId(id: string): CalendarInstanceRef {
|
|
78
|
+
const cut = id.indexOf(OCCURRENCE_SEPARATOR);
|
|
79
|
+
if (cut === -1) return { calendarObjectId: id, recurrenceId: "" };
|
|
80
|
+
return {
|
|
81
|
+
calendarObjectId: id.slice(0, cut),
|
|
82
|
+
recurrenceId: id.slice(cut + 1),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function toCalendarDescriptor(
|
|
87
|
+
calendar: RemitImapCalendarResponse,
|
|
88
|
+
): CalendarDescriptor {
|
|
89
|
+
return {
|
|
90
|
+
id: calendar.calendarId,
|
|
91
|
+
accountId: calendar.accountConfigId,
|
|
92
|
+
accountLabel: "",
|
|
93
|
+
name: calendar.displayName,
|
|
94
|
+
color: COLORS[calendar.color] ?? "cal-1",
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* One occurrence, drawn. `location`, `notes` and the guest list are projections
|
|
100
|
+
* the listing does not carry — the reading pane fetches the resource itself for
|
|
101
|
+
* those, and a grid chip has no room for them anyway.
|
|
102
|
+
*/
|
|
103
|
+
export function toCalendarEventData(
|
|
104
|
+
instance: RemitImapCalendarEventInstance,
|
|
105
|
+
timeZone: string,
|
|
106
|
+
): CalendarEventData {
|
|
107
|
+
return {
|
|
108
|
+
id: calendarInstanceId(instance.calendarObjectId, instance.recurrenceId),
|
|
109
|
+
calendarId: instance.calendarId,
|
|
110
|
+
title: instance.summary,
|
|
111
|
+
start: instance.start,
|
|
112
|
+
end: instance.end,
|
|
113
|
+
allDay: instance.allDay,
|
|
114
|
+
location: "",
|
|
115
|
+
notes: "",
|
|
116
|
+
attendees: [],
|
|
117
|
+
myRsvp: "accepted",
|
|
118
|
+
threadId: "",
|
|
119
|
+
threadSubject: "",
|
|
120
|
+
timeZone,
|
|
121
|
+
zoneCertainty: ZONES[instance.zoneCertainty] ?? "explicit",
|
|
122
|
+
recurrenceRule: instance.hasRecurrence ? "Repeats" : "",
|
|
123
|
+
seriesId: instance.hasRecurrence ? instance.icalUid : "",
|
|
124
|
+
seriesException: false,
|
|
125
|
+
status: instance.status === "Tentative" ? "tentative" : "confirmed",
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A cancelled occurrence is not a lighter shade of confirmed, and the kit has
|
|
131
|
+
* no third state to draw it in, so it is left off the calendar entirely rather
|
|
132
|
+
* than shown as an event that is still happening.
|
|
133
|
+
*/
|
|
134
|
+
export const isDrawnInstance = (
|
|
135
|
+
instance: RemitImapCalendarEventInstance,
|
|
136
|
+
): boolean => instance.status !== "Cancelled";
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repeat rules, both ways.
|
|
3
|
+
*
|
|
4
|
+
* The picker only ever offers the sentences `repeatChoices` derives, so the
|
|
5
|
+
* table has to cover exactly those and refuse everything else — a sentence
|
|
6
|
+
* turned into a rule it does not mean is an event that repeats on days nobody
|
|
7
|
+
* chose, and nothing on screen would say so.
|
|
8
|
+
*
|
|
9
|
+
* A rule written by something else — a native client, an invitation — must come
|
|
10
|
+
* back unread rather than snapped to the nearest sentence.
|
|
11
|
+
*/
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { describe, it } from "node:test";
|
|
14
|
+
import { repeatChoices } from "@remit/ui";
|
|
15
|
+
import { rruleFromText, textFromRrule } from "./recurrence-rule";
|
|
16
|
+
|
|
17
|
+
const DATE = "2026-06-10";
|
|
18
|
+
const TIME = "09:15";
|
|
19
|
+
|
|
20
|
+
describe("every rule the picker offers", () => {
|
|
21
|
+
it("becomes an RRULE, and reads back as the sentence it was picked from", () => {
|
|
22
|
+
for (const choice of repeatChoices(DATE, TIME)) {
|
|
23
|
+
const rule = rruleFromText(choice);
|
|
24
|
+
assert.ok(
|
|
25
|
+
rule !== undefined && rule !== "",
|
|
26
|
+
`no rule for the offered choice "${choice}"`,
|
|
27
|
+
);
|
|
28
|
+
assert.equal(textFromRrule(rule, TIME), choice);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("says what each one means", () => {
|
|
33
|
+
assert.equal(rruleFromText("Every day, 09:15"), "FREQ=DAILY");
|
|
34
|
+
assert.equal(
|
|
35
|
+
rruleFromText("Every weekday, 09:15"),
|
|
36
|
+
"FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
|
|
37
|
+
);
|
|
38
|
+
assert.equal(
|
|
39
|
+
rruleFromText("Every week on Wednesday, 09:15"),
|
|
40
|
+
"FREQ=WEEKLY;BYDAY=WE",
|
|
41
|
+
);
|
|
42
|
+
assert.equal(
|
|
43
|
+
rruleFromText("Every month on the second Wednesday, 09:15"),
|
|
44
|
+
"FREQ=MONTHLY;BYDAY=2WE",
|
|
45
|
+
);
|
|
46
|
+
assert.equal(
|
|
47
|
+
rruleFromText("Every year on 10 June, 09:15"),
|
|
48
|
+
"FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=10",
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("reads the last week of a month as the rule that means it", () => {
|
|
53
|
+
assert.equal(
|
|
54
|
+
rruleFromText("Every month on the last Friday"),
|
|
55
|
+
"FREQ=MONTHLY;BYDAY=-1FR",
|
|
56
|
+
);
|
|
57
|
+
assert.equal(
|
|
58
|
+
textFromRrule("FREQ=MONTHLY;BYDAY=-1FR", ""),
|
|
59
|
+
"Every month on the last Friday",
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("no repeat", () => {
|
|
65
|
+
it("is the empty rule rather than a refusal", () => {
|
|
66
|
+
assert.equal(rruleFromText(""), "");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("a sentence this table does not cover", () => {
|
|
71
|
+
it("is refused rather than guessed at", () => {
|
|
72
|
+
assert.equal(rruleFromText("Every other Tuesday"), undefined);
|
|
73
|
+
assert.equal(rruleFromText("Every week on Caturday"), undefined);
|
|
74
|
+
assert.equal(rruleFromText("Repeats"), undefined);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("a rule written by something else", () => {
|
|
79
|
+
it("comes back unread rather than snapped to the nearest sentence", () => {
|
|
80
|
+
assert.equal(
|
|
81
|
+
textFromRrule("FREQ=WEEKLY;INTERVAL=2;BYDAY=TU", ""),
|
|
82
|
+
undefined,
|
|
83
|
+
);
|
|
84
|
+
assert.equal(textFromRrule("FREQ=DAILY;COUNT=10", ""), undefined);
|
|
85
|
+
assert.equal(
|
|
86
|
+
textFromRrule("FREQ=DAILY;UNTIL=20261231T000000Z", ""),
|
|
87
|
+
undefined,
|
|
88
|
+
);
|
|
89
|
+
assert.equal(textFromRrule("FREQ=HOURLY", ""), undefined);
|
|
90
|
+
assert.equal(textFromRrule("FREQ=WEEKLY", ""), undefined);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repeat rules, between the sentence a person picks and the RRULE the API
|
|
3
|
+
* stores.
|
|
4
|
+
*
|
|
5
|
+
* The kit's picker deals only in sentences — "Every weekday, 09:15" — and the
|
|
6
|
+
* calendar API deals only in RFC 5545 rules. This is the single table read both
|
|
7
|
+
* ways, so a rule this client wrote reads back as the sentence it was picked
|
|
8
|
+
* from. A rule written by anything else, a native client or an invitation, is
|
|
9
|
+
* not forced into one of these sentences: it comes back unread, and the surface
|
|
10
|
+
* says the event repeats without claiming to know how.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const WEEKDAYS = [
|
|
14
|
+
"Sunday",
|
|
15
|
+
"Monday",
|
|
16
|
+
"Tuesday",
|
|
17
|
+
"Wednesday",
|
|
18
|
+
"Thursday",
|
|
19
|
+
"Friday",
|
|
20
|
+
"Saturday",
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const BYDAY = ["SU", "MO", "TU", "WE", "TH", "FR", "SA"];
|
|
24
|
+
|
|
25
|
+
const MONTHS = [
|
|
26
|
+
"January",
|
|
27
|
+
"February",
|
|
28
|
+
"March",
|
|
29
|
+
"April",
|
|
30
|
+
"May",
|
|
31
|
+
"June",
|
|
32
|
+
"July",
|
|
33
|
+
"August",
|
|
34
|
+
"September",
|
|
35
|
+
"October",
|
|
36
|
+
"November",
|
|
37
|
+
"December",
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const ORDINALS = ["first", "second", "third", "fourth", "last"];
|
|
41
|
+
|
|
42
|
+
const ORDINAL_NUMBERS = [1, 2, 3, 4, -1];
|
|
43
|
+
|
|
44
|
+
const WEEKDAY_SET = "MO,TU,WE,TH,FR";
|
|
45
|
+
|
|
46
|
+
/** The clock the picker appends, stripped so the rule itself can be read. */
|
|
47
|
+
const withoutClock = (text: string): string =>
|
|
48
|
+
text.replace(/,\s*\d{2}:\d{2}$/, "").trim();
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The RRULE a picked sentence means, or `undefined` for a sentence this table
|
|
52
|
+
* does not cover. A caller refuses the write rather than storing a rule it
|
|
53
|
+
* guessed at.
|
|
54
|
+
*/
|
|
55
|
+
export function rruleFromText(text: string): string | undefined {
|
|
56
|
+
const rule = withoutClock(text);
|
|
57
|
+
if (rule === "") return "";
|
|
58
|
+
if (rule === "Every day") return "FREQ=DAILY";
|
|
59
|
+
if (rule === "Every weekday") return `FREQ=WEEKLY;BYDAY=${WEEKDAY_SET}`;
|
|
60
|
+
|
|
61
|
+
const weekly = rule.match(/^Every week on (\w+)$/);
|
|
62
|
+
if (weekly) {
|
|
63
|
+
const day = WEEKDAYS.indexOf(weekly[1]);
|
|
64
|
+
return day === -1 ? undefined : `FREQ=WEEKLY;BYDAY=${BYDAY[day]}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const monthly = rule.match(/^Every month on the (\w+) (\w+)$/);
|
|
68
|
+
if (monthly) {
|
|
69
|
+
const ordinal = ORDINALS.indexOf(monthly[1]);
|
|
70
|
+
const day = WEEKDAYS.indexOf(monthly[2]);
|
|
71
|
+
if (ordinal === -1 || day === -1) return undefined;
|
|
72
|
+
return `FREQ=MONTHLY;BYDAY=${ORDINAL_NUMBERS[ordinal]}${BYDAY[day]}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const yearly = rule.match(/^Every year on (\d{1,2}) (\w+)$/);
|
|
76
|
+
if (yearly) {
|
|
77
|
+
const month = MONTHS.indexOf(yearly[2]);
|
|
78
|
+
if (month === -1) return undefined;
|
|
79
|
+
return `FREQ=YEARLY;BYMONTH=${month + 1};BYMONTHDAY=${Number(yearly[1])}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const clockSuffix = (startTime: string): string =>
|
|
86
|
+
startTime === "" ? "" : `, ${startTime}`;
|
|
87
|
+
|
|
88
|
+
/** The sentence an RRULE was picked from, or `undefined` where it was not. */
|
|
89
|
+
export function textFromRrule(
|
|
90
|
+
rule: string,
|
|
91
|
+
startTime: string,
|
|
92
|
+
): string | undefined {
|
|
93
|
+
const parts = new Map(
|
|
94
|
+
rule
|
|
95
|
+
.split(";")
|
|
96
|
+
.map((part) => part.split("="))
|
|
97
|
+
.filter((pair): pair is [string, string] => pair.length === 2)
|
|
98
|
+
.map(([name, value]) => [name.toUpperCase(), value.toUpperCase()]),
|
|
99
|
+
);
|
|
100
|
+
const clock = clockSuffix(startTime);
|
|
101
|
+
const frequency = parts.get("FREQ");
|
|
102
|
+
const byDay = parts.get("BYDAY");
|
|
103
|
+
|
|
104
|
+
if (parts.has("INTERVAL") && parts.get("INTERVAL") !== "1") return undefined;
|
|
105
|
+
if (parts.has("UNTIL") || parts.has("COUNT")) return undefined;
|
|
106
|
+
|
|
107
|
+
if (frequency === "DAILY") return `Every day${clock}`;
|
|
108
|
+
|
|
109
|
+
if (frequency === "WEEKLY") {
|
|
110
|
+
if (byDay === undefined) return undefined;
|
|
111
|
+
if (byDay === WEEKDAY_SET) return `Every weekday${clock}`;
|
|
112
|
+
const day = BYDAY.indexOf(byDay);
|
|
113
|
+
return day === -1 ? undefined : `Every week on ${WEEKDAYS[day]}${clock}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (frequency === "MONTHLY") {
|
|
117
|
+
const match = byDay?.match(/^(-?\d)([A-Z]{2})$/);
|
|
118
|
+
if (!match) return undefined;
|
|
119
|
+
const ordinal = ORDINAL_NUMBERS.indexOf(Number(match[1]));
|
|
120
|
+
const day = BYDAY.indexOf(match[2]);
|
|
121
|
+
if (ordinal === -1 || day === -1) return undefined;
|
|
122
|
+
return `Every month on the ${ORDINALS[ordinal]} ${WEEKDAYS[day]}${clock}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (frequency === "YEARLY") {
|
|
126
|
+
const month = Number(parts.get("BYMONTH"));
|
|
127
|
+
const day = Number(parts.get("BYMONTHDAY"));
|
|
128
|
+
if (!month || !day || month > 12) return undefined;
|
|
129
|
+
return `Every year on ${day} ${MONTHS[month - 1]}${clock}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading three properties off a stored resource.
|
|
3
|
+
*
|
|
4
|
+
* RFC 5545 folds a long line by breaking it and indenting the remainder, so a
|
|
5
|
+
* value read line-first stops halfway and looks like a shorter value rather
|
|
6
|
+
* than a broken one — a rule truncated that way turns a weekly meeting into a
|
|
7
|
+
* daily one on screen with nothing to say it happened.
|
|
8
|
+
*/
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { describe, it } from "node:test";
|
|
11
|
+
import { rruleFromIcalData, textFromIcalData } from "./resource";
|
|
12
|
+
|
|
13
|
+
const ics = (...lines: string[]): string =>
|
|
14
|
+
[
|
|
15
|
+
"BEGIN:VCALENDAR",
|
|
16
|
+
"BEGIN:VEVENT",
|
|
17
|
+
...lines,
|
|
18
|
+
"END:VEVENT",
|
|
19
|
+
"END:VCALENDAR",
|
|
20
|
+
].join("\r\n");
|
|
21
|
+
|
|
22
|
+
describe("the rule inside a resource", () => {
|
|
23
|
+
it("is read whole across a fold", () => {
|
|
24
|
+
assert.equal(
|
|
25
|
+
rruleFromIcalData(ics("RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,", " TH,FR")),
|
|
26
|
+
"FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR",
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("is empty where the event does not repeat", () => {
|
|
31
|
+
assert.equal(rruleFromIcalData(ics("SUMMARY:Roadmap review")), "");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("the text a listing does not carry", () => {
|
|
36
|
+
it("comes back unescaped", () => {
|
|
37
|
+
const text = textFromIcalData(
|
|
38
|
+
ics(
|
|
39
|
+
"LOCATION:Room Zuid\\, second floor",
|
|
40
|
+
"DESCRIPTION:Bring the numbers.\\nAnd the slides.",
|
|
41
|
+
),
|
|
42
|
+
);
|
|
43
|
+
assert.equal(text.location, "Room Zuid, second floor");
|
|
44
|
+
assert.equal(text.description, "Bring the numbers.\nAnd the slides.");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("reads a property that carries parameters", () => {
|
|
48
|
+
assert.equal(
|
|
49
|
+
textFromIcalData(ics("DESCRIPTION;LANGUAGE=en:Plain text")).description,
|
|
50
|
+
"Plain text",
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A parameter value may be quoted precisely so it can hold a colon, which is
|
|
56
|
+
* what a URI does. Splitting the line on its first colon hands back the tail
|
|
57
|
+
* of that URI as though it were the property's own value.
|
|
58
|
+
*/
|
|
59
|
+
it("reads past a colon inside a quoted parameter", () => {
|
|
60
|
+
assert.equal(
|
|
61
|
+
textFromIcalData(ics('DESCRIPTION;ALTREP="cid:part1.0":Plain text'))
|
|
62
|
+
.description,
|
|
63
|
+
"Plain text",
|
|
64
|
+
);
|
|
65
|
+
assert.equal(
|
|
66
|
+
textFromIcalData(
|
|
67
|
+
ics('LOCATION;X-ADDRESS="https://maps.example/x:1":Room Zuid'),
|
|
68
|
+
).location,
|
|
69
|
+
"Room Zuid",
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("does not answer for a property whose name merely starts the same", () => {
|
|
74
|
+
assert.equal(textFromIcalData(ics("LOCATION-TYPE:room")).location, "");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("takes the master's value rather than an override's", () => {
|
|
78
|
+
const overridden = [
|
|
79
|
+
"BEGIN:VCALENDAR",
|
|
80
|
+
"BEGIN:VEVENT",
|
|
81
|
+
"LOCATION:Room Zuid",
|
|
82
|
+
"END:VEVENT",
|
|
83
|
+
"BEGIN:VEVENT",
|
|
84
|
+
"RECURRENCE-ID:20260610T090000Z",
|
|
85
|
+
"LOCATION:Room Noord",
|
|
86
|
+
"END:VEVENT",
|
|
87
|
+
"END:VCALENDAR",
|
|
88
|
+
].join("\r\n");
|
|
89
|
+
assert.equal(textFromIcalData(overridden).location, "Room Zuid");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("is empty where the resource says nothing", () => {
|
|
93
|
+
assert.deepEqual(textFromIcalData(ics("SUMMARY:Standup")), {
|
|
94
|
+
location: "",
|
|
95
|
+
description: "",
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The few properties a reading pane needs that the occurrence listing does not
|
|
3
|
+
* carry, read off the stored resource.
|
|
4
|
+
*
|
|
5
|
+
* This is not a parser and never becomes one. It reads three named properties
|
|
6
|
+
* of the master VEVENT so the pane can show a location and the editor can send
|
|
7
|
+
* one back unchanged; anything structural — recurrence expansion above all —
|
|
8
|
+
* belongs to the server, which is the only thing that expands a series.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* RFC 5545 breaks a long property across lines with a leading space or tab, so
|
|
13
|
+
* the folds are joined before anything is matched — a value read fold-first
|
|
14
|
+
* stops halfway through and looks like a shorter value rather than a broken one.
|
|
15
|
+
*/
|
|
16
|
+
const unfold = (icalData: string): string =>
|
|
17
|
+
icalData.replace(/\r?\n[ \t]/g, "");
|
|
18
|
+
|
|
19
|
+
/** RFC 5545 3.3.11: the four escapes a TEXT value carries. */
|
|
20
|
+
const unescapeText = (value: string): string =>
|
|
21
|
+
value
|
|
22
|
+
.replace(/\\n/gi, "\n")
|
|
23
|
+
.replace(/\\,/g, ",")
|
|
24
|
+
.replace(/\\;/g, ";")
|
|
25
|
+
.replace(/\\\\/g, "\\");
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Where a content line's parameters end and its value begins.
|
|
29
|
+
*
|
|
30
|
+
* The separator is the first colon outside a quoted string, not the first colon
|
|
31
|
+
* (RFC 5545 3.1). A parameter value is allowed to be quoted precisely so it can
|
|
32
|
+
* hold one — `ALTREP="cid:part1"` is ordinary — and splitting on the first
|
|
33
|
+
* colon hands back the tail of a URI as though it were the property's value.
|
|
34
|
+
*/
|
|
35
|
+
function valueAt(line: string, name: string): string | undefined {
|
|
36
|
+
let quoted = false;
|
|
37
|
+
for (let index = name.length; index < line.length; index += 1) {
|
|
38
|
+
const character = line[index];
|
|
39
|
+
if (index === name.length && character !== ";" && character !== ":")
|
|
40
|
+
return undefined;
|
|
41
|
+
if (character === '"') {
|
|
42
|
+
quoted = !quoted;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (!quoted && character === ":") return line.slice(index + 1).trim();
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The first occurrence of a property, parameters and all. The master VEVENT is
|
|
52
|
+
* written before its overrides, so the first match is the series' own value.
|
|
53
|
+
*/
|
|
54
|
+
function firstProperty(icalData: string, name: string): string {
|
|
55
|
+
for (const line of unfold(icalData).split(/\r?\n/)) {
|
|
56
|
+
if (!line.startsWith(name)) continue;
|
|
57
|
+
const value = valueAt(line, name);
|
|
58
|
+
if (value !== undefined) return value;
|
|
59
|
+
}
|
|
60
|
+
return "";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const rruleFromIcalData = (icalData: string): string =>
|
|
64
|
+
firstProperty(icalData, "RRULE");
|
|
65
|
+
|
|
66
|
+
export interface CalendarResourceText {
|
|
67
|
+
location: string;
|
|
68
|
+
description: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const textFromIcalData = (icalData: string): CalendarResourceText => ({
|
|
72
|
+
location: unescapeText(firstProperty(icalData, "LOCATION")),
|
|
73
|
+
description: unescapeText(firstProperty(icalData, "DESCRIPTION")),
|
|
74
|
+
});
|