From 41c686ebb38175d1d05f56c9b68e55e4a3fecb9d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 15:34:05 +0000 Subject: [PATCH 1/6] feat: generate changelog translator credits from Crowdin Adds tools/translators/translators.sh, which pulls a Crowdin "Top Members" report for a release window (between two tags/dates) and prints the changelog "## Translations" block grouped by language. Crowdin contributors are joined against docs/changelog/translators.json, a Crowdin-username/id -> npub mapping kept alongside the changelogs. Contributors with no mapping are listed under UNMAPPED so they can be credited by hand and backfilled. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FWQWVdWLAwBUBX2gJ55y6b --- docs/changelog/translators.json | 15 ++++ tools/translators/README.md | 47 +++++++++++ tools/translators/translators.sh | 130 +++++++++++++++++++++++++++++++ 3 files changed, 192 insertions(+) create mode 100644 docs/changelog/translators.json create mode 100644 tools/translators/README.md create mode 100755 tools/translators/translators.sh diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json new file mode 100644 index 0000000000..9f47b77a7e --- /dev/null +++ b/docs/changelog/translators.json @@ -0,0 +1,15 @@ +{ + "_comment": [ + "Crowdin username (or numeric user id) -> Nostr npub.", + "Used by tools/translators/translators.sh to turn a Crowdin 'Top Members'", + "report for a release window into the changelog '## Translations' section.", + "Keys are matched case-insensitively against the Crowdin username; a numeric", + "key is matched against the Crowdin user id. Add a row whenever a new", + "translator gives you their npub. Contributors with no entry here are listed", + "by the script under 'UNMAPPED' so you can credit them by hand and backfill.", + "Example shape (replace with real Crowdin usernames):", + " \"vitorpamplona\": \"npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z\"" + ], + "mappings": { + } +} diff --git a/tools/translators/README.md b/tools/translators/README.md new file mode 100644 index 0000000000..fca6683f99 --- /dev/null +++ b/tools/translators/README.md @@ -0,0 +1,47 @@ +# Translator credits for the changelog + +Generates the `## Translations` block for a release by asking Crowdin **who +translated between two releases** and joining them against the npub mapping kept +in the changelog folder. + +## Pieces + +- **`docs/changelog/translators.json`** — the Crowdin-user → npub mapping (lives + next to the changelogs). Keyed by Crowdin username (case-insensitive) or numeric + user id. Add a row whenever a translator gives you their npub. +- **`tools/translators/translators.sh`** — pulls a Crowdin *Top Members* report + for a date window, joins it against the mapping, and prints the credit block + grouped by language. Anyone Crowdin reports who isn't in the mapping is listed + under `UNMAPPED` so you can credit them by hand and backfill the JSON. + +## Usage + +```bash +export CROWDIN_PROJECT_ID=... # same env vars crowdin.yml already uses +export CROWDIN_PERSONAL_TOKEN=... # token needs the "reports" scope + +# Between the previous tag and now: +tools/translators/translators.sh --from v1.12.00 + +# Between two tags: +tools/translators/translators.sh --from v1.11.00 --to v1.12.00 + +# Discover Crowdin usernames to add to translators.json: +tools/translators/translators.sh --from v1.12.00 --raw +``` + +`--from` / `--to` accept either a `YYYY-MM-DD` date or a git tag/ref (resolved to +its commit date). `--to` defaults to now. + +Requires `bash`, `curl`, `jq`, `git`. + +## How the window maps to "between two versions" + +A release is a git tag, so the contribution window is the commit date of the +previous tag → the commit date of the new tag. The Crowdin Top Members report +takes that `dateFrom`/`dateTo` and returns every member who translated/approved +in it, per language. + +> The script talks to the live `api.crowdin.com` REST API. The JSON field paths +> for the downloaded report follow Crowdin's `top-members` schema; if Crowdin +> changes it, adjust the `jq` block at the bottom of `translators.sh`. diff --git a/tools/translators/translators.sh b/tools/translators/translators.sh new file mode 100755 index 0000000000..e936d1e545 --- /dev/null +++ b/tools/translators/translators.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env bash +# +# Build the changelog "## Translations" section for a release window. +# +# Pulls a Crowdin "Top Members" report for a date range (the gap between two +# releases), joins each Crowdin contributor against docs/changelog/translators.json +# (Crowdin username/id -> npub), and prints a ready-to-paste credit block grouped +# by language. Contributors with no npub mapping are listed under UNMAPPED so you +# can credit them by hand and backfill translators.json. +# +# Usage: +# tools/translators/translators.sh --from --to +# +# --from / --to A date (YYYY-MM-DD) or a git tag/ref. Tags are resolved to +# their commit date. --to defaults to now if omitted. +# --mapping PATH Override mapping file (default docs/changelog/translators.json). +# --raw Also dump the raw per-member report rows (for debugging / +# discovering Crowdin usernames to add to the mapping). +# +# Environment (same names crowdin.yml already uses): +# CROWDIN_PROJECT_ID Crowdin numeric project id. +# CROWDIN_PERSONAL_TOKEN Crowdin personal access token (needs report scope). +# +# Requires: bash, curl, jq, git. +# +# NOTE: This talks to the live Crowdin REST API (api.crowdin.com). The JSON field +# paths for the downloaded "top-members" report are documented at +# https://developer.crowdin.com/api/v2/#operation/api.projects.reports.post and +# can be adjusted in the jq block below if Crowdin changes the schema. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +API="https://api.crowdin.com/api/v2" +MAPPING="$REPO_ROOT/docs/changelog/translators.json" +FROM="" +TO="" +RAW=0 + +die() { echo "error: $*" >&2; exit 1; } + +while [ $# -gt 0 ]; do + case "$1" in + --from) FROM="${2:?--from needs a value}"; shift 2 ;; + --to) TO="${2:?--to needs a value}"; shift 2 ;; + --mapping) MAPPING="${2:?--mapping needs a value}"; shift 2 ;; + --raw) RAW=1; shift ;; + -h|--help) sed -n '2,30p' "$0"; exit 0 ;; + *) die "unknown argument: $1" ;; + esac +done + +command -v jq >/dev/null || die "jq not found" +command -v curl >/dev/null || die "curl not found" +[ -n "$FROM" ] || die "--from is required" +[ -n "${CROWDIN_PROJECT_ID:-}" ] || die "CROWDIN_PROJECT_ID is not set" +[ -n "${CROWDIN_PERSONAL_TOKEN:-}" ] || die "CROWDIN_PERSONAL_TOKEN is not set" +[ -f "$MAPPING" ] || die "mapping file not found: $MAPPING" + +# Resolve a date (YYYY-MM-DD) or a git ref to an ISO-8601 timestamp. +resolve_ts() { + local v="$1" + if [[ "$v" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then + echo "${v}T00:00:00+00:00" + elif git -C "$REPO_ROOT" rev-parse -q --verify "$v" >/dev/null 2>&1; then + git -C "$REPO_ROOT" log -1 --format=%cI "$v" + else + die "--from/--to value '$v' is neither a YYYY-MM-DD date nor a known git ref" + fi +} + +DATE_FROM="$(resolve_ts "$FROM")" +DATE_TO="$( [ -n "$TO" ] && resolve_ts "$TO" || date -u +%Y-%m-%dT%H:%M:%S+00:00 )" +echo "# Crowdin contributors from $DATE_FROM to $DATE_TO" >&2 + +auth=(-H "Authorization: Bearer ${CROWDIN_PERSONAL_TOKEN}" -H "Content-Type: application/json") +base="${API}/projects/${CROWDIN_PROJECT_ID}/reports" + +# 1) Kick off a top-members report for the window. +gen_body="$(jq -n --arg from "$DATE_FROM" --arg to "$DATE_TO" '{ + name: "top-members", + schema: { unit: "words", format: "json", dateFrom: $from, dateTo: $to } +}')" +report_id="$(curl -fsS "${auth[@]}" -X POST "$base" -d "$gen_body" | jq -r '.data.identifier')" +[ -n "$report_id" ] && [ "$report_id" != "null" ] || die "Crowdin did not return a report identifier" + +# 2) Poll until the report is finished. +for _ in $(seq 1 60); do + status="$(curl -fsS "${auth[@]}" "${base}/${report_id}" | jq -r '.data.status')" + case "$status" in + finished) break ;; + failed) die "Crowdin report generation failed" ;; + *) sleep 2 ;; + esac +done +[ "$status" = "finished" ] || die "report did not finish in time (last status: $status)" + +# 3) Download the report JSON. +dl_url="$(curl -fsS "${auth[@]}" "${base}/${report_id}/download" | jq -r '.data.url')" +[ -n "$dl_url" ] && [ "$dl_url" != "null" ] || die "Crowdin did not return a download url" +report="$(curl -fsS "$dl_url")" + +if [ "$RAW" = "1" ]; then + echo "$report" | jq '(.data // .)' >&2 +fi + +# 4) Join report members against the npub mapping, grouped by language. +# Mapping keys are lower-cased; we match by username (lower) or numeric id. +echo "$report" | jq -r --slurpfile m "$MAPPING" ' + ($m[0].mappings // {}) as $map + | ( $map | with_entries(.key |= ascii_downcase) ) as $byname + | (.data // .) as $members + | reduce $members[] as $mem ({langs:{}, unmapped:[]}; + ($mem.user // {}) as $u + | ( ($u.username // "") | ascii_downcase ) as $uname + | ( $byname[$uname] // $map[($u.id|tostring)] ) as $npub + | if $npub == null then + .unmapped += [ ($u.fullName // $u.username // ("id " + ($u.id|tostring))) ] + else + reduce ( ($mem.languages // []) | if length>0 then . else [{name:"(unknown language)"}] end | .[] ) as $l (.; + .langs[$l.name] = ((.langs[$l.name] // []) + ["@\($npub)"] | unique)) + end + ) + | "## Translations\n" + + ( [ .langs | to_entries[] | "- \(.key) by " + (.value | sort | join(" and ")) ] | sort | join("\n") ) + + ( if (.unmapped|length)>0 + then "\n\n# UNMAPPED (no npub in translators.json — credit by hand, then add them):\n" + + ( [ .unmapped | unique[] | "# - " + . ] | join("\n") ) + else "" end ) +' From a5bf4451ed63ac5deb91eb71d417f943519f4ea7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 15:41:02 +0000 Subject: [PATCH 2/6] refactor: move translator-credits script to scripts/, document in RELEASE_OPS Moves the Crowdin translator-credits generator from tools/translators/ to scripts/translators.sh to sit with the other flat shell scripts. Drops the standalone README (the script is self-documenting via --help) and folds the release-time usage into RELEASE_OPS.md next to the changelog step. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FWQWVdWLAwBUBX2gJ55y6b --- RELEASE_OPS.md | 13 +++++ {tools/translators => scripts}/translators.sh | 13 +++-- tools/translators/README.md | 47 ------------------- 3 files changed, 23 insertions(+), 50 deletions(-) rename {tools/translators => scripts}/translators.sh (90%) delete mode 100644 tools/translators/README.md diff --git a/RELEASE_OPS.md b/RELEASE_OPS.md index 652f5ee8eb..d8af2df865 100644 --- a/RELEASE_OPS.md +++ b/RELEASE_OPS.md @@ -44,6 +44,19 @@ workflow. e.g. `v1.12.01.md`) and add it to `docs/changelog/README.md`. Follow the house style: plain text, short verb-first sentences. + For the `## Translations` section, generate the credits from Crowdin instead + of writing them by hand: + ```bash + export CROWDIN_PROJECT_ID=... CROWDIN_PERSONAL_TOKEN=... + scripts/translators.sh --from --to + ``` + It pulls a Crowdin *Top Members* report for the window between the two tags, + joins each contributor against `docs/changelog/translators.json` (a + Crowdin-username/id → npub map kept next to the changelogs), and prints the + `## Translations` block grouped by language. Contributors with no npub yet are + listed under `UNMAPPED` — credit them by hand, then add their npub to + `translators.json` so the next release picks them up automatically. + 3. **Publish the release-notes note on Nostr** with Amethyst's account and paste its event id into `amethyst/build.gradle.kts`: ```kotlin diff --git a/tools/translators/translators.sh b/scripts/translators.sh similarity index 90% rename from tools/translators/translators.sh rename to scripts/translators.sh index e936d1e545..c67f5d200e 100755 --- a/tools/translators/translators.sh +++ b/scripts/translators.sh @@ -9,7 +9,7 @@ # can credit them by hand and backfill translators.json. # # Usage: -# tools/translators/translators.sh --from --to +# scripts/translators.sh --from [--to ] # # --from / --to A date (YYYY-MM-DD) or a git tag/ref. Tags are resolved to # their commit date. --to defaults to now if omitted. @@ -21,6 +21,13 @@ # CROWDIN_PROJECT_ID Crowdin numeric project id. # CROWDIN_PERSONAL_TOKEN Crowdin personal access token (needs report scope). # +# Crowdin contributors are joined against docs/changelog/translators.json, a +# Crowdin-username/id -> npub mapping kept alongside the changelogs. Anyone +# Crowdin reports who isn't in the mapping is printed under UNMAPPED so you can +# credit them by hand and backfill the JSON. The contribution window for "between +# two versions" is the commit date of the previous tag -> the commit date of the +# new tag. +# # Requires: bash, curl, jq, git. # # NOTE: This talks to the live Crowdin REST API (api.crowdin.com). The JSON field @@ -30,7 +37,7 @@ set -euo pipefail -REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" API="https://api.crowdin.com/api/v2" MAPPING="$REPO_ROOT/docs/changelog/translators.json" FROM="" @@ -45,7 +52,7 @@ while [ $# -gt 0 ]; do --to) TO="${2:?--to needs a value}"; shift 2 ;; --mapping) MAPPING="${2:?--mapping needs a value}"; shift 2 ;; --raw) RAW=1; shift ;; - -h|--help) sed -n '2,30p' "$0"; exit 0 ;; + -h|--help) sed -n '2,36p' "$0"; exit 0 ;; *) die "unknown argument: $1" ;; esac done diff --git a/tools/translators/README.md b/tools/translators/README.md deleted file mode 100644 index fca6683f99..0000000000 --- a/tools/translators/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# Translator credits for the changelog - -Generates the `## Translations` block for a release by asking Crowdin **who -translated between two releases** and joining them against the npub mapping kept -in the changelog folder. - -## Pieces - -- **`docs/changelog/translators.json`** — the Crowdin-user → npub mapping (lives - next to the changelogs). Keyed by Crowdin username (case-insensitive) or numeric - user id. Add a row whenever a translator gives you their npub. -- **`tools/translators/translators.sh`** — pulls a Crowdin *Top Members* report - for a date window, joins it against the mapping, and prints the credit block - grouped by language. Anyone Crowdin reports who isn't in the mapping is listed - under `UNMAPPED` so you can credit them by hand and backfill the JSON. - -## Usage - -```bash -export CROWDIN_PROJECT_ID=... # same env vars crowdin.yml already uses -export CROWDIN_PERSONAL_TOKEN=... # token needs the "reports" scope - -# Between the previous tag and now: -tools/translators/translators.sh --from v1.12.00 - -# Between two tags: -tools/translators/translators.sh --from v1.11.00 --to v1.12.00 - -# Discover Crowdin usernames to add to translators.json: -tools/translators/translators.sh --from v1.12.00 --raw -``` - -`--from` / `--to` accept either a `YYYY-MM-DD` date or a git tag/ref (resolved to -its commit date). `--to` defaults to now. - -Requires `bash`, `curl`, `jq`, `git`. - -## How the window maps to "between two versions" - -A release is a git tag, so the contribution window is the commit date of the -previous tag → the commit date of the new tag. The Crowdin Top Members report -takes that `dateFrom`/`dateTo` and returns every member who translated/approved -in it, per language. - -> The script talks to the live `api.crowdin.com` REST API. The JSON field paths -> for the downloaded report follow Crowdin's `top-members` schema; if Crowdin -> changes it, adjust the `jq` block at the bottom of `translators.sh`. From fc816f2afdbd84e989e9872059a35aeee59a3dc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 15:42:39 +0000 Subject: [PATCH 3/6] feat: add --seed mode to pre-load translators.json from Crowdin scripts/translators.sh --seed fetches every contributor in the window (default: past two months) and merges their Crowdin usernames into docs/changelog/translators.json with blank npubs, preserving existing entries and deduping case-insensitively. Fill in the npubs afterwards. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FWQWVdWLAwBUBX2gJ55y6b --- scripts/translators.sh | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/scripts/translators.sh b/scripts/translators.sh index c67f5d200e..093e1f3753 100755 --- a/scripts/translators.sh +++ b/scripts/translators.sh @@ -16,6 +16,10 @@ # --mapping PATH Override mapping file (default docs/changelog/translators.json). # --raw Also dump the raw per-member report rows (for debugging / # discovering Crowdin usernames to add to the mapping). +# --seed Instead of printing credits, merge every contributor in the +# window into translators.json with a blank npub (existing +# entries kept). --from defaults to two months ago. Fill in the +# npubs afterwards. # # Environment (same names crowdin.yml already uses): # CROWDIN_PROJECT_ID Crowdin numeric project id. @@ -43,6 +47,7 @@ MAPPING="$REPO_ROOT/docs/changelog/translators.json" FROM="" TO="" RAW=0 +SEED=0 die() { echo "error: $*" >&2; exit 1; } @@ -52,6 +57,7 @@ while [ $# -gt 0 ]; do --to) TO="${2:?--to needs a value}"; shift 2 ;; --mapping) MAPPING="${2:?--mapping needs a value}"; shift 2 ;; --raw) RAW=1; shift ;; + --seed) SEED=1; shift ;; -h|--help) sed -n '2,36p' "$0"; exit 0 ;; *) die "unknown argument: $1" ;; esac @@ -59,7 +65,9 @@ done command -v jq >/dev/null || die "jq not found" command -v curl >/dev/null || die "curl not found" -[ -n "$FROM" ] || die "--from is required" +# --from defaults to two months ago (handy for --seed; explicit tags are better +# for generating a release's credits). +[ -n "$FROM" ] || FROM="$(date -u -d '2 months ago' +%Y-%m-%d 2>/dev/null || date -u -v-2m +%Y-%m-%d)" [ -n "${CROWDIN_PROJECT_ID:-}" ] || die "CROWDIN_PROJECT_ID is not set" [ -n "${CROWDIN_PERSONAL_TOKEN:-}" ] || die "CROWDIN_PERSONAL_TOKEN is not set" [ -f "$MAPPING" ] || die "mapping file not found: $MAPPING" @@ -111,7 +119,26 @@ if [ "$RAW" = "1" ]; then echo "$report" | jq '(.data // .)' >&2 fi -# 4) Join report members against the npub mapping, grouped by language. +# 4a) --seed: merge every contributor in the window into translators.json with an +# empty npub (keeping existing mappings), so the file is pre-loaded and you +# only have to fill in the npubs. Matching is case-insensitive on username. +if [ "$SEED" = "1" ]; then + before="$(jq '(.mappings // {}) | length' "$MAPPING")" + merged="$(jq -n --slurpfile cur "$MAPPING" --argjson rep "$report" ' + ($cur[0]) as $file + | [ ($rep.data // $rep)[] | .user | { key: (.username // (.id|tostring)) } ] + | reduce .[] as $u (($file.mappings // {}); + if ( [keys_unsorted[] | ascii_downcase] | index($u.key | ascii_downcase) ) + then . else . + { ($u.key): "" } end) + | $file + { mappings: . } + ')" + echo "$merged" > "$MAPPING" + after="$(jq '(.mappings // {}) | length' "$MAPPING")" + echo "# Seeded $MAPPING: $before -> $after entries (added $((after - before)) new, npubs left blank)." >&2 + exit 0 +fi + +# 4b) Join report members against the npub mapping, grouped by language. # Mapping keys are lower-cased; we match by username (lower) or numeric id. echo "$report" | jq -r --slurpfile m "$MAPPING" ' ($m[0].mappings // {}) as $map From 6c9ca11602b96d42a7bd5edc447286e7f2c79b20 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 15:46:33 +0000 Subject: [PATCH 4/6] ci: seed translators.json from Crowdin on every sync Adds a seed-translators job to the Crowdin workflow that runs scripts/translators.sh --seed (past two months) and opens/updates a single PR via peter-evans/create-pull-request whenever a new contributor appears, so docs/changelog/translators.json stays current without manual upkeep. The action is MIT and CI-only (not linked into any shipped artifact), and no-ops when there is no diff. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FWQWVdWLAwBUBX2gJ55y6b --- .github/workflows/crowdin.yml | 36 ++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml index e23a28596e..d270d80a45 100644 --- a/.github/workflows/crowdin.yml +++ b/.github/workflows/crowdin.yml @@ -31,4 +31,38 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} - CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} \ No newline at end of file + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} + + # Keep docs/changelog/translators.json seeded with everyone who has translated + # recently, so the per-release `## Translations` credits (scripts/translators.sh) + # can resolve them to npubs. Runs independently of the sync job above and only + # opens/updates a PR when a genuinely new contributor appears. + seed-translators: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Seed translator placeholders from Crowdin + run: bash scripts/translators.sh --seed + env: + CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} + CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }} + + - name: Open or update the seed PR + # peter-evans/create-pull-request is MIT-licensed CI-only tooling (not + # linked into any shipped artifact). It no-ops when there is no diff. + uses: peter-evans/create-pull-request@v7 + with: + token: ${{ secrets.GITHUB_TOKEN }} + base: main + branch: chore/seed-translators + add-paths: docs/changelog/translators.json + commit-message: 'chore: seed translator npub placeholders from Crowdin' + title: 'Seed translator npub placeholders' + body: | + New Crowdin contributors were added to `docs/changelog/translators.json` + with blank npubs. Fill in the npubs you have so the next release's + `## Translations` credits generate automatically via + `scripts/translators.sh --from --to `. \ No newline at end of file From 6b304866676bd12f8352ebde3deb8a7ebbc11409 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 15:52:27 +0000 Subject: [PATCH 5/6] feat: track a rolling "since last tag" translator list Reworks docs/changelog/translators.json into two lists maintained by scripts/translators.sh: - mappings: a forever-growing Crowdin-username/id -> npub registry. --seed appends new contributors with a blank npub and never deletes or overwrites existing entries. - sinceLastTag: a rolling snapshot of who has translated since the last v* tag, fully refreshed on every --seed run. The contribution window now defaults to the most recent v* tag instead of a fixed two months (falling back to two months ago when no tag is reachable). The CI seed job fetches tags (fetch-depth: 0) so it can resolve that window. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FWQWVdWLAwBUBX2gJ55y6b --- .github/workflows/crowdin.yml | 4 ++ docs/changelog/translators.json | 28 +++++++++---- scripts/translators.sh | 74 ++++++++++++++++++++++----------- 3 files changed, 73 insertions(+), 33 deletions(-) diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml index d270d80a45..719f8709cf 100644 --- a/.github/workflows/crowdin.yml +++ b/.github/workflows/crowdin.yml @@ -43,6 +43,10 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v6 + with: + # Need tags so the script can resolve the last v* release tag for the + # "since last tag" window. + fetch-depth: 0 - name: Seed translator placeholders from Crowdin run: bash scripts/translators.sh --seed diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index 9f47b77a7e..14169e57e8 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -1,15 +1,27 @@ { "_comment": [ - "Crowdin username (or numeric user id) -> Nostr npub.", - "Used by tools/translators/translators.sh to turn a Crowdin 'Top Members'", - "report for a release window into the changelog '## Translations' section.", - "Keys are matched case-insensitively against the Crowdin username; a numeric", - "key is matched against the Crowdin user id. Add a row whenever a new", - "translator gives you their npub. Contributors with no entry here are listed", - "by the script under 'UNMAPPED' so you can credit them by hand and backfill.", - "Example shape (replace with real Crowdin usernames):", + "Translator credits source for the changelog, kept by scripts/translators.sh.", + "", + "mappings: a forever-growing Crowdin-username (or numeric id) -> Nostr npub", + " registry. Entries are never deleted. The --seed run appends new", + " contributors with a blank npub; fill those in as translators share theirs.", + " Keys match case-insensitively against the Crowdin username; a numeric key", + " matches the Crowdin user id.", + "", + "sinceLastTag: a rolling snapshot of who has translated since the last release", + " tag. Refreshed on every --seed run; do not hand-edit. At release time the", + " changelog '## Translations' block is generated from this window and", + " resolved to npubs via mappings (anyone still blank is flagged UNMAPPED).", + "", + "Example mapping (replace with a real Crowdin username):", " \"vitorpamplona\": \"npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z\"" ], "mappings": { + }, + "sinceLastTag": { + "tag": "", + "since": "", + "updated": "", + "translators": [] } } diff --git a/scripts/translators.sh b/scripts/translators.sh index 093e1f3753..5fe164c7e1 100755 --- a/scripts/translators.sh +++ b/scripts/translators.sh @@ -12,25 +12,29 @@ # scripts/translators.sh --from [--to ] # # --from / --to A date (YYYY-MM-DD) or a git tag/ref. Tags are resolved to -# their commit date. --to defaults to now if omitted. +# their commit date. --from defaults to the most recent v* tag +# ("since the last release"); --to defaults to now. # --mapping PATH Override mapping file (default docs/changelog/translators.json). # --raw Also dump the raw per-member report rows (for debugging / # discovering Crowdin usernames to add to the mapping). -# --seed Instead of printing credits, merge every contributor in the -# window into translators.json with a blank npub (existing -# entries kept). --from defaults to two months ago. Fill in the -# npubs afterwards. +# --seed Instead of printing credits, update translators.json from the +# window (see the two lists below). Fill in any blank npubs +# afterwards. # # Environment (same names crowdin.yml already uses): # CROWDIN_PROJECT_ID Crowdin numeric project id. # CROWDIN_PERSONAL_TOKEN Crowdin personal access token (needs report scope). # -# Crowdin contributors are joined against docs/changelog/translators.json, a -# Crowdin-username/id -> npub mapping kept alongside the changelogs. Anyone -# Crowdin reports who isn't in the mapping is printed under UNMAPPED so you can -# credit them by hand and backfill the JSON. The contribution window for "between -# two versions" is the commit date of the previous tag -> the commit date of the -# new tag. +# docs/changelog/translators.json (kept alongside the changelogs) holds two lists: +# mappings A forever-growing Crowdin-username/id -> npub registry. --seed +# appends new contributors with a blank npub and never deletes or +# overwrites existing entries. +# sinceLastTag A rolling snapshot of who has translated since the last release +# tag, refreshed on every --seed run. +# +# When printing credits, contributors are grouped by language and resolved to +# npubs via mappings; anyone without an npub is listed under UNMAPPED so you can +# credit them by hand and backfill the registry. # # Requires: bash, curl, jq, git. # @@ -58,16 +62,25 @@ while [ $# -gt 0 ]; do --mapping) MAPPING="${2:?--mapping needs a value}"; shift 2 ;; --raw) RAW=1; shift ;; --seed) SEED=1; shift ;; - -h|--help) sed -n '2,36p' "$0"; exit 0 ;; + -h|--help) sed -n '2,38p' "$0"; exit 0 ;; *) die "unknown argument: $1" ;; esac done command -v jq >/dev/null || die "jq not found" command -v curl >/dev/null || die "curl not found" -# --from defaults to two months ago (handy for --seed; explicit tags are better -# for generating a release's credits). -[ -n "$FROM" ] || FROM="$(date -u -d '2 months ago' +%Y-%m-%d 2>/dev/null || date -u -v-2m +%Y-%m-%d)" +# The window is "since the last release": --from defaults to the most recent v* +# tag (falling back to two months ago if no tag is reachable — e.g. a shallow CI +# checkout without tags). The tag name, when found, is recorded in the file's +# sinceLastTag block. +LAST_TAG="$(git -C "$REPO_ROOT" describe --tags --abbrev=0 --match 'v*' 2>/dev/null || true)" +if [ -z "$FROM" ]; then + if [ -n "$LAST_TAG" ]; then + FROM="$LAST_TAG" + else + FROM="$(date -u -d '2 months ago' +%Y-%m-%d 2>/dev/null || date -u -v-2m +%Y-%m-%d)" + fi +fi [ -n "${CROWDIN_PROJECT_ID:-}" ] || die "CROWDIN_PROJECT_ID is not set" [ -n "${CROWDIN_PERSONAL_TOKEN:-}" ] || die "CROWDIN_PERSONAL_TOKEN is not set" [ -f "$MAPPING" ] || die "mapping file not found: $MAPPING" @@ -119,22 +132,33 @@ if [ "$RAW" = "1" ]; then echo "$report" | jq '(.data // .)' >&2 fi -# 4a) --seed: merge every contributor in the window into translators.json with an -# empty npub (keeping existing mappings), so the file is pre-loaded and you -# only have to fill in the npubs. Matching is case-insensitive on username. +# 4a) --seed: update translators.json from the window. Two lists are maintained: +# - mappings : the forever-growing username -> npub registry. New +# contributors are appended with a blank npub; existing +# entries (and their npubs) are never touched or removed. +# Matching is case-insensitive on username. +# - sinceLastTag : a rolling snapshot of who has translated since the last +# release tag. Fully replaced each run. if [ "$SEED" = "1" ]; then before="$(jq '(.mappings // {}) | length' "$MAPPING")" - merged="$(jq -n --slurpfile cur "$MAPPING" --argjson rep "$report" ' + merged="$(jq -n --slurpfile cur "$MAPPING" --argjson rep "$report" \ + --arg tag "$LAST_TAG" --arg since "$DATE_FROM" \ + --arg updated "$(date -u +%Y-%m-%dT%H:%M:%S+00:00)" ' ($cur[0]) as $file - | [ ($rep.data // $rep)[] | .user | { key: (.username // (.id|tostring)) } ] - | reduce .[] as $u (($file.mappings // {}); - if ( [keys_unsorted[] | ascii_downcase] | index($u.key | ascii_downcase) ) - then . else . + { ($u.key): "" } end) - | $file + { mappings: . } + | [ ($rep.data // $rep)[] | .user | (.username // (.id|tostring)) ] as $contributors + | ( reduce $contributors[] as $u (($file.mappings // {}); + if ( [keys_unsorted[] | ascii_downcase] | index($u | ascii_downcase) ) + then . else . + { ($u): "" } end) ) as $mappings + | $file + + { mappings: $mappings } + + { sinceLastTag: { tag: $tag, since: $since, updated: $updated, + translators: ($contributors | unique) } } ')" echo "$merged" > "$MAPPING" after="$(jq '(.mappings // {}) | length' "$MAPPING")" - echo "# Seeded $MAPPING: $before -> $after entries (added $((after - before)) new, npubs left blank)." >&2 + active="$(jq '(.sinceLastTag.translators // []) | length' "$MAPPING")" + echo "# Seeded $MAPPING: mappings $before -> $after (added $((after - before)) new, npubs blank);" >&2 + echo "# sinceLastTag = $active contributor(s) since ${LAST_TAG:-$DATE_FROM}." >&2 exit 0 fi From 765c1dfd7ea2e9ae231f21fa3ac43fe88e3fa160 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 16:02:47 +0000 Subject: [PATCH 6/6] feat: generate translator credits offline from the committed file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes docs/changelog/translators.json self-sufficient so a release no longer needs to re-query Crowdin: - sinceLastTag entries now carry each translator's languages ({user, languages}), recorded by the --seed run. - Default mode (no flags) is offline: it generates the "## Translations" block straight from the committed file — reading sinceLastTag, grouping by the stored languages, and resolving npubs via the mappings registry. No token, no network. - --seed/--raw remain the online paths (CI seeding / debugging). curl + git + credentials are only required there; the offline path needs just jq. RELEASE_OPS now points at the tokenless `scripts/translators.sh` for the changelog credits. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01FWQWVdWLAwBUBX2gJ55y6b --- RELEASE_OPS.md | 24 ++--- docs/changelog/translators.json | 6 +- scripts/translators.sh | 151 ++++++++++++++++++-------------- 3 files changed, 103 insertions(+), 78 deletions(-) diff --git a/RELEASE_OPS.md b/RELEASE_OPS.md index d8af2df865..1f0975423e 100644 --- a/RELEASE_OPS.md +++ b/RELEASE_OPS.md @@ -44,18 +44,22 @@ workflow. e.g. `v1.12.01.md`) and add it to `docs/changelog/README.md`. Follow the house style: plain text, short verb-first sentences. - For the `## Translations` section, generate the credits from Crowdin instead - of writing them by hand: + For the `## Translations` section, generate the credits instead of writing + them by hand — no token needed: ```bash - export CROWDIN_PROJECT_ID=... CROWDIN_PERSONAL_TOKEN=... - scripts/translators.sh --from --to + scripts/translators.sh ``` - It pulls a Crowdin *Top Members* report for the window between the two tags, - joins each contributor against `docs/changelog/translators.json` (a - Crowdin-username/id → npub map kept next to the changelogs), and prints the - `## Translations` block grouped by language. Contributors with no npub yet are - listed under `UNMAPPED` — credit them by hand, then add their npub to - `translators.json` so the next release picks them up automatically. + This runs offline. It reads `docs/changelog/translators.json` (kept next to + the changelogs), which CI keeps fresh: the Crowdin sync workflow's + `seed-translators` job records everyone who has translated since the last `v*` + tag, with their languages, in the file's `sinceLastTag` list, and accumulates + their npubs in the forever-growing `mappings` registry. The script resolves + that list to npubs and prints the `## Translations` block grouped by language. + Contributors with no npub yet are listed under `UNMAPPED` — credit them by + hand, then add their npub under `mappings` so future releases pick them up + automatically. (To re-query Crowdin live as a sanity check, run + `scripts/translators.sh --seed` with `CROWDIN_PROJECT_ID` / + `CROWDIN_PERSONAL_TOKEN` set, which refreshes the file.) 3. **Publish the release-notes note on Nostr** with Amethyst's account and paste its event id into `amethyst/build.gradle.kts`: diff --git a/docs/changelog/translators.json b/docs/changelog/translators.json index 14169e57e8..fa044ed3e6 100644 --- a/docs/changelog/translators.json +++ b/docs/changelog/translators.json @@ -9,9 +9,9 @@ " matches the Crowdin user id.", "", "sinceLastTag: a rolling snapshot of who has translated since the last release", - " tag. Refreshed on every --seed run; do not hand-edit. At release time the", - " changelog '## Translations' block is generated from this window and", - " resolved to npubs via mappings (anyone still blank is flagged UNMAPPED).", + " tag. Each entry is { user, languages }. Refreshed on every --seed run; do", + " not hand-edit. The changelog '## Translations' block is generated offline", + " from this list and resolved to npubs via mappings (blanks flagged UNMAPPED).", "", "Example mapping (replace with a real Crowdin username):", " \"vitorpamplona\": \"npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z\"" diff --git a/scripts/translators.sh b/scripts/translators.sh index 5fe164c7e1..25fd6b393d 100755 --- a/scripts/translators.sh +++ b/scripts/translators.sh @@ -1,27 +1,31 @@ #!/usr/bin/env bash # -# Build the changelog "## Translations" section for a release window. +# Build the changelog "## Translations" section, or keep its data file seeded. # -# Pulls a Crowdin "Top Members" report for a date range (the gap between two -# releases), joins each Crowdin contributor against docs/changelog/translators.json -# (Crowdin username/id -> npub), and prints a ready-to-paste credit block grouped -# by language. Contributors with no npub mapping are listed under UNMAPPED so you -# can credit them by hand and backfill translators.json. +# Default (no flags): OFFLINE. Generate the ready-to-paste credit block straight +# from docs/changelog/translators.json — no token, no network. This is the +# release-time command: it reads the sinceLastTag snapshot (kept fresh by CI, +# with each translator's languages) and resolves npubs via the mappings registry, +# grouping by language. Anyone without an npub is listed under UNMAPPED. # -# Usage: -# scripts/translators.sh --from [--to ] +# scripts/translators.sh # print the block for the current cycle # -# --from / --to A date (YYYY-MM-DD) or a git tag/ref. Tags are resolved to -# their commit date. --from defaults to the most recent v* tag -# ("since the last release"); --to defaults to now. -# --mapping PATH Override mapping file (default docs/changelog/translators.json). -# --raw Also dump the raw per-member report rows (for debugging / -# discovering Crowdin usernames to add to the mapping). -# --seed Instead of printing credits, update translators.json from the -# window (see the two lists below). Fill in any blank npubs -# afterwards. +# --seed: ONLINE. Query Crowdin's "Top Members" report for the window and update +# translators.json (see the two lists below). Used by CI; needs a token. # -# Environment (same names crowdin.yml already uses): +# scripts/translators.sh --seed +# +# Flags: +# --seed Refresh translators.json from Crowdin (online). Window is +# --from..--to. +# --raw Dump the raw per-member report rows to stderr (online; for +# discovering Crowdin usernames). Implies an API call. +# --from / --to Window for --seed/--raw. A date (YYYY-MM-DD) or a git tag/ref +# (resolved to its commit date). --from defaults to the most +# recent v* tag ("since the last release"); --to defaults to now. +# --mapping PATH Override the data file (default docs/changelog/translators.json). +# +# Environment (only needed for --seed/--raw; same names crowdin.yml uses): # CROWDIN_PROJECT_ID Crowdin numeric project id. # CROWDIN_PERSONAL_TOKEN Crowdin personal access token (needs report scope). # @@ -30,18 +34,15 @@ # appends new contributors with a blank npub and never deletes or # overwrites existing entries. # sinceLastTag A rolling snapshot of who has translated since the last release -# tag, refreshed on every --seed run. +# tag — each entry is { user, languages } — refreshed on every +# --seed run. The offline credit block is generated from this. # -# When printing credits, contributors are grouped by language and resolved to -# npubs via mappings; anyone without an npub is listed under UNMAPPED so you can -# credit them by hand and backfill the registry. +# Requires: bash, jq (always); curl, git (only for --seed/--raw). # -# Requires: bash, curl, jq, git. -# -# NOTE: This talks to the live Crowdin REST API (api.crowdin.com). The JSON field -# paths for the downloaded "top-members" report are documented at +# NOTE: --seed/--raw talk to the live Crowdin REST API (api.crowdin.com). The JSON +# field paths for the "top-members" report are documented at # https://developer.crowdin.com/api/v2/#operation/api.projects.reports.post and -# can be adjusted in the jq block below if Crowdin changes the schema. +# can be adjusted in the jq blocks below if Crowdin changes the schema. set -euo pipefail @@ -62,17 +63,58 @@ while [ $# -gt 0 ]; do --mapping) MAPPING="${2:?--mapping needs a value}"; shift 2 ;; --raw) RAW=1; shift ;; --seed) SEED=1; shift ;; - -h|--help) sed -n '2,38p' "$0"; exit 0 ;; + -h|--help) sed -n '2,45p' "$0"; exit 0 ;; *) die "unknown argument: $1" ;; esac done -command -v jq >/dev/null || die "jq not found" +command -v jq >/dev/null || die "jq not found" +[ -f "$MAPPING" ] || die "mapping file not found: $MAPPING" + +# Render the "## Translations" block from a {langs, unmapped} accumulator. Shared +# by the offline and online paths so they produce byte-identical output. +RENDER_BLOCK=' + "## Translations\n" + + ( [ .langs | to_entries[] | "- \(.key) by " + (.value | sort | join(" and ")) ] | sort | join("\n") ) + + ( if (.unmapped|length) > 0 + then "\n\n# UNMAPPED (no npub in translators.json — add it under mappings):\n" + + ( [ .unmapped | unique[] | "# - " + . ] | join("\n") ) + else "" end )' + +# --------------------------------------------------------------------------- +# Default (offline): generate the credits straight from the committed file. +# This is the release-time command — no token, no network. It reads the +# sinceLastTag snapshot (which CI keeps fresh, with each translator's languages) +# and resolves npubs via the forever-growing mappings registry. +# --------------------------------------------------------------------------- +if [ "$SEED" = "0" ] && [ "$RAW" = "0" ]; then + jq -r " + (.mappings // {}) as \$map + | ( \$map | with_entries(.key |= ascii_downcase) ) as \$byname + | ( (.sinceLastTag.translators // []) + | map(if type == \"object\" then . else { user: ., languages: [] } end) ) as \$list + | reduce \$list[] as \$t ({ langs: {}, unmapped: [] }; + (\$t.user) as \$u + | ( \$byname[\$u | ascii_downcase] // \$map[\$u] ) as \$npub + | ( if (\$t.languages | length) > 0 then \$t.languages else [\"(unknown language)\"] end ) as \$langs + | if (\$npub | type) == \"string\" and (\$npub | length) > 0 then + reduce \$langs[] as \$l (.; .langs[\$l] = ((.langs[\$l] // []) + [\"@\(\$npub)\"] | unique)) + else + .unmapped += [\$u] + end + ) + | $RENDER_BLOCK + " "$MAPPING" + exit 0 +fi + +# --------------------------------------------------------------------------- +# Online (--seed / --raw): query Crowdin's Top Members report for the window. +# --------------------------------------------------------------------------- command -v curl >/dev/null || die "curl not found" # The window is "since the last release": --from defaults to the most recent v* # tag (falling back to two months ago if no tag is reachable — e.g. a shallow CI -# checkout without tags). The tag name, when found, is recorded in the file's -# sinceLastTag block. +# checkout without tags). The tag name, when found, is recorded in sinceLastTag. LAST_TAG="$(git -C "$REPO_ROOT" describe --tags --abbrev=0 --match 'v*' 2>/dev/null || true)" if [ -z "$FROM" ]; then if [ -n "$LAST_TAG" ]; then @@ -83,7 +125,6 @@ if [ -z "$FROM" ]; then fi [ -n "${CROWDIN_PROJECT_ID:-}" ] || die "CROWDIN_PROJECT_ID is not set" [ -n "${CROWDIN_PERSONAL_TOKEN:-}" ] || die "CROWDIN_PERSONAL_TOKEN is not set" -[ -f "$MAPPING" ] || die "mapping file not found: $MAPPING" # Resolve a date (YYYY-MM-DD) or a git ref to an ISO-8601 timestamp. resolve_ts() { @@ -132,57 +173,37 @@ if [ "$RAW" = "1" ]; then echo "$report" | jq '(.data // .)' >&2 fi -# 4a) --seed: update translators.json from the window. Two lists are maintained: -# - mappings : the forever-growing username -> npub registry. New +# 4) --seed: update translators.json from the window. Two lists are maintained: +# - mappings : the forever-growing username -> npub registry. New # contributors are appended with a blank npub; existing # entries (and their npubs) are never touched or removed. # Matching is case-insensitive on username. -# - sinceLastTag : a rolling snapshot of who has translated since the last -# release tag. Fully replaced each run. +# - sinceLastTag : a rolling snapshot of who has translated since the last +# release tag, with each contributor's languages, so the +# offline credits can be generated without re-querying. +# Fully replaced each run. if [ "$SEED" = "1" ]; then before="$(jq '(.mappings // {}) | length' "$MAPPING")" merged="$(jq -n --slurpfile cur "$MAPPING" --argjson rep "$report" \ --arg tag "$LAST_TAG" --arg since "$DATE_FROM" \ --arg updated "$(date -u +%Y-%m-%dT%H:%M:%S+00:00)" ' ($cur[0]) as $file - | [ ($rep.data // $rep)[] | .user | (.username // (.id|tostring)) ] as $contributors - | ( reduce $contributors[] as $u (($file.mappings // {}); + | [ ($rep.data // $rep)[] | { + user: (.user.username // (.user.id|tostring)), + languages: ([ (.languages // [])[] | .name ] | unique) + } ] as $contribs + | ( $contribs | map(.user) ) as $names + | ( reduce $names[] as $u (($file.mappings // {}); if ( [keys_unsorted[] | ascii_downcase] | index($u | ascii_downcase) ) then . else . + { ($u): "" } end) ) as $mappings | $file + { mappings: $mappings } + { sinceLastTag: { tag: $tag, since: $since, updated: $updated, - translators: ($contributors | unique) } } + translators: $contribs } } ')" echo "$merged" > "$MAPPING" after="$(jq '(.mappings // {}) | length' "$MAPPING")" active="$(jq '(.sinceLastTag.translators // []) | length' "$MAPPING")" echo "# Seeded $MAPPING: mappings $before -> $after (added $((after - before)) new, npubs blank);" >&2 echo "# sinceLastTag = $active contributor(s) since ${LAST_TAG:-$DATE_FROM}." >&2 - exit 0 fi - -# 4b) Join report members against the npub mapping, grouped by language. -# Mapping keys are lower-cased; we match by username (lower) or numeric id. -echo "$report" | jq -r --slurpfile m "$MAPPING" ' - ($m[0].mappings // {}) as $map - | ( $map | with_entries(.key |= ascii_downcase) ) as $byname - | (.data // .) as $members - | reduce $members[] as $mem ({langs:{}, unmapped:[]}; - ($mem.user // {}) as $u - | ( ($u.username // "") | ascii_downcase ) as $uname - | ( $byname[$uname] // $map[($u.id|tostring)] ) as $npub - | if $npub == null then - .unmapped += [ ($u.fullName // $u.username // ("id " + ($u.id|tostring))) ] - else - reduce ( ($mem.languages // []) | if length>0 then . else [{name:"(unknown language)"}] end | .[] ) as $l (.; - .langs[$l.name] = ((.langs[$l.name] // []) + ["@\($npub)"] | unique)) - end - ) - | "## Translations\n" - + ( [ .langs | to_entries[] | "- \(.key) by " + (.value | sort | join(" and ")) ] | sort | join("\n") ) - + ( if (.unmapped|length)>0 - then "\n\n# UNMAPPED (no npub in translators.json — credit by hand, then add them):\n" - + ( [ .unmapped | unique[] | "# - " + . ] | join("\n") ) - else "" end ) -'