Finish the wonderland: System told truthfully, in eight tabs instead of ten
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -2,9 +2,25 @@
|
||||
|
||||
# System locale, via localectl.
|
||||
#
|
||||
# panama-locale list -> [{value, label, detail}]
|
||||
# panama-locale get -> the current LANG, e.g. en_US.UTF-8
|
||||
# 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
|
||||
@@ -24,11 +40,63 @@ 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() {
|
||||
localectl status 2>/dev/null \
|
||||
| awk -F'LANG=' '/System Locale:/ { print $2; exit }' \
|
||||
| tr -d '[:space:]'
|
||||
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() {
|
||||
@@ -75,10 +143,11 @@ cmd_list() {
|
||||
' <<<"$locales"
|
||||
}
|
||||
|
||||
cmd_set() {
|
||||
local locale="${1:-}"
|
||||
# Constrained rather than passed through: this reaches a privileged
|
||||
# command, and the set of legal locale names is narrow and well known.
|
||||
# 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
|
||||
@@ -87,12 +156,59 @@ cmd_set() {
|
||||
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) cmd_get ;;
|
||||
set) shift; cmd_set "${1:-}" ;;
|
||||
*) printf 'usage: panama-locale [list|get|set <locale>]\n' >&2; exit 2 ;;
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user