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;