diff --git a/src/core/WakaTimeCore.ts b/src/core/WakaTimeCore.ts index 9f7923f..0add85c 100644 --- a/src/core/WakaTimeCore.ts +++ b/src/core/WakaTimeCore.ts @@ -4,6 +4,11 @@ import { Tabs } from 'webextension-polyfill'; import { AxiosUserResponse, User } from '../types/user'; import config from '../config/config'; import { SummariesPayload, GrandTotal } from '../types/summaries'; +import changeExtensionState from '../utils/changeExtensionState'; +import inArray from '../utils/inArray'; +import contains from '../utils/contains'; +import { SendHeartbeat } from '../types/heartbeats'; +import getDomainFromUrl from '../utils/getDomainFromUrl'; class WakaTimeCore { tabsWithDevtoolsOpen: Tabs.Tab[]; @@ -30,6 +35,11 @@ class WakaTimeCore { return summariesAxiosPayload.data.data[0].grand_total; } + /** + * Checks if the user is logged in. + * + * @returns {*} + */ async checkAuth(api_key = ''): Promise { const userPayload: AxiosResponse = await axios.get( config.currentUserApiUrl, @@ -38,6 +48,10 @@ class WakaTimeCore { return userPayload.data.data; } + /** + * Depending on various factors detects the current active tab URL or domain, + * and sends it to WakaTime for logging. + */ async recordHeartbeat(): Promise { const items = await browser.storage.sync.get({ blacklist: '', @@ -45,6 +59,201 @@ class WakaTimeCore { loggingStyle: config.loggingStyle, whitelist: '', }); + if (items.loggingEnabled === true) { + await changeExtensionState('allGood'); + + const newState = await browser.idle.queryState(config.detectionIntervalInSeconds); + + if (newState === 'active') { + // Get current tab URL. + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + if (tabs.length == 0) return; + + const currentActiveTab = tabs[0]; + let debug = false; + + // If the current active tab has devtools open + if ( + inArray( + currentActiveTab.id, + this.tabsWithDevtoolsOpen.map((tab) => tab.id), + ) + ) { + debug = true; + } + + if (items.loggingStyle == 'blacklist') { + if (!contains(currentActiveTab.url as string, items.blacklist as string)) { + await this.sendHeartbeat( + { + project: null, + url: currentActiveTab.url as string, + }, + debug, + ); + } else { + await changeExtensionState('blacklisted'); + console.log(`${currentActiveTab.url} is on a blacklist.`); + } + } + + if (items.loggingStyle == 'whitelist') { + const heartbeat = this.getHeartbeat( + currentActiveTab.url as string, + items.whitelist as string, + ); + if (heartbeat.url) { + await this.sendHeartbeat(heartbeat, debug); + } else { + await changeExtensionState('whitelisted'); + console.log(`${currentActiveTab.url} is not on a whitelist.`); + } + } + } + } else { + await changeExtensionState('notLogging'); + } + } + + /** + * Creates an array from list using \n as delimiter + * and checks if any element in list is contained in the url. + * Also checks if element is assigned to a project using @@ as delimiter + * + * @param url + * @param list + * @returns {object} + */ + getHeartbeat(url: string, list: string) { + const projectIndicatorCharacters = '@@'; + + const lines = list.split('\n'); + for (let i = 0; i < lines.length; i++) { + // strip (http:// or https://) and trailing (`/` or `@@`) + const cleanLine = lines[i] + .trim() + .replace(/(\/|@@)$/, '') + .replace(/^(?:https?:\/\/)?/i, ''); + if (cleanLine === '') continue; + + const projectIndicatorIndex = cleanLine.lastIndexOf(projectIndicatorCharacters); + const projectIndicatorExists = projectIndicatorIndex > -1; + let projectName = null; + let urlFromLine = cleanLine; + if (projectIndicatorExists) { + const start = projectIndicatorIndex + projectIndicatorCharacters.length; + projectName = cleanLine.substring(start); + urlFromLine = cleanLine + .replace(cleanLine.substring(projectIndicatorIndex), '') + .replace(/\/$/, ''); + } + const schemaHttpExists = url.match(/^http:\/\//i); + const schemaHttpsExists = url.match(/^https:\/\//i); + let schema = ''; + if (schemaHttpExists) { + schema = 'http://'; + } + if (schemaHttpsExists) { + schema = 'https://'; + } + const cleanUrl = url + .trim() + .replace(/(\/|@@)$/, '') + .replace(/^(?:https?:\/\/)?/i, ''); + const startsWithUrl = cleanUrl.toLowerCase().includes(urlFromLine.toLowerCase()); + if (startsWithUrl) { + return { + project: projectName, + url: schema + urlFromLine, + }; + } + } + + return { + project: null, + url: null, + }; + } + + /** + * Given the heartbeat and logging type it creates a payload and + * sends an ajax post request to the API. + * + * @param heartbeat + * @param debug + */ + async sendHeartbeat(heartbeat: SendHeartbeat, debug: boolean): Promise { + let payload = null; + + const loggingType = await this.getLoggingType(); + // Get only the domain from the entity. + // And send that in heartbeat + if (loggingType == 'domain') { + heartbeat.url = getDomainFromUrl(heartbeat.url); + payload = this.preparePayload(heartbeat, 'domain', debug); + console.log(payload); + await this.sendAjaxRequestToApi(payload); + } + // Send entity in heartbeat + else if (loggingType == 'url') { + payload = this.preparePayload(heartbeat, 'url', debug); + console.log(payload); + await this.sendAjaxRequestToApi(payload); + } + } + + /** + * Returns a promise with logging type variable. + * + * @returns {*} + * @private + */ + async getLoggingType(): Promise { + const items = await browser.storage.sync.get({ + loggingType: config.loggingType, + }); + + return items.loggingType; + } + + /** + * Creates payload for the heartbeat and returns it as JSON. + * + * @param heartbeat + * @param type + * @param debug + * @returns {*} + * @private + */ + preparePayload(heartbeat: SendHeartbeat, type: string, debug = false): Record { + return { + entity: heartbeat.url, + is_debugging: debug, + plugin: 'browser-wakatime/' + config.version, + project: heartbeat.project ?? '<>', + time: moment().format('X'), + type: type, + }; + } + + /** + * Sends AJAX request with payload to the heartbeat API as JSON. + * + * @param payload + * @param method + * @returns {*} + */ + async sendAjaxRequestToApi(payload: Record, api_key = '') { + try { + const response = await axios.post(config.heartbeatApiUrl, payload, { + params: { + api_key, + }, + }); + return response.data; + } catch (err: unknown) { + await changeExtensionState('notSignedIn'); + } } } diff --git a/src/types/heartbeats.ts b/src/types/heartbeats.ts index e178266..8067433 100644 --- a/src/types/heartbeats.ts +++ b/src/types/heartbeats.ts @@ -26,3 +26,8 @@ export interface Datum { user_agent_id: string; user_id: string; } + +export interface SendHeartbeat { + project: string | null; + url: string; +} diff --git a/src/utils/inArray.ts b/src/utils/inArray.ts index d9d914c..55696eb 100644 --- a/src/utils/inArray.ts +++ b/src/utils/inArray.ts @@ -1,7 +1,7 @@ /** * Returns boolean if needle is found in haystack or not. */ -export default function in_array(needle: T, haystack: T[]): boolean { +export default function inArray(needle: T, haystack: T[]): boolean { for (let i = 0; i < haystack.length; i++) { if (needle == haystack[i]) { return true;