Files
felhom-controller/controller/internal/i18n/format.go
T
admin fd28d70bff v0.308.0: controller batch 2026-10-12 — clear-address warning (R-922), secret sweep over every page (R-255), read_failed (R-266), no NVMe heat band (R-333), share globe (R-577), Hungarian dates and numbers (R-562), lost-drive option pinned (R-521)
Operator rulings 2026-10-12, 09 §3 decisions 196, 204-207, 223, 227. MinAgent 0.131.0 unchanged.
R-255's sweep found one leak, fixed here: an optional-config secret_input was rendered into its
settings page; it is now revealed on demand. Hungarian parity fixtures re-captured once for R-562.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-12 15:13:59 +02:00

87 lines
3.0 KiB
Go

package i18n
import (
"strconv"
"strings"
"time"
)
// ── Dates and numbers per language (R-562, v0.308.0, `09` §3 decision 223) ────────────────────────
//
// The ONE place the dashboard decides how a date, a time or a decimal number looks:
//
// Hungarian 2026. 10. 10. 15:04 1,5 GB (the Hungarian date order with its dots; decimal COMMA)
// English 2026-10-10 15:04 1.5 GB (ISO-like; decimal point)
//
// Every function takes the time ALREADY in the zone it is shown in (the dashboard shows Budapest
// local time; the callers convert). An unknown language formats as Hungarian, like every other
// fallback in this package. Pinned by TestFormat_* in format_test.go.
// FormatDateTime renders a date with hour and minute.
func FormatDateTime(lang string, t time.Time) string {
if Normalize(lang) == "en" {
return t.Format("2006-01-02 15:04")
}
return t.Format("2006. 01. 02. 15:04")
}
// FormatDate renders a date alone.
func FormatDate(lang string, t time.Time) string {
if Normalize(lang) == "en" {
return t.Format("2006-01-02")
}
return t.Format("2006. 01. 02.")
}
// FormatMonthDayTime renders a date in the current year: month, day, hour and minute.
func FormatMonthDayTime(lang string, t time.Time) string {
if Normalize(lang) == "en" {
return t.Format("01-02 15:04")
}
return t.Format("01. 02. 15:04")
}
// FormatDecimal renders v with prec digits after the decimal separator of lang.
func FormatDecimal(lang string, v float64, prec int) string {
s := strconv.FormatFloat(v, 'f', prec, 64)
if Normalize(lang) == "en" {
return s
}
return strings.Replace(s, ".", ",", 1)
}
// LocalizeDecimals rewrites the decimal POINT of every number in an already formatted short string
// ("1.5 GB", "Immich (95.0 GB)") into lang's separator. Only a point with a digit on BOTH sides is
// touched, so a sentence's full stop, a version ("0.307.0" — see below) or an address is not a target
// by accident: a dot preceded by a digit and followed by a digit is rewritten only when the number it
// belongs to has exactly ONE dot. English returns s unchanged.
//
// For strings another package formatted with %.1f (appbackup.HumanizeBytes and its siblings) and
// handed to a page as data. A new formatter should call FormatDecimal instead.
func LocalizeDecimals(lang, s string) string {
if Normalize(lang) == "en" || !strings.Contains(s, ".") {
return s
}
b := []byte(s)
isDigit := func(c byte) bool { return c >= '0' && c <= '9' }
for i := 1; i < len(b)-1; i++ {
if b[i] != '.' || !isDigit(b[i-1]) || !isDigit(b[i+1]) {
continue
}
// The whole digit-and-dot run around this dot: more than one dot is a version or an address.
start, end := i, i
for start > 0 && (isDigit(b[start-1]) || b[start-1] == '.') {
start--
}
for end < len(b)-1 && (isDigit(b[end+1]) || b[end+1] == '.') {
end++
}
if strings.Count(string(b[start:end+1]), ".") != 1 {
i = end
continue
}
b[i] = ','
}
return string(b)
}