From 845deb43d60147603ec31fe4ae5fd7d747556d8c Mon Sep 17 00:00:00 2001 From: Jesse Plamondon-Willard Date: Tue, 1 Oct 2019 21:27:49 -0400 Subject: add support for core translation files --- build/common.targets | 5 + build/prepare-install-package.targets | 4 + docs/README.md | 20 ++++ docs/release-notes.md | 5 + .../Framework/ModHelpers/TranslationHelper.cs | 78 ++----------- src/SMAPI/Framework/SCore.cs | 127 ++++++++++++-------- src/SMAPI/Framework/SGame.cs | 7 +- src/SMAPI/Framework/Translator.cs | 128 +++++++++++++++++++++ src/SMAPI/SMAPI.csproj | 3 + src/SMAPI/i18n/default.json | 3 + 10 files changed, 265 insertions(+), 115 deletions(-) create mode 100644 src/SMAPI/Framework/Translator.cs create mode 100644 src/SMAPI/i18n/default.json diff --git a/build/common.targets b/build/common.targets index eb92cddd..10cdbe2c 100644 --- a/build/common.targets +++ b/build/common.targets @@ -21,6 +21,10 @@ + + + + @@ -29,6 +33,7 @@ + diff --git a/build/prepare-install-package.targets b/build/prepare-install-package.targets index 18a69a24..e5286bf5 100644 --- a/build/prepare-install-package.targets +++ b/build/prepare-install-package.targets @@ -17,6 +17,9 @@ windows unix + + + @@ -46,6 +49,7 @@ + diff --git a/docs/README.md b/docs/README.md index 90204387..fdb60693 100644 --- a/docs/README.md +++ b/docs/README.md @@ -54,3 +54,23 @@ developers and other modders! ### For SMAPI developers * [Technical docs](technical/smapi.md) + +## Translating SMAPI +SMAPI rarely shows text in-game, so it only has a few translations. Contributions are welcome! See +[Modding:Translations](https://stardewvalleywiki.com/Modding:Translations) on the wiki for help +contributing translations. + +locale | status +---------- | :---------------- +default | ✓ [fully translated](../src/SMAPI/i18n/default.json) +Chinese | ❑ not translated +French | ❑ not translated +German | ❑ not translated +Hungarian | ❑ not translated +Italian | ❑ not translated +Japanese | ❑ not translated +Korean | ❑ not translated +Portuguese | ❑ not translated +Russian | ❑ not translated +Spanish | ❑ not translated +Turkish | ❑ not translated diff --git a/docs/release-notes.md b/docs/release-notes.md index 26645210..5d8253b4 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -113,6 +113,11 @@ For modders: * Fixed changes to `Data\NPCDispositions` not always propagated correctly to existing NPCs. * Fixed `LoadStageChanged` event not raising correct flags in some cases when creating a new save. +### For SMAPI maintainers +* Added support for core translation files. +* Migrated to new `.csproj` format. +* Internal refactoring. + ## 2.11.3 Released 13 September 2019 for Stardew Valley 1.3.36. diff --git a/src/SMAPI/Framework/ModHelpers/TranslationHelper.cs b/src/SMAPI/Framework/ModHelpers/TranslationHelper.cs index 65850384..be7768e8 100644 --- a/src/SMAPI/Framework/ModHelpers/TranslationHelper.cs +++ b/src/SMAPI/Framework/ModHelpers/TranslationHelper.cs @@ -1,6 +1,4 @@ -using System; using System.Collections.Generic; -using System.Linq; using StardewValley; namespace StardewModdingAPI.Framework.ModHelpers @@ -11,21 +9,18 @@ namespace StardewModdingAPI.Framework.ModHelpers /********* ** Fields *********/ - /// The translations for each locale. - private readonly IDictionary> All = new Dictionary>(StringComparer.InvariantCultureIgnoreCase); - - /// The translations for the current locale, with locale fallback taken into account. - private IDictionary ForLocale; + /// The underlying translation manager. + private readonly Translator Translator; /********* ** Accessors *********/ /// The current locale. - public string Locale { get; private set; } + public string Locale => this.Translator.Locale; /// The game's current language code. - public LocalizedContentManager.LanguageCode LocaleEnum { get; private set; } + public LocalizedContentManager.LanguageCode LocaleEnum => this.Translator.LocaleEnum; /********* @@ -38,22 +33,21 @@ namespace StardewModdingAPI.Framework.ModHelpers public TranslationHelper(string modID, string locale, LocalizedContentManager.LanguageCode languageCode) : base(modID) { - // set locale - this.SetLocale(locale, languageCode); + this.Translator = new Translator(); + this.Translator.SetLocale(locale, languageCode); } /// Get all translations for the current locale. public IEnumerable GetTranslations() { - return this.ForLocale.Values.ToArray(); + return this.Translator.GetTranslations(); } /// Get a translation for the current locale. /// The translation key. public Translation Get(string key) { - this.ForLocale.TryGetValue(key, out Translation translation); - return translation ?? new Translation(this.Locale, key, null); + return this.Translator.Get(key); } /// Get a translation for the current locale. @@ -61,21 +55,14 @@ namespace StardewModdingAPI.Framework.ModHelpers /// An object containing token key/value pairs. This can be an anonymous object (like new { value = 42, name = "Cranberries" }), a dictionary, or a class instance. public Translation Get(string key, object tokens) { - return this.Get(key).Tokens(tokens); + return this.Translator.Get(key, tokens); } /// Set the translations to use. /// The translations to use. internal TranslationHelper SetTranslations(IDictionary> translations) { - // reset translations - this.All.Clear(); - foreach (var pair in translations) - this.All[pair.Key] = new Dictionary(pair.Value, StringComparer.InvariantCultureIgnoreCase); - - // rebuild cache - this.SetLocale(this.Locale, this.LocaleEnum); - + this.Translator.SetTranslations(translations); return this; } @@ -84,50 +71,7 @@ namespace StardewModdingAPI.Framework.ModHelpers /// The game's current language code. internal void SetLocale(string locale, LocalizedContentManager.LanguageCode localeEnum) { - this.Locale = locale.ToLower().Trim(); - this.LocaleEnum = localeEnum; - - this.ForLocale = new Dictionary(StringComparer.InvariantCultureIgnoreCase); - foreach (string next in this.GetRelevantLocales(this.Locale)) - { - // skip if locale not defined - if (!this.All.TryGetValue(next, out IDictionary translations)) - continue; - - // add missing translations - foreach (var pair in translations) - { - if (!this.ForLocale.ContainsKey(pair.Key)) - this.ForLocale.Add(pair.Key, new Translation(this.Locale, pair.Key, pair.Value)); - } - } - } - - - /********* - ** Private methods - *********/ - /// Get the locales which can provide translations for the given locale, in precedence order. - /// The locale for which to find valid locales. - private IEnumerable GetRelevantLocales(string locale) - { - // given locale - yield return locale; - - // broader locales (like pt-BR => pt) - while (true) - { - int dashIndex = locale.LastIndexOf('-'); - if (dashIndex <= 0) - break; - - locale = locale.Substring(0, dashIndex); - yield return locale; - } - - // default - if (locale != "default") - yield return "default"; + this.Translator.SetLocale(locale, localeEnum); } } } diff --git a/src/SMAPI/Framework/SCore.cs b/src/SMAPI/Framework/SCore.cs index a4b38a50..bd131762 100644 --- a/src/SMAPI/Framework/SCore.cs +++ b/src/SMAPI/Framework/SCore.cs @@ -61,6 +61,9 @@ namespace StardewModdingAPI.Framework /// Simplifies access to private game code. private readonly Reflector Reflection = new Reflector(); + /// Encapsulates access to SMAPI core translations. + private readonly Translator Translator = new Translator(); + /// The SMAPI configuration settings. private readonly SConfig Settings; @@ -223,6 +226,7 @@ namespace StardewModdingAPI.Framework monitor: this.Monitor, monitorForGame: this.MonitorForGame, reflection: this.Reflection, + translator: this.Translator, eventManager: this.EventManager, jsonHelper: this.Toolkit.JsonHelper, modRegistry: this.ModRegistry, @@ -232,6 +236,7 @@ namespace StardewModdingAPI.Framework cancellationToken: this.CancellationToken, logNetworkTraffic: this.Settings.LogNetworkTraffic ); + this.Translator.SetLocale(this.GameInstance.ContentCore.GetLocale(), this.GameInstance.ContentCore.Language); StardewValley.Program.gamePtr = this.GameInstance; // apply game patches @@ -466,6 +471,9 @@ namespace StardewModdingAPI.Framework string locale = this.ContentCore.GetLocale(); LocalizedContentManager.LanguageCode languageCode = this.ContentCore.Language; + // update core translations + this.Translator.SetLocale(locale, languageCode); + // update mod translation helpers foreach (IModMetadata mod in this.ModRegistry.GetAll()) mod.Translations.SetLocale(locale, languageCode); @@ -1027,6 +1035,14 @@ namespace StardewModdingAPI.Framework TranslationHelper translationHelper = new TranslationHelper(manifest.UniqueID, contentCore.GetLocale(), contentCore.Language); IModHelper modHelper; { + IContentPack CreateFakeContentPack(string packDirPath, IManifest packManifest) + { + IMonitor packMonitor = this.GetSecondaryMonitor(packManifest.Name); + IContentHelper packContentHelper = new ContentHelper(contentCore, packDirPath, packManifest.UniqueID, packManifest.Name, packMonitor); + ITranslationHelper packTranslationHelper = new TranslationHelper(packManifest.UniqueID, contentCore.GetLocale(), contentCore.Language); + return new ContentPack(packDirPath, packManifest, packContentHelper, packTranslationHelper, this.Toolkit.JsonHelper); + } + IModEvents events = new ModEvents(mod, this.EventManager); ICommandHelper commandHelper = new CommandHelper(mod, this.GameInstance.CommandManager); IContentHelper contentHelper = new ContentHelper(contentCore, mod.DirectoryPath, manifest.UniqueID, mod.DisplayName, monitor); @@ -1036,14 +1052,6 @@ namespace StardewModdingAPI.Framework IModRegistry modRegistryHelper = new ModRegistryHelper(manifest.UniqueID, this.ModRegistry, proxyFactory, monitor); IMultiplayerHelper multiplayerHelper = new MultiplayerHelper(manifest.UniqueID, this.GameInstance.Multiplayer); - IContentPack CreateFakeContentPack(string packDirPath, IManifest packManifest) - { - IMonitor packMonitor = this.GetSecondaryMonitor(packManifest.Name); - IContentHelper packContentHelper = new ContentHelper(contentCore, packDirPath, packManifest.UniqueID, packManifest.Name, packMonitor); - ITranslationHelper packTranslationHelper = new TranslationHelper(packManifest.UniqueID, contentCore.GetLocale(), contentCore.Language); - return new ContentPack(packDirPath, packManifest, packContentHelper, packTranslationHelper, this.Toolkit.JsonHelper); - } - modHelper = new ModHelper(manifest.UniqueID, mod.DirectoryPath, this.GameInstance.Input, events, contentHelper, contentPackHelper, commandHelper, dataHelper, modRegistryHelper, reflectionHelper, multiplayerHelper, translationHelper); } @@ -1205,60 +1213,85 @@ namespace StardewModdingAPI.Framework /// The mods for which to reload translations. private void ReloadTranslations(IEnumerable mods) { - JsonHelper jsonHelper = this.Toolkit.JsonHelper; + // core SMAPI translations + { + var translations = this.ReadTranslationFiles(Path.Combine(Constants.InternalFilesPath, "i18n"), out IList errors); + if (errors.Any() || !translations.Any()) + { + this.Monitor.Log("SMAPI couldn't load some core translations. You may need to reinstall SMAPI.", LogLevel.Warn); + foreach (string error in errors) + this.Monitor.Log($" - {error}", LogLevel.Warn); + } + this.Translator.SetTranslations(translations); + } + + // mod translations foreach (IModMetadata metadata in mods) { - // read translation files - IDictionary> translations = new Dictionary>(); - DirectoryInfo translationsDir = new DirectoryInfo(Path.Combine(metadata.DirectoryPath, "i18n")); - if (translationsDir.Exists) + var translations = this.ReadTranslationFiles(Path.Combine(metadata.DirectoryPath, "i18n"), out IList errors); + if (errors.Any()) { - foreach (FileInfo file in translationsDir.EnumerateFiles("*.json")) + metadata.LogAsMod("Mod couldn't load some translation files:", LogLevel.Warn); + foreach (string error in errors) + metadata.LogAsMod($" - {error}", LogLevel.Warn); + } + metadata.Translations.SetTranslations(translations); + } + } + + /// Read translations from a directory containing JSON translation files. + /// The folder path to search. + /// The errors indicating why translation files couldn't be parsed, indexed by translation filename. + private IDictionary> ReadTranslationFiles(string folderPath, out IList errors) + { + JsonHelper jsonHelper = this.Toolkit.JsonHelper; + + // read translation files + var translations = new Dictionary>(); + errors = new List(); + DirectoryInfo translationsDir = new DirectoryInfo(folderPath); + if (translationsDir.Exists) + { + foreach (FileInfo file in translationsDir.EnumerateFiles("*.json")) + { + string locale = Path.GetFileNameWithoutExtension(file.Name.ToLower().Trim()); + try { - string locale = Path.GetFileNameWithoutExtension(file.Name.ToLower().Trim()); - try - { - if (jsonHelper.ReadJsonFileIfExists(file.FullName, out IDictionary data)) - translations[locale] = data; - else - metadata.LogAsMod($"Mod's i18n/{locale}.json file couldn't be parsed.", LogLevel.Warn); - } - catch (Exception ex) + if (!jsonHelper.ReadJsonFileIfExists(file.FullName, out IDictionary data)) { - metadata.LogAsMod($"Mod's i18n/{locale}.json file couldn't be parsed: {ex.GetLogSummary()}", LogLevel.Warn); + errors.Add($"{file.Name} file couldn't be read"); // should never happen, since we're iterating files that exist + continue; } - } - } - // validate translations - foreach (string locale in translations.Keys.ToArray()) - { - // skip empty files - if (translations[locale] == null || !translations[locale].Keys.Any()) + translations[locale] = data; + } + catch (Exception ex) { - metadata.LogAsMod($"Mod's i18n/{locale}.json is empty and will be ignored.", LogLevel.Warn); - translations.Remove(locale); + errors.Add($"{file.Name} file couldn't be parsed: {ex.GetLogSummary()}"); continue; } + } + } - // handle duplicates - HashSet keys = new HashSet(StringComparer.InvariantCultureIgnoreCase); - HashSet duplicateKeys = new HashSet(StringComparer.InvariantCultureIgnoreCase); - foreach (string key in translations[locale].Keys.ToArray()) + // validate translations + foreach (string locale in translations.Keys.ToArray()) + { + // handle duplicates + HashSet keys = new HashSet(StringComparer.InvariantCultureIgnoreCase); + HashSet duplicateKeys = new HashSet(StringComparer.InvariantCultureIgnoreCase); + foreach (string key in translations[locale].Keys.ToArray()) + { + if (!keys.Add(key)) { - if (!keys.Add(key)) - { - duplicateKeys.Add(key); - translations[locale].Remove(key); - } + duplicateKeys.Add(key); + translations[locale].Remove(key); } - if (duplicateKeys.Any()) - metadata.LogAsMod($"Mod's i18n/{locale}.json has duplicate translation keys: [{string.Join(", ", duplicateKeys)}]. Keys are case-insensitive.", LogLevel.Warn); } - - // update translation - metadata.Translations.SetTranslations(translations); + if (duplicateKeys.Any()) + errors.Add($"{locale}.json has duplicate translation keys: [{string.Join(", ", duplicateKeys)}]. Keys are case-insensitive."); } + + return translations; } /// The method called when the user submits a core SMAPI command in the console. diff --git a/src/SMAPI/Framework/SGame.cs b/src/SMAPI/Framework/SGame.cs index cb1b9be5..89705352 100644 --- a/src/SMAPI/Framework/SGame.cs +++ b/src/SMAPI/Framework/SGame.cs @@ -83,6 +83,9 @@ namespace StardewModdingAPI.Framework /// Simplifies access to private game code. private readonly Reflector Reflection; + /// Encapsulates access to SMAPI core translations. + private readonly Translator Translator; + /// Propagates notification that SMAPI should exit. private readonly CancellationTokenSource CancellationToken; @@ -135,6 +138,7 @@ namespace StardewModdingAPI.Framework /// Encapsulates monitoring and logging for SMAPI. /// Encapsulates monitoring and logging on the game's behalf. /// Simplifies access to private game code. + /// Encapsulates access to arbitrary translations. /// Manages SMAPI events for mods. /// Encapsulates SMAPI's JSON file parsing. /// Tracks the installed mods. @@ -143,7 +147,7 @@ namespace StardewModdingAPI.Framework /// A callback to invoke when the game exits. /// Propagates notification that SMAPI should exit. /// Whether to log network traffic. - internal SGame(Monitor monitor, IMonitor monitorForGame, Reflector reflection, EventManager eventManager, JsonHelper jsonHelper, ModRegistry modRegistry, DeprecationManager deprecationManager, Action onGameInitialized, Action onGameExiting, CancellationTokenSource cancellationToken, bool logNetworkTraffic) + internal SGame(Monitor monitor, IMonitor monitorForGame, Reflector reflection, Translator translator, EventManager eventManager, JsonHelper jsonHelper, ModRegistry modRegistry, DeprecationManager deprecationManager, Action onGameInitialized, Action onGameExiting, CancellationTokenSource cancellationToken, bool logNetworkTraffic) { this.OnLoadingFirstAsset = SGame.ConstructorHack.OnLoadingFirstAsset; SGame.ConstructorHack = null; @@ -161,6 +165,7 @@ namespace StardewModdingAPI.Framework this.Events = eventManager; this.ModRegistry = modRegistry; this.Reflection = reflection; + this.Translator = translator; this.DeprecationManager = deprecationManager; this.OnGameInitialized = onGameInitialized; this.OnGameExiting = onGameExiting; diff --git a/src/SMAPI/Framework/Translator.cs b/src/SMAPI/Framework/Translator.cs new file mode 100644 index 00000000..f2738633 --- /dev/null +++ b/src/SMAPI/Framework/Translator.cs @@ -0,0 +1,128 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using StardewValley; + +namespace StardewModdingAPI.Framework +{ + /// Encapsulates access to arbitrary translations. Translations are fetched with locale fallback, so missing translations are filled in from broader locales (like pt-BR.json < pt.json < default.json). + internal class Translator + { + /********* + ** Fields + *********/ + /// The translations for each locale. + private readonly IDictionary> All = new Dictionary>(StringComparer.InvariantCultureIgnoreCase); + + /// The translations for the current locale, with locale fallback taken into account. + private IDictionary ForLocale; + + + /********* + ** Accessors + *********/ + /// The current locale. + public string Locale { get; private set; } + + /// The game's current language code. + public LocalizedContentManager.LanguageCode LocaleEnum { get; private set; } + + + /********* + ** Public methods + *********/ + /// Construct an instance. + public Translator() + { + this.SetLocale(string.Empty, LocalizedContentManager.LanguageCode.en); + } + + /// Set the current locale and precache translations. + /// The current locale. + /// The game's current language code. + public void SetLocale(string locale, LocalizedContentManager.LanguageCode localeEnum) + { + this.Locale = locale.ToLower().Trim(); + this.LocaleEnum = localeEnum; + + this.ForLocale = new Dictionary(StringComparer.InvariantCultureIgnoreCase); + foreach (string next in this.GetRelevantLocales(this.Locale)) + { + // skip if locale not defined + if (!this.All.TryGetValue(next, out IDictionary translations)) + continue; + + // add missing translations + foreach (var pair in translations) + { + if (!this.ForLocale.ContainsKey(pair.Key)) + this.ForLocale.Add(pair.Key, new Translation(this.Locale, pair.Key, pair.Value)); + } + } + } + + /// Get all translations for the current locale. + public IEnumerable GetTranslations() + { + return this.ForLocale.Values.ToArray(); + } + + /// Get a translation for the current locale. + /// The translation key. + public Translation Get(string key) + { + this.ForLocale.TryGetValue(key, out Translation translation); + return translation ?? new Translation(this.Locale, key, null); + } + + /// Get a translation for the current locale. + /// The translation key. + /// An object containing token key/value pairs. This can be an anonymous object (like new { value = 42, name = "Cranberries" }), a dictionary, or a class instance. + public Translation Get(string key, object tokens) + { + return this.Get(key).Tokens(tokens); + } + + /// Set the translations to use. + /// The translations to use. + internal Translator SetTranslations(IDictionary> translations) + { + // reset translations + this.All.Clear(); + foreach (var pair in translations) + this.All[pair.Key] = new Dictionary(pair.Value, StringComparer.InvariantCultureIgnoreCase); + + // rebuild cache + this.SetLocale(this.Locale, this.LocaleEnum); + + return this; + } + + + /********* + ** Private methods + *********/ + /// Get the locales which can provide translations for the given locale, in precedence order. + /// The locale for which to find valid locales. + private IEnumerable GetRelevantLocales(string locale) + { + // given locale + yield return locale; + + // broader locales (like pt-BR => pt) + while (true) + { + int dashIndex = locale.LastIndexOf('-'); + if (dashIndex <= 0) + break; + + locale = locale.Substring(0, dashIndex); + yield return locale; + } + + // default + if (locale != "default") + yield return "default"; + } + } +} diff --git a/src/SMAPI/SMAPI.csproj b/src/SMAPI/SMAPI.csproj index 7c7bfc71..62002a40 100644 --- a/src/SMAPI/SMAPI.csproj +++ b/src/SMAPI/SMAPI.csproj @@ -99,6 +99,9 @@ SMAPI.metadata.json PreserveNewest + + PreserveNewest + PreserveNewest diff --git a/src/SMAPI/i18n/default.json b/src/SMAPI/i18n/default.json new file mode 100644 index 00000000..0db3279e --- /dev/null +++ b/src/SMAPI/i18n/default.json @@ -0,0 +1,3 @@ +{ + +} -- cgit