forked from Flare576/sync-multiple-google-calendars
-
Notifications
You must be signed in to change notification settings - Fork 0
/
MergeCalendarsTogether.gs
357 lines (322 loc) · 11.3 KB
/
MergeCalendarsTogether.gs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
// Calendars to merge.
const CALENDARS_TO_MERGE = [
'calendar-id1@gmail.com',
'calendar-id2@gmail.com',
];
// Number of days in the past and future to sync.
const SYNC_DAYS_IN_PAST = 7;
const SYNC_DAYS_IN_FUTURE = 30;
// While set to "true", this script will make ABSOLUTELY NO CHANGES to any Calendar
// Set this to "false" when your happy with the debug output!
const DEBUG_ONLY = true;
// Configure event summaries to ignore (don't sync). These values are used with
// RegExp.test() so when just a string literal, they act like a case-sensitive
// "contains" check. If you want more control, use the line start (^) and/or
// line end ($) regex symbols.
const IGNORE_LIST_REGEXES = [
// 'Contains Match',
// '^Starts With Match',
// 'Ends With Match$',
// '^Some Exact Match$',
// '^Exact start.*Exact end$', // with anything in the middle
]
// Configure event summaries to obfuscate (sync but with no details). These values
// are used with RegExp.test() so when just a string literal, they act like a
// case-sensitive "contains" check. If you want more control, use the line start
// (^) and/or line end ($) regex symbols.
const OBFUSCATE_LIST_REGEXES = [
// 'Contains Match',
// '^Starts With Match',
// 'Ends With Match$',
// '^Some Exact Match$',
// '^Exact start.*Exact end$', // with anything in the middle
]
// should we copy event descriptions?
const USER_INCLUDE_DESC = false;
// ----------------------------------------------------------------------------
// DO NOT TOUCH FROM HERE ON
// ----------------------------------------------------------------------------
const VERSION = '0.1.1';
const ENDPOINT_BASE = 'https://www.googleapis.com/calendar/v3/calendars';
const MERGE_PREFIX = '🔄 ';
const DESC_NOT_COPIED_MSG = '(description not copied)'
const SUMMARY_NOT_COPIED_MSG = '(summary not copied)'
const LOC_NOT_COPIED_MSG = '(location not copied)'
// listed as first function so it's the default to run in the web UI
function MergeCalendarsTogether() {
const dates = GetStartEndDates();
const calendars = RetrieveCalendars(dates[0], dates[1]);
MergeCalendars(calendars);
}
function DeleteAllMerged () {
const dates = GetStartEndDates();
const calendars = RetrieveCalendars(dates[0], dates[1]);
// Easiest way to clear out all merged events is to ensure there's no matching Primary events
calendars.forEach(calendar => {
calendar.primary = [];
});
MergeCalendars(calendars);
}
function GetStartEndDates () {
const SDIP = typeof module !== 'undefined' && typeof module.exports.TEST_SYNC_DAYS_IN_PAST === 'number'
? module.exports.TEST_SYNC_DAYS_IN_PAST
: SYNC_DAYS_IN_PAST
const SDIF = typeof module !== 'undefined' && typeof module.exports.TEST_SYNC_DAYS_IN_FUTURE === 'number'
? module.exports.TEST_SYNC_DAYS_IN_FUTURE
: SYNC_DAYS_IN_FUTURE
// Midnight today
const startDate = new Date();
startDate.setHours(0, 0, 0, 0);
startDate.setDate(startDate.getDate() - SDIP);
const endDate = new Date();
endDate.setHours(0, 0, 0, 0);
endDate.setDate(endDate.getDate() + SDIF);
return [startDate, endDate];
}
function INCLUDE_DESC() {
if (typeof module === 'undefined') {
return USER_INCLUDE_DESC
}
return typeof module.exports.TEST_INCLUDE_DESC === 'boolean'
? module.exports.TEST_INCLUDE_DESC
: USER_INCLUDE_DESC
}
function IsOnIgnoreList(event) {
for (const currRe of IGNORE_LIST_REGEXES) {
const isMatch = new RegExp(currRe).test(event.summary)
if (isMatch) {
console.log(`Ignoring event "${event.summary}" that matches regex "${currRe}"`)
return true
}
}
return false
}
function IsOnObfuscateList(event) {
for (const currRe of OBFUSCATE_LIST_REGEXES) {
const isMatch = new RegExp(currRe).test(event.summary)
if (isMatch) {
console.log(`Obfuscating event "${event.summary}" that matches regex "${currRe}"`)
return true
}
}
return false
}
function GetMergeSummary(event) {
return `${MERGE_PREFIX}${event.summary}`;
}
function IsMergeSummary(event) {
return (event.summary || '').startsWith(MERGE_PREFIX);
}
function GetRealStart(event) {
// Convert all date-times to UTC for comparisons
return new Date(event.start.dateTime).toUTCString();
}
function DateObjectToItems(dateObject) {
return Object.keys(dateObject).reduce((items, day) => items.concat(dateObject[day]), [])
}
function ExistsInOrigin(origin, mergedEvent) {
const realStart = GetRealStart(mergedEvent);
return !!origin.primary[realStart]
?.some(originEvent => {
return mergedEvent.summary === GetMergeSummary(originEvent) &&
mergedEvent.location === originEvent.location
})
}
function ExistsInDestination(destination, originEvent) {
const realStart = GetRealStart(originEvent);
return !!destination.merged[realStart]
?.some(mergedEvent => {
return mergedEvent.summary === GetMergeSummary(originEvent) &&
mergedEvent.location === originEvent.location &&
!isDescWrong(mergedEvent) // sorry for the double negative :'(
})
}
function GetDesc(event) {
if (!INCLUDE_DESC()) {
return DESC_NOT_COPIED_MSG
}
return event.description
}
function isDescWrong(event) {
if (INCLUDE_DESC()) {
const shouldHaveDescButDoesNot = event.description === DESC_NOT_COPIED_MSG
return shouldHaveDescButDoesNot
}
const shouldNotHaveDescButDoes = event.description !== DESC_NOT_COPIED_MSG
return shouldNotHaveDescButDoes
}
function SortEvents(calendarId, items) {
const primary = {};
const merged = {};
items.forEach((event) => {
// Don't copy "free" events.
if (event.transparency === 'transparent') {
console.log(`Ignoring transparent event: ${event.summary}`)
return;
}
const realStart = GetRealStart(event);
if (IsMergeSummary(event)) {
const eventDateTime = merged[realStart] || [];
if (eventDateTime.some(e => e.summary === event.summary)) {
event.isDuplicate = true;
console.log(`Marking "${event.summary}" as duplicate`)
}
eventDateTime.push(event)
merged[realStart] = eventDateTime;
} else {
// only check ignores for the "primary". We need them to still end up in the
// "merged" so they'll be cleaned up when new ignores are added.
if (IsOnIgnoreList(event)) {
return
}
const eventDateTime = primary[realStart] || [];
const [summary, description, location] = (() => {
if (!IsOnObfuscateList(event)) {
return [event.summary, event.description, event.location]
}
return [SUMMARY_NOT_COPIED_MSG, DESC_NOT_COPIED_MSG, LOC_NOT_COPIED_MSG]
})()
eventDateTime.push({
...event,
summary,
description,
location,
})
primary[realStart] = eventDateTime;
}
});
return {
calendarId,
primary,
merged,
}
}
function RetrieveCalendars(startTime, endTime) {
const calendars = []
CALENDARS_TO_MERGE.forEach(calendarId => {
const calendarCheck = CalendarApp.getCalendarById(calendarId);
if (!calendarCheck) {
const msg = `Calendar not found: ${calendarId}. Be sure you've shared the`
+ `calendar to this account AND accepted the share!`
console.log(msg)
return;
}
// Find events
const items = [];
let nextPage;
do {
let options = {
timeMin: startTime.toISOString(),
timeMax: endTime.toISOString(),
singleEvents: true,
orderBy: 'startTime',
};
if (nextPage) {
options.pageToken = nextPage;
}
const result = Calendar.Events.list(calendarId, options);
items.push(...result.items)
nextPage = result.nextPageToken;
} while(nextPage);
console.log(`Found ${items.length} items for ${calendarId}`)
const isNoEventsFound = !items.length
if (isNoEventsFound) {
return;
}
calendars.push(SortEvents(calendarId, items));
});
return calendars;
}
function MergeCalendars (calendars) {
// One Calender per batch...
const payloadSets = {};
calendars.forEach(({calendarId, primary, merged}) => {
// Now that we have all events for all calendars, ensure each calendar's
// primary events are merged to others
DateObjectToItems(primary).forEach(originEvent => {
calendars
.filter(destination => destination.calendarId !== calendarId) // Don't send to the current calendar
.forEach(destination => {
const calendarRequests = payloadSets[destination.calendarId] || [];
if (!ExistsInDestination(destination, originEvent)) {
const body = {
summary: GetMergeSummary(originEvent),
location: originEvent.location,
reminders: {
useDefault: false,
overrides: [], // No reminders
},
description: GetDesc(originEvent),
start: originEvent.start,
end: originEvent.end,
}
calendarRequests.push({
method: 'POST',
endpoint: `${ENDPOINT_BASE}/${destination.calendarId}/events`,
summary: body.summary, // Only used in debugging statements
requestBody: body,
});
}
payloadSets[destination.calendarId] = calendarRequests;
});
});
// Also make sure that all of our merged appointments still exist in some
// other calendar's primary list
DateObjectToItems(merged).forEach(mergedEvent => {
const primaryFound = calendars
.some(origin => origin.calendarId !== calendarId &&
ExistsInOrigin(origin, mergedEvent));
if (!primaryFound || mergedEvent.isDuplicate || isDescWrong(mergedEvent)) {
let calendarRequests = payloadSets[calendarId] || [];
calendarRequests.push({
method: 'DELETE',
endpoint: `${ENDPOINT_BASE}/${calendarId}/events/${mergedEvent.getId()
.replace('@google.com', '')}`,
summary: mergedEvent.summary, // Only used in debugging statements
});
payloadSets[calendarId] = calendarRequests;
}
});
});
Object.keys(payloadSets).forEach(calendarId => {
const calendarRequests = payloadSets[calendarId];
if (!(calendarRequests || []).length) {
console.log(`No events to modify for ${calendarId}.`);
return
}
if (!DEBUG_ONLY) {
const result = new BatchRequest({
batchPath: 'batch/calendar/v3',
requests: calendarRequests,
});
if (!result.getResponseCode || result.getResponseCode() !== 200) {
console.log(result)
}
console.log(`${calendarRequests.length} events modified for ${calendarId}:`);
} else {
console.log(`DEBUG: ${calendarRequests.length} events would have been modified for ${calendarId}:`);
}
const loggable = calendarRequests
.map(({method, endpoint, summary}) => ({method, endpoint, summary}))
console.log(`Requests for ${calendarId}`, JSON.stringify(loggable, null, 2));
});
}
if (typeof module !== 'undefined') {
module.exports = {
GetStartEndDates,
ExistsInOrigin,
ExistsInDestination,
MERGE_PREFIX,
DESC_NOT_COPIED_MSG,
isDescWrong,
SortEvents,
IGNORE_LIST_REGEXES,
IsOnIgnoreList,
IsOnObfuscateList,
OBFUSCATE_LIST_REGEXES,
SUMMARY_NOT_COPIED_MSG,
LOC_NOT_COPIED_MSG,
SYNC_DAYS_IN_PAST,
SYNC_DAYS_IN_FUTURE,
}
}