Add a more general NamingTemplate

This commit is contained in:
Mbucari 2023-02-02 16:04:14 -07:00 committed by Michael Bucari-Tovo
parent 867085600c
commit 20474e0b3c
29 changed files with 1689 additions and 1075 deletions

View File

@ -25,13 +25,12 @@ namespace FileLiberator
if (seriesParent is not null) if (seriesParent is not null)
{ {
var baseDir = Templates.Folder.GetFilename(seriesParent.ToDto()); var baseDir = Templates.Folder.GetFilename(seriesParent.ToDto(), "", "");
return Templates.Folder.GetFilename(libraryBook.ToDto(), baseDir); return Templates.Folder.GetFilename(libraryBook.ToDto(), baseDir, "");
} }
} }
} }
return Templates.Folder.GetFilename(libraryBook.ToDto(), "", "");
return Templates.Folder.GetFilename(libraryBook.ToDto());
} }
/// <summary> /// <summary>

View File

@ -38,7 +38,7 @@ namespace FileLiberator
=> Templates.ChapterFile.GetFilename(LibraryBookDto, props); => Templates.ChapterFile.GetFilename(LibraryBookDto, props);
public string GetMultipartTitle(MultiConvertFileProperties props) public string GetMultipartTitle(MultiConvertFileProperties props)
=> Templates.ChapterTitle.GetTitle(LibraryBookDto, props); => Templates.ChapterTitle.GetName(LibraryBookDto, props);
public async Task<string> SaveClipsAndBookmarksAsync(string fileName) public async Task<string> SaveClipsAndBookmarksAsync(string fileName)
{ {

View File

@ -41,6 +41,7 @@ namespace FileLiberator
SeriesName = libraryBook.Book.SeriesLink.FirstOrDefault()?.Series.Name, SeriesName = libraryBook.Book.SeriesLink.FirstOrDefault()?.Series.Name,
SeriesNumber = libraryBook.Book.SeriesLink.FirstOrDefault()?.Order, SeriesNumber = libraryBook.Book.SeriesLink.FirstOrDefault()?.Order,
IsPodcast = libraryBook.Book.IsEpisodeChild(),
BitRate = libraryBook.Book.AudioFormat.Bitrate, BitRate = libraryBook.Book.AudioFormat.Bitrate,
SampleRate = libraryBook.Book.AudioFormat.SampleRate, SampleRate = libraryBook.Book.AudioFormat.SampleRate,

View File

@ -1,123 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
namespace FileManager
{
/// <summary>Get valid filename. Advanced features incl. parameterized template</summary>
public class FileNamingTemplate : NamingTemplate
{
public ReplacementCharacters ReplacementCharacters { get; }
/// <param name="template">Proposed file name with optional html-styled template tags.</param>
public FileNamingTemplate(string template, ReplacementCharacters replacement) : base(template)
{
ReplacementCharacters = replacement ?? ReplacementCharacters.Default;
}
/// <summary>Generate a valid path for this file or directory</summary>
public LongPath GetFilePath(string fileExtension, bool returnFirstExisting = false)
{
string fileName =
Template.EndsWith(Path.DirectorySeparatorChar) || Template.EndsWith(Path.AltDirectorySeparatorChar) ?
FileUtility.RemoveLastCharacter(Template) :
Template;
List<string> 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<string,string> paramReplacements, int maxFilenameLength)
{
List<StringBuilder> 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());
}
}
}

View File

@ -56,9 +56,9 @@ namespace FileManager
//don't care about encoding, so how unicode characters are encoded is //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 ///a choice made by the linux kernel. As best as I can tell, pretty
//much everyone uses UTF-8. //much everyone uses UTF-8.
public static int GetFilesystemStringLength(StringBuilder filename) public static int GetFilesystemStringLength(string filename)
=> IsWindows ? filename.Length => IsWindows ? filename.Length
: Encoding.UTF8.GetByteCount(filename.ToString()); : Encoding.UTF8.GetByteCount(filename);
public static implicit operator LongPath(string path) public static implicit operator LongPath(string path)
{ {

View File

@ -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;
}
}
}

View File

@ -1,28 +0,0 @@
using Dinah.Core;
using System;
using System.Collections.Generic;
namespace FileManager
{
public class NamingTemplate
{
/// <summary>Proposed full name. May contain optional html-styled template tags. Eg: &lt;name&gt;</summary>
public string Template { get; }
/// <param name="template">Proposed file name with optional html-styled template tags.</param>
public NamingTemplate(string template) => Template = ArgumentValidator.EnsureNotNullOrWhiteSpace(template, nameof(template));
/// <summary>Optional step 1: Replace html-styled template tags with parameters. Eg {"name", "Bill Gates"} => /&lt;name&gt;/ => /Bill Gates/</summary>
public Dictionary<string, object> ParameterReplacements { get; } = new Dictionary<string, object>();
/// <summary>Convenience method</summary>
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(">", "");
}
}

View File

@ -0,0 +1,64 @@
using System;
using System.Linq.Expressions;
using System.Text.RegularExpressions;
namespace FileManager.NamingTemplate;
internal interface IClosingPropertyTag : IPropertyTag
{
/// <summary>The <see cref="Regex"/> used to match the closing <see cref="IPropertyTag.TemplateTag"/> in template strings.</summary>
public Regex NameCloseMatcher { get; }
/// <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="propertyTag">The registered <see cref="IPropertyTag"/></param>
/// <returns>True if the <paramref name="templateString"/> starts with this tag.</returns>
bool StartsWithClosing(string templateString, out string exactName, out IClosingPropertyTag propertyTag);
}
public class ConditionalTagClass<TClass> : TagClass
{
public ConditionalTagClass(bool caseSensative = true) :base(typeof(TClass), caseSensative) { }
public void RegisterCondition(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; }
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;
}
}

View File

@ -0,0 +1,6 @@
namespace FileManager.NamingTemplate;
public interface ITemplateTag
{
string TagName { get; }
}

View File

@ -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<ITemplateTag> TagsInUse => _tagsInUse;
public IEnumerable<ITemplateTag> TagsRegistered => Classes.SelectMany(p => p.TemplateTags).DistinctBy(f => f.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 List<ITemplateTag> _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: <title>";
/// <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;
}
}

View File

@ -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)));
}
}
}

View File

@ -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}]";
}
}

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -11,14 +11,12 @@ using ReactiveUI;
using Avalonia.Controls.Documents; using Avalonia.Controls.Documents;
using Avalonia.Collections; using Avalonia.Collections;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Markup.Xaml.Templates;
namespace LibationAvalonia.Dialogs namespace LibationAvalonia.Dialogs
{ {
public partial class EditTemplateDialog : DialogWindow public partial class EditTemplateDialog : DialogWindow
{ {
// final value. post-validity check
public string TemplateText { get; private set; }
private EditTemplateViewModel _viewModel; private EditTemplateViewModel _viewModel;
public EditTemplateDialog() public EditTemplateDialog()
@ -28,20 +26,21 @@ namespace LibationAvalonia.Dialogs
if (Design.IsDesignMode) if (Design.IsDesignMode)
{ {
_ = Configuration.Instance.LibationFiles; _ = Configuration.Instance.LibationFiles;
_viewModel = new(Configuration.Instance, Templates.File); var editor = TemplateEditor<Templates.FileTemplate>.CreateFilenameEditor(Configuration.Instance.Books, Configuration.Instance.FileTemplate);
_viewModel.resetTextBox(_viewModel.Template.DefaultTemplate); _viewModel = new(Configuration.Instance, editor);
Title = $"Edit {_viewModel.Template.Name}"; _viewModel.resetTextBox(editor.EditingTemplate.TemplateText);
Title = $"Edit {editor.EditingTemplate.Name}";
DataContext = _viewModel; 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 = new EditTemplateViewModel(Configuration.Instance, templateEditor);
_viewModel.resetTextBox(inputTemplateText); _viewModel.resetTextBox(templateEditor.EditingTemplate.TemplateText);
Title = $"Edit {template.Name}"; Title = $"Edit {templateEditor.EditingTemplate.Name}";
DataContext = _viewModel; DataContext = _viewModel;
} }
@ -64,7 +63,6 @@ namespace LibationAvalonia.Dialogs
if (!await _viewModel.Validate()) if (!await _viewModel.Validate())
return; return;
TemplateText = _viewModel.workingTemplateText;
await base.SaveAndCloseAsync(); await base.SaveAndCloseAsync();
} }
@ -72,23 +70,25 @@ namespace LibationAvalonia.Dialogs
=> await SaveAndCloseAsync(); => await SaveAndCloseAsync();
public void ResetButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) 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 class EditTemplateViewModel : ViewModels.ViewModelBase
{ {
private readonly Configuration config; private readonly Configuration config;
public FontFamily FontFamily { get; } = FontManager.Current.DefaultFontFamilyName; public FontFamily FontFamily { get; } = FontManager.Current.DefaultFontFamilyName;
public InlineCollection Inlines { get; } = new(); public InlineCollection Inlines { get; } = new();
public Templates Template { get; } public ITemplateEditor TemplateEditor { get; }
public EditTemplateViewModel(Configuration configuration, Templates templates) public EditTemplateViewModel(Configuration configuration, ITemplateEditor templates)
{ {
config = configuration; config = configuration;
Template = templates; TemplateEditor = templates;
Description = templates.Description; Description = templates.EditingTemplate.Description;
ListItems ListItems
= new AvaloniaList<Tuple<string, string, string>>( = new AvaloniaList<Tuple<string, string, string>>(
Template TemplateEditor
.GetTemplateTags() .EditingTemplate
.TagsRegistered
.Cast<TemplateTags>()
.Select( .Select(
t => new Tuple<string, string, string>( t => new Tuple<string, string, string>(
$"<{t.TagName.Replace("->", "-\x200C>").Replace("<-", "<\x200C-")}>", $"<{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; private string _warningText;
public string WarningText { get => _warningText; set => this.RaiseAndSetIfChanged(ref _warningText, value); } public string WarningText { get => _warningText; set => this.RaiseAndSetIfChanged(ref _warningText, value); }
@ -123,78 +122,22 @@ namespace LibationAvalonia.Dialogs
public async Task<bool> Validate() public async Task<bool> Validate()
{ {
if (Template.IsValid(workingTemplateText)) if (TemplateEditor.EditingTemplate.IsValid)
return true; return true;
var errors = Template
.GetErrors(workingTemplateText) var errors
.Select(err => $"- {err}") = TemplateEditor
.Aggregate((a, b) => $"{a}\r\n{b}"); .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); await MessageBox.Show($"This template text is not valid. Errors:\r\n{errors}", "Invalid", MessageBoxButtons.OK, MessageBoxIcon.Error);
return false; return false;
} }
private void templateTb_TextChanged() private void templateTb_TextChanged()
{ {
var isChapterTitle = Template == Templates.ChapterTitle; TemplateEditor.SetTemplateText(UserTemplateText);
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);
const char ZERO_WIDTH_SPACE = '\u200B'; const char ZERO_WIDTH_SPACE = '\u200B';
var sing = $"{Path.DirectorySeparatorChar}"; var sing = $"{Path.DirectorySeparatorChar}";
@ -207,11 +150,12 @@ namespace LibationAvalonia.Dialogs
string slashWrap(string val) => val.Replace(sing, $"{ZERO_WIDTH_SPACE}{sing}"); string slashWrap(string val) => val.Replace(sing, $"{ZERO_WIDTH_SPACE}{sing}");
WarningText WarningText
= !Template.HasWarnings(workingTemplateText) = !TemplateEditor.EditingTemplate.HasWarnings
? "" ? ""
: "Warning:\r\n" + : "Warning:\r\n" +
Template TemplateEditor
.GetWarnings(workingTemplateText) .EditingTemplate
.Warnings
.Select(err => $"- {err}") .Select(err => $"- {err}")
.Aggregate((a, b) => $"{a}\r\n{b}"); .Aggregate((a, b) => $"{a}\r\n{b}");
@ -220,20 +164,24 @@ namespace LibationAvalonia.Dialogs
Inlines.Clear(); Inlines.Clear();
if (isChapterTitle) if (!TemplateEditor.IsFilePath)
{ {
Inlines.Add(new Run(chapterTitle) { FontWeight = bold }); Inlines.Add(new Run(TemplateEditor.GetName()) { FontWeight = bold });
return; 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(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(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}")); Inlines.Add(new Run($".{ext}"));
} }

View File

@ -52,21 +52,22 @@ namespace LibationAvalonia.Dialogs
public async void EditFolderTemplateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) 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) if (newTemplate is not null)
settingsDisp.DownloadDecryptSettings.FolderTemplate = newTemplate; settingsDisp.DownloadDecryptSettings.FolderTemplate = newTemplate;
} }
public async void EditFileTemplateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) 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) if (newTemplate is not null)
settingsDisp.DownloadDecryptSettings.FileTemplate = newTemplate; settingsDisp.DownloadDecryptSettings.FileTemplate = newTemplate;
} }
public async void EditChapterFileTemplateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) 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) if (newTemplate is not null)
settingsDisp.DownloadDecryptSettings.ChapterFileTemplate = newTemplate; settingsDisp.DownloadDecryptSettings.ChapterFileTemplate = newTemplate;
} }
@ -79,16 +80,16 @@ namespace LibationAvalonia.Dialogs
public async void EditChapterTitleTemplateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) 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) if (newTemplate is not null)
settingsDisp.AudioSettings.ChapterTitleTemplate = newTemplate; 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) if (await form.ShowDialog<DialogResult>(this) == DialogResult.OK)
return form.TemplateText; return template.EditingTemplate.TemplateText;
else return null; else return null;
} }
} }
@ -266,28 +267,8 @@ namespace LibationAvalonia.Dialogs
UseCoverAsFolderIcon = config.UseCoverAsFolderIcon; 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 config.BadBook
= BadBookAbort ? Configuration.BadBookAction.Abort = BadBookAbort ? Configuration.BadBookAction.Abort
: BadBookRetry ? Configuration.BadBookAction.Retry : BadBookRetry ? Configuration.BadBookAction.Retry
@ -301,7 +282,7 @@ namespace LibationAvalonia.Dialogs
config.UseCoverAsFolderIcon = UseCoverAsFolderIcon; config.UseCoverAsFolderIcon = UseCoverAsFolderIcon;
return true; return Task.FromResult(true);
} }
public string UseCoverAsFolderIconText { get; } = Configuration.GetDescription(nameof(Configuration.UseCoverAsFolderIcon)); public string UseCoverAsFolderIconText { get; } = Configuration.GetDescription(nameof(Configuration.UseCoverAsFolderIcon));

View File

@ -80,7 +80,7 @@ namespace LibationFileManager
public bool BetaOptIn { get => GetNonString(defaultValue: false); set => SetNonString(value); } public bool BetaOptIn { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("Location for book storage. Includes destination of newly liberated books")] [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 // 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")] [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")] [Description("How to format the folders in which files will be saved")]
public string FolderTemplate public string FolderTemplate
{ {
get => Templates.Folder.GetValid(GetString(defaultValue: Templates.Folder.DefaultTemplate)); get => getTemplate<Templates.FolderTemplate>();
set => setTemplate(Templates.Folder, value); set => setTemplate<Templates.FolderTemplate>(value);
} }
[Description("How to format the saved pdf and audio files")] [Description("How to format the saved pdf and audio files")]
public string FileTemplate public string FileTemplate
{ {
get => Templates.File.GetValid(GetString(defaultValue: Templates.File.DefaultTemplate)); get => getTemplate<Templates.FileTemplate>();
set => setTemplate(Templates.File, value); set => setTemplate<Templates.FileTemplate>(value);
} }
[Description("How to format the saved audio files when split by chapters")] [Description("How to format the saved audio files when split by chapters")]
public string ChapterFileTemplate public string ChapterFileTemplate
{ {
get => Templates.ChapterFile.GetValid(GetString(defaultValue: Templates.ChapterFile.DefaultTemplate)); get => getTemplate<Templates.ChapterFileTemplate>();
set => setTemplate(Templates.ChapterFile, value); set => setTemplate<Templates.ChapterFileTemplate>(value);
} }
[Description("How to format the file's Tile stored in metadata")] [Description("How to format the file's Tile stored in metadata")]
public string ChapterTitleTemplate public string ChapterTitleTemplate
{ {
get => Templates.ChapterTitle.GetValid(GetString(defaultValue: Templates.ChapterTitle.DefaultTemplate)); get => getTemplate<Templates.ChapterTitleTemplate>();
set => setTemplate(Templates.ChapterTitle, value); 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(); return Templates.GetTemplate<T>(GetString(defaultValue: T.DefaultTemplate, propertyName)).TemplateText;
if (templ.IsValid(template)) }
SetString(template, propertyName);
private void setTemplate<T>(string newValue, [CallerMemberName] string propertyName = "")
where T : Templates, ITemplate, new()
{
SetString(Templates.GetTemplate<T>(newValue).TemplateText, propertyName);
} }
#endregion #endregion
} }

View File

@ -21,6 +21,7 @@ namespace LibationFileManager
public string SeriesName { get; set; } public string SeriesName { get; set; }
public string SeriesNumber { get; set; } public string SeriesNumber { get; set; }
public bool IsPodcast { get; set; }
public int BitRate { get; set; } public int BitRate { get; set; }
public int SampleRate { get; set; } public int SampleRate { get; set; }

View File

@ -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;
}
}
}

View File

@ -1,31 +1,27 @@
using System; using FileManager.NamingTemplate;
using System.Collections.Generic;
using System.Linq;
using Dinah.Core;
namespace LibationFileManager namespace LibationFileManager
{ {
public sealed class TemplateTags : Enumeration<TemplateTags> public sealed class TemplateTags : ITemplateTag
{ {
public string TagName => DisplayName; public const string DEFAULT_DATE_FORMAT = "yyyy-MM-dd";
public string DefaultValue { get; } public string TagName { get; }
public string DefaultValue { get; }
public string Description { get; } public string Description { get; }
public bool IsChapterOnly { get; } public string Display { 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}>";
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");
public static TemplateTags ChCount { get; } = new TemplateTags("ch count", "Number of chapters", true); public static TemplateTags ChTitle { get; } = new TemplateTags("ch title", "Chapter title");
public static TemplateTags ChTitle { get; } = new TemplateTags("ch title", "Chapter title", true); public static TemplateTags ChNumber { get; } = new TemplateTags("ch#", "Chapter #");
public static TemplateTags ChNumber { get; } = new TemplateTags("ch#", "Chapter #", true); public static TemplateTags ChNumber0 { get; } = new TemplateTags("ch# 0", "Chapter # with leading zeros");
public static TemplateTags ChNumber0 { get; } = new TemplateTags("ch# 0", "Chapter # with leading zeros", true);
public static TemplateTags Id { get; } = new TemplateTags("id", "Audible ID"); public static TemplateTags Id { get; } = new TemplateTags("id", "Audible ID");
public static TemplateTags Title { get; } = new TemplateTags("title", "Full title"); 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 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 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 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 YearPublished { get; } = new("year", "Year published");
public static TemplateTags Language { get; } = new("language", "Book's language"); 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 LanguageShort { get; } = new("language short", "Book's language abbreviated. Eg: ENG");
// Special cases. Aren't mapped to replacements in Templates.cs 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 [...]>");
// Included here for display by EditTemplateDialog 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 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 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 DatePublished { get; } = new TemplateTags("pub date [...]", "Publication date. e.g. yyyy-MM-dd", false, $"<pub date [{Templates.DEFAULT_DATE_FORMAT}]>"); 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 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 IfPodcast { get; } = new TemplateTags("if podcast", "Only include if part of a podcast", "<if podcast-><-if podcast>", "<if podcast->...<-if podcast>");
public static TemplateTags IfSeries { get; } = new TemplateTags("if series->...<-if series", "Only include if part of a series", false, "<if series-><-if series>");
} }
} }

View File

@ -2,115 +2,253 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions; using AaxDecrypter;
using Dinah.Core; using Dinah.Core;
using Dinah.Core.Collections.Generic;
using FileManager; using FileManager;
using FileManager.NamingTemplate;
namespace LibationFileManager namespace LibationFileManager
{ {
public interface ITemplate
{
static abstract string DefaultTemplate { get; }
static abstract IEnumerable<TagClass> TagClass { get; }
}
public abstract class Templates 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_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 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(); //Assign the properties in the static constructor will require all
public static FileTemplate File { get; } = new FileTemplate(); //Templates users to have a valid configuration file. To allow tests
public static ChapterFileTemplate ChapterFile { get; } = new ChapterFileTemplate(); //to work without access to Configuration, only load templates on demand.
public static ChapterTitleTemplate ChapterTitle { get; } = new ChapterTitleTemplate(); 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 Name { get; }
public abstract string Description { get; } public abstract string Description { get; }
public abstract string DefaultTemplate { get; } public string TemplateText => Template.TemplateText;
protected abstract bool IsChapterized { get; } protected NamingTemplate Template { get; private set; }
protected Templates() { } #endregion
#region validation #region validation
internal string GetValid(string configValue)
{
var value = configValue?.Trim();
return IsValid(value) ? value : DefaultTemplate;
}
public abstract IEnumerable<string> GetErrors(string template); public virtual IEnumerable<string> Errors => Template.Errors;
public bool IsValid(string template) => !GetErrors(template).Any(); public bool IsValid => !Errors.Any();
public abstract IEnumerable<string> GetWarnings(string template); public virtual IEnumerable<string> Warnings => Template.Warnings;
public bool HasWarnings(string template) => GetWarnings(template).Any(); 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 #endregion
#region to file name #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"; public string GetName(LibraryBookDto libraryBookDto, MultiConvertFileProperties multiChapProps)
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); ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto));
private static Regex datePublishedTagRegex { get; } = new Regex(@"<pub\s*?date\s*?(?:\[([^\[\]]*?)\]\s*?)?>", RegexOptions.Compiled | RegexOptions.IgnoreCase); ArgumentValidator.EnsureNotNull(multiChapProps, nameof(multiChapProps));
private static Regex ifSeriesRegex { get; } = new Regex("<if series->(.*?)<-if series>", RegexOptions.Compiled | RegexOptions.IgnoreCase); 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) private static string getLanguageShort(string language)
{ {
@ -123,330 +261,94 @@ namespace LibationFileManager
return language[..3].ToUpper(); 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)); if (string.Compare(formatString, "u", ignoreCase: true) == 0) return value?.ToUpper();
ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto)); else if (string.Compare(formatString, "l", ignoreCase: true) == 0) return value?.ToLower();
else return value;
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;
} }
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 #endregion
#region DateTime Tags public class FolderTemplate : Templates, ITemplate
/// <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 override string Name => "Folder Template"; public override string Name => "Folder Template";
public override string Description => Configuration.GetDescription(nameof(Configuration.FolderTemplate)); public override string Description => Configuration.GetDescription(nameof(Configuration.FolderTemplate));
public override string DefaultTemplate { get; } = "<title short> [<id>]"; public static string DefaultTemplate { get; } = "<title short> [<id>]";
protected override bool IsChapterized { get; } = false; 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 protected override List<string> GetTemplatePartsStrings(List<TemplatePart> parts, ReplacementCharacters replacements)
public override IEnumerable<string> GetErrors(string template)
{ {
// null is invalid. whitespace is valid but not recommended foreach (var tp in parts)
if (template is null) {
return new[] { ERROR_NULL_IS_INVALID }; //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 (parts[^1].Value.Length > 0 && parts[^1].Value[^1] == Path.DirectorySeparatorChar)
if (template.Contains(':')) parts[^1].Value = parts[^1].Value.Remove(parts[^1].Value.Length - 1, 1);
return new[] { ERROR_FULL_PATH_IS_INVALID }; }
return parts.Select(p => p.Value).ToList();
// 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;
} }
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 Name => "File Template";
public override string Description => Configuration.GetDescription(nameof(Configuration.FileTemplate)); public override string Description => Configuration.GetDescription(nameof(Configuration.FileTemplate));
public override string DefaultTemplate { get; } = "<title> [<id>]"; public static string DefaultTemplate { get; } = "<title> [<id>]";
protected override bool IsChapterized { get; } = false; public static IEnumerable<TagClass> TagClass { get; } = new TagClass[] { filePropertyTags, conditionalTags };
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 class ChapterFileTemplate : Templates public class ChapterFileTemplate : Templates, ITemplate
{ {
public override string Name => "Chapter File Template"; public override string Name => "Chapter File Template";
public override string Description => Configuration.GetDescription(nameof(Configuration.ChapterFileTemplate)); public override string Description => Configuration.GetDescription(nameof(Configuration.ChapterFileTemplate));
public override string DefaultTemplate { get; } = "<title> [<id>] - <ch# 0> - <ch title>"; public static string DefaultTemplate { get; } = "<title> [<id>] - <ch# 0> - <ch title>";
protected override bool IsChapterized { get; } = true; public static IEnumerable<TagClass> TagClass { get; }
= chapterPropertyTags.Append(filePropertyTags).Append(conditionalTags);
internal ChapterFileTemplate() : base() { } public override IEnumerable<string> Warnings
=> Template.TagsInUse.Any(t => t.TagName.In(TemplateTags.ChNumber.TagName, TemplateTags.ChNumber0.TagName))
#region validation ? base.Warnings
public override IEnumerable<string> GetErrors(string template) => GetFileErrors(template); : base.Warnings.Append(WARNING_NO_CHAPTER_NUMBER_TAG);
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 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 Name => "Chapter Title Template";
public override string Description => Configuration.GetDescription(nameof(Configuration.ChapterTitleTemplate)); 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 IEnumerable<string> GetTemplatePartsStrings(List<TemplatePart> parts, ReplacementCharacters replacements)
=> parts.Select(p => p.Value);
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;
} }
} }
} }

View File

@ -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;
}
}

View File

@ -1,5 +1,4 @@
using System; using System;
using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.IO; using System.IO;
using System.Windows.Forms; using System.Windows.Forms;
@ -10,33 +9,19 @@ namespace LibationWinForms.Dialogs
{ {
public partial class EditTemplateDialog : Form public partial class EditTemplateDialog : Form
{ {
// final value. post-validity check private void resetTextBox(string value) => this.templateTb.Text = value;
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 Configuration config { get; } = Configuration.Instance; private Configuration config { get; } = Configuration.Instance;
private ITemplateEditor templateEditor { get;}
private Templates template { get; }
private string inputTemplateText { get; }
public EditTemplateDialog() public EditTemplateDialog()
{ {
InitializeComponent(); InitializeComponent();
this.SetLibationIcon(); this.SetLibationIcon();
} }
public EditTemplateDialog(Templates template, string inputTemplateText) : this()
public EditTemplateDialog(ITemplateEditor templateEditor) : this()
{ {
this.template = ArgumentValidator.EnsureNotNull(template, nameof(template)); this.templateEditor = ArgumentValidator.EnsureNotNull(templateEditor, nameof(templateEditor));
this.inputTemplateText = inputTemplateText ?? "";
} }
private void EditTemplateDialog_Load(object sender, EventArgs e) private void EditTemplateDialog_Load(object sender, EventArgs e)
@ -44,89 +29,31 @@ namespace LibationWinForms.Dialogs
if (this.DesignMode) if (this.DesignMode)
return; 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; return;
} }
warningsLbl.Text = ""; warningsLbl.Text = "";
this.Text = $"Edit {template.Name}"; this.Text = $"Edit {templateEditor.EditingTemplate.Name}";
this.templateLbl.Text = template.Description; this.templateLbl.Text = templateEditor.EditingTemplate.Description;
resetTextBox(inputTemplateText); resetTextBox(templateEditor.EditingTemplate.TemplateText);
// populate list view // populate list view
foreach (var tag in template.GetTemplateTags()) foreach (TemplateTags tag in templateEditor.EditingTemplate.TagsRegistered)
listView1.Items.Add(new ListViewItem(new[] { $"<{tag.TagName}>", tag.Description }) { Tag = tag.DefaultValue }); 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) private void templateTb_TextChanged(object sender, EventArgs e)
{ {
workingTemplateText = templateTb.Text; templateEditor.SetTemplateText(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);
const char ZERO_WIDTH_SPACE = '\u200B'; const char ZERO_WIDTH_SPACE = '\u200B';
var sing = $"{Path.DirectorySeparatorChar}"; var sing = $"{Path.DirectorySeparatorChar}";
@ -139,11 +66,12 @@ namespace LibationWinForms.Dialogs
string slashWrap(string val) => val.Replace(sing, $"{ZERO_WIDTH_SPACE}{sing}"); string slashWrap(string val) => val.Replace(sing, $"{ZERO_WIDTH_SPACE}{sing}");
warningsLbl.Text warningsLbl.Text
= !template.HasWarnings(workingTemplateText) = !templateEditor.EditingTemplate.HasWarnings
? "" ? ""
: "Warning:\r\n" + : "Warning:\r\n" +
template templateEditor
.GetWarnings(workingTemplateText) .EditingTemplate
.Warnings
.Select(err => $"- {err}") .Select(err => $"- {err}")
.Aggregate((a, b) => $"{a}\r\n{b}"); .Aggregate((a, b) => $"{a}\r\n{b}");
@ -153,51 +81,52 @@ namespace LibationWinForms.Dialogs
richTextBox1.Clear(); richTextBox1.Clear();
richTextBox1.SelectionFont = reg; richTextBox1.SelectionFont = reg;
if (isChapterTitle) if (!templateEditor.IsFilePath)
{ {
richTextBox1.SelectionFont = bold; richTextBox1.SelectionFont = bold;
richTextBox1.AppendText(chapterTitle); richTextBox1.AppendText(templateEditor.GetName());
return; 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); richTextBox1.AppendText(sing);
if (isFolder) if (templateEditor.IsFolder)
richTextBox1.SelectionFont = bold; richTextBox1.SelectionFont = bold;
richTextBox1.AppendText(slashWrap(folder)); richTextBox1.AppendText(slashWrap(folder));
if (isFolder) if (templateEditor.IsFolder)
richTextBox1.SelectionFont = reg; richTextBox1.SelectionFont = reg;
richTextBox1.AppendText(sing); richTextBox1.AppendText(sing);
if (!isFolder) if (templateEditor.IsFilePath && !templateEditor.IsFolder)
richTextBox1.SelectionFont = bold; richTextBox1.SelectionFont = bold;
richTextBox1.AppendText(file); richTextBox1.AppendText(file);
if (!isFolder) richTextBox1.SelectionFont = reg;
richTextBox1.SelectionFont = reg;
richTextBox1.AppendText($".{ext}"); richTextBox1.AppendText($".{ext}");
} }
private void saveBtn_Click(object sender, EventArgs e) private void saveBtn_Click(object sender, EventArgs e)
{ {
if (!template.IsValid(workingTemplateText)) if (!templateEditor.EditingTemplate.IsValid)
{ {
var errors = template var errors = templateEditor
.GetErrors(workingTemplateText) .EditingTemplate
.Errors
.Select(err => $"- {err}") .Select(err => $"- {err}")
.Aggregate((a, b) => $"{a}\r\n{b}"); .Aggregate((a, b) => $"{a}\r\n{b}");
MessageBox.Show($"This template text is not valid. Errors:\r\n{errors}", "Invalid", MessageBoxButtons.OK, MessageBoxIcon.Error); MessageBox.Show($"This template text is not valid. Errors:\r\n{errors}", "Invalid", MessageBoxButtons.OK, MessageBoxIcon.Error);
return; return;
} }
TemplateText = workingTemplateText;
this.DialogResult = DialogResult.OK; this.DialogResult = DialogResult.OK;
this.Close(); this.Close();
} }

View File

@ -106,7 +106,8 @@ namespace LibationWinForms.Dialogs
chapterTitleTemplateGb.Enabled = splitFilesByChapterCbox.Checked; 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) private void convertFormatRb_CheckedChanged(object sender, EventArgs e)
{ {

View File

@ -7,10 +7,12 @@ namespace LibationWinForms.Dialogs
{ {
public partial class SettingsDialog public partial class SettingsDialog
{ {
private void folderTemplateBtn_Click(object sender, EventArgs e) => editTemplate(Templates.Folder, folderTemplateTb); private void folderTemplateBtn_Click(object sender, EventArgs e)
private void fileTemplateBtn_Click(object sender, EventArgs e) => editTemplate(Templates.File, fileTemplateTb); => editTemplate(TemplateEditor<Templates.FolderTemplate>.CreateFilenameEditor(config.Books, folderTemplateTb.Text), folderTemplateTb);
private void chapterFileTemplateBtn_Click(object sender, EventArgs e) => editTemplate(Templates.ChapterFile, chapterFileTemplateTb); 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) private void editCharreplacementBtn_Click(object sender, EventArgs e)
{ {

View File

@ -56,23 +56,6 @@ namespace LibationWinForms.Dialogs
validationError("Cannot set Books Location to blank", "Location is blank"); validationError("Cannot set Books Location to blank", "Location is blank");
return; 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 #endregion
LongPath lonNewBooks = newBooks; LongPath lonNewBooks = newBooks;

View File

@ -27,11 +27,11 @@ namespace LibationWinForms.Dialogs
Load_AudioSettings(config); 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) if (form.ShowDialog() == DialogResult.OK)
textBox.Text = form.TemplateText; textBox.Text = template.EditingTemplate.TemplateText;
} }
private void saveBtn_Click(object sender, EventArgs e) private void saveBtn_Click(object sender, EventArgs e)

View File

@ -1,82 +1,184 @@
using System; using System.Linq;
using System.Collections.Generic; using FileManager.NamingTemplate;
using System.IO;
using System.Linq;
using Dinah.Core;
using FileManager;
using FluentAssertions; using FluentAssertions;
using Microsoft.VisualStudio.TestTools.UnitTesting; using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace FileNamingTemplateTests namespace NamingTemplateTests
{ {
[TestClass] class TemplateTag : ITemplateTag
public class GetFilePath
{ {
static ReplacementCharacters Replacements = ReplacementCharacters.Default; public string TagName { get; init; }
}
[TestMethod] class PropertyClass1
[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 string Item1 { get; set; }
public void equiv_GetValidFilename(string dirFullPath, string expected, PlatformID platformID) 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) Item1 = "prop1_item1",
return; Item2 = "prop1_item2",
Item3 = "prop1_item3",
Int1 = 55,
Condition = true,
};
var sb = new System.Text.StringBuilder(); PropertyClass2 propertyClass2 = new()
sb.Append('0', 300); {
var longText = sb.ToString(); 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); template.TagsInUse.Should().HaveCount(numTags);
var fullfilename = Path.Combine(dirFullPath, template + extension); template.Warnings.Should().HaveCount(numTags > 0 ? 0 : 1);
template.Errors.Should().HaveCount(0);
var fileNamingTemplate = new FileNamingTemplate(fullfilename, Replacements); var templateText = string.Join("", template.Evaluate(propertyClass3, propertyClass2, propertyClass1).Select(v => v.Value));
fileNamingTemplate.AddParameterReplacement("title", filename);
fileNamingTemplate.AddParameterReplacement("id", metadataSuffix); templateText.Should().Be(outStr);
return fileNamingTemplate.GetFilePath(extension).PathWithoutPrefix;
} }
[TestMethod] [TestMethod]
[DataRow(@"C:\foo\bar\my file.txt", @"C:\foo\bar\my file - 002 - title.txt", PlatformID.Win32NT)] [DataRow("<ifc2-><ifc1-><ifc3-><item1><item4><item3_2><-ifc3><-ifc1><ifc2->", new string[] { "Missing <-ifc2> closing conditional.", "Missing <-ifc2> closing conditional." })]
[DataRow(@"/foo/bar/my file.txt", @"/foo/bar/my file - 002 - title.txt", PlatformID.Unix)] [DataRow("<ifc2-><ifc1-><ifc3-><-ifc3><-ifc1><-ifc2>", new string[] { "Should use tags. Eg: <title>" })]
public void equiv_GetMultipartFileName(string inStr, string outStr, PlatformID platformID) [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) var template = NamingTemplate.Parse(inStr, new TagClass[] { props1, props2, props3, conditional1, conditional2, conditional3 });
NEW_GetMultipartFileName_FileNamingTemplate(inStr, 2, 100, "title").Should().Be(outStr);
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] [TestMethod]
[DataRow(@"\foo\<title>.txt", @"\foo\slashes.txt", PlatformID.Win32NT)] [DataRow("<int1>", "55")]
[DataRow(@"/foo/<title>.txt", @"/foo/s\la\sh\es.txt", PlatformID.Unix)] [DataRow("<int1[]>", "55")]
public void remove_slashes(string inStr, string outStr, PlatformID platformID) [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); if (int.TryParse(format, out var numDecs))
fileNamingTemplate.AddParameterReplacement("title", @"s\l/a\s/h\e/s"); return value.ToString($"D{numDecs}");
fileNamingTemplate.GetFilePath("txt").PathWithoutPrefix.Should().Be(outStr); 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;
} }
} }
} }

View File

@ -4,6 +4,7 @@ using System.IO;
using System.Linq; using System.Linq;
using Dinah.Core; using Dinah.Core;
using FileManager; using FileManager;
using FileManager.NamingTemplate;
using FluentAssertions; using FluentAssertions;
using LibationFileManager; using LibationFileManager;
using Microsoft.VisualStudio.TestTools.UnitTesting; using Microsoft.VisualStudio.TestTools.UnitTesting;
@ -42,46 +43,21 @@ namespace TemplatesTests
Channels = 2, Channels = 2,
Language = "English" 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] [TestClass]
public class ContainsTag public class ContainsTag
{ {
[TestMethod] [TestMethod]
[DataRow("<ch#>", "ch#", true)] [DataRow("<ch#>", 0)]
[DataRow("<id>", "ch#", false)] [DataRow("<id>", 1)]
[DataRow("<id><ch#>", "ch#", true)] [DataRow("<id><ch#>", 1)]
public void Tests(string template, string tag, bool expected) => Templates.ContainsTag(template, tag).Should().Be(expected); public void Tests(string template, int numTags)
{
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
fileTemplate.TagsInUse.Should().HaveCount(numTags);
}
} }
[TestClass] [TestClass]
@ -89,19 +65,22 @@ namespace TemplatesTests
{ {
static ReplacementCharacters Replacements = ReplacementCharacters.Default; 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] [TestMethod]
[DataRow(null, @"C:\", "ext")] [DataRow("")]
[ExpectedException(typeof(ArgumentNullException))] [DataRow(" ")]
public void arg_null_exception(string template, string dirFullPath, string extension) public void template_empty(string template)
=> Templates.getFileNamingTemplate(GetLibraryBook(), template, dirFullPath, extension, Replacements); {
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var t).Should().BeTrue();
[TestMethod] t.Warnings.Should().HaveCount(2);
[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);
[TestMethod] [TestMethod]
[DataRow("f.txt", @"C:\foo\bar", "", @"C:\foo\bar\f.txt")] [DataRow("f.txt", @"C:\foo\bar", "", @"C:\foo\bar\f.txt")]
@ -119,10 +98,26 @@ namespace TemplatesTests
expected = expected.Replace("C:", "").Replace('\\', '/'); expected = expected.Replace("C:", "").Replace('\\', '/');
} }
Templates.getFileNamingTemplate(GetLibraryBook(), template, dirFullPath, extension, Replacements) Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
.GetFilePath(extension)
.PathWithoutPrefix fileTemplate
.Should().Be(expected); .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] [TestMethod]
@ -145,8 +140,9 @@ namespace TemplatesTests
expected = expected.Replace("C:", "").Replace('\\', '/'); expected = expected.Replace("C:", "").Replace('\\', '/');
} }
Templates.getFileNamingTemplate(GetLibraryBook(), template, dirFullPath, extension, Replacements) Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
.GetFilePath(extension) fileTemplate
.GetFilename(GetLibraryBook(), dirFullPath, extension, Replacements)
.PathWithoutPrefix .PathWithoutPrefix
.Should().Be(expected); .Should().Be(expected);
} }
@ -170,11 +166,12 @@ namespace TemplatesTests
if (Environment.OSVersion.Platform is not PlatformID.Win32NT) if (Environment.OSVersion.Platform is not PlatformID.Win32NT)
{ {
dirFullPath = dirFullPath.Replace("C:", "").Replace('\\', '/'); 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) Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
.GetFilePath(extension) fileTemplate
.GetFilename(GetLibraryBook(), dirFullPath, extension, Replacements)
.PathWithoutPrefix .PathWithoutPrefix
.Should().Be(expected); .Should().Be(expected);
} }
@ -191,8 +188,9 @@ namespace TemplatesTests
expected = expected.Replace("C:", "").Replace('\\', '/'); expected = expected.Replace("C:", "").Replace('\\', '/');
} }
Templates.getFileNamingTemplate(GetLibraryBook(), template, dirFullPath, extension, Replacements) Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
.GetFilePath(extension) fileTemplate
.GetFilename(GetLibraryBook(), dirFullPath, extension, Replacements)
.PathWithoutPrefix .PathWithoutPrefix
.Should().Be(expected); .Should().Be(expected);
} }
@ -208,13 +206,13 @@ namespace TemplatesTests
{ {
if (Environment.OSVersion.Platform == platformID) if (Environment.OSVersion.Platform == platformID)
{ {
Templates.File.HasWarnings(template).Should().BeTrue(); Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
Templates.File.HasWarnings(Templates.File.Sanitize(template, Replacements)).Should().BeFalse();
Templates.getFileNamingTemplate(GetLibraryBook(), template, dirFullPath, extension, Replacements) fileTemplate.HasWarnings.Should().BeFalse();
.GetFilePath(extension) fileTemplate
.GetFilename(GetLibraryBook(), dirFullPath, extension, Replacements)
.PathWithoutPrefix .PathWithoutPrefix
.Should().Be(expected); .Should().Be(expected);
} }
} }
@ -229,11 +227,15 @@ namespace TemplatesTests
expected = expected.Replace("C:", "").Replace('\\', '/'); expected = expected.Replace("C:", "").Replace('\\', '/');
} }
Templates.getFileNamingTemplate(GetLibraryBookWithNullDates(), template, dirFullPath, extension, Replacements) var lbDto = GetLibraryBook();
.GetFilePath(extension) lbDto.DatePublished = null;
lbDto.DateAdded = null;
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
fileTemplate
.GetFilename(lbDto, dirFullPath, extension, Replacements)
.PathWithoutPrefix .PathWithoutPrefix
.Should().Be(expected); .Should().Be(expected);
} }
[TestMethod] [TestMethod]
@ -242,10 +244,14 @@ namespace TemplatesTests
public void IfSeries_empty(string directory, string expected, PlatformID platformID) public void IfSeries_empty(string directory, string expected, PlatformID platformID)
{ {
if (Environment.OSVersion.Platform == platformID) if (Environment.OSVersion.Platform == platformID)
Templates.getFileNamingTemplate(GetLibraryBook(), "foo<if series-><-if series>bar", directory, "ext", Replacements) {
.GetFilePath(".ext") Templates.TryGetTemplate<Templates.FileTemplate>("foo<if series-><-if series>bar", out var fileTemplate).Should().BeTrue();
.PathWithoutPrefix
.Should().Be(expected); fileTemplate
.GetFilename(GetLibraryBook(), directory, "ext", Replacements)
.PathWithoutPrefix
.Should().Be(expected);
}
} }
[TestMethod] [TestMethod]
@ -254,10 +260,13 @@ namespace TemplatesTests
public void IfSeries_no_series(string directory, string expected, PlatformID platformID) public void IfSeries_no_series(string directory, string expected, PlatformID platformID)
{ {
if (Environment.OSVersion.Platform == 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 .PathWithoutPrefix
.Should().Be(expected); .Should().Be(expected);
}
} }
[TestMethod] [TestMethod]
@ -266,10 +275,112 @@ namespace TemplatesTests
public void IfSeries_with_series(string directory, string expected, PlatformID platformID) public void IfSeries_with_series(string directory, string expected, PlatformID platformID)
{ {
if (Environment.OSVersion.Platform == platformID) if (Environment.OSVersion.Platform == platformID)
Templates.getFileNamingTemplate(GetLibraryBook(), "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();
.PathWithoutPrefix
.Should().Be(expected); 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\slashes.txt", PlatformID.Win32NT)]
[DataRow(@"/foo/<title>.txt", @"/foo/s\la\sh\es.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 public class GetErrors
{ {
[TestMethod] [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] [TestMethod]
public void empty_is_valid() => valid_tests(""); public void empty_is_valid() => valid_tests("");
@ -296,15 +407,19 @@ namespace Templates_Folder_Tests
[DataRow(@"foo\bar")] [DataRow(@"foo\bar")]
[DataRow(@"<id>")] [DataRow(@"<id>")]
[DataRow(@"<id>\<title>")] [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] [TestMethod]
[DataRow(@"C:\", Templates.ERROR_FULL_PATH_IS_INVALID)] [DataRow(@"C:\", PlatformID.Win32NT, Templates.ERROR_FULL_PATH_IS_INVALID)]
public void Tests(string template, params string[] expected) public void Tests(string template, PlatformID platformID, params string[] expected)
{ {
var result = Templates.Folder.GetErrors(template); if ((platformID & Environment.OSVersion.Platform) == Environment.OSVersion.Platform)
result.Count().Should().Be(expected.Length); {
result.Should().BeEquivalentTo(expected); 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 public class IsValid
{ {
[TestMethod] [TestMethod]
public void null_is_invalid() => Tests(null, false); public void null_is_invalid() => Templates.TryGetTemplate<Templates.FolderTemplate>(null, out _).Should().BeFalse();
[TestMethod] [TestMethod]
public void empty_is_valid() => Tests("", true); public void empty_is_valid() => Tests("", true, PlatformID.Win32NT | PlatformID.Unix);
[TestMethod] [TestMethod]
public void whitespace_is_valid() => Tests(" ", true); public void whitespace_is_valid() => Tests(" ", true, PlatformID.Win32NT | PlatformID.Unix);
[TestMethod] [TestMethod]
[DataRow(@"C:\", false)] [DataRow(@"C:\", false, PlatformID.Win32NT)]
[DataRow(@"foo", true)] [DataRow(@"foo", true, PlatformID.Win32NT | PlatformID.Unix)]
[DataRow(@"\foo", true)] [DataRow(@"\foo", true, PlatformID.Win32NT | PlatformID.Unix)]
[DataRow(@"foo\", true)] [DataRow(@"foo\", true, PlatformID.Win32NT | PlatformID.Unix)]
[DataRow(@"\foo\", true)] [DataRow(@"\foo\", true, PlatformID.Win32NT | PlatformID.Unix)]
[DataRow(@"foo\bar", true)] [DataRow(@"foo\bar", true, PlatformID.Win32NT | PlatformID.Unix)]
[DataRow(@"<id>", true)] [DataRow(@"<id>", true, PlatformID.Win32NT | PlatformID.Unix)]
[DataRow(@"<id>\<title>", true)] [DataRow(@"<id>\<title>", true, PlatformID.Win32NT | PlatformID.Unix)]
public void Tests(string template, bool expected) => Templates.Folder.IsValid(template).Should().Be(expected); 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] [TestClass]
public class GetWarnings public class GetWarnings
{ {
[TestMethod] [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] [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] [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] [TestMethod]
[DataRow(@"<id>\foo\bar")] [DataRow(@"<id>\foo\bar")]
public void valid_tests(string template) => Tests(template, Array.Empty<string>()); public void valid_tests(string template) => Tests(template, Array.Empty<string>());
[TestMethod] [TestMethod]
[DataRow(@"no tags", Templates.WARNING_NO_TAGS)] [DataRow(@"no tags", NamingTemplate.WARNING_NO_TAGS)]
[DataRow("<ch#> <id>", Templates.WARNING_HAS_CHAPTER_TAGS)] [DataRow("<ch#> chapter tag", NamingTemplate.WARNING_NO_TAGS)]
[DataRow("<ch#> chapter tag", Templates.WARNING_NO_TAGS, Templates.WARNING_HAS_CHAPTER_TAGS)]
public void Tests(string template, params string[] expected) public void Tests(string template, params string[] expected)
{ {
var result = Templates.Folder.GetWarnings(template); Templates.TryGetTemplate<Templates.FolderTemplate>(template, out var folderTemplate);
result.Count().Should().Be(expected.Length); var result = folderTemplate.Warnings;
result.Should().HaveCount(expected.Length);
result.Should().BeEquivalentTo(expected); result.Should().BeEquivalentTo(expected);
} }
} }
@ -375,16 +497,23 @@ namespace Templates_Folder_Tests
[TestMethod] [TestMethod]
[DataRow(@"no tags", true)] [DataRow(@"no tags", true)]
[DataRow(@"<id>\foo\bar", false)] [DataRow(@"<id>\foo\bar", false)]
[DataRow("<ch#> <id>", true)]
[DataRow("<ch#> chapter tag", 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] [TestClass]
public class TagCount public class TagCount
{ {
[TestMethod] [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] [TestMethod]
public void empty() => Tests("", 0); public void empty() => Tests("", 0);
@ -402,7 +531,11 @@ namespace Templates_Folder_Tests
[DataRow("<not a real tag>", 0)] [DataRow("<not a real tag>", 0)]
[DataRow("<ch#> non-folder tag", 0)] [DataRow("<ch#> non-folder tag", 0)]
[DataRow("<ID> case specific", 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 public class GetErrors
{ {
[TestMethod] [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] [TestMethod]
public void empty_is_valid() => valid_tests(""); public void empty_is_valid() => valid_tests("");
@ -425,19 +558,13 @@ namespace Templates_File_Tests
[DataRow(@"<id>")] [DataRow(@"<id>")]
public void valid_tests(string template) => Tests(template, Environment.OSVersion.Platform, Array.Empty<string>()); 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) public void Tests(string template, PlatformID platformID, params string[] expected)
{ {
if (Environment.OSVersion.Platform == platformID) if (Environment.OSVersion.Platform == platformID)
{ {
var result = Templates.File.GetErrors(template); Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate);
result.Count().Should().Be(expected.Length); var result = fileTemplate.Errors;
result.Should().HaveCount(expected.Length);
result.Should().BeEquivalentTo(expected); result.Should().BeEquivalentTo(expected);
} }
} }
@ -447,28 +574,26 @@ namespace Templates_File_Tests
public class IsValid public class IsValid
{ {
[TestMethod] [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] [TestMethod]
public void empty_is_valid() => Tests("", true, Environment.OSVersion.Platform); public void empty_is_valid() => Tests("", true);
[TestMethod] [TestMethod]
public void whitespace_is_valid() => Tests(" ", true, Environment.OSVersion.Platform); public void whitespace_is_valid() => Tests(" ", true);
[TestMethod] [TestMethod]
[DataRow(@"C:\", false, PlatformID.Win32NT)] [DataRow(@"foo", true)]
[DataRow(@"/", false, PlatformID.Unix)] [DataRow(@"\foo", true)]
[DataRow(@"foo", true, PlatformID.Win32NT)] [DataRow(@"foo\", true)]
[DataRow(@"foo", true, PlatformID.Unix)] [DataRow(@"\foo\", true)]
[DataRow(@"\foo", false, PlatformID.Win32NT)] [DataRow(@"foo\bar", true)]
[DataRow(@"\foo", true, PlatformID.Unix)] [DataRow(@"<id>", true)]
[DataRow(@"/foo", false, PlatformID.Win32NT)] [DataRow(@"<id>\<title>", true)]
[DataRow(@"<id>", true, PlatformID.Win32NT)] public void Tests(string template, bool expected)
[DataRow(@"<id>", true, PlatformID.Unix)]
public void Tests(string template, bool expected, PlatformID platformID)
{ {
if (Environment.OSVersion.Platform == platformID) Templates.TryGetTemplate<Templates.FileTemplate>(template, out var folderTemplate).Should().BeTrue();
Templates.File.IsValid(template).Should().Be(expected); folderTemplate.IsValid.Should().Be(expected);
} }
} }
@ -499,13 +624,13 @@ namespace Templates_ChapterFile_Tests
public class GetWarnings public class GetWarnings
{ {
[TestMethod] [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] [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] [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] [TestMethod]
[DataRow("<ch#>")] [DataRow("<ch#>")]
@ -513,18 +638,20 @@ namespace Templates_ChapterFile_Tests
public void valid_tests(string template) => Tests(template, null, Array.Empty<string>()); public void valid_tests(string template) => Tests(template, null, Array.Empty<string>());
[TestMethod] [TestMethod]
[DataRow(@"no tags", 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.ERROR_INVALID_FILE_NAME_CHAR, Templates.WARNING_NO_CHAPTER_NUMBER_TAG)] [DataRow(@"<id>\foo\bar", true, Templates.WARNING_NO_CHAPTER_NUMBER_TAG)]
[DataRow(@"<id>/foo/bar", false, Templates.ERROR_INVALID_FILE_NAME_CHAR, 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, Templates.WARNING_NO_TAGS, 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) 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 true && Environment.OSVersion.Platform is PlatformID.Win32NT)
|| (windows is false && Environment.OSVersion.Platform is PlatformID.Unix)) || (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); result.Should().BeEquivalentTo(expected);
} }
} }
@ -548,14 +675,18 @@ namespace Templates_ChapterFile_Tests
[DataRow("<ch#> <id>", false)] [DataRow("<ch#> <id>", false)]
[DataRow("<ch#> -- chapter tag", false)] [DataRow("<ch#> -- chapter tag", false)]
[DataRow("<chapter count> -- chapter tag but not ch# or ch_#", true)] [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] [TestClass]
public class TagCount public class TagCount
{ {
[TestMethod] [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] [TestMethod]
public void empty_is_not_recommended() => Tests("", 0); public void empty_is_not_recommended() => Tests("", 0);
@ -573,11 +704,15 @@ namespace Templates_ChapterFile_Tests
[DataRow("<not a real tag>", 0)] [DataRow("<not a real tag>", 0)]
[DataRow("<ch#> non-folder tag", 1)] [DataRow("<ch#> non-folder tag", 1)]
[DataRow("<ID> case specific", 0)] [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] [TestClass]
public class GetPortionFilename public class GetFilename
{ {
static readonly ReplacementCharacters Default = ReplacementCharacters.Default; 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) public void Tests(string template, string dir, string ext, int pos, int total, string chapter, string expected, PlatformID platformID)
{ {
if (Environment.OSVersion.Platform == 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);
}
} }
} }
} }