-
Notifications
You must be signed in to change notification settings - Fork 50
/
code.gs
2657 lines (2485 loc) · 96.9 KB
/
code.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
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/* global Logger ScriptApp ContactsApp Utilities Calendar CalendarApp UrlFetchApp MailApp Session */
/* eslint no-multi-spaces: ["error", { ignoreEOLComments: true }] */
/* eslint comma-dangle: ["error", "only-multiline"] */
/*
* Thanks to this script you are going to receive an email before events of each of your contacts.
* The script is easily customizable via some variables listed below.
*/
// SETTINGS
var settings = {
user: {
/*
* GOOGLE EMAIL ADDRESS
*
* Replace this fake Gmail address with the Gmail (or G Suite/Google Apps) address of your
* own Google Account. This is needed to retrieve information about your contacts.
*/
googleEmail: '[email protected]',
/*
* NOTIFICATION EMAIL ADDRESS
*
* Replace this fake email address with the one you want the notifications to be sent
* to. This can be the same email address as 'googleEmail' on or any other email
* address. Non-Gmail addresses are fine as well.
*/
notificationEmail: '[email protected]',
/*
* EMAIL SENDER NAME
*
* This is the name you will see as the sender of the email: if you leave it blank it will
* default to your Google account name.
* Note: this may not work when notificationEmail is a Gmail address.
*/
emailSenderName: 'Contacts Events Notifications',
/*
* LANGUAGE
*
* To translate the notifications messages into your language enter the two-letter language
* code here.
* Available languages are:
* en, cs, de, el, es, fa, fr, he, id, it, kr, lt, nl, no, nb, pl, pt, pt-BR, ru, th, tr.
* If you want to add your own language find the variable called i18n below and follow the
* instructions: it's quite simple as long as you can translate from one of the available
* languages.
*/
lang: 'en'
},
notifications: {
/*
* HOUR OF THE NOTIFICATION
*
* Specify at which hour of the day would you like to receive the email notifications.
* This must be an integer between 0 and 23. This will set and automatic trigger for
* the script between e.g. 6 and 7 am.
*/
hour: 6,
/*
* NOTIFICATION TIMEZONE
*
* To ensure the correctness of the notifications timing please set this variable to the
* timezone you are living in.
* Accepted values:
* GMT (e.g. 'GMT-4', 'GMT+6')
* regional timezones (e.g. 'Europe/Berlin' - See here for a complete list: http://joda-time.sourceforge.net/timezones.html)
*/
timeZone: 'Europe/Rome',
/*
* HOW MANY DAYS BEFORE EVENT
*
* Here you have to decide when you want to receive the email notification.
* Insert a comma-separated list of numbers between the square brackets, where each number
* represents how many days before an event you want to be notified.
* If you want to be notified only once then enter a single number between the brackets.
*
* Examples:
* [0] means "Notify me the day of the event";
* [0, 7] means "Notify me the day of the event and 7 days before";
* [0, 1, 7] means "Notify me the day of the event, the day before and 7 days before";
*
* Note: in any case you will receive one email per day: all the notifications will be grouped
* together in that email.
*/
anticipateDays: [0, 1, 7],
/*
* TYPE OF EVENTS
*
* This script can track any Google Contact Event: you can decide which ones by placing true
* or false next to each type in the following lines.
* By default the script only tracks birthday events.
*/
eventTypes: {
BIRTHDAY: true,
ANNIVERSARY: false,
CUSTOM: false
},
/*
* MAXIMUM NUMBER OF EMAIL ADDRESSES
*
* You can limit the maximum number of email addresses displayed for each contact in the notification emails
* by changing this number. If you don't want to impose any limits change it to -1, if you don't want any
* email address to be shown change it to 0.
*/
maxEmailsCount: -1,
/*
* MAXIMUM NUMBER OF PHONE NUMBERS
*
* You can limit the maximum number of phone numbers displayed for each contact in the notification emails
* by changing this number. If you don't want to impose any limits change it to -1, if you don't want any
* phone number to be shown change it to 0.
*/
maxPhonesCount: -1,
/*
* INDENT SIZE
*
* Use this variable to determine how many spaces are used for indentation.
* This is used in the plaintext part of emails only (invisible to email clients which display
* the html part by default).
*/
indentSize: 4,
/*
* GROUP ALL LABELS
*
* By default only the main emails and phone numbers (work, home, mobile, main) are displayed with their
* own label: all the other special and/or custom emails and phone numbers are grouped into a single
* "other" group. By setting this variable to false instead, every phone and email will be grouped
* under its own label.
*/
compactGrouping: true
},
debug: {
log: {
/*
* LOGGING FILTER LEVEL
*
* This settings lets you filter which type of events will get logged:
* - 'INFO' will log all types of events event (messages, warnings and errors);
* - 'WARNING' will log warnings and errors only (discarding messages);
* - 'ERROR' will log errors only (discarding messages and warnings);
* - 'FATAL_ERROR' will log fatal errors only (discarding messages, warnings and non-fatal errors);
* - 'MAX' will effectively disable the logging (nothing will be logged);
*/
filterLevel: 'INFO',
/*
* Set this variable to: 'INFO', 'WARNING', 'ERROR', 'FATAL_ERROR' or 'MAX'. You will be sent an
* email containing the full execution log of the script if at least one event of priority
* equal or greater to sendTrigger has been logged. 'MAX' means that such emails will
* never be sent.
* Note: filterLevel has precedence over this setting! For example if you set filterLevel
* to 'MAX' and sendTrigger to 'WARNING' you will never receive any email as nothing will
* be logged due to the filterLevel setting.
*/
sendTrigger: 'ERROR'
},
/*
* TEST DATE
*
* When using the test() function this date will be used as "now". The date must be in the
* yyyy/MM/dd HH:mm:ss format.
* Choose a date you know should trigger an event notification.
*/
testDate: new Date('2017/08/01 06:00:00')
},
developer: {
/* NB: Users shouldn't need to (or want to) touch these settings. They are here for the
* convenience of developers/maintainers only.
*/
version: '5.1.4',
repoName: 'GioBonvi/GoogleContactsEventsNotifier',
gitHubBranch: 'master'
}
};
/*
* There is no need to edit anything below this line.
* The script will work if you inserted valid values up
* until here, however feel free to take a peek at the code ;)
*/
// CLASSES
/**
* Initialize a LocalCache object.
*
* A LocalCache object is used to store external resources which are used multiple
* times to optimize the number of `UrlFetchApp.fetch()` calls.
*
* @class
*/
function LocalCache () {
this.cache = {};
}
/**
* Fetch an URL, optionally making more than one try.
*
* @param {!string} url - The URL which has to be fetched.
* @param {?number} [tries=1] - Number of times to try the fetch operation before failing.
* @returns {?Object} - The fetch response or null if the fetch failed.
*/
LocalCache.prototype.fetch = function (url, tries) {
var response, i;
tries = tries || 1;
response = null;
// Try fetching the data.
for (i = 0; i < tries; i++) {
try {
response = UrlFetchApp.fetch(url);
if (response.getResponseCode() !== 200) {
throw new Error('');
}
// Break the loop if the fetch was successful.
break;
} catch (error) {
response = null;
Utilities.sleep(1000);
}
}
// Store the result in the cache and return it.
this.cache[url] = response;
return this.cache[url];
};
/**
* Determine whether an url has already been cached.
*
* @param {!string} url - The URL to check.
* @returns {boolean} - True if the cache contains an object for the URL, false otherwise.
*/
LocalCache.prototype.isCached = function (url) {
return !!this.cache[url];
};
/**
* Retrieve an object from the cache.
*
* The object is loaded from the cache if present, otherwise it is fetched.
*
* @param {!string} url - The URL to retrieve.
* @param {?number} tries - Number of times to try the fetch operation before failing (passed to `this.fetch()`).
* @returns {Object} - The response object.
*/
LocalCache.prototype.retrieve = function (url, tries) {
if (this.isCached(url)) {
return this.cache[url];
} else {
return this.fetch(url, tries);
}
};
/**
* Initialize an empty contact.
*
* A MergedContact object holds the data about a contact collected from multiple sources.
*
* @class
*/
function MergedContact () {
/** @type {?string} */
this.contactId = null;
// Consider all the event types excluded by settings.notifications.eventTypes
// as blacklisted for all contacts.
/** @type {string[]} */
this.blacklist = Object.keys(settings.notifications.eventTypes)
.filter(function (label) { return settings.notifications.eventTypes[label] === false; })
.map(eventLabelToLowerCase);
/** @type {ContactDataDC} */
this.data = new ContactDataDC(
null, // Name.
null, // Nickname.
null // Profile image URL.
);
/** @type {EmailAddressDC[]} */
this.emails = [];
/** @type {PhoneNumberDC[]} */
this.phones = [];
/** @type {EventDC[]} */
this.events = [];
}
/**
* Extract all the available data from the raw event object and store them in the `MergedContact`.
*
* @param {Object} rawEvent - The object containing all the data about the event, obtained
* from the Google Calendar API.
*/
MergedContact.prototype.getInfoFromRawEvent = function (rawEvent) {
var self, eventData, eventDate, eventMonth, eventDay, eventLabel;
log.add('Extracting info from raw event object...', Priority.INFO);
// We already know .gadget.preferences exists, we checked before getting contactId, before
// calling this method - to know whether to "merge to existing" or "create new" contact.
eventData = rawEvent.gadget.preferences;
// The raw event can contain the full name and profile photo of the contact (no nickname).
this.data.merge(new ContactDataDC(
eventData['goo.contactsFullName'], // Name.
null, // Nickname.
eventData['goo.contactsPhotoUrl'] // Profile image URL.
));
// The raw event contains an email of the contact, but without label.
this.addToField('emails', new EmailAddressDC(
null, // Label.
eventData['goo.contactsEmail'] // Email address.
));
// The raw event contains the type, day and month of the event, but not the year.
eventDate = /^(\d\d\d\d)-(\d\d)-(\d\d)$/.exec(rawEvent.start.date);
eventMonth = null;
eventDay = null;
if (eventDate) {
eventLabel = eventData['goo.contactsEventType'];
if (eventLabel === 'SELF') {
// Your own birthday is marked as 'SELF'.
eventLabel = 'BIRTHDAY';
} else if (eventLabel === 'CUSTOM') {
// Custom events have an additional field containing the custom name of the event.
eventLabel += ':' + (eventData['goo.contactsCustomEventType'] || '');
}
eventMonth = (eventDate[2] !== '00' ? parseInt(eventDate[2], 10) : null);
eventDay = (eventDate[3] !== '00' ? parseInt(eventDate[3], 10) : null);
}
// Collect info from the contactId if not already collected and if contactsContactId exists.
if (this.contactId === null && eventData['goo.contactsContactId']) {
this.getInfoFromContact(eventData['goo.contactsContactId'], eventMonth, eventDay);
}
// delete any events marked as blacklisted (but already added e.g. from raw event data)
if (this.blacklist) {
self = this;
self.blacklist.forEach(function (label) {
self.deleteFromField('events', label, false);
});
}
};
/**
* Update the `MergedContact` with info collected from a Google Contact.
*
* Some raw events will contain a Google Contact ID which gives access
* to a bunch of new data about the contact.
*
* This data is used to update the information collected until now.
*
* @param {!string} contactId - The id from which to collect the data.
* @param {?string} eventMonth - The month to match events.
* @param {?string} eventDay - The day to match events.
*/
MergedContact.prototype.getInfoFromContact = function (contactId, eventMonth, eventDay) {
var self, googleContact, blacklist;
self = this;
log.add('Extracting info from Google Contact...', Priority.INFO);
log.add('Fetching contact info for: ' + contactId, Priority.INFO);
var pageToken = null;
try {
do {
var requestParams = {personFields: "metadata", pageSize: 1000};
if (pageToken != null) {
requestParams.pageToken = pageToken;
}
const allContacts = People.People.Connections.list('people/me', requestParams);
pageToken = allContacts.getNextPageToken();
// unfortunately, the people API uses a different ID than the calendar API
// so we iterate over all contacts and find the first one that has a source with the correct contact id
function findContactWithId(connections, contactId) {
for (var i = 0; i < connections.length; i++) {
for (var j = 0; j < connections[i].metadata.sources.length; j++) {
if (connections[i].metadata.sources[j].id == contactId) {
return connections[i];
}
}
}
return undefined;
}
googleContact = findContactWithId(allContacts.connections, contactId);
if (googleContact !== undefined) {
log.add('Found contact: ' + googleContact.resourceName, Priority.INFO);
googleContact = People.People.get(googleContact.resourceName, {personFields: "names,events,emailAddresses,phoneNumbers,birthdays,userDefined"});
break;
}
} while(pageToken != null);
if (googleContact === null || googleContact === undefined) {
throw new Error('No suitable contact found');
}
} catch (err) {
log.add(err.message, Priority.WARNING);
log.add('Invalid Google Contact ID or error retrieving data for ID: ' + contactId, Priority.WARNING);
return;
}
try {
self.contactId = googleContact.resourceName;
// Contact identification data.
self.data.merge(new ContactDataDC(
googleContact.names[0].displayName, // Name.
googleContact.givenName, // Nickname.
null // Profile image URL.
));
// Events blacklist.
blacklist = googleContact.getUserDefined('notificationBlacklist');
if (blacklist && blacklist[0]) {
self.blacklist = uniqueStrings(self.blacklist.concat(blacklist[0].getValue().replace(/,+/g, ',').replace(/(^,|,$)/g, '').split(',').map(function (x) {
return x.toLocaleLowerCase();
})));
}
function processEvent(event) {
const date = event.date;
if (date.getDay() !== eventDay || date.getMonth() !== eventMonth) {
return;
}
if (self.blacklist && self.blacklist.length && isIn(event.type.toLocaleLowerCase(), self.blacklist)) {
return;
}
self.addToField('events', new EventDC(
event.formattedType,
date.getYear(),
eventMonth,
eventDay
));
}
if (settings.notifications.eventTypes.CUSTOM) {
googleContact.getEvents()?.forEach(processEvent);
}
bdays = googleContact.getBirthdays();
for (var i = 0; i < bdays.length; i++) {
bdays[i].type = "BIRTHDAY";
bdays[i].formattedType = bdays[i].type;
processEvent(bdays[i]);
}
// Email addresses.
if (googleContact.getEmailAddresses() !== undefined) {
googleContact.getEmailAddresses().forEach(function (emailField) {
self.addToField('emails', new EmailAddressDC(
String(emailField.getFormattedType()),
emailField.getValue()
));
});
}
// Phone numbers.
if (googleContact.getPhoneNumbers() !== undefined) {
googleContact.getPhoneNumbers().forEach(function (phoneField) {
self.addToField('phones', new PhoneNumberDC(
String(phoneField.getFormattedType()),
phoneField.getValue()
));
});
}
} catch (err) {
log.add(err.message, Priority.WARNING)
log.add('Error merging info for: ' + self.contactId, Priority.WARNING);
return;
}
};
/**
* This method is used to insert a new DataCollector into an array of
* DataCollectors.
*
* For example take `EventDC e` and `EventDC[] arr`; This method checks
* all the elements of `arr`: if it finds one that is compatible with `e`
* it merges `e` into that element, otherwise, if no element in the array
* is compatible or if the array is empty, it just adds `e` at the end of
* the array.
*
* @param {!string} field - The name of the field in which to insert the object.
* @param {DataCollector} incData - The object to insert.
*/
MergedContact.prototype.addToField = function (field, incData) {
var merged, i, data;
// incData must have at least one non-empty property.
if (
Object.keys(incData.prop).length === 0 ||
Object.keys(incData.prop)
.filter(function (key) { return !incData.isPropEmpty(key); })
.length === 0
) {
return;
}
// Try to find a non-conflicting object to merge with in the given field.
merged = false;
// Use 'for' instead of 'forEach', so we can short-circuit with 'break'
for (i = 0; i < this[field].length; i++) {
data = this[field][i];
if (!data.isConflicting(incData)) {
data.merge(incData);
merged = true;
break;
}
}
// If incData could not be merged simply append it to the field.
if (!merged) {
this[field].push(incData);
}
};
/**
* This method is used to delete a DataCollector from an array of
* DataCollectors based on label.
*
* @param {!string} field - The name of the field from which to delete the object.
* @param {!string} label - The label to match to signify deletion.
* @param {?boolean} caseSensitive - Whether to match labels case-sensitively or not.
*/
MergedContact.prototype.deleteFromField = function (field, label, caseSensitive) {
var data, eachLabel, fieldIter;
if (!caseSensitive) {
label = eventLabelToLowerCase(label);
}
// Iterate by reverse index to allow safe splicing from within the loop
fieldIter = this[field].length;
while (fieldIter--) {
data = this[field][fieldIter];
eachLabel = data.getProp('label');
if (!caseSensitive) {
eachLabel = eventLabelToLowerCase(eachLabel);
}
// Delete those events whose label exactly matches the one given or,
// if the given label is 'Custom', all the custom events.
if (label === eachLabel || (label === 'custom' && eachLabel.indexOf('CUSTOM:') === 0)) {
this[field].splice(fieldIter, 1);
break;
}
}
};
/**
* Generate a list of text lines of the given format, each describing an
* event of the contact of the type specified on the date specified.
*
* @param {!string} type - The type of the event.
* @param {!Date} date - The date of the event.
* @param {!NotificationType} format - The format of the text line.
* @returns {string[]} - A list of the plain text descriptions of the events.
*/
MergedContact.prototype.getLines = function (type, date, format) {
var self;
self = this;
return self.events.filter(function (event) {
var typeMatch;
switch (event.getProp('label')) {
case 'BIRTHDAY':
typeMatch = (type === 'BIRTHDAY');
break;
case 'ANNIVERSARY':
typeMatch = (type === 'ANNIVERSARY');
break;
default:
typeMatch = (type === 'CUSTOM');
}
return typeMatch && event.getProp('day') === date.getDate() && event.getProp('month') === (date.getMonth() + 1);
}).map(function (event) {
var line, eventLabel, imgCount;
line = [];
// Start line.
switch (format) {
case NotificationType.PLAIN_TEXT:
line.push(indent);
break;
case NotificationType.HTML:
line.push('<li>');
}
// Profile photo.
switch (format) {
case NotificationType.HTML:
imgCount = Object.keys(inlineImages).length;
try {
// Get the default profile image from the cache.
inlineImages['contact-img-' + imgCount] = cache.retrieve(self.data.getProp('photoURL')).getBlob().setName('contact-img-' + imgCount);
line.push('<img src="cid:contact-img-' + imgCount + '" style="height:1.4em;margin-right:0.4em" alt="" />');
} catch (err) {
log.add('Unable to get the profile picture with URL ' + self.data.getProp('photoURL'), Priority.WARNING);
}
}
// Custom label
if (type === 'CUSTOM') {
eventLabel = event.getProp('label') || 'OTHER';
switch (format) {
case NotificationType.PLAIN_TEXT:
line.push('<', beautifyLabel(eventLabel), '> ');
break;
case NotificationType.HTML:
line.push(htmlEscape('<' + beautifyLabel(eventLabel) + '> '));
}
}
// Full name.
switch (format) {
case NotificationType.PLAIN_TEXT:
line.push(self.data.getProp('fullName'));
break;
case NotificationType.HTML:
line.push(htmlEscape(self.data.getProp('fullName')));
}
// Nickname.
if (!self.data.isPropEmpty('nickname')) {
switch (format) {
case NotificationType.PLAIN_TEXT:
line.push(' "', self.data.getProp('nickname'), '"');
break;
case NotificationType.HTML:
line.push(htmlEscape(' "' + self.data.getProp('nickname') + '"'));
}
}
// Age/years passed.
if (!event.isPropEmpty('year')) {
if (type === 'BIRTHDAY') {
switch (format) {
case NotificationType.PLAIN_TEXT:
line.push(' - ', _('Age'), ': ');
break;
case NotificationType.HTML:
line.push(' - ', htmlEscape(_('Age')), ': ');
}
} else {
switch (format) {
case NotificationType.PLAIN_TEXT:
line.push(' - ', _('Years'), ': ');
break;
case NotificationType.HTML:
line.push(' - ', htmlEscape(_('Years')), ': ');
}
}
line.push(Math.round(date.getFullYear() - event.getProp('year')));
}
// Email addresses and phone numbers.
var collected;
// Emails and phones are grouped by label: these are the default main label groups.
collected = {
HOME_EMAIL: [],
WORK_EMAIL: [],
OTHER_EMAIL: [],
MAIN_PHONE: [],
HOME_PHONE: [],
WORK_PHONE: [],
MOBILE_PHONE: [],
OTHER_PHONE: []
};
// Collect and group the email addresses.
self.emails.forEach(function (email, i) {
var label, emailAddr;
if (settings.notifications.maxEmailsCount < 0 || i < settings.notifications.maxEmailsCount) {
label = email.getProp('label');
emailAddr = email.getProp('address');
if (!isIn(collected[label], [undefined, null])) {
// Store the value if the label group is already defined.
collected[label].push(emailAddr);
} else if (!settings.notifications.compactGrouping && label) {
// Define a new label groups different from the main ones only if compactGrouping is set to false.
// Note: Google's OTHER label actually is an empty string.
collected[label] = [emailAddr];
} else {
// Store any other label in the OTHER_EMAIL label group.
collected['OTHER_EMAIL'].push(emailAddr);
}
}
});
// Collect and group the phone numbers.
self.phones.forEach(function (phone, i) {
var label, phoneNum;
if (settings.notifications.maxPhonesCount < 0 || i < settings.notifications.maxPhonesCount) {
label = phone.getProp('label');
phoneNum = phone.getProp('number');
if (!isIn(collected[label], [undefined, null])) {
// Store the value if the label group is already defined.
collected[label].push(phoneNum);
} else if (!settings.notifications.compactGrouping && label) {
// Define a new label groups different from the main ones only if compactGrouping is set to false.
// Note: Google's OTHER label actually is an empty string.
collected[label] = [phoneNum];
} else {
// Store any other label in the OTHER_PHONE label group.
collected['OTHER_PHONE'].push(phoneNum);
}
}
});
// If there is at least an email address/phone number to be added to the email...
if (Object.keys(collected).reduce(function (acc, label) { return acc + collected[label].length; }, 0) >= 1) {
// ...generate the text from the grouped emails and phone numbers.
line.push(' (');
line.push(
Object.keys(collected).map(function (label) {
var output;
if (collected[label].length) {
switch (format) {
case NotificationType.PLAIN_TEXT:
output = beautifyLabel(label);
break;
case NotificationType.HTML:
output = htmlEscape(beautifyLabel(label));
}
return output + ': ' + collected[label].map(function (val) {
var buffer;
switch (format) {
case NotificationType.PLAIN_TEXT:
return val;
case NotificationType.HTML:
buffer = '<a href="';
if (label.match(/_EMAIL$/)) {
buffer += 'mailto';
} else if (label.match(/_PHONE$/)) {
buffer += 'tel';
}
return buffer + ':' + htmlEscape(val) + '">' + htmlEscape(val) + '</a>';
}
}).join(' - ');
}
}).filter(function (val) {
return val;
}).join(', ')
);
line.push(')');
}
// Finish line.
switch (format) {
case NotificationType.HTML:
line.push('</li>');
}
return line.join('');
});
};
/**
* DataCollector is a structure used to collect data about any "object" (an event, an
* email address, a phone number...) from multiple incomplete sources.
*
* For example the raw event could contain the day and month of the birthday, while
* the Google Contact could hold the year as well. DataCollector can be used to accumulate
* the data in multiple takes: each take updates the values that were left empty by the
* previous ones until all info have been collected.
*
* Each DataCollector object can contain an arbitrary number of properties in the form of
* name -> value, stored in the prop object.
*
* Empty properties have null value.
*
* DataCollector is an abstract class. Each data type should have its own implementation
* (`EventDC`, `EmailAddressDC`, `PhoneNumberDC`).
*
* @class
*/
var DataCollector = function () {
if (this.constructor === DataCollector) {
throw new Error('DataCollector is an abstract class and cannot be instantiated!');
}
/** @type {Object.<string,string>} */
this.prop = {};
};
/**
* Get the value of a given property.
*
* @param {!string} key - The name of the property.
* @returns {?string} - The value of the property.
*/
DataCollector.prototype.getProp = function (key) {
return this.prop[key];
};
/**
* Set a given property to a certain value.
*
* If the value is undefined or an empty string it's replaced by `null`.
*
* @param {!string} key - The name of the property.
* @param {?string} value - The value of the property.
*/
DataCollector.prototype.setProp = function (key, value) {
this.prop[key] = value || null;
};
/**
* Determines whether a given property is empty or not.
*
* @param {!string} key - The name of the property.
* @returns {boolean} - True if the property is empty, false otherwise.
*/
DataCollector.prototype.isPropEmpty = function (key) {
return this.prop[key] === null;
};
/**
* Detect whether two DataCollectors have the same constructor or not.
*
* * Examples:
* DC_1 = new EventDC(...a, b, c...)
* DC_2 = new EventDC(...x, y, z...)
* DC_3 = new EmailAddressDC(...a, b, c...)
* DC_4 = new EmailAddressDC(...x, y, z...)
*
* DC_1.isCompatible(DC_2) -> true
* DC_1.isCompatible(DC_3) -> false
* DC_1.isCompatible(DC_4) -> false
*
* @param {DataCollector} otherData - The object to compare the current one with.
* @returns {boolean} - True if the tow objects have the same constructor, false otherwise.
*/
DataCollector.prototype.isCompatible = function (otherData) {
// Only same-implementation objects of DataCollector can be compared.
return this.constructor === otherData.constructor;
};
/**
* Detect whether two DataCollectors are conflicting or not.
*
* * Examples:
* DC_1 = {name='test', number=3, field=null}
* DC_2 = {name=null, number=3, field=3}
* DC_3 = {name='test', number=null, field=1}
* DC_4 = {name='test', number=3, otherfield=null} (using different DC implementation)
*
* DC_1.isConflicting(DC_2) -> false
* DC_1.isConflicting(DC_3) -> false
* DC_1.isConflicting(DC_4) -> false (not .isCompatible())
* DC_2.isConflicting(DC_3) -> true (conflict on field)
*
* @param {DataCollector} otherData - The object to compare the current one with.
* @returns {boolean} - True if the two objects are conflicting, false otherwise.
*/
DataCollector.prototype.isConflicting = function (otherData) {
var self;
self = this;
if (!self.isCompatible(otherData)) {
return false;
}
return Object.keys(otherData.prop)
.filter(function (key) {
return !self.isPropEmpty(key) && !otherData.isPropEmpty(key) && self.getProp(key) !== otherData.getProp(key);
}).length !== 0;
};
/**
* Merge two `DataCollector` objects, filling the empty properties of the
* first one with the non-empty properties of the second one.
*
* * Examples:
* DC_1 = {name='test', number=3, field=null}
* DC_2 = {name=null, number=3, field=3}
* DC_2 = {name='test', number=null, field=1}
*
* DC_1.merge(DC_2) -> {name='test', number=3, field=3}
* DC_1.isCompatible(DC_3) -> {name='test', number=3, field=1}
* DC_2.isCompatible(DC_3) -> INCOMPATIBLE
*
* @param {DataCollector} otherDataCollector - The object to merge into the current one.
*/
DataCollector.prototype.merge = function (otherDataCollector) {
var self;
self = this;
if (!self.isCompatible(otherDataCollector)) {
throw new Error('Trying to merge two different implementations of IncompleteData!');
}
// Fill each empty key of the current DataCollector with the value from the given one.
Object.keys(self.prop).forEach(function (key) {
if (self.isPropEmpty(key)) {
self.setProp(key, otherDataCollector.getProp(key));
}
});
};
// Implementations of DataCollector.
/**
* Init an Event Data Collector.
*
* @param {!string} label - Label of the event (BIRTHDAY, ANNIVERSARY, ANYTHING_ELSE...)
* @param {!number} year - Year of the event.
* @param {!number} month - Month of the event.
* @param {!number} day - Day of the event.
*/
var EventDC = function (label, year, month, day) {
DataCollector.apply(this);
this.setProp('label', label);
this.setProp('year', year);
this.setProp('month', month);
this.setProp('day', day);
};
EventDC.prototype = Object.create(DataCollector.prototype);
EventDC.prototype.constructor = EventDC;
/**
* Init an EmailAddress Data Collector.
*
* @param {!string} label - The label of the email address (WORK_EMAIL, HOME_EMAIL...).
* @param {!string} address - The email address.
*/
var EmailAddressDC = function (label, address) {
DataCollector.apply(this);
this.setProp('label', label);
this.setProp('address', address);
};
EmailAddressDC.prototype = Object.create(DataCollector.prototype);
EmailAddressDC.prototype.constructor = EmailAddressDC;
/**
* Init a PhoneNumber Data Collector.
*
* @param {!string} label - The label of the phone number (WORK_PHONE, HOME_PHONE...).
* @param {!string} number - The phone number.
*/
var PhoneNumberDC = function (label, number) {
DataCollector.apply(this);
this.setProp('label', label);
this.setProp('number', number);
};
PhoneNumberDC.prototype = Object.create(DataCollector.prototype);
PhoneNumberDC.prototype.constructor = PhoneNumberDC;
/**
* Init a ContactData Data Collector.
*
* @param {!string} fullName - The full name of the contact.
* @param {!string} nickname - The nickname of the contact.
* @param {!string} photoURL - The URL of the profile image of the contact.
*/
var ContactDataDC = function (fullName, nickname, photoURL) {
DataCollector.apply(this);
this.setProp('fullName', fullName);
this.setProp('nickname', nickname);
this.setProp('photoURL', photoURL);
};
ContactDataDC.prototype = Object.create(DataCollector.prototype);
ContactDataDC.prototype.constructor = ContactDataDC;
/**
* Init a Log object, used to manage a collection of logEvents {time, text, priority}.
*
* @param {?Priority} [minimumPriority=Priority.INFO] - Logs with priority lower than this will not be recorded.
* @param {?Priority} [emailMinimumPriority=Priority.ERROR] - If at least one log with priority greater than or
equal to this is recorded an email with all the logs will be sent to the user.
* @param {?boolean} [testing=false] - If this is true logging an event with Priority.FATAL_ERROR will not
* cause execution to stop.
* @class
*/
function Log (minimumPriority, emailMinimumPriority, testing) {
this.minimumPriority = minimumPriority || Priority.INFO;
this.emailMinimumPriority = emailMinimumPriority || Priority.ERROR;
this.testing = testing || false;
/** @type {Object[]} */
this.events = [];
}
/**
* Store a new event in the log. The default priority is the lowest one (`INFO`).
*
* @param {!any} data - The data to be logged: best if a string, Objects get JSONized.
* @param {?Priority} [priority=Priority.INFO] - Priority of the log event.
*/
Log.prototype.add = function (data, priority) {
var text;
priority = priority || Priority.INFO;
if (typeof data === 'object') {
text = JSON.stringify(data);
} else if (typeof data !== 'string') {
text = String(data);
} else {
text = data;
}
if (priority.value >= this.minimumPriority.value) {
this.events.push(new LogEvent(new Date(), text, priority));
}
// Still log into the standard logger as a backup in case the program crashes.
Logger.log(priority.name[0] + ': ' + text);
// Throw an Error and interrupt the execution if the log event had FATAL_ERROR
// priority and we are not in test mode.
if (priority.value === Priority.FATAL_ERROR.value && !this.testing) {
this.sendEmail(settings.user.notificationEmail, settings.user.emailSenderName);
throw new Error(text);