using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using AaxDecrypter; using Dinah.Core; using FileManager; using FileManager.NamingTemplate; using NameParser; #nullable enable namespace LibationFileManager.Templates { public interface ITemplate { static abstract string Name { get; } static abstract string Description { get; } static abstract string DefaultTemplate { get; } static abstract IEnumerable TagCollections { get; } } public abstract class Templates { public const string ERROR_FULL_PATH_IS_INVALID = @"No colons or full paths allowed. Eg: should not start with C:\"; public const string WARNING_NO_CHAPTER_NUMBER_TAG = "Should include chapter number tag in template used for naming files which are split by chapter. Ie: or "; //Assigning the properties in the static constructor will require all //Templates users to have a valid configuration file. To allow tests //to work without access to Configuration, only load templates on demand. private static FolderTemplate? _folder; private static FileTemplate? _file; private static ChapterFileTemplate? _chapterFile; private static ChapterTitleTemplate? _chapterTitle; public static FolderTemplate Folder => _folder ??= GetTemplate(Configuration.Instance.FolderTemplate); public static FileTemplate File => _file ??= GetTemplate(Configuration.Instance.FileTemplate); public static ChapterFileTemplate ChapterFile => _chapterFile ??= GetTemplate(Configuration.Instance.ChapterFileTemplate); public static ChapterTitleTemplate ChapterTitle => _chapterTitle ??= GetTemplate(Configuration.Instance.ChapterTitleTemplate); #region Template Parsing public static T GetTemplate(string? templateText) where T : Templates, ITemplate, new() => TryGetTemplate(templateText ?? "", out var template) ? template : GetDefaultTemplate(); public static bool TryGetTemplate(string templateText, [NotNullWhen(true)] out T? template) where T : Templates, ITemplate, new() { var namingTemplate = NamingTemplate.Parse(templateText, T.TagCollections); template = new() { NamingTemplate = namingTemplate }; return !namingTemplate.Errors.Any(); } private static T GetDefaultTemplate() where T : Templates, ITemplate, new() => new() { NamingTemplate = NamingTemplate.Parse(T.DefaultTemplate, T.TagCollections) }; static Templates() { Configuration.Instance.PropertyChanged += [PropertyChangeFilter(nameof(Configuration.FolderTemplate))] (_, e) => _folder = GetTemplate(e.NewValue as string); Configuration.Instance.PropertyChanged += [PropertyChangeFilter(nameof(Configuration.FileTemplate))] (_, e) => _file = GetTemplate(e.NewValue as string); Configuration.Instance.PropertyChanged += [PropertyChangeFilter(nameof(Configuration.ChapterFileTemplate))] (_, e) => _chapterFile = GetTemplate(e.NewValue as string); Configuration.Instance.PropertyChanged += [PropertyChangeFilter(nameof(Configuration.ChapterTitleTemplate))] (_, e) => _chapterTitle = GetTemplate(e.NewValue as string); HumanName.Suffixes.Add("ret"); HumanName.Titles.Add("professor"); } #endregion #region Template Properties public IEnumerable TagsRegistered => NamingTemplate?.TagsRegistered.Cast() ?? Enumerable.Empty(); public IEnumerable TagsInUse => NamingTemplate?.TagsInUse.Cast() ?? Enumerable.Empty(); public string TemplateText => NamingTemplate?.TemplateText ?? ""; private readonly NamingTemplate? _namingTemplate; protected NamingTemplate NamingTemplate { get => _namingTemplate ?? throw new NullReferenceException(nameof(_namingTemplate)); private init => _namingTemplate = value; } #endregion #region validation public virtual IEnumerable Errors => NamingTemplate.Errors; public bool IsValid => !Errors.Any(); public virtual IEnumerable Warnings => NamingTemplate.Warnings; public bool HasWarnings => Warnings.Any(); #endregion #region to file name public string GetName(LibraryBookDto libraryBookDto, MultiConvertFileProperties multiChapProps) { ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto)); ArgumentValidator.EnsureNotNull(multiChapProps, nameof(multiChapProps)); return string.Concat(NamingTemplate.Evaluate(libraryBookDto, multiChapProps).Select(p => p.Value)); } public LongPath GetFilename(LibraryBookDto libraryBookDto, string baseDir, string fileExtension, ReplacementCharacters? replacements = null, bool returnFirstExisting = false) { ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto)); ArgumentValidator.EnsureNotNull(baseDir, nameof(baseDir)); ArgumentValidator.EnsureNotNull(fileExtension, nameof(fileExtension)); replacements ??= Configuration.Instance.ReplacementCharacters; return GetFilename(baseDir, fileExtension, replacements, returnFirstExisting, libraryBookDto); } public LongPath GetFilename(LibraryBookDto libraryBookDto, MultiConvertFileProperties multiChapProps, string baseDir, string fileExtension, ReplacementCharacters? replacements = null, bool returnFirstExisting = false) { ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto)); ArgumentValidator.EnsureNotNull(multiChapProps, nameof(multiChapProps)); ArgumentValidator.EnsureNotNull(baseDir, nameof(baseDir)); ArgumentValidator.EnsureNotNull(fileExtension, nameof(fileExtension)); replacements ??= Configuration.Instance.ReplacementCharacters; return GetFilename(baseDir, fileExtension, replacements, returnFirstExisting, libraryBookDto, multiChapProps); } protected virtual IEnumerable GetTemplatePartsStrings(List parts, ReplacementCharacters replacements) => parts.Select(p => replacements.ReplaceFilenameChars(p.Value)); private LongPath GetFilename(string baseDir, string fileExtension, ReplacementCharacters replacements, bool returnFirstExisting, params object[] dtos) { fileExtension = FileUtility.GetStandardizedExtension(fileExtension); var parts = NamingTemplate.Evaluate(dtos).ToList(); var pathParts = GetPathParts(GetTemplatePartsStrings(parts, replacements)); //Remove 1 character from the end of the longest filename part until //the total filename is less than max filename length for (int i = 0; i < pathParts.Count; i++) { var part = pathParts[i]; //If file already exists, GetValidFilename will append " (n)" to the filename. //This could cause the filename length to exceed MaxFilenameLength, so reduce //allowable filename length by 5 chars, allowing for up to 99 duplicates. var maxFilenameLength = LongPath.MaxFilenameLength - (i < pathParts.Count - 1 || string.IsNullOrEmpty(fileExtension) ? 0 : fileExtension.Length + 5); while (part.Sum(GetFilenameLength) > maxFilenameLength) { int maxLength = part.Max(p => p.Length); var maxEntry = part.First(p => p.Length == maxLength); var maxIndex = part.IndexOf(maxEntry); part.RemoveAt(maxIndex); part.Insert(maxIndex, maxEntry.Remove(maxLength - 1, 1)); } } var fullPath = Path.Combine(pathParts.Select(fileParts => string.Concat(fileParts)).Prepend(baseDir).ToArray()); return FileUtility.GetValidFilename(fullPath, replacements, fileExtension, returnFirstExisting); } private static int GetFilenameLength(string filename) => Configuration.Instance.BooksCanWrite255UnicodeChars ? filename.Length : System.Text.Encoding.UTF8.GetByteCount(filename); /// /// Organize template parts into directories. Any Extra slashes will be /// returned as empty directories and are taken care of by Path.Combine() /// /// A List of template directories. Each directory is a list of template part strings private static List> GetPathParts(IEnumerable templateParts) { List> directories = new(); List dir = new(); foreach (var part in templateParts) { int slashIndex, lastIndex = 0; while ((slashIndex = part.IndexOf(Path.DirectorySeparatorChar, lastIndex)) > -1) { dir.Add(part[lastIndex..slashIndex]); RemoveSpaces(dir); directories.Add(dir); dir = new(); lastIndex = slashIndex + 1; } dir.Add(part[lastIndex..]); } RemoveSpaces(dir); directories.Add(dir); return directories; } /// /// Remove spaces from the filename parts to ensure that after concatenation ///
-
There is no leading or trailing white space ///
-
There are no multispace instances ///
private static void RemoveSpaces(List parts) { while (parts.Count > 0 && string.IsNullOrWhiteSpace(parts[0])) parts.RemoveAt(0); while (parts.Count > 0 && string.IsNullOrWhiteSpace(parts[^1])) parts.RemoveAt(parts.Count - 1); if (parts.Count == 0) return; parts[0] = parts[0].TrimStart(); parts[^1] = parts[^1].TrimEnd(); //Replace all multispace substrings with single space for (int i = 0; i < parts.Count; i++) { string original; do { original = parts[i]; parts[i] = original.Replace(" ", " "); } while (original.Length != parts[i].Length); } //Remove instances of double spaces at part boundaries for (int i = 1; i < parts.Count; i++) { if (parts[i - 1].EndsWith(' ') && parts[i].StartsWith(' ')) { parts[i] = parts[i].Substring(1); if (parts[i].Length == 0) { parts.RemoveAt(i); i--; } } } } #endregion #region Registered Template Properties private static readonly PropertyTagCollection filePropertyTags = new(caseSensative: true, StringFormatter, DateTimeFormatter, IntegerFormatter, FloatFormatter) { //Don't allow formatting of Id { TemplateTags.Id, lb => lb.AudibleProductId, v => v ?? "" }, { TemplateTags.Title, lb => lb.TitleWithSubtitle }, { TemplateTags.TitleShort, lb => getTitleShort(lb.Title) }, { TemplateTags.AudibleTitle, lb => lb.Title }, { TemplateTags.AudibleSubtitle, lb => lb.Subtitle }, { TemplateTags.Author, lb => lb.Authors, NameListFormat.Formatter }, { TemplateTags.FirstAuthor, lb => lb.FirstAuthor, FormattableFormatter }, { TemplateTags.Narrator, lb => lb.Narrators, NameListFormat.Formatter }, { TemplateTags.FirstNarrator, lb => lb.FirstNarrator, FormattableFormatter }, { TemplateTags.Series, lb => lb.Series, SeriesListFormat.Formatter }, { TemplateTags.FirstSeries, lb => lb.FirstSeries, FormattableFormatter }, { TemplateTags.SeriesNumber, lb => lb.FirstSeries?.Order, FormattableFormatter }, { TemplateTags.Language, lb => lb.Language }, //Don't allow formatting of LanguageShort { TemplateTags.LanguageShort, lb =>lb.Language, getLanguageShort }, { TemplateTags.Account, lb => lb.Account }, { TemplateTags.AccountNickname, lb => lb.AccountNickname }, { TemplateTags.Locale, lb => lb.Locale }, { TemplateTags.YearPublished, lb => lb.YearPublished }, { TemplateTags.DatePublished, lb => lb.DatePublished }, { TemplateTags.DateAdded, lb => lb.DateAdded }, { TemplateTags.FileDate, lb => lb.FileDate }, }; private static readonly PropertyTagCollection audioFilePropertyTags = new(caseSensative: true, StringFormatter, IntegerFormatter) { { TemplateTags.Bitrate, lb => lb.BitRate }, { TemplateTags.SampleRate, lb => lb.SampleRate }, { TemplateTags.Channels, lb => lb.Channels }, { TemplateTags.Codec, lb => lb.Codec }, { TemplateTags.FileVersion, lb => lb.FileVersion }, { TemplateTags.LibationVersion, lb => lb.LibationVersion }, }; private static readonly List chapterPropertyTags = new() { new PropertyTagCollection(caseSensative: true, StringFormatter) { { TemplateTags.Title, lb => lb.TitleWithSubtitle }, { TemplateTags.TitleShort, lb => getTitleShort(lb.Title) }, { TemplateTags.AudibleTitle, lb => lb.Title }, { TemplateTags.AudibleSubtitle, lb => lb.Subtitle }, { TemplateTags.Series, lb => lb.Series, SeriesListFormat.Formatter }, { TemplateTags.FirstSeries, lb => lb.FirstSeries, FormattableFormatter }, }, new PropertyTagCollection(caseSensative: true, StringFormatter, IntegerFormatter, DateTimeFormatter) { { TemplateTags.ChCount, m => m.PartsTotal }, { TemplateTags.ChNumber, m => m.PartsPosition }, { TemplateTags.ChNumber0, m => m.PartsPosition.ToString("D" + ((int)Math.Log10(m.PartsTotal) + 1)) }, { TemplateTags.ChTitle, m => m.Title }, { TemplateTags.FileDate, m => m.FileDate } } }; private static readonly ConditionalTagCollection conditionalTags = new() { { TemplateTags.IfSeries, lb => lb.IsSeries || lb.IsPodcastParent }, { TemplateTags.IfPodcast, lb => lb.IsPodcast || lb.IsPodcastParent }, { TemplateTags.IfBookseries, lb => lb.IsSeries && !lb.IsPodcast && !lb.IsPodcastParent }, }; private static readonly ConditionalTagCollection folderConditionalTags = new() { { TemplateTags.IfPodcastParent, lb => lb.IsPodcastParent } }; #endregion #region Tag Formatters private static string? getTitleShort(string? title) => title?.IndexOf(':') > 0 ? title.Substring(0, title.IndexOf(':')) : title; private static string getLanguageShort(string? language) { if (language is null) return ""; language = language.Trim(); if (language.Length <= 3) return language.ToUpper(); return language[..3].ToUpper(); } private static string FormattableFormatter(ITemplateTag templateTag, IFormattable? value, string formatString) => value?.ToString(formatString, null) ?? ""; private static string StringFormatter(ITemplateTag templateTag, string value, string formatString) { if (value is null) return ""; else if (string.Compare(formatString, "u", ignoreCase: true) == 0) return value.ToUpper(); else if (string.Compare(formatString, "l", ignoreCase: true) == 0) return value.ToLower(); else return value; } private static string IntegerFormatter(ITemplateTag templateTag, int value, string formatString) => FloatFormatter(templateTag, value, formatString); private static string FloatFormatter(ITemplateTag templateTag, float value, string formatString) { if (int.TryParse(formatString, out var numDigits) && numDigits > 0) { //Zero-pad the integer part var strValue = value.ToString(); var decIndex = strValue.IndexOf(System.Globalization.NumberFormatInfo.CurrentInfo.NumberDecimalSeparator); var zeroPad = decIndex == -1 ? int.Max(0, numDigits - strValue.Length) : int.Max(0, numDigits - decIndex); return new string('0', zeroPad) + strValue; } return value.ToString(formatString); } private static string DateTimeFormatter(ITemplateTag templateTag, DateTime value, string formatString) { if (string.IsNullOrEmpty(formatString)) return value.ToString(TemplateTags.DEFAULT_DATE_FORMAT); return value.ToString(formatString); } #endregion public class FolderTemplate : Templates, ITemplate { public static string Name { get; } = "Folder Template"; public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.FolderTemplate)) ?? ""; public static string DefaultTemplate { get; } = " [<id>]"; public static IEnumerable<TagCollection> TagCollections { get; } = [filePropertyTags, audioFilePropertyTags, conditionalTags, folderConditionalTags]; public override IEnumerable<string> Errors => TemplateText?.Length >= 2 && Path.IsPathFullyQualified(TemplateText) ? base.Errors.Append(ERROR_FULL_PATH_IS_INVALID) : base.Errors; protected override List<string> GetTemplatePartsStrings(List<TemplatePart> parts, ReplacementCharacters replacements) => parts .Select(tp => tp.TemplateTag is null //FolderTemplate literals can have directory separator characters ? replacements.ReplacePathChars(tp.Value.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar)) : replacements.ReplaceFilenameChars(tp.Value) ).ToList(); } public class FileTemplate : Templates, ITemplate { public static string Name { get; } = "File Template"; public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.FileTemplate)) ?? ""; public static string DefaultTemplate { get; } = "<title> [<id>]"; public static IEnumerable<TagCollection> TagCollections { get; } = [filePropertyTags, audioFilePropertyTags, conditionalTags]; } public class ChapterFileTemplate : Templates, ITemplate { public static string Name { get; } = "Chapter File Template"; public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.ChapterFileTemplate)) ?? ""; public static string DefaultTemplate { get; } = "<title> [<id>] - <ch# 0> - <ch title>"; public static IEnumerable<TagCollection> TagCollections { get; } = chapterPropertyTags.Append(filePropertyTags).Append(audioFilePropertyTags).Append(conditionalTags); public override IEnumerable<string> Warnings => NamingTemplate.TagsInUse.Any(t => t.TagName.In(TemplateTags.ChNumber.TagName, TemplateTags.ChNumber0.TagName)) ? base.Warnings : base.Warnings.Append(WARNING_NO_CHAPTER_NUMBER_TAG); } public class ChapterTitleTemplate : Templates, ITemplate { public static string Name { get; } = "Chapter Title Template"; public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.ChapterTitleTemplate)) ?? ""; public static string DefaultTemplate => "<ch#> - <title short>: <ch title>"; public static IEnumerable<TagCollection> TagCollections { get; } = chapterPropertyTags.Append(conditionalTags); protected override IEnumerable<string> GetTemplatePartsStrings(List<TemplatePart> parts, ReplacementCharacters replacements) => parts.Select(p => p.Value); } } }