diff options
-rw-r--r-- | src/StardewModdingAPI/Framework/CommandHelper.cs | 2 | ||||
-rw-r--r-- | src/StardewModdingAPI/Framework/ModHelper.cs | 19 | ||||
-rw-r--r-- | src/StardewModdingAPI/Framework/SContentManager.cs | 6 | ||||
-rw-r--r-- | src/StardewModdingAPI/Framework/TranslationHelper.cs | 117 | ||||
-rw-r--r-- | src/StardewModdingAPI/IModHelper.cs | 10 | ||||
-rw-r--r-- | src/StardewModdingAPI/ITranslationHelper.cs | 29 | ||||
-rw-r--r-- | src/StardewModdingAPI/Program.cs | 49 | ||||
-rw-r--r-- | src/StardewModdingAPI/StardewModdingAPI.csproj | 3 | ||||
-rw-r--r-- | src/StardewModdingAPI/Translation.cs | 139 |
9 files changed, 368 insertions, 6 deletions
diff --git a/src/StardewModdingAPI/Framework/CommandHelper.cs b/src/StardewModdingAPI/Framework/CommandHelper.cs index 2e9dea8e..86734fc5 100644 --- a/src/StardewModdingAPI/Framework/CommandHelper.cs +++ b/src/StardewModdingAPI/Framework/CommandHelper.cs @@ -50,4 +50,4 @@ namespace StardewModdingAPI.Framework return this.CommandManager.Trigger(name, arguments); } } -}
\ No newline at end of file +} diff --git a/src/StardewModdingAPI/Framework/ModHelper.cs b/src/StardewModdingAPI/Framework/ModHelper.cs index f939b83c..8c578dbe 100644 --- a/src/StardewModdingAPI/Framework/ModHelper.cs +++ b/src/StardewModdingAPI/Framework/ModHelper.cs @@ -32,22 +32,25 @@ namespace StardewModdingAPI.Framework /// <summary>An API for managing console commands.</summary> public ICommandHelper ConsoleCommands { get; } + /// <summary>Provides translations stored in the mod's <c>i18n</c> folder, with one file per locale (like <c>en.json</c>) containing a flat key => value structure. Translations are fetched with locale fallback, so missing translations are filled in from broader locales (like <c>pt-BR.json</c> < <c>pt.json</c> < <c>default.json</c>).</summary> + public ITranslationHelper Translation { get; } + /********* ** Public methods *********/ /// <summary>Construct an instance.</summary> /// <param name="displayName">The mod's display name.</param> - /// <param name="manifest">The manifest for the associated mod.</param> /// <param name="modDirectory">The full path to the mod's folder.</param> /// <param name="jsonHelper">Encapsulate SMAPI's JSON parsing.</param> /// <param name="modRegistry">Metadata about loaded mods.</param> /// <param name="commandManager">Manages console commands.</param> /// <param name="contentManager">The content manager which loads content assets.</param> /// <param name="reflection">Simplifies access to private game code.</param> + /// <param name="translations">Provides translations stored in the mod folder.</param> /// <exception cref="ArgumentNullException">An argument is null or empty.</exception> /// <exception cref="InvalidOperationException">The <paramref name="modDirectory"/> path does not exist on disk.</exception> - public ModHelper(string displayName, IManifest manifest, string modDirectory, JsonHelper jsonHelper, IModRegistry modRegistry, CommandManager commandManager, SContentManager contentManager, IReflectionHelper reflection) + public ModHelper(string displayName, string modDirectory, JsonHelper jsonHelper, IModRegistry modRegistry, CommandManager commandManager, SContentManager contentManager, IReflectionHelper reflection, ITranslationHelper translations) { // validate if (string.IsNullOrWhiteSpace(modDirectory)) @@ -66,6 +69,7 @@ namespace StardewModdingAPI.Framework this.ModRegistry = modRegistry; this.ConsoleCommands = new CommandHelper(displayName, commandManager); this.Reflection = reflection; + this.Translation = translations; } /**** @@ -115,6 +119,17 @@ namespace StardewModdingAPI.Framework this.JsonHelper.WriteJsonFile(path, model); } + + /**** + ** Translation + ****/ + /// <summary>Get a translation for the current locale. This is a convenience shortcut for <see cref="IModHelper.Translation"/>.</summary> + /// <param name="key">The translation key.</param> + public Translation Translate(string key) + { + return this.Translation.Translate(key); + } + /**** ** Disposal ****/ diff --git a/src/StardewModdingAPI/Framework/SContentManager.cs b/src/StardewModdingAPI/Framework/SContentManager.cs index 54349a91..acd3e108 100644 --- a/src/StardewModdingAPI/Framework/SContentManager.cs +++ b/src/StardewModdingAPI/Framework/SContentManager.cs @@ -145,6 +145,12 @@ namespace StardewModdingAPI.Framework this.Cache[assetName] = value; } + /// <summary>Get the current content locale.</summary> + public string GetLocale() + { + return this.GetKeyLocale.Invoke<string>(); + } + /********* ** Private methods *********/ diff --git a/src/StardewModdingAPI/Framework/TranslationHelper.cs b/src/StardewModdingAPI/Framework/TranslationHelper.cs new file mode 100644 index 00000000..dece6214 --- /dev/null +++ b/src/StardewModdingAPI/Framework/TranslationHelper.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections.Generic; +using StardewValley; + +namespace StardewModdingAPI.Framework +{ + /// <summary>Provides translations stored in the mod's <c>i18n</c> folder, with one file per locale (like <c>en.json</c>) containing a flat key => value structure. Translations are fetched with locale fallback, so missing translations are filled in from broader locales (like <c>pt-BR.json</c> < <c>pt.json</c> < <c>default.json</c>).</summary> + internal class TranslationHelper : ITranslationHelper + { + /********* + ** Properties + *********/ + /// <summary>The name of the relevant mod for error messages.</summary> + private readonly string ModName; + + /// <summary>The translations for each locale.</summary> + private readonly IDictionary<string, IDictionary<string, string>> All = new Dictionary<string, IDictionary<string, string>>(StringComparer.InvariantCultureIgnoreCase); + + /// <summary>The translations for the current locale, with locale fallback taken into account.</summary> + private IDictionary<string, string> ForLocale; + + + /********* + ** Accessors + *********/ + /// <summary>The current locale.</summary> + public string Locale { get; private set; } + + /// <summary>The game's current language code.</summary> + public LocalizedContentManager.LanguageCode LocaleEnum { get; private set; } + + + /********* + ** Public methods + *********/ + /// <summary>Construct an instance.</summary> + /// <param name="modName">The name of the relevant mod for error messages.</param> + /// <param name="locale">The initial locale.</param> + /// <param name="languageCode">The game's current language code.</param> + /// <param name="translations">The translations for each locale.</param> + public TranslationHelper(string modName, string locale, LocalizedContentManager.LanguageCode languageCode, IDictionary<string, IDictionary<string, string>> translations) + { + // save data + this.ModName = modName; + foreach (var pair in translations) + this.All[pair.Key] = new Dictionary<string, string>(pair.Value, StringComparer.InvariantCultureIgnoreCase); + + // set locale + this.SetLocale(locale, languageCode); + } + + /// <summary>Get all translations for the current locale.</summary> + public IDictionary<string, string> GetTranslations() + { + return new Dictionary<string, string>(this.ForLocale, StringComparer.InvariantCultureIgnoreCase); + } + + /// <summary>Get a translation for the current locale.</summary> + /// <param name="key">The translation key.</param> + public Translation Translate(string key) + { + this.ForLocale.TryGetValue(key, out string text); + return new Translation(this.ModName, this.Locale, key, text); + } + + /// <summary>Set the current locale and precache translations.</summary> + /// <param name="locale">The current locale.</param> + /// <param name="localeEnum">The game's current language code.</param> + internal void SetLocale(string locale, LocalizedContentManager.LanguageCode localeEnum) + { + this.Locale = locale.ToLower().Trim(); + this.LocaleEnum = localeEnum; + + this.ForLocale = new Dictionary<string, string>(StringComparer.InvariantCultureIgnoreCase); + foreach (string next in this.GetRelevantLocales(this.Locale)) + { + // skip if locale not defined + if (!this.All.TryGetValue(next, out IDictionary<string, string> translations)) + continue; + + // add missing translations + foreach (var pair in translations) + { + if (!this.ForLocale.ContainsKey(pair.Key)) + this.ForLocale.Add(pair); + } + } + } + + + /********* + ** Private methods + *********/ + /// <summary>Get the locales which can provide translations for the given locale, in precedence order.</summary> + /// <param name="locale">The locale for which to find valid locales.</param> + private IEnumerable<string> 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/StardewModdingAPI/IModHelper.cs b/src/StardewModdingAPI/IModHelper.cs index cdff6ac8..38bfd366 100644 --- a/src/StardewModdingAPI/IModHelper.cs +++ b/src/StardewModdingAPI/IModHelper.cs @@ -21,6 +21,9 @@ /// <summary>An API for managing console commands.</summary> ICommandHelper ConsoleCommands { get; } + /// <summary>Provides translations stored in the mod's <c>i18n</c> folder, with one file per locale (like <c>en.json</c>) containing a flat key => value structure. Translations are fetched with locale fallback, so missing translations are filled in from broader locales (like <c>pt-BR.json</c> < <c>pt.json</c> < <c>default.json</c>).</summary> + ITranslationHelper Translation { get; } + /********* ** Public methods @@ -51,5 +54,12 @@ /// <param name="path">The file path relative to the mod directory.</param> /// <param name="model">The model to save.</param> void WriteJsonFile<TModel>(string path, TModel model) where TModel : class; + + /**** + ** Translations + ****/ + /// <summary>Get a translation for the current locale. This is a convenience shortcut for <see cref="IModHelper.Translation"/>.</summary> + /// <param name="key">The translation key.</param> + Translation Translate(string key); } }
\ No newline at end of file diff --git a/src/StardewModdingAPI/ITranslationHelper.cs b/src/StardewModdingAPI/ITranslationHelper.cs new file mode 100644 index 00000000..84571d0e --- /dev/null +++ b/src/StardewModdingAPI/ITranslationHelper.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using StardewValley; + +namespace StardewModdingAPI +{ + /// <summary>Provides translations stored in the mod's <c>i18n</c> folder, with one file per locale (like <c>en.json</c>) containing a flat key => value structure. Translations are fetched with locale fallback, so missing translations are filled in from broader locales (like <c>pt-BR.json</c> < <c>pt.json</c> < <c>default.json</c>).</summary> + public interface ITranslationHelper + { + /********* + ** Accessors + *********/ + /// <summary>The current locale.</summary> + string Locale { get; } + + /// <summary>The game's current language code.</summary> + LocalizedContentManager.LanguageCode LocaleEnum { get; } + + + /********* + ** Public methods + *********/ + /// <summary>Get all translations for the current locale.</summary> + IDictionary<string, string> GetTranslations(); + + /// <summary>Get a translation for the current locale.</summary> + /// <param name="key">The translation key.</param> + Translation Translate(string key); + } +} diff --git a/src/StardewModdingAPI/Program.cs b/src/StardewModdingAPI/Program.cs index b92108c3..b1cfb32d 100644 --- a/src/StardewModdingAPI/Program.cs +++ b/src/StardewModdingAPI/Program.cs @@ -48,6 +48,9 @@ namespace StardewModdingAPI /// <summary>The underlying game instance.</summary> private SGame GameInstance; + /// <summary>The underlying content manager.</summary> + private SContentManager ContentManager => (SContentManager)this.GameInstance.Content; + /// <summary>The SMAPI configuration settings.</summary> /// <remarks>This is initialised after the game starts.</remarks> private SConfig Settings; @@ -179,6 +182,7 @@ namespace StardewModdingAPI this.GameInstance.Window.ClientSizeChanged += (sender, e) => GraphicsEvents.InvokeResize(this.Monitor, sender, e); GameEvents.InitializeInternal += (sender, e) => this.InitialiseAfterGameStart(); GameEvents.GameLoadedInternal += (sender, e) => this.CheckForUpdateAsync(); + ContentEvents.AfterLocaleChanged += (sender, e) => this.OnLocaleChanged(); // set window titles this.GameInstance.Window.Title = $"Stardew Valley {Constants.GetGameDisplayVersion(Constants.GameVersion)} - running SMAPI {Constants.ApiVersion}"; @@ -405,7 +409,7 @@ namespace StardewModdingAPI mods = resolver.ProcessDependencies(mods).ToArray(); // load mods - modsLoaded = this.LoadMods(mods, new JsonHelper(), (SContentManager)Game1.content, deprecationWarnings); + modsLoaded = this.LoadMods(mods, new JsonHelper(), this.ContentManager, deprecationWarnings); foreach (Action warning in deprecationWarnings) warning(); } @@ -423,6 +427,18 @@ namespace StardewModdingAPI new Thread(this.RunConsoleLoop).Start(); } + /// <summary>Handle the game changing locale.</summary> + private void OnLocaleChanged() + { + // get locale + string locale = this.ContentManager.GetLocale(); + LocalizedContentManager.LanguageCode languageCode = this.ContentManager.GetCurrentLanguage(); + + // update mod translation helpers + foreach (IModMetadata mod in this.ModRegistry.GetMods()) + (mod.Mod.Helper.Translation as TranslationHelper)?.SetLocale(locale, languageCode); + } + /// <summary>Run a loop handling console input.</summary> [SuppressMessage("ReSharper", "FunctionNeverReturns", Justification = "The thread is aborted when the game exits.")] private void RunConsoleLoop() @@ -620,16 +636,43 @@ namespace StardewModdingAPI continue; } + // get translations + TranslationHelper translations; + { + IDictionary<string, IDictionary<string, string>> translationValues = new Dictionary<string, IDictionary<string, string>>(); + + // read translation files + DirectoryInfo translationsDir = new DirectoryInfo(Path.Combine(metadata.DirectoryPath, "i18n")); + if (translationsDir.Exists) + { + foreach (FileInfo file in translationsDir.EnumerateFiles("*.json")) + { + string locale = Path.GetFileNameWithoutExtension(file.Name.ToLower().Trim()); + try + { + translationValues[locale] = jsonHelper.ReadJsonFile<IDictionary<string, string>>(file.FullName); + } + catch (Exception ex) + { + this.Monitor.Log($"Couldn't read {metadata.DisplayName}'s i18n/{locale}.json file: {ex.GetLogSummary()}"); + } + } + } + + // create translation helper + translations = new TranslationHelper(metadata.DisplayName, contentManager.GetLocale(), contentManager.GetCurrentLanguage(), translationValues); + } + // inject data mod.ModManifest = manifest; - mod.Helper = new ModHelper(metadata.DisplayName, manifest, metadata.DirectoryPath, jsonHelper, this.ModRegistry, this.CommandManager, contentManager, this.Reflection); + mod.Helper = new ModHelper(metadata.DisplayName, metadata.DirectoryPath, jsonHelper, this.ModRegistry, this.CommandManager, contentManager, this.Reflection, translations); mod.Monitor = this.GetSecondaryMonitor(metadata.DisplayName); mod.PathOnDisk = metadata.DirectoryPath; // track mod metadata.SetMod(mod); this.ModRegistry.Add(metadata); - modsLoaded += 1; + modsLoaded++; this.Monitor.Log($"Loaded {metadata.DisplayName} by {manifest.Author}, v{manifest.Version} | {manifest.Description}", LogLevel.Info); } catch (Exception ex) diff --git a/src/StardewModdingAPI/StardewModdingAPI.csproj b/src/StardewModdingAPI/StardewModdingAPI.csproj index d8bfd473..31b2c1cf 100644 --- a/src/StardewModdingAPI/StardewModdingAPI.csproj +++ b/src/StardewModdingAPI/StardewModdingAPI.csproj @@ -149,6 +149,7 @@ <Compile Include="Framework\Serialisation\JsonHelper.cs" /> <Compile Include="Framework\Serialisation\SelectiveStringEnumConverter.cs" /> <Compile Include="Framework\Serialisation\ManifestFieldConverter.cs" /> + <Compile Include="Framework\TranslationHelper.cs" /> <Compile Include="ICommandHelper.cs" /> <Compile Include="IContentEventData.cs" /> <Compile Include="IContentEventHelper.cs" /> @@ -178,6 +179,7 @@ <Compile Include="Framework\Logging\LogFileManager.cs" /> <Compile Include="IPrivateProperty.cs" /> <Compile Include="ISemanticVersion.cs" /> + <Compile Include="ITranslationHelper.cs" /> <Compile Include="LogLevel.cs" /> <Compile Include="Framework\ModRegistry.cs" /> <Compile Include="Framework\UpdateHelper.cs" /> @@ -198,6 +200,7 @@ <Compile Include="IPrivateMethod.cs" /> <Compile Include="IReflectionHelper.cs" /> <Compile Include="SemanticVersion.cs" /> + <Compile Include="Translation.cs" /> </ItemGroup> <ItemGroup> <None Include="App.config"> diff --git a/src/StardewModdingAPI/Translation.cs b/src/StardewModdingAPI/Translation.cs new file mode 100644 index 00000000..ae4b833c --- /dev/null +++ b/src/StardewModdingAPI/Translation.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; + +namespace StardewModdingAPI +{ + /// <summary>A translation string with a fluent API to customise it.</summary> + public class Translation + { + /********* + ** Properties + *********/ + /// <summary>The name of the relevant mod for error messages.</summary> + private readonly string ModName; + + /// <summary>The locale for which the translation was fetched.</summary> + private readonly string Locale; + + /// <summary>The translation key.</summary> + private readonly string Key; + + /// <summary>The underlying translation text.</summary> + private readonly string Text; + + /// <summary>The value to return if the translations is undefined.</summary> + private readonly string Placeholder; + + + /********* + ** Public methods + *********/ + /// <summary>Construct an isntance.</summary> + /// <param name="modName">The name of the relevant mod for error messages.</param> + /// <param name="locale">The locale for which the translation was fetched.</param> + /// <param name="key">The translation key.</param> + /// <param name="text">The underlying translation text.</param> + internal Translation(string modName, string locale, string key, string text) + : this(modName, locale, key, text, $"(no translation:{key})") { } + + /// <summary>Construct an isntance.</summary> + /// <param name="modName">The name of the relevant mod for error messages.</param> + /// <param name="locale">The locale for which the translation was fetched.</param> + /// <param name="key">The translation key.</param> + /// <param name="text">The underlying translation text.</param> + /// <param name="placeholder">The value to return if the translations is undefined.</param> + internal Translation(string modName, string locale, string key, string text, string placeholder) + { + this.ModName = modName; + this.Locale = locale; + this.Key = key; + this.Text = text; + this.Placeholder = placeholder; + } + + /// <summary>Throw an exception if the translation text is <c>null</c> or empty.</summary> + /// <exception cref="KeyNotFoundException">There's no available translation matching the requested key and locale.</exception> + public Translation Assert() + { + if (!this.HasValue()) + throw new KeyNotFoundException($"The '{this.ModName}' mod doesn't have a translation with key '{this.Key}' for the '{this.Locale}' locale or its fallbacks."); + return this; + } + + /// <summary>Replace the text if it's <c>null</c> or empty. If you set a <c>null</c> or empty value, the translation will show the fallback "no translation" placeholder (see <see cref="UsePlaceholder"/> if you want to disable that). Returns a new instance if changed.</summary> + /// <param name="default">The default value.</param> + public Translation Default(string @default) + { + return this.HasValue() + ? this + : new Translation(this.ModName, this.Locale, this.Key, @default); + } + + /// <summary>Whether to return a "no translation" placeholder if the translation is <c>null</c> or empty. Returns a new instance.</summary> + /// <param name="use">Whether to return a placeholder.</param> + public Translation UsePlaceholder(bool use) + { + return new Translation(this.ModName, this.Locale, this.Key, this.Text, use ? $"(no translation:{this.Key})" : null); + } + + /// <summary>Replace tokens in the text like <c>{{value}}</c> with the given values. Returns a new instance.</summary> + /// <param name="tokens">An anonymous object containing token key/value pairs, like <c>new { value = 42, name = "Cranberries" }</c>.</param> + /// <exception cref="ArgumentNullException">The <paramref name="tokens"/> argument is <c>null</c>.</exception> + public Translation Tokens(object tokens) + { + if (tokens == null) + throw new ArgumentNullException(nameof(tokens)); + + IDictionary<string, object> dictionary = tokens + .GetType() + .GetProperties() + .ToDictionary( + p => p.Name, + p => p.GetValue(tokens) + ); + return this.Tokens(dictionary); + } + + /// <summary>Replace tokens in the text like <c>{{value}}</c> with the given values. Returns a new instance.</summary> + /// <param name="tokens">A dictionary containing token key/value pairs.</param> + /// <exception cref="ArgumentNullException">The <paramref name="tokens"/> argument is <c>null</c>.</exception> + public Translation Tokens(IDictionary<string, object> tokens) + { + if (tokens == null) + throw new ArgumentNullException(nameof(tokens)); + + tokens = tokens.ToDictionary(p => p.Key.Trim(), p => p.Value, StringComparer.InvariantCultureIgnoreCase); + string text = Regex.Replace(this.Text, @"{{([ \w\.\-]+)}}", match => + { + string key = match.Groups[1].Value.Trim(); + return tokens.TryGetValue(key, out object value) + ? value?.ToString() + : match.Value; + }); + return new Translation(this.ModName, this.Locale, this.Key, text); + } + + /// <summary>Get whether the translation has a defined value.</summary> + public bool HasValue() + { + return !string.IsNullOrEmpty(this.Text); + } + + /// <summary>Get the translation text. Calling this method isn't strictly necessary, since you can assign a <see cref="Translation"/> value directly to a string.</summary> + public override string ToString() + { + return this.Placeholder != null && !this.HasValue() + ? this.Placeholder + : this.Text; + } + + /// <summary>Get a string representation of the given translation.</summary> + /// <param name="translation">The translation key.</param> + public static implicit operator string(Translation translation) + { + return translation?.ToString(); + } + } +} |