diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 0794a2a1..55cdc468 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -179,6 +179,14 @@ jobs: print(f"supporters: {len(supporters)}") PY + # Publish an OpenCADStudio-branded star-history chart for the README. + # The timestamp media type returns when each current stargazer starred + # the repository; the script follows every API page and emits both themes. + - name: Generate star history charts + env: + GITHUB_TOKEN: ${{ github.token }} + run: python3 scripts/generate-star-history.py --output-dir dist + # Browsers cannot reliably fetch YouTube playlist/oEmbed responses because # of CORS. Build a same-origin listing and thumbnail directory for the # Start page. Keep the checked-in snapshot if YouTube is temporarily diff --git a/README.md b/README.md index 77efd676..ae71fb7c 100644 --- a/README.md +++ b/README.md @@ -158,11 +158,11 @@ cargo build --release --bin OpenCADStudio ``` ## Star History - + - - - Star History Chart + + + OpenCADStudio GitHub star history diff --git a/scripts/generate-star-history.py b/scripts/generate-star-history.py new file mode 100644 index 00000000..e4f523de --- /dev/null +++ b/scripts/generate-star-history.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""Generate OpenCADStudio-branded GitHub star-history SVG charts.""" + +from __future__ import annotations + +import argparse +import json +import math +import os +import re +import urllib.request +from datetime import datetime, timedelta, timezone +from pathlib import Path +from xml.sax.saxutils import escape + + +API_VERSION = "2026-03-10" +WIDTH = 960 +HEIGHT = 380 +PLOT_LEFT = 72 +PLOT_RIGHT = 928 +PLOT_TOP = 82 +PLOT_BOTTOM = 326 + + +THEMES = { + "light": { + "background": "#ffffff", + "border": "#dbe3ef", + "grid": "#dbe3ef", + "text": "#0f172a", + "muted": "#64748b", + "line": "#0284c7", + "area": "#38bdf8", + "point": "#0369a1", + }, + "dark": { + "background": "#0b1220", + "border": "#263449", + "grid": "#263449", + "text": "#e5edf7", + "muted": "#94a3b8", + "line": "#38bdf8", + "area": "#0ea5e9", + "point": "#7dd3fc", + }, +} + + +def next_link(header: str | None) -> str | None: + if not header: + return None + for item in header.split(","): + match = re.match(r'\s*<([^>]+)>;\s*rel="([^"]+)"', item) + if match and match.group(2) == "next": + return match.group(1) + return None + + +def fetch_star_dates(repository: str, token: str | None) -> list[datetime]: + url = f"https://api.github.com/repos/{repository}/stargazers?per_page=100" + headers = { + "Accept": "application/vnd.github.star+json", + "User-Agent": "OpenCADStudio-star-history", + "X-GitHub-Api-Version": API_VERSION, + } + if token: + headers["Authorization"] = f"Bearer {token}" + + dates: list[datetime] = [] + for _ in range(100): + request = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(request, timeout=30) as response: + payload = json.load(response) + link = response.headers.get("Link") + for entry in payload: + value = entry.get("starred_at") + if value: + dates.append(datetime.fromisoformat(value.replace("Z", "+00:00"))) + url = next_link(link) + if not url: + break + else: + raise RuntimeError("stargazer pagination exceeded 100 pages") + + if not dates: + raise RuntimeError("GitHub returned no dated stargazers") + dates.sort() + return dates + + +def nice_axis(stars: int) -> tuple[int, int]: + rough_step = max(stars / 5, 1) + magnitude = 10 ** math.floor(math.log10(rough_step)) + step = magnitude + for multiplier in (1, 2, 5, 10): + candidate = multiplier * magnitude + if candidate >= rough_step: + step = candidate + break + top = math.ceil(stars / step) * step + if top <= stars: + top += step + return int(top), int(step) + + +def render_svg(repository: str, dates: list[datetime], theme: str) -> str: + colors = THEMES[theme] + now = datetime.now(timezone.utc) + start = dates[0] - timedelta(days=2) + end = max(now, dates[-1] + timedelta(days=2)) + duration = max((end - start).total_seconds(), 1) + plot_width = PLOT_RIGHT - PLOT_LEFT + plot_height = PLOT_BOTTOM - PLOT_TOP + y_top, y_step = nice_axis(len(dates)) + + def x_position(moment: datetime) -> float: + ratio = (moment - start).total_seconds() / duration + return PLOT_LEFT + ratio * plot_width + + def y_position(stars: int) -> float: + return PLOT_BOTTOM - (stars / y_top) * plot_height + + line_parts = [f"M {PLOT_LEFT:.1f} {PLOT_BOTTOM:.1f}"] + for count, moment in enumerate(dates, 1): + line_parts.append(f"H {x_position(moment):.1f} V {y_position(count):.1f}") + line_path = " ".join(line_parts) + area_path = f"{line_path} V {PLOT_BOTTOM:.1f} H {PLOT_LEFT:.1f} Z" + + grid = [] + for value in range(0, y_top + 1, y_step): + y = y_position(value) + grid.append( + f'' + ) + grid.append( + f'{value}' + ) + + x_labels = [] + for index in range(5): + moment = start + (end - start) * (index / 4) + x = x_position(moment) + if index in (0, 4): + label = moment.strftime("%b %Y") + else: + label = moment.strftime("%b") + x_labels.append( + f'' + ) + x_labels.append( + f'{escape(label)}' + ) + + title = f"{repository.split('/')[-1]} Star History" + subtitle = f"{len(dates)} GitHub stars ยท Updated {now:%d %b %Y}" + last_x = x_position(dates[-1]) + last_y = y_position(len(dates)) + label_y = max(PLOT_TOP + 16, last_y - 14) + + return f''' + {escape(title)} + {escape(subtitle)} + + + + + + + + + {escape(title)} + {escape(subtitle)} + {''.join(grid)} + {''.join(x_labels)} + + + + {len(dates)} stars + + +''' + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "--repository", + default=os.environ.get("GITHUB_REPOSITORY", "HakanSeven12/OpenCADStudio"), + ) + parser.add_argument("--output-dir", type=Path, default=Path("dist")) + args = parser.parse_args() + + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + dates = fetch_star_dates(args.repository, token) + args.output_dir.mkdir(parents=True, exist_ok=True) + for theme in THEMES: + output = args.output_dir / f"star-history-{theme}.svg" + output.write_text(render_svg(args.repository, dates, theme), encoding="utf-8") + print(f"star history: {len(dates)} stars") + + +if __name__ == "__main__": + main()