9167cf53af
gates / gates (push) Successful in 23s
The hub has written every customer e-mail in Hungarian whatever the box was set to. The box has published its language since controller v0.247.0; nothing read it. Now it does. Nothing an operator reads changes. The Hungarian mails are byte-identical, and that is a diff rather than a reading: 56 goldens per language captured from v0.117.0 BEFORE any string moved, and all 56 Hungarian ones pass unchanged after every sentence was routed through the new bundle. - internal/i18n: flat bundle, 79 keys, hu authoritative + hu fallback, ceiling 0. - customerMessages/severityLabels are DERIVED from the bundle, so a sentence is written in one place and all 40+ tests that read those maps still work. - Language order: last reported -> created-with -> hu. reports.language defaults to EMPTY, never hu: "never told us" is not "chose Hungarian". - message_customer on POST /api/v1/event, additive and optional forever, for the sentences the box composes and the hub cannot translate. - The bind page is per-language, and its `expired` state stays Hungarian: it is the state an unknown token lands in, so rendering a real English customer's token in English would make the LANGUAGE answer what the TEXT refuses to. Two defects found inside the release: - R-581: the newest report was picked by received_at, which has SECOND granularity, so same-second reports tied and the winner was arbitrary. Ordered by the autoincrement id now. GetCustomers() still has the shape - row open. - R-582: the English copy-guard stems, ported word for word from Hungarian, convicted 141 honest sentences. The English claim is a phrase with a modal. R-555 closed: the language allowlist entry is out of wire_contract_gate.py. hub_copy_gate.py follows the sentences into the bundle - without that it would have scanned four files that no longer hold any customer text and reported success. Three new decoys incl. an innocent control. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
201 lines
6.4 KiB
Go
201 lines
6.4 KiB
Go
// Package i18n is the hub's customer-facing message bundle: one flat key → text map per language,
|
|
// embedded in the binary, Hungarian first and authoritative.
|
|
//
|
|
// Design: felhom.eu/documentation/architecture/10-localisation.md, slice 3 (R-558).
|
|
//
|
|
// THIS BUNDLE IS FOR THE HOUSEHOLD, NOT THE OPERATOR. Everything the operator reads — the hub's own
|
|
// pages, its logs, and FormatOperatorEmail — stays exactly as it is, in the language it is already
|
|
// in. A key that would change an operator surface does not belong here.
|
|
//
|
|
// Three rules, the same three the controller's bundle follows:
|
|
//
|
|
// 1. HUNGARIAN IS THE SOURCE. hu.json holds every key. A key missing from hu is a programming
|
|
// error and Msg says so loudly rather than rendering a blank line into a customer's mail.
|
|
// 2. FALLBACK IS HUNGARIAN, AND IT IS COUNTED. A key absent from en.json renders the Hungarian and
|
|
// is reported by MissingKeys, which the gate holds at zero. A household never sees a key name
|
|
// or an empty space where a sentence should be.
|
|
// 3. THE BOX'S OWN SENTENCES ARE NOT IN HERE. Roughly a third of the customer mails carry a
|
|
// sentence composed on the box ("the remote target is not set"). The hub cannot translate those
|
|
// and does not try: the box sends the household's version beside the Hungarian one
|
|
// (`message_customer`), and the hub picks. See FormatCustomerEmail.
|
|
//
|
|
// Unlike the controller's bundle there is no template expansion here: a mail is plain text, so the
|
|
// contextual-escaping problem that shaped the controller's design does not exist. Msg is a plain
|
|
// lookup and Msgf formats with the Go verbs the Hungarian already uses.
|
|
package i18n
|
|
|
|
import (
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/fs"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
//go:embed locales/*.json
|
|
var localeFS embed.FS
|
|
|
|
// Default is the language of a household that never chose one — and the only language every key has.
|
|
const Default = "hu"
|
|
|
|
// Supported lists the languages a household may be written to in, Default first.
|
|
var Supported = []string{"hu", "en"}
|
|
|
|
// Bundle holds every language's messages.
|
|
type Bundle struct {
|
|
msgs map[string]map[string]string
|
|
}
|
|
|
|
// Normalize maps any input to a supported language; everything unknown is Default.
|
|
//
|
|
// This is the ONLY gate between a reported language and a rendered mail. A box that reports
|
|
// garbage, an old box that reports nothing, and a box that reports "EN " all land on a real
|
|
// language rather than on an empty lookup.
|
|
func Normalize(lang string) string {
|
|
l := strings.ToLower(strings.TrimSpace(lang))
|
|
for _, s := range Supported {
|
|
if l == s {
|
|
return s
|
|
}
|
|
}
|
|
return Default
|
|
}
|
|
|
|
// IsSupported reports whether lang is exactly one of Supported. Used where an input must be
|
|
// REFUSED rather than coerced — the customer-creation form, which should not silently store `hu`
|
|
// for an operator who typed something else.
|
|
func IsSupported(lang string) bool {
|
|
for _, s := range Supported {
|
|
if lang == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Load reads the embedded bundles.
|
|
func Load() (*Bundle, error) { return loadFrom(localeFS) }
|
|
|
|
func loadFrom(fsys fs.FS) (*Bundle, error) {
|
|
entries, err := fs.Glob(fsys, "locales/*.json")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
b := &Bundle{msgs: make(map[string]map[string]string, len(entries))}
|
|
for _, e := range entries {
|
|
raw, err := fs.ReadFile(fsys, e)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading %s: %w", e, err)
|
|
}
|
|
var m map[string]string
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
return nil, fmt.Errorf("parsing %s: %w", e, err)
|
|
}
|
|
lang := strings.TrimSuffix(strings.TrimPrefix(e, "locales/"), ".json")
|
|
b.msgs[lang] = m
|
|
}
|
|
if _, ok := b.msgs[Default]; !ok {
|
|
return nil, fmt.Errorf("i18n: no %s.json — Hungarian is the source and must exist", Default)
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
var (
|
|
sharedOnce sync.Once
|
|
shared *Bundle
|
|
sharedErr error
|
|
)
|
|
|
|
// Shared returns the process-wide bundle, loaded once.
|
|
//
|
|
// It panics if the embedded bundle cannot be parsed. That is deliberate and it is a BUILD-TIME
|
|
// class of fault, not a runtime one: the JSON is compiled into the binary, so a parse failure means
|
|
// every mail this binary will ever send is broken. Failing at the first send, loudly, beats sending
|
|
// a thousand mails with blank bodies.
|
|
func Shared() *Bundle {
|
|
sharedOnce.Do(func() { shared, sharedErr = Load() })
|
|
if sharedErr != nil {
|
|
panic("i18n: embedded bundle is unloadable: " + sharedErr.Error())
|
|
}
|
|
return shared
|
|
}
|
|
|
|
// Msg returns the message for key in lang, falling back to Hungarian.
|
|
//
|
|
// A key that exists in NO language returns a visible marker rather than an empty string. An empty
|
|
// string in a mail body is invisible — the customer gets a mail with a hole in it and nobody ever
|
|
// learns. The marker is ugly on purpose.
|
|
func (b *Bundle) Msg(lang, key string) string {
|
|
if b == nil {
|
|
return "!" + key + "!"
|
|
}
|
|
if m, ok := b.msgs[Normalize(lang)]; ok {
|
|
if v, ok := m[key]; ok && v != "" {
|
|
return v
|
|
}
|
|
}
|
|
if m, ok := b.msgs[Default]; ok {
|
|
if v, ok := m[key]; ok && v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return "!" + key + "!"
|
|
}
|
|
|
|
// Msgf formats the message for key in lang with args.
|
|
//
|
|
// Word order is the reason this exists rather than string concatenation at the call site: English
|
|
// reorders what Hungarian does not, and a translation reorders with Go's explicit argument indexes
|
|
// (`%[2]s`) inside its own value. The Hungarian value is then free to stay exactly the format
|
|
// string it always was, which is what makes the goldens hold.
|
|
func (b *Bundle) Msgf(lang, key string, args ...any) string {
|
|
return fmt.Sprintf(b.Msg(lang, key), args...)
|
|
}
|
|
|
|
// Has reports whether lang carries key in its own file (no fallback).
|
|
func (b *Bundle) Has(lang, key string) bool {
|
|
if b == nil {
|
|
return false
|
|
}
|
|
m, ok := b.msgs[lang]
|
|
if !ok {
|
|
return false
|
|
}
|
|
v, ok := m[key]
|
|
return ok && v != ""
|
|
}
|
|
|
|
// Keys lists lang's own keys, sorted.
|
|
func (b *Bundle) Keys(lang string) []string {
|
|
if b == nil {
|
|
return nil
|
|
}
|
|
m := b.msgs[lang]
|
|
out := make([]string, 0, len(m))
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// MissingKeys lists the Hungarian keys that lang does not carry, sorted.
|
|
//
|
|
// The gate holds this at zero for every supported language. It is the difference between "we
|
|
// translated it" and "we believe we translated it".
|
|
func (b *Bundle) MissingKeys(lang string) []string {
|
|
if b == nil {
|
|
return nil
|
|
}
|
|
var out []string
|
|
for _, k := range b.Keys(Default) {
|
|
if !b.Has(lang, k) {
|
|
out = append(out, k)
|
|
}
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|