#!/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: # 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. # --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. # 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 # 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 SEED=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 ;; --seed) SEED=1; shift ;; -h|--help) sed -n '2,36p' "$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)" [ -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 # 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 | ( $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 ) '