@craftguild/jscalendar 0.1.0 → 0.3.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/README.md +98 -1
- package/dist/__tests__/builders.test.d.ts +1 -0
- package/dist/__tests__/builders.test.js +82 -0
- package/dist/__tests__/calendar-extra.test.js +36 -0
- package/dist/__tests__/recurrence.test.js +123 -0
- package/dist/__tests__/search.test.js +27 -0
- package/dist/__tests__/utils.test.js +3 -0
- package/dist/__tests__/validation.test.js +113 -0
- package/dist/ical.d.ts +6 -0
- package/dist/ical.js +71 -3
- package/dist/jscal/base.d.ts +90 -0
- package/dist/jscal/base.js +181 -0
- package/dist/jscal/builders.d.ts +135 -0
- package/dist/jscal/builders.js +220 -0
- package/dist/jscal/constants.d.ts +11 -0
- package/dist/jscal/constants.js +11 -0
- package/dist/jscal/datetime.d.ts +14 -0
- package/dist/jscal/datetime.js +42 -0
- package/dist/jscal/defaults.d.ts +31 -0
- package/dist/jscal/defaults.js +102 -0
- package/dist/jscal/duration.d.ts +43 -0
- package/dist/jscal/duration.js +72 -0
- package/dist/jscal/event.d.ts +17 -0
- package/dist/jscal/event.js +71 -0
- package/dist/jscal/group.d.ts +25 -0
- package/dist/jscal/group.js +62 -0
- package/dist/jscal/guards.d.ts +19 -0
- package/dist/jscal/guards.js +25 -0
- package/dist/jscal/ids.d.ts +11 -0
- package/dist/jscal/ids.js +77 -0
- package/dist/jscal/normalize.d.ts +32 -0
- package/dist/jscal/normalize.js +45 -0
- package/dist/jscal/task.d.ts +17 -0
- package/dist/jscal/task.js +60 -0
- package/dist/jscal/types.d.ts +38 -0
- package/dist/jscal/types.js +1 -0
- package/dist/jscal.d.ts +77 -70
- package/dist/jscal.js +77 -465
- package/dist/patch.d.ts +13 -0
- package/dist/patch.js +166 -41
- package/dist/recurrence/constants.d.ts +13 -0
- package/dist/recurrence/constants.js +13 -0
- package/dist/recurrence/date-utils.d.ts +125 -0
- package/dist/recurrence/date-utils.js +259 -0
- package/dist/recurrence/expand.d.ts +23 -0
- package/dist/recurrence/expand.js +294 -0
- package/dist/recurrence/rule-candidates.d.ts +21 -0
- package/dist/recurrence/rule-candidates.js +120 -0
- package/dist/recurrence/rule-generate.d.ts +11 -0
- package/dist/recurrence/rule-generate.js +36 -0
- package/dist/recurrence/rule-matchers.d.ts +34 -0
- package/dist/recurrence/rule-matchers.js +120 -0
- package/dist/recurrence/rule-normalize.d.ts +9 -0
- package/dist/recurrence/rule-normalize.js +57 -0
- package/dist/recurrence/rule-selectors.d.ts +7 -0
- package/dist/recurrence/rule-selectors.js +21 -0
- package/dist/recurrence/rules.d.ts +14 -0
- package/dist/recurrence/rules.js +57 -0
- package/dist/recurrence/types.d.ts +27 -0
- package/dist/recurrence/types.js +1 -0
- package/dist/recurrence.d.ts +2 -15
- package/dist/recurrence.js +1 -674
- package/dist/search.d.ts +30 -0
- package/dist/search.js +92 -8
- package/dist/timezones/chunk_1.d.ts +2 -0
- package/dist/timezones/chunk_1.js +72 -0
- package/dist/timezones/chunk_2.d.ts +2 -0
- package/dist/timezones/chunk_2.js +72 -0
- package/dist/timezones/chunk_3.d.ts +2 -0
- package/dist/timezones/chunk_3.js +72 -0
- package/dist/timezones/chunk_4.d.ts +2 -0
- package/dist/timezones/chunk_4.js +72 -0
- package/dist/timezones/chunk_5.d.ts +2 -0
- package/dist/timezones/chunk_5.js +72 -0
- package/dist/timezones/chunk_6.d.ts +2 -0
- package/dist/timezones/chunk_6.js +72 -0
- package/dist/timezones/chunk_7.d.ts +2 -0
- package/dist/timezones/chunk_7.js +6 -0
- package/dist/timezones.d.ts +5 -0
- package/dist/timezones.js +14 -3
- package/dist/utils.d.ts +72 -0
- package/dist/utils.js +85 -1
- package/dist/validate/asserts.d.ts +155 -0
- package/dist/validate/asserts.js +381 -0
- package/dist/validate/constants.d.ts +25 -0
- package/dist/validate/constants.js +33 -0
- package/dist/validate/error.d.ts +19 -0
- package/dist/validate/error.js +25 -0
- package/dist/validate/validators-common.d.ts +64 -0
- package/dist/validate/validators-common.js +385 -0
- package/dist/validate/validators-objects.d.ts +8 -0
- package/dist/validate/validators-objects.js +70 -0
- package/dist/validate/validators-recurrence.d.ts +15 -0
- package/dist/validate/validators-recurrence.js +115 -0
- package/dist/validate/validators.d.ts +1 -0
- package/dist/validate/validators.js +1 -0
- package/dist/validate.d.ts +2 -6
- package/dist/validate.js +2 -745
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -62,13 +62,110 @@ const task = new JsCal.Task({
|
|
|
62
62
|
|
|
63
63
|
### Group
|
|
64
64
|
|
|
65
|
+
`entries` accepts either plain JSCalendar objects (including `eject()` results)
|
|
66
|
+
or `JsCal.Event`/`JsCal.Task` instances.
|
|
67
|
+
|
|
65
68
|
```ts
|
|
66
69
|
const group = new JsCal.Group({
|
|
67
70
|
title: "Project A",
|
|
68
|
-
entries: [event
|
|
71
|
+
entries: [event, task.eject()],
|
|
69
72
|
});
|
|
70
73
|
```
|
|
71
74
|
|
|
75
|
+
## Builder Helpers (Strict, Validated Inputs)
|
|
76
|
+
|
|
77
|
+
JSCalendar objects require `@type` fields for nested objects like
|
|
78
|
+
participants, locations, alerts, and recurrence rules. Requiring every
|
|
79
|
+
caller to manually specify `@type` is noisy and error-prone. To avoid
|
|
80
|
+
leaking RFC-specific details into app code, this library provides
|
|
81
|
+
**builder helpers** that:
|
|
82
|
+
|
|
83
|
+
- Fill in the correct `@type`
|
|
84
|
+
- Validate the result against RFC 8984 immediately
|
|
85
|
+
|
|
86
|
+
You can still pass strict plain JSCalendar objects directly (e.g., from
|
|
87
|
+
your database), but builders offer a safer, clearer option for app code.
|
|
88
|
+
|
|
89
|
+
### Using builders (recommended for app code)
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
const task = new JsCal.Task({
|
|
93
|
+
title: "Write report",
|
|
94
|
+
start: "2026-02-11T09:00:00",
|
|
95
|
+
participants: JsCal.participants([
|
|
96
|
+
JsCal.Participant({ name: "Alice", email: "a@example.com" }),
|
|
97
|
+
JsCal.Participant({ name: "Bob" }),
|
|
98
|
+
]),
|
|
99
|
+
locations: JsCal.locations([
|
|
100
|
+
JsCal.Location({ name: "Room A" }),
|
|
101
|
+
]),
|
|
102
|
+
alerts: JsCal.alerts([
|
|
103
|
+
JsCal.Alert({ trigger: { "@type": "OffsetTrigger", offset: "-PT15M" } }),
|
|
104
|
+
]),
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Using strict plain objects (for DB/imported data)
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
const task = new JsCal.Task({
|
|
112
|
+
title: "Imported task",
|
|
113
|
+
start: "2026-02-11T09:00:00",
|
|
114
|
+
participants: {
|
|
115
|
+
p1: { "@type": "Participant", name: "Alice", email: "a@example.com" },
|
|
116
|
+
},
|
|
117
|
+
locations: {
|
|
118
|
+
l1: { "@type": "Location", name: "Room A" },
|
|
119
|
+
},
|
|
120
|
+
alerts: {
|
|
121
|
+
a1: { "@type": "Alert", trigger: { "@type": "OffsetTrigger", offset: "-PT15M" } },
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Validation behavior
|
|
127
|
+
|
|
128
|
+
Builders validate on creation, and constructors validate on object
|
|
129
|
+
creation. That means:
|
|
130
|
+
|
|
131
|
+
- Builder inputs are validated immediately
|
|
132
|
+
- `new JsCal.Event/Task/Group(...)` also validates the final object
|
|
133
|
+
|
|
134
|
+
This double validation is intentional for safety: even if data originates
|
|
135
|
+
from an untrusted or inconsistent source, you still get strict RFC 8984
|
|
136
|
+
checks at the point of object creation.
|
|
137
|
+
|
|
138
|
+
## Ejecting to plain objects
|
|
139
|
+
|
|
140
|
+
`JsCal.Event`/`JsCal.Task` are class instances with helper methods and a
|
|
141
|
+
`data` field that stores the RFC 8984 object. `eject()` returns a deep
|
|
142
|
+
clone of that underlying JSCalendar object for serialization, storage,
|
|
143
|
+
or passing across app boundaries.
|
|
144
|
+
|
|
145
|
+
Why `eject()` exists:
|
|
146
|
+
- Class instances are convenient for building and mutating objects with
|
|
147
|
+
helpers like `update`, `patch`, and `addParticipant`.
|
|
148
|
+
- External APIs, storage layers, and JSON stringify expect plain objects.
|
|
149
|
+
- A deep clone makes it safe to hand off data without accidental mutation
|
|
150
|
+
from the original instance (and vice versa).
|
|
151
|
+
|
|
152
|
+
What changes after `eject()`:
|
|
153
|
+
- You lose helper methods; the result is just a plain JSCalendar object.
|
|
154
|
+
- Mutating the plain object does not affect the original instance.
|
|
155
|
+
- The instance can still be used and updated independently.
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
const event = new JsCal.Event({ title: "Kickoff", start: "2026-02-02T10:00:00" });
|
|
159
|
+
const plain = event.eject();
|
|
160
|
+
|
|
161
|
+
// Plain object is ready for JSON / storage / network.
|
|
162
|
+
JSON.stringify(plain);
|
|
163
|
+
|
|
164
|
+
// Changes do not affect each other.
|
|
165
|
+
plain.title = "Exported";
|
|
166
|
+
event.update({ title: "Live" });
|
|
167
|
+
```
|
|
168
|
+
|
|
72
169
|
## Updates and Mutations
|
|
73
170
|
|
|
74
171
|
Mutation helpers update the underlying data and keep metadata such as
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { JsCal } from "../jscal.js";
|
|
3
|
+
import { buildAlert, buildIdMap, buildLink, buildLocation, buildNDay, buildRecurrenceRule, buildRelation, buildTimeZone, buildTimeZoneMap, buildTimeZoneRule, buildVirtualLocation, } from "../jscal/builders.js";
|
|
4
|
+
describe("builders", () => {
|
|
5
|
+
it("builds strict objects with @type", () => {
|
|
6
|
+
const participant = JsCal.Participant({ name: "Alice", roles: { attendee: true } });
|
|
7
|
+
const location = buildLocation({ name: "Room A" });
|
|
8
|
+
const vloc = buildVirtualLocation({ name: "Zoom", uri: "https://example.com" });
|
|
9
|
+
const link = buildLink({ href: "https://example.com" });
|
|
10
|
+
const relation = buildRelation({ relation: { parent: true } });
|
|
11
|
+
const alert = buildAlert({ trigger: { "@type": "OffsetTrigger", offset: "-PT15M" } });
|
|
12
|
+
const nday = buildNDay({ day: "mo" });
|
|
13
|
+
const rule = buildRecurrenceRule({ frequency: "daily" });
|
|
14
|
+
const tzRule = buildTimeZoneRule({
|
|
15
|
+
start: "2026-01-01T00:00:00",
|
|
16
|
+
offsetFrom: "+09:00",
|
|
17
|
+
offsetTo: "+09:00",
|
|
18
|
+
});
|
|
19
|
+
const tz = buildTimeZone({
|
|
20
|
+
tzId: "Asia/Tokyo",
|
|
21
|
+
standard: [tzRule],
|
|
22
|
+
});
|
|
23
|
+
expect(participant["@type"]).toBe("Participant");
|
|
24
|
+
expect(location["@type"]).toBe("Location");
|
|
25
|
+
expect(vloc["@type"]).toBe("VirtualLocation");
|
|
26
|
+
expect(link["@type"]).toBe("Link");
|
|
27
|
+
expect(relation["@type"]).toBe("Relation");
|
|
28
|
+
expect(alert["@type"]).toBe("Alert");
|
|
29
|
+
expect(nday["@type"]).toBe("NDay");
|
|
30
|
+
expect(rule["@type"]).toBe("RecurrenceRule");
|
|
31
|
+
expect(tz["@type"]).toBe("TimeZone");
|
|
32
|
+
});
|
|
33
|
+
it("builds id maps with generated ids", () => {
|
|
34
|
+
const participants = JsCal.participants([
|
|
35
|
+
{ name: "Alice", roles: { attendee: true } },
|
|
36
|
+
{ name: "Bob", roles: { attendee: true } },
|
|
37
|
+
]);
|
|
38
|
+
expect(Object.keys(participants).length).toBe(2);
|
|
39
|
+
const locations = JsCal.locations([{ name: "Room A" }]);
|
|
40
|
+
expect(Object.keys(locations).length).toBe(1);
|
|
41
|
+
const links = JsCal.links([{ href: "https://example.com" }]);
|
|
42
|
+
expect(Object.keys(links).length).toBe(1);
|
|
43
|
+
const related = JsCal.relatedTo([{ relation: { parent: true } }]);
|
|
44
|
+
expect(Object.keys(related).length).toBe(1);
|
|
45
|
+
});
|
|
46
|
+
it("builds a time zone map keyed by tzId", () => {
|
|
47
|
+
const map = buildTimeZoneMap([
|
|
48
|
+
{ tzId: "Asia/Tokyo", standard: [{ "@type": "TimeZoneRule", start: "2026-01-01T00:00:00", offsetFrom: "+09:00", offsetTo: "+09:00" }] },
|
|
49
|
+
]);
|
|
50
|
+
expect(map["Asia/Tokyo"]?.tzId).toBe("Asia/Tokyo");
|
|
51
|
+
});
|
|
52
|
+
it("buildIdMap uses a custom id function", () => {
|
|
53
|
+
const map = buildIdMap([{ name: "A" }], (item) => item, (_item, index) => `id-${index}`);
|
|
54
|
+
expect(Object.keys(map)).toEqual(["id-0"]);
|
|
55
|
+
});
|
|
56
|
+
it("throws when @type mismatches", () => {
|
|
57
|
+
// Intentionally bypass type safety to assert @type validation errors.
|
|
58
|
+
const bad = { "@type": "Location" };
|
|
59
|
+
expect(() => JsCal.Participant(bad)).toThrowError("participant: must have @type Participant");
|
|
60
|
+
});
|
|
61
|
+
it("throws for mismatched @type across builders", () => {
|
|
62
|
+
// Intentionally bypass type safety to assert @type validation errors.
|
|
63
|
+
expect(() => buildLocation({ "@type": "Participant" }))
|
|
64
|
+
.toThrowError("location: must have @type Location");
|
|
65
|
+
expect(() => buildVirtualLocation({ "@type": "Location" }))
|
|
66
|
+
.toThrowError("virtualLocation: must have @type VirtualLocation");
|
|
67
|
+
expect(() => buildAlert({ "@type": "Link" }))
|
|
68
|
+
.toThrowError("alert: must have @type Alert");
|
|
69
|
+
expect(() => buildRelation({ "@type": "Link" }))
|
|
70
|
+
.toThrowError("relation: must have @type Relation");
|
|
71
|
+
expect(() => buildLink({ "@type": "Relation" }))
|
|
72
|
+
.toThrowError("link: must have @type Link");
|
|
73
|
+
expect(() => buildTimeZone({ "@type": "Alert" }))
|
|
74
|
+
.toThrowError("timeZone: must have @type TimeZone");
|
|
75
|
+
expect(() => buildTimeZoneRule({ "@type": "TimeZone" }))
|
|
76
|
+
.toThrowError("timeZoneRule: must have @type TimeZoneRule");
|
|
77
|
+
expect(() => buildRecurrenceRule({ "@type": "NDay" }))
|
|
78
|
+
.toThrowError("recurrenceRule: must have @type RecurrenceRule");
|
|
79
|
+
expect(() => buildNDay({ "@type": "RecurrenceRule" }))
|
|
80
|
+
.toThrowError("nday: must have @type NDay");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { JsCal, createId, isEvent, isGroup, isTask } from "../jscal.js";
|
|
3
|
+
import { Base } from "../jscal/base.js";
|
|
3
4
|
const fixedNow = () => "2026-02-01T00:00:00Z";
|
|
4
5
|
function makeEvent() {
|
|
5
6
|
return new JsCal.Event({
|
|
@@ -14,6 +15,16 @@ describe("JsCal helpers", () => {
|
|
|
14
15
|
group.addEntry(event.data);
|
|
15
16
|
expect(group.data.entries.length).toBe(1);
|
|
16
17
|
});
|
|
18
|
+
it("accepts JsCal instances and ejected entries in groups", () => {
|
|
19
|
+
const event = makeEvent();
|
|
20
|
+
const task = new JsCal.Task({ title: "Task", start: "2026-02-02T10:00:00" }, { now: fixedNow });
|
|
21
|
+
const group = new JsCal.Group({
|
|
22
|
+
entries: [event, task.eject()],
|
|
23
|
+
}, { now: fixedNow });
|
|
24
|
+
expect(group.data.entries.length).toBe(2);
|
|
25
|
+
expect(group.data.entries[0]?.["@type"]).toBe("Event");
|
|
26
|
+
expect(group.data.entries[1]?.["@type"]).toBe("Task");
|
|
27
|
+
});
|
|
17
28
|
it("does not expose addEntry on non-group", () => {
|
|
18
29
|
const event = makeEvent();
|
|
19
30
|
expect("addEntry" in event).toBe(false);
|
|
@@ -79,6 +90,31 @@ describe("JsCal helpers", () => {
|
|
|
79
90
|
json.title = "Changed";
|
|
80
91
|
expect(event.data.title).toBe("Kickoff");
|
|
81
92
|
});
|
|
93
|
+
it("clones Base instances with deep-copied data", () => {
|
|
94
|
+
const base = new Base({ "@type": "Event", uid: "base", updated: "2026-02-01T00:00:00Z", start: "2026-02-01T10:00:00" });
|
|
95
|
+
const cloned = base.clone();
|
|
96
|
+
cloned.data.uid = "changed";
|
|
97
|
+
expect(base.data.uid).toBe("base");
|
|
98
|
+
});
|
|
99
|
+
it("builds participant inputs without @type", () => {
|
|
100
|
+
const participant = JsCal.Participant({ name: "Alice", roles: { attendee: true } });
|
|
101
|
+
const task = new JsCal.Task({
|
|
102
|
+
start: "2026-02-02T10:00:00",
|
|
103
|
+
participants: { p1: participant },
|
|
104
|
+
});
|
|
105
|
+
expect(task.data.participants?.p1?.["@type"]).toBe("Participant");
|
|
106
|
+
});
|
|
107
|
+
it("builds participant maps with stable ids", () => {
|
|
108
|
+
const participants = JsCal.participants([
|
|
109
|
+
{ name: "Alice", roles: { attendee: true } },
|
|
110
|
+
{ name: "Bob", roles: { attendee: true } },
|
|
111
|
+
]);
|
|
112
|
+
const task = new JsCal.Task({
|
|
113
|
+
start: "2026-02-02T10:00:00",
|
|
114
|
+
participants,
|
|
115
|
+
});
|
|
116
|
+
expect(Object.keys(task.data.participants ?? {}).length).toBe(2);
|
|
117
|
+
});
|
|
82
118
|
it("createId returns base64url without padding", () => {
|
|
83
119
|
const id = createId();
|
|
84
120
|
expect(id).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
@@ -449,6 +449,129 @@ describe("recurrence expansion", () => {
|
|
|
449
449
|
"2026-02-02T09:00:00",
|
|
450
450
|
]);
|
|
451
451
|
});
|
|
452
|
+
it("uses implicit byMonthDay for monthly rules", () => {
|
|
453
|
+
const event = new JsCal.Event({
|
|
454
|
+
title: "Monthly Default",
|
|
455
|
+
start: "2026-02-10T09:00:00",
|
|
456
|
+
recurrenceRules: [
|
|
457
|
+
{ "@type": "RecurrenceRule", frequency: "monthly", count: 2 },
|
|
458
|
+
],
|
|
459
|
+
});
|
|
460
|
+
const occ = collect(JsCal.expandRecurrence([event], {
|
|
461
|
+
from: new Date("2026-02-01"),
|
|
462
|
+
to: new Date("2026-03-31"),
|
|
463
|
+
}));
|
|
464
|
+
const starts = occ.map((o) => o.recurrenceId);
|
|
465
|
+
expect(starts).toEqual([
|
|
466
|
+
"2026-02-10T09:00:00",
|
|
467
|
+
"2026-03-10T09:00:00",
|
|
468
|
+
]);
|
|
469
|
+
});
|
|
470
|
+
it("adds byMonth defaults for yearly rules with byMonthDay", () => {
|
|
471
|
+
const event = new JsCal.Event({
|
|
472
|
+
title: "Yearly ByMonthDay",
|
|
473
|
+
start: "2026-02-01T09:00:00",
|
|
474
|
+
recurrenceRules: [
|
|
475
|
+
{ "@type": "RecurrenceRule", frequency: "yearly", byMonthDay: [1], count: 2 },
|
|
476
|
+
],
|
|
477
|
+
});
|
|
478
|
+
const occ = collect(JsCal.expandRecurrence([event], {
|
|
479
|
+
from: new Date("2026-02-01"),
|
|
480
|
+
to: new Date("2027-02-02"),
|
|
481
|
+
}));
|
|
482
|
+
const starts = occ.map((o) => o.recurrenceId);
|
|
483
|
+
expect(starts).toEqual([
|
|
484
|
+
"2026-02-01T09:00:00",
|
|
485
|
+
"2027-02-01T09:00:00",
|
|
486
|
+
]);
|
|
487
|
+
});
|
|
488
|
+
it("adds byDay defaults for yearly byWeekNo rules", () => {
|
|
489
|
+
const event = new JsCal.Event({
|
|
490
|
+
title: "Week 1 Default Day",
|
|
491
|
+
start: "2026-01-01T09:00:00",
|
|
492
|
+
recurrenceRules: [
|
|
493
|
+
{ "@type": "RecurrenceRule", frequency: "yearly", byWeekNo: [1], count: 1 },
|
|
494
|
+
],
|
|
495
|
+
});
|
|
496
|
+
const occ = collect(JsCal.expandRecurrence([event], {
|
|
497
|
+
from: new Date("2026-01-01"),
|
|
498
|
+
to: new Date("2026-01-10"),
|
|
499
|
+
}));
|
|
500
|
+
const starts = occ.map((o) => o.recurrenceId);
|
|
501
|
+
expect(starts).toEqual([
|
|
502
|
+
"2026-01-01T09:00:00",
|
|
503
|
+
]);
|
|
504
|
+
});
|
|
505
|
+
it("supports negative bySetPosition values", () => {
|
|
506
|
+
const event = new JsCal.Event({
|
|
507
|
+
title: "Last Wednesday",
|
|
508
|
+
start: "2026-01-07T10:00:00",
|
|
509
|
+
recurrenceRules: [
|
|
510
|
+
{
|
|
511
|
+
"@type": "RecurrenceRule",
|
|
512
|
+
frequency: "monthly",
|
|
513
|
+
byDay: [{ "@type": "NDay", day: "we" }],
|
|
514
|
+
bySetPosition: [-1],
|
|
515
|
+
},
|
|
516
|
+
],
|
|
517
|
+
});
|
|
518
|
+
const occ = collect(JsCal.expandRecurrence([event], {
|
|
519
|
+
from: new Date("2026-01-01"),
|
|
520
|
+
to: new Date("2026-03-31"),
|
|
521
|
+
}));
|
|
522
|
+
const starts = occ.map((o) => o.recurrenceId);
|
|
523
|
+
expect(starts).toEqual([
|
|
524
|
+
"2026-01-07T10:00:00",
|
|
525
|
+
"2026-01-28T10:00:00",
|
|
526
|
+
"2026-02-25T10:00:00",
|
|
527
|
+
"2026-03-25T10:00:00",
|
|
528
|
+
]);
|
|
529
|
+
});
|
|
530
|
+
it("expands tasks that only have due dates", () => {
|
|
531
|
+
const task = new JsCal.Task({
|
|
532
|
+
title: "Due Only",
|
|
533
|
+
due: "2026-02-01T10:00:00",
|
|
534
|
+
recurrenceRules: [
|
|
535
|
+
{ "@type": "RecurrenceRule", frequency: "daily", count: 2 },
|
|
536
|
+
],
|
|
537
|
+
recurrenceOverrides: {
|
|
538
|
+
"2026-02-02T10:00:00": { "/due": "2026-02-05T10:00:00" },
|
|
539
|
+
},
|
|
540
|
+
});
|
|
541
|
+
const occ = Array.from(JsCal.expandRecurrence([task], {
|
|
542
|
+
from: new Date("2026-02-01"),
|
|
543
|
+
to: new Date("2026-02-03"),
|
|
544
|
+
}));
|
|
545
|
+
expect(occ.map((o) => o.recurrenceId)).toEqual([
|
|
546
|
+
"2026-02-01T10:00:00",
|
|
547
|
+
"2026-02-02T10:00:00",
|
|
548
|
+
]);
|
|
549
|
+
// Intentional cast to Task for test-only access to due.
|
|
550
|
+
const second = occ[1];
|
|
551
|
+
expect(second?.due).toBe("2026-02-05T10:00:00");
|
|
552
|
+
});
|
|
553
|
+
it("skips tasks without start or due dates", () => {
|
|
554
|
+
const task = new JsCal.Task({ title: "No Dates" });
|
|
555
|
+
const occ = Array.from(JsCal.expandRecurrence([task], {
|
|
556
|
+
from: new Date("2026-02-01"),
|
|
557
|
+
to: new Date("2026-02-02"),
|
|
558
|
+
}));
|
|
559
|
+
expect(occ.length).toBe(0);
|
|
560
|
+
});
|
|
561
|
+
it("throws on unsupported rscale during expansion", () => {
|
|
562
|
+
const event = new JsCal.Event({
|
|
563
|
+
title: "Bad Rscale",
|
|
564
|
+
start: "2026-02-01T10:00:00",
|
|
565
|
+
recurrenceRules: [
|
|
566
|
+
{ "@type": "RecurrenceRule", frequency: "daily", rscale: "hebrew" },
|
|
567
|
+
],
|
|
568
|
+
}, { validate: false });
|
|
569
|
+
expect(() => {
|
|
570
|
+
for (const _ of JsCal.expandRecurrence([event], { from: new Date("2026-02-01"), to: new Date("2026-02-02") })) {
|
|
571
|
+
void 0;
|
|
572
|
+
}
|
|
573
|
+
}).toThrow("Unsupported rscale");
|
|
574
|
+
});
|
|
452
575
|
it("pages recurrence expansion with cursor and limit", () => {
|
|
453
576
|
const event = new JsCal.Event({
|
|
454
577
|
title: "Weekly",
|
|
@@ -84,6 +84,33 @@ describe("search helpers", () => {
|
|
|
84
84
|
throw new Error("Missing result");
|
|
85
85
|
expect(first.uid).toBe("e1");
|
|
86
86
|
});
|
|
87
|
+
it("handles Date range for UTC events without time zones", () => {
|
|
88
|
+
const utcEvent = {
|
|
89
|
+
"@type": "Event",
|
|
90
|
+
uid: "eUtc",
|
|
91
|
+
updated: "2026-02-01T00:00:00Z",
|
|
92
|
+
start: "2026-02-01T10:00:00Z",
|
|
93
|
+
duration: "PT1H",
|
|
94
|
+
};
|
|
95
|
+
const results = filterByDateRange([utcEvent], {
|
|
96
|
+
start: new Date("2026-02-01T09:30:00Z"),
|
|
97
|
+
end: new Date("2026-02-01T10:30:00Z"),
|
|
98
|
+
});
|
|
99
|
+
expect(results.map((item) => item.uid)).toEqual(["eUtc"]);
|
|
100
|
+
});
|
|
101
|
+
it("handles Date range for UTC tasks without time zones", () => {
|
|
102
|
+
const utcTask = {
|
|
103
|
+
"@type": "Task",
|
|
104
|
+
uid: "tUtc",
|
|
105
|
+
updated: "2026-02-01T00:00:00Z",
|
|
106
|
+
start: "2026-02-01T10:00:00Z",
|
|
107
|
+
};
|
|
108
|
+
const results = filterByDateRange([utcTask], {
|
|
109
|
+
start: new Date("2026-02-01T09:30:00Z"),
|
|
110
|
+
end: new Date("2026-02-01T10:30:00Z"),
|
|
111
|
+
});
|
|
112
|
+
expect(results.map((item) => item.uid)).toEqual(["tUtc"]);
|
|
113
|
+
});
|
|
87
114
|
it("uses JsCal.groupByType wrapper", () => {
|
|
88
115
|
const grouped = JsCal.groupByType([event, task]);
|
|
89
116
|
expect(Object.keys(grouped)).toContain("Event");
|
|
@@ -25,6 +25,9 @@ describe("utils", () => {
|
|
|
25
25
|
expect(compareDateTime("2026-02-01T00:00:00Z", "2026-02-01T01:00:00Z")).toBe(-1);
|
|
26
26
|
expect(compareDateTime("2026-02-01T01:00:00Z", "2026-02-01T00:00:00Z")).toBe(1);
|
|
27
27
|
});
|
|
28
|
+
it("returns null for invalid UTC date-times", () => {
|
|
29
|
+
expect(compareDateTime("2026-99-99T00:00:00Z", "2026-02-01T00:00:00Z")).toBeNull();
|
|
30
|
+
});
|
|
28
31
|
it("compares local date-times lexicographically", () => {
|
|
29
32
|
expect(compareDateTime("2026-02-01T00:00:00", "2026-02-01T00:00:00")).toBe(0);
|
|
30
33
|
expect(compareDateTime("2026-02-01T00:00:00", "2026-02-02T00:00:00")).toBe(-1);
|
|
@@ -16,6 +16,12 @@ describe("validation", () => {
|
|
|
16
16
|
descriptionContentType: "application/json",
|
|
17
17
|
})).toThrowError("object.descriptionContentType: must be a text/* media type");
|
|
18
18
|
});
|
|
19
|
+
it("rejects non-utf8 charset parameters", () => {
|
|
20
|
+
expect(() => new JsCal.Event({
|
|
21
|
+
start: "2026-02-01T10:00:00",
|
|
22
|
+
descriptionContentType: "text/plain; charset=ascii",
|
|
23
|
+
})).toThrowError("object.descriptionContentType: charset parameter must be utf-8");
|
|
24
|
+
});
|
|
19
25
|
it("allows validation to be disabled for create, update, and patch", () => {
|
|
20
26
|
const event = new JsCal.Event({ start: "2026-02-01T10:00:00Z" }, { validate: false });
|
|
21
27
|
expect(event.get("start")).toBe("2026-02-01T10:00:00Z");
|
|
@@ -62,6 +68,113 @@ describe("validation", () => {
|
|
|
62
68
|
},
|
|
63
69
|
})).toThrowError("object.locations.bad id: must use base64url characters");
|
|
64
70
|
});
|
|
71
|
+
it("rejects invalid content-id formats", () => {
|
|
72
|
+
expect(() => new JsCal.Event({
|
|
73
|
+
start: "2026-02-01T10:00:00",
|
|
74
|
+
links: {
|
|
75
|
+
l1: { "@type": "Link", href: "https://example.com", cid: "<a@example.com" },
|
|
76
|
+
},
|
|
77
|
+
})).toThrowError("object.links.l1.cid: must use matching angle brackets");
|
|
78
|
+
});
|
|
79
|
+
it("rejects boolean map entries that are not true", () => {
|
|
80
|
+
expect(() => new JsCal.Event({
|
|
81
|
+
start: "2026-02-01T10:00:00",
|
|
82
|
+
participants: {
|
|
83
|
+
// Intentionally violate the BooleanMap type to assert validation errors.
|
|
84
|
+
p1: { "@type": "Participant", roles: { attendee: false } },
|
|
85
|
+
},
|
|
86
|
+
})).toThrowError("object.participants.p1.roles.attendee: must be true");
|
|
87
|
+
});
|
|
88
|
+
it("rejects invalid time zone ids", () => {
|
|
89
|
+
expect(() => new JsCal.Event({
|
|
90
|
+
start: "2026-02-01T10:00:00",
|
|
91
|
+
// Intentionally bypass TimeZoneInput typing to test invalid IDs.
|
|
92
|
+
timeZone: "Mars/Phobos",
|
|
93
|
+
})).toThrowError("Unknown time zone: Mars/Phobos");
|
|
94
|
+
});
|
|
95
|
+
it("rejects invalid recurrence rule values", () => {
|
|
96
|
+
expect(() => new JsCal.Event({
|
|
97
|
+
start: "2026-02-01T10:00:00",
|
|
98
|
+
recurrenceRules: [
|
|
99
|
+
{ "@type": "RecurrenceRule", frequency: "daily", byMonthDay: [0] },
|
|
100
|
+
],
|
|
101
|
+
})).toThrowError("object.recurrenceRules[0].byMonthDay[0]: must be an integer between -31 and 31, excluding 0");
|
|
102
|
+
});
|
|
103
|
+
it("accepts extended common fields and nested validation", () => {
|
|
104
|
+
const event = new JsCal.Event({
|
|
105
|
+
start: "2026-02-01T10:00:00",
|
|
106
|
+
method: "publish",
|
|
107
|
+
keywords: { sprint: true },
|
|
108
|
+
categories: { meeting: true },
|
|
109
|
+
relatedTo: {
|
|
110
|
+
rel1: { "@type": "Relation", relation: { parent: true } },
|
|
111
|
+
},
|
|
112
|
+
replyTo: {
|
|
113
|
+
"mailto:team@example.com": "mailto:team@example.com",
|
|
114
|
+
},
|
|
115
|
+
locations: {
|
|
116
|
+
loc1: {
|
|
117
|
+
"@type": "Location",
|
|
118
|
+
name: "Room A",
|
|
119
|
+
links: {
|
|
120
|
+
link1: { "@type": "Link", href: "https://example.com" },
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
virtualLocations: {
|
|
125
|
+
v1: {
|
|
126
|
+
"@type": "VirtualLocation",
|
|
127
|
+
uri: "https://example.com",
|
|
128
|
+
features: { chat: true },
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
participants: {
|
|
132
|
+
p1: {
|
|
133
|
+
"@type": "Participant",
|
|
134
|
+
roles: { attendee: true },
|
|
135
|
+
sendTo: { imap: "mailto:a@example.com" },
|
|
136
|
+
kind: "individual",
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
alerts: {
|
|
140
|
+
a1: {
|
|
141
|
+
"@type": "Alert",
|
|
142
|
+
trigger: { "@type": "AbsoluteTrigger", when: "2026-02-01T00:00:00Z" },
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
localizations: {
|
|
146
|
+
en: { title: "Localized", keywords: ["a"] },
|
|
147
|
+
},
|
|
148
|
+
recurrenceOverrides: {
|
|
149
|
+
"2026-02-02T10:00:00": { title: null, locations: ["x"] },
|
|
150
|
+
},
|
|
151
|
+
timeZones: {
|
|
152
|
+
"Asia/Tokyo": {
|
|
153
|
+
"@type": "TimeZone",
|
|
154
|
+
tzId: "Asia/Tokyo",
|
|
155
|
+
aliases: { JST: true },
|
|
156
|
+
standard: [
|
|
157
|
+
{
|
|
158
|
+
"@type": "TimeZoneRule",
|
|
159
|
+
start: "2026-01-01T00:00:00",
|
|
160
|
+
offsetFrom: "+09:00",
|
|
161
|
+
offsetTo: "+09:00",
|
|
162
|
+
comments: ["note"],
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
daylight: [
|
|
166
|
+
{
|
|
167
|
+
"@type": "TimeZoneRule",
|
|
168
|
+
start: "2026-06-01T00:00:00",
|
|
169
|
+
offsetFrom: "+09:00",
|
|
170
|
+
offsetTo: "+10:00",
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
expect(event.get("method")).toBe("publish");
|
|
177
|
+
});
|
|
65
178
|
it("rejects non-gregorian rscale values", () => {
|
|
66
179
|
expect(() => new JsCal.Event({
|
|
67
180
|
start: "2026-02-01T10:00:00",
|
package/dist/ical.d.ts
CHANGED
|
@@ -4,4 +4,10 @@ export type ICalOptions = {
|
|
|
4
4
|
method?: string;
|
|
5
5
|
includeXJSCalendar?: boolean;
|
|
6
6
|
};
|
|
7
|
+
/**
|
|
8
|
+
* Convert JSCalendar objects into an iCalendar string.
|
|
9
|
+
* @param objects JSCalendar objects to export.
|
|
10
|
+
* @param options Export options.
|
|
11
|
+
* @return iCalendar text.
|
|
12
|
+
*/
|
|
7
13
|
export declare function toICal(objects: JSCalendarObject[], options?: ICalOptions): string;
|