diff --git a/Applications/Pgan.PoracleWebNet.Api/Controllers/SettingsController.cs b/Applications/Pgan.PoracleWebNet.Api/Controllers/SettingsController.cs
index 4ef28886..ceab1c27 100644
--- a/Applications/Pgan.PoracleWebNet.Api/Controllers/SettingsController.cs
+++ b/Applications/Pgan.PoracleWebNet.Api/Controllers/SettingsController.cs
@@ -40,8 +40,9 @@ public partial class SettingsController(
// only -- the one group that least needs it -- so an admin configuring it saw it work and had no
// way to tell it was invisible to everyone else. See #513.
"custom_page_name", "custom_page_url", "custom_page_icon",
- // Poracle's own locale, synthesized rather than stored -- see GetPoracleLocaleAsync.
- PoracleLocaleKey,
+ // Poracle's own locale and its alert-language allow-list, synthesized rather than stored --
+ // see GetPoracleProjectionsAsync.
+ PoracleLocaleKey, PoracleAlertLanguagesKey,
};
///
@@ -66,7 +67,19 @@ public partial class SettingsController(
///
internal const string PoracleLocaleKey = "poracle_locale";
- private const string PoracleLocaleCacheKey = "settings:poracle_locale";
+ ///
+ /// Pseudo-setting carrying the language codes Poracle will accept for a human's alert
+ /// language, comma-separated, from availableLanguages on GET /api/config/poracleWeb.
+ ///
+ ///
+ /// Absent when Poracle restricts nothing — which covers both an unrestricted 5.2.1 and any server
+ /// too old to report the field — so the SPA reads an absent value as "offer everything". Nothing to
+ /// do with allowed_languages, which is this site's own restriction on the display
+ /// language; the two govern different menus and neither substitutes for the other.
+ ///
+ internal const string PoracleAlertLanguagesKey = "poracle_alert_languages";
+
+ private const string PoracleProjectionsCacheKey = "settings:poracle_projections";
/// Matches the shape of a locale tag (de, pt-BR, zh-cn) and nothing else.
[GeneratedRegex("^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})?$")]
@@ -97,7 +110,7 @@ public async Task GetAll()
settings = settings.Where(s => IsUserVisible(s.Key));
}
- return this.Ok(await this.WithPoracleLocaleAsync(settings));
+ return this.Ok(await this.WithPoracleProjectionsAsync(settings));
}
/// True when a non-admin may read .
@@ -152,7 +165,7 @@ public async Task GetCostumeCapability()
public async Task GetPublic()
{
var publicSettings = await this._siteSettingService.GetPublicAsync();
- return this.Ok(await this.WithPoracleLocaleAsync(publicSettings));
+ return this.Ok(await this.WithPoracleProjectionsAsync(publicSettings));
}
[HttpGet("discord-config")]
@@ -252,14 +265,15 @@ public async Task Upsert(string key, [FromBody] SiteSettingReques
});
}
- // poracle_locale is a projection of Poracle's config, not a row this page owns. Nothing stopped
- // it being written, and because a real row wins over the synthesized value, one accidental save
+ // Both of these are projections of Poracle's config, not rows this page owns. Nothing stopped
+ // them being written, and because a real row wins over the synthesized value, one accidental save
// would have pinned the language default forever and silently stopped tracking Poracle. See #780.
- if (string.Equals(key, PoracleLocaleKey, StringComparison.OrdinalIgnoreCase))
+ if (string.Equals(key, PoracleLocaleKey, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(key, PoracleAlertLanguagesKey, StringComparison.OrdinalIgnoreCase))
{
return this.BadRequest(new
{
- error = "poracle_locale is read from Poracle's configuration and cannot be set here."
+ error = $"{key} is read from Poracle's configuration and cannot be set here."
});
}
@@ -304,58 +318,87 @@ public async Task Upsert(string key, [FromBody] SiteSettingReques
}
///
- /// Appends the Poracle locale pseudo-setting to , unless a real row of the
- /// same key already exists -- an admin-set value wins over what Poracle reports.
+ /// Appends the two Poracle pseudo-settings to , each unless a real row of
+ /// the same key already exists -- an admin-set value wins over what Poracle reports.
///
- private async Task> WithPoracleLocaleAsync(IEnumerable settings)
+ private async Task> WithPoracleProjectionsAsync(IEnumerable settings)
{
var list = settings.ToList();
- if (list.Exists(s => string.Equals(s.Key, PoracleLocaleKey, StringComparison.OrdinalIgnoreCase)))
- {
- return list;
- }
+ var (locale, alertLanguages) = await this.GetPoracleProjectionsAsync();
- var locale = await this.GetPoracleLocaleAsync();
- if (!string.IsNullOrEmpty(locale))
+ Project(list, PoracleLocaleKey, locale);
+ Project(list, PoracleAlertLanguagesKey, alertLanguages);
+
+ return list;
+ }
+
+ private static void Project(List list, string key, string? value)
+ {
+ if (string.IsNullOrEmpty(value)
+ || list.Exists(s => string.Equals(s.Key, key, StringComparison.OrdinalIgnoreCase)))
{
- list.Add(new SiteSetting
- {
- Key = PoracleLocaleKey,
- Value = locale,
- Category = "branding",
- ValueType = "string",
- });
+ return;
}
- return list;
+ list.Add(new SiteSetting
+ {
+ Key = key,
+ Value = value,
+ Category = "branding",
+ ValueType = "string",
+ });
}
///
- /// Reads locale from Poracle's config, cached for five minutes. Both the settings endpoints that
- /// serve it are hit on every page load, and one of them is anonymous, so an uncached read would put a
- /// PoracleNG roundtrip in front of the login page. A Poracle outage caches a null and the SPA keeps its
- /// existing stored/browser/en ordering -- the locale is a nicety, never a blocker.
+ /// Reads Poracle's locale and its alert-language allow-list from one config call, cached for
+ /// five minutes. Both the settings endpoints that serve them are hit on every page load, and one of
+ /// them is anonymous, so an uncached read would put a PoracleNG roundtrip in front of the login page.
+ /// A Poracle outage caches two nulls: the SPA keeps its existing stored/browser/en ordering
+ /// and offers the full alert-language menu, both of which are what an unrestricted server would give
+ /// anyway. Neither value is ever a blocker.
///
- private async Task GetPoracleLocaleAsync()
+ private async Task<(string? Locale, string? AlertLanguages)> GetPoracleProjectionsAsync()
{
- if (this._cache.TryGetValue(PoracleLocaleCacheKey, out var cached))
+ if (this._cache.TryGetValue<(string?, string?)>(PoracleProjectionsCacheKey, out var cached))
{
return cached;
}
- string? locale = null;
+ (string? Locale, string? AlertLanguages) projections = (null, null);
try
{
var config = await this._poracleApiProxy.GetConfigAsync();
- locale = NormalizeLocale(config?.Locale);
+ projections = (NormalizeLocale(config?.Locale), NormalizeAlertLanguages(config?.AvailableLanguages));
}
catch (Exception ex)
{
LogFetchLocaleFailed(this._logger, ex);
}
- this._cache.Set(PoracleLocaleCacheKey, locale, TimeSpan.FromMinutes(5));
- return locale;
+ this._cache.Set(PoracleProjectionsCacheKey, projections, TimeSpan.FromMinutes(5));
+ return projections;
+ }
+
+ ///
+ /// Renders Poracle's availableLanguages as a comma-separated list, or null when it restricts
+ /// nothing. Null upstream means unrestricted -- an unset and an empty map both report it, because
+ /// Poracle's own write path only validates a non-empty one -- and so does an absent field, which is
+ /// what a server older than 5.2.1 sends. All three are the same answer here: no row, full menu.
+ /// Individual codes are shape-checked and dropped rather than the whole list being discarded.
+ ///
+ internal static string? NormalizeAlertLanguages(IEnumerable? availableLanguages)
+ {
+ if (availableLanguages is null)
+ {
+ return null;
+ }
+
+ var codes = availableLanguages
+ .Select(NormalizeLocale)
+ .Where(c => !string.IsNullOrEmpty(c))
+ .ToList();
+
+ return codes.Count == 0 ? null : string.Join(',', codes);
}
///
@@ -370,7 +413,7 @@ private async Task> WithPoracleLocaleAsync(IEnumerable
-
+ @if (alertLanguage.languages().length > 0) {
+
+ }