From f8dbf91d76cc98aa6145521ca762e4f9800f9c78 Mon Sep 17 00:00:00 2001 From: martin-mfg <2026226+martin-mfg@users.noreply.github.com> Date: Tue, 30 Sep 2025 15:58:03 +0000 Subject: [PATCH] copy most of backend to frontend --- .../_dot_vercel_copy/output/config.json | 3 + .../output/functions/api.func/.vc-config.json | 5 + .../output/functions/api.func/router.js | 71 ++ .../functions/api.prerender-config.json | 5 + .../output/functions/api/authenticate.func | 1 + .../output/functions/api/delete-user.func | 1 + .../output/functions/api/downgrade.func | 1 + .../output/functions/api/gist.func | 1 + .../functions/api/gist.prerender-config.json | 5 + .../output/functions/api/pin.func | 1 + .../functions/api/pin.prerender-config.json | 5 + .../output/functions/api/private-access.func | 1 + .../output/functions/api/repeat-recent.func | 1 + .../output/functions/api/status/pat-info.func | 1 + .../output/functions/api/status/up.func | 1 + .../output/functions/api/top-langs.func | 1 + .../api/top-langs.prerender-config.json | 5 + .../output/functions/api/wakatime.func | 1 + .../api/wakatime.prerender-config.json | 5 + .../src/backend/api-renamed/authenticate.js | 17 + .../src/backend/api-renamed/delete-user.js | 17 + .../src/backend/api-renamed/downgrade.js | 74 ++ .../frontend/src/backend/api-renamed/gist.js | 109 +++ .../frontend/src/backend/api-renamed/index.js | 207 ++++ .../frontend/src/backend/api-renamed/pin.js | 173 ++++ .../src/backend/api-renamed/private-access.js | 17 + .../src/backend/api-renamed/repeat-recent.js | 18 + .../backend/api-renamed/status/pat-info.js | 158 ++++ .../src/backend/api-renamed/status/up.js | 126 +++ .../src/backend/api-renamed/top-langs.js | 151 +++ .../src/backend/api-renamed/wakatime.js | 131 +++ .../frontend/src/backend/src/calculateRank.js | 87 ++ .../frontend/src/backend/src/cards/gist.js | 152 +++ .../frontend/src/backend/src/cards/index.js | 4 + .../frontend/src/backend/src/cards/repo.js | 326 +++++++ .../frontend/src/backend/src/cards/stats.js | 616 ++++++++++++ .../src/backend/src/cards/top-languages.js | 890 ++++++++++++++++++ .../frontend/src/backend/src/cards/types.d.ts | 70 ++ .../src/backend/src/cards/wakatime.js | 458 +++++++++ .../frontend/src/backend/src/common/Card.js | 273 ++++++ .../frontend/src/backend/src/common/I18n.js | 41 + .../src/backend/src/common/blacklist.js | 10 + .../backend/src/common/createProgressNode.js | 46 + .../src/backend/src/common/database.js | 256 +++++ .../frontend/src/backend/src/common/icons.js | 54 ++ .../frontend/src/backend/src/common/index.js | 30 + .../backend/src/common/languageColors.json | 643 +++++++++++++ .../src/backend/src/common/retryer.js | 76 ++ .../frontend/src/backend/src/common/utils.js | 679 +++++++++++++ .../src/backend/src/common/whitelist.js | 10 + .../frontend/src/backend/src/fetchers/gist.js | 114 +++ .../frontend/src/backend/src/fetchers/repo.js | 165 ++++ .../src/backend/src/fetchers/stats.js | 442 +++++++++ .../src/backend/src/fetchers/top-languages.js | 170 ++++ .../src/backend/src/fetchers/types.d.ts | 128 +++ .../src/backend/src/fetchers/wakatime.js | 35 + frontend/frontend/src/backend/src/index.js | 2 + .../src/backend/src/repeatRequests.js | 65 ++ .../frontend/src/backend/src/translations.js | 827 ++++++++++++++++ frontend/frontend/src/backend/src/users.js | 93 ++ .../frontend/src/backend/themes/README.md | 229 +++++ frontend/frontend/src/backend/themes/index.js | 467 +++++++++ 62 files changed, 8771 insertions(+) create mode 100644 frontend/frontend/src/backend/_dot_vercel_copy/output/config.json create mode 100644 frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api.func/.vc-config.json create mode 100644 frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api.func/router.js create mode 100644 frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api.prerender-config.json create mode 120000 frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/authenticate.func create mode 120000 frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/delete-user.func create mode 120000 frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/downgrade.func create mode 120000 frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/gist.func create mode 100644 frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/gist.prerender-config.json create mode 120000 frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/pin.func create mode 100644 frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/pin.prerender-config.json create mode 120000 frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/private-access.func create mode 120000 frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/repeat-recent.func create mode 120000 frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/status/pat-info.func create mode 120000 frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/status/up.func create mode 120000 frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/top-langs.func create mode 100644 frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/top-langs.prerender-config.json create mode 120000 frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/wakatime.func create mode 100644 frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/wakatime.prerender-config.json create mode 100644 frontend/frontend/src/backend/api-renamed/authenticate.js create mode 100644 frontend/frontend/src/backend/api-renamed/delete-user.js create mode 100644 frontend/frontend/src/backend/api-renamed/downgrade.js create mode 100644 frontend/frontend/src/backend/api-renamed/gist.js create mode 100644 frontend/frontend/src/backend/api-renamed/index.js create mode 100644 frontend/frontend/src/backend/api-renamed/pin.js create mode 100644 frontend/frontend/src/backend/api-renamed/private-access.js create mode 100644 frontend/frontend/src/backend/api-renamed/repeat-recent.js create mode 100644 frontend/frontend/src/backend/api-renamed/status/pat-info.js create mode 100644 frontend/frontend/src/backend/api-renamed/status/up.js create mode 100644 frontend/frontend/src/backend/api-renamed/top-langs.js create mode 100644 frontend/frontend/src/backend/api-renamed/wakatime.js create mode 100644 frontend/frontend/src/backend/src/calculateRank.js create mode 100644 frontend/frontend/src/backend/src/cards/gist.js create mode 100644 frontend/frontend/src/backend/src/cards/index.js create mode 100644 frontend/frontend/src/backend/src/cards/repo.js create mode 100644 frontend/frontend/src/backend/src/cards/stats.js create mode 100644 frontend/frontend/src/backend/src/cards/top-languages.js create mode 100644 frontend/frontend/src/backend/src/cards/types.d.ts create mode 100644 frontend/frontend/src/backend/src/cards/wakatime.js create mode 100644 frontend/frontend/src/backend/src/common/Card.js create mode 100644 frontend/frontend/src/backend/src/common/I18n.js create mode 100644 frontend/frontend/src/backend/src/common/blacklist.js create mode 100644 frontend/frontend/src/backend/src/common/createProgressNode.js create mode 100644 frontend/frontend/src/backend/src/common/database.js create mode 100644 frontend/frontend/src/backend/src/common/icons.js create mode 100644 frontend/frontend/src/backend/src/common/index.js create mode 100644 frontend/frontend/src/backend/src/common/languageColors.json create mode 100644 frontend/frontend/src/backend/src/common/retryer.js create mode 100644 frontend/frontend/src/backend/src/common/utils.js create mode 100644 frontend/frontend/src/backend/src/common/whitelist.js create mode 100644 frontend/frontend/src/backend/src/fetchers/gist.js create mode 100644 frontend/frontend/src/backend/src/fetchers/repo.js create mode 100644 frontend/frontend/src/backend/src/fetchers/stats.js create mode 100644 frontend/frontend/src/backend/src/fetchers/top-languages.js create mode 100644 frontend/frontend/src/backend/src/fetchers/types.d.ts create mode 100644 frontend/frontend/src/backend/src/fetchers/wakatime.js create mode 100644 frontend/frontend/src/backend/src/index.js create mode 100644 frontend/frontend/src/backend/src/repeatRequests.js create mode 100644 frontend/frontend/src/backend/src/translations.js create mode 100644 frontend/frontend/src/backend/src/users.js create mode 100644 frontend/frontend/src/backend/themes/README.md create mode 100644 frontend/frontend/src/backend/themes/index.js diff --git a/frontend/frontend/src/backend/_dot_vercel_copy/output/config.json b/frontend/frontend/src/backend/_dot_vercel_copy/output/config.json new file mode 100644 index 00000000..cd2f236b --- /dev/null +++ b/frontend/frontend/src/backend/_dot_vercel_copy/output/config.json @@ -0,0 +1,3 @@ +{ + "version": 3 +} diff --git a/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api.func/.vc-config.json b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api.func/.vc-config.json new file mode 100644 index 00000000..605ee7c0 --- /dev/null +++ b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api.func/.vc-config.json @@ -0,0 +1,5 @@ +{ + "runtime": "nodejs22.x", + "handler": "router.js", + "launcherType": "Nodejs" +} diff --git a/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api.func/router.js b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api.func/router.js new file mode 100644 index 00000000..e03265d8 --- /dev/null +++ b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api.func/router.js @@ -0,0 +1,71 @@ +import { default as api } from "./api-renamed/index.js"; +import { default as gist } from "./api-renamed/gist.js"; +import { default as pin } from "./api-renamed/pin.js"; +import { default as topLangs } from "./api-renamed/top-langs.js"; +import { default as wakatime } from "./api-renamed/wakatime.js"; +import { default as repeatRecent } from "./api-renamed/repeat-recent.js"; +import { default as patInfo } from "./api-renamed/status/pat-info.js"; +import { default as statusUp } from "./api-renamed/status/up.js"; +import { default as authenticate } from "./api-renamed/authenticate.js"; +import { default as deleteUser } from "./api-renamed/delete-user.js"; +import { default as privateAccess } from "./api-renamed/private-access.js"; +import { default as downgrade } from "./api-renamed/downgrade.js"; + +export default async (req, res) => { + // remaining code expects express.js-like request and response objects + res.send = function (data) { + if (typeof data === "object") { + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify(data)); + } else if (typeof data === "string") { + res.end(data); + } else { + res.end(String(data)); + } + }; + const url = new URL(req.url, "https://localhost"); + req.query = Object.fromEntries(url.searchParams.entries()); + + switch (url.pathname) { + case "/api": + api(req, res); + break; + case "/api/gist": + gist(req, res); + break; + case "/api/pin": + pin(req, res); + break; + case "/api/top-langs": + topLangs(req, res); + break; + case "/api/wakatime": + wakatime(req, res); + break; + case "/api/repeat-recent": + repeatRecent(req, res); + break; + case "/api/status/pat-info": + patInfo(req, res); + break; + case "/api/status/up": + statusUp(req, res); + break; + case "/api/authenticate": + authenticate(req, res); + break; + case "/api/delete-user": + deleteUser(req, res); + break; + case "/api/private-access": + privateAccess(req, res); + break; + case "/api/downgrade": + downgrade(req, res); + break; + default: + res.statusCode = 404; + res.end("Not Found"); + break; + } +}; diff --git a/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api.prerender-config.json b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api.prerender-config.json new file mode 100644 index 00000000..d7747ff4 --- /dev/null +++ b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api.prerender-config.json @@ -0,0 +1,5 @@ +{ + "expiration": 39600, + "bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n", + "passQuery": true +} \ No newline at end of file diff --git a/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/authenticate.func b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/authenticate.func new file mode 120000 index 00000000..2e79e533 --- /dev/null +++ b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/authenticate.func @@ -0,0 +1 @@ +../api.func \ No newline at end of file diff --git a/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/delete-user.func b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/delete-user.func new file mode 120000 index 00000000..2e79e533 --- /dev/null +++ b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/delete-user.func @@ -0,0 +1 @@ +../api.func \ No newline at end of file diff --git a/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/downgrade.func b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/downgrade.func new file mode 120000 index 00000000..2e79e533 --- /dev/null +++ b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/downgrade.func @@ -0,0 +1 @@ +../api.func \ No newline at end of file diff --git a/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/gist.func b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/gist.func new file mode 120000 index 00000000..2e79e533 --- /dev/null +++ b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/gist.func @@ -0,0 +1 @@ +../api.func \ No newline at end of file diff --git a/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/gist.prerender-config.json b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/gist.prerender-config.json new file mode 100644 index 00000000..d7747ff4 --- /dev/null +++ b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/gist.prerender-config.json @@ -0,0 +1,5 @@ +{ + "expiration": 39600, + "bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n", + "passQuery": true +} \ No newline at end of file diff --git a/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/pin.func b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/pin.func new file mode 120000 index 00000000..2e79e533 --- /dev/null +++ b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/pin.func @@ -0,0 +1 @@ +../api.func \ No newline at end of file diff --git a/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/pin.prerender-config.json b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/pin.prerender-config.json new file mode 100644 index 00000000..d7747ff4 --- /dev/null +++ b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/pin.prerender-config.json @@ -0,0 +1,5 @@ +{ + "expiration": 39600, + "bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n", + "passQuery": true +} \ No newline at end of file diff --git a/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/private-access.func b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/private-access.func new file mode 120000 index 00000000..2e79e533 --- /dev/null +++ b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/private-access.func @@ -0,0 +1 @@ +../api.func \ No newline at end of file diff --git a/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/repeat-recent.func b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/repeat-recent.func new file mode 120000 index 00000000..2e79e533 --- /dev/null +++ b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/repeat-recent.func @@ -0,0 +1 @@ +../api.func \ No newline at end of file diff --git a/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/status/pat-info.func b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/status/pat-info.func new file mode 120000 index 00000000..0f4906ce --- /dev/null +++ b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/status/pat-info.func @@ -0,0 +1 @@ +../../api.func \ No newline at end of file diff --git a/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/status/up.func b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/status/up.func new file mode 120000 index 00000000..0f4906ce --- /dev/null +++ b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/status/up.func @@ -0,0 +1 @@ +../../api.func \ No newline at end of file diff --git a/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/top-langs.func b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/top-langs.func new file mode 120000 index 00000000..2e79e533 --- /dev/null +++ b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/top-langs.func @@ -0,0 +1 @@ +../api.func \ No newline at end of file diff --git a/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/top-langs.prerender-config.json b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/top-langs.prerender-config.json new file mode 100644 index 00000000..d7747ff4 --- /dev/null +++ b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/top-langs.prerender-config.json @@ -0,0 +1,5 @@ +{ + "expiration": 39600, + "bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n", + "passQuery": true +} \ No newline at end of file diff --git a/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/wakatime.func b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/wakatime.func new file mode 120000 index 00000000..2e79e533 --- /dev/null +++ b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/wakatime.func @@ -0,0 +1 @@ +../api.func \ No newline at end of file diff --git a/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/wakatime.prerender-config.json b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/wakatime.prerender-config.json new file mode 100644 index 00000000..d7747ff4 --- /dev/null +++ b/frontend/frontend/src/backend/_dot_vercel_copy/output/functions/api/wakatime.prerender-config.json @@ -0,0 +1,5 @@ +{ + "expiration": 39600, + "bypassToken": "r3fr3shT0k3n-r3fr3shT0k3n-r3fr3shT0k3n", + "passQuery": true +} \ No newline at end of file diff --git a/frontend/frontend/src/backend/api-renamed/authenticate.js b/frontend/frontend/src/backend/api-renamed/authenticate.js new file mode 100644 index 00000000..b5852982 --- /dev/null +++ b/frontend/frontend/src/backend/api-renamed/authenticate.js @@ -0,0 +1,17 @@ +import { logger } from "../src/common/utils.js"; +import { authenticate } from "../src/users.js"; + +/** + * @param {any} req The request. + * @param {any} res The response. + */ +export default async (req, res) => { + const { code, private_access, user_key } = req.query; + try { + let userId = await authenticate(code, private_access === "true", user_key); + res.send(userId); + } catch (err) { + logger.error(err); + res.send("Something went wrong: " + err.message); + } +}; diff --git a/frontend/frontend/src/backend/api-renamed/delete-user.js b/frontend/frontend/src/backend/api-renamed/delete-user.js new file mode 100644 index 00000000..0a561668 --- /dev/null +++ b/frontend/frontend/src/backend/api-renamed/delete-user.js @@ -0,0 +1,17 @@ +import { logger } from "../src/common/utils.js"; +import { deleteUser } from "../src/common/database.js"; + +/** + * @param {any} req The request. + * @param {any} res The response. + */ +export default async (req, res) => { + const { user_key } = req.query; + try { + await deleteUser(user_key); + } catch (err) { + logger.error(err); + res.send("Something went wrong: " + err.message); + } + res.send("ok"); +}; diff --git a/frontend/frontend/src/backend/api-renamed/downgrade.js b/frontend/frontend/src/backend/api-renamed/downgrade.js new file mode 100644 index 00000000..34d7714e --- /dev/null +++ b/frontend/frontend/src/backend/api-renamed/downgrade.js @@ -0,0 +1,74 @@ +import { hasPrivateAccess, getUserToken, deleteUser } from "../src/common/database.js"; +import axios from "axios"; +import { logger } from "../src/index.js"; + +export default async (req, res) => { + // We could optimize this method by doing all 3 database operations in one statement, using "DELETE ... RETURNING ..." + + const { user_key } = req.query; + if (!user_key) { + res.statusCode = 400; + res.send("missing user_key"); + return; + } + + if ( + !process.env.OAUTH_CLIENT_ID || + !process.env.OAUTH_CLIENT_SECRET || + !process.env.OAUTH_REDIRECT_URI + ) { + throw new Error( + "OAuth Error: One or more required environment variables (OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_REDIRECT_URI) are not set.", + ); + } + + // verify that user has private access + const privateAccess = await hasPrivateAccess(user_key); + if (!privateAccess) { + res.statusCode = 400; + res.send("user does not have private access"); + return; + } + + // get access token for user + const token = await getUserToken(user_key); + if (!token) { + res.statusCode = 404; + res.send("user not found"); + return; + } + + // delete existing app authorization via GitHub API + try { + await axios.delete( + `https://api.github.com/applications/${process.env.OAUTH_CLIENT_ID}/grant`, + { + auth: { + username: process.env.OAUTH_CLIENT_ID, + password: process.env.OAUTH_CLIENT_SECRET, + }, + data: { access_token: token }, + headers: { + Accept: "application/vnd.github+json", + }, + }, + ); + } catch (err) { + logger.error(err); + res.statusCode = 500; + res.send("Failed to delete GitHub authorization with private access"); + return; + } + + await deleteUser(user_key); + + // redirect to GitHub OAuth for public access + const params = new URLSearchParams({ + client_id: process.env.OAUTH_CLIENT_ID, + redirect_uri: process.env.OAUTH_REDIRECT_URI, + }).toString(); + + res.statusCode = 302; + res.setHeader("Location", `https://github.com/login/oauth/authorize?${params}`); + res.end(); +}; diff --git a/frontend/frontend/src/backend/api-renamed/gist.js b/frontend/frontend/src/backend/api-renamed/gist.js new file mode 100644 index 00000000..8f2de341 --- /dev/null +++ b/frontend/frontend/src/backend/api-renamed/gist.js @@ -0,0 +1,109 @@ +import { + clampValue, + CONSTANTS, + renderError, + parseBoolean, +} from "../src/common/utils.js"; +import { gistWhitelist } from "../src/common/whitelist.js"; +import { isLocaleAvailable } from "../src/translations.js"; +import { renderGistCard } from "../src/cards/gist.js"; +import { fetchGist } from "../src/fetchers/gist.js"; +import { storeRequest } from "../src/common/database.js"; + +export default async (req, res) => { + const { + id, + title_color, + icon_color, + text_color, + bg_color, + theme, + cache_seconds, + locale, + border_radius, + border_color, + show_owner, + hide_border, + } = req.query; + + res.setHeader("Content-Type", "image/svg+xml"); + + if (gistWhitelist && !gistWhitelist.includes(id)) { + return res.send( + renderError( + "This gist ID is not whitelisted", + "Please deploy your own instance", + { + title_color, + text_color, + bg_color, + border_color, + theme, + show_repo_link: false, + }, + ), + ); + } + + if (locale && !isLocaleAvailable(locale)) { + return res.send( + renderError("Something went wrong", "Language not found", { + title_color, + text_color, + bg_color, + border_color, + theme, + }), + ); + } + + try { + await storeRequest(req); + const gistData = await fetchGist(id); + + let cacheSeconds = clampValue( + parseInt(cache_seconds || CONSTANTS.TEN_HOURS, 10), + CONSTANTS.FOUR_HOURS, + CONSTANTS.SIX_DAY, + ); + cacheSeconds = process.env.CACHE_SECONDS + ? parseInt(process.env.CACHE_SECONDS, 10) || cacheSeconds + : cacheSeconds; + + res.setHeader( + "Cache-Control", + `max-age=${cacheSeconds}, s-maxage=${cacheSeconds}`, + ); + + return res.send( + renderGistCard(gistData, { + title_color, + icon_color, + text_color, + bg_color, + theme, + border_radius, + border_color, + locale: locale ? locale.toLowerCase() : null, + show_owner: parseBoolean(show_owner), + hide_border: parseBoolean(hide_border), + }), + ); + } catch (err) { + res.setHeader( + "Cache-Control", + `max-age=${CONSTANTS.ERROR_CACHE_SECONDS / 2}, s-maxage=${ + CONSTANTS.ERROR_CACHE_SECONDS + }, stale-while-revalidate=${CONSTANTS.ONE_DAY}`, + ); // Use lower cache period for errors. + return res.send( + renderError(err.message, err.secondaryMessage, { + title_color, + text_color, + bg_color, + border_color, + theme, + }), + ); + } +}; diff --git a/frontend/frontend/src/backend/api-renamed/index.js b/frontend/frontend/src/backend/api-renamed/index.js new file mode 100644 index 00000000..a1a7b90b --- /dev/null +++ b/frontend/frontend/src/backend/api-renamed/index.js @@ -0,0 +1,207 @@ +import { renderStatsCard } from "../src/cards/stats.js"; +import { blacklist } from "../src/common/blacklist.js"; +import { whitelist } from "../src/common/whitelist.js"; +import { + clampValue, + CONSTANTS, + parseArray, + parseBoolean, + renderError, +} from "../src/common/utils.js"; +import { fetchStats } from "../src/fetchers/stats.js"; +import { isLocaleAvailable } from "../src/translations.js"; +import { storeRequest } from "../src/common/database.js"; + +export default async (req, res) => { + const { + username, + repo, + owner, + hide, + hide_title, + hide_border, + card_width, + hide_rank, + show_icons, + include_all_commits, + line_height, + title_color, + ring_color, + icon_color, + text_color, + text_bold, + bg_color, + theme, + cache_seconds, + exclude_repo, + custom_title, + locale, + disable_animations, + border_radius, + number_format, + role, + border_color, + rank_icon, + show, + } = req.query; + res.setHeader("Content-Type", "image/svg+xml"); + + if (whitelist && !whitelist.includes(username)) { + return res.send( + renderError( + "This username is not whitelisted", + "Please deploy your own instance", + { + title_color, + text_color, + bg_color, + border_color, + theme, + show_repo_link: false, + }, + ), + ); + } + + if (whitelist === undefined && blacklist.includes(username)) { + return res.send( + renderError( + "This username is blacklisted", + "Please deploy your own instance", + { + title_color, + text_color, + bg_color, + border_color, + theme, + show_repo_link: false, + }, + ), + ); + } + + if (locale && !isLocaleAvailable(locale)) { + return res.send( + renderError("Something went wrong", "Language not found", { + title_color, + text_color, + bg_color, + border_color, + theme, + }), + ); + } + + const safePattern = /^[-\w\/.,]+$/; + if ( + (username && !safePattern.test(username)) || + (repo && !safePattern.test(repo)) || + (owner && !safePattern.test(owner)) + ) { + return res.send( + renderError( + "Something went wrong", + "Username, repository or owner contains unsafe characters", + { + title_color, + text_color, + bg_color, + border_color, + theme, + }, + ), + ); + } + + try { + await storeRequest(req); + const showStats = parseArray(show); + const repoOwner = parseArray(owner); + let repository = parseArray(repo); + repository = repository.map((repo) => + repo.includes("/") ? repo : `${username}/${repo}`, + ); + + const stats = await fetchStats( + username, + parseBoolean(include_all_commits), + parseArray(exclude_repo), + showStats.includes("prs_merged") || + showStats.includes("prs_merged_percentage"), + showStats.includes("discussions_started"), + showStats.includes("discussions_answered"), + repository, + repoOwner, + showStats.includes("prs_authored"), + showStats.includes("prs_commented"), + showStats.includes("prs_reviewed"), + showStats.includes("issues_authored"), + showStats.includes("issues_commented"), + parseArray(role), + ); + + let cacheSeconds = clampValue( + parseInt(cache_seconds || CONSTANTS.CARD_CACHE_SECONDS, 10), + CONSTANTS.FOUR_HOURS, + CONSTANTS.TWO_DAY, + ); + cacheSeconds = process.env.CACHE_SECONDS + ? parseInt(process.env.CACHE_SECONDS, 10) || cacheSeconds + : cacheSeconds; + + res.setHeader( + "Cache-Control", + `max-age=${cacheSeconds}, s-maxage=${cacheSeconds}, stale-while-revalidate=${CONSTANTS.ONE_DAY}`, + ); + + return res.send( + renderStatsCard( + stats, + { + hide: parseArray(hide), + show_icons: parseBoolean(show_icons), + hide_title: parseBoolean(hide_title), + hide_border: parseBoolean(hide_border), + card_width: parseInt(card_width, 10), + hide_rank: parseBoolean(hide_rank), + include_all_commits: parseBoolean(include_all_commits), + line_height, + title_color, + ring_color, + icon_color, + text_color, + text_bold: parseBoolean(text_bold), + bg_color, + theme, + custom_title, + border_radius, + border_color, + number_format, + locale: locale ? locale.toLowerCase() : null, + disable_animations: parseBoolean(disable_animations), + rank_icon, + show: showStats, + }, + username, + repository, + repoOwner, + ), + ); + } catch (err) { + res.setHeader( + "Cache-Control", + `max-age=${CONSTANTS.ERROR_CACHE_SECONDS / 2}, s-maxage=${ + CONSTANTS.ERROR_CACHE_SECONDS + }, stale-while-revalidate=${CONSTANTS.ONE_DAY}`, + ); // Use lower cache period for errors. + return res.send( + renderError(err.message, err.secondaryMessage, { + title_color, + text_color, + bg_color, + border_color, + theme, + }), + ); + } +}; diff --git a/frontend/frontend/src/backend/api-renamed/pin.js b/frontend/frontend/src/backend/api-renamed/pin.js new file mode 100644 index 00000000..8dc7e8d7 --- /dev/null +++ b/frontend/frontend/src/backend/api-renamed/pin.js @@ -0,0 +1,173 @@ +import { renderRepoCard } from "../src/cards/repo.js"; +import { blacklist } from "../src/common/blacklist.js"; +import { whitelist } from "../src/common/whitelist.js"; +import { + clampValue, + CONSTANTS, + parseArray, + parseBoolean, + renderError, +} from "../src/common/utils.js"; +import { fetchRepo } from "../src/fetchers/repo.js"; +import { isLocaleAvailable } from "../src/translations.js"; +import { storeRequest } from "../src/common/database.js"; + +export default async (req, res) => { + const { + username, + repo, + hide_border, + title_color, + icon_color, + text_color, + bg_color, + card_width, + theme, + show_owner, + show, + show_icons, + number_format, + text_bold, + line_height, + cache_seconds, + locale, + border_radius, + border_color, + description_lines_count, + } = req.query; + + res.setHeader("Content-Type", "image/svg+xml"); + + if (whitelist && !whitelist.includes(username)) { + return res.send( + renderError( + "This username is not whitelisted", + "Please deploy your own instance", + { + title_color, + text_color, + bg_color, + border_color, + theme, + show_repo_link: false, + }, + ), + ); + } + + if (whitelist === undefined && blacklist.includes(username)) { + return res.send( + renderError( + "This username is blacklisted", + "Please deploy your own instance", + { + title_color, + text_color, + bg_color, + border_color, + theme, + show_repo_link: false, + }, + ), + ); + } + + if (locale && !isLocaleAvailable(locale)) { + return res.send( + renderError("Something went wrong", "Language not found", { + title_color, + text_color, + bg_color, + border_color, + theme, + }), + ); + } + + const safePattern = /^[-\w\/.,]+$/; + if ( + (username && !safePattern.test(username)) || + (repo && !safePattern.test(repo)) + ) { + return res.send( + renderError( + "Something went wrong", + "Username or repository contains unsafe characters", + { + title_color, + text_color, + bg_color, + border_color, + theme, + }, + ), + ); + } + + try { + await storeRequest(req); + const showStats = parseArray(show); + const repoData = await fetchRepo( + username, + repo, + showStats.includes("prs_authored"), + showStats.includes("prs_commented"), + showStats.includes("prs_reviewed"), + showStats.includes("issues_authored"), + showStats.includes("issues_commented"), + ); + + let cacheSeconds = clampValue( + parseInt(cache_seconds || CONSTANTS.PIN_CARD_CACHE_SECONDS, 10), + CONSTANTS.FOUR_HOURS, + CONSTANTS.TEN_DAY, + ); + cacheSeconds = process.env.CACHE_SECONDS + ? parseInt(process.env.CACHE_SECONDS, 10) || cacheSeconds + : cacheSeconds; + + res.setHeader( + "Cache-Control", + `max-age=${cacheSeconds}, s-maxage=${cacheSeconds}`, + ); + + return res.send( + renderRepoCard(repoData, { + hide_border: parseBoolean(hide_border), + title_color, + icon_color, + text_color, + bg_color, + theme, + border_radius, + border_color, + card_width_input: parseInt(card_width, 10), + show_owner: parseBoolean(show_owner), + show: showStats, + show_icons: parseBoolean(show_icons), + number_format, + text_bold: parseBoolean(text_bold), + line_height, + username, + locale: locale ? locale.toLowerCase() : null, + description_lines_count, + }), + ); + } catch (err) { + res.setHeader( + "Cache-Control", + `max-age=${CONSTANTS.ERROR_CACHE_SECONDS / 2}, s-maxage=${ + CONSTANTS.ERROR_CACHE_SECONDS + }, stale-while-revalidate=${CONSTANTS.ONE_DAY}`, + ); // Use lower cache period for errors. + return res.send( + renderError(err.message, err.secondaryMessage, { + title_color, + text_color, + bg_color, + border_color, + theme, + }), + ); + } +}; diff --git a/frontend/frontend/src/backend/api-renamed/private-access.js b/frontend/frontend/src/backend/api-renamed/private-access.js new file mode 100644 index 00000000..c7957164 --- /dev/null +++ b/frontend/frontend/src/backend/api-renamed/private-access.js @@ -0,0 +1,17 @@ +import { logger } from "../src/common/utils.js"; +import { hasPrivateAccess } from "../src/common/database.js"; + +/** + * @param {any} req The request. + * @param {any} res The response. + */ +export default async (req, res) => { + const { user_key } = req.query; + try { + const result = await hasPrivateAccess(user_key); + res.send(result); + } catch (err) { + logger.error(err); + res.send("Something went wrong: " + err.message); + } +}; diff --git a/frontend/frontend/src/backend/api-renamed/repeat-recent.js b/frontend/frontend/src/backend/api-renamed/repeat-recent.js new file mode 100644 index 00000000..a54ce464 --- /dev/null +++ b/frontend/frontend/src/backend/api-renamed/repeat-recent.js @@ -0,0 +1,18 @@ +import { repeatRecentRequests } from "../src/repeatRequests.js"; + +export default async (req, res) => { + if (req.method !== "POST") { + res.statusCode = 405; + res.send({ error: "Method Not Allowed" }); + return; + } + try { + await repeatRecentRequests(); + res.statusCode = 200; + res.send({ message: "Recent requests repeated successfully." }); + } catch (error) { + console.error("Error repeating recent requests:", error); + res.statusCode = 500; + res.send({ error: error.message || "Internal Server Error" }); + } +}; diff --git a/frontend/frontend/src/backend/api-renamed/status/pat-info.js b/frontend/frontend/src/backend/api-renamed/status/pat-info.js new file mode 100644 index 00000000..8b15d76b --- /dev/null +++ b/frontend/frontend/src/backend/api-renamed/status/pat-info.js @@ -0,0 +1,158 @@ +/** + * @file Contains a simple cloud function that can be used to check which PATs are no + * longer working. It returns a list of valid PATs, expired PATs and PATs with errors. + * + * @description This function is currently rate limited to 1 request per 3 minutes. + */ + +import { logger, request, dateDiff } from "../../src/common/utils.js"; +export const RATE_LIMIT_SECONDS = 60 * 3; // 1 request per 3 minutes + +/** + * @typedef {import('axios').AxiosRequestHeaders} AxiosRequestHeaders Axios request headers. + * @typedef {import('axios').AxiosResponse} AxiosResponse Axios response. + */ + +/** + * Simple uptime check fetcher for the PATs. + * + * @param {AxiosRequestHeaders} variables Fetcher variables. + * @param {string} token GitHub token. + * @returns {Promise} The response. + */ +const uptimeFetcher = (variables, token) => { + return request( + { + query: ` + query { + rateLimit { + remaining + resetAt + }, + }`, + variables, + }, + { + Authorization: `bearer ${token}`, + }, + ); +}; + +const getAllPATs = () => { + return Object.keys(process.env).filter((key) => /PAT_\d*$/.exec(key)); +}; + +/** + * @typedef {(variables: AxiosRequestHeaders, token: string) => Promise} Fetcher The fetcher function. + * @typedef {{validPATs: string[], expiredPATs: string[], exhaustedPATs: string[], suspendedPATs: string[], errorPATs: string[], details: any}} PATInfo The PAT info. + */ + +/** + * Check whether any of the PATs is expired. + * + * @param {Fetcher} fetcher The fetcher function. + * @param {AxiosRequestHeaders} variables Fetcher variables. + * @returns {Promise} The response. + */ +const getPATInfo = async (fetcher, variables) => { + const details = {}; + const PATs = getAllPATs(); + + for (const pat of PATs) { + try { + const response = await fetcher(variables, process.env[pat]); + const errors = response.data.errors; + const hasErrors = Boolean(errors); + const errorType = errors?.[0]?.type; + const isRateLimited = + (hasErrors && errorType === "RATE_LIMITED") || + response.data.data?.rateLimit?.remaining === 0; + + // Store PATs with errors. + if (hasErrors && errorType !== "RATE_LIMITED") { + details[pat] = { + status: "error", + error: { + type: errors[0].type, + message: errors[0].message, + }, + }; + continue; + } else if (isRateLimited) { + const date1 = new Date(); + const date2 = new Date(response.data?.data?.rateLimit?.resetAt); + details[pat] = { + status: "exhausted", + remaining: 0, + resetIn: dateDiff(date2, date1) + " minutes", + }; + } else { + details[pat] = { + status: "valid", + remaining: response.data.data.rateLimit.remaining, + }; + } + } catch (err) { + // Store the PAT if it is expired. + const errorMessage = err.response?.data?.message?.toLowerCase(); + if (errorMessage === "bad credentials") { + details[pat] = { + status: "expired", + }; + } else if (errorMessage === "sorry. your account was suspended.") { + details[pat] = { + status: "suspended", + }; + } else { + throw err; + } + } + } + + const filterPATsByStatus = (status) => { + return Object.keys(details).filter((pat) => details[pat].status === status); + }; + + const sortedDetails = Object.keys(details) + .sort() + .reduce((obj, key) => { + obj[key] = details[key]; + return obj; + }, {}); + + return { + validPATs: filterPATsByStatus("valid"), + expiredPATs: filterPATsByStatus("expired"), + exhaustedPATs: filterPATsByStatus("exhausted"), + suspendedPATs: filterPATsByStatus("suspended"), + errorPATs: filterPATsByStatus("error"), + details: sortedDetails, + }; +}; + +/** + * Cloud function that returns information about the used PATs. + * + * @param {any} _ The request. + * @param {any} res The response. + * @returns {Promise} The response. + */ +export default async (_, res) => { + res.setHeader("Content-Type", "application/json"); + try { + // Add header to prevent abuse. + const PATsInfo = await getPATInfo(uptimeFetcher, {}); + if (PATsInfo) { + res.setHeader( + "Cache-Control", + `max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`, + ); + } + res.send(JSON.stringify(PATsInfo, null, 2)); + } catch (err) { + // Throw error if something went wrong. + logger.error(err); + res.setHeader("Cache-Control", "no-store"); + res.send("Something went wrong: " + err.message); + } +}; diff --git a/frontend/frontend/src/backend/api-renamed/status/up.js b/frontend/frontend/src/backend/api-renamed/status/up.js new file mode 100644 index 00000000..168930b7 --- /dev/null +++ b/frontend/frontend/src/backend/api-renamed/status/up.js @@ -0,0 +1,126 @@ +/** + * @file Contains a simple cloud function that can be used to check if the PATs are still + * functional. + * + * @description This function is currently rate limited to 1 request per 3 minutes. + */ + +import retryer from "../../src/common/retryer.js"; +import { logger, request } from "../../src/common/utils.js"; + +export const RATE_LIMIT_SECONDS = 60 * 3; // 1 request per 3 minutes + +/** + * @typedef {import('axios').AxiosRequestHeaders} AxiosRequestHeaders Axios request headers. + * @typedef {import('axios').AxiosResponse} AxiosResponse Axios response. + */ + +/** + * Simple uptime check fetcher for the PATs. + * + * @param {AxiosRequestHeaders} variables Fetcher variables. + * @param {string} token GitHub token. + * @returns {Promise} The response. + */ +const uptimeFetcher = (variables, token) => { + return request( + { + query: ` + query { + rateLimit { + remaining + } + } + `, + variables, + }, + { + Authorization: `bearer ${token}`, + }, + ); +}; + +/** + * @typedef {{ + * schemaVersion: number; + * label: string; + * message: "up" | "down"; + * color: "brightgreen" | "red"; + * isError: boolean + * }} ShieldsResponse Shields.io response object. + */ + +/** + * Creates Json response that can be used for shields.io dynamic card generation. + * + * @param {boolean} up Whether the PATs are up or not. + * @returns {ShieldsResponse} Dynamic shields.io JSON response object. + * + * @see https://shields.io/endpoint. + */ +const shieldsUptimeBadge = (up) => { + const schemaVersion = 1; + const isError = true; + const label = "Public Instance"; + const message = up ? "up" : "down"; + const color = up ? "brightgreen" : "red"; + return { + schemaVersion, + label, + message, + color, + isError, + }; +}; + +/** + * Cloud function that returns whether the PATs are still functional. + * + * @param {any} req The request. + * @param {any} res The response. + * @returns {Promise} Nothing. + */ +export default async (req, res) => { + let { type } = req.query; + type = type ? type.toLowerCase() : "boolean"; + + res.setHeader("Content-Type", "application/json"); + + try { + let PATsValid = true; + try { + await retryer(uptimeFetcher, {}); + } catch (err) { + // Resolve eslint no-unused-vars + err; + + PATsValid = false; + } + + if (PATsValid) { + res.setHeader( + "Cache-Control", + `max-age=0, s-maxage=${RATE_LIMIT_SECONDS}`, + ); + } else { + res.setHeader("Cache-Control", "no-store"); + } + + switch (type) { + case "shields": + res.send(shieldsUptimeBadge(PATsValid)); + break; + case "json": + res.send({ up: PATsValid }); + break; + default: + res.send(PATsValid); + break; + } + } catch (err) { + // Return fail boolean if something went wrong. + logger.error(err); + res.setHeader("Cache-Control", "no-store"); + res.send("Something went wrong: " + err.message); + } +}; diff --git a/frontend/frontend/src/backend/api-renamed/top-langs.js b/frontend/frontend/src/backend/api-renamed/top-langs.js new file mode 100644 index 00000000..46672cf4 --- /dev/null +++ b/frontend/frontend/src/backend/api-renamed/top-langs.js @@ -0,0 +1,151 @@ +import { renderTopLanguages } from "../src/cards/top-languages.js"; +import { blacklist } from "../src/common/blacklist.js"; +import { whitelist } from "../src/common/whitelist.js"; +import { + clampValue, + CONSTANTS, + parseArray, + parseBoolean, + renderError, +} from "../src/common/utils.js"; +import { fetchTopLanguages } from "../src/fetchers/top-languages.js"; +import { isLocaleAvailable } from "../src/translations.js"; +import { storeRequest } from "../src/common/database.js"; + +export default async (req, res) => { + const { + username, + hide, + hide_title, + hide_border, + card_width, + title_color, + text_color, + bg_color, + theme, + cache_seconds, + layout, + langs_count, + exclude_repo, + size_weight, + count_weight, + custom_title, + locale, + border_radius, + border_color, + role, + disable_animations, + hide_progress, + } = req.query; + res.setHeader("Content-Type", "image/svg+xml"); + + if (whitelist && !whitelist.includes(username)) { + return res.send( + renderError( + "This username is not whitelisted", + "Please deploy your own instance", + { + title_color, + text_color, + bg_color, + border_color, + theme, + show_repo_link: false, + }, + ), + ); + } + + if (whitelist === undefined && blacklist.includes(username)) { + return res.send( + renderError( + "This username is blacklisted", + "Please deploy your own instance", + { + title_color, + text_color, + bg_color, + border_color, + theme, + show_repo_link: false, + }, + ), + ); + } + + if (locale && !isLocaleAvailable(locale)) { + return res.send(renderError("Something went wrong", "Locale not found")); + } + + if ( + layout !== undefined && + (typeof layout !== "string" || + !["compact", "normal", "donut", "donut-vertical", "pie"].includes(layout)) + ) { + return res.send( + renderError("Something went wrong", "Incorrect layout input"), + ); + } + + try { + await storeRequest(req); + const topLangs = await fetchTopLanguages( + username, + parseArray(exclude_repo), + size_weight, + count_weight, + parseArray(role), + ); + + let cacheSeconds = clampValue( + parseInt(cache_seconds || CONSTANTS.TOP_LANGS_CACHE_SECONDS, 10), + CONSTANTS.FOUR_HOURS, + CONSTANTS.TEN_DAY, + ); + cacheSeconds = process.env.CACHE_SECONDS + ? parseInt(process.env.CACHE_SECONDS, 10) || cacheSeconds + : cacheSeconds; + + res.setHeader( + "Cache-Control", + `max-age=${cacheSeconds / 2}, s-maxage=${cacheSeconds}`, + ); + + return res.send( + renderTopLanguages(topLangs, { + custom_title, + hide_title: parseBoolean(hide_title), + hide_border: parseBoolean(hide_border), + card_width: parseInt(card_width, 10), + hide: parseArray(hide), + title_color, + text_color, + bg_color, + theme, + layout, + langs_count, + border_radius, + border_color, + locale: locale ? locale.toLowerCase() : null, + disable_animations: parseBoolean(disable_animations), + hide_progress: parseBoolean(hide_progress), + }), + ); + } catch (err) { + res.setHeader( + "Cache-Control", + `max-age=${CONSTANTS.ERROR_CACHE_SECONDS / 2}, s-maxage=${ + CONSTANTS.ERROR_CACHE_SECONDS + }, stale-while-revalidate=${CONSTANTS.ONE_DAY}`, + ); // Use lower cache period for errors. + return res.send( + renderError(err.message, err.secondaryMessage, { + title_color, + text_color, + bg_color, + border_color, + theme, + }), + ); + } +}; diff --git a/frontend/frontend/src/backend/api-renamed/wakatime.js b/frontend/frontend/src/backend/api-renamed/wakatime.js new file mode 100644 index 00000000..32c501a4 --- /dev/null +++ b/frontend/frontend/src/backend/api-renamed/wakatime.js @@ -0,0 +1,131 @@ +import { renderWakatimeCard } from "../src/cards/wakatime.js"; +import { + clampValue, + CONSTANTS, + parseArray, + parseBoolean, + renderError, +} from "../src/common/utils.js"; +import { whitelist } from "../src/common/whitelist.js"; +import { fetchWakatimeStats } from "../src/fetchers/wakatime.js"; +import { isLocaleAvailable } from "../src/translations.js"; +import { storeRequest } from "../src/common/database.js"; + +export default async (req, res) => { + const { + username, + title_color, + icon_color, + hide_border, + card_width, + line_height, + text_color, + bg_color, + theme, + cache_seconds, + hide_title, + hide_progress, + custom_title, + locale, + layout, + langs_count, + hide, + api_domain, + border_radius, + border_color, + display_format, + disable_animations, + } = req.query; + + res.setHeader("Content-Type", "image/svg+xml"); + + if (whitelist && !whitelist.includes(username)) { + return res.send( + renderError( + "This username is not whitelisted", + "Please deploy your own instance", + { + title_color, + text_color, + bg_color, + border_color, + theme, + show_repo_link: false, + }, + ), + ); + } + + if (locale && !isLocaleAvailable(locale)) { + return res.send( + renderError("Something went wrong", "Language not found", { + title_color, + text_color, + bg_color, + border_color, + theme, + }), + ); + } + + try { + await storeRequest(req); + const stats = await fetchWakatimeStats({ username, api_domain }); + + let cacheSeconds = clampValue( + parseInt(cache_seconds || CONSTANTS.CARD_CACHE_SECONDS, 10), + CONSTANTS.FOUR_HOURS, + CONSTANTS.TWO_DAY, + ); + cacheSeconds = process.env.CACHE_SECONDS + ? parseInt(process.env.CACHE_SECONDS, 10) || cacheSeconds + : cacheSeconds; + + res.setHeader( + "Cache-Control", + `max-age=${ + cacheSeconds / 2 + }, s-maxage=${cacheSeconds}, stale-while-revalidate=${CONSTANTS.ONE_DAY}`, + ); + + return res.send( + renderWakatimeCard(stats, { + custom_title, + hide_title: parseBoolean(hide_title), + hide_border: parseBoolean(hide_border), + card_width: parseInt(card_width, 10), + hide: parseArray(hide), + line_height, + title_color, + icon_color, + text_color, + bg_color, + theme, + hide_progress, + border_radius, + border_color, + locale: locale ? locale.toLowerCase() : null, + layout, + langs_count, + display_format, + disable_animations: parseBoolean(disable_animations), + }), + ); + } catch (err) { + res.setHeader( + "Cache-Control", + `max-age=${CONSTANTS.ERROR_CACHE_SECONDS / 2}, s-maxage=${ + CONSTANTS.ERROR_CACHE_SECONDS + }, stale-while-revalidate=${CONSTANTS.ONE_DAY}`, + ); // Use lower cache period for errors. + return res.send( + renderError(err.message, err.secondaryMessage, { + title_color, + text_color, + bg_color, + border_color, + theme, + }), + ); + } +}; diff --git a/frontend/frontend/src/backend/src/calculateRank.js b/frontend/frontend/src/backend/src/calculateRank.js new file mode 100644 index 00000000..4724d038 --- /dev/null +++ b/frontend/frontend/src/backend/src/calculateRank.js @@ -0,0 +1,87 @@ +/** + * Calculates the exponential cdf. + * + * @param {number} x The value. + * @returns {number} The exponential cdf. + */ +function exponential_cdf(x) { + return 1 - 2 ** -x; +} + +/** + * Calculates the log normal cdf. + * + * @param {number} x The value. + * @returns {number} The log normal cdf. + */ +function log_normal_cdf(x) { + // approximation + return x / (1 + x); +} + +/** + * Calculates the users rank. + * + * @param {object} params Parameters on which the user's rank depends. + * @param {boolean} params.all_commits Whether `include_all_commits` was used. + * @param {number} params.commits Number of commits. + * @param {number} params.prs The number of pull requests. + * @param {number} params.issues The number of issues. + * @param {number} params.reviews The number of reviews. + * @param {number} params.repos Total number of repos. + * @param {number} params.stars The number of stars. + * @param {number} params.followers The number of followers. + * @returns {{level: string, percentile: number}}} The users rank. + */ +function calculateRank({ + all_commits, + commits, + prs, + issues, + reviews, + // eslint-disable-next-line no-unused-vars + repos, // unused + stars, + followers, +}) { + const COMMITS_MEDIAN = all_commits ? 1000 : 250, + COMMITS_WEIGHT = 2; + const PRS_MEDIAN = 50, + PRS_WEIGHT = 3; + const ISSUES_MEDIAN = 25, + ISSUES_WEIGHT = 1; + const REVIEWS_MEDIAN = 2, + REVIEWS_WEIGHT = 1; + const STARS_MEDIAN = 50, + STARS_WEIGHT = 4; + const FOLLOWERS_MEDIAN = 10, + FOLLOWERS_WEIGHT = 1; + + const TOTAL_WEIGHT = + COMMITS_WEIGHT + + PRS_WEIGHT + + ISSUES_WEIGHT + + REVIEWS_WEIGHT + + STARS_WEIGHT + + FOLLOWERS_WEIGHT; + + const THRESHOLDS = [1, 12.5, 25, 37.5, 50, 62.5, 75, 87.5, 100]; + const LEVELS = ["S", "A+", "A", "A-", "B+", "B", "B-", "C+", "C"]; + + const rank = + 1 - + (COMMITS_WEIGHT * exponential_cdf(commits / COMMITS_MEDIAN) + + PRS_WEIGHT * exponential_cdf(prs / PRS_MEDIAN) + + ISSUES_WEIGHT * exponential_cdf(issues / ISSUES_MEDIAN) + + REVIEWS_WEIGHT * exponential_cdf(reviews / REVIEWS_MEDIAN) + + STARS_WEIGHT * log_normal_cdf(stars / STARS_MEDIAN) + + FOLLOWERS_WEIGHT * log_normal_cdf(followers / FOLLOWERS_MEDIAN)) / + TOTAL_WEIGHT; + + const level = LEVELS[THRESHOLDS.findIndex((t) => rank * 100 <= t)]; + + return { level, percentile: rank * 100 }; +} + +export { calculateRank }; +export default calculateRank; diff --git a/frontend/frontend/src/backend/src/cards/gist.js b/frontend/frontend/src/backend/src/cards/gist.js new file mode 100644 index 00000000..9e889e74 --- /dev/null +++ b/frontend/frontend/src/backend/src/cards/gist.js @@ -0,0 +1,152 @@ +// @ts-check + +import { + getCardColors, + parseEmojis, + wrapTextMultiline, + encodeHTML, + kFormatter, + measureText, + flexLayout, + iconWithLabel, + createLanguageNode, +} from "../common/utils.js"; +import Card from "../common/Card.js"; +import { icons } from "../common/icons.js"; + +/** Import language colors. + * + * @description Here we use the workaround found in + * https://stackoverflow.com/questions/66726365/how-should-i-import-json-in-node + * since vercel is using v16.14.0 which does not yet support json imports without the + * --experimental-json-modules flag. + */ +import { createRequire } from "module"; +const require = createRequire(import.meta.url); +const languageColors = require("../common/languageColors.json"); // now works + +const ICON_SIZE = 16; +const CARD_DEFAULT_WIDTH = 400; +const HEADER_MAX_LENGTH = 35; + +/** + * @typedef {import('./types').GistCardOptions} GistCardOptions Gist card options. + * @typedef {import('../fetchers/types').GistData} GistData Gist data. + */ + +/** + * Render gist card. + * + * @param {GistData} gistData Gist data. + * @param {Partial} options Gist card options. + * @returns {string} Gist card. + */ +const renderGistCard = (gistData, options = {}) => { + const { name, nameWithOwner, description, language, starsCount, forksCount } = + gistData; + const { + title_color, + icon_color, + text_color, + bg_color, + theme, + border_radius, + border_color, + show_owner = false, + hide_border = false, + } = options; + + // returns theme based colors with proper overrides and defaults + const { titleColor, textColor, iconColor, bgColor, borderColor } = + getCardColors({ + title_color, + icon_color, + text_color, + bg_color, + border_color, + theme, + }); + + const lineWidth = 59; + const linesLimit = 10; + const desc = parseEmojis(description || "No description provided"); + const multiLineDescription = wrapTextMultiline(desc, lineWidth, linesLimit); + const descriptionLines = multiLineDescription.length; + const descriptionSvg = multiLineDescription + .map((line) => `${encodeHTML(line)}`) + .join(""); + + const lineHeight = descriptionLines > 3 ? 12 : 10; + const height = + (descriptionLines > 1 ? 120 : 110) + descriptionLines * lineHeight; + + const totalStars = kFormatter(starsCount); + const totalForks = kFormatter(forksCount); + const svgStars = iconWithLabel( + icons.star, + totalStars, + "starsCount", + ICON_SIZE, + ); + const svgForks = iconWithLabel( + icons.fork, + totalForks, + "forksCount", + ICON_SIZE, + ); + + const languageName = language || "Unspecified"; + const languageColor = languageColors[languageName] || "#858585"; + + const svgLanguage = createLanguageNode(languageName, languageColor); + + const starAndForkCount = flexLayout({ + items: [svgLanguage, svgStars, svgForks], + sizes: [ + measureText(languageName, 12), + ICON_SIZE + measureText(`${totalStars}`, 12), + ICON_SIZE + measureText(`${totalForks}`, 12), + ], + gap: 25, + }).join(""); + + const header = show_owner ? nameWithOwner : name; + + const card = new Card({ + defaultTitle: + header.length > HEADER_MAX_LENGTH + ? `${header.slice(0, HEADER_MAX_LENGTH)}...` + : header, + titlePrefixIcon: icons.gist, + width: CARD_DEFAULT_WIDTH, + height, + border_radius, + colors: { + titleColor, + textColor, + iconColor, + bgColor, + borderColor, + }, + }); + + card.setCSS(` + .description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} } + .gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} } + .icon { fill: ${iconColor} } + `); + card.setHideBorder(hide_border); + + return card.render(` + + ${descriptionSvg} + + + + ${starAndForkCount} + + `); +}; + +export { renderGistCard, HEADER_MAX_LENGTH }; +export default renderGistCard; diff --git a/frontend/frontend/src/backend/src/cards/index.js b/frontend/frontend/src/backend/src/cards/index.js new file mode 100644 index 00000000..5ca3a97a --- /dev/null +++ b/frontend/frontend/src/backend/src/cards/index.js @@ -0,0 +1,4 @@ +export { renderRepoCard } from "./repo.js"; +export { renderStatsCard } from "./stats.js"; +export { renderTopLanguages } from "./top-languages.js"; +export { renderWakatimeCard } from "./wakatime.js"; diff --git a/frontend/frontend/src/backend/src/cards/repo.js b/frontend/frontend/src/backend/src/cards/repo.js new file mode 100644 index 00000000..52010548 --- /dev/null +++ b/frontend/frontend/src/backend/src/cards/repo.js @@ -0,0 +1,326 @@ +// @ts-check +import { Card } from "../common/Card.js"; +import { I18n } from "../common/I18n.js"; +import { icons } from "../common/icons.js"; +import { + encodeHTML, + flexLayout, + getCardColors, + kFormatter, + measureText, + parseEmojis, + wrapTextMultiline, + iconWithLabel, + createLanguageNode, + clampValue, + buildSearchFilter, +} from "../common/utils.js"; +import { repoCardLocales } from "../translations.js"; +import { createTextNode } from "./stats.js"; + +const ICON_SIZE = 16; +const DESCRIPTION_LINE_WIDTH = 59; +const DESCRIPTION_MAX_LINES = 3; + +/** + * Retrieves the repository description and wraps it to fit the card width. + * + * @param {string} label The repository description. + * @param {string} textColor The color of the text. + * @returns {string} Wrapped repo description SVG object. + */ +const getBadgeSVG = (label, textColor, xOffset = 0) => ` + + + + ${label} + + +`; + +/** + * @typedef {import("../fetchers/types").RepositoryData} RepositoryData Repository data. + * @typedef {import("./types").RepoCardOptions} RepoCardOptions Repo card options. + */ + +/** + * Renders repository card details. + * + * @param {RepositoryData} repo Repository data. + * @param {Partial} options Card options. + * @returns {string} Repository card SVG object. + */ +const renderRepoCard = (repo, options = {}) => { + const { + name, + nameWithOwner, + description, + primaryLanguage, + isArchived, + isTemplate, + starCount, + forkCount, + totalPRsAuthored, + totalPRsCommented, + totalPRsReviewed, + totalIssuesAuthored, + totalIssuesCommented, + } = repo; + const { + hide_border = false, + title_color, + icon_color, + text_color, + bg_color, + card_width_input, + show_owner = false, + show = [], + show_icons = true, + number_format = "short", + text_bold = false, + line_height = 22, + username, + theme = "default_repocard", + border_radius, + border_color, + locale, + description_lines_count, + } = options; + + const card_width = + card_width_input && !isNaN(card_width_input) + ? card_width_input + : show.length >= 2 + ? 430 + : 400; + + const i18n = new I18n({ + locale, + translations: repoCardLocales, + }); + + let repoFilter = encodeURIComponent(buildSearchFilter([nameWithOwner], [])); + const STATS = {}; + if (show.includes("prs_authored")) { + STATS.prs_authored = { + icon: icons.prs, + label: i18n.t("repocard.prs-authored"), + value: totalPRsAuthored, + id: "prs_authored", + link: `https://github.com/search?q=${repoFilter}author%3A${username}&type=pullrequests`, + }; + } + if (show.includes("prs_commented")) { + STATS.prs_commented = { + icon: icons.comments, + label: i18n.t("repocard.prs-commented"), + value: totalPRsCommented, + id: "prs_commented", + link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&type=pullrequests`, + }; + } + if (show.includes("prs_reviewed")) { + STATS.prs_reviewed = { + icon: icons.reviews, + label: i18n.t("repocard.prs-reviewed"), + value: totalPRsReviewed, + id: "prs_reviewed", + link: `https://github.com/search?q=${repoFilter}reviewed-by%3A${username}+-author%3A${username}&type=pullrequests`, + }; + } + if (show.includes("issues_authored")) { + STATS.issues_authored = { + icon: icons.issues, + label: i18n.t("repocard.issues-authored"), + value: totalIssuesAuthored, + id: "issues_authored", + link: `https://github.com/search?q=${repoFilter}author%3A${username}&type=issues`, + }; + } + if (show.includes("issues_commented")) { + STATS.issues_commented = { + icon: icons.discussions_started, + label: i18n.t("repocard.issues-commented"), + value: totalIssuesCommented, + id: "issues_commented", + link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&type=issues`, + }; + } + + const statItems = Object.keys(STATS).map((key, index) => + // create the text nodes, and pass index so that we can calculate the line spacing + createTextNode({ + icon: STATS[key].icon, + label: STATS[key].label, + value: STATS[key].value, + id: STATS[key].id, + unitSymbol: STATS[key].unitSymbol, + index, + showIcons: show_icons, + shiftValuePos: 14.01, + bold: text_bold, + number_format, + link: STATS[key].link, + labelXOffset: 23, + }), + ); + + const extraLHeight = parseInt(String(line_height), 10); + const lineHeight = 10; + const header = show_owner ? nameWithOwner : name; + const langName = (primaryLanguage && primaryLanguage.name) || "Unspecified"; + const langColor = (primaryLanguage && primaryLanguage.color) || "#333"; + const descriptionMaxLines = description_lines_count + ? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES) + : DESCRIPTION_MAX_LINES; + + const desc = parseEmojis(description || "No description provided"); + const multiLineDescription = wrapTextMultiline( + desc, + Math.round((card_width - 400) / 5.93 + DESCRIPTION_LINE_WIDTH), + descriptionMaxLines, + ); + const descriptionLinesCount = description_lines_count + ? clampValue(description_lines_count, 1, DESCRIPTION_MAX_LINES) + : multiLineDescription.length; + + const descriptionSvg = multiLineDescription + .map((line) => `${encodeHTML(line)}`) + .join(""); + + const extraHeight = Object.keys(STATS).length + ? -7 + (Math.ceil(statItems.length / 2) + 1) * extraLHeight + : 0; + const height = + (descriptionLinesCount > 1 ? 120 : 110) + + descriptionLinesCount * lineHeight + + extraHeight; + + // returns theme based colors with proper overrides and defaults + const colors = getCardColors({ + title_color, + icon_color, + text_color, + bg_color, + border_color, + theme, + }); + + const svgLanguage = primaryLanguage + ? createLanguageNode(langName, langColor) + : ""; + + const totalStars = kFormatter(starCount); + const totalForks = kFormatter(forkCount); + const svgStars = iconWithLabel( + icons.star, + totalStars, + "stargazers", + ICON_SIZE, + ); + const svgForks = iconWithLabel( + icons.fork, + totalForks, + "forkcount", + ICON_SIZE, + ); + + const starAndForkCount = flexLayout({ + items: [svgLanguage, svgStars, svgForks], + sizes: [ + measureText(langName, 12), + ICON_SIZE + measureText(`${totalStars}`, 12), + ICON_SIZE + measureText(`${totalForks}`, 12), + ], + gap: 25, + }).join(""); + + let extraRows = []; + for (let i = 0; i < statItems.length; i += 2) { + extraRows.push( + flexLayout({ + items: statItems.slice(i, i + 2), + gap: 210, + direction: "row", + }).join(""), + ); + } + const extraItems = ` + + ${flexLayout({ + items: extraRows, + gap: extraLHeight, + direction: "column", + }).join("")} + + `; + + const card = new Card({ + defaultTitle: header.length > 35 ? `${header.slice(0, 35)}...` : header, + titlePrefixIcon: icons.contribs, + width: card_width, + height, + border_radius, + colors, + }); + + card.disableAnimations(); + card.setHideBorder(hide_border); + card.setHideTitle(false); + card.setCSS(` + .description { font: 400 13px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} } + .gray { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} } + .badge { font: 600 11px 'Segoe UI', Ubuntu, Sans-Serif; } + .badge rect { opacity: 0.2 } + + .stat { font: 400 12px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${colors.textColor} } + .stagger { + opacity: 0; + animation: fadeInAnimation 0.3s ease-in-out forwards; + } + .not_bold { font-weight: 400 } + .bold { font-weight: 700 } + .icon { + fill: ${colors.iconColor}; + display: block; + } + `); + + return card.render(` + ${ + isTemplate + ? // @ts-ignore + getBadgeSVG( + i18n.t("repocard.template"), + colors.textColor, + card_width - 400, + ) + : isArchived + ? // @ts-ignore + getBadgeSVG( + i18n.t("repocard.archived"), + colors.textColor, + card_width - 400, + ) + : "" + } + + + ${descriptionSvg} + + + + ${starAndForkCount} + + ${extraItems} + `); +}; + +export { renderRepoCard }; +export default renderRepoCard; diff --git a/frontend/frontend/src/backend/src/cards/stats.js b/frontend/frontend/src/backend/src/cards/stats.js new file mode 100644 index 00000000..d92b2199 --- /dev/null +++ b/frontend/frontend/src/backend/src/cards/stats.js @@ -0,0 +1,616 @@ +// @ts-check +import { Card } from "../common/Card.js"; +import { I18n } from "../common/I18n.js"; +import { icons, rankIcon } from "../common/icons.js"; +import { + CustomError, + clampValue, + flexLayout, + getCardColors, + kFormatter, + measureText, + buildSearchFilter, +} from "../common/utils.js"; +import { statCardLocales } from "../translations.js"; + +const CARD_MIN_WIDTH = 287; +const CARD_DEFAULT_WIDTH = 287; +const RANK_CARD_MIN_WIDTH = 420; +const RANK_CARD_DEFAULT_WIDTH = 450; +const RANK_ONLY_CARD_MIN_WIDTH = 290; +const RANK_ONLY_CARD_DEFAULT_WIDTH = 290; + +/** + * Create a stats card text item. + * + * @param {object} createTextNodeParams Object that contains the createTextNode parameters. + * @param {string} createTextNodeParams.icon The icon to display. + * @param {string} createTextNodeParams.label The label to display. + * @param {number} createTextNodeParams.value The value to display. + * @param {string} createTextNodeParams.id The id of the stat. + * @param {string=} createTextNodeParams.unitSymbol The unit symbol of the stat. + * @param {number} createTextNodeParams.index The index of the stat. + * @param {boolean} createTextNodeParams.showIcons Whether to show icons. + * @param {number} createTextNodeParams.shiftValuePos Number of pixels the value has to be shifted to the right. + * @param {boolean} createTextNodeParams.bold Whether to bold the label. + * @param {string} createTextNodeParams.number_format The format of numbers on card. + * @param {string} createTextNodeParams.link Url to link to. + * @param {number} createTextNodeParams.labelXOffset horizontal offset for label. + * @returns {string} The stats card text item SVG object. + */ +const createTextNode = ({ + icon, + label, + value, + id, + unitSymbol, + index, + showIcons, + shiftValuePos, + bold, + number_format, + link, + labelXOffset = 25, +}) => { + const kValue = + number_format.toLowerCase() === "long" ? value : kFormatter(value); + const staggerDelay = (index + 3) * 150; + + const labelOffset = showIcons ? `x="${labelXOffset}"` : ""; + const iconSvg = showIcons + ? ` + + ${icon} + + ` + : ""; + return ( + ` + ` + + (link ? `` : "") + + ` + ${iconSvg} + ${label}: + ${kValue}${unitSymbol ? ` ${unitSymbol}` : ""}` + + (link ? "" : "") + + ` + + ` + ); +}; + +/** + * Calculates progress along the boundary of the circle, i.e. its circumference. + * + * @param {number} value The rank value to calculate progress for. + * @returns {number} Progress value. + */ +const calculateCircleProgress = (value) => { + const radius = 40; + const c = Math.PI * (radius * 2); + + if (value < 0) { + value = 0; + } + if (value > 100) { + value = 100; + } + + return ((100 - value) / 100) * c; +}; + +/** + * Retrieves the animation to display progress along the circumference of circle + * from the beginning to the given value in a clockwise direction. + * + * @param {{progress: number}} progress The progress value to animate to. + * @returns {string} Progress animation css. + */ +const getProgressAnimation = ({ progress }) => { + return ` + @keyframes rankAnimation { + from { + stroke-dashoffset: ${calculateCircleProgress(0)}; + } + to { + stroke-dashoffset: ${calculateCircleProgress(progress)}; + } + } + `; +}; + +/** + * Retrieves CSS styles for a card. + * + * @param {Object} colors The colors to use for the card. + * @param {string} colors.titleColor The title color. + * @param {string} colors.textColor The text color. + * @param {string} colors.iconColor The icon color. + * @param {string} colors.ringColor The ring color. + * @param {boolean} colors.show_icons Whether to show icons. + * @param {number} colors.progress The progress value to animate to. + * @returns {string} Card CSS styles. + */ +const getStyles = ({ + // eslint-disable-next-line no-unused-vars + titleColor, + textColor, + iconColor, + ringColor, + show_icons, + progress, +}) => { + return ` + .stat { + font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor}; + } + @supports(-moz-appearance: auto) { + /* Selector detects Firefox */ + .stat { font-size:12px; } + } + .stagger { + opacity: 0; + animation: fadeInAnimation 0.3s ease-in-out forwards; + } + .rank-text { + font: 800 24px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor}; + animation: scaleInAnimation 0.3s ease-in-out forwards; + } + .rank-percentile-header { + font-size: 14px; + } + .rank-percentile-text { + font-size: 16px; + } + + .not_bold { font-weight: 400 } + .bold { font-weight: 700 } + .icon { + fill: ${iconColor}; + display: ${show_icons ? "block" : "none"}; + } + + .rank-circle-rim { + stroke: ${ringColor}; + fill: none; + stroke-width: 6; + opacity: 0.2; + } + .rank-circle { + stroke: ${ringColor}; + stroke-dasharray: 250; + fill: none; + stroke-width: 6; + stroke-linecap: round; + opacity: 0.8; + transform-origin: -10px 8px; + transform: rotate(-90deg); + animation: rankAnimation 1s forwards ease-in-out; + } + ${process.env.NODE_ENV === "test" ? "" : getProgressAnimation({ progress })} + `; +}; + +/** + * @typedef {import('../fetchers/types').StatsData} StatsData + * @typedef {import('./types').StatCardOptions} StatCardOptions + */ + +/** + * Renders the stats card. + * + * @param {StatsData} stats The stats data. + * @param {Partial} options The card options. + * @returns {string} The stats card SVG object. + */ +const renderStatsCard = ( + stats, + options = {}, + username, + repo = [], + owner = [], +) => { + const { + name, + totalStars, + totalCommits, + totalIssues, + totalPRs, + totalPRsMerged, + mergedPRsPercentage, + totalReviews, + totalDiscussionsStarted, + totalDiscussionsAnswered, + contributedTo, + totalPRsAuthored, + totalPRsCommented, + totalPRsReviewed, + totalIssuesAuthored, + totalIssuesCommented, + rank, + } = stats; + const { + hide = [], + show_icons = false, + hide_title = false, + hide_border = false, + card_width, + hide_rank = false, + include_all_commits = false, + line_height = 25, + title_color, + ring_color, + icon_color, + text_color, + text_bold = true, + bg_color, + theme = "default", + custom_title, + border_radius, + border_color, + number_format = "short", + locale, + disable_animations = false, + rank_icon = "default", + show = [], + } = options; + + const lheight = parseInt(String(line_height), 10); + + // returns theme based colors with proper overrides and defaults + const { titleColor, iconColor, textColor, bgColor, borderColor, ringColor } = + getCardColors({ + title_color, + text_color, + icon_color, + bg_color, + border_color, + ring_color, + theme, + }); + + const apostrophe = /s$/i.test(name.trim()) ? "" : "s"; + const i18n = new I18n({ + locale, + translations: statCardLocales({ name, apostrophe }), + }); + + // Meta data for creating text nodes with createTextNode function + const STATS = {}; + + STATS.stars = { + icon: icons.star, + label: i18n.t("statcard.totalstars"), + value: totalStars, + id: "stars", + }; + STATS.commits = { + icon: icons.commits, + label: `${i18n.t("statcard.commits")}${ + include_all_commits ? "" : ` (${new Date().getFullYear()})` + }`, + value: totalCommits, + id: "commits", + }; + STATS.prs = { + icon: icons.prs, + label: i18n.t("statcard.prs"), + value: totalPRs, + id: "prs", + }; + + if (show.includes("prs_merged")) { + STATS.prs_merged = { + icon: icons.prs_merged, + label: i18n.t("statcard.prs-merged"), + value: totalPRsMerged, + id: "prs_merged", + }; + } + + if (show.includes("prs_merged_percentage")) { + STATS.prs_merged_percentage = { + icon: icons.prs_merged_percentage, + label: i18n.t("statcard.prs-merged-percentage"), + value: mergedPRsPercentage.toFixed(2), + id: "prs_merged_percentage", + unitSymbol: "%", + }; + } + + if (show.includes("reviews")) { + STATS.reviews = { + icon: icons.reviews, + label: i18n.t("statcard.reviews"), + value: totalReviews, + id: "reviews", + }; + } + + STATS.issues = { + icon: icons.issues, + label: i18n.t("statcard.issues"), + value: totalIssues, + id: "issues", + }; + + if (show.includes("discussions_started")) { + STATS.discussions_started = { + icon: icons.discussions_started, + label: i18n.t("statcard.discussions-started"), + value: totalDiscussionsStarted, + id: "discussions_started", + }; + } + if (show.includes("discussions_answered")) { + STATS.discussions_answered = { + icon: icons.discussions_answered, + label: i18n.t("statcard.discussions-answered"), + value: totalDiscussionsAnswered, + id: "discussions_answered", + }; + } + + let repoFilter = encodeURIComponent(buildSearchFilter(repo, owner)); + if (show.includes("prs_authored")) { + STATS.prs_authored = { + icon: icons.prs, + label: i18n.t("statcard.prs-authored"), + value: totalPRsAuthored, + id: "prs_authored", + link: `https://github.com/search?q=${repoFilter}author%3A${username}&type=pullrequests`, + }; + } + if (show.includes("prs_commented")) { + STATS.prs_commented = { + icon: icons.comments, + label: i18n.t("statcard.prs-commented"), + value: totalPRsCommented, + id: "prs_commented", + link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&type=pullrequests`, + }; + } + if (show.includes("prs_reviewed")) { + STATS.prs_reviewed = { + icon: icons.reviews, + label: i18n.t("statcard.prs-reviewed"), + value: totalPRsReviewed, + id: "prs_reviewed", + link: `https://github.com/search?q=${repoFilter}reviewed-by%3A${username}+-author%3A${username}&type=pullrequests`, + }; + } + if (show.includes("issues_authored")) { + STATS.issues_authored = { + icon: icons.issues, + label: i18n.t("statcard.issues-authored"), + value: totalIssuesAuthored, + id: "issues_authored", + link: `https://github.com/search?q=${repoFilter}author%3A${username}&type=issues`, + }; + } + if (show.includes("issues_commented")) { + STATS.issues_commented = { + icon: icons.discussions_started, + label: i18n.t("statcard.issues-commented"), + value: totalIssuesCommented, + id: "issues_commented", + link: `https://github.com/search?q=${repoFilter}commenter%3A${username}+-author%3A${username}&type=issues`, + }; + } + + STATS.contribs = { + icon: icons.contribs, + label: i18n.t("statcard.contribs"), + value: contributedTo, + id: "contribs", + }; + + const longLocales = [ + "cn", + "es", + "fr", + "pt-br", + "ru", + "uk-ua", + "id", + "ml", + "my", + "pl", + "de", + "nl", + "zh-tw", + "uz", + ]; + const isLongLocale = locale ? longLocales.includes(locale) : false; + + // check if all used labels are short + const longLabels = + Object.keys(STATS) + .filter((key) => !hide.includes(key)) + .filter((key) => STATS[key].label.length > 18).length > 0; + + // filter out hidden stats defined by user & create the text nodes + const statItems = Object.keys(STATS) + .filter((key) => !hide.includes(key)) + .map((key, index) => + // create the text nodes, and pass index so that we can calculate the line spacing + createTextNode({ + icon: STATS[key].icon, + label: STATS[key].label, + value: STATS[key].value, + id: STATS[key].id, + unitSymbol: STATS[key].unitSymbol, + index, + showIcons: show_icons, + shiftValuePos: 29.01 + (longLabels ? 50 : 0) + (isLongLocale ? 50 : 0), + bold: text_bold, + number_format, + link: STATS[key].link, + }), + ); + + if (statItems.length === 0 && hide_rank) { + throw new CustomError( + "Could not render stats card.", + "Either stats or rank are required.", + ); + } + + // Calculate the card height depending on how many items there are + // but if rank circle is visible clamp the minimum height to `150` + let height = Math.max( + 45 + (statItems.length + 1) * lheight, + hide_rank ? 0 : statItems.length ? 150 : 180, + ); + + // the lower the user's percentile the better + const progress = 100 - rank.percentile; + const cssStyles = getStyles({ + titleColor, + ringColor, + textColor, + iconColor, + show_icons, + progress, + }); + + const calculateTextWidth = () => { + return measureText( + custom_title + ? custom_title + : statItems.length + ? i18n.t("statcard.title") + : i18n.t("statcard.ranktitle"), + ); + }; + + /* + When hide_rank=true, the minimum card width is 270 px + the title length and padding. + When hide_rank=false, the minimum card_width is 340 px + the icon width (if show_icons=true). + Numbers are picked by looking at existing dimensions on production. + */ + const iconWidth = show_icons && statItems.length ? 16 + /* padding */ 1 : 0; + const minCardWidth = + (hide_rank + ? clampValue( + 50 /* padding */ + calculateTextWidth() * 2, + CARD_MIN_WIDTH, + Infinity, + ) + : statItems.length + ? RANK_CARD_MIN_WIDTH + : RANK_ONLY_CARD_MIN_WIDTH) + iconWidth; + const defaultCardWidth = + (hide_rank + ? CARD_DEFAULT_WIDTH + : statItems.length + ? RANK_CARD_DEFAULT_WIDTH + : RANK_ONLY_CARD_DEFAULT_WIDTH) + iconWidth; + let width = card_width + ? isNaN(card_width) + ? Math.max(defaultCardWidth, minCardWidth) + : card_width + : Math.max(defaultCardWidth, minCardWidth); + + const card = new Card({ + customTitle: custom_title, + defaultTitle: statItems.length + ? i18n.t("statcard.title") + : i18n.t("statcard.ranktitle"), + width, + height, + border_radius, + colors: { + titleColor, + textColor, + iconColor, + bgColor, + borderColor, + }, + }); + + card.setHideBorder(hide_border); + card.setHideTitle(hide_title); + card.setCSS(cssStyles); + + if (disable_animations) { + card.disableAnimations(); + } + + /** + * Calculates the right rank circle translation values such that the rank circle + * keeps respecting the following padding: + * + * width > RANK_CARD_DEFAULT_WIDTH: The default right padding of 70 px will be used. + * width < RANK_CARD_DEFAULT_WIDTH: The left and right padding will be enlarged + * equally from a certain minimum at RANK_CARD_MIN_WIDTH. + * + * @returns {number} - Rank circle translation value. + */ + const calculateRankXTranslation = () => { + if (statItems.length) { + const minXTranslation = RANK_CARD_MIN_WIDTH + iconWidth - 70; + if (width > RANK_CARD_DEFAULT_WIDTH) { + const xMaxExpansion = minXTranslation + (450 - minCardWidth) / 2; + return xMaxExpansion + width - RANK_CARD_DEFAULT_WIDTH; + } else { + return minXTranslation + (width - minCardWidth) / 2; + } + } else { + return width / 2 + 20 - 10; + } + }; + + // Conditionally rendered elements + const rankCircle = hide_rank + ? "" + : ` + + + + ${rankIcon(rank_icon, rank?.level, rank?.percentile)} + + `; + + // Accessibility Labels + const labels = Object.keys(STATS) + .filter((key) => !hide.includes(key)) + .map((key) => { + if (key === "commits") { + return `${i18n.t("statcard.commits")} ${ + include_all_commits ? "" : `in ${new Date().getFullYear()}` + } : ${STATS[key].value}`; + } + return `${STATS[key].label}: ${STATS[key].value}`; + }) + .join(", "); + + card.setAccessibilityLabel({ + title: `${card.title}, Rank: ${rank.level}`, + desc: labels, + }); + + return card.render(` + ${rankCircle} + + ${flexLayout({ + items: statItems, + gap: lheight, + direction: "column", + }).join("")} + + `); +}; + +export { renderStatsCard, createTextNode }; +export default renderStatsCard; diff --git a/frontend/frontend/src/backend/src/cards/top-languages.js b/frontend/frontend/src/backend/src/cards/top-languages.js new file mode 100644 index 00000000..9385f4a7 --- /dev/null +++ b/frontend/frontend/src/backend/src/cards/top-languages.js @@ -0,0 +1,890 @@ +// @ts-check +import { Card } from "../common/Card.js"; +import { createProgressNode } from "../common/createProgressNode.js"; +import { I18n } from "../common/I18n.js"; +import { + chunkArray, + clampValue, + flexLayout, + getCardColors, + lowercaseTrim, + measureText, +} from "../common/utils.js"; +import { langCardLocales } from "../translations.js"; + +const DEFAULT_CARD_WIDTH = 300; +const MIN_CARD_WIDTH = 280; +const DEFAULT_LANG_COLOR = "#858585"; +const CARD_PADDING = 25; +const COMPACT_LAYOUT_BASE_HEIGHT = 90; +const MAXIMUM_LANGS_COUNT = 20; + +const NORMAL_LAYOUT_DEFAULT_LANGS_COUNT = 5; +const COMPACT_LAYOUT_DEFAULT_LANGS_COUNT = 6; +const DONUT_LAYOUT_DEFAULT_LANGS_COUNT = 5; +const PIE_LAYOUT_DEFAULT_LANGS_COUNT = 6; +const DONUT_VERTICAL_LAYOUT_DEFAULT_LANGS_COUNT = 6; + +/** + * @typedef {import("../fetchers/types").Lang} Lang + */ + +/** + * Retrieves the programming language whose name is the longest. + * + * @param {Lang[]} arr Array of programming languages. + * @returns {{ name: string, size: number, color: string }} Longest programming language object. + */ +const getLongestLang = (arr) => + arr.reduce( + (savedLang, lang) => + lang.name.length > savedLang.name.length ? lang : savedLang, + { name: "", size: 0, color: "" }, + ); + +/** + * Convert degrees to radians. + * + * @param {number} angleInDegrees Angle in degrees. + * @returns {number} Angle in radians. + */ +const degreesToRadians = (angleInDegrees) => angleInDegrees * (Math.PI / 180.0); + +/** + * Convert radians to degrees. + * + * @param {number} angleInRadians Angle in radians. + * @returns {number} Angle in degrees. + */ +const radiansToDegrees = (angleInRadians) => angleInRadians / (Math.PI / 180.0); + +/** + * Convert polar coordinates to cartesian coordinates. + * + * @param {number} centerX Center x coordinate. + * @param {number} centerY Center y coordinate. + * @param {number} radius Radius of the circle. + * @param {number} angleInDegrees Angle in degrees. + * @returns {{x: number, y: number}} Cartesian coordinates. + */ +const polarToCartesian = (centerX, centerY, radius, angleInDegrees) => { + const rads = degreesToRadians(angleInDegrees); + return { + x: centerX + radius * Math.cos(rads), + y: centerY + radius * Math.sin(rads), + }; +}; + +/** + * Convert cartesian coordinates to polar coordinates. + * + * @param {number} centerX Center x coordinate. + * @param {number} centerY Center y coordinate. + * @param {number} x Point x coordinate. + * @param {number} y Point y coordinate. + * @returns {{radius: number, angleInDegrees: number}} Polar coordinates. + */ +const cartesianToPolar = (centerX, centerY, x, y) => { + const radius = Math.sqrt(Math.pow(x - centerX, 2) + Math.pow(y - centerY, 2)); + let angleInDegrees = radiansToDegrees(Math.atan2(y - centerY, x - centerX)); + if (angleInDegrees < 0) { + angleInDegrees += 360; + } + return { radius, angleInDegrees }; +}; + +/** + * Calculates length of circle. + * + * @param {number} radius Radius of the circle. + * @returns {number} The length of the circle. + */ +const getCircleLength = (radius) => { + return 2 * Math.PI * radius; +}; + +/** + * Calculates height for the compact layout. + * + * @param {number} totalLangs Total number of languages. + * @returns {number} Card height. + */ +const calculateCompactLayoutHeight = (totalLangs) => { + return COMPACT_LAYOUT_BASE_HEIGHT + Math.round(totalLangs / 2) * 25; +}; + +/** + * Calculates height for the normal layout. + * + * @param {number} totalLangs Total number of languages. + * @returns {number} Card height. + */ +const calculateNormalLayoutHeight = (totalLangs) => { + return 45 + (totalLangs + 1) * 40; +}; + +/** + * Calculates height for the donut layout. + * + * @param {number} totalLangs Total number of languages. + * @returns {number} Card height. + */ +const calculateDonutLayoutHeight = (totalLangs) => { + return 215 + Math.max(totalLangs - 5, 0) * 32; +}; + +/** + * Calculates height for the donut vertical layout. + * + * @param {number} totalLangs Total number of languages. + * @returns {number} Card height. + */ +const calculateDonutVerticalLayoutHeight = (totalLangs) => { + return 300 + Math.round(totalLangs / 2) * 25; +}; + +/** + * Calculates height for the pie layout. + * + * @param {number} totalLangs Total number of languages. + * @returns {number} Card height. + */ +const calculatePieLayoutHeight = (totalLangs) => { + return 300 + Math.round(totalLangs / 2) * 25; +}; + +/** + * Calculates the center translation needed to keep the donut chart centred. + * @param {number} totalLangs Total number of languages. + * @returns {number} Donut center translation. + */ +const donutCenterTranslation = (totalLangs) => { + return -45 + Math.max(totalLangs - 5, 0) * 16; +}; + +/** + * Trim top languages to lang_count while also hiding certain languages. + * + * @param {Record} topLangs Top languages. + * @param {number} langs_count Number of languages to show. + * @param {string[]=} hide Languages to hide. + * @returns {{ langs: Lang[], totalLanguageSize: number }} Trimmed top languages and total size. + */ +const trimTopLanguages = (topLangs, langs_count, hide) => { + let langs = Object.values(topLangs); + let langsToHide = {}; + let langsCount = clampValue(langs_count, 1, MAXIMUM_LANGS_COUNT); + + // populate langsToHide map for quick lookup + // while filtering out + if (hide) { + hide.forEach((langName) => { + langsToHide[lowercaseTrim(langName)] = true; + }); + } + + // filter out languages to be hidden + langs = langs + .sort((a, b) => b.size - a.size) + .filter((lang) => { + return !langsToHide[lowercaseTrim(lang.name)]; + }) + .slice(0, langsCount); + + const totalLanguageSize = langs.reduce((acc, curr) => acc + curr.size, 0); + + return { langs, totalLanguageSize }; +}; + +/** + * Create progress bar text item for a programming language. + * + * @param {object} props Function properties. + * @param {number} props.width The card width + * @param {string} props.color Color of the programming language. + * @param {string} props.name Name of the programming language. + * @param {number} props.progress Usage of the programming language in percentage. + * @param {number} props.index Index of the programming language. + * @returns {string} Programming language SVG node. + */ +const createProgressTextNode = ({ width, color, name, progress, index }) => { + const staggerDelay = (index + 3) * 150; + const paddingRight = 95; + const progressTextX = width - paddingRight + 10; + const progressWidth = width - paddingRight; + + return ` + + ${name} + ${progress}% + ${createProgressNode({ + x: 0, + y: 25, + color, + width: progressWidth, + progress, + progressBarBackgroundColor: "#ddd", + delay: staggerDelay + 300, + })} + + `; +}; + +/** + * Creates compact text item for a programming language. + * + * @param {object} props Function properties. + * @param {Lang} props.lang Programming language object. + * @param {number} props.totalSize Total size of all languages. + * @param {boolean=} props.hideProgress Whether to hide percentage. + * @param {number} props.index Index of the programming language. + * @returns {string} Compact layout programming language SVG node. + */ +const createCompactLangNode = ({ lang, totalSize, hideProgress, index }) => { + const percentage = ((lang.size / totalSize) * 100).toFixed(2); + const staggerDelay = (index + 3) * 150; + const color = lang.color || "#858585"; + + return ` + + + + ${lang.name} ${hideProgress ? "" : percentage + "%"} + + + `; +}; + +/** + * Create compact languages text items for all programming languages. + * + * @param {object} props Function properties. + * @param {Lang[]} props.langs Array of programming languages. + * @param {number} props.totalSize Total size of all languages. + * @param {boolean=} props.hideProgress Whether to hide percentage. + * @returns {string} Programming languages SVG node. + */ +const createLanguageTextNode = ({ langs, totalSize, hideProgress }) => { + const longestLang = getLongestLang(langs); + const chunked = chunkArray(langs, langs.length / 2); + const layouts = chunked.map((array) => { + // @ts-ignore + const items = array.map((lang, index) => + createCompactLangNode({ + lang, + totalSize, + hideProgress, + index, + }), + ); + return flexLayout({ + items, + gap: 25, + direction: "column", + }).join(""); + }); + + const percent = ((longestLang.size / totalSize) * 100).toFixed(2); + const minGap = 150; + const maxGap = 20 + measureText(`${longestLang.name} ${percent}%`, 11); + return flexLayout({ + items: layouts, + gap: maxGap < minGap ? minGap : maxGap, + }).join(""); +}; + +/** + * Create donut languages text items for all programming languages. + * + * @param {object} props Function properties. + * @param {Lang[]} props.langs Array of programming languages. + * @param {number} props.totalSize Total size of all languages. + * @returns {string} Donut layout programming language SVG node. + */ +const createDonutLanguagesNode = ({ langs, totalSize }) => { + return flexLayout({ + items: langs.map((lang, index) => { + return createCompactLangNode({ + lang, + totalSize, + hideProgress: false, + index, + }); + }), + gap: 32, + direction: "column", + }).join(""); +}; + +/** + * Renders the default language card layout. + * + * @param {Lang[]} langs Array of programming languages. + * @param {number} width Card width. + * @param {number} totalLanguageSize Total size of all languages. + * @returns {string} Normal layout card SVG object. + */ +const renderNormalLayout = (langs, width, totalLanguageSize) => { + return flexLayout({ + items: langs.map((lang, index) => { + return createProgressTextNode({ + width, + name: lang.name, + color: lang.color || DEFAULT_LANG_COLOR, + progress: parseFloat( + ((lang.size / totalLanguageSize) * 100).toFixed(2), + ), + index, + }); + }), + gap: 40, + direction: "column", + }).join(""); +}; + +/** + * Renders the compact language card layout. + * + * @param {Lang[]} langs Array of programming languages. + * @param {number} width Card width. + * @param {number} totalLanguageSize Total size of all languages. + * @param {boolean=} hideProgress Whether to hide progress bar. + * @returns {string} Compact layout card SVG object. + */ +const renderCompactLayout = (langs, width, totalLanguageSize, hideProgress) => { + const paddingRight = 50; + const offsetWidth = width - paddingRight; + // progressOffset holds the previous language's width and used to offset the next language + // so that we can stack them one after another, like this: [--][----][---] + let progressOffset = 0; + const compactProgressBar = langs + .map((lang) => { + const percentage = parseFloat( + ((lang.size / totalLanguageSize) * offsetWidth).toFixed(2), + ); + + const progress = percentage < 10 ? percentage + 10 : percentage; + + const output = ` + + `; + progressOffset += percentage; + return output; + }) + .join(""); + + return ` + ${ + hideProgress + ? "" + : ` + + + + ${compactProgressBar} + ` + } + + ${createLanguageTextNode({ + langs, + totalSize: totalLanguageSize, + hideProgress, + })} + + `; +}; + +/** + * Renders donut vertical layout to display user's most frequently used programming languages. + * + * @param {Lang[]} langs Array of programming languages. + * @param {number} totalLanguageSize Total size of all languages. + * @returns {string} Compact layout card SVG object. + */ +const renderDonutVerticalLayout = (langs, totalLanguageSize) => { + // Donut vertical chart radius and total length + const radius = 80; + const totalCircleLength = getCircleLength(radius); + + // SVG circles + let circles = []; + + // Start indent for donut vertical chart parts + let indent = 0; + + // Start delay coefficient for donut vertical chart parts + let startDelayCoefficient = 1; + + // Generate each donut vertical chart part + for (const lang of langs) { + const percentage = (lang.size / totalLanguageSize) * 100; + const circleLength = totalCircleLength * (percentage / 100); + const delay = startDelayCoefficient * 100; + + circles.push(` + + + + `); + + // Update the indent for the next part + indent += circleLength; + // Update the start delay coefficient for the next part + startDelayCoefficient += 1; + } + + return ` + + + + ${circles.join("")} + + + + + ${createLanguageTextNode({ + langs, + totalSize: totalLanguageSize, + hideProgress: false, + })} + + + + `; +}; + +/** + * Renders pie layout to display user's most frequently used programming languages. + * + * @param {Lang[]} langs Array of programming languages. + * @param {number} totalLanguageSize Total size of all languages. + * @returns {string} Compact layout card SVG object. + */ +const renderPieLayout = (langs, totalLanguageSize) => { + // Pie chart radius and center coordinates + const radius = 90; + const centerX = 150; + const centerY = 100; + + // Start angle for the pie chart parts + let startAngle = 0; + + // Start delay coefficient for the pie chart parts + let startDelayCoefficient = 1; + + // SVG paths + const paths = []; + + // Generate each pie chart part + for (const lang of langs) { + if (langs.length === 1) { + paths.push(` + + `); + break; + } + + const langSizePart = lang.size / totalLanguageSize; + const percentage = langSizePart * 100; + // Calculate the angle for the current part + const angle = langSizePart * 360; + + // Calculate the end angle + const endAngle = startAngle + angle; + + // Calculate the coordinates of the start and end points of the arc + const startPoint = polarToCartesian(centerX, centerY, radius, startAngle); + const endPoint = polarToCartesian(centerX, centerY, radius, endAngle); + + // Determine the large arc flag based on the angle + const largeArcFlag = angle > 180 ? 1 : 0; + + // Calculate delay + const delay = startDelayCoefficient * 100; + + // SVG arc markup + paths.push(` + + + + `); + + // Update the start angle for the next part + startAngle = endAngle; + // Update the start delay coefficient for the next part + startDelayCoefficient += 1; + } + + return ` + + + + ${paths.join("")} + + + + + ${createLanguageTextNode({ + langs, + totalSize: totalLanguageSize, + hideProgress: false, + })} + + + + `; +}; + +/** + * Creates the SVG paths for the language donut chart. + * + * @param {number} cx Donut center x-position. + * @param {number} cy Donut center y-position. + * @param {number} radius Donut arc Radius. + * @param {number[]} percentages Array with donut section percentages. + * @returns {{d: string, percent: number}[]} Array of svg path elements + */ +const createDonutPaths = (cx, cy, radius, percentages) => { + const paths = []; + let startAngle = 0; + let endAngle = 0; + + const totalPercent = percentages.reduce((acc, curr) => acc + curr, 0); + for (let i = 0; i < percentages.length; i++) { + const tmpPath = {}; + + let percent = parseFloat( + ((percentages[i] / totalPercent) * 100).toFixed(2), + ); + + endAngle = 3.6 * percent + startAngle; + const startPoint = polarToCartesian(cx, cy, radius, endAngle - 90); // rotate donut 90 degrees counter-clockwise. + const endPoint = polarToCartesian(cx, cy, radius, startAngle - 90); // rotate donut 90 degrees counter-clockwise. + const largeArc = endAngle - startAngle <= 180 ? 0 : 1; + + tmpPath.percent = percent; + tmpPath.d = `M ${startPoint.x} ${startPoint.y} A ${radius} ${radius} 0 ${largeArc} 0 ${endPoint.x} ${endPoint.y}`; + + paths.push(tmpPath); + startAngle = endAngle; + } + + return paths; +}; + +/** + * Renders the donut language card layout. + * + * @param {Lang[]} langs Array of programming languages. + * @param {number} width Card width. + * @param {number} totalLanguageSize Total size of all languages. + * @returns {string} Donut layout card SVG object. + */ +const renderDonutLayout = (langs, width, totalLanguageSize) => { + const centerX = width / 3; + const centerY = width / 3; + const radius = centerX - 60; + const strokeWidth = 12; + + const colors = langs.map((lang) => lang.color); + const langsPercents = langs.map((lang) => + parseFloat(((lang.size / totalLanguageSize) * 100).toFixed(2)), + ); + + const langPaths = createDonutPaths(centerX, centerY, radius, langsPercents); + + const donutPaths = + langs.length === 1 + ? `` + : langPaths + .map((section, index) => { + const staggerDelay = (index + 3) * 100; + const delay = staggerDelay + 300; + + const output = ` + + + + + `; + + return output; + }) + .join(""); + + const donut = `${donutPaths}`; + + return ` + + + ${createDonutLanguagesNode({ langs, totalSize: totalLanguageSize })} + + + + ${donut} + + + `; +}; + +/** + * @typedef {import("./types").TopLangOptions} TopLangOptions + * @typedef {TopLangOptions["layout"]} Layout + */ + +/** + * Creates the no languages data SVG node. + * + * @param {object} props Object with function properties. + * @param {string} props.color No languages data text color. + * @param {string} props.text No languages data translated text. + * @param {Layout | undefined} props.layout Card layout. + * @returns {string} No languages data SVG node string. + */ +const noLanguagesDataNode = ({ color, text, layout }) => { + return ` + ${text} + `; +}; + +/** + * Get default languages count for provided card layout. + * + * @param {object} props Function properties. + * @param {Layout=} props.layout Input layout string. + * @param {boolean=} props.hide_progress Input hide_progress parameter value. + * @returns {number} Default languages count for input layout. + */ +const getDefaultLanguagesCountByLayout = ({ layout, hide_progress }) => { + if (layout === "compact" || hide_progress === true) { + return COMPACT_LAYOUT_DEFAULT_LANGS_COUNT; + } else if (layout === "donut") { + return DONUT_LAYOUT_DEFAULT_LANGS_COUNT; + } else if (layout === "donut-vertical") { + return DONUT_VERTICAL_LAYOUT_DEFAULT_LANGS_COUNT; + } else if (layout === "pie") { + return PIE_LAYOUT_DEFAULT_LANGS_COUNT; + } else { + return NORMAL_LAYOUT_DEFAULT_LANGS_COUNT; + } +}; + +/** + * @typedef {import('../fetchers/types').TopLangData} TopLangData + */ + +/** + * Renders card that display user's most frequently used programming languages. + * + * @param {TopLangData} topLangs User's most frequently used programming languages. + * @param {Partial} options Card options. + * @returns {string} Language card SVG object. + */ +const renderTopLanguages = (topLangs, options = {}) => { + const { + hide_title = false, + hide_border = false, + card_width, + title_color, + text_color, + bg_color, + hide, + hide_progress, + theme, + layout, + custom_title, + locale, + langs_count = getDefaultLanguagesCountByLayout({ layout, hide_progress }), + border_radius, + border_color, + disable_animations, + } = options; + + const i18n = new I18n({ + locale, + translations: langCardLocales, + }); + + const { langs, totalLanguageSize } = trimTopLanguages( + topLangs, + langs_count, + hide, + ); + + let width = card_width + ? isNaN(card_width) + ? DEFAULT_CARD_WIDTH + : card_width < MIN_CARD_WIDTH + ? MIN_CARD_WIDTH + : card_width + : DEFAULT_CARD_WIDTH; + let height = calculateNormalLayoutHeight(langs.length); + + // returns theme based colors with proper overrides and defaults + const colors = getCardColors({ + title_color, + text_color, + bg_color, + border_color, + theme, + }); + + let finalLayout = ""; + if (langs.length === 0) { + height = COMPACT_LAYOUT_BASE_HEIGHT; + finalLayout = noLanguagesDataNode({ + color: colors.textColor, + text: i18n.t("langcard.nodata"), + layout, + }); + } else if (layout === "pie") { + height = calculatePieLayoutHeight(langs.length); + finalLayout = renderPieLayout(langs, totalLanguageSize); + } else if (layout === "donut-vertical") { + height = calculateDonutVerticalLayoutHeight(langs.length); + finalLayout = renderDonutVerticalLayout(langs, totalLanguageSize); + } else if (layout === "compact" || hide_progress == true) { + height = + calculateCompactLayoutHeight(langs.length) + (hide_progress ? -25 : 0); + + finalLayout = renderCompactLayout( + langs, + width, + totalLanguageSize, + hide_progress, + ); + } else if (layout === "donut") { + height = calculateDonutLayoutHeight(langs.length); + width = width + 50; // padding + finalLayout = renderDonutLayout(langs, width, totalLanguageSize); + } else { + finalLayout = renderNormalLayout(langs, width, totalLanguageSize); + } + + const card = new Card({ + customTitle: custom_title, + defaultTitle: i18n.t("langcard.title"), + width, + height, + border_radius, + colors, + }); + + if (disable_animations) { + card.disableAnimations(); + } + + card.setHideBorder(hide_border); + card.setHideTitle(hide_title); + card.setCSS( + ` + @keyframes slideInAnimation { + from { + width: 0; + } + to { + width: calc(100%-100px); + } + } + @keyframes growWidthAnimation { + from { + width: 0; + } + to { + width: 100%; + } + } + .stat { + font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${colors.textColor}; + } + @supports(-moz-appearance: auto) { + /* Selector detects Firefox */ + .stat { font-size:12px; } + } + .bold { font-weight: 700 } + .lang-name { + font: 400 11px "Segoe UI", Ubuntu, Sans-Serif; + fill: ${colors.textColor}; + } + .stagger { + opacity: 0; + animation: fadeInAnimation 0.3s ease-in-out forwards; + } + #rect-mask rect{ + animation: slideInAnimation 1s ease-in-out forwards; + } + .lang-progress{ + animation: growWidthAnimation 0.6s ease-in-out forwards; + } + `, + ); + + if (layout === "pie" || layout === "donut-vertical") { + return card.render(finalLayout); + } + + return card.render(` + + ${finalLayout} + + `); +}; + +export { + getLongestLang, + degreesToRadians, + radiansToDegrees, + polarToCartesian, + cartesianToPolar, + getCircleLength, + calculateCompactLayoutHeight, + calculateNormalLayoutHeight, + calculateDonutLayoutHeight, + calculateDonutVerticalLayoutHeight, + calculatePieLayoutHeight, + donutCenterTranslation, + trimTopLanguages, + renderTopLanguages, + MIN_CARD_WIDTH, + getDefaultLanguagesCountByLayout, +}; diff --git a/frontend/frontend/src/backend/src/cards/types.d.ts b/frontend/frontend/src/backend/src/cards/types.d.ts new file mode 100644 index 00000000..4549d3c8 --- /dev/null +++ b/frontend/frontend/src/backend/src/cards/types.d.ts @@ -0,0 +1,70 @@ +type ThemeNames = keyof typeof import("../../themes/index.js"); +type RankIcon = "default" | "github" | "percentile"; + +export type CommonOptions = { + title_color: string; + icon_color: string; + text_color: string; + bg_color: string; + theme: ThemeNames; + border_radius: number; + border_color: string; + locale: string; + hide_border: boolean; +}; + +export type StatCardOptions = CommonOptions & { + hide: string[]; + show_icons: boolean; + hide_title: boolean; + card_width: number; + hide_rank: boolean; + include_all_commits: boolean; + line_height: number | string; + custom_title: string; + disable_animations: boolean; + number_format: string; + ring_color: string; + text_bold: boolean; + rank_icon: RankIcon; + show: string[]; +}; + +export type RepoCardOptions = CommonOptions & { + show_owner: boolean; + description_lines_count: number; + card_width_input; + show: string[]; + show_icons: boolean; + number_format: string; + text_bold: boolean; + line_height: number | string; + username; +}; + +export type TopLangOptions = CommonOptions & { + hide_title: boolean; + card_width: number; + hide: string[]; + layout: "compact" | "normal" | "donut" | "donut-vertical" | "pie"; + custom_title: string; + langs_count: number; + disable_animations: boolean; + hide_progress: boolean; +}; + +export type WakaTimeOptions = CommonOptions & { + hide_title: boolean; + hide: string[]; + line_height: string; + hide_progress: boolean; + custom_title: string; + layout: "compact" | "normal"; + langs_count: number; + display_format: "time" | "percent"; + disable_animations: boolean; +}; + +export type GistCardOptions = CommonOptions & { + show_owner: boolean; +}; diff --git a/frontend/frontend/src/backend/src/cards/wakatime.js b/frontend/frontend/src/backend/src/cards/wakatime.js new file mode 100644 index 00000000..9f1404e0 --- /dev/null +++ b/frontend/frontend/src/backend/src/cards/wakatime.js @@ -0,0 +1,458 @@ +// @ts-check +import { Card } from "../common/Card.js"; +import { createProgressNode } from "../common/createProgressNode.js"; +import { I18n } from "../common/I18n.js"; +import { + clampValue, + flexLayout, + getCardColors, + lowercaseTrim, +} from "../common/utils.js"; +import { wakatimeCardLocales } from "../translations.js"; + +/** Import language colors. + * + * @description Here we use the workaround found in + * https://stackoverflow.com/questions/66726365/how-should-i-import-json-in-node + * since vercel is using v16.14.0 which does not yet support json imports without the + * --experimental-json-modules flag. + */ +import { createRequire } from "module"; +const require = createRequire(import.meta.url); +const languageColors = require("../common/languageColors.json"); // now works + +/** + * Creates the no coding activity SVG node. + * + * @param {object} props The function properties. + * @param {string} props.color No coding activity text color. + * @param {string} props.text No coding activity translated text. + * @returns {string} No coding activity SVG node string. + */ +const noCodingActivityNode = ({ color, text }) => { + return ` + ${text} + `; +}; + +/** + * @typedef {import('../fetchers/types').WakaTimeLang} WakaTimeLang + */ + +/** + * Format language value. + * + * @param {Object} args The function arguments. + * @param {WakaTimeLang} args.lang The language object. + * @param {"time" | "percent"} args.display_format The display format of the language node. + * @returns {string} The formatted language value. + */ +const formatLanguageValue = ({ display_format, lang }) => { + return display_format === "percent" + ? `${lang.percent.toFixed(2).toString()} %` + : lang.text; +}; + +/** + * Create compact WakaTime layout. + * + * @param {Object} args The function arguments. + * @param {WakaTimeLang} args.lang The languages array. + * @param {number} args.x The x position of the language node. + * @param {number} args.y The y position of the language node. + * @param {"time" | "percent"} args.display_format The display format of the language node. + * @returns {string} The compact layout language SVG node. + */ +const createCompactLangNode = ({ lang, x, y, display_format }) => { + const color = languageColors[lang.name] || "#858585"; + const value = formatLanguageValue({ display_format, lang }); + + return ` + + + + ${lang.name} - ${value} + + + `; +}; + +/** + * Create WakaTime language text node item. + * + * @param {Object} args The function arguments. + * @param {WakaTimeLang[]} args.langs The language objects. + * @param {number} args.y The y position of the language node. + * @param {"time" | "percent"} args.display_format The display format of the language node. + * @param {number} args.card_width Width in px of the card. + * @returns {string[]} The language text node items. + */ +const createLanguageTextNode = ({ langs, y, display_format, card_width }) => { + return langs.map((lang, index) => { + if (index % 2 === 0) { + return createCompactLangNode({ + lang, + x: 25, + y: 12.5 * index + y, + display_format, + }); + } + return createCompactLangNode({ + lang, + x: 230 + (card_width - 495) / 2, + y: 12.5 + 12.5 * index, + display_format, + }); + }); +}; + +/** + * Create WakaTime text item. + * + * @param {Object} args The function arguments. + * @param {string} args.id The id of the text node item. + * @param {string} args.label The label of the text node item. + * @param {string} args.value The value of the text node item. + * @param {number} args.index The index of the text node item. + * @param {number} args.percent Percentage of the text node item. + * @param {boolean=} args.hideProgress Whether to hide the progress bar. + * @param {string} args.progressBarColor The color of the progress bar. + * @param {string} args.progressBarBackgroundColor The color of the progress bar background. + * @param {number} args.progressBarWidth The width of the progress bar. + * @returns {string} The text SVG node. + */ +const createTextNode = ({ + id, + label, + value, + index, + percent, + hideProgress, + progressBarColor, + progressBarBackgroundColor, + progressBarWidth, +}) => { + const staggerDelay = (index + 3) * 150; + + const cardProgress = hideProgress + ? null + : createProgressNode({ + x: 110, + y: 4, + progress: percent, + color: progressBarColor, + width: progressBarWidth, + // @ts-ignore + name: label, + progressBarBackgroundColor, + delay: staggerDelay + 300, + }); + + return ` + + ${label}: + ${value} + ${cardProgress} + + `; +}; + +/** + * Recalculating percentages so that, compact layout's progress bar does not break when + * hiding languages. + * + * @param {WakaTimeLang[]} languages The languages array. + * @returns {void} The recalculated languages array. + */ +const recalculatePercentages = (languages) => { + const totalSum = languages.reduce( + (totalSum, language) => totalSum + language.percent, + 0, + ); + const weight = +(100 / totalSum).toFixed(2); + languages.forEach((language) => { + language.percent = +(language.percent * weight).toFixed(2); + }); +}; + +/** + * Retrieves CSS styles for a card. + * + * @param {Object} colors The colors to use for the card. + * @param {string} colors.titleColor The title color. + * @param {string} colors.textColor The text color. + * @returns {string} Card CSS styles. + */ +const getStyles = ({ + // eslint-disable-next-line no-unused-vars + titleColor, + textColor, +}) => { + return ` + .stat { + font: 600 14px 'Segoe UI', Ubuntu, "Helvetica Neue", Sans-Serif; fill: ${textColor}; + } + @supports(-moz-appearance: auto) { + /* Selector detects Firefox */ + .stat { font-size:12px; } + } + .stagger { + opacity: 0; + animation: fadeInAnimation 0.3s ease-in-out forwards; + } + .not_bold { font-weight: 400 } + .bold { font-weight: 700 } + `; +}; + +/** + * @typedef {import('../fetchers/types').WakaTimeData} WakaTimeData + * @typedef {import('./types').WakaTimeOptions} WakaTimeOptions + */ + +/** + * Renders WakaTime card. + * + * @param {Partial} stats WakaTime stats. + * @param {Partial} options Card options. + * @returns {string} WakaTime card SVG. + */ +const renderWakatimeCard = (stats = {}, options = { hide: [] }) => { + let { languages = [] } = stats; + let { + hide_title = false, + hide_border = false, + card_width, + hide, + line_height = 25, + title_color, + icon_color, + text_color, + bg_color, + theme = "default", + hide_progress, + custom_title, + locale, + layout, + langs_count = languages.length, + border_radius, + border_color, + display_format = "time", + disable_animations, + } = options; + + if (isNaN(card_width)) { + card_width = 495; + } + + const shouldHideLangs = Array.isArray(hide) && hide.length > 0; + if (shouldHideLangs) { + const languagesToHide = new Set(hide.map((lang) => lowercaseTrim(lang))); + languages = languages.filter( + (lang) => !languagesToHide.has(lowercaseTrim(lang.name)), + ); + } + + // Since the percentages are sorted in descending order, we can just + // slice from the beginning without sorting. + languages = languages.slice(0, langs_count); + recalculatePercentages(languages); + + const i18n = new I18n({ + locale, + translations: wakatimeCardLocales, + }); + + const lheight = parseInt(String(line_height), 10); + + const langsCount = clampValue(langs_count, 1, langs_count); + + // returns theme based colors with proper overrides and defaults + const { titleColor, textColor, iconColor, bgColor, borderColor } = + getCardColors({ + title_color, + icon_color, + text_color, + bg_color, + border_color, + theme, + }); + + const filteredLanguages = languages + .filter((language) => language.hours || language.minutes) + .slice(0, langsCount); + + // Calculate the card height depending on how many items there are + // but if rank circle is visible clamp the minimum height to `150` + let height = Math.max(45 + (filteredLanguages.length + 1) * lheight, 150); + + const cssStyles = getStyles({ + titleColor, + textColor, + }); + + let finalLayout = ""; + + // RENDER COMPACT LAYOUT + if (layout === "compact") { + let width = card_width - 5; + height = 90 + Math.round(filteredLanguages.length / 2) * 25; + + // progressOffset holds the previous language's width and used to offset the next language + // so that we can stack them one after another, like this: [--][----][---] + let progressOffset = 0; + const compactProgressBar = filteredLanguages + .map((language) => { + // const progress = (width * lang.percent) / 100; + const progress = ((width - 25) * language.percent) / 100; + + const languageColor = languageColors[language.name] || "#858585"; + + const output = ` + + `; + progressOffset += progress; + return output; + }) + .join(""); + + finalLayout = ` + + + + ${compactProgressBar} + ${ + filteredLanguages.length + ? createLanguageTextNode({ + y: 25, + langs: filteredLanguages, + display_format, + card_width, + }).join("") + : noCodingActivityNode({ + // @ts-ignore + color: textColor, + text: stats.is_coding_activity_visible + ? stats.is_other_usage_visible + ? i18n.t("wakatimecard.nocodingactivity") + : i18n.t("wakatimecard.nocodedetails") + : i18n.t("wakatimecard.notpublic"), + }) + } + `; + } else { + finalLayout = flexLayout({ + items: filteredLanguages.length + ? filteredLanguages.map((language, index) => { + return createTextNode({ + id: language.name, + label: language.name, + value: formatLanguageValue({ display_format, lang: language }), + index, + percent: language.percent, + // @ts-ignore + progressBarColor: titleColor, + // @ts-ignore + progressBarBackgroundColor: textColor, + hideProgress: hide_progress, + progressBarWidth: card_width - 275, + }); + }) + : [ + noCodingActivityNode({ + // @ts-ignore + color: textColor, + text: stats.is_coding_activity_visible + ? stats.is_other_usage_visible + ? i18n.t("wakatimecard.nocodingactivity") + : i18n.t("wakatimecard.nocodedetails") + : i18n.t("wakatimecard.notpublic"), + }), + ], + gap: lheight, + direction: "column", + }).join(""); + } + + // Get title range text + let titleText = i18n.t("wakatimecard.title"); + switch (stats.range) { + case "last_7_days": + titleText += ` (${i18n.t("wakatimecard.last7days")})`; + break; + case "last_year": + titleText += ` (${i18n.t("wakatimecard.lastyear")})`; + break; + } + + const card = new Card({ + customTitle: custom_title, + defaultTitle: titleText, + width: card_width, + height, + border_radius, + colors: { + titleColor, + textColor, + iconColor, + bgColor, + borderColor, + }, + }); + + if (disable_animations) { + card.disableAnimations(); + } + + card.setHideBorder(hide_border); + card.setHideTitle(hide_title); + card.setCSS( + ` + ${cssStyles} + @keyframes slideInAnimation { + from { + width: 0; + } + to { + width: calc(100%-100px); + } + } + @keyframes growWidthAnimation { + from { + width: 0; + } + to { + width: 100%; + } + } + .lang-name { font: 400 11px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${textColor} } + #rect-mask rect{ + animation: slideInAnimation 1s ease-in-out forwards; + } + .lang-progress{ + animation: growWidthAnimation 0.6s ease-in-out forwards; + } + `, + ); + + return card.render(` + + ${finalLayout} + + `); +}; + +export { renderWakatimeCard }; +export default renderWakatimeCard; diff --git a/frontend/frontend/src/backend/src/common/Card.js b/frontend/frontend/src/backend/src/common/Card.js new file mode 100644 index 00000000..d32da562 --- /dev/null +++ b/frontend/frontend/src/backend/src/common/Card.js @@ -0,0 +1,273 @@ +import { encodeHTML, flexLayout } from "./utils.js"; + +class Card { + /** + * Creates a new card instance. + * + * @param {object} args Card arguments. + * @param {number?=} args.width Card width. + * @param {number?=} args.height Card height. + * @param {number?=} args.border_radius Card border radius. + * @param {string?=} args.customTitle Card custom title. + * @param {string?=} args.defaultTitle Card default title. + * @param {string?=} args.titlePrefixIcon Card title prefix icon. + * @param {object?=} args.colors Card colors arguments. + * @param {string} args.colors.titleColor Card title color. + * @param {string} args.colors.textColor Card text color. + * @param {string} args.colors.iconColor Card icon color. + * @param {string|Array} args.colors.bgColor Card background color. + * @param {string} args.colors.borderColor Card border color. + * @returns {Card} Card instance. + */ + constructor({ + width = 100, + height = 100, + border_radius = 4.5, + colors = {}, + customTitle, + defaultTitle = "", + titlePrefixIcon, + }) { + this.width = width; + this.height = height; + + this.hideBorder = false; + this.hideTitle = false; + + this.border_radius = border_radius; + + // returns theme based colors with proper overrides and defaults + this.colors = colors; + this.title = + customTitle === undefined + ? encodeHTML(defaultTitle) + : encodeHTML(customTitle); + + this.css = ""; + + this.paddingX = 25; + this.paddingY = 35; + this.titlePrefixIcon = titlePrefixIcon; + this.animations = true; + this.a11yTitle = ""; + this.a11yDesc = ""; + } + + /** + * @returns {void} + */ + disableAnimations() { + this.animations = false; + } + + /** + * @param {Object} props The props object. + * @param {string} props.title Accessibility title. + * @param {string} props.desc Accessibility description. + * @returns {void} + */ + setAccessibilityLabel({ title, desc }) { + this.a11yTitle = title; + this.a11yDesc = desc; + } + + /** + * @param {string} value The CSS to add to the card. + * @returns {void} + */ + setCSS(value) { + this.css = value; + } + + /** + * @param {boolean} value Whether to hide the border or not. + * @returns {void} + */ + setHideBorder(value) { + this.hideBorder = value; + } + + /** + * @param {boolean} value Whether to hide the title or not. + * @returns {void} + */ + setHideTitle(value) { + this.hideTitle = value; + if (value) { + this.height -= 30; + } + } + + /** + * @param {string} text The title to set. + * @returns {void} + */ + setTitle(text) { + this.title = text; + } + + /** + * @returns {string} The rendered card title. + */ + renderTitle() { + const titleText = ` + ${this.title} + `; + + const prefixIcon = ` + + ${this.titlePrefixIcon} + + `; + return ` + + ${flexLayout({ + items: [this.titlePrefixIcon && prefixIcon, titleText], + gap: 25, + }).join("")} + + `; + } + + /** + * @returns {string} The rendered card gradient. + */ + renderGradient() { + if (typeof this.colors.bgColor !== "object") { + return ""; + } + + const gradients = this.colors.bgColor.slice(1); + return typeof this.colors.bgColor === "object" + ? ` + + + ${gradients.map((grad, index) => { + let offset = (index * 100) / (gradients.length - 1); + return ``; + })} + + + ` + : ""; + } + + /** + * Retrieves css animations for a card. + * + * @returns {string} Animation css. + */ + getAnimations = () => { + return ` + /* Animations */ + @keyframes scaleInAnimation { + from { + transform: translate(-5px, 5px) scale(0); + } + to { + transform: translate(-5px, 5px) scale(1); + } + } + @keyframes fadeInAnimation { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + `; + }; + + /** + * @param {string} body The inner body of the card. + * @returns {string} The rendered card. + */ + render(body) { + return ` + + ${this.a11yTitle} + ${this.a11yDesc} + + + ${this.renderGradient()} + + + + ${this.hideTitle ? "" : this.renderTitle()} + + + ${body} + + + `; + } +} + +export { Card }; +export default Card; diff --git a/frontend/frontend/src/backend/src/common/I18n.js b/frontend/frontend/src/backend/src/common/I18n.js new file mode 100644 index 00000000..bd5f29fc --- /dev/null +++ b/frontend/frontend/src/backend/src/common/I18n.js @@ -0,0 +1,41 @@ +const FALLBACK_LOCALE = "en"; + +/** + * I18n translation class. + */ +class I18n { + /** + * Constructor. + * + * @param {Object} options Options. + * @param {string=} options.locale Locale. + * @param {Object} options.translations Translations. + */ + constructor({ locale, translations }) { + this.locale = locale || FALLBACK_LOCALE; + this.translations = translations; + } + + /** + * Get translation. + * + * @param {string} str String to translate. + * @returns {string} Translated string. + */ + t(str) { + if (!this.translations[str]) { + throw new Error(`${str} Translation string not found`); + } + + if (!this.translations[str][this.locale]) { + throw new Error( + `'${str}' translation not found for locale '${this.locale}'`, + ); + } + + return this.translations[str][this.locale]; + } +} + +export { I18n }; +export default I18n; diff --git a/frontend/frontend/src/backend/src/common/blacklist.js b/frontend/frontend/src/backend/src/common/blacklist.js new file mode 100644 index 00000000..c363a071 --- /dev/null +++ b/frontend/frontend/src/backend/src/common/blacklist.js @@ -0,0 +1,10 @@ +const blacklist = [ + "renovate-bot", + "technote-space", + "sw-yx", + "YourUsername", + "[YourUsername]", +]; + +export { blacklist }; +export default blacklist; diff --git a/frontend/frontend/src/backend/src/common/createProgressNode.js b/frontend/frontend/src/backend/src/common/createProgressNode.js new file mode 100644 index 00000000..2d7303a5 --- /dev/null +++ b/frontend/frontend/src/backend/src/common/createProgressNode.js @@ -0,0 +1,46 @@ +// @ts-check + +import { clampValue } from "./utils.js"; + +/** + * Create a node to indicate progress in percentage along a horizontal line. + * + * @param {Object} createProgressNodeParams Object that contains the createProgressNode parameters. + * @param {number} createProgressNodeParams.x X-axis position. + * @param {number} createProgressNodeParams.y Y-axis position. + * @param {number} createProgressNodeParams.width Width of progress bar. + * @param {string} createProgressNodeParams.color Progress color. + * @param {number} createProgressNodeParams.progress Progress value. + * @param {string} createProgressNodeParams.progressBarBackgroundColor Progress bar bg color. + * @param {number} createProgressNodeParams.delay Delay before animation starts. + * @returns {string} Progress node. + */ +const createProgressNode = ({ + x, + y, + width, + color, + progress, + progressBarBackgroundColor, + delay, +}) => { + const progressPercentage = clampValue(progress, 2, 100); + + return ` + + + + + + + `; +}; + +export { createProgressNode }; +export default createProgressNode; diff --git a/frontend/frontend/src/backend/src/common/database.js b/frontend/frontend/src/backend/src/common/database.js new file mode 100644 index 00000000..20572c4e --- /dev/null +++ b/frontend/frontend/src/backend/src/common/database.js @@ -0,0 +1,256 @@ +import pkg from "pg"; +const { Pool } = pkg; + +export const pool = process.env.POSTGRES_URL + ? new Pool({ + connectionString: process.env.POSTGRES_URL, + }) + : null; + +/** + * Creates all required tables if they do not exist. + */ +async function createAllTables() { + if (!pool) { + return; + } + + await pool.query(` + CREATE TABLE IF NOT EXISTS requests ( + request TEXT PRIMARY KEY, + requested_at TIMESTAMP NOT NULL DEFAULT now(), + user_requested_at TIMESTAMP NOT NULL DEFAULT now() + ); + CREATE TABLE IF NOT EXISTS authenticated_users ( + user_id TEXT PRIMARY KEY, + access_token TEXT NOT NULL, + user_key TEXT, + private_access BOOLEAN NOT NULL DEFAULT false + ); + `); +} + +/** + * Stores or updates a request in the database. + */ +export async function storeRequest(req) { + if (!pool) { + return; + } + + const isBypass = req.headers && req.headers["x-bypass-store"]; + const insertQuery = isBypass + ? ` + INSERT INTO requests (request, requested_at) + VALUES ($1, NOW()) + ON CONFLICT (request) + DO UPDATE SET requested_at = EXCLUDED.requested_at + ` + : ` + INSERT INTO requests (request, requested_at, user_requested_at) + VALUES ($1, NOW(), NOW()) + ON CONFLICT (request) + DO UPDATE SET requested_at = EXCLUDED.requested_at, user_requested_at = EXCLUDED.user_requested_at + `; + + try { + await pool.query(insertQuery, [req.url]); + } catch (err) { + // Check for undefined_table error (SQLSTATE 42P01) + if (err.code === "42P01") { + await createAllTables(); + // Retry the insert after creating the table + await pool.query(insertQuery, [req.url]); + } else { + throw err; // Re-throw if it's some other error + } + } +} + +/** + * Deletes all requests older than 8 days from the database. + */ +export async function deleteOldRequests() { + if (!pool) { + return; + } + + const deleteQuery = ` + DELETE FROM requests + WHERE user_requested_at < NOW() - INTERVAL '8 days' + `; + try { + let result = await pool.query(deleteQuery); + console.log(`Deleted ${result.rowCount} old requests.`); + } catch (err) { + if (err.code === "42P01") { + console.log("Error deleting requests, table doesn't exist"); + } else { + throw err; + } + } +} + +/** + * Fetches all requests which are between 11 hours and 8 days old. + * + * @returns {Promise} Array of all requests between 11 hours and 8 days old. + */ +export async function getRecentRequests() { + if (!pool) { + return []; + } + + const query = ` + SELECT request + FROM requests + WHERE requested_at >= NOW() - INTERVAL '8 days' + AND requested_at < NOW() - INTERVAL '11 hours' + ORDER BY requested_at ASC + `; + let rows; + try { + ({ rows } = await pool.query(query)); + } catch (err) { + if (err.code === "42P01") { + console.log("Error fetching requests, table doesn't exist"); + } else { + throw err; + } + } + return rows.map((row) => row.request); +} + +/** + * Inserts or updates a user in the database. + * + * @param {string} userId GitHub userId (login name) + * @param {string} accessToken GitHub access token + * @param {string|null} userKey Optional user key + * @param {boolean} privateAccess Whether private access was requested + */ +export async function storeUser(userId, accessToken, userKey, privateAccess) { + if (!pool) { + return; + } + + const insertQuery = ` + INSERT INTO authenticated_users (user_id, access_token, user_key, private_access) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id) + DO UPDATE SET + access_token = EXCLUDED.access_token, + user_key = EXCLUDED.user_key, + private_access = EXCLUDED.private_access + `; + + try { + await pool.query(insertQuery, [ + userId, + accessToken, + userKey, + privateAccess, + ]); + } catch (err) { + if (err.code === "42P01") { + await createAllTables(); + await pool.query(insertQuery, [ + userId, + accessToken, + userKey, + privateAccess, + ]); + } else { + throw err; + } + } +} + +/** + * Delete a user from the database. + * + * @param userKey user key of the user which is to be deleted. + */ +export async function deleteUser(userKey) { + if (!pool) { + return; + } + + const deleteQuery = ` + DELETE FROM authenticated_users + WHERE user_key = $1 + `; + try { + await pool.query(deleteQuery, [userKey]); + } catch (err) { + if (err.code === "42P01") { + console.log("Error deleting user, table doesn't exist"); + } else { + throw err; + } + } +} + +/** + * Checks if private_access is true for the given user_key. + * + * @param {string} userKey user key of the user to be checked + * @returns {Promise} true if private_access is true, false otherwise + */ +export async function hasPrivateAccess(userKey) { + if (!pool) { + return null; + } + + const query = ` + SELECT private_access + FROM authenticated_users + WHERE user_key = $1 + LIMIT 1 + `; + try { + const { rows } = await pool.query(query, [userKey]); + if (rows.length === 0) { + return null; + } + return rows[0].private_access; + } catch (err) { + if (err.code === "42P01") { + return null; + } else { + throw err; + } + } +} + +/** + * Fetches access_token for a given user_key. + * + * @param {string} userKey user key of the user to fetch token for + * @returns Returns user key if found, null otherwise + */ +export async function getUserToken(userKey) { + if (!pool) { + return null; + } + + const query = ` + SELECT access_token + FROM authenticated_users + WHERE user_key = $1 + LIMIT 1 + `; + try { + const { rows } = await pool.query(query, [userKey]); + if (rows.length === 0) { + return null; + } + return rows[0].access_token; + } catch (err) { + if (err.code === "42P01") { + return null; + } else { + throw err; + } + } +} diff --git a/frontend/frontend/src/backend/src/common/icons.js b/frontend/frontend/src/backend/src/common/icons.js new file mode 100644 index 00000000..b08f3fbb --- /dev/null +++ b/frontend/frontend/src/backend/src/common/icons.js @@ -0,0 +1,54 @@ +const icons = { + star: ``, + commits: ``, + prs: ``, + prs_merged: ``, + prs_merged_percentage: ``, + issues: ``, + icon: ``, + contribs: ``, + fork: ``, + reviews: ``, + discussions_started: ``, + discussions_answered: ``, + comments: ``, + gist: ``, +}; + +/** + * Get rank icon + * + * @param {string} rankIcon - The rank icon type. + * @param {string} rankLevel - The rank level. + * @param {number} percentile - The rank percentile. + * @returns {string} - The SVG code of the rank icon + */ +const rankIcon = (rankIcon, rankLevel, percentile) => { + switch (rankIcon) { + case "github": + return ` + + `; + case "percentile": + return ` + + Top + + + ${percentile.toFixed(1)}% + + `; + case "default": + default: + return ` + + ${rankLevel} + + `; + } +}; + +export { icons, rankIcon }; +export default icons; diff --git a/frontend/frontend/src/backend/src/common/index.js b/frontend/frontend/src/backend/src/common/index.js new file mode 100644 index 00000000..2e7e9cb2 --- /dev/null +++ b/frontend/frontend/src/backend/src/common/index.js @@ -0,0 +1,30 @@ +export { blacklist } from "./blacklist.js"; +export { Card } from "./Card.js"; +export { createProgressNode } from "./createProgressNode.js"; +export { I18n } from "./I18n.js"; +export { icons } from "./icons.js"; +export { retryer } from "./retryer.js"; +export { + ERROR_CARD_LENGTH, + renderError, + encodeHTML, + kFormatter, + isValidHexColor, + parseBoolean, + parseArray, + clampValue, + isValidGradient, + fallbackColor, + request, + flexLayout, + getCardColors, + wrapTextMultiline, + logger, + CONSTANTS, + CustomError, + MissingParamError, + measureText, + lowercaseTrim, + chunkArray, + parseEmojis, +} from "./utils.js"; diff --git a/frontend/frontend/src/backend/src/common/languageColors.json b/frontend/frontend/src/backend/src/common/languageColors.json new file mode 100644 index 00000000..9d25c890 --- /dev/null +++ b/frontend/frontend/src/backend/src/common/languageColors.json @@ -0,0 +1,643 @@ +{ + "1C Enterprise": "#814CCC", + "2-Dimensional Array": "#38761D", + "4D": "#004289", + "ABAP": "#E8274B", + "ABAP CDS": "#555e25", + "AGS Script": "#B9D9FF", + "AIDL": "#34EB6B", + "AL": "#3AA2B5", + "AMPL": "#E6EFBB", + "ANTLR": "#9DC3FF", + "API Blueprint": "#2ACCA8", + "APL": "#5A8164", + "ASP.NET": "#9400ff", + "ATS": "#1ac620", + "ActionScript": "#882B0F", + "Ada": "#02f88c", + "Adblock Filter List": "#800000", + "Adobe Font Metrics": "#fa0f00", + "Agda": "#315665", + "Aiken": "#640ff8", + "Alloy": "#64C800", + "Alpine Abuild": "#0D597F", + "Altium Designer": "#A89663", + "AngelScript": "#C7D7DC", + "Answer Set Programming": "#A9CC29", + "Ant Build System": "#A9157E", + "Antlers": "#ff269e", + "ApacheConf": "#d12127", + "Apex": "#1797c0", + "Apollo Guidance Computer": "#0B3D91", + "AppleScript": "#101F1F", + "Arc": "#aa2afe", + "AsciiDoc": "#73a0c5", + "AspectJ": "#a957b0", + "Assembly": "#6E4C13", + "Astro": "#ff5a03", + "Asymptote": "#ff0000", + "Augeas": "#9CC134", + "AutoHotkey": "#6594b9", + "AutoIt": "#1C3552", + "Avro IDL": "#0040FF", + "Awk": "#c30e9b", + "B4X": "#00e4ff", + "BASIC": "#ff0000", + "BQN": "#2b7067", + "Ballerina": "#FF5000", + "Batchfile": "#C1F12E", + "Beef": "#a52f4e", + "Berry": "#15A13C", + "BibTeX": "#778899", + "Bicep": "#519aba", + "Bikeshed": "#5562ac", + "Bison": "#6A463F", + "BitBake": "#00bce4", + "Blade": "#f7523f", + "BlitzBasic": "#00FFAE", + "BlitzMax": "#cd6400", + "Bluespec": "#12223c", + "Bluespec BH": "#12223c", + "Boo": "#d4bec1", + "Boogie": "#c80fa0", + "Brainfuck": "#2F2530", + "BrighterScript": "#66AABB", + "Brightscript": "#662D91", + "Browserslist": "#ffd539", + "BuildStream": "#006bff", + "C": "#555555", + "C#": "#178600", + "C++": "#f34b7d", + "C3": "#2563eb", + "CAP CDS": "#0092d1", + "CLIPS": "#00A300", + "CMake": "#DA3434", + "COLLADA": "#F1A42B", + "CSON": "#244776", + "CSS": "#663399", + "CSV": "#237346", + "CUE": "#5886E1", + "CWeb": "#00007a", + "Cabal Config": "#483465", + "Caddyfile": "#22b638", + "Cadence": "#00ef8b", + "Cairo": "#ff4a48", + "Cairo Zero": "#ff4a48", + "CameLIGO": "#3be133", + "Cap'n Proto": "#c42727", + "Carbon": "#222222", + "Ceylon": "#dfa535", + "Chapel": "#8dc63f", + "ChucK": "#3f8000", + "Circom": "#707575", + "Cirru": "#ccccff", + "Clarion": "#db901e", + "Clarity": "#5546ff", + "Classic ASP": "#6a40fd", + "Clean": "#3F85AF", + "Click": "#E4E6F3", + "Clojure": "#db5855", + "Closure Templates": "#0d948f", + "Cloud Firestore Security Rules": "#FFA000", + "Clue": "#0009b5", + "CodeQL": "#140f46", + "CoffeeScript": "#244776", + "ColdFusion": "#ed2cd6", + "ColdFusion CFC": "#ed2cd6", + "Common Lisp": "#3fb68b", + "Common Workflow Language": "#B5314C", + "Component Pascal": "#B0CE4E", + "Cooklang": "#E15A29", + "Crystal": "#000100", + "Csound": "#1a1a1a", + "Csound Document": "#1a1a1a", + "Csound Score": "#1a1a1a", + "Cuda": "#3A4E3A", + "Curry": "#531242", + "Cylc": "#00b3fd", + "Cypher": "#34c0eb", + "Cython": "#fedf5b", + "D": "#ba595e", + "D2": "#526ee8", + "DM": "#447265", + "Dafny": "#FFEC25", + "Darcs Patch": "#8eff23", + "Dart": "#00B4AB", + "Daslang": "#d3d3d3", + "DataWeave": "#003a52", + "Debian Package Control File": "#D70751", + "DenizenScript": "#FBEE96", + "Dhall": "#dfafff", + "DirectX 3D File": "#aace60", + "Dockerfile": "#384d54", + "Dogescript": "#cca760", + "Dotenv": "#e5d559", + "Dune": "#89421e", + "Dylan": "#6c616e", + "E": "#ccce35", + "ECL": "#8a1267", + "ECLiPSe": "#001d9d", + "EJS": "#a91e50", + "EQ": "#a78649", + "Earthly": "#2af0ff", + "Easybuild": "#069406", + "Ecere Projects": "#913960", + "Ecmarkup": "#eb8131", + "Edge": "#0dffe0", + "EdgeQL": "#31A7FF", + "EditorConfig": "#fff1f2", + "Eiffel": "#4d6977", + "Elixir": "#6e4a7e", + "Elm": "#60B5CC", + "Elvish": "#55BB55", + "Elvish Transcript": "#55BB55", + "Emacs Lisp": "#c065db", + "EmberScript": "#FFF4F3", + "Erlang": "#B83998", + "Euphoria": "#FF790B", + "F#": "#b845fc", + "F*": "#572e30", + "FIGlet Font": "#FFDDBB", + "FIRRTL": "#2f632f", + "FLUX": "#88ccff", + "Factor": "#636746", + "Fancy": "#7b9db4", + "Fantom": "#14253c", + "Faust": "#c37240", + "Fennel": "#fff3d7", + "Filebench WML": "#F6B900", + "Fluent": "#ffcc33", + "Forth": "#341708", + "Fortran": "#4d41b1", + "Fortran Free Form": "#4d41b1", + "FreeBASIC": "#141AC9", + "FreeMarker": "#0050b2", + "Frege": "#00cafe", + "Futhark": "#5f021f", + "G-code": "#D08CF2", + "GAML": "#FFC766", + "GAMS": "#f49a22", + "GAP": "#0000cc", + "GCC Machine Description": "#FFCFAB", + "GDScript": "#355570", + "GDShader": "#478CBF", + "GEDCOM": "#003058", + "GLSL": "#5686a5", + "GSC": "#FF6800", + "Game Maker Language": "#71b417", + "Gemfile.lock": "#701516", + "Gemini": "#ff6900", + "Genero 4gl": "#63408e", + "Genero per": "#d8df39", + "Genie": "#fb855d", + "Genshi": "#951531", + "Gentoo Ebuild": "#9400ff", + "Gentoo Eclass": "#9400ff", + "Gerber Image": "#d20b00", + "Gherkin": "#5B2063", + "Git Attributes": "#F44D27", + "Git Config": "#F44D27", + "Git Revision List": "#F44D27", + "Gleam": "#ffaff3", + "Glimmer JS": "#F5835F", + "Glimmer TS": "#3178c6", + "Glyph": "#c1ac7f", + "Gnuplot": "#f0a9f0", + "Go": "#00ADD8", + "Go Checksums": "#00ADD8", + "Go Module": "#00ADD8", + "Go Workspace": "#00ADD8", + "Godot Resource": "#355570", + "Golo": "#88562A", + "Gosu": "#82937f", + "Grace": "#615f8b", + "Gradle": "#02303a", + "Gradle Kotlin DSL": "#02303a", + "Grammatical Framework": "#ff0000", + "GraphQL": "#e10098", + "Graphviz (DOT)": "#2596be", + "Groovy": "#4298b8", + "Groovy Server Pages": "#4298b8", + "HAProxy": "#106da9", + "HCL": "#844FBA", + "HIP": "#4F3A4F", + "HLSL": "#aace60", + "HOCON": "#9ff8ee", + "HTML": "#e34c26", + "HTML+ECR": "#2e1052", + "HTML+EEX": "#6e4a7e", + "HTML+ERB": "#701516", + "HTML+PHP": "#4f5d95", + "HTML+Razor": "#512be4", + "HTTP": "#005C9C", + "HXML": "#f68712", + "Hack": "#878787", + "Haml": "#ece2a9", + "Handlebars": "#f7931e", + "Harbour": "#0e60e3", + "Hare": "#9d7424", + "Haskell": "#5e5086", + "Haxe": "#df7900", + "HiveQL": "#dce200", + "HolyC": "#ffefaf", + "Hosts File": "#308888", + "Hy": "#7790B2", + "IDL": "#a3522f", + "IGOR Pro": "#0000cc", + "INI": "#d1dbe0", + "ISPC": "#2D68B1", + "Idris": "#b30000", + "Ignore List": "#000000", + "ImageJ Macro": "#99AAFF", + "Imba": "#16cec6", + "Inno Setup": "#264b99", + "Io": "#a9188d", + "Ioke": "#078193", + "Isabelle": "#FEFE00", + "Isabelle ROOT": "#FEFE00", + "J": "#9EEDFF", + "JAR Manifest": "#b07219", + "JCL": "#d90e09", + "JFlex": "#DBCA00", + "JSON": "#292929", + "JSON with Comments": "#292929", + "JSON5": "#267CB9", + "JSONLD": "#0c479c", + "JSONiq": "#40d47e", + "Jai": "#ab8b4b", + "Janet": "#0886a5", + "Jasmin": "#d03600", + "Java": "#b07219", + "Java Properties": "#2A6277", + "Java Server Pages": "#2A6277", + "Java Template Engine": "#2A6277", + "JavaScript": "#f1e05a", + "JavaScript+ERB": "#f1e05a", + "Jest Snapshot": "#15c213", + "JetBrains MPS": "#21D789", + "Jinja": "#a52a22", + "Jison": "#56b3cb", + "Jison Lex": "#56b3cb", + "Jolie": "#843179", + "Jsonnet": "#0064bd", + "Julia": "#a270ba", + "Julia REPL": "#a270ba", + "Jupyter Notebook": "#DA5B0B", + "Just": "#384d54", + "KDL": "#ffb3b3", + "KRL": "#28430A", + "Kaitai Struct": "#773b37", + "KakouneScript": "#6f8042", + "KerboScript": "#41adf0", + "KiCad Layout": "#2f4aab", + "KiCad Legacy Layout": "#2f4aab", + "KiCad Schematic": "#2f4aab", + "Koka": "#215166", + "Kotlin": "#A97BFF", + "LFE": "#4C3023", + "LLVM": "#185619", + "LOLCODE": "#cc9900", + "LSL": "#3d9970", + "LabVIEW": "#fede06", + "Lark": "#2980B9", + "Lasso": "#999999", + "Latte": "#f2a542", + "Leo": "#C4FFC2", + "Less": "#1d365d", + "Lex": "#DBCA00", + "LigoLANG": "#0e74ff", + "LilyPond": "#9ccc7c", + "Liquid": "#67b8de", + "Literate Agda": "#315665", + "Literate CoffeeScript": "#244776", + "Literate Haskell": "#5e5086", + "LiveCode Script": "#0c5ba5", + "LiveScript": "#499886", + "Logtalk": "#295b9a", + "LookML": "#652B81", + "Lua": "#000080", + "Luau": "#00A2FF", + "M3U": "#179C7D", + "MATLAB": "#e16737", + "MAXScript": "#00a6a6", + "MDX": "#fcb32c", + "MLIR": "#5EC8DB", + "MQL4": "#62A8D6", + "MQL5": "#4A76B8", + "MTML": "#b7e1f4", + "Macaulay2": "#d8ffff", + "Makefile": "#427819", + "Mako": "#7e858d", + "Markdown": "#083fa1", + "Marko": "#42bff2", + "Mask": "#f97732", + "Mathematica": "#dd1100", + "Max": "#c4a79c", + "Mercury": "#ff2b2b", + "Mermaid": "#ff3670", + "Meson": "#007800", + "Metal": "#8f14e9", + "MiniYAML": "#ff1111", + "MiniZinc": "#06a9e6", + "Mint": "#02b046", + "Mirah": "#c7a938", + "Modelica": "#de1d31", + "Modula-2": "#10253f", + "Modula-3": "#223388", + "Mojo": "#ff4c1f", + "Monkey C": "#8D6747", + "MoonBit": "#b92381", + "MoonScript": "#ff4585", + "Motoko": "#fbb03b", + "Motorola 68K Assembly": "#005daa", + "Move": "#4a137a", + "Mustache": "#724b3b", + "NCL": "#28431f", + "NMODL": "#00356B", + "NPM Config": "#cb3837", + "NWScript": "#111522", + "Nasal": "#1d2c4e", + "Nearley": "#990000", + "Nemerle": "#3d3c6e", + "NetLinx": "#0aa0ff", + "NetLinx+ERB": "#747faa", + "NetLogo": "#ff6375", + "NewLisp": "#87AED7", + "Nextflow": "#3ac486", + "Nginx": "#009639", + "Nickel": "#E0C3FC", + "Nim": "#ffc200", + "Nit": "#009917", + "Nix": "#7e7eff", + "Noir": "#2f1f49", + "Nu": "#c9df40", + "NumPy": "#9C8AF9", + "Nunjucks": "#3d8137", + "Nushell": "#4E9906", + "OASv2-json": "#85ea2d", + "OASv2-yaml": "#85ea2d", + "OASv3-json": "#85ea2d", + "OASv3-yaml": "#85ea2d", + "OCaml": "#ef7a08", + "OMNeT++ MSG": "#a0e0a0", + "OMNeT++ NED": "#08607c", + "ObjectScript": "#424893", + "Objective-C": "#438eff", + "Objective-C++": "#6866fb", + "Objective-J": "#ff0c5a", + "Odin": "#60AFFE", + "Omgrofl": "#cabbff", + "Opal": "#f7ede0", + "Open Policy Agent": "#7d9199", + "OpenAPI Specification v2": "#85ea2d", + "OpenAPI Specification v3": "#85ea2d", + "OpenCL": "#ed2e2d", + "OpenEdge ABL": "#5ce600", + "OpenQASM": "#AA70FF", + "OpenSCAD": "#e5cd45", + "Option List": "#476732", + "Org": "#77aa99", + "OverpassQL": "#cce2aa", + "Oxygene": "#cdd0e3", + "Oz": "#fab738", + "P4": "#7055b5", + "PDDL": "#0d00ff", + "PEG.js": "#234d6b", + "PHP": "#4F5D95", + "PLSQL": "#dad8d8", + "PLpgSQL": "#336790", + "POV-Ray SDL": "#6bac65", + "Pact": "#F7A8B8", + "Pan": "#cc0000", + "Papyrus": "#6600cc", + "Parrot": "#f3ca0a", + "Pascal": "#E3F171", + "Pawn": "#dbb284", + "Pep8": "#C76F5B", + "Perl": "#0298c3", + "PicoLisp": "#6067af", + "PigLatin": "#fcd7de", + "Pike": "#005390", + "Pip Requirements": "#FFD343", + "Pkl": "#6b9543", + "PlantUML": "#fbbd16", + "PogoScript": "#d80074", + "Polar": "#ae81ff", + "Portugol": "#f8bd00", + "PostCSS": "#dc3a0c", + "PostScript": "#da291c", + "PowerBuilder": "#8f0f8d", + "PowerShell": "#012456", + "Praat": "#c8506d", + "Prisma": "#0c344b", + "Processing": "#0096D8", + "Procfile": "#3B2F63", + "Prolog": "#74283c", + "Promela": "#de0000", + "Propeller Spin": "#7fa2a7", + "Pug": "#a86454", + "Puppet": "#302B6D", + "PureBasic": "#5a6986", + "PureScript": "#1D222D", + "Pyret": "#ee1e10", + "Python": "#3572A5", + "Python console": "#3572A5", + "Python traceback": "#3572A5", + "Q#": "#fed659", + "QML": "#44a51c", + "Qt Script": "#00b841", + "Quake": "#882233", + "QuakeC": "#975777", + "QuickBASIC": "#008080", + "R": "#198CE7", + "RAML": "#77d9fb", + "RBS": "#701516", + "RDoc": "#701516", + "REXX": "#d90e09", + "RMarkdown": "#198ce7", + "RON": "#a62c00", + "ROS Interface": "#22314e", + "RPGLE": "#2BDE21", + "RUNOFF": "#665a4e", + "Racket": "#3c5caa", + "Ragel": "#9d5200", + "Raku": "#0000fb", + "Rascal": "#fffaa0", + "ReScript": "#ed5051", + "Reason": "#ff5847", + "ReasonLIGO": "#ff5847", + "Rebol": "#358a5b", + "Record Jar": "#0673ba", + "Red": "#f50000", + "Regular Expression": "#009a00", + "Ren'Py": "#ff7f7f", + "Rez": "#FFDAB3", + "Ring": "#2D54CB", + "Riot": "#A71E49", + "RobotFramework": "#00c0b5", + "Roc": "#7c38f5", + "Rocq Prover": "#d0b68c", + "Roff": "#ecdebe", + "Roff Manpage": "#ecdebe", + "Rouge": "#cc0088", + "RouterOS Script": "#DE3941", + "Ruby": "#701516", + "Rust": "#dea584", + "SAS": "#B34936", + "SCSS": "#c6538c", + "SPARQL": "#0C4597", + "SQF": "#3F3F3F", + "SQL": "#e38c00", + "SQLPL": "#e38c00", + "SRecode Template": "#348a34", + "STL": "#373b5e", + "SVG": "#ff9900", + "Sail": "#259dd5", + "SaltStack": "#646464", + "Sass": "#a53b70", + "Scala": "#c22d40", + "Scaml": "#bd181a", + "Scenic": "#fdc700", + "Scheme": "#1e4aec", + "Scilab": "#ca0f21", + "Self": "#0579aa", + "ShaderLab": "#222c37", + "Shell": "#89e051", + "ShellCheck Config": "#cecfcb", + "Shen": "#120F14", + "Simple File Verification": "#C9BFED", + "Singularity": "#64E6AD", + "Slang": "#1fbec9", + "Slash": "#007eff", + "Slice": "#003fa2", + "Slim": "#2b2b2b", + "Slint": "#2379F4", + "SmPL": "#c94949", + "Smalltalk": "#596706", + "Smarty": "#f0c040", + "Smithy": "#c44536", + "Snakemake": "#419179", + "Solidity": "#AA6746", + "SourcePawn": "#f69e1d", + "Squirrel": "#800000", + "Stan": "#b2011d", + "Standard ML": "#dc566d", + "Starlark": "#76d275", + "Stata": "#1a5f91", + "StringTemplate": "#3fb34f", + "Stylus": "#ff6347", + "SubRip Text": "#9e0101", + "SugarSS": "#2fcc9f", + "SuperCollider": "#46390b", + "Survex data": "#ffcc99", + "Svelte": "#ff3e00", + "Sway": "#00F58C", + "Sweave": "#198ce7", + "Swift": "#F05138", + "SystemVerilog": "#DAE1C2", + "TI Program": "#A0AA87", + "TL-Verilog": "#C40023", + "TLA": "#4b0079", + "TOML": "#9c4221", + "TSQL": "#e38c00", + "TSV": "#237346", + "TSX": "#3178c6", + "TXL": "#0178b8", + "Tact": "#48b5ff", + "Talon": "#333333", + "Tcl": "#e4cc98", + "TeX": "#3D6117", + "Terra": "#00004c", + "Terraform Template": "#7b42bb", + "TextGrid": "#c8506d", + "TextMate Properties": "#df66e4", + "Textile": "#ffe7ac", + "Thrift": "#D12127", + "Toit": "#c2c9fb", + "Tor Config": "#59316b", + "Tree-sitter Query": "#8ea64c", + "Turing": "#cf142b", + "Twig": "#c1d026", + "TypeScript": "#3178c6", + "TypeSpec": "#4A3665", + "Typst": "#239dad", + "Unified Parallel C": "#4e3617", + "Unity3D Asset": "#222c37", + "Uno": "#9933cc", + "UnrealScript": "#a54c4d", + "Untyped Plutus Core": "#36adbd", + "UrWeb": "#ccccee", + "V": "#4f87c4", + "VBA": "#867db1", + "VBScript": "#15dcdc", + "VCL": "#148AA8", + "VHDL": "#adb2cb", + "Vala": "#a56de2", + "Valve Data Format": "#f26025", + "Velocity Template Language": "#507cff", + "Vento": "#ff0080", + "Verilog": "#b2b7f8", + "Vim Help File": "#199f4b", + "Vim Script": "#199f4b", + "Vim Snippet": "#199f4b", + "Visual Basic .NET": "#945db7", + "Visual Basic 6.0": "#2c6353", + "Volt": "#1F1F1F", + "Vue": "#41b883", + "Vyper": "#9F4CF2", + "WDL": "#42f1f4", + "WGSL": "#1a5e9a", + "Web Ontology Language": "#5b70bd", + "WebAssembly": "#04133b", + "WebAssembly Interface Type": "#6250e7", + "Whiley": "#d5c397", + "Wikitext": "#fc5757", + "Windows Registry Entries": "#52d5ff", + "Witcher Script": "#ff0000", + "Wollok": "#a23738", + "World of Warcraft Addon Data": "#f7e43f", + "Wren": "#383838", + "X10": "#4B6BEF", + "XC": "#99DA07", + "XML": "#0060ac", + "XML Property List": "#0060ac", + "XQuery": "#5232e7", + "XSLT": "#EB8CEB", + "Xmake": "#22a079", + "Xojo": "#81bd41", + "Xonsh": "#285EEF", + "Xtend": "#24255d", + "YAML": "#cb171e", + "YARA": "#220000", + "YASnippet": "#32AB90", + "Yacc": "#4B6C4B", + "Yul": "#794932", + "ZAP": "#0d665e", + "ZIL": "#dc75e5", + "ZenScript": "#00BCD1", + "Zephir": "#118f9e", + "Zig": "#ec915c", + "Zimpl": "#d67711", + "Zmodel": "#ff7100", + "crontab": "#ead7ac", + "eC": "#913960", + "fish": "#4aae47", + "hoon": "#00b171", + "iCalendar": "#ec564c", + "jq": "#c7254e", + "kvlang": "#1da6e0", + "mIRC Script": "#3d57c3", + "mcfunction": "#E22837", + "mdsvex": "#5f9ea0", + "mupad": "#244963", + "nanorc": "#2d004d", + "nesC": "#94B0C7", + "ooc": "#b0b77e", + "q": "#0040cd", + "reStructuredText": "#141414", + "sed": "#64b970", + "templ": "#66D0DD", + "vCard": "#ee2647", + "wisp": "#7582D1", + "xBase": "#403a40" +} \ No newline at end of file diff --git a/frontend/frontend/src/backend/src/common/retryer.js b/frontend/frontend/src/backend/src/common/retryer.js new file mode 100644 index 00000000..bbef173f --- /dev/null +++ b/frontend/frontend/src/backend/src/common/retryer.js @@ -0,0 +1,76 @@ +import { CustomError, logger } from "./utils.js"; + +// Script variables. + +// Count the number of GitHub API tokens available. +const PATs = Object.keys(process.env).filter((key) => + /PAT_\d*$/.exec(key), +).length; +const RETRIES = process.env.NODE_ENV === "test" ? 7 : PATs; + +/** + * @typedef {import("axios").AxiosResponse} AxiosResponse Axios response. + * @typedef {(variables: object, token: string) => Promise} FetcherFunction Fetcher function. + */ + +/** + * Try to execute the fetcher function until it succeeds or the max number of retries is reached. + * + * @param {FetcherFunction} fetcher The fetcher function. + * @param {object} variables Object with arguments to pass to the fetcher function. + * @param {number} retries How many times to retry. + * @returns {Promise} The response from the fetcher function. + */ +const retryer = async (fetcher, variables, retries = 0) => { + if (!RETRIES) { + throw new CustomError("No GitHub API tokens found", CustomError.NO_TOKENS); + } + if (retries > RETRIES) { + throw new CustomError( + "Downtime due to GitHub API rate limiting", + CustomError.MAX_RETRY, + ); + } + try { + // try to fetch with the first token since RETRIES is 0 index i'm adding +1 + let response = await fetcher( + variables, + process.env[`PAT_${retries + 1}`], + retries, + ); + + // prettier-ignore + const isRateExceeded = response.data.errors && response.data.errors[0].type === "RATE_LIMITED"; + + // if rate limit is hit increase the RETRIES and recursively call the retryer + // with username, and current RETRIES + if (isRateExceeded) { + logger.log(`PAT_${retries + 1} Failed due to rate limiting`); + retries++; + // directly return from the function + return retryer(fetcher, variables, retries); + } + + // finally return the response + return response; + } catch (err) { + // prettier-ignore + // also checking for bad credentials if any tokens gets invalidated + const isBadCredential = err.response.data && err.response.data.message === "Bad credentials"; + const isAccountSuspended = + err.response.data && + err.response.data.message === "Sorry. Your account was suspended."; + + if (isBadCredential || isAccountSuspended) { + logger.log(`PAT_${retries + 1} Failed due to bad credentials`); + retries++; + // directly return from the function + return retryer(fetcher, variables, retries); + } else { + return err.response; + } + } +}; + +export { retryer, RETRIES }; +export default retryer; diff --git a/frontend/frontend/src/backend/src/common/utils.js b/frontend/frontend/src/backend/src/common/utils.js new file mode 100644 index 00000000..046c7fc7 --- /dev/null +++ b/frontend/frontend/src/backend/src/common/utils.js @@ -0,0 +1,679 @@ +// @ts-check +import axios from "axios"; +import toEmoji from "emoji-name-map"; +import wrap from "word-wrap"; +import { themes } from "../../themes/index.js"; + +const OWNER_AFFILIATIONS = ["OWNER", "COLLABORATOR", "ORGANIZATION_MEMBER"]; + +const TRY_AGAIN_LATER = "Please try again later"; + +const SECONDARY_ERROR_MESSAGES = { + MAX_RETRY: + "You can deploy own instance or wait until public will be no longer limited", + NO_TOKENS: + "Please add an env variable called PAT_1 with your GitHub API token in vercel", + USER_NOT_FOUND: "Make sure the provided username is not an organization", + GRAPHQL_ERROR: TRY_AGAIN_LATER, + GITHUB_REST_API_ERROR: TRY_AGAIN_LATER, + WAKATIME_USER_NOT_FOUND: "Make sure you have a public WakaTime profile", + INVALID_AFFILIATION: `Invalid owner affiliations. Valid values are: ${OWNER_AFFILIATIONS.join( + ", ", + )}`, +}; + +/** + * Custom error class to handle custom GRS errors. + */ +class CustomError extends Error { + /** + * @param {string} message Error message. + * @param {string} type Error type. + */ + constructor(message, type) { + super(message); + this.type = type; + this.secondaryMessage = SECONDARY_ERROR_MESSAGES[type] || type; + } + + static MAX_RETRY = "MAX_RETRY"; + static NO_TOKENS = "NO_TOKENS"; + static USER_NOT_FOUND = "USER_NOT_FOUND"; + static GRAPHQL_ERROR = "GRAPHQL_ERROR"; + static GITHUB_REST_API_ERROR = "GITHUB_REST_API_ERROR"; + static WAKATIME_ERROR = "WAKATIME_ERROR"; + static INVALID_AFFILIATION = "INVALID_AFFILIATION"; +} + +/** + * Auto layout utility, allows us to layout things vertically or horizontally with + * proper gaping. + * + * @param {object} props Function properties. + * @param {string[]} props.items Array of items to layout. + * @param {number} props.gap Gap between items. + * @param {"column" | "row"=} props.direction Direction to layout items. + * @param {number[]=} props.sizes Array of sizes for each item. + * @returns {string[]} Array of items with proper layout. + */ +const flexLayout = ({ items, gap, direction, sizes = [] }) => { + let lastSize = 0; + // filter() for filtering out empty strings + return items.filter(Boolean).map((item, i) => { + const size = sizes[i] || 0; + let transform = `translate(${lastSize}, 0)`; + if (direction === "column") { + transform = `translate(0, ${lastSize})`; + } + lastSize += size + gap; + return `${item}`; + }); +}; + +/** + * Creates a node to display the primary programming language of the repository/gist. + * + * @param {string} langName Language name. + * @param {string} langColor Language color. + * @returns {string} Language display SVG object. + */ +const createLanguageNode = (langName, langColor) => { + return ` + + + ${langName} + + `; +}; + +/** + * Creates an icon with label to display repository/gist stats like forks, stars, etc. + * + * @param {string} icon The icon to display. + * @param {number|string} label The label to display. + * @param {string} testid The testid to assign to the label. + * @param {number} iconSize The size of the icon. + * @returns {string} Icon with label SVG object. + */ +const iconWithLabel = (icon, label, testid, iconSize) => { + if (typeof label === "number" && label <= 0) { + return ""; + } + const iconSvg = ` + + ${icon} + + `; + const text = `${label}`; + return flexLayout({ items: [iconSvg, text], gap: 20 }).join(""); +}; + +/** + * Retrieves num with suffix k(thousands) precise to 1 decimal if greater than 999. + * + * @param {number} num The number to format. + * @returns {string|number} The formatted number. + */ +const kFormatter = (num) => { + return Math.abs(num) > 999 + ? Math.sign(num) * parseFloat((Math.abs(num) / 1000).toFixed(1)) + "k" + : Math.sign(num) * Math.abs(num); +}; + +/** + * Checks if a string is a valid hex color. + * + * @param {string} hexColor String to check. + * @returns {boolean} True if the given string is a valid hex color. + */ +const isValidHexColor = (hexColor) => { + return new RegExp( + /^([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3}|[A-Fa-f0-9]{4})$/, + ).test(hexColor); +}; + +/** + * Returns boolean if value is either "true" or "false" else the value as it is. + * + * @param {string | boolean} value The value to parse. + * @returns {boolean | undefined } The parsed value. + */ +const parseBoolean = (value) => { + if (typeof value === "boolean") { + return value; + } + + if (typeof value === "string") { + if (value.toLowerCase() === "true") { + return true; + } else if (value.toLowerCase() === "false") { + return false; + } + } + return undefined; +}; + +/** + * Parse string to array of strings. + * + * @param {string} str The string to parse. + * @returns {string[]} The array of strings. + */ +const parseArray = (str) => { + if (!str) { + return []; + } + return str.split(","); +}; + +/** + * Clamp the given number between the given range. + * + * @param {number} number The number to clamp. + * @param {number} min The minimum value. + * @param {number} max The maximum value. + * @returns {number} The clamped number. + */ +const clampValue = (number, min, max) => { + // @ts-ignore + if (Number.isNaN(parseInt(number, 10))) { + return min; + } + return Math.max(min, Math.min(number, max)); +}; + +/** + * Check if the given string is a valid gradient. + * + * @param {string[]} colors Array of colors. + * @returns {boolean} True if the given string is a valid gradient. + */ +const isValidGradient = (colors) => { + return ( + colors.length > 2 && + colors.slice(1).every((color) => isValidHexColor(color)) + ); +}; + +/** + * Retrieves a gradient if color has more than one valid hex codes else a single color. + * + * @param {string} color The color to parse. + * @param {string | string[]} fallbackColor The fallback color. + * @returns {string | string[]} The gradient or color. + */ +const fallbackColor = (color, fallbackColor) => { + let gradient = null; + + let colors = color ? color.split(",") : []; + if (colors.length > 1 && isValidGradient(colors)) { + gradient = colors; + } + + return ( + (gradient ? gradient : isValidHexColor(color) && `#${color}`) || + fallbackColor + ); +}; + +const buildSearchFilter = (repos = [], owners = []) => { + let repoFilter = + Array.isArray(repos) && repos.length > 0 + ? repos.map((r) => `repo:${r} `).join("") + : ""; + let orgFilter = + Array.isArray(owners) && owners.length > 0 + ? owners.map((o) => `owner:${o} `).join("") + : ""; + return repoFilter + orgFilter; +}; + +/** + * @typedef {import('axios').AxiosRequestConfig['data']} AxiosRequestConfigData Axios request data. + * @typedef {import('axios').AxiosRequestConfig['headers']} AxiosRequestConfigHeaders Axios request headers. + */ + +/** + * Send GraphQL request to GitHub API. + * + * @param {AxiosRequestConfigData} data Request data. + * @param {AxiosRequestConfigHeaders} headers Request headers. + * @returns {Promise} Request response. + */ +const request = (data, headers) => { + return axios({ + url: "https://api.github.com/graphql", + method: "post", + headers, + data, + }); +}; + +/** + * Object containing card colors. + * @typedef {{ + * titleColor: string; + * iconColor: string; + * textColor: string; + * bgColor: string | string[]; + * borderColor: string; + * ringColor: string; + * }} CardColors + */ + +/** + * Returns theme based colors with proper overrides and defaults. + * + * @param {Object} args Function arguments. + * @param {string=} args.title_color Card title color. + * @param {string=} args.text_color Card text color. + * @param {string=} args.icon_color Card icon color. + * @param {string=} args.bg_color Card background color. + * @param {string=} args.border_color Card border color. + * @param {string=} args.ring_color Card ring color. + * @param {string=} args.theme Card theme. + * @param {string=} args.fallbackTheme Fallback theme. + * @returns {CardColors} Card colors. + */ +const getCardColors = ({ + title_color, + text_color, + icon_color, + bg_color, + border_color, + ring_color, + theme, + fallbackTheme = "default", +}) => { + const defaultTheme = themes[fallbackTheme]; + const selectedTheme = themes[theme] || defaultTheme; + const defaultBorderColor = + selectedTheme.border_color || defaultTheme.border_color; + + // get the color provided by the user else the theme color + // finally if both colors are invalid fallback to default theme + const titleColor = fallbackColor( + title_color || selectedTheme.title_color, + "#" + defaultTheme.title_color, + ); + + // get the color provided by the user else the theme color + // finally if both colors are invalid we use the titleColor + const ringColor = fallbackColor( + ring_color || selectedTheme.ring_color, + titleColor, + ); + const iconColor = fallbackColor( + icon_color || selectedTheme.icon_color, + "#" + defaultTheme.icon_color, + ); + const textColor = fallbackColor( + text_color || selectedTheme.text_color, + "#" + defaultTheme.text_color, + ); + const bgColor = fallbackColor( + bg_color || selectedTheme.bg_color, + "#" + defaultTheme.bg_color, + ); + + const borderColor = fallbackColor( + border_color || defaultBorderColor, + "#" + defaultBorderColor, + ); + + if ( + typeof titleColor !== "string" || + typeof textColor !== "string" || + typeof ringColor !== "string" || + typeof iconColor !== "string" || + typeof borderColor !== "string" + ) { + throw new Error( + "Unexpected behavior, all colors except background should be string.", + ); + } + + return { titleColor, iconColor, textColor, bgColor, borderColor, ringColor }; +}; + +// Script parameters. +const ERROR_CARD_LENGTH = 576.5; + +/** + * Encode string as HTML. + * + * @see https://stackoverflow.com/a/48073476/10629172 + * + * @param {string} str String to encode. + * @returns {string} Encoded string. + */ +const encodeHTML = (str) => { + return str + .replace(/[\u00A0-\u9999<>&](?!#)/gim, (i) => { + return "&#" + i.charCodeAt(0) + ";"; + }) + .replace(/\u0008/gim, ""); +}; + +const UPSTREAM_API_ERRORS = [ + TRY_AGAIN_LATER, + SECONDARY_ERROR_MESSAGES.MAX_RETRY, +]; + +/** + * Renders error message on the card. + * + * @param {string} message Main error message. + * @param {string} secondaryMessage The secondary error message. + * @param {object} options Function options. + * @param {string=} options.title_color Card title color. + * @param {string=} options.text_color Card text color. + * @param {string=} options.bg_color Card background color. + * @param {string=} options.border_color Card border color. + * @param {string=} options.theme Card theme. + * @param {boolean=} options.show_repo_link Whether to show repo link or not. + * @returns {string} The SVG markup. + */ +const renderError = (message, secondaryMessage = "", options = {}) => { + const { + title_color, + text_color, + bg_color, + border_color, + theme = "default", + show_repo_link = true, + } = options; + + // returns theme based colors with proper overrides and defaults + const { titleColor, textColor, bgColor, borderColor } = getCardColors({ + title_color, + text_color, + icon_color: "", + bg_color, + border_color, + ring_color: "", + theme, + }); + + return ` + + + + Something went wrong!${ + UPSTREAM_API_ERRORS.includes(secondaryMessage) || !show_repo_link + ? "" + : " file an issue at https://tiny.one/readme-stats" + } + + ${encodeHTML(message)} + ${secondaryMessage} + + + `; +}; + +/** + * Split text over multiple lines based on the card width. + * + * @param {string} text Text to split. + * @param {number} width Line width in number of characters. + * @param {number} maxLines Maximum number of lines. + * @returns {string[]} Array of lines. + */ +const wrapTextMultiline = (text, width = 59, maxLines = 3) => { + const fullWidthComma = ","; + const encoded = encodeHTML(text); + const isChinese = encoded.includes(fullWidthComma); + + let wrapped = []; + + if (isChinese) { + wrapped = encoded.split(fullWidthComma); // Chinese full punctuation + } else { + wrapped = wrap(encoded, { + width, + }).split("\n"); // Split wrapped lines to get an array of lines + } + + const lines = wrapped.map((line) => line.trim()).slice(0, maxLines); // Only consider maxLines lines + + // Add "..." to the last line if the text exceeds maxLines + if (wrapped.length > maxLines) { + lines[maxLines - 1] += "..."; + } + + // Remove empty lines if text fits in less than maxLines lines + const multiLineText = lines.filter(Boolean); + return multiLineText; +}; + +const noop = () => {}; +// return console instance based on the environment +const logger = + process.env.NODE_ENV === "test" ? { log: noop, error: noop } : console; + +const MIN = 60; +const HOUR = 60 * MIN; +const DAY = 24 * HOUR; + +const CONSTANTS = { + ONE_MINUTE: MIN, + FIVE_MINUTES: 5 * MIN, + TEN_MINUTES: 10 * MIN, + FIFTEEN_MINUTES: 15 * MIN, + THIRTY_MINUTES: 30 * MIN, + + TWO_HOURS: 2 * HOUR, + FOUR_HOURS: 4 * HOUR, + SIX_HOURS: 6 * HOUR, + EIGHT_HOURS: 8 * HOUR, + TEN_HOURS: 10 * HOUR, + TWELVE_HOURS: 12 * HOUR, + + ONE_DAY: DAY, + TWO_DAY: 2 * DAY, + SIX_DAY: 6 * DAY, + TEN_DAY: 10 * DAY, + + CARD_CACHE_SECONDS: 10 * HOUR, + TOP_LANGS_CACHE_SECONDS: 10 * HOUR, + PIN_CARD_CACHE_SECONDS: 10 * HOUR, + ERROR_CACHE_SECONDS: 10 * MIN, +}; + +/** + * Missing query parameter class. + */ +class MissingParamError extends Error { + /** + * Missing query parameter error constructor. + * + * @param {string[]} missedParams An array of missing parameters names. + * @param {string=} secondaryMessage Optional secondary message to display. + */ + constructor(missedParams, secondaryMessage) { + const msg = `Missing params ${missedParams + .map((p) => `"${p}"`) + .join(", ")} make sure you pass the parameters in URL`; + super(msg); + this.missedParams = missedParams; + this.secondaryMessage = secondaryMessage; + } +} + +/** + * Retrieve text length. + * + * @see https://stackoverflow.com/a/48172630/10629172 + * @param {string} str String to measure. + * @param {number} fontSize Font size. + * @returns {number} Text length. + */ +const measureText = (str, fontSize = 10) => { + // prettier-ignore + const widths = [ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0.2796875, 0.2765625, + 0.3546875, 0.5546875, 0.5546875, 0.8890625, 0.665625, 0.190625, + 0.3328125, 0.3328125, 0.3890625, 0.5828125, 0.2765625, 0.3328125, + 0.2765625, 0.3015625, 0.5546875, 0.5546875, 0.5546875, 0.5546875, + 0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.5546875, 0.5546875, + 0.2765625, 0.2765625, 0.584375, 0.5828125, 0.584375, 0.5546875, + 1.0140625, 0.665625, 0.665625, 0.721875, 0.721875, 0.665625, + 0.609375, 0.7765625, 0.721875, 0.2765625, 0.5, 0.665625, + 0.5546875, 0.8328125, 0.721875, 0.7765625, 0.665625, 0.7765625, + 0.721875, 0.665625, 0.609375, 0.721875, 0.665625, 0.94375, + 0.665625, 0.665625, 0.609375, 0.2765625, 0.3546875, 0.2765625, + 0.4765625, 0.5546875, 0.3328125, 0.5546875, 0.5546875, 0.5, + 0.5546875, 0.5546875, 0.2765625, 0.5546875, 0.5546875, 0.221875, + 0.240625, 0.5, 0.221875, 0.8328125, 0.5546875, 0.5546875, + 0.5546875, 0.5546875, 0.3328125, 0.5, 0.2765625, 0.5546875, + 0.5, 0.721875, 0.5, 0.5, 0.5, 0.3546875, 0.259375, 0.353125, 0.5890625, + ]; + + const avg = 0.5279276315789471; + return ( + str + .split("") + .map((c) => + c.charCodeAt(0) < widths.length ? widths[c.charCodeAt(0)] : avg, + ) + .reduce((cur, acc) => acc + cur) * fontSize + ); +}; + +/** + * Lowercase and trim string. + * + * @param {string} name String to lowercase and trim. + * @returns {string} Lowercased and trimmed string. + */ +const lowercaseTrim = (name) => name.toLowerCase().trim(); + +/** + * Split array of languages in two columns. + * + * @template T Language object. + * @param {Array} arr Array of languages. + * @param {number} perChunk Number of languages per column. + * @returns {Array} Array of languages split in two columns. + */ +const chunkArray = (arr, perChunk) => { + return arr.reduce((resultArray, item, index) => { + const chunkIndex = Math.floor(index / perChunk); + + if (!resultArray[chunkIndex]) { + // @ts-ignore + resultArray[chunkIndex] = []; // start a new chunk + } + + // @ts-ignore + resultArray[chunkIndex].push(item); + + return resultArray; + }, []); +}; + +/** + * Parse emoji from string. + * + * @param {string} str String to parse emoji from. + * @returns {string} String with emoji parsed. + */ +const parseEmojis = (str) => { + if (!str) { + throw new Error("[parseEmoji]: str argument not provided"); + } + return str.replace(/:\w+:/gm, (emoji) => { + return toEmoji.get(emoji) || ""; + }); +}; +/** + * Parse owner affiliations. + * + * @param {string[]} affiliations input affiliations to be parsed. + * @returns {string[]} Parsed affiliations. + * + * @throws {CustomError} If affiliations contains invalid values. + */ +const parseOwnerAffiliations = (affiliations) => { + // Set default value for ownerAffiliations. + // NOTE: Done here since parseArray() will always return an empty array even nothing + //was specified. + affiliations = + affiliations && affiliations.length > 0 + ? affiliations.map((affiliation) => affiliation.toUpperCase()) + : ["OWNER"]; + + // Check if ownerAffiliations contains valid values. + if ( + affiliations.some( + (affiliation) => !OWNER_AFFILIATIONS.includes(affiliation), + ) + ) { + throw new CustomError( + "Invalid query parameter", + CustomError.INVALID_AFFILIATION, + ); + } + return affiliations; +}; + +/** + * Get diff in minutes between two dates. + * + * @param {Date} d1 First date. + * @param {Date} d2 Second date. + * @returns {number} Number of minutes between the two dates. + */ +const dateDiff = (d1, d2) => { + const date1 = new Date(d1); + const date2 = new Date(d2); + const diff = date1.getTime() - date2.getTime(); + return Math.round(diff / (1000 * 60)); +}; + +export { + ERROR_CARD_LENGTH, + renderError, + createLanguageNode, + iconWithLabel, + encodeHTML, + kFormatter, + isValidHexColor, + parseBoolean, + parseArray, + clampValue, + isValidGradient, + fallbackColor, + buildSearchFilter, + request, + flexLayout, + getCardColors, + wrapTextMultiline, + logger, + CONSTANTS, + OWNER_AFFILIATIONS, + CustomError, + MissingParamError, + measureText, + lowercaseTrim, + chunkArray, + parseEmojis, + parseOwnerAffiliations, + dateDiff, +}; diff --git a/frontend/frontend/src/backend/src/common/whitelist.js b/frontend/frontend/src/backend/src/common/whitelist.js new file mode 100644 index 00000000..b5df7c70 --- /dev/null +++ b/frontend/frontend/src/backend/src/common/whitelist.js @@ -0,0 +1,10 @@ +const whitelist = process.env.WHITELIST + ? process.env.WHITELIST.split(",") + : undefined; + +const gistWhitelist = process.env.GIST_WHITELIST + ? process.env.GIST_WHITELIST.split(",") + : undefined; + +export { whitelist, gistWhitelist }; +export default whitelist; diff --git a/frontend/frontend/src/backend/src/fetchers/gist.js b/frontend/frontend/src/backend/src/fetchers/gist.js new file mode 100644 index 00000000..cd4006b2 --- /dev/null +++ b/frontend/frontend/src/backend/src/fetchers/gist.js @@ -0,0 +1,114 @@ +// @ts-check + +import { request, MissingParamError } from "../common/utils.js"; +import { retryer } from "../common/retryer.js"; + +/** + * @typedef {import('axios').AxiosRequestHeaders} AxiosRequestHeaders Axios request headers. + * @typedef {import('axios').AxiosResponse} AxiosResponse Axios response. + */ + +const QUERY = ` +query gistInfo($gistName: String!) { + viewer { + gist(name: $gistName) { + description + owner { + login + } + stargazerCount + forks { + totalCount + } + files { + name + language { + name + } + size + } + } + } +} +`; + +/** + * Gist data fetcher. + * + * @param {AxiosRequestHeaders} variables Fetcher variables. + * @param {string} token GitHub token. + * @returns {Promise} The response. + */ +const fetcher = async (variables, token) => { + return await request( + { query: QUERY, variables }, + { Authorization: `token ${token}` }, + ); +}; + +/** + * @typedef {{ name: string; language: { name: string; }, size: number }} GistFile Gist file. + */ + +/** + * This function calculates the primary language of a gist by files size. + * + * @param {GistFile[]} files Files. + * @returns {string} Primary language. + */ +const calculatePrimaryLanguage = (files) => { + const languages = {}; + for (const file of files) { + if (file.language) { + if (languages[file.language.name]) { + languages[file.language.name] += file.size; + } else { + languages[file.language.name] = file.size; + } + } + } + let primaryLanguage = Object.keys(languages)[0]; + for (const language in languages) { + if (languages[language] > languages[primaryLanguage]) { + primaryLanguage = language; + } + } + return primaryLanguage; +}; + +/** + * @typedef {import('./types').GistData} GistData Gist data. + */ + +/** + * Fetch GitHub gist information by given username and ID. + * + * @param {string} id GitHub gist ID. + * @returns {Promise} Gist data. + */ +const fetchGist = async (id) => { + if (!id) { + throw new MissingParamError(["id"], "/api/gist?id=GIST_ID"); + } + const res = await retryer(fetcher, { gistName: id }); + if (res.data.errors) { + throw new Error(res.data.errors[0].message); + } + if (!res.data.data.viewer.gist) { + throw new Error("Gist not found"); + } + const data = res.data.data.viewer.gist; + return { + name: data.files[Object.keys(data.files)[0]].name, + nameWithOwner: `${data.owner.login}/${ + data.files[Object.keys(data.files)[0]].name + }`, + description: data.description, + language: calculatePrimaryLanguage(data.files), + starsCount: data.stargazerCount, + forksCount: data.forks.totalCount, + }; +}; + +export { fetchGist }; +export default fetchGist; diff --git a/frontend/frontend/src/backend/src/fetchers/repo.js b/frontend/frontend/src/backend/src/fetchers/repo.js new file mode 100644 index 00000000..72ab79e4 --- /dev/null +++ b/frontend/frontend/src/backend/src/fetchers/repo.js @@ -0,0 +1,165 @@ +// @ts-check +import { retryer } from "../common/retryer.js"; +import { MissingParamError, request } from "../common/utils.js"; +import { fetchRepoUserStats } from "./stats.js"; + +/** + * @typedef {import('axios').AxiosRequestHeaders} AxiosRequestHeaders Axios request headers. + * @typedef {import('axios').AxiosResponse} AxiosResponse Axios response. + */ + +/** + * Repo data fetcher. + * + * @param {AxiosRequestHeaders} variables Fetcher variables. + * @param {string} token GitHub token. + * @returns {Promise} The response. + */ +const fetcher = (variables, token) => { + return request( + { + query: ` + fragment RepoInfo on Repository { + name + nameWithOwner + isPrivate + isArchived + isTemplate + stargazers { + totalCount + } + description + primaryLanguage { + color + id + name + } + forkCount + } + query getRepo($login: String!, $repo: String!) { + user(login: $login) { + repository(name: $repo) { + ...RepoInfo + } + } + organization(login: $login) { + repository(name: $repo) { + ...RepoInfo + } + } + } + `, + variables, + }, + { + Authorization: `token ${token}`, + }, + ); +}; + +const urlExample = "/api/pin?username=USERNAME&repo=REPO_NAME"; + +/** + * @typedef {import("./types").RepositoryData} RepositoryData Repository data. + */ + +/** + * Fetch repository data. + * + * @param {string} username GitHub username. + * @param {string} reponame GitHub repository name. + * @returns {Promise} Repository data. + */ +const fetchRepo = async ( + username, + reponame, + include_prs_authored = false, + include_prs_commented = false, + include_prs_reviewed = false, + include_issues_authored = false, + include_issues_commented = false, +) => { + let owner = username; + if (reponame && reponame.includes("/")) { + const [parsed_owner, parsed_repo] = reponame.split("/"); + owner = parsed_owner; + reponame = parsed_repo; + } + + if (owner && !username) { + username = owner; + } + if (username && !owner) { + owner = username; + } + if (!username && !reponame) { + throw new MissingParamError(["username", "repo"], urlExample); + } + if (!username) { + throw new MissingParamError(["username"], urlExample); + } + if (!reponame) { + throw new MissingParamError(["repo"], urlExample); + } + + let res = await retryer(fetcher, { login: owner, repo: reponame }); + + const data = res.data.data; + + if (!data.user && !data.organization) { + throw new Error("Not found"); + } + + const isUser = data.organization === null && data.user; + const isOrg = data.user === null && data.organization; + + if (isUser) { + if (!data.user.repository || data.user.repository.isPrivate) { + throw new Error("User Repository Not found"); + } + let repoUserStats = await fetchRepoUserStats( + username, + [owner + "/" + reponame], + [], + include_prs_authored, + include_prs_commented, + include_prs_reviewed, + include_issues_authored, + include_issues_commented, + ); + return { + ...repoUserStats, + ...data.user.repository, + starCount: data.user.repository.stargazers.totalCount, + }; + } + + if (isOrg) { + if ( + !data.organization.repository || + data.organization.repository.isPrivate + ) { + throw new Error("Organization Repository Not found"); + } + let repoUserStats = await fetchRepoUserStats( + username, + [owner + "/" + reponame], + [], + include_prs_authored, + include_prs_commented, + include_prs_reviewed, + include_issues_authored, + include_issues_commented, + ); + return { + ...repoUserStats, + ...data.organization.repository, + starCount: data.organization.repository.stargazers.totalCount, + }; + } + + throw new Error("Unexpected behavior"); +}; + +export { fetchRepo }; +export default fetchRepo; diff --git a/frontend/frontend/src/backend/src/fetchers/stats.js b/frontend/frontend/src/backend/src/fetchers/stats.js new file mode 100644 index 00000000..c55dd044 --- /dev/null +++ b/frontend/frontend/src/backend/src/fetchers/stats.js @@ -0,0 +1,442 @@ +// @ts-check +import axios from "axios"; +import * as dotenv from "dotenv"; +import githubUsernameRegex from "github-username-regex"; +import { calculateRank } from "../calculateRank.js"; +import { retryer } from "../common/retryer.js"; +import { + buildSearchFilter, + CustomError, + logger, + MissingParamError, + request, + wrapTextMultiline, + parseOwnerAffiliations, +} from "../common/utils.js"; + +dotenv.config(); + +// GraphQL queries. +const GRAPHQL_REPOS_FIELD = ` + repositories(first: 100, after: $after, ownerAffiliations: $ownerAffiliations, orderBy: {direction: DESC, field: STARGAZERS}) { + totalCount + nodes { + name + stargazers { + totalCount + } + } + pageInfo { + hasNextPage + endCursor + } + } +`; + +const GRAPHQL_REPOS_QUERY = ` + query userInfo($login: String!, $after: String, $ownerAffiliations: [RepositoryAffiliation]) { + user(login: $login, ownerAffiliations: $ownerAffiliations) { + ${GRAPHQL_REPOS_FIELD} + } + } +`; + +const GRAPHQL_STATS_QUERY = ` + query userInfo($login: String!, $after: String, $includeMergedPullRequests: Boolean!, $includeDiscussions: Boolean!, $includeDiscussionsAnswers: Boolean!, $ownerAffiliations: [RepositoryAffiliation]) { + user(login: $login) { + name + login + contributionsCollection { + totalCommitContributions, + totalPullRequestReviewContributions + } + repositoriesContributedTo(first: 1, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY]) { + totalCount + } + pullRequests(first: 1) { + totalCount + } + mergedPullRequests: pullRequests(states: MERGED) @include(if: $includeMergedPullRequests) { + totalCount + } + openIssues: issues(states: OPEN) { + totalCount + } + closedIssues: issues(states: CLOSED) { + totalCount + } + followers { + totalCount + } + repositoryDiscussions @include(if: $includeDiscussions) { + totalCount + } + repositoryDiscussionComments(onlyAnswers: true) @include(if: $includeDiscussionsAnswers) { + totalCount + } + ${GRAPHQL_REPOS_FIELD} + } + } +`; + +/** + * @typedef {import('axios').AxiosResponse} AxiosResponse Axios response. + */ + +/** + * Stats fetcher object. + * + * @param {object} variables Fetcher variables. + * @param {string} token GitHub token. + * @returns {Promise} Axios response. + */ +const fetcher = (variables, token) => { + const query = variables.after ? GRAPHQL_REPOS_QUERY : GRAPHQL_STATS_QUERY; + return request( + { + query, + variables, + }, + { + Authorization: `bearer ${token}`, + }, + ); +}; + +/** + * Fetch stats information for a given username. + * + * @param {object} variables Fetcher variables. + * @param {string} variables.username GitHub username. + * @param {boolean} variables.includeMergedPullRequests Include merged pull requests. + * @param {boolean} variables.includeDiscussions Include discussions. + * @param {boolean} variables.includeDiscussionsAnswers Include discussions answers. + * @param {string[]} ownerAffiliations The owner affiliations to filter by. Default: OWNER. + * @returns {Promise} Axios response. + * + * @description This function supports multi-page fetching if the 'FETCH_MULTI_PAGE_STARS' environment variable is set to true. + */ +const statsFetcher = async ({ + username, + includeMergedPullRequests, + includeDiscussions, + includeDiscussionsAnswers, + ownerAffiliations, +}) => { + let stats; + let hasNextPage = true; + let endCursor = null; + while (hasNextPage) { + const variables = { + login: username, + first: 100, + after: endCursor, + includeMergedPullRequests, + includeDiscussions, + includeDiscussionsAnswers, + ownerAffiliations, + }; + let res = await retryer(fetcher, variables); + if (res.data.errors) { + return res; + } + + // Store stats data. + const repoNodes = res.data.data.user.repositories.nodes; + if (stats) { + stats.data.data.user.repositories.nodes.push(...repoNodes); + } else { + stats = res; + } + + // Disable multi page fetching on public Vercel instance due to rate limits. + const repoNodesWithStars = repoNodes.filter( + (node) => node.stargazers.totalCount !== 0, + ); + hasNextPage = + process.env.FETCH_MULTI_PAGE_STARS === "true" && + repoNodes.length === repoNodesWithStars.length && + res.data.data.user.repositories.pageInfo.hasNextPage; + endCursor = res.data.data.user.repositories.pageInfo.endCursor; + } + + return stats; +}; + +/** + * Fetch all the commits for all the repositories of a given username. + * + * @param {string} username GitHub username. + * @returns {Promise} Total commits. + * + * @description Done like this because the GitHub API does not provide a way to fetch all the commits. See + * #92#issuecomment-661026467 and #211 for more information. + */ +const totalItemsFetcher = async (username, repo, owner, type, filter) => { + if (!githubUsernameRegex.test(username)) { + logger.log("Invalid username provided."); + throw new Error("Invalid username provided."); + } + + // https://developer.github.com/v3/search/#search-commits + const fetchTotalItems = (variables, token) => { + return axios({ + method: "get", + url: + `https://api.github.com/search/` + + type + + `?per_page=1&q=` + + buildSearchFilter(variables.repo, variables.owner).replaceAll( + " ", + "+", + ) + + filter, + headers: { + "Content-Type": "application/json", + Accept: "application/vnd.github.cloak-preview", + Authorization: `token ${token}`, + }, + }); + }; + + let res; + try { + res = await retryer(fetchTotalItems, { + login: username, + repo, + owner, + }); + } catch (err) { + logger.log(err); + throw new Error(err); + } + + const totalCount = res.data.total_count; + if (isNaN(totalCount)) { + logger.error("GitHub error: " + JSON.stringify(res.data)); + throw new CustomError( + "Could not fetch data from GitHub REST API.", + CustomError.GITHUB_REST_API_ERROR, + ); + } + return totalCount; +}; + +const fetchRepoUserStats = async ( + username, + repo, + owner, + include_prs_authored, + include_prs_commented, + include_prs_reviewed, + include_issues_authored, + include_issues_commented, +) => { + let stats = {}; + if (include_prs_authored) { + stats.totalPRsAuthored = await totalItemsFetcher( + username, + repo, + owner, + "issues", + `author:${username}+type:pr`, + ); + } + if (include_prs_commented) { + stats.totalPRsCommented = await totalItemsFetcher( + username, + repo, + owner, + "issues", + `commenter:${username}+-author:${username}+type:pr`, + ); + } + if (include_prs_reviewed) { + stats.totalPRsReviewed = await totalItemsFetcher( + username, + repo, + owner, + "issues", + `reviewed-by:${username}+-author:${username}+type:pr`, + ); + } + if (include_issues_authored) { + stats.totalIssuesAuthored = await totalItemsFetcher( + username, + repo, + owner, + "issues", + `author:${username}+type:issue`, + ); + } + if (include_issues_commented) { + stats.totalIssuesCommented = await totalItemsFetcher( + username, + repo, + owner, + "issues", + `commenter:${username}+-author:${username}+type:issue`, + ); + } + return stats; +}; + +/** + * @typedef {import("./types").StatsData} StatsData Stats data. + */ + +/** + * Fetch stats for a given username. + * + * @param {string} username GitHub username. + * @param {boolean} include_all_commits Include all commits. + * @param {string[]} exclude_repo Repositories to exclude. + * @param {boolean} include_merged_pull_requests Include merged pull requests. + * @param {boolean} include_discussions Include discussions. + * @param {boolean} include_discussions_answers Include discussions answers. + * @param {string[]} ownerAffiliations Owner affiliations. Default: OWNER. + * @returns {Promise} Stats data. + */ +const fetchStats = async ( + username, + include_all_commits = false, + exclude_repo = [], + include_merged_pull_requests = false, + include_discussions = false, + include_discussions_answers = false, + repo = [], + owner = [], + include_prs_authored = false, + include_prs_commented = false, + include_prs_reviewed = false, + include_issues_authored = false, + include_issues_commented = false, + ownerAffiliations = [], +) => { + if (!username) { + throw new MissingParamError(["username"]); + } + + const stats = { + name: "", + totalPRs: 0, + totalPRsMerged: 0, + mergedPRsPercentage: 0, + totalReviews: 0, + totalCommits: 0, + totalIssues: 0, + totalStars: 0, + totalDiscussionsStarted: 0, + totalDiscussionsAnswered: 0, + contributedTo: 0, + totalPRsAuthored: 0, + totalPRsCommented: 0, + totalPRsReviewed: 0, + totalIssuesAuthored: 0, + totalIssuesCommented: 0, + rank: { level: "C", percentile: 100 }, + }; + ownerAffiliations = parseOwnerAffiliations(ownerAffiliations); + + let res = await statsFetcher({ + username, + includeMergedPullRequests: include_merged_pull_requests, + includeDiscussions: include_discussions, + includeDiscussionsAnswers: include_discussions_answers, + ownerAffiliations, + }); + + // Catch GraphQL errors. + if (res.data.errors) { + logger.error(res.data.errors); + if (res.data.errors[0].type === "NOT_FOUND") { + throw new CustomError( + res.data.errors[0].message || "Could not fetch user.", + CustomError.USER_NOT_FOUND, + ); + } + if (res.data.errors[0].message) { + throw new CustomError( + wrapTextMultiline(res.data.errors[0].message, 90, 1)[0], + res.statusText, + ); + } + throw new CustomError( + "Something went wrong while trying to retrieve the stats data using the GraphQL API.", + CustomError.GRAPHQL_ERROR, + ); + } + + const user = res.data.data.user; + + stats.name = user.name || user.login; + + // if include_all_commits, fetch all commits using the REST API. + if (include_all_commits) { + stats.totalCommits = await totalItemsFetcher( + username, + repo, + owner, + "commits", + `author:${username}`, + ); + } else { + stats.totalCommits = user.contributionsCollection.totalCommitContributions; + } + let repoUserStats = await fetchRepoUserStats( + username, + repo, + owner, + include_prs_authored, + include_prs_commented, + include_prs_reviewed, + include_issues_authored, + include_issues_commented, + ); + Object.assign(stats, repoUserStats); + + stats.totalPRs = user.pullRequests.totalCount; + if (include_merged_pull_requests) { + stats.totalPRsMerged = user.mergedPullRequests.totalCount; + stats.mergedPRsPercentage = + (user.mergedPullRequests.totalCount / user.pullRequests.totalCount) * 100; + } + stats.totalReviews = + user.contributionsCollection.totalPullRequestReviewContributions; + stats.totalIssues = user.openIssues.totalCount + user.closedIssues.totalCount; + if (include_discussions) { + stats.totalDiscussionsStarted = user.repositoryDiscussions.totalCount; + } + if (include_discussions_answers) { + stats.totalDiscussionsAnswered = + user.repositoryDiscussionComments.totalCount; + } + stats.contributedTo = user.repositoriesContributedTo.totalCount; + + // Retrieve stars while filtering out repositories to be hidden. + let repoToHide = new Set(exclude_repo); + + stats.totalStars = user.repositories.nodes + .filter((data) => { + return !repoToHide.has(data.name); + }) + .reduce((prev, curr) => { + return prev + curr.stargazers.totalCount; + }, 0); + + stats.rank = calculateRank({ + all_commits: include_all_commits, + commits: stats.totalCommits, + prs: stats.totalPRs, + reviews: stats.totalReviews, + issues: stats.totalIssues, + repos: user.repositories.totalCount, + stars: stats.totalStars, + followers: user.followers.totalCount, + }); + + return stats; +}; + +export { fetchStats, fetchRepoUserStats }; +export default fetchStats; diff --git a/frontend/frontend/src/backend/src/fetchers/top-languages.js b/frontend/frontend/src/backend/src/fetchers/top-languages.js new file mode 100644 index 00000000..816c63a2 --- /dev/null +++ b/frontend/frontend/src/backend/src/fetchers/top-languages.js @@ -0,0 +1,170 @@ +// @ts-check +import { retryer } from "../common/retryer.js"; +import { + CustomError, + logger, + MissingParamError, + request, + wrapTextMultiline, + parseOwnerAffiliations, +} from "../common/utils.js"; + +/** + * @typedef {import("axios").AxiosRequestHeaders} AxiosRequestHeaders Axios request headers. + * @typedef {import("axios").AxiosResponse} AxiosResponse Axios response. + */ + +/** + * Top languages fetcher object. + * + * @param {AxiosRequestHeaders} variables Fetcher variables. + * @param {string} token GitHub token. + * @returns {Promise} Languages fetcher response. + */ +const fetcher = (variables, token) => { + return request( + { + query: ` + query userInfo($login: String!, $ownerAffiliations: [RepositoryAffiliation]) { + user(login: $login) { + # do not fetch forks + repositories(ownerAffiliations: $ownerAffiliations, isFork: false, first: 100) { + nodes { + name + languages(first: 10, orderBy: {field: SIZE, direction: DESC}) { + edges { + size + node { + color + name + } + } + } + } + } + } + } + `, + variables, + }, + { + Authorization: `token ${token}`, + }, + ); +}; + +/** + * @typedef {import("./types").TopLangData} TopLangData Top languages data. + */ + +/** + * Fetch top languages for a given username. + * + * @param {string} username GitHub username. + * @param {string[]} exclude_repo List of repositories to exclude. Default: []. + * @param {number} size_weight Weightage to be given to size. + * @param {number} count_weight Weightage to be given to count. + * @param {string[]} ownerAffiliations The owner affiliations to filter by. Default: OWNER. + * @returns {Promise} Top languages data. + */ +const fetchTopLanguages = async ( + username, + exclude_repo = [], + size_weight = 1, + count_weight = 0, + ownerAffiliations = [], +) => { + if (!username) { + throw new MissingParamError(["username"]); + } + ownerAffiliations = parseOwnerAffiliations(ownerAffiliations); + + const res = await retryer(fetcher, { login: username, ownerAffiliations }); + + if (res.data.errors) { + logger.error(res.data.errors); + if (res.data.errors[0].type === "NOT_FOUND") { + throw new CustomError( + res.data.errors[0].message || "Could not fetch user.", + CustomError.USER_NOT_FOUND, + ); + } + if (res.data.errors[0].message) { + throw new CustomError( + wrapTextMultiline(res.data.errors[0].message, 90, 1)[0], + res.statusText, + ); + } + throw new CustomError( + "Something went wrong while trying to retrieve the language data using the GraphQL API.", + CustomError.GRAPHQL_ERROR, + ); + } + + let repoNodes = res.data.data.user.repositories.nodes; + let repoToHide = {}; + + // populate repoToHide map for quick lookup + // while filtering out + if (exclude_repo) { + exclude_repo.forEach((repoName) => { + repoToHide[repoName] = true; + }); + } + + // filter out repositories to be hidden + repoNodes = repoNodes + .sort((a, b) => b.size - a.size) + .filter((name) => !repoToHide[name.name]); + + let repoCount = 0; + + repoNodes = repoNodes + .filter((node) => node.languages.edges.length > 0) + // flatten the list of language nodes + .reduce((acc, curr) => curr.languages.edges.concat(acc), []) + .reduce((acc, prev) => { + // get the size of the language (bytes) + let langSize = prev.size; + + // if we already have the language in the accumulator + // & the current language name is same as previous name + // add the size to the language size and increase repoCount. + if (acc[prev.node.name] && prev.node.name === acc[prev.node.name].name) { + langSize = prev.size + acc[prev.node.name].size; + repoCount += 1; + } else { + // reset repoCount to 1 + // language must exist in at least one repo to be detected + repoCount = 1; + } + return { + ...acc, + [prev.node.name]: { + name: prev.node.name, + color: prev.node.color, + size: langSize, + count: repoCount, + }, + }; + }, {}); + + Object.keys(repoNodes).forEach((name) => { + // comparison index calculation + repoNodes[name].size = + Math.pow(repoNodes[name].size, size_weight) * + Math.pow(repoNodes[name].count, count_weight); + }); + + const topLangs = Object.keys(repoNodes) + .sort((a, b) => repoNodes[b].size - repoNodes[a].size) + .reduce((result, key) => { + result[key] = repoNodes[key]; + return result; + }, {}); + + return topLangs; +}; + +export { fetchTopLanguages }; +export default fetchTopLanguages; diff --git a/frontend/frontend/src/backend/src/fetchers/types.d.ts b/frontend/frontend/src/backend/src/fetchers/types.d.ts new file mode 100644 index 00000000..1588c921 --- /dev/null +++ b/frontend/frontend/src/backend/src/fetchers/types.d.ts @@ -0,0 +1,128 @@ +export type GistData = { + name: string; + nameWithOwner: string; + description: string | null; + language: string | null; + starsCount: number; + forksCount: number; +}; + +export type RepositoryData = { + name: string; + nameWithOwner: string; + isPrivate: boolean; + isArchived: boolean; + isTemplate: boolean; + stargazers: { totalCount: number }; + description: string; + primaryLanguage: { + color: string; + id: string; + name: string; + }; + forkCount: number; + starCount: number; + totalPRsAuthored: number; + totalPRsCommented: number; + totalPRsReviewed: number; + totalIssuesAuthored: number; + totalIssuesCommented: number; +}; + +export type StatsData = { + name: string; + totalPRs: number; + totalPRsMerged: number; + mergedPRsPercentage: number; + totalReviews: number; + totalCommits: number; + totalIssues: number; + totalStars: number; + totalDiscussionsStarted: number; + totalDiscussionsAnswered: number; + contributedTo: number; + totalPRsAuthored: number; + totalPRsCommented: number; + totalPRsReviewed: number; + totalIssuesAuthored: number; + totalIssuesCommented: number; + rank: { level: string; percentile: number }; +}; + +export type Lang = { + name: string; + color: string; + size: number; +}; + +export type TopLangData = Record; + +export type WakaTimeData = { + categories: { + digital: string; + hours: number; + minutes: number; + name: string; + percent: number; + text: string; + total_seconds: number; + }[]; + daily_average: number; + daily_average_including_other_language: number; + days_including_holidays: number; + days_minus_holidays: number; + editors: { + digital: string; + hours: number; + minutes: number; + name: string; + percent: number; + text: string; + total_seconds: number; + }[]; + holidays: number; + human_readable_daily_average: string; + human_readable_daily_average_including_other_language: string; + human_readable_total: string; + human_readable_total_including_other_language: string; + id: string; + is_already_updating: boolean; + is_coding_activity_visible: boolean; + is_including_today: boolean; + is_other_usage_visible: boolean; + is_stuck: boolean; + is_up_to_date: boolean; + languages: { + digital: string; + hours: number; + minutes: number; + name: string; + percent: number; + text: string; + total_seconds: number; + }[]; + operating_systems: { + digital: string; + hours: number; + minutes: number; + name: string; + percent: number; + text: string; + total_seconds: number; + }[]; + percent_calculated: number; + range: string; + status: string; + timeout: number; + total_seconds: number; + total_seconds_including_other_language: number; + user_id: string; + username: string; + writes_only: boolean; +}; + +export type WakaTimeLang = { + name: string; + text: string; + percent: number; +}; diff --git a/frontend/frontend/src/backend/src/fetchers/wakatime.js b/frontend/frontend/src/backend/src/fetchers/wakatime.js new file mode 100644 index 00000000..f69d6ae4 --- /dev/null +++ b/frontend/frontend/src/backend/src/fetchers/wakatime.js @@ -0,0 +1,35 @@ +import axios from "axios"; +import { CustomError, MissingParamError } from "../common/utils.js"; + +/** + * WakaTime data fetcher. + * + * @param {{username: string, api_domain: string }} props Fetcher props. + * @returns {Promise} WakaTime data response. + */ +const fetchWakatimeStats = async ({ username, api_domain }) => { + if (!username) { + throw new MissingParamError(["username"]); + } + + try { + const { data } = await axios.get( + `https://${ + api_domain ? api_domain.replace(/\/$/gi, "") : "wakatime.com" + }/api/v1/users/${username}/stats?is_including_today=true`, + ); + + return data.data; + } catch (err) { + if (err.response.status < 200 || err.response.status > 299) { + throw new CustomError( + `Could not resolve to a User with the login of '${username}'`, + "WAKATIME_USER_NOT_FOUND", + ); + } + throw err; + } +}; + +export { fetchWakatimeStats }; +export default fetchWakatimeStats; diff --git a/frontend/frontend/src/backend/src/index.js b/frontend/frontend/src/backend/src/index.js new file mode 100644 index 00000000..ca8d586d --- /dev/null +++ b/frontend/frontend/src/backend/src/index.js @@ -0,0 +1,2 @@ +export * from "./common/index.js"; +export * from "./cards/index.js"; diff --git a/frontend/frontend/src/backend/src/repeatRequests.js b/frontend/frontend/src/backend/src/repeatRequests.js new file mode 100644 index 00000000..033f7fe5 --- /dev/null +++ b/frontend/frontend/src/backend/src/repeatRequests.js @@ -0,0 +1,65 @@ +import axios from "axios"; +import { + pool, + deleteOldRequests, + getRecentRequests, +} from "./common/database.js"; + +/** + * Processes URLs with a thread pool of given size using axios.get. + * + * @param {string[]} urls An array of URLs to process. + * @param {number} poolSize The number of concurrent requests to process. + * @returns {Promise} A promise that resolves when all requests are processed. + */ +async function makeRequests(urls, poolSize) { + let current = 0; + + /** + * Worker function to process `urls`. + */ + async function worker() { + while (true) { + let idx = current++; + if (idx >= urls.length) { + break; + } + const url = "https://" + process.env.VERCEL_BRANCH_URL + urls[idx]; + try { + if (idx % 10 === 0) { + console.log(`Processing request ${idx + 1} out of ${urls.length}`); + } + await axios.get(url, { + timeout: 10000, + headers: { "x-bypass-store": "true" }, + }); + } catch (err) { + console.error(`Error fetching ${url}:`, err.message); + } + } + } + + const workers = []; + for (let i = 0; i < poolSize; i++) { + workers.push(worker()); + } + await Promise.all(workers); +} + +/** + * Repeats requests made in the last 8 days, excluding those made in the last 11 hours. + */ +export async function repeatRecentRequests() { + if (!pool) { + console.error("Postgres pool is not initialized."); + return; + } + + await deleteOldRequests(); + const urls = await getRecentRequests(); + if (urls.length === 0) { + console.log("No recent requests found."); + } else { + await makeRequests(urls, 5); + } +} diff --git a/frontend/frontend/src/backend/src/translations.js b/frontend/frontend/src/backend/src/translations.js new file mode 100644 index 00000000..719cec71 --- /dev/null +++ b/frontend/frontend/src/backend/src/translations.js @@ -0,0 +1,827 @@ +// @ts-check + +import { encodeHTML } from "./common/utils.js"; + +/** + * Retrieves stat card labels in the available locales. + * + * @param {object} props Function arguments. + * @param {string} props.name The name of the locale. + * @param {string} props.apostrophe Whether to use apostrophe or not. + * @returns {object} The locales object. + * + * @see https://www.andiamo.co.uk/resources/iso-language-codes/ for language codes. + */ +const statCardLocales = ({ name, apostrophe }) => { + const encodedName = encodeHTML(name); + return { + "statcard.title": { + ar: `${encodedName} إحصائيات جيت هاب`, + az: `${encodedName}'${apostrophe} Hesabının GitHub Statistikası`, + cn: `${encodedName} 的 GitHub 统计数据`, + "zh-tw": `${encodedName} 的 GitHub 統計資料`, + cs: `GitHub statistiky uživatele ${encodedName}`, + de: `${encodedName + apostrophe} GitHub-Statistiken`, + en: `${encodedName}'${apostrophe} GitHub Stats`, + bn: `${encodedName} এর GitHub পরিসংখ্যান`, + es: `Estadísticas de GitHub de ${encodedName}`, + fi: `${encodedName}:n GitHub-tilastot`, + fr: `Statistiques GitHub de ${encodedName}`, + hu: `${encodedName} GitHub statisztika`, + it: `Statistiche GitHub di ${encodedName}`, + ja: `${encodedName}の GitHub 統計`, + kr: `${encodedName}의 GitHub 통계`, + nl: `${encodedName}'${apostrophe} GitHub-statistieken`, + "pt-pt": `Estatísticas do GitHub de ${encodedName}`, + "pt-br": `Estatísticas do GitHub de ${encodedName}`, + np: `${encodedName}'${apostrophe} गिटहब तथ्याङ्क`, + el: `Στατιστικά GitHub του ${encodedName}`, + ro: `Statisticile GitHub ale lui ${encodedName}`, + ru: `Статистика GitHub пользователя ${encodedName}`, + "uk-ua": `Статистика GitHub користувача ${encodedName}`, + id: `Statistik GitHub ${encodedName}`, + ml: `${encodedName}'${apostrophe} ഗിറ്റ്ഹബ് സ്ഥിതിവിവരക്കണക്കുകൾ`, + my: `Statistik GitHub ${encodedName}`, + sk: `GitHub štatistiky používateľa ${encodedName}`, + tr: `${encodedName} Hesabının GitHub İstatistikleri`, + pl: `Statystyki GitHub użytkownika ${encodedName}`, + uz: `${encodedName}ning GitHub'dagi statistikasi`, + vi: `Thống Kê GitHub ${encodedName}`, + se: `GitHubstatistik för ${encodedName}`, + }, + "statcard.ranktitle": { + ar: `${encodedName} إحصائيات جيت هاب`, + az: `${encodedName}'${apostrophe} Hesabının GitHub Statistikası`, + cn: `${encodedName} 的 GitHub 统计数据`, + "zh-tw": `${encodedName} 的 GitHub 統計資料`, + cs: `GitHub statistiky uživatele ${encodedName}`, + de: `${encodedName + apostrophe} GitHub-Statistiken`, + en: `${encodedName}'${apostrophe} GitHub Rank`, + bn: `${encodedName} এর GitHub পরিসংখ্যান`, + es: `Estadísticas de GitHub de ${encodedName}`, + fi: `${encodedName}:n GitHub-sijoitus`, + fr: `Statistiques GitHub de ${encodedName}`, + hu: `${encodedName} GitHub statisztika`, + it: `Statistiche GitHub di ${encodedName}`, + ja: `${encodedName} の GitHub ランク`, + kr: `${encodedName}의 GitHub 통계`, + nl: `${encodedName}'${apostrophe} GitHub-statistieken`, + "pt-pt": `Estatísticas do GitHub de ${encodedName}`, + "pt-br": `Estatísticas do GitHub de ${encodedName}`, + np: `${encodedName}'${apostrophe} गिटहब तथ्याङ्क`, + el: `Στατιστικά GitHub του ${encodedName}`, + ro: `Rankul GitHub al lui ${encodedName}`, + ru: `Рейтинг GitHub пользователя ${encodedName}`, + "uk-ua": `Статистика GitHub користувача ${encodedName}`, + id: `Statistik GitHub ${encodedName}`, + ml: `${encodedName}'${apostrophe} ഗിറ്റ്ഹബ് സ്ഥിതിവിവരക്കണക്കുകൾ`, + my: `Statistik GitHub ${encodedName}`, + sk: `GitHub štatistiky používateľa ${encodedName}`, + tr: `${encodedName} Hesabının GitHub Yıldızları`, + pl: `Statystyki GitHub użytkownika ${encodedName}`, + uz: `${encodedName}ning GitHub'dagi statistikasi`, + vi: `Thống Kê GitHub ${encodedName}`, + se: `GitHubstatistik för ${encodedName}`, + }, + "statcard.totalstars": { + ar: "مجموع النجوم", + az: "Ümumi Ulduz", + cn: "获标星数", + "zh-tw": "得標星星數量(Star)", + cs: "Celkem hvězd", + de: "Insgesamt erhaltene Sterne", + en: "Total Stars Earned", + bn: "সর্বমোট Star", + es: "Estrellas totales", + fi: "Ansaitut tähdet yhteensä", + fr: "Total d'étoiles", + hu: "Csillagok", + it: "Stelle totali", + ja: "スターされた数", + kr: "받은 스타 수", + nl: "Totaal Sterren Ontvangen", + "pt-pt": "Total de estrelas", + "pt-br": "Total de estrelas", + np: "कुल ताराहरू", + el: "Σύνολο Αστεριών", + ro: "Total de stele câștigate", + ru: "Всего звёзд", + "uk-ua": "Всього зірок", + id: "Total Bintang", + ml: "ആകെ നക്ഷത്രങ്ങൾ", + my: "Jumlah Bintang", + sk: "Hviezdy", + tr: "Toplam Yıldız", + pl: "Liczba otrzymanych gwiazdek", + uz: "Yulduzchalar", + vi: "Tổng Số Sao", + se: "Antal intjänade stjärnor", + }, + "statcard.commits": { + ar: "مجموع المساهمات", + az: "Ümumi Commit", + cn: "累计提交总数", + "zh-tw": "累計提交數量(Commit)", + cs: "Celkem commitů", + de: "Anzahl Commits", + en: "Total Commits", + bn: "সর্বমোট Commit", + es: "Commits totales", + fi: "Yhteensä committeja", + fr: "Total des Commits", + hu: "Összes commit", + it: "Commit totali", + ja: "合計コミット数", + kr: "전체 커밋 수", + nl: "Aantal commits", + "pt-pt": "Total de Commits", + "pt-br": "Total de Commits", + np: "कुल Commits", + el: "Σύνολο Commits", + ro: "Total Commit-uri", + ru: "Всего коммитов", + "uk-ua": "Всього комітів", + id: "Total Komitmen", + ml: "ആകെ കമ്മിറ്റുകൾ", + my: "Jumlah Komitmen", + sk: "Všetky commity", + tr: "Toplam Commit", + pl: "Wszystkie commity", + uz: "'Commit'lar", + vi: "Tổng Số Cam Kết", + se: "Totalt antal commits", + }, + "statcard.prs": { + ar: "مجموع طلبات السحب", + az: "Ümumi PR", + cn: "发起的 PR 总数", + "zh-tw": "拉取請求數量(PR)", + cs: "Celkem PRs", + de: "PRs Insgesamt", + en: "Total PRs", + bn: "সর্বমোট PR", + es: "PRs totales", + fi: "Yhteensä PR:t", + fr: "Total des PRs", + hu: "Összes PR", + it: "PR totali", + ja: "合計 PR", + kr: "PR 횟수", + nl: "Aantal PR's", + "pt-pt": "Total de PRs", + "pt-br": "Total de PRs", + np: "कुल PRs", + el: "Σύνολο PRs", + ro: "Total PR-uri", + ru: "Всего запросов изменений", + "uk-ua": "Всього pull request`iв", + id: "Total Permintaan Tarik", + ml: "ആകെ പുൾ അഭ്യർത്ഥനകൾ", + my: "Jumlah PR", + sk: "Všetky PR", + tr: "Toplam PR", + pl: "Wszystkie PR-y", + uz: "'Pull Request'lar", + vi: "Tổng Số PR", + se: "Totalt antal PR", + }, + "statcard.issues": { + ar: "مجموع التحسينات", + az: "Ümumi Problem", + cn: "提出的 issue 总数", + "zh-tw": "提出問題數量(Issue)", + cs: "Celkem problémů", + de: "Anzahl Issues", + en: "Total Issues", + bn: "সর্বমোট Issue", + es: "Issues totales", + fi: "Yhteensä ongelmat", + fr: "Nombre total d'incidents", + hu: "Összes hibajegy", + it: "Segnalazioni totali", + ja: "合計 issue", + kr: "이슈 개수", + nl: "Aantal kwesties", + "pt-pt": "Total de Issues", + "pt-br": "Total de Issues", + np: "कुल मुद्दाहरू", + el: "Σύνολο Ζητημάτων", + ro: "Total Issue-uri", + ru: "Всего вопросов", + "uk-ua": "Всього issue", + id: "Total Masalah Dilaporkan", + ml: "ആകെ ലക്കങ്ങൾ", + my: "Jumlah Isu Dilaporkan", + sk: "Všetky problémy", + tr: "Toplam Hata", + pl: "Wszystkie problemy", + uz: "'Issue'lar", + vi: "Tổng Số Vấn Đề", + se: "Total antal issues", + }, + "statcard.contribs": { + ar: "ساهم في (العام الماضي)", + az: "Töhfə verdi (ötən il)", + cn: "贡献的项目数(去年)", + "zh-tw": "參與項目數量(去年)", + cs: "Přispěl k (minulý rok)", + de: "Beigetragen zu (letztes Jahr)", + en: "Contributed to (last year)", + bn: "অবদান (গত বছর)", + es: "Contribuciones en (el año pasado)", + fi: "Osallistunut (viime vuonna)", + fr: "Contribué à (l'année dernière)", + hu: "Hozzájárulások (tavaly)", + it: "Ha contribuito a (l'anno scorso)", + ja: "貢献したリポジトリ (昨年)", + kr: "(작년) 기여", + nl: "Bijgedragen aan (vorig jaar)", + "pt-pt": "Contribuiu em (ano passado)", + "pt-br": "Contribuiu para (ano passado)", + np: "कुल योगदानहरू (गत वर्ष)", + el: "Συνεισφέρθηκε σε (πέρυσι)", + ro: "Total Contribuiri", + ru: "Внесено вклада (за прошлый год)", + "uk-ua": "Зробив внесок у (за минулий рік)", + id: "Berkontribusi ke (tahun lalu)", + ml: "സമർപ്പിച്ചിരിക്കുന്നത് (കഴിഞ്ഞ വർഷം)", + my: "Menyumbang kepada (tahun lepas)", + sk: "Účasti (minulý rok)", + tr: "Katkı Verildi (geçen yıl)", + pl: "Kontrybucje (w zeszłym roku)", + uz: "Hissa qoʻshgan (o'tgan yili)", + vi: "Đã Đóng Góp (năm ngoái)", + se: "Bidragit till (förra året)", + }, + "statcard.reviews": { + ar: "طلبات السحب التي تم مراجعتها", + az: "Nəzərdən Keçirilən Ümumi PR", + cn: "审查的 PR 总数", + "zh-tw": "審核的 PR 總計", + cs: "Celkový počet PR", + de: "Insgesamt überprüfte PRs", + en: "Total PRs Reviewed", + bn: "সর্বমোট পুনরালোচনা করা PR", + es: "PR totales revisados", + fi: "Yhteensä tarkastettuja PR:itä", + fr: "Nombre total de PR examinés", + hu: "Összes ellenőrzött PR", + it: "PR totali esaminati", + ja: "レビューされた PR の総数", + kr: "검토된 총 PR", + nl: "Totaal beoordeelde PR's", + "pt-pt": "Total de PRs revistos", + "pt-br": "Total de PRs revisados", + np: "कुल पीआर समीक्षित", + el: "Σύνολο Αναθεωρημένων PR", + ro: "Total PR-uri Revizuite", + ru: "Всего запросов проверено", + "uk-ua": "Всього pull request`iв перевірено", + id: "Total PR yang Direview", + ml: "ആകെ പുൾ അഭിപ്രായങ്ങൾ", + my: "Jumlah PR Dikaji Semula", + sk: "Celkový počet PR", + tr: "İncelenen toplam PR", + pl: "Łącznie sprawdzonych PR", + uz: "Koʻrib chiqilgan PR-lar soni", + vi: "Tổng Số PR Đã Xem Xét", + se: "Totalt antal granskade PR", + }, + "statcard.discussions-started": { + ar: "مجموع المناقشات التي بدأها", + az: "Başladılan Ümumi Müzakirə", + cn: "发起的讨论总数", + "zh-tw": "發起的討論總數", + cs: "Celkem zahájených diskusí", + de: "Gesamt gestartete Diskussionen", + en: "Total Discussions Started", + bn: "সর্বমোট আলোচনা শুরু", + es: "Discusiones totales iniciadas", + fi: "Aloitetut keskustelut yhteensä", + fr: "Nombre total de discussions lancées", + hu: "Összes megkezdett megbeszélés", + it: "Discussioni totali avviate", + ja: "開始されたディスカッションの総数", + kr: "시작된 토론 총 수", + nl: "Totaal gestarte discussies", + "pt-pt": "Total de Discussões Iniciadas", + "pt-br": "Total de Discussões Iniciadas", + np: "कुल चर्चा सुरु", + el: "Σύνολο Συζητήσεων που Ξεκίνησαν", + ro: "Total Discuții Începute", + ru: "Всего начатых обсуждений", + "uk-ua": "Всього розпочатих дискусій", + id: "Total Diskusi Dimulai", + ml: "ആരംഭിച്ച ആലോചനകൾ", + my: "Jumlah Perbincangan Bermula", + sk: "Celkový počet začatých diskusií", + tr: "Başlatılan Toplam Tartışma", + pl: "Łącznie rozpoczętych dyskusji", + uz: "Boshlangan muzokaralar soni", + vi: "Tổng Số Thảo Luận Bắt Đầu", + se: "Totalt antal diskussioner startade", + }, + "statcard.discussions-answered": { + ar: "مجموع المناقشات المُجابة", + az: "Cavablandırılan Ümumi Müzakirə", + cn: "回复的讨论总数", + "zh-tw": "回覆討論總計", + cs: "Celkem zodpovězených diskusí", + de: "Gesamt beantwortete Diskussionen", + en: "Total Discussions Answered", + bn: "সর্বমোট আলোচনা উত্তর", + es: "Discusiones totales respondidas", + fi: "Vastatut keskustelut yhteensä", + fr: "Nombre total de discussions répondues", + hu: "Összes megválaszolt megbeszélés", + it: "Discussioni totali risposte", + ja: "回答されたディスカッションの総数", + kr: "답변된 토론 총 수", + nl: "Totaal beantwoorde discussies", + "pt-pt": "Total de Discussões Respondidas", + "pt-br": "Total de Discussões Respondidas", + np: "कुल चर्चा उत्तर", + el: "Σύνολο Συζητήσεων που Απαντήθηκαν", + ro: "Total Răspunsuri La Discuții", + ru: "Всего отвеченных обсуждений", + "uk-ua": "Всього відповідей на дискусії", + id: "Total Diskusi Dibalas", + ml: "ഉത്തരം നൽകിയ ആലോചനകൾ", + my: "Jumlah Perbincangan Dijawab", + sk: "Celkový počet zodpovedaných diskusií", + tr: "Toplam Cevaplanan Tartışma", + pl: "Łącznie odpowiedzianych dyskusji", + uz: "Javob berilgan muzokaralar soni", + vi: "Tổng Số Thảo Luận Đã Trả Lời", + se: "Totalt antal diskussioner besvarade", + }, + "statcard.prs-authored": { + en: "PRs Created", + }, + "statcard.prs-commented": { + en: "PRs Commented", + }, + "statcard.prs-reviewed": { + en: "PRs Reviewed", + }, + "statcard.issues-authored": { + en: "Issues Created", + }, + "statcard.issues-commented": { + en: "Issues Commented", + }, + "statcard.prs-merged": { + ar: "مجموع طلبات السحب المُدمجة", + az: "Birləşdirilmiş Ümumi PR", + cn: "合并的 PR 总数", + "zh-tw": "合併的 PR 總計", + cs: "Celkem sloučených PR", + de: "Insgesamt zusammengeführte PRs", + en: "Total PRs Merged", + bn: "সর্বমোট PR একত্রীকৃত", + es: "PR totales fusionados", + fi: "Yhteensä yhdistetyt PR:t", + fr: "Nombre total de PR fusionnés", + hu: "Összes egyesített PR", + it: "PR totali uniti", + ja: "マージされた PR の総数", + kr: "병합된 총 PR", + nl: "Totaal samengevoegde PR's", + "pt-pt": "Total de PRs Fundidos", + "pt-br": "Total de PRs Integrados", + np: "कुल PRs मर्ज गरिएको", + el: "Σύνολο Συγχωνευμένων PR", + ro: "Total PR-uri Fuzionate", + ru: "Всего объединённых запросов", + "uk-ua": "Всього об'єднаних pull request`iв", + id: "Total PR Digabungkan", + my: "Jumlah PR Digabungkan", + sk: "Celkový počet zlúčených PR", + tr: "Toplam Birleştirilmiş PR", + pl: "Łącznie połączonych PR", + uz: "Birlangan PR-lar soni", + vi: "Tổng Số PR Đã Hợp Nhất", + se: "Totalt antal sammanfogade PR", + }, + "statcard.prs-merged-percentage": { + ar: "نسبة طلبات السحب المُدمجة", + az: "Birləşdirilmiş PR-ların Faizi", + cn: "被合并的 PR 占比", + "zh-tw": "合併的 PR 百分比", + cs: "Sloučené PRs v procentech", + de: "Zusammengeführte PRs in Prozent", + en: "Merged PRs Percentage", + bn: "PR একত্রীকরণের শতাংশ", + es: "Porcentaje de PR fusionados", + fi: "Yhdistettyjen PR:ien prosentti", + fr: "Pourcentage de PR fusionnés", + hu: "Egyesített PR-k százaléka", + it: "Percentuale di PR uniti", + ja: "マージされた PR の割合", + kr: "병합된 PR의 비율", + nl: "Percentage samengevoegde PR's", + "pt-pt": "Percentagem de PRs Fundidos", + "pt-br": "Porcentagem de PRs Integrados", + np: "PR मर्ज गरिएको प्रतिशत", + el: "Ποσοστό Συγχωνευμένων PR", + ro: "Procentaj PR-uri Fuzionate", + ru: "Процент объединённых запросов", + "uk-ua": "Відсоток об'єднаних pull request`iв", + id: "Persentase PR Digabungkan", + my: "Peratus PR Digabungkan", + sk: "Percento zlúčených PR", + tr: "Birleştirilmiş PR Yüzdesi", + pl: "Procent połączonych PR", + uz: "Birlangan PR-lar foizi", + vi: "Tỷ Lệ PR Đã Hợp Nhất", + se: "Procent av sammanfogade PR", + }, + }; +}; + +const repoCardLocales = { + "repocard.template": { + ar: "قالب", + az: "Şablon", + bn: "টেমপ্লেট", + cn: "模板", + "zh-tw": "模板", + cs: "Šablona", + de: "Vorlage", + en: "Template", + es: "Plantilla", + fi: "Malli", + fr: "Modèle", + hu: "Sablon", + it: "Template", + ja: "テンプレート", + kr: "템플릿", + nl: "Sjabloon", + "pt-pt": "Modelo", + "pt-br": "Modelo", + np: "टेम्पलेट", + el: "Πρότυπο", + ro: "Șablon", + ru: "Шаблон", + "uk-ua": "Шаблон", + id: "Pola", + ml: "ടെംപ്ലേറ്റ്", + my: "Templat", + sk: "Šablóna", + tr: "Şablon", + pl: "Szablony", + uz: "Shablon", + vi: "Mẫu", + se: "Mall", + }, + "repocard.archived": { + ar: "مُؤرشف", + az: "Arxiv", + bn: "আর্কাইভড", + cn: "已归档", + "zh-tw": "已封存", + cs: "Archivováno", + de: "Archiviert", + en: "Archived", + es: "Archivados", + fi: "Arkistoitu", + fr: "Archivé", + hu: "Archivált", + it: "Archiviata", + ja: "アーカイブ済み", + kr: "보관됨", + nl: "Gearchiveerd", + "pt-pt": "Arquivados", + "pt-br": "Arquivados", + np: "अभिलेख राखियो", + el: "Αρχειοθετημένα", + ro: "Arhivat", + ru: "Архивирован", + "uk-ua": "Архивований", + id: "Arsip", + ml: "ശേഖരിച്ചത്", + my: "Arkib", + sk: "Archivované", + tr: "Arşiv", + pl: "Zarchiwizowano", + uz: "Arxivlangan", + vi: "Đã Lưu Trữ", + se: "Arkiverade", + }, + "repocard.prs-authored": { + en: "my created PRs", + }, + "repocard.prs-commented": { + en: "my commented PRs", + }, + "repocard.prs-reviewed": { + en: "my reviewed PRs", + }, + "repocard.issues-authored": { + en: "my created issues", + }, + "repocard.issues-commented": { + en: "my commented issues", + }, +}; + +const langCardLocales = { + "langcard.title": { + ar: "أكثر اللغات استخدامًا", + az: "Ən Çox İstifadə Olunan Dillər", + cn: "最常用的语言", + "zh-tw": "最常用的語言", + cs: "Nejpoužívanější jazyky", + de: "Meist verwendete Sprachen", + bn: "সর্বাধিক ব্যবহৃত ভাষা সমূহ", + en: "Most Used Languages", + es: "Lenguajes más usados", + fi: "Käytetyimmät kielet", + fr: "Langages les plus utilisés", + hu: "Leggyakrabban használt nyelvek", + it: "Linguaggi più utilizzati", + ja: "最もよく使っている言語", + kr: "가장 많이 사용된 언어", + nl: "Meest gebruikte talen", + "pt-pt": "Linguagens mais usadas", + "pt-br": "Linguagens mais usadas", + np: "अधिक प्रयोग गरिएको भाषाहरू", + el: "Οι περισσότερο χρησιμοποιούμενες γλώσσες", + ro: "Cele Mai Folosite Limbaje", + ru: "Наиболее используемые языки", + "uk-ua": "Найчастіше використовувані мови", + id: "Bahasa Yang Paling Banyak Digunakan", + ml: "കൂടുതൽ ഉപയോഗിച്ച ഭാഷകൾ", + my: "Bahasa Paling Digunakan", + sk: "Najviac používané jazyky", + tr: "En Çok Kullanılan Diller", + pl: "Najczęściej używane języki", + uz: "Eng koʻp ishlatiladigan tillar", + vi: "Ngôn Ngữ Thường Sử Dụng", + se: "Mest använda språken", + }, + "langcard.nodata": { + ar: "لا توجد بيانات للغات.", + az: "Dil məlumatı yoxdur.", + cn: "没有语言数据。", + "zh-tw": "沒有語言資料。", + cs: "Žádné jazykové údaje.", + de: "Keine Sprachdaten.", + bn: "কোন ভাষার ডেটা নেই।", + en: "No languages data.", + es: "Sin datos de idiomas.", + fi: "Ei kielitietoja.", + fr: "Aucune donnée sur les langues.", + hu: "Nincsenek nyelvi adatok.", + it: "Nessun dato sulle lingue.", + ja: "言語データがありません。", + kr: "언어 데이터가 없습니다.", + nl: "Ingen sprogdata.", + "pt-pt": "Sem dados de linguagens.", + "pt-br": "Sem dados de linguagens.", + np: "कुनै भाषा डाटा छैन।", + el: "Δεν υπάρχουν δεδομένα γλωσσών.", + ro: "Lipsesc date despre limbă.", + ru: "Нет данных о языках.", + "uk-ua": "Немає даних про мови.", + id: "Tidak ada data bahasa.", + ml: "ഭാഷാ ഡാറ്റയില്ല.", + my: "Tiada data bahasa.", + sk: "Žiadne údaje o jazykoch.", + tr: "Dil verisi yok.", + pl: "Brak danych dotyczących języków.", + uz: "Til haqida ma'lumot yo'q.", + vi: "Không có dữ liệu ngôn ngữ.", + se: "Inga språkdata.", + }, +}; + +const wakatimeCardLocales = { + "wakatimecard.title": { + ar: "إحصائيات واكا تايم", + az: "WakaTime Statistikası", + cn: "WakaTime 周统计", + "zh-tw": "WakaTime 周統計", + cs: "Statistiky WakaTime", + de: "WakaTime Status", + en: "WakaTime Stats", + bn: "WakaTime স্ট্যাটাস", + es: "Estadísticas de WakaTime", + fi: "WakaTime-tilastot", + fr: "Statistiques de WakaTime", + hu: "WakaTime statisztika", + it: "Statistiche WakaTime", + ja: "WakaTime ワカタイム統計", + kr: "WakaTime 주간 통계", + nl: "WakaTime-statistieken", + "pt-pt": "Estatísticas WakaTime", + "pt-br": "Estatísticas WakaTime", + np: "WakaTime तथ्या .्क", + el: "Στατιστικά WakaTime", + ro: "Statistici WakaTime", + ru: "Статистика WakaTime", + "uk-ua": "Статистика WakaTime", + id: "Status WakaTime", + ml: "വേക്ക് ടൈം സ്ഥിതിവിവരക്കണക്കുകൾ", + my: "Statistik WakaTime", + sk: "WakaTime štatistika", + tr: "WakaTime İstatistikler", + pl: "Statystyki WakaTime", + uz: "WakaTime statistikasi", + vi: "Thống Kê WakaTime", + se: "WakaTime statistik", + }, + "wakatimecard.lastyear": { + ar: "العام الماضي", + az: "Ötən il", + cn: "去年", + "zh-tw": "去年", + cs: "Minulý rok", + de: "Letztes Jahr", + en: "last year", + bn: "গত বছর", + es: "El año pasado", + fi: "Viime vuosi", + fr: "L'année dernière", + hu: "Tavaly", + it: "L'anno scorso", + ja: "昨年", + kr: "작년", + nl: "Vorig jaar", + "pt-pt": "Ano passado", + "pt-br": "Ano passado", + np: "गत वर्ष", + el: "Πέρυσι", + ro: "Anul trecut", + ru: "За прошлый год", + "uk-ua": "За минулий рік", + id: "Tahun lalu", + ml: "കഴിഞ്ഞ വർഷം", + my: "Tahun lepas", + sk: "Minulý rok", + tr: "Geçen yıl", + pl: "W zeszłym roku", + uz: "O'tgan yil", + vi: "Năm ngoái", + se: "Förra året", + }, + "wakatimecard.last7days": { + ar: "آخر 7 أيام", + az: "Son 7 gün", + cn: "最近 7 天", + "zh-tw": "最近 7 天", + cs: "Posledních 7 dní", + de: "Letzte 7 Tage", + en: "last 7 days", + bn: "গত ৭ দিন", + es: "Últimos 7 días", + fi: "Viimeiset 7 päivää", + fr: "7 derniers jours", + hu: "Elmúlt 7 nap", + it: "Ultimi 7 giorni", + ja: "過去 7 日間", + kr: "지난 7 일", + nl: "Afgelopen 7 dagen", + "pt-pt": "Últimos 7 dias", + "pt-br": "Últimos 7 dias", + np: "गत ७ दिन", + el: "Τελευταίες 7 ημέρες", + ro: "Ultimele 7 zile", + ru: "Последние 7 дней", + "uk-ua": "Останні 7 днів", + id: "7 hari terakhir", + ml: "കഴിഞ്ഞ 7 ദിവസം", + my: "7 hari lepas", + sk: "Posledných 7 dní", + tr: "Son 7 gün", + pl: "Ostatnie 7 dni", + uz: "O'tgan 7 kun", + vi: "7 ngày qua", + se: "Senaste 7 dagarna", + }, + "wakatimecard.notpublic": { + ar: "ملف مستخدم واكا تايم شخصي", + az: "WakaTime istifadəçi profili ictimai deyil", + cn: "WakaTime 用户个人资料未公开", + "zh-tw": "WakaTime 使用者個人資料未公開", + cs: "Profil uživatele WakaTime není veřejný", + de: "WakaTime-Benutzerprofil nicht öffentlich", + en: "WakaTime user profile not public", + bn: "WakaTime ব্যবহারকারীর প্রোফাইল প্রকাশ্য নয়", + es: "Perfil de usuario de WakaTime no público", + fi: "WakaTime-käyttäjäprofiili ei ole julkinen", + fr: "Profil utilisateur WakaTime non public", + hu: "A WakaTime felhasználói profilja nem nyilvános", + it: "Profilo utente WakaTime non pubblico", + ja: "WakaTime ユーザープロファイルは公開されていません", + kr: "WakaTime 사용자 프로필이 공개되지 않았습니다", + nl: "WakaTime gebruikersprofiel niet openbaar", + "pt-pt": "Perfil de utilizador WakaTime não público", + "pt-br": "Perfil de usuário WakaTime não público", + np: "WakaTime प्रयोगकर्ता प्रोफाइल सार्वजनिक छैन", + el: "Το προφίλ χρήστη WakaTime δεν είναι δημόσιο", + ro: "Profilul utilizatorului de Wakatime nu este public", + ru: "Профиль пользователя WakaTime не общедоступный", + "uk-ua": "Профіль користувача WakaTime не є публічним", + id: "Profil pengguna WakaTime tidak publik", + ml: "WakaTime ഉപയോക്തൃ പ്രൊഫൈൽ പൊതുവായി പ്രസിദ്ധീകരിക്കപ്പെടാത്തതാണ്", + my: "Profil pengguna WakaTime tidak awam", + sk: "Profil používateľa WakaTime nie je verejný", + tr: "WakaTime kullanıcı profili herkese açık değil", + pl: "Profil użytkownika WakaTime nie jest publiczny", + uz: "WakaTime foydalanuvchi profili ochiq emas", + vi: "Hồ sơ người dùng WakaTime không công khai", + se: "WakaTime användarprofil inte offentlig", + }, + "wakatimecard.nocodedetails": { + ar: "المستخدم لا يشارك المعلومات التفصيلية", + az: "İstifadəçi kod statistikalarını ictimai şəkildə paylaşmır", + cn: "用户不公开分享详细的代码统计信息", + "zh-tw": "使用者不公開分享詳細的程式碼統計資訊", + cs: "Uživatel nesdílí podrobné statistiky kódu", + de: "Benutzer teilt keine detaillierten Code-Statistiken", + en: "User doesn't publicly share detailed code statistics", + bn: "ব্যবহারকারী বিস্তারিত কোড পরিসংখ্যান প্রকাশ করেন না", + es: "El usuario no comparte públicamente estadísticas detalladas de código", + fi: "Käyttäjä ei jaa julkisesti tarkkoja kooditilastoja", + fr: "L'utilisateur ne partage pas publiquement de statistiques de code détaillées", + hu: "A felhasználó nem osztja meg nyilvánosan a részletes kódstatisztikákat", + it: "L'utente non condivide pubblicamente statistiche dettagliate sul codice", + ja: "ユーザーは詳細なコード統計を公開しません", + kr: "사용자는 자세한 코드 통계를 공개하지 않습니다", + nl: "Gebruiker deelt geen gedetailleerde code-statistieken", + "pt-pt": + "O utilizador não partilha publicamente estatísticas detalhadas de código", + "pt-br": + "O usuário não compartilha publicamente estatísticas detalhadas de código", + np: "प्रयोगकर्ता सार्वजनिक रूपमा विस्तृत कोड तथ्याङ्क साझा गर्दैन", + el: "Ο χρήστης δεν δημοσιεύει δημόσια λεπτομερείς στατιστικές κώδικα", + ro: "Utilizatorul nu își publică statisticile detaliate ale codului", + ru: "Пользователь не делится подробной статистикой кода", + "uk-ua": "Користувач не публікує детальну статистику коду", + id: "Pengguna tidak membagikan statistik kode terperinci secara publik", + ml: "ഉപയോക്താവ് പൊതുവെ വിശദീകരിച്ച കോഡ് സ്റ്റാറ്റിസ്റ്റിക്സ് പങ്കിടുന്നില്ല", + my: "Pengguna tidak berkongsi statistik kod terperinci secara awam", + sk: "Používateľ neposkytuje verejne podrobné štatistiky kódu", + tr: "Kullanıcı ayrıntılı kod istatistiklerini herkese açık olarak paylaşmıyor", + pl: "Użytkownik nie udostępnia publicznie szczegółowych statystyk kodu", + uz: "Foydalanuvchi umumiy ko`d statistikasini ochiq ravishda almashmaydi", + vi: "Người dùng không chia sẻ thống kê mã chi tiết công khai", + se: "Användaren delar inte offentligt detaljerad kodstatistik", + }, + "wakatimecard.nocodingactivity": { + ar: "لا يوجد نشاط برمجي لهذا الأسبوع", + az: "Bu həftə heç bir kodlaşdırma fəaliyyəti olmayıb", + cn: "本周没有编程活动", + "zh-tw": "本周沒有編程活動", + cs: "Tento týden žádná aktivita v kódování", + de: "Keine Aktivitäten in dieser Woche", + en: "No coding activity this week", + bn: "এই সপ্তাহে কোন কোডিং অ্যাক্টিভিটি নেই", + es: "No hay actividad de codificación esta semana", + fi: "Ei koodaustoimintaa tällä viikolla", + fr: "Aucune activité de codage cette semaine", + hu: "Nem volt aktivitás ezen a héten", + it: "Nessuna attività in questa settimana", + ja: "今週のコーディング活動はありません", + kr: "이번 주 작업내역 없음", + nl: "Geen programmeeractiviteit deze week", + "pt-pt": "Sem atividade esta semana", + "pt-br": "Nenhuma atividade de codificação esta semana", + np: "यस हप्ता कुनै कोडिंग गतिविधि छैन", + el: "Δεν υπάρχει δραστηριότητα κώδικα γι' αυτή την εβδομάδα", + ro: "Nicio activitate de programare săptămâna aceasta", + ru: "На этой неделе не было активности", + "uk-ua": "На цьому тижні не було активності", + id: "Tidak ada aktivitas perkodingan minggu ini", + ml: "ഈ ആഴ്ച കോഡിംഗ് പ്രവർത്തനങ്ങളൊന്നുമില്ല", + my: "Tiada aktiviti pengekodan minggu ini", + sk: "Žiadna kódovacia aktivita tento týždeň", + tr: "Bu hafta herhangi bir kod yazma aktivitesi olmadı", + pl: "Brak aktywności w tym tygodniu", + uz: "Bu hafta faol bo'lmadi", + vi: "Không Có Hoạt Động Trong Tuần Này", + se: "Ingen aktivitet denna vecka", + }, +}; + +const availableLocales = Object.keys(repoCardLocales["repocard.archived"]); + +/** + * Checks whether the locale is available or not. + * + * @param {string} locale The locale to check. + * @returns {boolean} Boolean specifying whether the locale is available or not. + */ +const isLocaleAvailable = (locale) => { + return availableLocales.includes(locale.toLowerCase()); +}; + +export { + availableLocales, + isLocaleAvailable, + langCardLocales, + repoCardLocales, + statCardLocales, + wakatimeCardLocales, +}; diff --git a/frontend/frontend/src/backend/src/users.js b/frontend/frontend/src/backend/src/users.js new file mode 100644 index 00000000..a71c302f --- /dev/null +++ b/frontend/frontend/src/backend/src/users.js @@ -0,0 +1,93 @@ +import axios from "axios"; +import { storeUser } from "./common/database.js"; + +/** + * Given an access token, return the GitHub login (userId) or null if invalid + * + * @param {string} accessToken GitHub access token + * @returns {Promise} login name or null if invalid access_token + */ +async function getUserFromToken(accessToken) { + const res = await axios.get("https://api.github.com/user", { + headers: { + Accept: "application/vnd.github.v3+json", + Authorization: `bearer ${accessToken}`, + }, + }); + + return res.data && res.data.login ? res.data.login : null; +} + +/** + * Exchanges OAuth code for access token and returns userId + accessToken + * + * @param {string} code GitHub authentication code from OAuth process + * @returns {Promise<{userId: string, accessToken: string}>} user_id and access_token of authenticated user + */ +async function githubAuthenticate(code) { + if ( + !process.env.OAUTH_CLIENT_ID || + !process.env.OAUTH_CLIENT_SECRET || + !process.env.OAUTH_REDIRECT_URI + ) { + throw new Error( + "OAuth Error: One or more required environment variables (OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET, OAUTH_REDIRECT_URI) are not set.", + ); + } + + const start = Date.now(); + const params = new URLSearchParams({ + client_id: process.env.OAUTH_CLIENT_ID, + client_secret: process.env.OAUTH_CLIENT_SECRET, + code, + redirect_uri: process.env.OAUTH_REDIRECT_URI, + }); + + try { + const res = await axios.post( + "https://github.com/login/oauth/access_token", + params.toString(), + { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + }, + ); + + const body = res.data; + const accessToken = body && body.access_token ? body.access_token : null; + + if (!accessToken) { + throw new Error("OAuth Error: access_token missing from response"); + } + + const userId = await getUserFromToken(accessToken); + + if (!userId) { + throw new Error("OAuth Error: Invalid user_id/access_token"); + } + + console.log("GitHub Authentication", `${Date.now() - start} ms`); + return { userId, accessToken }; + } catch (err) { + if (err.response) { + throw new Error(`OAuth Error: ${err.response.status}`); + } + throw err; + } +} + +/** + * Authenticate using the OAuth code and update DB with associated user info. + * + * @param {string} code GitHub authentication code from OAuth process + * @param {boolean} privateAccess whether private access was requested + * @param {string} userKey user key to associate with the user + * @returns {Promise} user_id of authenticated user + */ +export async function authenticate(code, privateAccess, userKey) { + const { userId, accessToken } = await githubAuthenticate(code); + await storeUser(userId, accessToken, userKey, privateAccess); + return userId; +} diff --git a/frontend/frontend/src/backend/themes/README.md b/frontend/frontend/src/backend/themes/README.md new file mode 100644 index 00000000..c8dffda2 --- /dev/null +++ b/frontend/frontend/src/backend/themes/README.md @@ -0,0 +1,229 @@ +## Available Themes + + + +With inbuilt themes, you can customize the look of the card without doing any manual customization. + +Use `?theme=THEME_NAME` parameter like so: + +```md +![Anurag's GitHub stats](https://github-readme-stats.vercel.app/api?username=anuraghazra&theme=dark&show_icons=true) +``` + +## Stats + +> These themes work with all five of our cards: Stats Card, Repo Card, Gist Card, Top Languages Card, and WakaTime Card. + +| | | | +| :--: | :--: | :--: | +| `default` ![default][default] | `transparent` ![transparent][transparent] | `shadow_red` ![shadow_red][shadow_red] | +| `shadow_green` ![shadow_green][shadow_green] | `shadow_blue` ![shadow_blue][shadow_blue] | `dark` ![dark][dark] | +| `radical` ![radical][radical] | `merko` ![merko][merko] | `gruvbox` ![gruvbox][gruvbox] | +| `gruvbox_light` ![gruvbox_light][gruvbox_light] | `tokyonight` ![tokyonight][tokyonight] | `onedark` ![onedark][onedark] | +| `cobalt` ![cobalt][cobalt] | `synthwave` ![synthwave][synthwave] | `highcontrast` ![highcontrast][highcontrast] | +| `dracula` ![dracula][dracula] | `prussian` ![prussian][prussian] | `monokai` ![monokai][monokai] | +| `vue` ![vue][vue] | `vue-dark` ![vue-dark][vue-dark] | `shades-of-purple` ![shades-of-purple][shades-of-purple] | +| `nightowl` ![nightowl][nightowl] | `buefy` ![buefy][buefy] | `blue-green` ![blue-green][blue-green] | +| `algolia` ![algolia][algolia] | `great-gatsby` ![great-gatsby][great-gatsby] | `darcula` ![darcula][darcula] | +| `bear` ![bear][bear] | `solarized-dark` ![solarized-dark][solarized-dark] | `solarized-light` ![solarized-light][solarized-light] | +| `chartreuse-dark` ![chartreuse-dark][chartreuse-dark] | `nord` ![nord][nord] | `gotham` ![gotham][gotham] | +| `material-palenight` ![material-palenight][material-palenight] | `graywhite` ![graywhite][graywhite] | `vision-friendly-dark` ![vision-friendly-dark][vision-friendly-dark] | +| `ayu-mirage` ![ayu-mirage][ayu-mirage] | `midnight-purple` ![midnight-purple][midnight-purple] | `calm` ![calm][calm] | +| `flag-india` ![flag-india][flag-india] | `omni` ![omni][omni] | `react` ![react][react] | +| `jolly` ![jolly][jolly] | `maroongold` ![maroongold][maroongold] | `yeblu` ![yeblu][yeblu] | +| `blueberry` ![blueberry][blueberry] | `slateorange` ![slateorange][slateorange] | `kacho_ga` ![kacho_ga][kacho_ga] | +| `outrun` ![outrun][outrun] | `ocean_dark` ![ocean_dark][ocean_dark] | `city_lights` ![city_lights][city_lights] | +| `github_dark` ![github_dark][github_dark] | `github_dark_dimmed` ![github_dark_dimmed][github_dark_dimmed] | `discord_old_blurple` ![discord_old_blurple][discord_old_blurple] | +| `aura_dark` ![aura_dark][aura_dark] | `panda` ![panda][panda] | `noctis_minimus` ![noctis_minimus][noctis_minimus] | +| `cobalt2` ![cobalt2][cobalt2] | `swift` ![swift][swift] | `aura` ![aura][aura] | +| `apprentice` ![apprentice][apprentice] | `moltack` ![moltack][moltack] | `codeSTACKr` ![codeSTACKr][codeSTACKr] | +| `rose_pine` ![rose_pine][rose_pine] | `catppuccin_latte` ![catppuccin_latte][catppuccin_latte] | `catppuccin_mocha` ![catppuccin_mocha][catppuccin_mocha] | +| `date_night` ![date_night][date_night] | `one_dark_pro` ![one_dark_pro][one_dark_pro] | `rose` ![rose][rose] | +| `holi` ![holi][holi] | `neon` ![neon][neon] | `blue_navy` ![blue_navy][blue_navy] | +| `calm_pink` ![calm_pink][calm_pink] | `ambient_gradient` ![ambient_gradient][ambient_gradient] | | + +## Repo Card + +> These themes work with all five of our cards: Stats Card, Repo Card, Gist Card, Top Languages Card, and WakaTime Card. + +| | | | +| :--: | :--: | :--: | +| `default_repocard` ![default_repocard][default_repocard_repo] | `transparent` ![transparent][transparent_repo] | `shadow_red` ![shadow_red][shadow_red_repo] | +| `shadow_green` ![shadow_green][shadow_green_repo] | `shadow_blue` ![shadow_blue][shadow_blue_repo] | `dark` ![dark][dark_repo] | +| `radical` ![radical][radical_repo] | `merko` ![merko][merko_repo] | `gruvbox` ![gruvbox][gruvbox_repo] | +| `gruvbox_light` ![gruvbox_light][gruvbox_light_repo] | `tokyonight` ![tokyonight][tokyonight_repo] | `onedark` ![onedark][onedark_repo] | +| `cobalt` ![cobalt][cobalt_repo] | `synthwave` ![synthwave][synthwave_repo] | `highcontrast` ![highcontrast][highcontrast_repo] | +| `dracula` ![dracula][dracula_repo] | `prussian` ![prussian][prussian_repo] | `monokai` ![monokai][monokai_repo] | +| `vue` ![vue][vue_repo] | `vue-dark` ![vue-dark][vue-dark_repo] | `shades-of-purple` ![shades-of-purple][shades-of-purple_repo] | +| `nightowl` ![nightowl][nightowl_repo] | `buefy` ![buefy][buefy_repo] | `blue-green` ![blue-green][blue-green_repo] | +| `algolia` ![algolia][algolia_repo] | `great-gatsby` ![great-gatsby][great-gatsby_repo] | `darcula` ![darcula][darcula_repo] | +| `bear` ![bear][bear_repo] | `solarized-dark` ![solarized-dark][solarized-dark_repo] | `solarized-light` ![solarized-light][solarized-light_repo] | +| `chartreuse-dark` ![chartreuse-dark][chartreuse-dark_repo] | `nord` ![nord][nord_repo] | `gotham` ![gotham][gotham_repo] | +| `material-palenight` ![material-palenight][material-palenight_repo] | `graywhite` ![graywhite][graywhite_repo] | `vision-friendly-dark` ![vision-friendly-dark][vision-friendly-dark_repo] | +| `ayu-mirage` ![ayu-mirage][ayu-mirage_repo] | `midnight-purple` ![midnight-purple][midnight-purple_repo] | `calm` ![calm][calm_repo] | +| `flag-india` ![flag-india][flag-india_repo] | `omni` ![omni][omni_repo] | `react` ![react][react_repo] | +| `jolly` ![jolly][jolly_repo] | `maroongold` ![maroongold][maroongold_repo] | `yeblu` ![yeblu][yeblu_repo] | +| `blueberry` ![blueberry][blueberry_repo] | `slateorange` ![slateorange][slateorange_repo] | `kacho_ga` ![kacho_ga][kacho_ga_repo] | +| `outrun` ![outrun][outrun_repo] | `ocean_dark` ![ocean_dark][ocean_dark_repo] | `city_lights` ![city_lights][city_lights_repo] | +| `github_dark` ![github_dark][github_dark_repo] | `github_dark_dimmed` ![github_dark_dimmed][github_dark_dimmed_repo] | `discord_old_blurple` ![discord_old_blurple][discord_old_blurple_repo] | +| `aura_dark` ![aura_dark][aura_dark_repo] | `panda` ![panda][panda_repo] | `noctis_minimus` ![noctis_minimus][noctis_minimus_repo] | +| `cobalt2` ![cobalt2][cobalt2_repo] | `swift` ![swift][swift_repo] | `aura` ![aura][aura_repo] | +| `apprentice` ![apprentice][apprentice_repo] | `moltack` ![moltack][moltack_repo] | `codeSTACKr` ![codeSTACKr][codeSTACKr_repo] | +| `rose_pine` ![rose_pine][rose_pine_repo] | `catppuccin_latte` ![catppuccin_latte][catppuccin_latte_repo] | `catppuccin_mocha` ![catppuccin_mocha][catppuccin_mocha_repo] | +| `date_night` ![date_night][date_night_repo] | `one_dark_pro` ![one_dark_pro][one_dark_pro_repo] | `rose` ![rose][rose_repo] | +| `holi` ![holi][holi_repo] | `neon` ![neon][neon_repo] | `blue_navy` ![blue_navy][blue_navy_repo] | +| `calm_pink` ![calm_pink][calm_pink_repo] | `ambient_gradient` ![ambient_gradient][ambient_gradient_repo] | | + + +[default]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=default +[default_repocard]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=default_repocard +[transparent]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=transparent +[shadow_red]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=shadow_red +[shadow_green]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=shadow_green +[shadow_blue]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=shadow_blue +[dark]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=dark +[radical]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=radical +[merko]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=merko +[gruvbox]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=gruvbox +[gruvbox_light]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=gruvbox_light +[tokyonight]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=tokyonight +[onedark]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=onedark +[cobalt]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=cobalt +[synthwave]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=synthwave +[highcontrast]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=highcontrast +[dracula]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=dracula +[prussian]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=prussian +[monokai]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=monokai +[vue]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=vue +[vue-dark]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=vue-dark +[shades-of-purple]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=shades-of-purple +[nightowl]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=nightowl +[buefy]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=buefy +[blue-green]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=blue-green +[algolia]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=algolia +[great-gatsby]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=great-gatsby +[darcula]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=darcula +[bear]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=bear +[solarized-dark]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=solarized-dark +[solarized-light]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=solarized-light +[chartreuse-dark]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=chartreuse-dark +[nord]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=nord +[gotham]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=gotham +[material-palenight]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=material-palenight +[graywhite]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=graywhite +[vision-friendly-dark]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=vision-friendly-dark +[ayu-mirage]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=ayu-mirage +[midnight-purple]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=midnight-purple +[calm]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=calm +[flag-india]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=flag-india +[omni]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=omni +[react]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=react +[jolly]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=jolly +[maroongold]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=maroongold +[yeblu]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=yeblu +[blueberry]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=blueberry +[slateorange]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=slateorange +[kacho_ga]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=kacho_ga +[outrun]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=outrun +[ocean_dark]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=ocean_dark +[city_lights]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=city_lights +[github_dark]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=github_dark +[github_dark_dimmed]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=github_dark_dimmed +[discord_old_blurple]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=discord_old_blurple +[aura_dark]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=aura_dark +[panda]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=panda +[noctis_minimus]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=noctis_minimus +[cobalt2]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=cobalt2 +[swift]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=swift +[aura]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=aura +[apprentice]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=apprentice +[moltack]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=moltack +[codeSTACKr]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=codeSTACKr +[rose_pine]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=rose_pine +[catppuccin_latte]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=catppuccin_latte +[catppuccin_mocha]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=catppuccin_mocha +[date_night]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=date_night +[one_dark_pro]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=one_dark_pro +[rose]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=rose +[holi]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=holi +[neon]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=neon +[blue_navy]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=blue_navy +[calm_pink]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=calm_pink +[ambient_gradient]: https://github-readme-stats.vercel.app/api?username=anuraghazra&show_icons=true&hide=contribs,prs&cache_seconds=86400&theme=ambient_gradient + + +[default_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=default +[default_repocard_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=default_repocard +[transparent_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=transparent +[shadow_red_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=shadow_red +[shadow_green_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=shadow_green +[shadow_blue_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=shadow_blue +[dark_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=dark +[radical_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=radical +[merko_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=merko +[gruvbox_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=gruvbox +[gruvbox_light_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=gruvbox_light +[tokyonight_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=tokyonight +[onedark_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=onedark +[cobalt_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=cobalt +[synthwave_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=synthwave +[highcontrast_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=highcontrast +[dracula_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=dracula +[prussian_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=prussian +[monokai_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=monokai +[vue_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=vue +[vue-dark_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=vue-dark +[shades-of-purple_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=shades-of-purple +[nightowl_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=nightowl +[buefy_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=buefy +[blue-green_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=blue-green +[algolia_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=algolia +[great-gatsby_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=great-gatsby +[darcula_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=darcula +[bear_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=bear +[solarized-dark_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=solarized-dark +[solarized-light_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=solarized-light +[chartreuse-dark_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=chartreuse-dark +[nord_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=nord +[gotham_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=gotham +[material-palenight_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=material-palenight +[graywhite_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=graywhite +[vision-friendly-dark_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=vision-friendly-dark +[ayu-mirage_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=ayu-mirage +[midnight-purple_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=midnight-purple +[calm_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=calm +[flag-india_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=flag-india +[omni_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=omni +[react_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=react +[jolly_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=jolly +[maroongold_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=maroongold +[yeblu_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=yeblu +[blueberry_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=blueberry +[slateorange_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=slateorange +[kacho_ga_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=kacho_ga +[outrun_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=outrun +[ocean_dark_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=ocean_dark +[city_lights_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=city_lights +[github_dark_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=github_dark +[github_dark_dimmed_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=github_dark_dimmed +[discord_old_blurple_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=discord_old_blurple +[aura_dark_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=aura_dark +[panda_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=panda +[noctis_minimus_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=noctis_minimus +[cobalt2_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=cobalt2 +[swift_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=swift +[aura_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=aura +[apprentice_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=apprentice +[moltack_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=moltack +[codeSTACKr_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=codeSTACKr +[rose_pine_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=rose_pine +[catppuccin_latte_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=catppuccin_latte +[catppuccin_mocha_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=catppuccin_mocha +[date_night_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=date_night +[one_dark_pro_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=one_dark_pro +[rose_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=rose +[holi_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=holi +[neon_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=neon +[blue_navy_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=blue_navy +[calm_pink_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=calm_pink +[ambient_gradient_repo]: https://github-readme-stats.vercel.app/api/pin/?username=anuraghazra&repo=github-readme-stats&cache_seconds=86400&theme=ambient_gradient diff --git a/frontend/frontend/src/backend/themes/index.js b/frontend/frontend/src/backend/themes/index.js new file mode 100644 index 00000000..f5d8d916 --- /dev/null +++ b/frontend/frontend/src/backend/themes/index.js @@ -0,0 +1,467 @@ +export const themes = { + default: { + title_color: "2f80ed", + icon_color: "4c71f2", + text_color: "434d58", + bg_color: "fffefe", + border_color: "e4e2e2", + }, + default_repocard: { + title_color: "2f80ed", + icon_color: "586069", // icon color is different + text_color: "434d58", + bg_color: "fffefe", + }, + transparent: { + title_color: "006AFF", + icon_color: "0579C3", + text_color: "417E87", + bg_color: "ffffff00", + }, + shadow_red: { + title_color: "9A0000", + text_color: "444", + icon_color: "4F0000", + border_color: "4F0000", + bg_color: "ffffff00", + }, + shadow_green: { + title_color: "007A00", + text_color: "444", + icon_color: "003D00", + border_color: "003D00", + bg_color: "ffffff00", + }, + shadow_blue: { + title_color: "00779A", + text_color: "444", + icon_color: "004450", + border_color: "004490", + bg_color: "ffffff00", + }, + dark: { + title_color: "fff", + icon_color: "79ff97", + text_color: "9f9f9f", + bg_color: "151515", + }, + radical: { + title_color: "fe428e", + icon_color: "f8d847", + text_color: "a9fef7", + bg_color: "141321", + }, + merko: { + title_color: "abd200", + icon_color: "b7d364", + text_color: "68b587", + bg_color: "0a0f0b", + }, + gruvbox: { + title_color: "fabd2f", + icon_color: "fe8019", + text_color: "8ec07c", + bg_color: "282828", + }, + gruvbox_light: { + title_color: "b57614", + icon_color: "af3a03", + text_color: "427b58", + bg_color: "fbf1c7", + }, + tokyonight: { + title_color: "70a5fd", + icon_color: "bf91f3", + text_color: "38bdae", + bg_color: "1a1b27", + }, + onedark: { + title_color: "e4bf7a", + icon_color: "8eb573", + text_color: "df6d74", + bg_color: "282c34", + }, + cobalt: { + title_color: "e683d9", + icon_color: "0480ef", + text_color: "75eeb2", + bg_color: "193549", + }, + synthwave: { + title_color: "e2e9ec", + icon_color: "ef8539", + text_color: "e5289e", + bg_color: "2b213a", + }, + highcontrast: { + title_color: "e7f216", + icon_color: "00ffff", + text_color: "fff", + bg_color: "000", + }, + dracula: { + title_color: "ff6e96", + icon_color: "79dafa", + text_color: "f8f8f2", + bg_color: "282a36", + }, + prussian: { + title_color: "bddfff", + icon_color: "38a0ff", + text_color: "6e93b5", + bg_color: "172f45", + }, + monokai: { + title_color: "eb1f6a", + icon_color: "e28905", + text_color: "f1f1eb", + bg_color: "272822", + }, + vue: { + title_color: "41b883", + icon_color: "41b883", + text_color: "273849", + bg_color: "fffefe", + }, + "vue-dark": { + title_color: "41b883", + icon_color: "41b883", + text_color: "fffefe", + bg_color: "273849", + }, + "shades-of-purple": { + title_color: "fad000", + icon_color: "b362ff", + text_color: "a599e9", + bg_color: "2d2b55", + }, + nightowl: { + title_color: "c792ea", + icon_color: "ffeb95", + text_color: "7fdbca", + bg_color: "011627", + }, + buefy: { + title_color: "7957d5", + icon_color: "ff3860", + text_color: "363636", + bg_color: "ffffff", + }, + "blue-green": { + title_color: "2f97c1", + icon_color: "f5b700", + text_color: "0cf574", + bg_color: "040f0f", + }, + algolia: { + title_color: "00AEFF", + icon_color: "2DDE98", + text_color: "FFFFFF", + bg_color: "050F2C", + }, + "great-gatsby": { + title_color: "ffa726", + icon_color: "ffb74d", + text_color: "ffd95b", + bg_color: "000000", + }, + darcula: { + title_color: "BA5F17", + icon_color: "84628F", + text_color: "BEBEBE", + bg_color: "242424", + }, + bear: { + title_color: "e03c8a", + icon_color: "00AEFF", + text_color: "bcb28d", + bg_color: "1f2023", + }, + "solarized-dark": { + title_color: "268bd2", + icon_color: "b58900", + text_color: "859900", + bg_color: "002b36", + }, + "solarized-light": { + title_color: "268bd2", + icon_color: "b58900", + text_color: "859900", + bg_color: "fdf6e3", + }, + "chartreuse-dark": { + title_color: "7fff00", + icon_color: "00AEFF", + text_color: "fff", + bg_color: "000", + }, + nord: { + title_color: "81a1c1", + text_color: "d8dee9", + icon_color: "88c0d0", + bg_color: "2e3440", + }, + gotham: { + title_color: "2aa889", + icon_color: "599cab", + text_color: "99d1ce", + bg_color: "0c1014", + }, + "material-palenight": { + title_color: "c792ea", + icon_color: "89ddff", + text_color: "a6accd", + bg_color: "292d3e", + }, + graywhite: { + title_color: "24292e", + icon_color: "24292e", + text_color: "24292e", + bg_color: "ffffff", + }, + "vision-friendly-dark": { + title_color: "ffb000", + icon_color: "785ef0", + text_color: "ffffff", + bg_color: "000000", + }, + "ayu-mirage": { + title_color: "f4cd7c", + icon_color: "73d0ff", + text_color: "c7c8c2", + bg_color: "1f2430", + }, + "midnight-purple": { + title_color: "9745f5", + icon_color: "9f4bff", + text_color: "ffffff", + bg_color: "000000", + }, + calm: { + title_color: "e07a5f", + icon_color: "edae49", + text_color: "ebcfb2", + bg_color: "373f51", + }, + "flag-india": { + title_color: "ff8f1c", + icon_color: "250E62", + text_color: "509E2F", + bg_color: "ffffff", + }, + omni: { + title_color: "FF79C6", + icon_color: "e7de79", + text_color: "E1E1E6", + bg_color: "191622", + }, + react: { + title_color: "61dafb", + icon_color: "61dafb", + text_color: "ffffff", + bg_color: "20232a", + }, + jolly: { + title_color: "ff64da", + icon_color: "a960ff", + text_color: "ffffff", + bg_color: "291B3E", + }, + maroongold: { + title_color: "F7EF8A", + icon_color: "F7EF8A", + text_color: "E0AA3E", + bg_color: "260000", + }, + yeblu: { + title_color: "ffff00", + icon_color: "ffff00", + text_color: "ffffff", + bg_color: "002046", + }, + blueberry: { + title_color: "82aaff", + icon_color: "89ddff", + text_color: "27e8a7", + bg_color: "242938", + }, + slateorange: { + title_color: "faa627", + icon_color: "faa627", + text_color: "ffffff", + bg_color: "36393f", + }, + kacho_ga: { + title_color: "bf4a3f", + icon_color: "a64833", + text_color: "d9c8a9", + bg_color: "402b23", + }, + outrun: { + title_color: "ffcc00", + icon_color: "ff1aff", + text_color: "8080ff", + bg_color: "141439", + }, + ocean_dark: { + title_color: "8957B2", + icon_color: "FFFFFF", + text_color: "92D534", + bg_color: "151A28", + }, + city_lights: { + title_color: "5D8CB3", + icon_color: "4798FF", + text_color: "718CA1", + bg_color: "1D252C", + }, + github_dark: { + title_color: "58A6FF", + icon_color: "1F6FEB", + text_color: "C3D1D9", + bg_color: "0D1117", + }, + github_dark_dimmed: { + title_color: "539bf5", + icon_color: "539bf5", + text_color: "ADBAC7", + bg_color: "24292F", + border_color: "373E47", + }, + discord_old_blurple: { + title_color: "7289DA", + icon_color: "7289DA", + text_color: "FFFFFF", + bg_color: "2C2F33", + }, + aura_dark: { + title_color: "ff7372", + icon_color: "6cffd0", + text_color: "dbdbdb", + bg_color: "252334", + }, + panda: { + title_color: "19f9d899", + icon_color: "19f9d899", + text_color: "FF75B5", + bg_color: "31353a", + }, + noctis_minimus: { + title_color: "d3b692", + icon_color: "72b7c0", + text_color: "c5cdd3", + bg_color: "1b2932", + }, + cobalt2: { + title_color: "ffc600", + icon_color: "ffffff", + text_color: "0088ff", + bg_color: "193549", + }, + swift: { + title_color: "000000", + icon_color: "f05237", + text_color: "000000", + bg_color: "f7f7f7", + }, + aura: { + title_color: "a277ff", + icon_color: "ffca85", + text_color: "61ffca", + bg_color: "15141b", + }, + apprentice: { + title_color: "ffffff", + icon_color: "ffffaf", + text_color: "bcbcbc", + bg_color: "262626", + }, + moltack: { + title_color: "86092C", + icon_color: "86092C", + text_color: "574038", + bg_color: "F5E1C0", + }, + codeSTACKr: { + title_color: "ff652f", + icon_color: "FFE400", + text_color: "ffffff", + bg_color: "09131B", + border_color: "0c1a25", + }, + rose_pine: { + title_color: "9ccfd8", + icon_color: "ebbcba", + text_color: "e0def4", + bg_color: "191724", + }, + catppuccin_latte: { + title_color: "137980", + icon_color: "8839ef", + text_color: "4c4f69", + bg_color: "eff1f5", + }, + catppuccin_mocha: { + title_color: "94e2d5", + icon_color: "cba6f7", + text_color: "cdd6f4", + bg_color: "1e1e2e", + }, + date_night: { + title_color: "DA7885", + text_color: "E1B2A2", + icon_color: "BB8470", + border_color: "170F0C", + bg_color: "170F0C", + }, + one_dark_pro: { + title_color: "61AFEF", + text_color: "E5C06E", + icon_color: "C678DD", + border_color: "3B4048", + bg_color: "23272E", + }, + rose: { + title_color: "8d192b", + text_color: "862931", + icon_color: "B71F36", + border_color: "e9d8d4", + bg_color: "e9d8d4", + }, + holi: { + title_color: "5FABEE", + text_color: "D6E7FF", + icon_color: "5FABEE", + border_color: "85A4C0", + bg_color: "030314", + }, + neon: { + title_color: "00EAD3", + text_color: "FF449F", + icon_color: "00EAD3", + border_color: "ffffff", + bg_color: "000000", + }, + blue_navy: { + title_color: "82AAFF", + text_color: "82AAFF", + icon_color: "82AAFF", + border_color: "ffffff", + bg_color: "000000", + }, + calm_pink: { + title_color: "e07a5f", + text_color: "edae49", + icon_color: "ebcfb2", + border_color: "e1bc29", + bg_color: "2b2d40", + }, + ambient_gradient: { + title_color: "ffffff", + text_color: "ffffff", + icon_color: "ffffff", + bg_color: "35,4158d0,c850c0,ffcc70", + }, +}; + +export default themes;