feat: implement data migration service to convert ANSI escape codes to printable color tags

This commit is contained in:
HueByte
2026-02-19 18:57:57 +01:00
parent 8dfb1a4fb8
commit d9a66749e7
3 changed files with 100 additions and 51 deletions
+10 -42
View File
@@ -90,22 +90,17 @@ public partial class ChatLine
} }
/// <summary> /// <summary>
/// Returns true if a line contains color tags (new format or legacy ANSI). /// Returns true if a line contains printable color tags.
/// </summary> /// </summary>
public static bool HasColorTags(string text) => public static bool HasColorTags(string text) =>
text.Contains("{F:") || text.Contains("{B:") || text.Contains("{X}") || text.Contains('\x1b'); text.Contains("{F:") || text.Contains("{B:") || text.Contains("{X}");
/// <summary> /// <summary>
/// Parse a string containing color tags into colored segments. /// Parse a string containing printable color tags into colored segments.
/// Supports the new printable format ({F:RRGGBB}, {B:RRGGBB}, {X}) /// Format: {F:RRGGBB} (foreground), {B:RRGGBB} (background), {X} (reset).
/// and legacy ANSI format (\x1b[38;2;R;G;Bm, \x1b[48;2;R;G;Bm, \x1b[0m).
/// </summary> /// </summary>
public static ChatLine FromColoredText(string text, Attribute? defaultAttr = null) public static ChatLine FromColoredText(string text, Attribute? defaultAttr = null)
{ {
// Detect which format is used and pick the right regex
var regex = text.Contains('\x1b') ? AnsiColorRegex() : ColorTagRegex();
bool isAnsi = text.Contains('\x1b');
var segments = new List<ChatSegment>(); var segments = new List<ChatSegment>();
int lastIndex = 0; int lastIndex = 0;
Color? currentFg = null; Color? currentFg = null;
@@ -121,7 +116,7 @@ public partial class ChatLine
return new Attribute(fg, bg); return new Attribute(fg, bg);
} }
foreach (Match match in regex.Matches(text)) foreach (Match match in ColorTagRegex().Matches(text))
{ {
if (match.Index > lastIndex) if (match.Index > lastIndex)
{ {
@@ -130,46 +125,23 @@ public partial class ChatLine
segments.Add(new ChatSegment(t, BuildAttr())); segments.Add(new ChatSegment(t, BuildAttr()));
} }
if (isAnsi) if (match.Groups[1].Success)
{
// Legacy ANSI format
if (match.Groups[1].Value == "0")
{
currentFg = null;
currentBg = null;
}
else if (match.Groups[2].Success)
{
var r = int.Parse(match.Groups[3].Value);
var g = int.Parse(match.Groups[4].Value);
var b = int.Parse(match.Groups[5].Value);
if (match.Groups[2].Value == "38;2")
currentFg = new Color(r, g, b);
else
currentBg = new Color(r, g, b);
}
}
else
{
// New printable tag format: {F:RRGGBB}, {B:RRGGBB}, {X}
if (match.Groups[6].Success)
{ {
// Reset {X} // Reset {X}
currentFg = null; currentFg = null;
currentBg = null; currentBg = null;
} }
else if (match.Groups[7].Success) else if (match.Groups[2].Success)
{ {
var hex = match.Groups[8].Value; var hex = match.Groups[3].Value;
var r = Convert.ToInt32(hex[..2], 16); var r = Convert.ToInt32(hex[..2], 16);
var g = Convert.ToInt32(hex[2..4], 16); var g = Convert.ToInt32(hex[2..4], 16);
var b = Convert.ToInt32(hex[4..6], 16); var b = Convert.ToInt32(hex[4..6], 16);
if (match.Groups[7].Value == "F") if (match.Groups[2].Value == "F")
currentFg = new Color(r, g, b); currentFg = new Color(r, g, b);
else else
currentBg = new Color(r, g, b); currentBg = new Color(r, g, b);
} }
}
lastIndex = match.Index + match.Length; lastIndex = match.Index + match.Length;
} }
@@ -184,11 +156,7 @@ public partial class ChatLine
return segments.Count > 0 ? new ChatLine(segments) : new ChatLine(""); return segments.Count > 0 ? new ChatLine(segments) : new ChatLine("");
} }
// Legacy: \x1b[0m, \x1b[38;2;R;G;Bm, \x1b[48;2;R;G;Bm // {X} (reset), {F:RRGGBB} (foreground), {B:RRGGBB} (background)
[GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2|48;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")]
private static partial Regex AnsiColorRegex();
// New: {X} (reset), {F:RRGGBB} (foreground), {B:RRGGBB} (background)
[GeneratedRegex(@"\{(?:(X)|(?:(F|B):([0-9A-Fa-f]{6})))\}")] [GeneratedRegex(@"\{(?:(X)|(?:(F|B):([0-9A-Fa-f]{6})))\}")]
private static partial Regex ColorTagRegex(); private static partial Regex ColorTagRegex();
} }
@@ -0,0 +1,78 @@
using System.Text.RegularExpressions;
using EchoHub.Server.Data;
using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Setup;
public static partial class DataMigrationService
{
public static async Task RunAsync(IServiceProvider services)
{
using var scope = services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger("EchoHub.Server.Setup.DataMigration");
await MigrateAnsiMessagesAsync(db, logger);
}
private static async Task MigrateAnsiMessagesAsync(EchoHubDbContext db, ILogger logger)
{
// Load messages that contain the ESC byte (0x1B) — these have legacy ANSI color codes.
// Filter by Image type first (only images have ANSI art), then check content in memory.
var messages = await db.Messages
.Where(m => m.Type == Core.Models.MessageType.Image)
.ToListAsync();
var toMigrate = messages.Where(m => m.Content.Contains('\x1b')).ToList();
if (toMigrate.Count == 0)
return;
logger.LogInformation("Found {Count} messages with legacy ANSI color codes. Migrating to color tag format...", toMigrate.Count);
var modified = 0;
foreach (var message in toMigrate)
{
var converted = AnsiToColorTags(message.Content);
if (converted != message.Content)
{
message.Content = converted;
modified++;
}
}
if (modified > 0)
{
await db.SaveChangesAsync();
logger.LogInformation("Migrated {Count} messages from ANSI escape codes to printable color tags.", modified);
}
}
/// <summary>
/// Convert ANSI escape codes to printable color tags.
/// \x1b[38;2;R;G;Bm → {F:RRGGBB}, \x1b[48;2;R;G;Bm → {B:RRGGBB}, \x1b[0m → {X}
/// </summary>
public static string AnsiToColorTags(string text)
{
return AnsiColorRegex().Replace(text, match =>
{
if (match.Groups[1].Value == "0")
return "{X}";
if (match.Groups[2].Success)
{
var r = int.Parse(match.Groups[3].Value);
var g = int.Parse(match.Groups[4].Value);
var b = int.Parse(match.Groups[5].Value);
var type = match.Groups[2].Value == "38;2" ? "F" : "B";
return $"{{{type}:{r:X2}{g:X2}{b:X2}}}";
}
return match.Value;
});
}
[GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2|48;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")]
private static partial Regex AnsiColorRegex();
}
@@ -16,6 +16,9 @@ public static class DatabaseSetup
await MigrateAsync(db, logger); await MigrateAsync(db, logger);
await SeedDefaultChannelAsync(db, logger); await SeedDefaultChannelAsync(db, logger);
// Run data migrations (e.g. ANSI → color tag format)
await DataMigrationService.RunAsync(services);
} }
private static async Task MigrateAsync(EchoHubDbContext db, ILogger logger) private static async Task MigrateAsync(EchoHubDbContext db, ILogger logger)