From 5cf5fdfffa872301a14d08518c20e4cdc651e9c0 Mon Sep 17 00:00:00 2001 From: Inari Tommiska Date: Fri, 1 Aug 2025 20:57:49 +0300 Subject: [PATCH] support for structured json in lang files Adjust the lang file parsing to allow nesting of keys. Generate translation keys as paths based on the structure of the json. For example, given this JSON: { "foo": { "bar": "baz" }, "yay": "wohoo!" } This can *kind of* already be achieved with the current parser as: { "foo-bar": "baz", "yay": "wohoo!", } However, this lacks structure and is harder to read, especially if desired structure has multiple levels of nesting. The structured version has the added benefit of supporting code folding in editors, helping when working with larger sets of translation keys. The example JSONs for the existing parser and for the updated parser both generate identical translation keys: domain:foo-bar => "baz" domain:yay => "wohoo!" Therefore, the changes are mostly opt-in, giving more flexibility, while still allowing using the old "flat" lang files as-is. --- Localization/TranslationService.cs | 107 +++++++++++++++++++++++++---- 1 file changed, 95 insertions(+), 12 deletions(-) diff --git a/Localization/TranslationService.cs b/Localization/TranslationService.cs index aaab145f6..9cb5be5b8 100644 --- a/Localization/TranslationService.cs +++ b/Localization/TranslationService.cs @@ -5,7 +5,7 @@ using System.Runtime.CompilerServices; using System.Text; using System.Text.RegularExpressions; -using Newtonsoft.Json; +using Newtonsoft.Json.Linq; using Vintagestory.API.Client; using Vintagestory.API.Common; using Vintagestory.API.Util; @@ -83,7 +83,7 @@ public void Load(bool lazyLoad = false) try { var json = asset.ToText(); - LoadEntries(entryCache, regexCache, wildcardCache, JsonConvert.DeserializeObject>(json), asset.Location.Domain); + LoadEntries(entryCache, regexCache, wildcardCache, JToken.Parse(json), asset.Location.Domain); } catch (Exception ex) { @@ -123,7 +123,7 @@ public void PreLoad(string assetsPath, bool lazyLoad = false) try { var json = File.ReadAllText(file.FullName); - LoadEntries(entryCache, regexCache, wildcardCache, JsonConvert.DeserializeObject>(json)); + LoadEntries(entryCache, regexCache, wildcardCache, JToken.Parse(json)); } catch (Exception ex) { @@ -174,7 +174,7 @@ public void PreLoadModWorldConfig(string modPath = null, string modDomain = null try { var json = File.ReadAllText(file.FullName); - LoadEntries(entryCache, regexCache, wildcardCache, JsonConvert.DeserializeObject>(json)); + LoadEntries(entryCache, regexCache, wildcardCache, JToken.Parse(json)); } catch (Exception ex) { @@ -574,35 +574,118 @@ public string GetMatchingIfExists(string key, params object[] args) .FirstOrDefault(); } - private void LoadEntries(Dictionary entryCache, Dictionary> regexCache, Dictionary wildcardCache, Dictionary entries, string domain = GlobalConstants.DefaultDomain) + /// + /// Loads translation KVPs from a JSON tree. Supports nested objects by + /// recursively traversing through object values, until leaf string + /// properties are encountered. + /// + /// As the function recurses deeper, the traversed path is accumulated + /// and used as a prefix for the translation key. + /// + /// For "vanilla style", flat translation files this is trivial, as + /// each property within the root-level object corresponds to a single + /// translation KVP: + /// + /// { + /// "item-axe-copper": "Copper axe", + /// "item-axe-iron": "Iron axe", + /// "item-axe-steel": "Steel axe", + /// } + /// + /// Alternatively, the parser supports constructing the keys from a + /// nested structure, where parts of the path are split into nested + /// objects, with arbitrary nesting: + /// + /// { + /// "item": { + /// "axe-copper": "Copper axe", + /// "axe-iron": "Iron axe", + /// "axe": { + /// "steel": "Steel axe" + /// } + /// } + /// } + /// + /// The translation values in the examples above are functionally the + /// same. Both produce identical translation keys. + /// + /// For the latter, nested structure, the final keys are computed by + /// concatenating the parent keys as prefixes to the key, using a dash + /// (-) as a separator. + /// + /// For example, in the above sample, the item and its child + /// property axe-copper gets concatenated as item-axe-copper + /// . Likewise, the key steel has its parents axe and + /// item prefixed to it, resulting in the full key + /// item-axe-steel. + /// + private static void LoadEntries(Dictionary entryCache, Dictionary> regexCache, Dictionary wildcardCache, JToken json, string domain = GlobalConstants.DefaultDomain) + { + var key = new StringBuilder(domain, 256) + .Append(AssetLocation.LocationSeparator); + LoadEntries(entryCache, regexCache, wildcardCache, json, key, domain, isFirstPart: true); + } + + private static void LoadEntries(Dictionary entryCache, Dictionary> regexCache, Dictionary wildcardCache, JToken json, StringBuilder key, string domain, bool isFirstPart) { - foreach (var entry in entries) + switch (json) { - LoadEntry(entryCache, regexCache, wildcardCache, entry, domain); + case JObject jsonObject: + if (!isFirstPart) + { + key.Append('-'); + } + + var prefixLength = key.Length; + foreach (var property in jsonObject.Properties()) + { + key.Length = prefixLength; + key.Append(property.Name); + LoadEntries(entryCache, regexCache, wildcardCache, property.Value, key, domain, isFirstPart: false); + } + break; + case JValue jsonValue when jsonValue.Type == JTokenType.String && !isFirstPart: + LoadEntry(entryCache, regexCache, wildcardCache, key, jsonValue.ToString(), domain); + break; + default: + throw new InvalidOperationException($"Unexpected token: {json.Type}"); } } - private void LoadEntry(Dictionary entryCache, Dictionary> regexCache, Dictionary wildcardCache, KeyValuePair entry, string domain = GlobalConstants.DefaultDomain) + private static void LoadEntry(Dictionary entryCache, Dictionary> regexCache, Dictionary wildcardCache, StringBuilder keyBuilder, string value, string domain) { - var key = KeyWithDomain(entry.Key, domain); + var key = EnsureSingleDomainPrefix(keyBuilder, domain); switch (key.CountChars('*')) { case 0: - entryCache[key] = entry.Value; + entryCache[key] = value; break; case 1 when key.EndsWith('*'): - wildcardCache[key.TrimEnd('*')] = entry.Value; + wildcardCache[key.TrimEnd('*')] = value; break; // we can probably do better here, as we have our own wildcardsearch now default: { var regex = new Regex("^" + key.Replace("*", "(.*)") + "$", RegexOptions.Compiled); - regexCache[key] = new KeyValuePair(regex, entry.Value); + regexCache[key] = new KeyValuePair(regex, value); break; } } } + private static string EnsureSingleDomainPrefix(StringBuilder keyBuilder, string domain = GlobalConstants.DefaultDomain) + { + var key = keyBuilder.ToString(); + var defaultDomainEndIndex = domain.Length + 1; + if (key.IndexOf(AssetLocation.LocationSeparator, defaultDomainEndIndex) >= 0) + { + // Key contains a custom prefix, drop the default prefix + return key.Substring(defaultDomainEndIndex); + } + + return key; + } + private static string KeyWithDomain(string key, string domain = GlobalConstants.DefaultDomain) { if (key.Contains(AssetLocation.LocationSeparator)) return key;