#! /bin/sh # # Copyright (C) 2002-2026 Free Software Foundation, Inc. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . # # Written by Bruno Haible. # This program fetches a set of PO files, produced by translators, from # a translation project's site on the internet, and updates the LINGUAS # file accordingly. # # It uses GNU wget. Alternatives would be: # - GNU wget2 # - curl # - wcurl # but each alternative would require separate testing. progname=$0 package=gettext-tools version=1.0 # func_exit STATUS # exits with a given status. # This function needs to be used, rather than 'exit', when a 'trap' handler is # in effect that refers to $?. func_exit () { (exit $1); exit $1 } # func_tmpdir # creates a temporary directory. # Input: # - progname name of this program # Sets variable # - tmp pathname of freshly created temporary directory func_tmpdir () { # Use the environment variable TMPDIR, falling back to /tmp. This allows # users to specify a different temporary directory, for example, if their # /tmp is filled up or too small. : "${TMPDIR=/tmp}" { # Use the mktemp program if available. If not available, hide the error # message. tmp=`(umask 077 && mktemp -d "$TMPDIR/gtXXXXXX") 2>/dev/null` && test -n "$tmp" && test -d "$tmp" } || { # Use a simple mkdir command. It is guaranteed to fail if the directory # already exists. $RANDOM is bash specific and expands to empty in shells # other than bash, ksh and zsh. Its use does not increase security; # rather, it minimizes the probability of failure in a very cluttered /tmp # directory. tmp=$TMPDIR/gt$$-$RANDOM (umask 077 && mkdir "$tmp") } || { echo "$progname: cannot create a temporary directory in $TMPDIR" >&2 func_exit 1 } } # func_usage # outputs to stdout the --help usage message. func_usage () { echo "\ Usage: po-fetch [OPTION...] TP DOMAIN po-fetch [OPTION...] Weblate BASE-URL PROJECT COMPONENT po-fetch [OPTION...] git BASE-URL BRANCH SUBDIR Fetches a set of PO files, produced by translators, from a translation project's site on the internet, and updates the LINGUAS file accordingly. The first argument indicates the kind of translation project: TP denotes the Translation Project (translationproject.org). Weblate denotes a Weblate instance. git denotes a web front-end to a git repository (such as a gitweb instance, a cgit instance, or a GitLab or GitHub or Forgejo instance). Options: --git Make changes in the git index, to prepare for a git commit. Output files location: The PO files and the LINGUAS file are stored in the current directory. Informative output: --help Show this help text. --version Show version and authorship information. Report bugs in the bug tracker at or by email to ." } # func_version # outputs to stdout the --version message. func_version () { echo "$progname (GNU $package) $version" echo "Copyright (C) 2002-2025 Free Software Foundation, Inc. License GPLv3+: GNU GPL version 3 or later This is free software: you are free to change and redistribute it. There is NO WARRANTY, to the extent permitted by law." printf 'Written by %s.\n' "Bruno Haible" } # func_fetch_TP DOMAIN # fetches a set of PO files from the Translation Project. # Input: # - tmp an empty temporary directory func_fetch_TP () { domain="$1" main_url="https://translationproject.org/latest/${domain}/" # The contents of this URL is an HTML page with hyperlinks to the PO files. # We use 'wget' as an HTML parsing engine. # Options '--recursive --level=1' tell wget to download the HTML page and the # files referenced by the hyperlinks. # Options '--no-host-directories --cut-dirs=2' tell wget to not create deeply # nested subdirectories. The option '--no-directories' does not work as well, # because it causes modified PO files to be downloaded as *.po.1, *.po.2, etc. # which is not what we want. # Option '--accept=po' tells wget to create only files named *.po and throw # away all other downloaded data. # Option '--directory-prefix=...' tells wget to store the resulting files in # the specified subdirectory. We use a temporary directory, so that when a # translation disappears (for example, when a translation team was renamed), # we will actually remove that PO file. # Option '--no-verbose' avoids too much chatter. # Option '--https-only' is a safety measure, to prevent downloading PO files # from insecure 'http' URLs. wget --recursive --level=1 \ --no-host-directories --cut-dirs=2 \ --accept=po \ --directory-prefix="$tmp" \ --no-verbose \ --https-only \ "${main_url}" \ || func_exit 1 } # func_fetch_weblate BASE-URL PROJECT COMPONENT # fetches a set of PO files from a Weblate instance. # Input: # - tmp an empty temporary directory func_fetch_weblate () { base_url="$1" project="$2" component="$3" # Ensure $base_url ends in a slash. case "$base_url" in */) ;; *) base_url="${base_url}/" ;; esac webui_url="${base_url}projects/${project}/${component}/" # The contents of this URL is an HTML page with a menu entry # "Files > Download translation files as ZIP file". zip_url="${base_url}download/${project}/${component}/?format=zip" # Download this file. # (Alternatively, we could use the Weblate REST API # , # to download the PO files one by one.) # Option '--no-verbose' avoids too much chatter. wget -O "$tmp"/_all_.zip --no-verbose "$zip_url" \ || func_exit 1 # Unpack it. It contains a subdirectory with a .pot file and some .po files. (cd "$tmp" && unzip -x -n -j _all_.zip) \ || func_exit 1 rm -f "$tmp"/_all_.zip # Remove all non-PO files. (cd "$tmp" \ && for file in *; do \ case "$file" in \ *.po) ;; \ *) rm -f "$file" ;; \ esac; \ done \ ) # Some projects use a "File mask" of "SOME_PREFIX.*.po" rather than "*.po". # Rename the PO files so that they fit the usual naming scheme. # Also, make all of the PO files world-readable. (cd "$tmp" \ && sed_trim='s/^.*\.\(.*\.po\)/\1/' \ && for file in *.po; do \ chmod a+r "$file"; \ case "$file" in \ *.*.po) g=`echo "$file" | sed -e "$sed_trim"`; mv "$file" "$g";; \ esac; \ done \ ) } # func_fetch_gitweb BASE-URL BRANCH SUBDIR # fetches a set of PO files from a git repository via the gitweb protocol. # Input: # - tmp an empty temporary directory func_fetch_gitweb () { base_url="$1" branch="$2" subdir="$3" # Validate the base_url. case "$base_url" in *"?p="*) ;; *) echo "gitweb URL is not valid (missing 'p' parameter)" 1>&2 func_exit 1 ;; esac webui_url="${base_url};a=tree;hb=refs/heads/${branch}" if test -n "$subdir"; then webui_url="${webui_url};f=${subdir}" fi # The contents of this URL is an HTML page with a list of files. # Download it. # Option '--no-verbose' avoids too much chatter. wget -O "$tmp"/_all_.html --no-verbose "$webui_url" \ || func_exit 1 # For each file, there is an HTML element # FILENAME sed -n -e 's/^.*\([^<>]*\)<[/]a>.*$/\1/p' < "$tmp"/_all_.html \ | while read filename; do case "$filename" in *.po) if test -n "$subdir"; then subdir_filename="$subdir/$filename" else subdir_filename="$filename" fi file_url="${base_url};a=blob_plain;hb=refs/heads/${branch};f=${subdir_filename}" wget -O "$tmp/$filename" --no-verbose "$file_url" \ || func_exit 1 ;; esac done rm -f "$tmp"/_all_.html } # func_fetch_cgit BASE-URL BRANCH SUBDIR # fetches a set of PO files from a git repository via the cgit protocol. # Input: # - tmp an empty temporary directory func_fetch_cgit () { base_url="$1" branch="$2" subdir="$3" # Validate the base_url. case "$base_url" in *.git) ;; *) echo "cgit URL is not valid (missing '.git' suffix)" 1>&2 func_exit 1 ;; esac webui_url="${base_url}/tree" if test -n "$subdir"; then webui_url="${webui_url}/${subdir}" fi webui_url="${webui_url}?h=${branch}" # The contents of this URL is an HTML page with a list of files. # Download it. # Option '--no-verbose' avoids too much chatter. wget -O "$tmp"/_all_.html --no-verbose "$webui_url" \ || func_exit 1 # For each file, there is an HTML element # ]*>\([^<>]*\)<[/]a>.*$/\1/p' < "$tmp"/_all_.html \ | while read filename; do case "$filename" in *.po) if test -n "$subdir"; then subdir_filename="$subdir/$filename" else subdir_filename="$filename" fi file_url="${base_url}/plain/${subdir_filename}?h=${branch}" wget -O "$tmp/$filename" --no-verbose "$file_url" \ || func_exit 1 ;; esac done rm -f "$tmp"/_all_.html } # func_fetch_gitlab BASE-URL BRANCH SUBDIR # fetches a set of PO files from a git repository in a GitLab instance # browsable through some web UI. # Input: # - tmp an empty temporary directory func_fetch_gitlab () { base_url="$1" branch="$2" subdir="$3" # Ensure $base_url ends in a slash. case "$base_url" in */) ;; *) base_url="${base_url}/" ;; esac webui_url="${base_url}-/tree/${branch}" if test -n "$subdir"; then webui_url="${webui_url}/${subdir}" fi # The contents of this URL is an HTML page. But it does not contain the list # of files. Instead, the web request that returns the list of files is filelist_url="${base_url}-/refs/${branch}/logs_tree" if test -n "$subdir"; then filelist_url="${filelist_url}/${subdir}" fi filelist_url="${filelist_url}?format=json&offset=0" # Download it. # Option '--no-verbose' avoids too much chatter. wget -O "$tmp"/_all_.json --no-verbose "$filelist_url" \ || func_exit 1 # For each file, there is a JSON array element {"file_name":"FILENAME",...}. sed_add_newlines='s/},{"file_name":/},\ {"file_name":/g' sed_extract_filename='s/^.*{"file_name":"\([^"]*\)",.*$/\1/p' { sed -e "$sed_add_newlines" < "$tmp"/_all_.json; echo; } \ | sed -n -e "$sed_extract_filename" \ | while read filename; do case "$filename" in *.po) if test -n "$subdir"; then subdir_filename="$subdir/$filename" else subdir_filename="$filename" fi file_url="${base_url}-/raw/${branch}/${subdir_filename}" wget -O "$tmp/$filename" --no-verbose "$file_url" \ || func_exit 1 ;; esac done rm -f "$tmp"/_all_.json } # func_fetch_github BASE-URL BRANCH SUBDIR # fetches a set of PO files from a git repository in a GitHub instance # browsable through some web UI. # Input: # - tmp an empty temporary directory func_fetch_github () { base_url="$1" branch="$2" subdir="$3" # Ensure $base_url ends in a slash. case "$base_url" in */) ;; *) base_url="${base_url}/" ;; esac webui_url="${base_url}tree/${branch}" if test -n "$subdir"; then webui_url="${webui_url}/${subdir}" fi # The contents of this URL is an HTML page with a list of files. # But a web request with a much smaller response, that also contains the list # of files, is: filelist_url="${base_url}tree-commit-info/${branch}" if test -n "$subdir"; then filelist_url="${filelist_url}/${subdir}" fi # Download it. # The 'Accept' header is necessary to avoid HTTP status 400 (Bad Request). # Option '--no-verbose' avoids too much chatter. wget -O "$tmp"/_all_.json --header="Accept: application/json" --no-verbose "$filelist_url" \ || func_exit 1 # For each file, there is a JSON object element "FILENAME":{"oid":...}. sed_add_newlines='s/},"\([^"]*\)":{"oid":/},\ "\1":{"oid":/g' sed_extract_filename='s/^[^"]*"\([^"]*\)":.*/\1/p' { sed -e "$sed_add_newlines" < "$tmp"/_all_.json; echo; } \ | sed -n -e "$sed_extract_filename" \ | while read filename; do case "$filename" in *.po) if test -n "$subdir"; then subdir_filename="$subdir/$filename" else subdir_filename="$filename" fi file_url="${base_url}raw/refs/heads/${branch}/${subdir_filename}" wget -O "$tmp/$filename" --no-verbose "$file_url" \ || func_exit 1 ;; esac done rm -f "$tmp"/_all_.json } # func_fetch_forgejo BASE-URL BRANCH SUBDIR # fetches a set of PO files from a git repository in a Forgejo instance # browsable through some web UI. # Input: # - tmp an empty temporary directory func_fetch_forgejo () { base_url="$1" branch="$2" subdir="$3" # Ensure $base_url ends in a slash. case "$base_url" in */) ;; *) base_url="${base_url}/" ;; esac base_url_path=`echo "$base_url" | sed -e 's|^[^/]*//[^/]*||'` webui_url="${base_url}src/branch/${branch}" if test -n "$subdir"; then webui_url="${webui_url}/${subdir}" fi # The contents of this URL is an HTML page with a list of files. # Download it. # Option '--no-verbose' avoids too much chatter. wget -O "$tmp"/_all_.html --no-verbose "$webui_url" \ || func_exit 1 # For each file, there is an HTML element # expected_href_prefix="${base_url_path}src/branch/${branch}" if test -n "$subdir"; then expected_href_prefix="${expected_href_prefix}/${subdir}" fi expected_href_prefix="${expected_href_prefix}/" sed -n -e 's/^.*]* href="\([^"]*\).*$/\1/p' < "$tmp"/_all_.html \ | while read anchor; do : echo "anchor=$anchor" case "$anchor" in "$expected_href_prefix"*) filename=`echo "$anchor" | sed -e "s|^${expected_href_prefix}||"` : echo "filename=$filename" case "$filename" in *.po) if test -n "$subdir"; then subdir_filename="$subdir/$filename" else subdir_filename="$filename" fi file_url="${base_url}raw/branch/${branch}/${subdir_filename}" wget -O "$tmp/$filename" --no-verbose "$file_url" \ || func_exit 1 ;; esac ;; esac done rm -f "$tmp"/_all_.html } # Unset CDPATH. Otherwise, output from 'cd dir' can surprise callers. (unset CDPATH) >/dev/null 2>&1 && unset CDPATH # Command-line option processing. # Removes the OPTIONS from the arguments. Sets the variables: # - kind denotes the kind of translation project # - do_git true if changes should be staged in git, false otherwise { kind= do_git=false while test $# -gt 0; do case "$1" in --git | --gi | --g ) shift do_git=true ;; --help | --hel | --he | --h ) func_usage func_exit $? ;; --version | --versio | --versi | --vers | --ver | --ve | --v ) func_version func_exit $? ;; -- ) # Stop option processing shift break ;; -* ) echo "po-fetch: unknown option $1" 1>&2 echo "Try 'po-fetch --help' for more information." 1>&2 func_exit 1 ;; * ) break ;; esac done } if test $# = 0; then echo "po-fetch: missing argument" 1>&2 echo "Try 'po-fetch --help' for more information." 1>&2 func_exit 1 fi kind="$1" shift # Create a temporary directory, and prepare for cleaning it up. func_tmpdir trap 'exit_status=$? if test "$signal" != EXIT; then echo "caught signal SIG$signal" >&2 fi rm -rf "$tmp" exit $exit_status' EXIT for signal in HUP INT QUIT PIPE TERM; do trap '{ signal='$signal'; func_exit 1; }' $signal done signal=EXIT # Fetch the PO files into the temporary directory. case "$kind" in TP) if test $# -lt 1; then echo "po-fetch: missing argument" 1>&2 echo "Try 'po-fetch --help' for more information." 1>&2 func_exit 1 fi func_fetch_TP "$@" ;; Weblate) if test $# -lt 3; then echo "po-fetch: missing argument" 1>&2 echo "Try 'po-fetch --help' for more information." 1>&2 func_exit 1 fi func_fetch_weblate "$@" ;; git) if test $# -lt 3; then echo "po-fetch: missing argument" 1>&2 echo "Try 'po-fetch --help' for more information." 1>&2 func_exit 1 fi # Download the contents of the BASE-URL. # Option '--no-verbose' avoids too much chatter. wget -O "$tmp"/_meta_.html --no-verbose "$1" \ || func_exit 1 # Analyze it, to find the supported protocol. protocol= sed_extract_generator_1='s/^.*&2 echo "Try 'po-fetch --help' for more information." 1>&2 func_exit 1 ;; esac ;; *) echo "po-fetch: invalid first argument '$kind'" 1>&2 echo "Try 'po-fetch --help' for more information." 1>&2 func_exit 1 ;; esac # Eliminate broken PO files. (cd "$tmp" for file in *.po; do if test -f "$file"; then # Check against encoding error. if msgcat -t UTF-8 "$file" 2>&1 >/dev/null | grep .; then echo "po-fetch: warning: $file has an encoding error; skipping it" 1>&2 rm -f "$file" else # Check against other errors. if msgfmt -c -o /dev/null "$file"; then # Check whether it contains some translations at all. if msgfmt --statistics -o /dev/null "$file" 2>&1 | grep '^0 ' >/dev/null; then echo "po-fetch: note: $file has no translations; skipping it" 1>&2 rm -f "$file" fi else echo "po-fetch: warning: $file has errors; skipping it" 1>&2 rm -f "$file" fi fi fi done ) # Backup the LINGUAS file. if test -f LINGUAS; then mv LINGUAS LINGUAS~ fi # Create the new LINGUAS file. { echo '# Set of available languages.' echo '# This file is automatically generated from the list of PO files.' (cd "$tmp" for file in *.po; do if test -f "$file"; then echo "$file" fi done ) | sed -e 's/\.po$//' | LC_ALL=C sort } > LINGUAS \ || func_exit 1 if $do_git; then git add LINGUAS fi # Update existing PO files. Remove outdated PO files. for file in *.po; do if test -f "$file"; then if test -f "$tmp/$file"; then mv "$tmp/$file" "$file" || func_exit 1 if $do_git; then git add "$file" fi else rm -f "$file" if $do_git; then git rm "$file" fi fi fi done # Add new PO files. for tmpf in "$tmp"/*.po; do if test -f "$tmpf"; then file=`echo "$tmpf" | sed -e 's|^.*/||'` mv "$tmpf" "$file" || func_exit 1 if $do_git; then git add "$file" fi fi done # Examples for testing: # $ po-fetch TP hello # $ po-fetch Weblate https://translate.codeberg.org/ gnuhealth health_dentistry # $ po-fetch Weblate https://translate.codeberg.org/ lazarr lzr_gui # $ po-fetch git https://gitweb.git.savannah.gnu.org/gitweb/?p=gettext.git master gettext-runtime/po # $ po-fetch git https://cgit.git.savannah.gnu.org/cgit/gettext.git master gettext-runtime/po # $ po-fetch git https://gitlab.com/qemu-project/qemu master po # $ po-fetch git https://github.com/autotools-mirror/gettext/ master gettext-runtime/po # $ po-fetch git https://codeberg.org/guix/translations/ master po/guix