@craftguild/jscalendar 0.1.0
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/LICENSE.md +7 -0
- package/README.md +295 -0
- package/dist/__tests__/calendar-extra.test.d.ts +1 -0
- package/dist/__tests__/calendar-extra.test.js +185 -0
- package/dist/__tests__/calendar.test.d.ts +1 -0
- package/dist/__tests__/calendar.test.js +104 -0
- package/dist/__tests__/ical-extra.test.d.ts +1 -0
- package/dist/__tests__/ical-extra.test.js +87 -0
- package/dist/__tests__/ical.test.d.ts +1 -0
- package/dist/__tests__/ical.test.js +72 -0
- package/dist/__tests__/index.test.d.ts +1 -0
- package/dist/__tests__/index.test.js +9 -0
- package/dist/__tests__/patch.test.d.ts +1 -0
- package/dist/__tests__/patch.test.js +47 -0
- package/dist/__tests__/recurrence.test.d.ts +1 -0
- package/dist/__tests__/recurrence.test.js +498 -0
- package/dist/__tests__/search.test.d.ts +1 -0
- package/dist/__tests__/search.test.js +237 -0
- package/dist/__tests__/timezones.test.d.ts +1 -0
- package/dist/__tests__/timezones.test.js +12 -0
- package/dist/__tests__/utils.test.d.ts +1 -0
- package/dist/__tests__/utils.test.js +116 -0
- package/dist/__tests__/validation.test.d.ts +1 -0
- package/dist/__tests__/validation.test.js +91 -0
- package/dist/ical.d.ts +7 -0
- package/dist/ical.js +202 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/jscal.d.ts +129 -0
- package/dist/jscal.js +504 -0
- package/dist/patch.d.ts +5 -0
- package/dist/patch.js +91 -0
- package/dist/recurrence.d.ts +15 -0
- package/dist/recurrence.js +674 -0
- package/dist/search.d.ts +14 -0
- package/dist/search.js +208 -0
- package/dist/timezones.d.ts +4 -0
- package/dist/timezones.js +441 -0
- package/dist/types.d.ts +219 -0
- package/dist/types.js +1 -0
- package/dist/utils.d.ts +10 -0
- package/dist/utils.js +80 -0
- package/dist/validate.d.ts +6 -0
- package/dist/validate.js +745 -0
- package/package.json +33 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { filterByDateRange, filterByText, findByUid, groupByType, } from "../search.js";
|
|
3
|
+
import { JsCal } from "../jscal.js";
|
|
4
|
+
const event = {
|
|
5
|
+
"@type": "Event",
|
|
6
|
+
uid: "e1",
|
|
7
|
+
updated: "2026-02-01T00:00:00Z",
|
|
8
|
+
title: "Sprint planning",
|
|
9
|
+
description: "Discuss backlog",
|
|
10
|
+
start: "2026-02-01T10:00:00",
|
|
11
|
+
duration: "PT1H",
|
|
12
|
+
};
|
|
13
|
+
const task = {
|
|
14
|
+
"@type": "Task",
|
|
15
|
+
uid: "t1",
|
|
16
|
+
updated: "2026-02-01T00:00:00Z",
|
|
17
|
+
title: "Write notes",
|
|
18
|
+
start: "2026-02-02T09:00:00",
|
|
19
|
+
};
|
|
20
|
+
describe("search helpers", () => {
|
|
21
|
+
it("finds by uid", () => {
|
|
22
|
+
expect(findByUid([event, task], "t1")?.uid).toBe("t1");
|
|
23
|
+
});
|
|
24
|
+
it("groups by type", () => {
|
|
25
|
+
const grouped = groupByType([event, task]);
|
|
26
|
+
const events = grouped.Event ?? [];
|
|
27
|
+
const tasks = grouped.Task ?? [];
|
|
28
|
+
expect(events.length).toBe(1);
|
|
29
|
+
expect(tasks.length).toBe(1);
|
|
30
|
+
});
|
|
31
|
+
it("filters by text", () => {
|
|
32
|
+
const results = filterByText([event, task], "backlog");
|
|
33
|
+
expect(results.length).toBe(1);
|
|
34
|
+
const first = results[0];
|
|
35
|
+
expect(first).toBeDefined();
|
|
36
|
+
if (!first)
|
|
37
|
+
throw new Error("Missing result");
|
|
38
|
+
expect(first.uid).toBe("e1");
|
|
39
|
+
});
|
|
40
|
+
it("returns all items for empty text query", () => {
|
|
41
|
+
const results = filterByText([event, task], " ");
|
|
42
|
+
expect(results.length).toBe(2);
|
|
43
|
+
});
|
|
44
|
+
it("indexes locations, virtual locations, and participants for text search", () => {
|
|
45
|
+
const item = new JsCal.Event({
|
|
46
|
+
start: "2026-02-01T10:00:00",
|
|
47
|
+
title: "Meeting",
|
|
48
|
+
locations: {
|
|
49
|
+
l1: { "@type": "Location", name: "Room A", description: "Main room" },
|
|
50
|
+
},
|
|
51
|
+
virtualLocations: {
|
|
52
|
+
v1: { "@type": "VirtualLocation", name: "Zoom", uri: "https://example.com" },
|
|
53
|
+
},
|
|
54
|
+
participants: {
|
|
55
|
+
p1: { "@type": "Participant", roles: { attendee: true }, name: "Alice", email: "a@example.com" },
|
|
56
|
+
},
|
|
57
|
+
}).eject();
|
|
58
|
+
const results = filterByText([item], "example.com");
|
|
59
|
+
expect(results.length).toBe(1);
|
|
60
|
+
});
|
|
61
|
+
it("accepts JsCal instances via JsCal.filterByText", () => {
|
|
62
|
+
const instance = new JsCal.Event({ start: "2026-02-01T10:00:00", title: "Planning" });
|
|
63
|
+
const results = JsCal.filterByText([instance], "planning");
|
|
64
|
+
expect(results.length).toBe(1);
|
|
65
|
+
expect(results[0]?.uid).toBe(instance.data.uid);
|
|
66
|
+
});
|
|
67
|
+
it("uses JsCal.findByUid and JsCal.filterByType wrappers", () => {
|
|
68
|
+
const items = [event, task];
|
|
69
|
+
const found = JsCal.findByUid(items, "t1");
|
|
70
|
+
expect(found?.uid).toBe("t1");
|
|
71
|
+
const events = JsCal.filterByType(items, "Event");
|
|
72
|
+
expect(events.length).toBe(1);
|
|
73
|
+
expect(events[0]?.uid).toBe("e1");
|
|
74
|
+
});
|
|
75
|
+
it("filters by date range", () => {
|
|
76
|
+
const results = filterByDateRange([event, task], {
|
|
77
|
+
start: "2026-02-01T09:30:00",
|
|
78
|
+
end: "2026-02-01T10:30:00",
|
|
79
|
+
});
|
|
80
|
+
expect(results.length).toBe(1);
|
|
81
|
+
const first = results[0];
|
|
82
|
+
expect(first).toBeDefined();
|
|
83
|
+
if (!first)
|
|
84
|
+
throw new Error("Missing result");
|
|
85
|
+
expect(first.uid).toBe("e1");
|
|
86
|
+
});
|
|
87
|
+
it("uses JsCal.groupByType wrapper", () => {
|
|
88
|
+
const grouped = JsCal.groupByType([event, task]);
|
|
89
|
+
expect(Object.keys(grouped)).toContain("Event");
|
|
90
|
+
expect(Object.keys(grouped)).toContain("Task");
|
|
91
|
+
});
|
|
92
|
+
it("includes incomparable when requested", () => {
|
|
93
|
+
const noDateTask = new JsCal.Task({ uid: "t2", title: "No date" });
|
|
94
|
+
const results = JsCal.filterByDateRange([noDateTask, event], { start: "2026-02-01T00:00:00" }, { includeIncomparable: true });
|
|
95
|
+
expect(results.map((item) => item.uid).sort()).toEqual(["e1", "t2"]);
|
|
96
|
+
});
|
|
97
|
+
it("computes group range from entries", () => {
|
|
98
|
+
const group = new JsCal.Group({
|
|
99
|
+
uid: "g1",
|
|
100
|
+
entries: [
|
|
101
|
+
new JsCal.Event({ uid: "e2", start: "2026-02-01T10:00:00" }).eject(),
|
|
102
|
+
new JsCal.Event({ uid: "e3", start: "2026-02-03T10:00:00" }).eject(),
|
|
103
|
+
],
|
|
104
|
+
});
|
|
105
|
+
const results = JsCal.filterByDateRange([group], { start: "2026-02-02T00:00:00" });
|
|
106
|
+
expect(results.map((item) => item.uid)).toEqual(["g1"]);
|
|
107
|
+
});
|
|
108
|
+
it("excludes groups with no dated entries", () => {
|
|
109
|
+
const group = new JsCal.Group({
|
|
110
|
+
uid: "g2",
|
|
111
|
+
entries: [],
|
|
112
|
+
});
|
|
113
|
+
const results = JsCal.filterByDateRange([group], { start: "2026-02-01T00:00:00" });
|
|
114
|
+
expect(results.length).toBe(0);
|
|
115
|
+
});
|
|
116
|
+
it("handles duration ranges with time zones", () => {
|
|
117
|
+
const tzEvent = new JsCal.Event({
|
|
118
|
+
uid: "e4",
|
|
119
|
+
start: "2026-02-01T10:00:00",
|
|
120
|
+
timeZone: "Asia/Tokyo",
|
|
121
|
+
duration: "PT1H",
|
|
122
|
+
});
|
|
123
|
+
const results = JsCal.filterByDateRange([tzEvent], { start: new Date("2026-02-01T01:30:00Z") });
|
|
124
|
+
expect(results.map((item) => item.uid)).toEqual(["e4"]);
|
|
125
|
+
});
|
|
126
|
+
it("converts Date range into event time zone", () => {
|
|
127
|
+
const eventWithTz = new JsCal.Event({
|
|
128
|
+
uid: "e7",
|
|
129
|
+
start: "2026-02-01T10:00:00",
|
|
130
|
+
timeZone: "Asia/Tokyo",
|
|
131
|
+
});
|
|
132
|
+
const results = JsCal.filterByDateRange([eventWithTz], { start: new Date("2026-02-01T01:00:00Z"), end: new Date("2026-02-01T01:00:00Z") });
|
|
133
|
+
expect(results.map((item) => item.uid)).toEqual(["e7"]);
|
|
134
|
+
});
|
|
135
|
+
it("excludes local floating events when Date range is used", () => {
|
|
136
|
+
const floating = new JsCal.Event({
|
|
137
|
+
uid: "e8",
|
|
138
|
+
start: "2026-02-01T10:00:00",
|
|
139
|
+
});
|
|
140
|
+
const results = JsCal.filterByDateRange([floating], { start: new Date("2026-02-01T01:00:00Z") });
|
|
141
|
+
expect(results.length).toBe(0);
|
|
142
|
+
});
|
|
143
|
+
it("handles Date range with task time zone", () => {
|
|
144
|
+
const taskWithTz = new JsCal.Task({
|
|
145
|
+
uid: "t3",
|
|
146
|
+
start: "2026-02-01T10:00:00",
|
|
147
|
+
timeZone: "Asia/Tokyo",
|
|
148
|
+
});
|
|
149
|
+
const results = JsCal.filterByDateRange([taskWithTz], { start: new Date("2026-02-01T01:00:00Z"), end: new Date("2026-02-01T01:00:00Z") });
|
|
150
|
+
expect(results.map((item) => item.uid)).toEqual(["t3"]);
|
|
151
|
+
});
|
|
152
|
+
it("handles Date range for groups using entry time zones", () => {
|
|
153
|
+
const group = new JsCal.Group({
|
|
154
|
+
uid: "g3",
|
|
155
|
+
entries: [
|
|
156
|
+
new JsCal.Event({ uid: "e9", start: "2026-02-01T10:00:00", timeZone: "Asia/Tokyo" }).eject(),
|
|
157
|
+
],
|
|
158
|
+
});
|
|
159
|
+
const results = JsCal.filterByDateRange([group], { start: new Date("2026-02-01T01:00:00Z"), end: new Date("2026-02-01T01:00:00Z") });
|
|
160
|
+
expect(results.map((item) => item.uid)).toEqual(["g3"]);
|
|
161
|
+
});
|
|
162
|
+
it("handles Date range for time zone events and tasks", () => {
|
|
163
|
+
const utcEvent = new JsCal.Event({
|
|
164
|
+
uid: "e10",
|
|
165
|
+
start: "2026-02-01T10:00:00",
|
|
166
|
+
timeZone: "Asia/Tokyo",
|
|
167
|
+
duration: "PT1H",
|
|
168
|
+
});
|
|
169
|
+
const utcTask = new JsCal.Task({
|
|
170
|
+
uid: "t5",
|
|
171
|
+
start: "2026-02-01T10:00:00",
|
|
172
|
+
timeZone: "Asia/Tokyo",
|
|
173
|
+
});
|
|
174
|
+
const results = filterByDateRange([utcEvent.eject(), utcTask.eject()], { start: new Date("2026-02-01T01:00:00Z"), end: new Date("2026-02-01T01:00:00Z") });
|
|
175
|
+
expect(results.map((item) => item.uid).sort()).toEqual(["e10", "t5"]);
|
|
176
|
+
});
|
|
177
|
+
it("handles local duration without UTC end calculation", () => {
|
|
178
|
+
const localEvent = new JsCal.Event({
|
|
179
|
+
uid: "e5",
|
|
180
|
+
start: "2026-02-01T10:00:00",
|
|
181
|
+
duration: "PT1H",
|
|
182
|
+
});
|
|
183
|
+
const results = JsCal.filterByDateRange([localEvent], { end: "2026-02-01T09:00:00" });
|
|
184
|
+
expect(results.length).toBe(0);
|
|
185
|
+
});
|
|
186
|
+
it("excludes items outside range bounds", () => {
|
|
187
|
+
const utcEvent = new JsCal.Event({
|
|
188
|
+
uid: "e6",
|
|
189
|
+
start: "2026-02-01T10:00:00",
|
|
190
|
+
duration: "PT1H",
|
|
191
|
+
});
|
|
192
|
+
const afterRange = filterByDateRange([utcEvent.eject()], { start: "2026-02-01T12:30:00" });
|
|
193
|
+
expect(afterRange.length).toBe(0);
|
|
194
|
+
const beforeRange = filterByDateRange([utcEvent.eject()], { end: "2026-02-01T09:30:00" });
|
|
195
|
+
expect(beforeRange.length).toBe(0);
|
|
196
|
+
});
|
|
197
|
+
it("treats unknown types as incomparable", () => {
|
|
198
|
+
const unknownObject = {
|
|
199
|
+
"@type": "Unknown",
|
|
200
|
+
uid: "u1",
|
|
201
|
+
updated: "2026-02-01T00:00:00Z",
|
|
202
|
+
};
|
|
203
|
+
// @ts-expect-error unknown object type
|
|
204
|
+
const results = filterByDateRange([unknownObject], { start: "2026-02-01T00:00:00Z" }, { includeIncomparable: true });
|
|
205
|
+
expect(results.length).toBe(1);
|
|
206
|
+
});
|
|
207
|
+
it("handles Date range for unknown types", () => {
|
|
208
|
+
const unknownObject = {
|
|
209
|
+
"@type": "Unknown",
|
|
210
|
+
uid: "u2",
|
|
211
|
+
updated: "2026-02-01T00:00:00Z",
|
|
212
|
+
};
|
|
213
|
+
// @ts-expect-error unknown object type
|
|
214
|
+
const included = filterByDateRange([unknownObject], { start: new Date("2026-02-01T00:00:00Z") }, { includeIncomparable: true });
|
|
215
|
+
expect(included.length).toBe(1);
|
|
216
|
+
// @ts-expect-error unknown object type
|
|
217
|
+
const excluded = filterByDateRange([unknownObject], { start: new Date("2026-02-01T00:00:00Z") });
|
|
218
|
+
expect(excluded.length).toBe(0);
|
|
219
|
+
});
|
|
220
|
+
it("handles Date range for local tasks as incomparable", () => {
|
|
221
|
+
const localTask = new JsCal.Task({
|
|
222
|
+
uid: "t4",
|
|
223
|
+
start: "2026-02-01T10:00:00",
|
|
224
|
+
});
|
|
225
|
+
const included = JsCal.filterByDateRange([localTask], { start: new Date("2026-02-01T00:00:00Z") }, { includeIncomparable: true });
|
|
226
|
+
expect(included.length).toBe(1);
|
|
227
|
+
const excluded = JsCal.filterByDateRange([localTask], { start: new Date("2026-02-01T00:00:00Z") });
|
|
228
|
+
expect(excluded.length).toBe(0);
|
|
229
|
+
});
|
|
230
|
+
it("handles incomparable date-times", () => {
|
|
231
|
+
const results = filterByDateRange([event, task], {
|
|
232
|
+
start: "2026-02-01T09:30:00",
|
|
233
|
+
end: "2026-02-01T10:30:00",
|
|
234
|
+
}, { includeIncomparable: true });
|
|
235
|
+
expect(results.length).toBe(1);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { JsCal } from "../jscal.js";
|
|
3
|
+
import { resolveTimeZone } from "../timezones.js";
|
|
4
|
+
describe("time zones", () => {
|
|
5
|
+
it("resolves lowercase inputs", () => {
|
|
6
|
+
expect(resolveTimeZone("asia/tokyo")).toBe("Asia/Tokyo");
|
|
7
|
+
});
|
|
8
|
+
it("throws on unknown time zones", () => {
|
|
9
|
+
// @ts-expect-error invalid time zone input
|
|
10
|
+
expect(() => JsCal.timeZone("Invalid/Zone")).toThrow();
|
|
11
|
+
});
|
|
12
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { compareDateTime, dateTimeInTimeZone, deepClone, durationToMilliseconds, isUtcDateTime, localDateTimeFromDate, localDateTimeToUtcDate, normalizeUtcDateTime, } from "../utils.js";
|
|
3
|
+
import { JsCal } from "../jscal.js";
|
|
4
|
+
describe("utils", () => {
|
|
5
|
+
it("detects UTC date-time", () => {
|
|
6
|
+
expect(isUtcDateTime("2026-02-01T00:00:00Z")).toBe(true);
|
|
7
|
+
expect(isUtcDateTime("2026-02-01T00:00:00")).toBe(false);
|
|
8
|
+
});
|
|
9
|
+
it("normalizes UTC date-time", () => {
|
|
10
|
+
expect(normalizeUtcDateTime("2026-02-01T00:00:00.000Z")).toBe("2026-02-01T00:00:00Z");
|
|
11
|
+
});
|
|
12
|
+
it("formats local date-time from Date", () => {
|
|
13
|
+
const value = localDateTimeFromDate(new Date("2026-02-01T10:00:00Z"));
|
|
14
|
+
expect(value).toMatch(/^2026-02-01T/);
|
|
15
|
+
});
|
|
16
|
+
it("formats date-time in time zone", () => {
|
|
17
|
+
const value = dateTimeInTimeZone(new Date("2026-02-01T10:00:00Z"), "UTC");
|
|
18
|
+
expect(value).toBe("2026-02-01T10:00:00");
|
|
19
|
+
});
|
|
20
|
+
it("rejects invalid LocalDateTime in time zone conversion", () => {
|
|
21
|
+
expect(() => localDateTimeToUtcDate("invalid", "Asia/Tokyo")).toThrow();
|
|
22
|
+
});
|
|
23
|
+
it("compares UTC date-times", () => {
|
|
24
|
+
expect(compareDateTime("2026-02-01T00:00:00Z", "2026-02-01T00:00:00Z")).toBe(0);
|
|
25
|
+
expect(compareDateTime("2026-02-01T00:00:00Z", "2026-02-01T01:00:00Z")).toBe(-1);
|
|
26
|
+
expect(compareDateTime("2026-02-01T01:00:00Z", "2026-02-01T00:00:00Z")).toBe(1);
|
|
27
|
+
});
|
|
28
|
+
it("compares local date-times lexicographically", () => {
|
|
29
|
+
expect(compareDateTime("2026-02-01T00:00:00", "2026-02-01T00:00:00")).toBe(0);
|
|
30
|
+
expect(compareDateTime("2026-02-01T00:00:00", "2026-02-02T00:00:00")).toBe(-1);
|
|
31
|
+
});
|
|
32
|
+
it("returns null when comparing mixed UTC/local", () => {
|
|
33
|
+
expect(compareDateTime("2026-02-01T00:00:00", "2026-02-01T00:00:00Z")).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
it("parses durations", () => {
|
|
36
|
+
expect(durationToMilliseconds("PT1H")).toBe(60 * 60 * 1000);
|
|
37
|
+
expect(durationToMilliseconds("P1DT30M")).toBe(24 * 60 * 60 * 1000 + 30 * 60 * 1000);
|
|
38
|
+
});
|
|
39
|
+
it("rejects invalid durations", () => {
|
|
40
|
+
expect(durationToMilliseconds("invalid")).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
it("builds duration strings", () => {
|
|
43
|
+
expect(JsCal.duration.seconds(90)).toBe("PT1M30S");
|
|
44
|
+
expect(JsCal.duration.minutes(90)).toBe("PT1H30M");
|
|
45
|
+
expect(JsCal.duration.hours(1)).toBe("PT1H");
|
|
46
|
+
expect(JsCal.duration.days(1)).toBe("P1D");
|
|
47
|
+
expect(JsCal.duration.from({ hours: 1, minutes: 15 })).toBe("PT1H15M");
|
|
48
|
+
});
|
|
49
|
+
it("creates base64url ids", () => {
|
|
50
|
+
const id = JsCal.createId();
|
|
51
|
+
expect(id).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
52
|
+
});
|
|
53
|
+
it("falls back to random bytes when randomUUID is unavailable", () => {
|
|
54
|
+
const original = globalThis.crypto;
|
|
55
|
+
const fakeCrypto = {
|
|
56
|
+
getRandomValues(values) {
|
|
57
|
+
for (let i = 0; i < values.length; i += 1) {
|
|
58
|
+
values[i] = i & 0xff;
|
|
59
|
+
}
|
|
60
|
+
return values;
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
Object.defineProperty(globalThis, "crypto", {
|
|
64
|
+
value: fakeCrypto,
|
|
65
|
+
configurable: true,
|
|
66
|
+
});
|
|
67
|
+
try {
|
|
68
|
+
const uid = JsCal.createUid();
|
|
69
|
+
expect(uid).toMatch(/^[0-9a-f-]+$/);
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
Object.defineProperty(globalThis, "crypto", {
|
|
73
|
+
value: original,
|
|
74
|
+
configurable: true,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
it("uses Math.random fallback when crypto is missing", () => {
|
|
79
|
+
const original = globalThis.crypto;
|
|
80
|
+
Object.defineProperty(globalThis, "crypto", {
|
|
81
|
+
value: undefined,
|
|
82
|
+
configurable: true,
|
|
83
|
+
});
|
|
84
|
+
try {
|
|
85
|
+
const id = JsCal.createId();
|
|
86
|
+
expect(id).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
87
|
+
}
|
|
88
|
+
finally {
|
|
89
|
+
Object.defineProperty(globalThis, "crypto", {
|
|
90
|
+
value: original,
|
|
91
|
+
configurable: true,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
it("resolves time zones", () => {
|
|
96
|
+
const tz = JsCal.timeZone("asia/tokyo");
|
|
97
|
+
expect(tz).toBe("Asia/Tokyo");
|
|
98
|
+
expect(JsCal.timeZones.includes("Asia/Tokyo")).toBe(true);
|
|
99
|
+
});
|
|
100
|
+
it("throws when structuredClone is unavailable", () => {
|
|
101
|
+
const original = globalThis.structuredClone;
|
|
102
|
+
Object.defineProperty(globalThis, "structuredClone", {
|
|
103
|
+
value: undefined,
|
|
104
|
+
configurable: true,
|
|
105
|
+
});
|
|
106
|
+
try {
|
|
107
|
+
expect(() => deepClone({ value: 1 })).toThrow();
|
|
108
|
+
}
|
|
109
|
+
finally {
|
|
110
|
+
Object.defineProperty(globalThis, "structuredClone", {
|
|
111
|
+
value: original,
|
|
112
|
+
configurable: true,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { JsCal, ValidationError } from "../index.js";
|
|
3
|
+
describe("validation", () => {
|
|
4
|
+
it("rejects LocalDateTime with time zone offset", () => {
|
|
5
|
+
expect(() => new JsCal.Event({ start: "2026-02-01T10:00:00Z" })).toThrowError("object.start: must not include time zone offset");
|
|
6
|
+
});
|
|
7
|
+
it("rejects UTCDateTime with trailing zero fractional seconds", () => {
|
|
8
|
+
expect(() => new JsCal.Event({
|
|
9
|
+
start: "2026-02-01T10:00:00",
|
|
10
|
+
updated: "2026-02-01T00:00:00.120Z",
|
|
11
|
+
})).toThrowError("object.updated: fractional seconds must not have trailing zeros");
|
|
12
|
+
});
|
|
13
|
+
it("rejects invalid description content type", () => {
|
|
14
|
+
expect(() => new JsCal.Event({
|
|
15
|
+
start: "2026-02-01T10:00:00",
|
|
16
|
+
descriptionContentType: "application/json",
|
|
17
|
+
})).toThrowError("object.descriptionContentType: must be a text/* media type");
|
|
18
|
+
});
|
|
19
|
+
it("allows validation to be disabled for create, update, and patch", () => {
|
|
20
|
+
const event = new JsCal.Event({ start: "2026-02-01T10:00:00Z" }, { validate: false });
|
|
21
|
+
expect(event.get("start")).toBe("2026-02-01T10:00:00Z");
|
|
22
|
+
event.update({ start: "2026-02-01T10:00:00Z" }, { validate: false });
|
|
23
|
+
event.patch({ "/start": "2026-02-01T10:00:00Z" }, { validate: false });
|
|
24
|
+
});
|
|
25
|
+
it("throws ValidationError with path and message", () => {
|
|
26
|
+
expect(() => new JsCal.Event({ start: "2026-02-01T10:00:00Z" })).toThrowError(ValidationError);
|
|
27
|
+
});
|
|
28
|
+
it("accepts nested objects with valid ids and media types", () => {
|
|
29
|
+
const event = new JsCal.Event({
|
|
30
|
+
uid: "evt_1",
|
|
31
|
+
start: "2026-02-01T10:00:00",
|
|
32
|
+
descriptionContentType: "text/plain; charset=utf-8",
|
|
33
|
+
locations: {
|
|
34
|
+
loc1: { "@type": "Location", name: "Room A" },
|
|
35
|
+
},
|
|
36
|
+
virtualLocations: {
|
|
37
|
+
v1: { "@type": "VirtualLocation", uri: "https://example.com" },
|
|
38
|
+
},
|
|
39
|
+
links: {
|
|
40
|
+
l1: { "@type": "Link", href: "https://example.com", contentType: "text/plain; charset=utf-8" },
|
|
41
|
+
},
|
|
42
|
+
participants: {
|
|
43
|
+
p1: { "@type": "Participant", roles: { attendee: true }, email: "a@example.com" },
|
|
44
|
+
},
|
|
45
|
+
alerts: {
|
|
46
|
+
a1: {
|
|
47
|
+
"@type": "Alert",
|
|
48
|
+
trigger: { "@type": "OffsetTrigger", offset: "-PT15M" },
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
relatedTo: {
|
|
52
|
+
rel1: { "@type": "Relation", relation: { first: true } },
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
expect(event.get("uid")).toBe("evt_1");
|
|
56
|
+
});
|
|
57
|
+
it("rejects invalid map key ids", () => {
|
|
58
|
+
expect(() => new JsCal.Event({
|
|
59
|
+
start: "2026-02-01T10:00:00",
|
|
60
|
+
locations: {
|
|
61
|
+
"bad id": { "@type": "Location", name: "Room A" },
|
|
62
|
+
},
|
|
63
|
+
})).toThrowError("object.locations.bad id: must use base64url characters");
|
|
64
|
+
});
|
|
65
|
+
it("rejects non-gregorian rscale values", () => {
|
|
66
|
+
expect(() => new JsCal.Event({
|
|
67
|
+
start: "2026-02-01T10:00:00",
|
|
68
|
+
recurrenceRules: [{ "@type": "RecurrenceRule", frequency: "daily", rscale: "hebrew" }],
|
|
69
|
+
})).toThrowError("object.recurrenceRules[0].rscale: only gregorian is supported");
|
|
70
|
+
});
|
|
71
|
+
it("accepts time zone objects", () => {
|
|
72
|
+
const event = new JsCal.Event({
|
|
73
|
+
start: "2026-02-01T10:00:00",
|
|
74
|
+
timeZones: {
|
|
75
|
+
"Asia/Tokyo": {
|
|
76
|
+
"@type": "TimeZone",
|
|
77
|
+
tzId: "Asia/Tokyo",
|
|
78
|
+
standard: [
|
|
79
|
+
{
|
|
80
|
+
"@type": "TimeZoneRule",
|
|
81
|
+
start: "2026-01-01T00:00:00",
|
|
82
|
+
offsetFrom: "+09:00",
|
|
83
|
+
offsetTo: "+09:00",
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
expect(event.get("timeZones")?.["Asia/Tokyo"]?.tzId).toBe("Asia/Tokyo");
|
|
90
|
+
});
|
|
91
|
+
});
|
package/dist/ical.d.ts
ADDED
package/dist/ical.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { normalizeUtcDateTime } from "./utils.js";
|
|
2
|
+
const DEFAULT_PRODID = "-//craftguild//EN";
|
|
3
|
+
export function toICal(objects, options = {}) {
|
|
4
|
+
const lines = [];
|
|
5
|
+
const includeX = options.includeXJSCalendar !== false;
|
|
6
|
+
lines.push("BEGIN:VCALENDAR");
|
|
7
|
+
lines.push("VERSION:2.0");
|
|
8
|
+
lines.push(`PRODID:${options.prodId ?? DEFAULT_PRODID}`);
|
|
9
|
+
const method = options.method ?? findMethod(objects);
|
|
10
|
+
if (method)
|
|
11
|
+
lines.push(`METHOD:${method.toUpperCase()}`);
|
|
12
|
+
for (const object of objects) {
|
|
13
|
+
if (object["@type"] === "Group") {
|
|
14
|
+
const group = object;
|
|
15
|
+
if (includeX) {
|
|
16
|
+
lines.push(`X-JSCALENDAR-GROUP:${escapeText(JSON.stringify(stripEntries(group)))}`);
|
|
17
|
+
}
|
|
18
|
+
for (const entry of group.entries) {
|
|
19
|
+
lines.push(...buildComponent(entry, includeX));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
lines.push(...buildComponent(object, includeX));
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
lines.push("END:VCALENDAR");
|
|
27
|
+
return foldLines(lines).join("\r\n");
|
|
28
|
+
}
|
|
29
|
+
function findMethod(objects) {
|
|
30
|
+
for (const object of objects) {
|
|
31
|
+
if (object.method)
|
|
32
|
+
return object.method;
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
function buildComponent(object, includeX) {
|
|
37
|
+
if (object["@type"] === "Event")
|
|
38
|
+
return buildEvent(object, includeX);
|
|
39
|
+
if (object["@type"] === "Task")
|
|
40
|
+
return buildTask(object, includeX);
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
function buildEvent(event, includeX) {
|
|
44
|
+
const lines = [];
|
|
45
|
+
lines.push("BEGIN:VEVENT");
|
|
46
|
+
lines.push(`UID:${escapeText(event.uid)}`);
|
|
47
|
+
lines.push(`DTSTAMP:${formatUtcDateTime(event.updated)}`);
|
|
48
|
+
if (event.sequence !== undefined)
|
|
49
|
+
lines.push(`SEQUENCE:${event.sequence}`);
|
|
50
|
+
if (event.title)
|
|
51
|
+
lines.push(`SUMMARY:${escapeText(event.title)}`);
|
|
52
|
+
if (event.description)
|
|
53
|
+
lines.push(`DESCRIPTION:${escapeText(event.description)}`);
|
|
54
|
+
const dtStart = formatLocalDateTime(event.start);
|
|
55
|
+
if (event.timeZone) {
|
|
56
|
+
lines.push(`DTSTART;TZID=${event.timeZone}:${dtStart}`);
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
lines.push(`DTSTART:${dtStart}`);
|
|
60
|
+
}
|
|
61
|
+
if (event.duration)
|
|
62
|
+
lines.push(`DURATION:${event.duration}`);
|
|
63
|
+
if (event.status)
|
|
64
|
+
lines.push(`STATUS:${event.status.toUpperCase()}`);
|
|
65
|
+
appendRecurrence(lines, event.recurrenceRules);
|
|
66
|
+
if (includeX) {
|
|
67
|
+
lines.push(`X-JSCALENDAR:${escapeText(JSON.stringify(event))}`);
|
|
68
|
+
}
|
|
69
|
+
lines.push("END:VEVENT");
|
|
70
|
+
return lines;
|
|
71
|
+
}
|
|
72
|
+
function buildTask(task, includeX) {
|
|
73
|
+
const lines = [];
|
|
74
|
+
lines.push("BEGIN:VTODO");
|
|
75
|
+
lines.push(`UID:${escapeText(task.uid)}`);
|
|
76
|
+
lines.push(`DTSTAMP:${formatUtcDateTime(task.updated)}`);
|
|
77
|
+
if (task.sequence !== undefined)
|
|
78
|
+
lines.push(`SEQUENCE:${task.sequence}`);
|
|
79
|
+
if (task.title)
|
|
80
|
+
lines.push(`SUMMARY:${escapeText(task.title)}`);
|
|
81
|
+
if (task.description)
|
|
82
|
+
lines.push(`DESCRIPTION:${escapeText(task.description)}`);
|
|
83
|
+
if (task.start) {
|
|
84
|
+
const dtStart = formatLocalDateTime(task.start);
|
|
85
|
+
if (task.timeZone) {
|
|
86
|
+
lines.push(`DTSTART;TZID=${task.timeZone}:${dtStart}`);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
lines.push(`DTSTART:${dtStart}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (task.due) {
|
|
93
|
+
const due = formatLocalDateTime(task.due);
|
|
94
|
+
if (task.timeZone) {
|
|
95
|
+
lines.push(`DUE;TZID=${task.timeZone}:${due}`);
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
lines.push(`DUE:${due}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (task.percentComplete !== undefined) {
|
|
102
|
+
lines.push(`PERCENT-COMPLETE:${task.percentComplete}`);
|
|
103
|
+
}
|
|
104
|
+
if (task.progress) {
|
|
105
|
+
lines.push(`STATUS:${task.progress.toUpperCase()}`);
|
|
106
|
+
}
|
|
107
|
+
appendRecurrence(lines, task.recurrenceRules);
|
|
108
|
+
if (includeX) {
|
|
109
|
+
lines.push(`X-JSCALENDAR:${escapeText(JSON.stringify(task))}`);
|
|
110
|
+
}
|
|
111
|
+
lines.push("END:VTODO");
|
|
112
|
+
return lines;
|
|
113
|
+
}
|
|
114
|
+
function appendRecurrence(lines, rules) {
|
|
115
|
+
if (!rules)
|
|
116
|
+
return;
|
|
117
|
+
for (const rule of rules) {
|
|
118
|
+
const rrule = recurrenceRuleToRRule(rule);
|
|
119
|
+
if (rrule)
|
|
120
|
+
lines.push(`RRULE:${rrule}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
function recurrenceRuleToRRule(rule) {
|
|
124
|
+
const parts = [];
|
|
125
|
+
parts.push(`FREQ=${rule.frequency.toUpperCase()}`);
|
|
126
|
+
if (rule.interval)
|
|
127
|
+
parts.push(`INTERVAL=${rule.interval}`);
|
|
128
|
+
if (rule.count)
|
|
129
|
+
parts.push(`COUNT=${rule.count}`);
|
|
130
|
+
if (rule.until)
|
|
131
|
+
parts.push(`UNTIL=${formatLocalDateTime(rule.until)}`);
|
|
132
|
+
if (rule.byDay?.length) {
|
|
133
|
+
const days = rule.byDay
|
|
134
|
+
.map((day) => `${day.nthOfPeriod ?? ""}${day.day.toUpperCase()}`)
|
|
135
|
+
.join(",");
|
|
136
|
+
parts.push(`BYDAY=${days}`);
|
|
137
|
+
}
|
|
138
|
+
if (rule.byMonthDay?.length)
|
|
139
|
+
parts.push(`BYMONTHDAY=${rule.byMonthDay.join(",")}`);
|
|
140
|
+
if (rule.byMonth?.length)
|
|
141
|
+
parts.push(`BYMONTH=${rule.byMonth.join(",")}`);
|
|
142
|
+
if (rule.byYearDay?.length)
|
|
143
|
+
parts.push(`BYYEARDAY=${rule.byYearDay.join(",")}`);
|
|
144
|
+
if (rule.byWeekNo?.length)
|
|
145
|
+
parts.push(`BYWEEKNO=${rule.byWeekNo.join(",")}`);
|
|
146
|
+
if (rule.byHour?.length)
|
|
147
|
+
parts.push(`BYHOUR=${rule.byHour.join(",")}`);
|
|
148
|
+
if (rule.byMinute?.length)
|
|
149
|
+
parts.push(`BYMINUTE=${rule.byMinute.join(",")}`);
|
|
150
|
+
if (rule.bySecond?.length)
|
|
151
|
+
parts.push(`BYSECOND=${rule.bySecond.join(",")}`);
|
|
152
|
+
if (rule.bySetPosition?.length)
|
|
153
|
+
parts.push(`BYSETPOS=${rule.bySetPosition.join(",")}`);
|
|
154
|
+
if (rule.firstDayOfWeek)
|
|
155
|
+
parts.push(`WKST=${rule.firstDayOfWeek.toUpperCase()}`);
|
|
156
|
+
if (rule.rscale)
|
|
157
|
+
parts.push(`RSCALE=${rule.rscale.toUpperCase()}`);
|
|
158
|
+
if (rule.skip)
|
|
159
|
+
parts.push(`SKIP=${rule.skip.toUpperCase()}`);
|
|
160
|
+
return parts.join(";");
|
|
161
|
+
}
|
|
162
|
+
function formatUtcDateTime(value) {
|
|
163
|
+
const normalized = normalizeUtcDateTime(value);
|
|
164
|
+
return normalized
|
|
165
|
+
.replace(/[-:]/g, "")
|
|
166
|
+
.replace(/\.\d+Z$/, "Z")
|
|
167
|
+
.replace(/Z$/, "Z");
|
|
168
|
+
}
|
|
169
|
+
function formatLocalDateTime(value) {
|
|
170
|
+
return value
|
|
171
|
+
.replace(/[-:]/g, "")
|
|
172
|
+
.replace(/\.\d+$/, "")
|
|
173
|
+
.replace(/Z$/, "");
|
|
174
|
+
}
|
|
175
|
+
function escapeText(value) {
|
|
176
|
+
return value
|
|
177
|
+
.replace(/\\/g, "\\\\")
|
|
178
|
+
.replace(/\n/g, "\\n")
|
|
179
|
+
.replace(/;/g, "\\;")
|
|
180
|
+
.replace(/,/g, "\\,");
|
|
181
|
+
}
|
|
182
|
+
function foldLines(lines) {
|
|
183
|
+
const result = [];
|
|
184
|
+
for (const line of lines) {
|
|
185
|
+
if (line.length <= 75) {
|
|
186
|
+
result.push(line);
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
let remaining = line;
|
|
190
|
+
result.push(remaining.slice(0, 75));
|
|
191
|
+
remaining = remaining.slice(75);
|
|
192
|
+
while (remaining.length > 0) {
|
|
193
|
+
result.push(` ${remaining.slice(0, 74)}`);
|
|
194
|
+
remaining = remaining.slice(74);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
function stripEntries(group) {
|
|
200
|
+
const { entries: _entries, ...rest } = group;
|
|
201
|
+
return rest;
|
|
202
|
+
}
|
package/dist/index.d.ts
ADDED