mirror of
https://github.com/h3pdesign/Neon-Vision-Editor
synced 2026-04-21 13:27:16 +00:00
288 lines
10 KiB
Python
Executable file
288 lines
10 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Refresh README download badges/text and regenerate the release trend SVG.
|
|
|
|
Usage:
|
|
scripts/update_download_metrics.py
|
|
scripts/update_download_metrics.py --check
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import datetime as dt
|
|
import json
|
|
import math
|
|
import pathlib
|
|
import re
|
|
import sys
|
|
import urllib.request
|
|
from dataclasses import dataclass
|
|
|
|
|
|
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
|
README = ROOT / "README.md"
|
|
SVG_PATH = ROOT / "docs" / "images" / "release-download-trend.svg"
|
|
OWNER = "h3pdesign"
|
|
REPO = "Neon-Vision-Editor"
|
|
API_URL = f"https://api.github.com/repos/{OWNER}/{REPO}/releases?per_page=100"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ReleasePoint:
|
|
tag: str
|
|
downloads: int
|
|
published_at: dt.datetime
|
|
|
|
|
|
def fetch_releases() -> list[ReleasePoint]:
|
|
req = urllib.request.Request(
|
|
API_URL,
|
|
headers={
|
|
"Accept": "application/vnd.github+json",
|
|
"User-Agent": "neon-vision-editor-metrics-updater",
|
|
},
|
|
)
|
|
with urllib.request.urlopen(req, timeout=20) as resp:
|
|
payload = json.loads(resp.read().decode("utf-8"))
|
|
|
|
points: list[ReleasePoint] = []
|
|
for release in payload:
|
|
if release.get("draft"):
|
|
continue
|
|
tag = str(release.get("tag_name", "")).strip()
|
|
published_raw = release.get("published_at")
|
|
if not tag or not published_raw:
|
|
continue
|
|
try:
|
|
published = dt.datetime.fromisoformat(published_raw.replace("Z", "+00:00"))
|
|
except ValueError:
|
|
continue
|
|
assets = release.get("assets", [])
|
|
downloads = 0
|
|
for asset in assets:
|
|
value = asset.get("download_count", 0)
|
|
if isinstance(value, int):
|
|
downloads += value
|
|
points.append(ReleasePoint(tag=tag, downloads=downloads, published_at=published))
|
|
|
|
if not points:
|
|
raise RuntimeError("No stable releases found from GitHub API.")
|
|
return points
|
|
|
|
|
|
def y_top(max_value: int, ticks: int = 4) -> int:
|
|
if max_value <= 0:
|
|
return ticks
|
|
rough = max_value / ticks
|
|
magnitude = 10 ** max(0, int(math.log10(max(1, rough))))
|
|
step = max(1, int(math.ceil(rough / magnitude) * magnitude))
|
|
return step * ticks
|
|
|
|
|
|
def generate_svg(points: list[ReleasePoint], snapshot_date: str) -> str:
|
|
width = 1200
|
|
height = 460
|
|
left = 130
|
|
right = 1070
|
|
top = 84
|
|
bottom = 340
|
|
|
|
max_downloads = max(p.downloads for p in points)
|
|
top_value = y_top(max_downloads, ticks=4)
|
|
if top_value == 0:
|
|
top_value = 4
|
|
|
|
span_x = right - left
|
|
span_y = bottom - top
|
|
step_x = span_x / max(1, len(points) - 1)
|
|
|
|
coords: list[tuple[float, float]] = []
|
|
for idx, point in enumerate(points):
|
|
x = left + (idx * step_x)
|
|
y = bottom - (point.downloads / top_value) * span_y
|
|
coords.append((x, y))
|
|
|
|
grid_lines: list[str] = []
|
|
y_labels: list[str] = []
|
|
for i in range(5):
|
|
value = int((top_value / 4) * i)
|
|
y = bottom - (value / top_value) * span_y if top_value else bottom
|
|
color = "#37566F" if i in (0, 4) else "#2B4255"
|
|
grid_lines.append(
|
|
f' <line x1="{left}" y1="{y:.1f}" x2="{right}" y2="{y:.1f}" stroke="{color}" stroke-width="1"/>'
|
|
)
|
|
y_labels.append(
|
|
f' <text x="{78 if value >= 10 else 90}" y="{y + 6:.1f}" fill="#9CC3E6" font-size="14" '
|
|
'font-family="SF Pro Text, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif">'
|
|
f"{value}</text>"
|
|
)
|
|
|
|
point_nodes: list[str] = []
|
|
x_labels: list[str] = []
|
|
value_labels: list[str] = []
|
|
colors = ["#00C2FF", "#00D7D2", "#1AE7C0", "#34EDAA", "#47F193", "#5AF57D", "#72FA64", "#8CFF5A"]
|
|
for idx, ((x, y), point) in enumerate(zip(coords, points)):
|
|
fill = colors[idx % len(colors)]
|
|
point_nodes.append(
|
|
f' <circle cx="{x:.1f}" cy="{y:.1f}" r="7" fill="{fill}" stroke="#D7F7FF" stroke-width="2"/>'
|
|
)
|
|
x_labels.append(
|
|
f' <text x="{x - 14:.1f}" y="372" fill="#D7E8F8" font-size="13" '
|
|
'font-family="SF Pro Text, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif">'
|
|
f"{point.tag}</text>"
|
|
)
|
|
label_y = y - 14 if y > top + 26 else y + 22
|
|
value_labels.append(
|
|
f' <text x="{x - 10:.1f}" y="{label_y:.1f}" fill="#D7F7FF" font-size="15" '
|
|
'font-family="SF Pro Text, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" '
|
|
f'font-weight="600">{point.downloads}</text>'
|
|
)
|
|
|
|
polyline_points = " ".join(f"{x:.1f},{y:.1f}" for x, y in coords)
|
|
|
|
return """<svg width="1200" height="460" viewBox="0 0 1200 460" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-labelledby="title desc">
|
|
<title id="title">GitHub Release Downloads Trend</title>
|
|
<desc id="desc">Line chart of release downloads with highlighted points.</desc>
|
|
<defs>
|
|
<linearGradient id="bg" x1="0" y1="0" x2="1200" y2="460" gradientUnits="userSpaceOnUse">
|
|
<stop stop-color="#061423"/>
|
|
<stop offset="1" stop-color="#041C16"/>
|
|
</linearGradient>
|
|
<linearGradient id="line" x1="130" y1="86" x2="1070" y2="340" gradientUnits="userSpaceOnUse">
|
|
<stop stop-color="#00C2FF"/>
|
|
<stop offset="0.55" stop-color="#00E2B8"/>
|
|
<stop offset="1" stop-color="#8CFF5A"/>
|
|
</linearGradient>
|
|
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
|
<feGaussianBlur stdDeviation="4" result="blur"/>
|
|
<feMerge>
|
|
<feMergeNode in="blur"/>
|
|
<feMergeNode in="SourceGraphic"/>
|
|
</feMerge>
|
|
</filter>
|
|
</defs>
|
|
|
|
<rect width="1200" height="460" rx="18" fill="url(#bg)"/>
|
|
<rect x="24" y="24" width="1152" height="412" rx="14" stroke="#2A4762" stroke-width="1.5"/>
|
|
|
|
<text x="70" y="68" fill="#E6F3FF" font-size="30" font-family="SF Pro Display, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif" font-weight="700">GitHub Release Downloads</text>
|
|
<text x="70" y="96" fill="#9CC3E6" font-size="18" font-family="SF Pro Text, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif">Snapshot: SNAPSHOT_DATE</text>
|
|
|
|
GRID_LINES
|
|
Y_LABELS
|
|
|
|
<polyline
|
|
points="POLYLINE_POINTS"
|
|
fill="none"
|
|
stroke="url(#line)"
|
|
stroke-width="5"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
filter="url(#glow)"
|
|
/>
|
|
|
|
POINT_NODES
|
|
X_LABELS
|
|
VALUE_LABELS
|
|
|
|
<rect x="884" y="42" width="248" height="54" rx="10" fill="#0A2D3B" stroke="#276B84" stroke-width="1.2"/>
|
|
<circle cx="908" cy="69" r="6" fill="#00D6CB"/>
|
|
<text x="924" y="74" fill="#D7F7FF" font-size="15" font-family="SF Pro Text, -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif">Trend line with highlighted points</text>
|
|
</svg>
|
|
""".replace("SNAPSHOT_DATE", snapshot_date).replace(
|
|
"GRID_LINES", "\n".join(grid_lines)
|
|
).replace(
|
|
"Y_LABELS", "\n".join(y_labels)
|
|
).replace(
|
|
"POLYLINE_POINTS", polyline_points
|
|
).replace(
|
|
"POINT_NODES", "\n".join(point_nodes)
|
|
).replace(
|
|
"X_LABELS", "\n".join(x_labels)
|
|
).replace(
|
|
"VALUE_LABELS", "\n".join(value_labels)
|
|
)
|
|
|
|
|
|
def update_readme(content: str, latest_tag: str, total_downloads: int, today: str) -> str:
|
|
release_badge_line = (
|
|
' <img alt="{tag} Downloads" '
|
|
'src="https://img.shields.io/github/downloads/h3pdesign/Neon-Vision-Editor/{tag}/total'
|
|
'?style=for-the-badge&label={tag}&color=22C55E">'
|
|
).format(tag=latest_tag)
|
|
content = re.sub(
|
|
r'(?m)^ <img alt="v[^"]+ Downloads" src="https://img\.shields\.io/github/downloads/h3pdesign/Neon-Vision-Editor/v[^/]+/total\?style=for-the-badge&label=v[^"&]+&color=22C55E">$',
|
|
release_badge_line,
|
|
content,
|
|
)
|
|
content = re.sub(
|
|
r'<p align="center">Snapshot total downloads: <strong>\d+</strong> across releases\.</p>',
|
|
f'<p align="center">Snapshot total downloads: <strong>{total_downloads}</strong> across releases.</p>',
|
|
content,
|
|
)
|
|
content = re.sub(
|
|
r"(?m)^> Latest release: \*\*.*\*\*$",
|
|
f"> Latest release: **{latest_tag}**",
|
|
content,
|
|
)
|
|
content = re.sub(
|
|
r"(?m)^- Latest release: \*\*.*\*\*$",
|
|
f"- Latest release: **{latest_tag}**",
|
|
content,
|
|
)
|
|
content = re.sub(
|
|
r"(?m)^> Last updated \(README\): \*\*.*\*\* for release line \*\*.*\*\*$",
|
|
f"> Last updated (README): **{today}** for release line **{latest_tag}**",
|
|
content,
|
|
)
|
|
if f"label={latest_tag}" not in content:
|
|
raise RuntimeError("README download badge replacement failed.")
|
|
return content
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="Refresh README download metrics and chart.")
|
|
parser.add_argument("--check", action="store_true", help="Fail if files are not up-to-date.")
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
releases = fetch_releases()
|
|
releases_desc = sorted(releases, key=lambda r: r.published_at, reverse=True)
|
|
latest = releases_desc[0]
|
|
total_downloads = sum(r.downloads for r in releases_desc)
|
|
|
|
trend_points = sorted(releases_desc[:8], key=lambda r: r.published_at)
|
|
snapshot_date = dt.date.today().isoformat()
|
|
svg = generate_svg(trend_points, snapshot_date)
|
|
|
|
readme_before = README.read_text(encoding="utf-8")
|
|
readme_after = update_readme(
|
|
readme_before,
|
|
latest_tag=latest.tag,
|
|
total_downloads=total_downloads,
|
|
today=snapshot_date,
|
|
)
|
|
|
|
svg_before = SVG_PATH.read_text(encoding="utf-8") if SVG_PATH.exists() else ""
|
|
|
|
changed = (readme_before != readme_after) or (svg_before != svg)
|
|
if args.check:
|
|
if changed:
|
|
print("Download metrics are outdated. Run scripts/update_download_metrics.py", file=sys.stderr)
|
|
return 1
|
|
return 0
|
|
|
|
README.write_text(readme_after, encoding="utf-8")
|
|
SVG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
SVG_PATH.write_text(svg, encoding="utf-8")
|
|
print(
|
|
f"Updated metrics: latest={latest.tag} ({latest.downloads}) total={total_downloads} "
|
|
f"points={len(trend_points)}"
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|