diff --git a/package-lock.json b/package-lock.json index b376d158..df738d3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "idb": "^7.1.1", "jquery": "^3.6.3", "moment": "^2.29.4", + "node-html-parser": "^6.1.13", "react": "^18.2.0", "react-dom": "^18.2.0", "react-redux": "^8.0.5", @@ -6348,8 +6349,7 @@ "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" }, "node_modules/bootstrap": { "version": "5.3.2", @@ -8043,7 +8043,6 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", - "dev": true, "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", @@ -8059,7 +8058,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "dev": true, "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", @@ -8073,7 +8071,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true, "funding": [ { "type": "github", @@ -8085,7 +8082,6 @@ "version": "5.0.3", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "dev": true, "dependencies": { "domelementtype": "^2.3.0" }, @@ -8100,7 +8096,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", - "dev": true, "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", @@ -8114,7 +8109,6 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, "engines": { "node": ">=0.12" }, @@ -8126,7 +8120,6 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", - "dev": true, "engines": { "node": ">= 6" }, @@ -11383,7 +11376,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true, "bin": { "he": "bin/he" } @@ -16769,6 +16761,16 @@ "node": ">= 6.13.0" } }, + "node_modules/node-html-parser": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz", + "integrity": "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==", + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -16895,7 +16897,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dev": true, "dependencies": { "boolbase": "^1.0.0" }, @@ -27257,8 +27258,7 @@ "boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" }, "bootstrap": { "version": "5.3.2", @@ -28574,7 +28574,6 @@ "version": "5.1.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", - "dev": true, "requires": { "boolbase": "^1.0.0", "css-what": "^6.1.0", @@ -28587,7 +28586,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "dev": true, "requires": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", @@ -28597,14 +28595,12 @@ "domelementtype": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==" }, "domhandler": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "dev": true, "requires": { "domelementtype": "^2.3.0" } @@ -28613,7 +28609,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", - "dev": true, "requires": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", @@ -28623,16 +28618,14 @@ "entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==" } } }, "css-what": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", - "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", - "dev": true + "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==" }, "css.escape": { "version": "1.5.1", @@ -31102,8 +31095,7 @@ "he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==" }, "hmac-drbg": { "version": "1.0.1", @@ -35120,6 +35112,15 @@ "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", "dev": true }, + "node-html-parser": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz", + "integrity": "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==", + "requires": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, "node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -35223,7 +35224,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dev": true, "requires": { "boolbase": "^1.0.0" } diff --git a/package.json b/package.json index 6a73f5c9..ac861707 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "idb": "^7.1.1", "jquery": "^3.6.3", "moment": "^2.29.4", + "node-html-parser": "^6.1.13", "react": "^18.2.0", "react-dom": "^18.2.0", "react-redux": "^8.0.5", diff --git a/src/background.ts b/src/background.ts index 0eb455a7..e0c57ab6 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,6 +1,7 @@ import browser from 'webextension-polyfill'; import WakaTimeCore from './core/WakaTimeCore'; import { PostHeartbeatMessage } from './types/heartbeats'; +import { getHtmlContentByTabId } from './utils'; // Add a listener to resolve alarms browser.alarms.onAlarm.addListener(async (alarm) => { @@ -22,9 +23,10 @@ browser.alarms.create('heartbeatAlarm', { periodInMinutes: 2 }); /** * Whenever a active tab is changed it records a heartbeat with that tab url. */ -browser.tabs.onActivated.addListener(async () => { +browser.tabs.onActivated.addListener(async (activeInfo) => { console.log('recording a heartbeat - active tab changed'); - await WakaTimeCore.recordHeartbeat(); + const html = await getHtmlContentByTabId(activeInfo.tabId); + await WakaTimeCore.recordHeartbeat(html); }); /** @@ -33,7 +35,17 @@ browser.tabs.onActivated.addListener(async () => { browser.windows.onFocusChanged.addListener(async (windowId) => { if (windowId != browser.windows.WINDOW_ID_NONE) { console.log('recording a heartbeat - active window changed'); - await WakaTimeCore.recordHeartbeat(); + const tabs: browser.Tabs.Tab[] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + + let html = ''; + const tabId = tabs[0]?.id; + if (tabId) { + html = await getHtmlContentByTabId(tabId); + } + await WakaTimeCore.recordHeartbeat(html); } }); @@ -50,7 +62,8 @@ browser.tabs.onUpdated.addListener(async (tabId, changeInfo) => { }); // If tab updated is the same as active tab if (tabId == tabs[0]?.id) { - await WakaTimeCore.recordHeartbeat(); + const html = await getHtmlContentByTabId(tabId); + await WakaTimeCore.recordHeartbeat(html); } } }); @@ -63,9 +76,12 @@ self.addEventListener('activate', async () => { await WakaTimeCore.createDB(); }); -browser.runtime.onMessage.addListener(async (request: PostHeartbeatMessage) => { +browser.runtime.onMessage.addListener(async (request: PostHeartbeatMessage, sender) => { if (request.recordHeartbeat === true) { - await WakaTimeCore.recordHeartbeat(request.projectDetails); + if (sender.tab?.id) { + const html = await getHtmlContentByTabId(sender.tab.id); + await WakaTimeCore.recordHeartbeat(html, request.projectDetails); + } } }); diff --git a/src/components/MainList.test.tsx b/src/components/MainList.test.tsx index 86da35e5..9d744ab6 100644 --- a/src/components/MainList.test.tsx +++ b/src/components/MainList.test.tsx @@ -20,6 +20,7 @@ describe('MainList', () => { totalTimeLoggedToday = '1/1/1999'; }); it('should render properly', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const { container } = renderWithProviders( , ); diff --git a/src/components/NavBar.test.tsx b/src/components/NavBar.test.tsx index 7d2b8812..ba72b658 100644 --- a/src/components/NavBar.test.tsx +++ b/src/components/NavBar.test.tsx @@ -14,6 +14,7 @@ jest.mock('webextension-polyfill', () => { describe('NavBar', () => { it('should render properly', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const { container } = renderWithProviders(); expect(container).toMatchInlineSnapshot(`
diff --git a/src/core/WakaTimeCore.ts b/src/core/WakaTimeCore.ts index 67f3e44e..9c6f4f8a 100644 --- a/src/core/WakaTimeCore.ts +++ b/src/core/WakaTimeCore.ts @@ -112,7 +112,7 @@ class WakaTimeCore { * Depending on various factors detects the current active tab URL or domain, * and sends it to WakaTime for logging. */ - async recordHeartbeat(payload: Record = {}): Promise { + async recordHeartbeat(html: string, payload: Record = {}): Promise { const apiKey = await getApiKey(); if (!apiKey) { return changeExtensionState('notLogging'); @@ -164,7 +164,7 @@ class WakaTimeCore { } // Checks dev websites - const project = generateProjectFromDevSites(url); + const project = generateProjectFromDevSites(url, html); // Check if code reviewing const codeReviewing = isCodeReviewing(url); diff --git a/src/manifests/chrome.json b/src/manifests/chrome.json index 93c0d360..f5de4ef0 100644 --- a/src/manifests/chrome.json +++ b/src/manifests/chrome.json @@ -32,6 +32,6 @@ "options_ui": { "page": "options.html" }, - "permissions": ["alarms", "tabs", "storage", "idle"], + "permissions": ["", "alarms", "tabs", "storage", "idle", "activeTab", "scripting"], "version": "3.0.22" } diff --git a/src/manifests/edge.json b/src/manifests/edge.json index f111d83e..c8a3b6f6 100644 --- a/src/manifests/edge.json +++ b/src/manifests/edge.json @@ -32,6 +32,6 @@ "options_ui": { "page": "options.html" }, - "permissions": ["alarms", "tabs", "storage", "idle"], + "permissions": ["", "alarms", "tabs", "storage", "idle", "activeTab", "scripting"], "version": "3.0.22" } diff --git a/src/manifests/firefox.json b/src/manifests/firefox.json index fc18db29..c8563d98 100644 --- a/src/manifests/firefox.json +++ b/src/manifests/firefox.json @@ -38,6 +38,6 @@ "chrome_style": false, "page": "options.html" }, - "permissions": ["", "alarms", "tabs", "storage", "idle"], + "permissions": ["", "alarms", "tabs", "storage", "idle", "activeTab", "scripting"], "version": "3.0.22" } diff --git a/src/options.tsx b/src/options.tsx index ab0582d0..c9c8c725 100644 --- a/src/options.tsx +++ b/src/options.tsx @@ -3,10 +3,14 @@ import { createRoot } from 'react-dom/client'; import Options from './components/Options'; /* This is a fix for Bootstrap requiring jQuery */ +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-expect-error +// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment global.jQuery = require('jquery'); require('bootstrap'); const container = document.getElementById('wakatime-options'); -const root = createRoot(container!); - -root.render(); +if (container) { + const root = createRoot(container); + root.render(); +} diff --git a/src/popup.tsx b/src/popup.tsx index 78d68c37..80187f3d 100644 --- a/src/popup.tsx +++ b/src/popup.tsx @@ -8,12 +8,14 @@ import checkCurrentUser from './utils/checkCurrentUser'; import 'bootstrap/dist/js/bootstrap'; const container = document.getElementById('wakatime'); -const root = createRoot(container!); -const store = createStore('WakaTime-Options'); -checkCurrentUser(store)(30 * 1000); +if (container) { + const root = createRoot(container); + const store = createStore(); + checkCurrentUser(store)(30 * 1000); -root.render( - - - , -); + root.render( + + + , + ); +} diff --git a/src/types/heartbeats.ts b/src/types/heartbeats.ts index 4897eaa2..21fbac2f 100644 --- a/src/types/heartbeats.ts +++ b/src/types/heartbeats.ts @@ -35,6 +35,7 @@ export interface SendHeartbeat { } export interface ProjectDetails { + [key: string]: string; category: string; editor: string; language: string; diff --git a/src/utils/index.ts b/src/utils/index.ts index 11217c9d..0be4a51d 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,33 +1,146 @@ +import { parse } from 'node-html-parser'; + export const IS_EDGE = navigator.userAgent.includes('Edg'); export const IS_FIREFOX = navigator.userAgent.includes('Firefox'); export const IS_CHROME = IS_EDGE === false && IS_FIREFOX === false; -const REG_LIST = [ - // GitHub. Eg. URL: https://github.com/workspace-name/project-name - /(?<=github\.(?:com|dev)\/[^/]+\/)([^/?#]+)/, - // Gitlab. Eg. URL: https://gitlab.com/workspace-name/project-name - /(?<=gitlab\.com\/[^/]+\/)([^/?#]+)/, - // BitBucket. Eg. URL: https://bitbucket.org/workspace-name/project-name/src - /(?<=bitbucket\.org\/[^/]+\/)([^/?#]+)/, - // Travis CI. Eg. URL: https://app.travis-ci.com/github/workspace-name/project-name/no-build?serverType=git - /(?<=app\.travis-ci\.com\/[^/]+\/[^/]+\/)([^/?#]+)/, - // Circle CI. Eg. URL: https://app.circleci.com/projects/project-setup/github/workspace-name/project-name/ - /(?<=app\.circleci\.com\/projects\/[^/]+\/[^/]+\/[^/]+\/)([^/?#]+)/, - // Vercel. Eg. URL: http://vercel.com/team-name/project-name - /(?<=vercel\.com\/[^/]+\/)([^/?#]+)/, -]; +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; +}; -export const generateProjectFromDevSites = (url: string): string | null => { - let match: RegExpMatchArray | null = null; +const BitBucket: ProjectNameExtractor = (url: string, html: string): string | null => { + const match = url.match(/(?<=bitbucket\.org\/[^/]+\/)([^/?#]+)/); - for (const reg of REG_LIST) { - match = url.match(reg); - if (match) { - break; + 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 match?.[0] ?? null; + 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 generateProjectFromDevSites = (url: string, html: string): 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/]; @@ -40,3 +153,10 @@ export const isCodeReviewing = (url: string): boolean => { } 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/test-utils.tsx b/src/utils/test-utils.tsx index d472da34..c5e21b88 100644 --- a/src/utils/test-utils.tsx +++ b/src/utils/test-utils.tsx @@ -1,14 +1,14 @@ -import React, { PropsWithChildren } from 'react'; -import { render } from '@testing-library/react'; -import type { RenderOptions } from '@testing-library/react'; -import { combineReducers, configureStore, Store } from '@reduxjs/toolkit'; import type { PreloadedState } from '@reduxjs/toolkit'; +import { combineReducers, configureStore, Store } from '@reduxjs/toolkit'; +import type { RenderOptions } from '@testing-library/react'; +import { render } from '@testing-library/react'; +import React, { PropsWithChildren } from 'react'; import { Provider } from 'react-redux'; import { RootState } from '../stores/createStore'; // As a basic setup, import your same slice reducers -import userReducer, { initialState as InitalCurrentUser } from '../reducers/currentUser'; import configReducer, { initialConfigState } from '../reducers/configReducer'; +import userReducer, { initialState as InitalCurrentUser } from '../reducers/currentUser'; // This type interface extends the default options for render from RTL, as well // as allows the user to specify other things such as initialState, store. @@ -33,6 +33,7 @@ export function renderWithProviders( store = configureStore({ preloadedState, reducer: rootReducer }), ...renderOptions }: ExtendedRenderOptions = {}, + // eslint-disable-next-line @typescript-eslint/no-explicit-any ): any { function Wrapper({ children }: PropsWithChildren>): JSX.Element { return {children}; diff --git a/src/utils/user.ts b/src/utils/user.ts index 1eac8052..152fc11b 100644 --- a/src/utils/user.ts +++ b/src/utils/user.ts @@ -1,8 +1,9 @@ import { AnyAction, Dispatch } from '@reduxjs/toolkit'; -import { setApiKey, setLoggingEnabled, setTotalTimeLoggedToday } from '../reducers/configReducer'; import config from '../config/config'; import WakaTimeCore from '../core/WakaTimeCore'; +import { setApiKey, setLoggingEnabled, setTotalTimeLoggedToday } from '../reducers/configReducer'; import { setUser } from '../reducers/currentUser'; +import { getHtmlContentByTabId } from '.'; import changeExtensionState from './changeExtensionState'; export const logUserIn = async (apiKey: string): Promise => { @@ -65,7 +66,17 @@ export const fetchUserData = async ( dispatch(setLoggingEnabled(items.loggingEnabled as boolean)); dispatch(setTotalTimeLoggedToday(totalTimeLoggedTodayResponse.text)); - await WakaTimeCore.recordHeartbeat(); + const tabs = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + let html = ''; + const tabId = tabs[0]?.id; + if (tabId) { + html = await getHtmlContentByTabId(tabId); + } + + await WakaTimeCore.recordHeartbeat(html); } catch (err: unknown) { await changeExtensionState('notSignedIn'); } diff --git a/src/wakatimeScript.ts b/src/wakatimeScript.ts index 63649fbc..3addfdeb 100644 --- a/src/wakatimeScript.ts +++ b/src/wakatimeScript.ts @@ -83,3 +83,9 @@ document.body.addEventListener( debounce(() => init()), true, ); + +chrome.runtime.onMessage.addListener((request: { message: string }, sender, sendResponse) => { + if (request.message === 'get_html') { + sendResponse({ html: document.documentElement.outerHTML }); + } +}); diff --git a/tsconfig.json b/tsconfig.json index 6b1da8d5..e76c4d5f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,8 @@ "outDir": "lib", "lib": ["es2015", "es2016", "es2017", "dom"], "baseUrl": "./", - "paths": {} + "paths": {}, + "strictNullChecks": true }, "exclude": [] }