diff --git a/Documentation/Advanced.md b/Documentation/Advanced.md
index 28ca18ac..6188cdcd 100644
--- a/Documentation/Advanced.md
+++ b/Documentation/Advanced.md
@@ -9,7 +9,7 @@
- [Files and folders](#files-and-folders)
- [Settings](#settings)
-- [Custom File Naming](#custom-file-naming)
+- [Custom File Naming](NamingTemplates.md)
- [Command Line Interface](#command-line-interface)
@@ -28,12 +28,6 @@ To make upgrades and reinstalls easier, Libation separates all of its responsibi
* Allow Libation to fix up audiobook metadata. After decrypting a title, Libation attempts to fix details like chapters and cover art. Some power users and/or control freaks prefer to manage this themselves. By unchecking this setting, Libation will only decrypt the book and will leave metadata as-is, warts and all.
-### Custom File Naming
-
-In Settings, on the Download/Decrypt tab, you can specify the format in which you want your files to be named. As you edit these templates, a live example will be shown. Parameters are listed for folders, files, and files split by chapter including an explanation of what each naming option means. For instance: you can use template `
- of - ` to create the file `A Study in Scarlet - 04 of 10 - A Flight for Life.m4b`.
-
-These templates apply to GUI and CLI.
-
### Command Line Interface
Libationcli.exe allows limited access to Libation's functionalities as a CLI.
diff --git a/Documentation/NamingTemplates.md b/Documentation/NamingTemplates.md
new file mode 100644
index 00000000..02e93017
--- /dev/null
+++ b/Documentation/NamingTemplates.md
@@ -0,0 +1,107 @@
+# Naming Templates
+File and Folder names can be customized using Libation's built-in tag template naming engine. To edit how folder and file names are created, go to Settings \> Download/Decrypt and edit the naming templates. If you're splitting your audiobook into multiple files by chapter, you can also use a custom template to set each chapter's title metadata tag by editing the template in Settings \> Audio File Options.
+
+These templates apply to both GUI and CLI.
+
+# Table of Contents
+
+- [Template Tags](#template-tags)
+ - [Property Tags](#property-tags)
+ - [Conditional Tags](#conditional-tags)
+- [Tag Formatters](#tag-formatters)
+ - [Text Formatters](#text-formatters)
+ - [Integer Formatters](#integer-formatters)
+ - [Date Formatters](#date-formatters)
+
+
+# Template Tags
+
+These are the naming template tags currently supported by Libation.
+
+## Property Tags
+These tags will be replaced in the template with the audiobook's values.
+
+|Tag|Description|Type|
+|-|-|-|
+|\|Audible book ID (ASIN)|Text|
+|\|Full title|Text|
+|\|Title. Stop at first colon|Text|
+|\|Author(s)|Text|
+|\|First author|Text|
+|\|Narrator(s)|Text|
+|\|First narrator|Text|
+|\|Name of series|Text|
+|\|Number order in series|Text|
+|\|File's original bitrate (Kbps)|Integer|
+|\|File's original audio sample rate|Integer|
+|\|Number of audio channels|Integer|
+|\|Audible account of this book|Text|
+|\|Region/country|Text|
+|\|Year published|Integer|
+|\|Book's language|Text|
+|\|Book's language abbreviated. Eg: ENG|Text|
+|\|File creation date/time.|DateTime|
+|\|Audiobook publication date|DateTime|
+|\|Date the book added to your Audible account|DateTime|
+|\|Number of chapters **†**|Integer|
+|\|Chapter title **†**|Text|
+|\|Chapter number **†**|Integer|
+|\|Chapter number with leading zeros **†**|Integer|
+
+**†** Only valid for Chapter Filename and Chapter Tile Metadata
+
+To change how these properties are displayed, [read about custom formatters](#tag-formatters)
+
+## Conditional Tags
+Anything between the opening tag (``) and closing tag (`<-tagname>`) will only appear in the name if the condition evaluates to true.
+
+|Tag|Description|Type|
+|-|-|-|
+|\...\<-if series\>|Only include if part of a book series or podcast|Conditional|
+|\...\<-if podcast\>|Only include if part of a podcast|Conditional|
+|\...\<-if bookseries\>|Only include if part of a book series|Conditional|
+
+For example, \\<-if podcast\> will evaluate to the podcast's series name if the file is a podcast. For audiobooks that are not podcasts, that tag will be blank.
+
+You can invert the condition (instead of displaying the text when the condition is true, display the text when it is false) by playing a '!' symbol before the opening tag name.
+
+As an example, this folder template will place all Liberated podcasts into a "Podcasts" folder and all liberated books (not podcasts) into a "Books" folder.
+
+\Podcasts<-if podcast\>\Books\<-if podcast\>\\\
+
+
+# Tag Formatters
+**Text**, **Integer**, and **DateTime** tags can be optionally formatted using format text in square brackets after the tag name. Below is a list of supported formatters for each tag type.
+
+## Text Formatters
+|Formatter|Description|Example Usage|Example Result|
+|-|-|-|-|
+|L|Converts text to lowercase|\|a study in scarlet꞉ a sherlock holmes novel|
+|U|Converts text to uppercase|\|A STUDY IN SCARLET|
+
+## Integer Formatters
+|Formatter|Description|Example Usage|Example Result|
+|-|-|-|-|
+|# (a number)|Zero-pads the number|\
\
\|0128
001
044100|
+
+**Text**, **Integer**, and **DateTime** tags can be optionally formatted using format text in square brackets after the tag name. Below is a list of supported formatters for each tag type.
+
+## Date Formatters
+Form more standard formatters, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings).
+### Standard DateTime Formatters
+|Formatter|Description|Example Usage|Example Result|
+|-|-|-|-|
+|s|Sortable date/time pattern.|\|2023-02-14T13:45:30|
+|Y|Year month pattern.|\|February 2023|
+
+### Custom DateTime Formatters
+You can use custom formatters to construct customized DateTime string. For more custom formatters and examples, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings).
+|Formatter|Description|Example Usage|Example Result|
+|-|-|-|-|
+|yyyy|4-digit year|\|2023|
+|yy|2-digit year|\|23|
+|MM|2-digit month|\|02|
+|dd|2-digit day of the month|\|2023-02-14|
+|HH
mm|The hour, using a 24-hour clock from 00 to 23
The minute, from 00 through 59.|\|14:45|
+
+
diff --git a/README.md b/README.md
index 2cdd8819..524a031f 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,7 @@
- [Advanced](Documentation/Advanced.md)
- [Files and folders](Documentation/Advanced.md#files-and-folders)
- [Settings](Documentation/Advanced.md#settings)
- - [Custom File Naming](Documentation/Advanced.md#custom-file-naming)
+ - [Custom File Naming](Documentation/NamingTemplates.md)
- [Command Line Interface](Documentation/Advanced.md#command-line-interface)
- [Docker](Documentation/Docker.md)
diff --git a/Source/FileLiberator/AudioFileStorageExt.cs b/Source/FileLiberator/AudioFileStorageExt.cs
index 45b79aaf..dc96fa27 100644
--- a/Source/FileLiberator/AudioFileStorageExt.cs
+++ b/Source/FileLiberator/AudioFileStorageExt.cs
@@ -25,13 +25,12 @@ namespace FileLiberator
if (seriesParent is not null)
{
- var baseDir = Templates.Folder.GetFilename(seriesParent.ToDto());
- return Templates.Folder.GetFilename(libraryBook.ToDto(), baseDir);
+ var baseDir = Templates.Folder.GetFilename(seriesParent.ToDto(), AudibleFileStorage.BooksDirectory, "");
+ return Templates.Folder.GetFilename(libraryBook.ToDto(), baseDir, "");
}
}
}
-
- return Templates.Folder.GetFilename(libraryBook.ToDto());
+ return Templates.Folder.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, "");
}
///
diff --git a/Source/FileLiberator/DownloadOptions.cs b/Source/FileLiberator/DownloadOptions.cs
index 2790d01e..a79678d2 100644
--- a/Source/FileLiberator/DownloadOptions.cs
+++ b/Source/FileLiberator/DownloadOptions.cs
@@ -35,10 +35,14 @@ namespace FileLiberator
public bool MoveMoovToBeginning => config.MoveMoovToBeginning;
public string GetMultipartFileName(MultiConvertFileProperties props)
- => Templates.ChapterFile.GetFilename(LibraryBookDto, props);
+ {
+ var baseDir = Path.GetDirectoryName(props.OutputFileName);
+ var extension = Path.GetExtension(props.OutputFileName);
+ return Templates.ChapterFile.GetFilename(LibraryBookDto, props, baseDir, extension);
+ }
public string GetMultipartTitle(MultiConvertFileProperties props)
- => Templates.ChapterTitle.GetTitle(LibraryBookDto, props);
+ => Templates.ChapterTitle.GetName(LibraryBookDto, props);
public async Task SaveClipsAndBookmarksAsync(string fileName)
{
diff --git a/Source/FileLiberator/UtilityExtensions.cs b/Source/FileLiberator/UtilityExtensions.cs
index 47533d5c..b85eee0c 100644
--- a/Source/FileLiberator/UtilityExtensions.cs
+++ b/Source/FileLiberator/UtilityExtensions.cs
@@ -40,7 +40,8 @@ namespace FileLiberator
Narrators = libraryBook.Book.Narrators.Select(c => c.Name).ToList(),
SeriesName = libraryBook.Book.SeriesLink.FirstOrDefault()?.Series.Name,
- SeriesNumber = libraryBook.Book.SeriesLink.FirstOrDefault()?.Order,
+ SeriesNumber = (int?)libraryBook.Book.SeriesLink.FirstOrDefault()?.Index,
+ IsPodcast = libraryBook.Book.IsEpisodeChild(),
BitRate = libraryBook.Book.AudioFormat.Bitrate,
SampleRate = libraryBook.Book.AudioFormat.SampleRate,
diff --git a/Source/FileManager/FileNamingTemplate.cs b/Source/FileManager/FileNamingTemplate.cs
deleted file mode 100644
index 2ce85c0d..00000000
--- a/Source/FileManager/FileNamingTemplate.cs
+++ /dev/null
@@ -1,123 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using System.Text;
-
-namespace FileManager
-{
- /// Get valid filename. Advanced features incl. parameterized template
- public class FileNamingTemplate : NamingTemplate
- {
- public ReplacementCharacters ReplacementCharacters { get; }
- /// Proposed file name with optional html-styled template tags.
- public FileNamingTemplate(string template, ReplacementCharacters replacement) : base(template)
- {
- ReplacementCharacters = replacement ?? ReplacementCharacters.Default;
- }
-
- /// Generate a valid path for this file or directory
- public LongPath GetFilePath(string fileExtension, bool returnFirstExisting = false)
- {
- string fileName =
- Template.EndsWith(Path.DirectorySeparatorChar) || Template.EndsWith(Path.AltDirectorySeparatorChar) ?
- FileUtility.RemoveLastCharacter(Template) :
- Template;
-
- List pathParts = new();
-
- var paramReplacements = ParameterReplacements.ToDictionary(r => $"<{formatKey(r.Key)}>", r => formatValue(r.Value, ReplacementCharacters));
-
- while (!string.IsNullOrEmpty(fileName))
- {
- var file = Path.GetFileName(fileName);
-
- if (Path.IsPathRooted(Template) && file == string.Empty)
- {
- pathParts.Add(fileName);
- break;
- }
- else
- {
- pathParts.Add(file);
- fileName = Path.GetDirectoryName(fileName);
- }
- }
-
- pathParts.Reverse();
- var fileNamePart = pathParts[^1];
- pathParts.Remove(fileNamePart);
-
- fileNamePart = fileNamePart[..^fileExtension.Length];
-
- LongPath directory = Path.Join(pathParts.Select(p => replaceFileName(p, paramReplacements, LongPath.MaxFilenameLength)).ToArray());
-
- //If file already exists, GetValidFilename will append " (n)" to the filename.
- //This could cause the filename length to exceed MaxFilenameLength, so reduce
- //allowable filename length by 5 chars, allowing for up to 99 duplicates.
- return FileUtility
- .GetValidFilename(
- Path.Join(directory, replaceFileName(fileNamePart, paramReplacements, LongPath.MaxFilenameLength - fileExtension.Length - 5)) + fileExtension,
- ReplacementCharacters,
- fileExtension,
- returnFirstExisting
- );
- }
-
- private static string replaceFileName(string filename, Dictionary paramReplacements, int maxFilenameLength)
- {
- List filenameParts = new();
- //Build the filename in parts, replacing replacement parameters with
- //their values, and storing the parts in a list.
- while (!string.IsNullOrEmpty(filename))
- {
- int openIndex = filename.IndexOf('<');
- int closeIndex = filename.IndexOf('>');
-
- if (openIndex == 0 && closeIndex > 0)
- {
- var key = filename[..(closeIndex + 1)];
-
- if (paramReplacements.ContainsKey(key))
- filenameParts.Add(new StringBuilder(paramReplacements[key]));
- else
- filenameParts.Add(new StringBuilder(key));
-
- filename = filename[(closeIndex + 1)..];
- }
- else if (openIndex > 0 && closeIndex > openIndex)
- {
- var other = filename[..openIndex];
- filenameParts.Add(new StringBuilder(other));
- filename = filename[openIndex..];
- }
- else
- {
- filenameParts.Add(new StringBuilder(filename));
- filename = string.Empty;
- }
- }
-
- //Remove 1 character from the end of the longest filename part until
- //the total filename is less than max filename length
- while (filenameParts.Sum(p => LongPath.GetFilesystemStringLength(p)) > maxFilenameLength)
- {
- int maxLength = filenameParts.Max(p => p.Length);
- var maxEntry = filenameParts.First(p => p.Length == maxLength);
-
- maxEntry.Remove(maxLength - 1, 1);
- }
- return string.Join("", filenameParts);
- }
-
- private static string formatValue(object value, ReplacementCharacters replacements)
- {
- if (value is null)
- return "";
-
- // Other illegal characters will be taken care of later. Must take care of slashes now so params can't introduce new folders.
- // Esp important for file templates.
- return replacements.ReplaceFilenameChars(value.ToString());
- }
- }
-}
diff --git a/Source/FileManager/LongPath.cs b/Source/FileManager/LongPath.cs
index 06f1db6a..f6f3f8fa 100644
--- a/Source/FileManager/LongPath.cs
+++ b/Source/FileManager/LongPath.cs
@@ -56,9 +56,9 @@ namespace FileManager
//don't care about encoding, so how unicode characters are encoded is
///a choice made by the linux kernel. As best as I can tell, pretty
//much everyone uses UTF-8.
- public static int GetFilesystemStringLength(StringBuilder filename)
+ public static int GetFilesystemStringLength(string filename)
=> IsWindows ? filename.Length
- : Encoding.UTF8.GetByteCount(filename.ToString());
+ : Encoding.UTF8.GetByteCount(filename);
public static implicit operator LongPath(string path)
{
diff --git a/Source/FileManager/MetadataNamingTemplate.cs b/Source/FileManager/MetadataNamingTemplate.cs
deleted file mode 100644
index af5281fe..00000000
--- a/Source/FileManager/MetadataNamingTemplate.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-using System;
-using System.Linq;
-
-namespace FileManager
-{
- public class MetadataNamingTemplate : NamingTemplate
- {
- public MetadataNamingTemplate(string template) : base(template) { }
-
- public string GetTagContents()
- {
- var tagValue = Template;
-
- foreach (var r in ParameterReplacements)
- tagValue = tagValue.Replace($"<{formatKey(r.Key)}>", r.Value?.ToString() ?? "");
-
- return tagValue;
- }
- }
-}
diff --git a/Source/FileManager/NamingTemplate.cs b/Source/FileManager/NamingTemplate.cs
deleted file mode 100644
index 126b8f61..00000000
--- a/Source/FileManager/NamingTemplate.cs
+++ /dev/null
@@ -1,28 +0,0 @@
-using Dinah.Core;
-using System;
-using System.Collections.Generic;
-
-namespace FileManager
-{
- public class NamingTemplate
- {
- /// Proposed full name. May contain optional html-styled template tags. Eg: <name>
- public string Template { get; }
-
- /// Proposed file name with optional html-styled template tags.
- public NamingTemplate(string template) => Template = ArgumentValidator.EnsureNotNullOrWhiteSpace(template, nameof(template));
-
- /// Optional step 1: Replace html-styled template tags with parameters. Eg {"name", "Bill Gates"} => /<name>/ => /Bill Gates/
- public Dictionary ParameterReplacements { get; } = new Dictionary();
-
- /// Convenience method
- public void AddParameterReplacement(string key, object value)
- // using .Add() instead of "[key] = value" will make unintended overwriting throw exception
- => ParameterReplacements.Add(key, value);
-
- protected static string formatKey(string key)
- => key
- .Replace("<", "")
- .Replace(">", "");
- }
-}
diff --git a/Source/FileManager/NamingTemplate/ConditionalTagClass[TClass].cs b/Source/FileManager/NamingTemplate/ConditionalTagClass[TClass].cs
new file mode 100644
index 00000000..69d3fdc9
--- /dev/null
+++ b/Source/FileManager/NamingTemplate/ConditionalTagClass[TClass].cs
@@ -0,0 +1,64 @@
+using System;
+using System.Linq.Expressions;
+using System.Text.RegularExpressions;
+
+namespace FileManager.NamingTemplate;
+
+internal interface IClosingPropertyTag : IPropertyTag
+{
+ /// The used to match the closing in template strings.
+ public Regex NameCloseMatcher { get; }
+
+ ///
+ /// Determine if the template string starts with 's closing tag signature,
+ /// and if it does output the matching tag's
+ ///
+ /// Template string
+ /// The substring that was matched.
+ /// The registered
+ /// True if the starts with this tag.
+ bool StartsWithClosing(string templateString, out string exactName, out IClosingPropertyTag propertyTag);
+}
+
+public class ConditionalTagClass : TagClass
+{
+ public ConditionalTagClass(bool caseSensative = true) :base(typeof(TClass), caseSensative) { }
+
+ public void RegisterCondition(ITemplateTag templateTag, Func propertyGetter)
+ {
+ var expr = Expression.Call(Expression.Constant(propertyGetter.Target), propertyGetter.Method, Parameter);
+
+ AddPropertyTag(new ConditionalTag(templateTag, Options, expr));
+ }
+
+ private class ConditionalTag : TagBase, IClosingPropertyTag
+ {
+ public Regex NameCloseMatcher { get; }
+
+ public ConditionalTag(ITemplateTag templateTag, RegexOptions options, Expression conditionExpression)
+ : base(templateTag, conditionExpression)
+ {
+ NameMatcher = new Regex($"^<(!)?{templateTag.TagName}->", options);
+ NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options);
+ }
+
+ public bool StartsWithClosing(string templateString, out string exactName, out IClosingPropertyTag propertyTag)
+ {
+ var match = NameCloseMatcher.Match(templateString);
+ if (match.Success)
+ {
+ exactName = match.Value;
+ propertyTag = this;
+ return true;
+ }
+ else
+ {
+ exactName = null;
+ propertyTag = null;
+ return false;
+ }
+ }
+
+ protected override Expression GetTagExpression(string exactName, string formatter) => formatter == "!" ? Expression.Not(ExpressionValue) : ExpressionValue;
+ }
+}
diff --git a/Source/FileManager/NamingTemplate/ITemplateTag.cs b/Source/FileManager/NamingTemplate/ITemplateTag.cs
new file mode 100644
index 00000000..2b397ba1
--- /dev/null
+++ b/Source/FileManager/NamingTemplate/ITemplateTag.cs
@@ -0,0 +1,6 @@
+namespace FileManager.NamingTemplate;
+
+public interface ITemplateTag
+{
+ string TagName { get; }
+}
diff --git a/Source/FileManager/NamingTemplate/NamingTemplate.cs b/Source/FileManager/NamingTemplate/NamingTemplate.cs
new file mode 100644
index 00000000..17ee7ec3
--- /dev/null
+++ b/Source/FileManager/NamingTemplate/NamingTemplate.cs
@@ -0,0 +1,273 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Linq.Expressions;
+
+namespace FileManager.NamingTemplate;
+
+public class NamingTemplate
+{
+ public string TemplateText { get; private set; }
+ public IEnumerable TagsInUse => _tagsInUse;
+ public IEnumerable TagsRegistered => Classes.SelectMany(p => p.TemplateTags).DistinctBy(f => f.TagName);
+ public IEnumerable Warnings => errors.Concat(warnings);
+ public IEnumerable Errors => errors;
+
+ private Delegate templateToString;
+ private readonly List warnings = new();
+ private readonly List errors = new();
+ private readonly IEnumerable Classes;
+ private readonly List _tagsInUse = new();
+
+ public const string ERROR_NULL_IS_INVALID = "Null template is invalid.";
+ public const string WARNING_EMPTY = "Template is empty.";
+ public const string WARNING_WHITE_SPACE = "Template is white space.";
+ public const string WARNING_NO_TAGS = "Should use tags. Eg: ";
+
+ ///
+ /// Invoke the to
+ ///
+ /// Instances of the TClass used in and
+ ///
+ 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;
+ }
+
+ /// Parse a template string to a
+ /// The template string to parse
+ /// A collection of with
+ /// properties registered to match to the
+ public static NamingTemplate Parse(string template, IEnumerable tagClasses)
+ {
+ var namingTemplate = new NamingTemplate(tagClasses);
+ try
+ {
+ BinaryNode intermediate = namingTemplate.IntermediateParse(template);
+ Expression evalTree = GetExpressionTree(intermediate);
+
+ List 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 properties)
+ {
+ Classes = properties;
+ }
+
+ /// Builds an tree that will evaluate to a
+ 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));
+ }
+
+ /// Parse a template string into a tree
+ 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 literalChars = new();
+
+ while (templateString.Length > 0)
+ {
+ if (StartsWith(templateString, out string exactPropertyName, out var propertyTag, out var valueExpression))
+ {
+ checkAndAddLiterals();
+
+ if (propertyTag is IClosingPropertyTag)
+ currentNode = currentNode.AddNewNode(BinaryNode.CreateConditional(propertyTag.TemplateTag, valueExpression));
+ else
+ {
+ currentNode = currentNode.AddNewNode(BinaryNode.CreateValue(propertyTag.TemplateTag, valueExpression));
+ _tagsInUse.Add(propertyTag.TemplateTag);
+ }
+
+ templateString = templateString[exactPropertyName.Length..];
+ }
+ else if (StartsWithClosing(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 = currentNode.AddNewNode(BinaryNode.CreateValue(new string(literalChars.ToArray())));
+ literalChars.Clear();
+ }
+ }
+ }
+
+ private bool StartsWith(string template, out string exactName, out IPropertyTag propertyTag, out Expression valueExpression)
+ {
+ foreach (var pc in Classes)
+ {
+ if (pc.StartsWith(template, out exactName, out propertyTag, out valueExpression))
+ return true;
+ }
+ exactName = null;
+ valueExpression = null;
+ propertyTag = null;
+ return false;
+ }
+
+ private bool StartsWithClosing(string template, out string exactName, out IClosingPropertyTag closingPropertyTag)
+ {
+ foreach (var pc in Classes)
+ {
+ if (pc.StartsWithClosing(template, out exactName, out closingPropertyTag))
+ return true;
+ }
+ exactName = null;
+ closingPropertyTag = null;
+ return false;
+ }
+
+ private class BinaryNode
+ {
+ public string Name { get; }
+ public BinaryNode Parent { get; private set; }
+ public BinaryNode RightChild { get; private set; }
+ public BinaryNode LeftChild { get; private set; }
+ public Expression Expression { get; private init; }
+ public bool IsConditional { get; private init; } = false;
+ public bool IsValue { get; private init; } = false;
+
+ 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
+ };
+
+ private 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;
+
+ public BinaryNode AddNewNode(BinaryNode newNode)
+ {
+ BinaryNode currentNode = this;
+
+ if (LeftChild is null)
+ {
+ newNode.Parent = currentNode;
+ LeftChild = newNode;
+ }
+ else if (RightChild is null)
+ {
+ newNode.Parent = currentNode;
+ RightChild = newNode;
+ }
+ else
+ {
+ RightChild = CreateConcatenation(RightChild, newNode);
+ RightChild.Parent = currentNode;
+ currentNode = RightChild;
+ }
+
+ return newNode.IsConditional ? newNode : currentNode;
+ }
+ }
+}
diff --git a/Source/FileManager/NamingTemplate/PropertyTagClass[TClass].cs b/Source/FileManager/NamingTemplate/PropertyTagClass[TClass].cs
new file mode 100644
index 00000000..04eac040
--- /dev/null
+++ b/Source/FileManager/NamingTemplate/PropertyTagClass[TClass].cs
@@ -0,0 +1,88 @@
+using System;
+using System.Linq.Expressions;
+using System.Reflection;
+using System.Text.RegularExpressions;
+
+namespace FileManager.NamingTemplate;
+
+public delegate string PropertyFormatter(ITemplateTag templateTag, T value, string formatString);
+
+public class PropertyTagClass : TagClass
+{
+ public PropertyTagClass(bool caseSensative = true) : base(typeof(TClass), caseSensative) { }
+
+ ///
+ /// Register a nullable value type property.
+ ///
+ /// Type of the property from
+ /// A Func to get the property value from
+ /// Optional formatting function that accepts the property and a formatting string and returnes the value formatted to string
+ public void RegisterProperty(ITemplateTag templateTag, Func propertyGetter, PropertyFormatter formatter = null)
+ where U : struct
+ => RegisterPropertyInternal(templateTag, propertyGetter, formatter);
+
+ ///
+ /// Register a non-nullable value type property
+ ///
+ /// Type of the property from
+ /// A Func to get the property value from
+ /// Optional formatting function that accepts the property and a formatting string and returnes the value formatted to string
+ public void RegisterProperty(ITemplateTag templateTag, Func propertyGetter, PropertyFormatter formatter = null)
+ where U : struct
+ => RegisterPropertyInternal(templateTag, propertyGetter, formatter);
+
+ ///
+ /// Register a string type property.
+ ///
+ /// A Func to get the string property from
+ /// Optional formatting function that accepts the string property and a formatting string and returnes the value formatted to string
+ public void RegisterProperty(ITemplateTag templateTag, Func propertyGetter, PropertyFormatter formatter = null)
+ => RegisterPropertyInternal(templateTag, propertyGetter, formatter);
+
+ private void RegisterPropertyInternal(ITemplateTag templateTag, Delegate propertyGetter, Delegate formatter)
+ {
+ if (formatter?.Target is not null)
+ throw new ArgumentException($"{nameof(formatter)} must be a static method");
+
+ var expr = Expression.Call(Expression.Constant(propertyGetter.Target), propertyGetter.Method, Parameter);
+
+ AddPropertyTag(new PropertyTag(templateTag, Options, expr, formatter?.Method));
+ }
+
+ private class PropertyTag : TagBase
+ {
+ private readonly Func 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()))
+ : (expValue, retTyp, format) => Expression.Call(null, formatter, Expression.Constant(templateTag), expValue, Expression.Constant(format));
+ }
+
+ protected override Expression GetTagExpression(string exactName, string formatString)
+ {
+ var underlyingType = Nullable.GetUnderlyingType(ReturnType);
+
+ Expression toStringExpression
+ = ReturnType == typeof(string)
+ ? createToStringExpression(Expression.Coalesce(ExpressionValue, Expression.Constant("")), ReturnType, formatString)
+ : underlyingType is null
+ ? createToStringExpression(ExpressionValue, ReturnType, formatString)
+ : Expression.Condition(
+ Expression.PropertyOrField(ExpressionValue, "HasValue"),
+ createToStringExpression(Expression.PropertyOrField(ExpressionValue, "Value"), underlyingType, formatString),
+ Expression.Constant(""));
+
+ return Expression.TryCatch(toStringExpression, Expression.Catch(typeof(Exception), Expression.Constant(exactName)));
+ }
+ }
+}
diff --git a/Source/FileManager/NamingTemplate/TagBase.cs b/Source/FileManager/NamingTemplate/TagBase.cs
new file mode 100644
index 00000000..00622cac
--- /dev/null
+++ b/Source/FileManager/NamingTemplate/TagBase.cs
@@ -0,0 +1,67 @@
+using System;
+using System.Linq.Expressions;
+using System.Text.RegularExpressions;
+
+namespace FileManager.NamingTemplate;
+
+internal interface IPropertyTag
+{
+ /// The tag that will be matched in a tag string
+ ITemplateTag TemplateTag { get; }
+
+ /// 's
+ Type ReturnType { get; }
+
+ /// The used to match in template strings.
+ public Regex NameMatcher { get; }
+
+ ///
+ /// Determine if the template string starts with , and if it does parse the tag to an
+ ///
+ /// Template string
+ /// The substring that was matched.
+ /// The that returns the property's value
+ /// True if the starts with this tag.
+ 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;
+ }
+
+ /// Create an that returns the property's value.
+ /// The exact string that was matched to
+ /// The optional format string in the match inside the square brackets
+ protected abstract Expression GetTagExpression(string exactName, string formatter);
+
+ public bool StartsWith(string templateString, out string exactName, out Expression propertyValue)
+ {
+ var match = NameMatcher.Match(templateString);
+ if (match.Success)
+ {
+ exactName = match.Value;
+ propertyValue = GetTagExpression(exactName, match.Groups.Count == 2 ? match.Groups[1].Value.Trim() : "");
+ return true;
+ }
+ else
+ {
+ exactName = null;
+ propertyValue = null;
+ return false;
+ }
+ }
+
+ public override string ToString()
+ {
+ return $"[Name = {TemplateTag.TagName}, Type = {ReturnType.Name}]";
+ }
+}
diff --git a/Source/FileManager/NamingTemplate/TagClass.cs b/Source/FileManager/NamingTemplate/TagClass.cs
new file mode 100644
index 00000000..1f044601
--- /dev/null
+++ b/Source/FileManager/NamingTemplate/TagClass.cs
@@ -0,0 +1,77 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Linq.Expressions;
+using System.Text.RegularExpressions;
+
+namespace FileManager.NamingTemplate;
+
+
+/// A collection of s registered to a single .
+public abstract class TagClass
+{
+ /// The of the 's TClass type.
+ public ParameterExpression Parameter { get; }
+ /// The s registered with this
+ public IEnumerable TemplateTags => PropertyTags.Select(p => p.TemplateTag);
+
+ protected RegexOptions Options { get; } = RegexOptions.Compiled;
+ private protected List PropertyTags { get; } = new();
+
+ protected TagClass(Type classType, bool caseSensative = true)
+ {
+ Parameter = Expression.Parameter(classType, classType.Name);
+ Options |= caseSensative ? RegexOptions.None : RegexOptions.IgnoreCase;
+ }
+
+ ///
+ /// Determine if the template string starts with any of the s' signatures,
+ /// and if it does parse the tag to an
+ ///
+ /// Template string
+ /// The substring that was matched.
+ /// The that returns the 's value
+ /// True if the starts with a tag registered in this class.
+ 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;
+ }
+
+ ///
+ /// Determine if the template string starts with 's closing tag signature,
+ /// and if it does output the matching tag's
+ ///
+ /// Template string
+ /// The substring that was matched.
+ /// The registered
+ /// True if the starts with this tag.
+ internal bool StartsWithClosing(string templateString, out string exactName, out IClosingPropertyTag closingPropertyTag)
+ {
+ foreach (var cg in PropertyTags.OfType())
+ {
+ if (cg.StartsWithClosing(templateString, out exactName, out closingPropertyTag))
+ return true;
+ }
+
+ closingPropertyTag = null;
+ exactName = null;
+ return false;
+ }
+
+ private protected void AddPropertyTag(IPropertyTag propertyTag)
+ {
+ if (!PropertyTags.Any(c => c.TemplateTag.TagName == propertyTag.TemplateTag.TagName))
+ PropertyTags.Add(propertyTag);
+ }
+}
diff --git a/Source/FileManager/NamingTemplate/TemplatePart.cs b/Source/FileManager/NamingTemplate/TemplatePart.cs
new file mode 100644
index 00000000..b26e8887
--- /dev/null
+++ b/Source/FileManager/NamingTemplate/TemplatePart.cs
@@ -0,0 +1,109 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using System.Linq.Expressions;
+using System.Reflection;
+
+namespace FileManager.NamingTemplate;
+
+/// Represents one part of an evaluated .
+public class TemplatePart : IEnumerable
+{
+ /// The name. If is
+ /// a registered property, this value is
+ public string TagName { get; }
+
+ /// The 's if is
+ /// a registered property, otherwise for string literals.
+ public ITemplateTag TemplateTag { get; }
+
+ /// The evaluated string.
+ 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 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.FirstPart;
+ }
+}
diff --git a/Source/LibationAvalonia/Dialogs/EditTemplateDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/EditTemplateDialog.axaml.cs
index 41e2450f..493a16eb 100644
--- a/Source/LibationAvalonia/Dialogs/EditTemplateDialog.axaml.cs
+++ b/Source/LibationAvalonia/Dialogs/EditTemplateDialog.axaml.cs
@@ -11,14 +11,12 @@ using ReactiveUI;
using Avalonia.Controls.Documents;
using Avalonia.Collections;
using Avalonia.Controls;
+using Avalonia.Markup.Xaml.Templates;
namespace LibationAvalonia.Dialogs
{
public partial class EditTemplateDialog : DialogWindow
{
- // final value. post-validity check
- public string TemplateText { get; private set; }
-
private EditTemplateViewModel _viewModel;
public EditTemplateDialog()
@@ -28,20 +26,21 @@ namespace LibationAvalonia.Dialogs
if (Design.IsDesignMode)
{
_ = Configuration.Instance.LibationFiles;
- _viewModel = new(Configuration.Instance, Templates.File);
- _viewModel.resetTextBox(_viewModel.Template.DefaultTemplate);
- Title = $"Edit {_viewModel.Template.Name}";
+ var editor = TemplateEditor.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>(
- Template
- .GetTemplateTags()
+ TemplateEditor
+ .EditingTemplate
+ .TagsRegistered
+ .Cast()
.Select(
t => new Tuple(
$"<{t.TagName.Replace("->", "-\x200C>").Replace("<-", "<\x200C-")}>",
@@ -111,7 +111,6 @@ namespace LibationAvalonia.Dialogs
}
}
- public string workingTemplateText => Template.Sanitize(UserTemplateText, Configuration.Instance.ReplacementCharacters);
private string _warningText;
public string WarningText { get => _warningText; set => this.RaiseAndSetIfChanged(ref _warningText, value); }
@@ -123,78 +122,22 @@ namespace LibationAvalonia.Dialogs
public async Task Validate()
{
- if (Template.IsValid(workingTemplateText))
+ if (TemplateEditor.EditingTemplate.IsValid)
return true;
- var errors = Template
- .GetErrors(workingTemplateText)
- .Select(err => $"- {err}")
- .Aggregate((a, b) => $"{a}\r\n{b}");
+
+ var errors
+ = TemplateEditor
+ .EditingTemplate
+ .Errors
+ .Select(err => $"- {err}")
+ .Aggregate((a, b) => $"{a}\r\n{b}");
await MessageBox.Show($"This template text is not valid. Errors:\r\n{errors}", "Invalid", MessageBoxButtons.OK, MessageBoxIcon.Error);
return false;
}
private void templateTb_TextChanged()
{
- var isChapterTitle = Template == Templates.ChapterTitle;
- var isFolder = Template == Templates.Folder;
-
- var libraryBookDto = new LibraryBookDto
- {
- Account = "my account",
- DateAdded = new DateTime(2022, 6, 9, 0, 0, 0),
- DatePublished = new DateTime(2017, 2, 27, 0, 0, 0),
- AudibleProductId = "123456789",
- Title = "A Study in Scarlet: A Sherlock Holmes Novel",
- Locale = "us",
- YearPublished = 2017,
- Authors = new List { "Arthur Conan Doyle", "Stephen Fry - introductions" },
- Narrators = new List { "Stephen Fry" },
- SeriesName = "Sherlock Holmes",
- SeriesNumber = "1",
- BitRate = 128,
- SampleRate = 44100,
- Channels = 2,
- Language = "English"
- };
- var chapterName = "A Flight for Life";
- var chapterNumber = 4;
- var chaptersTotal = 10;
-
- var partFileProperties = new AaxDecrypter.MultiConvertFileProperties()
- {
- OutputFileName = "",
- PartsPosition = chapterNumber,
- PartsTotal = chaptersTotal,
- Title = chapterName
- };
-
- /*
- * Path must be rooted for windows to allow long file paths. This is
- * only necessary for folder templates because they may contain several
- * subdirectories. Without rooting, we won't be allowed to create a
- * relative path longer than MAX_PATH.
- */
-
- var books = config.Books;
- var folder = Templates.Folder.GetPortionFilename(
- libraryBookDto,
- Path.Combine(books, isFolder ? workingTemplateText : config.FolderTemplate), "");
-
- folder = Path.GetRelativePath(books, folder);
-
- var file
- = Template == Templates.ChapterFile
- ? Templates.ChapterFile.GetPortionFilename(
- libraryBookDto,
- workingTemplateText,
- partFileProperties,
- "")
- : Templates.File.GetPortionFilename(
- libraryBookDto,
- isFolder ? config.FileTemplate : workingTemplateText, "");
- var ext = config.DecryptToLossy ? "mp3" : "m4b";
-
- var chapterTitle = Templates.ChapterTitle.GetPortionTitle(libraryBookDto, workingTemplateText, partFileProperties);
+ TemplateEditor.SetTemplateText(UserTemplateText);
const char ZERO_WIDTH_SPACE = '\u200B';
var sing = $"{Path.DirectorySeparatorChar}";
@@ -207,11 +150,12 @@ namespace LibationAvalonia.Dialogs
string slashWrap(string val) => val.Replace(sing, $"{ZERO_WIDTH_SPACE}{sing}");
WarningText
- = !Template.HasWarnings(workingTemplateText)
+ = !TemplateEditor.EditingTemplate.HasWarnings
? ""
: "Warning:\r\n" +
- Template
- .GetWarnings(workingTemplateText)
+ TemplateEditor
+ .EditingTemplate
+ .Warnings
.Select(err => $"- {err}")
.Aggregate((a, b) => $"{a}\r\n{b}");
@@ -220,20 +164,24 @@ namespace LibationAvalonia.Dialogs
Inlines.Clear();
- if (isChapterTitle)
+ if (!TemplateEditor.IsFilePath)
{
- Inlines.Add(new Run(chapterTitle) { FontWeight = bold });
+ Inlines.Add(new Run(TemplateEditor.GetName()) { FontWeight = bold });
return;
}
- Inlines.Add(new Run(slashWrap(books)) { FontWeight = reg });
+ var folder = TemplateEditor.GetFolderName();
+ var file = TemplateEditor.GetFileName();
+ var ext = config.DecryptToLossy ? "mp3" : "m4b";
+
+ Inlines.Add(new Run(slashWrap(TemplateEditor.BaseDirectory.PathWithoutPrefix)) { FontWeight = reg });
Inlines.Add(new Run(sing) { FontWeight = reg });
- Inlines.Add(new Run(slashWrap(folder)) { FontWeight = isFolder ? bold : reg });
+ Inlines.Add(new Run(slashWrap(folder)) { FontWeight = TemplateEditor.IsFolder ? bold : reg });
Inlines.Add(new Run(sing));
- Inlines.Add(new Run(slashWrap(file)) { FontWeight = isFolder ? reg : bold });
+ Inlines.Add(new Run(slashWrap(file)) { FontWeight = TemplateEditor.IsFolder ? reg : bold });
Inlines.Add(new Run($".{ext}"));
}
diff --git a/Source/LibationAvalonia/Dialogs/SettingsDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/SettingsDialog.axaml.cs
index 48edebec..f3c721de 100644
--- a/Source/LibationAvalonia/Dialogs/SettingsDialog.axaml.cs
+++ b/Source/LibationAvalonia/Dialogs/SettingsDialog.axaml.cs
@@ -52,21 +52,22 @@ namespace LibationAvalonia.Dialogs
public async void EditFolderTemplateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
- var newTemplate = await editTemplate(Templates.Folder, settingsDisp.DownloadDecryptSettings.FolderTemplate);
+ var newTemplate = await editTemplate(TemplateEditor.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.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.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.CreateNameEditor(settingsDisp.AudioSettings.ChapterTitleTemplate));
if (newTemplate is not null)
settingsDisp.AudioSettings.ChapterTitleTemplate = newTemplate;
}
- private async Task editTemplate(Templates template, string existingTemplate)
+ private async Task editTemplate(ITemplateEditor template)
{
- var form = new EditTemplateDialog(template, existingTemplate);
+ var form = new EditTemplateDialog(template);
if (await form.ShowDialog(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 SaveSettingsAsync(Configuration config)
+ public Task SaveSettingsAsync(Configuration config)
{
- static Task validationError(string text, string caption)
- => MessageBox.Show(text, caption, MessageBoxButtons.OK, MessageBoxIcon.Error);
-
- // these 3 should do nothing. Configuration will only init these with a valid value. EditTemplateDialog ensures valid before returning
- if (!Templates.Folder.IsValid(FolderTemplate))
- {
- await validationError($"Not saving change to folder naming template. Invalid format.", "Invalid folder template");
- return false;
- }
- if (!Templates.File.IsValid(FileTemplate))
- {
- await validationError($"Not saving change to file naming template. Invalid format.", "Invalid file template");
- return false;
- }
- if (!Templates.ChapterFile.IsValid(ChapterFileTemplate))
- {
- await validationError($"Not saving change to chapter file naming template. Invalid format.", "Invalid chapter file template");
- return false;
- }
-
config.BadBook
= BadBookAbort ? Configuration.BadBookAction.Abort
: BadBookRetry ? Configuration.BadBookAction.Retry
@@ -301,7 +282,7 @@ namespace LibationAvalonia.Dialogs
config.UseCoverAsFolderIcon = UseCoverAsFolderIcon;
- return true;
+ return Task.FromResult(true);
}
public string UseCoverAsFolderIconText { get; } = Configuration.GetDescription(nameof(Configuration.UseCoverAsFolderIcon));
diff --git a/Source/LibationFileManager/Configuration.PersistentSettings.cs b/Source/LibationFileManager/Configuration.PersistentSettings.cs
index e5745713..152ebe5f 100644
--- a/Source/LibationFileManager/Configuration.PersistentSettings.cs
+++ b/Source/LibationFileManager/Configuration.PersistentSettings.cs
@@ -80,7 +80,7 @@ namespace LibationFileManager
public bool BetaOptIn { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("Location for book storage. Includes destination of newly liberated books")]
- public string Books { get => GetString(); set => SetString(value); }
+ public LongPath Books { get => GetString(); set => SetString(value); }
// temp/working dir(s) should be outside of dropbox
[Description("Temporary location of files while they're in process of being downloaded and decrypted.\r\nWhen decryption is complete, the final file will be in Books location\r\nRecommend not using a folder which is backed up real time. Eg: Dropbox, iCloud, Google Drive")]
@@ -223,36 +223,41 @@ namespace LibationFileManager
[Description("How to format the folders in which files will be saved")]
public string FolderTemplate
{
- get => Templates.Folder.GetValid(GetString(defaultValue: Templates.Folder.DefaultTemplate));
- set => setTemplate(Templates.Folder, value);
+ get => getTemplate();
+ set => setTemplate(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();
+ set => setTemplate(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();
+ set => setTemplate(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();
+ set => setTemplate(value);
}
- private void setTemplate(Templates templ, string newValue, [CallerMemberName] string propertyName = "")
+ private string getTemplate([CallerMemberName] string propertyName = "")
+ where T : Templates, ITemplate, new()
{
- var template = newValue?.Trim();
- if (templ.IsValid(template))
- SetString(template, propertyName);
+ return Templates.GetTemplate(GetString(defaultValue: T.DefaultTemplate, propertyName)).TemplateText;
+ }
+
+ private void setTemplate(string newValue, [CallerMemberName] string propertyName = "")
+ where T : Templates, ITemplate, new()
+ {
+ SetString(Templates.GetTemplate(newValue).TemplateText, propertyName);
}
#endregion
}
diff --git a/Source/LibationFileManager/LibraryBookDto.cs b/Source/LibationFileManager/LibraryBookDto.cs
index e46cc562..859f18c9 100644
--- a/Source/LibationFileManager/LibraryBookDto.cs
+++ b/Source/LibationFileManager/LibraryBookDto.cs
@@ -20,7 +20,9 @@ namespace LibationFileManager
public string FirstNarrator => Narrators.FirstOrDefault();
public string SeriesName { get; set; }
- public string SeriesNumber { get; set; }
+ public int? SeriesNumber { get; set; }
+ public bool IsSeries => !string.IsNullOrEmpty(SeriesName);
+ public bool IsPodcast { get; set; }
public int BitRate { get; set; }
public int SampleRate { get; set; }
diff --git a/Source/LibationFileManager/TemplateEditor[T].cs b/Source/LibationFileManager/TemplateEditor[T].cs
new file mode 100644
index 00000000..a7226de2
--- /dev/null
+++ b/Source/LibationFileManager/TemplateEditor[T].cs
@@ -0,0 +1,130 @@
+using AaxDecrypter;
+using FileManager;
+using System.Collections.Generic;
+using System;
+using System.IO;
+
+namespace LibationFileManager
+{
+ public interface ITemplateEditor
+ {
+ bool IsFolder { get; }
+ bool IsFilePath { get; }
+ LongPath BaseDirectory { get; }
+ string DefaultTemplate { get; }
+ Templates Folder { get; }
+ Templates File { get; }
+ Templates Name { get; }
+ Templates EditingTemplate { get; }
+ void SetTemplateText(string templateText);
+ string GetFolderName();
+ string GetFileName();
+ string GetName();
+ }
+
+ public class TemplateEditor : 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(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 { "Arthur Conan Doyle", "Stephen Fry - introductions" },
+ Narrators = new List { "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(templateText, out var template);
+
+ var templateEditor = new TemplateEditor
+ {
+ _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(templateText, out var nameTemplate);
+
+ var templateEditor = new TemplateEditor
+ {
+ _editingTemplate = nameTemplate,
+ DefaultTemplate = T.DefaultTemplate
+ };
+
+ if (templateEditor.IsFolder || templateEditor.IsFilePath)
+ throw new InvalidOperationException($"This method is only for name templates. Use {nameof(CreateFilenameEditor)} for file templates");
+
+ return templateEditor;
+ }
+ }
+}
diff --git a/Source/LibationFileManager/TemplateTags.cs b/Source/LibationFileManager/TemplateTags.cs
index 469924a1..8adcd4d6 100644
--- a/Source/LibationFileManager/TemplateTags.cs
+++ b/Source/LibationFileManager/TemplateTags.cs
@@ -1,31 +1,27 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using Dinah.Core;
+using FileManager.NamingTemplate;
namespace LibationFileManager
{
- public sealed class TemplateTags : Enumeration
- {
- public string TagName => DisplayName;
- public string DefaultValue { get; }
+ public sealed class TemplateTags : ITemplateTag
+ {
+ public const string DEFAULT_DATE_FORMAT = "yyyy-MM-dd";
+ public string TagName { get; }
+ public string DefaultValue { get; }
public string Description { get; }
- public bool IsChapterOnly { get; }
-
- private static int value = 0;
- private TemplateTags(string tagName, string description, bool isChapterOnly = false, string defaultValue = null) : base(value++, tagName)
- {
- Description = description;
- IsChapterOnly = isChapterOnly;
- DefaultValue = defaultValue ?? $"<{tagName}>";
+ public string Display { get; }
+ private TemplateTags(string tagName, string description, string defaultValue = null, string display = null)
+ {
+ TagName = tagName;
+ Description = description;
+ DefaultValue = defaultValue ?? $"<{tagName}>";
+ Display = display ?? $"<{tagName}>";
}
- // putting these first is the incredibly lazy way to make them show up first in the EditTemplateDialog
- public static TemplateTags ChCount { get; } = new TemplateTags("ch count", "Number of chapters", true);
- public static TemplateTags ChTitle { get; } = new TemplateTags("ch title", "Chapter title", true);
- public static TemplateTags ChNumber { get; } = new TemplateTags("ch#", "Chapter #", true);
- public static TemplateTags ChNumber0 { get; } = new TemplateTags("ch# 0", "Chapter # with leading zeros", true);
+ public static TemplateTags ChCount { get; } = new TemplateTags("ch count", "Number of chapters");
+ public static TemplateTags ChTitle { get; } = new TemplateTags("ch title", "Chapter title");
+ public static TemplateTags ChNumber { get; } = new TemplateTags("ch#", "Chapter #");
+ public static TemplateTags ChNumber0 { get; } = new TemplateTags("ch# 0", "Chapter # with leading zeros");
public static TemplateTags Id { get; } = new TemplateTags("id", "Audible ID");
public static TemplateTags Title { get; } = new TemplateTags("title", "Full title");
@@ -41,16 +37,16 @@ namespace LibationFileManager
public static TemplateTags SampleRate { get; } = new TemplateTags("samplerate", "File's orig. sample rate");
public static TemplateTags Channels { get; } = new TemplateTags("channels", "Number of audio channels");
public static TemplateTags Account { get; } = new TemplateTags("account", "Audible account of this book");
- public static TemplateTags Locale { get; } = new("locale", "Region/country");
+ public static TemplateTags Locale { get; } = new ("locale", "Region/country");
public static TemplateTags YearPublished { get; } = new("year", "Year published");
- public static TemplateTags Language { get; } = new("language", "Book's language");
- public static TemplateTags LanguageShort { get; } = new("language short", "Book's language abbreviated. Eg: ENG");
+ public static TemplateTags Language { get; } = new("language", "Book's language");
+ public static TemplateTags LanguageShort { get; } = new("language short", "Book's language abbreviated. Eg: ENG");
- // Special cases. Aren't mapped to replacements in Templates.cs
- // Included here for display by EditTemplateDialog
- public static TemplateTags FileDate { get; } = new TemplateTags("file date [...]", "File date/time. e.g. yyyy-MM-dd HH-mm", false, $"");
- public static TemplateTags DatePublished { get; } = new TemplateTags("pub date [...]", "Publication date. e.g. yyyy-MM-dd", false, $"");
- public static TemplateTags DateAdded { get; } = new TemplateTags("date added [...]", "Date added to your Audible account. e.g. yyyy-MM-dd", false, $"");
- public static TemplateTags IfSeries { get; } = new TemplateTags("if series->...<-if series", "Only include if part of a series", false, "<-if series>");
+ public static TemplateTags FileDate { get; } = new TemplateTags("file date", "File date/time. e.g. yyyy-MM-dd HH-mm", $"", "");
+ public static TemplateTags DatePublished { get; } = new TemplateTags("pub date", "Publication date. e.g. yyyy-MM-dd", $"", "");
+ public static TemplateTags DateAdded { get; } = new TemplateTags("date added", "Date added to your Audible account. e.g. yyyy-MM-dd", $"", "");
+ public static TemplateTags IfSeries { get; } = new TemplateTags("if series", "Only include if part of a book series or podcast", "<-if series>", "...<-if series>");
+ public static TemplateTags IfPodcast { get; } = new TemplateTags("if podcast", "Only include if part of a podcast", "<-if podcast>", "...<-if podcast>");
+ public static TemplateTags IfBookseries { get; } = new TemplateTags("if bookseries", "Only include if part of a book series", "<-if bookseries>", "...<-if bookseries>");
}
}
diff --git a/Source/LibationFileManager/Templates.cs b/Source/LibationFileManager/Templates.cs
index cdaf3a44..e62a62b2 100644
--- a/Source/LibationFileManager/Templates.cs
+++ b/Source/LibationFileManager/Templates.cs
@@ -2,115 +2,256 @@
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;
+using Serilog.Formatting;
namespace LibationFileManager
{
+ public interface ITemplate
+ {
+ static abstract string DefaultTemplate { get; }
+ static abstract IEnumerable TagClass { get; }
+ }
+
public abstract class Templates
{
- protected static string[] Valid => Array.Empty();
- 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: ";
- 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: ";
public const string WARNING_NO_CHAPTER_NUMBER_TAG = "Should include chapter number tag in template used for naming files which are split by chapter. Ie: or ";
- 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(Configuration.Instance.FolderTemplate);
+ public static FileTemplate File => _file ??= GetTemplate(Configuration.Instance.FileTemplate);
+ public static ChapterFileTemplate ChapterFile => _chapterFile ??= GetTemplate(Configuration.Instance.ChapterFileTemplate);
+ public static ChapterTitleTemplate ChapterTitle => _chapterTitle ??= GetTemplate(Configuration.Instance.ChapterTitleTemplate);
+
+ #region Template Parsing
+ public static T GetTemplate(string templateText) where T : Templates, ITemplate, new()
+ => TryGetTemplate(templateText, out var template) ? template : GetDefaultTemplate();
+
+ public static bool TryGetTemplate(string templateText, 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() where T : Templates, ITemplate, new()
+ => new() { Template = NamingTemplate.Parse(T.DefaultTemplate, T.TagClass) };
+
+ static Templates()
+ {
+ Configuration.Instance.PropertyChanged +=
+ [PropertyChangeFilter(nameof(Configuration.FolderTemplate))]
+ (_,e) => _folder = GetTemplate((string)e.NewValue);
+
+ Configuration.Instance.PropertyChanged
+ += [PropertyChangeFilter(nameof(Configuration.FileTemplate))]
+ (_, e) => _file = GetTemplate((string)e.NewValue);
+
+ Configuration.Instance.PropertyChanged
+ += [PropertyChangeFilter(nameof(Configuration.ChapterFileTemplate))]
+ (_, e) => _chapterFile = GetTemplate((string)e.NewValue);
+
+ Configuration.Instance.PropertyChanged
+ += [PropertyChangeFilter(nameof(Configuration.ChapterTitleTemplate))]
+ (_, e) => _chapterTitle = GetTemplate((string)e.NewValue);
+ }
+ #endregion
+
+ #region Template Properties
+ public IEnumerable TagsRegistered => Template.TagsRegistered.Cast();
+ public IEnumerable TagsInUse => Template.TagsInUse.Cast();
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 GetErrors(string template);
- public bool IsValid(string template) => !GetErrors(template).Any();
+ public virtual IEnumerable Errors => Template.Errors;
+ public bool IsValid => !Errors.Any();
- public abstract IEnumerable GetWarnings(string template);
- public bool HasWarnings(string template) => GetWarnings(template).Any();
+ public virtual IEnumerable 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 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 == 1, use:
- // .Count(t => template.Contains($"<{t.TagName}>"))
- // .Sum() impl: == 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
- ///
- /// EditTemplateDialog: Get template generated filename for portion of path
- ///
- 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(@"", RegexOptions.Compiled | RegexOptions.IgnoreCase);
- private static Regex dateAddedTagRegex { get; } = new Regex(@"", RegexOptions.Compiled | RegexOptions.IgnoreCase);
- private static Regex datePublishedTagRegex { get; } = new Regex(@"", RegexOptions.Compiled | RegexOptions.IgnoreCase);
- private static Regex ifSeriesRegex { get; } = new Regex("(.*?)<-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,replacements, returnFirstExisting, libraryBookDto);
+ }
+
+ public LongPath GetFilename(LibraryBookDto libraryBookDto, MultiConvertFileProperties multiChapProps, string baseDir, string fileExtension, ReplacementCharacters replacements = null, bool returnFirstExisting = false)
+ {
+ ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto));
+ ArgumentValidator.EnsureNotNull(multiChapProps, nameof(multiChapProps));
+ ArgumentValidator.EnsureNotNull(baseDir, nameof(baseDir));
+ ArgumentValidator.EnsureNotNull(fileExtension, nameof(fileExtension));
+
+ replacements ??= Configuration.Instance.ReplacementCharacters;
+ return GetFilename(baseDir, fileExtension, replacements, returnFirstExisting, libraryBookDto, multiChapProps);
+ }
+
+ protected virtual IEnumerable GetTemplatePartsStrings(List parts, ReplacementCharacters replacements)
+ => parts.Select(p => replacements.ReplaceFilenameChars(p.Value));
+
+ private LongPath GetFilename(string baseDir, string fileExtension, ReplacementCharacters replacements, bool returnFirstExisting, params object[] dtos)
+ {
+ fileExtension = FileUtility.GetStandardizedExtension(fileExtension);
+
+ var parts = 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
+ for (int i = 0; i < pathParts.Count; i++)
+ {
+ var part = pathParts[i];
+
+ //If file already exists, GetValidFilename will append " (n)" to the filename.
+ //This could cause the filename length to exceed MaxFilenameLength, so reduce
+ //allowable filename length by 5 chars, allowing for up to 99 duplicates.
+ var maxFilenameLength = LongPath.MaxFilenameLength -
+ (i < pathParts.Count - 1 || string.IsNullOrEmpty(fileExtension) ? 0 : fileExtension.Length + 5);
+
+ while (part.Sum(LongPath.GetFilesystemStringLength) > 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));
+ }
+ }
+ //Any
+ var fullPath = Path.Combine(pathParts.Select(fileParts => string.Join("", fileParts)).Prepend(baseDir).ToArray());
+
+ return FileUtility.GetValidFilename(fullPath, replacements, fileExtension, returnFirstExisting);
+ }
+
+ ///
+ /// Organize template parts into directories. Any Extra slashes will be
+ /// returned as empty directories and are taken care of by Path.Combine()
+ ///
+ /// A List of template directories. Each directory is a list of template part strings
+ private List> GetPathParts(IEnumerable templateParts)
+ {
+ List> directories = new();
+ List dir = new();
+
+ foreach (var part in templateParts)
+ {
+ int slashIndex, lastIndex = 0;
+ while((slashIndex = part.IndexOf(Path.DirectorySeparatorChar, lastIndex)) > -1)
+ {
+ dir.Add(part[lastIndex..slashIndex]);
+ 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 filePropertyTags = GetFilePropertyTags();
+ private static readonly ConditionalTagClass conditionalTags = GetConditionalTags();
+ private static readonly List chapterPropertyTags = GetChapterPropertyTags();
+
+ private static ConditionalTagClass GetConditionalTags()
+ {
+ ConditionalTagClass lbConditions = new();
+
+ lbConditions.RegisterCondition(TemplateTags.IfSeries, lb => lb.IsSeries);
+ lbConditions.RegisterCondition(TemplateTags.IfPodcast, lb => lb.IsPodcast);
+ lbConditions.RegisterCondition(TemplateTags.IfBookseries, lb => lb.IsSeries && !lb.IsPodcast);
+
+ return lbConditions;
+ }
+
+ private static PropertyTagClass GetFilePropertyTags()
+ {
+ PropertyTagClass lbProperties = new();
+ lbProperties.RegisterProperty(TemplateTags.Id, lb => lb.AudibleProductId);
+ lbProperties.RegisterProperty(TemplateTags.Title, lb => lb.Title, StringFormatter);
+ lbProperties.RegisterProperty(TemplateTags.TitleShort, lb => lb.Title.IndexOf(':') < 1 ? lb.Title : lb.Title.Substring(0, lb.Title.IndexOf(':')), StringFormatter);
+ lbProperties.RegisterProperty(TemplateTags.Author, lb => lb.AuthorNames, StringFormatter);
+ lbProperties.RegisterProperty(TemplateTags.FirstAuthor, lb => lb.FirstAuthor, StringFormatter);
+ lbProperties.RegisterProperty(TemplateTags.Narrator, lb => lb.NarratorNames, StringFormatter);
+ lbProperties.RegisterProperty(TemplateTags.FirstNarrator, lb => lb.FirstNarrator, StringFormatter);
+ lbProperties.RegisterProperty(TemplateTags.Series, lb => lb.SeriesName, StringFormatter);
+ lbProperties.RegisterProperty(TemplateTags.SeriesNumber, lb => lb.SeriesNumber, IntegerFormatter);
+ lbProperties.RegisterProperty(TemplateTags.Language, lb => lb.Language, StringFormatter);
+ lbProperties.RegisterProperty(TemplateTags.LanguageShort, lb => getLanguageShort(lb.Language), StringFormatter);
+ lbProperties.RegisterProperty(TemplateTags.Bitrate, lb => lb.BitRate, IntegerFormatter);
+ lbProperties.RegisterProperty(TemplateTags.SampleRate, lb => lb.SampleRate, IntegerFormatter);
+ lbProperties.RegisterProperty(TemplateTags.Channels, lb => lb.Channels, IntegerFormatter);
+ lbProperties.RegisterProperty(TemplateTags.Account, lb => lb.Account, StringFormatter);
+ lbProperties.RegisterProperty(TemplateTags.Locale, lb => lb.Locale, StringFormatter);
+ lbProperties.RegisterProperty(TemplateTags.YearPublished, lb => lb.YearPublished, IntegerFormatter);
+ lbProperties.RegisterProperty(TemplateTags.DatePublished, lb => lb.DatePublished, DateTimeFormatter);
+ lbProperties.RegisterProperty(TemplateTags.DateAdded, lb => lb.DateAdded, DateTimeFormatter);
+ lbProperties.RegisterProperty(TemplateTags.FileDate, lb => lb.FileDate, DateTimeFormatter);
+ return lbProperties;
+ }
+
+ private static List GetChapterPropertyTags()
+ {
+ PropertyTagClass lbProperties = new();
+ PropertyTagClass multiConvertProperties = new();
+
+ lbProperties.RegisterProperty(TemplateTags.Title, lb => lb.Title, StringFormatter);
+ lbProperties.RegisterProperty(TemplateTags.TitleShort, lb => lb?.Title?.IndexOf(':') > 0 ? lb.Title.Substring(0, lb.Title.IndexOf(':')) : lb.Title, StringFormatter);
+ lbProperties.RegisterProperty(TemplateTags.Series, lb => lb.SeriesName, StringFormatter);
+
+ multiConvertProperties.RegisterProperty(TemplateTags.ChCount, lb => lb.PartsTotal, IntegerFormatter);
+ multiConvertProperties.RegisterProperty(TemplateTags.ChNumber, lb => lb.PartsPosition, IntegerFormatter);
+ multiConvertProperties.RegisterProperty(TemplateTags.ChNumber0, m => m.PartsPosition.ToString("D" + ((int)Math.Log10(m.PartsTotal) + 1)));
+ multiConvertProperties.RegisterProperty(TemplateTags.ChTitle, m => m.Title, StringFormatter);
+ multiConvertProperties.RegisterProperty(TemplateTags.FileDate, lb => lb.FileDate, DateTimeFormatter);
+
+ return new List { lbProperties, multiConvertProperties };
+ }
+
+ #endregion
+
+ #region Tag Formatters
private static string getLanguageShort(string language)
{
@@ -123,330 +264,85 @@ 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 and <-if series> tags and everything in between
- // for series, remove 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
-
- /// 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.
- /// A list of parameter replacement key-value pairs
- private static List> getSanitizeDateReplacementParameters(Regex datePattern, ref string template, ReplacementCharacters replacements, DateTime? dateTime)
- {
- List> 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;
- }
-
- /// a date parameter replacement tag with the format string sanitized
- 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 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; } = " []";
- protected override bool IsChapterized { get; } = false;
+ public static string DefaultTemplate { get; } = " []";
+ public static IEnumerable TagClass => new TagClass[] { filePropertyTags, conditionalTags };
- internal FolderTemplate() : base() { }
+ public override IEnumerable Errors
+ => TemplateText?.Length >= 2 && Path.IsPathFullyQualified(TemplateText) ? base.Errors.Append(ERROR_FULL_PATH_IS_INVALID) : base.Errors;
- #region validation
- public override IEnumerable GetErrors(string template)
+ protected override List GetTemplatePartsStrings(List parts, ReplacementCharacters replacements)
{
- // null is invalid. whitespace is valid but not recommended
- if (template is null)
- return new[] { ERROR_NULL_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 (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;
+ 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);
+ }
+ return parts.Select(p => p.Value).ToList();
}
-
- public override IEnumerable GetWarnings(string template) => GetStandardWarnings(template);
- #endregion
-
- #region to file name
- /// USES LIVE CONFIGURATION VALUES
- 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; } = " []";
- protected override bool IsChapterized { get; } = false;
-
- internal FileTemplate() : base() { }
-
- #region validation
- public override IEnumerable GetErrors(string template) => GetFileErrors(template);
-
- public override IEnumerable GetWarnings(string template) => GetStandardWarnings(template);
- #endregion
-
- #region to file name
- /// USES LIVE CONFIGURATION VALUES
- 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; } = " []";
+ public static IEnumerable 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; } = " [] - - ";
- protected override bool IsChapterized { get; } = true;
+ public static string DefaultTemplate { get; } = " [] - - ";
+ public static IEnumerable TagClass { get; }
+ = chapterPropertyTags.Append(filePropertyTags).Append(conditionalTags);
- internal ChapterFileTemplate() : base() { }
-
- #region validation
- public override IEnumerable GetErrors(string template) => GetFileErrors(template);
-
- public override IEnumerable GetWarnings(string template)
- {
- var warnings = GetStandardWarnings(template).ToList();
- if (template is null)
- return warnings;
-
- // recommended to incl. or
- 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
- /// USES LIVE CONFIGURATION VALUES
- public string GetFilename(LibraryBookDto libraryBookDto, AaxDecrypter.MultiConvertFileProperties props)
- => GetPortionFilename(libraryBookDto, Configuration.Instance.ChapterFileTemplate, props, AudibleFileStorage.DecryptInProgressDirectory);
-
- public string GetPortionFilename(LibraryBookDto libraryBookDto, string template, AaxDecrypter.MultiConvertFileProperties props, string fullDirPath, ReplacementCharacters replacements = null)
- {
- if (string.IsNullOrWhiteSpace(template)) return string.Empty;
-
- replacements ??= Configuration.Instance.ReplacementCharacters;
- var fileExtension = Path.GetExtension(props.OutputFileName);
- var fileNamingTemplate = getFileNamingTemplate(libraryBookDto, template, fullDirPath, fileExtension, replacements);
-
- fileNamingTemplate.AddParameterReplacement(TemplateTags.ChCount, props.PartsTotal);
- fileNamingTemplate.AddParameterReplacement(TemplateTags.ChNumber, props.PartsPosition);
- fileNamingTemplate.AddParameterReplacement(TemplateTags.ChNumber0, FileUtility.GetSequenceFormatted(props.PartsPosition, props.PartsTotal));
- fileNamingTemplate.AddParameterReplacement(TemplateTags.ChTitle, props.Title ?? "");
-
- foreach (Match dateTag in fileDateTagRegex.Matches(fileNamingTemplate.Template))
- {
- var sanitizedTag = sanitizeDateParameterTag(dateTag, replacements, out string sanitizedFormatter);
- if (tryFormatDateTime(props.FileDate, sanitizedFormatter, replacements, out var formattedDateString))
- fileNamingTemplate.ParameterReplacements[sanitizedTag] = formattedDateString;
- }
-
- return fileNamingTemplate.GetFilePath(fileExtension).PathWithoutPrefix;
- }
- #endregion
+ public override IEnumerable Warnings
+ => Template.TagsInUse.Any(t => t.TagName.In(TemplateTags.ChNumber.TagName, TemplateTags.ChNumber0.TagName))
+ ? base.Warnings
+ : base.Warnings.Append(WARNING_NO_CHAPTER_NUMBER_TAG);
}
- public class ChapterTitleTemplate : Templates
+ public class ChapterTitleTemplate : Templates, ITemplate
{
- private List _templateTags { 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 => " - : ";
+ public static IEnumerable TagClass { get; }
+ = chapterPropertyTags.Append(conditionalTags);
- public override string DefaultTemplate => " - : ";
-
- protected override bool IsChapterized => true;
-
- public override IEnumerable GetErrors(string template)
- => new List();
-
- public override IEnumerable 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 GetTemplateTags() => _templateTags;
+ protected override IEnumerable GetTemplatePartsStrings(List parts, ReplacementCharacters replacements)
+ => parts.Select(p => p.Value);
}
}
}
diff --git a/Source/LibationFileManager/UtilityExtensions.cs b/Source/LibationFileManager/UtilityExtensions.cs
deleted file mode 100644
index 8ffe8c9e..00000000
--- a/Source/LibationFileManager/UtilityExtensions.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using FileManager;
-
-namespace LibationFileManager
-{
- public static class UtilityExtensions
- {
- public static void AddParameterReplacement(this NamingTemplate fileNamingTemplate, TemplateTags templateTags, object value)
- => fileNamingTemplate.AddParameterReplacement(templateTags.TagName, value);
-
- public static void AddUniqueParameterReplacement(this NamingTemplate namingTemplate, string key, object value)
- => namingTemplate.ParameterReplacements[key] = value;
- }
-}
diff --git a/Source/LibationWinForms/Dialogs/EditTemplateDialog.cs b/Source/LibationWinForms/Dialogs/EditTemplateDialog.cs
index 55679ffc..12f42c6a 100644
--- a/Source/LibationWinForms/Dialogs/EditTemplateDialog.cs
+++ b/Source/LibationWinForms/Dialogs/EditTemplateDialog.cs
@@ -1,5 +1,4 @@
using System;
-using System.Collections.Generic;
using System.Linq;
using System.IO;
using System.Windows.Forms;
@@ -10,33 +9,19 @@ namespace LibationWinForms.Dialogs
{
public partial class EditTemplateDialog : Form
{
- // final value. post-validity check
- public string TemplateText { get; private set; }
-
- // hold the work-in-progress value. not guaranteed to be valid
- private string _workingTemplateText;
- private string workingTemplateText
- {
- get => _workingTemplateText;
- set => _workingTemplateText = template.Sanitize(value, Configuration.Instance.ReplacementCharacters);
- }
-
- private void resetTextBox(string value) => this.templateTb.Text = workingTemplateText = value;
-
+ private void resetTextBox(string value) => this.templateTb.Text = value;
private Configuration config { get; } = Configuration.Instance;
-
- private Templates template { get; }
- private string inputTemplateText { get; }
+ private ITemplateEditor templateEditor { get;}
public EditTemplateDialog()
{
InitializeComponent();
this.SetLibationIcon();
}
- public EditTemplateDialog(Templates template, string inputTemplateText) : this()
+
+ public EditTemplateDialog(ITemplateEditor templateEditor) : this()
{
- this.template = ArgumentValidator.EnsureNotNull(template, nameof(template));
- this.inputTemplateText = inputTemplateText ?? "";
+ this.templateEditor = ArgumentValidator.EnsureNotNull(templateEditor, nameof(templateEditor));
}
private void EditTemplateDialog_Load(object sender, EventArgs e)
@@ -44,89 +29,31 @@ namespace LibationWinForms.Dialogs
if (this.DesignMode)
return;
- if (template is null)
+ if (templateEditor is null)
{
- MessageBoxLib.ShowAdminAlert(this, $"Programming error. {nameof(EditTemplateDialog)} was not created correctly", "Edit template error", new NullReferenceException($"{nameof(template)} is null"));
+ MessageBoxLib.ShowAdminAlert(this, $"Programming error. {nameof(EditTemplateDialog)} was not created correctly", "Edit template error", new NullReferenceException($"{nameof(templateEditor)} is null"));
return;
}
warningsLbl.Text = "";
- this.Text = $"Edit {template.Name}";
+ this.Text = $"Edit {templateEditor.EditingTemplate.Name}";
- this.templateLbl.Text = template.Description;
- resetTextBox(inputTemplateText);
+ this.templateLbl.Text = templateEditor.EditingTemplate.Description;
+ resetTextBox(templateEditor.EditingTemplate.TemplateText);
// populate list view
- foreach (var tag in template.GetTemplateTags())
- listView1.Items.Add(new ListViewItem(new[] { $"<{tag.TagName}>", tag.Description }) { Tag = tag.DefaultValue });
+ foreach (TemplateTags tag in templateEditor.EditingTemplate.TagsRegistered)
+ listView1.Items.Add(new ListViewItem(new[] { tag.Display, tag.Description }) { Tag = tag.DefaultValue });
+
+ listView1.AutoResizeColumns(ColumnHeaderAutoResizeStyle.ColumnContent);
}
- private void resetToDefaultBtn_Click(object sender, EventArgs e) => resetTextBox(template.DefaultTemplate);
+ private void resetToDefaultBtn_Click(object sender, EventArgs e) => resetTextBox(templateEditor.DefaultTemplate);
private void templateTb_TextChanged(object sender, EventArgs e)
{
- workingTemplateText = templateTb.Text;
- var isChapterTitle = template == Templates.ChapterTitle;
- var isFolder = template == Templates.Folder;
-
- var libraryBookDto = new LibraryBookDto
- {
- Account = "my account",
- DateAdded = new DateTime(2022, 6, 9, 0, 0, 0),
- DatePublished = new DateTime(2017, 2, 27, 0, 0, 0),
- AudibleProductId = "123456789",
- Title = "A Study in Scarlet: A Sherlock Holmes Novel",
- Locale = "us",
- YearPublished = 2017,
- Authors = new List { "Arthur Conan Doyle", "Stephen Fry - introductions" },
- Narrators = new List { "Stephen Fry" },
- SeriesName = "Sherlock Holmes",
- SeriesNumber = "1",
- BitRate = 128,
- SampleRate = 44100,
- Channels = 2,
- Language = "English"
- };
- var chapterName = "A Flight for Life";
- var chapterNumber = 4;
- var chaptersTotal = 10;
-
- var partFileProperties = new AaxDecrypter.MultiConvertFileProperties()
- {
- OutputFileName = "",
- PartsPosition = chapterNumber,
- PartsTotal = chaptersTotal,
- Title = chapterName
- };
-
-
- /*
- * Path must be rooted for windows to allow long file paths. This is
- * only necessary for folder templates because they may contain several
- * subdirectories. Without rooting, we won't be allowed to create a
- * relative path longer than MAX_PATH.
- */
- var books = config.Books;
- var folder = Templates.Folder.GetPortionFilename(
- libraryBookDto,
- Path.Combine(books, isFolder ? workingTemplateText : config.FolderTemplate), "");
-
- folder = Path.GetRelativePath(books, folder);
-
- var file
- = template == Templates.ChapterFile
- ? Templates.ChapterFile.GetPortionFilename(
- libraryBookDto,
- workingTemplateText,
- partFileProperties,
- "")
- : Templates.File.GetPortionFilename(
- libraryBookDto,
- isFolder ? config.FileTemplate : workingTemplateText, "");
- var ext = config.DecryptToLossy ? "mp3" : "m4b";
-
- var chapterTitle = Templates.ChapterTitle.GetPortionTitle(libraryBookDto, workingTemplateText, partFileProperties);
+ templateEditor.SetTemplateText(templateTb.Text);
const char ZERO_WIDTH_SPACE = '\u200B';
var sing = $"{Path.DirectorySeparatorChar}";
@@ -139,11 +66,12 @@ namespace LibationWinForms.Dialogs
string slashWrap(string val) => val.Replace(sing, $"{ZERO_WIDTH_SPACE}{sing}");
warningsLbl.Text
- = !template.HasWarnings(workingTemplateText)
+ = !templateEditor.EditingTemplate.HasWarnings
? ""
: "Warning:\r\n" +
- template
- .GetWarnings(workingTemplateText)
+ templateEditor
+ .EditingTemplate
+ .Warnings
.Select(err => $"- {err}")
.Aggregate((a, b) => $"{a}\r\n{b}");
@@ -153,51 +81,52 @@ namespace LibationWinForms.Dialogs
richTextBox1.Clear();
richTextBox1.SelectionFont = reg;
- if (isChapterTitle)
+ if (!templateEditor.IsFilePath)
{
richTextBox1.SelectionFont = bold;
- richTextBox1.AppendText(chapterTitle);
+ richTextBox1.AppendText(templateEditor.GetName());
return;
}
- richTextBox1.AppendText(slashWrap(books));
+ var folder = templateEditor.GetFolderName();
+ var file = templateEditor.GetFileName();
+ var ext = config.DecryptToLossy ? "mp3" : "m4b";
+
+ richTextBox1.AppendText(slashWrap(templateEditor.BaseDirectory.PathWithoutPrefix));
richTextBox1.AppendText(sing);
- if (isFolder)
+ if (templateEditor.IsFolder)
richTextBox1.SelectionFont = bold;
richTextBox1.AppendText(slashWrap(folder));
- if (isFolder)
+ if (templateEditor.IsFolder)
richTextBox1.SelectionFont = reg;
richTextBox1.AppendText(sing);
- if (!isFolder)
+ if (templateEditor.IsFilePath && !templateEditor.IsFolder)
richTextBox1.SelectionFont = bold;
richTextBox1.AppendText(file);
- if (!isFolder)
- richTextBox1.SelectionFont = reg;
-
+ richTextBox1.SelectionFont = reg;
richTextBox1.AppendText($".{ext}");
}
private void saveBtn_Click(object sender, EventArgs e)
{
- if (!template.IsValid(workingTemplateText))
+ if (!templateEditor.EditingTemplate.IsValid)
{
- var errors = template
- .GetErrors(workingTemplateText)
+ var errors = templateEditor
+ .EditingTemplate
+ .Errors
.Select(err => $"- {err}")
.Aggregate((a, b) => $"{a}\r\n{b}");
MessageBox.Show($"This template text is not valid. Errors:\r\n{errors}", "Invalid", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
- TemplateText = workingTemplateText;
-
this.DialogResult = DialogResult.OK;
this.Close();
}
diff --git a/Source/LibationWinForms/Dialogs/SettingsDialog.AudioSettings.cs b/Source/LibationWinForms/Dialogs/SettingsDialog.AudioSettings.cs
index d26c7448..0647cc2b 100644
--- a/Source/LibationWinForms/Dialogs/SettingsDialog.AudioSettings.cs
+++ b/Source/LibationWinForms/Dialogs/SettingsDialog.AudioSettings.cs
@@ -106,7 +106,8 @@ namespace LibationWinForms.Dialogs
chapterTitleTemplateGb.Enabled = splitFilesByChapterCbox.Checked;
}
- private void chapterTitleTemplateBtn_Click(object sender, EventArgs e) => editTemplate(Templates.ChapterTitle, chapterTitleTemplateTb);
+ private void chapterTitleTemplateBtn_Click(object sender, EventArgs e)
+ => editTemplate(TemplateEditor.CreateNameEditor(chapterTitleTemplateTb.Text), chapterTitleTemplateTb);
private void convertFormatRb_CheckedChanged(object sender, EventArgs e)
{
diff --git a/Source/LibationWinForms/Dialogs/SettingsDialog.DownloadDecrypt.cs b/Source/LibationWinForms/Dialogs/SettingsDialog.DownloadDecrypt.cs
index d6d70113..f3a92ac3 100644
--- a/Source/LibationWinForms/Dialogs/SettingsDialog.DownloadDecrypt.cs
+++ b/Source/LibationWinForms/Dialogs/SettingsDialog.DownloadDecrypt.cs
@@ -7,10 +7,12 @@ namespace LibationWinForms.Dialogs
{
public partial class SettingsDialog
{
- private void folderTemplateBtn_Click(object sender, EventArgs e) => editTemplate(Templates.Folder, folderTemplateTb);
- private void fileTemplateBtn_Click(object sender, EventArgs e) => editTemplate(Templates.File, fileTemplateTb);
- private void chapterFileTemplateBtn_Click(object sender, EventArgs e) => editTemplate(Templates.ChapterFile, chapterFileTemplateTb);
-
+ private void folderTemplateBtn_Click(object sender, EventArgs e)
+ => editTemplate(TemplateEditor.CreateFilenameEditor(config.Books, folderTemplateTb.Text), folderTemplateTb);
+ private void fileTemplateBtn_Click(object sender, EventArgs e)
+ => editTemplate(TemplateEditor.CreateFilenameEditor(config.Books, fileTemplateTb.Text), fileTemplateTb);
+ private void chapterFileTemplateBtn_Click(object sender, EventArgs e)
+ => editTemplate(TemplateEditor.CreateFilenameEditor(config.Books, chapterFileTemplateTb.Text), chapterFileTemplateTb);
private void editCharreplacementBtn_Click(object sender, EventArgs e)
{
diff --git a/Source/LibationWinForms/Dialogs/SettingsDialog.Important.cs b/Source/LibationWinForms/Dialogs/SettingsDialog.Important.cs
index a2379c70..1718ad34 100644
--- a/Source/LibationWinForms/Dialogs/SettingsDialog.Important.cs
+++ b/Source/LibationWinForms/Dialogs/SettingsDialog.Important.cs
@@ -56,23 +56,6 @@ namespace LibationWinForms.Dialogs
validationError("Cannot set Books Location to blank", "Location is blank");
return;
}
-
- // these 3 should do nothing. Configuration will only init these with a valid value. EditTemplateDialog ensures valid before returning
- if (!Templates.Folder.IsValid(folderTemplateTb.Text))
- {
- validationError($"Not saving change to folder naming template. Invalid format.", "Invalid folder template");
- return;
- }
- if (!Templates.File.IsValid(fileTemplateTb.Text))
- {
- validationError($"Not saving change to file naming template. Invalid format.", "Invalid file template");
- return;
- }
- if (!Templates.ChapterFile.IsValid(chapterFileTemplateTb.Text))
- {
- validationError($"Not saving change to chapter file naming template. Invalid format.", "Invalid chapter file template");
- return;
- }
#endregion
LongPath lonNewBooks = newBooks;
diff --git a/Source/LibationWinForms/Dialogs/SettingsDialog.cs b/Source/LibationWinForms/Dialogs/SettingsDialog.cs
index 7447ea22..17ac5d4e 100644
--- a/Source/LibationWinForms/Dialogs/SettingsDialog.cs
+++ b/Source/LibationWinForms/Dialogs/SettingsDialog.cs
@@ -27,11 +27,11 @@ namespace LibationWinForms.Dialogs
Load_AudioSettings(config);
}
- private static void editTemplate(Templates template, TextBox textBox)
+ private static void editTemplate(ITemplateEditor template, TextBox textBox)
{
- var form = new EditTemplateDialog(template, textBox.Text);
+ var form = new EditTemplateDialog(template);
if (form.ShowDialog() == DialogResult.OK)
- textBox.Text = form.TemplateText;
+ textBox.Text = template.EditingTemplate.TemplateText;
}
private void saveBtn_Click(object sender, EventArgs e)
diff --git a/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs b/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs
index b1822357..90a04e15 100644
--- a/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs
+++ b/Source/_Tests/FileManager.Tests/FileNamingTemplateTests.cs
@@ -1,82 +1,185 @@
-using System;
-using System.Collections.Generic;
-using System.IO;
-using System.Linq;
-using Dinah.Core;
-using FileManager;
+using System.Linq;
+using FileManager.NamingTemplate;
using FluentAssertions;
using Microsoft.VisualStudio.TestTools.UnitTesting;
-namespace FileNamingTemplateTests
+namespace NamingTemplateTests
{
- [TestClass]
- public class GetFilePath
+ class TemplateTag : ITemplateTag
{
- static ReplacementCharacters Replacements = ReplacementCharacters.Default;
+ public string TagName { get; init; }
+ }
- [TestMethod]
- [DataRow(@"C:\foo\bar", @"C:\foo\bar\my꞉ book 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 [ID123456].txt", PlatformID.Win32NT)]
- [DataRow(@"/foo/bar", @"/foo/bar/my: book 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 [ID123456].txt", PlatformID.Unix)]
- public void equiv_GetValidFilename(string dirFullPath, string expected, PlatformID platformID)
+ class PropertyClass1
+ {
+ public string Item1 { get; set; }
+ public string Item2 { get; set; }
+ public string Item3 { get; set; }
+ public int Int1 { get; set; }
+ public bool Condition { get; set; }
+ }
+
+ class PropertyClass2
+ {
+ public string Item1 { get; set; }
+ public string Item2 { get; set; }
+ public string Item3 { get; set; }
+ public string Item4 { get; set; }
+ public bool Condition { get; set; }
+ }
+ class PropertyClass3
+ {
+ public string Item1 { get; set; }
+ public string Item2 { get; set; }
+ public string Item3 { get; set; }
+ public string Item4 { get; set; }
+ public int? Int2 { get; set; }
+ public bool Condition { get; set; }
+ }
+
+
+ [TestClass]
+ public class GetPortionFilename
+ {
+ PropertyTagClass props1 = new();
+ PropertyTagClass props2 = new();
+ PropertyTagClass props3 = new();
+ ConditionalTagClass conditional1 = new();
+ ConditionalTagClass