half way through refactor

This commit is contained in:
Alan Hamlett
2024-08-27 14:48:22 +02:00
parent d5e94de63c
commit d34c8ca347
13 changed files with 313 additions and 143 deletions

View File

@@ -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;

View File

@@ -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,

View File

@@ -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 (
<div className="py-4 px-2 pt-0">
<NavBar />
{isApiKeyValid && extensionState === 'notSignedIn' && (
{isApiKeyValid && extensionStatus === 'notSignedIn' && (
<Alert
type={config.alert.failure.type}
text={'Invalid API key or API url'}

View File

@@ -3,7 +3,7 @@ import browser from 'webextension-polyfill';
/**
* Logging
*/
export type ApiStates = 'allGood' | 'notLogging' | 'notSignedIn' | 'ignored';
export type ExtensionStatus = 'allGood' | 'notLogging' | 'notSignedIn' | 'ignored';
/**
* Supported logging style
*/
@@ -90,7 +90,7 @@ export interface Config {
name: string;
nonTrackableSites: string[];
socialMediaSites: string[];
states: ApiStates[];
states: ExtensionStatus[];
/**
* Get stats from the wakatime api
*/

View File

@@ -1,23 +1,25 @@
import { IDBPDatabase, openDB } from 'idb';
import moment from 'moment';
import browser, { Tabs } from 'webextension-polyfill';
import { IS_EDGE, IS_FIREFOX, getOperatingSystem, isCodeReviewing } from '../utils';
import { getHeartbeatFromPage } from '../utils/heartbeat';
/* eslint-disable no-fallthrough */
/* eslint-disable default-case */
import moment from 'moment';
import { getOperatingSystem, isCodeReviewing } from '../utils';
import { changeExtensionStatus } from '../utils/changeExtensionStatus';
import getDomainFromUrl, { getDomain } from '../utils/getDomainFromUrl';
import { getSettings, Settings } from '../utils/settings';
import config from '../config/config';
import config, { ExtensionStatus } from '../config/config';
import { Heartbeat } from '../types/heartbeats';
import { getApiKey } from '../utils/apiKey';
import changeExtensionState from '../utils/changeExtensionState';
import contains from '../utils/contains';
import { getHeartbeatFromPage } from '../utils/heartbeat';
import { getLoggingType } from '../utils/logging';
class WakaTimeCore {
tabsWithDevtoolsOpen: Tabs.Tab[];
lastHeartbeat: Heartbeat | undefined;
lastHeartbeatSentAt = 0;
lastExtensionState: ExtensionStatus = 'allGood';
db: IDBPDatabase | undefined;
constructor() {
this.tabsWithDevtoolsOpen = [];
@@ -55,47 +57,56 @@ class WakaTimeCore {
return false;
}
async processHeartbeat(heartbeat: Heartbeat) {
const apiKey = await getApiKey();
if (!apiKey) return changeExtensionState('notLogging');
canSendHeartbeat(url: string, settings: Settings): boolean {
if (!settings.trackSocialMedia) {
const domain = getDomain(url);
if (
settings.socialMediaSites.find((pattern) => {
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;
}
} 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.`);
if (this.db) {
// append heartbeat to queue
await this.db.add('cacheHeartbeats', heartbeat);
if (settings.extensionStatus !== 'notSignedIn') {
await changeExtensionStatus('allGood');
}
} else {
throw Error(`Unknown logging styel: ${items.loggingStyle}`);
}
}
@@ -106,7 +117,7 @@ class WakaTimeCore {
async recordHeartbeat(html: string, payload: Record<string, unknown> = {}): Promise<void> {
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<void> {
const apiKey = await getApiKey();
if (!apiKey) {
return changeExtensionState('notLogging');
return changeExtensionStatus('notLogging');
}
if (this.db) {

View File

@@ -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<void> {
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
}
}

View File

@@ -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<void> {
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 });
}

View File

@@ -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<void> {
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<void> {
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<void> {
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
}
}

View File

@@ -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<void> {
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
}
}

View File

@@ -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<Settings> => {
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,

158
src/utils/sites.ts Normal file
View File

@@ -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<string> => {
const response = (await browser.tabs.sendMessage(tabId, { message: 'get_html' })) as {
html: string;
};
return response.html;
};

View File

@@ -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.

View File

@@ -1,5 +1,5 @@
import chai from 'chai';
import changeExtensionState from '../../src/utils/changeExtensionState';
import changeExtensionState from '../../src/utils/changeExtensionStatus';
const expect = chai.expect;