google-event-normalizer.mjs
170 lines 4.6 KB
Raw
sha256:baa800aedf841dbf32081aa7b2befa288ac33dfc7175ac55014c55c4d8c742f9 docs: move durable-auth freeze/evidence to local developmen… Human 11 days ago
1 /**
2 * Pure Google Calendar API event → Calendar Events v0 normalizer (Phase 1D).
3 *
4 * No network. Drops attendees, organizer, location, description, conferencing
5 * links, and attachments per D8. Recurring instances carry recurrence_rule=null.
6 *
7 * @see docs/CALENDAR-OAUTH-CONNECTOR-1D-SPEC.md — D5, D8
8 */
9
10 import { normalizeStatus } from './ics-normalizer.mjs';
11
12 /** @typedef {'confirmed' | 'cancelled' | 'tentative'} CalendarEventStatus */
13
14 /**
15 * @typedef {Object} NormalizedCalendarEvent
16 * @property {string} external_uid
17 * @property {string} start
18 * @property {string} end
19 * @property {string} timezone
20 * @property {string|null} summary
21 * @property {boolean} busy
22 * @property {CalendarEventStatus} status
23 * @property {null} recurrence_rule
24 */
25
26 const MAX_EVENTS = 10_000;
27 const MAX_SUMMARY_LEN = 512;
28
29 /**
30 * @param {unknown} value
31 * @returns {string|null}
32 */
33 function boundedSummary(value) {
34 if (typeof value !== 'string') {
35 return null;
36 }
37 const trimmed = value.trim();
38 if (!trimmed) {
39 return null;
40 }
41 return trimmed.slice(0, MAX_SUMMARY_LEN);
42 }
43
44 /**
45 * @param {Record<string, unknown>|undefined|null} dateTimeObj
46 * @param {string} [fallbackTz='UTC']
47 * @returns {{ instant: string, timezone: string } | null}
48 */
49 function parseGoogleDateTime(dateTimeObj, fallbackTz = 'UTC') {
50 if (!dateTimeObj || typeof dateTimeObj !== 'object') {
51 return null;
52 }
53 const obj = /** @type {Record<string, unknown>} */ (dateTimeObj);
54 if (typeof obj.date === 'string') {
55 const match = obj.date.match(/^(\d{4})-(\d{2})-(\d{2})$/);
56 if (!match) {
57 return null;
58 }
59 const tz = typeof obj.timeZone === 'string' && obj.timeZone.trim()
60 ? obj.timeZone.trim()
61 : fallbackTz;
62 const instant = new Date(Date.UTC(
63 Number(match[1]),
64 Number(match[2]) - 1,
65 Number(match[3]),
66 0,
67 0,
68 0,
69 )).toISOString();
70 return { instant, timezone: tz };
71 }
72 if (typeof obj.dateTime === 'string') {
73 const parsed = Date.parse(obj.dateTime);
74 if (Number.isNaN(parsed)) {
75 return null;
76 }
77 const tz = typeof obj.timeZone === 'string' && obj.timeZone.trim()
78 ? obj.timeZone.trim()
79 : fallbackTz;
80 return { instant: new Date(parsed).toISOString(), timezone: tz };
81 }
82 return null;
83 }
84
85 /**
86 * Normalize one Google Calendar API event item.
87 *
88 * @param {Record<string, unknown>} googleEvent
89 * @returns {NormalizedCalendarEvent | null}
90 */
91 export function normalizeGoogleEvent(googleEvent) {
92 if (!googleEvent || typeof googleEvent !== 'object') {
93 return null;
94 }
95 const id = typeof googleEvent.id === 'string' ? googleEvent.id.trim() : '';
96 if (!id) {
97 return null;
98 }
99 const start = parseGoogleDateTime(
100 /** @type {Record<string, unknown>|undefined} */ (googleEvent.start),
101 );
102 if (!start) {
103 return null;
104 }
105 let end = parseGoogleDateTime(
106 /** @type {Record<string, unknown>|undefined} */ (googleEvent.end),
107 start.timezone,
108 );
109 if (!end) {
110 end = {
111 instant: new Date(Date.parse(start.instant) + 3_600_000).toISOString(),
112 timezone: start.timezone,
113 };
114 }
115 if (Date.parse(end.instant) <= Date.parse(start.instant)) {
116 end = {
117 instant: new Date(Date.parse(start.instant) + 3_600_000).toISOString(),
118 timezone: start.timezone,
119 };
120 }
121 const statusRaw = typeof googleEvent.status === 'string' ? googleEvent.status : 'confirmed';
122 const status = normalizeStatus(statusRaw.toUpperCase());
123 const transparency = typeof googleEvent.transparency === 'string'
124 ? googleEvent.transparency.toLowerCase()
125 : 'opaque';
126 return {
127 external_uid: id,
128 start: start.instant,
129 end: end.instant,
130 timezone: start.timezone,
131 summary: boundedSummary(googleEvent.summary),
132 busy: transparency !== 'transparent',
133 status,
134 recurrence_rule: null,
135 };
136 }
137
138 /**
139 * Normalize a list of Google events, skipping invalid rows.
140 *
141 * @param {unknown[]} items
142 * @returns {NormalizedCalendarEvent[]}
143 */
144 export function normalizeGoogleEvents(items) {
145 if (!Array.isArray(items)) {
146 return [];
147 }
148 if (items.length > MAX_EVENTS) {
149 throw new RangeError(`Google event list exceeds ${MAX_EVENTS} items`);
150 }
151 /** @type {NormalizedCalendarEvent[]} */
152 const out = [];
153 for (const item of items) {
154 const normalized = normalizeGoogleEvent(/** @type {Record<string, unknown>} */ (item));
155 if (normalized) {
156 out.push(normalized);
157 }
158 }
159 return out;
160 }
161
162 /**
163 * Returns true when a normalized event should be tombstoned (cancelled).
164 *
165 * @param {NormalizedCalendarEvent} event
166 * @returns {boolean}
167 */
168 export function isGoogleEventTombstone(event) {
169 return event.status === 'cancelled';
170 }
File History 1 commit
sha256:baa800aedf841dbf32081aa7b2befa288ac33dfc7175ac55014c55c4d8c742f9 docs: move durable-auth freeze/evidence to local developmen… Human 11 days ago