{html.escape(counts(day, False))}
') lines.append('- ')
for kind, item in items[:WEB_ITEMS_PER_DAY]:
lines.append(f'
- {html.escape(kind)}{inline_html(item)} ') lines.append("
#!/usr/bin/env python3
"""Puts CHANGELOG.md where people look, and draws the commit calendar.
python3 Scripts/sync_changelog.py
Rewrites, from CHANGELOG.md and `git log`:
- the "recent updates" block in README.md and README.zh-CN.md, between
and ;
- the same block, as HTML, in web/index.html;
- Assets/readme/activity.svg and activity.zh.svg, 26 weeks of commits.
Run it after every CHANGELOG edit. Standard library only, so it runs on a
stock Mac and in CI. Adapted from QuotaBar's script of the same name.
"""
import argparse
import datetime
import html
import math
import pathlib
import re
import subprocess
from collections import Counter
ROOT = pathlib.Path(__file__).resolve().parent.parent
RECENT_DAYS = 3
WEEKS = 26
KIND_EN = {"新增": "Added", "样式": "Style", "修复": "Fixed"}
WEB_ITEMS_PER_DAY = 8
CHANGELOG_URL = "https://github.com/gentpan/OpenStats/blob/main/CHANGELOG.md"
# ── CHANGELOG.md ──────────────────────────────────────────────────────────
def parse(text):
"""Releases, newest first: each with its days, each day with its groups.
"## 未发布" holds "### YYYY-MM-DD" days; "## 0.1.0 · 2026-09-20" holds its
groups directly, which count as one day on the release date.
"""
releases, release, day, group = [], None, None, None
for line in text.splitlines():
heading = re.match(r"^## (.+)$", line)
dated = re.match(r"^### (\d{4}-\d{2}-\d{2})\s*$", line)
kind = re.match(r"^#### (.+)$", line)
if heading:
title = heading.group(1).strip()
version = re.match(r"^v?([\d.]+)\s*·\s*(\d{4}-\d{2}-\d{2})$", title)
release = {
"version": version.group(1) if version else None,
"date": version.group(2) if version else None,
"days": [],
}
releases.append(release)
day = {"date": release["date"], "groups": []} if version else None
if day:
release["days"].append(day)
group = None
elif dated and release is not None:
# A release that kept its dated days: drop the empty day that
# stood in for the release date.
if release["days"] and not release["days"][-1]["groups"]:
release["days"].pop()
day = {"date": dated.group(1), "groups": []}
release["days"].append(day)
group = None
elif kind and day is not None:
group = {"kind": kind.group(1).strip(), "items": []}
day["groups"].append(group)
elif line.startswith("- ") and group is not None:
group["items"].append(line[2:].strip())
elif line.startswith(" ") and line.strip() and group is not None and group["items"]:
group["items"][-1] += line.strip()
for release in releases:
release["days"] = [d for d in release["days"] if any(g["items"] for g in d["groups"])]
return [r for r in releases if r["days"]]
def day_entries(releases):
"""Every day, newest first, with the release it belongs to."""
days = [(release, day) for release in releases for day in release["days"]]
days.sort(key=lambda pair: pair[1]["date"] or "", reverse=True)
return days
def item_count(day):
return sum(len(g["items"]) for g in day["groups"])
def counts(day, en):
parts = []
for g in day["groups"]:
n = len(g["items"])
if en:
parts.append(f"{n} {KIND_EN.get(g['kind'], g['kind']).lower()}")
else:
parts.append(f"{g['kind']} {n}")
return " · ".join(parts)
def latest_release(releases):
return next((r for r in releases if r["version"]), None)
def unreleased(releases):
return next((r for r in releases if not r["version"]), None)
# ── README ────────────────────────────────────────────────────────────────
def readme_block(releases, en):
latest = latest_release(releases)
pending = unreleased(releases)
pending_count = sum(item_count(d) for d in pending["days"]) if pending else 0
lines = [""]
lines.append(""
if en else "")
head = []
if latest:
head.append(f"Latest release **{latest['version']}** ({latest['date']})" if en
else f"最新版本 **{latest['version']}**({latest['date']})")
elif en:
head.append("Not released yet")
else:
head.append("尚未发布正式版本")
if pending_count:
head.append(f"**{pending_count}** changes in development" if en
else f"开发中 **{pending_count}** 项改动尚未发布")
head.append("[full changelog](CHANGELOG.md) (kept in Chinese)" if en else "[完整更新日志](CHANGELOG.md)")
lines += ["", " · ".join(head), ""]
for index, (release, day) in enumerate(day_entries(releases)[:RECENT_DAYS]):
label = release["version"] or ("Unreleased" if en else "未发布")
opened = " open" if index == 0 else ""
lines.append(f"{day['date']} · {label} · {counts(day, en)}
")
lines.append("")
for g in day["groups"]:
lines.append(f"**{KIND_EN.get(g['kind'], g['kind']) if en else g['kind']}**")
lines.append("")
lines += [f"- {item}" for item in g["items"]]
lines.append("")
lines.append("\1", out)
out = re.sub(r"\*\*([^*]+)\*\*", r"\1", out)
out = re.sub(r"\[([^\]]+)\]\((https?://[^)\s]+)\)", r'\1', out)
return out
def web_block(releases, indent=" "):
lines = ["",
"",
'
{html.escape(counts(day, False))}
') lines.append('