using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Globalization; using System.IO; using System.Linq; using System.Text.RegularExpressions; namespace Kavita.Common.Extensions; #nullable enable public static partial class StringExtensions { private static readonly Regex SentenceCaseRegex = new(@"(^[a-z])|\.\s+(.)", RegexOptions.ExplicitCapture | RegexOptions.Compiled, TimeSpan.FromMilliseconds(500)); /// /// Normalize everything within Kavita. Some characters don't fall under Unicode, like full-width characters and need to be /// added on a case-by-case basis. /// private static readonly Regex NormalizeRegex = new(@"[^\p{L}0-9\+!*!+]", RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant, TimeSpan.FromMilliseconds(500)); extension(string input) { public string Sanitize() { if (string.IsNullOrEmpty(input)) return string.Empty; // Remove all newline and control characters var sanitized = input .Replace(Environment.NewLine, string.Empty) .Replace("\n", string.Empty) .Replace("\r", string.Empty); // Optionally remove other potentially unwanted characters sanitized = Regex.Replace(sanitized, @"[^\u0020-\u007E]", string.Empty); // Removes non-printable ASCII return sanitized.Trim(); // Trim any leading/trailing whitespace } public string SentenceCase() { return SentenceCaseRegex.Replace(input.ToLower(), s => s.Value.ToUpper()); } } /// extension(string? value) { /// /// Apply normalization on the String /// /// public string ToNormalized() { return string.IsNullOrEmpty(value) ? string.Empty : NormalizeRegex.Replace(value, string.Empty).Trim().ToLower(); } /// /// Normalizes the slashes in a path to be /// /// /manga/1\1 -> /manga/1/1 /// public string NormalizePath() { return string.IsNullOrEmpty(value) ? string.Empty : value.Replace('\\', Path.AltDirectorySeparatorChar) .Replace(@"//", Path.AltDirectorySeparatorChar + string.Empty); } public float AsFloat(float defaultValue = 0.0f) { return string.IsNullOrEmpty(value) ? defaultValue : float.Parse(value, CultureInfo.InvariantCulture); } public double AsDouble(double defaultValue = 0.0f) { return string.IsNullOrEmpty(value) ? defaultValue : double.Parse(value, CultureInfo.InvariantCulture); } public string TrimPrefix(string prefix) { if (string.IsNullOrEmpty(value)) return string.Empty; if (!value.StartsWith(prefix)) return value; return value.Substring(prefix.Length); } /// /// Censor the input string by removing all but the first and last char. /// /// /// If the input is an email (contains @), the domain will remain untouched public string Censor() { if (string.IsNullOrWhiteSpace(value)) return value ?? string.Empty; var atIdx = value.IndexOf('@'); if (atIdx == -1) { return $"{value[0]}{new string('*', value.Length - 1)}"; } return value[0] + new string('*', atIdx - 1) + value[atIdx..]; } /// /// Repeat returns a string that is equal to the original string repeat n times /// /// Amount of times to repeat /// public string Repeat(int n) { return string.IsNullOrEmpty(value) ? string.Empty : string.Concat(Enumerable.Repeat(value, n)); } } extension(string value) { /// /// Splits the string by the given separator. While cleaning out entries and removing duplicates /// /// /// public IList SplitBy(char separator) { if (string.IsNullOrEmpty(value)) { return ImmutableList.Empty; } return value.Split(separator, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) .DistinctBy(s => s.ToNormalized()) .ToList(); } public IList ParseIntArray() { if (string.IsNullOrWhiteSpace(value)) { return []; } return value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(int.Parse) .ToList(); } /// /// Parses a human-readable file size string (e.g. "1.43 GB") into bytes. /// /// Byte count as long /// The input string like "1.43 GB", "4.2 KB", "512 B" public long ParseHumanReadableBytes() { if (string.IsNullOrWhiteSpace(value)) { throw new ArgumentException("Input cannot be null or empty.", nameof(value)); } var match = HumanReadableBytesRegex().Match(value); if (!match.Success) { throw new FormatException($"Invalid format: '{value}'"); } var value1 = double.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture); var unit = match.Groups[2].Value.ToUpperInvariant(); var multiplier = unit switch { "B" => 1L, "KB" => 1L << 10, "MB" => 1L << 20, "GB" => 1L << 30, "TB" => 1L << 40, "PB" => 1L << 50, "EB" => 1L << 60, _ => throw new FormatException($"Unknown unit: '{unit}'") }; return (long)(value1 * multiplier); } } [GeneratedRegex(@"^\s*(\d+(?:\.\d+)?)\s*([KMGTPE]?B)\s*$", RegexOptions.IgnoreCase)] private static partial Regex HumanReadableBytesRegex(); }