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 ) +'