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)
{
var baseDir = Templates.Folder.GetFilename(seriesParent.ToDto());
return Templates.Folder.GetFilename(libraryBook.ToDto(), baseDir);
var baseDir = Templates.Folder.GetFilename(seriesParent.ToDto(), "", "");
return Templates.Folder.GetFilename(libraryBook.ToDto(), baseDir, "");
}
}
}
return Templates.Folder.GetFilename(libraryBook.ToDto());
return Templates.Folder.GetFilename(libraryBook.ToDto(), "", "");
}
/// <summary>

View File

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

View File

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

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
///a choice made by the linux kernel. As best as I can tell, pretty
//much everyone uses UTF-8.
public static int GetFilesystemStringLength(StringBuilder filename)
public static int GetFilesystemStringLength(string filename)
=> IsWindows ? filename.Length
: Encoding.UTF8.GetByteCount(filename.ToString());
: Encoding.UTF8.GetByteCount(filename);
public static implicit operator LongPath(string path)
{

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.Collections;
using Avalonia.Controls;
using Avalonia.Markup.Xaml.Templates;
namespace LibationAvalonia.Dialogs
{
public partial class EditTemplateDialog : DialogWindow
{
// final value. post-validity check
public string TemplateText { get; private set; }
private EditTemplateViewModel _viewModel;
public EditTemplateDialog()
@ -28,20 +26,21 @@ namespace LibationAvalonia.Dialogs
if (Design.IsDesignMode)
{
_ = Configuration.Instance.LibationFiles;
_viewModel = new(Configuration.Instance, Templates.File);
_viewModel.resetTextBox(_viewModel.Template.DefaultTemplate);
Title = $"Edit {_viewModel.Template.Name}";
var editor = TemplateEditor<Templates.FileTemplate>.CreateFilenameEditor(Configuration.Instance.Books, Configuration.Instance.FileTemplate);
_viewModel = new(Configuration.Instance, editor);
_viewModel.resetTextBox(editor.EditingTemplate.TemplateText);
Title = $"Edit {editor.EditingTemplate.Name}";
DataContext = _viewModel;
}
}
public EditTemplateDialog(Templates template, string inputTemplateText) : this()
public EditTemplateDialog(ITemplateEditor templateEditor) : this()
{
ArgumentValidator.EnsureNotNull(template, nameof(template));
ArgumentValidator.EnsureNotNull(templateEditor, nameof(templateEditor));
_viewModel = new EditTemplateViewModel(Configuration.Instance, template);
_viewModel.resetTextBox(inputTemplateText);
Title = $"Edit {template.Name}";
_viewModel = new EditTemplateViewModel(Configuration.Instance, templateEditor);
_viewModel.resetTextBox(templateEditor.EditingTemplate.TemplateText);
Title = $"Edit {templateEditor.EditingTemplate.Name}";
DataContext = _viewModel;
}
@ -64,7 +63,6 @@ namespace LibationAvalonia.Dialogs
if (!await _viewModel.Validate())
return;
TemplateText = _viewModel.workingTemplateText;
await base.SaveAndCloseAsync();
}
@ -72,23 +70,25 @@ namespace LibationAvalonia.Dialogs
=> await SaveAndCloseAsync();
public void ResetButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> _viewModel.resetTextBox(_viewModel.Template.DefaultTemplate);
=> _viewModel.resetTextBox(_viewModel.TemplateEditor.DefaultTemplate);
private class EditTemplateViewModel : ViewModels.ViewModelBase
{
private readonly Configuration config;
public FontFamily FontFamily { get; } = FontManager.Current.DefaultFontFamilyName;
public InlineCollection Inlines { get; } = new();
public Templates Template { get; }
public EditTemplateViewModel(Configuration configuration, Templates templates)
public ITemplateEditor TemplateEditor { get; }
public EditTemplateViewModel(Configuration configuration, ITemplateEditor templates)
{
config = configuration;
Template = templates;
Description = templates.Description;
TemplateEditor = templates;
Description = templates.EditingTemplate.Description;
ListItems
= new AvaloniaList<Tuple<string, string, string>>(
Template
.GetTemplateTags()
TemplateEditor
.EditingTemplate
.TagsRegistered
.Cast<TemplateTags>()
.Select(
t => new Tuple<string, string, string>(
$"<{t.TagName.Replace("->", "-\x200C>").Replace("<-", "<\x200C-")}>",
@ -111,7 +111,6 @@ namespace LibationAvalonia.Dialogs
}
}
public string workingTemplateText => Template.Sanitize(UserTemplateText, Configuration.Instance.ReplacementCharacters);
private string _warningText;
public string WarningText { get => _warningText; set => this.RaiseAndSetIfChanged(ref _warningText, value); }
@ -123,10 +122,13 @@ namespace LibationAvalonia.Dialogs
public async Task<bool> Validate()
{
if (Template.IsValid(workingTemplateText))
if (TemplateEditor.EditingTemplate.IsValid)
return true;
var errors = Template
.GetErrors(workingTemplateText)
var errors
= TemplateEditor
.EditingTemplate
.Errors
.Select(err => $"- {err}")
.Aggregate((a, b) => $"{a}\r\n{b}");
await MessageBox.Show($"This template text is not valid. Errors:\r\n{errors}", "Invalid", MessageBoxButtons.OK, MessageBoxIcon.Error);
@ -135,66 +137,7 @@ namespace LibationAvalonia.Dialogs
private void templateTb_TextChanged()
{
var isChapterTitle = Template == Templates.ChapterTitle;
var isFolder = Template == Templates.Folder;
var libraryBookDto = new LibraryBookDto
{
Account = "my account",
DateAdded = new DateTime(2022, 6, 9, 0, 0, 0),
DatePublished = new DateTime(2017, 2, 27, 0, 0, 0),
AudibleProductId = "123456789",
Title = "A Study in Scarlet: A Sherlock Holmes Novel",
Locale = "us",
YearPublished = 2017,
Authors = new List<string> { "Arthur Conan Doyle", "Stephen Fry - introductions" },
Narrators = new List<string> { "Stephen Fry" },
SeriesName = "Sherlock Holmes",
SeriesNumber = "1",
BitRate = 128,
SampleRate = 44100,
Channels = 2,
Language = "English"
};
var chapterName = "A Flight for Life";
var chapterNumber = 4;
var chaptersTotal = 10;
var partFileProperties = new AaxDecrypter.MultiConvertFileProperties()
{
OutputFileName = "",
PartsPosition = chapterNumber,
PartsTotal = chaptersTotal,
Title = chapterName
};
/*
* Path must be rooted for windows to allow long file paths. This is
* only necessary for folder templates because they may contain several
* subdirectories. Without rooting, we won't be allowed to create a
* relative path longer than MAX_PATH.
*/
var books = config.Books;
var folder = Templates.Folder.GetPortionFilename(
libraryBookDto,
Path.Combine(books, isFolder ? workingTemplateText : config.FolderTemplate), "");
folder = Path.GetRelativePath(books, folder);
var file
= Template == Templates.ChapterFile
? Templates.ChapterFile.GetPortionFilename(
libraryBookDto,
workingTemplateText,
partFileProperties,
"")
: Templates.File.GetPortionFilename(
libraryBookDto,
isFolder ? config.FileTemplate : workingTemplateText, "");
var ext = config.DecryptToLossy ? "mp3" : "m4b";
var chapterTitle = Templates.ChapterTitle.GetPortionTitle(libraryBookDto, workingTemplateText, partFileProperties);
TemplateEditor.SetTemplateText(UserTemplateText);
const char ZERO_WIDTH_SPACE = '\u200B';
var sing = $"{Path.DirectorySeparatorChar}";
@ -207,11 +150,12 @@ namespace LibationAvalonia.Dialogs
string slashWrap(string val) => val.Replace(sing, $"{ZERO_WIDTH_SPACE}{sing}");
WarningText
= !Template.HasWarnings(workingTemplateText)
= !TemplateEditor.EditingTemplate.HasWarnings
? ""
: "Warning:\r\n" +
Template
.GetWarnings(workingTemplateText)
TemplateEditor
.EditingTemplate
.Warnings
.Select(err => $"- {err}")
.Aggregate((a, b) => $"{a}\r\n{b}");
@ -220,20 +164,24 @@ namespace LibationAvalonia.Dialogs
Inlines.Clear();
if (isChapterTitle)
if (!TemplateEditor.IsFilePath)
{
Inlines.Add(new Run(chapterTitle) { FontWeight = bold });
Inlines.Add(new Run(TemplateEditor.GetName()) { FontWeight = bold });
return;
}
Inlines.Add(new Run(slashWrap(books)) { FontWeight = reg });
var folder = TemplateEditor.GetFolderName();
var file = TemplateEditor.GetFileName();
var ext = config.DecryptToLossy ? "mp3" : "m4b";
Inlines.Add(new Run(slashWrap(TemplateEditor.BaseDirectory.PathWithoutPrefix)) { FontWeight = reg });
Inlines.Add(new Run(sing) { FontWeight = reg });
Inlines.Add(new Run(slashWrap(folder)) { FontWeight = isFolder ? bold : reg });
Inlines.Add(new Run(slashWrap(folder)) { FontWeight = TemplateEditor.IsFolder ? bold : reg });
Inlines.Add(new Run(sing));
Inlines.Add(new Run(slashWrap(file)) { FontWeight = isFolder ? reg : bold });
Inlines.Add(new Run(slashWrap(file)) { FontWeight = TemplateEditor.IsFolder ? reg : bold });
Inlines.Add(new Run($".{ext}"));
}

View File

@ -52,21 +52,22 @@ namespace LibationAvalonia.Dialogs
public async void EditFolderTemplateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var newTemplate = await editTemplate(Templates.Folder, settingsDisp.DownloadDecryptSettings.FolderTemplate);
var newTemplate = await editTemplate(TemplateEditor<Templates.FolderTemplate>.CreateFilenameEditor(config.Books, settingsDisp.DownloadDecryptSettings.FolderTemplate));
if (newTemplate is not null)
settingsDisp.DownloadDecryptSettings.FolderTemplate = newTemplate;
}
public async void EditFileTemplateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var newTemplate = await editTemplate(Templates.File, settingsDisp.DownloadDecryptSettings.FileTemplate);
var newTemplate = await editTemplate(TemplateEditor<Templates.FileTemplate>.CreateFilenameEditor(config.Books, settingsDisp.DownloadDecryptSettings.FileTemplate));
if (newTemplate is not null)
settingsDisp.DownloadDecryptSettings.FileTemplate = newTemplate;
}
public async void EditChapterFileTemplateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var newTemplate = await editTemplate(Templates.ChapterFile, settingsDisp.DownloadDecryptSettings.ChapterFileTemplate);
var newTemplate = await editTemplate(TemplateEditor<Templates.ChapterFileTemplate>.CreateFilenameEditor(config.Books, settingsDisp.DownloadDecryptSettings.ChapterFileTemplate));
if (newTemplate is not null)
settingsDisp.DownloadDecryptSettings.ChapterFileTemplate = newTemplate;
}
@ -79,16 +80,16 @@ namespace LibationAvalonia.Dialogs
public async void EditChapterTitleTemplateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var newTemplate = await editTemplate(Templates.ChapterTitle, settingsDisp.AudioSettings.ChapterTitleTemplate);
var newTemplate = await editTemplate(TemplateEditor<Templates.ChapterTitleTemplate>.CreateNameEditor(settingsDisp.AudioSettings.ChapterTitleTemplate));
if (newTemplate is not null)
settingsDisp.AudioSettings.ChapterTitleTemplate = newTemplate;
}
private async Task<string> editTemplate(Templates template, string existingTemplate)
private async Task<string> editTemplate(ITemplateEditor template)
{
var form = new EditTemplateDialog(template, existingTemplate);
var form = new EditTemplateDialog(template);
if (await form.ShowDialog<DialogResult>(this) == DialogResult.OK)
return form.TemplateText;
return template.EditingTemplate.TemplateText;
else return null;
}
}
@ -266,28 +267,8 @@ namespace LibationAvalonia.Dialogs
UseCoverAsFolderIcon = config.UseCoverAsFolderIcon;
}
public async Task<bool> SaveSettingsAsync(Configuration config)
public Task<bool> SaveSettingsAsync(Configuration config)
{
static Task validationError(string text, string caption)
=> MessageBox.Show(text, caption, MessageBoxButtons.OK, MessageBoxIcon.Error);
// these 3 should do nothing. Configuration will only init these with a valid value. EditTemplateDialog ensures valid before returning
if (!Templates.Folder.IsValid(FolderTemplate))
{
await validationError($"Not saving change to folder naming template. Invalid format.", "Invalid folder template");
return false;
}
if (!Templates.File.IsValid(FileTemplate))
{
await validationError($"Not saving change to file naming template. Invalid format.", "Invalid file template");
return false;
}
if (!Templates.ChapterFile.IsValid(ChapterFileTemplate))
{
await validationError($"Not saving change to chapter file naming template. Invalid format.", "Invalid chapter file template");
return false;
}
config.BadBook
= BadBookAbort ? Configuration.BadBookAction.Abort
: BadBookRetry ? Configuration.BadBookAction.Retry
@ -301,7 +282,7 @@ namespace LibationAvalonia.Dialogs
config.UseCoverAsFolderIcon = UseCoverAsFolderIcon;
return true;
return Task.FromResult(true);
}
public string UseCoverAsFolderIconText { get; } = Configuration.GetDescription(nameof(Configuration.UseCoverAsFolderIcon));

View File

@ -80,7 +80,7 @@ namespace LibationFileManager
public bool BetaOptIn { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("Location for book storage. Includes destination of newly liberated books")]
public string Books { get => GetString(); set => SetString(value); }
public LongPath Books { get => GetString(); set => SetString(value); }
// temp/working dir(s) should be outside of dropbox
[Description("Temporary location of files while they're in process of being downloaded and decrypted.\r\nWhen decryption is complete, the final file will be in Books location\r\nRecommend not using a folder which is backed up real time. Eg: Dropbox, iCloud, Google Drive")]
@ -223,36 +223,41 @@ namespace LibationFileManager
[Description("How to format the folders in which files will be saved")]
public string FolderTemplate
{
get => Templates.Folder.GetValid(GetString(defaultValue: Templates.Folder.DefaultTemplate));
set => setTemplate(Templates.Folder, value);
get => getTemplate<Templates.FolderTemplate>();
set => setTemplate<Templates.FolderTemplate>(value);
}
[Description("How to format the saved pdf and audio files")]
public string FileTemplate
{
get => Templates.File.GetValid(GetString(defaultValue: Templates.File.DefaultTemplate));
set => setTemplate(Templates.File, value);
get => getTemplate<Templates.FileTemplate>();
set => setTemplate<Templates.FileTemplate>(value);
}
[Description("How to format the saved audio files when split by chapters")]
public string ChapterFileTemplate
{
get => Templates.ChapterFile.GetValid(GetString(defaultValue: Templates.ChapterFile.DefaultTemplate));
set => setTemplate(Templates.ChapterFile, value);
get => getTemplate<Templates.ChapterFileTemplate>();
set => setTemplate<Templates.ChapterFileTemplate>(value);
}
[Description("How to format the file's Tile stored in metadata")]
public string ChapterTitleTemplate
{
get => Templates.ChapterTitle.GetValid(GetString(defaultValue: Templates.ChapterTitle.DefaultTemplate));
set => setTemplate(Templates.ChapterTitle, value);
get => getTemplate<Templates.ChapterTitleTemplate>();
set => setTemplate<Templates.ChapterTitleTemplate>(value);
}
private void setTemplate(Templates templ, string newValue, [CallerMemberName] string propertyName = "")
private string getTemplate<T>([CallerMemberName] string propertyName = "")
where T : Templates, ITemplate, new()
{
var template = newValue?.Trim();
if (templ.IsValid(template))
SetString(template, propertyName);
return Templates.GetTemplate<T>(GetString(defaultValue: T.DefaultTemplate, propertyName)).TemplateText;
}
private void setTemplate<T>(string newValue, [CallerMemberName] string propertyName = "")
where T : Templates, ITemplate, new()
{
SetString(Templates.GetTemplate<T>(newValue).TemplateText, propertyName);
}
#endregion
}

View File

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

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 System.Collections.Generic;
using System.Linq;
using Dinah.Core;
using FileManager.NamingTemplate;
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 TagName { get; }
public string DefaultValue { 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)
private TemplateTags(string tagName, string description, string defaultValue = null, string display = null)
{
TagName = tagName;
Description = description;
IsChapterOnly = isChapterOnly;
DefaultValue = defaultValue ?? $"<{tagName}>";
Display = display ?? $"<{tagName}>";
}
// putting these first is the incredibly lazy way to make them show up first in the EditTemplateDialog
public static TemplateTags ChCount { get; } = new TemplateTags("ch count", "Number of chapters", true);
public static TemplateTags ChTitle { get; } = new TemplateTags("ch title", "Chapter title", true);
public static TemplateTags ChNumber { get; } = new TemplateTags("ch#", "Chapter #", true);
public static TemplateTags ChNumber0 { get; } = new TemplateTags("ch# 0", "Chapter # with leading zeros", true);
public static TemplateTags ChCount { get; } = new TemplateTags("ch count", "Number of chapters");
public static TemplateTags ChTitle { get; } = new TemplateTags("ch title", "Chapter title");
public static TemplateTags ChNumber { get; } = new TemplateTags("ch#", "Chapter #");
public static TemplateTags ChNumber0 { get; } = new TemplateTags("ch# 0", "Chapter # with leading zeros");
public static TemplateTags Id { get; } = new TemplateTags("id", "Audible ID");
public static TemplateTags Title { get; } = new TemplateTags("title", "Full title");
@ -46,11 +42,10 @@ namespace LibationFileManager
public static TemplateTags Language { get; } = new("language", "Book's language");
public static TemplateTags LanguageShort { get; } = new("language short", "Book's language abbreviated. Eg: ENG");
// Special cases. Aren't mapped to replacements in Templates.cs
// Included here for display by EditTemplateDialog
public static TemplateTags FileDate { get; } = new TemplateTags("file date [...]", "File date/time. e.g. yyyy-MM-dd HH-mm", false, $"<file date [{Templates.DEFAULT_DATE_FORMAT}]>");
public static TemplateTags DatePublished { get; } = new TemplateTags("pub date [...]", "Publication date. e.g. yyyy-MM-dd", false, $"<pub date [{Templates.DEFAULT_DATE_FORMAT}]>");
public static TemplateTags DateAdded { get; } = new TemplateTags("date added [...]", "Date added to your Audible account. e.g. yyyy-MM-dd", false, $"<date added [{Templates.DEFAULT_DATE_FORMAT}]>");
public static TemplateTags IfSeries { get; } = new TemplateTags("if series->...<-if series", "Only include if part of a series", false, "<if series-><-if series>");
public static TemplateTags FileDate { get; } = new TemplateTags("file date", "File date/time. e.g. yyyy-MM-dd HH-mm", $"<file date [{DEFAULT_DATE_FORMAT}]>", "<file date [...]>");
public static TemplateTags DatePublished { get; } = new TemplateTags("pub date", "Publication date. e.g. yyyy-MM-dd", $"<pub date [{DEFAULT_DATE_FORMAT}]>", "<pub date [...]>");
public static TemplateTags DateAdded { get; } = new TemplateTags("date added", "Date added to your Audible account. e.g. yyyy-MM-dd", $"<date added [{DEFAULT_DATE_FORMAT}]>", "<date added [...]>");
public static TemplateTags IfSeries { get; } = new TemplateTags("if series", "Only include if part of a series", "<if series-><-if series>", "<if series->...<-if series>");
public static TemplateTags IfPodcast { get; } = new TemplateTags("if podcast", "Only include if part of a podcast", "<if podcast-><-if podcast>", "<if podcast->...<-if podcast>");
}
}

View File

@ -2,115 +2,253 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using AaxDecrypter;
using Dinah.Core;
using Dinah.Core.Collections.Generic;
using FileManager;
using FileManager.NamingTemplate;
namespace LibationFileManager
{
public interface ITemplate
{
static abstract string DefaultTemplate { get; }
static abstract IEnumerable<TagClass> TagClass { get; }
}
public abstract class Templates
{
protected static string[] Valid => Array.Empty<string>();
public const string ERROR_NULL_IS_INVALID = "Null template is invalid.";
public const string ERROR_FULL_PATH_IS_INVALID = @"No colons or full paths allowed. Eg: should not start with C:\";
public const string ERROR_INVALID_FILE_NAME_CHAR = @"Only file name friendly characters allowed. Eg: no colons or slashes";
public const string WARNING_EMPTY = "Template is empty.";
public const string WARNING_WHITE_SPACE = "Template is white space.";
public const string WARNING_NO_TAGS = "Should use tags. Eg: <title>";
public const string WARNING_HAS_CHAPTER_TAGS = "Chapter tags should only be used in the template used for naming files which are split by chapter. Eg: <ch title>";
public const string WARNING_NO_CHAPTER_NUMBER_TAG = "Should include chapter number tag in template used for naming files which are split by chapter. Ie: <ch#> or <ch# 0>";
public static FolderTemplate Folder { get; } = new FolderTemplate();
public static FileTemplate File { get; } = new FileTemplate();
public static ChapterFileTemplate ChapterFile { get; } = new ChapterFileTemplate();
public static ChapterTitleTemplate ChapterTitle { get; } = new ChapterTitleTemplate();
//Assign the properties in the static constructor will require all
//Templates users to have a valid configuration file. To allow tests
//to work without access to Configuration, only load templates on demand.
private static FolderTemplate _folder;
private static FileTemplate _file;
private static ChapterFileTemplate _chapterFile;
private static ChapterTitleTemplate _chapterTitle;
public static FolderTemplate Folder => _folder ??= GetTemplate<FolderTemplate>(Configuration.Instance.FolderTemplate);
public static FileTemplate File => _file ??= GetTemplate<FileTemplate>(Configuration.Instance.FileTemplate);
public static ChapterFileTemplate ChapterFile => _chapterFile ??= GetTemplate<ChapterFileTemplate>(Configuration.Instance.ChapterFileTemplate);
public static ChapterTitleTemplate ChapterTitle => _chapterTitle ??= GetTemplate<ChapterTitleTemplate>(Configuration.Instance.ChapterTitleTemplate);
#region Template Parsing
public static T GetTemplate<T>(string templateText) where T : Templates, ITemplate, new()
=> TryGetTemplate<T>(templateText, out var template) ? template : GetDefaultTemplate<T>();
public static bool TryGetTemplate<T>(string templateText, out T template) where T : Templates, ITemplate, new()
{
var namingTemplate = NamingTemplate.Parse(templateText, T.TagClass);
template = new() { Template = namingTemplate };
return !namingTemplate.Errors.Any();
}
private static T GetDefaultTemplate<T>() where T : Templates, ITemplate, new()
=> new() { Template = NamingTemplate.Parse(T.DefaultTemplate, T.TagClass) };
static Templates()
{
Configuration.Instance.PropertyChanged += FolderTemplate_PropertyChanged;
Configuration.Instance.PropertyChanged += FileTemplate_PropertyChanged;
Configuration.Instance.PropertyChanged += ChapterFileTemplate_PropertyChanged;
Configuration.Instance.PropertyChanged += ChapterTitleTemplate_PropertyChanged;
}
[PropertyChangeFilter(nameof(Configuration.FolderTemplate))]
private static void FolderTemplate_PropertyChanged(object sender, PropertyChangedEventArgsEx e)
{
_folder = GetTemplate<FolderTemplate>((string)e.NewValue);
}
[PropertyChangeFilter(nameof(Configuration.FileTemplate))]
private static void FileTemplate_PropertyChanged(object sender, PropertyChangedEventArgsEx e)
{
_file = GetTemplate<FileTemplate>((string)e.NewValue);
}
[PropertyChangeFilter(nameof(Configuration.ChapterFileTemplate))]
private static void ChapterFileTemplate_PropertyChanged(object sender, PropertyChangedEventArgsEx e)
{
_chapterFile = GetTemplate<ChapterFileTemplate>((string)e.NewValue);
}
[PropertyChangeFilter(nameof(Configuration.ChapterTitleTemplate))]
private static void ChapterTitleTemplate_PropertyChanged(object sender, PropertyChangedEventArgsEx e)
{
_chapterTitle = GetTemplate<ChapterTitleTemplate>((string)e.NewValue);
}
#endregion
#region Template Properties
public IEnumerable<TemplateTags> TagsRegistered => Template.TagsRegistered.Cast<TemplateTags>();
public IEnumerable<TemplateTags> TagsInUse => Template.TagsInUse.Cast<TemplateTags>();
public abstract string Name { get; }
public abstract string Description { get; }
public abstract string DefaultTemplate { get; }
protected abstract bool IsChapterized { get; }
public string TemplateText => Template.TemplateText;
protected NamingTemplate Template { get; private set; }
protected Templates() { }
#endregion
#region validation
internal string GetValid(string configValue)
{
var value = configValue?.Trim();
return IsValid(value) ? value : DefaultTemplate;
}
public abstract IEnumerable<string> GetErrors(string template);
public bool IsValid(string template) => !GetErrors(template).Any();
public virtual IEnumerable<string> Errors => Template.Errors;
public bool IsValid => !Errors.Any();
public abstract IEnumerable<string> GetWarnings(string template);
public bool HasWarnings(string template) => GetWarnings(template).Any();
public virtual IEnumerable<string> Warnings => Template.Warnings;
public bool HasWarnings => Warnings.Any();
protected static string[] GetFileErrors(string template)
{
// File name only; not path. all other path chars are valid enough to pass this check and will be handled on final save.
// null is invalid. whitespace is valid but not recommended
if (template is null)
return new[] { ERROR_NULL_IS_INVALID };
if (ReplacementCharacters.ContainsInvalidFilenameChar(template.Replace("<","").Replace(">","")))
return new[] { ERROR_INVALID_FILE_NAME_CHAR };
return Valid;
}
protected IEnumerable<string> GetStandardWarnings(string template)
{
var warnings = GetErrors(template).ToList();
if (template is null)
return warnings;
if (string.IsNullOrEmpty(template))
warnings.Add(WARNING_EMPTY);
else if (string.IsNullOrWhiteSpace(template))
warnings.Add(WARNING_WHITE_SPACE);
if (TagCount(template) == 0)
warnings.Add(WARNING_NO_TAGS);
if (!IsChapterized && ContainsChapterOnlyTags(template))
warnings.Add(WARNING_HAS_CHAPTER_TAGS);
return warnings;
}
internal int TagCount(string template)
=> GetTemplateTags()
// for <id><id> == 1, use:
// .Count(t => template.Contains($"<{t.TagName}>"))
// .Sum() impl: <id><id> == 2
.Sum(t => template.Split($"<{t.TagName}>").Length - 1);
internal static bool ContainsChapterOnlyTags(string template)
=> TemplateTags.GetAll()
.Where(t => t.IsChapterOnly)
.Any(t => ContainsTag(template, t.TagName));
internal static bool ContainsTag(string template, string tag) => template.Contains($"<{tag}>");
#endregion
#region to file name
/// <summary>
/// EditTemplateDialog: Get template generated filename for portion of path
/// </summary>
public string GetPortionFilename(LibraryBookDto libraryBookDto, string template, string fileExtension)
=> string.IsNullOrWhiteSpace(template)
? ""
: getFileNamingTemplate(libraryBookDto, template, null, fileExtension, Configuration.Instance.ReplacementCharacters)
.GetFilePath(fileExtension).PathWithoutPrefix;
public const string DEFAULT_DATE_FORMAT = "yyyy-MM-dd";
private static Regex fileDateTagRegex { get; } = new Regex(@"<file\s*?date\s*?(?:\[([^\[\]]*?)\]\s*?)?>", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static Regex dateAddedTagRegex { get; } = new Regex(@"<date\s*?added\s*?(?:\[([^\[\]]*?)\]\s*?)?>", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static Regex datePublishedTagRegex { get; } = new Regex(@"<pub\s*?date\s*?(?:\[([^\[\]]*?)\]\s*?)?>", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static Regex ifSeriesRegex { get; } = new Regex("<if series->(.*?)<-if series>", RegexOptions.Compiled | RegexOptions.IgnoreCase);
public string GetName(LibraryBookDto libraryBookDto, MultiConvertFileProperties multiChapProps)
{
ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto));
ArgumentValidator.EnsureNotNull(multiChapProps, nameof(multiChapProps));
return string.Join("", Template.Evaluate(libraryBookDto, multiChapProps).Select(p => p.Value));
}
public LongPath GetFilename(LibraryBookDto libraryBookDto, string baseDir, string fileExtension, ReplacementCharacters replacements = null, bool returnFirstExisting = false)
{
ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto));
ArgumentValidator.EnsureNotNull(baseDir, nameof(baseDir));
ArgumentValidator.EnsureNotNull(fileExtension, nameof(fileExtension));
replacements ??= Configuration.Instance.ReplacementCharacters;
return GetFilename(baseDir, fileExtension, returnFirstExisting, replacements, libraryBookDto);
}
public LongPath GetFilename(LibraryBookDto libraryBookDto, MultiConvertFileProperties multiChapProps, string baseDir = "", string fileExtension = null, ReplacementCharacters replacements = null)
{
ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto));
ArgumentValidator.EnsureNotNull(multiChapProps, nameof(multiChapProps));
ArgumentValidator.EnsureNotNull(baseDir, nameof(baseDir));
replacements ??= Configuration.Instance.ReplacementCharacters;
fileExtension ??= Path.GetExtension(multiChapProps.OutputFileName);
return GetFilename(baseDir, fileExtension, false, replacements, libraryBookDto, multiChapProps);
}
protected virtual IEnumerable<string> GetTemplatePartsStrings(List<TemplatePart> parts, ReplacementCharacters replacements)
=> parts.Select(p => replacements.ReplaceFilenameChars(p.Value));
private LongPath GetFilename(string baseDir, string fileExtension, bool returnFirstExisting, ReplacementCharacters replacements, params object[] dtos)
{
var parts = Template.Evaluate(dtos).ToList();
var pathParts = GetPathParts(GetTemplatePartsStrings(parts, replacements));
//Remove 1 character from the end of the longest filename part until
//the total filename is less than max filename length
foreach (var part in pathParts)
{
while (part.Sum(LongPath.GetFilesystemStringLength) > LongPath.MaxFilenameLength)
{
int maxLength = part.Max(p => p.Length);
var maxEntry = part.First(p => p.Length == maxLength);
var maxIndex = part.IndexOf(maxEntry);
part.RemoveAt(maxIndex);
part.Insert(maxIndex, maxEntry.Remove(maxLength - 1, 1));
}
}
var fullPath = Path.Combine(pathParts.Select(p => string.Join("", p)).Prepend(baseDir).ToArray());
return FileUtility.GetValidFilename(fullPath, replacements, fileExtension, returnFirstExisting);
}
/// <summary>
/// Organize template parts into directories.
/// </summary>
/// <returns>A List of template directories. Each directory is a list of template part strings</returns>
private List<List<string>> GetPathParts(IEnumerable<string> templateParts)
{
List<List<string>> directories = new();
List<string> dir = new();
foreach (var part in templateParts)
{
int slashIndex, lastIndex = 0;
while((slashIndex = part.IndexOf(Path.DirectorySeparatorChar, lastIndex)) > -1)
{
dir.Add(part[lastIndex..slashIndex]);
directories.Add(dir);
dir = new();
lastIndex = slashIndex + 1;
}
dir.Add(part[lastIndex..]);
}
directories.Add(dir);
return directories;
}
#endregion
#region Registered Template Properties
private static readonly PropertyTagClass<LibraryBookDto> filePropertyTags = GetFilePropertyTags();
private static readonly ConditionalTagClass<LibraryBookDto> conditionalTags = GetConditionalTags();
private static readonly List<TagClass> chapterPropertyTags = GetChapterPropertyTags();
private static ConditionalTagClass<LibraryBookDto> GetConditionalTags()
{
ConditionalTagClass<LibraryBookDto> lbConditions = new();
lbConditions.RegisterCondition(TemplateTags.IfSeries, lb => !string.IsNullOrWhiteSpace(lb.SeriesName));
lbConditions.RegisterCondition(TemplateTags.IfPodcast, lb => lb.IsPodcast);
return lbConditions;
}
private static PropertyTagClass<LibraryBookDto> GetFilePropertyTags()
{
PropertyTagClass<LibraryBookDto> lbProperties = new();
lbProperties.RegisterProperty(TemplateTags.Id, lb => lb.AudibleProductId);
lbProperties.RegisterProperty(TemplateTags.Title, lb => lb.Title ?? "", StringFormatter);
lbProperties.RegisterProperty(TemplateTags.TitleShort, lb => lb.Title.IndexOf(':') < 1 ? lb.Title : lb.Title.Substring(0, lb.Title.IndexOf(':')), StringFormatter);
lbProperties.RegisterProperty(TemplateTags.Author, lb => lb.AuthorNames, StringFormatter);
lbProperties.RegisterProperty(TemplateTags.FirstAuthor, lb => lb.FirstAuthor, StringFormatter);
lbProperties.RegisterProperty(TemplateTags.Narrator, lb => lb.NarratorNames, StringFormatter);
lbProperties.RegisterProperty(TemplateTags.FirstNarrator, lb => lb.FirstNarrator, StringFormatter);
lbProperties.RegisterProperty(TemplateTags.Series, lb => lb.SeriesName ?? "", StringFormatter);
lbProperties.RegisterProperty(TemplateTags.SeriesNumber, lb => lb.SeriesNumber);
lbProperties.RegisterProperty(TemplateTags.Language, lb => lb.Language);
lbProperties.RegisterProperty(TemplateTags.LanguageShort, lb => getLanguageShort(lb.Language));
lbProperties.RegisterProperty(TemplateTags.Bitrate, lb => lb.BitRate, IntegerFormatter);
lbProperties.RegisterProperty(TemplateTags.SampleRate, lb => lb.SampleRate, IntegerFormatter);
lbProperties.RegisterProperty(TemplateTags.Channels, lb => lb.Channels, IntegerFormatter);
lbProperties.RegisterProperty(TemplateTags.Account, lb => lb.Account, StringFormatter);
lbProperties.RegisterProperty(TemplateTags.Locale, lb => lb.Locale, StringFormatter);
lbProperties.RegisterProperty(TemplateTags.YearPublished, lb => lb.YearPublished, IntegerFormatter);
lbProperties.RegisterProperty(TemplateTags.DatePublished, lb => lb.DatePublished, DateTimeFormatter);
lbProperties.RegisterProperty(TemplateTags.DateAdded, lb => lb.DateAdded, DateTimeFormatter);
lbProperties.RegisterProperty(TemplateTags.FileDate, lb => lb.FileDate, DateTimeFormatter);
return lbProperties;
}
private static List<TagClass> GetChapterPropertyTags()
{
PropertyTagClass<LibraryBookDto> lbProperties = new();
PropertyTagClass<MultiConvertFileProperties> multiConvertProperties = new();
lbProperties.RegisterProperty(TemplateTags.Title, lb => lb.Title ?? "");
lbProperties.RegisterProperty(TemplateTags.TitleShort, lb => lb.Title.IndexOf(':') < 1 ? lb.Title : lb.Title.Substring(0, lb.Title.IndexOf(':')));
lbProperties.RegisterProperty(TemplateTags.Series, lb => lb.SeriesName ?? "");
multiConvertProperties.RegisterProperty(TemplateTags.ChCount, lb => lb.PartsTotal, IntegerFormatter);
multiConvertProperties.RegisterProperty(TemplateTags.ChNumber, lb => lb.PartsPosition, IntegerFormatter);
multiConvertProperties.RegisterProperty(TemplateTags.ChNumber0, m => m.PartsPosition.ToString("D" + ((int)Math.Log10(m.PartsTotal) + 1)));
multiConvertProperties.RegisterProperty(TemplateTags.ChTitle, m => m.Title ?? "", StringFormatter);
return new List<TagClass> { lbProperties, multiConvertProperties };
}
#endregion
#region Tag Formatters
private static string getLanguageShort(string language)
{
@ -123,330 +261,94 @@ namespace LibationFileManager
return language[..3].ToUpper();
}
internal static FileNamingTemplate getFileNamingTemplate(LibraryBookDto libraryBookDto, string template, string dirFullPath, string extension, ReplacementCharacters replacements)
private static string StringFormatter(ITemplateTag templateTag, string value, string formatString)
{
ArgumentValidator.EnsureNotNullOrWhiteSpace(template, nameof(template));
ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto));
replacements ??= Configuration.Instance.ReplacementCharacters;
dirFullPath = dirFullPath?.Trim() ?? "";
// for non-series, remove <if series-> and <-if series> tags and everything in between
// for series, remove <if series-> and <-if series> tags, what's in between will remain
template = ifSeriesRegex.Replace(
template,
string.IsNullOrWhiteSpace(libraryBookDto.SeriesName) ? "" : "$1");
//Get date replacement parameters. Sanitizes the format text and replaces
//the template with the sanitized text before creating FileNamingTemplate
var fileDateParams = getSanitizeDateReplacementParameters(fileDateTagRegex, ref template, replacements, libraryBookDto.FileDate);
var dateAddedParams = getSanitizeDateReplacementParameters(dateAddedTagRegex, ref template, replacements, libraryBookDto.DateAdded);
var pubDateParams = getSanitizeDateReplacementParameters(datePublishedTagRegex, ref template, replacements, libraryBookDto.DatePublished);
var t = template + FileUtility.GetStandardizedExtension(extension);
var fullfilename = dirFullPath == "" ? t : Path.Combine(dirFullPath, t);
var fileNamingTemplate = new FileNamingTemplate(fullfilename, replacements);
var title = libraryBookDto.Title ?? "";
var titleShort = title.IndexOf(':') < 1 ? title : title.Substring(0, title.IndexOf(':'));
fileNamingTemplate.AddParameterReplacement(TemplateTags.Id, libraryBookDto.AudibleProductId);
fileNamingTemplate.AddParameterReplacement(TemplateTags.Title, title);
fileNamingTemplate.AddParameterReplacement(TemplateTags.TitleShort, titleShort);
fileNamingTemplate.AddParameterReplacement(TemplateTags.Author, libraryBookDto.AuthorNames);
fileNamingTemplate.AddParameterReplacement(TemplateTags.FirstAuthor, libraryBookDto.FirstAuthor);
fileNamingTemplate.AddParameterReplacement(TemplateTags.Narrator, libraryBookDto.NarratorNames);
fileNamingTemplate.AddParameterReplacement(TemplateTags.FirstNarrator, libraryBookDto.FirstNarrator);
fileNamingTemplate.AddParameterReplacement(TemplateTags.Series, libraryBookDto.SeriesName);
fileNamingTemplate.AddParameterReplacement(TemplateTags.SeriesNumber, libraryBookDto.SeriesNumber);
fileNamingTemplate.AddParameterReplacement(TemplateTags.Bitrate, libraryBookDto.BitRate);
fileNamingTemplate.AddParameterReplacement(TemplateTags.SampleRate, libraryBookDto.SampleRate);
fileNamingTemplate.AddParameterReplacement(TemplateTags.Channels, libraryBookDto.Channels);
fileNamingTemplate.AddParameterReplacement(TemplateTags.Account, libraryBookDto.Account);
fileNamingTemplate.AddParameterReplacement(TemplateTags.Locale, libraryBookDto.Locale);
fileNamingTemplate.AddParameterReplacement(TemplateTags.YearPublished, libraryBookDto.YearPublished?.ToString() ?? "1900");
fileNamingTemplate.AddParameterReplacement(TemplateTags.Language, libraryBookDto.Language);
fileNamingTemplate.AddParameterReplacement(TemplateTags.LanguageShort, getLanguageShort(libraryBookDto.Language));
//Add the sanitized replacement parameters
foreach (var param in fileDateParams)
fileNamingTemplate.ParameterReplacements.AddIfNotContains(param);
foreach (var param in dateAddedParams)
fileNamingTemplate.ParameterReplacements.AddIfNotContains(param);
foreach (var param in pubDateParams)
fileNamingTemplate.ParameterReplacements.AddIfNotContains(param);
return fileNamingTemplate;
if (string.Compare(formatString, "u", ignoreCase: true) == 0) return value?.ToUpper();
else if (string.Compare(formatString, "l", ignoreCase: true) == 0) return value?.ToLower();
else return value;
}
private static string IntegerFormatter(ITemplateTag templateTag, int value, string formatString)
{
if (int.TryParse(formatString, out var numDigits))
return value.ToString($"D{numDigits}");
return value.ToString();
}
private static string DateTimeFormatter(ITemplateTag templateTag, DateTime value, string formatString)
{
if (string.IsNullOrEmpty(formatString))
return value.ToString(TemplateTags.DEFAULT_DATE_FORMAT);
return value.ToString(formatString);
}
#endregion
#region DateTime Tags
/// <param name="template">the file naming template. Any found date tags will be sanitized,
/// and the template's original date tag will be replaced with the sanitized tag.</param>
/// <returns>A list of parameter replacement key-value pairs</returns>
private static List<KeyValuePair<string, object>> getSanitizeDateReplacementParameters(Regex datePattern, ref string template, ReplacementCharacters replacements, DateTime? dateTime)
{
List<KeyValuePair<string, object>> dateParams = new();
foreach (Match dateTag in datePattern.Matches(template))
{
var sanitizedTag = sanitizeDateParameterTag(dateTag, replacements, out var sanitizedFormatter);
if (tryFormatDateTime(dateTime, sanitizedFormatter, replacements, out var formattedDateString))
{
dateParams.Add(new(sanitizedTag, formattedDateString));
template = template.Replace(dateTag.Value, sanitizedTag);
}
}
return dateParams;
}
/// <returns>a date parameter replacement tag with the format string sanitized</returns>
private static string sanitizeDateParameterTag(Match dateTag, ReplacementCharacters replacements, out string sanitizedFormatter)
{
if (dateTag.Groups.Count != 2 || string.IsNullOrWhiteSpace(dateTag.Groups[1].Value))
{
sanitizedFormatter = DEFAULT_DATE_FORMAT;
return dateTag.Value;
}
var formatter = dateTag.Groups[1].Value;
sanitizedFormatter = replacements.ReplaceFilenameChars(formatter).Trim();
return dateTag.Value.Replace(formatter, sanitizedFormatter);
}
private static bool tryFormatDateTime(DateTime? dateTime, string sanitizedFormatter, ReplacementCharacters replacements, out string formattedDateString)
{
if (!dateTime.HasValue)
{
formattedDateString = string.Empty;
return true;
}
try
{
formattedDateString = replacements.ReplaceFilenameChars(dateTime.Value.ToString(sanitizedFormatter)).Trim();
return true;
}
catch
{
formattedDateString = null;
return false;
}
}
#endregion
public virtual IEnumerable<TemplateTags> GetTemplateTags()
=> TemplateTags.GetAll()
// yeah, this line is a little funky but it works when you think through it. also: trust the unit tests
.Where(t => IsChapterized || !t.IsChapterOnly);
public string Sanitize(string template, ReplacementCharacters replacements)
{
var value = template ?? "";
// Replace invalid filename characters in the DateTime format provider so we don't trip any alarms.
// Illegal filename characters in the formatter are allowed because they will be replaced by
// getFileNamingTemplate()
value = fileDateTagRegex.Replace(value, m => sanitizeDateParameterTag(m, replacements, out _));
value = dateAddedTagRegex.Replace(value, m => sanitizeDateParameterTag(m, replacements, out _));
value = datePublishedTagRegex.Replace(value, m => sanitizeDateParameterTag(m, replacements, out _));
// don't use alt slash
value = value.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
// don't allow double slashes
var sing = $"{Path.DirectorySeparatorChar}";
var dbl = $"{Path.DirectorySeparatorChar}{Path.DirectorySeparatorChar}";
while (value.Contains(dbl))
value = value.Replace(dbl, sing);
// trim. don't start or end with slash
while (true)
{
var start = value.Length;
value = value
.Trim()
.Trim(Path.DirectorySeparatorChar);
var end = value.Length;
if (start == end)
break;
}
return value;
}
public class FolderTemplate : Templates
public class FolderTemplate : Templates, ITemplate
{
public override string Name => "Folder Template";
public override string Description => Configuration.GetDescription(nameof(Configuration.FolderTemplate));
public override string DefaultTemplate { get; } = "<title short> [<id>]";
protected override bool IsChapterized { get; } = false;
public static string DefaultTemplate { get; } = "<title short> [<id>]";
public static IEnumerable<TagClass> TagClass => new TagClass[] { filePropertyTags, conditionalTags };
internal FolderTemplate() : base() { }
public override IEnumerable<string> Errors
=> TemplateText?.Length >= 2 && Path.IsPathFullyQualified(TemplateText) ? base.Errors.Append(ERROR_FULL_PATH_IS_INVALID) : base.Errors;
#region validation
public override IEnumerable<string> GetErrors(string template)
protected override List<string> GetTemplatePartsStrings(List<TemplatePart> parts, ReplacementCharacters replacements)
{
// null is invalid. whitespace is valid but not recommended
if (template is null)
return new[] { ERROR_NULL_IS_INVALID };
foreach (var tp in parts)
{
//FolderTemplate literals can have directory separator characters
if (tp.TemplateTag is null)
tp.Value = replacements.ReplacePathChars(tp.Value.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar));
else
tp.Value = replacements.ReplaceFilenameChars(tp.Value);
}
if (parts.Count > 0)
{
//Remove DirectorySeparatorChar at beginning and end of template
if (parts[0].Value.Length > 0 && parts[0].Value[0] == Path.DirectorySeparatorChar)
parts[0].Value = parts[0].Value.Remove(0,1);
// must be relative. no colons. all other path chars are valid enough to pass this check and will be handled on final save.
if (template.Contains(':'))
return new[] { ERROR_FULL_PATH_IS_INVALID };
// must be relative. no colons. all other path chars are valid enough to pass this check and will be handled on final save.
if (ReplacementCharacters.ContainsInvalidPathChar(template.Replace("<", "").Replace(">", "")))
return new[] { ERROR_INVALID_FILE_NAME_CHAR };
return Valid;
if (parts[^1].Value.Length > 0 && parts[^1].Value[^1] == Path.DirectorySeparatorChar)
parts[^1].Value = parts[^1].Value.Remove(parts[^1].Value.Length - 1, 1);
}
return parts.Select(p => p.Value).ToList();
}
}
public override IEnumerable<string> GetWarnings(string template) => GetStandardWarnings(template);
#endregion
#region to file name
/// <summary>USES LIVE CONFIGURATION VALUES</summary>
public string GetFilename(LibraryBookDto libraryBookDto, string baseDir = null)
=> getFileNamingTemplate(libraryBookDto, Configuration.Instance.FolderTemplate, baseDir ?? AudibleFileStorage.BooksDirectory, null, Configuration.Instance.ReplacementCharacters)
.GetFilePath(string.Empty);
#endregion
}
public class FileTemplate : Templates
public class FileTemplate : Templates, ITemplate
{
public override string Name => "File Template";
public override string Description => Configuration.GetDescription(nameof(Configuration.FileTemplate));
public override string DefaultTemplate { get; } = "<title> [<id>]";
protected override bool IsChapterized { get; } = false;
internal FileTemplate() : base() { }
#region validation
public override IEnumerable<string> GetErrors(string template) => GetFileErrors(template);
public override IEnumerable<string> GetWarnings(string template) => GetStandardWarnings(template);
#endregion
#region to file name
/// <summary>USES LIVE CONFIGURATION VALUES</summary>
public string GetFilename(LibraryBookDto libraryBookDto, string dirFullPath, string extension, bool returnFirstExisting = false)
=> getFileNamingTemplate(libraryBookDto, Configuration.Instance.FileTemplate, dirFullPath, extension, Configuration.Instance.ReplacementCharacters)
.GetFilePath(extension, returnFirstExisting);
#endregion
public static string DefaultTemplate { get; } = "<title> [<id>]";
public static IEnumerable<TagClass> TagClass { get; } = new TagClass[] { filePropertyTags, conditionalTags };
}
public class ChapterFileTemplate : Templates
public class ChapterFileTemplate : Templates, ITemplate
{
public override string Name => "Chapter File Template";
public override string Description => Configuration.GetDescription(nameof(Configuration.ChapterFileTemplate));
public override string DefaultTemplate { get; } = "<title> [<id>] - <ch# 0> - <ch title>";
protected override bool IsChapterized { get; } = true;
public static string DefaultTemplate { get; } = "<title> [<id>] - <ch# 0> - <ch title>";
public static IEnumerable<TagClass> TagClass { get; }
= chapterPropertyTags.Append(filePropertyTags).Append(conditionalTags);
internal ChapterFileTemplate() : base() { }
#region validation
public override IEnumerable<string> GetErrors(string template) => GetFileErrors(template);
public override IEnumerable<string> GetWarnings(string template)
{
var warnings = GetStandardWarnings(template).ToList();
if (template is null)
return warnings;
// recommended to incl. <ch#> or <ch# 0>
if (!ContainsTag(template, TemplateTags.ChNumber.TagName) && !ContainsTag(template, TemplateTags.ChNumber0.TagName))
warnings.Add(WARNING_NO_CHAPTER_NUMBER_TAG);
return warnings;
}
#endregion
#region to file name
/// <summary>USES LIVE CONFIGURATION VALUES</summary>
public string GetFilename(LibraryBookDto libraryBookDto, AaxDecrypter.MultiConvertFileProperties props)
=> GetPortionFilename(libraryBookDto, Configuration.Instance.ChapterFileTemplate, props, AudibleFileStorage.DecryptInProgressDirectory);
public string GetPortionFilename(LibraryBookDto libraryBookDto, string template, AaxDecrypter.MultiConvertFileProperties props, string fullDirPath, ReplacementCharacters replacements = null)
{
if (string.IsNullOrWhiteSpace(template)) return string.Empty;
replacements ??= Configuration.Instance.ReplacementCharacters;
var fileExtension = Path.GetExtension(props.OutputFileName);
var fileNamingTemplate = getFileNamingTemplate(libraryBookDto, template, fullDirPath, fileExtension, replacements);
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChCount, props.PartsTotal);
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChNumber, props.PartsPosition);
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChNumber0, FileUtility.GetSequenceFormatted(props.PartsPosition, props.PartsTotal));
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChTitle, props.Title ?? "");
foreach (Match dateTag in fileDateTagRegex.Matches(fileNamingTemplate.Template))
{
var sanitizedTag = sanitizeDateParameterTag(dateTag, replacements, out string sanitizedFormatter);
if (tryFormatDateTime(props.FileDate, sanitizedFormatter, replacements, out var formattedDateString))
fileNamingTemplate.ParameterReplacements[sanitizedTag] = formattedDateString;
public override IEnumerable<string> Warnings
=> Template.TagsInUse.Any(t => t.TagName.In(TemplateTags.ChNumber.TagName, TemplateTags.ChNumber0.TagName))
? base.Warnings
: base.Warnings.Append(WARNING_NO_CHAPTER_NUMBER_TAG);
}
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 Description => Configuration.GetDescription(nameof(Configuration.ChapterTitleTemplate));
public static string DefaultTemplate => "<ch#> - <title short>: <ch title>";
public static IEnumerable<TagClass> TagClass { get; }
= chapterPropertyTags.Append(conditionalTags);
public override string DefaultTemplate => "<ch#> - <title short>: <ch title>";
protected override bool IsChapterized => true;
public override IEnumerable<string> GetErrors(string template)
=> new List<string>();
public override IEnumerable<string> GetWarnings(string template)
=> GetStandardWarnings(template).ToList();
public string GetTitle(LibraryBookDto libraryBookDto, AaxDecrypter.MultiConvertFileProperties props)
=> GetPortionTitle(libraryBookDto, Configuration.Instance.ChapterTitleTemplate, props);
public string GetPortionTitle(LibraryBookDto libraryBookDto, string template, AaxDecrypter.MultiConvertFileProperties props)
{
if (string.IsNullOrEmpty(template)) return string.Empty;
ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto));
var fileNamingTemplate = new MetadataNamingTemplate(template);
var title = libraryBookDto.Title ?? "";
var titleShort = title.IndexOf(':') < 1 ? title : title.Substring(0, title.IndexOf(':'));
fileNamingTemplate.AddParameterReplacement(TemplateTags.Title, title);
fileNamingTemplate.AddParameterReplacement(TemplateTags.TitleShort, titleShort);
fileNamingTemplate.AddParameterReplacement(TemplateTags.Series, libraryBookDto.SeriesName);
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChCount, props.PartsTotal);
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChNumber, props.PartsPosition);
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChNumber0, FileUtility.GetSequenceFormatted(props.PartsPosition, props.PartsTotal));
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChTitle, props.Title ?? "");
return fileNamingTemplate.GetTagContents();
}
public override IEnumerable<TemplateTags> GetTemplateTags() => _templateTags;
protected override IEnumerable<string> GetTemplatePartsStrings(List<TemplatePart> parts, ReplacementCharacters replacements)
=> parts.Select(p => p.Value);
}
}
}

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.Collections.Generic;
using System.Linq;
using System.IO;
using System.Windows.Forms;
@ -10,33 +9,19 @@ namespace LibationWinForms.Dialogs
{
public partial class EditTemplateDialog : Form
{
// final value. post-validity check
public string TemplateText { get; private set; }
// hold the work-in-progress value. not guaranteed to be valid
private string _workingTemplateText;
private string workingTemplateText
{
get => _workingTemplateText;
set => _workingTemplateText = template.Sanitize(value, Configuration.Instance.ReplacementCharacters);
}
private void resetTextBox(string value) => this.templateTb.Text = workingTemplateText = value;
private void resetTextBox(string value) => this.templateTb.Text = value;
private Configuration config { get; } = Configuration.Instance;
private Templates template { get; }
private string inputTemplateText { get; }
private ITemplateEditor templateEditor { get;}
public EditTemplateDialog()
{
InitializeComponent();
this.SetLibationIcon();
}
public EditTemplateDialog(Templates template, string inputTemplateText) : this()
public EditTemplateDialog(ITemplateEditor templateEditor) : this()
{
this.template = ArgumentValidator.EnsureNotNull(template, nameof(template));
this.inputTemplateText = inputTemplateText ?? "";
this.templateEditor = ArgumentValidator.EnsureNotNull(templateEditor, nameof(templateEditor));
}
private void EditTemplateDialog_Load(object sender, EventArgs e)
@ -44,89 +29,31 @@ namespace LibationWinForms.Dialogs
if (this.DesignMode)
return;
if (template is null)
if (templateEditor is null)
{
MessageBoxLib.ShowAdminAlert(this, $"Programming error. {nameof(EditTemplateDialog)} was not created correctly", "Edit template error", new NullReferenceException($"{nameof(template)} is null"));
MessageBoxLib.ShowAdminAlert(this, $"Programming error. {nameof(EditTemplateDialog)} was not created correctly", "Edit template error", new NullReferenceException($"{nameof(templateEditor)} is null"));
return;
}
warningsLbl.Text = "";
this.Text = $"Edit {template.Name}";
this.Text = $"Edit {templateEditor.EditingTemplate.Name}";
this.templateLbl.Text = template.Description;
resetTextBox(inputTemplateText);
this.templateLbl.Text = templateEditor.EditingTemplate.Description;
resetTextBox(templateEditor.EditingTemplate.TemplateText);
// populate list view
foreach (var tag in template.GetTemplateTags())
listView1.Items.Add(new ListViewItem(new[] { $"<{tag.TagName}>", tag.Description }) { Tag = tag.DefaultValue });
foreach (TemplateTags tag in templateEditor.EditingTemplate.TagsRegistered)
listView1.Items.Add(new ListViewItem(new[] { tag.Display, tag.Description }) { Tag = tag.DefaultValue });
listView1.AutoResizeColumns(ColumnHeaderAutoResizeStyle.ColumnContent);
}
private void resetToDefaultBtn_Click(object sender, EventArgs e) => resetTextBox(template.DefaultTemplate);
private void resetToDefaultBtn_Click(object sender, EventArgs e) => resetTextBox(templateEditor.DefaultTemplate);
private void templateTb_TextChanged(object sender, EventArgs e)
{
workingTemplateText = templateTb.Text;
var isChapterTitle = template == Templates.ChapterTitle;
var isFolder = template == Templates.Folder;
var libraryBookDto = new LibraryBookDto
{
Account = "my account",
DateAdded = new DateTime(2022, 6, 9, 0, 0, 0),
DatePublished = new DateTime(2017, 2, 27, 0, 0, 0),
AudibleProductId = "123456789",
Title = "A Study in Scarlet: A Sherlock Holmes Novel",
Locale = "us",
YearPublished = 2017,
Authors = new List<string> { "Arthur Conan Doyle", "Stephen Fry - introductions" },
Narrators = new List<string> { "Stephen Fry" },
SeriesName = "Sherlock Holmes",
SeriesNumber = "1",
BitRate = 128,
SampleRate = 44100,
Channels = 2,
Language = "English"
};
var chapterName = "A Flight for Life";
var chapterNumber = 4;
var chaptersTotal = 10;
var partFileProperties = new AaxDecrypter.MultiConvertFileProperties()
{
OutputFileName = "",
PartsPosition = chapterNumber,
PartsTotal = chaptersTotal,
Title = chapterName
};
/*
* Path must be rooted for windows to allow long file paths. This is
* only necessary for folder templates because they may contain several
* subdirectories. Without rooting, we won't be allowed to create a
* relative path longer than MAX_PATH.
*/
var books = config.Books;
var folder = Templates.Folder.GetPortionFilename(
libraryBookDto,
Path.Combine(books, isFolder ? workingTemplateText : config.FolderTemplate), "");
folder = Path.GetRelativePath(books, folder);
var file
= template == Templates.ChapterFile
? Templates.ChapterFile.GetPortionFilename(
libraryBookDto,
workingTemplateText,
partFileProperties,
"")
: Templates.File.GetPortionFilename(
libraryBookDto,
isFolder ? config.FileTemplate : workingTemplateText, "");
var ext = config.DecryptToLossy ? "mp3" : "m4b";
var chapterTitle = Templates.ChapterTitle.GetPortionTitle(libraryBookDto, workingTemplateText, partFileProperties);
templateEditor.SetTemplateText(templateTb.Text);
const char ZERO_WIDTH_SPACE = '\u200B';
var sing = $"{Path.DirectorySeparatorChar}";
@ -139,11 +66,12 @@ namespace LibationWinForms.Dialogs
string slashWrap(string val) => val.Replace(sing, $"{ZERO_WIDTH_SPACE}{sing}");
warningsLbl.Text
= !template.HasWarnings(workingTemplateText)
= !templateEditor.EditingTemplate.HasWarnings
? ""
: "Warning:\r\n" +
template
.GetWarnings(workingTemplateText)
templateEditor
.EditingTemplate
.Warnings
.Select(err => $"- {err}")
.Aggregate((a, b) => $"{a}\r\n{b}");
@ -153,51 +81,52 @@ namespace LibationWinForms.Dialogs
richTextBox1.Clear();
richTextBox1.SelectionFont = reg;
if (isChapterTitle)
if (!templateEditor.IsFilePath)
{
richTextBox1.SelectionFont = bold;
richTextBox1.AppendText(chapterTitle);
richTextBox1.AppendText(templateEditor.GetName());
return;
}
richTextBox1.AppendText(slashWrap(books));
var folder = templateEditor.GetFolderName();
var file = templateEditor.GetFileName();
var ext = config.DecryptToLossy ? "mp3" : "m4b";
richTextBox1.AppendText(slashWrap(templateEditor.BaseDirectory.PathWithoutPrefix));
richTextBox1.AppendText(sing);
if (isFolder)
if (templateEditor.IsFolder)
richTextBox1.SelectionFont = bold;
richTextBox1.AppendText(slashWrap(folder));
if (isFolder)
if (templateEditor.IsFolder)
richTextBox1.SelectionFont = reg;
richTextBox1.AppendText(sing);
if (!isFolder)
if (templateEditor.IsFilePath && !templateEditor.IsFolder)
richTextBox1.SelectionFont = bold;
richTextBox1.AppendText(file);
if (!isFolder)
richTextBox1.SelectionFont = reg;
richTextBox1.AppendText($".{ext}");
}
private void saveBtn_Click(object sender, EventArgs e)
{
if (!template.IsValid(workingTemplateText))
if (!templateEditor.EditingTemplate.IsValid)
{
var errors = template
.GetErrors(workingTemplateText)
var errors = templateEditor
.EditingTemplate
.Errors
.Select(err => $"- {err}")
.Aggregate((a, b) => $"{a}\r\n{b}");
MessageBox.Show($"This template text is not valid. Errors:\r\n{errors}", "Invalid", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
TemplateText = workingTemplateText;
this.DialogResult = DialogResult.OK;
this.Close();
}

View File

@ -106,7 +106,8 @@ namespace LibationWinForms.Dialogs
chapterTitleTemplateGb.Enabled = splitFilesByChapterCbox.Checked;
}
private void chapterTitleTemplateBtn_Click(object sender, EventArgs e) => editTemplate(Templates.ChapterTitle, chapterTitleTemplateTb);
private void chapterTitleTemplateBtn_Click(object sender, EventArgs e)
=> editTemplate(TemplateEditor<Templates.ChapterTitleTemplate>.CreateNameEditor(chapterTitleTemplateTb.Text), chapterTitleTemplateTb);
private void convertFormatRb_CheckedChanged(object sender, EventArgs e)
{

View File

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

View File

@ -56,23 +56,6 @@ namespace LibationWinForms.Dialogs
validationError("Cannot set Books Location to blank", "Location is blank");
return;
}
// these 3 should do nothing. Configuration will only init these with a valid value. EditTemplateDialog ensures valid before returning
if (!Templates.Folder.IsValid(folderTemplateTb.Text))
{
validationError($"Not saving change to folder naming template. Invalid format.", "Invalid folder template");
return;
}
if (!Templates.File.IsValid(fileTemplateTb.Text))
{
validationError($"Not saving change to file naming template. Invalid format.", "Invalid file template");
return;
}
if (!Templates.ChapterFile.IsValid(chapterFileTemplateTb.Text))
{
validationError($"Not saving change to chapter file naming template. Invalid format.", "Invalid chapter file template");
return;
}
#endregion
LongPath lonNewBooks = newBooks;

View File

@ -27,11 +27,11 @@ namespace LibationWinForms.Dialogs
Load_AudioSettings(config);
}
private static void editTemplate(Templates template, TextBox textBox)
private static void editTemplate(ITemplateEditor template, TextBox textBox)
{
var form = new EditTemplateDialog(template, textBox.Text);
var form = new EditTemplateDialog(template);
if (form.ShowDialog() == DialogResult.OK)
textBox.Text = form.TemplateText;
textBox.Text = template.EditingTemplate.TemplateText;
}
private void saveBtn_Click(object sender, EventArgs e)

View File

@ -1,82 +1,184 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Dinah.Core;
using FileManager;
using System.Linq;
using FileManager.NamingTemplate;
using FluentAssertions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace FileNamingTemplateTests
namespace NamingTemplateTests
{
class TemplateTag : ITemplateTag
{
public string TagName { get; init; }
}
class PropertyClass1
{
public string Item1 { get; set; }
public string Item2 { get; set; }
public string Item3 { get; set; }
public int Int1 { get; set; }
public bool Condition { get; set; }
}
class PropertyClass2
{
public string Item1 { get; set; }
public string Item2 { get; set; }
public string Item3 { get; set; }
public string Item4 { get; set; }
public bool Condition { get; set; }
}
class PropertyClass3
{
public string Item1 { get; set; }
public string Item2 { get; set; }
public string Item3 { get; set; }
public string Item4 { get; set; }
public int? Int2 { get; set; }
public bool Condition { get; set; }
}
[TestClass]
public class GetFilePath
public class GetPortionFilename
{
static ReplacementCharacters Replacements = ReplacementCharacters.Default;
PropertyTagClass<PropertyClass1> props1 = new();
PropertyTagClass<PropertyClass2> props2 = new();
PropertyTagClass<PropertyClass3> props3 = new();
ConditionalTagClass<PropertyClass1> conditional1 = new();
ConditionalTagClass<PropertyClass2> conditional2 = new();
ConditionalTagClass<PropertyClass3> conditional3 = new();
[TestMethod]
[DataRow(@"C:\foo\bar", @"C:\foo\bar\my book 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 [ID123456].txt", PlatformID.Win32NT)]
[DataRow(@"/foo/bar", @"/foo/bar/my: book 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 [ID123456].txt", PlatformID.Unix)]
public void equiv_GetValidFilename(string dirFullPath, string expected, PlatformID platformID)
PropertyClass1 propertyClass1 = new()
{
if (Environment.OSVersion.Platform != platformID)
return;
Item1 = "prop1_item1",
Item2 = "prop1_item2",
Item3 = "prop1_item3",
Int1 = 55,
Condition = true,
};
var sb = new System.Text.StringBuilder();
sb.Append('0', 300);
var longText = sb.ToString();
PropertyClass2 propertyClass2 = new()
{
Item1 = "prop2_item1",
Item3 = "prop2_item3",
Item4 = "prop2_item4",
Condition = false
};
NEW_GetValidFilename_FileNamingTemplate(dirFullPath, "my: book " + longText, "txt", "ID123456").Should().Be(expected);
PropertyClass3 propertyClass3 = new()
{
Item1 = "prop3_item1",
Item2 = "prop3_item2",
Item3 = "Prop3_Item3",
Item4 = "prop3_item4",
Condition = true
};
public GetPortionFilename()
{
props1.RegisterProperty(new TemplateTag { TagName = "item1" }, i => i.Item1);
props1.RegisterProperty(new TemplateTag { TagName = "item2" }, i => i.Item2);
props1.RegisterProperty(new TemplateTag { TagName = "item3" }, i => i.Item3);
props2.RegisterProperty(new TemplateTag { TagName = "item1" }, i => i.Item1);
props2.RegisterProperty(new TemplateTag { TagName = "item2" }, i => i.Item2);
props2.RegisterProperty(new TemplateTag { TagName = "item3" }, i => i.Item3);
props2.RegisterProperty(new TemplateTag { TagName = "item4" }, i => i.Item4);
props3.RegisterProperty(new TemplateTag { TagName = "item3_1" }, i => i.Item1);
props3.RegisterProperty(new TemplateTag { TagName = "item3_2" }, i => i.Item2);
props3.RegisterProperty(new TemplateTag { TagName = "item3_3" }, i => i.Item3);
props3.RegisterProperty(new TemplateTag { TagName = "item3_4" }, i => i.Item4);
conditional1.RegisterCondition(new TemplateTag { TagName = "ifc1" }, i => i.Condition);
conditional2.RegisterCondition(new TemplateTag { TagName = "ifc2" }, i => i.Condition);
conditional3.RegisterCondition(new TemplateTag { TagName = "ifc3" }, i => i.Condition);
}
private static string NEW_GetValidFilename_FileNamingTemplate(string dirFullPath, string filename, string extension, string metadataSuffix)
[TestMethod]
[DataRow("<item1>", "prop1_item1", 1)]
[DataRow("< item1>", "< item1>", 0)]
[DataRow("<item1 >", "<item1 >", 0)]
[DataRow("< item1 >", "< item1 >", 0)]
[DataRow("<item3_1>", "prop3_item1", 1)]
[DataRow("<item1> <item2> <item3> <item4>", "prop1_item1 prop1_item2 prop1_item3 prop2_item4", 4)]
[DataRow("<item3_1> <item3_2> <item3> <item4>", "prop3_item1 prop3_item2 prop1_item3 prop2_item4", 4)]
[DataRow("<ifc1-><item1><-ifc1><ifc2-><item4><-ifc2><ifc3-><item3_2><-ifc3>", "prop1_item1prop3_item2", 3)]
[DataRow("<ifc1-><ifc3-><item1><ifc2-><item4><-ifc2><item3_2><-ifc3><-ifc1>", "prop1_item1prop3_item2", 3)]
[DataRow("<ifc2-><ifc1-><ifc3-><item1><item4><item3_2><-ifc3><-ifc1><-ifc2>", "", 3)]
public void test(string inStr, string outStr, int numTags)
{
var template = $"<title> [<id>]";
var template = NamingTemplate.Parse(inStr, new TagClass[] { props1, props2, props3, conditional1, conditional2, conditional3 });
extension = FileUtility.GetStandardizedExtension(extension);
var fullfilename = Path.Combine(dirFullPath, template + extension);
template.TagsInUse.Should().HaveCount(numTags);
template.Warnings.Should().HaveCount(numTags > 0 ? 0 : 1);
template.Errors.Should().HaveCount(0);
var fileNamingTemplate = new FileNamingTemplate(fullfilename, Replacements);
fileNamingTemplate.AddParameterReplacement("title", filename);
fileNamingTemplate.AddParameterReplacement("id", metadataSuffix);
return fileNamingTemplate.GetFilePath(extension).PathWithoutPrefix;
var templateText = string.Join("", template.Evaluate(propertyClass3, propertyClass2, propertyClass1).Select(v => v.Value));
templateText.Should().Be(outStr);
}
[TestMethod]
[DataRow(@"C:\foo\bar\my file.txt", @"C:\foo\bar\my file - 002 - title.txt", PlatformID.Win32NT)]
[DataRow(@"/foo/bar/my file.txt", @"/foo/bar/my file - 002 - title.txt", PlatformID.Unix)]
public void equiv_GetMultipartFileName(string inStr, string outStr, PlatformID platformID)
[DataRow("<ifc2-><ifc1-><ifc3-><item1><item4><item3_2><-ifc3><-ifc1><ifc2->", new string[] { "Missing <-ifc2> closing conditional.", "Missing <-ifc2> closing conditional." })]
[DataRow("<ifc2-><ifc1-><ifc3-><-ifc3><-ifc1><-ifc2>", new string[] { "Should use tags. Eg: <title>" })]
[DataRow("<ifc1-><ifc3-><item1><-ifc3><-ifc1><-ifc2>", new string[] { "Missing <ifc2-> open conditional." })]
[DataRow("<ifc1-><ifc3-><-ifc3><-ifc1><-ifc2>", new string[] { "Missing <ifc2-> open conditional.", "Should use tags. Eg: <title>" })]
[DataRow("<ifc2-><ifc1-><ifc3-><item1><item4><item3_2><-ifc3><-ifc1>", new string[] { "Missing <-ifc2> closing conditional." })]
[DataRow("<ifc2-><ifc1-><ifc3-><item1><item4><item3_2><-ifc3>", new string[] { "Missing <-ifc1> closing conditional.", "Missing <-ifc2> closing conditional." })]
[DataRow("<ifc2-><ifc1-><ifc3-><item1><item4>", new string[] { "Missing <-ifc3> closing conditional.", "Missing <-ifc1> closing conditional.", "Missing <-ifc2> closing conditional." })]
[DataRow("<ifc2-><ifc1-><ifc3-><item1><item4><item3_2><-ifc1><-ifc2>", new string[] { "Missing <-ifc3> closing conditional.", "Missing <-ifc3> closing conditional.", "Missing <-ifc1> closing conditional.", "Missing <-ifc2> closing conditional." })]
public void condition_error(string inStr, string[] warnings)
{
if (Environment.OSVersion.Platform == platformID)
NEW_GetMultipartFileName_FileNamingTemplate(inStr, 2, 100, "title").Should().Be(outStr);
var template = NamingTemplate.Parse(inStr, new TagClass[] { props1, props2, props3, conditional1, conditional2, conditional3 });
template.Errors.Should().HaveCount(0);
template.Warnings.Should().BeEquivalentTo(warnings);
}
private static string NEW_GetMultipartFileName_FileNamingTemplate(string originalPath, int partsPosition, int partsTotal, string suffix)
{
// 1-9 => 1-9
// 10-99 => 01-99
// 100-999 => 001-999
var chapterCountLeadingZeros = partsPosition.ToString().PadLeft(partsTotal.ToString().Length, '0');
var estension = Path.GetExtension(originalPath);
var t = Path.ChangeExtension(originalPath, null) + " - <chapter> - <title>" + estension;
var fileNamingTemplate = new FileNamingTemplate(t, Replacements);
fileNamingTemplate.AddParameterReplacement("chapter", chapterCountLeadingZeros);
fileNamingTemplate.AddParameterReplacement("title", suffix);
return fileNamingTemplate.GetFilePath(estension).PathWithoutPrefix;
}
[TestMethod]
[DataRow(@"\foo\<title>.txt", @"\foo\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)
[DataRow("<int1>", "55")]
[DataRow("<int1[]>", "55")]
[DataRow("<int1[5]>", "00055")]
[DataRow("<int2>", "")]
[DataRow("<int2[]>", "")]
[DataRow("<int2[4]>", "")]
[DataRow("<item3_format>", "Prop3_Item3")]
[DataRow("<item3_format[]>", "Prop3_Item3")]
[DataRow("<item3_format[rtreue5]>", "Prop3_Item3")]
[DataRow("<item3_format[l]>", "prop3_item3")]
[DataRow("<item3_format[u]>", "PROP3_ITEM3")]
[DataRow("<item2_2_null>", "")]
[DataRow("<item2_2_null[]>", "")]
[DataRow("<item2_2_null[l]>", "")]
public void formatting(string inStr, string outStr)
{
if (Environment.OSVersion.Platform == platformID)
props1.RegisterProperty(new TemplateTag { TagName = "int1" }, i => i.Int1, formatInt);
props3.RegisterProperty(new TemplateTag { TagName = "int2" }, i => i.Int2, formatInt);
props3.RegisterProperty(new TemplateTag { TagName = "item3_format" }, i => i.Item3, formatString);
props2.RegisterProperty(new TemplateTag { TagName = "item2_2_null" }, i => i.Item2, formatString);
var template = NamingTemplate.Parse(inStr, new TagClass[] { props1, props2, props3, conditional1, conditional2, conditional3 });
template.Warnings.Should().HaveCount(0);
template.Errors.Should().HaveCount(0);
var templateText = string.Join("", template.Evaluate(propertyClass3, propertyClass2, propertyClass1).Select(v => v.Value));
templateText.Should().Be(outStr);
string formatInt(ITemplateTag templateTag, int value, string format)
{
var fileNamingTemplate = new FileNamingTemplate(inStr, Replacements);
fileNamingTemplate.AddParameterReplacement("title", @"s\l/a\s/h\e/s");
fileNamingTemplate.GetFilePath("txt").PathWithoutPrefix.Should().Be(outStr);
if (int.TryParse(format, out var numDecs))
return value.ToString($"D{numDecs}");
return value.ToString();
}
string formatString(ITemplateTag templateTag, string value, string formatString)
{
if (string.Compare(formatString, "u", ignoreCase: true) == 0) return value?.ToUpper();
else if (string.Compare(formatString, "l", ignoreCase: true) == 0) return value?.ToLower();
else return value;
}
}
}

View File

@ -4,6 +4,7 @@ using System.IO;
using System.Linq;
using Dinah.Core;
using FileManager;
using FileManager.NamingTemplate;
using FluentAssertions;
using LibationFileManager;
using Microsoft.VisualStudio.TestTools.UnitTesting;
@ -42,46 +43,21 @@ namespace TemplatesTests
Channels = 2,
Language = "English"
};
public static LibraryBookDto GetLibraryBookWithNullDates(string seriesName = "Sherlock Holmes")
=> new()
{
Account = "my account",
FileDate = new DateTime(2023, 1, 28, 0, 0, 0),
AudibleProductId = "asin",
Title = "A Study in Scarlet: A Sherlock Holmes Novel",
Locale = "us",
YearPublished = 2017,
Authors = new List<string> { "Arthur Conan Doyle", "Stephen Fry - introductions" },
Narrators = new List<string> { "Stephen Fry" },
SeriesName = seriesName ?? "",
SeriesNumber = "1",
BitRate = 128,
SampleRate = 44100,
Channels = 2,
Language = "English"
};
}
[TestClass]
public class ContainsChapterOnlyTags
{
[TestMethod]
[DataRow("<ch>", false)]
[DataRow("<ch#>", true)]
[DataRow("<id>", false)]
[DataRow("<id><ch#>", true)]
public void Tests(string template, bool expected) => Templates.ContainsChapterOnlyTags(template).Should().Be(expected);
}
[TestClass]
public class ContainsTag
{
[TestMethod]
[DataRow("<ch#>", "ch#", true)]
[DataRow("<id>", "ch#", false)]
[DataRow("<id><ch#>", "ch#", true)]
public void Tests(string template, string tag, bool expected) => Templates.ContainsTag(template, tag).Should().Be(expected);
[DataRow("<ch#>", 0)]
[DataRow("<id>", 1)]
[DataRow("<id><ch#>", 1)]
public void Tests(string template, int numTags)
{
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
fileTemplate.TagsInUse.Should().HaveCount(numTags);
}
}
[TestClass]
@ -89,19 +65,22 @@ namespace TemplatesTests
{
static ReplacementCharacters Replacements = ReplacementCharacters.Default;
[TestMethod]
[DataRow(null)]
public void template_null(string template)
{
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var t).Should().BeFalse();
t.IsValid.Should().BeFalse();
}
[TestMethod]
[DataRow(null, @"C:\", "ext")]
[ExpectedException(typeof(ArgumentNullException))]
public void arg_null_exception(string template, string dirFullPath, string extension)
=> Templates.getFileNamingTemplate(GetLibraryBook(), template, dirFullPath, extension, Replacements);
[TestMethod]
[DataRow("", @"C:\foo\bar", "ext")]
[DataRow(" ", @"C:\foo\bar", "ext")]
[ExpectedException(typeof(ArgumentException))]
public void arg_exception(string template, string dirFullPath, string extension)
=> Templates.getFileNamingTemplate(GetLibraryBook(), template, dirFullPath, extension, Replacements);
[DataRow("")]
[DataRow(" ")]
public void template_empty(string template)
{
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var t).Should().BeTrue();
t.Warnings.Should().HaveCount(2);
}
[TestMethod]
[DataRow("f.txt", @"C:\foo\bar", "", @"C:\foo\bar\f.txt")]
@ -119,12 +98,28 @@ namespace TemplatesTests
expected = expected.Replace("C:", "").Replace('\\', '/');
}
Templates.getFileNamingTemplate(GetLibraryBook(), template, dirFullPath, extension, Replacements)
.GetFilePath(extension)
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
fileTemplate
.GetFilename(GetLibraryBook(), dirFullPath, extension, Replacements)
.PathWithoutPrefix
.Should().Be(expected);
}
[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]
[DataRow("<id> - <filedate[yy-MM-dd]>", @"C:\foo\bar", "m4b", @"C:\foo\bar\asin - 23-01-28.m4b")]
[DataRow("<id> - <filedate [ yy-MM-dd ] >", @"C:\foo\bar", "m4b", @"C:\foo\bar\asin - 23-01-28.m4b")]
@ -145,8 +140,9 @@ namespace TemplatesTests
expected = expected.Replace("C:", "").Replace('\\', '/');
}
Templates.getFileNamingTemplate(GetLibraryBook(), template, dirFullPath, extension, Replacements)
.GetFilePath(extension)
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
fileTemplate
.GetFilename(GetLibraryBook(), dirFullPath, extension, Replacements)
.PathWithoutPrefix
.Should().Be(expected);
}
@ -173,8 +169,9 @@ namespace TemplatesTests
expected = expected.Replace("C:", "").Replace('\\', '/').Replace('', '<').Replace('', '>');
}
Templates.getFileNamingTemplate(GetLibraryBook(), template, dirFullPath, extension, Replacements)
.GetFilePath(extension)
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
fileTemplate
.GetFilename(GetLibraryBook(), dirFullPath, extension, Replacements)
.PathWithoutPrefix
.Should().Be(expected);
}
@ -191,8 +188,9 @@ namespace TemplatesTests
expected = expected.Replace("C:", "").Replace('\\', '/');
}
Templates.getFileNamingTemplate(GetLibraryBook(), template, dirFullPath, extension, Replacements)
.GetFilePath(extension)
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
fileTemplate
.GetFilename(GetLibraryBook(), dirFullPath, extension, Replacements)
.PathWithoutPrefix
.Should().Be(expected);
}
@ -208,13 +206,13 @@ namespace TemplatesTests
{
if (Environment.OSVersion.Platform == platformID)
{
Templates.File.HasWarnings(template).Should().BeTrue();
Templates.File.HasWarnings(Templates.File.Sanitize(template, Replacements)).Should().BeFalse();
Templates.getFileNamingTemplate(GetLibraryBook(), template, dirFullPath, extension, Replacements)
.GetFilePath(extension)
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
fileTemplate.HasWarnings.Should().BeFalse();
fileTemplate
.GetFilename(GetLibraryBook(), dirFullPath, extension, Replacements)
.PathWithoutPrefix
.Should().Be(expected);
}
}
@ -229,11 +227,15 @@ namespace TemplatesTests
expected = expected.Replace("C:", "").Replace('\\', '/');
}
Templates.getFileNamingTemplate(GetLibraryBookWithNullDates(), template, dirFullPath, extension, Replacements)
.GetFilePath(extension)
var lbDto = GetLibraryBook();
lbDto.DatePublished = null;
lbDto.DateAdded = null;
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
fileTemplate
.GetFilename(lbDto, dirFullPath, extension, Replacements)
.PathWithoutPrefix
.Should().Be(expected);
}
[TestMethod]
@ -242,11 +244,15 @@ namespace TemplatesTests
public void IfSeries_empty(string directory, string expected, PlatformID platformID)
{
if (Environment.OSVersion.Platform == platformID)
Templates.getFileNamingTemplate(GetLibraryBook(), "foo<if series-><-if series>bar", directory, "ext", Replacements)
.GetFilePath(".ext")
{
Templates.TryGetTemplate<Templates.FileTemplate>("foo<if series-><-if series>bar", out var fileTemplate).Should().BeTrue();
fileTemplate
.GetFilename(GetLibraryBook(), directory, "ext", Replacements)
.PathWithoutPrefix
.Should().Be(expected);
}
}
[TestMethod]
[DataRow(@"C:\a\b", @"C:\a\b\foobar.ext", PlatformID.Win32NT)]
@ -254,11 +260,14 @@ namespace TemplatesTests
public void IfSeries_no_series(string directory, string expected, PlatformID platformID)
{
if (Environment.OSVersion.Platform == platformID)
Templates.getFileNamingTemplate(GetLibraryBook(null), "foo<if series->-<series>-<id>-<-if series>bar", directory, "ext", Replacements)
.GetFilePath(".ext")
{
Templates.TryGetTemplate<Templates.FileTemplate>("foo<if series->-<series>-<id>-<-if series>bar", out var fileTemplate).Should().BeTrue();
fileTemplate.GetFilename(GetLibraryBook(null), directory, "ext", Replacements)
.PathWithoutPrefix
.Should().Be(expected);
}
}
[TestMethod]
[DataRow(@"C:\a\b", @"C:\a\b\foo-Sherlock Holmes-asin-bar.ext", PlatformID.Win32NT)]
@ -266,13 +275,115 @@ namespace TemplatesTests
public void IfSeries_with_series(string directory, string expected, PlatformID platformID)
{
if (Environment.OSVersion.Platform == platformID)
Templates.getFileNamingTemplate(GetLibraryBook(), "foo<if series->-<series>-<id>-<-if series>bar", directory, "ext", Replacements)
.GetFilePath(".ext")
{
Templates.TryGetTemplate<Templates.FileTemplate>("foo<if series->-<series>-<id>-<-if series>bar", out var fileTemplate).Should().BeTrue();
fileTemplate
.GetFilename(GetLibraryBook(), directory, "ext", Replacements)
.PathWithoutPrefix
.Should().Be(expected);
}
}
}
}
namespace Templates_Other
{
[TestClass]
public class GetFilePath
{
static ReplacementCharacters Replacements = ReplacementCharacters.Default;
[TestMethod]
[DataRow(@"C:\foo\bar", @"C:\foo\bar\Folder\my book 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\[ID123456].txt", PlatformID.Win32NT)]
[DataRow(@"/foo/bar", @"/foo/bar/Folder/my: book 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000/[ID123456].txt", PlatformID.Unix)]
public void equiv_GetValidFilename(string dirFullPath, string expected, PlatformID platformID)
{
if (Environment.OSVersion.Platform != platformID)
return;
var sb = new System.Text.StringBuilder();
sb.Append('0', 300);
var longText = sb.ToString();
NEW_GetValidFilename_FileNamingTemplate(dirFullPath, "my: book " + longText, "txt", "ID123456").Should().Be(expected);
}
private class TemplateTag : ITemplateTag
{
public string TagName { get; init; }
public string DefaultValue { get; }
public string Description { get; }
public string Display { get; }
}
private static string NEW_GetValidFilename_FileNamingTemplate(string dirFullPath, string filename, string extension, string metadataSuffix)
{
char slash = Path.DirectorySeparatorChar;
var template = $"{slash}Folder{slash}<title>{slash}[<id>]{slash}";
extension = FileUtility.GetStandardizedExtension(extension);
var lbDto = GetLibraryBook();
lbDto.Title = filename;
lbDto.AudibleProductId = metadataSuffix;
Templates.TryGetTemplate<Templates.FolderTemplate>(template, out var fileNamingTemplate).Should().BeTrue();
return fileNamingTemplate.GetFilename(lbDto, dirFullPath, extension, Replacements).PathWithoutPrefix;
}
[TestMethod]
[DataRow(@"C:\foo\bar\my file.txt", @"C:\foo\bar\my file - 002 - title.txt", PlatformID.Win32NT)]
[DataRow(@"/foo/bar/my file.txt", @"/foo/bar/my file - 002 - title.txt", PlatformID.Unix)]
public void equiv_GetMultipartFileName(string inStr, string outStr, PlatformID platformID)
{
if (Environment.OSVersion.Platform == platformID)
NEW_GetMultipartFileName_FileNamingTemplate(inStr, 2, 100, "title").Should().Be(outStr);
}
private static string NEW_GetMultipartFileName_FileNamingTemplate(string originalPath, int partsPosition, int partsTotal, string suffix)
{
// 1-9 => 1-9
// 10-99 => 01-99
// 100-999 => 001-999
var estension = Path.GetExtension(originalPath);
var dir = Path.GetDirectoryName(originalPath);
var template = Path.GetFileNameWithoutExtension(originalPath) + " - <ch# 0> - <title>" + estension;
var lbDto = GetLibraryBook();
lbDto.Title = suffix;
Templates.TryGetTemplate<Templates.ChapterFileTemplate>(template, out var chapterFileTemplate).Should().BeTrue();
return chapterFileTemplate
.GetFilename(lbDto, new AaxDecrypter.MultiConvertFileProperties { Title = suffix, PartsTotal = partsTotal, PartsPosition = partsPosition }, dir, estension, Replacements)
.PathWithoutPrefix;
}
[TestMethod]
[DataRow(@"\foo\<title>.txt", @"\foo\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);
}
}
}
}
namespace Templates_Folder_Tests
{
@ -280,7 +391,7 @@ namespace Templates_Folder_Tests
public class GetErrors
{
[TestMethod]
public void null_is_invalid() => Tests(null, new[] { Templates.ERROR_NULL_IS_INVALID });
public void null_is_invalid() => Tests(null, PlatformID.Win32NT | PlatformID.Unix, new[] { NamingTemplate.ERROR_NULL_IS_INVALID });
[TestMethod]
public void empty_is_valid() => valid_tests("");
@ -296,66 +407,77 @@ namespace Templates_Folder_Tests
[DataRow(@"foo\bar")]
[DataRow(@"<id>")]
[DataRow(@"<id>\<title>")]
public void valid_tests(string template) => Tests(template, Array.Empty<string>());
public void valid_tests(string template) => Tests(template, PlatformID.Win32NT | PlatformID.Unix, Array.Empty<string>());
[TestMethod]
[DataRow(@"C:\", Templates.ERROR_FULL_PATH_IS_INVALID)]
public void Tests(string template, params string[] expected)
[DataRow(@"C:\", PlatformID.Win32NT, Templates.ERROR_FULL_PATH_IS_INVALID)]
public void Tests(string template, PlatformID platformID, params string[] expected)
{
var result = Templates.Folder.GetErrors(template);
result.Count().Should().Be(expected.Length);
if ((platformID & Environment.OSVersion.Platform) == Environment.OSVersion.Platform)
{
Templates.TryGetTemplate<Templates.FolderTemplate>(template, out var folderTemplate);
var result = folderTemplate.Errors;
result.Should().HaveCount(expected.Length);
result.Should().BeEquivalentTo(expected);
}
}
}
[TestClass]
public class IsValid
{
[TestMethod]
public void null_is_invalid() => Tests(null, false);
public void null_is_invalid() => Templates.TryGetTemplate<Templates.FolderTemplate>(null, out _).Should().BeFalse();
[TestMethod]
public void empty_is_valid() => Tests("", true);
public void empty_is_valid() => Tests("", true, PlatformID.Win32NT | PlatformID.Unix);
[TestMethod]
public void whitespace_is_valid() => Tests(" ", true);
public void whitespace_is_valid() => Tests(" ", true, PlatformID.Win32NT | PlatformID.Unix);
[TestMethod]
[DataRow(@"C:\", false)]
[DataRow(@"foo", true)]
[DataRow(@"\foo", true)]
[DataRow(@"foo\", true)]
[DataRow(@"\foo\", true)]
[DataRow(@"foo\bar", true)]
[DataRow(@"<id>", true)]
[DataRow(@"<id>\<title>", true)]
public void Tests(string template, bool expected) => Templates.Folder.IsValid(template).Should().Be(expected);
[DataRow(@"C:\", false, PlatformID.Win32NT)]
[DataRow(@"foo", true, PlatformID.Win32NT | PlatformID.Unix)]
[DataRow(@"\foo", true, PlatformID.Win32NT | PlatformID.Unix)]
[DataRow(@"foo\", true, PlatformID.Win32NT | PlatformID.Unix)]
[DataRow(@"\foo\", true, PlatformID.Win32NT | PlatformID.Unix)]
[DataRow(@"foo\bar", true, PlatformID.Win32NT | PlatformID.Unix)]
[DataRow(@"<id>", true, PlatformID.Win32NT | PlatformID.Unix)]
[DataRow(@"<id>\<title>", true, PlatformID.Win32NT | PlatformID.Unix)]
public void Tests(string template, bool expected, PlatformID platformID)
{
if ((platformID & Environment.OSVersion.Platform) == Environment.OSVersion.Platform)
{
Templates.TryGetTemplate<Templates.FolderTemplate>(template, out var folderTemplate).Should().BeTrue();
folderTemplate.IsValid.Should().Be(expected);
}
}
}
[TestClass]
public class GetWarnings
{
[TestMethod]
public void null_is_invalid() => Tests(null, new[] { Templates.ERROR_NULL_IS_INVALID });
public void null_is_invalid() => Tests(null, new[] { NamingTemplate.ERROR_NULL_IS_INVALID });
[TestMethod]
public void empty_has_warnings() => Tests("", Templates.WARNING_EMPTY, Templates.WARNING_NO_TAGS);
public void empty_has_warnings() => Tests("", NamingTemplate.WARNING_EMPTY, NamingTemplate.WARNING_NO_TAGS);
[TestMethod]
public void whitespace_has_warnings() => Tests(" ", Templates.WARNING_WHITE_SPACE, Templates.WARNING_NO_TAGS);
public void whitespace_has_warnings() => Tests(" ", NamingTemplate.WARNING_WHITE_SPACE, NamingTemplate.WARNING_NO_TAGS);
[TestMethod]
[DataRow(@"<id>\foo\bar")]
public void valid_tests(string template) => Tests(template, Array.Empty<string>());
[TestMethod]
[DataRow(@"no tags", Templates.WARNING_NO_TAGS)]
[DataRow("<ch#> <id>", Templates.WARNING_HAS_CHAPTER_TAGS)]
[DataRow("<ch#> chapter tag", Templates.WARNING_NO_TAGS, Templates.WARNING_HAS_CHAPTER_TAGS)]
[DataRow(@"no tags", NamingTemplate.WARNING_NO_TAGS)]
[DataRow("<ch#> chapter tag", NamingTemplate.WARNING_NO_TAGS)]
public void Tests(string template, params string[] expected)
{
var result = Templates.Folder.GetWarnings(template);
result.Count().Should().Be(expected.Length);
Templates.TryGetTemplate<Templates.FolderTemplate>(template, out var folderTemplate);
var result = folderTemplate.Warnings;
result.Should().HaveCount(expected.Length);
result.Should().BeEquivalentTo(expected);
}
}
@ -375,16 +497,23 @@ namespace Templates_Folder_Tests
[TestMethod]
[DataRow(@"no tags", true)]
[DataRow(@"<id>\foo\bar", false)]
[DataRow("<ch#> <id>", true)]
[DataRow("<ch#> chapter tag", true)]
public void Tests(string template, bool expected) => Templates.Folder.HasWarnings(template).Should().Be(expected);
public void Tests(string template, bool expected)
{
Templates.TryGetTemplate<Templates.FolderTemplate>(template, out var folderTemplate);
folderTemplate.HasWarnings.Should().Be(expected);
}
}
[TestClass]
public class TagCount
{
[TestMethod]
public void null_throws() => Assert.ThrowsException<NullReferenceException>(() => Templates.Folder.TagCount(null));
public void null_invalid()
{
Templates.TryGetTemplate<Templates.FolderTemplate>(null, out var template).Should().BeFalse();
template.IsValid.Should().BeFalse();
}
[TestMethod]
public void empty() => Tests("", 0);
@ -402,7 +531,11 @@ namespace Templates_Folder_Tests
[DataRow("<not a real tag>", 0)]
[DataRow("<ch#> non-folder tag", 0)]
[DataRow("<ID> case specific", 0)]
public void Tests(string template, int expected) => Templates.Folder.TagCount(template).Should().Be(expected);
public void Tests(string template, int expected)
{
Templates.TryGetTemplate<Templates.FolderTemplate>(template, out var folderTemplate).Should().BeTrue();
folderTemplate.TagsInUse.Count().Should().Be(expected);
}
}
}
@ -412,7 +545,7 @@ namespace Templates_File_Tests
public class GetErrors
{
[TestMethod]
public void null_is_invalid() => Tests(null, Environment.OSVersion.Platform, new[] { Templates.ERROR_NULL_IS_INVALID });
public void null_is_invalid() => Tests(null, Environment.OSVersion.Platform, new[] { NamingTemplate.ERROR_NULL_IS_INVALID });
[TestMethod]
public void empty_is_valid() => valid_tests("");
@ -425,19 +558,13 @@ namespace Templates_File_Tests
[DataRow(@"<id>")]
public void valid_tests(string template) => Tests(template, Environment.OSVersion.Platform, Array.Empty<string>());
[TestMethod]
[DataRow(@"C:\", PlatformID.Win32NT, Templates.ERROR_INVALID_FILE_NAME_CHAR)]
[DataRow(@"/", PlatformID.Unix, Templates.ERROR_INVALID_FILE_NAME_CHAR)]
[DataRow(@"\foo", PlatformID.Win32NT, Templates.ERROR_INVALID_FILE_NAME_CHAR)]
[DataRow(@"/foo", PlatformID.Win32NT, Templates.ERROR_INVALID_FILE_NAME_CHAR)]
[DataRow(@"/foo", PlatformID.Unix, Templates.ERROR_INVALID_FILE_NAME_CHAR)]
public void Tests(string template, PlatformID platformID, params string[] expected)
{
if (Environment.OSVersion.Platform == platformID)
{
var result = Templates.File.GetErrors(template);
result.Count().Should().Be(expected.Length);
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate);
var result = fileTemplate.Errors;
result.Should().HaveCount(expected.Length);
result.Should().BeEquivalentTo(expected);
}
}
@ -447,28 +574,26 @@ namespace Templates_File_Tests
public class IsValid
{
[TestMethod]
public void null_is_invalid() => Tests(null, false, Environment.OSVersion.Platform);
public void null_is_invalid() => Templates.TryGetTemplate<Templates.FileTemplate>(null, out _).Should().BeFalse();
[TestMethod]
public void empty_is_valid() => Tests("", true, Environment.OSVersion.Platform);
public void empty_is_valid() => Tests("", true);
[TestMethod]
public void whitespace_is_valid() => Tests(" ", true, Environment.OSVersion.Platform);
public void whitespace_is_valid() => Tests(" ", true);
[TestMethod]
[DataRow(@"C:\", false, PlatformID.Win32NT)]
[DataRow(@"/", false, PlatformID.Unix)]
[DataRow(@"foo", true, PlatformID.Win32NT)]
[DataRow(@"foo", true, PlatformID.Unix)]
[DataRow(@"\foo", false, PlatformID.Win32NT)]
[DataRow(@"\foo", true, PlatformID.Unix)]
[DataRow(@"/foo", false, PlatformID.Win32NT)]
[DataRow(@"<id>", true, PlatformID.Win32NT)]
[DataRow(@"<id>", true, PlatformID.Unix)]
public void Tests(string template, bool expected, PlatformID platformID)
[DataRow(@"foo", true)]
[DataRow(@"\foo", true)]
[DataRow(@"foo\", true)]
[DataRow(@"\foo\", true)]
[DataRow(@"foo\bar", true)]
[DataRow(@"<id>", true)]
[DataRow(@"<id>\<title>", true)]
public void Tests(string template, bool expected)
{
if (Environment.OSVersion.Platform == platformID)
Templates.File.IsValid(template).Should().Be(expected);
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var folderTemplate).Should().BeTrue();
folderTemplate.IsValid.Should().Be(expected);
}
}
@ -499,13 +624,13 @@ namespace Templates_ChapterFile_Tests
public class GetWarnings
{
[TestMethod]
public void null_is_invalid() => Tests(null, null, new[] { Templates.ERROR_NULL_IS_INVALID });
public void null_is_invalid() => Tests(null, null, new[] { NamingTemplate.ERROR_NULL_IS_INVALID, Templates.WARNING_NO_CHAPTER_NUMBER_TAG });
[TestMethod]
public void empty_has_warnings() => Tests("", null, Templates.WARNING_EMPTY, Templates.WARNING_NO_TAGS, Templates.WARNING_NO_CHAPTER_NUMBER_TAG);
public void empty_has_warnings() => Tests("", null, NamingTemplate.WARNING_EMPTY, NamingTemplate.WARNING_NO_TAGS, Templates.WARNING_NO_CHAPTER_NUMBER_TAG);
[TestMethod]
public void whitespace_has_warnings() => Tests(" ", null, Templates.WARNING_WHITE_SPACE, Templates.WARNING_NO_TAGS, Templates.WARNING_NO_CHAPTER_NUMBER_TAG);
public void whitespace_has_warnings() => Tests(" ", null, NamingTemplate.WARNING_WHITE_SPACE, NamingTemplate.WARNING_NO_TAGS, Templates.WARNING_NO_CHAPTER_NUMBER_TAG);
[TestMethod]
[DataRow("<ch#>")]
@ -513,18 +638,20 @@ namespace Templates_ChapterFile_Tests
public void valid_tests(string template) => Tests(template, null, Array.Empty<string>());
[TestMethod]
[DataRow(@"no tags", null, Templates.WARNING_NO_TAGS, Templates.WARNING_NO_CHAPTER_NUMBER_TAG)]
[DataRow(@"<id>\foo\bar", true, Templates.ERROR_INVALID_FILE_NAME_CHAR, Templates.WARNING_NO_CHAPTER_NUMBER_TAG)]
[DataRow(@"<id>/foo/bar", false, Templates.ERROR_INVALID_FILE_NAME_CHAR, Templates.WARNING_NO_CHAPTER_NUMBER_TAG)]
[DataRow("<chapter count> -- chapter tag but not ch# or ch_#", null, Templates.WARNING_NO_TAGS, Templates.WARNING_NO_CHAPTER_NUMBER_TAG)]
[DataRow(@"no tags", null, NamingTemplate.WARNING_NO_TAGS, Templates.WARNING_NO_CHAPTER_NUMBER_TAG)]
[DataRow(@"<id>\foo\bar", true, Templates.WARNING_NO_CHAPTER_NUMBER_TAG)]
[DataRow(@"<id>/foo/bar", false, Templates.WARNING_NO_CHAPTER_NUMBER_TAG)]
[DataRow("<chapter count> -- chapter tag but not ch# or ch_#", null, NamingTemplate.WARNING_NO_TAGS, Templates.WARNING_NO_CHAPTER_NUMBER_TAG)]
public void Tests(string template, bool? windows, params string[] expected)
{
if (windows is null
|| (windows is true && Environment.OSVersion.Platform is PlatformID.Win32NT)
|| (windows is false && Environment.OSVersion.Platform is PlatformID.Unix))
{
var result = Templates.ChapterFile.GetWarnings(template);
result.Count().Should().Be(expected.Length);
Templates.TryGetTemplate<Templates.ChapterFileTemplate>(template, out var chapterFileTemplate);
var result = chapterFileTemplate.Warnings;
result.Should().HaveCount(expected.Length);
result.Should().BeEquivalentTo(expected);
}
}
@ -548,14 +675,18 @@ namespace Templates_ChapterFile_Tests
[DataRow("<ch#> <id>", false)]
[DataRow("<ch#> -- chapter tag", false)]
[DataRow("<chapter count> -- chapter tag but not ch# or ch_#", true)]
public void Tests(string template, bool expected) => Templates.ChapterFile.HasWarnings(template).Should().Be(expected);
public void Tests(string template, bool expected)
{
Templates.TryGetTemplate<Templates.ChapterFileTemplate>(template, out var chapterFileTemplate);
chapterFileTemplate.HasWarnings.Should().Be(expected);
}
}
[TestClass]
public class TagCount
{
[TestMethod]
public void null_is_not_recommended() => Assert.ThrowsException<NullReferenceException>(() => Tests(null, -1));
public void null_is_not_recommended() => Templates.TryGetTemplate<Templates.ChapterFileTemplate>(null, out _).Should().BeFalse();
[TestMethod]
public void empty_is_not_recommended() => Tests("", 0);
@ -573,11 +704,15 @@ namespace Templates_ChapterFile_Tests
[DataRow("<not a real tag>", 0)]
[DataRow("<ch#> non-folder tag", 1)]
[DataRow("<ID> case specific", 0)]
public void Tests(string template, int expected) => Templates.ChapterFile.TagCount(template).Should().Be(expected);
public void Tests(string template, int expected)
{
Templates.TryGetTemplate<Templates.ChapterFileTemplate>(template, out var chapterFileTemplate).Should().BeTrue();
chapterFileTemplate.TagsInUse.Count().Should().Be(expected);
}
}
[TestClass]
public class GetPortionFilename
public class GetFilename
{
static readonly ReplacementCharacters Default = ReplacementCharacters.Default;
@ -589,8 +724,13 @@ namespace Templates_ChapterFile_Tests
public void Tests(string template, string dir, string ext, int pos, int total, string chapter, string expected, PlatformID platformID)
{
if (Environment.OSVersion.Platform == platformID)
Templates.ChapterFile.GetPortionFilename(GetLibraryBook(), template, new() { OutputFileName = $"xyz.{ext}", PartsPosition = pos, PartsTotal = total, Title = chapter }, dir, Default)
{
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);
}
}
}
}