From 20474e0b3ca723c7332207a875caf62b4b0c4f54 Mon Sep 17 00:00:00 2001 From: Mbucari Date: Thu, 2 Feb 2023 16:04:14 -0700 Subject: [PATCH 1/7] Add a more general NamingTemplate --- Source/FileLiberator/AudioFileStorageExt.cs | 7 +- Source/FileLiberator/DownloadOptions.cs | 2 +- Source/FileLiberator/UtilityExtensions.cs | 1 + Source/FileManager/FileNamingTemplate.cs | 123 ---- Source/FileManager/LongPath.cs | 4 +- Source/FileManager/MetadataNamingTemplate.cs | 20 - Source/FileManager/NamingTemplate.cs | 28 - .../ConditionalTagClass[TClass].cs | 64 ++ .../NamingTemplate/ITemplateTag.cs | 6 + .../NamingTemplate/NamingTemplate.cs | 271 +++++++ .../PropertyTagClass[TClass].cs | 85 +++ Source/FileManager/NamingTemplate/TagBase.cs | 67 ++ Source/FileManager/NamingTemplate/TagClass.cs | 77 ++ .../NamingTemplate/TemplatePart.cs | 112 +++ .../Dialogs/EditTemplateDialog.axaml.cs | 134 ++-- .../Dialogs/SettingsDialog.axaml.cs | 41 +- .../Configuration.PersistentSettings.cs | 31 +- Source/LibationFileManager/LibraryBookDto.cs | 1 + .../LibationFileManager/TemplateEditor[T].cs | 130 ++++ Source/LibationFileManager/TemplateTags.cs | 55 +- Source/LibationFileManager/Templates.cs | 664 ++++++++---------- .../LibationFileManager/UtilityExtensions.cs | 16 - .../Dialogs/EditTemplateDialog.cs | 141 +--- .../Dialogs/SettingsDialog.AudioSettings.cs | 3 +- .../Dialogs/SettingsDialog.DownloadDecrypt.cs | 10 +- .../Dialogs/SettingsDialog.Important.cs | 17 - .../Dialogs/SettingsDialog.cs | 6 +- .../FileNamingTemplateTests.cs | 216 ++++-- .../TemplatesTests.cs | 432 ++++++++---- 29 files changed, 1689 insertions(+), 1075 deletions(-) delete mode 100644 Source/FileManager/FileNamingTemplate.cs delete mode 100644 Source/FileManager/MetadataNamingTemplate.cs delete mode 100644 Source/FileManager/NamingTemplate.cs create mode 100644 Source/FileManager/NamingTemplate/ConditionalTagClass[TClass].cs create mode 100644 Source/FileManager/NamingTemplate/ITemplateTag.cs create mode 100644 Source/FileManager/NamingTemplate/NamingTemplate.cs create mode 100644 Source/FileManager/NamingTemplate/PropertyTagClass[TClass].cs create mode 100644 Source/FileManager/NamingTemplate/TagBase.cs create mode 100644 Source/FileManager/NamingTemplate/TagClass.cs create mode 100644 Source/FileManager/NamingTemplate/TemplatePart.cs create mode 100644 Source/LibationFileManager/TemplateEditor[T].cs delete mode 100644 Source/LibationFileManager/UtilityExtensions.cs diff --git a/Source/FileLiberator/AudioFileStorageExt.cs b/Source/FileLiberator/AudioFileStorageExt.cs index 45b79aaf..913bc18a 100644 --- a/Source/FileLiberator/AudioFileStorageExt.cs +++ b/Source/FileLiberator/AudioFileStorageExt.cs @@ -25,13 +25,12 @@ namespace FileLiberator if (seriesParent is not null) { - var baseDir = Templates.Folder.GetFilename(seriesParent.ToDto()); - return Templates.Folder.GetFilename(libraryBook.ToDto(), baseDir); + var baseDir = Templates.Folder.GetFilename(seriesParent.ToDto(), "", ""); + return Templates.Folder.GetFilename(libraryBook.ToDto(), baseDir, ""); } } } - - return Templates.Folder.GetFilename(libraryBook.ToDto()); + return Templates.Folder.GetFilename(libraryBook.ToDto(), "", ""); } /// diff --git a/Source/FileLiberator/DownloadOptions.cs b/Source/FileLiberator/DownloadOptions.cs index 2790d01e..9efb9fb0 100644 --- a/Source/FileLiberator/DownloadOptions.cs +++ b/Source/FileLiberator/DownloadOptions.cs @@ -38,7 +38,7 @@ namespace FileLiberator => Templates.ChapterFile.GetFilename(LibraryBookDto, props); public string GetMultipartTitle(MultiConvertFileProperties props) - => Templates.ChapterTitle.GetTitle(LibraryBookDto, props); + => Templates.ChapterTitle.GetName(LibraryBookDto, props); public async Task SaveClipsAndBookmarksAsync(string fileName) { diff --git a/Source/FileLiberator/UtilityExtensions.cs b/Source/FileLiberator/UtilityExtensions.cs index 47533d5c..22a728d9 100644 --- a/Source/FileLiberator/UtilityExtensions.cs +++ b/Source/FileLiberator/UtilityExtensions.cs @@ -41,6 +41,7 @@ namespace FileLiberator SeriesName = libraryBook.Book.SeriesLink.FirstOrDefault()?.Series.Name, SeriesNumber = libraryBook.Book.SeriesLink.FirstOrDefault()?.Order, + IsPodcast = libraryBook.Book.IsEpisodeChild(), BitRate = libraryBook.Book.AudioFormat.Bitrate, SampleRate = libraryBook.Book.AudioFormat.SampleRate, diff --git a/Source/FileManager/FileNamingTemplate.cs b/Source/FileManager/FileNamingTemplate.cs deleted file mode 100644 index 2ce85c0d..00000000 --- a/Source/FileManager/FileNamingTemplate.cs +++ /dev/null @@ -1,123 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; - -namespace FileManager -{ - /// Get valid filename. Advanced features incl. parameterized template - public class FileNamingTemplate : NamingTemplate - { - public ReplacementCharacters ReplacementCharacters { get; } - /// Proposed file name with optional html-styled template tags. - public FileNamingTemplate(string template, ReplacementCharacters replacement) : base(template) - { - ReplacementCharacters = replacement ?? ReplacementCharacters.Default; - } - - /// Generate a valid path for this file or directory - public LongPath GetFilePath(string fileExtension, bool returnFirstExisting = false) - { - string fileName = - Template.EndsWith(Path.DirectorySeparatorChar) || Template.EndsWith(Path.AltDirectorySeparatorChar) ? - FileUtility.RemoveLastCharacter(Template) : - Template; - - List pathParts = new(); - - var paramReplacements = ParameterReplacements.ToDictionary(r => $"<{formatKey(r.Key)}>", r => formatValue(r.Value, ReplacementCharacters)); - - while (!string.IsNullOrEmpty(fileName)) - { - var file = Path.GetFileName(fileName); - - if (Path.IsPathRooted(Template) && file == string.Empty) - { - pathParts.Add(fileName); - break; - } - else - { - pathParts.Add(file); - fileName = Path.GetDirectoryName(fileName); - } - } - - pathParts.Reverse(); - var fileNamePart = pathParts[^1]; - pathParts.Remove(fileNamePart); - - fileNamePart = fileNamePart[..^fileExtension.Length]; - - LongPath directory = Path.Join(pathParts.Select(p => replaceFileName(p, paramReplacements, LongPath.MaxFilenameLength)).ToArray()); - - //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. - return FileUtility - .GetValidFilename( - Path.Join(directory, replaceFileName(fileNamePart, paramReplacements, LongPath.MaxFilenameLength - fileExtension.Length - 5)) + fileExtension, - ReplacementCharacters, - fileExtension, - returnFirstExisting - ); - } - - private static string replaceFileName(string filename, Dictionary paramReplacements, int maxFilenameLength) - { - List filenameParts = new(); - //Build the filename in parts, replacing replacement parameters with - //their values, and storing the parts in a list. - while (!string.IsNullOrEmpty(filename)) - { - int openIndex = filename.IndexOf('<'); - int closeIndex = filename.IndexOf('>'); - - if (openIndex == 0 && closeIndex > 0) - { - var key = filename[..(closeIndex + 1)]; - - if (paramReplacements.ContainsKey(key)) - filenameParts.Add(new StringBuilder(paramReplacements[key])); - else - filenameParts.Add(new StringBuilder(key)); - - filename = filename[(closeIndex + 1)..]; - } - else if (openIndex > 0 && closeIndex > openIndex) - { - var other = filename[..openIndex]; - filenameParts.Add(new StringBuilder(other)); - filename = filename[openIndex..]; - } - else - { - filenameParts.Add(new StringBuilder(filename)); - filename = string.Empty; - } - } - - //Remove 1 character from the end of the longest filename part until - //the total filename is less than max filename length - while (filenameParts.Sum(p => LongPath.GetFilesystemStringLength(p)) > maxFilenameLength) - { - int maxLength = filenameParts.Max(p => p.Length); - var maxEntry = filenameParts.First(p => p.Length == maxLength); - - maxEntry.Remove(maxLength - 1, 1); - } - return string.Join("", filenameParts); - } - - private static string formatValue(object value, ReplacementCharacters replacements) - { - if (value is null) - return ""; - - // Other illegal characters will be taken care of later. Must take care of slashes now so params can't introduce new folders. - // Esp important for file templates. - return replacements.ReplaceFilenameChars(value.ToString()); - } - } -} diff --git a/Source/FileManager/LongPath.cs b/Source/FileManager/LongPath.cs index 06f1db6a..f6f3f8fa 100644 --- a/Source/FileManager/LongPath.cs +++ b/Source/FileManager/LongPath.cs @@ -56,9 +56,9 @@ namespace FileManager //don't care about encoding, so how unicode characters are encoded is ///a choice made by the linux kernel. As best as I can tell, pretty //much everyone uses UTF-8. - public static int GetFilesystemStringLength(StringBuilder filename) + public static int GetFilesystemStringLength(string filename) => IsWindows ? filename.Length - : Encoding.UTF8.GetByteCount(filename.ToString()); + : Encoding.UTF8.GetByteCount(filename); public static implicit operator LongPath(string path) { diff --git a/Source/FileManager/MetadataNamingTemplate.cs b/Source/FileManager/MetadataNamingTemplate.cs deleted file mode 100644 index af5281fe..00000000 --- a/Source/FileManager/MetadataNamingTemplate.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using System.Linq; - -namespace FileManager -{ - public class MetadataNamingTemplate : NamingTemplate - { - public MetadataNamingTemplate(string template) : base(template) { } - - public string GetTagContents() - { - var tagValue = Template; - - foreach (var r in ParameterReplacements) - tagValue = tagValue.Replace($"<{formatKey(r.Key)}>", r.Value?.ToString() ?? ""); - - return tagValue; - } - } -} diff --git a/Source/FileManager/NamingTemplate.cs b/Source/FileManager/NamingTemplate.cs deleted file mode 100644 index 126b8f61..00000000 --- a/Source/FileManager/NamingTemplate.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Dinah.Core; -using System; -using System.Collections.Generic; - -namespace FileManager -{ - public class NamingTemplate - { - /// Proposed full name. May contain optional html-styled template tags. Eg: <name> - public string Template { get; } - - /// Proposed file name with optional html-styled template tags. - public NamingTemplate(string template) => Template = ArgumentValidator.EnsureNotNullOrWhiteSpace(template, nameof(template)); - - /// Optional step 1: Replace html-styled template tags with parameters. Eg {"name", "Bill Gates"} => /<name>/ => /Bill Gates/ - public Dictionary ParameterReplacements { get; } = new Dictionary(); - - /// Convenience method - public void AddParameterReplacement(string key, object value) - // using .Add() instead of "[key] = value" will make unintended overwriting throw exception - => ParameterReplacements.Add(key, value); - - protected static string formatKey(string key) - => key - .Replace("<", "") - .Replace(">", ""); - } -} diff --git a/Source/FileManager/NamingTemplate/ConditionalTagClass[TClass].cs b/Source/FileManager/NamingTemplate/ConditionalTagClass[TClass].cs new file mode 100644 index 00000000..531e58b7 --- /dev/null +++ b/Source/FileManager/NamingTemplate/ConditionalTagClass[TClass].cs @@ -0,0 +1,64 @@ +using System; +using System.Linq.Expressions; +using System.Text.RegularExpressions; + +namespace FileManager.NamingTemplate; + +internal interface IClosingPropertyTag : IPropertyTag +{ + /// The used to match the closing in template strings. + public Regex NameCloseMatcher { get; } + + /// + /// Determine if the template string starts with 's closing tag signature, + /// and if it does output the matching tag's + /// + /// Template string + /// The substring that was matched. + /// The registered + /// True if the starts with this tag. + bool StartsWithClosing(string templateString, out string exactName, out IClosingPropertyTag propertyTag); +} + +public class ConditionalTagClass : TagClass +{ + public ConditionalTagClass(bool caseSensative = true) :base(typeof(TClass), caseSensative) { } + + public void RegisterCondition(ITemplateTag templateTag, Func propertyGetter) + { + var expr = Expression.Call(Expression.Constant(propertyGetter.Target), propertyGetter.Method, Parameter); + + AddPropertyTag(new ConditionalTag(templateTag, Options, expr)); + } + + private class ConditionalTag : TagBase, IClosingPropertyTag + { + public Regex NameCloseMatcher { get; } + + public ConditionalTag(ITemplateTag templateTag, RegexOptions options, Expression conditionExpression) + : base(templateTag, conditionExpression) + { + NameMatcher = new Regex($"^<{templateTag.TagName}->", options); + NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options); + } + + public bool StartsWithClosing(string templateString, out string exactName, out IClosingPropertyTag propertyTag) + { + var match = NameCloseMatcher.Match(templateString); + if (match.Success) + { + exactName = match.Value; + propertyTag = this; + return true; + } + else + { + exactName = null; + propertyTag = null; + return false; + } + } + + protected override Expression GetTagExpression(string exactName, string formatter) => ExpressionValue; + } +} diff --git a/Source/FileManager/NamingTemplate/ITemplateTag.cs b/Source/FileManager/NamingTemplate/ITemplateTag.cs new file mode 100644 index 00000000..2b397ba1 --- /dev/null +++ b/Source/FileManager/NamingTemplate/ITemplateTag.cs @@ -0,0 +1,6 @@ +namespace FileManager.NamingTemplate; + +public interface ITemplateTag +{ + string TagName { get; } +} diff --git a/Source/FileManager/NamingTemplate/NamingTemplate.cs b/Source/FileManager/NamingTemplate/NamingTemplate.cs new file mode 100644 index 00000000..dc6fbc71 --- /dev/null +++ b/Source/FileManager/NamingTemplate/NamingTemplate.cs @@ -0,0 +1,271 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; + +namespace FileManager.NamingTemplate; + +public class NamingTemplate +{ + public string TemplateText { get; private set; } + public IEnumerable TagsInUse => _tagsInUse; + public IEnumerable TagsRegistered => Classes.SelectMany(p => p.TemplateTags).DistinctBy(f => f.TagName); + public IEnumerable Warnings => errors.Concat(warnings); + public IEnumerable Errors => errors; + + private Delegate templateToString; + private readonly List warnings = new(); + private readonly List errors = new(); + private readonly IEnumerable Classes; + private readonly List _tagsInUse = new(); + + public const string ERROR_NULL_IS_INVALID = "Null template is invalid."; + public const string WARNING_EMPTY = "Template is empty."; + public const string WARNING_WHITE_SPACE = "Template is white space."; + public const string WARNING_NO_TAGS = "Should use tags. Eg: "; + + /// <summary> + /// Invoke the <see cref="NamingTemplate"/> to + /// </summary> + /// <param name="propertyClasses">Instances of the TClass used in <see cref="PropertyTagClass{TClass}"/> and <see cref="ConditionalTagClass{TClass}"/></param> + /// <returns></returns> + public TemplatePart Evaluate(params object[] propertyClasses) + { + //Match propertyClasses to the arguments required by templateToString.DynamicInvoke() + var delegateArgTypes = templateToString.GetType().GenericTypeArguments[..^1]; + + object[] args = new object[delegateArgTypes.Length]; + + for (int i = 0; i < delegateArgTypes.Length; i++) + args[i] = propertyClasses.First(o => o.GetType() == delegateArgTypes[i]); + + if (args.Any(a => a is null)) + throw new ArgumentException($"This instance of {nameof(NamingTemplate)} requires the following arguments: {string.Join(", ", delegateArgTypes.Select(t => t.Name).Distinct())}"); + + return ((TemplatePart)templateToString.DynamicInvoke(args)).FirstPart; + } + + /// <summary>Parse a template string to a <see cref="NamingTemplate"/></summary> + /// <param name="template">The template string to parse</param> + /// <param name="tagClasses">A collection of <see cref="ITagClass"/> with + /// properties registered to match to the <paramref name="template"/></param> + public static NamingTemplate Parse(string template, IEnumerable<TagClass> tagClasses) + { + var namingTemplate = new NamingTemplate(tagClasses); + try + { + BinaryNode intermediate = namingTemplate.IntermediateParse(template); + Expression evalTree = GetExpressionTree(intermediate); + + List<ParameterExpression> parameters = new(); + + foreach (var tagclass in tagClasses) + parameters.Add(tagclass.Parameter); + + namingTemplate.templateToString = Expression.Lambda(evalTree, parameters).Compile(); + } + catch(Exception ex) + { + namingTemplate.errors.Add(ex.Message); + } + return namingTemplate; + } + + private NamingTemplate(IEnumerable<TagClass> properties) + { + Classes = properties; + } + + /// <summary>Builds an <see cref="Expression"/> tree that will evaluate to a <see cref="TemplatePart"/></summary> + private static Expression GetExpressionTree(BinaryNode node) + { + if (node is null) return TemplatePart.Blank; + else if (node.IsValue) return node.Expression; + else if (node.IsConditional) return Expression.Condition(node.Expression, concatExpression(node), TemplatePart.Blank); + else return concatExpression(node); + + Expression concatExpression(BinaryNode node) + => TemplatePart.CreateConcatenation(GetExpressionTree(node.LeftChild), GetExpressionTree(node.RightChild)); + } + + /// <summary>Parse a template string into a <see cref="BinaryNode"/> tree</summary> + private BinaryNode IntermediateParse(string templateString) + { + if (templateString is null) + throw new NullReferenceException(ERROR_NULL_IS_INVALID); + else if (string.IsNullOrEmpty(templateString)) + warnings.Add(WARNING_EMPTY); + else if (string.IsNullOrWhiteSpace(templateString)) + warnings.Add(WARNING_WHITE_SPACE); + + TemplateText = templateString; + + BinaryNode currentNode = BinaryNode.CreateRoot(); + BinaryNode topNode = currentNode; + List<char> literalChars = new(); + + while (templateString.Length > 0) + { + if (StartsWith(Classes, templateString, out string exactPropertyName, out var propertyTag, out var valueExpression)) + { + checkAndAddLiterals(); + + if (propertyTag is IClosingPropertyTag) + currentNode = AddNewNode(currentNode, BinaryNode.CreateConditional(propertyTag.TemplateTag, valueExpression)); + else + { + currentNode = AddNewNode(currentNode, BinaryNode.CreateValue(propertyTag.TemplateTag, valueExpression)); + _tagsInUse.Add(propertyTag.TemplateTag); + } + + templateString = templateString[exactPropertyName.Length..]; + } + else if (StartsWithClosing(Classes, templateString, out exactPropertyName, out var closingPropertyTag)) + { + checkAndAddLiterals(); + + BinaryNode lastParenth = currentNode; + + while (lastParenth?.IsConditional is false) + lastParenth = lastParenth.Parent; + + if (lastParenth?.Parent is null) + { + warnings.Add($"Missing <{closingPropertyTag.TemplateTag.TagName}-> open conditional."); + break; + } + else if (lastParenth.Name != closingPropertyTag.TemplateTag.TagName) + { + warnings.Add($"Missing <-{lastParenth.Name}> closing conditional."); + break; + } + + currentNode = lastParenth.Parent; + templateString = templateString[exactPropertyName.Length..]; + } + else + { + //templateString does not start with a tag, so the first + //character is a literal and not part of a tag expression. + literalChars.Add(templateString[0]); + templateString = templateString[1..]; + } + } + checkAndAddLiterals(); + + //Check for any conditionals that haven't been closed + while (currentNode is not null) + { + if (currentNode.IsConditional) + warnings.Add($"Missing <-{currentNode.Name}> closing conditional."); + currentNode = currentNode.Parent; + } + + if (!_tagsInUse.Any()) + warnings.Add(WARNING_NO_TAGS); + + return topNode; + + void checkAndAddLiterals() + { + if (literalChars.Count != 0) + { + currentNode = AddNewNode(currentNode, BinaryNode.CreateValue(new string(literalChars.ToArray()))); + literalChars.Clear(); + } + } + } + + private static bool StartsWith(IEnumerable<TagClass> propertyClasses, string template, out string exactName, out IPropertyTag propertyTag, out Expression valueExpression) + { + foreach (var pc in propertyClasses) + { + if (pc.StartsWith(template, out exactName, out propertyTag, out valueExpression)) + return true; + } + exactName = null; + valueExpression = null; + propertyTag = null; + return false; + } + + private static bool StartsWithClosing(IEnumerable<TagClass> conditionalGroups, string template, out string exactName, out IClosingPropertyTag closingPropertyTag) + { + foreach (var pc in conditionalGroups) + { + if (pc.StartsWithClosing(template, out exactName, out closingPropertyTag)) + return true; + } + exactName = null; + closingPropertyTag = null; + return false; + } + + private static BinaryNode AddNewNode(BinaryNode currentNode, BinaryNode newNode) + { + if (currentNode.LeftChild is null) + { + newNode.Parent = currentNode; + currentNode.LeftChild = newNode; + } + else if (currentNode.RightChild is null) + { + newNode.Parent = currentNode; + currentNode.RightChild = newNode; + } + else + { + currentNode.RightChild = BinaryNode.CreateConcatenation(currentNode.RightChild, newNode); + currentNode.RightChild.Parent = currentNode; + currentNode = currentNode.RightChild; + } + + return newNode.IsConditional ? newNode : currentNode; + } + + private class BinaryNode + { + public string Name { get; } + public BinaryNode Parent { get; set; } + public BinaryNode RightChild { get; set; } + public BinaryNode LeftChild { get; set; } + public Expression Expression { get; private init; } + public bool IsConditional { get; private init; } = false; + public bool IsValue { get; private init; } = false; + + public static BinaryNode CreateRoot() => new("Root"); + + public static BinaryNode CreateValue(string literal) => new("Literal") + { + IsValue = true, + Expression = TemplatePart.CreateLiteral(literal) + }; + + public static BinaryNode CreateValue(ITemplateTag templateTag, Expression property) => new(templateTag.TagName) + { + IsValue = true, + Expression = TemplatePart.CreateProperty(templateTag, property) + }; + + public static BinaryNode CreateConditional(ITemplateTag templateTag, Expression property) => new(templateTag.TagName) + { + IsConditional = true, + Expression = property + }; + + public static BinaryNode CreateConcatenation(BinaryNode left, BinaryNode right) + { + var newNode = new BinaryNode("Concatenation") + { + LeftChild = left, + RightChild = right + }; + newNode.LeftChild.Parent = newNode; + newNode.RightChild.Parent = newNode; + return newNode; + } + + private BinaryNode(string name) => Name = name; + public override string ToString() => Name; + } +} diff --git a/Source/FileManager/NamingTemplate/PropertyTagClass[TClass].cs b/Source/FileManager/NamingTemplate/PropertyTagClass[TClass].cs new file mode 100644 index 00000000..1d53f7ae --- /dev/null +++ b/Source/FileManager/NamingTemplate/PropertyTagClass[TClass].cs @@ -0,0 +1,85 @@ +using System; +using System.Linq.Expressions; +using System.Reflection; +using System.Text.RegularExpressions; + +namespace FileManager.NamingTemplate; + +public delegate string PropertyFormatter<T>(ITemplateTag templateTag, T value, string formatString); + +public class PropertyTagClass<TClass> : TagClass +{ + public PropertyTagClass(bool caseSensative = true) : base(typeof(TClass), caseSensative) { } + + /// <summary> + /// Register a nullable value type property. + /// </summary> + /// <typeparam name="U">Type of the property from <see cref="TClass"/></typeparam> + /// <param name="propertyGetter">A Func to get the property value from <see cref="TClass"/></param> + /// <param name="formatter">Optional formatting function that accepts the <typeparamref name="U"/> property and a formatting string and returnes the value formatted to string</param> + public void RegisterProperty<U>(ITemplateTag templateTag, Func<TClass, U?> propertyGetter, PropertyFormatter<U> formatter = null) + where U : struct + => RegisterProperty(templateTag, propertyGetter, formatter?.Method); + + /// <summary> + /// Register a non-nullable value type property + /// </summary> + /// <typeparam name="U">Type of the property from <see cref="TClass"/></typeparam> + /// <param name="propertyGetter">A Func to get the property value from <see cref="TClass"/></param> + /// <param name="formatter">Optional formatting function that accepts the <typeparamref name="U"/> property and a formatting string and returnes the value formatted to string</param> + public void RegisterProperty<U>(ITemplateTag templateTag, Func<TClass, U> propertyGetter, PropertyFormatter<U> formatter = null) + where U : struct + => RegisterProperty(templateTag, propertyGetter, formatter?.Method); + + /// <summary> + /// Register a string type property. + /// </summary> + /// <param name="propertyGetter">A Func to get the string property from <see cref="TClass"/></param> + /// <param name="formatter">Optional formatting function that accepts the string property and a formatting string and returnes the value formatted to string</param> + public void RegisterProperty(ITemplateTag templateTag, Func<TClass, string> propertyGetter, PropertyFormatter<string> formatter = null) + => RegisterProperty(templateTag, propertyGetter, formatter?.Method); + + private void RegisterProperty(ITemplateTag templateTag, Delegate propertyGetter, MethodInfo formatter) + { + var expr = Expression.Call(Expression.Constant(propertyGetter.Target), propertyGetter.Method, Parameter); + + AddPropertyTag(new PropertyTag(templateTag, Options, expr, formatter)); + } + + private class PropertyTag : TagBase + { + private readonly Func<Expression, Type, string, Expression> createToStringExpression; + + public PropertyTag(ITemplateTag templateTag, RegexOptions options, Expression propertyExpression, MethodInfo formatter) + : base(templateTag, propertyExpression) + { + var regexStr = formatter is null ? @$"^<{TemplateTag.TagName}>" : @$"^<{TemplateTag.TagName.Replace(" ", "\\s*?")}\s*?(?:\[([^\[\]]*?)\]\s*?)?>"; + NameMatcher = new Regex(regexStr, options); + + //Create the ToString() expression for the TagBase.ExpressionValue's type. + //If a formatter delegate was registered for this property, use that. + //Otherwise use the object.Tostring() method. + createToStringExpression + = formatter is null + ? (expValue, retTyp, format) => Expression.Call(expValue, retTyp.GetMethod(nameof(object.ToString), Array.Empty<Type>())) + : (expValue, retTyp, format) => Expression.Call(null, formatter, Expression.Constant(templateTag), expValue, Expression.Constant(format)); + } + + protected override Expression GetTagExpression(string exactName, string formatString) + { + var underlyingType = Nullable.GetUnderlyingType(ReturnType); + + Expression toStringExpression + = ReturnType == typeof(string) + ? createToStringExpression(Expression.Coalesce(ExpressionValue, Expression.Constant("")), ReturnType, formatString) + : underlyingType is null + ? createToStringExpression(ExpressionValue, ReturnType, formatString) + : Expression.Condition( + Expression.PropertyOrField(ExpressionValue, "HasValue"), + createToStringExpression(Expression.PropertyOrField(ExpressionValue, "Value"), underlyingType, formatString), + Expression.Constant("")); + + return Expression.TryCatch(toStringExpression, Expression.Catch(typeof(Exception), Expression.Constant(exactName))); + } + } +} diff --git a/Source/FileManager/NamingTemplate/TagBase.cs b/Source/FileManager/NamingTemplate/TagBase.cs new file mode 100644 index 00000000..15c550d1 --- /dev/null +++ b/Source/FileManager/NamingTemplate/TagBase.cs @@ -0,0 +1,67 @@ +using System; +using System.Linq.Expressions; +using System.Text.RegularExpressions; + +namespace FileManager.NamingTemplate; + +internal interface IPropertyTag +{ + /// <summary>The tag that will be matched in a tag string</summary> + ITemplateTag TemplateTag { get; } + + /// <summary><see cref="TemplateTag"/>'s <see cref="Type"/></summary> + Type ReturnType { get; } + + /// <summary>The <see cref="Regex"/> used to match <see cref="TemplateTag"/> in template strings.</summary> + public Regex NameMatcher { get; } + + /// <summary> + /// Determine if the template string starts with <see cref="TemplateTag"/>, and if it does parse the tag to an <see cref="ITagExpression"/> + /// </summary> + /// <param name="templateString">Template string</param> + /// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param> + /// <param name="propertyValue">The <see cref="Expression"/> that returns the property's value</param> + /// <returns>True if the <paramref name="templateString"/> starts with this tag.</returns> + bool StartsWith(string templateString, out string exactName, out Expression propertyValue); +} + +internal abstract class TagBase : IPropertyTag +{ + public ITemplateTag TemplateTag { get; } + public Regex NameMatcher { get; protected init; } + public Type ReturnType => ExpressionValue.Type; + protected Expression ExpressionValue { get; } + + protected TagBase(ITemplateTag templateTag, Expression propertyExpression) + { + TemplateTag = templateTag; + ExpressionValue = propertyExpression; + } + + /// <summary>Create an <see cref="Expression"/> that returns the property's value.</summary> + /// <param name="exactName">The exact string that was matched to <see cref="ITemplateTag"/></param> + /// <param name="formatter">The optional format string in the match inside the square brackets</param> + protected abstract Expression GetTagExpression(string exactName, string formatter); + + public bool StartsWith(string templateString, out string exactName, out Expression propertyValue) + { + var match = NameMatcher.Match(templateString); + if (match.Success) + { + exactName = match.Value; + propertyValue = GetTagExpression(exactName, match.Groups.Count == 2 ? match.Groups[1].Value.Trim() : ""); + return true; + } + else + { + exactName = null; + propertyValue = null; + return false; + } + } + + public override string ToString() + { + return $"[Name = {TemplateTag.TagName}, Type = {ReturnType.Name}]"; + } +} diff --git a/Source/FileManager/NamingTemplate/TagClass.cs b/Source/FileManager/NamingTemplate/TagClass.cs new file mode 100644 index 00000000..1f044601 --- /dev/null +++ b/Source/FileManager/NamingTemplate/TagClass.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Text.RegularExpressions; + +namespace FileManager.NamingTemplate; + + +/// <summary>A collection of <see cref="IPropertyTag"/>s registered to a single <see cref="Type"/>.</summary> +public abstract class TagClass +{ + /// <summary>The <see cref="ParameterExpression"/> of the <see cref="TagClass"/>'s TClass type.</summary> + public ParameterExpression Parameter { get; } + /// <summary>The <see cref="ITemplateTag"/>s registered with this <see cref="TagClass"/> </summary> + public IEnumerable<ITemplateTag> TemplateTags => PropertyTags.Select(p => p.TemplateTag); + + protected RegexOptions Options { get; } = RegexOptions.Compiled; + private protected List<IPropertyTag> PropertyTags { get; } = new(); + + protected TagClass(Type classType, bool caseSensative = true) + { + Parameter = Expression.Parameter(classType, classType.Name); + Options |= caseSensative ? RegexOptions.None : RegexOptions.IgnoreCase; + } + + /// <summary> + /// Determine if the template string starts with any of the <see cref="TemplateTags"/>s' <see cref="ITemplateTag"/> signatures, + /// and if it does parse the tag to an <see cref="Expression"/> + /// </summary> + /// <param name="templateString">Template string</param> + /// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param> + /// <param name="propertyValue">The <see cref="Expression"/> that returns the <paramref name="propertyTag"/>'s value</param> + /// <returns>True if the <paramref name="templateString"/> starts with a tag registered in this class.</returns> + internal bool StartsWith(string templateString, out string exactName, out IPropertyTag propertyTag, out Expression propertyValue) + { + foreach (var p in PropertyTags) + { + if (p.StartsWith(templateString, out exactName, out propertyValue)) + { + propertyTag = p; + return true; + } + } + propertyValue = null; + propertyTag = null; + exactName = null; + return false; + } + + /// <summary> + /// Determine if the template string starts with <see cref="IPropertyTag.TemplateTag"/>'s closing tag signature, + /// and if it does output the matching tag's <see cref="ITemplateTag"/> + /// </summary> + /// <param name="templateString">Template string</param> + /// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param> + /// <param name="closingPropertyTag">The registered <see cref="IClosingPropertyTag"/></param> + /// <returns>True if the <paramref name="templateString"/> starts with this tag.</returns> + internal bool StartsWithClosing(string templateString, out string exactName, out IClosingPropertyTag closingPropertyTag) + { + foreach (var cg in PropertyTags.OfType<IClosingPropertyTag>()) + { + if (cg.StartsWithClosing(templateString, out exactName, out closingPropertyTag)) + return true; + } + + closingPropertyTag = null; + exactName = null; + return false; + } + + private protected void AddPropertyTag(IPropertyTag propertyTag) + { + if (!PropertyTags.Any(c => c.TemplateTag.TagName == propertyTag.TemplateTag.TagName)) + PropertyTags.Add(propertyTag); + } +} diff --git a/Source/FileManager/NamingTemplate/TemplatePart.cs b/Source/FileManager/NamingTemplate/TemplatePart.cs new file mode 100644 index 00000000..4ad6afab --- /dev/null +++ b/Source/FileManager/NamingTemplate/TemplatePart.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Reflection; + +namespace FileManager.NamingTemplate; + +/// <summary>Represents one part of an evaluated <see cref="NamingTemplate"/>.</summary> +public class TemplatePart : IEnumerable<TemplatePart> +{ + /// <summary>The <see cref="TemplatePart"/> name. If <see cref="TemplatePart"/> is + /// a registered property, this value is <see cref="ITemplateTag.TagName"/></summary> + public string TagName { get; } + + /// <summary> The <see cref="IPropertyTag"/>'s <see cref="ITemplateTag"/> if <see cref="TemplatePart"/> is + /// a registered property, otherwise <see cref="null"/> for string literals. </summary> + public ITemplateTag TemplateTag { get; } + + /// <summary>The evaluated string.</summary> + public string Value { get; set; } + + private TemplatePart previous; + private TemplatePart next; + private TemplatePart(string name, string value) + { + TagName = name; + Value = value; + } + private TemplatePart(ITemplateTag templateTag, string value) + { + TemplateTag = templateTag; + TagName = templateTag.TagName; + Value = value; + } + + internal static Expression Blank + => CreateExpression("Blank", Expression.Constant("")); + + internal static Expression CreateLiteral(string constant) + => CreateExpression("Literal", Expression.Constant(constant)); + + internal static Expression CreateProperty(ITemplateTag templateTag, Expression property) + => Expression.New(tagTemplateConstructorInfo, Expression.Constant(templateTag), property); + + internal static Expression CreateConcatenation(Expression left, Expression right) + { + if (left.Type != typeof(TemplatePart) || right.Type != typeof(TemplatePart)) + throw new InvalidOperationException($"Cannot concatenate expressions of types {left.Type.Name} and {right.Type.Name}"); + return Expression.Add(left, right, addMethodInfo); + } + + private static Expression CreateExpression(string name, Expression value) + => Expression.New(constructorInfo, Expression.Constant(name), value); + + private static readonly ConstructorInfo constructorInfo + = typeof(TemplatePart).GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, new Type[] { typeof(string), typeof(string) }); + + private static readonly ConstructorInfo tagTemplateConstructorInfo + = typeof(TemplatePart).GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, new Type[] { typeof(ITemplateTag), typeof(string) }); + + private static readonly MethodInfo addMethodInfo + = typeof(TemplatePart).GetMethod(nameof(Concatenate), BindingFlags.NonPublic | BindingFlags.Static, new Type[] { typeof(TemplatePart), typeof(TemplatePart) }); + + public IEnumerator<TemplatePart> GetEnumerator() + { + var firstPart = FirstPart; + + do + { + if (firstPart.TemplateTag is not null || firstPart.TagName is not "Blank") + yield return firstPart; + firstPart = firstPart.next; + } + while (firstPart is not null); + } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + internal TemplatePart FirstPart + { + get + { + var part = this; + while (part.previous is not null) + part = part.previous; + return part; + } + } + + private TemplatePart LastPart + { + get + { + var part = this; + while (part.next is not null) + part = part.next; + return part; + } + } + + private static TemplatePart Concatenate(TemplatePart left, TemplatePart right) + { + var last = left.LastPart; + last.next = right; + right.previous = last; + return left; + } + + public static TemplatePart operator +(TemplatePart left, TemplatePart right) + => Concatenate(left, right); +} diff --git a/Source/LibationAvalonia/Dialogs/EditTemplateDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/EditTemplateDialog.axaml.cs index 41e2450f..493a16eb 100644 --- a/Source/LibationAvalonia/Dialogs/EditTemplateDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/EditTemplateDialog.axaml.cs @@ -11,14 +11,12 @@ using ReactiveUI; using Avalonia.Controls.Documents; using Avalonia.Collections; using Avalonia.Controls; +using Avalonia.Markup.Xaml.Templates; namespace LibationAvalonia.Dialogs { public partial class EditTemplateDialog : DialogWindow { - // final value. post-validity check - public string TemplateText { get; private set; } - private EditTemplateViewModel _viewModel; public EditTemplateDialog() @@ -28,20 +26,21 @@ namespace LibationAvalonia.Dialogs if (Design.IsDesignMode) { _ = Configuration.Instance.LibationFiles; - _viewModel = new(Configuration.Instance, Templates.File); - _viewModel.resetTextBox(_viewModel.Template.DefaultTemplate); - Title = $"Edit {_viewModel.Template.Name}"; + var editor = TemplateEditor<Templates.FileTemplate>.CreateFilenameEditor(Configuration.Instance.Books, Configuration.Instance.FileTemplate); + _viewModel = new(Configuration.Instance, editor); + _viewModel.resetTextBox(editor.EditingTemplate.TemplateText); + Title = $"Edit {editor.EditingTemplate.Name}"; DataContext = _viewModel; } } - public EditTemplateDialog(Templates template, string inputTemplateText) : this() + public EditTemplateDialog(ITemplateEditor templateEditor) : this() { - ArgumentValidator.EnsureNotNull(template, nameof(template)); + ArgumentValidator.EnsureNotNull(templateEditor, nameof(templateEditor)); - _viewModel = new EditTemplateViewModel(Configuration.Instance, template); - _viewModel.resetTextBox(inputTemplateText); - Title = $"Edit {template.Name}"; + _viewModel = new EditTemplateViewModel(Configuration.Instance, templateEditor); + _viewModel.resetTextBox(templateEditor.EditingTemplate.TemplateText); + Title = $"Edit {templateEditor.EditingTemplate.Name}"; DataContext = _viewModel; } @@ -64,7 +63,6 @@ namespace LibationAvalonia.Dialogs if (!await _viewModel.Validate()) return; - TemplateText = _viewModel.workingTemplateText; await base.SaveAndCloseAsync(); } @@ -72,23 +70,25 @@ namespace LibationAvalonia.Dialogs => await SaveAndCloseAsync(); public void ResetButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) - => _viewModel.resetTextBox(_viewModel.Template.DefaultTemplate); + => _viewModel.resetTextBox(_viewModel.TemplateEditor.DefaultTemplate); private class EditTemplateViewModel : ViewModels.ViewModelBase { private readonly Configuration config; public FontFamily FontFamily { get; } = FontManager.Current.DefaultFontFamilyName; public InlineCollection Inlines { get; } = new(); - public Templates Template { get; } - public EditTemplateViewModel(Configuration configuration, Templates templates) + public ITemplateEditor TemplateEditor { get; } + public EditTemplateViewModel(Configuration configuration, ITemplateEditor templates) { config = configuration; - Template = templates; - Description = templates.Description; + TemplateEditor = templates; + Description = templates.EditingTemplate.Description; ListItems = new AvaloniaList<Tuple<string, string, string>>( - Template - .GetTemplateTags() + TemplateEditor + .EditingTemplate + .TagsRegistered + .Cast<TemplateTags>() .Select( t => new Tuple<string, string, string>( $"<{t.TagName.Replace("->", "-\x200C>").Replace("<-", "<\x200C-")}>", @@ -111,7 +111,6 @@ namespace LibationAvalonia.Dialogs } } - public string workingTemplateText => Template.Sanitize(UserTemplateText, Configuration.Instance.ReplacementCharacters); private string _warningText; public string WarningText { get => _warningText; set => this.RaiseAndSetIfChanged(ref _warningText, value); } @@ -123,78 +122,22 @@ namespace LibationAvalonia.Dialogs public async Task<bool> Validate() { - if (Template.IsValid(workingTemplateText)) + if (TemplateEditor.EditingTemplate.IsValid) return true; - var errors = Template - .GetErrors(workingTemplateText) - .Select(err => $"- {err}") - .Aggregate((a, b) => $"{a}\r\n{b}"); + + var errors + = TemplateEditor + .EditingTemplate + .Errors + .Select(err => $"- {err}") + .Aggregate((a, b) => $"{a}\r\n{b}"); await MessageBox.Show($"This template text is not valid. Errors:\r\n{errors}", "Invalid", MessageBoxButtons.OK, MessageBoxIcon.Error); return false; } private void templateTb_TextChanged() { - var isChapterTitle = Template == Templates.ChapterTitle; - var isFolder = Template == Templates.Folder; - - var libraryBookDto = new LibraryBookDto - { - Account = "my account", - DateAdded = new DateTime(2022, 6, 9, 0, 0, 0), - DatePublished = new DateTime(2017, 2, 27, 0, 0, 0), - AudibleProductId = "123456789", - Title = "A Study in Scarlet: A Sherlock Holmes Novel", - Locale = "us", - YearPublished = 2017, - Authors = new List<string> { "Arthur Conan Doyle", "Stephen Fry - introductions" }, - Narrators = new List<string> { "Stephen Fry" }, - SeriesName = "Sherlock Holmes", - SeriesNumber = "1", - BitRate = 128, - SampleRate = 44100, - Channels = 2, - Language = "English" - }; - var chapterName = "A Flight for Life"; - var chapterNumber = 4; - var chaptersTotal = 10; - - var partFileProperties = new AaxDecrypter.MultiConvertFileProperties() - { - OutputFileName = "", - PartsPosition = chapterNumber, - PartsTotal = chaptersTotal, - Title = chapterName - }; - - /* - * Path must be rooted for windows to allow long file paths. This is - * only necessary for folder templates because they may contain several - * subdirectories. Without rooting, we won't be allowed to create a - * relative path longer than MAX_PATH. - */ - - var books = config.Books; - var folder = Templates.Folder.GetPortionFilename( - libraryBookDto, - Path.Combine(books, isFolder ? workingTemplateText : config.FolderTemplate), ""); - - folder = Path.GetRelativePath(books, folder); - - var file - = Template == Templates.ChapterFile - ? Templates.ChapterFile.GetPortionFilename( - libraryBookDto, - workingTemplateText, - partFileProperties, - "") - : Templates.File.GetPortionFilename( - libraryBookDto, - isFolder ? config.FileTemplate : workingTemplateText, ""); - var ext = config.DecryptToLossy ? "mp3" : "m4b"; - - var chapterTitle = Templates.ChapterTitle.GetPortionTitle(libraryBookDto, workingTemplateText, partFileProperties); + TemplateEditor.SetTemplateText(UserTemplateText); const char ZERO_WIDTH_SPACE = '\u200B'; var sing = $"{Path.DirectorySeparatorChar}"; @@ -207,11 +150,12 @@ namespace LibationAvalonia.Dialogs string slashWrap(string val) => val.Replace(sing, $"{ZERO_WIDTH_SPACE}{sing}"); WarningText - = !Template.HasWarnings(workingTemplateText) + = !TemplateEditor.EditingTemplate.HasWarnings ? "" : "Warning:\r\n" + - Template - .GetWarnings(workingTemplateText) + TemplateEditor + .EditingTemplate + .Warnings .Select(err => $"- {err}") .Aggregate((a, b) => $"{a}\r\n{b}"); @@ -220,20 +164,24 @@ namespace LibationAvalonia.Dialogs Inlines.Clear(); - if (isChapterTitle) + if (!TemplateEditor.IsFilePath) { - Inlines.Add(new Run(chapterTitle) { FontWeight = bold }); + Inlines.Add(new Run(TemplateEditor.GetName()) { FontWeight = bold }); return; } - Inlines.Add(new Run(slashWrap(books)) { FontWeight = reg }); + var folder = TemplateEditor.GetFolderName(); + var file = TemplateEditor.GetFileName(); + var ext = config.DecryptToLossy ? "mp3" : "m4b"; + + Inlines.Add(new Run(slashWrap(TemplateEditor.BaseDirectory.PathWithoutPrefix)) { FontWeight = reg }); Inlines.Add(new Run(sing) { FontWeight = reg }); - Inlines.Add(new Run(slashWrap(folder)) { FontWeight = isFolder ? bold : reg }); + Inlines.Add(new Run(slashWrap(folder)) { FontWeight = TemplateEditor.IsFolder ? bold : reg }); Inlines.Add(new Run(sing)); - Inlines.Add(new Run(slashWrap(file)) { FontWeight = isFolder ? reg : bold }); + Inlines.Add(new Run(slashWrap(file)) { FontWeight = TemplateEditor.IsFolder ? reg : bold }); Inlines.Add(new Run($".{ext}")); } diff --git a/Source/LibationAvalonia/Dialogs/SettingsDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/SettingsDialog.axaml.cs index 48edebec..f3c721de 100644 --- a/Source/LibationAvalonia/Dialogs/SettingsDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/SettingsDialog.axaml.cs @@ -52,21 +52,22 @@ namespace LibationAvalonia.Dialogs public async void EditFolderTemplateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) { - var newTemplate = await editTemplate(Templates.Folder, settingsDisp.DownloadDecryptSettings.FolderTemplate); + var newTemplate = await editTemplate(TemplateEditor<Templates.FolderTemplate>.CreateFilenameEditor(config.Books, settingsDisp.DownloadDecryptSettings.FolderTemplate)); if (newTemplate is not null) settingsDisp.DownloadDecryptSettings.FolderTemplate = newTemplate; } public async void EditFileTemplateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) - { - var newTemplate = await editTemplate(Templates.File, settingsDisp.DownloadDecryptSettings.FileTemplate); + { + var newTemplate = await editTemplate(TemplateEditor<Templates.FileTemplate>.CreateFilenameEditor(config.Books, settingsDisp.DownloadDecryptSettings.FileTemplate)); if (newTemplate is not null) settingsDisp.DownloadDecryptSettings.FileTemplate = newTemplate; } public async void EditChapterFileTemplateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) { - var newTemplate = await editTemplate(Templates.ChapterFile, settingsDisp.DownloadDecryptSettings.ChapterFileTemplate); + + var newTemplate = await editTemplate(TemplateEditor<Templates.ChapterFileTemplate>.CreateFilenameEditor(config.Books, settingsDisp.DownloadDecryptSettings.ChapterFileTemplate)); if (newTemplate is not null) settingsDisp.DownloadDecryptSettings.ChapterFileTemplate = newTemplate; } @@ -79,16 +80,16 @@ namespace LibationAvalonia.Dialogs public async void EditChapterTitleTemplateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) { - var newTemplate = await editTemplate(Templates.ChapterTitle, settingsDisp.AudioSettings.ChapterTitleTemplate); + var newTemplate = await editTemplate(TemplateEditor<Templates.ChapterTitleTemplate>.CreateNameEditor(settingsDisp.AudioSettings.ChapterTitleTemplate)); if (newTemplate is not null) settingsDisp.AudioSettings.ChapterTitleTemplate = newTemplate; } - private async Task<string> editTemplate(Templates template, string existingTemplate) + private async Task<string> editTemplate(ITemplateEditor template) { - var form = new EditTemplateDialog(template, existingTemplate); + var form = new EditTemplateDialog(template); if (await form.ShowDialog<DialogResult>(this) == DialogResult.OK) - return form.TemplateText; + return template.EditingTemplate.TemplateText; else return null; } } @@ -266,28 +267,8 @@ namespace LibationAvalonia.Dialogs UseCoverAsFolderIcon = config.UseCoverAsFolderIcon; } - public async Task<bool> SaveSettingsAsync(Configuration config) + public Task<bool> SaveSettingsAsync(Configuration config) { - static Task validationError(string text, string caption) - => MessageBox.Show(text, caption, MessageBoxButtons.OK, MessageBoxIcon.Error); - - // these 3 should do nothing. Configuration will only init these with a valid value. EditTemplateDialog ensures valid before returning - if (!Templates.Folder.IsValid(FolderTemplate)) - { - await validationError($"Not saving change to folder naming template. Invalid format.", "Invalid folder template"); - return false; - } - if (!Templates.File.IsValid(FileTemplate)) - { - await validationError($"Not saving change to file naming template. Invalid format.", "Invalid file template"); - return false; - } - if (!Templates.ChapterFile.IsValid(ChapterFileTemplate)) - { - await validationError($"Not saving change to chapter file naming template. Invalid format.", "Invalid chapter file template"); - return false; - } - config.BadBook = BadBookAbort ? Configuration.BadBookAction.Abort : BadBookRetry ? Configuration.BadBookAction.Retry @@ -301,7 +282,7 @@ namespace LibationAvalonia.Dialogs config.UseCoverAsFolderIcon = UseCoverAsFolderIcon; - return true; + return Task.FromResult(true); } public string UseCoverAsFolderIconText { get; } = Configuration.GetDescription(nameof(Configuration.UseCoverAsFolderIcon)); diff --git a/Source/LibationFileManager/Configuration.PersistentSettings.cs b/Source/LibationFileManager/Configuration.PersistentSettings.cs index e5745713..152ebe5f 100644 --- a/Source/LibationFileManager/Configuration.PersistentSettings.cs +++ b/Source/LibationFileManager/Configuration.PersistentSettings.cs @@ -80,7 +80,7 @@ namespace LibationFileManager public bool BetaOptIn { get => GetNonString(defaultValue: false); set => SetNonString(value); } [Description("Location for book storage. Includes destination of newly liberated books")] - public string Books { get => GetString(); set => SetString(value); } + public LongPath Books { get => GetString(); set => SetString(value); } // temp/working dir(s) should be outside of dropbox [Description("Temporary location of files while they're in process of being downloaded and decrypted.\r\nWhen decryption is complete, the final file will be in Books location\r\nRecommend not using a folder which is backed up real time. Eg: Dropbox, iCloud, Google Drive")] @@ -223,36 +223,41 @@ namespace LibationFileManager [Description("How to format the folders in which files will be saved")] public string FolderTemplate { - get => Templates.Folder.GetValid(GetString(defaultValue: Templates.Folder.DefaultTemplate)); - set => setTemplate(Templates.Folder, value); + get => getTemplate<Templates.FolderTemplate>(); + set => setTemplate<Templates.FolderTemplate>(value); } [Description("How to format the saved pdf and audio files")] public string FileTemplate { - get => Templates.File.GetValid(GetString(defaultValue: Templates.File.DefaultTemplate)); - set => setTemplate(Templates.File, value); + get => getTemplate<Templates.FileTemplate>(); + set => setTemplate<Templates.FileTemplate>(value); } [Description("How to format the saved audio files when split by chapters")] public string ChapterFileTemplate { - get => Templates.ChapterFile.GetValid(GetString(defaultValue: Templates.ChapterFile.DefaultTemplate)); - set => setTemplate(Templates.ChapterFile, value); + get => getTemplate<Templates.ChapterFileTemplate>(); + set => setTemplate<Templates.ChapterFileTemplate>(value); } [Description("How to format the file's Tile stored in metadata")] public string ChapterTitleTemplate { - get => Templates.ChapterTitle.GetValid(GetString(defaultValue: Templates.ChapterTitle.DefaultTemplate)); - set => setTemplate(Templates.ChapterTitle, value); + get => getTemplate<Templates.ChapterTitleTemplate>(); + set => setTemplate<Templates.ChapterTitleTemplate>(value); } - private void setTemplate(Templates templ, string newValue, [CallerMemberName] string propertyName = "") + private string getTemplate<T>([CallerMemberName] string propertyName = "") + where T : Templates, ITemplate, new() { - var template = newValue?.Trim(); - if (templ.IsValid(template)) - SetString(template, propertyName); + return Templates.GetTemplate<T>(GetString(defaultValue: T.DefaultTemplate, propertyName)).TemplateText; + } + + private void setTemplate<T>(string newValue, [CallerMemberName] string propertyName = "") + where T : Templates, ITemplate, new() + { + SetString(Templates.GetTemplate<T>(newValue).TemplateText, propertyName); } #endregion } diff --git a/Source/LibationFileManager/LibraryBookDto.cs b/Source/LibationFileManager/LibraryBookDto.cs index e46cc562..60114040 100644 --- a/Source/LibationFileManager/LibraryBookDto.cs +++ b/Source/LibationFileManager/LibraryBookDto.cs @@ -21,6 +21,7 @@ namespace LibationFileManager public string SeriesName { get; set; } public string SeriesNumber { get; set; } + public bool IsPodcast { get; set; } public int BitRate { get; set; } public int SampleRate { get; set; } diff --git a/Source/LibationFileManager/TemplateEditor[T].cs b/Source/LibationFileManager/TemplateEditor[T].cs new file mode 100644 index 00000000..d20663b9 --- /dev/null +++ b/Source/LibationFileManager/TemplateEditor[T].cs @@ -0,0 +1,130 @@ +using AaxDecrypter; +using FileManager; +using System.Collections.Generic; +using System; +using System.IO; + +namespace LibationFileManager +{ + public interface ITemplateEditor + { + bool IsFolder { get; } + bool IsFilePath { get; } + LongPath BaseDirectory { get; } + string DefaultTemplate { get; } + Templates Folder { get; } + Templates File { get; } + Templates Name { get; } + Templates EditingTemplate { get; } + void SetTemplateText(string templateText); + string GetFolderName(); + string GetFileName(); + string GetName(); + } + + public class TemplateEditor<T> : ITemplateEditor where T : Templates, ITemplate, new() + { + public bool IsFolder => EditingTemplate is Templates.FolderTemplate; + public bool IsFilePath => EditingTemplate is not Templates.ChapterTitleTemplate; + public LongPath BaseDirectory { get; private init; } + public string DefaultTemplate { get; private init; } + public Templates Folder { get; private set; } + public Templates File { get; private set; } + public Templates Name { get; private set; } + public Templates EditingTemplate + { + get => _editingTemplate; + private set => _editingTemplate = !IsFilePath ? Name = value : IsFolder ? Folder = value : File = value; + } + + private Templates _editingTemplate; + + public void SetTemplateText(string templateText) + { + Templates.TryGetTemplate<T>(templateText, out var template); + EditingTemplate = template; + } + + private static readonly LibraryBookDto libraryBookDto + = new() + { + Account = "my account", + DateAdded = new DateTime(2022, 6, 9, 0, 0, 0), + DatePublished = new DateTime(2017, 2, 27, 0, 0, 0), + AudibleProductId = "123456789", + Title = "A Study in Scarlet: A Sherlock Holmes Novel", + Locale = "us", + YearPublished = 2017, + Authors = new List<string> { "Arthur Conan Doyle", "Stephen Fry - introductions" }, + Narrators = new List<string> { "Stephen Fry" }, + SeriesName = "Sherlock Holmes", + SeriesNumber = "1", + BitRate = 128, + SampleRate = 44100, + Channels = 2, + Language = "English" + }; + + private static readonly MultiConvertFileProperties partFileProperties + = new() + { + OutputFileName = "", + PartsPosition = 4, + PartsTotal = 10, + Title = "A Flight for Life" + }; + + public string GetFolderName() + { + /* + * Path must be rooted for windows to allow long file paths. This is + * only necessary for folder templates because they may contain several + * subdirectories. Without rooting, we won't be allowed to create a + * relative path longer than MAX_PATH. + */ + var dir = Folder.GetFilename(libraryBookDto, BaseDirectory, ""); + return Path.GetRelativePath(BaseDirectory, dir); + } + + public string GetFileName() + => File.GetFilename(libraryBookDto, partFileProperties, "", ""); + public string GetName() + => Name.GetName(libraryBookDto, partFileProperties); + + public static ITemplateEditor CreateFilenameEditor(LongPath baseDir, string templateText) + { + Templates.TryGetTemplate<T>(templateText, out var template); + + var templateEditor = new TemplateEditor<T> + { + _editingTemplate = template, + BaseDirectory = baseDir, + DefaultTemplate = T.DefaultTemplate + }; + + if (!templateEditor.IsFolder && !templateEditor.IsFilePath) + throw new InvalidOperationException($"This method is only for File and Folder templates. Use {nameof(CreateNameEditor)} for name templates"); + + templateEditor.Folder = templateEditor.IsFolder ? template : Templates.Folder; + templateEditor.File = templateEditor.IsFolder ? Templates.File : template; + + return templateEditor; + } + + public static ITemplateEditor CreateNameEditor(string templateText) + { + Templates.TryGetTemplate<T>(templateText, out var nameTemplate); + + var templateEditor = new TemplateEditor<T> + { + _editingTemplate = nameTemplate, + DefaultTemplate = T.DefaultTemplate + }; + + if (templateEditor.IsFolder || templateEditor.IsFilePath) + throw new InvalidOperationException($"This method is only for name templates. Use {nameof(CreateFilenameEditor)} for file templates"); + + return templateEditor; + } + } +} diff --git a/Source/LibationFileManager/TemplateTags.cs b/Source/LibationFileManager/TemplateTags.cs index 469924a1..879363a0 100644 --- a/Source/LibationFileManager/TemplateTags.cs +++ b/Source/LibationFileManager/TemplateTags.cs @@ -1,31 +1,27 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Dinah.Core; +using FileManager.NamingTemplate; namespace LibationFileManager { - public sealed class TemplateTags : Enumeration<TemplateTags> - { - public string TagName => DisplayName; - public string DefaultValue { get; } + public sealed class TemplateTags : ITemplateTag + { + public const string DEFAULT_DATE_FORMAT = "yyyy-MM-dd"; + public string TagName { get; } + public string DefaultValue { get; } public string Description { get; } - public bool IsChapterOnly { get; } - - private static int value = 0; - private TemplateTags(string tagName, string description, bool isChapterOnly = false, string defaultValue = null) : base(value++, tagName) - { - Description = description; - IsChapterOnly = isChapterOnly; - DefaultValue = defaultValue ?? $"<{tagName}>"; + public string Display { get; } + private TemplateTags(string tagName, string description, string defaultValue = null, string display = null) + { + TagName = tagName; + Description = description; + DefaultValue = defaultValue ?? $"<{tagName}>"; + Display = display ?? $"<{tagName}>"; } - // putting these first is the incredibly lazy way to make them show up first in the EditTemplateDialog - public static TemplateTags ChCount { get; } = new TemplateTags("ch count", "Number of chapters", true); - public static TemplateTags ChTitle { get; } = new TemplateTags("ch title", "Chapter title", true); - public static TemplateTags ChNumber { get; } = new TemplateTags("ch#", "Chapter #", true); - public static TemplateTags ChNumber0 { get; } = new TemplateTags("ch# 0", "Chapter # with leading zeros", true); + public static TemplateTags ChCount { get; } = new TemplateTags("ch count", "Number of chapters"); + public static TemplateTags ChTitle { get; } = new TemplateTags("ch title", "Chapter title"); + public static TemplateTags ChNumber { get; } = new TemplateTags("ch#", "Chapter #"); + public static TemplateTags ChNumber0 { get; } = new TemplateTags("ch# 0", "Chapter # with leading zeros"); public static TemplateTags Id { get; } = new TemplateTags("id", "Audible ID"); public static TemplateTags Title { get; } = new TemplateTags("title", "Full title"); @@ -41,16 +37,15 @@ namespace LibationFileManager public static TemplateTags SampleRate { get; } = new TemplateTags("samplerate", "File's orig. sample rate"); public static TemplateTags Channels { get; } = new TemplateTags("channels", "Number of audio channels"); public static TemplateTags Account { get; } = new TemplateTags("account", "Audible account of this book"); - public static TemplateTags Locale { get; } = new("locale", "Region/country"); + public static TemplateTags Locale { get; } = new ("locale", "Region/country"); public static TemplateTags YearPublished { get; } = new("year", "Year published"); - public static TemplateTags Language { get; } = new("language", "Book's language"); - public static TemplateTags LanguageShort { get; } = new("language short", "Book's language abbreviated. Eg: ENG"); + public static TemplateTags Language { get; } = new("language", "Book's language"); + public static TemplateTags LanguageShort { get; } = new("language short", "Book's language abbreviated. Eg: ENG"); - // Special cases. Aren't mapped to replacements in Templates.cs - // Included here for display by EditTemplateDialog - public static TemplateTags FileDate { get; } = new TemplateTags("file date [...]", "File date/time. e.g. yyyy-MM-dd HH-mm", false, $"<file date [{Templates.DEFAULT_DATE_FORMAT}]>"); - public static TemplateTags DatePublished { get; } = new TemplateTags("pub date [...]", "Publication date. e.g. yyyy-MM-dd", false, $"<pub date [{Templates.DEFAULT_DATE_FORMAT}]>"); - public static TemplateTags DateAdded { get; } = new TemplateTags("date added [...]", "Date added to your Audible account. e.g. yyyy-MM-dd", false, $"<date added [{Templates.DEFAULT_DATE_FORMAT}]>"); - public static TemplateTags IfSeries { get; } = new TemplateTags("if series->...<-if series", "Only include if part of a series", false, "<if series-><-if series>"); + public static TemplateTags FileDate { get; } = new TemplateTags("file date", "File date/time. e.g. yyyy-MM-dd HH-mm", $"<file date [{DEFAULT_DATE_FORMAT}]>", "<file date [...]>"); + public static TemplateTags DatePublished { get; } = new TemplateTags("pub date", "Publication date. e.g. yyyy-MM-dd", $"<pub date [{DEFAULT_DATE_FORMAT}]>", "<pub date [...]>"); + public static TemplateTags DateAdded { get; } = new TemplateTags("date added", "Date added to your Audible account. e.g. yyyy-MM-dd", $"<date added [{DEFAULT_DATE_FORMAT}]>", "<date added [...]>"); + public static TemplateTags IfSeries { get; } = new TemplateTags("if series", "Only include if part of a series", "<if series-><-if series>", "<if series->...<-if series>"); + public static TemplateTags IfPodcast { get; } = new TemplateTags("if podcast", "Only include if part of a podcast", "<if podcast-><-if podcast>", "<if podcast->...<-if podcast>"); } } diff --git a/Source/LibationFileManager/Templates.cs b/Source/LibationFileManager/Templates.cs index cdaf3a44..a0f8afbc 100644 --- a/Source/LibationFileManager/Templates.cs +++ b/Source/LibationFileManager/Templates.cs @@ -2,115 +2,253 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Text.RegularExpressions; +using AaxDecrypter; using Dinah.Core; -using Dinah.Core.Collections.Generic; using FileManager; +using FileManager.NamingTemplate; namespace LibationFileManager { + public interface ITemplate + { + static abstract string DefaultTemplate { get; } + static abstract IEnumerable<TagClass> TagClass { get; } + } + public abstract class Templates { - protected static string[] Valid => Array.Empty<string>(); - public const string ERROR_NULL_IS_INVALID = "Null template is invalid."; public const string ERROR_FULL_PATH_IS_INVALID = @"No colons or full paths allowed. Eg: should not start with C:\"; - public const string ERROR_INVALID_FILE_NAME_CHAR = @"Only file name friendly characters allowed. Eg: no colons or slashes"; - - public const string WARNING_EMPTY = "Template is empty."; - public const string WARNING_WHITE_SPACE = "Template is white space."; - public const string WARNING_NO_TAGS = "Should use tags. Eg: <title>"; - public const string WARNING_HAS_CHAPTER_TAGS = "Chapter tags should only be used in the template used for naming files which are split by chapter. Eg: <ch title>"; 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: <ch#> or <ch# 0>"; - public static FolderTemplate Folder { get; } = new FolderTemplate(); - public static FileTemplate File { get; } = new FileTemplate(); - public static ChapterFileTemplate ChapterFile { get; } = new ChapterFileTemplate(); - public static ChapterTitleTemplate ChapterTitle { get; } = new ChapterTitleTemplate(); + //Assign 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<FolderTemplate>(Configuration.Instance.FolderTemplate); + public static FileTemplate File => _file ??= GetTemplate<FileTemplate>(Configuration.Instance.FileTemplate); + public static ChapterFileTemplate ChapterFile => _chapterFile ??= GetTemplate<ChapterFileTemplate>(Configuration.Instance.ChapterFileTemplate); + public static ChapterTitleTemplate ChapterTitle => _chapterTitle ??= GetTemplate<ChapterTitleTemplate>(Configuration.Instance.ChapterTitleTemplate); + + #region Template Parsing + public static T GetTemplate<T>(string templateText) where T : Templates, ITemplate, new() + => TryGetTemplate<T>(templateText, out var template) ? template : GetDefaultTemplate<T>(); + + public static bool TryGetTemplate<T>(string templateText, out T template) where T : Templates, ITemplate, new() + { + var namingTemplate = NamingTemplate.Parse(templateText, T.TagClass); + + template = new() { Template = namingTemplate }; + return !namingTemplate.Errors.Any(); + } + + private static T GetDefaultTemplate<T>() where T : Templates, ITemplate, new() + => new() { Template = NamingTemplate.Parse(T.DefaultTemplate, T.TagClass) }; + + static Templates() + { + Configuration.Instance.PropertyChanged += FolderTemplate_PropertyChanged; + Configuration.Instance.PropertyChanged += FileTemplate_PropertyChanged; + Configuration.Instance.PropertyChanged += ChapterFileTemplate_PropertyChanged; + Configuration.Instance.PropertyChanged += ChapterTitleTemplate_PropertyChanged; + } + + [PropertyChangeFilter(nameof(Configuration.FolderTemplate))] + private static void FolderTemplate_PropertyChanged(object sender, PropertyChangedEventArgsEx e) + { + _folder = GetTemplate<FolderTemplate>((string)e.NewValue); + } + [PropertyChangeFilter(nameof(Configuration.FileTemplate))] + private static void FileTemplate_PropertyChanged(object sender, PropertyChangedEventArgsEx e) + { + _file = GetTemplate<FileTemplate>((string)e.NewValue); + } + [PropertyChangeFilter(nameof(Configuration.ChapterFileTemplate))] + private static void ChapterFileTemplate_PropertyChanged(object sender, PropertyChangedEventArgsEx e) + { + _chapterFile = GetTemplate<ChapterFileTemplate>((string)e.NewValue); + } + [PropertyChangeFilter(nameof(Configuration.ChapterTitleTemplate))] + private static void ChapterTitleTemplate_PropertyChanged(object sender, PropertyChangedEventArgsEx e) + { + _chapterTitle = GetTemplate<ChapterTitleTemplate>((string)e.NewValue); + } + + #endregion + + #region Template Properties + public IEnumerable<TemplateTags> TagsRegistered => Template.TagsRegistered.Cast<TemplateTags>(); + public IEnumerable<TemplateTags> TagsInUse => Template.TagsInUse.Cast<TemplateTags>(); public abstract string Name { get; } public abstract string Description { get; } - public abstract string DefaultTemplate { get; } - protected abstract bool IsChapterized { get; } + public string TemplateText => Template.TemplateText; + protected NamingTemplate Template { get; private set; } - protected Templates() { } + #endregion #region validation - internal string GetValid(string configValue) - { - var value = configValue?.Trim(); - return IsValid(value) ? value : DefaultTemplate; - } - public abstract IEnumerable<string> GetErrors(string template); - public bool IsValid(string template) => !GetErrors(template).Any(); + public virtual IEnumerable<string> Errors => Template.Errors; + public bool IsValid => !Errors.Any(); - public abstract IEnumerable<string> GetWarnings(string template); - public bool HasWarnings(string template) => GetWarnings(template).Any(); + public virtual IEnumerable<string> Warnings => Template.Warnings; + public bool HasWarnings => Warnings.Any(); - protected static string[] GetFileErrors(string template) - { - // File name only; not path. all other path chars are valid enough to pass this check and will be handled on final save. - - // null is invalid. whitespace is valid but not recommended - if (template is null) - return new[] { ERROR_NULL_IS_INVALID }; - - if (ReplacementCharacters.ContainsInvalidFilenameChar(template.Replace("<","").Replace(">",""))) - return new[] { ERROR_INVALID_FILE_NAME_CHAR }; - - return Valid; - } - - protected IEnumerable<string> GetStandardWarnings(string template) - { - var warnings = GetErrors(template).ToList(); - if (template is null) - return warnings; - - if (string.IsNullOrEmpty(template)) - warnings.Add(WARNING_EMPTY); - else if (string.IsNullOrWhiteSpace(template)) - warnings.Add(WARNING_WHITE_SPACE); - - if (TagCount(template) == 0) - warnings.Add(WARNING_NO_TAGS); - - if (!IsChapterized && ContainsChapterOnlyTags(template)) - warnings.Add(WARNING_HAS_CHAPTER_TAGS); - - return warnings; - } - - internal int TagCount(string template) - => GetTemplateTags() - // for <id><id> == 1, use: - // .Count(t => template.Contains($"<{t.TagName}>")) - // .Sum() impl: <id><id> == 2 - .Sum(t => template.Split($"<{t.TagName}>").Length - 1); - - internal static bool ContainsChapterOnlyTags(string template) - => TemplateTags.GetAll() - .Where(t => t.IsChapterOnly) - .Any(t => ContainsTag(template, t.TagName)); - - internal static bool ContainsTag(string template, string tag) => template.Contains($"<{tag}>"); #endregion #region to file name - /// <summary> - /// EditTemplateDialog: Get template generated filename for portion of path - /// </summary> - public string GetPortionFilename(LibraryBookDto libraryBookDto, string template, string fileExtension) - => string.IsNullOrWhiteSpace(template) - ? "" - : getFileNamingTemplate(libraryBookDto, template, null, fileExtension, Configuration.Instance.ReplacementCharacters) - .GetFilePath(fileExtension).PathWithoutPrefix; - public const string DEFAULT_DATE_FORMAT = "yyyy-MM-dd"; - private static Regex fileDateTagRegex { get; } = new Regex(@"<file\s*?date\s*?(?:\[([^\[\]]*?)\]\s*?)?>", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static Regex dateAddedTagRegex { get; } = new Regex(@"<date\s*?added\s*?(?:\[([^\[\]]*?)\]\s*?)?>", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static Regex datePublishedTagRegex { get; } = new Regex(@"<pub\s*?date\s*?(?:\[([^\[\]]*?)\]\s*?)?>", RegexOptions.Compiled | RegexOptions.IgnoreCase); - private static Regex ifSeriesRegex { get; } = new Regex("<if series->(.*?)<-if series>", RegexOptions.Compiled | RegexOptions.IgnoreCase); + public string GetName(LibraryBookDto libraryBookDto, MultiConvertFileProperties multiChapProps) + { + ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto)); + ArgumentValidator.EnsureNotNull(multiChapProps, nameof(multiChapProps)); + return string.Join("", Template.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, returnFirstExisting, replacements, libraryBookDto); + } + + public LongPath GetFilename(LibraryBookDto libraryBookDto, MultiConvertFileProperties multiChapProps, string baseDir = "", string fileExtension = null, ReplacementCharacters replacements = null) + { + ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto)); + ArgumentValidator.EnsureNotNull(multiChapProps, nameof(multiChapProps)); + ArgumentValidator.EnsureNotNull(baseDir, nameof(baseDir)); + + replacements ??= Configuration.Instance.ReplacementCharacters; + fileExtension ??= Path.GetExtension(multiChapProps.OutputFileName); + return GetFilename(baseDir, fileExtension, false, replacements, libraryBookDto, multiChapProps); + } + + protected virtual IEnumerable<string> GetTemplatePartsStrings(List<TemplatePart> parts, ReplacementCharacters replacements) + => parts.Select(p => replacements.ReplaceFilenameChars(p.Value)); + + private LongPath GetFilename(string baseDir, string fileExtension, bool returnFirstExisting, ReplacementCharacters replacements, params object[] dtos) + { + var parts = Template.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 + foreach (var part in pathParts) + { + while (part.Sum(LongPath.GetFilesystemStringLength) > LongPath.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(p => string.Join("", p)).Prepend(baseDir).ToArray()); + + return FileUtility.GetValidFilename(fullPath, replacements, fileExtension, returnFirstExisting); + } + + /// <summary> + /// Organize template parts into directories. + /// </summary> + /// <returns>A List of template directories. Each directory is a list of template part strings</returns> + private List<List<string>> GetPathParts(IEnumerable<string> templateParts) + { + List<List<string>> directories = new(); + List<string> dir = new(); + + foreach (var part in templateParts) + { + int slashIndex, lastIndex = 0; + while((slashIndex = part.IndexOf(Path.DirectorySeparatorChar, lastIndex)) > -1) + { + dir.Add(part[lastIndex..slashIndex]); + directories.Add(dir); + dir = new(); + + lastIndex = slashIndex + 1; + } + dir.Add(part[lastIndex..]); + } + directories.Add(dir); + + return directories; + } + + #endregion + + #region Registered Template Properties + + private static readonly PropertyTagClass<LibraryBookDto> filePropertyTags = GetFilePropertyTags(); + private static readonly ConditionalTagClass<LibraryBookDto> conditionalTags = GetConditionalTags(); + private static readonly List<TagClass> chapterPropertyTags = GetChapterPropertyTags(); + + private static ConditionalTagClass<LibraryBookDto> GetConditionalTags() + { + ConditionalTagClass<LibraryBookDto> lbConditions = new(); + + lbConditions.RegisterCondition(TemplateTags.IfSeries, lb => !string.IsNullOrWhiteSpace(lb.SeriesName)); + lbConditions.RegisterCondition(TemplateTags.IfPodcast, lb => lb.IsPodcast); + + return lbConditions; + } + + private static PropertyTagClass<LibraryBookDto> GetFilePropertyTags() + { + PropertyTagClass<LibraryBookDto> lbProperties = new(); + lbProperties.RegisterProperty(TemplateTags.Id, lb => lb.AudibleProductId); + lbProperties.RegisterProperty(TemplateTags.Title, lb => lb.Title ?? "", StringFormatter); + lbProperties.RegisterProperty(TemplateTags.TitleShort, lb => lb.Title.IndexOf(':') < 1 ? lb.Title : lb.Title.Substring(0, lb.Title.IndexOf(':')), StringFormatter); + lbProperties.RegisterProperty(TemplateTags.Author, lb => lb.AuthorNames, StringFormatter); + lbProperties.RegisterProperty(TemplateTags.FirstAuthor, lb => lb.FirstAuthor, StringFormatter); + lbProperties.RegisterProperty(TemplateTags.Narrator, lb => lb.NarratorNames, StringFormatter); + lbProperties.RegisterProperty(TemplateTags.FirstNarrator, lb => lb.FirstNarrator, StringFormatter); + lbProperties.RegisterProperty(TemplateTags.Series, lb => lb.SeriesName ?? "", StringFormatter); + lbProperties.RegisterProperty(TemplateTags.SeriesNumber, lb => lb.SeriesNumber); + lbProperties.RegisterProperty(TemplateTags.Language, lb => lb.Language); + lbProperties.RegisterProperty(TemplateTags.LanguageShort, lb => getLanguageShort(lb.Language)); + lbProperties.RegisterProperty(TemplateTags.Bitrate, lb => lb.BitRate, IntegerFormatter); + lbProperties.RegisterProperty(TemplateTags.SampleRate, lb => lb.SampleRate, IntegerFormatter); + lbProperties.RegisterProperty(TemplateTags.Channels, lb => lb.Channels, IntegerFormatter); + lbProperties.RegisterProperty(TemplateTags.Account, lb => lb.Account, StringFormatter); + lbProperties.RegisterProperty(TemplateTags.Locale, lb => lb.Locale, StringFormatter); + lbProperties.RegisterProperty(TemplateTags.YearPublished, lb => lb.YearPublished, IntegerFormatter); + lbProperties.RegisterProperty(TemplateTags.DatePublished, lb => lb.DatePublished, DateTimeFormatter); + lbProperties.RegisterProperty(TemplateTags.DateAdded, lb => lb.DateAdded, DateTimeFormatter); + lbProperties.RegisterProperty(TemplateTags.FileDate, lb => lb.FileDate, DateTimeFormatter); + return lbProperties; + } + + private static List<TagClass> GetChapterPropertyTags() + { + PropertyTagClass<LibraryBookDto> lbProperties = new(); + PropertyTagClass<MultiConvertFileProperties> multiConvertProperties = new(); + + lbProperties.RegisterProperty(TemplateTags.Title, lb => lb.Title ?? ""); + lbProperties.RegisterProperty(TemplateTags.TitleShort, lb => lb.Title.IndexOf(':') < 1 ? lb.Title : lb.Title.Substring(0, lb.Title.IndexOf(':'))); + lbProperties.RegisterProperty(TemplateTags.Series, lb => lb.SeriesName ?? ""); + + multiConvertProperties.RegisterProperty(TemplateTags.ChCount, lb => lb.PartsTotal, IntegerFormatter); + multiConvertProperties.RegisterProperty(TemplateTags.ChNumber, lb => lb.PartsPosition, IntegerFormatter); + multiConvertProperties.RegisterProperty(TemplateTags.ChNumber0, m => m.PartsPosition.ToString("D" + ((int)Math.Log10(m.PartsTotal) + 1))); + multiConvertProperties.RegisterProperty(TemplateTags.ChTitle, m => m.Title ?? "", StringFormatter); + + return new List<TagClass> { lbProperties, multiConvertProperties }; + } + + #endregion + + #region Tag Formatters private static string getLanguageShort(string language) { @@ -123,330 +261,94 @@ namespace LibationFileManager return language[..3].ToUpper(); } - internal static FileNamingTemplate getFileNamingTemplate(LibraryBookDto libraryBookDto, string template, string dirFullPath, string extension, ReplacementCharacters replacements) + private static string StringFormatter(ITemplateTag templateTag, string value, string formatString) { - ArgumentValidator.EnsureNotNullOrWhiteSpace(template, nameof(template)); - ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto)); - - replacements ??= Configuration.Instance.ReplacementCharacters; - dirFullPath = dirFullPath?.Trim() ?? ""; - - // for non-series, remove <if series-> and <-if series> tags and everything in between - // for series, remove <if series-> and <-if series> tags, what's in between will remain - template = ifSeriesRegex.Replace( - template, - string.IsNullOrWhiteSpace(libraryBookDto.SeriesName) ? "" : "$1"); - - //Get date replacement parameters. Sanitizes the format text and replaces - //the template with the sanitized text before creating FileNamingTemplate - var fileDateParams = getSanitizeDateReplacementParameters(fileDateTagRegex, ref template, replacements, libraryBookDto.FileDate); - var dateAddedParams = getSanitizeDateReplacementParameters(dateAddedTagRegex, ref template, replacements, libraryBookDto.DateAdded); - var pubDateParams = getSanitizeDateReplacementParameters(datePublishedTagRegex, ref template, replacements, libraryBookDto.DatePublished); - - var t = template + FileUtility.GetStandardizedExtension(extension); - var fullfilename = dirFullPath == "" ? t : Path.Combine(dirFullPath, t); - - var fileNamingTemplate = new FileNamingTemplate(fullfilename, replacements); - - var title = libraryBookDto.Title ?? ""; - var titleShort = title.IndexOf(':') < 1 ? title : title.Substring(0, title.IndexOf(':')); - - fileNamingTemplate.AddParameterReplacement(TemplateTags.Id, libraryBookDto.AudibleProductId); - fileNamingTemplate.AddParameterReplacement(TemplateTags.Title, title); - fileNamingTemplate.AddParameterReplacement(TemplateTags.TitleShort, titleShort); - fileNamingTemplate.AddParameterReplacement(TemplateTags.Author, libraryBookDto.AuthorNames); - fileNamingTemplate.AddParameterReplacement(TemplateTags.FirstAuthor, libraryBookDto.FirstAuthor); - fileNamingTemplate.AddParameterReplacement(TemplateTags.Narrator, libraryBookDto.NarratorNames); - fileNamingTemplate.AddParameterReplacement(TemplateTags.FirstNarrator, libraryBookDto.FirstNarrator); - fileNamingTemplate.AddParameterReplacement(TemplateTags.Series, libraryBookDto.SeriesName); - fileNamingTemplate.AddParameterReplacement(TemplateTags.SeriesNumber, libraryBookDto.SeriesNumber); - fileNamingTemplate.AddParameterReplacement(TemplateTags.Bitrate, libraryBookDto.BitRate); - fileNamingTemplate.AddParameterReplacement(TemplateTags.SampleRate, libraryBookDto.SampleRate); - fileNamingTemplate.AddParameterReplacement(TemplateTags.Channels, libraryBookDto.Channels); - fileNamingTemplate.AddParameterReplacement(TemplateTags.Account, libraryBookDto.Account); - fileNamingTemplate.AddParameterReplacement(TemplateTags.Locale, libraryBookDto.Locale); - fileNamingTemplate.AddParameterReplacement(TemplateTags.YearPublished, libraryBookDto.YearPublished?.ToString() ?? "1900"); - fileNamingTemplate.AddParameterReplacement(TemplateTags.Language, libraryBookDto.Language); - fileNamingTemplate.AddParameterReplacement(TemplateTags.LanguageShort, getLanguageShort(libraryBookDto.Language)); - - //Add the sanitized replacement parameters - foreach (var param in fileDateParams) - fileNamingTemplate.ParameterReplacements.AddIfNotContains(param); - foreach (var param in dateAddedParams) - fileNamingTemplate.ParameterReplacements.AddIfNotContains(param); - foreach (var param in pubDateParams) - fileNamingTemplate.ParameterReplacements.AddIfNotContains(param); - - return fileNamingTemplate; + 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) + { + if (int.TryParse(formatString, out var numDigits)) + return value.ToString($"D{numDigits}"); + return value.ToString(); + } + + 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 - #region DateTime Tags - - /// <param name="template">the file naming template. Any found date tags will be sanitized, - /// and the template's original date tag will be replaced with the sanitized tag.</param> - /// <returns>A list of parameter replacement key-value pairs</returns> - private static List<KeyValuePair<string, object>> getSanitizeDateReplacementParameters(Regex datePattern, ref string template, ReplacementCharacters replacements, DateTime? dateTime) - { - List<KeyValuePair<string, object>> dateParams = new(); - - foreach (Match dateTag in datePattern.Matches(template)) - { - var sanitizedTag = sanitizeDateParameterTag(dateTag, replacements, out var sanitizedFormatter); - if (tryFormatDateTime(dateTime, sanitizedFormatter, replacements, out var formattedDateString)) - { - dateParams.Add(new(sanitizedTag, formattedDateString)); - template = template.Replace(dateTag.Value, sanitizedTag); - } - } - return dateParams; - } - - /// <returns>a date parameter replacement tag with the format string sanitized</returns> - private static string sanitizeDateParameterTag(Match dateTag, ReplacementCharacters replacements, out string sanitizedFormatter) - { - if (dateTag.Groups.Count != 2 || string.IsNullOrWhiteSpace(dateTag.Groups[1].Value)) - { - sanitizedFormatter = DEFAULT_DATE_FORMAT; - return dateTag.Value; - } - - var formatter = dateTag.Groups[1].Value; - - sanitizedFormatter = replacements.ReplaceFilenameChars(formatter).Trim(); - - return dateTag.Value.Replace(formatter, sanitizedFormatter); - } - - private static bool tryFormatDateTime(DateTime? dateTime, string sanitizedFormatter, ReplacementCharacters replacements, out string formattedDateString) - { - if (!dateTime.HasValue) - { - formattedDateString = string.Empty; - return true; - } - - try - { - formattedDateString = replacements.ReplaceFilenameChars(dateTime.Value.ToString(sanitizedFormatter)).Trim(); - return true; - } - catch - { - formattedDateString = null; - return false; - } - } - #endregion - - public virtual IEnumerable<TemplateTags> GetTemplateTags() - => TemplateTags.GetAll() - // yeah, this line is a little funky but it works when you think through it. also: trust the unit tests - .Where(t => IsChapterized || !t.IsChapterOnly); - - public string Sanitize(string template, ReplacementCharacters replacements) - { - var value = template ?? ""; - - // Replace invalid filename characters in the DateTime format provider so we don't trip any alarms. - // Illegal filename characters in the formatter are allowed because they will be replaced by - // getFileNamingTemplate() - value = fileDateTagRegex.Replace(value, m => sanitizeDateParameterTag(m, replacements, out _)); - value = dateAddedTagRegex.Replace(value, m => sanitizeDateParameterTag(m, replacements, out _)); - value = datePublishedTagRegex.Replace(value, m => sanitizeDateParameterTag(m, replacements, out _)); - - // don't use alt slash - value = value.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); - - // don't allow double slashes - var sing = $"{Path.DirectorySeparatorChar}"; - var dbl = $"{Path.DirectorySeparatorChar}{Path.DirectorySeparatorChar}"; - while (value.Contains(dbl)) - value = value.Replace(dbl, sing); - - // trim. don't start or end with slash - while (true) - { - var start = value.Length; - value = value - .Trim() - .Trim(Path.DirectorySeparatorChar); - var end = value.Length; - if (start == end) - break; - } - - return value; - } - - public class FolderTemplate : Templates + public class FolderTemplate : Templates, ITemplate { public override string Name => "Folder Template"; public override string Description => Configuration.GetDescription(nameof(Configuration.FolderTemplate)); - public override string DefaultTemplate { get; } = "<title short> [<id>]"; - protected override bool IsChapterized { get; } = false; + public static string DefaultTemplate { get; } = "<title short> [<id>]"; + public static IEnumerable<TagClass> TagClass => new TagClass[] { filePropertyTags, conditionalTags }; - internal FolderTemplate() : base() { } + public override IEnumerable<string> Errors + => TemplateText?.Length >= 2 && Path.IsPathFullyQualified(TemplateText) ? base.Errors.Append(ERROR_FULL_PATH_IS_INVALID) : base.Errors; - #region validation - public override IEnumerable<string> GetErrors(string template) + protected override List<string> GetTemplatePartsStrings(List<TemplatePart> parts, ReplacementCharacters replacements) { - // null is invalid. whitespace is valid but not recommended - if (template is null) - return new[] { ERROR_NULL_IS_INVALID }; + foreach (var tp in parts) + { + //FolderTemplate literals can have directory separator characters + if (tp.TemplateTag is null) + tp.Value = replacements.ReplacePathChars(tp.Value.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar)); + else + tp.Value = replacements.ReplaceFilenameChars(tp.Value); + } + if (parts.Count > 0) + { + //Remove DirectorySeparatorChar at beginning and end of template + if (parts[0].Value.Length > 0 && parts[0].Value[0] == Path.DirectorySeparatorChar) + parts[0].Value = parts[0].Value.Remove(0,1); - // must be relative. no colons. all other path chars are valid enough to pass this check and will be handled on final save. - if (template.Contains(':')) - return new[] { ERROR_FULL_PATH_IS_INVALID }; - - // must be relative. no colons. all other path chars are valid enough to pass this check and will be handled on final save. - if (ReplacementCharacters.ContainsInvalidPathChar(template.Replace("<", "").Replace(">", ""))) - return new[] { ERROR_INVALID_FILE_NAME_CHAR }; - - return Valid; + if (parts[^1].Value.Length > 0 && parts[^1].Value[^1] == Path.DirectorySeparatorChar) + parts[^1].Value = parts[^1].Value.Remove(parts[^1].Value.Length - 1, 1); + } + return parts.Select(p => p.Value).ToList(); } - - public override IEnumerable<string> GetWarnings(string template) => GetStandardWarnings(template); - #endregion - - #region to file name - /// <summary>USES LIVE CONFIGURATION VALUES</summary> - public string GetFilename(LibraryBookDto libraryBookDto, string baseDir = null) - => getFileNamingTemplate(libraryBookDto, Configuration.Instance.FolderTemplate, baseDir ?? AudibleFileStorage.BooksDirectory, null, Configuration.Instance.ReplacementCharacters) - .GetFilePath(string.Empty); - #endregion } - public class FileTemplate : Templates + public class FileTemplate : Templates, ITemplate { public override string Name => "File Template"; public override string Description => Configuration.GetDescription(nameof(Configuration.FileTemplate)); - public override string DefaultTemplate { get; } = "<title> [<id>]"; - protected override bool IsChapterized { get; } = false; - - internal FileTemplate() : base() { } - - #region validation - public override IEnumerable<string> GetErrors(string template) => GetFileErrors(template); - - public override IEnumerable<string> GetWarnings(string template) => GetStandardWarnings(template); - #endregion - - #region to file name - /// <summary>USES LIVE CONFIGURATION VALUES</summary> - public string GetFilename(LibraryBookDto libraryBookDto, string dirFullPath, string extension, bool returnFirstExisting = false) - => getFileNamingTemplate(libraryBookDto, Configuration.Instance.FileTemplate, dirFullPath, extension, Configuration.Instance.ReplacementCharacters) - .GetFilePath(extension, returnFirstExisting); - #endregion + public static string DefaultTemplate { get; } = "<title> [<id>]"; + public static IEnumerable<TagClass> TagClass { get; } = new TagClass[] { filePropertyTags, conditionalTags }; } - public class ChapterFileTemplate : Templates + public class ChapterFileTemplate : Templates, ITemplate { public override string Name => "Chapter File Template"; public override string Description => Configuration.GetDescription(nameof(Configuration.ChapterFileTemplate)); - public override string DefaultTemplate { get; } = "<title> [<id>] - <ch# 0> - <ch title>"; - protected override bool IsChapterized { get; } = true; + public static string DefaultTemplate { get; } = "<title> [<id>] - <ch# 0> - <ch title>"; + public static IEnumerable<TagClass> TagClass { get; } + = chapterPropertyTags.Append(filePropertyTags).Append(conditionalTags); - internal ChapterFileTemplate() : base() { } - - #region validation - public override IEnumerable<string> GetErrors(string template) => GetFileErrors(template); - - public override IEnumerable<string> GetWarnings(string template) - { - var warnings = GetStandardWarnings(template).ToList(); - if (template is null) - return warnings; - - // recommended to incl. <ch#> or <ch# 0> - if (!ContainsTag(template, TemplateTags.ChNumber.TagName) && !ContainsTag(template, TemplateTags.ChNumber0.TagName)) - warnings.Add(WARNING_NO_CHAPTER_NUMBER_TAG); - - return warnings; - } - #endregion - - #region to file name - /// <summary>USES LIVE CONFIGURATION VALUES</summary> - public string GetFilename(LibraryBookDto libraryBookDto, AaxDecrypter.MultiConvertFileProperties props) - => GetPortionFilename(libraryBookDto, Configuration.Instance.ChapterFileTemplate, props, AudibleFileStorage.DecryptInProgressDirectory); - - public string GetPortionFilename(LibraryBookDto libraryBookDto, string template, AaxDecrypter.MultiConvertFileProperties props, string fullDirPath, ReplacementCharacters replacements = null) - { - if (string.IsNullOrWhiteSpace(template)) return string.Empty; - - replacements ??= Configuration.Instance.ReplacementCharacters; - var fileExtension = Path.GetExtension(props.OutputFileName); - var fileNamingTemplate = getFileNamingTemplate(libraryBookDto, template, fullDirPath, fileExtension, replacements); - - fileNamingTemplate.AddParameterReplacement(TemplateTags.ChCount, props.PartsTotal); - fileNamingTemplate.AddParameterReplacement(TemplateTags.ChNumber, props.PartsPosition); - fileNamingTemplate.AddParameterReplacement(TemplateTags.ChNumber0, FileUtility.GetSequenceFormatted(props.PartsPosition, props.PartsTotal)); - fileNamingTemplate.AddParameterReplacement(TemplateTags.ChTitle, props.Title ?? ""); - - foreach (Match dateTag in fileDateTagRegex.Matches(fileNamingTemplate.Template)) - { - var sanitizedTag = sanitizeDateParameterTag(dateTag, replacements, out string sanitizedFormatter); - if (tryFormatDateTime(props.FileDate, sanitizedFormatter, replacements, out var formattedDateString)) - fileNamingTemplate.ParameterReplacements[sanitizedTag] = formattedDateString; - } - - return fileNamingTemplate.GetFilePath(fileExtension).PathWithoutPrefix; - } - #endregion + public override IEnumerable<string> Warnings + => Template.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 + public class ChapterTitleTemplate : Templates, ITemplate { - private List<TemplateTags> _templateTags { get; } = new() - { - TemplateTags.Title, - TemplateTags.TitleShort, - TemplateTags.Series, - TemplateTags.ChCount, - TemplateTags.ChNumber, - TemplateTags.ChNumber0, - TemplateTags.ChTitle, - }; public override string Name => "Chapter Title Template"; - public override string Description => Configuration.GetDescription(nameof(Configuration.ChapterTitleTemplate)); + public static string DefaultTemplate => "<ch#> - <title short>: <ch title>"; + public static IEnumerable<TagClass> TagClass { get; } + = chapterPropertyTags.Append(conditionalTags); - public override string DefaultTemplate => "<ch#> - <title short>: <ch title>"; - - protected override bool IsChapterized => true; - - public override IEnumerable<string> GetErrors(string template) - => new List<string>(); - - public override IEnumerable<string> GetWarnings(string template) - => GetStandardWarnings(template).ToList(); - - public string GetTitle(LibraryBookDto libraryBookDto, AaxDecrypter.MultiConvertFileProperties props) - => GetPortionTitle(libraryBookDto, Configuration.Instance.ChapterTitleTemplate, props); - - public string GetPortionTitle(LibraryBookDto libraryBookDto, string template, AaxDecrypter.MultiConvertFileProperties props) - { - if (string.IsNullOrEmpty(template)) return string.Empty; - - ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto)); - - var fileNamingTemplate = new MetadataNamingTemplate(template); - - var title = libraryBookDto.Title ?? ""; - var titleShort = title.IndexOf(':') < 1 ? title : title.Substring(0, title.IndexOf(':')); - - fileNamingTemplate.AddParameterReplacement(TemplateTags.Title, title); - fileNamingTemplate.AddParameterReplacement(TemplateTags.TitleShort, titleShort); - fileNamingTemplate.AddParameterReplacement(TemplateTags.Series, libraryBookDto.SeriesName); - fileNamingTemplate.AddParameterReplacement(TemplateTags.ChCount, props.PartsTotal); - fileNamingTemplate.AddParameterReplacement(TemplateTags.ChNumber, props.PartsPosition); - fileNamingTemplate.AddParameterReplacement(TemplateTags.ChNumber0, FileUtility.GetSequenceFormatted(props.PartsPosition, props.PartsTotal)); - fileNamingTemplate.AddParameterReplacement(TemplateTags.ChTitle, props.Title ?? ""); - - return fileNamingTemplate.GetTagContents(); - } - public override IEnumerable<TemplateTags> GetTemplateTags() => _templateTags; + protected override IEnumerable<string> GetTemplatePartsStrings(List<TemplatePart> parts, ReplacementCharacters replacements) + => parts.Select(p => p.Value); } } } diff --git a/Source/LibationFileManager/UtilityExtensions.cs b/Source/LibationFileManager/UtilityExtensions.cs deleted file mode 100644 index 8ffe8c9e..00000000 --- a/Source/LibationFileManager/UtilityExtensions.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using FileManager; - -namespace LibationFileManager -{ - public static class UtilityExtensions - { - public static void AddParameterReplacement(this NamingTemplate fileNamingTemplate, TemplateTags templateTags, object value) - => fileNamingTemplate.AddParameterReplacement(templateTags.TagName, value); - - public static void AddUniqueParameterReplacement(this NamingTemplate namingTemplate, string key, object value) - => namingTemplate.ParameterReplacements[key] = value; - } -} diff --git a/Source/LibationWinForms/Dialogs/EditTemplateDialog.cs b/Source/LibationWinForms/Dialogs/EditTemplateDialog.cs index 55679ffc..12f42c6a 100644 --- a/Source/LibationWinForms/Dialogs/EditTemplateDialog.cs +++ b/Source/LibationWinForms/Dialogs/EditTemplateDialog.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Linq; using System.IO; using System.Windows.Forms; @@ -10,33 +9,19 @@ namespace LibationWinForms.Dialogs { public partial class EditTemplateDialog : Form { - // final value. post-validity check - public string TemplateText { get; private set; } - - // hold the work-in-progress value. not guaranteed to be valid - private string _workingTemplateText; - private string workingTemplateText - { - get => _workingTemplateText; - set => _workingTemplateText = template.Sanitize(value, Configuration.Instance.ReplacementCharacters); - } - - private void resetTextBox(string value) => this.templateTb.Text = workingTemplateText = value; - + private void resetTextBox(string value) => this.templateTb.Text = value; private Configuration config { get; } = Configuration.Instance; - - private Templates template { get; } - private string inputTemplateText { get; } + private ITemplateEditor templateEditor { get;} public EditTemplateDialog() { InitializeComponent(); this.SetLibationIcon(); } - public EditTemplateDialog(Templates template, string inputTemplateText) : this() + + public EditTemplateDialog(ITemplateEditor templateEditor) : this() { - this.template = ArgumentValidator.EnsureNotNull(template, nameof(template)); - this.inputTemplateText = inputTemplateText ?? ""; + this.templateEditor = ArgumentValidator.EnsureNotNull(templateEditor, nameof(templateEditor)); } private void EditTemplateDialog_Load(object sender, EventArgs e) @@ -44,89 +29,31 @@ namespace LibationWinForms.Dialogs if (this.DesignMode) return; - if (template is null) + if (templateEditor is null) { - MessageBoxLib.ShowAdminAlert(this, $"Programming error. {nameof(EditTemplateDialog)} was not created correctly", "Edit template error", new NullReferenceException($"{nameof(template)} is null")); + MessageBoxLib.ShowAdminAlert(this, $"Programming error. {nameof(EditTemplateDialog)} was not created correctly", "Edit template error", new NullReferenceException($"{nameof(templateEditor)} is null")); return; } warningsLbl.Text = ""; - this.Text = $"Edit {template.Name}"; + this.Text = $"Edit {templateEditor.EditingTemplate.Name}"; - this.templateLbl.Text = template.Description; - resetTextBox(inputTemplateText); + this.templateLbl.Text = templateEditor.EditingTemplate.Description; + resetTextBox(templateEditor.EditingTemplate.TemplateText); // populate list view - foreach (var tag in template.GetTemplateTags()) - listView1.Items.Add(new ListViewItem(new[] { $"<{tag.TagName}>", tag.Description }) { Tag = tag.DefaultValue }); + foreach (TemplateTags tag in templateEditor.EditingTemplate.TagsRegistered) + listView1.Items.Add(new ListViewItem(new[] { tag.Display, tag.Description }) { Tag = tag.DefaultValue }); + + listView1.AutoResizeColumns(ColumnHeaderAutoResizeStyle.ColumnContent); } - private void resetToDefaultBtn_Click(object sender, EventArgs e) => resetTextBox(template.DefaultTemplate); + private void resetToDefaultBtn_Click(object sender, EventArgs e) => resetTextBox(templateEditor.DefaultTemplate); private void templateTb_TextChanged(object sender, EventArgs e) { - workingTemplateText = templateTb.Text; - var isChapterTitle = template == Templates.ChapterTitle; - var isFolder = template == Templates.Folder; - - var libraryBookDto = new LibraryBookDto - { - Account = "my account", - DateAdded = new DateTime(2022, 6, 9, 0, 0, 0), - DatePublished = new DateTime(2017, 2, 27, 0, 0, 0), - AudibleProductId = "123456789", - Title = "A Study in Scarlet: A Sherlock Holmes Novel", - Locale = "us", - YearPublished = 2017, - Authors = new List<string> { "Arthur Conan Doyle", "Stephen Fry - introductions" }, - Narrators = new List<string> { "Stephen Fry" }, - SeriesName = "Sherlock Holmes", - SeriesNumber = "1", - BitRate = 128, - SampleRate = 44100, - Channels = 2, - Language = "English" - }; - var chapterName = "A Flight for Life"; - var chapterNumber = 4; - var chaptersTotal = 10; - - var partFileProperties = new AaxDecrypter.MultiConvertFileProperties() - { - OutputFileName = "", - PartsPosition = chapterNumber, - PartsTotal = chaptersTotal, - Title = chapterName - }; - - - /* - * Path must be rooted for windows to allow long file paths. This is - * only necessary for folder templates because they may contain several - * subdirectories. Without rooting, we won't be allowed to create a - * relative path longer than MAX_PATH. - */ - var books = config.Books; - var folder = Templates.Folder.GetPortionFilename( - libraryBookDto, - Path.Combine(books, isFolder ? workingTemplateText : config.FolderTemplate), ""); - - folder = Path.GetRelativePath(books, folder); - - var file - = template == Templates.ChapterFile - ? Templates.ChapterFile.GetPortionFilename( - libraryBookDto, - workingTemplateText, - partFileProperties, - "") - : Templates.File.GetPortionFilename( - libraryBookDto, - isFolder ? config.FileTemplate : workingTemplateText, ""); - var ext = config.DecryptToLossy ? "mp3" : "m4b"; - - var chapterTitle = Templates.ChapterTitle.GetPortionTitle(libraryBookDto, workingTemplateText, partFileProperties); + templateEditor.SetTemplateText(templateTb.Text); const char ZERO_WIDTH_SPACE = '\u200B'; var sing = $"{Path.DirectorySeparatorChar}"; @@ -139,11 +66,12 @@ namespace LibationWinForms.Dialogs string slashWrap(string val) => val.Replace(sing, $"{ZERO_WIDTH_SPACE}{sing}"); warningsLbl.Text - = !template.HasWarnings(workingTemplateText) + = !templateEditor.EditingTemplate.HasWarnings ? "" : "Warning:\r\n" + - template - .GetWarnings(workingTemplateText) + templateEditor + .EditingTemplate + .Warnings .Select(err => $"- {err}") .Aggregate((a, b) => $"{a}\r\n{b}"); @@ -153,51 +81,52 @@ namespace LibationWinForms.Dialogs richTextBox1.Clear(); richTextBox1.SelectionFont = reg; - if (isChapterTitle) + if (!templateEditor.IsFilePath) { richTextBox1.SelectionFont = bold; - richTextBox1.AppendText(chapterTitle); + richTextBox1.AppendText(templateEditor.GetName()); return; } - richTextBox1.AppendText(slashWrap(books)); + var folder = templateEditor.GetFolderName(); + var file = templateEditor.GetFileName(); + var ext = config.DecryptToLossy ? "mp3" : "m4b"; + + richTextBox1.AppendText(slashWrap(templateEditor.BaseDirectory.PathWithoutPrefix)); richTextBox1.AppendText(sing); - if (isFolder) + if (templateEditor.IsFolder) richTextBox1.SelectionFont = bold; richTextBox1.AppendText(slashWrap(folder)); - if (isFolder) + if (templateEditor.IsFolder) richTextBox1.SelectionFont = reg; richTextBox1.AppendText(sing); - if (!isFolder) + if (templateEditor.IsFilePath && !templateEditor.IsFolder) richTextBox1.SelectionFont = bold; richTextBox1.AppendText(file); - if (!isFolder) - richTextBox1.SelectionFont = reg; - + richTextBox1.SelectionFont = reg; richTextBox1.AppendText($".{ext}"); } private void saveBtn_Click(object sender, EventArgs e) { - if (!template.IsValid(workingTemplateText)) + if (!templateEditor.EditingTemplate.IsValid) { - var errors = template - .GetErrors(workingTemplateText) + var errors = templateEditor + .EditingTemplate + .Errors .Select(err => $"- {err}") .Aggregate((a, b) => $"{a}\r\n{b}"); MessageBox.Show($"This template text is not valid. Errors:\r\n{errors}", "Invalid", MessageBoxButtons.OK, MessageBoxIcon.Error); return; } - TemplateText = workingTemplateText; - this.DialogResult = DialogResult.OK; this.Close(); } diff --git a/Source/LibationWinForms/Dialogs/SettingsDialog.AudioSettings.cs b/Source/LibationWinForms/Dialogs/SettingsDialog.AudioSettings.cs index d26c7448..0647cc2b 100644 --- a/Source/LibationWinForms/Dialogs/SettingsDialog.AudioSettings.cs +++ b/Source/LibationWinForms/Dialogs/SettingsDialog.AudioSettings.cs @@ -106,7 +106,8 @@ namespace LibationWinForms.Dialogs chapterTitleTemplateGb.Enabled = splitFilesByChapterCbox.Checked; } - private void chapterTitleTemplateBtn_Click(object sender, EventArgs e) => editTemplate(Templates.ChapterTitle, chapterTitleTemplateTb); + private void chapterTitleTemplateBtn_Click(object sender, EventArgs e) + => editTemplate(TemplateEditor<Templates.ChapterTitleTemplate>.CreateNameEditor(chapterTitleTemplateTb.Text), chapterTitleTemplateTb); private void convertFormatRb_CheckedChanged(object sender, EventArgs e) { diff --git a/Source/LibationWinForms/Dialogs/SettingsDialog.DownloadDecrypt.cs b/Source/LibationWinForms/Dialogs/SettingsDialog.DownloadDecrypt.cs index d6d70113..f3a92ac3 100644 --- a/Source/LibationWinForms/Dialogs/SettingsDialog.DownloadDecrypt.cs +++ b/Source/LibationWinForms/Dialogs/SettingsDialog.DownloadDecrypt.cs @@ -7,10 +7,12 @@ namespace LibationWinForms.Dialogs { public partial class SettingsDialog { - private void folderTemplateBtn_Click(object sender, EventArgs e) => editTemplate(Templates.Folder, folderTemplateTb); - private void fileTemplateBtn_Click(object sender, EventArgs e) => editTemplate(Templates.File, fileTemplateTb); - private void chapterFileTemplateBtn_Click(object sender, EventArgs e) => editTemplate(Templates.ChapterFile, chapterFileTemplateTb); - + private void folderTemplateBtn_Click(object sender, EventArgs e) + => editTemplate(TemplateEditor<Templates.FolderTemplate>.CreateFilenameEditor(config.Books, folderTemplateTb.Text), folderTemplateTb); + private void fileTemplateBtn_Click(object sender, EventArgs e) + => editTemplate(TemplateEditor<Templates.FileTemplate>.CreateFilenameEditor(config.Books, fileTemplateTb.Text), fileTemplateTb); + private void chapterFileTemplateBtn_Click(object sender, EventArgs e) + => editTemplate(TemplateEditor<Templates.ChapterFileTemplate>.CreateFilenameEditor(config.Books, chapterFileTemplateTb.Text), chapterFileTemplateTb); private void editCharreplacementBtn_Click(object sender, EventArgs e) { diff --git a/Source/LibationWinForms/Dialogs/SettingsDialog.Important.cs b/Source/LibationWinForms/Dialogs/SettingsDialog.Important.cs index a2379c70..1718ad34 100644 --- a/Source/LibationWinForms/Dialogs/SettingsDialog.Important.cs +++ b/Source/LibationWinForms/Dialogs/SettingsDialog.Important.cs @@ -56,23 +56,6 @@ namespace LibationWinForms.Dialogs validationError("Cannot set Books Location to blank", "Location is blank"); return; } - - // these 3 should do nothing. Configuration will only init these with a valid value. EditTemplateDialog ensures valid before returning - if (!Templates.Folder.IsValid(folderTemplateTb.Text)) - { - validationError($"Not saving change to folder naming template. Invalid format.", "Invalid folder template"); - return; - } - if (!Templates.File.IsValid(fileTemplateTb.Text)) - { - validationError($"Not saving change to file naming template. Invalid format.", "Invalid file template"); - return; - } - if (!Templates.ChapterFile.IsValid(chapterFileTemplateTb.Text)) - { - validationError($"Not saving change to chapter file naming template. Invalid format.", "Invalid chapter file template"); - return; - } #endregion LongPath lonNewBooks = newBooks; diff --git a/Source/LibationWinForms/Dialogs/SettingsDialog.cs b/Source/LibationWinForms/Dialogs/SettingsDialog.cs index 7447ea22..17ac5d4e 100644 --- a/Source/LibationWinForms/Dialogs/SettingsDialog.cs +++ b/Source/LibationWinForms/Dialogs/SettingsDialog.cs @@ -27,11 +27,11 @@ namespace LibationWinForms.Dialogs Load_AudioSettings(config); } - private static void editTemplate(Templates template, TextBox textBox) + private static void editTemplate(ITemplateEditor template, TextBox textBox) { - var form = new EditTemplateDialog(template, textBox.Text); + var form = new EditTemplateDialog(template); if (form.ShowDialog() == DialogResult.OK) - textBox.Text = form.TemplateText; + textBox.Text = template.EditingTemplate.TemplateText; } private void saveBtn_Click(object sender, EventArgs e) diff --git a/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs b/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs index b1822357..4eed5156 100644 --- a/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs +++ b/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs @@ -1,82 +1,184 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using Dinah.Core; -using FileManager; +using System.Linq; +using FileManager.NamingTemplate; using FluentAssertions; using Microsoft.VisualStudio.TestTools.UnitTesting; -namespace FileNamingTemplateTests +namespace NamingTemplateTests { - [TestClass] - public class GetFilePath + class TemplateTag : ITemplateTag { - static ReplacementCharacters Replacements = ReplacementCharacters.Default; + public string TagName { get; init; } + } - [TestMethod] - [DataRow(@"C:\foo\bar", @"C:\foo\bar\my꞉ book 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 [ID123456].txt", PlatformID.Win32NT)] - [DataRow(@"/foo/bar", @"/foo/bar/my: book 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 [ID123456].txt", PlatformID.Unix)] - public void equiv_GetValidFilename(string dirFullPath, string expected, PlatformID platformID) + class PropertyClass1 + { + public string Item1 { get; set; } + public string Item2 { get; set; } + public string Item3 { get; set; } + public int Int1 { get; set; } + public bool Condition { get; set; } + } + + class PropertyClass2 + { + public string Item1 { get; set; } + public string Item2 { get; set; } + public string Item3 { get; set; } + public string Item4 { get; set; } + public bool Condition { get; set; } + } + class PropertyClass3 + { + public string Item1 { get; set; } + public string Item2 { get; set; } + public string Item3 { get; set; } + public string Item4 { get; set; } + public int? Int2 { get; set; } + public bool Condition { get; set; } + } + + + [TestClass] + public class GetPortionFilename + { + PropertyTagClass<PropertyClass1> props1 = new(); + PropertyTagClass<PropertyClass2> props2 = new(); + PropertyTagClass<PropertyClass3> props3 = new(); + ConditionalTagClass<PropertyClass1> conditional1 = new(); + ConditionalTagClass<PropertyClass2> conditional2 = new(); + ConditionalTagClass<PropertyClass3> conditional3 = new(); + + PropertyClass1 propertyClass1 = new() { - if (Environment.OSVersion.Platform != platformID) - return; + Item1 = "prop1_item1", + Item2 = "prop1_item2", + Item3 = "prop1_item3", + Int1 = 55, + Condition = true, + }; - var sb = new System.Text.StringBuilder(); - sb.Append('0', 300); - var longText = sb.ToString(); + PropertyClass2 propertyClass2 = new() + { + Item1 = "prop2_item1", + Item3 = "prop2_item3", + Item4 = "prop2_item4", + Condition = false + }; - NEW_GetValidFilename_FileNamingTemplate(dirFullPath, "my: book " + longText, "txt", "ID123456").Should().Be(expected); + PropertyClass3 propertyClass3 = new() + { + Item1 = "prop3_item1", + Item2 = "prop3_item2", + Item3 = "Prop3_Item3", + Item4 = "prop3_item4", + Condition = true + }; + + public GetPortionFilename() + { + props1.RegisterProperty(new TemplateTag { TagName = "item1" }, i => i.Item1); + props1.RegisterProperty(new TemplateTag { TagName = "item2" }, i => i.Item2); + props1.RegisterProperty(new TemplateTag { TagName = "item3" }, i => i.Item3); + + props2.RegisterProperty(new TemplateTag { TagName = "item1" }, i => i.Item1); + props2.RegisterProperty(new TemplateTag { TagName = "item2" }, i => i.Item2); + props2.RegisterProperty(new TemplateTag { TagName = "item3" }, i => i.Item3); + props2.RegisterProperty(new TemplateTag { TagName = "item4" }, i => i.Item4); + + props3.RegisterProperty(new TemplateTag { TagName = "item3_1" }, i => i.Item1); + props3.RegisterProperty(new TemplateTag { TagName = "item3_2" }, i => i.Item2); + props3.RegisterProperty(new TemplateTag { TagName = "item3_3" }, i => i.Item3); + props3.RegisterProperty(new TemplateTag { TagName = "item3_4" }, i => i.Item4); + + conditional1.RegisterCondition(new TemplateTag { TagName = "ifc1" }, i => i.Condition); + conditional2.RegisterCondition(new TemplateTag { TagName = "ifc2" }, i => i.Condition); + conditional3.RegisterCondition(new TemplateTag { TagName = "ifc3" }, i => i.Condition); } - private static string NEW_GetValidFilename_FileNamingTemplate(string dirFullPath, string filename, string extension, string metadataSuffix) + + [TestMethod] + [DataRow("<item1>", "prop1_item1", 1)] + [DataRow("< item1>", "< item1>", 0)] + [DataRow("<item1 >", "<item1 >", 0)] + [DataRow("< item1 >", "< item1 >", 0)] + [DataRow("<item3_1>", "prop3_item1", 1)] + [DataRow("<item1> <item2> <item3> <item4>", "prop1_item1 prop1_item2 prop1_item3 prop2_item4", 4)] + [DataRow("<item3_1> <item3_2> <item3> <item4>", "prop3_item1 prop3_item2 prop1_item3 prop2_item4", 4)] + [DataRow("<ifc1-><item1><-ifc1><ifc2-><item4><-ifc2><ifc3-><item3_2><-ifc3>", "prop1_item1prop3_item2", 3)] + [DataRow("<ifc1-><ifc3-><item1><ifc2-><item4><-ifc2><item3_2><-ifc3><-ifc1>", "prop1_item1prop3_item2", 3)] + [DataRow("<ifc2-><ifc1-><ifc3-><item1><item4><item3_2><-ifc3><-ifc1><-ifc2>", "", 3)] + public void test(string inStr, string outStr, int numTags) { - var template = $"<title> [<id>]"; + var template = NamingTemplate.Parse(inStr, new TagClass[] { props1, props2, props3, conditional1, conditional2, conditional3 }); - extension = FileUtility.GetStandardizedExtension(extension); - var fullfilename = Path.Combine(dirFullPath, template + extension); + template.TagsInUse.Should().HaveCount(numTags); + template.Warnings.Should().HaveCount(numTags > 0 ? 0 : 1); + template.Errors.Should().HaveCount(0); - var fileNamingTemplate = new FileNamingTemplate(fullfilename, Replacements); - fileNamingTemplate.AddParameterReplacement("title", filename); - fileNamingTemplate.AddParameterReplacement("id", metadataSuffix); - return fileNamingTemplate.GetFilePath(extension).PathWithoutPrefix; + var templateText = string.Join("", template.Evaluate(propertyClass3, propertyClass2, propertyClass1).Select(v => v.Value)); + + templateText.Should().Be(outStr); } [TestMethod] - [DataRow(@"C:\foo\bar\my file.txt", @"C:\foo\bar\my file - 002 - title.txt", PlatformID.Win32NT)] - [DataRow(@"/foo/bar/my file.txt", @"/foo/bar/my file - 002 - title.txt", PlatformID.Unix)] - public void equiv_GetMultipartFileName(string inStr, string outStr, PlatformID platformID) + [DataRow("<ifc2-><ifc1-><ifc3-><item1><item4><item3_2><-ifc3><-ifc1><ifc2->", new string[] { "Missing <-ifc2> closing conditional.", "Missing <-ifc2> closing conditional." })] + [DataRow("<ifc2-><ifc1-><ifc3-><-ifc3><-ifc1><-ifc2>", new string[] { "Should use tags. Eg: <title>" })] + [DataRow("<ifc1-><ifc3-><item1><-ifc3><-ifc1><-ifc2>", new string[] { "Missing <ifc2-> open conditional." })] + [DataRow("<ifc1-><ifc3-><-ifc3><-ifc1><-ifc2>", new string[] { "Missing <ifc2-> open conditional.", "Should use tags. Eg: <title>" })] + [DataRow("<ifc2-><ifc1-><ifc3-><item1><item4><item3_2><-ifc3><-ifc1>", new string[] { "Missing <-ifc2> closing conditional." })] + [DataRow("<ifc2-><ifc1-><ifc3-><item1><item4><item3_2><-ifc3>", new string[] { "Missing <-ifc1> closing conditional.", "Missing <-ifc2> closing conditional." })] + [DataRow("<ifc2-><ifc1-><ifc3-><item1><item4>", new string[] { "Missing <-ifc3> closing conditional.", "Missing <-ifc1> closing conditional.", "Missing <-ifc2> closing conditional." })] + [DataRow("<ifc2-><ifc1-><ifc3-><item1><item4><item3_2><-ifc1><-ifc2>", new string[] { "Missing <-ifc3> closing conditional.", "Missing <-ifc3> closing conditional.", "Missing <-ifc1> closing conditional.", "Missing <-ifc2> closing conditional." })] + public void condition_error(string inStr, string[] warnings) { - if (Environment.OSVersion.Platform == platformID) - NEW_GetMultipartFileName_FileNamingTemplate(inStr, 2, 100, "title").Should().Be(outStr); + var template = NamingTemplate.Parse(inStr, new TagClass[] { props1, props2, props3, conditional1, conditional2, conditional3 }); + + template.Errors.Should().HaveCount(0); + template.Warnings.Should().BeEquivalentTo(warnings); } - - private static string NEW_GetMultipartFileName_FileNamingTemplate(string originalPath, int partsPosition, int partsTotal, string suffix) - { - // 1-9 => 1-9 - // 10-99 => 01-99 - // 100-999 => 001-999 - var chapterCountLeadingZeros = partsPosition.ToString().PadLeft(partsTotal.ToString().Length, '0'); - - var estension = Path.GetExtension(originalPath); - var t = Path.ChangeExtension(originalPath, null) + " - <chapter> - <title>" + estension; - - var fileNamingTemplate = new FileNamingTemplate(t, Replacements); - fileNamingTemplate.AddParameterReplacement("chapter", chapterCountLeadingZeros); - fileNamingTemplate.AddParameterReplacement("title", suffix); - return fileNamingTemplate.GetFilePath(estension).PathWithoutPrefix; - } - [TestMethod] - [DataRow(@"\foo\<title>.txt", @"\foo\sl∕as∕he∕s.txt", PlatformID.Win32NT)] - [DataRow(@"/foo/<title>.txt", @"/foo/s\l∕a\s∕h\e∕s.txt", PlatformID.Unix)] - public void remove_slashes(string inStr, string outStr, PlatformID platformID) + [DataRow("<int1>", "55")] + [DataRow("<int1[]>", "55")] + [DataRow("<int1[5]>", "00055")] + [DataRow("<int2>", "")] + [DataRow("<int2[]>", "")] + [DataRow("<int2[4]>", "")] + [DataRow("<item3_format>", "Prop3_Item3")] + [DataRow("<item3_format[]>", "Prop3_Item3")] + [DataRow("<item3_format[rtreue5]>", "Prop3_Item3")] + [DataRow("<item3_format[l]>", "prop3_item3")] + [DataRow("<item3_format[u]>", "PROP3_ITEM3")] + [DataRow("<item2_2_null>", "")] + [DataRow("<item2_2_null[]>", "")] + [DataRow("<item2_2_null[l]>", "")] + public void formatting(string inStr, string outStr) { - if (Environment.OSVersion.Platform == platformID) + props1.RegisterProperty(new TemplateTag { TagName = "int1" }, i => i.Int1, formatInt); + props3.RegisterProperty(new TemplateTag { TagName = "int2" }, i => i.Int2, formatInt); + props3.RegisterProperty(new TemplateTag { TagName = "item3_format" }, i => i.Item3, formatString); + props2.RegisterProperty(new TemplateTag { TagName = "item2_2_null" }, i => i.Item2, formatString); + + var template = NamingTemplate.Parse(inStr, new TagClass[] { props1, props2, props3, conditional1, conditional2, conditional3 }); + + template.Warnings.Should().HaveCount(0); + template.Errors.Should().HaveCount(0); + + var templateText = string.Join("", template.Evaluate(propertyClass3, propertyClass2, propertyClass1).Select(v => v.Value)); + + templateText.Should().Be(outStr); + + string formatInt(ITemplateTag templateTag, int value, string format) { - var fileNamingTemplate = new FileNamingTemplate(inStr, Replacements); - fileNamingTemplate.AddParameterReplacement("title", @"s\l/a\s/h\e/s"); - fileNamingTemplate.GetFilePath("txt").PathWithoutPrefix.Should().Be(outStr); + if (int.TryParse(format, out var numDecs)) + return value.ToString($"D{numDecs}"); + return value.ToString(); + } + + string formatString(ITemplateTag templateTag, string value, string formatString) + { + 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; } } } diff --git a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs index eb1bc77a..89c2bacb 100644 --- a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -4,6 +4,7 @@ using System.IO; using System.Linq; using Dinah.Core; using FileManager; +using FileManager.NamingTemplate; using FluentAssertions; using LibationFileManager; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -42,46 +43,21 @@ namespace TemplatesTests Channels = 2, Language = "English" }; - - public static LibraryBookDto GetLibraryBookWithNullDates(string seriesName = "Sherlock Holmes") - => new() - { - Account = "my account", - FileDate = new DateTime(2023, 1, 28, 0, 0, 0), - AudibleProductId = "asin", - Title = "A Study in Scarlet: A Sherlock Holmes Novel", - Locale = "us", - YearPublished = 2017, - Authors = new List<string> { "Arthur Conan Doyle", "Stephen Fry - introductions" }, - Narrators = new List<string> { "Stephen Fry" }, - SeriesName = seriesName ?? "", - SeriesNumber = "1", - BitRate = 128, - SampleRate = 44100, - Channels = 2, - Language = "English" - }; - } - - [TestClass] - public class ContainsChapterOnlyTags - { - [TestMethod] - [DataRow("<ch>", false)] - [DataRow("<ch#>", true)] - [DataRow("<id>", false)] - [DataRow("<id><ch#>", true)] - public void Tests(string template, bool expected) => Templates.ContainsChapterOnlyTags(template).Should().Be(expected); } [TestClass] public class ContainsTag { [TestMethod] - [DataRow("<ch#>", "ch#", true)] - [DataRow("<id>", "ch#", false)] - [DataRow("<id><ch#>", "ch#", true)] - public void Tests(string template, string tag, bool expected) => Templates.ContainsTag(template, tag).Should().Be(expected); + [DataRow("<ch#>", 0)] + [DataRow("<id>", 1)] + [DataRow("<id><ch#>", 1)] + public void Tests(string template, int numTags) + { + Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue(); + + fileTemplate.TagsInUse.Should().HaveCount(numTags); + } } [TestClass] @@ -89,19 +65,22 @@ namespace TemplatesTests { static ReplacementCharacters Replacements = ReplacementCharacters.Default; + [TestMethod] + [DataRow(null)] + public void template_null(string template) + { + Templates.TryGetTemplate<Templates.FileTemplate>(template, out var t).Should().BeFalse(); + t.IsValid.Should().BeFalse(); + } [TestMethod] - [DataRow(null, @"C:\", "ext")] - [ExpectedException(typeof(ArgumentNullException))] - public void arg_null_exception(string template, string dirFullPath, string extension) - => Templates.getFileNamingTemplate(GetLibraryBook(), template, dirFullPath, extension, Replacements); - - [TestMethod] - [DataRow("", @"C:\foo\bar", "ext")] - [DataRow(" ", @"C:\foo\bar", "ext")] - [ExpectedException(typeof(ArgumentException))] - public void arg_exception(string template, string dirFullPath, string extension) - => Templates.getFileNamingTemplate(GetLibraryBook(), template, dirFullPath, extension, Replacements); + [DataRow("")] + [DataRow(" ")] + public void template_empty(string template) + { + Templates.TryGetTemplate<Templates.FileTemplate>(template, out var t).Should().BeTrue(); + t.Warnings.Should().HaveCount(2); + } [TestMethod] [DataRow("f.txt", @"C:\foo\bar", "", @"C:\foo\bar\f.txt")] @@ -119,10 +98,26 @@ namespace TemplatesTests expected = expected.Replace("C:", "").Replace('\\', '/'); } - Templates.getFileNamingTemplate(GetLibraryBook(), template, dirFullPath, extension, Replacements) - .GetFilePath(extension) - .PathWithoutPrefix - .Should().Be(expected); + Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue(); + + fileTemplate + .GetFilename(GetLibraryBook(), dirFullPath, extension, Replacements) + .PathWithoutPrefix + .Should().Be(expected); + } + + [TestMethod] + [DataRow("<bitrate>Kbps <samplerate>Hz", "128Kbps 44100Hz")] + [DataRow("<bitrate>Kbps <samplerate[6]>Hz", "128Kbps 044100Hz")] + [DataRow("<bitrate[4]>Kbps <samplerate>Hz", "0128Kbps 44100Hz")] + [DataRow("<bitrate[4]>Kbps <titleshort[u]>", "0128Kbps A STUDY IN SCARLET")] + [DataRow("<bitrate[4]>Kbps <titleshort[l]>", "0128Kbps a study in scarlet")] + [DataRow("<bitrate[4]>Kbps <samplerate[6]>Hz", "0128Kbps 044100Hz")] + [DataRow("<bitrate [ 4 ] >Kbps <samplerate [ 6 ] >Hz", "0128Kbps 044100Hz")] + public void FormatTags(string template, string expected) + { + Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue(); + fileTemplate.GetFilename(GetLibraryBook(), "", "", Replacements).PathWithoutPrefix.Should().Be(expected); } [TestMethod] @@ -145,8 +140,9 @@ namespace TemplatesTests expected = expected.Replace("C:", "").Replace('\\', '/'); } - Templates.getFileNamingTemplate(GetLibraryBook(), template, dirFullPath, extension, Replacements) - .GetFilePath(extension) + Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue(); + fileTemplate + .GetFilename(GetLibraryBook(), dirFullPath, extension, Replacements) .PathWithoutPrefix .Should().Be(expected); } @@ -170,11 +166,12 @@ namespace TemplatesTests if (Environment.OSVersion.Platform is not PlatformID.Win32NT) { dirFullPath = dirFullPath.Replace("C:", "").Replace('\\', '/'); - expected = expected.Replace("C:", "").Replace('\\', '/').Replace('<', '<').Replace('>','>'); + expected = expected.Replace("C:", "").Replace('\\', '/').Replace('<', '<').Replace('>', '>'); } - Templates.getFileNamingTemplate(GetLibraryBook(), template, dirFullPath, extension, Replacements) - .GetFilePath(extension) + Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue(); + fileTemplate + .GetFilename(GetLibraryBook(), dirFullPath, extension, Replacements) .PathWithoutPrefix .Should().Be(expected); } @@ -191,8 +188,9 @@ namespace TemplatesTests expected = expected.Replace("C:", "").Replace('\\', '/'); } - Templates.getFileNamingTemplate(GetLibraryBook(), template, dirFullPath, extension, Replacements) - .GetFilePath(extension) + Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue(); + fileTemplate + .GetFilename(GetLibraryBook(), dirFullPath, extension, Replacements) .PathWithoutPrefix .Should().Be(expected); } @@ -208,13 +206,13 @@ namespace TemplatesTests { if (Environment.OSVersion.Platform == platformID) { - Templates.File.HasWarnings(template).Should().BeTrue(); - Templates.File.HasWarnings(Templates.File.Sanitize(template, Replacements)).Should().BeFalse(); - Templates.getFileNamingTemplate(GetLibraryBook(), template, dirFullPath, extension, Replacements) - .GetFilePath(extension) + Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue(); + + fileTemplate.HasWarnings.Should().BeFalse(); + fileTemplate + .GetFilename(GetLibraryBook(), dirFullPath, extension, Replacements) .PathWithoutPrefix .Should().Be(expected); - } } @@ -229,11 +227,15 @@ namespace TemplatesTests expected = expected.Replace("C:", "").Replace('\\', '/'); } - Templates.getFileNamingTemplate(GetLibraryBookWithNullDates(), template, dirFullPath, extension, Replacements) - .GetFilePath(extension) + var lbDto = GetLibraryBook(); + lbDto.DatePublished = null; + lbDto.DateAdded = null; + + Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue(); + fileTemplate + .GetFilename(lbDto, dirFullPath, extension, Replacements) .PathWithoutPrefix .Should().Be(expected); - } [TestMethod] @@ -242,10 +244,14 @@ namespace TemplatesTests public void IfSeries_empty(string directory, string expected, PlatformID platformID) { if (Environment.OSVersion.Platform == platformID) - Templates.getFileNamingTemplate(GetLibraryBook(), "foo<if series-><-if series>bar", directory, "ext", Replacements) - .GetFilePath(".ext") - .PathWithoutPrefix - .Should().Be(expected); + { + Templates.TryGetTemplate<Templates.FileTemplate>("foo<if series-><-if series>bar", out var fileTemplate).Should().BeTrue(); + + fileTemplate + .GetFilename(GetLibraryBook(), directory, "ext", Replacements) + .PathWithoutPrefix + .Should().Be(expected); + } } [TestMethod] @@ -254,10 +260,13 @@ namespace TemplatesTests public void IfSeries_no_series(string directory, string expected, PlatformID platformID) { if (Environment.OSVersion.Platform == platformID) - Templates.getFileNamingTemplate(GetLibraryBook(null), "foo<if series->-<series>-<id>-<-if series>bar", directory, "ext", Replacements) - .GetFilePath(".ext") + { + Templates.TryGetTemplate<Templates.FileTemplate>("foo<if series->-<series>-<id>-<-if series>bar", out var fileTemplate).Should().BeTrue(); + + fileTemplate.GetFilename(GetLibraryBook(null), directory, "ext", Replacements) .PathWithoutPrefix .Should().Be(expected); + } } [TestMethod] @@ -266,10 +275,112 @@ namespace TemplatesTests public void IfSeries_with_series(string directory, string expected, PlatformID platformID) { if (Environment.OSVersion.Platform == platformID) - Templates.getFileNamingTemplate(GetLibraryBook(), "foo<if series->-<series>-<id>-<-if series>bar", directory, "ext", Replacements) - .GetFilePath(".ext") - .PathWithoutPrefix - .Should().Be(expected); + { + Templates.TryGetTemplate<Templates.FileTemplate>("foo<if series->-<series>-<id>-<-if series>bar", out var fileTemplate).Should().BeTrue(); + + fileTemplate + .GetFilename(GetLibraryBook(), directory, "ext", Replacements) + .PathWithoutPrefix + .Should().Be(expected); + } + } + } +} + + +namespace Templates_Other +{ + + [TestClass] + public class GetFilePath + { + static ReplacementCharacters Replacements = ReplacementCharacters.Default; + + [TestMethod] + [DataRow(@"C:\foo\bar", @"C:\foo\bar\Folder\my꞉ book 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\[ID123456].txt", PlatformID.Win32NT)] + [DataRow(@"/foo/bar", @"/foo/bar/Folder/my: book 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000/[ID123456].txt", PlatformID.Unix)] + public void equiv_GetValidFilename(string dirFullPath, string expected, PlatformID platformID) + { + if (Environment.OSVersion.Platform != platformID) + return; + + var sb = new System.Text.StringBuilder(); + sb.Append('0', 300); + var longText = sb.ToString(); + + NEW_GetValidFilename_FileNamingTemplate(dirFullPath, "my: book " + longText, "txt", "ID123456").Should().Be(expected); + } + + private class TemplateTag : ITemplateTag + { + public string TagName { get; init; } + public string DefaultValue { get; } + public string Description { get; } + public string Display { get; } + } + private static string NEW_GetValidFilename_FileNamingTemplate(string dirFullPath, string filename, string extension, string metadataSuffix) + { + char slash = Path.DirectorySeparatorChar; + + var template = $"{slash}Folder{slash}<title>{slash}[<id>]{slash}"; + + extension = FileUtility.GetStandardizedExtension(extension); + + var lbDto = GetLibraryBook(); + lbDto.Title = filename; + lbDto.AudibleProductId = metadataSuffix; + + Templates.TryGetTemplate<Templates.FolderTemplate>(template, out var fileNamingTemplate).Should().BeTrue(); + + return fileNamingTemplate.GetFilename(lbDto, dirFullPath, extension, Replacements).PathWithoutPrefix; + } + + [TestMethod] + [DataRow(@"C:\foo\bar\my file.txt", @"C:\foo\bar\my file - 002 - title.txt", PlatformID.Win32NT)] + [DataRow(@"/foo/bar/my file.txt", @"/foo/bar/my file - 002 - title.txt", PlatformID.Unix)] + public void equiv_GetMultipartFileName(string inStr, string outStr, PlatformID platformID) + { + if (Environment.OSVersion.Platform == platformID) + NEW_GetMultipartFileName_FileNamingTemplate(inStr, 2, 100, "title").Should().Be(outStr); + } + + private static string NEW_GetMultipartFileName_FileNamingTemplate(string originalPath, int partsPosition, int partsTotal, string suffix) + { + // 1-9 => 1-9 + // 10-99 => 01-99 + // 100-999 => 001-999 + + var estension = Path.GetExtension(originalPath); + var dir = Path.GetDirectoryName(originalPath); + var template = Path.GetFileNameWithoutExtension(originalPath) + " - <ch# 0> - <title>" + estension; + + var lbDto = GetLibraryBook(); + lbDto.Title = suffix; + + Templates.TryGetTemplate<Templates.ChapterFileTemplate>(template, out var chapterFileTemplate).Should().BeTrue(); + + return chapterFileTemplate + .GetFilename(lbDto, new AaxDecrypter.MultiConvertFileProperties { Title = suffix, PartsTotal = partsTotal, PartsPosition = partsPosition }, dir, estension, Replacements) + .PathWithoutPrefix; + } + + [TestMethod] + [DataRow(@"\foo\<title>.txt", @"\foo\sl∕as∕he∕s.txt", PlatformID.Win32NT)] + [DataRow(@"/foo/<title>.txt", @"/foo/s\l∕a\s∕h\e∕s.txt", PlatformID.Unix)] + public void remove_slashes(string inStr, string outStr, PlatformID platformID) + { + if (Environment.OSVersion.Platform == platformID) + { + var lbDto = GetLibraryBook(); + lbDto.Title = @"s\l/a\s/h\e/s"; + + var directory = Path.GetDirectoryName(inStr); + var fileName = Path.GetFileName(inStr); + + Templates.TryGetTemplate<Templates.FileTemplate>(fileName, out var fileNamingTemplate).Should().BeTrue(); + + fileNamingTemplate.GetFilename(lbDto, directory, "txt", Replacements).PathWithoutPrefix.Should().Be(outStr); + } } } } @@ -280,7 +391,7 @@ namespace Templates_Folder_Tests public class GetErrors { [TestMethod] - public void null_is_invalid() => Tests(null, new[] { Templates.ERROR_NULL_IS_INVALID }); + public void null_is_invalid() => Tests(null, PlatformID.Win32NT | PlatformID.Unix, new[] { NamingTemplate.ERROR_NULL_IS_INVALID }); [TestMethod] public void empty_is_valid() => valid_tests(""); @@ -296,15 +407,19 @@ namespace Templates_Folder_Tests [DataRow(@"foo\bar")] [DataRow(@"<id>")] [DataRow(@"<id>\<title>")] - public void valid_tests(string template) => Tests(template, Array.Empty<string>()); + public void valid_tests(string template) => Tests(template, PlatformID.Win32NT | PlatformID.Unix, Array.Empty<string>()); [TestMethod] - [DataRow(@"C:\", Templates.ERROR_FULL_PATH_IS_INVALID)] - public void Tests(string template, params string[] expected) + [DataRow(@"C:\", PlatformID.Win32NT, Templates.ERROR_FULL_PATH_IS_INVALID)] + public void Tests(string template, PlatformID platformID, params string[] expected) { - var result = Templates.Folder.GetErrors(template); - result.Count().Should().Be(expected.Length); - result.Should().BeEquivalentTo(expected); + if ((platformID & Environment.OSVersion.Platform) == Environment.OSVersion.Platform) + { + Templates.TryGetTemplate<Templates.FolderTemplate>(template, out var folderTemplate); + var result = folderTemplate.Errors; + result.Should().HaveCount(expected.Length); + result.Should().BeEquivalentTo(expected); + } } } @@ -312,50 +427,57 @@ namespace Templates_Folder_Tests public class IsValid { [TestMethod] - public void null_is_invalid() => Tests(null, false); + public void null_is_invalid() => Templates.TryGetTemplate<Templates.FolderTemplate>(null, out _).Should().BeFalse(); [TestMethod] - public void empty_is_valid() => Tests("", true); + public void empty_is_valid() => Tests("", true, PlatformID.Win32NT | PlatformID.Unix); [TestMethod] - public void whitespace_is_valid() => Tests(" ", true); + public void whitespace_is_valid() => Tests(" ", true, PlatformID.Win32NT | PlatformID.Unix); [TestMethod] - [DataRow(@"C:\", false)] - [DataRow(@"foo", true)] - [DataRow(@"\foo", true)] - [DataRow(@"foo\", true)] - [DataRow(@"\foo\", true)] - [DataRow(@"foo\bar", true)] - [DataRow(@"<id>", true)] - [DataRow(@"<id>\<title>", true)] - public void Tests(string template, bool expected) => Templates.Folder.IsValid(template).Should().Be(expected); + [DataRow(@"C:\", false, PlatformID.Win32NT)] + [DataRow(@"foo", true, PlatformID.Win32NT | PlatformID.Unix)] + [DataRow(@"\foo", true, PlatformID.Win32NT | PlatformID.Unix)] + [DataRow(@"foo\", true, PlatformID.Win32NT | PlatformID.Unix)] + [DataRow(@"\foo\", true, PlatformID.Win32NT | PlatformID.Unix)] + [DataRow(@"foo\bar", true, PlatformID.Win32NT | PlatformID.Unix)] + [DataRow(@"<id>", true, PlatformID.Win32NT | PlatformID.Unix)] + [DataRow(@"<id>\<title>", true, PlatformID.Win32NT | PlatformID.Unix)] + public void Tests(string template, bool expected, PlatformID platformID) + { + if ((platformID & Environment.OSVersion.Platform) == Environment.OSVersion.Platform) + { + Templates.TryGetTemplate<Templates.FolderTemplate>(template, out var folderTemplate).Should().BeTrue(); + folderTemplate.IsValid.Should().Be(expected); + } + } } [TestClass] public class GetWarnings { [TestMethod] - public void null_is_invalid() => Tests(null, new[] { Templates.ERROR_NULL_IS_INVALID }); + public void null_is_invalid() => Tests(null, new[] { NamingTemplate.ERROR_NULL_IS_INVALID }); [TestMethod] - public void empty_has_warnings() => Tests("", Templates.WARNING_EMPTY, Templates.WARNING_NO_TAGS); + public void empty_has_warnings() => Tests("", NamingTemplate.WARNING_EMPTY, NamingTemplate.WARNING_NO_TAGS); [TestMethod] - public void whitespace_has_warnings() => Tests(" ", Templates.WARNING_WHITE_SPACE, Templates.WARNING_NO_TAGS); + public void whitespace_has_warnings() => Tests(" ", NamingTemplate.WARNING_WHITE_SPACE, NamingTemplate.WARNING_NO_TAGS); [TestMethod] [DataRow(@"<id>\foo\bar")] public void valid_tests(string template) => Tests(template, Array.Empty<string>()); [TestMethod] - [DataRow(@"no tags", Templates.WARNING_NO_TAGS)] - [DataRow("<ch#> <id>", Templates.WARNING_HAS_CHAPTER_TAGS)] - [DataRow("<ch#> chapter tag", Templates.WARNING_NO_TAGS, Templates.WARNING_HAS_CHAPTER_TAGS)] + [DataRow(@"no tags", NamingTemplate.WARNING_NO_TAGS)] + [DataRow("<ch#> chapter tag", NamingTemplate.WARNING_NO_TAGS)] public void Tests(string template, params string[] expected) { - var result = Templates.Folder.GetWarnings(template); - result.Count().Should().Be(expected.Length); + Templates.TryGetTemplate<Templates.FolderTemplate>(template, out var folderTemplate); + var result = folderTemplate.Warnings; + result.Should().HaveCount(expected.Length); result.Should().BeEquivalentTo(expected); } } @@ -375,16 +497,23 @@ namespace Templates_Folder_Tests [TestMethod] [DataRow(@"no tags", true)] [DataRow(@"<id>\foo\bar", false)] - [DataRow("<ch#> <id>", true)] [DataRow("<ch#> chapter tag", true)] - public void Tests(string template, bool expected) => Templates.Folder.HasWarnings(template).Should().Be(expected); + public void Tests(string template, bool expected) + { + Templates.TryGetTemplate<Templates.FolderTemplate>(template, out var folderTemplate); + folderTemplate.HasWarnings.Should().Be(expected); + } } [TestClass] public class TagCount { [TestMethod] - public void null_throws() => Assert.ThrowsException<NullReferenceException>(() => Templates.Folder.TagCount(null)); + public void null_invalid() + { + Templates.TryGetTemplate<Templates.FolderTemplate>(null, out var template).Should().BeFalse(); + template.IsValid.Should().BeFalse(); + } [TestMethod] public void empty() => Tests("", 0); @@ -402,7 +531,11 @@ namespace Templates_Folder_Tests [DataRow("<not a real tag>", 0)] [DataRow("<ch#> non-folder tag", 0)] [DataRow("<ID> case specific", 0)] - public void Tests(string template, int expected) => Templates.Folder.TagCount(template).Should().Be(expected); + public void Tests(string template, int expected) + { + Templates.TryGetTemplate<Templates.FolderTemplate>(template, out var folderTemplate).Should().BeTrue(); + folderTemplate.TagsInUse.Count().Should().Be(expected); + } } } @@ -412,7 +545,7 @@ namespace Templates_File_Tests public class GetErrors { [TestMethod] - public void null_is_invalid() => Tests(null, Environment.OSVersion.Platform, new[] { Templates.ERROR_NULL_IS_INVALID }); + public void null_is_invalid() => Tests(null, Environment.OSVersion.Platform, new[] { NamingTemplate.ERROR_NULL_IS_INVALID }); [TestMethod] public void empty_is_valid() => valid_tests(""); @@ -425,19 +558,13 @@ namespace Templates_File_Tests [DataRow(@"<id>")] public void valid_tests(string template) => Tests(template, Environment.OSVersion.Platform, Array.Empty<string>()); - - [TestMethod] - [DataRow(@"C:\", PlatformID.Win32NT, Templates.ERROR_INVALID_FILE_NAME_CHAR)] - [DataRow(@"/", PlatformID.Unix, Templates.ERROR_INVALID_FILE_NAME_CHAR)] - [DataRow(@"\foo", PlatformID.Win32NT, Templates.ERROR_INVALID_FILE_NAME_CHAR)] - [DataRow(@"/foo", PlatformID.Win32NT, Templates.ERROR_INVALID_FILE_NAME_CHAR)] - [DataRow(@"/foo", PlatformID.Unix, Templates.ERROR_INVALID_FILE_NAME_CHAR)] public void Tests(string template, PlatformID platformID, params string[] expected) { if (Environment.OSVersion.Platform == platformID) { - var result = Templates.File.GetErrors(template); - result.Count().Should().Be(expected.Length); + Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate); + var result = fileTemplate.Errors; + result.Should().HaveCount(expected.Length); result.Should().BeEquivalentTo(expected); } } @@ -447,28 +574,26 @@ namespace Templates_File_Tests public class IsValid { [TestMethod] - public void null_is_invalid() => Tests(null, false, Environment.OSVersion.Platform); + public void null_is_invalid() => Templates.TryGetTemplate<Templates.FileTemplate>(null, out _).Should().BeFalse(); [TestMethod] - public void empty_is_valid() => Tests("", true, Environment.OSVersion.Platform); + public void empty_is_valid() => Tests("", true); [TestMethod] - public void whitespace_is_valid() => Tests(" ", true, Environment.OSVersion.Platform); + public void whitespace_is_valid() => Tests(" ", true); [TestMethod] - [DataRow(@"C:\", false, PlatformID.Win32NT)] - [DataRow(@"/", false, PlatformID.Unix)] - [DataRow(@"foo", true, PlatformID.Win32NT)] - [DataRow(@"foo", true, PlatformID.Unix)] - [DataRow(@"\foo", false, PlatformID.Win32NT)] - [DataRow(@"\foo", true, PlatformID.Unix)] - [DataRow(@"/foo", false, PlatformID.Win32NT)] - [DataRow(@"<id>", true, PlatformID.Win32NT)] - [DataRow(@"<id>", true, PlatformID.Unix)] - public void Tests(string template, bool expected, PlatformID platformID) + [DataRow(@"foo", true)] + [DataRow(@"\foo", true)] + [DataRow(@"foo\", true)] + [DataRow(@"\foo\", true)] + [DataRow(@"foo\bar", true)] + [DataRow(@"<id>", true)] + [DataRow(@"<id>\<title>", true)] + public void Tests(string template, bool expected) { - if (Environment.OSVersion.Platform == platformID) - Templates.File.IsValid(template).Should().Be(expected); + Templates.TryGetTemplate<Templates.FileTemplate>(template, out var folderTemplate).Should().BeTrue(); + folderTemplate.IsValid.Should().Be(expected); } } @@ -499,13 +624,13 @@ namespace Templates_ChapterFile_Tests public class GetWarnings { [TestMethod] - public void null_is_invalid() => Tests(null, null, new[] { Templates.ERROR_NULL_IS_INVALID }); + public void null_is_invalid() => Tests(null, null, new[] { NamingTemplate.ERROR_NULL_IS_INVALID, Templates.WARNING_NO_CHAPTER_NUMBER_TAG }); [TestMethod] - public void empty_has_warnings() => Tests("", null, Templates.WARNING_EMPTY, Templates.WARNING_NO_TAGS, Templates.WARNING_NO_CHAPTER_NUMBER_TAG); + public void empty_has_warnings() => Tests("", null, NamingTemplate.WARNING_EMPTY, NamingTemplate.WARNING_NO_TAGS, Templates.WARNING_NO_CHAPTER_NUMBER_TAG); [TestMethod] - public void whitespace_has_warnings() => Tests(" ", null, Templates.WARNING_WHITE_SPACE, Templates.WARNING_NO_TAGS, Templates.WARNING_NO_CHAPTER_NUMBER_TAG); + public void whitespace_has_warnings() => Tests(" ", null, NamingTemplate.WARNING_WHITE_SPACE, NamingTemplate.WARNING_NO_TAGS, Templates.WARNING_NO_CHAPTER_NUMBER_TAG); [TestMethod] [DataRow("<ch#>")] @@ -513,18 +638,20 @@ namespace Templates_ChapterFile_Tests public void valid_tests(string template) => Tests(template, null, Array.Empty<string>()); [TestMethod] - [DataRow(@"no tags", null, Templates.WARNING_NO_TAGS, Templates.WARNING_NO_CHAPTER_NUMBER_TAG)] - [DataRow(@"<id>\foo\bar", true, Templates.ERROR_INVALID_FILE_NAME_CHAR, Templates.WARNING_NO_CHAPTER_NUMBER_TAG)] - [DataRow(@"<id>/foo/bar", false, Templates.ERROR_INVALID_FILE_NAME_CHAR, Templates.WARNING_NO_CHAPTER_NUMBER_TAG)] - [DataRow("<chapter count> -- chapter tag but not ch# or ch_#", null, Templates.WARNING_NO_TAGS, Templates.WARNING_NO_CHAPTER_NUMBER_TAG)] + [DataRow(@"no tags", null, NamingTemplate.WARNING_NO_TAGS, Templates.WARNING_NO_CHAPTER_NUMBER_TAG)] + [DataRow(@"<id>\foo\bar", true, Templates.WARNING_NO_CHAPTER_NUMBER_TAG)] + [DataRow(@"<id>/foo/bar", false, Templates.WARNING_NO_CHAPTER_NUMBER_TAG)] + [DataRow("<chapter count> -- chapter tag but not ch# or ch_#", null, NamingTemplate.WARNING_NO_TAGS, Templates.WARNING_NO_CHAPTER_NUMBER_TAG)] public void Tests(string template, bool? windows, params string[] expected) { - if(windows is null + if (windows is null || (windows is true && Environment.OSVersion.Platform is PlatformID.Win32NT) || (windows is false && Environment.OSVersion.Platform is PlatformID.Unix)) { - var result = Templates.ChapterFile.GetWarnings(template); - result.Count().Should().Be(expected.Length); + + Templates.TryGetTemplate<Templates.ChapterFileTemplate>(template, out var chapterFileTemplate); + var result = chapterFileTemplate.Warnings; + result.Should().HaveCount(expected.Length); result.Should().BeEquivalentTo(expected); } } @@ -548,14 +675,18 @@ namespace Templates_ChapterFile_Tests [DataRow("<ch#> <id>", false)] [DataRow("<ch#> -- chapter tag", false)] [DataRow("<chapter count> -- chapter tag but not ch# or ch_#", true)] - public void Tests(string template, bool expected) => Templates.ChapterFile.HasWarnings(template).Should().Be(expected); + public void Tests(string template, bool expected) + { + Templates.TryGetTemplate<Templates.ChapterFileTemplate>(template, out var chapterFileTemplate); + chapterFileTemplate.HasWarnings.Should().Be(expected); + } } [TestClass] public class TagCount { [TestMethod] - public void null_is_not_recommended() => Assert.ThrowsException<NullReferenceException>(() => Tests(null, -1)); + public void null_is_not_recommended() => Templates.TryGetTemplate<Templates.ChapterFileTemplate>(null, out _).Should().BeFalse(); [TestMethod] public void empty_is_not_recommended() => Tests("", 0); @@ -573,11 +704,15 @@ namespace Templates_ChapterFile_Tests [DataRow("<not a real tag>", 0)] [DataRow("<ch#> non-folder tag", 1)] [DataRow("<ID> case specific", 0)] - public void Tests(string template, int expected) => Templates.ChapterFile.TagCount(template).Should().Be(expected); + public void Tests(string template, int expected) + { + Templates.TryGetTemplate<Templates.ChapterFileTemplate>(template, out var chapterFileTemplate).Should().BeTrue(); + chapterFileTemplate.TagsInUse.Count().Should().Be(expected); + } } [TestClass] - public class GetPortionFilename + public class GetFilename { static readonly ReplacementCharacters Default = ReplacementCharacters.Default; @@ -589,8 +724,13 @@ namespace Templates_ChapterFile_Tests public void Tests(string template, string dir, string ext, int pos, int total, string chapter, string expected, PlatformID platformID) { if (Environment.OSVersion.Platform == platformID) - Templates.ChapterFile.GetPortionFilename(GetLibraryBook(), template, new() { OutputFileName = $"xyz.{ext}", PartsPosition = pos, PartsTotal = total, Title = chapter }, dir, Default) - .Should().Be(expected); + { + Templates.TryGetTemplate<Templates.ChapterFileTemplate>(template, out var chapterTemplate).Should().BeTrue(); + chapterTemplate + .GetFilename(GetLibraryBook(), new() { OutputFileName = $"xyz.{ext}", PartsPosition = pos, PartsTotal = total, Title = chapter }, dir, ext, Default) + .PathWithoutPrefix + .Should().Be(expected); + } } } } From c72b64d74cb54d349662ece47dca4cc3b14d795f Mon Sep 17 00:00:00 2001 From: Mbucari <mbucari1@gmail.com> Date: Fri, 3 Feb 2023 09:53:40 -0700 Subject: [PATCH 2/7] Properly truncate filenames --- Source/FileLiberator/AudioFileStorageExt.cs | 4 +- .../NamingTemplate/NamingTemplate.cs | 12 ++-- Source/LibationFileManager/Templates.cs | 55 +++++++++---------- .../TemplatesTests.cs | 38 +++++++++---- 4 files changed, 62 insertions(+), 47 deletions(-) diff --git a/Source/FileLiberator/AudioFileStorageExt.cs b/Source/FileLiberator/AudioFileStorageExt.cs index 913bc18a..dc96fa27 100644 --- a/Source/FileLiberator/AudioFileStorageExt.cs +++ b/Source/FileLiberator/AudioFileStorageExt.cs @@ -25,12 +25,12 @@ namespace FileLiberator if (seriesParent is not null) { - var baseDir = Templates.Folder.GetFilename(seriesParent.ToDto(), "", ""); + var baseDir = Templates.Folder.GetFilename(seriesParent.ToDto(), AudibleFileStorage.BooksDirectory, ""); return Templates.Folder.GetFilename(libraryBook.ToDto(), baseDir, ""); } } } - return Templates.Folder.GetFilename(libraryBook.ToDto(), "", ""); + return Templates.Folder.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, ""); } /// <summary> diff --git a/Source/FileManager/NamingTemplate/NamingTemplate.cs b/Source/FileManager/NamingTemplate/NamingTemplate.cs index dc6fbc71..4d5016da 100644 --- a/Source/FileManager/NamingTemplate/NamingTemplate.cs +++ b/Source/FileManager/NamingTemplate/NamingTemplate.cs @@ -106,7 +106,7 @@ public class NamingTemplate while (templateString.Length > 0) { - if (StartsWith(Classes, templateString, out string exactPropertyName, out var propertyTag, out var valueExpression)) + if (StartsWith(templateString, out string exactPropertyName, out var propertyTag, out var valueExpression)) { checkAndAddLiterals(); @@ -120,7 +120,7 @@ public class NamingTemplate templateString = templateString[exactPropertyName.Length..]; } - else if (StartsWithClosing(Classes, templateString, out exactPropertyName, out var closingPropertyTag)) + else if (StartsWithClosing(templateString, out exactPropertyName, out var closingPropertyTag)) { checkAndAddLiterals(); @@ -176,9 +176,9 @@ public class NamingTemplate } } - private static bool StartsWith(IEnumerable<TagClass> propertyClasses, string template, out string exactName, out IPropertyTag propertyTag, out Expression valueExpression) + private bool StartsWith(string template, out string exactName, out IPropertyTag propertyTag, out Expression valueExpression) { - foreach (var pc in propertyClasses) + foreach (var pc in Classes) { if (pc.StartsWith(template, out exactName, out propertyTag, out valueExpression)) return true; @@ -189,9 +189,9 @@ public class NamingTemplate return false; } - private static bool StartsWithClosing(IEnumerable<TagClass> conditionalGroups, string template, out string exactName, out IClosingPropertyTag closingPropertyTag) + private bool StartsWithClosing(string template, out string exactName, out IClosingPropertyTag closingPropertyTag) { - foreach (var pc in conditionalGroups) + foreach (var pc in Classes) { if (pc.StartsWithClosing(template, out exactName, out closingPropertyTag)) return true; diff --git a/Source/LibationFileManager/Templates.cs b/Source/LibationFileManager/Templates.cs index a0f8afbc..d42e70a0 100644 --- a/Source/LibationFileManager/Templates.cs +++ b/Source/LibationFileManager/Templates.cs @@ -50,33 +50,22 @@ namespace LibationFileManager static Templates() { - Configuration.Instance.PropertyChanged += FolderTemplate_PropertyChanged; - Configuration.Instance.PropertyChanged += FileTemplate_PropertyChanged; - Configuration.Instance.PropertyChanged += ChapterFileTemplate_PropertyChanged; - Configuration.Instance.PropertyChanged += ChapterTitleTemplate_PropertyChanged; - } + Configuration.Instance.PropertyChanged += + [PropertyChangeFilter(nameof(Configuration.FolderTemplate))] + (_,e) => _folder = GetTemplate<FolderTemplate>((string)e.NewValue); - [PropertyChangeFilter(nameof(Configuration.FolderTemplate))] - private static void FolderTemplate_PropertyChanged(object sender, PropertyChangedEventArgsEx e) - { - _folder = GetTemplate<FolderTemplate>((string)e.NewValue); - } - [PropertyChangeFilter(nameof(Configuration.FileTemplate))] - private static void FileTemplate_PropertyChanged(object sender, PropertyChangedEventArgsEx e) - { - _file = GetTemplate<FileTemplate>((string)e.NewValue); - } - [PropertyChangeFilter(nameof(Configuration.ChapterFileTemplate))] - private static void ChapterFileTemplate_PropertyChanged(object sender, PropertyChangedEventArgsEx e) - { - _chapterFile = GetTemplate<ChapterFileTemplate>((string)e.NewValue); - } - [PropertyChangeFilter(nameof(Configuration.ChapterTitleTemplate))] - private static void ChapterTitleTemplate_PropertyChanged(object sender, PropertyChangedEventArgsEx e) - { - _chapterTitle = GetTemplate<ChapterTitleTemplate>((string)e.NewValue); - } + Configuration.Instance.PropertyChanged + += [PropertyChangeFilter(nameof(Configuration.FileTemplate))] + (_, e) => _file = GetTemplate<FileTemplate>((string)e.NewValue); + Configuration.Instance.PropertyChanged + += [PropertyChangeFilter(nameof(Configuration.ChapterFileTemplate))] + (_, e) => _chapterFile = GetTemplate<ChapterFileTemplate>((string)e.NewValue); + + Configuration.Instance.PropertyChanged + += [PropertyChangeFilter(nameof(Configuration.ChapterTitleTemplate))] + (_, e) => _chapterTitle = GetTemplate<ChapterTitleTemplate>((string)e.NewValue); + } #endregion #region Template Properties @@ -87,6 +76,7 @@ namespace LibationFileManager public string TemplateText => Template.TemplateText; protected NamingTemplate Template { get; private set; } + #endregion #region validation @@ -134,15 +124,24 @@ namespace LibationFileManager private LongPath GetFilename(string baseDir, string fileExtension, bool returnFirstExisting, ReplacementCharacters replacements, params object[] dtos) { - var parts = Template.Evaluate(dtos).ToList(); + fileExtension = FileUtility.GetStandardizedExtension(fileExtension); + var parts = Template.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 - foreach (var part in pathParts) + for (int i = 0; i < pathParts.Count; i++) { - while (part.Sum(LongPath.GetFilesystemStringLength) > LongPath.MaxFilenameLength) + 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(LongPath.GetFilesystemStringLength) > maxFilenameLength) { int maxLength = part.Max(p => p.Length); var maxEntry = part.First(p => p.Length == maxLength); diff --git a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs index 89c2bacb..46398993 100644 --- a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -297,9 +297,15 @@ namespace Templates_Other static ReplacementCharacters Replacements = ReplacementCharacters.Default; [TestMethod] - [DataRow(@"C:\foo\bar", @"C:\foo\bar\Folder\my꞉ book 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\[ID123456].txt", PlatformID.Win32NT)] - [DataRow(@"/foo/bar", @"/foo/bar/Folder/my: book 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000/[ID123456].txt", PlatformID.Unix)] - public void equiv_GetValidFilename(string dirFullPath, string expected, PlatformID platformID) + [DataRow(@"C:\foo\bar", @"\Folder\<title>\[<id>]\", @"C:\foo\bar\Folder\my꞉ book 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\[ID123456].txt", PlatformID.Win32NT)] + [DataRow("/foo/bar", "/Folder/<title>/[<id>]/", @" / foo/bar/Folder/my: book 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000/[ID123456].txt", PlatformID.Unix)] + [DataRow(@"C:\foo\bar", @"\Folder\<title> [<id>]", @"C:\foo\bar\Folder\my꞉ book 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 [ID123456].txt", PlatformID.Win32NT)] + [DataRow("/foo/bar", "/Folder/<title> [<id>]", @"/foo/bar/Folder/my: book 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 [ID123456].txt", PlatformID.Unix)] + [DataRow(@"C:\foo\bar", @"\Folder\<title> <title> <title> <title> <title> <title> <title> <title> <title> [<id>]", @"C:\foo\bar\Folder\my꞉ book 0000000000000000 my꞉ book 0000000000000000 my꞉ book 0000000000000000 my꞉ book 0000000000000000 my꞉ book 0000000000000000 my꞉ book 0000000000000000 my꞉ book 0000000000000000 my꞉ book 00000000000000000 my꞉ book 00000000000000000 [ID123456].txt", PlatformID.Win32NT)] + [DataRow("/foo/bar", "/Folder/<title> <title> <title> <title> <title> <title> <title> <title> <title> [<id>]", @"/foo/bar/Folder/my: book 0000000000000000 my: book 0000000000000000 my: book 0000000000000000 my: book 0000000000000000 my: book 0000000000000000 my: book 0000000000000000 my: book 0000000000000000 my: book 00000000000000000 my: book 00000000000000000 [ID123456].txt", PlatformID.Unix)] + [DataRow(@"C:\foo\bar", @"\<title>\<title> [<id>]", @"C:\foo\bar\my꞉ book 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\my꞉ book 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 [ID123456].txt", PlatformID.Win32NT)] + [DataRow("/foo/bar", @"/<title>/<title> [<id>]", "/foo/bar/my: book 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000/my: book 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 [ID123456].txt", PlatformID.Unix)] + public void Test_trim_to_max_path(string dirFullPath, string template, string expected, PlatformID platformID) { if (Environment.OSVersion.Platform != platformID) return; @@ -308,7 +314,21 @@ namespace Templates_Other sb.Append('0', 300); var longText = sb.ToString(); - NEW_GetValidFilename_FileNamingTemplate(dirFullPath, "my: book " + longText, "txt", "ID123456").Should().Be(expected); + NEW_GetValidFilename_FileNamingTemplate(dirFullPath, template, "my: book " + longText, "txt").Should().Be(expected); + } + + [TestMethod] + [DataRow(@"\foo\bar", @"<title>\<title>")] + [DataRow(@"\foooo\barrrr", "<title>")] + public void Test_windows_relative_path_too_long(string baseDir, string template) + { + if (Environment.OSVersion.Platform != PlatformID.Win32NT) + return; + + var sb = new System.Text.StringBuilder(); + sb.Append('0', 300); + var longText = sb.ToString(); + Assert.ThrowsException<PathTooLongException>(() => NEW_GetValidFilename_FileNamingTemplate(baseDir, template, "my: book " + longText, "txt")); } private class TemplateTag : ITemplateTag @@ -318,17 +338,13 @@ namespace Templates_Other public string Description { get; } public string Display { get; } } - private static string NEW_GetValidFilename_FileNamingTemplate(string dirFullPath, string filename, string extension, string metadataSuffix) + private static string NEW_GetValidFilename_FileNamingTemplate(string dirFullPath, string template, string title, string extension) { - char slash = Path.DirectorySeparatorChar; - - var template = $"{slash}Folder{slash}<title>{slash}[<id>]{slash}"; - extension = FileUtility.GetStandardizedExtension(extension); var lbDto = GetLibraryBook(); - lbDto.Title = filename; - lbDto.AudibleProductId = metadataSuffix; + lbDto.Title = title; + lbDto.AudibleProductId = "ID123456"; Templates.TryGetTemplate<Templates.FolderTemplate>(template, out var fileNamingTemplate).Should().BeTrue(); From 5c7db6cd23b738e92552eb4b0029491e38fa51d5 Mon Sep 17 00:00:00 2001 From: Mbucari <mbucari1@gmail.com> Date: Fri, 3 Feb 2023 10:08:26 -0700 Subject: [PATCH 3/7] Add <series#> tag zero padding (#466) --- Source/FileLiberator/DownloadOptions.cs | 6 +- Source/FileLiberator/UtilityExtensions.cs | 2 +- .../NamingTemplate/NamingTemplate.cs | 62 ++++++++++--------- .../PropertyTagClass[TClass].cs | 13 ++-- Source/LibationFileManager/LibraryBookDto.cs | 3 +- .../LibationFileManager/TemplateEditor[T].cs | 2 +- Source/LibationFileManager/TemplateTags.cs | 3 +- Source/LibationFileManager/Templates.cs | 49 +++++++-------- .../TemplatesTests.cs | 6 +- 9 files changed, 76 insertions(+), 70 deletions(-) diff --git a/Source/FileLiberator/DownloadOptions.cs b/Source/FileLiberator/DownloadOptions.cs index 9efb9fb0..a79678d2 100644 --- a/Source/FileLiberator/DownloadOptions.cs +++ b/Source/FileLiberator/DownloadOptions.cs @@ -35,7 +35,11 @@ namespace FileLiberator public bool MoveMoovToBeginning => config.MoveMoovToBeginning; public string GetMultipartFileName(MultiConvertFileProperties props) - => Templates.ChapterFile.GetFilename(LibraryBookDto, props); + { + var baseDir = Path.GetDirectoryName(props.OutputFileName); + var extension = Path.GetExtension(props.OutputFileName); + return Templates.ChapterFile.GetFilename(LibraryBookDto, props, baseDir, extension); + } public string GetMultipartTitle(MultiConvertFileProperties props) => Templates.ChapterTitle.GetName(LibraryBookDto, props); diff --git a/Source/FileLiberator/UtilityExtensions.cs b/Source/FileLiberator/UtilityExtensions.cs index 22a728d9..b85eee0c 100644 --- a/Source/FileLiberator/UtilityExtensions.cs +++ b/Source/FileLiberator/UtilityExtensions.cs @@ -40,7 +40,7 @@ namespace FileLiberator Narrators = libraryBook.Book.Narrators.Select(c => c.Name).ToList(), SeriesName = libraryBook.Book.SeriesLink.FirstOrDefault()?.Series.Name, - SeriesNumber = libraryBook.Book.SeriesLink.FirstOrDefault()?.Order, + SeriesNumber = (int?)libraryBook.Book.SeriesLink.FirstOrDefault()?.Index, IsPodcast = libraryBook.Book.IsEpisodeChild(), BitRate = libraryBook.Book.AudioFormat.Bitrate, diff --git a/Source/FileManager/NamingTemplate/NamingTemplate.cs b/Source/FileManager/NamingTemplate/NamingTemplate.cs index 4d5016da..17ee7ec3 100644 --- a/Source/FileManager/NamingTemplate/NamingTemplate.cs +++ b/Source/FileManager/NamingTemplate/NamingTemplate.cs @@ -47,7 +47,7 @@ public class NamingTemplate /// <summary>Parse a template string to a <see cref="NamingTemplate"/></summary> /// <param name="template">The template string to parse</param> - /// <param name="tagClasses">A collection of <see cref="ITagClass"/> with + /// <param name="tagClasses">A collection of <see cref="TagClass"/> with /// properties registered to match to the <paramref name="template"/></param> public static NamingTemplate Parse(string template, IEnumerable<TagClass> tagClasses) { @@ -111,10 +111,10 @@ public class NamingTemplate checkAndAddLiterals(); if (propertyTag is IClosingPropertyTag) - currentNode = AddNewNode(currentNode, BinaryNode.CreateConditional(propertyTag.TemplateTag, valueExpression)); + currentNode = currentNode.AddNewNode(BinaryNode.CreateConditional(propertyTag.TemplateTag, valueExpression)); else { - currentNode = AddNewNode(currentNode, BinaryNode.CreateValue(propertyTag.TemplateTag, valueExpression)); + currentNode = currentNode.AddNewNode(BinaryNode.CreateValue(propertyTag.TemplateTag, valueExpression)); _tagsInUse.Add(propertyTag.TemplateTag); } @@ -170,7 +170,7 @@ public class NamingTemplate { if (literalChars.Count != 0) { - currentNode = AddNewNode(currentNode, BinaryNode.CreateValue(new string(literalChars.ToArray()))); + currentNode = currentNode.AddNewNode(BinaryNode.CreateValue(new string(literalChars.ToArray()))); literalChars.Clear(); } } @@ -201,34 +201,12 @@ public class NamingTemplate return false; } - private static BinaryNode AddNewNode(BinaryNode currentNode, BinaryNode newNode) - { - if (currentNode.LeftChild is null) - { - newNode.Parent = currentNode; - currentNode.LeftChild = newNode; - } - else if (currentNode.RightChild is null) - { - newNode.Parent = currentNode; - currentNode.RightChild = newNode; - } - else - { - currentNode.RightChild = BinaryNode.CreateConcatenation(currentNode.RightChild, newNode); - currentNode.RightChild.Parent = currentNode; - currentNode = currentNode.RightChild; - } - - return newNode.IsConditional ? newNode : currentNode; - } - private class BinaryNode { public string Name { get; } - public BinaryNode Parent { get; set; } - public BinaryNode RightChild { get; set; } - public BinaryNode LeftChild { get; set; } + public BinaryNode Parent { get; private set; } + public BinaryNode RightChild { get; private set; } + public BinaryNode LeftChild { get; private set; } public Expression Expression { get; private init; } public bool IsConditional { get; private init; } = false; public bool IsValue { get; private init; } = false; @@ -253,7 +231,7 @@ public class NamingTemplate Expression = property }; - public static BinaryNode CreateConcatenation(BinaryNode left, BinaryNode right) + private static BinaryNode CreateConcatenation(BinaryNode left, BinaryNode right) { var newNode = new BinaryNode("Concatenation") { @@ -267,5 +245,29 @@ public class NamingTemplate private BinaryNode(string name) => Name = name; public override string ToString() => Name; + + public BinaryNode AddNewNode(BinaryNode newNode) + { + BinaryNode currentNode = this; + + if (LeftChild is null) + { + newNode.Parent = currentNode; + LeftChild = newNode; + } + else if (RightChild is null) + { + newNode.Parent = currentNode; + RightChild = newNode; + } + else + { + RightChild = CreateConcatenation(RightChild, newNode); + RightChild.Parent = currentNode; + currentNode = RightChild; + } + + return newNode.IsConditional ? newNode : currentNode; + } } } diff --git a/Source/FileManager/NamingTemplate/PropertyTagClass[TClass].cs b/Source/FileManager/NamingTemplate/PropertyTagClass[TClass].cs index 1d53f7ae..04eac040 100644 --- a/Source/FileManager/NamingTemplate/PropertyTagClass[TClass].cs +++ b/Source/FileManager/NamingTemplate/PropertyTagClass[TClass].cs @@ -19,7 +19,7 @@ public class PropertyTagClass<TClass> : TagClass /// <param name="formatter">Optional formatting function that accepts the <typeparamref name="U"/> property and a formatting string and returnes the value formatted to string</param> public void RegisterProperty<U>(ITemplateTag templateTag, Func<TClass, U?> propertyGetter, PropertyFormatter<U> formatter = null) where U : struct - => RegisterProperty(templateTag, propertyGetter, formatter?.Method); + => RegisterPropertyInternal(templateTag, propertyGetter, formatter); /// <summary> /// Register a non-nullable value type property @@ -29,7 +29,7 @@ public class PropertyTagClass<TClass> : TagClass /// <param name="formatter">Optional formatting function that accepts the <typeparamref name="U"/> property and a formatting string and returnes the value formatted to string</param> public void RegisterProperty<U>(ITemplateTag templateTag, Func<TClass, U> propertyGetter, PropertyFormatter<U> formatter = null) where U : struct - => RegisterProperty(templateTag, propertyGetter, formatter?.Method); + => RegisterPropertyInternal(templateTag, propertyGetter, formatter); /// <summary> /// Register a string type property. @@ -37,13 +37,16 @@ public class PropertyTagClass<TClass> : TagClass /// <param name="propertyGetter">A Func to get the string property from <see cref="TClass"/></param> /// <param name="formatter">Optional formatting function that accepts the string property and a formatting string and returnes the value formatted to string</param> public void RegisterProperty(ITemplateTag templateTag, Func<TClass, string> propertyGetter, PropertyFormatter<string> formatter = null) - => RegisterProperty(templateTag, propertyGetter, formatter?.Method); + => RegisterPropertyInternal(templateTag, propertyGetter, formatter); - private void RegisterProperty(ITemplateTag templateTag, Delegate propertyGetter, MethodInfo formatter) + private void RegisterPropertyInternal(ITemplateTag templateTag, Delegate propertyGetter, Delegate formatter) { + if (formatter?.Target is not null) + throw new ArgumentException($"{nameof(formatter)} must be a static method"); + var expr = Expression.Call(Expression.Constant(propertyGetter.Target), propertyGetter.Method, Parameter); - AddPropertyTag(new PropertyTag(templateTag, Options, expr, formatter)); + AddPropertyTag(new PropertyTag(templateTag, Options, expr, formatter?.Method)); } private class PropertyTag : TagBase diff --git a/Source/LibationFileManager/LibraryBookDto.cs b/Source/LibationFileManager/LibraryBookDto.cs index 60114040..859f18c9 100644 --- a/Source/LibationFileManager/LibraryBookDto.cs +++ b/Source/LibationFileManager/LibraryBookDto.cs @@ -20,7 +20,8 @@ namespace LibationFileManager public string FirstNarrator => Narrators.FirstOrDefault(); public string SeriesName { get; set; } - public string SeriesNumber { get; set; } + public int? SeriesNumber { get; set; } + public bool IsSeries => !string.IsNullOrEmpty(SeriesName); public bool IsPodcast { get; set; } public int BitRate { get; set; } diff --git a/Source/LibationFileManager/TemplateEditor[T].cs b/Source/LibationFileManager/TemplateEditor[T].cs index d20663b9..a7226de2 100644 --- a/Source/LibationFileManager/TemplateEditor[T].cs +++ b/Source/LibationFileManager/TemplateEditor[T].cs @@ -58,7 +58,7 @@ namespace LibationFileManager Authors = new List<string> { "Arthur Conan Doyle", "Stephen Fry - introductions" }, Narrators = new List<string> { "Stephen Fry" }, SeriesName = "Sherlock Holmes", - SeriesNumber = "1", + SeriesNumber = 1, BitRate = 128, SampleRate = 44100, Channels = 2, diff --git a/Source/LibationFileManager/TemplateTags.cs b/Source/LibationFileManager/TemplateTags.cs index 879363a0..8adcd4d6 100644 --- a/Source/LibationFileManager/TemplateTags.cs +++ b/Source/LibationFileManager/TemplateTags.cs @@ -45,7 +45,8 @@ namespace LibationFileManager public static TemplateTags FileDate { get; } = new TemplateTags("file date", "File date/time. e.g. yyyy-MM-dd HH-mm", $"<file date [{DEFAULT_DATE_FORMAT}]>", "<file date [...]>"); public static TemplateTags DatePublished { get; } = new TemplateTags("pub date", "Publication date. e.g. yyyy-MM-dd", $"<pub date [{DEFAULT_DATE_FORMAT}]>", "<pub date [...]>"); public static TemplateTags DateAdded { get; } = new TemplateTags("date added", "Date added to your Audible account. e.g. yyyy-MM-dd", $"<date added [{DEFAULT_DATE_FORMAT}]>", "<date added [...]>"); - public static TemplateTags IfSeries { get; } = new TemplateTags("if series", "Only include if part of a series", "<if series-><-if series>", "<if series->...<-if series>"); + public static TemplateTags IfSeries { get; } = new TemplateTags("if series", "Only include if part of a book series or podcast", "<if series-><-if series>", "<if series->...<-if series>"); public static TemplateTags IfPodcast { get; } = new TemplateTags("if podcast", "Only include if part of a podcast", "<if podcast-><-if podcast>", "<if podcast->...<-if podcast>"); + public static TemplateTags IfBookseries { get; } = new TemplateTags("if bookseries", "Only include if part of a book series", "<if bookseries-><-if bookseries>", "<if bookseries->...<-if bookseries>"); } } diff --git a/Source/LibationFileManager/Templates.cs b/Source/LibationFileManager/Templates.cs index d42e70a0..e62a62b2 100644 --- a/Source/LibationFileManager/Templates.cs +++ b/Source/LibationFileManager/Templates.cs @@ -6,6 +6,7 @@ using AaxDecrypter; using Dinah.Core; using FileManager; using FileManager.NamingTemplate; +using Serilog.Formatting; namespace LibationFileManager { @@ -105,24 +106,24 @@ namespace LibationFileManager ArgumentValidator.EnsureNotNull(fileExtension, nameof(fileExtension)); replacements ??= Configuration.Instance.ReplacementCharacters; - return GetFilename(baseDir, fileExtension, returnFirstExisting, replacements, libraryBookDto); + return GetFilename(baseDir, fileExtension,replacements, returnFirstExisting, libraryBookDto); } - public LongPath GetFilename(LibraryBookDto libraryBookDto, MultiConvertFileProperties multiChapProps, string baseDir = "", string fileExtension = null, ReplacementCharacters replacements = null) + 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; - fileExtension ??= Path.GetExtension(multiChapProps.OutputFileName); - return GetFilename(baseDir, fileExtension, false, replacements, libraryBookDto, multiChapProps); + return GetFilename(baseDir, fileExtension, replacements, returnFirstExisting, libraryBookDto, multiChapProps); } protected virtual IEnumerable<string> GetTemplatePartsStrings(List<TemplatePart> parts, ReplacementCharacters replacements) => parts.Select(p => replacements.ReplaceFilenameChars(p.Value)); - private LongPath GetFilename(string baseDir, string fileExtension, bool returnFirstExisting, ReplacementCharacters replacements, params object[] dtos) + private LongPath GetFilename(string baseDir, string fileExtension, ReplacementCharacters replacements, bool returnFirstExisting, params object[] dtos) { fileExtension = FileUtility.GetStandardizedExtension(fileExtension); @@ -151,14 +152,15 @@ namespace LibationFileManager part.Insert(maxIndex, maxEntry.Remove(maxLength - 1, 1)); } } - - var fullPath = Path.Combine(pathParts.Select(p => string.Join("", p)).Prepend(baseDir).ToArray()); + //Any + var fullPath = Path.Combine(pathParts.Select(fileParts => string.Join("", fileParts)).Prepend(baseDir).ToArray()); return FileUtility.GetValidFilename(fullPath, replacements, fileExtension, returnFirstExisting); } /// <summary> - /// Organize template parts into directories. + /// Organize template parts into directories. Any Extra slashes will be + /// returned as empty directories and are taken care of by Path.Combine() /// </summary> /// <returns>A List of template directories. Each directory is a list of template part strings</returns> private List<List<string>> GetPathParts(IEnumerable<string> templateParts) @@ -196,8 +198,9 @@ namespace LibationFileManager { ConditionalTagClass<LibraryBookDto> lbConditions = new(); - lbConditions.RegisterCondition(TemplateTags.IfSeries, lb => !string.IsNullOrWhiteSpace(lb.SeriesName)); + lbConditions.RegisterCondition(TemplateTags.IfSeries, lb => lb.IsSeries); lbConditions.RegisterCondition(TemplateTags.IfPodcast, lb => lb.IsPodcast); + lbConditions.RegisterCondition(TemplateTags.IfBookseries, lb => lb.IsSeries && !lb.IsPodcast); return lbConditions; } @@ -206,16 +209,16 @@ namespace LibationFileManager { PropertyTagClass<LibraryBookDto> lbProperties = new(); lbProperties.RegisterProperty(TemplateTags.Id, lb => lb.AudibleProductId); - lbProperties.RegisterProperty(TemplateTags.Title, lb => lb.Title ?? "", StringFormatter); + lbProperties.RegisterProperty(TemplateTags.Title, lb => lb.Title, StringFormatter); lbProperties.RegisterProperty(TemplateTags.TitleShort, lb => lb.Title.IndexOf(':') < 1 ? lb.Title : lb.Title.Substring(0, lb.Title.IndexOf(':')), StringFormatter); lbProperties.RegisterProperty(TemplateTags.Author, lb => lb.AuthorNames, StringFormatter); lbProperties.RegisterProperty(TemplateTags.FirstAuthor, lb => lb.FirstAuthor, StringFormatter); lbProperties.RegisterProperty(TemplateTags.Narrator, lb => lb.NarratorNames, StringFormatter); lbProperties.RegisterProperty(TemplateTags.FirstNarrator, lb => lb.FirstNarrator, StringFormatter); - lbProperties.RegisterProperty(TemplateTags.Series, lb => lb.SeriesName ?? "", StringFormatter); - lbProperties.RegisterProperty(TemplateTags.SeriesNumber, lb => lb.SeriesNumber); - lbProperties.RegisterProperty(TemplateTags.Language, lb => lb.Language); - lbProperties.RegisterProperty(TemplateTags.LanguageShort, lb => getLanguageShort(lb.Language)); + lbProperties.RegisterProperty(TemplateTags.Series, lb => lb.SeriesName, StringFormatter); + lbProperties.RegisterProperty(TemplateTags.SeriesNumber, lb => lb.SeriesNumber, IntegerFormatter); + lbProperties.RegisterProperty(TemplateTags.Language, lb => lb.Language, StringFormatter); + lbProperties.RegisterProperty(TemplateTags.LanguageShort, lb => getLanguageShort(lb.Language), StringFormatter); lbProperties.RegisterProperty(TemplateTags.Bitrate, lb => lb.BitRate, IntegerFormatter); lbProperties.RegisterProperty(TemplateTags.SampleRate, lb => lb.SampleRate, IntegerFormatter); lbProperties.RegisterProperty(TemplateTags.Channels, lb => lb.Channels, IntegerFormatter); @@ -233,14 +236,15 @@ namespace LibationFileManager PropertyTagClass<LibraryBookDto> lbProperties = new(); PropertyTagClass<MultiConvertFileProperties> multiConvertProperties = new(); - lbProperties.RegisterProperty(TemplateTags.Title, lb => lb.Title ?? ""); - lbProperties.RegisterProperty(TemplateTags.TitleShort, lb => lb.Title.IndexOf(':') < 1 ? lb.Title : lb.Title.Substring(0, lb.Title.IndexOf(':'))); - lbProperties.RegisterProperty(TemplateTags.Series, lb => lb.SeriesName ?? ""); + lbProperties.RegisterProperty(TemplateTags.Title, lb => lb.Title, StringFormatter); + lbProperties.RegisterProperty(TemplateTags.TitleShort, lb => lb?.Title?.IndexOf(':') > 0 ? lb.Title.Substring(0, lb.Title.IndexOf(':')) : lb.Title, StringFormatter); + lbProperties.RegisterProperty(TemplateTags.Series, lb => lb.SeriesName, StringFormatter); multiConvertProperties.RegisterProperty(TemplateTags.ChCount, lb => lb.PartsTotal, IntegerFormatter); multiConvertProperties.RegisterProperty(TemplateTags.ChNumber, lb => lb.PartsPosition, IntegerFormatter); multiConvertProperties.RegisterProperty(TemplateTags.ChNumber0, m => m.PartsPosition.ToString("D" + ((int)Math.Log10(m.PartsTotal) + 1))); - multiConvertProperties.RegisterProperty(TemplateTags.ChTitle, m => m.Title ?? "", StringFormatter); + multiConvertProperties.RegisterProperty(TemplateTags.ChTitle, m => m.Title, StringFormatter); + multiConvertProperties.RegisterProperty(TemplateTags.FileDate, lb => lb.FileDate, DateTimeFormatter); return new List<TagClass> { lbProperties, multiConvertProperties }; } @@ -303,15 +307,6 @@ namespace LibationFileManager else tp.Value = replacements.ReplaceFilenameChars(tp.Value); } - if (parts.Count > 0) - { - //Remove DirectorySeparatorChar at beginning and end of template - if (parts[0].Value.Length > 0 && parts[0].Value[0] == Path.DirectorySeparatorChar) - parts[0].Value = parts[0].Value.Remove(0,1); - - if (parts[^1].Value.Length > 0 && parts[^1].Value[^1] == Path.DirectorySeparatorChar) - parts[^1].Value = parts[^1].Value.Remove(parts[^1].Value.Length - 1, 1); - } return parts.Select(p => p.Value).ToList(); } } diff --git a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs index 46398993..394c7ecc 100644 --- a/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs +++ b/Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs @@ -37,7 +37,7 @@ namespace TemplatesTests Authors = new List<string> { "Arthur Conan Doyle", "Stephen Fry - introductions" }, Narrators = new List<string> { "Stephen Fry" }, SeriesName = seriesName ?? "", - SeriesNumber = "1", + SeriesNumber = 1, BitRate = 128, SampleRate = 44100, Channels = 2, @@ -297,8 +297,8 @@ namespace Templates_Other static ReplacementCharacters Replacements = ReplacementCharacters.Default; [TestMethod] - [DataRow(@"C:\foo\bar", @"\Folder\<title>\[<id>]\", @"C:\foo\bar\Folder\my꞉ book 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\[ID123456].txt", PlatformID.Win32NT)] - [DataRow("/foo/bar", "/Folder/<title>/[<id>]/", @" / foo/bar/Folder/my: book 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000/[ID123456].txt", PlatformID.Unix)] + [DataRow(@"C:\foo\bar", @"\\Folder\<title>\[<id>]\\", @"C:\foo\bar\Folder\my꞉ book 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\[ID123456].txt", PlatformID.Win32NT)] + [DataRow("/foo/bar", "/Folder/<title>/[<id>]/", @"/foo/bar/Folder/my: book 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000/[ID123456].txt", PlatformID.Unix)] [DataRow(@"C:\foo\bar", @"\Folder\<title> [<id>]", @"C:\foo\bar\Folder\my꞉ book 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 [ID123456].txt", PlatformID.Win32NT)] [DataRow("/foo/bar", "/Folder/<title> [<id>]", @"/foo/bar/Folder/my: book 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 [ID123456].txt", PlatformID.Unix)] [DataRow(@"C:\foo\bar", @"\Folder\<title> <title> <title> <title> <title> <title> <title> <title> <title> [<id>]", @"C:\foo\bar\Folder\my꞉ book 0000000000000000 my꞉ book 0000000000000000 my꞉ book 0000000000000000 my꞉ book 0000000000000000 my꞉ book 0000000000000000 my꞉ book 0000000000000000 my꞉ book 0000000000000000 my꞉ book 00000000000000000 my꞉ book 00000000000000000 [ID123456].txt", PlatformID.Win32NT)] From 55577729578ace7ef6070ddb58b46c701f431d9c Mon Sep 17 00:00:00 2001 From: Mbucari <mbucari1@gmail.com> Date: Fri, 3 Feb 2023 11:47:55 -0700 Subject: [PATCH 4/7] Add conditional negation --- .../FileManager/NamingTemplate/ConditionalTagClass[TClass].cs | 4 ++-- Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Source/FileManager/NamingTemplate/ConditionalTagClass[TClass].cs b/Source/FileManager/NamingTemplate/ConditionalTagClass[TClass].cs index 531e58b7..69d3fdc9 100644 --- a/Source/FileManager/NamingTemplate/ConditionalTagClass[TClass].cs +++ b/Source/FileManager/NamingTemplate/ConditionalTagClass[TClass].cs @@ -38,7 +38,7 @@ public class ConditionalTagClass<TClass> : TagClass public ConditionalTag(ITemplateTag templateTag, RegexOptions options, Expression conditionExpression) : base(templateTag, conditionExpression) { - NameMatcher = new Regex($"^<{templateTag.TagName}->", options); + NameMatcher = new Regex($"^<(!)?{templateTag.TagName}->", options); NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options); } @@ -59,6 +59,6 @@ public class ConditionalTagClass<TClass> : TagClass } } - protected override Expression GetTagExpression(string exactName, string formatter) => ExpressionValue; + protected override Expression GetTagExpression(string exactName, string formatter) => formatter == "!" ? Expression.Not(ExpressionValue) : ExpressionValue; } } diff --git a/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs b/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs index 4eed5156..0b7e60e4 100644 --- a/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs +++ b/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs @@ -106,7 +106,7 @@ namespace NamingTemplateTests [DataRow("<item3_1> <item3_2> <item3> <item4>", "prop3_item1 prop3_item2 prop1_item3 prop2_item4", 4)] [DataRow("<ifc1-><item1><-ifc1><ifc2-><item4><-ifc2><ifc3-><item3_2><-ifc3>", "prop1_item1prop3_item2", 3)] [DataRow("<ifc1-><ifc3-><item1><ifc2-><item4><-ifc2><item3_2><-ifc3><-ifc1>", "prop1_item1prop3_item2", 3)] - [DataRow("<ifc2-><ifc1-><ifc3-><item1><item4><item3_2><-ifc3><-ifc1><-ifc2>", "", 3)] + [DataRow("<!ifc2-><ifc1-><ifc3-><item1><item4><item3_2><-ifc3><-ifc1><-ifc2>", "prop1_item1prop2_item4prop3_item2", 3)] public void test(string inStr, string outStr, int numTags) { var template = NamingTemplate.Parse(inStr, new TagClass[] { props1, props2, props3, conditional1, conditional2, conditional3 }); From 1d96d39af7fcfa43944e4956a6602b144471319d Mon Sep 17 00:00:00 2001 From: Mbucari <37587114+Mbucari@users.noreply.github.com> Date: Fri, 3 Feb 2023 14:10:36 -0700 Subject: [PATCH 5/7] Add Naming Template Documentation --- Documentation/Advanced.md | 8 +-- Documentation/NamingTemplates.md | 107 +++++++++++++++++++++++++++++++ README.md | 2 +- 3 files changed, 109 insertions(+), 8 deletions(-) create mode 100644 Documentation/NamingTemplates.md diff --git a/Documentation/Advanced.md b/Documentation/Advanced.md index 28ca18ac..6188cdcd 100644 --- a/Documentation/Advanced.md +++ b/Documentation/Advanced.md @@ -9,7 +9,7 @@ - [Files and folders](#files-and-folders) - [Settings](#settings) -- [Custom File Naming](#custom-file-naming) +- [Custom File Naming](NamingTemplates.md) - [Command Line Interface](#command-line-interface) @@ -28,12 +28,6 @@ To make upgrades and reinstalls easier, Libation separates all of its responsibi * Allow Libation to fix up audiobook metadata. After decrypting a title, Libation attempts to fix details like chapters and cover art. Some power users and/or control freaks prefer to manage this themselves. By unchecking this setting, Libation will only decrypt the book and will leave metadata as-is, warts and all. -### Custom File Naming - -In Settings, on the Download/Decrypt tab, you can specify the format in which you want your files to be named. As you edit these templates, a live example will be shown. Parameters are listed for folders, files, and files split by chapter including an explanation of what each naming option means. For instance: you can use template `<title short> - <ch# 0> of <ch count> - <ch title>` to create the file `A Study in Scarlet - 04 of 10 - A Flight for Life.m4b`. - -These templates apply to GUI and CLI. - ### Command Line Interface Libationcli.exe allows limited access to Libation's functionalities as a CLI. diff --git a/Documentation/NamingTemplates.md b/Documentation/NamingTemplates.md new file mode 100644 index 00000000..02e93017 --- /dev/null +++ b/Documentation/NamingTemplates.md @@ -0,0 +1,107 @@ +# Naming Templates +File and Folder names can be customized using Libation's built-in tag template naming engine. To edit how folder and file names are created, go to Settings \> Download/Decrypt and edit the naming templates. If you're splitting your audiobook into multiple files by chapter, you can also use a custom template to set each chapter's title metadata tag by editing the template in Settings \> Audio File Options. + +These templates apply to both GUI and CLI. + +# Table of Contents + +- [Template Tags](#template-tags) + - [Property Tags](#property-tags) + - [Conditional Tags](#conditional-tags) +- [Tag Formatters](#tag-formatters) + - [Text Formatters](#text-formatters) + - [Integer Formatters](#integer-formatters) + - [Date Formatters](#date-formatters) + + +# Template Tags + +These are the naming template tags currently supported by Libation. + +## Property Tags +These tags will be replaced in the template with the audiobook's values. + +|Tag|Description|Type| +|-|-|-| +|\<id\>|Audible book ID (ASIN)|Text| +|\<title\>|Full title|Text| +|\<title short\>|Title. Stop at first colon|Text| +|\<author\>|Author(s)|Text| +|\<first author\>|First author|Text| +|\<narrator\>|Narrator(s)|Text| +|\<first narrator\>|First narrator|Text| +|\<series\>|Name of series|Text| +|\<series#\>|Number order in series|Text| +|\<bitrate\>|File's original bitrate (Kbps)|Integer| +|\<samplerate\>|File's original audio sample rate|Integer| +|\<channels\>|Number of audio channels|Integer| +|\<account\>|Audible account of this book|Text| +|\<locale\>|Region/country|Text| +|\<year\>|Year published|Integer| +|\<language\>|Book's language|Text| +|\<language short\>|Book's language abbreviated. Eg: ENG|Text| +|\<file date\>|File creation date/time.|DateTime| +|\<pub date\>|Audiobook publication date|DateTime| +|\<date added\>|Date the book added to your Audible account|DateTime| +|\<ch count\>|Number of chapters **†**|Integer| +|\<ch title\>|Chapter title **†**|Text| +|\<ch#\>|Chapter number **†**|Integer| +|\<ch# 0\>|Chapter number with leading zeros **†**|Integer| + +**†** Only valid for Chapter Filename and Chapter Tile Metadata + +To change how these properties are displayed, [read about custom formatters](#tag-formatters) + +## Conditional Tags +Anything between the opening tag (`<tagname->`) and closing tag (`<-tagname>`) will only appear in the name if the condition evaluates to true. + +|Tag|Description|Type| +|-|-|-| +|\<if series-\>...\<-if series\>|Only include if part of a book series or podcast|Conditional| +|\<if podcast-\>...\<-if podcast\>|Only include if part of a podcast|Conditional| +|\<if bookseries-\>...\<-if bookseries\>|Only include if part of a book series|Conditional| + +For example, <if podcast-\>\<series\>\<-if podcast\> will evaluate to the podcast's series name if the file is a podcast. For audiobooks that are not podcasts, that tag will be blank. + +You can invert the condition (instead of displaying the text when the condition is true, display the text when it is false) by playing a '!' symbol before the opening tag name. + +As an example, this folder template will place all Liberated podcasts into a "Podcasts" folder and all liberated books (not podcasts) into a "Books" folder. + +\<if podcast-\>Podcasts<-if podcast\>\<!if podcast-\>Books\<-if podcast\>\\\<title\> + + +# Tag Formatters +**Text**, **Integer**, and **DateTime** tags can be optionally formatted using format text in square brackets after the tag name. Below is a list of supported formatters for each tag type. + +## Text Formatters +|Formatter|Description|Example Usage|Example Result| +|-|-|-|-| +|L|Converts text to lowercase|\<title[L]\>|a study in scarlet꞉ a sherlock holmes novel| +|U|Converts text to uppercase|\<title short[U]\>|A STUDY IN SCARLET| + +## Integer Formatters +|Formatter|Description|Example Usage|Example Result| +|-|-|-|-| +|# (a number)|Zero-pads the number|\<bitrate[4]\><br>\<series#[3]\><br>\<samplerate[6]\>|0128<br>001<br>044100| + +**Text**, **Integer**, and **DateTime** tags can be optionally formatted using format text in square brackets after the tag name. Below is a list of supported formatters for each tag type. + +## Date Formatters +Form more standard formatters, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings). +### Standard DateTime Formatters +|Formatter|Description|Example Usage|Example Result| +|-|-|-|-| +|s|Sortable date/time pattern.|\<file date[s]\>|2023-02-14T13:45:30| +|Y|Year month pattern.|\<file date[Y]\>|February 2023| + +### Custom DateTime Formatters +You can use custom formatters to construct customized DateTime string. For more custom formatters and examples, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings). +|Formatter|Description|Example Usage|Example Result| +|-|-|-|-| +|yyyy|4-digit year|\<file date[yyyy]\>|2023| +|yy|2-digit year|\<file date[yy]\>|23| +|MM|2-digit month|\<file date[MM]\>|02| +|dd|2-digit day of the month|\<file date[yyyy-MM-dd]\>|2023-02-14| +|HH<br>mm|The hour, using a 24-hour clock from 00 to 23<br>The minute, from 00 through 59.|\<file date[HH:mm]\>|14:45| + + diff --git a/README.md b/README.md index 2cdd8819..524a031f 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ - [Advanced](Documentation/Advanced.md) - [Files and folders](Documentation/Advanced.md#files-and-folders) - [Settings](Documentation/Advanced.md#settings) - - [Custom File Naming](Documentation/Advanced.md#custom-file-naming) + - [Custom File Naming](Documentation/NamingTemplates.md) - [Command Line Interface](Documentation/Advanced.md#command-line-interface) - [Docker](Documentation/Docker.md) From 70607aaaf4904f4f97efd139c2106ee12544d31e Mon Sep 17 00:00:00 2001 From: Mbucari <mbucari1@gmail.com> Date: Fri, 3 Feb 2023 14:43:35 -0700 Subject: [PATCH 6/7] Documentation --- Source/FileManager/NamingTemplate/TagBase.cs | 2 +- Source/FileManager/NamingTemplate/TemplatePart.cs | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/Source/FileManager/NamingTemplate/TagBase.cs b/Source/FileManager/NamingTemplate/TagBase.cs index 15c550d1..00622cac 100644 --- a/Source/FileManager/NamingTemplate/TagBase.cs +++ b/Source/FileManager/NamingTemplate/TagBase.cs @@ -16,7 +16,7 @@ internal interface IPropertyTag public Regex NameMatcher { get; } /// <summary> - /// Determine if the template string starts with <see cref="TemplateTag"/>, and if it does parse the tag to an <see cref="ITagExpression"/> + /// Determine if the template string starts with <see cref="TemplateTag"/>, and if it does parse the tag to an <see cref="Expression"/> /// </summary> /// <param name="templateString">Template string</param> /// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param> diff --git a/Source/FileManager/NamingTemplate/TemplatePart.cs b/Source/FileManager/NamingTemplate/TemplatePart.cs index 4ad6afab..b26e8887 100644 --- a/Source/FileManager/NamingTemplate/TemplatePart.cs +++ b/Source/FileManager/NamingTemplate/TemplatePart.cs @@ -104,9 +104,6 @@ public class TemplatePart : IEnumerable<TemplatePart> var last = left.LastPart; last.next = right; right.previous = last; - return left; + return left.FirstPart; } - - public static TemplatePart operator +(TemplatePart left, TemplatePart right) - => Concatenate(left, right); } From 08b6f8fa1190c0a0ed2414d966d816db4eb03c51 Mon Sep 17 00:00:00 2001 From: Mbucari <mbucari1@gmail.com> Date: Fri, 3 Feb 2023 14:46:48 -0700 Subject: [PATCH 7/7] Add test --- Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs b/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs index 0b7e60e4..90a04e15 100644 --- a/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs +++ b/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs @@ -106,6 +106,7 @@ namespace NamingTemplateTests [DataRow("<item3_1> <item3_2> <item3> <item4>", "prop3_item1 prop3_item2 prop1_item3 prop2_item4", 4)] [DataRow("<ifc1-><item1><-ifc1><ifc2-><item4><-ifc2><ifc3-><item3_2><-ifc3>", "prop1_item1prop3_item2", 3)] [DataRow("<ifc1-><ifc3-><item1><ifc2-><item4><-ifc2><item3_2><-ifc3><-ifc1>", "prop1_item1prop3_item2", 3)] + [DataRow("<ifc2-><ifc1-><ifc3-><item1><item4><item3_2><-ifc3><-ifc1><-ifc2>", "", 3)] [DataRow("<!ifc2-><ifc1-><ifc3-><item1><item4><item3_2><-ifc3><-ifc1><-ifc2>", "prop1_item1prop2_item4prop3_item2", 3)] public void test(string inStr, string outStr, int numTags) {