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
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// R-562 (`09` §3 decision 223): Hungarian dates and numbers in Hungarian, ISO-like in English.
|
||||
func TestFormat_DatesPerLanguage(t *testing.T) {
|
||||
at := time.Date(2026, 10, 10, 15, 4, 0, 0, time.UTC)
|
||||
cases := []struct{ name, got, want string }{
|
||||
{"hu datetime", FormatDateTime("hu", at), "2026. 10. 10. 15:04"},
|
||||
{"en datetime", FormatDateTime("en", at), "2026-10-10 15:04"},
|
||||
{"hu date", FormatDate("hu", at), "2026. 10. 10."},
|
||||
{"en date", FormatDate("en", at), "2026-10-10"},
|
||||
{"hu month-day", FormatMonthDayTime("hu", at), "10. 10. 15:04"},
|
||||
{"en month-day", FormatMonthDayTime("en", at), "10-10 15:04"},
|
||||
{"unknown language falls back to Hungarian", FormatDateTime("xx", at), "2026. 10. 10. 15:04"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if c.got != c.want {
|
||||
t.Errorf("%s: got %q, want %q", c.name, c.got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormat_NumbersPerLanguage(t *testing.T) {
|
||||
cases := []struct{ name, got, want string }{
|
||||
{"hu decimal comma", FormatDecimal("hu", 1.53, 1), "1,5"},
|
||||
{"en decimal point", FormatDecimal("en", 1.53, 1), "1.5"},
|
||||
{"hu integer has no separator", FormatDecimal("hu", 12, 0), "12"},
|
||||
{"hu size string", LocalizeDecimals("hu", "1.5 GB"), "1,5 GB"},
|
||||
{"hu list of sizes", LocalizeDecimals("hu", "Immich (95.0 GB), Nextcloud (34.0 GB)"), "Immich (95,0 GB), Nextcloud (34,0 GB)"},
|
||||
{"en untouched", LocalizeDecimals("en", "1.5 GB"), "1.5 GB"},
|
||||
{"hu sentence full stop untouched", LocalizeDecimals("hu", "Kész. 3 elem."), "Kész. 3 elem."},
|
||||
{"hu version untouched", LocalizeDecimals("hu", "v0.307.0 és 1.5 GB"), "v0.307.0 és 1,5 GB"},
|
||||
{"hu address untouched", LocalizeDecimals("hu", "192.168.0.180"), "192.168.0.180"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if c.got != c.want {
|
||||
t.Errorf("%s: got %q, want %q", c.name, c.got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1508,6 +1508,7 @@
|
||||
"flash.share.disabled": "Sharing is off.",
|
||||
"flash.share.enable_failed": "Sharing could not be turned on.",
|
||||
"flash.share.enabled": "Sharing is on.",
|
||||
"flash.share.wrong_password": "Wrong password",
|
||||
"flash.share.not_on": "Sharing is not on.",
|
||||
"flash.share.pw_clear_failed": "The password could not be removed.",
|
||||
"flash.share.pw_cleared": "The share password is removed.",
|
||||
@@ -1885,7 +1886,7 @@
|
||||
"recovery.adatok": "Retrieve",
|
||||
"recovery.adatok_visszaszerzese_felhom": "Retrieve data — Felhom",
|
||||
"recovery.alkalmazas": "App",
|
||||
"recovery.amelyet_zartunk_le": ", which we\n sealed on <strong>{{.}}</strong>",
|
||||
"recovery.amelyet_zartunk_le": ", which we\n sealed on <strong>{{fmtTimeStr .}}</strong>",
|
||||
"recovery.biztosan_nem_kered_vissza_a": "Are you sure you do not want the earlier backups back?",
|
||||
"recovery.elerhetok": "are available",
|
||||
"recovery.ez_a_lehetoseg_akkor_valik": "this option becomes available once the machine has reconnected to the off-site\n storage. Until then your backups stay untouched.",
|
||||
@@ -1943,6 +1944,8 @@
|
||||
"settings_notifications.beallitasok_ertesitesek": "Settings — Notifications",
|
||||
"settings_notifications.bekapcsolas_utan_az_egyes_alkalmazasokna": "After turning it on, allow sending e-mail for each app too (on the app’s page).",
|
||||
"settings_notifications.biztonsagi_mentes_sikertelen": "Backup failed",
|
||||
"settings_notifications.clear_confirm": "Yes, remove the address",
|
||||
"settings_notifications.clear_warning": "If you remove the notification e-mail address, you will get no more notification e-mails. Nothing is saved until you confirm.",
|
||||
"settings_notifications.e_mail_cim": "E-mail address",
|
||||
"settings_notifications.elvart_adatbazismentes_elmaradt": "Expected database backup missed",
|
||||
"settings_notifications.elvart_fajlmentes_elmaradt": "Expected file backup missed",
|
||||
@@ -2010,7 +2013,7 @@
|
||||
"settings_security.uj_jelszo": "New password",
|
||||
"settings_security.uj_jelszo_megerositese": "Confirm new password",
|
||||
"settings_security.utolso": "Last:",
|
||||
"settings_security.utolso_szinkronizalas": "Last sync: {{.GeoLastSync}}",
|
||||
"settings_security.utolso_szinkronizalas": "Last sync: {{fmtTimeStr .GeoLastSync}}",
|
||||
"settings_security.veszhelyzeti_informaciok": "Emergency information",
|
||||
"settings_security.visszaallitasi_jelszo": "Retrieval password",
|
||||
"settings_system.a_kiszolgalo_ugynok_jelenleg_nem": "The host agent is not reachable right now — try again later.",
|
||||
@@ -2251,7 +2254,7 @@
|
||||
"storage.hiba": "Error:",
|
||||
"storage.host_tarhely_rendszer_sablonok_iso": "Host storage — system templates, ISOs, host-level backups. It holds no app data.",
|
||||
"storage.hozzaadas": "Add",
|
||||
"storage.idopont": "Time: {{.DecommissionedAt}}",
|
||||
"storage.idopont": "Time: {{fmtTimeStr .DecommissionedAt}}",
|
||||
"storage.inaktiv": "Inactive",
|
||||
"storage.ismeretlen": "unknown",
|
||||
"storage.kesz": "Done",
|
||||
|
||||
@@ -1499,6 +1499,7 @@
|
||||
"flash.share.disabled": "A megosztás kikapcsolva.",
|
||||
"flash.share.enable_failed": "A megosztás bekapcsolása nem sikerült.",
|
||||
"flash.share.enabled": "A megosztás bekapcsolva.",
|
||||
"flash.share.wrong_password": "Hibás jelszó",
|
||||
"flash.share.not_on": "A megosztás nincs bekapcsolva.",
|
||||
"flash.share.pw_clear_failed": "A jelszó törlése nem sikerült.",
|
||||
"flash.share.pw_cleared": "A megosztási jelszó törölve.",
|
||||
@@ -1873,7 +1874,7 @@
|
||||
"recovery.adatok": "Adatok",
|
||||
"recovery.adatok_visszaszerzese_felhom": "Adatok visszaszerzése — Felhom",
|
||||
"recovery.alkalmazas": "Alkalmazás",
|
||||
"recovery.amelyet_zartunk_le": ", amelyet\n <strong>{{.}}</strong> zártunk le",
|
||||
"recovery.amelyet_zartunk_le": ", amelyet\n <strong>{{fmtTimeStr .}}</strong> zártunk le",
|
||||
"recovery.biztosan_nem_kered_vissza_a": "Biztosan nem kéred vissza a korábbi mentéseket?",
|
||||
"recovery.elerhetok": "elérhetők",
|
||||
"recovery.ez_a_lehetoseg_akkor_valik": "ez a lehetőség akkor válik elérhetővé, ha a gép már újra kapcsolódott a házon kívüli\n tárhelyhez. Addig a mentéseid érintetlenül megmaradnak.",
|
||||
@@ -1931,6 +1932,8 @@
|
||||
"settings_notifications.beallitasok_ertesitesek": "Beállítások — Értesítések",
|
||||
"settings_notifications.bekapcsolas_utan_az_egyes_alkalmazasokna": "Bekapcsolás után az egyes alkalmazásoknál is engedélyezni kell az email-küldést (az alkalmazás oldalán).",
|
||||
"settings_notifications.biztonsagi_mentes_sikertelen": "Biztonsági mentés sikertelen",
|
||||
"settings_notifications.clear_confirm": "Igen, törlöm a címet",
|
||||
"settings_notifications.clear_warning": "Ha törlöd az értesítési e-mail-címet, nem kapsz több értesítő levelet. A mentés csak akkor történik meg, ha megerősíted.",
|
||||
"settings_notifications.e_mail_cim": "E-mail cím",
|
||||
"settings_notifications.elvart_adatbazismentes_elmaradt": "Elvárt adatbázismentés elmaradt",
|
||||
"settings_notifications.elvart_fajlmentes_elmaradt": "Elvárt fájlmentés elmaradt",
|
||||
@@ -1998,7 +2001,7 @@
|
||||
"settings_security.uj_jelszo": "Új jelszó",
|
||||
"settings_security.uj_jelszo_megerositese": "Új jelszó megerősítése",
|
||||
"settings_security.utolso": "Utolsó:",
|
||||
"settings_security.utolso_szinkronizalas": "Utolsó szinkronizálás: {{.GeoLastSync}}",
|
||||
"settings_security.utolso_szinkronizalas": "Utolsó szinkronizálás: {{fmtTimeStr .GeoLastSync}}",
|
||||
"settings_security.veszhelyzeti_informaciok": "Vészhelyzeti információk",
|
||||
"settings_security.visszaallitasi_jelszo": "Visszaállítási jelszó",
|
||||
"settings_system.a_kiszolgalo_ugynok_jelenleg_nem": "A kiszolgáló ügynök jelenleg nem elérhető — próbáld meg később.",
|
||||
@@ -2239,7 +2242,7 @@
|
||||
"storage.hiba": "Hiba:",
|
||||
"storage.host_tarhely_rendszer_sablonok_iso": "Host tárhely — rendszer-sablonok, ISO-k, host szintű mentések. Nem tárol alkalmazásadatot.",
|
||||
"storage.hozzaadas": "Hozzáadás",
|
||||
"storage.idopont": "Időpont: {{.DecommissionedAt}}",
|
||||
"storage.idopont": "Időpont: {{fmtTimeStr .DecommissionedAt}}",
|
||||
"storage.inaktiv": "Inaktív",
|
||||
"storage.ismeretlen": "ismeretlen",
|
||||
"storage.kesz": "Kész",
|
||||
|
||||
Reference in New Issue
Block a user