@absolutejs/calendar 0.1.0 → 0.2.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 +16 -0
- package/dist/index.js +10 -2
- package/dist/providers.d.ts +25 -0
- package/dist/providers.js +185 -0
- package/package.json +29 -1
package/README.md
CHANGED
|
@@ -11,3 +11,19 @@ const { days, undated } = groupByDate(tasks, task => task.due);
|
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
No provider calendars, timers, React or storage dependencies are required. Invalid date input throws for arithmetic and is retained in the undated bucket when grouping.
|
|
14
|
+
|
|
15
|
+
## Provider publishing (server only)
|
|
16
|
+
|
|
17
|
+
`@absolutejs/calendar/providers` exports `createCalendarPublisher` and
|
|
18
|
+
`CalendarProviderError`. Supply a Google or Microsoft owner-scoped access token from
|
|
19
|
+
Auth and a stable UUID event key. `publish({ key, title, date, description }, eventId?)`
|
|
20
|
+
creates or updates a private all-day event in the account's primary calendar;
|
|
21
|
+
`remove(key, eventId?)` is idempotent. Persist the UUID before calling the provider,
|
|
22
|
+
then persist the returned ID. Google uses a deterministic event ID; Microsoft uses
|
|
23
|
+
both transactionId and a queryable extended property to recover ambiguous writes.
|
|
24
|
+
Requests time out after 20 seconds, reject redirects and expose sanitized errors.
|
|
25
|
+
There are no attendees, invitations, default reminders or blocked busy time.
|
|
26
|
+
|
|
27
|
+
Apps own permissions, explicit publication intent, durable scheduling and retention.
|
|
28
|
+
Auth owns refresh and revocation. This adapter never reads mailbox content or stores
|
|
29
|
+
credentials. Use a fresh UUID after a completed removal when publishing again.
|
package/dist/index.js
CHANGED
|
@@ -36,7 +36,12 @@ function addMonths(value, months) {
|
|
|
36
36
|
return format(d);
|
|
37
37
|
}
|
|
38
38
|
function dateInZone(now = new Date, timeZone = "UTC") {
|
|
39
|
-
const parts = new Intl.DateTimeFormat("en-US", {
|
|
39
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
40
|
+
timeZone,
|
|
41
|
+
year: "numeric",
|
|
42
|
+
month: "2-digit",
|
|
43
|
+
day: "2-digit"
|
|
44
|
+
}).formatToParts(now);
|
|
40
45
|
const p = (type) => parts.find((p2) => p2.type === type).value;
|
|
41
46
|
return `${p("year")}-${p("month")}-${p("day")}`;
|
|
42
47
|
}
|
|
@@ -53,7 +58,10 @@ function monthDates(value, weekStartsOn = 0) {
|
|
|
53
58
|
return Array.from({ length: 42 }, (_, i) => addDays(first, i));
|
|
54
59
|
}
|
|
55
60
|
function formatCalendarDate(value, options = {}, locale = "en-US") {
|
|
56
|
-
return new Intl.DateTimeFormat(locale, {
|
|
61
|
+
return new Intl.DateTimeFormat(locale, {
|
|
62
|
+
...options,
|
|
63
|
+
timeZone: "UTC"
|
|
64
|
+
}).format(parse(value));
|
|
57
65
|
}
|
|
58
66
|
function groupByDate(items, getDate) {
|
|
59
67
|
const days = new Map, undated = [];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export type CalendarEvent = {
|
|
2
|
+
key: string;
|
|
3
|
+
title: string;
|
|
4
|
+
date: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
};
|
|
7
|
+
export type CalendarProvider = 'google' | 'microsoft';
|
|
8
|
+
export declare class CalendarProviderError extends Error {
|
|
9
|
+
readonly status: number;
|
|
10
|
+
readonly retryAfterMs?: number;
|
|
11
|
+
constructor(status: number, retryAfterMs?: number);
|
|
12
|
+
}
|
|
13
|
+
/** Server-only adapter. The caller supplies an owner-scoped lease; no tokens are persisted here.
|
|
14
|
+
* Durable UUID keys recover ambiguous creates without duplicating events. No attendees,
|
|
15
|
+
* invitations, conferencing, reminders or busy time are added. Dates are all-day dates.
|
|
16
|
+
*/
|
|
17
|
+
export declare function createCalendarPublisher(options: {
|
|
18
|
+
provider: CalendarProvider;
|
|
19
|
+
accessToken: string;
|
|
20
|
+
fetch?: typeof fetch;
|
|
21
|
+
signal?: AbortSignal;
|
|
22
|
+
}): {
|
|
23
|
+
publish(event: CalendarEvent, existingId?: string): Promise<string>;
|
|
24
|
+
remove(key: string, existingId?: string): Promise<void>;
|
|
25
|
+
};
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
function isCalendarDate(value) {
|
|
3
|
+
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value))
|
|
4
|
+
return false;
|
|
5
|
+
const d = new Date(value + "T12:00:00Z");
|
|
6
|
+
return Number.isFinite(d.getTime()) && d.toISOString().slice(0, 10) === value;
|
|
7
|
+
}
|
|
8
|
+
function parse(value) {
|
|
9
|
+
if (!isCalendarDate(value))
|
|
10
|
+
throw new RangeError("Invalid calendar date");
|
|
11
|
+
return new Date(value + "T12:00:00Z");
|
|
12
|
+
}
|
|
13
|
+
function format(date) {
|
|
14
|
+
const value = date.toISOString().slice(0, 10);
|
|
15
|
+
if (!isCalendarDate(value))
|
|
16
|
+
throw new RangeError("Calendar date out of range");
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
function addDays(value, days) {
|
|
20
|
+
if (!Number.isInteger(days))
|
|
21
|
+
throw new RangeError("Days must be an integer");
|
|
22
|
+
const d = parse(value);
|
|
23
|
+
d.setUTCDate(d.getUTCDate() + days);
|
|
24
|
+
return format(d);
|
|
25
|
+
}
|
|
26
|
+
function addMonths(value, months) {
|
|
27
|
+
if (!Number.isInteger(months))
|
|
28
|
+
throw new RangeError("Months must be an integer");
|
|
29
|
+
const d = parse(value), day = d.getUTCDate();
|
|
30
|
+
d.setUTCDate(1);
|
|
31
|
+
d.setUTCMonth(d.getUTCMonth() + months);
|
|
32
|
+
const last = new Date(d);
|
|
33
|
+
last.setUTCMonth(last.getUTCMonth() + 1);
|
|
34
|
+
last.setUTCDate(0);
|
|
35
|
+
d.setUTCDate(Math.min(day, last.getUTCDate()));
|
|
36
|
+
return format(d);
|
|
37
|
+
}
|
|
38
|
+
function dateInZone(now = new Date, timeZone = "UTC") {
|
|
39
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
40
|
+
timeZone,
|
|
41
|
+
year: "numeric",
|
|
42
|
+
month: "2-digit",
|
|
43
|
+
day: "2-digit"
|
|
44
|
+
}).formatToParts(now);
|
|
45
|
+
const p = (type) => parts.find((p2) => p2.type === type).value;
|
|
46
|
+
return `${p("year")}-${p("month")}-${p("day")}`;
|
|
47
|
+
}
|
|
48
|
+
function weekDates(value, weekStartsOn = 0) {
|
|
49
|
+
if (!Number.isInteger(weekStartsOn) || weekStartsOn < 0 || weekStartsOn > 6)
|
|
50
|
+
throw new RangeError("Week start must be 0–6");
|
|
51
|
+
const day = parse(value).getUTCDay();
|
|
52
|
+
const first = addDays(value, -((day - weekStartsOn + 7) % 7));
|
|
53
|
+
return Array.from({ length: 7 }, (_, i) => addDays(first, i));
|
|
54
|
+
}
|
|
55
|
+
function monthDates(value, weekStartsOn = 0) {
|
|
56
|
+
parse(value);
|
|
57
|
+
const first = weekDates(value.slice(0, 7) + "-01", weekStartsOn)[0];
|
|
58
|
+
return Array.from({ length: 42 }, (_, i) => addDays(first, i));
|
|
59
|
+
}
|
|
60
|
+
function formatCalendarDate(value, options = {}, locale = "en-US") {
|
|
61
|
+
return new Intl.DateTimeFormat(locale, {
|
|
62
|
+
...options,
|
|
63
|
+
timeZone: "UTC"
|
|
64
|
+
}).format(parse(value));
|
|
65
|
+
}
|
|
66
|
+
function groupByDate(items, getDate) {
|
|
67
|
+
const days = new Map, undated = [];
|
|
68
|
+
for (const item of items) {
|
|
69
|
+
const date = getDate(item);
|
|
70
|
+
if (!isCalendarDate(date)) {
|
|
71
|
+
undated.push(item);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const bucket = days.get(date) ?? [];
|
|
75
|
+
bucket.push(item);
|
|
76
|
+
days.set(date, bucket);
|
|
77
|
+
}
|
|
78
|
+
return { days, undated };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// src/providers.ts
|
|
82
|
+
class CalendarProviderError extends Error {
|
|
83
|
+
status;
|
|
84
|
+
retryAfterMs;
|
|
85
|
+
constructor(status, retryAfterMs) {
|
|
86
|
+
super(status === 401 ? "Reconnect your calendar account." : status === 403 ? "Calendar permission is unavailable." : "Calendar provider is temporarily unavailable.");
|
|
87
|
+
this.status = status;
|
|
88
|
+
this.retryAfterMs = retryAfterMs;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
var property = "String {c5887f52-395e-4fde-9a3c-f91d2902f62c} Name AbsoluteCalendarKey";
|
|
92
|
+
function createCalendarPublisher(options) {
|
|
93
|
+
const fetcher = options.fetch ?? fetch;
|
|
94
|
+
const base = options.provider === "google" ? "https://www.googleapis.com/calendar/v3/calendars/primary/events" : "https://graph.microsoft.com/v1.0/me/calendar/events";
|
|
95
|
+
const call = async (method, path = "", body) => {
|
|
96
|
+
const signal = options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(20000)]) : AbortSignal.timeout(20000);
|
|
97
|
+
const response = await fetcher(base + path, { method, signal, redirect: "error", headers: { Authorization: `Bearer ${options.accessToken}`, "Content-Type": "application/json", Prefer: 'IdType="ImmutableId"' }, ...body === undefined ? {} : { body: JSON.stringify(body) } });
|
|
98
|
+
if (!response.ok && ![404, 409, 410].includes(response.status)) {
|
|
99
|
+
const retry = response.headers.get("retry-after");
|
|
100
|
+
const seconds = retry ? Number(retry) : NaN;
|
|
101
|
+
throw new CalendarProviderError(response.status, Number.isFinite(seconds) ? Math.max(0, seconds * 1000) : undefined);
|
|
102
|
+
}
|
|
103
|
+
return response;
|
|
104
|
+
};
|
|
105
|
+
const keyId = (key) => {
|
|
106
|
+
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(key))
|
|
107
|
+
throw Error("A durable UUID event key is required");
|
|
108
|
+
return key.toLowerCase().replaceAll("-", "");
|
|
109
|
+
};
|
|
110
|
+
const find = async (key) => {
|
|
111
|
+
const id = keyId(key);
|
|
112
|
+
if (options.provider === "google")
|
|
113
|
+
return id;
|
|
114
|
+
const params = new URLSearchParams({ $filter: `singleValueExtendedProperties/Any(ep: ep/id eq '${property}' and ep/value eq '${id}')`, $select: "id", $top: "2" });
|
|
115
|
+
const response = await call("GET", "?" + params);
|
|
116
|
+
if (!response.ok)
|
|
117
|
+
throw new CalendarProviderError(response.status);
|
|
118
|
+
const data = await response.json();
|
|
119
|
+
if (!Array.isArray(data.value) || data.value.length > 1)
|
|
120
|
+
throw Error("Calendar event identity is ambiguous");
|
|
121
|
+
return data.value[0]?.id ?? null;
|
|
122
|
+
};
|
|
123
|
+
return {
|
|
124
|
+
async publish(event, existingId) {
|
|
125
|
+
const key = keyId(event.key);
|
|
126
|
+
if (!isCalendarDate(event.date) || !event.title.trim() || event.title.length > 1000 || (event.description?.length ?? 0) > 1e4)
|
|
127
|
+
throw Error("Invalid calendar event");
|
|
128
|
+
const end = addDays(event.date, 1);
|
|
129
|
+
const body = options.provider === "google" ? {
|
|
130
|
+
summary: event.title,
|
|
131
|
+
description: event.description ?? "",
|
|
132
|
+
start: { date: event.date },
|
|
133
|
+
end: { date: end },
|
|
134
|
+
transparency: "transparent",
|
|
135
|
+
visibility: "private",
|
|
136
|
+
reminders: { useDefault: false }
|
|
137
|
+
} : {
|
|
138
|
+
subject: event.title,
|
|
139
|
+
body: { contentType: "text", content: event.description ?? "" },
|
|
140
|
+
isAllDay: true,
|
|
141
|
+
start: { dateTime: event.date + "T00:00:00", timeZone: "UTC" },
|
|
142
|
+
end: { dateTime: end + "T00:00:00", timeZone: "UTC" },
|
|
143
|
+
showAs: "free",
|
|
144
|
+
sensitivity: "private",
|
|
145
|
+
isReminderOn: false
|
|
146
|
+
};
|
|
147
|
+
const id = existingId ?? await find(event.key);
|
|
148
|
+
if (id) {
|
|
149
|
+
const response2 = await call("PATCH", "/" + encodeURIComponent(id), body);
|
|
150
|
+
if (response2.ok)
|
|
151
|
+
return id;
|
|
152
|
+
if (![404, 410].includes(response2.status))
|
|
153
|
+
throw new CalendarProviderError(response2.status);
|
|
154
|
+
if (response2.status === 410)
|
|
155
|
+
throw new CalendarProviderError(410);
|
|
156
|
+
}
|
|
157
|
+
const response = await call("POST", "", { ...body, ...options.provider === "google" ? { id: key } : { transactionId: event.key, singleValueExtendedProperties: [{ id: property, value: key }] } });
|
|
158
|
+
if (response.status === 409 && options.provider === "google") {
|
|
159
|
+
const updated = await call("PATCH", "/" + key, body);
|
|
160
|
+
if (updated.ok)
|
|
161
|
+
return key;
|
|
162
|
+
throw new CalendarProviderError(updated.status);
|
|
163
|
+
}
|
|
164
|
+
if (!response.ok)
|
|
165
|
+
throw new CalendarProviderError(response.status);
|
|
166
|
+
const data = await response.json();
|
|
167
|
+
if (!data.id)
|
|
168
|
+
throw Error("Calendar provider returned no event ID");
|
|
169
|
+
return data.id;
|
|
170
|
+
},
|
|
171
|
+
async remove(key, existingId) {
|
|
172
|
+
keyId(key);
|
|
173
|
+
const id = existingId ?? await find(key);
|
|
174
|
+
if (!id)
|
|
175
|
+
return;
|
|
176
|
+
const response = await call("DELETE", "/" + encodeURIComponent(id));
|
|
177
|
+
if (!response.ok && ![404, 410].includes(response.status))
|
|
178
|
+
throw new CalendarProviderError(response.status);
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
export {
|
|
183
|
+
CalendarProviderError,
|
|
184
|
+
createCalendarPublisher
|
|
185
|
+
};
|
package/package.json
CHANGED
|
@@ -1 +1,29 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"name": "@absolutejs/calendar",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Date-only calendar ranges and layouts for AbsoluteJS applications",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"import": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./providers": {
|
|
13
|
+
"types": "./dist/providers.d.ts",
|
|
14
|
+
"import": "./dist/providers.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "bun build src/index.ts src/providers.ts --outdir dist --target browser && tsc --declaration --emitDeclarationOnly --target ES2022 --module ESNext --moduleResolution bundler --skipLibCheck --outDir dist src/index.ts src/providers.ts",
|
|
23
|
+
"test": "bun test"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"typescript": "5.9.3",
|
|
27
|
+
"@types/bun": "^1.3.14"
|
|
28
|
+
}
|
|
29
|
+
}
|