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:
Jesse Plamondon-Willard
2024-10-27 20:59:45 -04:00
parent 9b62d9a32c
commit f2cf0a3125
3 changed files with 147 additions and 70 deletions
+1
View File
@@ -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.
+3 -3
View File
@@ -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
View File
@@ -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;
}
}
}