Files
felhom-controller/controller/internal/util/msgerr.go
T
admin 48f3336956
gates / gates (push) Successful in 23s
v0.254.0 — the saved notes follow the language, and the switch becomes a globe (R-557 slice 2 release C; SLICE 2 CLOSED)
The notes a background run SAVES — last night's backup line, the last error, the proof
result, the restore outcome — are written in the BOX's language at the moment they are
written. A household that switches sees the previous run's note in the old language until
the next run rewrites it: the operator's §16 option 1, stated rather than hidden.
EndRestoreOp no longer receives a Hungarian literal from anywhere.

The language switch is a globe. Two text links wrapped in the sidebar footer and asked the
reader to recognise "Magyar"/"English" as links; a globe is the one symbol every web user
already reads as "language", so nobody has to read Hungarian to escape Hungarian. It is
<details>/<summary> — a menu with no script, drawn inline because the icon sprite lives
only in layout.html and the visitor pages have their own shell.

Those visitor pages get the same globe, and a visitor's choice stays theirs: a display-only
felhom_lang cookie that langFor reads ONLY when there is no session. A signed-in household
can never inherit a language a previous visitor picked in the same browser. POST /lang is
CSRF-exempt for a narrow reason written at the exemption — its only achievable effect is the
language of the page the victim's own browser shows them — and safeBackPath refuses
//evil.example as well as https://, because "starts with /" alone is not the test. §16 taken:
a successful claim carries the cookie into the household's setting.

TWO PARITY EXCEPTIONS, MEASURED: 106 fixtures compared with a real diff — exactly two change
shapes (the dashboard footer, the globe in the shells) and 5 byte-identical, which are the
three pages that must not change.

I INTRODUCED A DEADLOCK AND THE SUITE CAUGHT IT BY HANGING. UpdateOffboxStatus holds the
settings write lock while running its callback; boxLang() wants the read lock; sync.RWMutex
is not reentrant. On a real box an off-site run would have hung forever HOLDING the settings
lock. Fixed by resolving the language before the callback, and guarded by a test that names
the file and line in a second instead of hanging for 25 minutes.

MinAgent: 0.131.0 (unchanged). No hub release needed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-18 14:19:31 +02:00

174 lines
6.9 KiB
Go

package util
import (
"errors"
"fmt"
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
)
// ── An error that carries the KEY of the sentence it is, not only the sentence ────────────────────
//
// Localisation slice 2 release B (R-557). 179 Hungarian sentences in this product are built deep
// inside a package with `fmt.Errorf` and printed by whoever catches them — a page, a JSON answer, a
// flash. They cannot be translated where they are SHOWN, because by then they are a finished string;
// and they must not be translated where they are MADE, because that code has no request and no
// language. So the error carries its key and the display end renders it.
//
// return util.MsgErrorf(stacks.ErrRequiredField, "deploy.field_required", label)
// ...
// http.Error(w, s.errText(r, err), 400) // the household's language
// if errors.Is(err, stacks.ErrRequiredField) { … } // still true — R-553's signal is intact
//
// THREE PROPERTIES, each earned:
//
// 1. `Error()` RETURNS THE HUNGARIAN TEXT, byte for byte what `fmt.Errorf` produced. Every printer
// that has not been converted — a log line, a third-party wrapper, `%v` in a Printf — keeps
// printing exactly what it printed before. That is what makes converting 179 producers safe
// without converting all their printers in the same commit, and it is what the parity gate
// measures.
// 2. `errors.Is` STILL WORKS, for the kind AND for a wrapped cause. `Unwrap() []error` returns the
// kind first and then any error among the arguments, so `errors.Is(err, ErrRequiredField)` and
// `errors.Is(err, os.ErrNotExist)` are both answerable. The predecessor `KindErrorf` returned the
// kind alone and dropped the cause; this does not.
// 3. AN ERROR ARGUMENT IS RENDERED RECURSIVELY. `fmt.Errorf("formázás sikertelen: %w", err)` is a
// sentence wrapping a sentence. Passing the inner error as an ARGUMENT (not as pre-rendered text)
// lets `Text(lang, …)` translate the whole chain — the outer in the household's language and the
// inner too, when the inner also carries a key. An inner error with no key prints itself, which
// is the right answer for a restic or docker message that is not ours to translate.
// MsgErrorf builds an error whose message is the bundle's `key` filled with `args`.
//
// `kind` may be nil. When it is not, it is the sentinel `errors.Is` tests — the same contract
// KindErrorf established (R-553), and the reason a converted producer does not break a decision.
func MsgErrorf(kind error, key string, args ...interface{}) error {
return &msgError{kind: kind, key: key, args: args}
}
// MsgError is MsgErrorf with no kind, for a producer nothing branches on.
func MsgError(key string, args ...interface{}) error {
return &msgError{key: key, args: args}
}
type msgError struct {
kind error
key string
args []interface{}
}
// Error returns the Hungarian sentence — the default language, and the bytes the literal carried.
func (e *msgError) Error() string { return e.Text(i18n.Default) }
// Text renders the message in lang, rendering any error argument that also carries a key.
func (e *msgError) Text(lang string) string {
b, err := i18n.Shared()
if err != nil {
return e.key
}
if len(e.args) == 0 {
return b.Msg(lang, e.key)
}
out := make([]interface{}, len(e.args))
for i, a := range e.args {
if inner, ok := a.(error); ok {
out[i] = ErrText(lang, inner)
continue
}
out[i] = a
}
return b.Msgf(lang, e.key, out...)
}
// Key returns the bundle key, so a caller can name it without rendering it.
func (e *msgError) Key() string { return e.key }
// Args returns the message's parameters as strings, for a carrier that can only hold text — the
// `fa` parameters of a flash in a redirect URL.
//
// An ERROR argument is rendered in HUNGARIAN here, deliberately and with a known cost: the carrier is
// written by one request and read by another, so the writer cannot know the reader's language, and a
// nested foreign sentence (restic, docker) is not translatable anyway. The OUTER message still
// follows the reader's language, which is the sentence that carries the meaning.
func (e *msgError) Args() []string {
out := make([]string, 0, len(e.args))
for _, a := range e.args {
if inner, ok := a.(error); ok {
out = append(out, ErrText(i18n.Default, inner))
continue
}
out = append(out, fmt.Sprintf("%v", a))
}
return out
}
// Unwrap returns the kind FIRST and then every error among the arguments, so `errors.Is` answers for
// the decision signal and for the cause alike.
func (e *msgError) Unwrap() []error {
var out []error
if e.kind != nil {
out = append(out, e.kind)
}
for _, a := range e.args {
if inner, ok := a.(error); ok && inner != nil {
out = append(out, inner)
}
}
return out
}
// Msg is the interface a message-carrying error satisfies. Declared so a caller can test for the
// capability rather than for this concrete type — the point is "does this error know its key", not
// "was it built by this constructor".
type Msg interface {
error
Text(lang string) string
Key() string
Args() []string
}
// AsMsg reports whether err (or anything it wraps) carries a message key, and returns it.
func AsMsg(err error) (Msg, bool) {
var m Msg
if errors.As(err, &m) {
return m, true
}
return nil, false
}
// ErrText is the one function every display end calls: the error's sentence in lang.
//
// An error that carries a key is rendered in lang. **Anything else is returned verbatim** — a restic
// message, a docker message, an ssh message, a Go stdlib error. That is not a gap: those sentences
// are not written here and are not translated here (10-localisation.md §9, the rule R-553
// established). A customer seeing restic's own English is seeing what restic said.
func ErrText(lang string, err error) string {
if err == nil {
return ""
}
if m, ok := AsMsg(err); ok {
return m.Text(lang)
}
return err.Error()
}
// Text renders a bundle message in a KNOWN language — for a producer that has no request.
//
// Release C (v0.254.0) uses it for the sentences a background run SAVES: the note under last night's
// backup, the last error, the proof result. Those are written at 03:00 and read days later, so there
// is no reader to ask; the operator ruling (slice 2 §16, option 1) is that they are written in the
// box's language AT WRITE TIME and shown verbatim afterwards.
//
// The consequence, stated rather than hidden: a household that switches language sees last night's
// note in the old language until the next run rewrites it. The alternative — storing a code and
// rendering live — needs a dozen new persisted fields and a legacy path for each, which is the
// R-570 shape a dozen times over.
func Text(lang, key string, args ...interface{}) string {
b, err := i18n.Shared()
if err != nil {
return key
}
if len(args) == 0 {
return b.Msg(lang, key)
}
return b.Msgf(lang, key, args...)
}