using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System; using System.Collections.Generic; using System.IO; using System.Linq; #nullable enable namespace FileManager { public record Replacement { public const int FIXED_COUNT = 6; internal const char QUOTE_MARK = '"'; [JsonIgnore] public bool Mandatory { get; set; } [JsonProperty] public char CharacterToReplace { get; private set; } [JsonProperty] public string ReplacementString { get; private set; } [JsonProperty] public string Description { get; set; } public override string ToString() => $"{CharacterToReplace} → {ReplacementString} ({Description})"; public Replacement(char charToReplace, string replacementString, string description) { CharacterToReplace = charToReplace; ReplacementString = replacementString; Description = description; } private Replacement(char charToReplace, string replacementString, string description, bool mandatory) : this(charToReplace, replacementString, description) { Mandatory = mandatory; } public void Update(char charToReplace, string replacementString, string description) { ReplacementString = replacementString; if (!Mandatory) { CharacterToReplace = charToReplace; Description = description; } } public static Replacement OtherInvalid(string replacement) => new(default, replacement, "All other invalid characters", true); public static Replacement FilenameForwardSlash(string replacement) => new('/', replacement, "Forward Slash (Filename Only)", true); public static Replacement FilenameBackSlash(string replacement) => new('\\', replacement, "Back Slash (Filename Only)", true); public static Replacement OpenQuote(string replacement) => new('"', replacement, "Open Quote", true); public static Replacement CloseQuote(string replacement) => new('"', replacement, "Close Quote", true); public static Replacement OtherQuote(string replacement) => new('"', replacement, "Other Quote", true); public static Replacement Colon(string replacement) => new(':', replacement, "Colon"); public static Replacement Asterisk(string replacement) => new('*', replacement, "Asterisk"); public static Replacement QuestionMark(string replacement) => new('?', replacement, "Question Mark"); public static Replacement OpenAngleBracket(string replacement) => new('<', replacement, "Open Angle Bracket"); public static Replacement CloseAngleBracket(string replacement) => new('>', replacement, "Close Angle Bracket"); public static Replacement Pipe(string replacement) => new('|', replacement, "Vertical Line"); } [JsonConverter(typeof(ReplacementCharactersConverter))] public class ReplacementCharacters { public override bool Equals(object? obj) { if (obj is ReplacementCharacters second && Replacements.Count == second.Replacements.Count) { for (int i = 0; i < Replacements.Count; i++) if (Replacements[i] != second.Replacements[i]) return false; return true; } return false; } public override int GetHashCode() => Replacements.GetHashCode(); public static ReplacementCharacters Default(bool ntfs) => ntfs ? HiFi_NTFS : HiFi_Other; public static ReplacementCharacters LoFiDefault(bool ntfs) => ntfs ? LoFi_NTFS : LoFi_Other; public static ReplacementCharacters Barebones(bool ntfs) => ntfs ? BareBones_NTFS : BareBones_Other; #region Defaults private static readonly ReplacementCharacters HiFi_NTFS = new() { Replacements = [ Replacement.OtherInvalid("_"), Replacement.FilenameForwardSlash("∕"), Replacement.FilenameBackSlash(""), Replacement.OpenQuote("“"), Replacement.CloseQuote("”"), Replacement.OtherQuote("""), Replacement.OpenAngleBracket("<"), Replacement.CloseAngleBracket(">"), Replacement.Colon("_"), Replacement.Asterisk("✱"), Replacement.QuestionMark("?"), Replacement.Pipe("⏐")] }; private static readonly ReplacementCharacters HiFi_Other = new() { Replacements = [ Replacement.OtherInvalid("_"), Replacement.FilenameForwardSlash("∕"), Replacement.FilenameBackSlash("\\"), Replacement.OpenQuote("“"), Replacement.CloseQuote("”"), Replacement.OtherQuote("\"")] }; private static readonly ReplacementCharacters LoFi_NTFS = new() { Replacements = [ Replacement.OtherInvalid("_"), Replacement.FilenameForwardSlash("_"), Replacement.FilenameBackSlash("_"), Replacement.OpenQuote("'"), Replacement.CloseQuote("'"), Replacement.OtherQuote("'"), Replacement.OpenAngleBracket("{"), Replacement.CloseAngleBracket("}"), Replacement.Colon("-")] }; private static readonly ReplacementCharacters LoFi_Other = new() { Replacements = [ Replacement.OtherInvalid("_"), Replacement.FilenameForwardSlash("_"), Replacement.FilenameBackSlash("\\"), Replacement.OpenQuote("\""), Replacement.CloseQuote("\""), Replacement.OtherQuote("\"")] }; private static readonly ReplacementCharacters BareBones_NTFS = new() { Replacements = [ Replacement.OtherInvalid("_"), Replacement.FilenameForwardSlash("_"), Replacement.FilenameBackSlash("_"), Replacement.OpenQuote("_"), Replacement.CloseQuote("_"), Replacement.OtherQuote("_")] }; private static readonly ReplacementCharacters BareBones_Other = new() { Replacements = [ Replacement.OtherInvalid("_"), Replacement.FilenameForwardSlash("_"), Replacement.FilenameBackSlash("\\"), Replacement.OpenQuote("\""), Replacement.CloseQuote("\""), Replacement.OtherQuote("\"")] }; #endregion internal static bool IsWindows => Environment.OSVersion.Platform is PlatformID.Win32NT; private static readonly char[] invalidPathChars = Path.GetInvalidFileNameChars().Except(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }).ToArray(); private static readonly char[] invalidSlashes = Path.GetInvalidFileNameChars().Intersect(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }).ToArray(); required public IReadOnlyList Replacements { get; init; } private string DefaultReplacement => Replacements[0].ReplacementString; private Replacement ForwardSlash => Replacements[1]; private Replacement BackSlash => Replacements[2]; private string OpenQuote => Replacements[3].ReplacementString; private string CloseQuote => Replacements[4].ReplacementString; private string OtherQuote => Replacements[5].ReplacementString; private string GetFilenameCharReplacement(char toReplace, char preceding, char succeding) { if (toReplace == ForwardSlash.CharacterToReplace) return ForwardSlash.ReplacementString; else if (toReplace == BackSlash.CharacterToReplace) return BackSlash.ReplacementString; else return GetPathCharReplacement(toReplace, preceding, succeding); } private string GetPathCharReplacement(char toReplace, char preceding, char succeding) { if (toReplace == Replacement.QUOTE_MARK) { if ( preceding == default || ( !char.IsLetter(preceding) && !char.IsNumber(preceding) && (char.IsLetter(succeding) || char.IsNumber(succeding)) ) ) return OpenQuote; else if ( succeding == default || ( !char.IsLetter(succeding) && !char.IsNumber(succeding) && (char.IsLetter(preceding) || char.IsNumber(preceding)) ) ) return CloseQuote; else return OtherQuote; } if (!IsWindows && toReplace == BackSlash.CharacterToReplace) return BackSlash.ReplacementString; //Replace any other non-mandatory characters for (int i = Replacement.FIXED_COUNT; i < Replacements.Count; i++) { var r = Replacements[i]; if (r.CharacterToReplace == toReplace) return r.ReplacementString; } return DefaultReplacement; } public static bool ContainsInvalidPathChar(string path) => path.Any(c => invalidPathChars.Contains(c)); public static bool ContainsInvalidFilenameChar(string path) => ContainsInvalidPathChar(path) || path.Any(c => invalidSlashes.Contains(c)); public string ReplaceFilenameChars(string fileName) { if (string.IsNullOrEmpty(fileName)) return string.Empty; var builder = new System.Text.StringBuilder(); for (var i = 0; i < fileName.Length; i++) { var c = fileName[i]; if (invalidPathChars.Contains(c) || invalidSlashes.Contains(c) || Replacements.Any(r => r.CharacterToReplace == c) /* Replace any other legal characters that they user wants. */ ) { char preceding = i > 0 ? fileName[i - 1] : default; char succeeding = i < fileName.Length - 1 ? fileName[i + 1] : default; builder.Append(GetFilenameCharReplacement(c, preceding, succeeding)); } else builder.Append(c); } return builder.ToString(); } public string ReplacePathChars(string pathStr) { if (string.IsNullOrEmpty(pathStr)) return string.Empty; var builder = new System.Text.StringBuilder(); for (var i = 0; i < pathStr.Length; i++) { var c = pathStr[i]; if ( ( invalidPathChars.Contains(c) || ( // Replace any other legal characters that they user wants. c != Path.DirectorySeparatorChar && c != Path.AltDirectorySeparatorChar && Replacements.Any(r => r.CharacterToReplace == c) ) ) && !( // replace all colons except drive letter designator on Windows c == ':' && i == 1 && Path.IsPathRooted(pathStr) && IsWindows ) ) { char preceding = i > 0 ? pathStr[i - 1] : default; char succeeding = i < pathStr.Length - 1 ? pathStr[i + 1] : default; builder.Append(GetPathCharReplacement(c, preceding, succeeding)); } else builder.Append(c); } return builder.ToString(); } } #region JSON Converter internal class ReplacementCharactersConverter : JsonConverter { public override bool CanConvert(Type objectType) => objectType == typeof(ReplacementCharacters); public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer) { var defaults = ReplacementCharacters.Default(ReplacementCharacters.IsWindows).Replacements; var jObj = JObject.Load(reader); var replaceArr = jObj[nameof(Replacement)]; var dict = replaceArr?.ToObject()?.ToList() ?? defaults; //Ensure that the first 6 replacements are for the expected chars and that all replacement strings are valid. //If not, reset to default. for (int i = 0; i < Replacement.FIXED_COUNT; i++) { if (dict.Count < Replacement.FIXED_COUNT || dict[i].CharacterToReplace != defaults[i].CharacterToReplace || dict[i].Description != defaults[i].Description) { dict = defaults; break; } //First FIXED_COUNT are mandatory dict[i].Mandatory = true; } return new ReplacementCharacters { Replacements = dict }; } public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer) { if (value is not ReplacementCharacters replacements) return; var propertyNames = replacements.Replacements .Select(JObject.FromObject).ToList(); var prop = new JProperty(nameof(Replacement), new JArray(propertyNames)); var obj = new JObject(); obj.AddFirst(prop); obj.WriteTo(writer); } } #endregion }