summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/StardewModdingAPI/Framework/CommandHelper.cs2
-rw-r--r--src/StardewModdingAPI/Framework/ModHelper.cs19
-rw-r--r--src/StardewModdingAPI/Framework/SContentManager.cs6
-rw-r--r--src/StardewModdingAPI/Framework/TranslationHelper.cs117
-rw-r--r--src/StardewModdingAPI/IModHelper.cs10
-rw-r--r--src/StardewModdingAPI/ITranslationHelper.cs29
-rw-r--r--src/StardewModdingAPI/Program.cs49
-rw-r--r--src/StardewModdingAPI/StardewModdingAPI.csproj3
-rw-r--r--src/StardewModdingAPI/Translation.cs139
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> &lt; <c>pt.json</c> &lt; <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> &lt; <c>pt.json</c> &lt; <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> &lt; <c>pt.json</c> &lt; <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> &lt; <c>pt.json</c> &lt; <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();
+ }
+ }
+}