Refactor valid path/filename. Centralize validaion. Universal templating is one step closer

This commit is contained in:
Robert McRackan 2021-10-18 13:36:55 -04:00
parent 7720110460
commit d08962cffa
18 changed files with 415 additions and 74 deletions

View file

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

View file

@ -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: &lt;name&gt;</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"} => /&lt;name&gt;/ => /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;
}
}

View file

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