using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using System.Text.RegularExpressions; using ADOFAIRenderer.Core.Data; using ADOFAIRenderer.Data; namespace ADOFAIRenderer.Util; /// /// Formats and sanitizes render output file names according to customizable template patterns. /// internal static class RenderFileNameUtil { private static readonly Regex RichTextRegex = new(@"<[^>]+>", RegexOptions.Compiled); private static readonly Regex TokenRegex = new(@"\{([a-zA-Z0-9_]+)\}", RegexOptions.Compiled); private static readonly HashSet ReservedWindowsNames = new(StringComparer.OrdinalIgnoreCase) { "CON", "PRN", "AUX", "NUL", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" }; /// /// Removes Unity rich text tags (such as <color=...>, <b>, etc.) from the given text. /// public static string StripRichText(string? input) { if (string.IsNullOrEmpty(input)) return string.Empty; return RichTextRegex.Replace(input, string.Empty); } /// /// Replaces invalid file name characters within a single token with an underscore. /// public static string SanitizeToken(string? token) { if (string.IsNullOrEmpty(token)) return string.Empty; var invalidChars = Path.GetInvalidFileNameChars(); var sb = new StringBuilder(token.Length); foreach (var c in token) { sb.Append(invalidChars.Contains(c) ? '_' : c); } return sb.ToString().Trim(' ', '.'); } /// /// Sanitizes the final formatted file name, ensuring no illegal characters or Windows reserved device names. /// public static string SanitizeFileName(string? fileName) { if (string.IsNullOrWhiteSpace(fileName)) return string.Empty; var invalidChars = Path.GetInvalidFileNameChars(); var sb = new StringBuilder(fileName.Length); foreach (var c in fileName) { sb.Append(invalidChars.Contains(c) ? '_' : c); } var result = sb.ToString().Trim(' ', '.'); if (ReservedWindowsNames.Contains(result)) { result = "_" + result; } return result; } /// /// Formats a file name without extension by substituting template variables. /// Available tokens: {song}, {title}, {artist}, {author}, {level}, {preset}, /// {date}, {time}, {year}, {month}, {day}, {hour}, {minute}, {second}, /// {resolution}, {width}, {height}, {fps}. /// public static string FormatFileName( string? formatTemplate, DateTime startTime, RenderConfig config, string? presetName = null, string? levelPath = null) { var scn = scnGame.instance; string rawSong = ""; string rawArtist = ""; string rawAuthor = ""; if (scn != null && scn.levelData != null) { rawSong = scn.levelData.song ?? ""; rawArtist = scn.levelData.artist ?? ""; rawAuthor = scn.levelData.author ?? ""; } if (string.IsNullOrWhiteSpace(rawSong) && !string.IsNullOrWhiteSpace(levelPath)) { var dir = Path.GetDirectoryName(levelPath); rawSong = !string.IsNullOrWhiteSpace(dir) ? Path.GetFileName(dir) : Path.GetFileNameWithoutExtension(levelPath); } var song = SanitizeToken(StripRichText(rawSong)); var artist = SanitizeToken(StripRichText(rawArtist)); var author = SanitizeToken(StripRichText(rawAuthor)); var level = !string.IsNullOrWhiteSpace(levelPath) ? SanitizeToken(Path.GetFileNameWithoutExtension(levelPath)) : song; var preset = SanitizeToken(presetName ?? ""); var date = startTime.ToString("yyyy-MM-dd"); var time = startTime.ToString("HH-mm-ss"); var year = startTime.ToString("yyyy"); var month = startTime.ToString("MM"); var day = startTime.ToString("dd"); var hour = startTime.ToString("HH"); var minute = startTime.ToString("mm"); var second = startTime.ToString("ss"); var width = config.RenderWidth.ToString(); var height = config.RenderHeight.ToString(); var resolution = $"{config.RenderWidth}x{config.RenderHeight}"; var effectiveFps = Math.Min(config.VideoFPS, config.RenderFPS); var fps = Math.Abs(effectiveFps % 1) < 0.001 ? ((int)effectiveFps).ToString() : effectiveFps.ToString("0.##"); var tokens = new Dictionary(StringComparer.OrdinalIgnoreCase) { { "song", song }, { "title", song }, { "artist", artist }, { "author", author }, { "level", level }, { "preset", preset }, { "date", date }, { "time", time }, { "year", year }, { "month", month }, { "day", day }, { "hour", hour }, { "minute", minute }, { "second", second }, { "width", width }, { "height", height }, { "resolution", resolution }, { "fps", fps } }; var template = string.IsNullOrWhiteSpace(formatTemplate) ? GlobalSettings.DefaultFileNameFormat : formatTemplate; var replaced = TokenRegex.Replace(template, match => { var key = match.Groups[1].Value; return tokens.TryGetValue(key, out var val) ? val : match.Value; }); var sanitized = SanitizeFileName(replaced); return string.IsNullOrWhiteSpace(sanitized) ? startTime.ToString("yyyy-MM-dd HH-mm-ss") : sanitized; } /// /// Returns a collision-free output file path by appending (1), (2), etc. if a file with the given name already exists. /// public static string GetUniqueFilePath(string directory, string fileNameWithoutExt, string ext) { var normalizedExt = "." + ext.TrimStart('.').ToLowerInvariant(); var fileName = $"{fileNameWithoutExt}{normalizedExt}"; var fullPath = Path.Combine(directory, fileName); var counter = 1; while (File.Exists(fullPath)) { fileName = $"{fileNameWithoutExt} ({counter}){normalizedExt}"; fullPath = Path.Combine(directory, fileName); counter++; } return fullPath; } }