import { Category, EntityType } from '../types/heartbeats'; import { HeartbeatParser, KnownSite, OptionalHeartbeat, SiteParser, StackExchangeSite, } from '../types/sites'; import { STACKEXCHANGE_SITES } from './stackexchange-sites'; const githubLanguage = (): string | undefined => { const embedData = document.querySelector( 'script[data-target="react-app.embeddedData"]', )?.textContent; if (embedData) { try { const data = JSON.parse(embedData) as { payload?: { blob?: { language?: string } } }; if (data.payload?.blob?.language) return data.payload.blob.language; } catch (e: unknown) { console.error('Failed to parse GitHub language', e); } } const files = Array.from( document.querySelectorAll('div[data-details-container-group="file"]').values(), ); const languages = files .sort((a, b) => { const aSize = a .querySelector('.file-info') ?.querySelector('.sr-only') ?.textContent?.trim() .split(':')[1] .trim() .split(' ')[0] .replace(',', '') .replace('.', '') .replace('k', '000') .replace('M', '000000') ?? 0; const bSize = b .querySelector('.file-info') ?.querySelector('.sr-only') ?.textContent?.trim() .split(':')[1] .trim() .split(' ')[0] .replace(',', '') .replace('.', '') .replace('k', '000') .replace('M', '000000') ?? 0; return Number(bSize) - Number(aSize); }) .map((div) => div.getAttribute('data-tagsearch-lang')) .filter(Boolean) as string[]; return languages[0]; }; const GitHub: HeartbeatParser = (url: string) => { const { hostname } = new URL(url); const match = url.match(/(?<=github\.(?:com|dev)\/[^/]+\/)([^/?#]+)/); if (!match) return; if (hostname.endsWith('.dev')) { return { language: '<>', project: match[0], }; } const repo = document .querySelector('meta[name=octolytics-dimension-repository_nwo]') ?.getAttribute('content'); if (repo?.split('/')[1] !== match[0]) { return { language: '<>', }; } // TODO: parse language associated with this repo from the DOM // TODO: parse branch associated with the PR url from the DOM const re = new RegExp(/github.com\/[^/]+\/[^/]+\/pull\/\d+\/files/); const category: Category | undefined = re.test(url) ? Category.code_reviewing : undefined; return { category, language: githubLanguage() ?? '<>', project: match[0], }; }; const GitLab: HeartbeatParser = (url: string) => { const match = url.match(/(?<=gitlab\.com\/[^/]+\/)([^/?#]+)/); if (!match) return; const repoName = document.querySelector('body')?.getAttribute('data-project-full-path'); if (!repoName || repoName.split('/')[1] !== match[0]) { return { language: '<>', }; } return { language: '<>', project: match[0], }; }; const BitBucket: HeartbeatParser = (url: string) => { const match = url.match(/(?<=bitbucket\.org\/[^/]+\/)([^/?#]+)/); if (!match) return; // this regex extracts the project name from the title // eg. title: jhondoe / my-test-repo — Bitbucket const match2 = document.querySelector('title')?.textContent?.match(/(?<=\/\s)([^/\s]+)(?=\s—)/); if (!match2 || match2[0] !== match[0]) { return { language: '<>', }; } return { language: '<>', project: match[0], }; }; const TravisCI: HeartbeatParser = (url: string) => { const match = url.match(/(?<=app\.travis-ci\.com\/[^/]+\/[^/]+\/)([^/?#]+)/); if (!match) return; const projectName = document.querySelector('#ember737')?.textContent; if (projectName !== match[0]) { return { language: '<>', }; } return { language: '<>', project: match[0], }; }; const CircleCI: HeartbeatParser = (url: string) => { const projectPageMatch = url.match( /(?<=app\.circleci\.com\/projects\/[^/]+\/[^/]+\/[^/]+\/)([^/?#]+)/, ); if (projectPageMatch) { const seconndBreadcrumbLabel = document.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 = document.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 { language: '<>', project: projectPageMatch[0], }; } } const settingsPageMatch = url.match( /(?<=app\.circleci\.com\/settings\/project\/[^/]+\/[^/]+\/)([^/?#]+)/, ); if (settingsPageMatch) { const pageTitle = document.querySelector( '#__next > div > div:nth-child(1) > header > div > div:nth-child(2) > h1', )?.textContent; const pageSubtitle = document.querySelector( '#__next > div > div:nth-child(1) > header > div > div:nth-child(2) > div', )?.textContent; if (pageTitle === 'Project Settings' && pageSubtitle === settingsPageMatch[0]) { return { language: '<>', project: settingsPageMatch[0], }; } } return { language: '<>', }; }; const Vercel: HeartbeatParser = (url: string) => { const match = url.match(/(?<=vercel\.com\/[^/]+\/)([^/?#]+)/); if (!match) return; // this regex extracts the project name from the title // eg. title: test-website - Overview – Vercel const match2 = document.querySelector('title')?.textContent?.match(/^[^\s]+(?=\s-\s)/); if (!match2 || match2[0] !== match[0]) { return { language: '<>', }; } return { language: '<>', project: match[0], }; }; const StackOverflow: HeartbeatParser = (_url: string) => { const tags = Array.from(document.querySelectorAll('.post-tag').values()) .map((el) => el.textContent) .filter(Boolean) as string[]; if (tags.length === 0) { return { language: '<>', }; } const languages = Array.from(document.querySelectorAll('code[data-highlighted="yes"]').values()) .map((code) => { const cls = Array.from(code.classList.values()).find((c) => c.startsWith('language-')); return cls?.substring('language-'.length); }) .filter(Boolean) as string[]; for (const lang of languages) { if (tags.includes(lang)) { return { language: lang }; } } return { language: '<>', }; }; const Canva: HeartbeatParser = (_url: string): OptionalHeartbeat | undefined => { const projectName = (document.head.querySelector('meta[property="og:title"]') as HTMLMetaElement) .content; if (!projectName) return; // make sure the page title matches the design input element's value, meaning this is a design file const canvaProjectInput = Array.from( document.querySelector('nav')?.querySelectorAll('input').values() ?? [], ).find((inp) => inp.value === projectName); if (!canvaProjectInput) return; return { category: Category.designing, language: 'Image (svg)', plugin: 'Canva', project: projectName, }; }; const Figma: HeartbeatParser = (_url: string): OptionalHeartbeat | undefined => { const figmaProject = document.getElementsByClassName('gpu-view-content'); if (figmaProject.length === 0) return; const project = (document.querySelector('span[data-testid="filename"]') as HTMLElement).innerText; return { category: Category.designing, language: 'Image (svg)', plugin: 'Figma', project, }; }; const GoogleMeet: HeartbeatParser = (_url: string): OptionalHeartbeat | undefined => { const meetId = document.querySelector('[data-meeting-title]')?.getAttribute('data-meeting-title'); if (!meetId) return; return { category: Category.meeting, plugin: 'Google Meet', project: meetId, }; }; const Slack: HeartbeatParser = (_url: string): OptionalHeartbeat | undefined => { const title = document.querySelector('title')?.textContent?.split(' - '); if (!title || title.length < 3 || title[-1] !== 'Slack') { return { category: Category.communicating, plugin: 'Slack', }; } const entity = title[0]; const project = title[1]; return { category: Category.communicating, entity, entityType: EntityType.app, plugin: 'Slack', project, }; }; const Zoom: HeartbeatParser = (_url: string): OptionalHeartbeat | undefined => { const entity = document.querySelector('title')?.textContent; return { category: Category.communicating, entity: entity ?? undefined, entityType: entity ? EntityType.app : undefined, plugin: 'Zoom', }; }; const _normalizeUrl = (url?: string | null) => { if (!url) { return ''; } if (url.startsWith('http://')) { url = url.substring('http://'.length); } if (url.startsWith('https://')) { url = url.substring('https://'.length); } if (url.startsWith('www.')) { url = url.substring('www.'.length); } if (url.endsWith('/')) { url = url.substring(0, url.length - 1); } return url; }; const stackExchangeDomains = (STACKEXCHANGE_SITES as StackExchangeSite[]).map((site) => { return _normalizeUrl(site.site_url); }); const SITES: Record = { bitbucket: { parser: BitBucket, urls: [/^https?:\/\/(.+\.)?bitbucket.org\//], }, canva: { parser: Canva, urls: ['canva.com'], }, circleci: { parser: CircleCI, urls: [/^https?:\/\/(.+\.)?circleci.com\//], }, figma: { parser: Figma, urls: ['figma.com'], }, github: { parser: GitHub, urls: [ /^https?:\/\/(.+\.)?github.com\//, /^https?:\/\/(.+\.)?github.dev\//, /^https?:\/\/(.+\.)?github.blog\//, /^https?:\/\/(.+\.)?github.io\//, /^https?:\/\/(.+\.)?github.community\//, // /^https?:\/\/(.+\.)?ghcr.io\//, // /^https?:\/\/(.+\.)?githubapp.com\//, // /^https?:\/\/(.+\.)?githubassets.com\//, // /^https?:\/\/(.+\.)?githubusercontent.com\//, // /^https?:\/\/(.+\.)?githubnext.com\//, ], }, gitlab: { parser: GitLab, urls: [/^https?:\/\/(.+\.)?gitlab.com\//], }, googlemeet: { parser: GoogleMeet, urls: [/^https?:\/\/meet.google.com\//], }, slack: { parser: Slack, urls: [/^https:\/\/app.slack.com\/client\//], }, stackoverflow: { parser: StackOverflow, urls: stackExchangeDomains, }, travisci: { parser: TravisCI, urls: [/^https?:\/\/(.+\.)?travis-ci.com\//], }, vercel: { parser: Vercel, urls: [/^https?:\/\/(.+\.)?vercel.com\//], }, zoom: { parser: Zoom, urls: [/^https:\/\/(.+\.)?zoom.us\/[^?]+\/join/], }, }; const match = (url: string, pattern: RegExp | string): boolean => { if (typeof pattern === 'string') { return _normalizeUrl(url).startsWith(_normalizeUrl(pattern)); } return pattern.test(url); }; export const getSite = (url: string): SiteParser | undefined => { return Object.values(SITES).find((site) => { return site.urls.some((re) => match(url, re)); }); };