diff --git a/Documentation/NamingTemplates.md b/Documentation/NamingTemplates.md index 02e93017..4c0fea50 100644 --- a/Documentation/NamingTemplates.md +++ b/Documentation/NamingTemplates.md @@ -23,7 +23,7 @@ These tags will be replaced in the template with the audiobook's values. |Tag|Description|Type| |-|-|-| -|\|Audible book ID (ASIN)|Text| +|\ **†**|Audible book ID (ASIN)|Text| |\|Full title|Text| |\|Title. Stop at first colon|Text| |\<author\>|Author(s)|Text| @@ -39,16 +39,18 @@ These tags will be replaced in the template with the audiobook's values. |\<locale\>|Region/country|Text| |\<year\>|Year published|Integer| |\<language\>|Book's language|Text| -|\<language short\>|Book's language abbreviated. Eg: ENG|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| +|\<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 +**†** Does not support custom formatting + +**‡** Only valid for Chapter Filename and Chapter Tile Metadata To change how these properties are displayed, [read about custom formatters](#tag-formatters) diff --git a/Source/FileManager/NamingTemplate/ConditionalTagClass[TClass].cs b/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs similarity index 77% rename from Source/FileManager/NamingTemplate/ConditionalTagClass[TClass].cs rename to Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs index 69d3fdc9..c2d2a8b1 100644 --- a/Source/FileManager/NamingTemplate/ConditionalTagClass[TClass].cs +++ b/Source/FileManager/NamingTemplate/ConditionalTagCollection[TClass].cs @@ -20,17 +20,21 @@ internal interface IClosingPropertyTag : IPropertyTag bool StartsWithClosing(string templateString, out string exactName, out IClosingPropertyTag propertyTag); } -public class ConditionalTagClass<TClass> : TagClass +public class ConditionalTagCollection<TClass> : TagCollection { - public ConditionalTagClass(bool caseSensative = true) :base(typeof(TClass), caseSensative) { } + public ConditionalTagCollection(bool caseSensative = true) :base(typeof(TClass), caseSensative) { } - public void RegisterCondition(ITemplateTag templateTag, Func<TClass, bool> propertyGetter) + /// <summary> + /// Register a conditional tag. + /// </summary> + /// <param name="propertyGetter">A Func to get the condition's <see cref="bool"/> value from <see cref="TClass"/></param> + public void Add(ITemplateTag templateTag, Func<TClass, bool> 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; } @@ -51,14 +55,12 @@ public class ConditionalTagClass<TClass> : TagClass propertyTag = this; return true; } - else - { - exactName = null; - propertyTag = null; - return false; - } + + exactName = null; + propertyTag = null; + return false; } - protected override Expression GetTagExpression(string exactName, string formatter) => formatter == "!" ? Expression.Not(ExpressionValue) : ExpressionValue; + protected override Expression GetTagExpression(string exactName, string formatter) => formatter == "!" ? Expression.Not(ValueExpression) : ValueExpression; } } diff --git a/Source/FileManager/NamingTemplate/NamingTemplate.cs b/Source/FileManager/NamingTemplate/NamingTemplate.cs index 17ee7ec3..8e4a3d0c 100644 --- a/Source/FileManager/NamingTemplate/NamingTemplate.cs +++ b/Source/FileManager/NamingTemplate/NamingTemplate.cs @@ -9,14 +9,14 @@ public class NamingTemplate { public string TemplateText { get; private set; } public IEnumerable<ITemplateTag> TagsInUse => _tagsInUse; - public IEnumerable<ITemplateTag> TagsRegistered => Classes.SelectMany(p => p.TemplateTags).DistinctBy(f => f.TagName); + public IEnumerable<ITemplateTag> TagsRegistered => Classes.SelectMany(t => t).DistinctBy(t => t.TagName); public IEnumerable<string> Warnings => errors.Concat(warnings); public IEnumerable<string> Errors => errors; private Delegate templateToString; private readonly List<string> warnings = new(); private readonly List<string> errors = new(); - private readonly IEnumerable<TagClass> Classes; + private readonly IEnumerable<TagCollection> Classes; private readonly List<ITemplateTag> _tagsInUse = new(); public const string ERROR_NULL_IS_INVALID = "Null template is invalid."; @@ -27,7 +27,7 @@ public class NamingTemplate /// <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> + /// <param name="propertyClasses">Instances of the TClass used in <see cref="PropertyTagCollection{TClass}"/> and <see cref="ConditionalTagCollection{TClass}"/></param> /// <returns></returns> public TemplatePart Evaluate(params object[] propertyClasses) { @@ -47,9 +47,9 @@ 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="TagClass"/> with + /// <param name="tagClasses">A collection of <see cref="TagCollection"/> with /// properties registered to match to the <paramref name="template"/></param> - public static NamingTemplate Parse(string template, IEnumerable<TagClass> tagClasses) + public static NamingTemplate Parse(string template, IEnumerable<TagCollection> tagClasses) { var namingTemplate = new NamingTemplate(tagClasses); try @@ -71,7 +71,7 @@ public class NamingTemplate return namingTemplate; } - private NamingTemplate(IEnumerable<TagClass> properties) + private NamingTemplate(IEnumerable<TagCollection> properties) { Classes = properties; } @@ -183,6 +183,7 @@ public class NamingTemplate if (pc.StartsWith(template, out exactName, out propertyTag, out valueExpression)) return true; } + exactName = null; valueExpression = null; propertyTag = null; @@ -196,6 +197,7 @@ public class NamingTemplate if (pc.StartsWithClosing(template, out exactName, out closingPropertyTag)) return true; } + exactName = null; closingPropertyTag = null; return false; diff --git a/Source/FileManager/NamingTemplate/PropertyTagClass[TClass].cs b/Source/FileManager/NamingTemplate/PropertyTagClass[TClass].cs deleted file mode 100644 index 04eac040..00000000 --- a/Source/FileManager/NamingTemplate/PropertyTagClass[TClass].cs +++ /dev/null @@ -1,88 +0,0 @@ -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 - => RegisterPropertyInternal(templateTag, propertyGetter, formatter); - - /// <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 - => RegisterPropertyInternal(templateTag, propertyGetter, formatter); - - /// <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) - => RegisterPropertyInternal(templateTag, propertyGetter, 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?.Method)); - } - - 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/PropertyTagCollection[TClass].cs b/Source/FileManager/NamingTemplate/PropertyTagCollection[TClass].cs new file mode 100644 index 00000000..58683634 --- /dev/null +++ b/Source/FileManager/NamingTemplate/PropertyTagCollection[TClass].cs @@ -0,0 +1,164 @@ +using Dinah.Core; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Text.RegularExpressions; + +namespace FileManager.NamingTemplate; + +public delegate string PropertyFormatter<T>(ITemplateTag templateTag, T value, string formatString); + +public class PropertyTagCollection<TClass> : TagCollection +{ + private readonly Dictionary<Type, MulticastDelegate> defaultFormatters = new(); + + public PropertyTagCollection(bool caseSensative = true, params MulticastDelegate[] defaultFormatters) : base(typeof(TClass), caseSensative) + { + foreach (var formatter in defaultFormatters) + { + var parameters = formatter.Method.GetParameters(); + + if (formatter.Method.ReturnType != typeof(string) + || parameters.Length != 3 + || parameters[0].ParameterType != typeof(ITemplateTag) + || parameters[2].ParameterType != typeof(string)) + throw new ArgumentException($"{nameof(defaultFormatters)} must have a signature of [{nameof(String)} PropertyFormatter<T>({nameof(ITemplateTag)}, T, {nameof(String)})]"); + + this.defaultFormatters[parameters[1].ParameterType] = formatter; + } + } + + /// <summary> + /// Register a nullable value type <typeparamref name="TClass"/> property. + /// </summary> + /// <typeparam name="TProperty">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="TProperty"/> property + /// and a formatting string and returnes the value the formatted string. If <see cref="null"/>, use the default + /// <typeparamref name="TProperty"/> formatter if present, or <see cref="object.ToString"/></param> + public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty?> propertyGetter, PropertyFormatter<TProperty> formatter = null) + where TProperty : struct + => RegisterWithFormatter(templateTag, propertyGetter, formatter); + + /// <summary> + /// Register a nullable value type <typeparamref name="TClass"/> property. + /// </summary> + /// <param name="propertyGetter">A Func to get the string property from <see cref="TClass"/></param> + /// <param name="toString">ToString function that accepts the <typeparamref name="TProperty"/> property and returnes a string</param> + public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty?> propertyGetter, Func<TProperty, string> toString) + where TProperty : struct + => RegisterWithToString(templateTag, propertyGetter, toString); + + /// <summary> + /// Register a <typeparamref name="TClass"/> property + /// </summary> + /// <typeparam name="TProperty">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="TProperty"/> property + /// and a formatting string and returnes the value formatted to string. If <see cref="null"/>, use the default + /// <typeparamref name="TProperty"/> formatter if present, or <see cref="object.ToString"/></param> + public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PropertyFormatter<TProperty> formatter = null) + => RegisterWithFormatter(templateTag, propertyGetter, formatter); + + /// <summary> + /// Register a <typeparamref name="TClass"/> property. + /// </summary> + /// <param name="propertyGetter">A Func to get the string property from <see cref="TClass"/></param> + /// <param name="toString">ToString function that accepts the <typeparamref name="TProperty"/> property and returnes a string</param> + public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, Func<TProperty, string> toString) + => RegisterWithToString(templateTag, propertyGetter, toString); + + private void RegisterWithFormatter<TProperty, TPropertyValue> + (ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PropertyFormatter<TPropertyValue> formatter) + { + ArgumentValidator.EnsureNotNull(templateTag, nameof(templateTag)); + ArgumentValidator.EnsureNotNull(propertyGetter, nameof(propertyGetter)); + + formatter ??= GetDefaultFormatter<TPropertyValue>(); + + if (formatter is null) + RegisterWithToString<TProperty, TPropertyValue>(templateTag, propertyGetter, null); + else + { + var expr = Expression.Call(Expression.Constant(propertyGetter.Target), propertyGetter.Method, Parameter); + AddPropertyTag(PropertyTag.Create(templateTag, Options, expr, formatter)); + } + } + + private PropertyFormatter<T> GetDefaultFormatter<T>() + { + try + { + var del = defaultFormatters.FirstOrDefault(kvp => kvp.Key == typeof(T)).Value; + return del is null ? null : Delegate.CreateDelegate(typeof(PropertyFormatter<T>), del.Target, del.Method) as PropertyFormatter<T>; + } + catch + { + return null; + } + } + + private void RegisterWithToString<TProperty, TPropertyValue> + (ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, Func<TPropertyValue, string> toString) + { + static string ToStringFunc(TPropertyValue value) => value?.ToString() ?? ""; + ArgumentValidator.EnsureNotNull(templateTag, nameof(templateTag)); + ArgumentValidator.EnsureNotNull(propertyGetter, nameof(propertyGetter)); + + var expr = Expression.Call(Expression.Constant(propertyGetter.Target), propertyGetter.Method, Parameter); + AddPropertyTag(PropertyTag.Create(templateTag, Options, expr, toString ?? ToStringFunc)); + } + + private class PropertyTag : TagBase + { + private Func<Expression, string, Expression> CreateToStringExpression { get; init; } + private PropertyTag(ITemplateTag templateTag, Expression propertyGetter) : base(templateTag, propertyGetter) { } + + public static PropertyTag Create<TPropertyValue>(ITemplateTag templateTag, RegexOptions options, Expression propertyGetter, PropertyFormatter<TPropertyValue> formatter) + { + return new PropertyTag(templateTag, propertyGetter) + { + NameMatcher = new Regex(@$"^<{templateTag.TagName.Replace(" ", "\\s*?")}\s*?(?:\[([^\[\]]*?)\]\s*?)?>", options), + CreateToStringExpression = (expVal, format) => + Expression.Call( + formatter.Target is null ? null : Expression.Constant(formatter.Target), + formatter.Method, + Expression.Constant(templateTag), + expVal, + Expression.Constant(format)) + }; + } + + public static PropertyTag Create<TPropertyValue>(ITemplateTag templateTag, RegexOptions options, Expression propertyGetter, Func<TPropertyValue, string> toString) + { + return new PropertyTag(templateTag, propertyGetter) + { + NameMatcher = new Regex(@$"^<{templateTag.TagName}>", options), + CreateToStringExpression = (expVal, _) => + Expression.Call( + toString.Target is null ? null : Expression.Constant(toString.Target), + toString.Method, + expVal) + }; + } + + protected override Expression GetTagExpression(string exactName, string formatString) + { + Expression toStringExpression + = !ReturnType.IsValueType + ? Expression.Condition( + Expression.Equal(ValueExpression, Expression.Constant(null)), + Expression.Constant(""), + CreateToStringExpression(ValueExpression, formatString)) + : Nullable.GetUnderlyingType(ReturnType) is null + ? CreateToStringExpression(ValueExpression, formatString) + : Expression.Condition( + Expression.PropertyOrField(ValueExpression, "HasValue"), + CreateToStringExpression(Expression.PropertyOrField(ValueExpression, "Value"), 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 index 00622cac..a6bf9864 100644 --- a/Source/FileManager/NamingTemplate/TagBase.cs +++ b/Source/FileManager/NamingTemplate/TagBase.cs @@ -13,7 +13,7 @@ internal interface IPropertyTag Type ReturnType { get; } /// <summary>The <see cref="Regex"/> used to match <see cref="TemplateTag"/> in template strings.</summary> - public Regex NameMatcher { get; } + 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="Expression"/> @@ -29,13 +29,13 @@ internal abstract class TagBase : IPropertyTag { public ITemplateTag TemplateTag { get; } public Regex NameMatcher { get; protected init; } - public Type ReturnType => ExpressionValue.Type; - protected Expression ExpressionValue { get; } + public Type ReturnType => ValueExpression.Type; + protected Expression ValueExpression { get; } protected TagBase(ITemplateTag templateTag, Expression propertyExpression) { TemplateTag = templateTag; - ExpressionValue = propertyExpression; + ValueExpression = propertyExpression; } /// <summary>Create an <see cref="Expression"/> that returns the property's value.</summary> @@ -52,12 +52,10 @@ internal abstract class TagBase : IPropertyTag propertyValue = GetTagExpression(exactName, match.Groups.Count == 2 ? match.Groups[1].Value.Trim() : ""); return true; } - else - { - exactName = null; - propertyValue = null; - return false; - } + + exactName = null; + propertyValue = null; + return false; } public override string ToString() diff --git a/Source/FileManager/NamingTemplate/TagClass.cs b/Source/FileManager/NamingTemplate/TagCollection.cs similarity index 85% rename from Source/FileManager/NamingTemplate/TagClass.cs rename to Source/FileManager/NamingTemplate/TagCollection.cs index 1f044601..827db16e 100644 --- a/Source/FileManager/NamingTemplate/TagClass.cs +++ b/Source/FileManager/NamingTemplate/TagCollection.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; @@ -6,19 +7,18 @@ 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 +public abstract class TagCollection : IEnumerable<ITemplateTag> { - /// <summary>The <see cref="ParameterExpression"/> of the <see cref="TagClass"/>'s TClass type.</summary> + /// <summary>The <see cref="ParameterExpression"/> of the <see cref="TagCollection"/>'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); + /// <summary>The <see cref="ITemplateTag"/>s registered with this <see cref="TagCollection"/> </summary> + public IEnumerator<ITemplateTag> GetEnumerator() => PropertyTags.Select(p => p.TemplateTag).GetEnumerator(); protected RegexOptions Options { get; } = RegexOptions.Compiled; - private protected List<IPropertyTag> PropertyTags { get; } = new(); + private List<IPropertyTag> PropertyTags { get; } = new(); - protected TagClass(Type classType, bool caseSensative = true) + protected TagCollection(Type classType, bool caseSensative = true) { Parameter = Expression.Parameter(classType, classType.Name); Options |= caseSensative ? RegexOptions.None : RegexOptions.IgnoreCase; @@ -42,6 +42,7 @@ public abstract class TagClass return true; } } + propertyValue = null; propertyTag = null; exactName = null; @@ -74,4 +75,6 @@ public abstract class TagClass if (!PropertyTags.Any(c => c.TemplateTag.TagName == propertyTag.TemplateTag.TagName)) PropertyTags.Add(propertyTag); } + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); } diff --git a/Source/FileManager/NamingTemplate/TemplatePart.cs b/Source/FileManager/NamingTemplate/TemplatePart.cs index b26e8887..1b7f7630 100644 --- a/Source/FileManager/NamingTemplate/TemplatePart.cs +++ b/Source/FileManager/NamingTemplate/TemplatePart.cs @@ -18,7 +18,7 @@ public class TemplatePart : IEnumerable<TemplatePart> public ITemplateTag TemplateTag { get; } /// <summary>The evaluated string.</summary> - public string Value { get; set; } + public string Value { get; } private TemplatePart previous; private TemplatePart next; diff --git a/Source/LibationAvalonia/Views/MainWindow.Export.cs b/Source/LibationAvalonia/Views/MainWindow.Export.cs index 4864fa3e..24407f90 100644 --- a/Source/LibationAvalonia/Views/MainWindow.Export.cs +++ b/Source/LibationAvalonia/Views/MainWindow.Export.cs @@ -19,7 +19,7 @@ namespace LibationAvalonia.Views var options = new FilePickerSaveOptions { Title = "Where to export Library", - SuggestedStartLocation = new Avalonia.Platform.Storage.FileIO.BclStorageFolder(Configuration.Instance.Books), + SuggestedStartLocation = new Avalonia.Platform.Storage.FileIO.BclStorageFolder(Configuration.Instance.Books.PathWithoutPrefix), SuggestedFileName = $"Libation Library Export {DateTime.Now:yyyy-MM-dd}.xlsx", DefaultExtension = "xlsx", ShowOverwritePrompt = true, diff --git a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs index 7d3a3bc3..d407af76 100644 --- a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs +++ b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs @@ -111,7 +111,7 @@ namespace LibationAvalonia.Views { Title = $"Locate the audio file for '{entry.Book.Title}'", AllowMultiple = false, - SuggestedStartLocation = new Avalonia.Platform.Storage.FileIO.BclStorageFolder(Configuration.Instance.Books), + SuggestedStartLocation = new Avalonia.Platform.Storage.FileIO.BclStorageFolder(Configuration.Instance.Books.PathWithoutPrefix), FileTypeFilter = new FilePickerFileType[] { new("All files (*.*)") { Patterns = new[] { "*" } }, diff --git a/Source/LibationFileManager/Templates.cs b/Source/LibationFileManager/Templates.cs index e62a62b2..df672814 100644 --- a/Source/LibationFileManager/Templates.cs +++ b/Source/LibationFileManager/Templates.cs @@ -6,14 +6,13 @@ using AaxDecrypter; using Dinah.Core; using FileManager; using FileManager.NamingTemplate; -using Serilog.Formatting; namespace LibationFileManager { public interface ITemplate { static abstract string DefaultTemplate { get; } - static abstract IEnumerable<TagClass> TagClass { get; } + static abstract IEnumerable<TagCollection> TagCollections { get; } } public abstract class Templates @@ -21,7 +20,7 @@ namespace LibationFileManager public const string ERROR_FULL_PATH_IS_INVALID = @"No colons or full paths allowed. Eg: should not start with C:\"; public const string WARNING_NO_CHAPTER_NUMBER_TAG = "Should include chapter number tag in template used for naming files which are split by chapter. Ie: <ch#> or <ch# 0>"; - //Assign the properties in the static constructor will require all + //Assigning the properties in the static constructor will require all //Templates users to have a valid configuration file. To allow tests //to work without access to Configuration, only load templates on demand. private static FolderTemplate _folder; @@ -35,41 +34,44 @@ namespace LibationFileManager 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); + var namingTemplate = NamingTemplate.Parse(templateText, T.TagCollections); 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) }; + => new() { Template = NamingTemplate.Parse(T.DefaultTemplate, T.TagCollections) }; static Templates() { Configuration.Instance.PropertyChanged += [PropertyChangeFilter(nameof(Configuration.FolderTemplate))] - (_,e) => _folder = GetTemplate<FolderTemplate>((string)e.NewValue); + (_,e) => _folder = GetTemplate<FolderTemplate>((string)e.NewValue); - Configuration.Instance.PropertyChanged - += [PropertyChangeFilter(nameof(Configuration.FileTemplate))] - (_, e) => _file = GetTemplate<FileTemplate>((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.ChapterFileTemplate))] + (_, e) => _chapterFile = GetTemplate<ChapterFileTemplate>((string)e.NewValue); - Configuration.Instance.PropertyChanged - += [PropertyChangeFilter(nameof(Configuration.ChapterTitleTemplate))] - (_, e) => _chapterTitle = GetTemplate<ChapterTitleTemplate>((string)e.NewValue); + Configuration.Instance.PropertyChanged += + [PropertyChangeFilter(nameof(Configuration.ChapterTitleTemplate))] + (_, 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; } @@ -77,7 +79,6 @@ namespace LibationFileManager public string TemplateText => Template.TemplateText; protected NamingTemplate Template { get; private set; } - #endregion #region validation @@ -152,7 +153,7 @@ namespace LibationFileManager part.Insert(maxIndex, maxEntry.Remove(maxLength - 1, 1)); } } - //Any + var fullPath = Path.Combine(pathParts.Select(fileParts => string.Join("", fileParts)).Prepend(baseDir).ToArray()); return FileUtility.GetValidFilename(fullPath, replacements, fileExtension, returnFirstExisting); @@ -163,7 +164,7 @@ namespace LibationFileManager /// 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) + private static List<List<string>> GetPathParts(IEnumerable<string> templateParts) { List<List<string>> directories = new(); List<string> dir = new(); @@ -190,69 +191,65 @@ namespace LibationFileManager #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() + private static readonly PropertyTagCollection<LibraryBookDto> filePropertyTags = + new(caseSensative: true, StringFormatter, DateTimeFormatter, IntegerFormatter) { - ConditionalTagClass<LibraryBookDto> lbConditions = new(); + //Don't allow formatting of Id + { TemplateTags.Id, lb => lb.AudibleProductId, v => v }, + { TemplateTags.Title, lb => lb.Title }, + { TemplateTags.TitleShort, lb => getTitleShort(lb.Title) }, + { TemplateTags.Author, lb => lb.AuthorNames }, + { TemplateTags.FirstAuthor, lb => lb.FirstAuthor }, + { TemplateTags.Narrator, lb => lb.NarratorNames }, + { TemplateTags.FirstNarrator, lb => lb.FirstNarrator }, + { TemplateTags.Series, lb => lb.SeriesName }, + { TemplateTags.SeriesNumber, lb => lb.SeriesNumber }, + { TemplateTags.Language, lb => lb.Language }, + //Don't allow formatting of LanguageShort + { TemplateTags.LanguageShort, lb =>lb.Language, getLanguageShort }, + { TemplateTags.Bitrate, lb => lb.BitRate }, + { TemplateTags.SampleRate, lb => lb.SampleRate }, + { TemplateTags.Channels, lb => lb.Channels }, + { TemplateTags.Account, lb => lb.Account }, + { TemplateTags.Locale, lb => lb.Locale }, + { TemplateTags.YearPublished, lb => lb.YearPublished }, + { TemplateTags.DatePublished, lb => lb.DatePublished }, + { TemplateTags.DateAdded, lb => lb.DateAdded }, + { TemplateTags.FileDate, lb => lb.FileDate }, + }; - 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; - } - - private static PropertyTagClass<LibraryBookDto> GetFilePropertyTags() + private static readonly List<TagCollection> chapterPropertyTags = new() { - 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, 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); - 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; - } + new PropertyTagCollection<LibraryBookDto>(caseSensative: true, StringFormatter) + { + { TemplateTags.Title, lb => lb.Title }, + { TemplateTags.TitleShort, lb => getTitleShort(lb.Title) }, + { TemplateTags.Series, lb => lb.SeriesName }, + }, + new PropertyTagCollection<MultiConvertFileProperties>(caseSensative: true, StringFormatter, IntegerFormatter, DateTimeFormatter) + { + { TemplateTags.ChCount, m => m.PartsTotal }, + { TemplateTags.ChNumber, m => m.PartsPosition }, + { TemplateTags.ChNumber0, m => m.PartsPosition.ToString("D" + ((int)Math.Log10(m.PartsTotal) + 1)) }, + { TemplateTags.ChTitle, m => m.Title }, + { TemplateTags.FileDate, m => m.FileDate } + } + }; - private static List<TagClass> GetChapterPropertyTags() + private static readonly ConditionalTagCollection<LibraryBookDto> conditionalTags = new() { - PropertyTagClass<LibraryBookDto> lbProperties = new(); - PropertyTagClass<MultiConvertFileProperties> multiConvertProperties = new(); - - 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.FileDate, lb => lb.FileDate, DateTimeFormatter); - - return new List<TagClass> { lbProperties, multiConvertProperties }; - } + { TemplateTags.IfSeries, lb => lb.IsSeries }, + { TemplateTags.IfPodcast, lb => lb.IsPodcast }, + { TemplateTags.IfBookseries, lb => lb.IsSeries && !lb.IsPodcast }, + }; #endregion #region Tag Formatters + private static string getTitleShort(string title) + => title?.IndexOf(':') > 0 ? title.Substring(0, title.IndexOf(':')) : title; + private static string getLanguageShort(string language) { if (language is null) @@ -292,23 +289,18 @@ namespace LibationFileManager public override string Name => "Folder Template"; public override string Description => Configuration.GetDescription(nameof(Configuration.FolderTemplate)); public static string DefaultTemplate { get; } = "<title short> [<id>]"; - public static IEnumerable<TagClass> TagClass => new TagClass[] { filePropertyTags, conditionalTags }; + public static IEnumerable<TagCollection> TagCollections => new TagCollection[] { filePropertyTags, conditionalTags }; public override IEnumerable<string> Errors => TemplateText?.Length >= 2 && Path.IsPathFullyQualified(TemplateText) ? base.Errors.Append(ERROR_FULL_PATH_IS_INVALID) : base.Errors; protected override List<string> GetTemplatePartsStrings(List<TemplatePart> parts, ReplacementCharacters replacements) - { - foreach (var tp in parts) - { + => parts + .Select(tp => tp.TemplateTag is null //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); - } - return parts.Select(p => p.Value).ToList(); - } + ? replacements.ReplacePathChars(tp.Value.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar)) + : replacements.ReplaceFilenameChars(tp.Value) + ).ToList(); } public class FileTemplate : Templates, ITemplate @@ -316,7 +308,7 @@ namespace LibationFileManager public override string Name => "File Template"; public override string Description => Configuration.GetDescription(nameof(Configuration.FileTemplate)); public static string DefaultTemplate { get; } = "<title> [<id>]"; - public static IEnumerable<TagClass> TagClass { get; } = new TagClass[] { filePropertyTags, conditionalTags }; + public static IEnumerable<TagCollection> TagCollections { get; } = new TagCollection[] { filePropertyTags, conditionalTags }; } public class ChapterFileTemplate : Templates, ITemplate @@ -324,8 +316,7 @@ namespace LibationFileManager public override string Name => "Chapter File Template"; public override string Description => Configuration.GetDescription(nameof(Configuration.ChapterFileTemplate)); public static string DefaultTemplate { get; } = "<title> [<id>] - <ch# 0> - <ch title>"; - public static IEnumerable<TagClass> TagClass { get; } - = chapterPropertyTags.Append(filePropertyTags).Append(conditionalTags); + public static IEnumerable<TagCollection> TagCollections { get; } = chapterPropertyTags.Append(filePropertyTags).Append(conditionalTags); public override IEnumerable<string> Warnings => Template.TagsInUse.Any(t => t.TagName.In(TemplateTags.ChNumber.TagName, TemplateTags.ChNumber0.TagName)) @@ -338,8 +329,7 @@ namespace LibationFileManager 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 static IEnumerable<TagCollection> TagCollections { get; } = chapterPropertyTags.Append(conditionalTags); protected override IEnumerable<string> GetTemplatePartsStrings(List<TemplatePart> parts, ReplacementCharacters replacements) => parts.Select(p => p.Value); diff --git a/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs b/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs index 90a04e15..4470436b 100644 --- a/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs +++ b/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs @@ -33,20 +33,56 @@ namespace NamingTemplateTests public string Item2 { get; set; } public string Item3 { get; set; } public string Item4 { get; set; } + public ReferenceType RefType { get; set; } public int? Int2 { get; set; } public bool Condition { get; set; } } + class ReferenceType + { + public override string ToString() + { + return nameof(ReferenceType); + } + } [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(); + PropertyTagCollection<PropertyClass1> props1 = new() + { + { new TemplateTag { TagName = "item1" }, i => i.Item1 }, + { new TemplateTag { TagName = "item2" }, i => i.Item2 }, + { new TemplateTag { TagName = "item3" }, i => i.Item3 } + }; + + PropertyTagCollection<PropertyClass2> props2 = new() + { + { new TemplateTag { TagName = "item1" }, i => i.Item1 }, + { new TemplateTag { TagName = "item2" }, i => i.Item2 }, + { new TemplateTag { TagName = "item3" }, i => i.Item3 }, + { new TemplateTag { TagName = "item4" }, i => i.Item4 }, + }; + PropertyTagCollection<PropertyClass3> props3 = new(true, GetVal) + { + { new TemplateTag { TagName = "item3_1" }, i => i.Item1 }, + { new TemplateTag { TagName = "item3_2" }, i => i.Item2 }, + { new TemplateTag { TagName = "item3_3" }, i => i.Item3 }, + { new TemplateTag { TagName = "item3_4" }, i => i.Item4 }, + { new TemplateTag { TagName = "reftype" }, i => i.RefType }, + }; + ConditionalTagCollection<PropertyClass1> conditional1 = new() + { + { new TemplateTag { TagName = "ifc1" }, i => i.Condition }, + }; + ConditionalTagCollection<PropertyClass2> conditional2 = new() + { + { new TemplateTag { TagName = "ifc2" }, i => i.Condition }, + }; + ConditionalTagCollection<PropertyClass3> conditional3 = new() + { + { new TemplateTag { TagName = "ifc3" }, i => i.Condition }, + }; PropertyClass1 propertyClass1 = new() { @@ -74,27 +110,6 @@ namespace NamingTemplateTests 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); - } - [TestMethod] [DataRow("<item1>", "prop1_item1", 1)] @@ -110,7 +125,7 @@ namespace NamingTemplateTests [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 }); + var template = NamingTemplate.Parse(inStr, new TagCollection[] { props1, props2, props3, conditional1, conditional2, conditional3 }); template.TagsInUse.Should().HaveCount(numTags); template.Warnings.Should().HaveCount(numTags > 0 ? 0 : 1); @@ -132,11 +147,17 @@ namespace NamingTemplateTests [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) { - var template = NamingTemplate.Parse(inStr, new TagClass[] { props1, props2, props3, conditional1, conditional2, conditional3 }); + var template = NamingTemplate.Parse(inStr, new TagCollection[] { props1, props2, props3, conditional1, conditional2, conditional3 }); template.Errors.Should().HaveCount(0); template.Warnings.Should().BeEquivalentTo(warnings); } + + static string GetVal(ITemplateTag templateTag, ReferenceType referenceType, string format) + { + return ""; + } + [TestMethod] [DataRow("<int1>", "55")] [DataRow("<int1[]>", "55")] @@ -152,14 +173,15 @@ namespace NamingTemplateTests [DataRow("<item2_2_null>", "")] [DataRow("<item2_2_null[]>", "")] [DataRow("<item2_2_null[l]>", "")] + [DataRow("<reftype[l]>", "")] public void formatting(string inStr, string outStr) { - 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); + props1.Add(new TemplateTag { TagName = "int1" }, i => i.Int1, formatInt); + props3.Add(new TemplateTag { TagName = "int2" }, i => i.Int2, formatInt); + props3.Add(new TemplateTag { TagName = "item3_format" }, i => i.Item3, formatString); + props2.Add(new TemplateTag { TagName = "item2_2_null" }, i => i.Item2, formatString); - var template = NamingTemplate.Parse(inStr, new TagClass[] { props1, props2, props3, conditional1, conditional2, conditional3 }); + var template = NamingTemplate.Parse(inStr, new TagCollection[] { props1, props2, props3, conditional1, conditional2, conditional3 }); template.Warnings.Should().HaveCount(0); template.Errors.Should().HaveCount(0);