Skip to content

Instantly share code, notes, and snippets.

@AndyButland
Last active June 17, 2026 06:16
Show Gist options
  • Select an option

  • Save AndyButland/08645809ed46287f15aa5557044a4ecc to your computer and use it in GitHub Desktop.

Select an option

Save AndyButland/08645809ed46287f15aa5557044a4ecc to your computer and use it in GitHub Desktop.
Sample controller that can be used to change an Umbraco language from one ISO code to another (see https://github.com/umbraco/Umbraco-CMS/issues/20674). Use at your own risk and with care (backup database before using).
// ---------------------------------------------------------------------------------------------------
// Change a language's ISO code (culture) and re-align the data that references it by culture string.
//
// Issue: https://github.com/umbraco/Umbraco-CMS/issues/20674
// Version: Written against Umbraco 17.5.
// Access: Gated by a shared secret. Set a long random value in configuration and send the same value
// in the X-Maintenance-Key header; the endpoint returns 401 unless the key is configured AND
// the header matches it:
// "Umbraco": { CMS: { "Maintenance": { "ChangeLanguageCultureKey": "<long-random-secret>" } } }
// Delete this file once the fix has been applied.
// Usage: Drop into a project (adjust the namespace), take a database backup, take the site offline,
// then POST (header X-Maintenance-Key: <secret>):
// /maintenance/change-language-culture?oldIso=da-DK&newIso=en-GB
// Returns a plain-text report of what was changed. Rename only - newIso must not already exist.
// Scale: Suitable for small-to-medium sites. Property values are processed in bounded batches and
// progress is written to the logger, but the whole job runs synchronously in one request and
// one transaction. On a large site the request may exceed an HTTP/proxy timeout - pass
// rebuild=false to skip the (usually time-consuming) cache/Examine rebuilds and run them separately,
// or move this logic into a migration step or background task.
// ---------------------------------------------------------------------------------------------------
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Umbraco.Cms.Core;
using Umbraco.Cms.Core.Models;
using Umbraco.Cms.Core.PublishedCache;
using Umbraco.Cms.Core.Services;
using Umbraco.Cms.Core.Services.OperationStatus;
using Umbraco.Cms.Infrastructure.Examine;
using Umbraco.Cms.Infrastructure.Scoping;
using Umbraco.Cms.Infrastructure.Serialization;
namespace Umbraco.Cms.Web.UI.Controllers;
/// <summary>
/// One-off maintenance workaround for issue #20674: changes a language's culture (ISO code) and
/// re-aligns the culture strings that block editors persist inside their JSON (the <c>expose</c>
/// array and each variant block value), which are otherwise orphaned because they reference the
/// language by ISO string rather than by id. Also repairs redirect-URL cultures and, unless
/// <c>rebuild=false</c> is passed, rebuilds the published cache and Examine indexes.
///
/// Rename only: the new ISO code must not already be assigned to another language (a true swap of
/// two existing languages is intentionally out of scope). Take a database backup and run with the
/// site offline before invoking.
///
/// Gated by a shared secret: set the configuration key <c>Umbraco:Maintenance:ChangeLanguageCultureKey</c>
/// and pass the same value in the <c>X-Maintenance-Key</c> request header. The endpoint is inert until the
/// secret is configured, and is exposed as a POST so it cannot be triggered by a cross-site GET. Audit
/// entries are attributed to the super user. Delete this controller once the fix is applied.
/// </summary>
public class ChangeLanguageCultureController : Controller
{
private static readonly string[] _blockEditorAliases =
{
Constants.PropertyEditors.Aliases.BlockList,
Constants.PropertyEditors.Aliases.BlockGrid,
Constants.PropertyEditors.Aliases.RichText,
};
// Block values are loaded and rewritten one bounded batch at a time so a large site doesn't
// materialise every matching row (each holding potentially large JSON) at once. Kept well under
// Constants.Sql.MaxParameterCount for the WHERE id IN (...) detail load.
private const int RowProcessingBatchSize = 500;
// Match the encoder Umbraco uses for block values by calling the same factory Umbraco registers
// (DefaultJsonSerializerEncoderFactory) with the block-value serializer type (SystemTextJsonSerializer),
// rather than duplicating its literal. Re-serialized JSON then keeps byte-for-byte identical escaping -
// '<' and non-ASCII characters (e.g. å, æ, ø) are emitted as \uXXXX escapes, exactly as Umbraco stores
// them - so untouched rows are not rewritten, and this stays correct if Umbraco changes its default.
private static readonly JsonSerializerOptions _jsonOptions = new()
{
Encoder = new DefaultJsonSerializerEncoderFactory().CreateEncoder<SystemTextJsonSerializer>(),
};
private readonly ILanguageService _languageService;
private readonly IContentTypeService _contentTypeService;
private readonly IMediaTypeService _mediaTypeService;
private readonly IMemberTypeService _memberTypeService;
private readonly IScopeProvider _scopeProvider;
private readonly IDatabaseCacheRebuilder _databaseCacheRebuilder;
private readonly IIndexRebuilder _indexRebuilder;
private readonly IConfiguration _configuration;
private readonly ILogger<ChangeLanguageCultureController> _logger;
public ChangeLanguageCultureController(
ILanguageService languageService,
IContentTypeService contentTypeService,
IMediaTypeService mediaTypeService,
IMemberTypeService memberTypeService,
IScopeProvider scopeProvider,
IDatabaseCacheRebuilder databaseCacheRebuilder,
IIndexRebuilder indexRebuilder,
IConfiguration configuration,
ILogger<ChangeLanguageCultureController> logger)
{
_languageService = languageService;
_contentTypeService = contentTypeService;
_mediaTypeService = mediaTypeService;
_memberTypeService = memberTypeService;
_scopeProvider = scopeProvider;
_databaseCacheRebuilder = databaseCacheRebuilder;
_indexRebuilder = indexRebuilder;
_configuration = configuration;
_logger = logger;
}
[HttpPost("/maintenance/change-language-culture")]
public async Task<IActionResult> Run(string oldIso, string newIso, bool rebuild = true, CancellationToken cancellationToken = default)
{
var configuredKey = _configuration["Umbraco:CMS:Maintenance:ChangeLanguageCultureKey"];
if (string.IsNullOrWhiteSpace(configuredKey)
|| string.Equals(Request.Headers["X-Maintenance-Key"], configuredKey, StringComparison.Ordinal) is false)
{
return Unauthorized();
}
var report = new StringBuilder();
// Mirror every report line to the logger so progress is visible server-side even if the HTTP
// response is never received (e.g. a proxy times out the long-running request).
void Log(string message)
{
report.AppendLine(message);
_logger.LogInformation("[ChangeLanguageCulture] {Message}", message);
}
if (string.IsNullOrWhiteSpace(oldIso) || string.IsNullOrWhiteSpace(newIso))
{
return BadRequest("Provide both 'oldIso' and 'newIso' query parameters, e.g. /maintenance/change-language-culture?oldIso=da-DK&newIso=en-GB");
}
if (oldIso.Equals(newIso, StringComparison.OrdinalIgnoreCase))
{
return BadRequest("'oldIso' and 'newIso' must differ.");
}
if (IsValidCulture(newIso) is false)
{
return BadRequest($"'{newIso}' is not a recognised culture.");
}
ILanguage? language = await _languageService.GetAsync(oldIso);
if (language is null)
{
return BadRequest($"No language found with ISO code '{oldIso}'.");
}
if (await _languageService.GetAsync(newIso) is not null)
{
return BadRequest($"ISO code '{newIso}' is already assigned to another language. Swapping two existing languages is not supported by this workaround.");
}
// ISO lookups are case-insensitive, so the stored casing may differ from what was supplied.
// Work from the stored value: every culture string we rewrite (block JSON, redirect URLs) is
// persisted with the language's exact ISO casing, so an exact/ordinal match must use it too.
oldIso = language.IsoCode;
var scanned = 0;
var rewritten = 0;
int redirectsUpdated;
int domainsUsingLanguage;
// Rename and data fix-up share a single scope so a failure part-way through rolls back the
// rename too, rather than leaving the language renamed but its stored cultures unrepaired.
// The language service opens its own nested scope, which the ambient outer scope controls.
using (IScope scope = _scopeProvider.CreateScope())
{
// Rename the language first so the subsequent cache rebuild resolves the new culture name.
language.IsoCode = newIso;
language.CultureName = CultureInfo.GetCultureInfo(newIso).DisplayName;
Attempt<ILanguage, LanguageOperationStatus> updateAttempt =
await _languageService.UpdateAsync(language, Constants.Security.SuperUserKey);
if (updateAttempt.Success is false)
{
// Scope disposed without Complete - the rename rolls back.
return BadRequest($"Failed to update the language: {updateAttempt.Status}.");
}
Log($"Renamed language id {language.Id}: '{oldIso}' -> '{newIso}'.");
List<int> propertyTypeIds = GetBlockEditorPropertyTypeIds();
Log($"Found {propertyTypeIds.Count} block-editor property type(s) to scan.");
Log($"Note: only the core block aliases are scanned ({string.Join(", ", _blockEditorAliases)}); custom property editors that store culture-keyed JSON under other aliases are not touched.");
// Collect the ids of candidate rows first (ints only, so cheap to hold), then load and rewrite
// the large textValue payloads one bounded batch at a time rather than materialising them all.
// The LIKE filter stays deliberately broad ('%oldIso%'): a nested block value is stored as an
// escaped JSON string (\"culture\":\"da-DK\"), which a tighter "culture":"..." match would miss.
// False-positive matches are harmless - RewriteCultures leaves non-culture occurrences unchanged.
var candidateIds = new List<int>();
foreach (IEnumerable<int> typeIdGroup in propertyTypeIds.InGroupsOf(Constants.Sql.MaxParameterCount))
{
candidateIds.AddRange(scope.Database.Fetch<int>(
"SELECT id FROM umbracoPropertyData WHERE textValue IS NOT NULL AND textValue LIKE @likePattern AND propertyTypeId IN (@ids)",
new { likePattern = "%" + oldIso + "%", ids = typeIdGroup.ToArray() }));
}
Log($"Candidate block property values to inspect: {candidateIds.Count}.");
foreach (IEnumerable<int> idGroup in candidateIds.InGroupsOf(RowProcessingBatchSize))
{
// Stop cleanly between batches if the caller disconnects; the scope is disposed without
// Complete, so the rename and any updates so far roll back together.
cancellationToken.ThrowIfCancellationRequested();
List<PropertyDataRow> rows = scope.Database.Fetch<PropertyDataRow>(
"SELECT id AS Id, textValue AS TextValue FROM umbracoPropertyData WHERE id IN (@ids)",
new { ids = idGroup.ToArray() });
foreach (PropertyDataRow row in rows)
{
scanned++;
if (row.TextValue is null)
{
continue;
}
var newValue = RewriteCultures(row.TextValue, oldIso, newIso, out var changed);
if (changed is false)
{
continue;
}
scope.Database.Execute(
"UPDATE umbracoPropertyData SET textValue = @0 WHERE id = @1",
newValue,
row.Id);
rewritten++;
}
Log($"Progress: inspected {scanned}/{candidateIds.Count} candidate values, rewritten {rewritten} so far.");
}
redirectsUpdated = scope.Database.Execute(
"UPDATE umbracoRedirectUrl SET culture = @0 WHERE culture = @1",
newIso,
oldIso);
domainsUsingLanguage = scope.Database.ExecuteScalar<int>(
"SELECT COUNT(*) FROM umbracoDomain WHERE domainDefaultLanguage = @0",
language.Id);
scope.Complete();
}
Log($"Block property values scanned: {scanned}, rewritten (all versions): {rewritten}.");
Log($"Redirect URL rows updated: {redirectsUpdated}.");
Log($"Domains assigned to this language (follow the rename automatically via languageId): {domainsUsingLanguage}.");
if (rebuild)
{
await _databaseCacheRebuilder.RebuildAsync(useBackgroundThread: false);
Log("Published cache rebuilt.");
await _indexRebuilder.RebuildIndexesAsync(onlyEmptyIndexes: false, delay: null, useBackgroundThread: false);
Log("Examine indexes rebuilt.");
}
else
{
Log("Skipped cache and Examine rebuilds (rebuild=false). Rebuild the published cache and Examine indexes manually before bringing the site back online.");
}
Log("Done.");
return Content(report.ToString(), "text/plain");
}
private List<int> GetBlockEditorPropertyTypeIds()
{
var aliases = new HashSet<string>(_blockEditorAliases, StringComparer.Ordinal);
var ids = new HashSet<int>();
IEnumerable<IContentTypeComposition> allTypes = _contentTypeService.GetAll()
.Cast<IContentTypeComposition>()
.Concat(_mediaTypeService.GetAll())
.Concat(_memberTypeService.GetAll());
foreach (IContentTypeComposition type in allTypes)
{
foreach (IPropertyType propertyType in type.PropertyTypes)
{
if (aliases.Contains(propertyType.PropertyEditorAlias))
{
ids.Add(propertyType.Id);
}
}
}
return ids.ToList();
}
private static string RewriteCultures(string json, string oldIso, string newIso, out bool changed)
{
changed = false;
JsonNode? node;
try
{
node = JsonNode.Parse(json);
}
catch
{
return json;
}
if (node is null)
{
return json;
}
changed = Walk(node, oldIso, newIso);
// Only re-serialize when something actually changed; an unchanged row is left byte-for-byte
// intact rather than being rewritten in a (potentially differently-escaped) round-trip.
return changed ? node.ToJsonString(_jsonOptions) : json;
}
private static bool Walk(JsonNode node, string oldIso, string newIso)
{
var changed = false;
switch (node)
{
case JsonObject obj:
foreach (var key in obj.Select(kvp => kvp.Key).ToArray())
{
if (obj[key] is not JsonNode child)
{
continue;
}
if (child is JsonValue value && value.TryGetValue(out string? text) && text is not null)
{
if (key == "culture" && string.Equals(text, oldIso, StringComparison.OrdinalIgnoreCase))
{
obj[key] = newIso;
changed = true;
}
else if (LooksLikeBlockJson(text))
{
// Nested block editors store their inner block value as a JSON string, so the
// recursive walk can't reach it directly - re-parse, rewrite, and store back.
var rewritten = RewriteCultures(text, oldIso, newIso, out var nestedChanged);
if (nestedChanged)
{
obj[key] = rewritten;
changed = true;
}
}
}
else
{
changed |= Walk(child, oldIso, newIso);
}
}
break;
case JsonArray array:
foreach (JsonNode? item in array)
{
if (item is not null)
{
changed |= Walk(item, oldIso, newIso);
}
}
break;
}
return changed;
}
private static bool LooksLikeBlockJson(string value)
{
var trimmed = value.TrimStart();
return trimmed.StartsWith('{')
&& (value.Contains("\"expose\"", StringComparison.Ordinal)
|| value.Contains("\"contentData\"", StringComparison.Ordinal));
}
private static bool IsValidCulture(string isoCode)
{
try
{
CultureInfo.GetCultureInfo(isoCode);
return true;
}
catch (CultureNotFoundException)
{
return false;
}
}
private sealed record PropertyDataRow
{
public int Id { get; init; }
public string? TextValue { get; init; }
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment