Metadata captions: `caption` is authored, `displayCaption` is read
Updated
Every metadata object — a dictionary, a document type, a field, a menu item, a report — carries one authored caption and one translated one. The designer edits the first; the user reads the second. Mixing them up produces one of two bugs that both look like "the translation is missing": a screen stuck in the base language, or a translation silently saved over the original text.
The two fields#
| Column | Who writes it | Language |
|---|---|---|
Caption |
the designer, the workspace *.json, the model import |
always the base language (en in the caption language object) |
DisplayCaption |
the server, per request | the request's language, or null when there is no translation |
DisplayCaption is not stored. It lives on BaseMetadata
(src/ZuloOne.Shared/BaseMetadata.cs) as a plain property
and is filled on the way out, per response, from MetaTranslations
(PropertyName = 'Caption', or 'SubtypeCaption' for a document state).
So the client rule is always the same three-step fallback:
displayCaption || caption || name
Helpers exist — do not re-write the chain by hand: metaLabel(o) and
metaNameLabel(o) (frontend/src/api/metaLabel.ts), menuItemLabel(m)
(frontend/src/layout/menuTargets.ts).
Who fills it#
CaptionLocalizationFilter (src/ZuloOne.Core/Services/CaptionLocalizationFilter.cs),
a global IAsyncResultFilter. For every ObjectResult it:
- reads the language —
X-ZuloOne-Langfirst (what the SPA sends), thenAccept-Language, reduced to the primary subtag (ru-RU,ru;q=0.9→ru); - loads the two translation maps for that language from
CaptionMapCache(5-minute TTL, invalidated when translations are saved); - walks the response graph and, on each node, sets
DisplayCaptionif the node is aBaseMetadata, then applies whateverILocalizedCaptions(src/ZuloOne.Shared/ILocalizedCaptions.cs) the node declares.
No endpoint opts in. Which is exactly why the two ways to fall out of it are easy to miss.
Trap 1 — an anonymous projection has nowhere to write#
Warning
.Select(m => new { m.MetaId, m.Caption, … })ships the base language to every client, forever, with no error anywhere. The filter only descends into types whoseFullNamestarts withZuloOne., and an anonymous type has no namespace — so the walker turns around at the first node and never even looks at the captions inside.
The menu builder (/menu) is the easy place to walk into it: the ribbon
localizes (/api/metadata/menu/my translates by hand) while the builder's tree,
served by /api/metadata/menu, stays in English on a Russian stand if the
projection is anonymous.
GET /api/metadata/menu, X-ZuloOne-Lang: ru |
items | with displayCaption |
|---|---|---|
| anonymous projection | 252 | 0 |
projected into MenuItemDto |
252 | 252 |
Same endpoint, uk: 228 of 252 (the other 24 have no Ukrainian translation
yet — the client falls back to caption). Same endpoint, en: 0 of 252, and
that is correct — English is the caption, there is nothing to translate.
Always declare the projection type.
.Select(m => new MenuItemDto { MetaId = m.MetaId, Caption = m.Caption, … })
The same trap applies to GET /api/metadata/custom-reports
(CustomReportListDto) — the menu builder's object catalogue reads it.
The anonymous root counts too. return Ok(new { templateId, items = rows })
is skipped as a whole: the walker never reaches items, however well-typed the
rows are. /api/metadata/menu/my has that shape and therefore still localizes
by hand. Either return the list directly, or translate it yourself.
Trap 2 — a DTO that copied a caption out of a row#
A flattened DTO (a report column naming its dictionary, a menu item projected
out of MetaMenuItem) is not a BaseMetadata and has no MetaId the filter
can trust. ILocalizedCaptions is the seam: say which row each caption came
from and where to put the translated text.
public sealed class CustomReportColumnDto : ILocalizedCaptions
{
public string? Caption { get; set; } // from the SCRIPT — untranslatable
public Guid? DictionaryMetaId { get; set; }
public string? DictionaryCaption { get; set; } // from a real metadata row
public IEnumerable<(Guid MetaId, Action<string> Apply)> LocalizableCaptions()
{
if (DictionaryMetaId is Guid dict) yield return (dict, t => DictionaryCaption = t);
}
}
One DTO may expose several captions. Entries without a translation in the request language are left alone, so the base caption stays as the fallback.
Note what is not localizable in that example: Caption comes from authored
C# in the report's data-source script, not from a metadata row. Nothing can
translate it, and pretending otherwise would only hide that.
Trap 3 — never bind edit state to the display text#
Warning
defaultValue={metaLabel(o)}in a designer form saves the Russian text into the baseCaptionon the next Save. The base language then is Russian, for every tenant, and the original wording is gone.
A form that edits a caption binds to caption and leaves translations to
the Translate modal. A form that shows one binds to the fallback chain. The
menu builder does both on one screen: its tree rows and its target column read
menuItemLabel, its caption input reads caption.
Trap 4 — the header picker is not the language registry#
Warning
Switching the flag to Ukrainian used to leave every menu, field and column in English. The SPA chrome pack (
frontend/src/i18n/locales/uk.ts) loads on the client. Metadata captions live inMetaTranslations, and an import only writes the languages the tenant has already registered. Ukrainian was never seeded (English is), so picking it in the header sentX-ZuloOne-Lang: ukagainst an empty map. A handful of leftovercaption_ukrows from before that filter made boot treat the language as filled and skip the backfill.
The language code is uk, not ua. The picker shows the country flag code
UA; the workspace files and MetaLanguages.Code use the ISO 639-1 language.
Picking a bundled language (ru / uk / ar) now calls
POST /api/metadata/languages/ensure, which registers the code and reads the
captions out of the model tree in the image (WorkspaceLanguageCaptions).
Adding a language that is not bundled (Polish, …) is still Developer →
Languages. ua is accepted as an alias of uk.
The same rule applies when one object's caption is copied onto another: adding a menu item from the object catalogue seeds the new item's caption with the target's base caption, never with its display text — a menu item is its own metadata row with its own translations, and seeding it from the display text would freeze today's UI language into the base caption of a new row.
Checking an endpoint#
curl -s -H 'X-ZuloOne-Lang: ru' localhost:5257/api/metadata/menu \
| python -c "import sys,json; d=json.load(sys.stdin); \
print(len(d), sum(1 for x in d if x.get('displayCaption')))"
0 on the right with a non-empty left on a stand that has Russian
translations means the endpoint is in trap 1 or trap 2. Compare against a
known-good one (/api/metadata/dictionaries) before concluding the
translations themselves are missing.
Where next#
- Global core and country localization: what belongs to a country package and what is just i18n.
- The Arabic UI: RTL and the SPA side.
- Grid columns: the other place where "what is shown" and "what is stored" are deliberately two values.