package stacks import "strings" // CATALOG COPY IN A SECOND LANGUAGE (localisation slice 5, R-560). // // A catalog template's customer-facing text is Hungarian in the fields the controller has always // read (`description`, `app_info`, `deploy_fields[].label` …). English is a SIBLING BLOCK in the // SAME file: // // description: "Titkosított jegyzet és szöveg megosztás" // app_info: // first_steps: [...] // i18n: // en: // description: "Encrypted notes and text sharing" // app_info: // first_steps: [...] // // THREE PROPERTIES, EACH LOAD-BEARING. // // 1. **The Hungarian bytes never move.** A translation adds a block; it never edits a Hungarian // string. `For("hu")` is the parsed struct with `I18n` cleared — pinned by // TestMetaForHuIsIdentity over all 53 real catalog files. // // 2. **An older controller ignores the block.** `LoadMetadata` uses `yaml.Unmarshal`, which is // NON-STRICT (no `KnownFields`; verified — this repo constructs no yaml.Decoder at all), so a // controller built before this file silently drops `i18n:` and renders exactly as it did. That // is what lets the catalog be pushed to the whole fleet ahead of the floor raise. // // 3. **Fallback is FIELD BY FIELD, never all-or-nothing.** A missing — or empty — English field // shows the Hungarian one (10-localisation.md §4, operator ruling 3). A half-translated app is // therefore a legal, shippable state, which is what makes the batch pushes safe. // // LISTS ARE REPLACED WHOLE, NEVER MERGED BY INDEX. `use_cases`, `first_steps` and `prerequisites` // are prose in a running order; merging item 3 of one language with item 4 of another would produce // a list nobody wrote. An English list that is present replaces the Hungarian list entirely; an // absent or empty one leaves the Hungarian list alone. // // EVERY OTHER LIST IS MATCHED BY ITS OWN KEY, NEVER BY POSITION: `deploy_fields` by `env_var`, // `options` by `value`, `optional_config` groups by `match_group` (the Hungarian `group` value they // translate — a group has no other stable identity), `optional_config[].fields` by `env_var`, // `integrations` by `target`, `data_paths` by `path`. Position matching would silently mistranslate // the moment a Hungarian field is inserted above another, and nothing on the page would look wrong. // // WHAT AN OVERLAY MAY CARRY IS EXACTLY THE COPY FIELDS — no `env_var`, `type`, `default`, // `generate`, `required`, `locked_after_deploy`, `data_key`, `path`, `role`, `docs_url`, no image, // port or healthcheck. The struct shape below IS that rule: a translation cannot change what an app // does, only what it says. The catalog's `check-copy-i18n.py` gate states the same rule on the file // before it is ever parsed here. // MetadataOverlay is one language's copy for one app. Every scalar is a POINTER so that "the // translator did not write this field" is distinguishable from "the translator wrote an empty // string" — both fall back to Hungarian, but only the first is silent. type MetadataOverlay struct { Description *string `yaml:"description,omitempty"` AppInfo *AppInfoOverlay `yaml:"app_info,omitempty"` DeployFields []DeployFieldOverlay `yaml:"deploy_fields,omitempty"` OptionalConfig []OptionalConfigGroupOverlay `yaml:"optional_config,omitempty"` Integrations []IntegrationOverlay `yaml:"integrations,omitempty"` DataPaths []DataPathOverlay `yaml:"data_paths,omitempty"` InitialCreds *InitialCredentialsOverlay `yaml:"initial_credentials,omitempty"` } // AppInfoOverlay carries the info page's prose. The three lists replace whole. type AppInfoOverlay struct { Tagline *string `yaml:"tagline,omitempty"` UseCases []string `yaml:"use_cases,omitempty"` FirstSteps []string `yaml:"first_steps,omitempty"` Prerequisites []string `yaml:"prerequisites,omitempty"` DefaultCreds *string `yaml:"default_creds,omitempty"` } // DeployFieldOverlay translates one deploy field, found by EnvVar. type DeployFieldOverlay struct { EnvVar string `yaml:"env_var"` Label *string `yaml:"label,omitempty"` Description *string `yaml:"description,omitempty"` Placeholder *string `yaml:"placeholder,omitempty"` Options []SelectOptionOverlay `yaml:"options,omitempty"` } // SelectOptionOverlay translates one select option's label, found by Value. type SelectOptionOverlay struct { Value string `yaml:"value"` Label *string `yaml:"label,omitempty"` } // OptionalConfigGroupOverlay translates one optional-config group. MatchGroup is the HUNGARIAN // `group:` value it belongs to — a group carries no id, and its own label is the thing being // translated, so the match key has to be stated rather than derived. type OptionalConfigGroupOverlay struct { MatchGroup string `yaml:"match_group"` Group *string `yaml:"group,omitempty"` Description *string `yaml:"description,omitempty"` Fields []OptionalConfigFieldOverlay `yaml:"fields,omitempty"` } // OptionalConfigFieldOverlay translates one optional-config field, found by EnvVar. type OptionalConfigFieldOverlay struct { EnvVar string `yaml:"env_var"` Label *string `yaml:"label,omitempty"` HelpText *string `yaml:"help_text,omitempty"` } // IntegrationOverlay translates one integration offer, found by Target. type IntegrationOverlay struct { Target string `yaml:"target"` Label *string `yaml:"label,omitempty"` Description *string `yaml:"description,omitempty"` } // DataPathOverlay translates one data-folder label, found by Path. type DataPathOverlay struct { Path string `yaml:"path"` Label *string `yaml:"label,omitempty"` } // InitialCredentialsOverlay translates the note shown beside a first-login credential. Only the // note: the file path, the format and the keys are configuration, and the credential VALUE never // passes through here at all. type InitialCredentialsOverlay struct { Note *string `yaml:"note,omitempty"` } // BaseLanguage is the language the unmarshalled fields themselves are written in. It is not a // choice a template can make — the catalog's Hungarian is the base by construction (the freeze // gate in the catalog repo pins it byte for byte). const BaseLanguage = "hu" // overlayStr returns the translated string, or the base one when the translation is absent or // blank. A blank translation is treated as ABSENT and never renders an empty box — operator // ruling 3, 10-localisation.md §4. func overlayStr(p *string, base string) string { if p == nil || strings.TrimSpace(*p) == "" { return base } return *p } // overlayList returns the translated list, or the base one when the translation is absent or // empty. Whole-list replacement, never a per-index merge — see the header. func overlayList(in []string, base []string) []string { if len(in) == 0 { return base } out := make([]string, len(in)) copy(out, in) return out } // For returns this app's metadata as the given language renders it. // // For the base language — and for any language this template has no block for — it is the parsed // struct with `I18n` cleared, so a Hungarian page cannot be changed by the presence of a // translation. For a language with a block, copy fields are replaced one by one and EVERYTHING // ELSE is carried through untouched. // // It NEVER mutates the receiver. Every slice it changes is copied first: `Metadata` values live // inside the stack manager's cache and are handed to many requests at once, so an in-place edit // would leak one household's language into another's page. // // VALUE receiver, like CanInstall and CatalogSinceAge above it — see the comment there: a pointer // receiver on a type that reaches html/template inside an interface{} is a render-time 500. func (m Metadata) For(lang string) Metadata { out := m out.I18n = nil if lang == "" || lang == BaseLanguage { return out } ov, ok := m.I18n[lang] if !ok { return out } out.Description = overlayStr(ov.Description, out.Description) if ov.AppInfo != nil { out.AppInfo.Tagline = overlayStr(ov.AppInfo.Tagline, out.AppInfo.Tagline) out.AppInfo.DefaultCreds = overlayStr(ov.AppInfo.DefaultCreds, out.AppInfo.DefaultCreds) out.AppInfo.UseCases = overlayList(ov.AppInfo.UseCases, out.AppInfo.UseCases) out.AppInfo.FirstSteps = overlayList(ov.AppInfo.FirstSteps, out.AppInfo.FirstSteps) out.AppInfo.Prerequisites = overlayList(ov.AppInfo.Prerequisites, out.AppInfo.Prerequisites) } if len(ov.DeployFields) > 0 && len(out.DeployFields) > 0 { fields := make([]DeployField, len(out.DeployFields)) copy(fields, out.DeployFields) for _, fo := range ov.DeployFields { for i := range fields { if fields[i].EnvVar != fo.EnvVar || fo.EnvVar == "" { continue } fields[i].Label = overlayStr(fo.Label, fields[i].Label) fields[i].Description = overlayStr(fo.Description, fields[i].Description) fields[i].Placeholder = overlayStr(fo.Placeholder, fields[i].Placeholder) if len(fo.Options) > 0 && len(fields[i].Options) > 0 { opts := make([]SelectOption, len(fields[i].Options)) copy(opts, fields[i].Options) for _, oo := range fo.Options { for j := range opts { if opts[j].Value == oo.Value && oo.Value != "" { opts[j].Label = overlayStr(oo.Label, opts[j].Label) } } } fields[i].Options = opts } break } } out.DeployFields = fields } if len(ov.OptionalConfig) > 0 && len(out.OptionalConfig) > 0 { groups := make([]OptionalConfigGroup, len(out.OptionalConfig)) copy(groups, out.OptionalConfig) for _, gov := range ov.OptionalConfig { for i := range groups { if groups[i].Group != gov.MatchGroup || gov.MatchGroup == "" { continue } groups[i].Description = overlayStr(gov.Description, groups[i].Description) if len(gov.Fields) > 0 && len(groups[i].Fields) > 0 { flds := make([]OptionalConfigField, len(groups[i].Fields)) copy(flds, groups[i].Fields) for _, fo := range gov.Fields { for j := range flds { if flds[j].EnvVar == fo.EnvVar && fo.EnvVar != "" { flds[j].Label = overlayStr(fo.Label, flds[j].Label) flds[j].HelpText = overlayStr(fo.HelpText, flds[j].HelpText) } } } groups[i].Fields = flds } // Group LAST: it is the match key, so translating it earlier would make the // remaining overlay entries for this group unmatchable. groups[i].Group = overlayStr(gov.Group, groups[i].Group) break } } out.OptionalConfig = groups } if len(ov.Integrations) > 0 && len(out.Integrations) > 0 { ints := make([]IntegrationDef, len(out.Integrations)) copy(ints, out.Integrations) for _, io := range ov.Integrations { for i := range ints { if ints[i].Target == io.Target && io.Target != "" { ints[i].Label = overlayStr(io.Label, ints[i].Label) ints[i].Description = overlayStr(io.Description, ints[i].Description) } } } out.Integrations = ints } if len(ov.DataPaths) > 0 && len(out.DataPaths) > 0 { dps := make([]DataPath, len(out.DataPaths)) copy(dps, out.DataPaths) for _, do := range ov.DataPaths { for i := range dps { if dps[i].Path == do.Path && do.Path != "" { dps[i].Label = overlayStr(do.Label, dps[i].Label) } } } out.DataPaths = dps } if ov.InitialCreds != nil && out.InitialCreds != nil { ic := *out.InitialCreds ic.Note = overlayStr(ov.InitialCreds.Note, ic.Note) out.InitialCreds = &ic } return out } // MetaFor is For on a stack, and it is the ONLY way a page should reach catalog copy: reading // `stack.Meta.Description` straight out of the manager renders Hungarian to an English household. // TestNoDirectMetaCopyReadOnPages pins that rule on the handler sources. func (s Stack) MetaFor(lang string) Metadata { return s.Meta.For(lang) } // LocalizeStacks returns the list as the given language renders it. // // For the base language it returns the INPUT SLICE ITSELF — not a copy. That is deliberate and it // is the parity guarantee in code: the Hungarian page cannot differ from the pre-change one, // because nothing on its path was touched. For another language every element is copied (Stack is // a value type) and only its Meta replaced. func LocalizeStacks(in []Stack, lang string) []Stack { if lang == "" || lang == BaseLanguage { return in } out := make([]Stack, len(in)) copy(out, in) for i := range out { out[i].Meta = out[i].Meta.For(lang) } return out } // LocalizeStack is LocalizeStacks for a single stack. func LocalizeStack(in Stack, lang string) Stack { in.Meta = in.Meta.For(lang) return in } // LocalizeStackPtr is LocalizeStack for a stack POINTER, returning a NEW pointer so the value the // caller holds — which may be the manager's own — is never written through. A nil stack stays nil. func LocalizeStackPtr(in *Stack, lang string) *Stack { if in == nil { return nil } out := *in out.Meta = out.Meta.For(lang) return &out }