morph 0.1.0
Asynchronous bridge between GUI thread and business models
Loading...
Searching...
No Matches
catalogue.hpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
2
3#pragma once
4#include <algorithm>
5#include <cstddef>
6#include <expected>
7#include <map>
8#include <memory>
9#include <optional>
10#include <string>
11#include <string_view>
12#include <utility>
13#include <variant>
14#include <vector>
15
16#include "../render/i18n.hpp"
17#include "bundle.hpp"
18#include "message.hpp"
19#include "value.hpp"
20
21/// @file
22/// @brief Message catalogues (spec 6 §19): a bundle's `messages` files read into a `TranslationProvider` that `t`
23/// resolves through, and the search that matches a title in every locale the bundle has.
24///
25/// Specified in `docs/spec/ui/catalogue.md`.
26
27namespace morph::ui {
28
29/// @brief The most keys one catalogue may hold.
30inline constexpr std::size_t kMaxCatalogueKeys = 16384;
31
32/// @brief The id prefix of a message catalogue: `i18n/LOCALE.json`.
33inline constexpr std::string_view kCataloguePrefix = "i18n/";
34
35/// @brief The id suffix of a message catalogue.
36inline constexpr std::string_view kCatalogueSuffix = ".json";
37
38/// @brief One locale's messages, by key, with the patterns already parsed.
39struct Catalogue {
40 /// @brief The pattern text of each key whose pattern is in the `ui/1` subset.
41 std::map<std::string, std::string, std::less<>> messages;
42 /// @brief One line per key whose pattern is outside the subset; those keys show their fallback.
43 std::vector<std::string> dropped;
44};
45
46/// @brief Reads the locale from a catalogue's id.
47/// @param fileId The id, such as `i18n/de-AT.json`.
48/// @return The locale (`de-AT`), or nothing when the id is not `i18n/LOCALE.json` with a locale of ASCII letters,
49/// digits and `-` that starts with a letter.
50[[nodiscard]] inline std::optional<std::string> catalogueLocale(std::string_view fileId) {
51 if (!fileId.starts_with(kCataloguePrefix) || !fileId.ends_with(kCatalogueSuffix) ||
52 fileId.size() <= kCataloguePrefix.size() + kCatalogueSuffix.size()) {
53 return std::nullopt;
54 }
55 std::string_view const locale =
56 fileId.substr(kCataloguePrefix.size(), fileId.size() - kCataloguePrefix.size() - kCatalogueSuffix.size());
57 auto const letter = [](char const symbol) {
58 return (symbol >= 'a' && symbol <= 'z') || (symbol >= 'A' && symbol <= 'Z');
59 };
60 bool const valid = letter(locale.front()) && std::ranges::all_of(locale, [&letter](char const symbol) {
61 return letter(symbol) || (symbol >= '0' && symbol <= '9') || symbol == '-';
62 });
63 return valid ? std::optional<std::string>{std::string{locale}} : std::nullopt;
64}
65
66/// @brief Parses a catalogue: a flat JSON object of message key to MessageFormat 2 pattern.
67///
68/// Refused: text that is not JSON or nests deeper than `kMaxJsonDepth`, anything but an object, a must-understand
69/// (`!`) key, a value that is not a string, an empty key or one longer than `kMaxNameBytes`, and more than
70/// `kMaxCatalogueKeys` keys. A pattern outside the `ui/1` subset does not refuse the catalogue: its key is left out
71/// and listed in `dropped`, so `t` shows the fallback for it.
72/// @param text The catalogue's JSON text.
73/// @return The catalogue, or the reason it is refused.
74[[nodiscard]] inline std::expected<Catalogue, std::string> parseCatalogue(std::string_view text) {
75 auto const json = parseJson(text);
76 if (!json) {
77 return std::unexpected(json.error());
78 }
79 auto const* const object = std::get_if<Json::object_t>(&json->data);
80 if (object == nullptr) {
81 return std::unexpected(std::string{"a catalogue is an object of message keys"});
82 }
83 if (auto reason = detail::mustUnderstand(*json, "the catalogue")) {
84 return std::unexpected(std::move(*reason));
85 }
86 if (object->size() > kMaxCatalogueKeys) {
87 return std::unexpected("a catalogue holds more than " + std::to_string(kMaxCatalogueKeys) + " keys");
88 }
89 Catalogue catalogue;
90 for (auto const& [key, value] : *object) {
91 if (key.empty() || key.size() > kMaxNameBytes) {
92 return std::unexpected("a message key is 1 to " + std::to_string(kMaxNameBytes) + " bytes");
93 }
94 auto const* const pattern = std::get_if<std::string>(&value.data);
95 if (pattern == nullptr) {
96 return std::unexpected("the message `" + key + "` is not a string");
97 }
98 if (auto parsed = parseMessage(*pattern); !parsed) {
99 catalogue.dropped.push_back("`" + key + "`: " + parsed.error());
100 continue;
101 }
102 catalogue.messages.emplace(key, *pattern);
103 }
104 return catalogue;
105}
106
107/// @brief The message catalogues of one bundle, by locale, and the provider `t` resolves through.
109public:
110 /// @brief Adds, or replaces, a locale's catalogue.
111 /// @param locale The locale.
112 /// @param catalogue Its messages.
113 void add(std::string locale, Catalogue catalogue) {
114 auto next = std::make_shared<Table>(*_table);
115 next->insert_or_assign(std::move(locale), std::move(catalogue.messages));
116 _table = std::move(next);
117 }
118
119 /// @brief The locales with a catalogue, sorted.
120 /// @return They.
121 [[nodiscard]] std::vector<std::string> locales() const {
122 std::vector<std::string> out;
123 out.reserve(_table->size());
124 for (auto const& [locale, messages] : *_table) {
125 static_cast<void>(messages);
126 out.push_back(locale);
127 }
128 return out;
129 }
130
131 /// @brief What went wrong while loading: a catalogue left out, or a pattern dropped. Each line names the file.
132 /// @return The lines.
133 [[nodiscard]] std::vector<std::string> const& problems() const noexcept { return _problems; }
134
135 /// @brief Records a loading problem.
136 /// @param line What went wrong, naming the file.
137 void note(std::string line) { _problems.push_back(std::move(line)); }
138
139 /// @brief A message's pattern in a locale: the exact locale's catalogue, then its language's (`de-AT`, then
140 /// `de`).
141 /// @param key The message key.
142 /// @param locale The BCP 47 locale.
143 /// @return The pattern, or nothing; `t` then shows the fallback.
144 [[nodiscard]] std::optional<std::string> lookup(std::string_view key, std::string_view locale) const {
145 return find(*_table, key, locale);
146 }
147
148 /// @brief A provider over the catalogues as they are now. It shares them, so it stays valid after this object is
149 /// gone, and does not see a later `add`.
150 /// @return The provider, for `FormatContext::translations` (`Session::setTranslations`).
151 [[nodiscard]] render::TranslationProvider provider() const {
152 return [table = _table](std::string_view key, std::string_view locale) { return find(*table, key, locale); };
153 }
154
155private:
156 using Table = std::map<std::string, std::map<std::string, std::string, std::less<>>, std::less<>>;
157
158 // NOLINTNEXTLINE(bugprone-easily-swappable-parameters): key, then locale, as TranslationProvider takes them.
159 [[nodiscard]] static std::optional<std::string> find(Table const& table, std::string_view key,
160 std::string_view locale) {
161 std::string_view tag = locale;
162 while (true) {
163 if (auto const messages = table.find(tag); messages != table.end()) {
164 if (auto const message = messages->second.find(key); message != messages->second.end()) {
165 return message->second;
166 }
167 }
168 auto const dash = tag.rfind('-');
169 if (dash == std::string_view::npos) {
170 return std::nullopt;
171 }
172 tag = tag.substr(0, dash);
173 }
174 }
175
176 std::shared_ptr<Table const> _table = std::make_shared<Table const>();
177 std::vector<std::string> _problems;
178};
179
180/// @brief Reads every message catalogue a verified bundle lists.
181///
182/// A catalogue whose id is not `i18n/LOCALE.json`, whose bytes are not among @p files, or that `parseCatalogue`
183/// refuses, is left out, and its locale shows the fallbacks; a pattern outside the subset is dropped. Each is noted
184/// in `problems()`. Nothing is refused later, at a `t`.
185/// @param manifest The bundle's manifest (`LoadedBundle::manifest`).
186/// @param files The verified files by content hash (`LoadedBundle::files`).
187/// @return The catalogues.
188[[nodiscard]] inline MessageCatalogues loadCatalogues(Manifest const& manifest,
189 std::map<std::string, std::string, std::less<>> const& files) {
190 MessageCatalogues catalogues;
191 for (ManifestFile const& file : manifest.files) {
192 if (file.kind != kMessagesFile) {
193 continue;
194 }
195 auto locale = catalogueLocale(file.id);
196 if (!locale) {
197 catalogues.note("`" + file.id + "`: a catalogue's id is i18n/<locale>.json");
198 continue;
199 }
200 auto const bytes = files.find(file.hash);
201 if (bytes == files.end()) {
202 catalogues.note("`" + file.id + "`: not among the verified files");
203 continue;
204 }
205 auto catalogue = parseCatalogue(bytes->second);
206 if (!catalogue) {
207 catalogues.note("`" + file.id + "`: " + catalogue.error());
208 continue;
209 }
210 for (std::string const& dropped : catalogue->dropped) {
211 catalogues.note("`" + file.id + "`: " + dropped);
212 }
213 catalogues.add(std::move(*locale), std::move(*catalogue));
214 }
215 return catalogues;
216}
217
218/// @brief A message formatted in every locale the catalogues hold: what a search over titles compares against.
219/// @param catalogues The catalogues.
220/// @param key The message key.
221/// @param fallback The source text, shown for a locale without the key.
222/// @return One text per locale, in the order of `locales()`; the fallback alone when there are no catalogues.
223// NOLINTBEGIN(bugprone-easily-swappable-parameters): key, then fallback, as `t` takes them.
224[[nodiscard]] inline std::vector<std::string> textInEveryLocale(MessageCatalogues const& catalogues,
225 std::string_view key, std::string_view fallback) {
226 // NOLINTEND(bugprone-easily-swappable-parameters)
227 std::vector<std::string> const locales = catalogues.locales();
228 std::vector<std::string> out;
229 auto const format = [](std::string_view pattern, std::string const& locale) -> std::optional<std::string> {
230 auto parsed = parseMessage(pattern);
231 if (!parsed) {
232 return std::nullopt;
233 }
234 return formatMessage(*parsed, ValueRecord{}, MessageLocale{.locale = locale, .numeric = {}}).text;
235 };
236 if (locales.empty()) {
237 out.push_back(format(fallback, "en").value_or(std::string{fallback}));
238 return out;
239 }
240 out.reserve(locales.size());
241 for (std::string const& locale : locales) {
242 std::optional<std::string> text;
243 if (auto const pattern = catalogues.lookup(key, locale)) {
244 text = format(*pattern, locale);
245 }
246 if (!text) {
247 text = format(fallback, locale);
248 }
249 out.push_back(text.value_or(std::string{fallback}));
250 }
251 return out;
252}
253
254/// @brief Whether a message contains @p query in any locale the catalogues hold, or in its fallback: a navigator's
255/// search, so a user finds a screen by the title they know in any language.
256///
257/// ASCII letters compare without case; every other byte compares exactly.
258/// @param catalogues The catalogues.
259/// @param key The message key.
260/// @param fallback The source text.
261/// @param query What the user typed; an empty query matches.
262/// @return Whether any locale's text contains it.
263// NOLINTBEGIN(bugprone-easily-swappable-parameters): key, fallback and query, as `t` and a search box give them.
264[[nodiscard]] inline bool matchesInAnyLocale(MessageCatalogues const& catalogues, std::string_view key,
265 std::string_view fallback, std::string_view query) {
266 // NOLINTEND(bugprone-easily-swappable-parameters)
267 auto const fold = [](std::string_view text) {
268 std::string out{text};
269 std::ranges::transform(out, out.begin(), [](char const symbol) {
270 return symbol >= 'A' && symbol <= 'Z' ? static_cast<char>(symbol - 'A' + 'a') : symbol;
271 });
272 return out;
273 };
274 std::string const needle = fold(query);
275 std::vector<std::string> texts = textInEveryLocale(catalogues, key, fallback);
276 texts.emplace_back(fallback);
277 return std::ranges::any_of(texts, [&](std::string const& text) { return fold(text).contains(needle); });
278}
279
280namespace detail::catalogue {
281
282/// @brief A catalog title's parts: a plain string, or the message key of a `{"t": …}` reference and its fallback.
284 std::optional<std::string> text; ///< A plain string title.
285 std::optional<std::string> key; ///< A message reference's key.
286 std::optional<std::string> fallback; ///< A message reference's fallback.
287};
288
289/// @brief Splits a catalog title into its parts.
290/// @param title The title.
291/// @return Its parts; all empty for a title that is neither a string nor an object.
292[[nodiscard]] inline TitleParts titleParts(Json const& title) {
293 TitleParts parts;
294 if (std::string const* const text = std::get_if<std::string>(&title.data)) {
295 parts.text = *text;
296 return parts;
297 }
298 if (auto const* const object = std::get_if<Json::object_t>(&title.data)) {
299 for (auto const& [name, value] : *object) {
300 std::string const* const text = std::get_if<std::string>(&value.data);
301 if (text == nullptr) {
302 continue;
303 }
304 if (name == "t") {
305 parts.key = *text;
306 } else if (name == "fallback") {
307 parts.fallback = *text;
308 }
309 }
310 }
311 return parts;
312}
313
314/// @brief A message pattern formatted with no arguments.
315/// @param pattern The pattern.
316/// @param locale The locale.
317/// @return The text, or nothing when the pattern does not parse.
318[[nodiscard]] inline std::optional<std::string> formatPattern(std::string_view pattern, std::string const& locale) {
319 auto parsed = parseMessage(pattern);
320 if (!parsed) {
321 return std::nullopt;
322 }
323 return formatMessage(*parsed, ValueRecord{}, MessageLocale{.locale = locale, .numeric = {}}).text;
324}
325
326} // namespace detail::catalogue
327
328/// @brief A catalog entry's title in one locale, as a shell's tab or header shows it.
329///
330/// A string title is shown as is. A message reference (`{"t": key, "fallback": …}`) is formatted in @p locale from
331/// @p catalogues — the exact locale's catalogue, then its language's — and is its fallback when no catalogue has the
332/// key. A title that is neither, or a reference with no fallback that no catalogue resolves, is the entry's id.
333/// @param entry The entry.
334/// @param catalogues The message catalogues, or null for none.
335/// @param locale The BCP 47 locale to format in.
336/// @return The title.
337[[nodiscard]] inline std::string catalogTitle(CatalogEntry const& entry, MessageCatalogues const* catalogues,
338 std::string_view locale) {
340 if (parts.text) {
341 return *parts.text;
342 }
343 std::string const where{locale};
344 if (parts.key && catalogues != nullptr) {
345 if (auto const pattern = catalogues->lookup(*parts.key, where)) {
346 if (auto text = detail::catalogue::formatPattern(*pattern, where)) {
347 return std::move(*text);
348 }
349 }
350 }
351 if (parts.fallback) {
352 return detail::catalogue::formatPattern(*parts.fallback, where).value_or(*parts.fallback);
353 }
354 return entry.id;
355}
356
357/// @brief A catalog entry's title in every locale the catalogues hold: what a search over screen titles compares
358/// against.
359///
360/// With @p catalogues, a message reference is `textInEveryLocale` of its key and fallback (the entry's id when it
361/// has none). Without, the one title is a string title as is, else a reference's fallback, else the entry's id.
362/// @param entry The entry.
363/// @param catalogues The message catalogues, or null for none.
364/// @return The titles; never empty.
365[[nodiscard]] inline std::vector<std::string> catalogTitles(CatalogEntry const& entry,
366 MessageCatalogues const* catalogues) {
368 if (parts.text) {
369 return {*parts.text};
370 }
371 if (parts.key && catalogues != nullptr) {
372 return textInEveryLocale(*catalogues, *parts.key, parts.fallback.value_or(entry.id));
373 }
374 return {parts.fallback.value_or(entry.id)};
375}
376
377} // namespace morph::ui
What the UI service's client and server share (spec 5 §12): content hashes, the bundle's manifest,...
std::optional< std::string > mustUnderstand(Json const &json, std::string_view where)
The must-understand key (!name) of one object, if it has one.
Definition bundle.hpp:369
constexpr std::string_view kMessagesFile
A message catalogue.
Definition bundle.hpp:88
constexpr std::size_t kMaxNameBytes
The longest name: an id, a kind, a vocabulary, a host name, an application id or version.
Definition bundle.hpp:79
std::vector< std::string > catalogTitles(CatalogEntry const &entry, MessageCatalogues const *catalogues)
A catalog entry's title in every locale the catalogues hold: what a search over screen titles compare...
bool matchesInAnyLocale(MessageCatalogues const &catalogues, std::string_view key, std::string_view fallback, std::string_view query)
Whether a message contains query in any locale the catalogues hold, or in its fallback: a navigator's...
std::optional< std::string > catalogueLocale(std::string_view fileId)
Reads the locale from a catalogue's id.
Definition catalogue.hpp:50
std::expected< Catalogue, std::string > parseCatalogue(std::string_view text)
Parses a catalogue: a flat JSON object of message key to MessageFormat 2 pattern.
Definition catalogue.hpp:74
std::optional< std::string > formatPattern(std::string_view pattern, std::string const &locale)
A message pattern formatted with no arguments.
MessageCatalogues loadCatalogues(Manifest const &manifest, std::map< std::string, std::string, std::less<> > const &files)
Reads every message catalogue a verified bundle lists.
constexpr std::string_view kCatalogueSuffix
The id suffix of a message catalogue.
Definition catalogue.hpp:36
constexpr std::size_t kMaxCatalogueKeys
The most keys one catalogue may hold.
Definition catalogue.hpp:30
TitleParts titleParts(Json const &title)
Splits a catalog title into its parts.
std::string catalogTitle(CatalogEntry const &entry, MessageCatalogues const *catalogues, std::string_view locale)
A catalog entry's title in one locale, as a shell's tab or header shows it.
constexpr std::string_view kCataloguePrefix
The id prefix of a message catalogue: i18n/LOCALE.json.
Definition catalogue.hpp:33
std::vector< std::string > textInEveryLocale(MessageCatalogues const &catalogues, std::string_view key, std::string_view fallback)
A message formatted in every locale the catalogues hold: what a search over titles compares against.
The message catalogues of one bundle, by locale, and the provider t resolves through.
void note(std::string line)
Records a loading problem.
render::TranslationProvider provider() const
A provider over the catalogues as they are now. It shares them, so it stays valid after this object i...
std::vector< std::string > locales() const
The locales with a catalogue, sorted.
std::vector< std::string > const & problems() const noexcept
What went wrong while loading: a catalogue left out, or a pattern dropped. Each line names the file.
std::optional< std::string > lookup(std::string_view key, std::string_view locale) const
A message's pattern in a locale: the exact locale's catalogue, then its language's (de-AT,...
void add(std::string locale, Catalogue catalogue)
Adds, or replaces, a locale's catalogue.
The MessageFormat 2 subset ui/1 accepts for t(key, fallback, args), and the CLDR plural rules its ....
FormattedMessage formatMessage(MessagePattern const &message, ValueRecord const &args, MessageLocale const &locale)
Formats a parsed message with named arguments.
Definition message.hpp:775
std::expected< MessagePattern, std::string > parseMessage(std::string_view text)
Parses a message in the ui/1 subset of MessageFormat 2.
Definition message.hpp:631
The renderer-side translation-catalog seam over morph's derived message keys (morph::forms::i18n,...
std::function< std::optional< std::string >(std::string_view key, std::string_view bcp47Locale)> TranslationProvider
A host-supplied catalog lookup: key/bcp47Locale -> translated text, or std::nullopt on a miss.
Definition i18n.hpp:27
One catalog entry (spec 6 §8).
Definition bundle.hpp:251
Json title
The title: a string, or a message reference such as {"t": "lims.sample.title", "fallback": "…"}...
Definition bundle.hpp:255
std::string id
The screen's id.
Definition bundle.hpp:253
One locale's messages, by key, with the patterns already parsed.
Definition catalogue.hpp:39
std::vector< std::string > dropped
One line per key whose pattern is outside the subset; those keys show their fallback.
Definition catalogue.hpp:43
std::map< std::string, std::string, std::less<> > messages
The pattern text of each key whose pattern is in the ui/1 subset.
Definition catalogue.hpp:41
One file of a manifest.
Definition bundle.hpp:125
std::string hash
The file's content hash.
Definition bundle.hpp:131
std::string kind
screen, app, theme, messages, component or icon; a client fetches no kind it does not know.
Definition bundle.hpp:129
std::string id
The file's id: a screen's document id, app, a theme's, a catalogue's or a component's name.
Definition bundle.hpp:127
A bundle's manifest: every file the application serves, by its hash (spec 5 §12, "Content addressing"...
Definition bundle.hpp:143
std::vector< ManifestFile > files
The files, sorted by kind then id.
Definition bundle.hpp:149
The locale a message is formatted for.
Definition message.hpp:636
Keyed rows of cells under column headers. Each row's view is a Row whose children are the cells,...
Definition view.hpp:888
A catalog title's parts: a plain string, or the message key of a {"t": …} reference and its fallback.
std::optional< std::string > text
A plain string title.
std::optional< std::string > fallback
A message reference's fallback.
std::optional< std::string > key
A message reference's key.
The UI document's typed values (ui::Value), its types (ui::Type), their JSON form,...
std::expected< Json, std::string > parseJson(std::string_view text)
Parses JSON text into the document layer's JSON tree.
Definition value.hpp:752
glz::generic_u64 Json
The JSON tree the document layer reads and writes: glaze's generic value in its integer-preserving mo...
Definition value.hpp:35
std::map< std::string, Value, std::less<> > ValueRecord
A record value: field name to value, ordered by name.
Definition value.hpp:129
Node text(Text spec)
Static or bound text.
Definition view.hpp:1194
Node table(std::vector< TableColumn > columns, std::function< std::vector< RowT >()> rows, std::function< Key(RowT const &)> keyOf, std::function< std::vector< Node >(reactive::Signal< RowT > const &)> cells, TableOptions options={})
A table of keyed rows: one Row of cells per row, under the column headers.
Definition view.hpp:1464