11#!/usr/bin/env node
22
3- import { writeFile } from "node:fs/promises"
3+ import { readFile , writeFile } from "node:fs/promises"
44import { type } from "arktype"
55
66const CALENDAR_ID =
77 "linuxfoundation.org_ik79t9uuj2p32i3r203dgv5mo8@group.calendar.google.com"
88const API_KEY = process . env . GOOGLE_CALENDAR_API_KEY
9- const OUTPUT_FILE = new URL ( "./upcoming-events.ndjson" , import . meta. url )
10- const MAX_RESULTS = 25
9+ const OUTPUT_FILE = new URL ( "./working-group-events.ndjson" , import . meta. url )
10+ const DAYS_BACK = 30
11+ const DAYS_TO_KEEP = 90
12+ const DAYS_AHEAD = 30
1113const DATETIME_REGEX =
1214 / ^ \d { 4 } - \d { 2 } - \d { 2 } T \d { 2 } : \d { 2 } (?: : \d { 2 } ) ? (?: \. \d + ) ? (?: Z | [ + - ] \d { 2 } : \d { 2 } ) ? $ /
1315
@@ -31,6 +33,8 @@ const Event = type({
3133
3234const responseSchema = type ( {
3335 items : Event . array ( ) ,
36+ "nextSyncToken?" : "string" ,
37+ "nextPageToken?" : "string" ,
3438} )
3539
3640const WorkingGroupMeetingSchema = type ( {
@@ -54,25 +58,49 @@ async function main() {
5458 process . exit ( 1 )
5559 }
5660
57- const timeMin = new Date ( ) . toISOString ( )
61+ const now = new Date ( )
62+ const existingMeetings = await readExistingMeetings ( )
63+ console . log ( `Found ${ existingMeetings . length } existing event(s) in file` )
64+
65+ const lastMeeting = existingMeetings . at ( - 1 )
66+ const lastMeetingStart =
67+ lastMeeting ?. start . dateTime ??
68+ ( lastMeeting ?. start . date ? `${ lastMeeting . start . date } T00:00:00Z` : null )
69+ const cutoffDate = new Date (
70+ now . getTime ( ) - DAYS_TO_KEEP * 24 * 60 * 60 * 1000 ,
71+ )
72+
5873 const searchParams = new URLSearchParams ( {
5974 key : API_KEY ,
60- timeMin,
6175 singleEvents : "true" ,
62- orderBy : "startTime" ,
63- maxResults : String ( MAX_RESULTS ) ,
6476 } )
6577
78+ const timeMin =
79+ lastMeetingStart !== null && lastMeetingStart !== undefined
80+ ? new Date ( Math . min ( Date . parse ( lastMeetingStart ) + 1 , now . getTime ( ) ) )
81+ : new Date ( now . getTime ( ) - DAYS_BACK * 24 * 60 * 60 * 1000 )
82+
83+ const timeMax = new Date ( now . getTime ( ) + DAYS_AHEAD * 24 * 60 * 60 * 1000 )
84+ searchParams . set ( "timeMin" , timeMin . toISOString ( ) )
85+ searchParams . set ( "timeMax" , timeMax . toISOString ( ) )
86+ searchParams . set ( "orderBy" , "startTime" )
87+ console . log (
88+ `\nSyncing from: ${ timeMin . toLocaleDateString ( ) } (${ timeMin . toISOString ( ) } )` ,
89+ )
90+ console . log (
91+ `Limiting to before: ${ timeMax . toLocaleDateString ( ) } (${ timeMax . toISOString ( ) } )` ,
92+ )
93+
6694 const endpoint = new URL (
67- `${ encodeURIComponent ( CALENDAR_ID ) } /events?${ searchParams } ` ,
68- "https://www.googleapis.com/calendar/v3/calendars/ " ,
95+ `calendars/ ${ encodeURIComponent ( CALENDAR_ID ) } /events?${ searchParams } ` ,
96+ "https://www.googleapis.com/calendar/v3/" ,
6997 )
7098
71- console . log ( `\nFetching events for calendar: ${ CALENDAR_ID } ` )
72- console . log ( `Filtering from: ${ timeMin } ` )
99+ console . log ( `Fetching events for calendar: ${ CALENDAR_ID } ` )
73100
74101 const response = await fetch ( endpoint )
75102 const body = await response . json ( )
103+
76104 if ( ! response . ok ) {
77105 const errorDetails = body . error ?. message || response . statusText
78106 throw new Error (
@@ -81,20 +109,117 @@ async function main() {
81109 }
82110
83111 const payload = responseSchema . assert ( body )
84- const meetings = payload . items
112+
113+ let allNewMeetings = payload . items
85114 . filter ( event => event . status !== "cancelled" )
86115 . map ( toWorkingGroupMeeting )
87- . sort ( ( a , b ) => {
88- const aStart = a . start . dateTime ?? a . start . date ?? ""
89- const bStart = b . start . dateTime ?? b . start . date ?? ""
90- return aStart . localeCompare ( bStart )
91- } )
92-
93- const ndjson = meetings . map ( event => JSON . stringify ( event ) ) . join ( "\n" )
94- const content = meetings . length > 0 ? `${ ndjson } \n` : ""
116+
117+ if ( payload . nextPageToken ) {
118+ let pageToken : string | undefined = payload . nextPageToken
119+ while ( pageToken ) {
120+ const pageParams = new URLSearchParams ( searchParams )
121+ pageParams . set ( "pageToken" , pageToken )
122+ const pageEndpoint = new URL (
123+ `${ encodeURIComponent ( CALENDAR_ID ) } /events?${ pageParams } ` ,
124+ "https://www.googleapis.com/calendar/v3/calendars/" ,
125+ )
126+ const pageResponse = await fetch ( pageEndpoint )
127+ const pageBody = await pageResponse . json ( )
128+ if ( ! pageResponse . ok ) {
129+ throw new Error ( `Page fetch failed: ${ pageResponse . status } ` )
130+ }
131+ const pagePayload = responseSchema . assert ( pageBody )
132+ allNewMeetings = [
133+ ...allNewMeetings ,
134+ ...pagePayload . items
135+ . filter ( event => event . status !== "cancelled" )
136+ . map ( toWorkingGroupMeeting ) ,
137+ ]
138+ pageToken = pagePayload . nextPageToken
139+ }
140+ }
141+
142+ const newMeetings = allNewMeetings
143+ const newIds = new Set ( newMeetings . map ( meeting => meeting . id ) )
144+ const existingIds = new Set ( existingMeetings . map ( meeting => meeting . id ) )
145+ const newCount = Array . from ( newIds ) . filter ( id => ! existingIds . has ( id ) ) . length
146+ console . log (
147+ `Fetched ${ newMeetings . length } event(s) from API (${ newCount } new)` ,
148+ )
149+
150+ const allMeetings = mergeMeetings ( existingMeetings , newMeetings )
151+ const netChange = allMeetings . length - existingMeetings . length
152+
153+ if ( netChange > 0 ) {
154+ console . log ( `Added ${ netChange } new event(s)` )
155+ } else if ( netChange < 0 ) {
156+ console . log ( `Removed ${ Math . abs ( netChange ) } event(s)` )
157+ }
158+ const cutoffDateStr = cutoffDate . toISOString ( ) . split ( "T" ) [ 0 ]
159+ const futureLimit = new Date ( now . getTime ( ) + DAYS_AHEAD * 24 * 60 * 60 * 1000 )
160+ const futureLimitStr = futureLimit . toISOString ( ) . split ( "T" ) [ 0 ]
161+ const filteredMeetings = allMeetings . filter ( meeting => {
162+ const start = meeting . start . dateTime ?? meeting . start . date ?? ""
163+ const startDate = start . split ( "T" ) [ 0 ]
164+ return startDate >= cutoffDateStr && startDate <= futureLimitStr
165+ } )
166+
167+ console . log (
168+ `Keeping events from ${ cutoffDate . toLocaleDateString ( ) } onwards (${ DAYS_TO_KEEP } days)` ,
169+ )
170+ console . log (
171+ `Filtered to ${ filteredMeetings . length } event(s) after removing old entries` ,
172+ )
173+
174+ const sortedMeetings = filteredMeetings . sort ( ( a , b ) => {
175+ const aStart = a . start . dateTime ?? a . start . date ?? ""
176+ const bStart = b . start . dateTime ?? b . start . date ?? ""
177+ return aStart . localeCompare ( bStart )
178+ } )
179+
180+ const ndjson = sortedMeetings . map ( event => JSON . stringify ( event ) ) . join ( "\n" )
181+ const content = sortedMeetings . length > 0 ? `${ ndjson } \n` : ""
95182 await writeFile ( OUTPUT_FILE , content , "utf8" )
96183
97- console . log ( `Saved ${ meetings . length } event(s) to ${ OUTPUT_FILE . pathname } ` )
184+ console . log (
185+ `Saved ${ sortedMeetings . length } event(s) to ${ OUTPUT_FILE . pathname } ` ,
186+ )
187+ }
188+
189+ async function readExistingMeetings ( ) : Promise < WorkingGroupMeeting [ ] > {
190+ try {
191+ const content = await readFile ( OUTPUT_FILE , "utf8" )
192+ return content
193+ . trim ( )
194+ . split ( "\n" )
195+ . filter ( line => line . trim ( ) )
196+ . map ( line => WorkingGroupMeetingSchema . assert ( JSON . parse ( line ) ) )
197+ } catch ( error : any ) {
198+ if ( error . code === "ENOENT" ) {
199+ return [ ]
200+ }
201+ throw error
202+ }
203+ }
204+
205+ function mergeMeetings (
206+ existing : WorkingGroupMeeting [ ] ,
207+ incoming : WorkingGroupMeeting [ ] ,
208+ ) : WorkingGroupMeeting [ ] {
209+ const byId = new Map < string , WorkingGroupMeeting > ( )
210+
211+ for ( const meeting of existing ) {
212+ byId . set ( meeting . id , meeting )
213+ }
214+
215+ for ( const meeting of incoming ) {
216+ const existing = byId . get ( meeting . id )
217+ if ( ! existing || meeting . updated > existing . updated ) {
218+ byId . set ( meeting . id , meeting )
219+ }
220+ }
221+
222+ return Array . from ( byId . values ( ) )
98223}
99224
100225function toWorkingGroupMeeting ( event : CalendarEvent ) : WorkingGroupMeeting {
0 commit comments