UNPKG

gcal-sync

Version:

๐Ÿ”„ add an one way synchronization from github commits to google calendar and track your progress effortlessly.

980 lines (964 loc) โ€ข 63 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : typeof define === 'function' && define.amd ? define(factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.GcalSync = factory()); })(this, (function () { 'use strict'; const APP_INFO = { name: 'gcal-sync', github_repository: 'lucasvtiradentes/gcal-sync', version: '2.1.2'}; const asConstArrayToObject = (array, keyField, valueField) => { return array.reduce((acc, item) => { const key = item[keyField]; const value = item[valueField]; acc[key] = value; return acc; }, {}); }; var _a; const CONFIGS = { REQUIRED_GITHUB_VALIDATIONS_COUNT: 3, BATCH_SIZE: 15, BATCH_DELAY_MS: 2000, GITHUB_MAX_PAGES_PER_RANGE: 10, GITHUB_MONTHS_TO_FETCH: 6, GITHUB_DELAY_BETWEEN_PAGES_MS: 2100, GITHUB_DELAY_BETWEEN_RANGES_MS: 1000, IS_TEST_ENVIRONMENT: typeof process !== 'object' ? false : (_a = process === null || process === void 0 ? void 0 : process.env) === null || _a === void 0 ? void 0 : _a.NODE_ENV }; const GAS_PROPERTIES = [ { key: 'today_github_added_commits', initial_value: [] }, { key: 'today_github_deleted_commits', initial_value: [] }, { key: 'last_released_version_alerted', initial_value: '' }, { key: 'last_released_version_sent_date', initial_value: '' }, { key: 'last_daily_email_sent_date', initial_value: '' }, { key: 'github_commits_tracked_to_be_added_hash', initial_value: '' }, { key: 'github_commits_tracked_to_be_deleted_hash', initial_value: '' }, { key: 'github_commit_changes_count', initial_value: '' } ]; const GAS_PROPERTIES_INITIAL_VALUE_ENUM = asConstArrayToObject(GAS_PROPERTIES, 'key', 'initial_value'); const GAS_PROPERTIES_ENUM = asConstArrayToObject(GAS_PROPERTIES, 'key', 'key'); const ERRORS = { invalid_configs: 'schema invalid', production_only: 'This method cannot run in non-production environments'}; const githubConfigsKey = 'github_sync'; function getSessionEmail(sendToEmail, sessionStats) { const content = generateReportEmailContent(sessionStats); const emailObj = { to: sendToEmail, name: `${APP_INFO.name}`, subject: `session report - ${getTotalSessionEvents(sessionStats)} modifications - ${APP_INFO.name}`, htmlBody: content }; return emailObj; } function getDailySummaryEmail(sendToEmail, todaySession, todayDate) { const content = generateReportEmailContent(todaySession); const emailObj = { to: sendToEmail, name: `${APP_INFO.name}`, subject: `daily report for ${todayDate} - ${getTotalSessionEvents(todaySession)} modifications - ${APP_INFO.name}`, htmlBody: content }; return emailObj; } function getNewReleaseEmail(sendToEmail, lastReleaseObj) { const message = `Hi! <br/><br/> a new <a href="https://github.com/${APP_INFO.github_repository}">${APP_INFO.name}</a> version is available: <br/> <ul> <li>new version: ${lastReleaseObj.tag_name}</li> <li>published at: ${lastReleaseObj.published_at}</li> <li>details: <a href="https://github.com/${APP_INFO.github_repository}/releases">here</a></li> </ul> to update, replace the old version number in your apps scripts <a href="https://script.google.com/">gcal sync project</a> to the new version: ${lastReleaseObj.tag_name.replace('v', '')}<br/> and also check if you need to change the setup code in the <a href='https://github.com/${APP_INFO.github_repository}#installation'>installation section</a>. <br /><br /> Regards, your <a href='https://github.com/${APP_INFO.github_repository}'>${APP_INFO.name}</a> bot `; const emailObj = { to: sendToEmail, name: `${APP_INFO.name}`, subject: `new version [${lastReleaseObj.tag_name}] was released - ${APP_INFO.name}`, htmlBody: message }; return emailObj; } function getErrorEmail(sendToEmail, errorMessage) { const content = `Hi! <br/><br/> an error recently occurred: <br/><br/> <b>${errorMessage}</b> <br /><br /> Regards, your <a href='https://github.com/${APP_INFO.github_repository}'>${APP_INFO.name}</a> bot `; const emailObj = { to: sendToEmail, name: `${APP_INFO.name}`, subject: `an error occurred - ${APP_INFO.name}`, htmlBody: content }; return emailObj; } // ============================================================================= const TABLE_STYLES = { tableStyle: `style="border: 1px solid #333; width: 90%"`, tableRowStyle: `style="width: 100%"`, tableRowColumnStyle: `style="border: 1px solid #333"` }; const getParsedDateTime = (str) => ('date' in str ? str.date : str.dateTime); function getTotalSessionEvents(session) { const todayEventsCount = session.commits_added.length + session.commits_deleted.length; return todayEventsCount; } function getGithubEmailContant(session) { const addedGithubCommits = session.commits_added; const removedGithubCommits = session.commits_deleted; const getGithubBodyItemsHtml = (items) => { if (items.length === 0) return ''; // prettier-ignore const tableItems = items.map((gcalItem) => { const { repositoryLink, commitMessage, repositoryName } = gcalItem.extendedProperties.private; const parsedDate = getParsedDateTime(gcalItem.start).split('T')[0]; const itemHtmlRow = [parsedDate, `<a href="${repositoryLink}">${repositoryName}</a>`, `<a href="${gcalItem.htmlLink}">${commitMessage}</a>`].map(it => `<td ${TABLE_STYLES.tableRowColumnStyle}>&nbsp;&nbsp;${it}</td>`).join('\n'); return `<tr ${TABLE_STYLES.tableRowStyle}">\n${itemHtmlRow}\n</tr>`; }).join('\n'); return `${tableItems}`; }; const githubTableHeader = `<tr ${TABLE_STYLES.tableRowStyle}">\n<th ${TABLE_STYLES.tableRowColumnStyle} width="80px">date</th><th ${TABLE_STYLES.tableRowColumnStyle} width="130px">repository</th><th ${TABLE_STYLES.tableRowColumnStyle} width="auto">commit</th>\n</tr>`; let content = ''; content += addedGithubCommits.length > 0 ? `<br/>added commits events : ${addedGithubCommits.length}<br/><br/> \n <center>\n<table ${TABLE_STYLES.tableStyle}>\n${githubTableHeader}\n${getGithubBodyItemsHtml(addedGithubCommits)}\n</table>\n</center>\n` : ''; content += removedGithubCommits.length > 0 ? `<br/>removed commits events : ${removedGithubCommits.length}<br/><br/> \n <center>\n<table ${TABLE_STYLES.tableStyle}>\n${githubTableHeader}\n${getGithubBodyItemsHtml(removedGithubCommits)}\n</table>\n</center>\n` : ''; return content; } function generateReportEmailContent(session) { const todayEventsCount = getTotalSessionEvents(session); let content = ''; content = `Hi!<br/><br/>there were ${todayEventsCount} changes made to your google calendar:<br/>\n`; content += getGithubEmailContant(session); content += `<br/>Regards,<br/>your <a href='https://github.com/${APP_INFO.github_repository}'>${APP_INFO.name}</a> bot`; return content; } function checkIfShouldSync(extendedConfigs) { const shouldSyncGithub = extendedConfigs.configs[githubConfigsKey].commits_configs.should_sync; return { shouldSyncGithub }; } // GENERAL ===================================================================== function isRunningOnGAS() { return typeof Calendar !== 'undefined'; } // PROPERTIES ================================================================== function listAllGASProperties() { const allProperties = PropertiesService.getScriptProperties().getProperties(); return allProperties; } function getGASProperty(property) { const value = PropertiesService.getScriptProperties().getProperty(property); let parsedValue; try { parsedValue = JSON.parse(value); } catch (_a) { parsedValue = value; } return parsedValue; } function updateGASProperty(property, value) { const parsedValue = typeof value === 'string' ? value : JSON.stringify(value); const sizeInBytes = parsedValue.length; console.log(`updating property "${property}" with size: ${sizeInBytes} chars`); try { PropertiesService.getScriptProperties().setProperty(property, parsedValue); } catch (e) { console.log(`error updating property "${property}": ${e}`); throw e; } } function deleteGASProperty(property) { PropertiesService.getScriptProperties().deleteProperty(property); } // TRIGGERS ==================================================================== function getAppsScriptsTriggers() { return ScriptApp.getProjectTriggers(); } function addAppsScriptsTrigger(functionName, minutesLoop) { ScriptApp.newTrigger(functionName).timeBased().everyMinutes(minutesLoop).create(); } function removeAppsScriptsTrigger(functionName) { const allAppsScriptTriggers = getAppsScriptsTriggers(); const gcalSyncTrigger = allAppsScriptTriggers.find((item) => item.getHandlerFunction() === functionName); if (gcalSyncTrigger) { ScriptApp.deleteTrigger(gcalSyncTrigger); } } function getUserEmail() { return Session.getActiveUser().getEmail(); } function sendEmail(emailObj) { MailApp.sendEmail(emailObj); } class Logger { constructor() { this.logs = []; } info(message, ...optionalParams) { if (!CONFIGS.IS_TEST_ENVIRONMENT) { console.log(message, ...optionalParams); this.logs.push(message); } } error(message, ...optionalParams) { if (!CONFIGS.IS_TEST_ENVIRONMENT) { console.error(message, ...optionalParams); this.logs.push(message); } } } const logger = new Logger(); function getDateFixedByTimezone(timeZoneIndex) { const date = new Date(); date.setHours(date.getHours() + timeZoneIndex); return date; } function isCurrentTimeAfter(timeToCompare, timezone) { const dateFixedByTimezone = getDateFixedByTimezone(timezone); const curStamp = Number(dateFixedByTimezone.getHours()) * 60 + Number(dateFixedByTimezone.getMinutes()); const timeArr = timeToCompare.split(':'); const specifiedStamp = Number(timeArr[0]) * 60 + Number(timeArr[1]); return curStamp >= specifiedStamp; } function getCurrentDateInSpecifiedTimezone(timeZone) { const date = new Date(); const formatter = new Intl.DateTimeFormat('en-CA', { timeZone: timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); const parts = formatter.formatToParts(date); const findPart = (type) => parts.find((part) => part.type === type).value; const isoDate = `${findPart('year')}-${findPart('month')}-${findPart('day')}T${findPart('hour')}:${findPart('minute')}:${findPart('second')}.000`; return isoDate; } function getTimezoneOffset(timezone) { const date = new Date(); const utcDate = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours(), date.getMinutes(), date.getSeconds())); const tzDate = new Date(date.toLocaleString('en-US', { timeZone: timezone })); const offset = (Number(tzDate) - Number(utcDate)) / (1000 * 60 * 60); return offset; } function toMinimalCommit(commit) { return { htmlLink: commit.htmlLink, start: commit.start, extendedProperties: { private: { repositoryLink: commit.extendedProperties.private.repositoryLink, repositoryName: commit.extendedProperties.private.repositoryName, commitMessage: commit.extendedProperties.private.commitMessage } } }; } function getTodayStats() { const todayStats = { commits_added: getGASProperty(GAS_PROPERTIES_ENUM.today_github_added_commits), commits_deleted: getGASProperty(GAS_PROPERTIES_ENUM.today_github_deleted_commits) }; return todayStats; } function clearTodayEvents() { updateGASProperty(GAS_PROPERTIES_ENUM.today_github_added_commits, []); updateGASProperty(GAS_PROPERTIES_ENUM.today_github_deleted_commits, []); logger.info(`today stats were reseted!`); } function handleSessionData(extendedConfigs, sessionData) { var _a, _b; const { shouldSyncGithub } = checkIfShouldSync(extendedConfigs); const githubNewItems = sessionData.commits_added.length + sessionData.commits_deleted.length; logger.info(`[DEBUG][SESSION] shouldSyncGithub: ${shouldSyncGithub}, githubNewItems: ${githubNewItems}`); if (shouldSyncGithub && githubNewItems > 0) { const todayAddedCommits = (_a = getGASProperty(GAS_PROPERTIES_ENUM.today_github_added_commits)) !== null && _a !== void 0 ? _a : []; const todayDeletedCommits = (_b = getGASProperty(GAS_PROPERTIES_ENUM.today_github_deleted_commits)) !== null && _b !== void 0 ? _b : []; logger.info(`[DEBUG][SESSION] current todayAddedCommits: ${todayAddedCommits.length}, todayDeletedCommits: ${todayDeletedCommits.length}`); const minimalAdded = sessionData.commits_added.map(toMinimalCommit); const minimalDeleted = sessionData.commits_deleted.map(toMinimalCommit); logger.info(`[DEBUG][SESSION] adding ${minimalAdded.length} commits, deleting ${minimalDeleted.length} commits`); const newAddedCommits = [...todayAddedCommits, ...minimalAdded]; const newDeletedCommits = [...todayDeletedCommits, ...minimalDeleted]; const addedSize = JSON.stringify(newAddedCommits).length; const deletedSize = JSON.stringify(newDeletedCommits).length; logger.info(`[DEBUG][SESSION] new added commits size: ${addedSize} chars, deleted size: ${deletedSize} chars`); const MAX_PROPERTY_SIZE = 450000; if (addedSize > MAX_PROPERTY_SIZE) { logger.info(`[WARN][SESSION] added commits size (${addedSize}) exceeds limit (${MAX_PROPERTY_SIZE}), keeping only recent ${Math.min(100, newAddedCommits.length)} commits`); const recentAdded = newAddedCommits.slice(-100); try { updateGASProperty(GAS_PROPERTIES_ENUM.today_github_added_commits, recentAdded); } catch (e) { logger.info(`[ERROR][SESSION] failed to store added commits even after truncation: ${e}`); updateGASProperty(GAS_PROPERTIES_ENUM.today_github_added_commits, []); } } else { try { updateGASProperty(GAS_PROPERTIES_ENUM.today_github_added_commits, newAddedCommits); } catch (e) { logger.info(`[ERROR][SESSION] failed to store added commits: ${e}`); } } if (deletedSize > MAX_PROPERTY_SIZE) { logger.info(`[WARN][SESSION] deleted commits size (${deletedSize}) exceeds limit (${MAX_PROPERTY_SIZE}), keeping only recent ${Math.min(100, newDeletedCommits.length)} commits`); const recentDeleted = newDeletedCommits.slice(-100); try { updateGASProperty(GAS_PROPERTIES_ENUM.today_github_deleted_commits, recentDeleted); } catch (e) { logger.info(`[ERROR][SESSION] failed to store deleted commits even after truncation: ${e}`); updateGASProperty(GAS_PROPERTIES_ENUM.today_github_deleted_commits, []); } } else { try { updateGASProperty(GAS_PROPERTIES_ENUM.today_github_deleted_commits, newDeletedCommits); } catch (e) { logger.info(`[ERROR][SESSION] failed to store deleted commits: ${e}`); } } logger.info(`added ${githubNewItems} new github items to today's stats`); } // ========================================================================= const totalSessionNewItems = githubNewItems; sendSessionEmails(extendedConfigs, sessionData, totalSessionNewItems); // ========================================================================= const { commits_added, commits_deleted, commits_tracked_to_be_added, commits_tracked_to_be_deleted } = sessionData; return { commits_added: commits_added.length, commits_deleted: commits_deleted.length, commits_tracked_to_be_added: commits_tracked_to_be_added.length, commits_tracked_to_be_deleted: commits_tracked_to_be_deleted.length }; } function sendSessionEmails(extendedConfigs, sessionData, totalSessionNewItems) { var _a; const userEmail = extendedConfigs.user_email; if (extendedConfigs.configs.settings.per_sync_emails.email_session && totalSessionNewItems > 0) { const sessionEmail = getSessionEmail(userEmail, sessionData); sendEmail(sessionEmail); } const isNowTimeAfterDailyEmails = isCurrentTimeAfter(extendedConfigs.configs.settings.per_day_emails.time_to_send, extendedConfigs.timezone_offset); const alreadySentTodaySummaryEmail = extendedConfigs.today_date === getGASProperty(GAS_PROPERTIES_ENUM.last_daily_email_sent_date); if (isNowTimeAfterDailyEmails && extendedConfigs.configs.settings.per_day_emails.email_daily_summary && !alreadySentTodaySummaryEmail) { updateGASProperty(GAS_PROPERTIES_ENUM.last_daily_email_sent_date, extendedConfigs.today_date); const dailySummaryEmail = getDailySummaryEmail(userEmail, getTodayStats(), extendedConfigs.today_date); sendEmail(dailySummaryEmail); clearTodayEvents(); } const alreadySentTodayNewReleaseEmail = extendedConfigs.today_date === getGASProperty(GAS_PROPERTIES_ENUM.last_released_version_sent_date); const parseGcalVersion = (v) => { return Number(v.replace('v', '').split('.').join('')); }; const getLatestGcalSyncRelease = () => { var _a; const json_encoded = UrlFetchApp.fetch(`https://api.github.com/repos/${APP_INFO.github_repository}/releases?per_page=1`); const lastReleaseObj = (_a = JSON.parse(json_encoded.getContentText())[0]) !== null && _a !== void 0 ? _a : { tag_name: APP_INFO.version }; return lastReleaseObj; }; if (isNowTimeAfterDailyEmails && extendedConfigs.configs.settings.per_day_emails.email_new_gcal_sync_release && !alreadySentTodayNewReleaseEmail) { updateGASProperty(GAS_PROPERTIES_ENUM.last_released_version_sent_date, extendedConfigs.today_date); const latestRelease = getLatestGcalSyncRelease(); const latestVersion = parseGcalVersion(latestRelease.tag_name); const currentVersion = parseGcalVersion(APP_INFO.version); const lastAlertedVersion = (_a = getGASProperty(GAS_PROPERTIES_ENUM.last_released_version_alerted)) !== null && _a !== void 0 ? _a : ''; if (latestVersion > currentVersion && latestVersion.toString() != lastAlertedVersion) { const newReleaseEmail = getNewReleaseEmail(userEmail, latestRelease); sendEmail(newReleaseEmail); updateGASProperty(GAS_PROPERTIES_ENUM.last_released_version_alerted, latestVersion.toString()); } } } function getDateRanges(monthsBack = CONFIGS.GITHUB_MONTHS_TO_FETCH) { const ranges = []; const now = new Date(); for (let i = 0; i < monthsBack; i++) { const end = new Date(now.getFullYear(), now.getMonth() - i + 1, 1); const start = new Date(now.getFullYear(), now.getMonth() - i, 1); ranges.push({ start: start.toISOString().split('T')[0], end: end.toISOString().split('T')[0] }); } return ranges; } function getGithubDateRange() { const now = new Date(); const endDate = new Date(now.getFullYear(), now.getMonth() + 1, 1); const startDate = new Date(now.getFullYear(), now.getMonth() - CONFIGS.GITHUB_MONTHS_TO_FETCH, 1); return { startDate: startDate.toISOString().split('T')[0], endDate: endDate.toISOString().split('T')[0] }; } function fetchCommitsForDateRange(username, personalToken, startDate, endDate) { var _a, _b, _c; const commits = []; let pageNumber = 1; while (pageNumber <= CONFIGS.GITHUB_MAX_PAGES_PER_RANGE) { const query = `author:${username}+committer-date:${startDate}..${endDate}`; const url = `https://api.github.com/search/commits?q=${query}&page=${pageNumber}&sort=committer-date&per_page=100`; const options = { muteHttpExceptions: true, headers: personalToken ? { Authorization: `Bearer ${personalToken}` } : {} }; let response; try { response = UrlFetchApp.fetch(url, options); } catch (e) { console.log(`network error during ${startDate}..${endDate} page ${pageNumber}, returning partial results`); break; } const data = (_a = JSON.parse(response.getContentText())) !== null && _a !== void 0 ? _a : {}; if (response.getResponseCode() !== 200) { if (response.getResponseCode() === 403 && ((_b = data.message) === null || _b === void 0 ? void 0 : _b.includes('rate limit'))) { console.log(`GitHub rate limit hit during ${startDate}..${endDate}`); break; } break; } const items = (_c = data.items) !== null && _c !== void 0 ? _c : []; if (items.length === 0) break; commits.push(...items); if (items.length < 100) break; Utilities.sleep(CONFIGS.GITHUB_DELAY_BETWEEN_PAGES_MS); pageNumber++; } return commits; } function getAllGithubCommits(username, personalToken) { const allCommitsArr = []; const dateRanges = getDateRanges(); console.log(`fetching commits for ${dateRanges.length} date ranges (${CONFIGS.GITHUB_MONTHS_TO_FETCH} months)`); for (const range of dateRanges) { const commits = fetchCommitsForDateRange(username, personalToken, range.start, range.end); if (commits.length > 0) { allCommitsArr.push(...commits); console.log(`${range.start}..${range.end}: ${commits.length} commits (total: ${allCommitsArr.length})`); } Utilities.sleep(CONFIGS.GITHUB_DELAY_BETWEEN_RANGES_MS); } const parsedCommits = allCommitsArr.map((it) => { const commitObj = { commitDate: it.commit.author.date, commitMessage: it.commit.message.split('\n')[0], commitId: it.html_url.split('commit/')[1], commitUrl: it.html_url, repository: it.repository.full_name, repositoryLink: `https://github.com/${it.repository.full_name}`, repositoryId: it.repository.id, repositoryName: it.repository.name, repositoryOwner: it.repository.owner.login, repositoryDescription: it.repository.description, isRepositoryPrivate: it.repository.private, isRepositoryFork: it.repository.fork }; return commitObj; }); return parsedCommits; } function parseGithubEmojisString(str) { const gitmojiObj = { ':art:': '๐ŸŽจ', ':zap:': 'โšก๏ธ', ':fire:': '๐Ÿ”ฅ', ':bug:': '๐Ÿ›', ':ambulance:': '๐Ÿš‘๏ธ', ':sparkles:': 'โœจ', ':memo:': '๐Ÿ“', ':rocket:': '๐Ÿš€', ':lipstick:': '๐Ÿ’„', ':tada:': '๐ŸŽ‰', ':white_check_mark:': 'โœ…', ':lock:': '๐Ÿ”’๏ธ', ':closed_lock_with_key:': '๐Ÿ”', ':bookmark:': '๐Ÿ”–', ':rotating_light:': '๐Ÿšจ', ':construction:': '๐Ÿšง', ':green_heart:': '๐Ÿ’š', ':arrow_down:': 'โฌ‡๏ธ', ':arrow_up:': 'โฌ†๏ธ', ':pushpin:': '๐Ÿ“Œ', ':construction_worker:': '๐Ÿ‘ท', ':chart_with_upwards_trend:': '๐Ÿ“ˆ', ':recycle:': 'โ™ป๏ธ', ':heavy_plus_sign:': 'โž•', ':heavy_minus_sign:': 'โž–', ':wrench:': '๐Ÿ”ง', ':hammer:': '๐Ÿ”จ', ':globe_with_meridians:': '๐ŸŒ', ':pencil2:': 'โœ๏ธ', ':poop:': '๐Ÿ’ฉ', ':rewind:': 'โช๏ธ', ':twisted_rightwards_arrows:': '๐Ÿ”€', ':package:': '๐Ÿ“ฆ๏ธ', ':alien:': '๐Ÿ‘ฝ๏ธ', ':truck:': '๐Ÿšš', ':page_facing_up:': '๐Ÿ“„', ':boom:': '๐Ÿ’ฅ', ':bento:': '๐Ÿฑ', ':wheelchair:': 'โ™ฟ๏ธ', ':bulb:': '๐Ÿ’ก', ':beers:': '๐Ÿป', ':speech_balloon:': '๐Ÿ’ฌ', ':card_file_box:': '๐Ÿ—ƒ๏ธ', ':loud_sound:': '๐Ÿ”Š', ':mute:': '๐Ÿ”‡', ':busts_in_silhouette:': '๐Ÿ‘ฅ', ':children_crossing:': '๐Ÿšธ', ':building_construction:': '๐Ÿ—๏ธ', ':iphone:': '๐Ÿ“ฑ', ':clown_face:': '๐Ÿคก', ':egg:': '๐Ÿฅš', ':see_no_evil:': '๐Ÿ™ˆ', ':camera_flash:': '๐Ÿ“ธ', ':alembic:': 'โš—๏ธ', ':mag:': '๐Ÿ”๏ธ', ':label:': '๐Ÿท๏ธ', ':seedling:': '๐ŸŒฑ', ':triangular_flag_on_post:': '๐Ÿšฉ', ':goal_net:': '๐Ÿฅ…', ':dizzy:': '๐Ÿ’ซ', ':wastebasket:': '๐Ÿ—‘๏ธ', ':passport_control:': '๐Ÿ›‚', ':adhesive_bandage:': '๐Ÿฉน', ':monocle_face:': '๐Ÿง', ':coffin:': 'โšฐ๏ธ', ':test_tube:': '๐Ÿงช', ':necktie:': '๐Ÿ‘”', ':stethoscope:': '๐Ÿฉบ', ':bricks:': '๐Ÿงฑ', ':technologist:': '๐Ÿง‘โ€๐Ÿ’ป', ':money_with_wings:': '๐Ÿ’ธ', ':thread:': '๐Ÿงต', ':safety_vest:': '๐Ÿฆบ' }; let curString = str; for (const [key, value] of Object.entries(gitmojiObj)) { curString = curString.replace(key, value); } return curString; } // ============================================================================= const getCurrentTimezoneFromGoogleCalendar = () => { return CalendarApp.getDefaultCalendar().getTimeZone(); }; const createMissingCalendars = (allGcalendarsNames) => { let createdCalendar = false; logger.info(`checking calendars to create: ${JSON.stringify(allGcalendarsNames)}`); allGcalendarsNames.forEach((calName) => { const exists = checkIfCalendarExists(calName); logger.info(`calendar "${calName}" exists: ${!!exists}`); if (!exists) { createCalendar(calName); logger.info(`created google calendar: [${calName}]`); createdCalendar = true; } }); if (createdCalendar) { Utilities.sleep(2000); } }; const getAllCalendars = () => { var _a; const calendars = (_a = Calendar.CalendarList.list({ showHidden: true }).items) !== null && _a !== void 0 ? _a : []; return calendars; }; const checkIfCalendarExists = (calendarName) => { const allCalendars = getAllCalendars(); const calendar = allCalendars.find((cal) => cal.summary === calendarName); return calendar; }; const createCalendar = (calName) => { const calendarObj = Calendar; const owenedCalendars = calendarObj.CalendarList.list({ showHidden: true }).items.filter((cal) => cal.accessRole === 'owner'); const doesCalendarExists = owenedCalendars.map((cal) => cal.summary).includes(calName); if (doesCalendarExists) { throw new Error(`calendar ${calName} already exists!`); } const tmpCalendar = calendarObj.newCalendar(); tmpCalendar.summary = calName; tmpCalendar.timeZone = calendarObj.Settings.get('timezone').value; const calendar = calendarObj.Calendars.insert(tmpCalendar); return calendar; }; function getCalendarByName(calName) { const calendar = getAllCalendars().find((cal) => cal.summary === calName); return calendar; } function parseGoogleEvent(ev) { var _a, _b, _c, _d, _e; const parsedGoogleEvent = { id: ev.id, summary: ev.summary, description: (_a = ev.description) !== null && _a !== void 0 ? _a : '', htmlLink: ev.htmlLink, attendees: (_b = ev.attendees) !== null && _b !== void 0 ? _b : [], reminders: (_c = ev.reminders) !== null && _c !== void 0 ? _c : {}, visibility: (_d = ev.visibility) !== null && _d !== void 0 ? _d : 'default', start: ev.start, end: ev.end, created: ev.created, updated: ev.updated, colorId: ev.colorId, extendedProperties: ((_e = ev.extendedProperties) !== null && _e !== void 0 ? _e : {}) }; return parsedGoogleEvent; } function getEventsFromCalendarWithDateRange(calendar, startDate, endDate) { var _a; logger.info(`[DEBUG][GCAL] fetching events from ${calendar.id} between ${startDate} and ${endDate}`); const allEvents = []; let pageToken = undefined; let pageCount = 0; do { const response = Calendar.Events.list(calendar.id, { maxResults: 2500, timeMin: new Date(startDate).toISOString(), timeMax: new Date(endDate).toISOString(), singleEvents: true, orderBy: 'startTime', pageToken: pageToken }); const items = (_a = response.items) !== null && _a !== void 0 ? _a : []; allEvents.push(...items); pageToken = response.nextPageToken; pageCount++; logger.info(`[DEBUG][GCAL] page ${pageCount}: fetched ${items.length} events (total: ${allEvents.length})`); } while (pageToken); logger.info(`[DEBUG][GCAL] fetched ${allEvents.length} total events from calendar in ${pageCount} pages`); const parsedEventsArr = allEvents.map((ev) => parseGoogleEvent(ev)); return parsedEventsArr; } function getTasksFromGoogleCalendarsWithDateRange(allCalendars, startDate, endDate) { logger.info(`[DEBUG][GCAL] getTasksFromGoogleCalendarsWithDateRange called for ${allCalendars.length} calendars`); const tasks = allCalendars.reduce((acc, cur) => { const taskCalendar = cur; const calendar = getCalendarByName(taskCalendar); if (!calendar) { logger.info(`[DEBUG][GCAL] calendar "${taskCalendar}" not found`); return acc; } const tasksArray = getEventsFromCalendarWithDateRange(calendar, startDate, endDate); return [...acc, ...tasksArray]; }, []); logger.info(`[DEBUG][GCAL] total tasks from all calendars: ${tasks.length}`); return tasks; } function addEventsToCalendarBatch(calendar, events) { if (events.length === 0) return []; const token = ScriptApp.getOAuthToken(); const baseUrl = `https://www.googleapis.com/calendar/v3/calendars/${encodeURIComponent(calendar.id)}/events`; const requests = events.map((event) => ({ url: baseUrl, method: 'post', contentType: 'application/json', headers: { Authorization: `Bearer ${token}` }, payload: JSON.stringify(event), muteHttpExceptions: true })); const responses = UrlFetchApp.fetchAll(requests); const results = responses.map((response, index) => { if (response.getResponseCode() === 200) { return JSON.parse(response.getContentText()); } else { logger.info(`failed to add event ${index}: ${response.getContentText()}`); return null; } }); return results.filter((r) => r !== null); } function removeCalendarEvent(calendar, event) { Calendar.Events.remove(calendar.id, event.id); } function computeHash(ids) { const sorted = [...ids].sort().join(','); const digest = Utilities.computeDigest(Utilities.DigestAlgorithm.MD5, sorted); return digest.map((b) => (b < 0 ? b + 256 : b).toString(16).padStart(2, '0')).join(''); } function resetGithubSyncProperties() { updateGASProperty('github_commit_changes_count', '0'); updateGASProperty('github_commits_tracked_to_be_added_hash', ''); updateGASProperty('github_commits_tracked_to_be_deleted_hash', ''); } function getFilterGithubRepos(configs, commits) { const commitsSortedByDate = commits.sort((a, b) => Number(new Date(b.commitDate)) - Number(new Date(a.commitDate))); const onlyCommitsOnUserRepositories = commitsSortedByDate.filter((item) => item.repository.includes(configs[githubConfigsKey].username)); const filteredRepos = onlyCommitsOnUserRepositories.filter((item) => configs[githubConfigsKey].commits_configs.ignored_repos.includes(item.repositoryName) === false); return filteredRepos; } function syncGithub(configs) { var _a; logger.info(`syncing github commits`); const dateRange = getGithubDateRange(); logger.info(`[DEBUG] github date range: ${dateRange.startDate} to ${dateRange.endDate}`); const githubCommits = getAllGithubCommits(configs[githubConfigsKey].username, configs[githubConfigsKey].personal_token); logger.info(`[DEBUG] fetched ${githubCommits.length} total commits from github`); const githubGcalCommits = getTasksFromGoogleCalendarsWithDateRange([configs[githubConfigsKey].commits_configs.commits_calendar], dateRange.startDate, dateRange.endDate); logger.info(`[DEBUG] fetched ${githubGcalCommits.length} events from gcal within date range`); const info = { githubCommits, githubGcalCommits }; const oldGithubSyncIndex = getGASProperty('github_commit_changes_count'); const currentGithubSyncIndex = Number(oldGithubSyncIndex) + 1; logger.info(`[DEBUG] sync index: ${oldGithubSyncIndex} -> ${currentGithubSyncIndex}`); if (oldGithubSyncIndex === null) { logger.info(`[DEBUG] oldGithubSyncIndex is null, resetting properties`); resetGithubSyncProperties(); } updateGASProperty('github_commit_changes_count', currentGithubSyncIndex.toString()); if (currentGithubSyncIndex === 1) { logger.info(`checking commit changes: ${currentGithubSyncIndex}/${CONFIGS.REQUIRED_GITHUB_VALIDATIONS_COUNT}`); } else if (currentGithubSyncIndex > 1 && currentGithubSyncIndex < CONFIGS.REQUIRED_GITHUB_VALIDATIONS_COUNT) { logger.info(`confirming commit changes: ${currentGithubSyncIndex}/${CONFIGS.REQUIRED_GITHUB_VALIDATIONS_COUNT}`); } else if (currentGithubSyncIndex === CONFIGS.REQUIRED_GITHUB_VALIDATIONS_COUNT) { logger.info(`making commit changes if succeed: ${currentGithubSyncIndex}/${CONFIGS.REQUIRED_GITHUB_VALIDATIONS_COUNT}`); } const filteredRepos = getFilterGithubRepos(configs, info.githubCommits); logger.info(`found ${filteredRepos.length} commits after filtering`); logger.info(`[DEBUG] filtering removed ${info.githubCommits.length - filteredRepos.length} commits`); const githubCalendar = getCalendarByName(configs[githubConfigsKey].commits_configs.commits_calendar); logger.info(`github calendar "${configs[githubConfigsKey].commits_configs.commits_calendar}" found: ${!!githubCalendar}, id: ${(_a = githubCalendar === null || githubCalendar === void 0 ? void 0 : githubCalendar.id) !== null && _a !== void 0 ? _a : 'N/A'}`); const result = Object.assign(Object.assign({}, syncGithubCommitsToAdd({ currentGithubSyncIndex, githubCalendar, githubGcalCommits: info.githubGcalCommits, filteredRepos: filteredRepos, parseCommitEmojis: configs[githubConfigsKey].commits_configs.parse_commit_emojis })), syncGithubCommitsToDelete({ currentGithubSyncIndex, githubCalendar, githubGcalCommits: info.githubGcalCommits, filteredRepos: filteredRepos })); if (result.commits_tracked_to_be_added.length === 0 && result.commits_tracked_to_be_deleted.length === 0) { logger.info(`reset github commit properties due found no commits tracked`); resetGithubSyncProperties(); } return result; } function syncGithubCommitsToAdd({ filteredRepos, currentGithubSyncIndex, githubCalendar, githubGcalCommits, parseCommitEmojis }) { var _a, _b, _c, _d; logger.info(`[DEBUG][ADD] starting syncGithubCommitsToAdd`); logger.info(`[DEBUG][ADD] filteredRepos: ${filteredRepos.length}, gcalCommits: ${githubGcalCommits.length}, parseEmojis: ${parseCommitEmojis}`); const githubSessionStats = { commits_tracked_to_be_added: [], commits_added: [] }; const uniqueRepos = new Set(filteredRepos.map((c) => c.repository)); const gcalRepos = new Set(githubGcalCommits.map((c) => { var _a, _b; return (_b = (_a = c.extendedProperties) === null || _a === void 0 ? void 0 : _a.private) === null || _b === void 0 ? void 0 : _b.repository; }).filter(Boolean)); logger.info(`[DEBUG][ADD] unique github repos: ${uniqueRepos.size}, unique gcal repos: ${gcalRepos.size}`); const missingRepos = [...uniqueRepos].filter((r) => !gcalRepos.has(r)); if (missingRepos.length > 0) { logger.info(`[DEBUG][ADD] repos in github but NOT in gcal: ${missingRepos.join(', ')}`); } const gcalCommitsByRepo = new Map(); for (const gcalItem of githubGcalCommits) { const repo = (_b = (_a = gcalItem.extendedProperties) === null || _a === void 0 ? void 0 : _a.private) === null || _b === void 0 ? void 0 : _b.repository; if (repo) { if (!gcalCommitsByRepo.has(repo)) { gcalCommitsByRepo.set(repo, []); } gcalCommitsByRepo.get(repo).push(gcalItem); } } let matchedCount = 0; let noMatchReasonStats = { noSameRepo: 0, noDateMatch: 0, noMessageMatch: 0 }; const sampleMismatches = []; for (const githubCommitItem of filteredRepos) { const sameRepoCommits = (_c = gcalCommitsByRepo.get(githubCommitItem.repository)) !== null && _c !== void 0 ? _c : []; if (sameRepoCommits.length === 0) { noMatchReasonStats.noSameRepo++; const commitMessage = parseCommitEmojis ? parseGithubEmojisString(githubCommitItem.commitMessage) : githubCommitItem.commitMessage; const extendProps = { private: { commitMessage, commitDate: githubCommitItem.commitDate, repository: githubCommitItem.repository, repositoryName: githubCommitItem.repositoryName, repositoryLink: githubCommitItem.repositoryLink, commitId: githubCommitItem.commitId } }; const taskEvent = { summary: `${githubCommitItem.repositoryName} - ${commitMessage}`, description: `repository: https://github.com/${githubCommitItem.repository}\ncommit: ${githubCommitItem.commitUrl}`, start: { dateTime: githubCommitItem.commitDate }, end: { dateTime: githubCommitItem.commitDate }, reminders: { useDefault: false, overrides: [] }, extendedProperties: extendProps }; githubSessionStats.commits_tracked_to_be_added.push(taskEvent); if (sampleMismatches.length < 5) { sampleMismatches.push({ repo: githubCommitItem.repository, ghDate: githubCommitItem.commitDate, ghMsg: githubCommitItem.commitMessage.slice(0, 50) }); } continue; } let foundMatch = false; let hadDateMismatch = false; let hadMessageMismatch = false; for (const gcalItem of sameRepoCommits) { const gcalPrivate = (_d = gcalItem.extendedProperties) === null || _d === void 0 ? void 0 : _d.private; if (!gcalPrivate) continue; const dateMatch = gcalPrivate.commitDate === githubCommitItem.commitDate; const gcalMsg = parseGithubEmojisString(gcalPrivate.commitMessage || ''); const ghMsg = parseGithubEmojisString(githubCommitItem.commitMessage); const msgMatch = gcalMsg === ghMsg; if (dateMatch && msgMatch) { foundMatch = true; break; } if (!dateMatch) hadDateMismatch = true; if (dateMatch && !msgMatch) hadMessageMismatch = true; } if (foundMatch) { matchedCount++; } else { if (hadMessageMismatch) noMatchReasonStats.noMessageMatch++; else if (hadDateMismatch) noMatchReasonStats.noDateMatch++; if (sampleMismatches.length < 5) { sampleMismatches.push({ repo: githubCommitItem.repository, ghDate: githubCommitItem.commitDate, ghMsg: githubCommitItem.commitMessage.slice(0, 50) }); } const commitMessage = parseCommitEmojis ? parseGithubEmojisString(githubCommitItem.commitMessage) : githubCommitItem.commitMessage; const extendProps = { private: { commitMessage, commitDate: githubCommitItem.commitDate, repository: githubCommitItem.repository, repositoryName: githubCommitItem.repositoryName, repositoryLink: githubCommitItem.repositoryLink, commitId: githubCommitItem.commitId } }; const taskEvent = { summary: `${githubCommitItem.repositoryName} - ${commitMessage}`, description: `repository: https://github.com/${githubCommitItem.repository}\ncommit: ${githubCommitItem.commitUrl}`, start: { dateTime: githubCommitItem.commitDate }, end: { dateTime: githubCommitItem.commitDate }, reminders: { useDefault: false, overrides: [] }, extendedProperties: extendProps }; githubSessionStats.commits_tracked_to_be_added.push(taskEvent); } } logger.info(`[DEBUG][ADD] matched ${matchedCount}/${filteredRepos.length} commits`); logger.info(`[DEBUG][ADD] commits to add: ${githubSessionStats.commits_tracked_to_be_added.length}`); logger.info(`[DEBUG][ADD] no match reasons: noSameRepo=${noMatchReasonStats.noSameRepo}, noDateMatch=${noMatchReasonStats.noDateMatch}, noMessageMatch=${noMatchReasonStats.noMessageMatch}`); if (sampleMismatches.length > 0) { logger.info(`[DEBUG][ADD] sample commits that didn't match (first ${sampleMismatches.length}):`); sampleMismatches.forEach((m, i) => { logger.info(`[DEBUG][ADD] ${i + 1}. repo=${m.repo} date=${m.ghDate} msg=${m.ghMsg}`); }); } if (githubSessionStats.commits_tracked_to_be_added.length > 0 && githubSessionStats.commits_tracked_to_be_added.length <= 10) { logger.info(`[DEBUG][ADD] commits to add details:`); githubSessionStats.commits_tracked_to_be_added.forEach((c, i) => { logger.info(`[DEBUG][ADD] ${i + 1}. ${c.extendedProperties.private.repository} - ${c.extendedProperties.private.commitDate.slice(0, 10)} - ${c.extendedProperties.private.commitMessage.slice(0, 50)}`); }); } const commitIdsToAdd = githubSessionStats.commits_tracked_to_be_added.map((item) => item.extendedProperties.private.commitId); const currentHash = computeHash(commitIdsToAdd); logger.info(`[DEBUG][ADD] computed hash for ${commitIdsToAdd.length} commitIds: ${currentHash.slice(0, 16)}...`); if (currentGithubSyncIndex === 1) { logger.info(`storing hash for ${commitIdsToAdd.length} commits to track for addition`); updateGASProperty('github_commits_tracked_to_be_added_hash', currentHash); return githubSessionStats; } const lastHash = getGASProperty('github_commits_tracked_to_be_added_hash'); logger.info(`[DEBUG][ADD] lastHash from storage: ${lastHash ? lastHash.slice(0, 16) + '...' : 'null'}`); if (!lastHash) { logger.info(`no stored hash found, resetting to step 1`); resetGithubSyncProperties(); updateGASProperty('github_commits_tracked_to_be_added_hash', currentHash); return githubSessionStats; } logger.info(`comparing hashes: stored=${lastHash.slice(0, 8)}... current=${currentHash.slice(0, 8)}...`); if (lastHash !== currentHash) { logger.info(`reset github commit properties due hash mismatch in added commits`); logger.info(`[DEBUG][ADD] hash mismatch: stored=${lastHash}, current=${currentHash}`); resetGithubSyncProperties(); return githubSessionStats; } if (currentGithubSyncIndex === CONFIGS.REQUIRED_GITHUB_VALIDATIONS_COUNT && githubSessionStats.commits_tracked_to_be_added.length > 0) { const totalCommits = githubSessionStats.commits_tracked_to_be_added.length; const totalBatches = Math.ceil(totalCommits / CONFIGS.BATCH_SIZE); logger.info(`adding ${totalCommits} commits to gcal in ${totalBatches} batches of ${CONFIGS.BATCH_SIZE}`); for (let i = 0; i < totalCommits; i += CONFIGS.BATCH_SIZE) { const batch = githubSessionStats.commits_tracked_to_be_added.slice(i, i + CONFIGS.BATCH_SIZE); const addedEvents = addEventsToCalendarBatch(githubCalendar, batch); githubSessionStats.commits_added.push(...addedEvents); logger.info(`batch ${Math.floor(i / CONFIGS.BATCH_SIZE) + 1}/${totalBatches}: added ${addedEvents.length} commits`); if (i + CONFIGS.BATCH_SIZE < totalCommits) { Utilities.sleep(CONFIGS.BATCH_DELAY_MS); } } logger.info(`[DEBUG][ADD] finished adding commits, resetting properties`); resetGithubSyncProperties(); } return githubSessionStats; } function syncGithubCommitsToDelete({ githubGcalCommits, githubCalendar, currentGithubSyncIndex, filteredRepos }) { logger.info(`[DEBUG][DEL] starting syncGithubCommitsToDelete`); logger.info(`[DEBUG][DEL] gcalCommits: ${githubGcalCommits.length}, filteredRepos: ${filteredRepos.length}`); const githubSessionStats = { commits_deleted: [], commits_tracked_to_be_deleted: [] }; let matchedCount = 0; let noMatchReasonStats = { noSameRepo: 0, noDateMatch: 0, noMessageMatch: 0 }; githubGcalCommits.forEach((gcalItem) => { var _a; const gcalProperties = (_a = gcalItem.extendedProperties) === null || _a === void 0 ? void 0 : _a.private; if (!gcalProperties) { logger.info(`[DEBUG][DEL] skipping gcal item without private properties: ${gcalItem.id}`);