#!/usr/bin/env bash # System locale, via localectl. # # panama-locale list -> [{value, label, detail}] # panama-locale get -> the current LANG, e.g. en_US.UTF-8 # panama-locale set # panama-locale categories -> the category names, one per line # panama-locale overrides -> {LC_TIME: "...", ...}, "" for none # panama-locale get -> the override, or "" for "match language" # panama-locale set # # The categories are the five that a person actually chooses independently of # their language: LC_TIME, LC_NUMERIC, LC_MONETARY, LC_MEASUREMENT, LC_PAPER. # Someone reading in English while writing dates and currency the way their # country does is the ordinary case, not an exotic one. # # An empty value means "match language", which is the ABSENCE of an override # rather than a value equal to LANG -- the two behave the same today and # diverge the moment the language changes. localectl replaces /etc/locale.conf # with exactly the assignments it is given, so unsetting one category means # re-issuing all the others. That is why cmd_set_category reads the current # file first: passing only the survivors is the only way to remove one. # # Locale codes are not names. "pt_BR.UTF-8" tells you what it means only if you # already know, which defeats the point of a picker, so codes are resolved # against the iso-codes database into "Portuguese (Brazil)" the way GNOME does. # The code stays visible as the row's detail, because it is what actually gets # written and someone choosing between two Spanish variants needs to see it. # # The join happens in a single jq pass. Doing it per locale meant 327 jq # invocations, which took long enough to be visible when opening the page. # # Setting the locale is a privileged operation: localectl goes through polkit, # which prompts. It also only takes effect for programs started afterwards, so # the caller is responsible for saying a sign-out is needed -- this script does # not pretend the running session changed. set -uo pipefail readonly ISO_LANG=/usr/share/iso-codes/json/iso_639-2.json readonly ISO_COUNTRY=/usr/share/iso-codes/json/iso_3166-1.json readonly LOCALE_CONF=/etc/locale.conf readonly CATEGORIES=(LC_TIME LC_NUMERIC LC_MONETARY LC_MEASUREMENT LC_PAPER) is_category() { local candidate="$1" name for name in "${CATEGORIES[@]}"; do [[ "$name" == "$candidate" ]] && return 0 done return 1 } # LANG plus every category override, as VAR=value lines. /etc/locale.conf is # what localectl writes and is world-readable, so it is read directly rather # than scraped out of `localectl status`, whose multi-variable output wraps # across continuation lines and has no stable machine form. current_assignments() { [[ -r "$LOCALE_CONF" ]] || return 0 while IFS= read -r line; do line="${line%%#*}" [[ "$line" =~ ^[[:space:]]*(LANG|LANGUAGE|LC_[A-Z_]+)=(.*)$ ]] || continue local name="${BASH_REMATCH[1]}" value="${BASH_REMATCH[2]}" # locale.conf quotes values; localectl takes them bare. value="${value%\"}"; value="${value#\"}" value="${value%\'}"; value="${value#\'}" [[ -n "$value" ]] && printf '%s=%s\n' "$name" "$value" done <"$LOCALE_CONF" } cmd_get() { local name="${1:-LANG}" if [[ "$name" != "LANG" ]] && ! is_category "$name"; then printf 'panama-locale: %s is not a category this manages\n' "$name" >&2 return 2 fi local value value="$(current_assignments | awk -F= -v want="$name" '$1 == want { print $2; exit }')" # A machine with no /etc/locale.conf still has a LANG, and localectl is the # one that knows it. Categories have no such fallback: absent there means # absent, which is exactly "match language". if [[ -z "$value" && "$name" == "LANG" ]]; then value="$(localectl status 2>/dev/null \ | awk -F'LANG=' '/System Locale:/ { print $2; exit }' \ | tr -d '[:space:]')" fi printf '%s\n' "$value" } # Every category override in one answer: {"LC_TIME": "de_DE.UTF-8", ...} with # "" for the ones that match the language. Five separate `get` calls would be # five processes for one screenful of state. cmd_overrides() { local assignments name assignments="$(current_assignments)" { for name in "${CATEGORIES[@]}"; do printf '%s\t%s\n' "$name" \ "$(awk -F= -v want="$name" '$1 == want { print $2; exit }' <<<"$assignments")" done; } | jq -Rn '[inputs | split("\t") | {key: .[0], value: (.[1] // "")}] | from_entries' } cmd_list() { local locales locales="$(localectl list-locales 2>/dev/null)" || locales="" if [[ -z "$locales" ]]; then printf '[]\n' return 0 fi # Without iso-codes installed the codes are still perfectly usable; they # just do not get friendly names. That is a degraded list, not a failure. if [[ ! -r "$ISO_LANG" || ! -r "$ISO_COUNTRY" ]]; then jq -Rn --rawfile raw /dev/stdin \ '[$raw | split("\n")[] | select(length > 0) | {value: ., label: ., detail: ""}]' \ <<<"$locales" return 0 fi jq -Rn \ --slurpfile languages "$ISO_LANG" \ --slurpfile countries "$ISO_COUNTRY" \ --rawfile raw /dev/stdin ' # alpha_2 -> name, for both databases. Languages without a two-letter # code cannot appear in a locale name, so they are simply absent. ($languages[0]["639-2"] | map(select(.alpha_2)) | INDEX(.alpha_2) | map_values(.name)) as $lang | ($countries[0]["3166-1"] | INDEX(.alpha_2) | map_values(.name)) as $country | [ $raw | split("\n")[] | select(length > 0) | . as $value # en_US.UTF-8 -> ["en", "US"]; the codeset and any @modifier are # not part of the human name. | ($value | split(".")[0] | split("@")[0] | split("_")) as $parts | ($lang[$parts[0]] // $parts[0]) as $language | (if ($parts | length) > 1 then $country[$parts[1]] else null end) as $region | { value: $value, label: (if $region then "\($language) (\($region))" else $language end), detail: $value } ] | sort_by(.label) ' <<<"$locales" } # A locale name that is both well formed and actually installed. Everything # reaching localectl goes through this: it is a privileged command, and the set # of legal locale names is narrow and well known. installed_locale() { local locale="$1" [[ "$locale" =~ ^[a-zA-Z0-9_@.-]+$ ]] || { printf 'panama-locale: refusing a locale name with unexpected characters\n' >&2 return 2 } localectl list-locales 2>/dev/null | grep -qxF "$locale" || { printf 'panama-locale: %s is not an installed locale\n' "$locale" >&2 return 2 } } cmd_set() { local locale="${1:-}" installed_locale "$locale" || return 2 # LANG is set on its own rather than through the rewrite path: changing the # language must not quietly drop category overrides somebody chose, and # localectl merges a lone LANG= assignment into the existing file. localectl set-locale "LANG=$locale" } cmd_set_category() { local name="${1:-}" locale="${2:-}" is_category "$name" || { printf 'panama-locale: %s is not a category this manages\n' "$name" >&2 return 2 } local assignments=() line while IFS= read -r line; do [[ "${line%%=*}" == "$name" ]] && continue assignments+=("$line") done < <(current_assignments) if [[ -n "$locale" ]]; then installed_locale "$locale" || return 2 assignments+=("$name=$locale") fi # An empty file would leave the system with no LANG at all. Nothing here # should be able to produce that, but refusing is cheaper than explaining. (( ${#assignments[@]} > 0 )) || { printf 'panama-locale: refusing to clear every locale setting\n' >&2 return 2 } localectl set-locale "${assignments[@]}" } case "${1:-list}" in list) cmd_list ;; get) shift; cmd_get "${1:-LANG}" ;; set) shift if is_category "${1:-}"; then cmd_set_category "${1:-}" "${2:-}" else cmd_set "${1:-}" fi ;; categories) printf '%s\n' "${CATEGORIES[@]}" ;; overrides) cmd_overrides ;; *) printf 'usage: panama-locale [list|categories|overrides|get [category]|set [category] ]\n' >&2 exit 2 ;; esac