google-calendar-logger
Version:
Provides ways to log time to your Google Calendar
482 lines (406 loc) • 15 kB
JavaScript
;
const { google } = require('googleapis'),
googleAPIGetAuth = require('./authorize.js'),
chalk = require('chalk'),
moment = require('moment-timezone');
/** Google Calendar Logger */
module.exports = class GoogleCalendarLogger {
/**
* Create a Google Calendar Logger instance
* @param {Object} options
* @param {String} options.credentialsPath Path to credentials.json (generate here: https://developers.google.com/calendar/quickstart/nodejs)
* @param {String} options.tokenPath Path to where token.json should be placed (including filename + .json)
* @param {String} options.calendar How much time can exist between logged activities, before the log gets interrupted.
* @param {Number} [options.minutesUntilInactivity=10] How much time can exist between logged activities, before the log gets interrupted.
* @param {Object} [options.strings={}] Strings overrides.
* @param {Boolean} [options.showLinks=false] Print links to events in the CLI?
*/
constructor (options) {
this.setDefaults();
const {
// Required
credentialsPath,
tokenPath,
calendar: calendarSummary,
// Optional
minutesUntilInactivity,
strings: stringsOverrides = {},
showLinks = false,
} = options;
this.setCredentialsPath(credentialsPath);
this.setTokenPath(tokenPath);
this.setCalendarSummary(calendarSummary);
this.setMinutesUntilInactivity(minutesUntilInactivity);
this.setStringsOverrides(stringsOverrides);
this.setShowLinks(showLinks);
// Init Google Calendar connection
this.initCalendarConnection();
}
/***********************************************
* Default setters
**********************************************/
setDefaults () {
this.minutesUntilInactivity = 10;
this.strings = this.getDefaultStrings();
}
getDefaultStrings () {
return {
activityStarted: projectName => `Started working on ${projectName}`,
activityInProgress: projectName => `Working on ${projectName}`,
activityConcluded: projectName => `Worked on ${projectName}`,
activityLogged: projectName => `Activity in ${projectName}`,
closedDueToInactivity: projectName => `(closed due to inactivity)`,
}
}
/***********************************************
* Option setters
**********************************************/
setCredentialsPath (credentialsPath) {
if (typeof credentialsPath === 'string' && credentialsPath !== '') {
this.credentialsPath = credentialsPath;
}
else {
throw new Error(`✗ Missing or incorrect value for required option 'credentialsPath'`);
}
}
setTokenPath (tokenPath) {
if (typeof tokenPath === 'string' && tokenPath !== '') {
this.tokenPath = tokenPath;
}
else {
throw new Error(`✗ Missing or incorrect value for required option 'tokenPath'`);
}
}
setCalendarSummary (calendarSummary) {
if (typeof calendarSummary === 'string' && calendarSummary !== '') {
this.calendarSummary = calendarSummary;
}
else {
throw new Error(`✗ Missing or incorrect value for required option 'calendar'`);
}
}
setMinutesUntilInactivity (minutes) {
if (typeof minutes === 'number' && minutes > 0) {
this.minutesUntilInactivity = minutes;
}
}
setStringsOverrides (stringsOverrides) {
if (stringsOverrides !== null && typeof stringsOverrides === 'object' && !Array.isArray(stringsOverrides)) {
this.strings = Object.assign(this.strings, stringsOverrides);
}
}
setShowLinks (showLinks) {
if (typeof showLinks === 'boolean') {
this.showLinks = showLinks;
}
}
/***********************************************
* Authorization & Google Calendar connection
**********************************************/
initCalendarConnection () {
this.calendarConnection = new Promise(async (resolve, reject) => {
try {
const auth = await googleAPIGetAuth(this.credentialsPath, this.tokenPath);
const googleCalendar = google.calendar({
version: 'v3',
auth
});
const calendar = await this.getOrCreateCalendar(googleCalendar);
this.calendarId = calendar.id;
resolve(googleCalendar);
}
catch (err) {
console.error(chalk.red(`✗ Could not establish connection with Google Calendar API.`));
reject(err);
}
});
}
async getOrCreateCalendar (googleCalendar) {
return new Promise((resolve, reject) => {
googleCalendar.calendarList.list({}, (err, response) => {
if (err) {
return reject(err);
}
const { calendarSummary } = this,
calendar = response.data.items.find(item => item.summary === calendarSummary);
// If cal exists, return it
if (calendar) {
console.log(chalk.green(`Found calendar “${calendarSummary}”.`)); // TODO: too verbose?
return resolve(calendar);
}
// If cal doesn't exist, create it
else {
console.log(chalk.blue(`Creating calendar “${calendarSummary}”.`));
const newCal = {
resource: {
summary: calendarSummary,
},
};
googleCalendar.calendars.insert(newCal, (err, response) => {
if (err) {
return reject(err);
}
console.log(chalk.green(`✔ Created calendar “${calendarSummary}”.`));
return resolve(response.data);
});
}
});
});
}
/***********************************************
* Helpers
**********************************************/
get msUntilInactivity () {
return this.minutesUntilInactivity * 1000 * 60;
}
getCurrentTime () {
return {
currentTime: new Date(),
timeZone: moment.tz.guess(),
};
}
/***********************************************
* Log methods
*
* These are the methods by people who
* install this module will actually use.
**********************************************/
/**
* Create a start event
*/
async logStart () {
const googleCalendar = await this.calendarConnection,
logName = this.getLogName(this.strings.activityStarted);
const {
timeZone,
currentTime: startTime,
} = this.getCurrentTime();
// Create starting event
const eventParams = {
calendarId: this.calendarId,
resource: {
summary: logName,
description: this.addToDescription(startTime, logName),
start: {
dateTime: startTime.toISOString(),
timeZone,
},
end: {
// Set initial duration of 1 second
dateTime: new Date(+startTime + 1000).toISOString(),
timeZone,
},
extendedProperties: {
private: {
completed: 'false',
},
},
},
};
return new Promise((resolve, reject) => {
googleCalendar.events.insert(eventParams, (err, response) => {
if (err) {
console.log(chalk.red(`✗ There was an error creating a start event.`));
return reject(err);
}
const linkOrDot = (this.showLinks === true) ? `: ${response.data.htmlLink}.` : '.';
console.log(chalk.green(`✔ Start event created${linkOrDot}`));
resolve();
});
});
}
/**
* Log activity
*/
async logActivity (activityDescription = this.strings.activityLogged) {
const googleCalendar = await this.calendarConnection,
logName = this.getLogName(this.strings.activityInProgress);
const {
currentTime: activityTime,
timeZone,
} = this.getCurrentTime();
// Get the latest incomplete work log event
const latestIncompleteLogEvent = await this.getLatestIncompleteLogEvent(activityTime, timeZone);
// Check for inactivity
const inactivityDetected = this.hasInactivity(latestIncompleteLogEvent, activityTime);
if (inactivityDetected) {
// End previous log first
await this.logEndDueToInactivity(latestIncompleteLogEvent, activityTime);
// Then start new, so we don't accidentally immediately end the newly started log
await this.logStart();
// Then log the activity after all
await this.logActivity(activityDescription);
}
else {
const patchParams = {
calendarId: this.calendarId,
eventId: latestIncompleteLogEvent.id,
resource: {
summary: logName,
description: this.addToDescription(activityTime, activityDescription, latestIncompleteLogEvent.description),
end: {
dateTime: activityTime.toISOString(),
timeZone,
},
extendedProperties: {
private: {
completed: 'false',
},
},
},
};
return new Promise((resolve, reject) => {
googleCalendar.events.patch(patchParams, (err, response) => {
if (err) {
console.log(chalk.red('✗ An error occured during updating the work log.'));
return reject(err);
}
const linkOrDot = (this.showLinks === true) ? `: ${response.data.htmlLink}.` : '.';
console.log(chalk.green(`✔ Work logged${linkOrDot}`));
resolve();
});
});
}
}
/**
* Conclude timelog.
*/
async logEnd () {
const googleCalendar = await this.calendarConnection,
logName = this.getLogName(this.strings.activityConcluded);;
const {
currentTime: endTime,
timeZone,
} = this.getCurrentTime();
const concludedLogName = this.getLogName(this.strings.activityConcluded);
// Get the latest incomplete work log event
const latestIncompleteLogEvent = await this.getLatestIncompleteLogEvent(endTime, timeZone);
// Check for inactivity
const inactivityDetected = this.hasInactivity(latestIncompleteLogEvent, endTime);
if (inactivityDetected) {
// End the previous log
await this.logEndDueToInactivity(latestIncompleteLogEvent, endTime);
}
else {
const patchParams = {
calendarId: this.calendarId,
eventId: latestIncompleteLogEvent.id,
resource: {
summary: logName,
description: this.addToDescription(endTime, concludedLogName, latestIncompleteLogEvent.description),
end: {
dateTime: endTime.toISOString(),
timeZone,
},
extendedProperties: {
private: {
completed: 'true',
},
},
}
};
return new Promise((resolve, reject) => {
googleCalendar.events.patch(patchParams, (err, response) => {
if (err) {
console.log(chalk.red('✗ An error occured during creating the completed work log.'));
return reject(err);
}
const linkOrDot = (this.showLinks === true) ? `: ${response.data.htmlLink}.` : '.';
console.log(chalk.green(`✔ Work logged${linkOrDot}`));
resolve();
});
});
}
}
async logEndDueToInactivity (latestIncompleteLogEvent, activityTime) {
console.log(chalk.blue(`Inactivity detected, ending previous log and creating a new one.`));
const googleCalendar = await this.calendarConnection,
concludedLogName = this.getLogName(this.strings.activityConcluded),
closedDueToInactivity = this.getLogName(this.strings.closedDueToInactivity);
const patchParams = {
calendarId: this.calendarId,
eventId: latestIncompleteLogEvent.id,
resource: {
summary: concludedLogName,
description: this.addToDescription(activityTime, `${concludedLogName} ${closedDueToInactivity}`, latestIncompleteLogEvent.description),
extendedProperties: {
private: {
completed: 'true',
},
},
},
};
return new Promise((resolve, reject) => {
googleCalendar.events.patch(patchParams, (err, response) => {
if (err) {
console.log(chalk.red('✗ An error occured during creating the completed work log.'));
return reject(err);
}
const linkOrDot = (this.showLinks === true) ? `: ${response.data.htmlLink}.` : '.';
console.log(chalk.green(`✔ Work logged${linkOrDot}`));
resolve();
});
});
}
async getLatestIncompleteLogEvent (currentTime, timeZone) {
const googleCalendar = await this.calendarConnection;
const listParams = {
calendarId: this.calendarId,
// Google Calendar API doesn't support listing in descending order,
// we have to specify timeMin and timeMax instead and reverse order later.
// Assuming you didn't start working more than 1 week ago, this should work:
timeMin: new Date(+currentTime - 1000 * 60 * 60 * 24 * 7).toISOString(),
timeMax: currentTime.toISOString(),
timeZone,
singleEvents: true,
orderBy: 'startTime',
};
// Get the latest incomplete work log
return new Promise((resolve, reject) => {
googleCalendar.events.list(listParams, (err, response) => {
if (err) {
console.log(chalk.red('✗ An error occured during listing events.'));
return reject(err);
}
const events = response.data.items;
if (events.length) {
// Find the latest incomplete log
const latestIncompleteLogEvent = events.reverse().find((event) => {
try {
const { completed } = event.extendedProperties.private;
return completed === 'false';
} catch (err) {}
});
if (latestIncompleteLogEvent) {
resolve(latestIncompleteLogEvent);
}
else {
console.log(chalk.red(`✗ Couldn't create log.`));
reject(new Error(`No latest incomplete event found in the past 7 days.`));
}
}
else {
console.log(chalk.red(`✗ Couldn't create log.`));
reject(new Error(`Found no events in the past 7 days.`));
}
});
});
}
addToDescription (date, activity, description = '') {
// If not empty description, add newline
if (description !== '') description += `\n`;
const hh = `0${date.getHours()}`.slice(-2),
mm = `0${date.getMinutes()}`.slice(-2);
// Add the new activity
description += `${hh}:${mm} – ${activity}`;
return description;
}
getLogName (logName) {
return (typeof logName === 'function') ? logName(this.calendarSummary) : logName;
}
hasInactivity (latestIncompleteLogEvent, currentTime) {
// Check for inactivity
const lastActivity = +new Date(latestIncompleteLogEvent.updated || latestIncompleteLogEvent.created);
return (+currentTime - lastActivity) > this.msUntilInactivity;
}
}