diff --git a/src/components/MainList.tsx b/src/components/MainList.tsx index 2f447d8..92ba5e4 100644 --- a/src/components/MainList.tsx +++ b/src/components/MainList.tsx @@ -4,7 +4,7 @@ import { configLogout, setLoggingEnabled } from '../reducers/configReducer'; import { userLogout } from '../reducers/currentUser'; import { ReduxSelector } from '../types/store'; import { User } from '../types/user'; -import changeExtensionState from '../utils/changeExtensionState'; +import changeExtensionState from '../utils/changeExtensionStatus'; export interface MainListProps { loggingEnabled: boolean; diff --git a/src/components/Options.tsx b/src/components/Options.tsx index 414c32e..15de2ca 100644 --- a/src/components/Options.tsx +++ b/src/components/Options.tsx @@ -20,8 +20,10 @@ export default function Options(): JSX.Element { apiKey: '', apiUrl: config.apiUrl, denyList: [], + extensionStatus: 'allGood', hostname: '', loading: false, + loggingEnabled: true, loggingStyle: config.loggingStyle, loggingType: config.loggingType, socialMediaSites: config.socialMediaSites, @@ -54,7 +56,9 @@ export default function Options(): JSX.Element { apiKey: state.apiKey, apiUrl: state.apiUrl, denyList: state.denyList, + extensionStatus: state.extensionStatus, hostname: state.hostname, + loggingEnabled: state.loggingEnabled, loggingStyle: state.loggingStyle, loggingType: state.loggingType, socialMediaSites: state.socialMediaSites, diff --git a/src/components/WakaTime.tsx b/src/components/WakaTime.tsx index 23010e7..ff30c79 100644 --- a/src/components/WakaTime.tsx +++ b/src/components/WakaTime.tsx @@ -10,7 +10,7 @@ import NavBar from './NavBar'; export default function WakaTime(): JSX.Element { const dispatch = useDispatch(); - const [extensionState, setExtensionState] = useState(''); + const [extensionStatus, setExtensionStatus] = useState(''); const { apiKey: apiKeyFromRedux, @@ -21,8 +21,8 @@ export default function WakaTime(): JSX.Element { useEffect(() => { const fetchData = async () => { await fetchUserData(apiKeyFromRedux, dispatch); - const items = await browser.storage.sync.get({ extensionState: '' }); - setExtensionState(items.extensionState as string); + const items = await browser.storage.sync.get({ extensionStatus: '' }); + setExtensionStatus(items.extensionStatus as string); }; void fetchData(); }, []); @@ -32,7 +32,7 @@ export default function WakaTime(): JSX.Element { return (
- {isApiKeyValid && extensionState === 'notSignedIn' && ( + {isApiKeyValid && extensionStatus === 'notSignedIn' && ( { + const re = new RegExp(pattern.replace(/\*/g, '.*')); + return re.test(domain); + }) !== undefined + ) { + return false; + } + } + + if (settings.loggingStyle === 'deny') { + return ( + settings.denyList.find((pattern) => { + const re = new RegExp(pattern.replace(/\*/g, '.*')); + return re.test(url); + }) == undefined + ); + } + return ( + settings.allowList.find((pattern) => { + const re = new RegExp(pattern.replace(/\*/g, '.*')); + return re.test(url); + }) !== undefined + ); + } + + async handleActivity(url: string, heartbeat: Heartbeat) { + const settings = await getSettings(); + if (!settings.loggingEnabled) { + await changeExtensionStatus('notLogging'); + return; + } if (!this.shouldSendHeartbeat(heartbeat)) return; - if (items.loggingStyle == 'deny') { - if (!contains(url, items.denyList as string)) { - await this.sendHeartbeat( - { - branch: null, - hostname: items.hostname as string, - project, - url, - }, - apiKey, - payload, - ); - } else { - await changeExtensionState('ignored'); - console.log(`${url} is on denyList.`); + if (!this.canSendHeartbeat(url, settings)) { + await changeExtensionStatus('ignored'); + return; + } + + if (this.db) { + // append heartbeat to queue + await this.db.add('cacheHeartbeats', heartbeat); + + if (settings.extensionStatus !== 'notSignedIn') { + await changeExtensionStatus('allGood'); } - } else if (items.loggingStyle == 'allow') { - const heartbeat = this.getHeartbeat(url, items.allowList as string); - if (heartbeat.url) { - await this.sendHeartbeat( - { - ...heartbeat, - branch: null, - hostname: items.hostname as string, - project: heartbeat.project ?? project, - }, - apiKey, - payload, - ); - } else { - await changeExtensionState('ignored'); - console.log(`${url} is not on allowList.`); - } - } else { - throw Error(`Unknown logging styel: ${items.loggingStyle}`); } } @@ -106,7 +117,7 @@ class WakaTimeCore { async recordHeartbeat(html: string, payload: Record = {}): Promise { const apiKey = await getApiKey(); if (!apiKey) { - return changeExtensionState('notLogging'); + return changeExtensionStatus('notLogging'); } const items = await browser.storage.sync.get({ allowList: '', @@ -118,14 +129,14 @@ class WakaTimeCore { trackSocialMedia: config.trackSocialMedia, }); if (items.loggingEnabled === true) { - await changeExtensionState('allGood'); + await changeExtensionStatus('allGood'); let newState = ''; // Detects we are running this code in the extension scope if (browser.idle as browser.Idle.Static | undefined) { newState = await browser.idle.queryState(config.detectionIntervalInSeconds); if (newState !== 'active') { - return changeExtensionState('notLogging'); + return changeExtensionStatus('notLogging'); } } @@ -150,7 +161,7 @@ class WakaTimeCore { const hostname = getDomain(url); if (!items.trackSocialMedia) { if ((items.socialMediaSites as string[]).includes(hostname)) { - return changeExtensionState('ignored'); + return changeExtensionStatus('ignored'); } } @@ -176,7 +187,7 @@ class WakaTimeCore { payload, ); } else { - await changeExtensionState('ignored'); + await changeExtensionStatus('ignored'); console.log(`${url} is on denyList.`); } } else if (items.loggingStyle == 'allow') { @@ -193,7 +204,7 @@ class WakaTimeCore { payload, ); } else { - await changeExtensionState('ignored'); + await changeExtensionStatus('ignored'); console.log(`${url} is not on allowList.`); } } else { @@ -374,7 +385,7 @@ class WakaTimeCore { await this.db.add('cacheHeartbeats', payload); } - await changeExtensionState('notSignedIn'); + await changeExtensionStatus('notSignedIn'); } } @@ -385,7 +396,7 @@ class WakaTimeCore { async sendCachedHeartbeatsRequest(): Promise { const apiKey = await getApiKey(); if (!apiKey) { - return changeExtensionState('notLogging'); + return changeExtensionStatus('notLogging'); } if (this.db) { diff --git a/src/utils/changeExtensionIcon.ts b/src/utils/changeExtensionIcon.ts deleted file mode 100644 index 2821908..0000000 --- a/src/utils/changeExtensionIcon.ts +++ /dev/null @@ -1,30 +0,0 @@ -import browser from 'webextension-polyfill'; -import config from '../config/config'; -import { IS_FIREFOX } from '.'; - -type ColorIconTypes = 'gray' | 'red' | 'white' | ''; - -/** - * It changes the extension icon color. - */ -export default async function changeExtensionIcon(color?: ColorIconTypes): Promise { - let path; - if (color) { - path = `./graphics/wakatime-logo-38-${color}.png`; - } else { - const { theme } = await browser.storage.sync.get({ - theme: config.theme, - }); - path = - theme === config.theme - ? './graphics/wakatime-logo-38.png' - : './graphics/wakatime-logo-38-white.png'; - } - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (IS_FIREFOX && browser.browserAction) { - await browser.browserAction.setIcon({ path: path }); // Support for FF with manifest V2 - } else if ((browser.action as browser.Action.Static | undefined) !== undefined) { - await browser.action.setIcon({ path: path }); // Support for Chrome with manifest V3 - } -} diff --git a/src/utils/changeExtensionState.ts b/src/utils/changeExtensionState.ts deleted file mode 100644 index 19c4aaa..0000000 --- a/src/utils/changeExtensionState.ts +++ /dev/null @@ -1,31 +0,0 @@ -import browser from 'webextension-polyfill'; -import config, { ApiStates } from '../config/config'; -import changeExtensionIcon from './changeExtensionIcon'; -import changeExtensionTooltip from './changeExtensionTooltip'; - -/** - * Sets the current state of the extension. - */ -export default async function changeExtensionState(state: ApiStates): Promise { - switch (state) { - case 'allGood': - await changeExtensionIcon(config.colors.allGood); - await changeExtensionTooltip(config.tooltips.allGood); - break; - case 'notLogging': - await changeExtensionIcon(config.colors.notLogging); - await changeExtensionTooltip(config.tooltips.notLogging); - break; - case 'notSignedIn': - await changeExtensionIcon(config.colors.notSignedIn); - await changeExtensionTooltip(config.tooltips.notSignedIn); - break; - case 'ignored': - await changeExtensionIcon(config.colors.notLogging); - await changeExtensionTooltip(config.tooltips.ignored); - break; - default: - break; - } - await browser.storage.sync.set({ extensionState: state }); -} diff --git a/src/utils/changeExtensionStatus.ts b/src/utils/changeExtensionStatus.ts new file mode 100644 index 0000000..8ffd9e1 --- /dev/null +++ b/src/utils/changeExtensionStatus.ts @@ -0,0 +1,76 @@ +import browser from 'webextension-polyfill'; +import config, { ExtensionStatus } from '../config/config'; +import { IS_FIREFOX } from '.'; + +type ColorIconTypes = 'gray' | 'red' | 'white' | ''; + +/** + * Sets status of the extension. + */ +export async function changeExtensionStatus(status: ExtensionStatus): Promise { + switch (status) { + case 'allGood': + await changeExtensionIcon(config.colors.allGood); + await changeExtensionTooltip(config.tooltips.allGood); + break; + case 'notLogging': + await changeExtensionIcon(config.colors.notLogging); + await changeExtensionTooltip(config.tooltips.notLogging); + break; + case 'notSignedIn': + await changeExtensionIcon(config.colors.notSignedIn); + await changeExtensionTooltip(config.tooltips.notSignedIn); + break; + case 'ignored': + await changeExtensionIcon(config.colors.notLogging); + await changeExtensionTooltip(config.tooltips.ignored); + break; + default: + break; + } + await browser.storage.sync.set({ extensionStatus: status }); +} + +/** + * Changes the extension icon color. + */ +export async function changeExtensionIcon(color?: ColorIconTypes): Promise { + let path; + if (color) { + path = `./graphics/wakatime-logo-38-${color}.png`; + } else { + const { theme } = await browser.storage.sync.get({ + theme: config.theme, + }); + path = + theme === config.theme + ? './graphics/wakatime-logo-38.png' + : './graphics/wakatime-logo-38-white.png'; + } + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (IS_FIREFOX && browser.browserAction) { + await browser.browserAction.setIcon({ path: path }); // Support for FF with manifest V2 + } else if ((browser.action as browser.Action.Static | undefined) !== undefined) { + await browser.action.setIcon({ path: path }); // Support for Chrome with manifest V3 + } +} + +/** + * It changes the extension title + * + */ +export default async function changeExtensionTooltip(text: string): Promise { + if (text === '') { + text = config.name; + } else { + text = `${config.name} - ${text}`; + } + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (IS_FIREFOX && browser.browserAction) { + await browser.browserAction.setTitle({ title: text }); // Support for FF with manifest V2 + } else if ((browser.action as browser.Action.Static | undefined) !== undefined) { + await browser.action.setTitle({ title: text }); // Support for Chrome with manifest V3 + } +} diff --git a/src/utils/changeExtensionTooltip.ts b/src/utils/changeExtensionTooltip.ts deleted file mode 100644 index e9e3187..0000000 --- a/src/utils/changeExtensionTooltip.ts +++ /dev/null @@ -1,22 +0,0 @@ -import browser from 'webextension-polyfill'; -import config from '../config/config'; -import { IS_FIREFOX } from '.'; - -/** - * It changes the extension title - * - */ -export default async function changeExtensionTooltip(text: string): Promise { - if (text === '') { - text = config.name; - } else { - text = `${config.name} - ${text}`; - } - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (IS_FIREFOX && browser.browserAction) { - await browser.browserAction.setTitle({ title: text }); // Support for FF with manifest V2 - } else if ((browser.action as browser.Action.Static | undefined) !== undefined) { - await browser.action.setTitle({ title: text }); // Support for Chrome with manifest V3 - } -} diff --git a/src/utils/settings.ts b/src/utils/settings.ts index 95a0ea2..ccef201 100644 --- a/src/utils/settings.ts +++ b/src/utils/settings.ts @@ -1,12 +1,14 @@ import browser from 'webextension-polyfill'; -import config, { LoggingStyle, LoggingType, Theme } from '../config/config'; +import config, { ExtensionStatus, LoggingStyle, LoggingType, Theme } from '../config/config'; export interface Settings { allowList: string[]; apiKey: string; apiUrl: string; denyList: string[]; + extensionStatus: ExtensionStatus; hostname: string; + loggingEnabled: boolean; loggingStyle: LoggingStyle; loggingType: LoggingType; socialMediaSites: string[]; @@ -57,7 +59,9 @@ export const getSettings = async (): Promise => { apiKey: settings.apiKey, apiUrl: settings.apiUrl, denyList: settings.denyList, + extensionStatus: settings.extensionStatus, hostname: settings.hostname, + loggingEnabled: settings.loggingEnabled, loggingStyle: settings.loggingStyle, loggingType: settings.loggingType, socialMediaSites: settings.socialMediaSites, diff --git a/src/utils/sites.ts b/src/utils/sites.ts new file mode 100644 index 0000000..d383015 --- /dev/null +++ b/src/utils/sites.ts @@ -0,0 +1,158 @@ +import { parse } from 'node-html-parser'; + +type ProjectNameExtractor = (url: string, html: string) => string | null; + +const GitHub: ProjectNameExtractor = (url: string, html: string): string | null => { + const { hostname } = new URL(url); + const match = url.match(/(?<=github\.(?:com|dev)\/[^/]+\/)([^/?#]+)/); + + if (match) { + if (hostname.endsWith('.com')) { + const root = parse(html); + const repoName = root + .querySelector('meta[name=octolytics-dimension-repository_nwo]') + ?.getAttribute('content'); + if (!repoName || repoName.split('/')[1] !== match[0]) { + return null; + } + } + return match[0]; + } + + return null; +}; + +const GitLab: ProjectNameExtractor = (url: string, html: string): string | null => { + const match = url.match(/(?<=gitlab\.com\/[^/]+\/)([^/?#]+)/); + + if (match) { + const root = parse(html); + const repoName = root.querySelector('body')?.getAttribute('data-project-full-path'); + if (!repoName || repoName.split('/')[1] !== match[0]) { + return null; + } + return match[0]; + } + + return null; +}; + +const BitBucket: ProjectNameExtractor = (url: string, html: string): string | null => { + const match = url.match(/(?<=bitbucket\.org\/[^/]+\/)([^/?#]+)/); + + if (match) { + const root = parse(html); + // this regex extracts the project name from the title + // eg. title: jhondoe / my-test-repo — Bitbucket + const match2 = root.querySelector('title')?.textContent.match(/(?<=\/\s)([^/\s]+)(?=\s—)/); + if (match2 && match2[0] === match[0]) { + return match[0]; + } + } + + return null; +}; + +const TravisCI: ProjectNameExtractor = (url: string, html: string): string | null => { + const match = url.match(/(?<=app\.travis-ci\.com\/[^/]+\/[^/]+\/)([^/?#]+)/); + + if (match) { + const root = parse(html); + const projectName = root.querySelector('#ember737')?.textContent; + if (projectName === match[0]) { + return match[0]; + } + } + + return null; +}; + +const CircleCI: ProjectNameExtractor = (url: string, html: string): string | null => { + const projectPageMatch = url.match( + /(?<=app\.circleci\.com\/projects\/[^/]+\/[^/]+\/[^/]+\/)([^/?#]+)/, + ); + + if (projectPageMatch) { + const root = parse(html); + const seconndBreadcrumbLabel = root.querySelector( + '#__next > div:nth-child(2) > div > div > main > div > header > div:nth-child(1) > ol > li:nth-child(2) > div > div > span', + )?.textContent; + const seconndBreadcrumbValue = root.querySelector( + '#__next > div:nth-child(2) > div > div > main > div > header > div:nth-child(1) > ol > li:nth-child(2) > div > span', + )?.textContent; + if (seconndBreadcrumbLabel === 'Project' && seconndBreadcrumbValue === projectPageMatch[0]) { + return projectPageMatch[0]; + } + } + + const settingsPageMatch = url.match( + /(?<=app\.circleci\.com\/settings\/project\/[^/]+\/[^/]+\/)([^/?#]+)/, + ); + if (settingsPageMatch) { + const root = parse(html); + const pageTitle = root.querySelector( + '#__next > div > div:nth-child(1) > header > div > div:nth-child(2) > h1', + )?.textContent; + const pageSubtitle = root.querySelector( + '#__next > div > div:nth-child(1) > header > div > div:nth-child(2) > div', + )?.textContent; + if (pageTitle === 'Project Settings' && pageSubtitle === settingsPageMatch[0]) { + return settingsPageMatch[0]; + } + } + + return null; +}; + +const Vercel: ProjectNameExtractor = (url: string, html: string): string | null => { + const match = url.match(/(?<=vercel\.com\/[^/]+\/)([^/?#]+)/); + + if (match) { + const root = parse(html); + // this regex extracts the project name from the title + // eg. title: test-website - Overview – Vercel + const match2 = root.querySelector('title')?.textContent.match(/^[^\s]+(?=\s-\s)/); + if (match2 && match2[0] === match[0]) { + return match[0]; + } + } + + return null; +}; + +const ProjectNameExtractors: ProjectNameExtractor[] = [ + GitHub, + GitLab, + BitBucket, + TravisCI, + CircleCI, + Vercel, +]; + +export const getHeartbeatFromPage = (): string | null => { + for (const projectNameExtractor of ProjectNameExtractors) { + const projectName = projectNameExtractor(url, html); + if (projectName) { + return projectName; + } + } + return null; +}; + +const CODE_REVIEW_URL_REG_LIST = [/github.com\/[^/]+\/[^/]+\/pull\/\d+\/files/]; + +export const isCodeReviewing = (url: string): boolean => { + for (const reg of CODE_REVIEW_URL_REG_LIST) { + if (url.match(reg)) { + return true; + } + } + return false; +}; + +export const getHtmlContentByTabId = async (tabId: number): Promise => { + const response = (await browser.tabs.sendMessage(tabId, { message: 'get_html' })) as { + html: string; + }; + return response.html; +}; diff --git a/src/utils/user.ts b/src/utils/user.ts index c95c681..29857aa 100644 --- a/src/utils/user.ts +++ b/src/utils/user.ts @@ -7,7 +7,7 @@ import { setApiKey, setLoggingEnabled, setTotalTimeLoggedToday } from '../reduce import { setUser } from '../reducers/currentUser'; import { GrandTotal, Summaries } from '../types/summaries'; import { ApiKeyPayload, AxiosUserResponse, User } from '../types/user'; -import changeExtensionState from './changeExtensionState'; +import changeExtensionState from './changeExtensionStatus'; /** * Checks if the user is logged in. diff --git a/tests/utils/changeExtensionState.spec.ts b/tests/utils/changeExtensionState.spec.ts index 17b3340..d829143 100644 --- a/tests/utils/changeExtensionState.spec.ts +++ b/tests/utils/changeExtensionState.spec.ts @@ -1,5 +1,5 @@ import chai from 'chai'; -import changeExtensionState from '../../src/utils/changeExtensionState'; +import changeExtensionState from '../../src/utils/changeExtensionStatus'; const expect = chai.expect;