Implement media path scheme

This commit is contained in:
Stephan
2018-06-20 10:49:12 +02:00
parent aa8436f07f
commit fe445f5ecd
7 changed files with 224 additions and 396 deletions

View File

@@ -0,0 +1,41 @@
using System;
namespace Umbraco.Core.IO
{
/// <summary>
/// Represents a media file path scheme.
/// </summary>
public interface IMediaPathScheme
{
// fixme
// to anyone finding this code: YES the Initialize() method is CompletelyBroken™ (temporal whatever)
// but at the moment, the media filesystem wants a scheme which wants a filesystem, and it's all
// convoluted due to how filesystems are managed in FileSystems - clear that part first!
/// <summary>
/// Initialize.
/// </summary>
void Initialize(IFileSystem filesystem);
/// <summary>
/// Gets a media file path.
/// </summary>
/// <param name="itemGuid">The (content, media) item unique identifier.</param>
/// <param name="propertyGuid">The property type unique identifier.</param>
/// <param name="filename">The file name.</param>
/// <param name="previous">A previous filename.</param>
/// <returns>The filesystem-relative complete file path.</returns>
string GetFilePath(Guid itemGuid, Guid propertyGuid, string filename, string previous = null);
/// <summary>
/// Gets the directory that can be deleted when the file is deleted.
/// </summary>
/// <param name="filepath">The filesystem-relative path of the file.</param>
/// <returns>The filesystem-relative path of the directory.</returns>
/// <remarks>
/// <para>The directory, and anything below it, will be deleted.</para>
/// <para>Can return null (or empty) when no directory should be deleted.</para>
/// </remarks>
string GetDeleteDirectory(string filepath);
}
}

View File

@@ -11,6 +11,7 @@ using Umbraco.Core.Configuration;
using Umbraco.Core.Configuration.UmbracoSettings;
using Umbraco.Core.Composing;
using Umbraco.Core.Exceptions;
using Umbraco.Core.IO.MediaPathSchemes;
using Umbraco.Core.Logging;
using Umbraco.Core.Media;
using Umbraco.Core.Media.Exif;
@@ -24,10 +25,6 @@ namespace Umbraco.Core.IO
[FileSystemProvider("media")]
public class MediaFileSystem : FileSystemWrapper
{
private readonly object _folderCounterLock = new object();
private long _folderCounter;
private bool _folderCounterInitialized;
public MediaFileSystem(IFileSystem wrapped)
: base(wrapped)
{
@@ -36,7 +33,10 @@ namespace Umbraco.Core.IO
Current.Container.InjectProperties(this);
UploadAutoFillProperties = new UploadAutoFillProperties(this, Logger, ContentConfig);
}
}
[Inject]
internal IMediaPathScheme MediaPathScheme { get; set; }
[Inject]
internal IContentSection ContentConfig { get; set; }
@@ -45,14 +45,6 @@ namespace Umbraco.Core.IO
internal ILogger Logger { get; set; }
internal UploadAutoFillProperties UploadAutoFillProperties { get; }
// fixme - work-in-progress
// the new scheme is now the default scheme - media items with old paths will still
// work, but whenever a file is uploaded, the new scheme is used. we have a temp.
// option to let people opt-out of the new scheme. eventually, the media filesystem
// will only support the new scheme - but it should be an interface, and it should
// be possible to switch its implementation to whatever people want to use
public bool UseTheNewMediaPathScheme => ContentConfig.UseTheNewMediaPathScheme;
/// <summary>
/// Deletes all files passed in.
@@ -112,24 +104,9 @@ namespace Umbraco.Core.IO
if (FileExists(file) == false) return;
DeleteFile(file);
#pragma warning disable 162 // unreachable code
// ReSharper disable once ConditionIsAlwaysTrueOrFalse
if (UseTheNewMediaPathScheme == false)
{
// old scheme: filepath is "<int>/<filename>" OR "<int>-<filename>"
// remove the directory if any
var dir = Path.GetDirectoryName(file);
if (string.IsNullOrWhiteSpace(dir) == false)
DeleteDirectory(dir, true);
}
else
{
// new scheme: path is "<xuid>/<filename>" where xuid is a combination of cuid and puid
// remove the directory
var dir = Path.GetDirectoryName(file);
DeleteDirectory(dir, true);
}
#pragma warning restore 162
var directory = MediaPathScheme.GetDeleteDirectory(file);
if (!directory.IsNullOrWhiteSpace())
DeleteDirectory(directory, true);
}
catch (Exception e)
{
@@ -154,40 +131,7 @@ namespace Umbraco.Core.IO
if (filename == null) throw new ArgumentException("Cannot become a safe filename.", nameof(filename));
filename = IOHelper.SafeFileName(filename.ToLowerInvariant());
string folder;
#pragma warning disable 162 // unreachable code
// ReSharper disable once ConditionIsAlwaysTrueOrFalse
if (UseTheNewMediaPathScheme == false)
{
// old scheme: filepath is "<int>/<filename>" OR "<int>-<filename>"
// default media filesystem maps to "~/media/<filepath>"
folder = GetNextFolder();
}
else
{
// new scheme: path is "<xuid>/<filename>" where xuid is a combination of cuid and puid
// default media filesystem maps to "~/media/<filepath>"
// assumes that cuid and puid keys can be trusted - and that a single property type
// for a single content cannot store two different files with the same name
folder = Combine(cuid, puid).ToHexString(/*'/', 2, 4*/); // could use ext to fragment path eg 12/e4/f2/...
}
#pragma warning restore 162
var filepath = UmbracoConfig.For.UmbracoSettings().Content.UploadAllowDirectories
? Path.Combine(folder, filename).Replace('\\', '/')
: folder + "-" + filename;
return filepath;
}
private static byte[] Combine(Guid guid1, Guid guid2)
{
var bytes1 = guid1.ToByteArray();
var bytes2 = guid2.ToByteArray();
var bytes = new byte[bytes1.Length];
for (var i = 0; i < bytes1.Length; i++)
bytes[i] = (byte) (bytes1[i] ^ bytes2[i]);
return bytes;
return MediaPathScheme.GetFilePath(cuid, puid, filename);
}
/// <summary>
@@ -202,64 +146,11 @@ namespace Umbraco.Core.IO
/// specified by <paramref name="prevpath"/>. Else, we CREATE a new one. Each time we are invoked.</remarks>
public string GetMediaPath(string filename, string prevpath, Guid cuid, Guid puid)
{
#pragma warning disable 162 // unreachable code
// ReSharper disable once ConditionIsAlwaysTrueOrFalse
if (UseTheNewMediaPathScheme || string.IsNullOrWhiteSpace(prevpath))
return GetMediaPath(filename, cuid, puid);
filename = Path.GetFileName(filename);
if (filename == null) throw new ArgumentException("Cannot become a safe filename.", nameof(filename));
filename = IOHelper.SafeFileName(filename.ToLowerInvariant());
// old scheme, with a previous path
// prevpath should be "<int>/<filename>" OR "<int>-<filename>"
// and we want to reuse the "<int>" part, so try to find it
var sep = UmbracoConfig.For.UmbracoSettings().Content.UploadAllowDirectories ? "/" : "-";
var pos = prevpath.IndexOf(sep, StringComparison.Ordinal);
var s = pos > 0 ? prevpath.Substring(0, pos) : null;
var folder = (pos > 0 && int.TryParse(s, out _)) ? s : GetNextFolder();
// ReSharper disable once AssignNullToNotNullAttribute
var filepath = UmbracoConfig.For.UmbracoSettings().Content.UploadAllowDirectories
? Path.Combine(folder, filename)
: folder + "-" + filename;
return filepath;
#pragma warning restore 162 // unreachable code
}
/// <summary>
/// Gets the next media folder in the original number-based scheme.
/// </summary>
/// <returns></returns>
/// <remarks>Should be private, is internal for legacy FileHandlerData which is obsolete.</remarks>
internal string GetNextFolder()
{
EnsureFolderCounterIsInitialized();
return Interlocked.Increment(ref _folderCounter).ToString(CultureInfo.InvariantCulture);
}
private void EnsureFolderCounterIsInitialized()
{
lock (_folderCounterLock)
{
if (_folderCounterInitialized) return;
_folderCounter = 1000; // seed
var directories = GetDirectories("");
foreach (var directory in directories)
{
if (long.TryParse(directory, out var folderNumber) && folderNumber > _folderCounter)
_folderCounter = folderNumber;
}
// note: not multi-domains ie LB safe as another domain could create directories
// while we read and parse them - don't fix, move to new scheme eventually
_folderCounterInitialized = true;
}
return MediaPathScheme.GetFilePath(cuid, puid, filename, prevpath);
}
#endregion
@@ -429,282 +320,5 @@ namespace Umbraco.Core.IO
}
#endregion
// fixme - remove
//#region Manage thumbnails
//// note: this does not find 'custom' thumbnails?
//// will find _thumb and _big-thumb but NOT _custom?
//public IEnumerable<string> GetThumbnails(string path)
//{
// var parentDirectory = Path.GetDirectoryName(path);
// var extension = Path.GetExtension(path);
// return GetFiles(parentDirectory)
// .Where(x => x.StartsWith(path.TrimEnd(extension) + "_thumb") || x.StartsWith(path.TrimEnd(extension) + "_big-thumb"))
// .ToList();
//}
//public void DeleteFile(string path, bool deleteThumbnails)
//{
// base.DeleteFile(path);
// if (deleteThumbnails == false)
// return;
// DeleteThumbnails(path);
//}
//public void DeleteThumbnails(string path)
//{
// GetThumbnails(path)
// .ForEach(x => base.DeleteFile(x));
//}
//public void CopyThumbnails(string sourcePath, string targetPath)
//{
// var targetPathBase = Path.GetDirectoryName(targetPath) ?? "";
// foreach (var sourceThumbPath in GetThumbnails(sourcePath))
// {
// var sourceThumbFilename = Path.GetFileName(sourceThumbPath) ?? "";
// var targetThumbPath = Path.Combine(targetPathBase, sourceThumbFilename);
// this.CopyFile(sourceThumbPath, targetThumbPath);
// }
//}
//#endregion
//#region GenerateThumbnails
//public IEnumerable<ResizedImage> GenerateThumbnails(Image image, string filepath, string preValue)
//{
// if (string.IsNullOrWhiteSpace(preValue))
// return GenerateThumbnails(image, filepath);
// var additionalSizes = new List<int>();
// var sep = preValue.Contains(",") ? "," : ";";
// var values = preValue.Split(new[] { sep }, StringSplitOptions.RemoveEmptyEntries);
// foreach (var value in values)
// {
// int size;
// if (int.TryParse(value, out size))
// additionalSizes.Add(size);
// }
// return GenerateThumbnails(image, filepath, additionalSizes);
//}
//public IEnumerable<ResizedImage> GenerateThumbnails(Image image, string filepath, IEnumerable<int> additionalSizes = null)
//{
// var w = image.Width;
// var h = image.Height;
// var sizes = additionalSizes == null ? DefaultSizes.Keys : DefaultSizes.Keys.Concat(additionalSizes);
// // start with default sizes,
// // add additional sizes,
// // filter out duplicates,
// // filter out those that would be larger that the original image
// // and create the thumbnail
// return sizes
// .Distinct()
// .Where(x => w >= x && h >= x)
// .Select(x => GenerateResized(image, filepath, DefaultSizes.ContainsKey(x) ? DefaultSizes[x] : "", x))
// .ToList(); // now
//}
//public IEnumerable<ResizedImage> GenerateThumbnails(Stream filestream, string filepath, PropertyType propertyType)
//{
// // get the original image from the original stream
// if (filestream.CanSeek) filestream.Seek(0, 0); // fixme - what if we cannot seek?
// using (var image = Image.FromStream(filestream))
// {
// return GenerateThumbnails(image, filepath, propertyType);
// }
//}
//public IEnumerable<ResizedImage> GenerateThumbnails(Image image, string filepath, PropertyType propertyType)
//{
// // if the editor is an upload field, check for additional thumbnail sizes
// // that can be defined in the prevalue for the property data type. otherwise,
// // just use the default sizes.
// var sizes = propertyType.PropertyEditorAlias == Constants.PropertyEditors.UploadFieldAlias
// ? DataTypeService
// .GetPreValuesByDataTypeId(propertyType.DataTypeDefinitionId)
// .FirstOrDefault()
// : string.Empty;
// return GenerateThumbnails(image, filepath, sizes);
//}
//#endregion
//#region GenerateResized - Generate at resized filepath derived from origin filepath
//public ResizedImage GenerateResized(Image originImage, string originFilepath, string sizeName, int maxWidthHeight)
//{
// return GenerateResized(originImage, originFilepath, sizeName, maxWidthHeight, -1, -1);
//}
//public ResizedImage GenerateResized(Image originImage, string originFilepath, string sizeName, int fixedWidth, int fixedHeight)
//{
// return GenerateResized(originImage, originFilepath, sizeName, -1, fixedWidth, fixedHeight);
//}
//public ResizedImage GenerateResized(Image originImage, string originFilepath, string sizeName, int maxWidthHeight, int fixedWidth, int fixedHeight)
//{
// if (string.IsNullOrWhiteSpace(sizeName))
// sizeName = "UMBRACOSYSTHUMBNAIL";
// var extension = Path.GetExtension(originFilepath) ?? string.Empty;
// var filebase = originFilepath.TrimEnd(extension);
// var resizedFilepath = filebase + "_" + sizeName + extension;
// return GenerateResizedAt(originImage, resizedFilepath, maxWidthHeight, fixedWidth, fixedHeight);
//}
//#endregion
//#region GenerateResizedAt - Generate at specified resized filepath
//public ResizedImage GenerateResizedAt(Image originImage, string resizedFilepath, int maxWidthHeight)
//{
// return GenerateResizedAt(originImage, resizedFilepath, maxWidthHeight, -1, -1);
//}
//public ResizedImage GenerateResizedAt(Image originImage, int fixedWidth, int fixedHeight, string resizedFilepath)
//{
// return GenerateResizedAt(originImage, resizedFilepath, -1, fixedWidth, fixedHeight);
//}
//public ResizedImage GenerateResizedAt(Image originImage, string resizedFilepath, int maxWidthHeight, int fixedWidth, int fixedHeight)
//{
// // target dimensions
// int width, height;
// // if maxWidthHeight then get ratio
// if (maxWidthHeight > 0)
// {
// var fx = (float)originImage.Size.Width / maxWidthHeight;
// var fy = (float)originImage.Size.Height / maxWidthHeight;
// var f = Math.Max(fx, fy); // fit in thumbnail size
// width = (int)Math.Round(originImage.Size.Width / f);
// height = (int)Math.Round(originImage.Size.Height / f);
// if (width == 0) width = 1;
// if (height == 0) height = 1;
// }
// else if (fixedWidth > 0 && fixedHeight > 0)
// {
// width = fixedWidth;
// height = fixedHeight;
// }
// else
// {
// width = height = 1;
// }
// // create new image with best quality settings
// using (var bitmap = new Bitmap(width, height))
// using (var graphics = Graphics.FromImage(bitmap))
// {
// // if the image size is rather large we cannot use the best quality interpolation mode
// // because we'll get out of mem exceptions. So we detect how big the image is and use
// // the mid quality interpolation mode when the image size exceeds our max limit.
// graphics.InterpolationMode = originImage.Width > 5000 || originImage.Height > 5000
// ? InterpolationMode.Bilinear // mid quality
// : InterpolationMode.HighQualityBicubic; // best quality
// // everything else is best-quality
// graphics.SmoothingMode = SmoothingMode.HighQuality;
// graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
// graphics.CompositingQuality = CompositingQuality.HighQuality;
// // copy the old image to the new and resize
// var rect = new Rectangle(0, 0, width, height);
// graphics.DrawImage(originImage, rect, 0, 0, originImage.Width, originImage.Height, GraphicsUnit.Pixel);
// // copy metadata
// // fixme - er... no?
// // get an encoder - based upon the file type
// var extension = (Path.GetExtension(resizedFilepath) ?? "").TrimStart('.').ToLowerInvariant();
// var encoders = ImageCodecInfo.GetImageEncoders();
// ImageCodecInfo encoder;
// switch (extension)
// {
// case "png":
// encoder = encoders.Single(t => t.MimeType.Equals("image/png"));
// break;
// case "gif":
// encoder = encoders.Single(t => t.MimeType.Equals("image/gif"));
// break;
// case "tif":
// case "tiff":
// encoder = encoders.Single(t => t.MimeType.Equals("image/tiff"));
// break;
// case "bmp":
// encoder = encoders.Single(t => t.MimeType.Equals("image/bmp"));
// break;
// // TODO: this is dirty, defaulting to jpg but the return value of this thing is used all over the
// // place so left it here, but it needs to not set a codec if it doesn't know which one to pick
// // Note: when fixing this: both .jpg and .jpeg should be handled as extensions
// default:
// encoder = encoders.Single(t => t.MimeType.Equals("image/jpeg"));
// break;
// }
// // set compresion ratio to 90%
// var encoderParams = new EncoderParameters();
// encoderParams.Param[0] = new EncoderParameter(Encoder.Quality, 90L);
// // save the new image
// using (var stream = new MemoryStream())
// {
// bitmap.Save(stream, encoder, encoderParams);
// stream.Seek(0, 0);
// if (resizedFilepath.Contains("UMBRACOSYSTHUMBNAIL"))
// {
// var filepath = resizedFilepath.Replace("UMBRACOSYSTHUMBNAIL", maxWidthHeight.ToInvariantString());
// AddFile(filepath, stream);
// if (extension != "jpg")
// {
// filepath = filepath.TrimEnd(extension) + "jpg";
// stream.Seek(0, 0);
// AddFile(filepath, stream);
// }
// // TODO: Remove this, this is ONLY here for backwards compatibility but it is essentially completely unusable see U4-5385
// stream.Seek(0, 0);
// resizedFilepath = resizedFilepath.Replace("UMBRACOSYSTHUMBNAIL", width + "x" + height);
// }
// AddFile(resizedFilepath, stream);
// }
// return new ResizedImage(resizedFilepath, width, height);
// }
//}
//#endregion
//#region Inner classes
//public class ResizedImage
//{
// public ResizedImage()
// { }
// public ResizedImage(string filepath, int width, int height)
// {
// Filepath = filepath;
// Width = width;
// Height = height;
// }
// public string Filepath { get; set; }
// public int Width { get; set; }
// public int Height { get; set; }
//}
//#endregion
}
}

View File

@@ -0,0 +1,43 @@
using System;
using System.IO;
namespace Umbraco.Core.IO.MediaPathSchemes
{
/// <summary>
/// Implements a combined-guids media path scheme.
/// </summary>
/// <remarks>
/// <para>Path is "{combinedGuid}/{filename>}" where combinedGuid is a combination of itemGuid and propertyGuid.</para>
/// </remarks>
public class CombinedGuidsMediaPathScheme : IMediaPathScheme
{
/// <inheritdoc />
public void Initialize(IFileSystem filesystem)
{ }
/// <inheritdoc />
public string GetFilePath(Guid itemGuid, Guid propertyGuid, string filename, string previous = null)
{
// assumes that cuid and puid keys can be trusted - and that a single property type
// for a single content cannot store two different files with the same name
var directory = Combine(itemGuid, propertyGuid).ToHexString(/*'/', 2, 4*/); // could use ext to fragment path eg 12/e4/f2/...
return Path.Combine(directory, filename).Replace('\\', '/');
}
/// <inheritdoc />
public string GetDeleteDirectory(string filepath)
{
return Path.GetDirectoryName(filepath);
}
private static byte[] Combine(Guid guid1, Guid guid2)
{
var bytes1 = guid1.ToByteArray();
var bytes2 = guid2.ToByteArray();
var bytes = new byte[bytes1.Length];
for (var i = 0; i < bytes1.Length; i++)
bytes[i] = (byte) (bytes1[i] ^ bytes2[i]);
return bytes;
}
}
}

View File

@@ -0,0 +1,92 @@
using System;
using System.Globalization;
using System.IO;
using System.Threading;
using Umbraco.Core.Configuration;
namespace Umbraco.Core.IO.MediaPathSchemes
{
/// <summary>
/// Implements the original media path scheme.
/// </summary>
/// <remarks>
/// <para>Path is "{number}/{filename}" or "{number}-{filename}" where number is an incremented counter.</para>
/// <para>Use '/' or '-' depending on UploadAllowDirectories setting.</para>
/// </remarks>
// scheme: path is "<number>/<filename>" where number is an incremented counter
public class OriginalMediaPathScheme : IMediaPathScheme
{
private readonly object _folderCounterLock = new object();
private IFileSystem _filesystem;
private long _folderCounter;
private bool _folderCounterInitialized;
/// <inheritdoc />
public void Initialize(IFileSystem filesystem)
{
_filesystem = filesystem;
}
/// <inheritdoc />
public string GetFilePath(Guid itemGuid, Guid propertyGuid, string filename, string previous = null)
{
string directory;
if (previous != null)
{
// old scheme, with a previous path
// prevpath should be "<int>/<filename>" OR "<int>-<filename>"
// and we want to reuse the "<int>" part, so try to find it
var sep = UmbracoConfig.For.UmbracoSettings().Content.UploadAllowDirectories ? "/" : "-";
var pos = previous.IndexOf(sep, StringComparison.Ordinal);
var s = pos > 0 ? previous.Substring(0, pos) : null;
directory = pos > 0 && int.TryParse(s, out _) ? s : GetNextDirectory();
}
else
{
directory = GetNextDirectory();
}
if (directory == null)
throw new InvalidOperationException("Cannot use a null directory.");
return UmbracoConfig.For.UmbracoSettings().Content.UploadAllowDirectories
? Path.Combine(directory, filename).Replace('\\', '/')
: directory + "-" + filename;
}
/// <inheritdoc />
public string GetDeleteDirectory(string filepath)
{
return Path.GetDirectoryName(filepath);
}
private string GetNextDirectory()
{
EnsureFolderCounterIsInitialized();
return Interlocked.Increment(ref _folderCounter).ToString(CultureInfo.InvariantCulture);
}
private void EnsureFolderCounterIsInitialized()
{
lock (_folderCounterLock)
{
if (_folderCounterInitialized) return;
_folderCounter = 1000; // seed
var directories = _filesystem.GetDirectories("");
foreach (var directory in directories)
{
if (long.TryParse(directory, out var folderNumber) && folderNumber > _folderCounter)
_folderCounter = folderNumber;
}
// note: not multi-domains ie LB safe as another domain could create directories
// while we read and parse them - don't fix, move to new scheme eventually
_folderCounterInitialized = true;
}
}
}
}

View File

@@ -0,0 +1,31 @@
using System;
using System.IO;
namespace Umbraco.Core.IO.MediaPathSchemes
{
/// <summary>
/// Implements a two-guids media path scheme.
/// </summary>
/// <remarks>
/// <para>Path is "{itemGuid}-{propertyGuid}/{filename}".</para>
/// </remarks>
public class TwoGuidsMediaPathScheme : IMediaPathScheme
{
/// <inheritdoc />
public void Initialize(IFileSystem filesystem)
{ }
/// <inheritdoc />
public string GetFilePath(Guid itemGuid, Guid propertyGuid, string filename, string previous = null)
{
var directory = itemGuid.ToString("N") + "-" + propertyGuid.ToString("N");
return Path.Combine(directory, filename).Replace('\\', '/');
}
/// <inheritdoc />
public string GetDeleteDirectory(string filepath)
{
return Path.GetDirectoryName(filepath);
}
}
}

View File

@@ -11,6 +11,7 @@ using Umbraco.Core.Composing.CompositionRoots;
using Umbraco.Core.Configuration;
using Umbraco.Core.Configuration.UmbracoSettings;
using Umbraco.Core.IO;
using Umbraco.Core.IO.MediaPathSchemes;
using Umbraco.Core.Logging;
using Umbraco.Core.Manifest;
using Umbraco.Core.Migrations;
@@ -117,6 +118,8 @@ namespace Umbraco.Core.Runtime
// by default, register a noop factory
composition.Container.RegisterSingleton<IPublishedModelFactory, NoopPublishedModelFactory>();
composition.Container.RegisterSingleton<IMediaPathScheme, TwoGuidsMediaPathScheme>();
}
internal void Initialize(IEnumerable<Profile> mapperProfiles)

View File

@@ -308,6 +308,10 @@
<Compile Include="Events\ExportedMemberEventArgs.cs" />
<Compile Include="Events\RolesEventArgs.cs" />
<Compile Include="Events\UserGroupWithUsers.cs" />
<Compile Include="IO\MediaPathSchemes\CombinedGuidsMediaPathScheme.cs" />
<Compile Include="IO\IMediaPathScheme.cs" />
<Compile Include="IO\MediaPathSchemes\OriginalMediaPathScheme.cs" />
<Compile Include="IO\MediaPathSchemes\TwoGuidsMediaPathScheme.cs" />
<Compile Include="KeyValuePairExtensions.cs" />
<Compile Include="Logging\RollingFileCleanupAppender.cs" />
<Compile Include="Migrations\MigrationBase_Extra.cs" />