From d08962cffafbef18449ffdc5515176b62987f084 Mon Sep 17 00:00:00 2001 From: Robert McRackan Date: Mon, 18 Oct 2021 13:36:55 -0400 Subject: [PATCH] Refactor valid path/filename. Centralize validaion. Universal templating is one step closer --- AaxDecrypter/AaxDecrypter.csproj | 2 +- AaxDecrypter/AudiobookDownloadBase.cs | 4 +- .../UnencryptedAudiobookDownloader.cs | 8 +- AppScaffolding/AppScaffolding.csproj | 2 +- DataLayer/DataLayer.csproj | 2 +- FileLiberator/ConvertToMp3.cs | 4 +- FileLiberator/DownloadDecryptBook.cs | 2 +- FileLiberator/DownloadPdf.cs | 7 +- FileManager.Tests/FileManager.Tests.csproj | 1 + FileManager.Tests/FileTemplateTests.cs | 108 ++++++++++++ FileManager.Tests/FileUtilityTests.cs | 123 ++++++++++++- FileManager/FileManager.csproj | 2 +- FileManager/FileTemplate.cs | 52 ++++++ FileManager/FileUtility.cs | 163 ++++++++++++------ InternalUtilities/InternalUtilities.csproj | 2 +- LibationFileManager/AudibleFileStorage.cs | 1 - .../LibationFileManager.csproj | 4 + LibationWinForms/LibationWinForms.csproj | 2 +- 18 files changed, 415 insertions(+), 74 deletions(-) create mode 100644 FileManager.Tests/FileTemplateTests.cs create mode 100644 FileManager/FileTemplate.cs diff --git a/AaxDecrypter/AaxDecrypter.csproj b/AaxDecrypter/AaxDecrypter.csproj index 7629f675..f1d5f136 100644 --- a/AaxDecrypter/AaxDecrypter.csproj +++ b/AaxDecrypter/AaxDecrypter.csproj @@ -6,7 +6,7 @@ - + diff --git a/AaxDecrypter/AudiobookDownloadBase.cs b/AaxDecrypter/AudiobookDownloadBase.cs index 3c2e638b..3a2df566 100644 --- a/AaxDecrypter/AudiobookDownloadBase.cs +++ b/AaxDecrypter/AudiobookDownloadBase.cs @@ -32,7 +32,7 @@ namespace AaxDecrypter private NetworkFileStreamPersister nfsPersister; private string jsonDownloadState => Path.Combine(CacheDir, Path.GetFileNameWithoutExtension(OutputFileName) + ".json"); - private string tempFile => PathLib.ReplaceExtension(jsonDownloadState, ".tmp"); + private string tempFile => Path.ChangeExtension(jsonDownloadState, ".tmp"); public AudiobookDownloadBase(string outFileName, string cacheDirectory, DownloadLicense dlLic) { @@ -102,7 +102,7 @@ namespace AaxDecrypter // not a critical step. its failure should not prevent future steps from running try { - var path = PathLib.ReplaceExtension(OutputFileName, ".cue"); + var path = Path.ChangeExtension(OutputFileName, ".cue"); path = FileUtility.GetValidFilename(path); File.WriteAllText(path, Cue.CreateContents(Path.GetFileName(OutputFileName), DownloadLicense.ChapterInfo)); OnFileCreated(path); diff --git a/AaxDecrypter/UnencryptedAudiobookDownloader.cs b/AaxDecrypter/UnencryptedAudiobookDownloader.cs index 19fee327..55c7626c 100644 --- a/AaxDecrypter/UnencryptedAudiobookDownloader.cs +++ b/AaxDecrypter/UnencryptedAudiobookDownloader.cs @@ -19,8 +19,8 @@ namespace AaxDecrypter ["Step 1: Get Mp3 Metadata"] = Step1_GetMetadata, ["Step 2: Download Audiobook"] = Step2_DownloadAudiobookAsSingleFile, - ["Step 3: Create Cue"] = Step3_CreateCue, - ["Step 4: Cleanup"] = Step4_Cleanup, + ["Step 3: Create Cue"] = Step_CreateCue, + ["Step 4: Cleanup"] = Step_Cleanup, }; } @@ -66,9 +66,9 @@ namespace AaxDecrypter CloseInputFileStream(); - var realOutputFileName = FileUtility.Move(InputFileStream.SaveFilePath, OutputFileName); + var realOutputFileName = FileUtility.SaferMoveToValidPath(InputFileStream.SaveFilePath, OutputFileName); SetOutputFileName(realOutputFileName); - OnFileCreated(OutputFileName); + OnFileCreated(realOutputFileName); return !IsCanceled; } diff --git a/AppScaffolding/AppScaffolding.csproj b/AppScaffolding/AppScaffolding.csproj index 981b7c52..f7fbc3f1 100644 --- a/AppScaffolding/AppScaffolding.csproj +++ b/AppScaffolding/AppScaffolding.csproj @@ -3,7 +3,7 @@ net5.0 - 6.2.6.8 + 6.2.6.41 diff --git a/DataLayer/DataLayer.csproj b/DataLayer/DataLayer.csproj index 3250720f..41399a80 100644 --- a/DataLayer/DataLayer.csproj +++ b/DataLayer/DataLayer.csproj @@ -12,7 +12,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/FileLiberator/ConvertToMp3.cs b/FileLiberator/ConvertToMp3.cs index 42dc1570..106d4072 100644 --- a/FileLiberator/ConvertToMp3.cs +++ b/FileLiberator/ConvertToMp3.cs @@ -16,7 +16,7 @@ namespace FileLiberator private Mp4File m4bBook; private long fileSize; - private static string Mp3FileName(string m4bPath) => m4bPath is null ? string.Empty : PathLib.ReplaceExtension(m4bPath, ".mp3"); + private static string Mp3FileName(string m4bPath) => Path.ChangeExtension(m4bPath ?? "", ".mp3"); public override void Cancel() => m4bBook?.Cancel(); @@ -52,7 +52,7 @@ namespace FileLiberator mp3File.Close(); var proposedMp3Path = Mp3FileName(m4bPath); - var realMp3Path = FileUtility.Move(mp3File.Name, proposedMp3Path); + var realMp3Path = FileUtility.SaferMoveToValidPath(mp3File.Name, proposedMp3Path); OnFileCreated(libraryBook, realMp3Path); var statusHandler = new StatusHandler(); diff --git a/FileLiberator/DownloadDecryptBook.cs b/FileLiberator/DownloadDecryptBook.cs index 3262919a..74f904ba 100644 --- a/FileLiberator/DownloadDecryptBook.cs +++ b/FileLiberator/DownloadDecryptBook.cs @@ -198,7 +198,7 @@ namespace FileLiberator { var entry = entries[i]; - var realDest = FileUtility.Move(entry.Path, Path.Combine(destinationDir, Path.GetFileName(entry.Path))); + var realDest = FileUtility.SaferMoveToValidPath(entry.Path, Path.Combine(destinationDir, Path.GetFileName(entry.Path))); FilePathCache.Insert(book.AudibleProductId, realDest); // propogate corrected path. Must update cache with corrected path. Also want updated path for cue file (after this for-loop) diff --git a/FileLiberator/DownloadPdf.cs b/FileLiberator/DownloadPdf.cs index fe27d5ba..9640a6b3 100644 --- a/FileLiberator/DownloadPdf.cs +++ b/FileLiberator/DownloadPdf.cs @@ -47,12 +47,7 @@ namespace FileLiberator if (existingPath != null) return Path.Combine(existingPath, Path.GetFileName(file)); - var full = FileUtility.GetValidFilename( - AudibleFileStorage.PdfDirectory, - libraryBook.Book.Title, - Path.GetExtension(file), - libraryBook.Book.AudibleProductId); - return full; + return FileUtility.GetValidFilename(AudibleFileStorage.BooksDirectory, libraryBook.Book.Title, Path.GetExtension(file), libraryBook.Book.AudibleProductId); } private static string getdownloadUrl(LibraryBook libraryBook) diff --git a/FileManager.Tests/FileManager.Tests.csproj b/FileManager.Tests/FileManager.Tests.csproj index 2ae7e2bc..ec4c77ee 100644 --- a/FileManager.Tests/FileManager.Tests.csproj +++ b/FileManager.Tests/FileManager.Tests.csproj @@ -7,6 +7,7 @@ + diff --git a/FileManager.Tests/FileTemplateTests.cs b/FileManager.Tests/FileTemplateTests.cs new file mode 100644 index 00000000..061b50c0 --- /dev/null +++ b/FileManager.Tests/FileTemplateTests.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Dinah.Core; +using FileManager; +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace FileTemplateTests +{ + [TestClass] + public class GetFilename + { + [TestMethod] + public void equiv_GetValidFilename() + { + var expected = @"C:\foo\bar\my_ book LONG_1234567890_1234567890_1234567890_123 [ID123456].txt"; + var f1 = OLD_GetValidFilename(@"C:\foo\bar", "my: book LONG_1234567890_1234567890_1234567890_12345", "txt", "ID123456"); + var f2 = NEW_GetValidFilename_FileTemplate(@"C:\foo\bar", "my: book LONG_1234567890_1234567890_1234567890_12345", "txt", "ID123456"); + + f1.Should().Be(expected); + f1.Should().Be(f2); + } + private static string OLD_GetValidFilename(string dirFullPath, string filename, string extension, string metadataSuffix) + { + ArgumentValidator.EnsureNotNullOrWhiteSpace(dirFullPath, nameof(dirFullPath)); + + filename ??= ""; + + // sanitize. omit invalid characters. exception: colon => underscore + filename = filename.Replace(":", "_"); + filename = FileUtility.GetSafeFileName(filename); + + if (filename.Length > 50) + filename = filename.Substring(0, 50); + + if (!string.IsNullOrWhiteSpace(metadataSuffix)) + filename += $" [{metadataSuffix}]"; + + // extension is null when this method is used for directory names + extension = FileUtility.GetStandardizedExtension(extension); + + // ensure uniqueness + var fullfilename = Path.Combine(dirFullPath, filename + extension); + var i = 0; + while (File.Exists(fullfilename)) + fullfilename = Path.Combine(dirFullPath, filename + $" ({++i})" + extension); + + return fullfilename; + } + private static string NEW_GetValidFilename_FileTemplate(string dirFullPath, string filename, string extension, string metadataSuffix) + { + var template = $" [<id>]"; + + var fullfilename = Path.Combine(dirFullPath, template + FileUtility.GetStandardizedExtension(extension)); + + var fileTemplate = new FileTemplate(fullfilename) { IllegalCharacterReplacements = "_" }; + fileTemplate.AddParameterReplacement("title", filename); + fileTemplate.AddParameterReplacement("id", metadataSuffix); + return fileTemplate.GetFilename(); + } + + [TestMethod] + public void equiv_GetMultipartFileName() + { + var expected = @"C:\foo\bar\my file - 002 - title.txt"; + var f1 = OLD_GetMultipartFileName(@"C:\foo\bar\my file.txt", 2, 100, "title"); + var f2 = NEW_GetMultipartFileName_FileTemplate(@"C:\foo\bar\my file.txt", 2, 100, "title"); + + f1.Should().Be(expected); + f1.Should().Be(f2); + } + private static string OLD_GetMultipartFileName(string originalPath, int partsPosition, int partsTotal, string suffix) + { + // 1-9 => 1-9 + // 10-99 => 01-99 + // 100-999 => 001-999 + var chapterCountLeadingZeros = partsPosition.ToString().PadLeft(partsTotal.ToString().Length, '0'); + + string extension = Path.GetExtension(originalPath); + + var filenameBase = $"{Path.GetFileNameWithoutExtension(originalPath)} - {chapterCountLeadingZeros}"; + if (!string.IsNullOrWhiteSpace(suffix)) + filenameBase += $" - {suffix}"; + + // Replace illegal path characters with spaces + var fileName = FileUtility.GetSafeFileName(filenameBase, " "); + var path = Path.Combine(Path.GetDirectoryName(originalPath), fileName + extension); + return path; + } + private static string NEW_GetMultipartFileName_FileTemplate(string originalPath, int partsPosition, int partsTotal, string suffix) + { + // 1-9 => 1-9 + // 10-99 => 01-99 + // 100-999 => 001-999 + var chapterCountLeadingZeros = partsPosition.ToString().PadLeft(partsTotal.ToString().Length, '0'); + + var t = Path.ChangeExtension(originalPath, null) + " - <chapter> - <title>" + Path.GetExtension(originalPath); + + var fileTemplate = new FileTemplate(t) { IllegalCharacterReplacements = " " }; + fileTemplate.AddParameterReplacement("chapter", chapterCountLeadingZeros); + fileTemplate.AddParameterReplacement("title", suffix); + + return fileTemplate.GetFilename(); + } + } +} diff --git a/FileManager.Tests/FileUtilityTests.cs b/FileManager.Tests/FileUtilityTests.cs index cce74f3d..b8472a58 100644 --- a/FileManager.Tests/FileUtilityTests.cs +++ b/FileManager.Tests/FileUtilityTests.cs @@ -1,13 +1,130 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Dinah.Core; +using FileManager; +using FluentAssertions; using Microsoft.VisualStudio.TestTools.UnitTesting; namespace FileUtilityTests { + [TestClass] + public class GetSafePath + { + [TestMethod] + public void null_path_throws() => Assert.ThrowsException<ArgumentNullException>(() => FileUtility.GetSafePath(null)); + + // needs separate method. middle null param not running correctly in TestExplorer when used in DataRow() + [TestMethod] + [DataRow("http://test.com/a/b/c", @"http\\test.com\a\b\c")] + public void null_replacement(string inStr, string outStr) => Tests(inStr, null, outStr); + + [TestMethod] + // empty replacement + [DataRow("abc*abc.txt", "", "abcabc.txt")] + // non-empty replacement + [DataRow("abc*abc.txt", "ZZZ", "abcZZZabc.txt")] + // standardize slashes + [DataRow(@"a/b\c/d", "Z", @"a\b\c\d")] + // remove illegal chars + [DataRow("a*?:z.txt", "Z", "aZZZz.txt")] + // retain drive letter path colon + [DataRow(@"C:\az.txt", "Z", @"C:\az.txt")] + // replace all other colongs + [DataRow(@"a\b:c\d.txt", "ZZZ", @"a\bZZZc\d.txt")] + public void Tests(string inStr, string replacement, string outStr) => Assert.AreEqual(outStr, FileUtility.GetSafePath(inStr, replacement)); + } + + [TestClass] + public class GetSafeFileName + { + // needs separate method. middle null param not running correctly in TestExplorer when used in DataRow() + [TestMethod] + [DataRow("http://test.com/a/b/c", "httptest.comabc")] + public void url_null_replacement(string inStr, string outStr) => ReplacementTests(inStr, null, outStr); + + [TestMethod] + // empty replacement + [DataRow("http://test.com/a/b/c", "", "httptest.comabc")] + // single char replace + [DataRow("http://test.com/a/b/c", "_", "http___test.com_a_b_c")] + // multi char replace + [DataRow("http://test.com/a/b/c", "!!!", "http!!!!!!!!!test.com!!!a!!!b!!!c")] + public void ReplacementTests(string inStr, string replacement, string outStr) => FileUtility.GetSafeFileName(inStr, replacement).Should().Be(outStr); + } + [TestClass] public class GetValidFilename { [TestMethod] - public void TestMethod1() - { - } + [DataRow(null, "name", "ext", "suffix")] + [DataRow(@"C:\", null, "ext", "suffix")] + [ExpectedException(typeof(ArgumentNullException))] + public void arg_null_exception(string dirFullPath, string filename, string extension, string metadataSuffix) + => FileUtility.GetValidFilename(dirFullPath, filename, extension, metadataSuffix); + + [TestMethod] + [DataRow("", "name", "ext", "suffix")] + [DataRow(" ", "name", "ext", "suffix")] + [DataRow(@"C:\", "", "ext", "suffix")] + [DataRow(@"C:\", " ", "ext", "suffix")] + [ExpectedException(typeof(ArgumentException))] + public void arg_exception(string dirFullPath, string filename, string extension, string metadataSuffix) + => FileUtility.GetValidFilename(dirFullPath, filename, extension, metadataSuffix); + + [TestMethod] + public void null_extension() => Tests(@"C:\foo\bar", "my file", null, "meta", @"C:\foo\bar\my file [meta]"); + [TestMethod] + public void null_metadataSuffix() => Tests(@"C:\foo\bar", "my file", "txt", null, @"C:\foo\bar\my file [].txt"); + + [TestMethod] + [DataRow(@"C:\foo\bar", "my file", "txt", "my id", @"C:\foo\bar\my file [my id].txt")] + [DataRow(@"C:\foo\bar", "my file", "txt", "", @"C:\foo\bar\my file [].txt")] + public void Tests(string dirFullPath, string filename, string extension, string metadataSuffix, string expected) + => FileUtility.GetValidFilename(dirFullPath, filename, extension, metadataSuffix).Should().Be(expected); + } + + [TestClass] + public class GetMultipartFileName + { + [TestMethod] + public void null_path() => Assert.ThrowsException<ArgumentNullException>(() => FileUtility.GetMultipartFileName(null, 1, 1, "")); + + [TestMethod] + public void null_suffix() => Tests(@"C:\foo\bar\my file.txt", 2, 100, null, @"C:\foo\bar\my file - 002 - .txt"); + + [TestMethod] + public void negative_partsPosition() => Assert.ThrowsException<ArgumentException>(() + => FileUtility.GetMultipartFileName("foo", -1, 2, "") + ); + [TestMethod] + public void zero_partsPosition() => Assert.ThrowsException<ArgumentException>(() + => FileUtility.GetMultipartFileName("foo", 0, 2, "") + ); + + [TestMethod] + public void negative_partsTotal() => Assert.ThrowsException<ArgumentException>(() + => FileUtility.GetMultipartFileName("foo", 2, -1, "") + ); + [TestMethod] + public void zero_partsTotal() => Assert.ThrowsException<ArgumentException>(() + => FileUtility.GetMultipartFileName("foo", 2, 0, "") + ); + + [TestMethod] + public void partsPosition_greater_than_partsTotal() => Assert.ThrowsException<ArgumentException>(() + => FileUtility.GetMultipartFileName("foo", 2, 1, "") + ); + + [TestMethod] + // only part + [DataRow(@"C:\foo\bar\my file.txt", 1, 1, "title", @"C:\foo\bar\my file - 1 - title.txt")] + // 3 digits + [DataRow(@"C:\foo\bar\my file.txt", 2, 100, "title", @"C:\foo\bar\my file - 002 - title.txt")] + // no suffix + [DataRow(@"C:\foo\bar\my file.txt", 2, 100, "", @"C:\foo\bar\my file - 002 - .txt")] + public void Tests(string originalPath, int partsPosition, int partsTotal, string suffix, string expected) + => FileUtility.GetMultipartFileName(originalPath, partsPosition, partsTotal, suffix).Should().Be(expected); } } diff --git a/FileManager/FileManager.csproj b/FileManager/FileManager.csproj index eda85125..a98e4e01 100644 --- a/FileManager/FileManager.csproj +++ b/FileManager/FileManager.csproj @@ -5,7 +5,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Dinah.Core" Version="2.0.0.1" /> + <PackageReference Include="Dinah.Core" Version="2.0.1.1" /> <PackageReference Include="Polly" Version="7.2.2" /> </ItemGroup> diff --git a/FileManager/FileTemplate.cs b/FileManager/FileTemplate.cs new file mode 100644 index 00000000..5dff519e --- /dev/null +++ b/FileManager/FileTemplate.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Dinah.Core; + +namespace FileManager +{ + /// <summary>Get valid filename. Advanced features incl. parameterized template</summary> + public class FileTemplate + { + /// <summary>Proposed full file path. May contain optional html-styled template tags. Eg: <name></summary> + public string Template { get; } + + /// <param name="template">Proposed file name with optional html-styled template tags.</param> + public FileTemplate(string template) => Template = ArgumentValidator.EnsureNotNullOrWhiteSpace(template, nameof(template)); + + /// <summary>Optional step 1: Replace html-styled template tags with parameters. Eg {"name", "Bill Gates"} => /<name>/ => /Bill Gates/</summary> + public Dictionary<string, string> ParameterReplacements { get; } = new Dictionary<string, string>(); + + /// <summary>Convenience method</summary> + public void AddParameterReplacement(string key ,string value) => ParameterReplacements.Add(key, value); + + /// <summary>If set, truncate each parameter replacement to this many characters. Default 50</summary> + public int? ParameterMaxSize { get; set; } = 50; + + /// <summary>Optional step 2: Replace all illegal characters with this. Default=<see cref="string.Empty"/></summary> + public string IllegalCharacterReplacements { get; set; } + + /// <summary>Generate a valid path for this file or directory</summary> + public string GetFilename() + { + var filename = Template; + + foreach (var r in ParameterReplacements) + filename = filename.Replace($"<{formatKey(r.Key)}>", formatValue(r.Value)); + + return FileUtility.GetValidFilename(filename, IllegalCharacterReplacements); + } + + private string formatKey(string key) + => key + .Replace("<", "") + .Replace(">", ""); + + private string formatValue(string value) + => ParameterMaxSize.HasValue && ParameterMaxSize.Value > 0 + ? value?.Truncate(ParameterMaxSize.Value) + : value; + } + +} diff --git a/FileManager/FileUtility.cs b/FileManager/FileUtility.cs index 9bfcb3fb..c6cdbc88 100644 --- a/FileManager/FileUtility.cs +++ b/FileManager/FileUtility.cs @@ -10,70 +10,134 @@ namespace FileManager { public static class FileUtility { - private const int MAX_FILENAME_LENGTH = 255; - private const int MAX_DIRECTORY_LENGTH = 247; + /// <summary> + /// "txt" => ".txt" + /// <br />".txt" => ".txt" + /// <br />null or whitespace => "" + /// </summary> + public static string GetStandardizedExtension(string extension) + => string.IsNullOrWhiteSpace(extension) + ? (extension ?? "")?.Trim() + : '.' + extension.Trim('.'); public static string GetValidFilename(string dirFullPath, string filename, string extension, string metadataSuffix) { - if (string.IsNullOrWhiteSpace(dirFullPath)) - throw new ArgumentException($"{nameof(dirFullPath)} may not be null or whitespace", nameof(dirFullPath)); + ArgumentValidator.EnsureNotNullOrWhiteSpace(dirFullPath, nameof(dirFullPath)); + ArgumentValidator.EnsureNotNullOrWhiteSpace(filename, nameof(filename)); - filename ??= ""; + var template = $"<title> [<id>]"; - // sanitize. omit invalid characters. exception: colon => underscore - filename = filename.Replace(':', '_'); - filename = PathLib.ToPathSafeString(filename); + var fullfilename = Path.Combine(dirFullPath, template + GetStandardizedExtension(extension)); - if (filename.Length > 50) - filename = filename.Substring(0, 50) + "[...]"; - - if (!string.IsNullOrWhiteSpace(metadataSuffix)) - filename += $" [{metadataSuffix}]"; - - // extension is null when this method is used for directory names - if (!string.IsNullOrWhiteSpace(extension)) - extension = '.' + extension.Trim('.'); - - // ensure uniqueness - var fullfilename = Path.Combine(dirFullPath, filename + extension); - var i = 0; - while (File.Exists(fullfilename)) - fullfilename = Path.Combine(dirFullPath, filename + $" ({++i})" + extension); - - return fullfilename; + var fileTemplate = new FileTemplate(fullfilename) { IllegalCharacterReplacements = "_" }; + fileTemplate.AddParameterReplacement("title", filename); + fileTemplate.AddParameterReplacement("id", metadataSuffix); + return fileTemplate.GetFilename(); } public static string GetMultipartFileName(string originalPath, int partsPosition, int partsTotal, string suffix) { + ArgumentValidator.EnsureNotNull(originalPath, nameof(originalPath)); + ArgumentValidator.EnsureGreaterThan(partsPosition, nameof(partsPosition), 0); + ArgumentValidator.EnsureGreaterThan(partsTotal, nameof(partsTotal), 0); + if (partsPosition > partsTotal) + throw new ArgumentException($"{partsPosition} may not be greater than {partsTotal}"); + // 1-9 => 1-9 // 10-99 => 01-99 // 100-999 => 001-999 var chapterCountLeadingZeros = partsPosition.ToString().PadLeft(partsTotal.ToString().Length, '0'); - string extension = Path.GetExtension(originalPath); + var template = Path.ChangeExtension(originalPath, null) + " - <chapter> - <title>" + Path.GetExtension(originalPath); - var filenameBase = $"{Path.GetFileNameWithoutExtension(originalPath)} - {chapterCountLeadingZeros} - {suffix}"; + var fileTemplate = new FileTemplate(template) { IllegalCharacterReplacements = " " }; + fileTemplate.AddParameterReplacement("chapter", chapterCountLeadingZeros); + fileTemplate.AddParameterReplacement("title", suffix); - // Replace illegal path characters with spaces - var filenameBaseSafe = PathLib.ToPathSafeString(filenameBase, " "); - var fileName = filenameBaseSafe.Truncate(MAX_FILENAME_LENGTH - extension.Length); - var path = Path.Combine(Path.GetDirectoryName(originalPath), fileName + extension); - return path; + return fileTemplate.GetFilename(); } - public static string Move(string source, string destination) + private const int MAX_FILENAME_LENGTH = 255; + private const int MAX_DIRECTORY_LENGTH = 247; + + /// <summary> + /// Ensure valid file name path: + /// <br/>- remove invalid chars + /// <br/>- ensure uniqueness + /// <br/>- enforce max file length + /// </summary> + public static string GetValidFilename(string path, string illegalCharacterReplacements = "") { - // TODO: destination must be valid path. Use: " (#)" when needed + ArgumentValidator.EnsureNotNull(path, nameof(path)); + + // remove invalid chars + path = GetSafePath(path, illegalCharacterReplacements); + + // ensure uniqueness and check lengths + var dir = Path.GetDirectoryName(path); + dir = dir.Truncate(MAX_DIRECTORY_LENGTH); + + var filename = Path.GetFileNameWithoutExtension(path); + var fileStem = Path.Combine(dir, filename); + + var extension = Path.GetExtension(path); + + var fullfilename = fileStem.Truncate(MAX_FILENAME_LENGTH - extension.Length) + extension; + + var i = 0; + while (File.Exists(fullfilename)) + { + var increm = $" ({++i})"; + fullfilename = fileStem.Truncate(MAX_FILENAME_LENGTH - increm.Length - extension.Length) + increm + extension; + } + + return fullfilename; + } + + // GetInvalidFileNameChars contains everything in GetInvalidPathChars plus ':', '*', '?', '\\', '/' + + /// <summary>Use with file name, not full path. Valid path charaters which are invalid file name characters will be replaced: ':', '\\', '/'</summary> + public static string GetSafeFileName(string str, string illegalCharacterReplacements = "") + => string.Join(illegalCharacterReplacements ?? "", str.Split(Path.GetInvalidFileNameChars())); + + /// <summary>Use with full path, not file name. Valid path charaters which are invalid file name characters will be retained: '\\', '/'</summary> + public static string GetSafePath(string path, string illegalCharacterReplacements = "") + { + ArgumentValidator.EnsureNotNull(path, nameof(path)); + + var fixedPath = string + .Join(illegalCharacterReplacements ?? "", path.Split(Path.GetInvalidPathChars())) + .Replace("*", illegalCharacterReplacements) + .Replace("?", illegalCharacterReplacements) + .Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar); + + // replace all colons except within the first 2 chars + var builder = new System.Text.StringBuilder(); + for (var i = 0; i < fixedPath.Length; i++) + { + var c = fixedPath[i]; + if (i >= 2 && c == ':') + builder.Append(illegalCharacterReplacements); + else + builder.Append(c); + } + fixedPath = builder.ToString(); + return fixedPath; + } + + /// <summary> + /// Move file. + /// <br/>- Ensure valid file name path: remove invalid chars, ensure uniqueness, enforce max file length + /// <br/>- Perform <see cref="SaferMove"/> + /// <br/>- Return valid path + /// </summary> + public static string SaferMoveToValidPath(string source, string destination) + { + destination = GetValidFilename(destination); SaferMove(source, destination); return destination; } - public static string GetValidFilename(string path) - { - // TODO: destination must be valid path. Use: " (#)" when needed - return path; - } - private static int maxRetryAttempts { get; } = 3; private static TimeSpan pauseBetweenFailures { get; } = TimeSpan.FromMilliseconds(100); private static RetryPolicy retryPolicy { get; } = @@ -81,13 +145,13 @@ namespace FileManager .Handle<Exception>() .WaitAndRetry(maxRetryAttempts, i => pauseBetweenFailures); - /// <summary>Delete file. No error when source does not exist. Retry up to 3 times.</summary> + /// <summary>Delete file. No error when source does not exist. Retry up to 3 times before throwing exception.</summary> public static void SaferDelete(string source) => retryPolicy.Execute(() => { try { - if (!File.Exists(source)) + if (File.Exists(source)) { File.Delete(source); Serilog.Log.Logger.Information("File successfully deleted", new { source }); @@ -100,22 +164,23 @@ namespace FileManager } }); - /// <summary>Move file. No error when source does not exist. Retry up to 3 times.</summary> - public static void SaferMove(string source, string target) + /// <summary>Move file. No error when source does not exist. Retry up to 3 times before throwing exception.</summary> + public static void SaferMove(string source, string destination) => retryPolicy.Execute(() => { try { - if (!File.Exists(source)) + if (File.Exists(source)) { - SaferDelete(target); - File.Move(source, target); - Serilog.Log.Logger.Information("File successfully moved", new { source, target }); + SaferDelete(destination); + Directory.CreateDirectory(Path.GetDirectoryName(destination)); + File.Move(source, destination); + Serilog.Log.Logger.Information("File successfully moved", new { source, destination }); } } catch (Exception e) { - Serilog.Log.Logger.Error(e, "Failed to move file", new { source, target }); + Serilog.Log.Logger.Error(e, "Failed to move file", new { source, destination }); throw; } }); diff --git a/InternalUtilities/InternalUtilities.csproj b/InternalUtilities/InternalUtilities.csproj index 67020f0b..fc340968 100644 --- a/InternalUtilities/InternalUtilities.csproj +++ b/InternalUtilities/InternalUtilities.csproj @@ -5,7 +5,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="AudibleApi" Version="2.3.0.1" /> + <PackageReference Include="AudibleApi" Version="2.3.1.1" /> </ItemGroup> <ItemGroup> diff --git a/LibationFileManager/AudibleFileStorage.cs b/LibationFileManager/AudibleFileStorage.cs index 6182eed1..6e6a5be5 100644 --- a/LibationFileManager/AudibleFileStorage.cs +++ b/LibationFileManager/AudibleFileStorage.cs @@ -14,7 +14,6 @@ namespace LibationFileManager #region static public static string DownloadsInProgressDirectory => Directory.CreateDirectory(Path.Combine(Configuration.Instance.InProgress, "DownloadsInProgress")).FullName; public static string DecryptInProgressDirectory => Directory.CreateDirectory(Path.Combine(Configuration.Instance.InProgress, "DecryptInProgress")).FullName; - public static string PdfDirectory => BooksDirectory; private static AaxcFileStorage AAXC { get; } = new AaxcFileStorage(); public static bool AaxcExists(string productId) => AAXC.Exists(productId); diff --git a/LibationFileManager/LibationFileManager.csproj b/LibationFileManager/LibationFileManager.csproj index 9c5e806b..d6498bc0 100644 --- a/LibationFileManager/LibationFileManager.csproj +++ b/LibationFileManager/LibationFileManager.csproj @@ -4,6 +4,10 @@ <TargetFramework>net5.0</TargetFramework> </PropertyGroup> + <ItemGroup> + <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="5.0.0" /> + </ItemGroup> + <ItemGroup> <ProjectReference Include="..\FileManager\FileManager.csproj" /> </ItemGroup> diff --git a/LibationWinForms/LibationWinForms.csproj b/LibationWinForms/LibationWinForms.csproj index 615a8b52..b78d85c2 100644 --- a/LibationWinForms/LibationWinForms.csproj +++ b/LibationWinForms/LibationWinForms.csproj @@ -29,7 +29,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Dinah.Core.WindowsDesktop" Version="2.0.0.1" /> + <PackageReference Include="Dinah.Core.WindowsDesktop" Version="2.0.1.2" /> </ItemGroup> <ItemGroup>