From 3d349ff1c8355d5ea4e8ea3a9c033b5f104ed441 Mon Sep 17 00:00:00 2001 From: Prashanth Kumar G Date: Sun, 12 Jul 2026 00:48:55 +0530 Subject: [PATCH 1/9] Update index.php --- api/index.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/api/index.php b/api/index.php index 7d02a0aa..27428bce 100644 --- a/api/index.php +++ b/api/index.php @@ -1,5 +1,9 @@ Date: Sun, 12 Jul 2026 01:13:00 +0530 Subject: [PATCH 2/9] Update index.php --- api/index.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/api/index.php b/api/index.php index 27428bce..7d02a0aa 100644 --- a/api/index.php +++ b/api/index.php @@ -1,9 +1,5 @@ Date: Tue, 14 Jul 2026 00:25:05 +0530 Subject: [PATCH 3/9] Update index.php --- api/index.php | 1 + 1 file changed, 1 insertion(+) diff --git a/api/index.php b/api/index.php index 7d02a0aa..bfa06251 100644 --- a/api/index.php +++ b/api/index.php @@ -1,4 +1,5 @@ Date: Tue, 14 Jul 2026 00:25:39 +0530 Subject: [PATCH 4/9] Update card.php --- api/card.php | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/api/card.php b/api/card.php index 75af8e0a..7fab5bb1 100644 --- a/api/card.php +++ b/api/card.php @@ -1,4 +1,5 @@ $stats Streak stats - * @param array|NULL $params Request parameters + * @param array|null $params Request parameters * @return string The generated SVG Streak Stats card * * @throws InvalidArgumentException If a locale does not exist */ -function generateCard(array $stats, array $params = null): string +function generateCard(array $stats, ?array $params = null): string { $params = $params ?? $_REQUEST; @@ -612,10 +613,10 @@ function generateCard(array $stats, array $params = null): string * Generate SVG displaying an error message * * @param string $message The error message to display - * @param array|NULL $params Request parameters + * @param array|null $params Request parameters * @return string The generated SVG error card */ -function generateErrorCard(string $message, array $params = null): string +function generateErrorCard(string $message, ?array $params = null): string { $params = $params ?? $_REQUEST; @@ -803,11 +804,11 @@ function convertSvgToPng(string $svg, int $cardWidth, int $cardHeight): string * Return headers and response based on type * * @param string|array $output The stats (array) or error message (string) to display - * @param array|NULL $params Request parameters + * @param array|null $params Request parameters * @param int $errorCode The HTTP error code (used for JSON responses) * @return array The Content-Type header and the response body, and status code in case of an error */ -function generateOutput(string|array $output, array $params = null, int $errorCode = 200): array +function generateOutput(string|array $output, ?array $params = null, int $errorCode = 200): array { $params = $params ?? $_REQUEST; From 67823c933adf759a53f11b179959fad30e4dde4b Mon Sep 17 00:00:00 2001 From: Prashanth Kumar G Date: Tue, 14 Jul 2026 00:45:32 +0530 Subject: [PATCH 5/9] Update index.php --- api/index.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/api/index.php b/api/index.php index bfa06251..c1c1ea80 100644 --- a/api/index.php +++ b/api/index.php @@ -1,5 +1,10 @@ Date: Tue, 14 Jul 2026 00:46:14 +0530 Subject: [PATCH 6/9] Update cache.php --- api/cache.php | 920 +++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 796 insertions(+), 124 deletions(-) diff --git a/api/cache.php b/api/cache.php index d8f2f906..f829ad4c 100644 --- a/api/cache.php +++ b/api/cache.php @@ -1,209 +1,881 @@ getBestPattern("MMM d"); + $dateFormatter = new IntlDateFormatter( + $locale, + IntlDateFormatter::MEDIUM, + IntlDateFormatter::NONE, + pattern: $pattern, + ); + $formatted = $dateFormatter->format($date); + } + } + // otherwise, display month, day, and year + else { + if ($format) { + // remove brackets, but leave text within them + $formatted = date_format($date, str_replace(["[", "]"], "", $format)); + } else { + // format with year using locale + $pattern = $patternGenerator->getBestPattern("yyyy MMM d"); + $dateFormatter = new IntlDateFormatter( + $locale, + IntlDateFormatter::MEDIUM, + IntlDateFormatter::NONE, + pattern: $pattern, + ); + $formatted = $dateFormatter->format($date); + } + } + // sanitize and return formatted date + return htmlspecialchars($formatted); +} /** - * Generate a cache key for a user's request + * Translate days of the week + * + * Takes a list of days (eg. ["Sun", "Mon", "Sat"]) and returns the short abbreviation of the days of the week in another locale + * e.g. ["Sun", "Mon", "Sat"] -> ["dim", "lun", "sam"] * - * Uses structured JSON format to prevent hash collisions between different - * user/options combinations that could produce the same concatenated string. + * @param array $days List of days to translate + * @param string $locale Locale code * - * @param string $user GitHub username - * @param array $options Additional options that affect the stats (mode, exclude_days, starting_year) - * @return string Cache key (filename-safe) + * @return array Translated days */ -function getCacheKey(string $user, array $options = []): string +function translateDays(array $days, string $locale): array { - ksort($options); - try { - $keyData = json_encode(["user" => $user, "options" => $options], JSON_THROW_ON_ERROR); - } catch (JsonException $e) { - // Fallback to simple concatenation if JSON encoding fails - error_log("Cache key JSON encoding failed: " . $e->getMessage()); - $keyData = $user . serialize($options); + if ($locale === "en") { + return $days; } - return hash("sha256", $keyData); + $patternGenerator = new IntlDatePatternGenerator($locale); + $pattern = $patternGenerator->getBestPattern("EEE"); + $dateFormatter = new IntlDateFormatter( + $locale, + IntlDateFormatter::NONE, + IntlDateFormatter::NONE, + pattern: $pattern, + ); + $translatedDays = []; + foreach ($days as $day) { + $translatedDays[] = $dateFormatter->format(new DateTime($day)); + } + return $translatedDays; } /** - * Get the cache file path for a given key + * Get the excluding days text * - * @param string $key Cache key - * @return string Full path to cache file + * @param array $excludedDays List of excluded days + * @param array $localeTranslations Translations for the locale + * @param string $localeCode Locale code + * @return string Excluding days text */ -function getCacheFilePath(string $key): string +function getExcludingDaysText($excludedDays, $localeTranslations, $localeCode) { - return CACHE_DIR . "/" . $key . ".json"; + $separator = $localeTranslations["comma_separator"] ?? ", "; + $daysCommaSeparated = implode($separator, translateDays($excludedDays, $localeCode)); + return str_replace("{days}", $daysCommaSeparated, $localeTranslations["Excluding {days}"]); } /** - * Ensure the cache directory exists + * Normalize a theme name * - * @return bool True if directory exists or was created + * @param string $theme Theme name + * @return string Normalized theme name */ -function ensureCacheDir(): bool +function normalizeThemeName(string $theme): string { - if (!is_dir(CACHE_DIR)) { - return mkdir(CACHE_DIR, 0755, true); - } - return true; + return strtolower(str_replace("_", "-", $theme)); } /** - * Get cached stats if available and not expired + * Check theme and color customization parameters to generate a theme mapping * - * @param string $user GitHub username - * @param array $options Additional options - * @param int $maxAge Maximum age in seconds (default: 24 hours) - * @return array|null Cached stats array or null if not cached/expired + * @param array $params Request parameters + * @return array The chosen theme or default */ -function getCachedStats(string $user, array $options = [], int $maxAge = CACHE_DURATION): ?array +function getRequestedTheme(array $params): array { - $key = getCacheKey($user, $options); - $filePath = getCacheFilePath($key); + /** + * @var array> $THEMES + * List of theme names mapped to labeled colors + */ + $THEMES = include "themes.php"; - if (!file_exists($filePath)) { - return null; - } + /** + * @var array $CSS_COLORS + * List of valid CSS colors + */ + $CSS_COLORS = include "colors.php"; + + // normalize theme name + $selectedTheme = normalizeThemeName($params["theme"] ?? "default"); + + // get theme colors, or default colors if theme not found + $theme = $THEMES[$selectedTheme] ?? $THEMES["default"]; - $mtime = filemtime($filePath); - if ($mtime === false) { - return null; + // personal theme customizations + $properties = array_keys($theme); + foreach ($properties as $prop) { + // check if each property was passed as a parameter + if (isset($params[$prop])) { + // ignore case + $param = strtolower($params[$prop]); + // check if color is valid hex color (3, 4, 6, or 8 hex digits) + if (preg_match("/^([a-f0-9]{3}|[a-f0-9]{4}|[a-f0-9]{6}|[a-f0-9]{8})$/", $param)) { + // set property + $theme[$prop] = "#" . $param; + } + // check if color is valid css color + elseif (in_array($param, $CSS_COLORS)) { + // set property + $theme[$prop] = $param; + } + // if the property is background gradient is allowed (angle,start_color,...,end_color) + elseif ($prop == "background" && preg_match("/^-?[0-9]+,[a-f0-9]{3,8}(,[a-f0-9]{3,8})+$/", $param)) { + // set property + $theme[$prop] = $param; + } + } } - $fileAge = time() - $mtime; - if ($fileAge > $maxAge) { - unlink($filePath); - return null; + // hide borders + if (isset($params["hide_border"]) && $params["hide_border"] == "true") { + $theme["border"] = "#0000"; // transparent } - $handle = fopen($filePath, "r"); - if ($handle === false) { - return null; + // set background + $gradient = ""; + $backgroundParts = explode(",", $theme["background"] ?? ""); + if (count($backgroundParts) >= 3) { + $theme["background"] = "url(#gradient)"; + $gradient = ""; + $backgroundColors = array_slice($backgroundParts, 1); + $colorCount = count($backgroundColors); + for ($index = 0; $index < $colorCount; $index++) { + $offset = ($index * 100) / ($colorCount - 1); + $gradient .= ""; + } + $gradient .= ""; } + $theme["backgroundGradient"] = $gradient; + + return $theme; +} - if (!flock($handle, LOCK_SH)) { - fclose($handle); - return null; +/** + * Wraps a string to a given number of characters + * + * Similar to `wordwrap()`, but uses regex and does not break with certain non-ascii characters + * + * @param string $string The input string + * @param int $width The number of characters at which the string will be wrapped + * @param string $break The line is broken using the optional `break` parameter + * @param bool $cut_long_words If the `cut_long_words` parameter is set to true, the string is + * the string is always wrapped at or before the specified width. So if you have + * a word that is larger than the given width, it is broken apart. + * When false the function does not split the word even if the width is smaller + * than the word width. + * @return string The given string wrapped at the specified length + */ +function utf8WordWrap(string $string, int $width = 75, string $break = "\n", bool $cut_long_words = false): string +{ + // match anything 1 to $width chars long followed by whitespace or EOS + $string = preg_replace("/(.{1,$width})(?:\s|$)/uS", "$1$break", $string); + // split words that are too long after being broken up + if ($cut_long_words) { + $string = preg_replace("/(\S{" . $width . "})(?=\S)/u", "$1$break", $string); } + // trim any trailing line breaks + return rtrim($string, $break); +} - $contents = stream_get_contents($handle); - flock($handle, LOCK_UN); - fclose($handle); +/** + * Get the length of a string with utf8 characters + * + * Similar to `strlen()`, but uses regex and does not break with certain non-ascii characters + * + * @param string $string The input string + * @return int The length of the string + */ +function utf8Strlen(string $string): int +{ + return preg_match_all("/./us", $string, $matches); +} - if ($contents === false || $contents === "") { - return null; +/** + * Split lines of text using elements if it contains a newline or exceeds a maximum number of characters + * + * @param string $text Text to split + * @param int $maxChars Maximum number of characters per line + * @param int $line1Offset Offset for the first line + * @return string Original text if one line, or split text with elements + */ +function splitLines(string $text, int $maxChars, int $line1Offset): string +{ + // if too many characters, insert \n before a " " or "-" if possible + if ($maxChars > 0 && utf8Strlen($text) > $maxChars && strpos($text, "\n") === false) { + // prefer splitting at " - " if possible + if (strpos($text, " - ") !== false) { + $text = str_replace(" - ", "\n- ", $text); + } + // otherwise, use word wrap to split at spaces + else { + $text = utf8WordWrap($text, $maxChars, "\n", true); + } } + $text = htmlspecialchars($text); + return preg_replace( + "/^(.*)\n(.*)/", + "$1$2", + $text, + ); +} - $data = json_decode($contents, true); - if (!is_array($data)) { - return null; +/** + * Normalize a locale code + * + * @param string $localeCode Locale code + * @return string Normalized locale code + */ +function normalizeLocaleCode(string $localeCode): string +{ + preg_match("/^([a-z]{2,3})(?:[_-]([a-z]{4}))?(?:[_-]([0-9]{3}|[a-z]{2}))?$/i", $localeCode, $matches); + if (empty($matches)) { + return "en"; } + $language = $matches[1]; + $script = $matches[2] ?? ""; + $region = $matches[3] ?? ""; + // convert language to lowercase + $language = strtolower($language); + // convert script to title case + $script = ucfirst(strtolower($script)); + // convert region to uppercase + $region = strtoupper($region); + // combine language, script, and region using underscores + return implode("_", array_filter([$language, $script, $region])); +} + +/** + * Get the translations for a locale code after normalizing it + * + * @param string $localeCode Locale code + * @return array Translations for the locale code + */ +function getTranslations(string $localeCode): array +{ + // normalize locale code + $localeCode = normalizeLocaleCode($localeCode); + // get the labels from the translations file + $translations = include "translations.php"; + // if the locale does not exist, try without the script and region + if (!isset($translations[$localeCode])) { + $localeCode = explode("_", $localeCode)[0]; + } + // get the translations for the locale or empty array if it does not exist + $localeTranslations = $translations[$localeCode] ?? []; + // if the locale returned is a string, it is an alias for another locale + if (is_string($localeTranslations)) { + // get the translations for the alias + $localeTranslations = $translations[$localeTranslations]; + } + // fill in missing translations with English + $localeTranslations += $translations["en"]; + // return the translations + return $localeTranslations; +} + +/** + * Get the card width from params taking into account minimum and default values + * + * @param array $params Request parameters + * @param int $numColumns Number of columns in the card + * @return int Card width + */ +function getCardWidth(array $params, int $numColumns = 3): int +{ + $defaultWidth = 495; + $minimumWidth = 100 * $numColumns; + return max($minimumWidth, intval($params["card_width"] ?? $defaultWidth)); +} - return $data; +/** + * Get the card height from params taking into account minimum and default values + * + * @param array $params Request parameters + * @return int Card width + */ +function getCardHeight(array $params): int +{ + $defaultHeight = 195; + $minimumHeight = 170; + return max($minimumHeight, intval($params["card_height"] ?? $defaultHeight)); } /** - * Save stats to cache + * Format number using locale and short number if requested * - * @param string $user GitHub username - * @param array $options Additional options - * @param array $stats Stats array to cache - * @return bool True if successfully cached + * @param float $num The number to format + * @param string $localeCode Locale code + * @param bool $useShortNumbers Whether to use short numbers + * @return string The formatted number */ -function setCachedStats(string $user, array $options, array $stats): bool +function formatNumber(float $num, string $localeCode, bool $useShortNumbers): string { - if (!ensureCacheDir()) { - error_log("Failed to create cache directory: " . CACHE_DIR); - return false; + $numFormatter = new NumberFormatter($localeCode, NumberFormatter::DECIMAL); + $suffix = ""; + if ($useShortNumbers) { + $units = ["", "K", "M", "B", "T"]; + for ($i = 0; $num >= 1000; $i++) { + $num /= 1000; + } + $suffix = $units[$i]; + $num = round($num, 1); + } + return $numFormatter->format($num) . $suffix; +} + +/** + * Generate SVG output for a stats array + * + * @param array $stats Streak stats + * @param array|null $params Request parameters + * @return string The generated SVG Streak Stats card + * + * @throws InvalidArgumentException If a locale does not exist + */ +function generateCard(array $stats, ?array $params = null): string +{ + $params = $params ?? $_REQUEST; + + // get requested theme + $theme = getRequestedTheme($params); + + // get requested locale, default to English + $localeCode = $params["locale"] ?? "en"; + $localeTranslations = getTranslations($localeCode); + + // whether the locale is right-to-left + $direction = $localeTranslations["rtl"] ?? false ? "rtl" : "ltr"; + + // get date format + // locale date formatter (used only if date_format is not specified) + $dateFormat = $params["date_format"] ?? ($localeTranslations["date_format"] ?? null); + + // read border_radius parameter, default to 4.5 if not set + $borderRadius = $params["border_radius"] ?? 4.5; + + $showTotalContributions = ($params["hide_total_contributions"] ?? "") !== "true"; + $showCurrentStreak = ($params["hide_current_streak"] ?? "") !== "true"; + $showLongestStreak = ($params["hide_longest_streak"] ?? "") !== "true"; + $numColumns = intval($showTotalContributions) + intval($showCurrentStreak) + intval($showLongestStreak); + + $cardWidth = getCardWidth($params, $numColumns); + $rectWidth = $cardWidth - 1; + $columnWidth = $numColumns > 0 ? $cardWidth / $numColumns : 0; + + $cardHeight = getCardHeight($params); + $rectHeight = $cardHeight - 1; + $heightOffset = ($cardHeight - 195) / 2; + + // X offsets for the bars between columns + $barOffsets = [-999, -999]; + for ($i = 0; $i < $numColumns - 1; $i++) { + $barOffsets[$i] = $columnWidth * ($i + 1); + } + // offsets for the text in each column + $columnOffsets = []; + for ($i = 0; $i < $numColumns; $i++) { + $columnOffsets[] = $columnWidth / 2 + $columnWidth * $i; + } + // reverse the column offsets if the locale is right-to-left + if ($direction === "rtl") { + $columnOffsets = array_reverse($columnOffsets); + } + + $nextColumnIndex = 0; + $totalContributionsOffset = $showTotalContributions ? $columnOffsets[$nextColumnIndex++] : -999; + $currentStreakOffset = $showCurrentStreak ? $columnOffsets[$nextColumnIndex++] : -999; + $longestStreakOffset = $showLongestStreak ? $columnOffsets[$nextColumnIndex++] : -999; + + // Y offsets for the bars + $barHeightOffsets = [28 + $heightOffset / 2, 170 + $heightOffset]; + // Y offsets for the numbers and dates + $longestStreakHeightOffset = $totalContributionsHeightOffset = [ + 48 + $heightOffset, + 84 + $heightOffset, + 114 + $heightOffset, + ]; + $currentStreakHeightOffset = [ + 48 + $heightOffset, + 108 + $heightOffset, + 145 + $heightOffset, + 71 + $heightOffset, + 19.5 + $heightOffset, + ]; + + $useShortNumbers = ($params["short_numbers"] ?? "") === "true"; + + // total contributions + $totalContributions = formatNumber($stats["totalContributions"], $localeCode, $useShortNumbers); + $firstContribution = formatDate($stats["firstContribution"], $dateFormat, $localeCode); + $totalContributionsRange = $firstContribution . " - " . $localeTranslations["Present"]; + + // current streak + $currentStreak = formatNumber($stats["currentStreak"]["length"], $localeCode, $useShortNumbers); + $currentStreakStart = formatDate($stats["currentStreak"]["start"], $dateFormat, $localeCode); + $currentStreakEnd = formatDate($stats["currentStreak"]["end"], $dateFormat, $localeCode); + $currentStreakRange = $currentStreakStart; + if ($currentStreakStart != $currentStreakEnd) { + $currentStreakRange .= " - " . $currentStreakEnd; } - $key = getCacheKey($user, $options); - $filePath = getCacheFilePath($key); + // longest streak + $longestStreak = formatNumber($stats["longestStreak"]["length"], $localeCode, $useShortNumbers); + $longestStreakStart = formatDate($stats["longestStreak"]["start"], $dateFormat, $localeCode); + $longestStreakEnd = formatDate($stats["longestStreak"]["end"], $dateFormat, $localeCode); + $longestStreakRange = $longestStreakStart; + if ($longestStreakStart != $longestStreakEnd) { + $longestStreakRange .= " - " . $longestStreakEnd; + } - $data = json_encode($stats); - if ($data === false) { - error_log("Failed to encode stats to JSON for user: " . $user); - return false; + // if the translations contain over max characters or a newline, split the text into two tspan elements + $maxCharsPerLineLabels = $numColumns > 0 ? intval(floor($cardWidth / $numColumns / 7.5)) : 0; + $totalContributionsText = splitLines($localeTranslations["Total Contributions"], $maxCharsPerLineLabels, -9); + if ($stats["mode"] === "weekly") { + $currentStreakText = splitLines($localeTranslations["Week Streak"], $maxCharsPerLineLabels, -9); + $longestStreakText = splitLines($localeTranslations["Longest Week Streak"], $maxCharsPerLineLabels, -9); + } else { + $currentStreakText = splitLines($localeTranslations["Current Streak"], $maxCharsPerLineLabels, -9); + $longestStreakText = splitLines($localeTranslations["Longest Streak"], $maxCharsPerLineLabels, -9); } - $result = file_put_contents($filePath, $data, LOCK_EX); - if ($result === false) { - error_log("Failed to write cache file: " . $filePath); - return false; + // if the ranges contain over max characters, split the text into two tspan elements + $maxCharsPerLineDates = $numColumns > 0 ? intval(floor($cardWidth / $numColumns / 6)) : 0; + $totalContributionsRange = splitLines($totalContributionsRange, $maxCharsPerLineDates, 0); + $currentStreakRange = splitLines($currentStreakRange, $maxCharsPerLineDates, 0); + $longestStreakRange = splitLines($longestStreakRange, $maxCharsPerLineDates, 0); + + // if days are excluded, add a note to the corner + $excludedDays = ""; + if (!empty($stats["excludedDays"])) { + $offset = $direction === "rtl" ? $cardWidth - 5 : 5; + $excludingDaysText = getExcludingDaysText($stats["excludedDays"], $localeTranslations, $localeCode); + $excludedDays = " + + + + * {$excludingDaysText} + + + "; } - return true; + return " + + + + + + + + + + {$theme["backgroundGradient"]} + + + + + + + + + + + + + + {$totalContributions} + + + + + + + {$totalContributionsText} + + + + + + + {$totalContributionsRange} + + + + + + + + {$currentStreakText} + + + + + + + {$currentStreakRange} + + + + + + + + + + + + + + + + + {$currentStreak} + + + + + + + + + {$longestStreak} + + + + + + + {$longestStreakText} + + + + + + + {$longestStreakRange} + + + + {$excludedDays} + + +"; } /** - * Clear all expired cache files + * Generate SVG displaying an error message * - * @param int $maxAge Maximum age in seconds - * @return int Number of files deleted + * @param string $message The error message to display + * @param array|null $params Request parameters + * @return string The generated SVG error card */ -function clearExpiredCache(int $maxAge = CACHE_DURATION): int +function generateErrorCard(string $message, ?array $params = null): string { - if (!is_dir(CACHE_DIR)) { - return 0; - } + $params = $params ?? $_REQUEST; - $deleted = 0; - $files = glob(CACHE_DIR . "/*.json"); + // get requested theme, use $_REQUEST if no params array specified + $theme = getRequestedTheme($params); - if ($files === false) { - return 0; - } + // read border_radius parameter, default to 4.5 if not set + $borderRadius = $params["border_radius"] ?? 4.5; - foreach ($files as $file) { - $mtime = filemtime($file); - if ($mtime === false) { - continue; - } - $fileAge = time() - $mtime; - if ($fileAge > $maxAge) { - if (unlink($file)) { - $deleted++; + // read card_width parameter + $cardWidth = getCardWidth($params); + $rectWidth = $cardWidth - 1; + $centerOffset = $cardWidth / 2; + + // read card_height parameter + $cardHeight = getCardHeight($params); + $rectHeight = $cardHeight - 1; + $heightOffset = ($cardHeight - 195) / 2; + $errorLabelOffset = $cardHeight / 2 + 10.5; + + return " + + + + + + {$theme["backgroundGradient"]} + + + + + + + + + + {$message} + + + + + + + + + + + + + + + + + + + + +"; +} + +/** + * Remove animations from SVG + * + * @param string $svg The SVG for the card as a string + * @return string The SVG without animations + */ +function removeAnimations(string $svg): string +{ + $svg = preg_replace("/(