mirror of
https://github.com/Pathoschild/SMAPI.git
synced 2026-10-12 02:08:51 +00:00
defer translation formatting until it's used
This avoids various edge cases when chaining method calls. For example:
- calling `Tokens(...)` again with different values would ignore any values provided for a token that was already replaced;
- calling `Default("boop").UsePlaceholder(false).UsePlaceholder(true)` would reset to the initial default;
- etc.
This commit is contained in:
@@ -33,6 +33,7 @@
|
||||
* The SMAPI log now includes the assembly version of each loaded mod (thanks to spacechase0!).
|
||||
* Fixed `content.Load` ignoring language override in recent versions.
|
||||
* Fixed player sprites and building paint masks not always propagated on change.
|
||||
* Fixed various edge cases when chaining methods on `Translation` instances.
|
||||
|
||||
* For the update check server:
|
||||
* Rewrote update checks for mods on CurseForge and ModDrop to use new export API endpoints.
|
||||
|
||||
@@ -126,7 +126,7 @@ namespace SMAPI.Tests.Core
|
||||
Translation translation = new("pt-BR", "key", text);
|
||||
|
||||
// assert
|
||||
if (translation.HasValue())
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
translation.ToString().Should().Be(text, "the translation should match the valid input");
|
||||
else
|
||||
translation.ToString().Should().Be(this.GetPlaceholderText("key"), "the translation should match the placeholder given a null or empty input");
|
||||
@@ -139,7 +139,7 @@ namespace SMAPI.Tests.Core
|
||||
Translation translation = new("pt-BR", "key", text);
|
||||
|
||||
// assert
|
||||
if (translation.HasValue())
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
((string?)translation).Should().Be(text, "the translation should match the valid input");
|
||||
else
|
||||
((string?)translation).Should().Be(this.GetPlaceholderText("key"), "the translation should match the placeholder given a null or empty input");
|
||||
@@ -152,7 +152,7 @@ namespace SMAPI.Tests.Core
|
||||
Translation translation = new Translation("pt-BR", "key", text).UsePlaceholder(value);
|
||||
|
||||
// assert
|
||||
if (translation.HasValue())
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
translation.ToString().Should().Be(text, "the translation should match the valid input");
|
||||
else if (!value)
|
||||
translation.ToString().Should().Be(text, "the translation should return the text as-is given a null or empty input with the placeholder disabled");
|
||||
|
||||
+143
-67
@@ -23,7 +23,19 @@ namespace StardewModdingAPI
|
||||
private readonly string? Text;
|
||||
|
||||
/// <summary>The value to return if the translations is undefined.</summary>
|
||||
private readonly string? Placeholder;
|
||||
private string? Placeholder { get; init; }
|
||||
|
||||
/// <summary>Whether to use <see cref="Placeholder"/> if the translation is <c>null</c> or empty.</summary>
|
||||
private bool ShouldUsePlaceholder { get; init; }
|
||||
|
||||
/// <summary>The tokens to apply to the translation text, if any.</summary>
|
||||
private Dictionary<string, string?>? TokenValues { get; init; }
|
||||
|
||||
/// <summary>Whether the <see cref="CachedResult"/> has been prepared.</summary>
|
||||
private bool Cached;
|
||||
|
||||
/// <summary>The final output text, it's already been prepared.</summary>
|
||||
private string? CachedResult;
|
||||
|
||||
|
||||
/*********
|
||||
@@ -41,69 +53,82 @@ namespace StardewModdingAPI
|
||||
/// <param name="key">The translation key.</param>
|
||||
/// <param name="text">The underlying translation text.</param>
|
||||
internal Translation(string locale, string key, string? text)
|
||||
: this(locale, key, text, string.Format(Translation.PlaceholderText, key)) { }
|
||||
{
|
||||
this.Locale = locale;
|
||||
this.Key = key;
|
||||
this.Text = text;
|
||||
this.Placeholder = string.Format(Translation.PlaceholderText, key);
|
||||
this.ShouldUsePlaceholder = true;
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <summary>Construct an instance.</summary>
|
||||
/// <param name="other">The other translation to copy.</param>
|
||||
internal Translation(Translation other)
|
||||
: this(other.Locale, other.Key, other.Text)
|
||||
{
|
||||
this.Placeholder = other.Placeholder;
|
||||
this.ShouldUsePlaceholder = other.ShouldUsePlaceholder;
|
||||
this.TokenValues = other.TokenValues;
|
||||
}
|
||||
|
||||
/// <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).</summary>
|
||||
/// <param name="default">The default value.</param>
|
||||
/// <remarks>Returns a new instance if this would change the result, else the current instance.</remarks>
|
||||
public Translation Default(string? @default)
|
||||
{
|
||||
return this.HasValue()
|
||||
? this
|
||||
: new Translation(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. <strong>Due to limitations with nullable reference types, setting this to <c>false</c> will still mark the text non-nullable.</strong></param>
|
||||
public Translation UsePlaceholder(bool use)
|
||||
{
|
||||
return new Translation(this.Locale, this.Key, this.Text, use ? string.Format(Translation.PlaceholderText, 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 object containing token key/value pairs. This can be an anonymous object (like <c>new { value = 42, name = "Cranberries" }</c>), a dictionary, or a class instance.</param>
|
||||
/// <exception cref="ArgumentNullException">The <paramref name="tokens"/> argument is <c>null</c>.</exception>
|
||||
public Translation Tokens(object? tokens)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(this.Text) || tokens == null)
|
||||
if (this.HasValue())
|
||||
return this;
|
||||
|
||||
// get dictionary of tokens
|
||||
Dictionary<string, string?> tokenLookup = new(StringComparer.OrdinalIgnoreCase);
|
||||
{
|
||||
// from dictionary
|
||||
if (tokens is IDictionary inputLookup)
|
||||
{
|
||||
foreach (DictionaryEntry entry in inputLookup)
|
||||
{
|
||||
string? key = entry.Key.ToString()?.Trim();
|
||||
if (key != null)
|
||||
tokenLookup[key] = entry.Value?.ToString();
|
||||
}
|
||||
}
|
||||
string placeholder = string.IsNullOrEmpty(@default)
|
||||
? string.Format(Translation.PlaceholderText, this.Key)
|
||||
: @default;
|
||||
|
||||
// from object properties
|
||||
else
|
||||
{
|
||||
Type type = tokens.GetType();
|
||||
foreach (PropertyInfo prop in type.GetProperties())
|
||||
tokenLookup[prop.Name] = prop.GetValue(tokens)?.ToString();
|
||||
foreach (FieldInfo field in type.GetFields())
|
||||
tokenLookup[field.Name] = field.GetValue(tokens)?.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
// format translation
|
||||
string text = Regex.Replace(this.Text, @"{{([ \w\.\-]+)}}", match =>
|
||||
return new Translation(this)
|
||||
{
|
||||
string key = match.Groups[1].Value.Trim();
|
||||
return tokenLookup.TryGetValue(key, out string? value)
|
||||
? (value ?? "")
|
||||
: match.Value;
|
||||
});
|
||||
return new Translation(this.Locale, this.Key, text);
|
||||
Placeholder = placeholder
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Get whether the translation has a defined value.</summary>
|
||||
/// <summary>Whether to return a "no translation" placeholder if the translation is <c>null</c> or empty.</summary>
|
||||
/// <param name="use">Whether to return a placeholder. <strong>Due to limitations with nullable reference types, setting this to <c>false</c> will still mark the text non-nullable.</strong></param>
|
||||
/// <remarks>Returns a new instance if this would change the result, else the current instance.</remarks>
|
||||
public Translation UsePlaceholder(bool use)
|
||||
{
|
||||
if (this.HasValue() || this.ShouldUsePlaceholder == use)
|
||||
return this;
|
||||
|
||||
return new Translation(this)
|
||||
{
|
||||
ShouldUsePlaceholder = use
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Replace tokens in the text like <c>{{value}}</c> with the given values.</summary>
|
||||
/// <param name="tokens">An object containing token key/value pairs. This can be an anonymous object (like <c>new { value = 42, name = "Cranberries" }</c>), a dictionary, or a class instance.</param>
|
||||
/// <exception cref="ArgumentNullException">The <paramref name="tokens"/> argument is <c>null</c>.</exception>
|
||||
/// <remarks>Returns a new instance if this would change the result, else the current instance.</remarks>
|
||||
public Translation Tokens(object? tokens)
|
||||
{
|
||||
// get token values
|
||||
Dictionary<string, string?>? tokenValues = this.GetTokenValues(tokens);
|
||||
if (tokenValues is null)
|
||||
return this;
|
||||
|
||||
// include any previous values
|
||||
if (this.TokenValues != null)
|
||||
{
|
||||
foreach ((string key, string? value) in this.TokenValues)
|
||||
tokenValues.TryAdd(key, value);
|
||||
}
|
||||
|
||||
// build result
|
||||
return new Translation(this)
|
||||
{
|
||||
TokenValues = tokenValues
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Get whether the translation has a defined value, before tokens are replaced.</summary>
|
||||
public bool HasValue()
|
||||
{
|
||||
return !string.IsNullOrEmpty(this.Text);
|
||||
@@ -113,9 +138,17 @@ namespace StardewModdingAPI
|
||||
/// <remarks><strong>Limitation with nullable reference types: if there's no text and you disabled the fallback via <see cref="UsePlaceholder"/>, this will return null but the return value will still be marked non-nullable.</strong></remarks>
|
||||
public override string ToString()
|
||||
{
|
||||
return this.Placeholder != null && !this.HasValue()
|
||||
? this.Placeholder
|
||||
: this.Text!;
|
||||
if (!this.Cached)
|
||||
{
|
||||
string? rawText = this.ShouldUsePlaceholder && !this.HasValue()
|
||||
? this.Placeholder
|
||||
: this.FormatText(this.Text);
|
||||
|
||||
this.CachedResult = rawText;
|
||||
this.Cached = true;
|
||||
}
|
||||
|
||||
return this.CachedResult!;
|
||||
}
|
||||
|
||||
/// <summary>Get a string representation of the given translation.</summary>
|
||||
@@ -131,17 +164,60 @@ namespace StardewModdingAPI
|
||||
/*********
|
||||
** Private methods
|
||||
*********/
|
||||
/// <summary>Construct an instance.</summary>
|
||||
/// <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>
|
||||
private Translation(string locale, string key, string? text, string? placeholder)
|
||||
/// <summary>Apply any formatting or tokens to the given text.</summary>
|
||||
/// <param name="text">The text to format.</param>
|
||||
[return: NotNullIfNotNull("text")]
|
||||
private string? FormatText(string? text)
|
||||
{
|
||||
this.Locale = locale;
|
||||
this.Key = key;
|
||||
this.Text = text;
|
||||
this.Placeholder = placeholder;
|
||||
if (this.TokenValues?.Count > 0 && !string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
text = Regex.Replace(text, @"{{([ \w\.\-]+)}}", match =>
|
||||
{
|
||||
string key = match.Groups[1].Value.Trim();
|
||||
return this.TokenValues.TryGetValue(key, out string? value)
|
||||
? (value ?? "")
|
||||
: match.Value;
|
||||
});
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/// <summary>Get token values to apply from a given token source, if any.</summary>
|
||||
/// <param name="source">An object containing token key/value pairs. This can be an anonymous object (like <c>new { value = 42, name = "Cranberries" }</c>), a dictionary, or a class instance.</param>
|
||||
/// <returns>Returns the token values, or <c>null</c> if there were none.</returns>
|
||||
private Dictionary<string, string?>? GetTokenValues(object? source)
|
||||
{
|
||||
if (source != null)
|
||||
{
|
||||
Dictionary<string, string?> tokenValues = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// from dictionary
|
||||
if (source is IDictionary inputLookup)
|
||||
{
|
||||
foreach (DictionaryEntry entry in inputLookup)
|
||||
{
|
||||
string? key = entry.Key.ToString()?.Trim();
|
||||
if (key != null)
|
||||
tokenValues[key] = entry.Value?.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
// from object properties
|
||||
else
|
||||
{
|
||||
Type type = source.GetType();
|
||||
foreach (PropertyInfo prop in type.GetProperties())
|
||||
tokenValues[prop.Name] = prop.GetValue(source)?.ToString();
|
||||
foreach (FieldInfo field in type.GetFields())
|
||||
tokenValues[field.Name] = field.GetValue(source)?.ToString();
|
||||
}
|
||||
|
||||
if (tokenValues.Count > 0)
|
||||
return tokenValues;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user