/* 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: 'YOUREMAILHERE@gmail.com',
/*
* 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: 'YOUREMEAILHERE@example.com',
/*
* 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('
');
}
// 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('
');
} 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 = '' + htmlEscape(val) + '';
}
}).join(' - ');
}
}).filter(function (val) {
return val;
}).join(', ')
);
line.push(')');
}
// Finish line.
switch (format) {
case NotificationType.HTML:
line.push('');
}
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.} */
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);
}
};
/**
* Get the output of the log as an array of messages.
*
* @returns {string[]}
*/
Log.prototype.getOutput = function () {
return this.events.map(function (e) {
return e.toString();
});
};
/**
* Verify if the log contains at least an event with priority equal to or greater than
* the specified priority.
*
* @param {!Priority} minimumPriority - The numeric value representing the priority limit.
* @returns {boolean}
*/
Log.prototype.containsMinimumPriority = function (minimumPriority) {
var i;
for (i = 0; i < this.events.length; i++) {
if (this.events[i].priority.value >= minimumPriority.value) {
return true;
}
}
return false;
};
/**
* If the filter condition is met send all the logs collected to the specified email.
*
* @param {!string} to - The email address of the recipient of the email.
* @param {!string} senderName - The name of the sender.
*/
Log.prototype.sendEmail = function (to, senderName) {
if (this.containsMinimumPriority(this.emailMinimumPriority)) {
this.add('Sending logs via email.', Priority.INFO);
MailApp.sendEmail({
to: to,
subject: 'Logs for Google Contacts Events Notifications',
body: this.getOutput().join('\n'),
name: senderName
});
this.add('Email sent.', Priority.INFO);
}
};
/**
* A logged event.
*
* @param {Date} time - The time of the event.
* @param {string} message - The message of the event.
* @param {Priority} priority - The priority of the event.
*/
function LogEvent (time, message, priority) {
this.time = time;
this.message = message;
this.priority = priority;
}
/**
* Get a textual description of the LogEvent in this format
* (P is the first letter of the priority):
*
* [TIME] P: MESSAGE
*
* @returns {string} - The textual description of the event.
*/
LogEvent.prototype.toString = function () {
return '[' + Utilities.formatDate(this.time, Session.getScriptTimeZone(), 'dd-MM-yyyy HH:mm:ss') + ' ' + Session.getScriptTimeZone() + '] ' + this.priority.name[0] + ': ' + this.message;
};
/**
* An enum of plurals for eventTypes.
*
* @readonly
* @enum {string}
*/
var eventTypeNamePlural = {
BIRTHDAY: 'birthdays',
ANNIVERSARY: 'anniversaries',
CUSTOM: 'custom events'
};
/**
* A priority enum.
*
* @readonly
* @enum {Object.}
*/
var Priority = {
NONE: {name: 'None', value: 0},
INFO: {name: 'Info', value: 10},
WARNING: {name: 'Warning', value: 20},
ERROR: {name: 'Error', value: 30},
FATAL_ERROR: {name: 'Fatal error', value: 40},
MAX: {name: 'Max', value: 100}
};
/**
* Enum for notification type.
*
* @readonly
* @enum {number}
*/
var NotificationType = {
PLAIN_TEXT: 0,
HTML: 1
};
/**
* An object representing a simplified semantic version number.
*
* It must be composed of:
*
* * three dot-separated positive integers (major version,
* minor version and patch number);
* * optionally a pre-release identifier, prefixed by a hyphen;
* * optionally a metadata identifier, prefixed by a plus sign;
*
* This differs from the official SemVer style because the pre-release
* string is compared as a whole in version comparison instead of
* being spliced into chunks.
*
* @param {!string} versionNumber - The version number to build the object with.
*
* @class
*/
function SimplifiedSemanticVersion (versionNumber) {
var matches, self;
self = this;
/** @type {number[]} */
self.numbers = [0, 0, 0];
/** @type {string} */
self.preRelease = '';
/** @type {string} */
self.metadata = '';
// Extract the pieces of information from the given string.
matches = versionNumber.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+?))?(?:\+(.+))?$/);
if (matches) {
self.numbers[0] = parseInt(matches[1], 10);
self.numbers[1] = parseInt(matches[2], 10);
self.numbers[2] = parseInt(matches[3], 10);
self.preRelease = isIn(matches[4], [undefined, null]) ? '' : matches[4];
self.metadata = isIn(matches[5], [undefined, null]) ? '' : matches[5];
} else {
throw new Error('The version number "' + versionNumber + '" is not valid!');
}
}
/**
* Build the version number string from the data.
*
* @returns {string} - The version number of this version.
*/
SimplifiedSemanticVersion.prototype.toString = function () {
return this.numbers.join('.') +
(this.preRelease !== '' ? '-' + this.preRelease : '') +
(this.metadata !== '' ? '+' + this.metadata : '');
};
/**
* Compare a semantic version number with another one.
*
* Order of comparison: major number, minor number, patch number,
* preRelease string (ASCII comparison). Metadata do not influence
* comparisons.
*
* @param {!SimplifiedSemanticVersion} comparedVersion - The version to compare.
* @returns {number} - 1, 0 , -1 if this version number is greater than, equal to or smaller than the one passed as the parameter.
*/
SimplifiedSemanticVersion.prototype.compare = function (comparedVersion) {
var i;
for (i = 0; i < 3; i++) {
if (this.numbers[i] !== comparedVersion.numbers[i]) {
return (this.numbers[i] < comparedVersion.numbers[i] ? -1 : 1);
}
}
if (this.preRelease !== comparedVersion.preRelease) {
// Between two versions with the same numbers, one in pre-release and the
// other not, the one in pre-release must be considered smaller.
if (this.preRelease === '') {
return 1;
} else if (comparedVersion.preRelease === '') {
return -1;
}
return (this.preRelease < comparedVersion.preRelease ? -1 : 1);
}
return 0;
};
// EXTENDED NATIVE PROTOTYPES
if (isIn(Array.prototype.extend, [undefined, null])) {
/**
* Merge an array at the end of an existing array.
*
* * Example:
* a = [1, 2, 3], b = [4, 5, 6];
* a.extend(b);
* a -> [1, 2, 3, 4, 5, 6]
*
* @param {any[]} array - The array used to extend.
* @returns {any[]} - Returns this for subsequent calls.
*/
Array.prototype.extend = function (array) { // eslint-disable-line no-extend-native
var i;
for (i = 0; i < array.length; ++i) {
this.push(array[i]);
}
return this;
};
}
if (isIn(String.prototype.format, [undefined, null])) {
/**
* Format a string, replace {1}, {2}, etc with their corresponding trailing args.
*
* * Examples:
* 'This is a {0}'.format('test') -> 'This is a test.'
* 'This {0} a {1}'.format('is') -> 'This is a {1}.'
*
* @param {...!string} arguments
* @returns {string}
*/
String.prototype.format = function () { // eslint-disable-line no-extend-native
var args;
args = arguments;
return this.replace(/\{(\d+)\}/g, function (match, number) {
return isIn(args[number], [undefined, null])
? match
: args[number]
;
});
};
}
if (isIn(String.prototype.replaceAll, [undefined, null])) {
/**
* Replace all occurrences of a substring (not a regex).
*
* @param {!string} substr - The substring to be replaced.
* @param {!string} repl - The replacement for the substring.
* @returns {string} - The string with the substrings replaced.
*/
String.prototype.replaceAll = function (substr, repl) { // eslint-disable-line no-extend-native
return this.split(substr).join(repl);
};
}
if (isIn(Number.isInteger, [undefined, null])) {
/**
* Determine if a number is an integer.
*
* @param {number} n - The number to check.
* @returns {boolean} - True if the number is an integer, false otherwise.
*/
Number.isInteger = function (n) {
return typeof n === 'number' && (n % 1) === 0;
};
}
if (isIn(Date.prototype.addDays, [undefined, null])) {
/**
* Generate a new date adding a number of days to a given date.
*
* @param {number} days Number of days to be added to the date.
* @author AnthonyWJones
* @see {@link https://stackoverflow.com/a/563442|Stackoverflow}
*/
Date.prototype.addDays = function (days) { // eslint-disable-line no-extend-native
var dat = new Date(this.valueOf());
dat.setDate(dat.getDate() + days);
return dat;
};
}
// GLOBAL VARIABLES
/**
* The version of the script.
*
* @type {!SimplifiedSemanticVersion}
*/
var version = new SimplifiedSemanticVersion(settings.developer.version);
var cache = new LocalCache();
// These URLs are used to access the files in the repository or specific pages on GitHub.
var baseRawFilesURL = 'https://raw.githubusercontent.com/' + settings.developer.repoName + '/' + settings.developer.gitHubBranch + '/';
var baseGitHubProjectURL = 'https://github.com/' + settings.developer.repoName + '/';
var baseGitHubApiURL = 'https://api.github.com/repos/' + settings.developer.repoName + '/';
var defaultProfileImageURL = baseRawFilesURL + 'images/default_profile.jpg';
// Convert user-configured hash to an array
var eventTypes = Object.keys(settings.notifications.eventTypes)
.filter(function (x) { return settings.notifications.eventTypes[x]; });
// Build the indentation from the setting.
var indent = Array(settings.notifications.indentSize + 1).join(' ');
var inlineImages;
var log = new Log(Priority[settings.debug.log.filterLevel], Priority[settings.debug.log.sendTrigger]);
// NB: When Google fixes their too-broad scope bug with ScriptApp, re-wrap this i18n
// table in `eslint-*able comma-dangle` comments (see old git-commits to find it)
var i18n = {
// For all languages, if a translation is not present the untranslated string
// is returned, so just leave out translations which are the same as the English.
// NB: If ever adding a lang which uses non-latin numbers functionality will need
// to be added to handle that differently (arbitrary numbers, not just a small
// selection, e.g. for age calculation).
// An entry for 'en' marks it as a valid lang config-option, but leave it empty
// to just return unaltered phrases.
'en': {},
'cs': {
'Age': 'Věk',
'Years': 'Let',
'Events': 'Události',
'Birthdays today': 'Narozeniny dnes',
'Birthdays tomorrow': 'Narozeniny zítra',
'Birthdays in {0} days': 'Narozeniny za {0} dny/í',
'Anniversaries today': 'Výročí dnes',
'Anniversaries tomorrow': 'Výročí zítra',
'Anniversaries in {0} days': 'Výročí za {0} dny/í',
'Custom events today': 'Jiné události dnes',
'Custom events tomorrow': 'Jiné události zítra',
'Custom events in {0} days': 'Jiné události za {0} dny/í',
'Hey! Don\'t forget these events': 'Hej! Nezapomeň na tyto události',
'version': 'verze',
'dd-MM-yyyy': 'dd.MM.yyyy',
'Mobile phone': 'Mobil',
'Work phone': 'Telefon (pracovní)',
'Home phone': 'Telefon (soukromý)',
'Main phone': 'Telefon (hlavní)',
'Other phone': 'Jiné telefonní číslo',
'Home fax': 'Fax (soukromý)',
'Work fax': 'Fax (pracovní)',
'Google voice': 'Google voice',
'Pager': 'Pager',
'Home email': 'E-mail (soukromý)',
'Work email': 'E-mail (pracovní)',
'Other email': 'Jiné e-mailové adresy',
'It looks like you are using an outdated version of this script': 'Vypadatá to, že používáte zastaralou verzi skriptu',
'You can find the latest one here': 'Poslední verzi najdete zde',
},
'de': {
'Age': 'Alter',
'Years': 'Jahre',
'Events': 'Termine',
'Birthdays today': 'Geburtstage heute',
'Birthdays tomorrow': 'Geburtstage morgen',
'Birthdays in {0} days': 'Geburtstage in {0} Tagen',
'Anniversaries today': 'Jahrestage heute',
'Anniversaries tomorrow': 'Jahrestage morgen',
'Anniversaries in {0} days': 'Jahrestage in {0} Tagen',
'Custom events today': 'Benutzerdefinierte Termine heute',
'Custom events tomorrow': 'Benutzerdefinierte Termine morgen',
'Custom events in {0} days': 'Benutzerdefinierte Termine in {0} Tagen',
'Hey! Don\'t forget these events': 'Hey! Vergiss diese Termine nicht',
'version': 'Version',
'dd-MM-yyyy': 'dd-MM-yyyy',
'Mobile phone': 'Telefon (mobil)',
'Work phone': 'Telefon (geschäftlich)',
'Home phone': 'Telefon (privat)',
'Main phone': 'Telefon (haupt)',
'Other phone': 'Telefon (sonstige)',
'Home fax': 'Fax (privat)',
'Work fax': 'Fax (geschäftlich)',
'Google voice': 'Google Voice',
'Pager': 'Pager',
'Home email': 'E-Mail (privat)',
'Work email': 'E-Mail (geschäftlich)',
'Other email': 'E-Mail (sonstige)',
'It looks like you are using an outdated version of this script': 'Du verwendest anscheinend eine veraltete Version dieses Skripts',
'You can find the latest one here': 'Du findest die neuste Version hier', // Using feminime version of 'latest', because it refers to 'version'. There's possibility it won't fit into diffrent context.
},
'el': {
'Age': 'Ηλικία',
'Years': 'Χρόνια',
'Events': 'Γεγονότα',
'Birthdays today': 'Γενέθλια σήμερα',
'Birthdays tomorrow': 'Γενέθλια αύριο',
'Birthdays in {0} days': 'Γενέθλια σε {0} ημέρες',
'Anniversaries today': 'Επέτειοι σήμερα',
'Anniversaries tomorrow': 'Επέτειοι αύριο',
'Anniversaries in {0} days': 'Επέτειοι σε {0} ημέρες',
'Custom events today': 'Προσαρμοσμένα γεγονότα σήμερα',
'Custom events tomorrow': 'Προσαρμοσμένα γεγονότα αύριο',
'Custom events in {0} days': 'Προσαρμοσμένα γεγονότα σε {0} ημέρες',
'Hey! Don\'t forget these events': 'Και πού σαι! Μην ξεχάσεις αυτά τα γεγονότα',
'version': 'εκδοχή',
'dd-MM-yyyy': 'dd-MM-yyyy',
'Mobile phone': 'Κινητό',
'Work phone': 'Τηλέφωνο εργασίας',
'Home phone': 'Τηλέφωνο οικίας',
'Main phone': 'Κύριο τηλέφωνο',
'Other phone': 'Άλλο τηλέφωνο',
'Home fax': 'Φαξ οικίας',
'Work fax': 'Φαξ εργασίας',
'Google voice': 'Google voice',
'Pager': 'Τηλεειδοποίηση',
'Home email': 'Προσωπικό email',
'Work email': 'Επαγγελματικό email',
'Other email': 'Άλλο email',
'It looks like you are using an outdated version of this script': 'Φαίνεται οτι χρησιμοποιείς μια παλαιότερη εκδοχή αυτόυ του script',
'You can find the latest one here': 'Μπορείς να βρείς την τελευταία εδώ',
},
'es': {
'Age': 'Edad',
'Years': 'Años',
'Events': 'Eventos',
'Birthdays today': 'Cumpleaños hoy',
'Birthdays tomorrow': 'Cumpleaños mañana',
'Birthdays in {0} days': 'Cumpleaños en {0} días',
'Anniversaries today': 'Aniversarios hoy',
'Anniversaries tomorrow': 'Aniversarios mañana',
'Anniversaries in {0} days': 'Aniversarios en {0} días',
'Custom events today': 'Eventos personalizados de hoy',
'Custom events tomorrow': 'Eventos personalizados de mañana',
'Custom events in {0} days': 'Eventos personalizados en {0} das',
'Hey! Don\'t forget these events': 'Hey! No olvides estos eventos',
'version': 'versión',
'dd-MM-yyyy': 'dd-MM-yyyy',
'Mobile phone': 'Celular',
'Work phone': 'Teléfono del trabajo',
'Home phone': 'Teléfono del hogar',
'Main phone': 'Teléfono principal',
'Other phone': 'Otro teléfono',
'Home fax': 'Fax del hogar',
'Work fax': 'Fax del trabajo',
'Google voice': 'Google voice',
'Pager': 'Buscapersonas',
'Home email': 'Correo electrónico del hogar',
'Work email': 'Correo electrónico del trabajo',
'Other email': 'Otro correo electrónico',
'It looks like you are using an outdated version of this script': 'Parece que estás usando una versión antigua de este script',
'You can find the latest one here': 'Puedes encontrar la última aquí',
},
'fa': {
'Age': 'سن',
'Years': 'سال',
'Events': 'رویدادها',
'Birthdays today': 'تولدهای امروز',
'Birthdays tomorrow': 'تولدهای فردا',
'Birthdays in {0} days': 'تولدهای {0} روز آینده',
'Anniversaries today': 'سالگردهای امروز',
'Anniversaries tomorrow': 'سالگردهای فردا',
'Anniversaries in {0} days': 'سالگردهای {0} روز آینده',
'Custom events today': 'رویدادهای شخصی امروز',
'Custom events tomorrow': 'رویدادهای شخصی فردا',
'Custom events in {0} days': 'رویدادهای شخصی {0} روز آینده',
'Hey! Don\'t forget these events': 'سلام! این رویدادها را فراموش نکن',
'version': 'نسخه',
'dd-MM-yyyy': 'dd-MM-yyyy',
'Mobile phone': 'شماره موبایل',
'Work phone': 'شماره تلفن محل کار',
'Home phone': 'شماره تلفن خانه',
'Main phone': 'شماره تلفن اصلی',
'Other phone': 'شماره تلفن دیگر',
'Home fax': 'شماره فاکس خانه',
'Work fax': 'شماره فاکس محل کار',
'Google voice': 'وویس گوگل',
'Pager': 'پیجر',
'Home email': 'ایمیل خانه',
'Work email': 'ایمیل محل کار',
'Other email': 'ایمیل دیگر',
'It looks like you are using an outdated version of this script': 'به نظر می رسد شما نسخه قدیمی این اسکریپت را استفاده می کنید',
'You can find the latest one here': 'اینجا می توانید نسخه به روز را بیابید',
},
'fr': {
'Age': 'Age',
'Years': 'Années',
'Events': 'Evénements',
'Birthdays today': 'Anniversaires d`\'aujourd\'hui',
'Birthdays tomorrow': 'Anniversaires de demain',
'Birthdays in {0} days': 'Anniversaires dans {0} jours',
'Anniversaries today': 'Anniversaires d\'aujourd\'hui',
'Anniversaries tomorrow': 'Anniversaires de demain',
'Anniversaries in {0} days': 'Anniversaires dans {0} jours',
'Custom events today': 'Autres événements d\'aujourd\'hui',
'Custom events tomorrow': 'Autres événements de demain',
'Custom events in {0} days': 'Autres événements dans {0} jours',
'Hey! Don\'t forget these events': 'Hey ! N\'oubliez pas ces événements',
'version': 'version',
'dd-MM-yyyy': 'dd-MM-yyyy',
'Mobile phone': 'Téléphone portable',
'Work phone': 'Téléphone professionnel',
'Home phone': 'Téléphone personnel',
'Main phone': 'Téléphone principal',
'Other phone': 'Autre téléphone',
'Home fax': 'Fax personnel',
'Work fax': 'Fax professionnel',
'Google voice': 'Google voice',
'Pager': 'Téléavertisseur',
'Home email': 'Adresse mail personnelle',
'Work email': 'Adresse mail professionnelle',
'Other email': 'Autre adresse mail',
'It looks like you are using an outdated version of this script': 'Il semble que vous utilisez une ancienne version de ce script',
'You can find the latest one here': 'Vous pouvez trouver la dernière ici',
},
'he': {
'Age': 'גיל',
'Years': 'שנים',
'Events': 'אירועים',
'Birthdays today': 'ימי הולדת היום',
'Birthdays tomorrow': 'ימי הולדת מחר',
'Birthdays in {0} days': 'ימי הולדת בעוד {0} ימים',
'Anniversaries today': 'ימי נישואין היום',
'Anniversaries tomorrow': 'ימי נישואין מחר',
'Anniversaries in {0} days': 'ימי נישואין בעוד {0} ימים',
'Custom events today': 'אירועים מיוחדים היום',
'Custom events tomorrow': 'אירועים מיוחדים מחר',
'Custom events in {0} days': 'אירועים מיוחדים בעוד {0} ימים',
'Hey! Don\'t forget these events': 'היי, אל תשכח את האירועים האלה!',
'version': 'גרסה',
'dd-MM-yyyy': 'dd-MM-yyyy',
'Mobile phone': 'טלפון נייד',
'Work phone': 'טלפון בעבודה',
'Home phone': 'טלפון בבית',
'Main phone': 'מספר טלפון ראשי',
'Other phone': 'טלפון אחר',
'Home fax': 'פקס בבית',
'Work fax': 'פקס בעבודה',
'Google voice': 'Google voice',
'Pager': 'זימונית',
'Home email': 'מייל אישי',
'Work email': 'מייל בעבודה',
'Other email': 'כתובת מייל אחרת',
'It looks like you are using an outdated version of this script': 'נראה שאתה משתמש בגרסה לא עדכנית של התוכנה',
'You can find the latest one here': 'אתה יכול להוריד את הגרסה העדכנית כאן',
},
'id': {
'Age': 'Usia',
'Years': 'Tahun',
'Events': 'Acara',
'Birthdays today': 'Ulang tahun hari ini',
'Birthdays tomorrow': 'Ulang tahun besok',
'Birthdays in {0} days': 'Ulang tahun dalam {0} hari mendatang',
'Anniversaries today': 'Hari jadi hari ini',
'Anniversaries tomorrow': 'Hari jadi besok',
'Anniversaries in {0} days': 'Hari jadi dalam {0} hari mendatang',
'Custom events today': 'Acara khusus hari ini',
'Custom events tomorrow': 'Acara khusus besok',
'Custom events in {0} days': 'Acara khusus dalam {0} hari mendatang',
'Hey! Don\'t forget these events': 'Hei! Jangan lupa acara ini',
'version': 'versi',
'dd-MM-yyyy': 'dd-MM-yyyy',
'Mobile phone': 'Telp. Selular',
'Work phone': 'Telp. Kantor',
'Home phone': 'Telp. Rumah',
'Main phone': 'Telp. Utama',
'Other phone': 'Telp. Lain',
'Home fax': 'Faks. Rumah',
'Work fax': 'Faks. Kantor',
'Google voice': 'Google voice',
'Pager': 'Pager',
'Home email': 'Email Rumah',
'Work email': 'Email Kantor',
'Other email': 'Email Lain',
'It looks like you are using an outdated version of this script': 'Sepertinya anda menggunakan versi lama dari skrip ini',
'You can find the latest one here': 'Anda bisa menemukan versi terbaru di sini',
},
'it': {
'Age': 'Età',
'Years': 'Anni',
'Events': 'Eventi',
'Birthdays today': 'Compleanni oggi',
'Birthdays tomorrow': 'Compleanni domani',
'Birthdays in {0} days': 'Compleanni fra {0} giorni',
'Anniversaries today': 'Anniversari oggi',
'Anniversaries tomorrow': 'Anniversari domani',
'Anniversaries in {0} days': 'Anniversari fra {0} giorni',
'Custom events today': 'Eventi personalizzati oggi',
'Custom events tomorrow': 'Eventi personalizzati domani',
'Custom events in {0} days': 'Eventi personalizzati fra {0} giorni',
'Hey! Don\'t forget these events': 'Hey! Non dimenticare questi eventi',
'version': 'versione',
'dd-MM-yyyy': 'dd-MM-yyyy',
'Mobile phone': 'Cellulare',
'Work phone': 'Telefono di lavoro',
'Home phone': 'Telefono di casa',
'Main phone': 'Telefono principale',
'Other phone': 'Altro telefono',
'Home fax': 'Fax di casa',
'Work fax': 'Fax di lavoro',
'Google voice': 'Google voice',
'Pager': 'Cercapersone',
'Home email': 'Email di casa',
'Work email': 'Email di lavoro',
'Other email': 'Altra email',
'It looks like you are using an outdated version of this script': 'Sembra che tu stia usando una vecchia versione di questo script',
'You can find the latest one here': 'Puoi trovare l\'ultima qui',
},
'kr': {
'Age': '나이',
'Years': '년도',
'Events': '행사',
'Birthdays today': '오늘 생일',
'Birthdays tomorrow': '내일 생일',
'Birthdays in {0} days': '{0}일 동안 생일',
'Anniversaries today': '오늘 기념일',
'Anniversaries tomorrow': '내일 기념일',
'Anniversaries in {0} days': '{0}일 동안 기념일',
'Custom events today': '오늘 지정된 행사',
'Custom events tomorrow': '내일 지정된 행사',
'Custom events in {0} days': '{0}일 동안 지정된 행사',
'Hey! Don\'t forget these events': '이 행사들을 잊지 마세요!',
'version': '버전',
'dd-MM-yyyy': 'yyyy-MM-dd',
'Mobile phone': '휴대폰',
'Work phone': '직장 전화',
'Home phone': '집 전화',
'Main phone': '대표 전화',
'Other phone': '기타 전화',
'Home fax': '집 팩스',
'Work fax': '직장 팩스',
'Google voice': '구글 보이스',
'Pager': '무선호출기',
'Home email': '집 이메일',
'Work email': '직장 이메일',
'Other email': '기타 이메일',
'It looks like you are using an outdated version of this script': '옛날 버전 스크립트를 사용중인것 같네요',
'You can find the latest one here': '여기에서 최신버전을 찾을 수 있습니다',
},
'lt': {
'Age': 'Amžius',
'Years': 'Metai',
'Events': 'Įvykiai',
'Birthdays today': 'Šiandienos gimtadieniai',
'Birthdays tomorrow': 'Rytojaus gimtadieniai',
'Birthdays in {0} days': 'Gimtadieniai už {0} dienų',
'Anniversaries today': 'Šiandienos jubiliejai',
'Anniversaries tomorrow': 'Rytojaus jubiliejai',
'Anniversaries in {0} days': 'Jubiliejai už {0} dienų',
'Custom events today': 'Priskirti įvykiai šiandien',
'Custom events tomorrow': 'Priskirti įvykiai rytoj',
'Custom events in {0} days': 'Priskirti įvykiai už {0} dienų',
'Hey! Don\'t forget these events': 'Hey! Neužmiršk šių įvykių',
'version': 'versija',
'dd-MM-yyyy': 'yyyy-MM-dd',
'Mobile phone': 'Mobilus telefonas',
'Work phone': 'Darbo telefonas',
'Home phone': 'Namų telefonas',
'Main phone': 'Pagrindinis telefonas',
'Other phone': 'Kitas telefonas',
'Home fax': 'Namų faksas',
'Work fax': 'Darbo faksas',
'Google voice': 'Google voice',
'Pager': 'Peidžeris',
'Home email': 'Namų elektroninis paštas',
'Work email': 'Darbo elektroninis paštas',
'Other email': 'Kitas elektroninis paštas',
'It looks like you are using an outdated version of this script': 'Atrodo, kad jūs naudojate pasenusią šio skripto versiją',
'You can find the latest one here': 'Naujausią galite rasti čia',
},
'nb': {
'Age': 'Alder',
'Years': 'År',
'Events': 'Arrangementer',
'Birthdays today': 'Bursdager idag',
'Birthdays tomorrow': 'Bursdager imorgen',
'Birthdays in {0} days': 'Bursdager om {0} dager',
'Anniversaries today': 'Jubileer idag',
'Anniversaries tomorrow': 'Jubileer imorgen',
'Anniversaries in {0} days': 'Jubileer om {0} dager',
'Custom events today': 'Egendefinerte arrangementer idag',
'Custom events tomorrow': 'Egendefinerte arrangementer imorgen',
'Custom events in {0} days': 'Egendefinerte arrangementer om {0} dager',
'Hey! Don\'t forget these events': 'Hei! Ikke glem disse arrangementene',
'version': 'versjon',
'dd-MM-yyyy': 'dd-MM-yyyy',
'Mobile phone': 'Mobiltelefon',
'Work phone': 'Arbeidstelefon',
'Home phone': 'Hjemmetelefon',
'Main phone': 'Hovedtelefon',
'Other phone': 'Annen telefon',
'Home fax': 'Hjemme faks',
'Work fax': 'Arbeids faks',
'Google voice': 'Google voice',
'Pager': 'Personsøker ',
'Home email': 'Hjemme e-post',
'Work email': 'Arbeids e-post',
'Other email': 'Annen e-post',
'It looks like you are using an outdated version of this script': 'Det ser ut til at du bruker utdatert versjon av dette skriptet',
'You can find the latest one here': 'Du kan finne den nyeste her',
},
'nl': {
'Age': 'Leeftijd',
'Years': 'Jaar',
'Events': 'Gebeurtenissen',
'Birthdays today': 'Verjaardagen vandaag',
'Birthdays tomorrow': 'Verjaardagen morgen',
'Birthdays in {0} days': 'Verjaardagen over {0} dagen',
'Anniversaries today': 'Jubilea vandaag',
'Anniversaries tomorrow': 'Jubilea morgen',
'Anniversaries in {0} days': 'Jubilea over {0} dagen',
'Custom events today': 'Aangepaste gebeurtenissen vandaag',
'Custom events tomorrow': 'Aangepaste gebeurtenissen morgen',
'Custom events in {0} days': 'Aangepaste gebeurtenissen over {0} dagen',
'Hey! Don\'t forget these events': 'Hey! Vergeet volgende gebeurtenissen niet',
'version': 'versie',
'dd-MM-yyyy': 'dd-MM-yyyy',
'Mobile phone': 'Mobiel',
'Work phone': 'Tel. werk',
'Home phone': 'Tel. thuis',
'Main phone': 'Algemeen telefoonnummer',
'Other phone': 'Ander telefoonnummer',
'Home fax': 'Fax thuis',
'Work fax': 'Fax werk',
'Google voice': 'Google voice',
'Pager': 'Pager',
'Home email': 'E-mail thuis',
'Work email': 'E-mail werk',
'Other email': 'Ander e-mailadres',
'It looks like you are using an outdated version of this script': 'Het lijkt erop alsof je een verouderde versie van dit script gebruikt.',
'You can find the latest one here': 'Je kunt de laatste versie hier vinden',
},
'no': {
'Age': 'Alder',
'Years': 'År',
'Events': 'Arrangementer',
'Birthdays today': 'Bursdager i dag',
'Birthdays tomorrow': 'Bursdager i morgen',
'Birthdays in {0} days': 'Bursdager om {0} dager',
'Anniversaries today': 'Jubileum i dag',
'Anniversaries tomorrow': 'Jubileum i morgen',
'Anniversaries in {0} days': 'Jubileum om {0} dager',
'Custom events today': 'Egendefinerte hendelser i dag',
'Custom events tomorrow': 'Egendefinerte hendelser i morgen',
'Custom events in {0} days': 'Egendefinerte hendelser om {0} dager',
'Hey! Don\'t forget these events': 'Hei! Ikke glem disse arrangementene',
'version': 'versjon',
'dd-MM-yyyy': 'dd.MM.yyyy',
'Mobile phone': 'Mobil',
'Work phone': 'Jobbtelefon',
'Home phone': 'Hjemtelefon',
'Main phone': 'Hovedtelefon',
'Other phone': 'Annen telefon',
'Home fax': 'Hjemmefax',
'Work fax': 'Jobbfax',
'Google voice': 'Google voice',
'Pager': 'Personsøker',
'Home email': 'Hjem e-post',
'Work email': 'Jobb e-post',
'Other email': 'Annen e-post',
'It looks like you are using an outdated version of this script': 'Det ser ut som du bruker en gammel versjon av dette scriptet',
'You can find the latest one here': 'Du kan finne nyeste versjon her',
},
'pl': {
'Age': 'Wiek',
'Years': 'Lat(a)',
'Events': 'Wydarzenia',
'Birthdays today': 'Urodziny dzisiaj',
'Birthdays tomorrow': 'Urodziny jutro',
'Birthdays in {0} days': 'Urodziny za {0} dni',
'Anniversaries today': 'Rocznice dzisiaj',
'Anniversaries tomorrow': 'Rocznice jutro',
'Anniversaries in {0} days': 'Rocznice za {0} dni',
'Custom events today': 'Inne wydarzenia dzisiaj',
'Custom events tomorrow': 'Inne wydarzenia jutro',
'Custom events in {0} days': 'Inne wydarzenia za {0} dni',
'Hey! Don\'t forget these events': 'Hej! Nie zapomnij o tych datach',
'version': 'wersja',
'dd-MM-yyyy': 'dd.MM.yyyy',
'Mobile phone': 'Telefon',
'Work phone': 'Telefon (służbowy)',
'Home phone': 'Telefon (stacjonarny)',
'Main phone': 'Telefon (główny)',
'Other phone': 'Inne numery',
'Home fax': 'Fax (domowy)',
'Work fax': 'Fax (służbowy)',
'Google voice': 'Google voice',
'Pager': 'Pager',
'Home email': 'E-mail (prywatny)',
'Work email': 'E-mail (służbowy)',
'Other email': 'Inne adresy e-mail',
'It looks like you are using an outdated version of this script': 'Wygląda na to, że używasz nieaktualnej wersji skryptu',
'You can find the latest one here': 'Najnowszą możesz znaleźć tutaj', // Using feminime version of 'latest', because it refers to 'version'. There's possibility it won't fit into diffrent context.
},
'pt': {
'Age': 'Idade',
'Years': 'Anos',
'Events': 'Eventos',
'Birthdays today': 'Aniversários hoje',
'Birthdays tomorrow': 'Aniversários amanhã',
'Birthdays in {0} days': 'Aniversários em {0} dias',
'Anniversaries today': 'Aniversários hoje',
'Anniversaries tomorrow': 'Aniversários amanhã',
'Anniversaries in {0} days': 'Aniversários em {0} dias',
'Custom events today': 'Eventos personalizados hoje',
'Custom events tomorrow': 'Eventos personalizados amanhã',
'Custom events in {0} days': 'Eventos personalizados em {0} dias',
'Hey! Don\'t forget these events': 'Hey! Não te esqueças destes eventos',
'version': 'versão',
'dd-MM-yyyy': 'dd-MM-yyyy',
'Mobile phone': 'Número de telemóvel',
'Work phone': 'Número de trabalho',
'Home phone': 'Número de casa',
'Main phone': 'Número principal',
'Other phone': 'Outro número',
'Home fax': 'Fax de casa',
'Work fax': 'Fax de trabalho',
'Google voice': 'Google voice',
'Pager': 'Pager',
'Home email': 'Email de casa',
'Work email': 'Email de trabalho',
'Other email': 'Outro email',
'It looks like you are using an outdated version of this script': 'Parece que tens uma versão desatualizada deste script',
'You can find the latest one here': 'Podes encontrar a última versão aqui',
},
'pt-BR': {
'Age': 'Idade',
'Years': 'Anos',
'Events': 'Eventos',
'Birthdays today': 'Aniversários hoje',
'Birthdays tomorrow': 'Aniversários amanhã',
'Birthdays in {0} days': 'Aniversários em {0} dias',
'Anniversaries today': 'Aniversários hoje',
'Anniversaries tomorrow': 'Aniversários amanhã',
'Anniversaries in {0} days': 'Aniversários em {0} dias',
'Custom events today': 'Eventos personalizados hoje',
'Custom events tomorrow': 'Eventos personalizados amanhã',
'Custom events in {0} days': 'Eventos personalizados em {0} dias',
'Hey! Don\'t forget these events': 'Ei! Não se esqueça destes eventos',
'version': 'versão',
'dd-MM-yyyy': 'dd-MM-yyyy',
'Mobile phone': 'Celular',
'Work phone': 'Telefone de trabalho',
'Home phone': 'Telefone residencial',
'Main phone': 'Telefone principal',
'Other phone': 'Outro telefone',
'Home fax': 'Fax residencial',
'Work fax': 'Fax profissional',
'Google voice': 'Google voice',
'Pager': 'Pager',
'Home email': 'Email residencial',
'Work email': 'Email profissional',
'Other email': 'Outro email',
'It looks like you are using an outdated version of this script': 'Parece que você está usando uma versão desatualizada deste script',
'You can find the latest one here': 'Você pode encontrar a última versão aqui',
},
'ru': {
'Age': 'Возраст',
'Years': 'Лет',
'Events': 'События',
'Birthdays today': 'Дни рождения сегодня',
'Birthdays tomorrow': 'Дни рождения завтра',
'Birthdays in {0} days': 'Дни рождения через {0} дней',
'Anniversaries today': 'Юбилей сегодня',
'Anniversaries tomorrow': 'Юбилей завтра',
'Anniversaries in {0} days': 'Юбилей через {0} дней',
'Custom events today': 'Специальное событие сегодня',
'Custom events tomorrow': 'Специальное событие завтра',
'Custom events in {0} days': 'Специальное событие через {0} дней',
'Hey! Don\'t forget these events': 'Эй! Не забудь об этих мероприятиях',
'version': 'версия',
'dd-MM-yyyy': 'dd-MM-yyyy',
'Mobile phone': 'Мобильный телефон',
'Work phone': 'Рабочий телефон',
'Home phone': 'Домашний телефон',
'Main phone': 'Основной телефон',
'Other phone': 'Другой телефон',
'Home fax': 'Домашний факс',
'Work fax': 'Рабочий факс',
'Google voice': 'Google voice',
'Pager': 'Пейджер',
'Home email': 'Домашний email',
'Work email': 'Рабочий email',
'Other email': 'Другой email',
'It looks like you are using an outdated version of this script': 'Похоже вы используете устаревшую версию этой программы',
'You can find the latest one here': 'Вы можете найти последнюю версию здесь',
},
'th': {
'Age': 'อายุ',
'Years': 'ปี',
'Events': 'อีเวนท์',
'Birthdays today': 'วันเกิดวันนี้',
'Birthdays tomorrow': 'วันเกิดพรุ่งนี้',
'Birthdays in {0} days': 'วันเกิดในอีก {0} วัน',
'Anniversaries today': 'วันครบรอบวันนี้',
'Anniversaries tomorrow': 'วันครบรอบพรุ่งนี้',
'Anniversaries in {0} days': 'วันครบรอบในอีก {0} วัน',
'Custom events today': 'อีเวนท์ที่กำหนดเองวันนี้',
'Custom events tomorrow': 'อีเวนท์ที่กำหนดเองวันพรุ่งนี้',
'Custom events in {0} days': 'อีเวนท์ที่กำหนดเองในอีก {0} วัน',
'Hey! Don\'t forget these events': 'เฮ้! อย่าลืมอีเวน์เหล่านี้ล่ะ',
'version': 'เวอร์ชั่น',
'dd-MM-yyyy': 'dd-MM-yyyy',
'Mobile phone': 'เบอร์โทรศัพท์',
'Work phone': 'เบอร์โทรศัพท์ที่ทำงาน',
'Home phone': 'เบอร์โทรศัพท์บ้าน',
'Main phone': 'เบอร์โทรศัพท์หลัก',
'Other phone': 'เบอร์โทรศัพท์อื่นๆ',
'Home fax': 'แฟกซ์บ้าน',
'Work fax': 'แฟกซ์ที่ทำงาน',
'Google voice': 'Google voice',
'Pager': 'เพจเจอร์',
'Home email': 'อีเมลบ้าน',
'Work email': 'อีเมลที่ทำงาน',
'Other email': 'อีเมลอื่นๆ',
'It looks like you are using an outdated version of this script': 'ดูเหมือนว่าคุณกำลังใช้เวอร์ชั่นเก่าสำหรับสคริปท์นี้',
'You can find the latest one here': 'คุณสามารถหาเวอร์ชั่นใหม่ได้ที่นี่',
},
'tr': {
'Age': 'Yaş',
'Years': 'Yıl',
'Events': 'Etkinlikler',
'Birthdays today': 'Bugünkü doğum günleri',
'Birthdays tomorrow': 'Yarınki doğum günleri',
'Birthdays in {0} days': '{0} gün içindeki doğum günleri',
'Anniversaries today': 'Bugünkü yıldönümleri',
'Anniversaries tomorrow': 'Yarınki yıldönümleri',
'Anniversaries in {0} days': '{0} gün içindeki yıldönümleri',
'Custom events today': 'Bugünkü özel etkinlikler',
'Custom events tomorrow': 'Yarınki özel etkinlikler',
'Custom events in {0} days': '{0} gün içindeki özel etkinlikler',
'Hey! Don\'t forget these events': 'Hey! Bu etkinlikleri unutma!',
'version': 'sürüm',
'dd-MM-yyyy': 'dd-MM-yyyy',
'Mobile phone': 'Cep telefonu',
'Work phone': 'İş telefonu',
'Home phone': 'Ev telefonu',
'Main phone': 'Birincil telefon',
'Other phone': 'Diğer telefon',
'Home fax': 'Fax (ev)',
'Work fax': 'Fax (iş)',
'Google voice': 'Google voice',
'Pager': 'Çağrı cihazı',
'Home email': 'E-mail (ev)',
'Work email': 'E-mail (iş)',
'Other email': 'Email (diğer)',
'It looks like you are using an outdated version of this script': 'Görünüşe göre bu betiğin eski bir sürümünü kullanıyorsunuz',
'You can find the latest one here': 'En son sürümü burada bulabilirsiniz',
},
/* To add a language:
'[lang-code]': {
'[first phrase]': '[translation here]',
'[second phrase]': '[translation here]',
...
// Note: 'dd-MM-yyyy' should NOT be translated (especially in a different alphabet). You just need to reorder
// dd (day) MM (month) and yyyy (year) in the order your language usually represents dates.
// Examples:
// USA: (month/day/year) should be 'MM-dd-yyyy'
// Italy: (day/month/year) should be 'dd-MM-yyyy'
}
*/
};
// HELPER FUNCTIONS
/**
* Get the translation of a string.
*
* If the language or the chosen string is invalid return the string itself.
*
* @param {!string} str - String to attempt translation for.
* @returns {string}
*/
function _ (str) {
return i18n[settings.user.lang][str] || str;
}
/**
* Return whether an item exists as a value in an object.
*
* @param {!any} item - The item to search the values for.
* @param {!object} arr - The object to search in.
* @returns {boolean} - Whether the item exists as a value in the object.
*/
function isIn (item, arr) {
/*
* Must use "indexOf" with values rather than "in" with keys, because e.g.
* "null" and "undefined" can't be keys. No need for "typeof undefined"
* syntax for comparing "undefined" as we are not targeting browsers, let
* alone old ones.
*/
return arr.indexOf(item) !== -1;
}
/**
* Replace an event label string with its lowercased version, without
* changing the prefix 'CUSTOM:' if it is present.
*
* @param {!string} label - The label to be lowercased.
* @returns {string}
*/
function eventLabelToLowerCase (label) {
if (label.indexOf('CUSTOM:') === 0) {
return label.slice(0, 7) + label.slice(7).toLocaleLowerCase();
} else {
return label.toLocaleLowerCase();
}
}
/**
* Replace a `Field.Label` object with its "beautified" text representation.
*
* @param {?string} label - The internal label to transform to readable form.
* @returns {string}
*/
function beautifyLabel (label) {
switch (String(label)) {
/*
* Phone labels:
*/
case 'MOBILE_PHONE':
case 'WORK_PHONE':
case 'HOME_PHONE':
case 'MAIN_PHONE':
case 'HOME_FAX':
case 'WORK_FAX':
case 'GOOGLE_VOICE':
case 'PAGER':
case 'OTHER_PHONE': // Fake label for output.
/*
* (falls through)
* Email labels:
*/
case 'HOME_EMAIL':
case 'WORK_EMAIL':
case 'OTHER_EMAIL': // Fake label for output.
/*
* (falls through)
* Event labels:
*/
case 'OTHER':
case 'BIRTHDAY':
case 'ANNIVERSARY':
return _(label[0] + label.slice(1).replaceAll('_', ' ').toLocaleLowerCase());
/*
* Custom labels:
*/
case 'CUSTOM:' + label.slice('CUSTOM:'.length):
// Don't interfere with the upper/lower-casing for this one though
return label.slice('CUSTOM:'.length);
default:
return String(label);
}
}
/**
* Replace HTML special characters in a string with their HTML-escaped equivalent.
*
* @param {?string} str - The string to escape.
* @returns {string} - The escaped string.
*/
function htmlEscape (str) {
str = str || '';
return str
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(//g, '>')
.replace(/\//g, '/');
}
/**
* Check if the script is not updated to the latest version.
*
* The latest version number is obtained from the GitHub API and compared with the
* script's one.
*
* If there is any problem retrieving the latest version number false is returned.
*
* @returns {boolean} - True if the script version is lower than the latest released one, false otherwise.
*/
function isRunningOutdatedVersion () {
var response, latestVersion, fetchTries;
// Retrieve the last version info.
fetchTries = 2;
try {
response = cache.retrieve(baseGitHubApiURL + 'releases/latest', fetchTries);
if (response === null) {
throw new Error('');
}
} catch (err) {
log.add('Unable to get the latest version number after ' + fetchTries + ' tries', Priority.WARNING);
return false;
}
// Parse the info for the version number.
try {
response = JSON.parse(response);
if (typeof response !== 'object') {
throw new Error('');
}
} catch (err) {
log.add('Unable to get the latest version number: failed to parse the API response as JSON object', Priority.WARNING);
return false;
}
latestVersion = response.tag_name;
if (typeof latestVersion !== 'string' || latestVersion.length === 0) {
log.add('Unable to get the latest version number: there was no valid tag_name string in the API response.', Priority.WARNING);
return false;
}
if (latestVersion.substring(0, 1) === 'v') {
latestVersion = latestVersion.substring(1);
}
// Compare the versions.
try {
return (version).compare(new SimplifiedSemanticVersion(latestVersion)) === -1;
} catch (err) {
log.add(err.message, Priority.WARNING);
return false;
}
}
/**
* Get a `ContactsApp.Month`'s numerical representation.
*
* @param {!Object} month
* @returns {number} - 0-11 for each month, -1 for wrong values.
*/
function monthToInt (month) {
var i;
var months = [
ContactsApp.Month.JANUARY,
ContactsApp.Month.FEBRUARY,
ContactsApp.Month.MARCH,
ContactsApp.Month.APRIL,
ContactsApp.Month.MAY,
ContactsApp.Month.JUNE,
ContactsApp.Month.JULY,
ContactsApp.Month.AUGUST,
ContactsApp.Month.SEPTEMBER,
ContactsApp.Month.OCTOBER,
ContactsApp.Month.NOVEMBER,
ContactsApp.Month.DECEMBER
];
for (i = 0; i < 12; i++) {
if (month === months[i]) {
return i;
}
}
return -1;
}
/**
* Return an array of strings with duplicate strings removed.
*
* @param {!string[]} arr - The array containing the duplicates.
* @returns {string[]} - The array without duplicates.
*/
function uniqueStrings (arr) {
var seen = {};
return arr.filter(function (str) {
return seen.hasOwnProperty(str) ? false : (seen[str] = true);
});
}
// MAIN FUNCTIONS
/**
* Validate the settings, logging all problems found and stopping the script
* execution if a FATAL_ERROR is thrown.
*/
function validateSettings () {
var setting, calendarId;
log.add('validateSettings() running.');
setting = settings.user.googleEmail;
if (!setting || !/^(?!YOUREMAILHERE)\S+@\S+\.\S+$/.test(setting)) {
log.add('Your user.googleEmail setting is invalid!', Priority.FATAL_ERROR);
}
setting = settings.user.notificationEmail;
if (!setting || !/^(?!YOUREMEAILHERE)\S+@(?!example)\S+\.\S+$/.test(setting)) {
log.add('Your user.notificationEmail setting is invalid!', Priority.FATAL_ERROR);
}
// Get the calendar ID from Google Calendar.
calendarId = CalendarApp.getAllCalendars().filter(function (cal) {
// All the valid calendar IDs contain this string.
return isIn('#contacts@group.v.calendar.google.com', cal.getId());
}).map(function (cal) { return cal.getId(); });
if (calendarId.length > 0) {
settings.user.calendarId = calendarId[0];
} else {
log.add('Could not find the birthday calendar! Please check that you have enabled it!', Priority.FATAL_ERROR);
}
try {
if (Calendar.Calendars.get(settings.user.calendarId) === null) {
throw new Error('');
}
} catch (err) {
log.add('The birthday calendar failed to load!', Priority.FATAL_ERROR);
}
// emailSenderName has no restrictions.
// lang has no restrictions.
setting = settings.notifications.hour;
if (!Number.isInteger(setting) || setting < 0 || setting >= 24) {
log.add('Your notifications.hour setting is invalid!', Priority.ERROR);
// Default value.
settings.notifications.hour = 6;
}
// It would be quite difficult to test the timeZone.
setting = settings.notifications.anticipateDays;
if (
setting.constructor !== Array ||
setting.filter(function (x) {
return Number.isInteger(x) && x >= 0;
}).length !== setting.length
) {
log.add('Your notifications.anticipateDays setting is invalid!', Priority.ERROR);
// Default value.
settings.notifications.anticipateDays = [0, 1, 7];
}
setting = settings.notifications.eventTypes;
if (
typeof setting.BIRTHDAY !== 'boolean' ||
typeof setting.ANNIVERSARY !== 'boolean' ||
typeof setting.CUSTOM !== 'boolean'
) {
log.add('Your notifications.eventTypes setting is invalid!', Priority.ERROR);
// Default value.
settings.notifications.eventTypes = {
BIRTHDAY: true,
ANNIVERSARY: false,
CUSTOM: false
};
}
setting = settings.notifications.maxEmailsCount;
if (!Number.isInteger(setting) || setting < -1) {
log.add('Your notifications.maxEmailsCount setting is invalid!', Priority.ERROR);
// Default value.
settings.notifications.maxEmailsCount = -1;
}
setting = settings.notifications.maxPhonesCount;
if (!Number.isInteger(setting) || setting < -1) {
log.add('Your notifications.maxPhonesCount setting is invalid!', Priority.ERROR);
// Default value.
settings.notifications.maxPhonesCount = -1;
}
setting = settings.notifications.indentSize;
if (!Number.isInteger(setting) || setting <= 0) {
log.add('Your notifications.indentSize setting is invalid!', Priority.ERROR);
// Default value.
settings.notifications.indentSize = 4;
}
if (typeof settings.notifications.compactGrouping !== 'boolean') {
log.add('Your notifications.compactGrouping setting is invalid!', Priority.ERROR);
// Default value.
settings.notifications.compactGrouping = true;
}
setting = settings.debug.log.filterLevel;
if (typeof Priority[setting] !== 'object') {
log.add('Your debug.log.filterLevel setting is invalid!', Priority.ERROR);
// Default value.
settings.debug.log.filterLevel = 'INFO';
}
setting = settings.debug.log.sendTrigger;
if (typeof Priority[setting] !== 'object') {
log.add('Your debug.log.sendTrigger setting is invalid!', Priority.ERROR);
// Default value.
settings.debug.log.sendTrigger = 'ERROR';
}
setting = settings.debug.testDate;
if (setting.constructor !== Date) {
log.add('Your debug.log.testDate setting is invalid!', Priority.ERROR);
// Default value.
settings.debug.log.testDate = new Date();
}
}
/**
* Returns an array with the events happening in the calendar with
* ID `calendarId` on date `eventDate`.
*
* @param {!Number} year - The full year of the date of the event.
* @param {!Number} month - The number representing the month of the date of the event, starting from 0.
* @param {!Number} day - The number of the day of the date of the event.
* @param {!string} calendarId - The id of the calendar from which events are collected.
* @returns {Object[]} - A list of rawEvent Objects.
*/
function getEventsOnDate (year, month, day, calendarId) {
var eventCalendar, eventDate, startDate, endDate, events;
// Verify the existence of the events calendar.
try {
eventCalendar = Calendar.Calendars.get(calendarId);
if (eventCalendar === null) {
throw new Error('');
}
} catch (err) {
log.add('The calendar with ID "' + calendarId + '" is not accessible: check your calendarId value!', Priority.FATAL_ERROR);
}
eventDate = dateWithTimezone(year, month, day, 0, 0, 0, eventCalendar.timeZone);
// Query the events calendar for events on the specified date.
try {
// Look for events from 00:00:00 to 00:01:00 of the specified day.
startDate = Utilities.formatDate(eventDate, eventCalendar.timeZone, 'yyyy-MM-dd\'T\'HH:mm:ssXXX');
endDate = Utilities.formatDate(new Date(eventDate.getTime() + 60000), eventCalendar.timeZone, 'yyyy-MM-dd\'T\'HH:mm:ssXXX');
log.add('Looking for contacts events on ' + eventDate + ' (' + startDate + ' / ' + endDate + ')', Priority.INFO);
} catch (err) {
log.add(err.message, Priority.FATAL_ERROR);
}
events = Calendar.Events.list(
calendarId,
{
singleEvents: true,
timeMin: startDate,
timeMax: endDate
}
).items;
log.add('Found: ' + events.length);
return events;
}
/**
* Generate the content of an email to the user containing a list of the events
* of his/her contacts scheduled on a given date.
*
* @param {?Date} forceDate - If this value is not null it's used as 'now'.
* @returns {Object.} - The content of the email.
*/
function generateEmailNotification (forceDate) {
var now, events, contactList, subjectPrefix, subjectBuilder, subject,
bodyPrefix, bodySuffixes, bodyBuilder, body, htmlBody, htmlBodyBuilder,
contactIter, runningOutdatedVersion, maxSubjectLength, ellipsis;
log.add('generateEmailNotification() running.', Priority.INFO);
now = forceDate || new Date();
log.add('Date used: ' + now, Priority.INFO);
events = [].concat.apply(
[],
settings.notifications.anticipateDays
.map(function (days) {
var date = now.addDays(days);
return getEventsOnDate(
parseInt(Utilities.formatDate(date, settings.notifications.timeZone, 'yyyy'), 10),
parseInt(Utilities.formatDate(date, settings.notifications.timeZone, 'MM'), 10) - 1,
parseInt(Utilities.formatDate(date, settings.notifications.timeZone, 'dd'), 10),
settings.user.calendarId
);
})
);
if (events.length === 0) {
log.add('No events found. Exiting now.', Priority.INFO);
return null;
}
log.add('Found ' + events.length + ' events.', Priority.INFO);
contactList = [];
/*
* Build a list of contacts (with complete information) from the event list.
*
* **Note:** multiple events can refer to the same contact.
*/
events.forEach(function (rawEvent) {
var eventData;
if (!rawEvent.gadget || !rawEvent.gadget.preferences) {
log.add(rawEvent, Priority.INFO);
log.add('The structure of this event cannot be parsed.', Priority.FATAL_ERROR);
}
eventData = rawEvent.gadget.preferences;
// Look if the contact of this event is already in the contact list.
for (contactIter = 0; contactIter < contactList.length; contactIter++) {
if (
eventData['goo.contactsContactId'] !== null &&
eventData['goo.contactsContactId'] === contactList[contactIter].contactId
) {
// FOUND!
// Integrate this event information into the contact.
break;
}
}
if (contactIter === contactList.length) {
// NOT FOUND!
// Add a new contact to the contact list and store all the info in that contact.
contactList.push(new MergedContact());
}
contactList[contactIter].getInfoFromRawEvent(rawEvent);
});
// Iterate by reverse index to allow safe splicing from within the loop
contactIter = contactList.length;
while (contactIter--) {
if (!contactList[contactIter].events || !contactList[contactIter].events.length) {
contactList.splice(contactIter, 1);
}
}
if (contactList.length === 0) {
log.add('No contacts with valid events found. Exiting now.', Priority.INFO);
return null;
}
log.add('Found ' + contactList.length + ' contacts with matching events.', Priority.INFO);
// Give a default profile image to the contacts without one.
contactList.forEach(function (contact) {
contact.data.merge(new ContactDataDC(
null, // Full name.
null, // Nickname.
defaultProfileImageURL // Profile photo URL.
));
});
// Start building the email notification text.
subjectPrefix = _('Events') + ': ';
subjectBuilder = contactList.map(function (contact) { return contact.data.getProp('fullName'); });
bodyPrefix = _('Hey! Don\'t forget these events') + ':';
bodySuffixes = [
_('Google Contacts Events Notifier') + ' (' + _('version') + ' ' + version.toString() + ')',
_('It looks like you are using an outdated version of this script') + '.',
_('You can find the latest one here')
];
inlineImages = {};
// The email is built both with plain text and HTML text.
bodyBuilder = [];
htmlBodyBuilder = [];
settings.notifications.anticipateDays
.forEach(function (daysInterval) {
var date, formattedDate;
date = now.addDays(daysInterval);
formattedDate = Utilities.formatDate(date, settings.notifications.timeZone, _('dd-MM-yyyy'));
eventTypes.forEach(function (eventType) {
var plaintextLines, htmlLines, whenIsIt;
// Get all the matching 'eventType' events.
log.add('Checking ' + eventTypeNamePlural[eventType] + ' on ' + formattedDate, Priority.INFO);
plaintextLines = contactList
.map(function (contact) { return contact.getLines(eventType, date, NotificationType.PLAIN_TEXT); })
.filter(function (lines) { return lines.length > 0; });
htmlLines = contactList
.map(function (contact) { return contact.getLines(eventType, date, NotificationType.HTML); })
.filter(function (lines) { return lines.length > 0; });
if (plaintextLines.length === 0 || htmlLines.length === 0) {
log.add('No events found on this date.', Priority.INFO);
return;
}
log.add('Found ' + plaintextLines.length + ' ' + eventTypeNamePlural[eventType], Priority.INFO);
// Build the headers of 'eventType' event grouping by date.
bodyBuilder.push('\n * ');
htmlBodyBuilder.push('');
whenIsIt = eventTypeNamePlural[eventType].charAt(0).toUpperCase() + eventTypeNamePlural[eventType].slice(1);
switch (daysInterval) {
case 0:
whenIsIt += ' today';
break;
case 1:
whenIsIt += ' tomorrow';
break;
default:
whenIsIt += ' in {0} days';
}
whenIsIt = _(whenIsIt).format(daysInterval) + ' (' + formattedDate + ')';
bodyBuilder.push(whenIsIt, ':\n');
plaintextLines.forEach(function (line) { bodyBuilder.extend(line); });
htmlBodyBuilder.push(whenIsIt, '');
htmlLines.forEach(function (line) { htmlBodyBuilder.extend(line); });
htmlBodyBuilder.push('
');
});
});
if (bodyBuilder.length === 0) {
// If there is no email to send
return null;
} else {
// If there is an email to send build the content...
log.add('Building the email notification.', Priority.INFO);
runningOutdatedVersion = isRunningOutdatedVersion();
subject = subjectPrefix + subjectBuilder.join(' - ');
// An error is thrown if the subject of the email is longer than 250 characters.
maxSubjectLength = 250;
ellipsis = '...';
if (subject.length > maxSubjectLength) {
subject = subject.substr(0, maxSubjectLength - ellipsis.length) + ellipsis;
}
body = [bodyPrefix, '\n']
.concat(bodyBuilder)
.concat(['\n\n ', bodySuffixes[0], '\n '])
.concat('\n', runningOutdatedVersion ? [bodySuffixes[1], ' ', bodySuffixes[2], ':\n', baseGitHubProjectURL + 'releases/latest', '\n '] : [])
.join('');
htmlBody = ['', htmlEscape(bodyPrefix), '
']
.concat(htmlBodyBuilder)
.concat(['
', htmlEscape(bodySuffixes[0]), ''])
.concat(runningOutdatedVersion ? ['
', htmlEscape(bodySuffixes[1]), ' ', htmlEscape(bodySuffixes[2]), '.
'] : [''])
.join('');
// ...and return it.
return {
'subject': subject,
'body': body,
'htmlBody': htmlBody,
'inlineImages': inlineImages
};
}
}
/**
*
* Send an email notification to the user containing a list of the events
* of his/her contacts scheduled for the next days.
*
* @param {?Date} forceDate - If this value is not null it's used as 'now'.
*/
function main (forceDate) {
log.add('main() running.', Priority.INFO);
validateSettings();
var emailData = generateEmailNotification(forceDate);
// If generateEmailNotification returned mail content send it.
if (emailData !== null) {
log.add('Sending email...', Priority.INFO);
MailApp.sendEmail({
to: settings.user.notificationEmail,
subject: emailData.subject,
body: emailData.body,
htmlBody: emailData.htmlBody,
inlineImages: emailData.inlineImages,
name: settings.user.emailSenderName
});
log.add('Email sent.', Priority.INFO);
}
// Send the log if the debug options say so.
log.sendEmail(settings.user.notificationEmail, settings.user.emailSenderName);
}
/**
* Execute the `main()` function without forcing any date as "now".
*/
function normal () { // eslint-disable-line no-unused-vars
log.add('normal() running.', Priority.INFO);
main(null);
}
/**
* Execute the `main()` function forcing a given date (`settings.debug.testDate`) as "now".
*/
function test () { // eslint-disable-line no-unused-vars
log.add('test() running.', Priority.INFO);
main(settings.debug.testDate);
}
// NOTIFICATION SERVICE FUNCTIONS
/**
* Start the notification service.
*/
function notifStart () { // eslint-disable-line no-unused-vars
validateSettings();
// Delete old triggers.
notifStop();
// Add a new trigger.
try {
ScriptApp.newTrigger('normal')
.timeBased()
.atHour(settings.notifications.hour)
.everyDays(1)
.inTimezone(settings.notifications.timeZone)
.create();
} catch (err) {
log.add('Failed to start the notification service: make sure that settings.notifications.timeZone is a valid value.', Priority.FATAL_ERROR);
}
log.add('Notification service started.', Priority.INFO);
}
/**
* Stop the notification service.
*/
function notifStop () {
var triggers;
// Delete all the triggers.
triggers = ScriptApp.getProjectTriggers();
for (var i = 0; i < triggers.length; i++) {
ScriptApp.deleteTrigger(triggers[i]);
}
log.add('Notification service stopped.', Priority.INFO);
}
/**
* Check if notification service is running.
*/
function notifStatus () { // eslint-disable-line no-unused-vars
var toLog = 'Notifications are ';
if (ScriptApp.getProjectTriggers().length < 1) {
toLog += 'not ';
}
toLog += 'running.';
log.add(toLog);
log.sendEmail(settings.user.notificationEmail, settings.user.emailSenderName);
}
/**
* Generate a date with a given timezone id.
*
* @param {!Number} year - Full year of the date.
* @param {!Number} month - Month of the date, starting from 0.
* @param {!Number} day - Day of the date, starting from 1.
* @param {!Number} hour - Hour of the date.
* @param {!Number} minute - Minute of the date.
* @param {!Number} second - Second of the date,
* @param {String} timezoneId - A valid IANA timezone identifier.
*
* @returns {Date} - The date corresponding to the input.
*/
function dateWithTimezone (year, month, day, hour, minute, second, timezoneId) {
var date, offset;
// Generate the date as in the UTC0 timezone.
date = new Date(Date.UTC(year, month, day, hour, minute, second));
// Calculate the offset for the given timezone.
offset = Utilities.formatDate(date, timezoneId, 'Z');
// Evaluate the offset (in minutes).
offset = (offset[0] === '-' ? -1 : +1) * (parseInt(offset[1] + offset[2], 10) * 60 + parseInt(offset[3] + offset[4], 10));
// Apply the offse to the UTC date to get the correct date.
date = new Date(date.getTime() - offset * 60000);
return date;
}