Files
browser-wakatime/src/utils/sites.ts
2024-09-14 15:40:50 +02:00

411 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: '<<LAST_LANGUAGE>>',
project: match[0],
};
}
const repo = document
.querySelector('meta[name=octolytics-dimension-repository_nwo]')
?.getAttribute('content');
if (repo?.split('/')[1] !== match[0]) {
return {
language: '<<LAST_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() ?? '<<LAST_LANGUAGE>>',
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: '<<LAST_LANGUAGE>>',
};
}
return {
language: '<<LAST_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: '<<LAST_LANGUAGE>>',
};
}
return {
language: '<<LAST_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: '<<LAST_LANGUAGE>>',
};
}
return {
language: '<<LAST_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: '<<LAST_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: '<<LAST_LANGUAGE>>',
project: settingsPageMatch[0],
};
}
}
return {
language: '<<LAST_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: '<<LAST_LANGUAGE>>',
};
}
return {
language: '<<LAST_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: '<<LAST_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: '<<LAST_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<KnownSite, SiteParser> = {
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));
});
};