215 lines
8.4 KiB
Bash
Executable File
215 lines
8.4 KiB
Bash
Executable File
#!/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 <locale>
|
|
# panama-locale categories -> the category names, one per line
|
|
# panama-locale overrides -> {LC_TIME: "...", ...}, "" for none
|
|
# panama-locale get <category> -> the override, or "" for "match language"
|
|
# panama-locale set <category> <locale|"">
|
|
#
|
|
# 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" =~ ^[[email protected]]+$ ]] || {
|
|
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] <locale>]\n' >&2
|
|
exit 2
|
|
;;
|
|
esac
|