// 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 }