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"` AddPeople *string `yaml:"add_people,omitempty"` Guide *AppGuideOverlay `yaml:"guide,omitempty"` } // AppGuideOverlay translates the user guide. `checked_version` is NOT here and that is the rule // stated in code: a version vouches for a click-through, and a translator did not do it. // // SECTIONS AND FAQ ENTRIES ARE MATCHED BY INDEX, and they are the only lists in this file that are. // The header above says every list is matched by its own key — a guide section has no key to match // on. Its title is the very thing being translated, so a `match_title` would have to be kept in step // with the Hungarian by hand, which is the drift the key rule exists to prevent. Index matching is // safe HERE because the catalog's copy gate requires a translated guide to carry the same number of // sections and FAQ entries in the same running order, and because the fallback is field by field: // a misaligned entry shows the Hungarian one, never a sentence nobody wrote. An English list LONGER // than the Hungarian one is ignored past the Hungarian count, with one WARN naming the slug // (GuideOverlayExtras). type AppGuideOverlay struct { Sections []GuideSectionOverlay `yaml:"sections,omitempty"` FAQ []GuideFAQOverlay `yaml:"faq,omitempty"` } // GuideSectionOverlay translates one section, found by its POSITION. Steps replace whole, like // every other prose list in this file. type GuideSectionOverlay struct { Title *string `yaml:"title,omitempty"` Intro *string `yaml:"intro,omitempty"` Steps []string `yaml:"steps,omitempty"` } // GuideFAQOverlay translates one FAQ entry, found by its POSITION. type GuideFAQOverlay struct { Q *string `yaml:"q,omitempty"` A *string `yaml:"a,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 } // overlayGuide returns the translated guide. `CheckedVersion` is carried through from the base // UNTOUCHED: it is the render gate, not copy, so a translation can neither set it nor clear it — an // untranslated guide and a translated one are shown or hidden by the same fact. // // Entries are matched by index and every field falls back on its own (see AppGuideOverlay). Nothing // is mutated: both slices are rebuilt. func overlayGuide(ov *AppGuideOverlay, base AppGuide) AppGuide { if ov == nil { return base } out := base if len(ov.Sections) > 0 && len(base.Sections) > 0 { secs := make([]GuideSection, len(base.Sections)) copy(secs, base.Sections) for i := range secs { if i >= len(ov.Sections) { break } so := ov.Sections[i] secs[i].Title = overlayStr(so.Title, secs[i].Title) secs[i].Intro = overlayStr(so.Intro, secs[i].Intro) secs[i].Steps = overlayList(so.Steps, secs[i].Steps) } out.Sections = secs } if len(ov.FAQ) > 0 && len(base.FAQ) > 0 { faq := make([]GuideFAQ, len(base.FAQ)) copy(faq, base.FAQ) for i := range faq { if i >= len(ov.FAQ) { break } qo := ov.FAQ[i] faq[i].Q = overlayStr(qo.Q, faq[i].Q) faq[i].A = overlayStr(qo.A, faq[i].A) } out.FAQ = faq } return out } // GuideOverlayExtras counts the guide entries a language's overlay carries BEYOND the Hungarian // count — the ones overlayGuide ignores. It exists so the page that renders the guide can say so // once in a log line: `For` runs per request and holds no logger, and a silently dropped // translation is exactly the kind of absence nobody notices. Zero for the base language, for a // language with no block, and for a correctly sized overlay. func (m Metadata) GuideOverlayExtras(lang string) (sections, faq int) { if lang == "" || lang == BaseLanguage { return 0, 0 } ov, ok := m.I18n[lang] if !ok || ov.AppInfo == nil || ov.AppInfo.Guide == nil { return 0, 0 } if n := len(ov.AppInfo.Guide.Sections) - len(m.AppInfo.Guide.Sections); n > 0 { sections = n } if n := len(ov.AppInfo.Guide.FAQ) - len(m.AppInfo.Guide.FAQ); n > 0 { faq = n } return sections, faq } // 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.AddPeople = overlayStr(ov.AppInfo.AddPeople, out.AppInfo.AddPeople) 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) out.AppInfo.Guide = overlayGuide(ov.AppInfo.Guide, out.AppInfo.Guide) } 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 } // cloneStrPtr returns a fresh pointer to a copy of *p, or nil. func cloneStrPtr(p *string) *string { if p == nil { return nil } v := *p return &v } // cloneStrs returns a fresh copy of in, keeping nil as nil and empty as empty. func cloneStrs(in []string) []string { if in == nil { return nil } out := make([]string, len(in)) copy(out, in) return out } // Clone returns a deep copy of the overlay: no pointer, slice or nested struct is shared with the // receiver. It exists for deepCopyStack (R-591) — a Stack copy is "a snapshot the caller may // mutate", and the overlay must be one too. Pinned by TestDeepCopyStackI18nIsNotShared. func (o MetadataOverlay) Clone() MetadataOverlay { c := MetadataOverlay{ Description: cloneStrPtr(o.Description), } if o.AppInfo != nil { ai := AppInfoOverlay{ Tagline: cloneStrPtr(o.AppInfo.Tagline), UseCases: cloneStrs(o.AppInfo.UseCases), FirstSteps: cloneStrs(o.AppInfo.FirstSteps), Prerequisites: cloneStrs(o.AppInfo.Prerequisites), DefaultCreds: cloneStrPtr(o.AppInfo.DefaultCreds), AddPeople: cloneStrPtr(o.AppInfo.AddPeople), } if o.AppInfo.Guide != nil { g := AppGuideOverlay{} if o.AppInfo.Guide.Sections != nil { g.Sections = make([]GuideSectionOverlay, len(o.AppInfo.Guide.Sections)) for i, se := range o.AppInfo.Guide.Sections { g.Sections[i] = GuideSectionOverlay{ Title: cloneStrPtr(se.Title), Intro: cloneStrPtr(se.Intro), Steps: cloneStrs(se.Steps), } } } if o.AppInfo.Guide.FAQ != nil { g.FAQ = make([]GuideFAQOverlay, len(o.AppInfo.Guide.FAQ)) for i, qa := range o.AppInfo.Guide.FAQ { g.FAQ[i] = GuideFAQOverlay{Q: cloneStrPtr(qa.Q), A: cloneStrPtr(qa.A)} } } ai.Guide = &g } c.AppInfo = &ai } if o.DeployFields != nil { c.DeployFields = make([]DeployFieldOverlay, len(o.DeployFields)) for i, f := range o.DeployFields { nf := DeployFieldOverlay{ EnvVar: f.EnvVar, Label: cloneStrPtr(f.Label), Description: cloneStrPtr(f.Description), Placeholder: cloneStrPtr(f.Placeholder), } if f.Options != nil { nf.Options = make([]SelectOptionOverlay, len(f.Options)) for j, op := range f.Options { nf.Options[j] = SelectOptionOverlay{Value: op.Value, Label: cloneStrPtr(op.Label)} } } c.DeployFields[i] = nf } } if o.OptionalConfig != nil { c.OptionalConfig = make([]OptionalConfigGroupOverlay, len(o.OptionalConfig)) for i, g := range o.OptionalConfig { ng := OptionalConfigGroupOverlay{ MatchGroup: g.MatchGroup, Group: cloneStrPtr(g.Group), Description: cloneStrPtr(g.Description), } if g.Fields != nil { ng.Fields = make([]OptionalConfigFieldOverlay, len(g.Fields)) for j, f := range g.Fields { ng.Fields[j] = OptionalConfigFieldOverlay{EnvVar: f.EnvVar, Label: cloneStrPtr(f.Label), HelpText: cloneStrPtr(f.HelpText)} } } c.OptionalConfig[i] = ng } } if o.Integrations != nil { c.Integrations = make([]IntegrationOverlay, len(o.Integrations)) for i, in := range o.Integrations { c.Integrations[i] = IntegrationOverlay{Target: in.Target, Label: cloneStrPtr(in.Label), Description: cloneStrPtr(in.Description)} } } if o.DataPaths != nil { c.DataPaths = make([]DataPathOverlay, len(o.DataPaths)) for i, d := range o.DataPaths { c.DataPaths[i] = DataPathOverlay{Path: d.Path, Label: cloneStrPtr(d.Label)} } } if o.InitialCreds != nil { c.InitialCreds = &InitialCredentialsOverlay{Note: cloneStrPtr(o.InitialCreds.Note)} } return c }