@craftguild/jscalendar 0.2.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 +63 -0
- 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
|
@@ -72,6 +72,69 @@ const group = new JsCal.Group({
|
|
|
72
72
|
});
|
|
73
73
|
```
|
|
74
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
|
+
|
|
75
138
|
## Ejecting to plain objects
|
|
76
139
|
|
|
77
140
|
`JsCal.Event`/`JsCal.Task` are class instances with helper methods and a
|
|
@@ -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;
|
package/dist/ical.js
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { normalizeUtcDateTime } from "./utils.js";
|
|
2
|
+
const TYPE_EVENT = "Event";
|
|
3
|
+
const TYPE_GROUP = "Group";
|
|
4
|
+
const TYPE_TASK = "Task";
|
|
2
5
|
const DEFAULT_PRODID = "-//craftguild//EN";
|
|
6
|
+
/**
|
|
7
|
+
* Convert JSCalendar objects into an iCalendar string.
|
|
8
|
+
* @param objects JSCalendar objects to export.
|
|
9
|
+
* @param options Export options.
|
|
10
|
+
* @return iCalendar text.
|
|
11
|
+
*/
|
|
3
12
|
export function toICal(objects, options = {}) {
|
|
4
13
|
const lines = [];
|
|
5
14
|
const includeX = options.includeXJSCalendar !== false;
|
|
@@ -10,7 +19,7 @@ export function toICal(objects, options = {}) {
|
|
|
10
19
|
if (method)
|
|
11
20
|
lines.push(`METHOD:${method.toUpperCase()}`);
|
|
12
21
|
for (const object of objects) {
|
|
13
|
-
if (object["@type"] ===
|
|
22
|
+
if (object["@type"] === TYPE_GROUP) {
|
|
14
23
|
const group = object;
|
|
15
24
|
if (includeX) {
|
|
16
25
|
lines.push(`X-JSCALENDAR-GROUP:${escapeText(JSON.stringify(stripEntries(group)))}`);
|
|
@@ -26,6 +35,11 @@ export function toICal(objects, options = {}) {
|
|
|
26
35
|
lines.push("END:VCALENDAR");
|
|
27
36
|
return foldLines(lines).join("\r\n");
|
|
28
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Find the first METHOD value from objects.
|
|
40
|
+
* @param objects JSCalendar objects.
|
|
41
|
+
* @return METHOD value or undefined.
|
|
42
|
+
*/
|
|
29
43
|
function findMethod(objects) {
|
|
30
44
|
for (const object of objects) {
|
|
31
45
|
if (object.method)
|
|
@@ -33,13 +47,25 @@ function findMethod(objects) {
|
|
|
33
47
|
}
|
|
34
48
|
return undefined;
|
|
35
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Build an iCalendar component from a JSCalendar object.
|
|
52
|
+
* @param object JSCalendar object.
|
|
53
|
+
* @param includeX Whether to include X-JSCALENDAR.
|
|
54
|
+
* @return iCalendar lines for the component.
|
|
55
|
+
*/
|
|
36
56
|
function buildComponent(object, includeX) {
|
|
37
|
-
if (object["@type"] ===
|
|
57
|
+
if (object["@type"] === TYPE_EVENT)
|
|
38
58
|
return buildEvent(object, includeX);
|
|
39
|
-
if (object["@type"] ===
|
|
59
|
+
if (object["@type"] === TYPE_TASK)
|
|
40
60
|
return buildTask(object, includeX);
|
|
41
61
|
return [];
|
|
42
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Build a VEVENT component.
|
|
65
|
+
* @param event Event object.
|
|
66
|
+
* @param includeX Whether to include X-JSCALENDAR.
|
|
67
|
+
* @return VEVENT lines.
|
|
68
|
+
*/
|
|
43
69
|
function buildEvent(event, includeX) {
|
|
44
70
|
const lines = [];
|
|
45
71
|
lines.push("BEGIN:VEVENT");
|
|
@@ -69,6 +95,12 @@ function buildEvent(event, includeX) {
|
|
|
69
95
|
lines.push("END:VEVENT");
|
|
70
96
|
return lines;
|
|
71
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* Build a VTODO component.
|
|
100
|
+
* @param task Task object.
|
|
101
|
+
* @param includeX Whether to include X-JSCALENDAR.
|
|
102
|
+
* @return VTODO lines.
|
|
103
|
+
*/
|
|
72
104
|
function buildTask(task, includeX) {
|
|
73
105
|
const lines = [];
|
|
74
106
|
lines.push("BEGIN:VTODO");
|
|
@@ -111,6 +143,12 @@ function buildTask(task, includeX) {
|
|
|
111
143
|
lines.push("END:VTODO");
|
|
112
144
|
return lines;
|
|
113
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Append RRULE lines for recurrence rules.
|
|
148
|
+
* @param lines Lines to append to.
|
|
149
|
+
* @param rules Recurrence rules.
|
|
150
|
+
* @return Nothing.
|
|
151
|
+
*/
|
|
114
152
|
function appendRecurrence(lines, rules) {
|
|
115
153
|
if (!rules)
|
|
116
154
|
return;
|
|
@@ -120,6 +158,11 @@ function appendRecurrence(lines, rules) {
|
|
|
120
158
|
lines.push(`RRULE:${rrule}`);
|
|
121
159
|
}
|
|
122
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Convert a RecurrenceRule to an RRULE string.
|
|
163
|
+
* @param rule Recurrence rule.
|
|
164
|
+
* @return RRULE value or null.
|
|
165
|
+
*/
|
|
123
166
|
function recurrenceRuleToRRule(rule) {
|
|
124
167
|
const parts = [];
|
|
125
168
|
parts.push(`FREQ=${rule.frequency.toUpperCase()}`);
|
|
@@ -159,6 +202,11 @@ function recurrenceRuleToRRule(rule) {
|
|
|
159
202
|
parts.push(`SKIP=${rule.skip.toUpperCase()}`);
|
|
160
203
|
return parts.join(";");
|
|
161
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* Format a UTCDateTime for iCalendar.
|
|
207
|
+
* @param value UTCDateTime string.
|
|
208
|
+
* @return iCalendar-formatted UTCDateTime.
|
|
209
|
+
*/
|
|
162
210
|
function formatUtcDateTime(value) {
|
|
163
211
|
const normalized = normalizeUtcDateTime(value);
|
|
164
212
|
return normalized
|
|
@@ -166,12 +214,22 @@ function formatUtcDateTime(value) {
|
|
|
166
214
|
.replace(/\.\d+Z$/, "Z")
|
|
167
215
|
.replace(/Z$/, "Z");
|
|
168
216
|
}
|
|
217
|
+
/**
|
|
218
|
+
* Format a LocalDateTime for iCalendar.
|
|
219
|
+
* @param value LocalDateTime string.
|
|
220
|
+
* @return iCalendar-formatted LocalDateTime.
|
|
221
|
+
*/
|
|
169
222
|
function formatLocalDateTime(value) {
|
|
170
223
|
return value
|
|
171
224
|
.replace(/[-:]/g, "")
|
|
172
225
|
.replace(/\.\d+$/, "")
|
|
173
226
|
.replace(/Z$/, "");
|
|
174
227
|
}
|
|
228
|
+
/**
|
|
229
|
+
* Escape text for iCalendar content lines.
|
|
230
|
+
* @param value Raw text.
|
|
231
|
+
* @return Escaped text.
|
|
232
|
+
*/
|
|
175
233
|
function escapeText(value) {
|
|
176
234
|
return value
|
|
177
235
|
.replace(/\\/g, "\\\\")
|
|
@@ -179,6 +237,11 @@ function escapeText(value) {
|
|
|
179
237
|
.replace(/;/g, "\\;")
|
|
180
238
|
.replace(/,/g, "\\,");
|
|
181
239
|
}
|
|
240
|
+
/**
|
|
241
|
+
* Fold iCalendar lines to 75 octets per RFC.
|
|
242
|
+
* @param lines Lines to fold.
|
|
243
|
+
* @return Folded lines.
|
|
244
|
+
*/
|
|
182
245
|
function foldLines(lines) {
|
|
183
246
|
const result = [];
|
|
184
247
|
for (const line of lines) {
|
|
@@ -196,6 +259,11 @@ function foldLines(lines) {
|
|
|
196
259
|
}
|
|
197
260
|
return result;
|
|
198
261
|
}
|
|
262
|
+
/**
|
|
263
|
+
* Strip group entries for X-JSCALENDAR-GROUP payload.
|
|
264
|
+
* @param group Group object.
|
|
265
|
+
* @return Group without entries.
|
|
266
|
+
*/
|
|
199
267
|
function stripEntries(group) {
|
|
200
268
|
const { entries: _entries, ...rest } = group;
|
|
201
269
|
return rest;
|