From 939ccb706625dbef047569ba60a218f830b68a26 Mon Sep 17 00:00:00 2001 From: Piotr Karasinski Date: Fri, 12 Mar 2021 11:30:18 +0100 Subject: [PATCH] Rewrite. Switches from CommandLineParser to System.CommandLine. Replaces own implementation of file system isolation to System.IO.Abstractions. Replaces all tests with new ones using XUnit. These are coarse grained integration tests treating the whole app as a unit (they are longer but span over the entire application). --- .../CameraFileNameConverterTests.cs | 111 ---- .../CameraFiles/CameraFileFactoryTests.cs | 92 --- .../CameraFiles/ImageFileTests.cs | 53 -- .../CameraFiles/VideoFileTests.cs | 30 - CameraUtility.Tests/CameraFilesFinderTests.cs | 143 ----- .../CameraUtility.Tests.csproj | 8 +- CameraUtility.Tests/ProgramTests.cs | 232 ------- .../TransferFilesCommandsTests.cs | 584 ++++++++++++++++++ .../Utils/FileNameUtilTests.cs | 29 - CameraUtility/CameraFileCopier.cs | 60 -- CameraUtility/CameraFileNameConverter.cs | 50 +- .../CameraFiles/CameraFileFactory.cs | 4 +- .../CameraFiles/ICameraFileFactory.cs | 4 +- CameraUtility/CameraFiles/ImageFile.cs | 2 + CameraUtility/CameraFiles/VideoFile.cs | 2 +- CameraUtility/CameraFilesFinder.cs | 34 +- CameraUtility/CameraUtility.csproj | 6 +- .../AbstractTransferImageFilesCommand.cs | 103 +++ .../ImageFilesTransfer/CopyCommand.cs | 14 + .../Execution/CameraFileTransferer.cs | 69 +++ .../Execution/IOrchestrator.cs | 7 + .../Execution/Orchestrator.cs | 63 ++ .../ImageFilesTransfer/MoveCommand.cs | 14 + .../Options/DestinationDirectory.cs | 5 + .../ImageFilesTransfer/Options/DryRun.cs | 5 + .../ImageFilesTransfer/Options/KeepGoing.cs | 5 + .../Options/SkipDateSubdirectory.cs | 5 + .../ImageFilesTransfer/Options/SourcePath.cs | 5 + .../ImageFilesTransfer/Options/TypeWrapper.cs | 16 + .../Output/ConsoleOutput.cs | 84 +++ .../ImageFilesTransfer/Output/Report.cs | 130 ++++ .../Output/ReportingOrchestratorDecorator.cs | 32 + CameraUtility/CopyOrMoveMode.cs | 11 - CameraUtility/CopyingOrchestrator.cs | 35 -- ...nHandlingCameraDirectoryCopierDecorator.cs | 33 - ...eptionHandlingCameraFileCopierDecorator.cs | 34 - ...ptionHandlingCameraFileFactoryDecorator.cs | 41 -- ...xceptionHandlingMetadataReaderDecorator.cs | 30 - .../ExceptionHandling/InvalidFileException.cs | 15 - .../InvalidMetadataException.cs | 15 - CameraUtility/Exif/MetadataReader.cs | 2 +- .../FileSystemIsolation/FileSystem.cs | 118 ---- .../FileSystemIsolation/IFileSystem.cs | 82 --- CameraUtility/ICameraDirectoryCopier.cs | 29 - CameraUtility/ICameraFileCopier.cs | 9 - CameraUtility/ICameraFileNameConverter.cs | 17 - CameraUtility/ICameraFilesFinder.cs | 16 - CameraUtility/Program.cs | 266 ++++---- .../CountingCameraFileCopierDecorator.cs | 35 -- .../CountingCameraFilesFinderDecorator.cs | 31 - .../Reporting/CountingFileSystemDecorator.cs | 86 --- CameraUtility/Reporting/Report.cs | 155 ----- CameraUtility/Utils/FileNameUtil.cs | 18 +- README.md | 63 +- 54 files changed, 1374 insertions(+), 1768 deletions(-) delete mode 100755 CameraUtility.Tests/CameraFileNameConverterTests.cs delete mode 100755 CameraUtility.Tests/CameraFiles/CameraFileFactoryTests.cs delete mode 100755 CameraUtility.Tests/CameraFiles/ImageFileTests.cs delete mode 100755 CameraUtility.Tests/CameraFiles/VideoFileTests.cs delete mode 100644 CameraUtility.Tests/CameraFilesFinderTests.cs delete mode 100755 CameraUtility.Tests/ProgramTests.cs create mode 100644 CameraUtility.Tests/TransferFilesCommandsTests.cs delete mode 100755 CameraUtility.Tests/Utils/FileNameUtilTests.cs delete mode 100755 CameraUtility/CameraFileCopier.cs mode change 100755 => 100644 CameraUtility/CameraFileNameConverter.cs create mode 100644 CameraUtility/Commands/ImageFilesTransfer/AbstractTransferImageFilesCommand.cs create mode 100644 CameraUtility/Commands/ImageFilesTransfer/CopyCommand.cs create mode 100644 CameraUtility/Commands/ImageFilesTransfer/Execution/CameraFileTransferer.cs create mode 100644 CameraUtility/Commands/ImageFilesTransfer/Execution/IOrchestrator.cs create mode 100644 CameraUtility/Commands/ImageFilesTransfer/Execution/Orchestrator.cs create mode 100644 CameraUtility/Commands/ImageFilesTransfer/MoveCommand.cs create mode 100644 CameraUtility/Commands/ImageFilesTransfer/Options/DestinationDirectory.cs create mode 100644 CameraUtility/Commands/ImageFilesTransfer/Options/DryRun.cs create mode 100644 CameraUtility/Commands/ImageFilesTransfer/Options/KeepGoing.cs create mode 100644 CameraUtility/Commands/ImageFilesTransfer/Options/SkipDateSubdirectory.cs create mode 100644 CameraUtility/Commands/ImageFilesTransfer/Options/SourcePath.cs create mode 100644 CameraUtility/Commands/ImageFilesTransfer/Options/TypeWrapper.cs create mode 100644 CameraUtility/Commands/ImageFilesTransfer/Output/ConsoleOutput.cs create mode 100644 CameraUtility/Commands/ImageFilesTransfer/Output/Report.cs create mode 100644 CameraUtility/Commands/ImageFilesTransfer/Output/ReportingOrchestratorDecorator.cs delete mode 100644 CameraUtility/CopyOrMoveMode.cs delete mode 100755 CameraUtility/CopyingOrchestrator.cs delete mode 100755 CameraUtility/ExceptionHandling/ExceptionHandlingCameraDirectoryCopierDecorator.cs delete mode 100755 CameraUtility/ExceptionHandling/ExceptionHandlingCameraFileCopierDecorator.cs delete mode 100755 CameraUtility/ExceptionHandling/ExceptionHandlingCameraFileFactoryDecorator.cs delete mode 100755 CameraUtility/ExceptionHandling/ExceptionHandlingMetadataReaderDecorator.cs delete mode 100755 CameraUtility/ExceptionHandling/InvalidFileException.cs delete mode 100755 CameraUtility/ExceptionHandling/InvalidMetadataException.cs delete mode 100755 CameraUtility/FileSystemIsolation/FileSystem.cs delete mode 100755 CameraUtility/FileSystemIsolation/IFileSystem.cs delete mode 100755 CameraUtility/ICameraDirectoryCopier.cs delete mode 100755 CameraUtility/ICameraFileCopier.cs delete mode 100755 CameraUtility/ICameraFileNameConverter.cs delete mode 100755 CameraUtility/ICameraFilesFinder.cs mode change 100755 => 100644 CameraUtility/Program.cs delete mode 100755 CameraUtility/Reporting/CountingCameraFileCopierDecorator.cs delete mode 100755 CameraUtility/Reporting/CountingCameraFilesFinderDecorator.cs delete mode 100755 CameraUtility/Reporting/CountingFileSystemDecorator.cs delete mode 100755 CameraUtility/Reporting/Report.cs diff --git a/CameraUtility.Tests/CameraFileNameConverterTests.cs b/CameraUtility.Tests/CameraFileNameConverterTests.cs deleted file mode 100755 index 4304c80..0000000 --- a/CameraUtility.Tests/CameraFileNameConverterTests.cs +++ /dev/null @@ -1,111 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using AutoFixture; -using AutoFixture.AutoMoq; -using CameraUtility.CameraFiles; -using CameraUtility.Exif; -using CameraUtility.FileSystemIsolation; -using Moq; -using NUnit.Framework; - -namespace CameraUtility.Tests -{ - [TestFixture] - [TestOf(typeof(CameraFileNameConverter))] - [ExcludeFromCodeCoverage] - public class CameraFileNameConverterTests - { - private IFixture NewFixture() - { - var fixture = new Fixture().Customize(new AutoMoqCustomization()); - fixture - .Freeze>() - .Setup(fs => fs.CombinePaths(It.IsAny())) - .Returns(paths => string.Join('/', paths)); - return fixture; - } - - [Test] - [TestOf(nameof(ICameraFileNameConverter.Convert))] - public void Convert_ImageFile_DestinationSubDirectoryIsImageCreationDate() - { - var fixture = NewFixture(); - fixture - .Freeze>() - .Setup(f => f.Create(It.IsAny(), It.IsAny>())) - .Returns(Mock.Of(f => f.Created == new DateTime(2019, 08, 25))); - var cameraFileNameConverter = fixture.Create(); - cameraFileNameConverter.SkipDateSubDirectory = false; - ICameraFileNameConverter sut = cameraFileNameConverter; - - var (result, _) = sut.Convert("sourceDir/IMG_1234.jpg", "destDir"); - - Assert.AreEqual("destDir/2019_08_25", result); - } - - [Test] - [TestOf(nameof(ICameraFileNameConverter.Convert))] - public void Convert_SkipDateSubDirectoryIsTrue_DestinationSubDirectoryIsNotAltered() - { - var fixture = NewFixture(); - fixture - .Freeze>() - .Setup(f => f.Create(It.IsAny(), It.IsAny>())) - .Returns(Mock.Of(f => f.Created == new DateTime(2019, 08, 25))); - var cameraFileNameConverter = fixture.Create(); - cameraFileNameConverter.SkipDateSubDirectory = true; - ICameraFileNameConverter sut = cameraFileNameConverter; - - var (result, _) = sut.Convert("sourceDir/IMG_1234.jpg", "destDir"); - - Assert.AreEqual("destDir", result); - } - - [Test] - [TestOf(nameof(ICameraFileNameConverter.Convert))] - public void Convert_ImageFile_DestinationFileNameIsCorrect() - { - var fixture = NewFixture(); - fixture - .Freeze>() - .Setup(f => f.Create(It.IsAny(), It.IsAny>())) - .Returns( - Mock.Of(f => - f.DestinationNamePrefix == "IMG_" && - f.Extension == ".jpg" && - f.Created == new DateTime(2019, 08, 25, 14, 39, 42, 123))); - var cameraFileNameConverter = fixture.Create(); - cameraFileNameConverter.SkipDateSubDirectory = false; - ICameraFileNameConverter sut = cameraFileNameConverter; - - - var (_, result) = sut.Convert("sourceDir/IMG_1234.jpg", "destDir"); - - Assert.AreEqual("destDir/2019_08_25/IMG_20190825_143942123.jpg", result); - } - - [TestCase(".jpg")] - [TestCase(".jpeg")] - [TestCase(".cr2")] - [TestCase(".mp4")] - [TestCase(".dng")] - [TestOf(nameof(ICameraFileNameConverter.Convert))] - public void Convert_ImageFile_DestinationExtensionIsCorrect( - string extension) - { - var fixture = NewFixture(); - fixture - .Freeze>() - .Setup(f => f.Create(It.IsAny(), It.IsAny>())) - .Returns( - Mock.Of(f => - f.Extension == extension)); - ICameraFileNameConverter sut = fixture.Create(); - - var (_, result) = sut.Convert($"sourceDir/IMG_1234{extension}", "destDir"); - - Assert.IsTrue(result.EndsWith(extension)); - } - } -} \ No newline at end of file diff --git a/CameraUtility.Tests/CameraFiles/CameraFileFactoryTests.cs b/CameraUtility.Tests/CameraFiles/CameraFileFactoryTests.cs deleted file mode 100755 index acfa551..0000000 --- a/CameraUtility.Tests/CameraFiles/CameraFileFactoryTests.cs +++ /dev/null @@ -1,92 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using CameraUtility.CameraFiles; -using CameraUtility.Exif; -using Moq; -using NUnit.Framework; - -namespace CameraUtility.Tests.CameraFiles -{ - [TestFixture] - [TestOf(typeof(CameraFileFactory))] - [ExcludeFromCodeCoverage] - public sealed class CameraFileFactoryTests - { - [Test] - [TestOf(nameof(ICameraFileFactory.Create))] - public void Create_ExtensionIsJpg_ReturnsImageFile() - { - /* Arrange */ - ICameraFileFactory sut = new CameraFileFactory(); - var tags = new[] - { - Mock.Of(t => - t.Directory == "Exif SubIFD" && t.Type == 0x9003 && t.Value == "2010:11:12 13:14:15"), - Mock.Of(t => t.Directory == "Exif SubIFD" && t.Type == 0x9291 && t.Value == "42") - }; - - /* Act */ - var result = sut.Create("file.jpg", tags); - - /* Assert */ - Assert.IsInstanceOf(result); - } - - [Test] - [TestOf(nameof(ICameraFileFactory.Create))] - public void Create_ExtensionIsCr2_ReturnsImageFile() - { - /* Arrange */ - ICameraFileFactory sut = new CameraFileFactory(); - var tags = new[] - { - Mock.Of(t => - t.Directory == "Exif SubIFD" && t.Type == 0x9003 && t.Value == "2010:11:12 13:14:15"), - Mock.Of(t => t.Directory == "Exif SubIFD" && t.Type == 0x9291 && t.Value == "42") - }; - - /* Act */ - var result = sut.Create("file.cr2", tags); - - /* Assert */ - Assert.IsInstanceOf(result); - } - - [Test] - [TestOf(nameof(ICameraFileFactory.Create))] - public void Create_ExtensionIsDng_ReturnsDngImageFile() - { - /* Arrange */ - ICameraFileFactory sut = new CameraFileFactory(); - var tags = new[] - { - Mock.Of(t => t.Type == 0x9003 && t.Value == "2010:11:12 13:14:15"), - Mock.Of(t => t.Type == 0x9291 && t.Value == "42") - }; - - /* Act */ - var result = sut.Create("file.dng", tags); - - /* Assert */ - Assert.IsInstanceOf(result); - } - - [Test] - [TestOf(nameof(ICameraFileFactory.Create))] - public void Create_ExtensionIsMp4_ReturnsDngVideoFile() - { - /* Arrange */ - ICameraFileFactory sut = new CameraFileFactory(); - var tags = new[] - { - Mock.Of(t => - t.Directory == "QuickTime Movie Header" && t.Type == 0x3 && t.Value == "Fri Jun 13 14.15.16 1980") - }; - - /* Act */ - var result = sut.Create("file.mp4", tags); - - /* Assert */ - Assert.IsInstanceOf(result); - } - } -} \ No newline at end of file diff --git a/CameraUtility.Tests/CameraFiles/ImageFileTests.cs b/CameraUtility.Tests/CameraFiles/ImageFileTests.cs deleted file mode 100755 index f3e3b05..0000000 --- a/CameraUtility.Tests/CameraFiles/ImageFileTests.cs +++ /dev/null @@ -1,53 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using CameraUtility.CameraFiles; -using CameraUtility.Exif; -using Moq; -using NUnit.Framework; - -namespace CameraUtility.Tests.CameraFiles -{ - [TestFixture] - [TestOf(typeof(ImageFile))] - [ExcludeFromCodeCoverage] - public sealed class ImageFileTests - { - [Test] - public void Ctor_ParsesCorrectly() - { - /* Arrange */ - var dateTimeOriginalTagStub = - Mock.Of( - t => t.Type == 0x9003 && t.Value == "2010:11:12 13:14:15" && t.Directory == "Exif SubIFD"); - var subSecondTag = - Mock.Of( - t => t.Type == 0x9291 && t.Value == "16" && t.Directory == "Exif SubIFD"); - - /* Act */ - var result = new ImageFile("fakeFileName.jpg", new []{ dateTimeOriginalTagStub, subSecondTag }); - - /* Assert */ - Assert.AreEqual(new DateTime(2010, 11, 12, 13, 14, 15, 160), result.Created); - } - - [Test] - [TestOf(nameof(ICameraFile.DestinationNamePrefix))] - public void DestinationNamePrefix_IsImg() - { - /* Arrange */ - var dateTimeOriginalTagStub = - Mock.Of( - t => t.Type == 0x9003 && t.Value == "2010:11:12 13:14:15" && t.Directory == "Exif SubIFD"); - var subSecondTag = - Mock.Of( - t => t.Type == 0x9291 && t.Value == "16" && t.Directory == "Exif SubIFD"); - var sut = new ImageFile("fakeFileName.jpg", new []{ dateTimeOriginalTagStub, subSecondTag }); - - /* Act */ - var result = sut.DestinationNamePrefix; - - /* Assert */ - Assert.AreEqual("IMG_", result); - } - } -} \ No newline at end of file diff --git a/CameraUtility.Tests/CameraFiles/VideoFileTests.cs b/CameraUtility.Tests/CameraFiles/VideoFileTests.cs deleted file mode 100755 index 7f6395e..0000000 --- a/CameraUtility.Tests/CameraFiles/VideoFileTests.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using CameraUtility.CameraFiles; -using CameraUtility.Exif; -using Moq; -using NUnit.Framework; - -namespace CameraUtility.Tests.CameraFiles -{ - [TestFixture] - [TestOf(typeof(VideoFile))] - [ExcludeFromCodeCoverage] - public class VideoFileTests - { - [Test] - public void Ctor_ParsesCorrectly() - { - /* Arrange */ - var createdTag = - Mock.Of(t => - t.Directory == "QuickTime Movie Header" && t.Type == 0x3 && t.Value == "Fri Jun 13 14.15.16 1980"); - - /* Act */ - var result = new VideoFile("fakeFileName.mp4", new []{ createdTag }); - - /* Assert */ - Assert.AreEqual(new DateTime(1980, 06, 13, 14, 15, 16), result.Created); - } - } -} \ No newline at end of file diff --git a/CameraUtility.Tests/CameraFilesFinderTests.cs b/CameraUtility.Tests/CameraFilesFinderTests.cs deleted file mode 100644 index 5be1f6b..0000000 --- a/CameraUtility.Tests/CameraFilesFinderTests.cs +++ /dev/null @@ -1,143 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using AutoFixture; -using AutoFixture.AutoMoq; -using CameraUtility.FileSystemIsolation; -using Moq; -using NUnit.Framework; - -namespace CameraUtility.Tests -{ - [TestFixture] - [TestOf(typeof(CameraFilesFinder))] - [ExcludeFromCodeCoverage] - public class CameraFilesFinderTests - { - private IFixture CreateFixture() - { - return new Fixture().Customize(new AutoMoqCustomization()); - } - - [Test] - [TestOf(nameof(ICameraFilesFinder.FindCameraFiles))] - public void PathDoesNotExist_Throws() - { - var fixture = CreateFixture(); - var fileSystemStub = fixture.Freeze>(); - fileSystemStub - .Setup(fs => fs.Exists(It.IsAny())) - .Returns(false); - ICameraFilesFinder sut = fixture.Create(); - - void TestDelegate() => sut.FindCameraFiles("non existing path"); - - Assert.Throws(TestDelegate); - } - - [Test] - [TestOf(nameof(ICameraFilesFinder.FindCameraFiles))] - public void PathIsDirectory_ContainsImagesAndOtherFiles_ReturnsImageFilePathsOnly() - { - var fixture = CreateFixture(); - var fileSystemStub = fixture.Freeze>(); - fileSystemStub - .Setup(fs => fs.Exists(It.IsAny())) - .Returns(true); - fileSystemStub - .Setup(fs => fs.IsDirectory(It.IsAny())) - .Returns(true); - fileSystemStub - .Setup(fs => fs.GetFiles(It.IsAny(), It.IsAny())) - .Returns(new[] { - "image.cr2", - "notImage.txt", - "image.dng", - "notImage.pdf", - "image.jpg", - "notImage.asd", - "image.mp4" - }); - ICameraFilesFinder sut = fixture.Create(); - - var result = sut.FindCameraFiles("directory"); - - CollectionAssert.AreEquivalent( - new [] {"image.cr2", "image.dng", "image.jpg", "image.mp4"}, - result); - } - - [Test] - [TestOf(nameof(ICameraFilesFinder.FindCameraFiles))] - public void PathIsDirectory_DoesNotContainImages_ReturnsEmpty() - { - var fixture = CreateFixture(); - var fileSystemStub = fixture.Freeze>(); - fileSystemStub - .Setup(fs => fs.Exists(It.IsAny())) - .Returns(true); - fileSystemStub - .Setup(fs => fs.IsDirectory(It.IsAny())) - .Returns(true); - fileSystemStub - .Setup(fs => fs.GetFiles(It.IsAny(), It.IsAny())) - .Returns(new[] { - "notImage.txt", - "notImage.pdf", - "notImage.asd" - }); - ICameraFilesFinder sut = fixture.Create(); - - var result = sut.FindCameraFiles("directory"); - - CollectionAssert.IsEmpty(result); - } - - [Test] - [TestOf(nameof(ICameraFilesFinder.FindCameraFiles))] - public void PathIsDirectory_IsEmpty_ReturnsEmpty() - { - var fixture = CreateFixture(); - var fileSystemStub = fixture.Freeze>(); - fileSystemStub - .Setup(fs => fs.Exists(It.IsAny())) - .Returns(true); - fileSystemStub - .Setup(fs => fs.IsDirectory(It.IsAny())) - .Returns(true); - fileSystemStub - .Setup(fs => fs.GetFiles(It.IsAny(), It.IsAny())) - .Returns(Enumerable.Empty()); - ICameraFilesFinder sut = fixture.Create(); - - var result = sut.FindCameraFiles("directory"); - - CollectionAssert.IsEmpty(result); - } - - [TestCase("cr2")] - [TestCase("jpg")] - [TestCase("jpeg")] - [TestCase("dng")] - [TestCase("mp4")] - [TestOf(nameof(ICameraFilesFinder.FindCameraFiles))] - public void PathIsFile_IsImage_ReturnsPath( - string fileExtension) - { - var fixture = CreateFixture(); - var fileSystemStub = fixture.Freeze>(); - fileSystemStub - .Setup(fs => fs.Exists(It.IsAny())) - .Returns(true); - fileSystemStub - .Setup(fs => fs.IsDirectory(It.IsAny())) - .Returns(false); - ICameraFilesFinder sut = fixture.Create(); - - var result = sut.FindCameraFiles($"file.{fileExtension}"); - - Assert.AreEqual( - new [] { $"file.{fileExtension}" }, - result); - } - } -} \ No newline at end of file diff --git a/CameraUtility.Tests/CameraUtility.Tests.csproj b/CameraUtility.Tests/CameraUtility.Tests.csproj index 0f084eb..d7348ae 100755 --- a/CameraUtility.Tests/CameraUtility.Tests.csproj +++ b/CameraUtility.Tests/CameraUtility.Tests.csproj @@ -9,9 +9,13 @@ + - - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/CameraUtility.Tests/ProgramTests.cs b/CameraUtility.Tests/ProgramTests.cs deleted file mode 100755 index 0d384ca..0000000 --- a/CameraUtility.Tests/ProgramTests.cs +++ /dev/null @@ -1,232 +0,0 @@ -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using AutoFixture; -using AutoFixture.AutoMoq; -using CameraUtility.Exif; -using CameraUtility.FileSystemIsolation; -using Moq; -using NUnit.Framework; - -namespace CameraUtility.Tests -{ - /// - /// Integration test checking that entire application is working as expected. External resources are faked - /// (IFileSystem and IMetadataReader). This test considers entire app as a unit or work. It is more complex - /// but should be more resilient to refactorings. - /// - [TestFixture] - [Category("Integration")] - [TestOf(typeof(Program))] - [ExcludeFromCodeCoverage] - public sealed class ProgramTests - { - private IFixture NewFixture() - { - return new Fixture().Customize(new AutoMoqCustomization()); - } - - private Mock SetupDefaultFakeFileSystem(Mock fakeFileSystem) - { - /* By default GetFiles returns empty collection. Specific tests extend this setup individually. */ - fakeFileSystem - .Setup(fs => fs.GetFiles(It.IsAny(), It.IsAny())) - .Returns(Enumerable.Empty()); - - /* Lock otherwise system-dependent behavior of Path.Combine, so tests don't have to consider if they're - * executed in Windows or Linux. */ - fakeFileSystem - .Setup(fs => fs.CombinePaths(It.IsAny())) - .Returns(paths => string.Join('/', paths)); - - return fakeFileSystem; - } - - private ITag NewCreatedDateTimeOriginalTag(string value) - { - return Mock.Of(t => - t.Directory == "Exif SubIFD" && t.Type == 0x9003 && t.Value == value); - } - - private ITag NewSubSecondsTag(string value) - { - return Mock.Of(t => - t.Directory == "Exif SubIFD" && t.Type == 0x9291 && t.Value == value); - } - - private IEnumerable NewImageFileTags(string createdDateOriginal, string subSeconds) - { - return new[] - { - NewCreatedDateTimeOriginalTag(createdDateOriginal), - NewSubSecondsTag(subSeconds) - }; - } - - private ITag NewQuickTimeCreatedTag(string value) - { - return Mock.Of(t => - t.Directory == "QuickTime Movie Header" && t.Type == 0x3 && t.Value == value); - } - - private void AssertDirectoryCreated(Mock fileSystemMock, string directory) - { - fileSystemMock.Verify(fs => fs.CreateDirectoryIfNotExists(directory, false), Times.AtLeastOnce); - } - - private void AssertFileCopied(Mock fileSystemMock, string sourceFile, string destinationFile) - { - fileSystemMock.Verify(fs => fs.CopyFileIfDoesNotExist(sourceFile, destinationFile, false), Times.Once); - } - - private void AssertFileMoved(Mock fileSystemMock, string sourceFile, string destinationFile) - { - fileSystemMock.Verify(fs => fs.MoveFileIfDoesNotExist(sourceFile, destinationFile, false), Times.Once); - } - - private Mock SetupFilesInFileSystem(IFixture fixture, string sourceDir) - { - var fileSystemMock = fixture.Freeze>(); - fileSystemMock - .Setup(fs => fs.Exists($"{sourceDir}")) - .Returns(true); - fileSystemMock - .Setup(fs => fs.IsDirectory(It.IsAny())) - .Returns(true); - SetupDefaultFakeFileSystem(fileSystemMock) - .Setup(fs => fs.GetFiles(sourceDir, "*")) - .Returns(new[] { - $"{sourceDir}/IMG_1234.JPG", - $"{sourceDir}/IMG_4231.JPEG", - $"{sourceDir}/IMG_1234.CR2", - $"{sourceDir}/MVI_1234.MP4", - $"{sourceDir}/MVI_2345.MOV" - }); - fixture - .Freeze>() - .SetupSequence(mr => mr.ExtractTags(It.IsAny())) - .Returns(NewImageFileTags("2010:01:12 13:14:15", "42")) - .Returns(NewImageFileTags("2011:02:13 14:15:16", "43")) - .Returns(NewImageFileTags("2012:03:14 15:16:17", "44")) - .Returns(new[] { NewQuickTimeCreatedTag("Fri Jun 13 14.15.16 1980") }) - .Returns(new[] { NewQuickTimeCreatedTag("Fri Jun 13 15.16.17 1980") }); - return fileSystemMock; - } - - /// - /// Basic sanity check for copy-mode. - /// - [Test] - [TestOf(nameof(Program.Execute))] - public void Execute__FiveImageFiles_CopyMode__FilesGetCopied() - { - /* Arrange */ - var fixture = NewFixture(); - fixture.Inject( - new Program.Options( - "sourceDir", - "destDir", - false, - false, - false, - false)); - var fileSystemMock = SetupFilesInFileSystem(fixture, "sourceDir"); - var sut = fixture.Create(); - - /* Act */ - sut.Execute(); - - /* Assert */ - AssertDirectoryCreated(fileSystemMock, "destDir/2010_01_12"); - AssertFileCopied( - fileSystemMock, "sourceDir/IMG_1234.JPG", "destDir/2010_01_12/IMG_20100112_131415420.JPG"); - AssertDirectoryCreated(fileSystemMock, "destDir/2011_02_13"); - AssertFileCopied( - fileSystemMock, "sourceDir/IMG_4231.JPEG", "destDir/2011_02_13/IMG_20110213_141516430.JPEG"); - AssertDirectoryCreated(fileSystemMock, "destDir/2012_03_14"); - AssertFileCopied( - fileSystemMock, "sourceDir/IMG_1234.CR2", "destDir/2012_03_14/IMG_20120314_151617440.CR2"); - AssertDirectoryCreated(fileSystemMock, "destDir/1980_06_13"); - AssertFileCopied( - fileSystemMock, "sourceDir/MVI_1234.MP4", "destDir/1980_06_13/VID_19800613_141516000.MP4"); - AssertFileCopied( - fileSystemMock, "sourceDir/MVI_2345.MOV", "destDir/1980_06_13/VID_19800613_151617000.MOV"); - } - - /// - /// Basic sanity check for move-mode. - /// - [Test] - [TestOf(nameof(Program.Execute))] - public void Execute__FourImageFiles_MoveMode__FilesGetMoved() - { - /* Arrange */ - var fixture = NewFixture(); - fixture.Inject( - new Program.Options( - "sourceDir", - "destDir", - false, - false, - true, - false)); - var fileSystemMock = SetupFilesInFileSystem(fixture, "sourceDir"); - var sut = fixture.Create(); - - /* Act */ - sut.Execute(); - - /* Assert */ - AssertDirectoryCreated(fileSystemMock, "destDir/2010_01_12"); - AssertFileMoved( - fileSystemMock, "sourceDir/IMG_1234.JPG", "destDir/2010_01_12/IMG_20100112_131415420.JPG"); - AssertDirectoryCreated(fileSystemMock, "destDir/2011_02_13"); - AssertFileMoved( - fileSystemMock, "sourceDir/IMG_4231.JPEG", "destDir/2011_02_13/IMG_20110213_141516430.JPEG"); - AssertDirectoryCreated(fileSystemMock, "destDir/2012_03_14"); - AssertFileMoved( - fileSystemMock, "sourceDir/IMG_1234.CR2", "destDir/2012_03_14/IMG_20120314_151617440.CR2"); - AssertDirectoryCreated(fileSystemMock, "destDir/1980_06_13"); - AssertFileMoved( - fileSystemMock, "sourceDir/MVI_1234.MP4", "destDir/1980_06_13/VID_19800613_141516000.MP4"); - AssertFileMoved( - fileSystemMock, "sourceDir/MVI_2345.MOV", "destDir/1980_06_13/VID_19800613_151617000.MOV"); - } - - /// - /// Basic sanity check for dry-run-mode. - /// - [TestCase(true)] - [TestCase(false)] - [TestOf(nameof(Program.Execute))] - public void Execute__FourImageFiles_PretendMode__FilesGetMoved(bool moveMode) - { - /* Arrange */ - var fixture = NewFixture(); - fixture.Inject( - new Program.Options( - "sourceDir", - "destDir", - true, - false, - moveMode, - false)); - var fileSystemMock = SetupFilesInFileSystem(fixture, "sourceDir"); - var sut = fixture.Create(); - - /* Act */ - sut.Execute(); - - /* Assert */ - fileSystemMock.Verify( - fs => fs.CreateDirectoryIfNotExists(It.IsAny(), false), - Times.Never); - fileSystemMock.Verify( - fs => fs.CopyFileIfDoesNotExist(It.IsAny(), It.IsAny(), false), - Times.Never); - fileSystemMock.Verify( - fs => fs.MoveFileIfDoesNotExist(It.IsAny(), It.IsAny(), false), - Times.Never); - } - } -} \ No newline at end of file diff --git a/CameraUtility.Tests/TransferFilesCommandsTests.cs b/CameraUtility.Tests/TransferFilesCommandsTests.cs new file mode 100644 index 0000000..46a3162 --- /dev/null +++ b/CameraUtility.Tests/TransferFilesCommandsTests.cs @@ -0,0 +1,584 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Abstractions; +using System.Linq; +using System.Threading; +using AutoFixture; +using AutoFixture.AutoMoq; +using CameraUtility.Exif; +using FluentAssertions; +using Moq; +using Xunit; + +namespace CameraUtility.Tests +{ + public static class TransferFilesCommandsTests + { + private const string SourceDirPath = "/source/directory"; + private const string DestinationDirPath = "/destination/directory"; + + private static IEnumerable NewImageFileTags( + string createdDateOriginal, + string subSeconds) + { + return new[] + { + NewCreatedDateTimeOriginalTag(createdDateOriginal), + NewSubSecondsTag(subSeconds) + }; + + static ITag NewCreatedDateTimeOriginalTag(string value) + { + return Mock.Of(t => + t.Directory == "Exif SubIFD" && t.Type == 0x9003 && t.Value == value); + } + + static ITag NewSubSecondsTag(string value) + { + return Mock.Of(t => + t.Directory == "Exif SubIFD" && t.Type == 0x9291 && t.Value == value); + } + } + + private static IEnumerable NewQuickTimeFileTags(string value) + { + return new[] + { + Mock.Of(t => + t.Directory == "QuickTime Movie Header" && t.Type == 0x3 && t.Value == value) + }; + } + + public sealed class WithDateSubdirectory + { + private static readonly IEnumerable<( + string sourceFile, + IEnumerable exifTags, + string expectedDestinationDirectory, + string expectedDestinationFile)> + FilesWithMetadata = new[] + { + ( + $"{SourceDirPath}/IMG_1234.JPG", + NewImageFileTags("2010:01:12 13:14:15", "42"), + $"{DestinationDirPath}/2010_01_12", + "IMG_20100112_131415420.JPG"), + ( + $"{SourceDirPath}/IMG_4231.JPEG", + NewImageFileTags("2011:02:13 14:15:16", "43"), + $"{DestinationDirPath}/2011_02_13", + "IMG_20110213_141516430.JPEG"), + ( + $"{SourceDirPath}/IMG_1234.CR2", + NewImageFileTags("2012:03:14 15:16:17", "44"), + $"{DestinationDirPath}/2012_03_14", + "IMG_20120314_151617440.CR2"), + ( + $"{SourceDirPath}/MVI_1234.MP4", + NewQuickTimeFileTags("Fri Jun 13 14.15.16 1980"), + $"{DestinationDirPath}/1980_06_13", + "VID_19800613_141516000.MP4"), + ( + $"{SourceDirPath}/MVI_2345.MOV", + NewQuickTimeFileTags("Fri Jun 13 15.16.17 1980"), + $"{DestinationDirPath}/1980_06_13", + "VID_19800613_151617000.MOV"), + ( /* Checks if files in sub-directories are also copied and if extension is case-insensitive */ + $"{SourceDirPath}/sub-dir/IMG_1234.jpg", + NewImageFileTags("2013:04:15 16:17:18", "45"), + $"{DestinationDirPath}/2013_04_15", + "IMG_20130415_161718450.jpg") + }; + + /// + /// Basic sanity check + /// + [Fact] + public void Copy_directory_copies_all_supported_files() + { + /* Arrange */ + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + var fileSystemMock = + fixture + .Freeze>() + .SetupDefaults() + .SetupSourceDirectoryWithFiles(SourceDirPath, FilesWithMetadata.Select(f => f.sourceFile)); + fixture + .Freeze>() + .SetupMetadata(FilesWithMetadata.Select(f => (file: f.sourceFile, tags: f.exifTags))); + var consoleTextWriterMock = new StringWriter(); + fixture.Inject(consoleTextWriterMock); + var sut = fixture.Create(); + + /* Act */ + var result = sut.Execute($"copy --src-path {SourceDirPath} --dst-dir {DestinationDirPath}".Split()); + + /* Assert */ + result.Should().Be(0); + var output = consoleTextWriterMock.ToString(); + foreach (var (file, _, expectedDestinationDirectory, expectedDestinationFile) in FilesWithMetadata) + { + VerifyDirectoryCreatedAndFileCopied(file, expectedDestinationDirectory, expectedDestinationFile); + output.Should() + .Contain($"Created {expectedDestinationDirectory}").And + .Contain($"{file} -> {expectedDestinationDirectory}/{expectedDestinationFile}"); + } + + var filesCount = FilesWithMetadata.Count(); + output.Should().EndWith( + $"Found {filesCount} camera files. Processed {filesCount}. Skipped 0. Transferred {filesCount}.\n"); + + + void VerifyDirectoryCreatedAndFileCopied( + string sourceFile, + string destinationDirectory, + string destinationFile) + { + fileSystemMock.Verify(fs => fs.Directory.CreateDirectory(destinationDirectory)); + fileSystemMock.Verify(fs => + fs.File.Copy( + sourceFile, + $"{destinationDirectory}/{destinationFile}", + false)); + } + } + + [Fact] + public void Move_directory_copies_all_supported_files() + { + /* Arrange */ + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + var fileSystemMock = + fixture + .Freeze>() + .SetupDefaults() + .SetupSourceDirectoryWithFiles(SourceDirPath, FilesWithMetadata.Select(f => f.sourceFile)); + fixture + .Freeze>() + .SetupMetadata(FilesWithMetadata.Select(f => (file: f.sourceFile, tags: f.exifTags))); + var consoleTextWriterMock = new StringWriter(); + fixture.Inject(consoleTextWriterMock); + var sut = fixture.Create(); + + /* Act */ + var result = sut.Execute($"move --src-path {SourceDirPath} --dst-dir {DestinationDirPath}".Split()); + + /* Assert */ + result.Should().Be(0); + var output = consoleTextWriterMock.ToString(); + foreach (var (file, _, expectedDestinationDirectory, expectedDestinationFile) in FilesWithMetadata) + { + VerifyDirectoryCreatedAndFileCopied(file, expectedDestinationDirectory, expectedDestinationFile); + output.Should() + .Contain($"Created {expectedDestinationDirectory}").And + .Contain($"{file} -> {expectedDestinationDirectory}/{expectedDestinationFile}"); + } + + var filesCount = FilesWithMetadata.Count(); + output.Should().EndWith( + $"Found {filesCount} camera files. Processed {filesCount}. Skipped 0. Transferred {filesCount}.\n"); + + + void VerifyDirectoryCreatedAndFileCopied( + string sourceFile, + string destinationDirectory, + string destinationFile) + { + fileSystemMock.Verify(fs => fs.Directory.CreateDirectory(destinationDirectory)); + fileSystemMock.Verify(fs => + fs.File.Move( + sourceFile, + $"{destinationDirectory}/{destinationFile}", + false)); + } + } + + [Fact] + public void Copy_directory_skips_files_which_already_exist_in_the_destination() + { + /* Arrange */ + var fileWithMetadata = + ( + sourceFile: $"{SourceDirPath}/IMG_1234.JPG", + exifTags: NewImageFileTags("2010:01:12 13:14:15", "42"), + expectedDestinationDirectory: $"{DestinationDirPath}/2010_01_12", + expectedDestinationFile: $"{DestinationDirPath}/2010_01_12/IMG_20100112_131415420.JPG"); + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + var fileSystemMock = + fixture + .Freeze>() + .SetupDefaults() + .SetupSourceDirectoryWithFiles(SourceDirPath, new[] {fileWithMetadata.sourceFile}); + fileSystemMock + .Setup(fs => fs.Directory.Exists(fileWithMetadata.expectedDestinationDirectory)) + .Returns(true); + fileSystemMock + .Setup(fs => fs.File.Exists(fileWithMetadata.expectedDestinationFile)) + .Returns(true); + fixture + .Freeze>() + .SetupMetadata(new[] {(fileWithMetadata.sourceFile, fileWithMetadata.exifTags)}); + var consoleTextWriterMock = new StringWriter(); + fixture.Inject(consoleTextWriterMock); + var sut = fixture.Create(); + + /* Act */ + var result = sut.Execute($"copy --src-path {SourceDirPath} --dst-dir {DestinationDirPath}".Split()); + + /* Assert */ + result.Should().Be(0); + fileSystemMock.Verify(fs => + fs.File.Copy(It.IsAny(), fileWithMetadata.expectedDestinationFile, It.IsAny()), + Times.Never); + + var output = consoleTextWriterMock.ToString(); + output.Should() + .NotContain($"Created {fileWithMetadata.expectedDestinationDirectory}").And + .Contain( + $"Skipped {fileWithMetadata.sourceFile} " + + $"({fileWithMetadata.expectedDestinationFile} already exists"); + output.Should().EndWith( + "Skipped 1 file(s) because they already exist at the destination.\n" + + $"{fileWithMetadata.sourceFile} exists as {fileWithMetadata.expectedDestinationFile}\n\n" + + "Found 1 camera files. Processed 1. Skipped 1. Transferred 0.\n"); + } + + [Fact] + public void Error_is_printed_when_source_directory_does_not_exist() + { + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + var consoleTextWriterMock = new StringWriter(); + fixture.Inject(consoleTextWriterMock); + var sut = fixture.Create(); + + var result = sut.Execute("copy --src-path not-existing --dst-dir output-dir".Split()); + + result.Should().NotBe(0); + var output = consoleTextWriterMock.ToString(); + output.Should().StartWith("not-existing does not exist."); + } + + [Theory] + [InlineData("copy")] + [InlineData("move")] + public void Files_are_not_transferred_in_dry_run_mode( + string command) + { + /* Arrange */ + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + var fileSystemMock = + fixture + .Freeze>() + .SetupDefaults() + .SetupSourceDirectoryWithFiles(SourceDirPath, FilesWithMetadata.Select(f => f.sourceFile)); + fixture + .Freeze>() + .SetupMetadata(FilesWithMetadata.Select(f => (file: f.sourceFile, tags: f.exifTags))); + var consoleTextWriterMock = new StringWriter(); + fixture.Inject(consoleTextWriterMock); + var sut = fixture.Create(); + + /* Act */ + var result = sut.Execute( + $"{command} --src-path {SourceDirPath} --dst-dir {DestinationDirPath} --dry-run".Split()); + + /* Assert */ + result.Should().Be(0); + var output = consoleTextWriterMock.ToString(); + fileSystemMock.Verify(fs => + fs.File.Copy(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + fileSystemMock.Verify(fs => + fs.File.Copy(It.IsAny(), It.IsAny()), + Times.Never); + fileSystemMock.Verify(fs => + fs.File.Move(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + fileSystemMock.Verify(fs => + fs.File.Move(It.IsAny(), It.IsAny()), + Times.Never); + + foreach (var (file, _, expectedDestinationDirectory, expectedDestinationFile) in FilesWithMetadata) + { + output.Should() + .NotContain($"Created {expectedDestinationDirectory}").And + .Contain($"{file} -> {expectedDestinationDirectory}/{expectedDestinationFile}"); + } + + var filesCount = FilesWithMetadata.Count(); + output.Should().EndWith( + $"Found {filesCount} camera files. Processed {filesCount}. Skipped 0. Transferred 0.\n"); + } + + [Fact] + public void First_error_stops_execution() + { + /* Arrange */ + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + var fileSystemMock = + fixture + .Freeze>() + .SetupDefaults() + .SetupSourceDirectoryWithFiles( + SourceDirPath, + new[] {$"{SourceDirPath}/IMG_1234.JPG", $"{SourceDirPath}/IMG_4231.JPEG"}); + var metadataReader = fixture.Freeze>(); + metadataReader + .Setup(mr => mr.ExtractTags($"{SourceDirPath}/IMG_1234.JPG")) + .Throws(new Exception("PROCESSING EXCEPTION")); + metadataReader + .Setup(mr => mr.ExtractTags($"{SourceDirPath}/IMG_4231.JPEG")) + .Returns(NewImageFileTags("2011:02:13 14:15:16", "43")); + + var consoleTextWriterMock = new StringWriter(); + fixture.Inject(consoleTextWriterMock); + var sut = fixture.Create(); + + /* Act */ + var result = sut.Execute($"copy --src-path {SourceDirPath} --dst-dir {DestinationDirPath}".Split()); + + /* Assert */ + result.Should().NotBe(0); + var output = consoleTextWriterMock.ToString(); + output.Should().Be( + $"Failed {SourceDirPath}/IMG_1234.JPG: PROCESSING EXCEPTION\n\n" + + "Found 2 camera files. Processed 1. Skipped 0. Transferred 0.\n"); + fileSystemMock.Verify(fs => + fs.File.Copy(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + fileSystemMock.Verify(fs => + fs.File.Copy(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public void Errors_dont_stop_execution_in_keep_going_mode() + { + /* Arrange */ + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + var fileSystemMock = + fixture + .Freeze>() + .SetupDefaults() + .SetupSourceDirectoryWithFiles( + SourceDirPath, + new[] {$"{SourceDirPath}/IMG_1234.JPG", $"{SourceDirPath}/IMG_4231.JPEG"}); + var metadataReaderStub = fixture.Freeze>(); + metadataReaderStub + .Setup(mr => mr.ExtractTags($"{SourceDirPath}/IMG_1234.JPG")) + .Throws(new Exception("PROCESSING EXCEPTION")); + metadataReaderStub + .Setup(mr => mr.ExtractTags($"{SourceDirPath}/IMG_4231.JPEG")) + .Returns(NewImageFileTags("2011:02:13 14:15:16", "43")); + + var consoleTextWriterMock = new StringWriter(); + fixture.Inject(consoleTextWriterMock); + var sut = fixture.Create(); + + /* Act */ + var result = sut.Execute( + $"copy --src-path {SourceDirPath} --dst-dir {DestinationDirPath} --keep-going".Split()); + + /* Assert */ + result.Should().NotBe(0); + var output = consoleTextWriterMock.ToString(); + output.Should().Be( + $"Failed {SourceDirPath}/IMG_1234.JPG: PROCESSING EXCEPTION\n" + + $"Created {DestinationDirPath}/2011_02_13\n" + + $"{SourceDirPath}/IMG_4231.JPEG -> {DestinationDirPath}/2011_02_13/IMG_20110213_141516430.JPEG\n\n" + + "Following errors occurred:\n" + + $"{SourceDirPath}/IMG_1234.JPG: PROCESSING EXCEPTION\n\n" + + "Found 2 camera files. Processed 2. Skipped 0. Transferred 1.\n"); + fileSystemMock.Verify(fs => + fs.File.Copy($"{SourceDirPath}/IMG_1234.JPG", It.IsAny(), It.IsAny()), + Times.Never); + fileSystemMock.Verify(fs => + fs.File.Copy( + $"{SourceDirPath}/IMG_4231.JPEG", + $"{DestinationDirPath}/2011_02_13/IMG_20110213_141516430.JPEG", + false)); + } + + [Fact] + public void Cancellation_stops_execution_and_prints_summary() + { + /* Arrange */ + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + var fileSystemMock = + fixture + .Freeze>() + .SetupDefaults() + .SetupSourceDirectoryWithFiles( + SourceDirPath, + new[] + { + $"{SourceDirPath}/IMG_1234.JPG", + $"{SourceDirPath}/IMG_4231.JPEG", + $"{SourceDirPath}/IMG_1234.CR2" + }); + var cancellationTokenSource = new CancellationTokenSource(); + fixture.Inject(cancellationTokenSource); + var metadataReaderStub = fixture.Freeze>(); + metadataReaderStub + .Setup(mr => mr.ExtractTags($"{SourceDirPath}/IMG_1234.JPG")) + .Returns(NewImageFileTags("2010:01:12 13:14:15", "42")); + metadataReaderStub + .Setup(mr => mr.ExtractTags($"{SourceDirPath}/IMG_4231.JPEG")) + .Returns(NewImageFileTags("2011:02:13 14:15:16", "43")) + /* Simulate pressing Ctrl-C after second file has been processed */ + .Callback(cancellationTokenSource.Cancel); + + var consoleTextWriterMock = new StringWriter(); + fixture.Inject(consoleTextWriterMock); + var sut = fixture.Create(); + + /* Act */ + var result = sut.Execute($"copy --src-path {SourceDirPath} --dst-dir {DestinationDirPath}".Split()); + + /* Assert */ + result.Should().NotBe(0); + var output = consoleTextWriterMock.ToString(); + output.Should().Be( + $"Created {DestinationDirPath}/2010_01_12\n" + + $"{SourceDirPath}/IMG_1234.JPG -> {DestinationDirPath}/2010_01_12/IMG_20100112_131415420.JPG\n" + + $"Created {DestinationDirPath}/2011_02_13\n" + + $"{SourceDirPath}/IMG_4231.JPEG -> {DestinationDirPath}/2011_02_13/IMG_20110213_141516430.JPEG\n\n" + + "Found 3 camera files. Processed 2. Skipped 0. Transferred 2.\n" + + "Operation interrupted by user.\n"); + fileSystemMock.Verify(fs => + fs.File.Copy( + $"{SourceDirPath}/IMG_1234.JPG", + $"{DestinationDirPath}/2010_01_12/IMG_20100112_131415420.JPG", + false)); + fileSystemMock.Verify(fs => + fs.File.Copy( + $"{SourceDirPath}/IMG_4231.JPEG", + $"{DestinationDirPath}/2011_02_13/IMG_20110213_141516430.JPEG", + false)); + fileSystemMock.Verify(fs => + fs.File.Copy( + $"{SourceDirPath}/IMG_1234.CR2", + It.IsAny(), + It.IsAny()), + Times.Never); + } + } + + public sealed class WithoutDateSubdirectory + { + private static readonly IEnumerable<( + string sourceFile, + IEnumerable exifTags, + string expectedDestinationFile)> + FilesWithMetadata = new[] + { + ( + $"{SourceDirPath}/IMG_1234.JPG", + NewImageFileTags("2010:01:12 13:14:15", "42"), + "IMG_20100112_131415420.JPG"), + ( + $"{SourceDirPath}/IMG_1234.CR2", + NewImageFileTags("2012:03:14 15:16:17", "44"), + "IMG_20120314_151617440.CR2") + }; + + [Fact] + public void Copy_directory_copies_all_supported_files() + { + /* Arrange */ + var fixture = new Fixture().Customize(new AutoMoqCustomization()); + var fileSystemMock = + fixture + .Freeze>() + .SetupDefaults() + .SetupSourceDirectoryWithFiles(SourceDirPath, FilesWithMetadata.Select(f => f.sourceFile)); + fixture + .Freeze>() + .SetupMetadata(FilesWithMetadata.Select(f => (file: f.sourceFile, tags: f.exifTags))); + var consoleTextWriterMock = new StringWriter(); + fixture.Inject(consoleTextWriterMock); + var sut = fixture.Create(); + + /* Act */ + var result = sut.Execute( + $"copy --src-path {SourceDirPath} --dst-dir {DestinationDirPath} --skip-date-subdir".Split()); + + /* Assert */ + result.Should().Be(0); + var output = consoleTextWriterMock.ToString(); + foreach (var (file, _, expectedDestinationFile) in FilesWithMetadata) + { + VerifyDirectoryCreatedAndFileCopied(file, DestinationDirPath, expectedDestinationFile); + output.Should() + .Contain($"Created {DestinationDirPath}").And + .Contain($"{file} -> {DestinationDirPath}/{expectedDestinationFile}"); + } + + var filesCount = FilesWithMetadata.Count(); + output.Should().EndWith( + $"Found {filesCount} camera files. Processed {filesCount}. Skipped 0. Transferred {filesCount}.\n"); + + + void VerifyDirectoryCreatedAndFileCopied( + string sourceFile, + string destinationDirectory, + string destinationFile) + { + fileSystemMock.Verify(fs => fs.Directory.CreateDirectory(destinationDirectory)); + fileSystemMock.Verify(fs => + fs.File.Copy( + sourceFile, + $"{destinationDirectory}/{destinationFile}", + false)); + } + } + } + } + + internal static class TestExtensions + { + internal static Mock SetupDefaults( + this Mock fileSystemMock) + { + fileSystemMock + .Setup(fs => fs.Path.Combine(It.IsAny(), It.IsAny())) + .Returns((s1, s2) => $"{s1}/{s2}"); + return fileSystemMock; + } + + internal static Mock SetupSourceDirectoryWithFiles( + this Mock fileSystemMock, + string sourceDirectoryPath, + IEnumerable files) + { + fileSystemMock + .Setup(fs => fs.Directory.Exists(sourceDirectoryPath)) + .Returns(true); + + fileSystemMock + .Setup(fs => + fs.Directory.GetFiles( + sourceDirectoryPath, + It.Is(searchPattern => searchPattern == "*" || searchPattern == string.Empty), + SearchOption.AllDirectories)) + .Returns(files.ToArray()); + + return fileSystemMock; + } + + internal static Mock SetupMetadata( + this Mock metadataReaderMock, + IEnumerable<(string file, IEnumerable tags)> filesWithExifMetadata) + { + foreach (var (file, tags) in filesWithExifMetadata) + { + metadataReaderMock + .Setup(mr => mr.ExtractTags(file)) + .Returns(tags); + } + + return metadataReaderMock; + } + } +} \ No newline at end of file diff --git a/CameraUtility.Tests/Utils/FileNameUtilTests.cs b/CameraUtility.Tests/Utils/FileNameUtilTests.cs deleted file mode 100755 index 26d7b5e..0000000 --- a/CameraUtility.Tests/Utils/FileNameUtilTests.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using CameraUtility.Utils; -using NUnit.Framework; - -namespace CameraUtility.Tests.Utils -{ - [TestFixture] - [TestOf(typeof(FileNameUtil))] - [ExcludeFromCodeCoverage] - [SuppressMessage("ReSharper", "AssignNullToNotNullAttribute")] - public class FileNameUtilTests - { - [TestCase("file.ext", ".ext")] - [TestCase("file.asd.ext", ".ext")] - [TestCase("file", "")] - [TestCase("", "")] - [TestCase("file.EXT", ".EXT")] - [TestOf(nameof(FileNameUtil.GetExtension))] - public void GetExtension_InputNotNull_ReturnsCorrectExtension( - string input, string expected) - { - /* Act */ - var result = FileNameUtil.GetExtension(input); - - /* Assert */ - Assert.AreEqual(expected, result); - } - } -} \ No newline at end of file diff --git a/CameraUtility/CameraFileCopier.cs b/CameraUtility/CameraFileCopier.cs deleted file mode 100755 index dc6f006..0000000 --- a/CameraUtility/CameraFileCopier.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System; -using System.IO; -using CameraUtility.FileSystemIsolation; - -namespace CameraUtility -{ - public sealed class CameraFileCopier : ICameraFileCopier - { - private readonly ICameraFileNameConverter _cameraFileNameConverter; - private readonly CopyOrMoveMode _copyOrMoveMode; - private readonly IFileSystem _fileSystem; - private readonly bool _pretend; - - public CameraFileCopier( - ICameraFileNameConverter cameraFileNameConverter, - IFileSystem fileSystem, - bool pretend, - CopyOrMoveMode copyOrMoveMode) - { - _cameraFileNameConverter = cameraFileNameConverter; - _fileSystem = fileSystem; - _pretend = pretend; - _copyOrMoveMode = copyOrMoveMode; - } - - /// - /// TextWriter to write output of the program to. By default it is TextWriter.Null so no output is - /// generated (useful in tests), but sets it to System.IO.Console.Out so output is - /// printed to the console. - /// - internal TextWriter? Console { private get; set; } = TextWriter.Null; - - void ICameraFileCopier.ExecuteCopyFile( - string cameraFilePath, - string destinationDirectoryRoot) - { - var (destinationPath, destinationFileFullName) = - _cameraFileNameConverter.Convert(cameraFilePath, destinationDirectoryRoot); - - if (_fileSystem.CreateDirectoryIfNotExists(destinationPath, _pretend)) - { - Console?.WriteLine($"Created {destinationPath}"); - } - - var copied = _copyOrMoveMode switch - { - CopyOrMoveMode.Copy => - _fileSystem.CopyFileIfDoesNotExist(cameraFilePath, destinationFileFullName, _pretend), - CopyOrMoveMode.Move => - _fileSystem.MoveFileIfDoesNotExist(cameraFilePath, destinationFileFullName, _pretend), - _ => throw new ArgumentOutOfRangeException() - }; - - Console?.WriteLine( - copied - ? $"{cameraFilePath} -> {destinationFileFullName}" - : $"Skipped {cameraFilePath} ({destinationFileFullName} already exists)."); - } - } -} \ No newline at end of file diff --git a/CameraUtility/CameraFileNameConverter.cs b/CameraUtility/CameraFileNameConverter.cs old mode 100755 new mode 100644 index 03aa220..f731665 --- a/CameraUtility/CameraFileNameConverter.cs +++ b/CameraUtility/CameraFileNameConverter.cs @@ -1,38 +1,37 @@ -using System; +using System; +using System.IO.Abstractions; using CameraUtility.CameraFiles; +using CameraUtility.Commands.ImageFilesTransfer.Execution; using CameraUtility.Exif; -using CameraUtility.FileSystemIsolation; namespace CameraUtility { - public sealed class CameraFileNameConverter - : ICameraFileNameConverter + internal sealed class CameraFileNameConverter { - private readonly ICameraFileFactory _cameraFileFactory; + private readonly CameraFileFactory _cameraFileFactory; private readonly IFileSystem _fileSystem; private readonly IMetadataReader _metadataReader; - public CameraFileNameConverter( + internal CameraFileNameConverter( IMetadataReader metadataReader, - ICameraFileFactory cameraFileFactory, + CameraFileFactory cameraFileFactory, IFileSystem fileSystem) { _metadataReader = metadataReader; - _fileSystem = fileSystem; _cameraFileFactory = cameraFileFactory; + _fileSystem = fileSystem; } - public bool SkipDateSubDirectory { get; set; } = false; - - (string destinationDirectory, string destinationFileFullName) ICameraFileNameConverter.Convert( - string cameraFilePath, - string destinationRootPath) + internal (string destinationDirectory, string destinationFileName) Convert( + CameraFileTransferer.Args args) { + var (cameraFilePath, destinationRootDirectory, _, skipDateSubdirectoryOption) = args; var cameraFile = GetCameraFile(cameraFilePath); - var destinationDirectory = GetDestinationDirectory(destinationRootPath, cameraFile); - var destinationFileFullName = GetDestinationFileFullName(destinationDirectory, cameraFile); + var destinationDirectory = + GetDestinationDirectory(destinationRootDirectory, cameraFile, skipDateSubdirectoryOption); + var destinationFileName = GetDestinationFileName(cameraFile); - return (destinationDirectory, destinationFileFullName); + return (destinationDirectory, destinationFileName); } private ICameraFile GetCameraFile( @@ -44,14 +43,16 @@ private ICameraFile GetCameraFile( private string GetDestinationDirectory( string destinationRootPath, - ICameraFile cameraFile) + ICameraFile cameraFile, + bool skipDateSubDirectory) { - if (SkipDateSubDirectory) + if (skipDateSubDirectory) { return destinationRootPath; } + var destinationSubDirectory = GetDateSubDirectoryName(cameraFile.Created); - return _fileSystem.CombinePaths(destinationRootPath, destinationSubDirectory); + return _fileSystem.Path.Combine(destinationRootPath, destinationSubDirectory); } private string GetDateSubDirectoryName( @@ -60,16 +61,7 @@ private string GetDateSubDirectoryName( return $"{created.Year:0000}_{created.Month:00}_{created.Day:00}"; } - private string GetDestinationFileFullName( - string destinationDirectory, - ICameraFile cameraFile) - { - var fileName = NewCameraFileName(cameraFile); - var destinationFileFullName = _fileSystem.CombinePaths(destinationDirectory, fileName); - return destinationFileFullName; - } - - private string NewCameraFileName( + private string GetDestinationFileName( ICameraFile cameraFile) { return $"{cameraFile.DestinationNamePrefix}{GetDateForFileName(cameraFile.Created)}" + diff --git a/CameraUtility/CameraFiles/CameraFileFactory.cs b/CameraUtility/CameraFiles/CameraFileFactory.cs index 6694005..5b1477a 100755 --- a/CameraUtility/CameraFiles/CameraFileFactory.cs +++ b/CameraUtility/CameraFiles/CameraFileFactory.cs @@ -5,9 +5,9 @@ namespace CameraUtility.CameraFiles { - public sealed class CameraFileFactory : ICameraFileFactory + internal sealed class CameraFileFactory { - ICameraFile ICameraFileFactory.Create(string filePath, IEnumerable metadata) + internal ICameraFile Create(string filePath, IEnumerable metadata) { switch (FileNameUtil.GetExtension(filePath).ToLowerInvariant()) { diff --git a/CameraUtility/CameraFiles/ICameraFileFactory.cs b/CameraUtility/CameraFiles/ICameraFileFactory.cs index b5c7bcf..c0bf53a 100755 --- a/CameraUtility/CameraFiles/ICameraFileFactory.cs +++ b/CameraUtility/CameraFiles/ICameraFileFactory.cs @@ -4,12 +4,12 @@ namespace CameraUtility.CameraFiles { /// - /// Selects implementation based on file's extension. + /// Selects implementation based on file's extension. /// public interface ICameraFileFactory { /// - /// Creates new implementation based on file's extension. + /// Creates new implementation based on file's extension. /// ICameraFile Create( string filePath, diff --git a/CameraUtility/CameraFiles/ImageFile.cs b/CameraUtility/CameraFiles/ImageFile.cs index 42853d0..f1bf60d 100755 --- a/CameraUtility/CameraFiles/ImageFile.cs +++ b/CameraUtility/CameraFiles/ImageFile.cs @@ -40,6 +40,8 @@ public ImageFile(string fullName, IEnumerable exifTags) private ITag FindCreatedDateTimeTag( IList exifTags) { + // TODO InvalidOperationException is not very meaningful for the user. Introduce a factory method and return + // Result return exifTags.FirstOrDefault(t => t.Type == DateTimeOriginalTagType) /* Try fallback tag, if not found then an exception will be thrown */ ?? exifTags.First(t => t.Type == FallbackDateTimeTagType); diff --git a/CameraUtility/CameraFiles/VideoFile.cs b/CameraUtility/CameraFiles/VideoFile.cs index 94c6111..f790304 100755 --- a/CameraUtility/CameraFiles/VideoFile.cs +++ b/CameraUtility/CameraFiles/VideoFile.cs @@ -17,7 +17,7 @@ public sealed class VideoFile : public VideoFile(string fullName, IEnumerable exifTags) : base(fullName) { - var createdTag = + var createdTag = exifTags.First(t => t.Directory == "QuickTime Movie Header" && t.Type == QuickTimeCreatedTag); Created = DateTime.ParseExact(createdTag.Value, "ddd MMM dd HH.mm.ss yyyy", CultureInfo.InvariantCulture); } diff --git a/CameraUtility/CameraFilesFinder.cs b/CameraUtility/CameraFilesFinder.cs index bdbd028..3fdebf0 100755 --- a/CameraUtility/CameraFilesFinder.cs +++ b/CameraUtility/CameraFilesFinder.cs @@ -1,14 +1,14 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Globalization; using System.IO; +using System.IO.Abstractions; using System.Linq; -using CameraUtility.FileSystemIsolation; +using CSharpFunctionalExtensions; namespace CameraUtility { - public sealed class CameraFilesFinder : ICameraFilesFinder + internal sealed class CameraFilesFinder { private static readonly string[] CameraFileExtensions = { @@ -22,31 +22,33 @@ public sealed class CameraFilesFinder : ICameraFilesFinder private readonly IFileSystem _fileSystem; - public CameraFilesFinder( + internal CameraFilesFinder( IFileSystem fileSystem) { _fileSystem = fileSystem; } + internal event EventHandler OnCameraFilesFound = (_, _) => { }; - IEnumerable ICameraFilesFinder.FindCameraFiles( + internal Result> FindCameraFiles( string path) { - if (!_fileSystem.Exists(path)) + if (_fileSystem.Directory.Exists(path) is false && _fileSystem.File.Exists(path) is false) { - throw new PathNotFoundException($"{path} not found"); + return Result.Failure>($"{path} does not exist."); } - return FindFilePaths(path).Where(IsCameraFile).AsParallel(); + var result = FindFilePaths(path).Where(IsCameraFile).AsParallel().ToList(); + OnCameraFilesFound(this, result.Count); + return result; } - private IEnumerable FindFilePaths( string path) { - Debug.Assert(_fileSystem.Exists(path)); - - return _fileSystem.IsDirectory(path) ? _fileSystem.GetFiles(path) : new[] {path}; + return _fileSystem.Directory.Exists(path) + ? _fileSystem.Directory.GetFiles(path, "*", SearchOption.AllDirectories) + : new[] {path}; } private bool IsCameraFile( @@ -56,13 +58,5 @@ private bool IsCameraFile( return CameraFileExtensions.Any( supportedExtension => filePath.EndsWith(supportedExtension, ignoreCase, CultureInfo.InvariantCulture)); } - - public sealed class PathNotFoundException : IOException - { - public PathNotFoundException(string? message, Exception? innerException = null) - : base(message, innerException) - { - } - } } } \ No newline at end of file diff --git a/CameraUtility/CameraUtility.csproj b/CameraUtility/CameraUtility.csproj index 6d9340b..318cfe8 100755 --- a/CameraUtility/CameraUtility.csproj +++ b/CameraUtility/CameraUtility.csproj @@ -9,7 +9,6 @@ - default enable true @@ -42,9 +41,10 @@ - - + + + \ No newline at end of file diff --git a/CameraUtility/Commands/ImageFilesTransfer/AbstractTransferImageFilesCommand.cs b/CameraUtility/Commands/ImageFilesTransfer/AbstractTransferImageFilesCommand.cs new file mode 100644 index 0000000..0ebe85a --- /dev/null +++ b/CameraUtility/Commands/ImageFilesTransfer/AbstractTransferImageFilesCommand.cs @@ -0,0 +1,103 @@ +using System.CommandLine; +using System.CommandLine.Invocation; +using CameraUtility.Commands.ImageFilesTransfer.Options; + +namespace CameraUtility.Commands.ImageFilesTransfer +{ + internal abstract class AbstractTransferImageFilesCommand : + Command + { + protected AbstractTransferImageFilesCommand( + string name, + string description, + OptionsHandler handler) + : base(name, description) + { + AddOption(new SourceOption()); + AddOption(new DestinationOption()); + AddOption(new DryRunOption()); + AddOption(new KeepGoingOption()); + AddOption(new SkipDateSubdirOption()); + + Handler = CommandHandler.Create( + (srcPath, dstDir, dryRun, keepGoing, skipDateSubdir) => + handler( + new OptionArgs( + new SourcePath(srcPath), + new DestinationDirectory(dstDir), + new DryRun(dryRun), + new KeepGoing(keepGoing), + new SkipDateSubdirectory(skipDateSubdir)))); + } + + internal delegate int OptionsHandler(OptionArgs options); + + private class SourceOption : + Option + { + public SourceOption() + : base( + new[] {"--src-path", "-s"}, + "Path to a camera file (image or video) or a directory containing camera files. " + + "When a directory is specified, all sub-directories will be scanned as well.") + { + IsRequired = true; + } + } + + private class DestinationOption : + Option + { + public DestinationOption() + : base( + new[] {"--dst-dir", "-d"}, + "Destination directory root path where files will be copied or moved into auto-created " + + "sub-directories named after file creation date (e.g. 2019_08_22/), " + + "unless --skip-date-subdir option is present.") + { + IsRequired = true; + } + } + + private class DryRunOption : + Option + { + public DryRunOption() + : base( + new[] {"--dry-run", "-n"}, + "If present, no actual files will be transferred. " + + "The output will contain information about source and destination paths.") + { + } + } + + private class KeepGoingOption : + Option + { + public KeepGoingOption() + : base( + new[] {"--keep-going", "-k"}, + "Try to continue operation when errors for individual files occur.") + { + } + } + + private class SkipDateSubdirOption : + Option + { + public SkipDateSubdirOption() + : base( + "--skip-date-subdir", + "Do not create date sub-directories in destination directory.") + { + } + } + + internal sealed record OptionArgs( + SourcePath SourcePath, + DestinationDirectory DestinationDirectory, + DryRun DryRun, + KeepGoing KeepGoing, + SkipDateSubdirectory SkipDateSubdirectory); + } +} \ No newline at end of file diff --git a/CameraUtility/Commands/ImageFilesTransfer/CopyCommand.cs b/CameraUtility/Commands/ImageFilesTransfer/CopyCommand.cs new file mode 100644 index 0000000..1efcb0c --- /dev/null +++ b/CameraUtility/Commands/ImageFilesTransfer/CopyCommand.cs @@ -0,0 +1,14 @@ +namespace CameraUtility.Commands.ImageFilesTransfer +{ + internal sealed class CopyCommand : + AbstractTransferImageFilesCommand + { + private const string DescriptionText = "Copies suppported image files between two directories."; + + public CopyCommand( + OptionsHandler handler) + : base("copy", DescriptionText, handler) + { + } + } +} \ No newline at end of file diff --git a/CameraUtility/Commands/ImageFilesTransfer/Execution/CameraFileTransferer.cs b/CameraUtility/Commands/ImageFilesTransfer/Execution/CameraFileTransferer.cs new file mode 100644 index 0000000..a2a5861 --- /dev/null +++ b/CameraUtility/Commands/ImageFilesTransfer/Execution/CameraFileTransferer.cs @@ -0,0 +1,69 @@ +using System; +using System.IO.Abstractions; +using CameraUtility.Commands.ImageFilesTransfer.Options; + +namespace CameraUtility.Commands.ImageFilesTransfer.Execution +{ + internal sealed class CameraFileTransferer + { + private readonly CameraFileNameConverter _cameraFileNameConverter; + private readonly IFileSystem _fileSystem; + private readonly TransferFiles _transferFiles; + + internal CameraFileTransferer( + CameraFileNameConverter cameraFileNameConverter, + IFileSystem fileSystem, + TransferFiles transferFiles) + { + _cameraFileNameConverter = cameraFileNameConverter; + _fileSystem = fileSystem; + _transferFiles = transferFiles; + } + + internal void TransferFile(Args args) + { + var (destinationDirectory, destinationFileName) = _cameraFileNameConverter.Convert(args); + + if (!_fileSystem.Directory.Exists(destinationDirectory) && !args.DryRun) + { + _fileSystem.Directory.CreateDirectory(destinationDirectory); + OnDirectoryCreated(this, destinationDirectory); + } + + var destinationFilePath = _fileSystem.Path.Combine(destinationDirectory, destinationFileName); + var destinationAlreadyExists = _fileSystem.File.Exists(destinationFilePath); + if (destinationAlreadyExists) + { + OnFileSkipped(this, (args.CameraFilePath, destinationFilePath)); + return; + } + + if (!args.DryRun) + { + _transferFiles(args.CameraFilePath, destinationFilePath); + } + + OnFileTransferred(this, (args.CameraFilePath, destinationFilePath, args.DryRun)); + } + + internal event EventHandler OnDirectoryCreated = + (_, _) => { }; + + internal event EventHandler<(string sourceFile, string destinationFile)> OnFileSkipped = + (_, _) => { }; + + internal event EventHandler<(string sourceFile, string destinationFile, DryRun dryRun)> OnFileTransferred = + (_, _) => { }; + + internal delegate void TransferFiles( + string sourcePath, + string destinationPath, + bool overwrite = false); + + internal sealed record Args( + string CameraFilePath, + DestinationDirectory DestinationRootDirectory, + DryRun DryRun, + SkipDateSubdirectory SkipDateSubdirectory); + } +} \ No newline at end of file diff --git a/CameraUtility/Commands/ImageFilesTransfer/Execution/IOrchestrator.cs b/CameraUtility/Commands/ImageFilesTransfer/Execution/IOrchestrator.cs new file mode 100644 index 0000000..df3d138 --- /dev/null +++ b/CameraUtility/Commands/ImageFilesTransfer/Execution/IOrchestrator.cs @@ -0,0 +1,7 @@ +namespace CameraUtility.Commands.ImageFilesTransfer.Execution +{ + internal interface IOrchestrator + { + int Execute(AbstractTransferImageFilesCommand.OptionArgs args); + } +} \ No newline at end of file diff --git a/CameraUtility/Commands/ImageFilesTransfer/Execution/Orchestrator.cs b/CameraUtility/Commands/ImageFilesTransfer/Execution/Orchestrator.cs new file mode 100644 index 0000000..89f3c0d --- /dev/null +++ b/CameraUtility/Commands/ImageFilesTransfer/Execution/Orchestrator.cs @@ -0,0 +1,63 @@ +using System; +using System.Threading; + +namespace CameraUtility.Commands.ImageFilesTransfer.Execution +{ + internal sealed class Orchestrator : + IOrchestrator + { + private const int NoErrorsResultCode = 0; + private const int ErrorResultCode = 2; + private readonly CameraFilesFinder _cameraFilesFinder; + private readonly CameraFileTransferer _cameraFileTransferer; + private readonly CancellationToken _cancellationToken; + + internal Orchestrator( + CameraFilesFinder cameraFilesFinder, + CameraFileTransferer cameraFileTransferer, + CancellationToken cancellationToken) + { + _cameraFilesFinder = cameraFilesFinder; + _cameraFileTransferer = cameraFileTransferer; + _cancellationToken = cancellationToken; + } + + int IOrchestrator.Execute(AbstractTransferImageFilesCommand.OptionArgs args) + { + var cameraFilesResult = _cameraFilesFinder.FindCameraFiles(args.SourcePath); + if (cameraFilesResult.IsFailure) + { + OnError(this, cameraFilesResult.Error); + return ErrorResultCode; + } + + var result = NoErrorsResultCode; + foreach (var cameraFilePath in cameraFilesResult.Value) + { + _cancellationToken.ThrowIfCancellationRequested(); + try + { + _cameraFileTransferer.TransferFile( + new CameraFileTransferer.Args( + cameraFilePath, args.DestinationDirectory, args.DryRun, args.SkipDateSubdirectory)); + } + catch (Exception exception) + { + OnException(this, (cameraFilePath, exception)); + if (!args.KeepGoing) + { + return ErrorResultCode; + } + + result = ErrorResultCode; + } + } + + return result; + } + + internal event EventHandler OnError = (_, _) => { }; + + internal event EventHandler<(string filePath, Exception exception)> OnException = (_, _) => { }; + } +} \ No newline at end of file diff --git a/CameraUtility/Commands/ImageFilesTransfer/MoveCommand.cs b/CameraUtility/Commands/ImageFilesTransfer/MoveCommand.cs new file mode 100644 index 0000000..fa41c40 --- /dev/null +++ b/CameraUtility/Commands/ImageFilesTransfer/MoveCommand.cs @@ -0,0 +1,14 @@ +namespace CameraUtility.Commands.ImageFilesTransfer +{ + internal sealed class MoveCommand : + AbstractTransferImageFilesCommand + { + private const string DescriptionText = "Moves suppported image files between two directories."; + + public MoveCommand( + OptionsHandler handler) + : base("move", DescriptionText, handler) + { + } + } +} \ No newline at end of file diff --git a/CameraUtility/Commands/ImageFilesTransfer/Options/DestinationDirectory.cs b/CameraUtility/Commands/ImageFilesTransfer/Options/DestinationDirectory.cs new file mode 100644 index 0000000..0af4bd4 --- /dev/null +++ b/CameraUtility/Commands/ImageFilesTransfer/Options/DestinationDirectory.cs @@ -0,0 +1,5 @@ +namespace CameraUtility.Commands.ImageFilesTransfer.Options +{ + internal sealed record DestinationDirectory(string Value) : + TypeWrapper(Value); +} \ No newline at end of file diff --git a/CameraUtility/Commands/ImageFilesTransfer/Options/DryRun.cs b/CameraUtility/Commands/ImageFilesTransfer/Options/DryRun.cs new file mode 100644 index 0000000..ad63919 --- /dev/null +++ b/CameraUtility/Commands/ImageFilesTransfer/Options/DryRun.cs @@ -0,0 +1,5 @@ +namespace CameraUtility.Commands.ImageFilesTransfer.Options +{ + internal sealed record DryRun(bool Value) : + TypeWrapper(Value); +} \ No newline at end of file diff --git a/CameraUtility/Commands/ImageFilesTransfer/Options/KeepGoing.cs b/CameraUtility/Commands/ImageFilesTransfer/Options/KeepGoing.cs new file mode 100644 index 0000000..73aa26e --- /dev/null +++ b/CameraUtility/Commands/ImageFilesTransfer/Options/KeepGoing.cs @@ -0,0 +1,5 @@ +namespace CameraUtility.Commands.ImageFilesTransfer.Options +{ + internal sealed record KeepGoing(bool Value) : + TypeWrapper(Value); +} \ No newline at end of file diff --git a/CameraUtility/Commands/ImageFilesTransfer/Options/SkipDateSubdirectory.cs b/CameraUtility/Commands/ImageFilesTransfer/Options/SkipDateSubdirectory.cs new file mode 100644 index 0000000..6d79f46 --- /dev/null +++ b/CameraUtility/Commands/ImageFilesTransfer/Options/SkipDateSubdirectory.cs @@ -0,0 +1,5 @@ +namespace CameraUtility.Commands.ImageFilesTransfer.Options +{ + internal sealed record SkipDateSubdirectory(bool Value) : + TypeWrapper(Value); +} \ No newline at end of file diff --git a/CameraUtility/Commands/ImageFilesTransfer/Options/SourcePath.cs b/CameraUtility/Commands/ImageFilesTransfer/Options/SourcePath.cs new file mode 100644 index 0000000..c466564 --- /dev/null +++ b/CameraUtility/Commands/ImageFilesTransfer/Options/SourcePath.cs @@ -0,0 +1,5 @@ +namespace CameraUtility.Commands.ImageFilesTransfer.Options +{ + internal sealed record SourcePath(string Value) : + TypeWrapper(Value); +} \ No newline at end of file diff --git a/CameraUtility/Commands/ImageFilesTransfer/Options/TypeWrapper.cs b/CameraUtility/Commands/ImageFilesTransfer/Options/TypeWrapper.cs new file mode 100644 index 0000000..1c00da6 --- /dev/null +++ b/CameraUtility/Commands/ImageFilesTransfer/Options/TypeWrapper.cs @@ -0,0 +1,16 @@ +namespace CameraUtility.Commands.ImageFilesTransfer.Options +{ + internal abstract record TypeWrapper(T Value) + { + public static implicit operator T( + TypeWrapper dryRun) + { + return dryRun.Value; + } + + public override string ToString() + { + return Value!.ToString() ?? string.Empty; + } + } +} \ No newline at end of file diff --git a/CameraUtility/Commands/ImageFilesTransfer/Output/ConsoleOutput.cs b/CameraUtility/Commands/ImageFilesTransfer/Output/ConsoleOutput.cs new file mode 100644 index 0000000..45de55e --- /dev/null +++ b/CameraUtility/Commands/ImageFilesTransfer/Output/ConsoleOutput.cs @@ -0,0 +1,84 @@ +using System; +using System.IO; + +namespace CameraUtility.Commands.ImageFilesTransfer.Output +{ + internal sealed class ConsoleOutput + { + private readonly TextWriter _textWriter; + + internal ConsoleOutput( + TextWriter textWriter) + { + _textWriter = textWriter; + } + + internal void HandleError( + string error) + { + WriteLine( + error, + ConsoleColor.Red); + } + + internal void HandleCreatedDirectory( + string directory) + { + WriteLine( + $"Created {directory}", + ConsoleColor.Blue); + } + + internal void HandleFileCopied( + string sourcePath, + string destinationPath) + { + WriteLine($"{sourcePath} -> {destinationPath}"); + } + + internal void HandleFileMoved( + string sourcePath, + string destinationPath) + { + WriteLine($"{sourcePath} -> {destinationPath}"); + } + + internal void HandleFileSkipped( + string sourcePath, + string destinationPath) + { + WriteLine( + $"Skipped {sourcePath} ({destinationPath} already exists)", + ConsoleColor.Yellow); + } + + internal void HandleException( + string sourcePath, + Exception exception) + { + WriteLine( + $"Failed {sourcePath}: {exception.Message}", + ConsoleColor.Red); + } + + private void WriteLine( + string line, + ConsoleColor? color = default) + { + var currentColor = Console.ForegroundColor; + try + { + if (color.HasValue) + { + Console.ForegroundColor = color.Value; + } + + _textWriter.WriteLine(line); + } + finally + { + Console.ForegroundColor = currentColor; + } + } + } +} \ No newline at end of file diff --git a/CameraUtility/Commands/ImageFilesTransfer/Output/Report.cs b/CameraUtility/Commands/ImageFilesTransfer/Output/Report.cs new file mode 100644 index 0000000..fba6431 --- /dev/null +++ b/CameraUtility/Commands/ImageFilesTransfer/Output/Report.cs @@ -0,0 +1,130 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using CameraUtility.Commands.ImageFilesTransfer.Options; + +namespace CameraUtility.Commands.ImageFilesTransfer.Output +{ + internal sealed class Report + { + private readonly HashSet<(string source, string destination)> _cameraFilesSkipped = new(); + private readonly List _errors = new(); + private readonly TextWriter _textWriter; + private long _cameraFilesFound; + private long _cameraFilesProcessed; + private int _cameraFilesTransferred; + + internal Report( + TextWriter textWriter) + { + _textWriter = textWriter; + } + + internal void HandleCameraFilesFound( + object? sender, + long count) + { + _cameraFilesFound = count; + } + + internal void IncrementProcessed() + { + _cameraFilesProcessed++; + } + + internal void IncrementTransferred( + DryRun dryRun) + { + if (!dryRun) + { + _cameraFilesTransferred++; + } + } + + internal void AddSkippedFile( + string sourceFileName, + string destinationFileName) + { + _cameraFilesSkipped.Add((sourceFileName, destinationFileName)); + } + + internal void AddExceptionForFile( + string fileName, + Exception exception) + { + _errors.Add($"{fileName}: {exception.Message}"); + } + + internal void PrintReport( + bool withErrors) + { + var currentColor = Console.ForegroundColor; + try + { + PrintSkippedFiles(); + if (withErrors) + { + PrintErrors(); + } + + PrintSummary(); + } + finally + { + Console.ForegroundColor = currentColor; + } + } + + private void PrintSkippedFiles() + { + if (!_cameraFilesSkipped.Any()) + { + return; + } + + _textWriter.WriteLine(); + Console.ForegroundColor = ConsoleColor.Yellow; + var stringBuilder = new StringBuilder(); + stringBuilder.AppendLine( + $"Skipped {_cameraFilesSkipped.Count} file(s) because they already exist at the destination."); + foreach (var (source, destination) in _cameraFilesSkipped) + { + stringBuilder.AppendLine($"{source} exists as {destination}"); + } + + _textWriter.Write(stringBuilder); + } + + private void PrintErrors() + { + if (!_errors.Any()) + { + return; + } + + _textWriter.WriteLine(); + Console.ForegroundColor = ConsoleColor.Red; + var stringBuilder = new StringBuilder(); + stringBuilder.AppendLine("Following errors occurred:"); + foreach (var error in _errors) + { + stringBuilder.AppendLine(error); + } + + _textWriter.Write(stringBuilder); + } + + private void PrintSummary() + { + _textWriter.WriteLine(); + Console.ForegroundColor = _errors.Any() ? ConsoleColor.Red : ConsoleColor.Green; + _textWriter.WriteLine( + $"Found {_cameraFilesFound} camera files. " + + $"Processed {_cameraFilesProcessed}. " + + $"Skipped {_cameraFilesSkipped.Count}. " + + $"Transferred {_cameraFilesTransferred}."); + } + } +} \ No newline at end of file diff --git a/CameraUtility/Commands/ImageFilesTransfer/Output/ReportingOrchestratorDecorator.cs b/CameraUtility/Commands/ImageFilesTransfer/Output/ReportingOrchestratorDecorator.cs new file mode 100644 index 0000000..059b5e7 --- /dev/null +++ b/CameraUtility/Commands/ImageFilesTransfer/Output/ReportingOrchestratorDecorator.cs @@ -0,0 +1,32 @@ +using CameraUtility.Commands.ImageFilesTransfer.Execution; + +namespace CameraUtility.Commands.ImageFilesTransfer.Output +{ + internal sealed class ReportingOrchestratorDecorator : + IOrchestrator + { + private readonly IOrchestrator _decorated; + private readonly Report _report; + + internal ReportingOrchestratorDecorator( + IOrchestrator decorated, + Report report) + { + _decorated = decorated; + _report = report; + } + + int IOrchestrator.Execute( + AbstractTransferImageFilesCommand.OptionArgs args) + { + try + { + return _decorated.Execute(args); + } + finally + { + _report.PrintReport(args.KeepGoing); + } + } + } +} \ No newline at end of file diff --git a/CameraUtility/CopyOrMoveMode.cs b/CameraUtility/CopyOrMoveMode.cs deleted file mode 100644 index 5139e0e..0000000 --- a/CameraUtility/CopyOrMoveMode.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace CameraUtility -{ - /// - /// Indicates if program is copying or moving the files. - /// - public enum CopyOrMoveMode - { - Copy, - Move - } -} \ No newline at end of file diff --git a/CameraUtility/CopyingOrchestrator.cs b/CameraUtility/CopyingOrchestrator.cs deleted file mode 100755 index 247bad6..0000000 --- a/CameraUtility/CopyingOrchestrator.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System.Threading; - -namespace CameraUtility -{ - /// - /// Orchestrator for finding images and videos in source directory, and copying them to destination directory - /// (with changed name). - /// - public sealed class CopyingOrchestrator - : ICameraDirectoryCopier - { - private readonly ICameraFilesFinder _cameraFilesFinder; - private readonly ICameraFileCopier _cameraFileCopier; - - public CopyingOrchestrator( - ICameraFilesFinder cameraFilesFinder, - ICameraFileCopier cameraFileCopier) - { - _cameraFilesFinder = cameraFilesFinder; - _cameraFileCopier = cameraFileCopier; - } - - void ICameraDirectoryCopier.CopyCameraFiles( - string sourcePath, - string destinationDirectoryRoot, - CancellationToken cancellationToken) - { - foreach (var cameraFilePath in _cameraFilesFinder.FindCameraFiles(sourcePath)) - { - cancellationToken.ThrowIfCancellationRequested(); - _cameraFileCopier.ExecuteCopyFile(cameraFilePath, destinationDirectoryRoot); - } - } - } -} \ No newline at end of file diff --git a/CameraUtility/ExceptionHandling/ExceptionHandlingCameraDirectoryCopierDecorator.cs b/CameraUtility/ExceptionHandling/ExceptionHandlingCameraDirectoryCopierDecorator.cs deleted file mode 100755 index 5f2fbe7..0000000 --- a/CameraUtility/ExceptionHandling/ExceptionHandlingCameraDirectoryCopierDecorator.cs +++ /dev/null @@ -1,33 +0,0 @@ -using System; -using System.Threading; - -namespace CameraUtility.ExceptionHandling -{ - internal sealed class ExceptionHandlingCameraDirectoryCopierDecorator - : ICameraDirectoryCopier - { - private readonly ICameraDirectoryCopier _decorated; - - internal ExceptionHandlingCameraDirectoryCopierDecorator( - ICameraDirectoryCopier decorated) - { - _decorated = decorated; - } - - void ICameraDirectoryCopier.CopyCameraFiles( - string sourcePath, - string destinationDirectoryRoot, - CancellationToken cancellationToken) - { - try - { - _decorated.CopyCameraFiles(sourcePath, destinationDirectoryRoot, cancellationToken); - } - catch (Exception exception) when (!(exception is OperationCanceledException)) - { - Console.WriteLine("An error occurred. Further processing aborted."); - Console.Error.WriteLine(exception.Message); - } - } - } -} \ No newline at end of file diff --git a/CameraUtility/ExceptionHandling/ExceptionHandlingCameraFileCopierDecorator.cs b/CameraUtility/ExceptionHandling/ExceptionHandlingCameraFileCopierDecorator.cs deleted file mode 100755 index 9e2e409..0000000 --- a/CameraUtility/ExceptionHandling/ExceptionHandlingCameraFileCopierDecorator.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; - -namespace CameraUtility.ExceptionHandling -{ - internal sealed class ExceptionHandlingCameraFileCopierDecorator - : ICameraFileCopier - { - private readonly bool _continueOnError; - private readonly ICameraFileCopier _decorated; - - public ExceptionHandlingCameraFileCopierDecorator( - ICameraFileCopier decorated, - bool continueOnError) - { - _decorated = decorated; - _continueOnError = continueOnError; - } - - public void ExecuteCopyFile( - string cameraFilePath, - string destinationDirectoryRoot) - { - try - { - _decorated.ExecuteCopyFile(cameraFilePath, destinationDirectoryRoot); - } - catch (Exception exception) when (_continueOnError && - (exception is InvalidMetadataException || - exception is InvalidFileException)) - { - } - } - } -} \ No newline at end of file diff --git a/CameraUtility/ExceptionHandling/ExceptionHandlingCameraFileFactoryDecorator.cs b/CameraUtility/ExceptionHandling/ExceptionHandlingCameraFileFactoryDecorator.cs deleted file mode 100755 index 4233c1f..0000000 --- a/CameraUtility/ExceptionHandling/ExceptionHandlingCameraFileFactoryDecorator.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using System.Collections.Generic; -using CameraUtility.CameraFiles; -using CameraUtility.Exif; - -namespace CameraUtility.ExceptionHandling -{ - internal sealed class ExceptionHandlingCameraFileFactoryDecorator - : ICameraFileFactory - { - private readonly ICameraFileFactory _decorated; - - internal ExceptionHandlingCameraFileFactoryDecorator( - ICameraFileFactory decorated) - { - _decorated = decorated; - } - - ICameraFile ICameraFileFactory.Create( - string filePath, - IEnumerable metadata) - { - try - { - return _decorated.Create(filePath, metadata); - } - catch (InvalidOperationException exception) - { - throw new InvalidMetadataException( - $"Failed to find necessary metadata in {filePath}", - exception); - } - catch (FormatException exception) - { - throw new InvalidMetadataException( - $"Failed to parse metadata for {filePath}", - exception); - } - } - } -} \ No newline at end of file diff --git a/CameraUtility/ExceptionHandling/ExceptionHandlingMetadataReaderDecorator.cs b/CameraUtility/ExceptionHandling/ExceptionHandlingMetadataReaderDecorator.cs deleted file mode 100755 index 05ad5df..0000000 --- a/CameraUtility/ExceptionHandling/ExceptionHandlingMetadataReaderDecorator.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Collections.Generic; -using CameraUtility.Exif; -using MetadataExtractor; - -namespace CameraUtility.ExceptionHandling -{ - internal sealed class ExceptionHandlingMetadataReaderDecorator - : IMetadataReader - { - private readonly IMetadataReader _decorated; - - internal ExceptionHandlingMetadataReaderDecorator( - IMetadataReader decorated) - { - _decorated = decorated; - } - - IEnumerable IMetadataReader.ExtractTags(string filePath) - { - try - { - return _decorated.ExtractTags(filePath); - } - catch (ImageProcessingException exception) - { - throw new InvalidFileException(filePath, exception); - } - } - } -} \ No newline at end of file diff --git a/CameraUtility/ExceptionHandling/InvalidFileException.cs b/CameraUtility/ExceptionHandling/InvalidFileException.cs deleted file mode 100755 index c7f305a..0000000 --- a/CameraUtility/ExceptionHandling/InvalidFileException.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; - -namespace CameraUtility.ExceptionHandling -{ - internal sealed class InvalidFileException - : Exception - { - internal InvalidFileException( - string filePath, - Exception innerException) - : base($"Failed to extract metadata from file {filePath}", innerException) - { - } - } -} \ No newline at end of file diff --git a/CameraUtility/ExceptionHandling/InvalidMetadataException.cs b/CameraUtility/ExceptionHandling/InvalidMetadataException.cs deleted file mode 100755 index f6ee904..0000000 --- a/CameraUtility/ExceptionHandling/InvalidMetadataException.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; - -namespace CameraUtility.ExceptionHandling -{ - internal sealed class InvalidMetadataException - : Exception - { - internal InvalidMetadataException( - string message, - Exception innerException) - : base(message, innerException) - { - } - } -} \ No newline at end of file diff --git a/CameraUtility/Exif/MetadataReader.cs b/CameraUtility/Exif/MetadataReader.cs index 5898286..7b50c2f 100755 --- a/CameraUtility/Exif/MetadataReader.cs +++ b/CameraUtility/Exif/MetadataReader.cs @@ -6,7 +6,7 @@ namespace CameraUtility.Exif { - public sealed class MetadataReader : IMetadataReader + internal sealed class MetadataReader : IMetadataReader { IEnumerable IMetadataReader.ExtractTags(string filePath) { diff --git a/CameraUtility/FileSystemIsolation/FileSystem.cs b/CameraUtility/FileSystemIsolation/FileSystem.cs deleted file mode 100755 index e7775d8..0000000 --- a/CameraUtility/FileSystemIsolation/FileSystem.cs +++ /dev/null @@ -1,118 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.IO; - -namespace CameraUtility.FileSystemIsolation -{ - internal sealed class FileSystem : IFileSystem - { - bool IFileSystem.CreateDirectoryIfNotExists( - string path, - bool pretend) - { - if (string.IsNullOrWhiteSpace(path)) - { - throw new ArgumentException("Value cannot be null or whitespace.", nameof(path)); - } - - var exists = Directory.Exists(path); - if (exists) - { - return false; - } - - if (!pretend) - { - Directory.CreateDirectory(path); - Debug.WriteLine($"Created {path}"); - } - - return true; - } - - bool IFileSystem.CopyFileIfDoesNotExist( - string source, - string destination, - bool pretend) - { - var copied = CopyOrMove(source, destination, pretend, File.Copy); - Debug.WriteLineIf(!pretend && copied, $"Copied {source} -> {destination}"); - return copied; - } - - bool IFileSystem.MoveFileIfDoesNotExist( - string source, - string destination, - bool pretend) - { - var moved = CopyOrMove(source, destination, pretend, File.Move); - Debug.WriteLineIf(!pretend && moved, $"Moved {source} -> {destination}"); - return moved; - } - - private bool CopyOrMove( - string source, - string destination, - bool pretend, - Action copyOrMove) - { - if (string.IsNullOrWhiteSpace(source)) - { - throw new ArgumentException("Value cannot be null or whitespace.", nameof(source)); - } - - if (string.IsNullOrWhiteSpace(destination)) - { - throw new ArgumentException("Value cannot be null or whitespace.", nameof(destination)); - } - - if (File.Exists(destination)) - { - Debug.WriteLine($"Skipped {source} -> {destination}"); - return false; - } - if (!pretend) - { - copyOrMove(source, destination, false); - } - return true; - } - - IEnumerable IFileSystem.GetFiles( - string directory, - string searchMask) - { - if (string.IsNullOrWhiteSpace(directory)) - { - throw new ArgumentException("Value cannot be null or whitespace.", nameof(directory)); - } - - if (string.IsNullOrWhiteSpace(searchMask)) - { - throw new ArgumentException("Value cannot be null or whitespace.", nameof(searchMask)); - } - - return Directory.GetFiles( - directory, - searchMask, - SearchOption.AllDirectories); - } - - string IFileSystem.CombinePaths( - params string[] paths) - { - return Path.Combine(paths); - } - - bool IFileSystem.Exists(string path) - { - return File.Exists(path) || Directory.Exists(path); - } - - bool IFileSystem.IsDirectory(string path) - { - return File.GetAttributes(path).HasFlag(FileAttributes.Directory); - } - } -} \ No newline at end of file diff --git a/CameraUtility/FileSystemIsolation/IFileSystem.cs b/CameraUtility/FileSystemIsolation/IFileSystem.cs deleted file mode 100755 index ac2cb4a..0000000 --- a/CameraUtility/FileSystemIsolation/IFileSystem.cs +++ /dev/null @@ -1,82 +0,0 @@ -using System.Collections.Generic; - -namespace CameraUtility.FileSystemIsolation -{ - /// - /// Isolates file-system operations (static Directory, File and Path classes). - /// - public interface IFileSystem - { - /// - /// Finds file names (including their paths) that match the specified search pattern in - /// and its sub-directories. - /// - /// - /// - /// The search string to match against the names of files in path. This parameter can contain a combination - /// of valid literal path and wildcard (* and ?) characters, but it does not support regular expressions. - /// - /// - IEnumerable GetFiles( - string directory, - string searchMask = "*"); - - /// - /// Checks if directory exists and if not, creates it. - /// - /// - /// - /// If true, files will not be copied, but new names will be detected and printed out. - /// - /// True if directory was created; false if it already exists. - bool CreateDirectoryIfNotExists( - string path, - bool pretend); - - /// - /// Checks if destination file exists and if not, copies source to destination. - /// - /// - /// - /// - /// If true, files will not be copied, but new names will be detected and printed out. - /// - /// True if source file was copied to destination; false if it already exists. - bool CopyFileIfDoesNotExist( - string source, - string destination, - bool pretend); - - /// - /// Checks if destination file exists and if not, moves source to destination. - /// - /// - /// - /// - /// If true, files will not be moved, but new names will be detected and printed out. - /// - /// True if source file was moved to destination; false if it already exists. - bool MoveFileIfDoesNotExist( - string source, - string destination, - bool pretend); - - /// - /// Concatenates paths with system-dependent path separator ('/' on Unix/Linux and '\' on Windows). - /// - string CombinePaths( - params string[] paths); - - /// - /// Checks if file or directory exists. - /// - bool Exists( - string path); - - /// - /// Checks if path denotes a directory. - /// - bool IsDirectory( - string path); - } -} \ No newline at end of file diff --git a/CameraUtility/ICameraDirectoryCopier.cs b/CameraUtility/ICameraDirectoryCopier.cs deleted file mode 100755 index feecf5b..0000000 --- a/CameraUtility/ICameraDirectoryCopier.cs +++ /dev/null @@ -1,29 +0,0 @@ -using System.Threading; - -namespace CameraUtility -{ - public interface ICameraDirectoryCopier - { - /// - /// Find images and videos in source directory, and copy them to destination directory (with changed name). - /// Files are copied into sub-directories of . Sub-directory - /// names are generated from files creation date (extracted from metadata), e.g. - /// {destinationDirectoryRoot}/2019_08_26/. File names are changed in the destination to a form - /// resembling Android file names, e.g. IMG_20190826_102233123.jpg or MVI_20190826_102233123.mp4. The time - /// portion of the name contains milliseconds to support photos taken with high-speed continuous shooting, - /// where more than one picture per second is taken. - /// - /// - /// Directory containing image and video files to be copied. - /// - /// - /// Directory where files will be copied to auto-created sub-directories. Sub-directory names are generated - /// from files creation date (extracted from metadata), e.g. {destinationDirectoryRoot}/2019_08_26/. - /// - /// - void CopyCameraFiles( - string sourcePath, - string destinationDirectoryRoot, - CancellationToken cancellationToken = default); - } -} \ No newline at end of file diff --git a/CameraUtility/ICameraFileCopier.cs b/CameraUtility/ICameraFileCopier.cs deleted file mode 100755 index 6fe0d7b..0000000 --- a/CameraUtility/ICameraFileCopier.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace CameraUtility -{ - public interface ICameraFileCopier - { - void ExecuteCopyFile( - string cameraFilePath, - string destinationDirectoryRoot); - } -} \ No newline at end of file diff --git a/CameraUtility/ICameraFileNameConverter.cs b/CameraUtility/ICameraFileNameConverter.cs deleted file mode 100755 index c33b6a6..0000000 --- a/CameraUtility/ICameraFileNameConverter.cs +++ /dev/null @@ -1,17 +0,0 @@ -namespace CameraUtility -{ - /// - /// Generates new name for a file, based on its type (image or video) and creation date (extracted from Exif - /// metadata). - /// - public interface ICameraFileNameConverter - { - /// - /// Returns a tuple containing destination directory (including date-sub-directory) and new file name (full - /// path). - /// - (string destinationDirectory, string destinationFileFullName) Convert( - string cameraFilePath, - string destinationRootPath); - } -} \ No newline at end of file diff --git a/CameraUtility/ICameraFilesFinder.cs b/CameraUtility/ICameraFilesFinder.cs deleted file mode 100755 index eebff00..0000000 --- a/CameraUtility/ICameraFilesFinder.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Collections.Generic; - -namespace CameraUtility -{ - public interface ICameraFilesFinder - { - /// - /// Finds all the files of supported types located in and its sub-directories. If - /// is pointing to a file, returns that file if it is of the supported type, - /// otherwise throws an exception. - /// - /// - /// - IEnumerable FindCameraFiles(string path); - } -} \ No newline at end of file diff --git a/CameraUtility/Program.cs b/CameraUtility/Program.cs old mode 100755 new mode 100644 index 099af73..e8192bf --- a/CameraUtility/Program.cs +++ b/CameraUtility/Program.cs @@ -1,121 +1,168 @@ using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; +using System.CommandLine; +using System.CommandLine.Builder; +using System.CommandLine.Parsing; +using System.IO; +using System.IO.Abstractions; +using System.Reflection; using System.Threading; using CameraUtility.CameraFiles; -using CameraUtility.ExceptionHandling; +using CameraUtility.Commands.ImageFilesTransfer; +using CameraUtility.Commands.ImageFilesTransfer.Execution; +using CameraUtility.Commands.ImageFilesTransfer.Output; using CameraUtility.Exif; -using CameraUtility.FileSystemIsolation; -using CameraUtility.Reporting; -using CommandLine; namespace CameraUtility { - public class Program + public sealed class Program { - private readonly ICameraDirectoryCopier _cameraDirectoryCopier; + private const int CancelledExitCode = 2; + private readonly CancellationTokenSource _cancellationTokenSource; - private readonly Options _options; - private readonly Report _report; - /* AutoFixture uses this constructor implicitly. It should not be made private. */ - [SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] + private readonly CopyCommand _copyCommand; + private readonly MoveCommand _moveCommand; + + private readonly TextWriter _outputWriter; + public Program( - Options options, IFileSystem fileSystem, IMetadataReader metadataReader, + TextWriter outputWriter, CancellationTokenSource cancellationTokenSource) { /* Composition Root. Out of process resources can be swapped with fakes in tests. */ - var copyOrMove = options.MoveMode ? CopyOrMoveMode.Move : CopyOrMoveMode.Copy; - _report = new Report(copyOrMove); - fileSystem = new CountingFileSystemDecorator(fileSystem, _report); - _cameraDirectoryCopier = - new ExceptionHandlingCameraDirectoryCopierDecorator( - new CopyingOrchestrator( - new CountingCameraFilesFinderDecorator( - new CameraFilesFinder( - fileSystem), - _report), - new ExceptionHandlingCameraFileCopierDecorator( - new CountingCameraFileCopierDecorator( - new CameraFileCopier( - new CameraFileNameConverter( - new ExceptionHandlingMetadataReaderDecorator( - metadataReader), - new ExceptionHandlingCameraFileFactoryDecorator( - new CameraFileFactory()), - fileSystem) - { - SkipDateSubDirectory = options.SkipDateSubDirectory - }, - fileSystem, - options.DryRun, - copyOrMove) - { - Console = Console.Out - }, - _report), - options.TryContinueOnError - ))); + _outputWriter = outputWriter; _cancellationTokenSource = cancellationTokenSource; - _options = options; - } + var report = new Report(_outputWriter); + var cameraFilesFinder = new CameraFilesFinder(fileSystem); + cameraFilesFinder.OnCameraFilesFound += report.HandleCameraFilesFound; + var cameraFileNameConverter = + new CameraFileNameConverter( + metadataReader, + new CameraFileFactory(), + fileSystem); + var consoleOutput = new ConsoleOutput(_outputWriter); + var cameraFileCopier = + new CameraFileTransferer( + cameraFileNameConverter, + fileSystem, + (s, d, o) => fileSystem.File.Copy(s, d, o)); + cameraFileCopier.OnFileTransferred += + (_, args) => consoleOutput.HandleFileCopied(args.sourceFile, args.destinationFile); + AssignEventHandlersForCameraFileTransferer(cameraFileCopier); + var internalCopyingOrchestrator = + new Orchestrator( + cameraFilesFinder, + cameraFileCopier, + _cancellationTokenSource.Token); + internalCopyingOrchestrator.OnError += + (_, error) => consoleOutput.HandleError(error); + internalCopyingOrchestrator.OnException += + (_, args) => consoleOutput.HandleException(args.filePath, args.exception); + internalCopyingOrchestrator.OnException += + (_, args) => report.AddExceptionForFile(args.filePath, args.exception); + internalCopyingOrchestrator.OnException += + (_, _) => report.IncrementProcessed(); + IOrchestrator copyingOrchestrator = + new ReportingOrchestratorDecorator( + internalCopyingOrchestrator, + report); + var cameraFileMover = + new CameraFileTransferer( + cameraFileNameConverter, + fileSystem, + (s, d, o) => fileSystem.File.Move(s, d, o)); + cameraFileMover.OnFileTransferred += + (_, args) => consoleOutput.HandleFileMoved(args.sourceFile, args.destinationFile); + AssignEventHandlersForCameraFileTransferer(cameraFileMover); + var internalMovingOrchestrator = + new Orchestrator( + cameraFilesFinder, + cameraFileMover, + _cancellationTokenSource.Token); + internalMovingOrchestrator.OnError += + (_, error) => consoleOutput.HandleError(error); + internalMovingOrchestrator.OnException += + (_, args) => consoleOutput.HandleException(args.filePath, args.exception); + internalMovingOrchestrator.OnException += + (_, args) => report.AddExceptionForFile(args.filePath, args.exception); + internalMovingOrchestrator.OnException += + (_, _) => report.IncrementProcessed(); + IOrchestrator movingOrchestrator = + new ReportingOrchestratorDecorator( + internalMovingOrchestrator, + report); - private static void Main( - string[] args) - { - var options = ParseArgs(args); - if (options is null) + _copyCommand = new CopyCommand(args => copyingOrchestrator.Execute(args)); + _moveCommand = new MoveCommand(args => movingOrchestrator.Execute(args)); + + void AssignEventHandlersForCameraFileTransferer( + CameraFileTransferer cameraFileTransferer) { - Environment.Exit(1); + cameraFileTransferer.OnDirectoryCreated += + (_, directory) => consoleOutput.HandleCreatedDirectory(directory); + cameraFileTransferer.OnFileSkipped += + (_, args) => consoleOutput.HandleFileSkipped(args.sourceFile, args.destinationFile); + cameraFileTransferer.OnFileSkipped += + (_, args) => report.AddSkippedFile(args.sourceFile, args.destinationFile); + cameraFileTransferer.OnFileSkipped += + (_, _) => report.IncrementProcessed(); + cameraFileTransferer.OnFileTransferred += + (_, _) => report.IncrementProcessed(); + cameraFileTransferer.OnFileTransferred += + (_, args) => report.IncrementTransferred(args.dryRun); } + } - /* Compose the application with "real" implementations of FileSystem and MetadataReader. */ + private static int Main(string[] args) + { + /* Compose the application with "real" dependencies. */ var program = new Program( - options, new FileSystem(), new MetadataReader(), + Console.Out, new CancellationTokenSource()); Console.CancelKeyPress += program.Abort(); + return program.Execute(args); + } + + public int Execute(params string[] args) + { + var parser = + new CommandLineBuilder( + new RootCommand + { + _copyCommand, + _moveCommand + }) + /* Workaround https://github.com/dotnet/command-line-api/issues/796#issuecomment-673083521 */ + .UseVersionOption() + .UseHelp() + .UseEnvironmentVariableDirective() + .UseParseDirective() + .UseDebugDirective() + .UseSuggestDirective() + .RegisterWithDotnetSuggest() + .UseTypoCorrections() + .UseParseErrorReporting() + .CancelOnProcessTermination() + .UseExceptionHandler((exception, _) => throw exception) + .Build(); try { - program.Execute(); - } - catch (OperationCanceledException) - { - Console.WriteLine("Operation interrupted by user."); + return parser.Invoke(args); } - finally + catch (TargetInvocationException exception) when (exception.InnerException is OperationCanceledException) { - program.PrintReport(); + _outputWriter.WriteLine("Operation interrupted by user."); + return CancelledExitCode; } } - private static Options? ParseArgs( - IEnumerable args) - { - Options? result = null; - Parser.Default.ParseArguments(args) - .WithParsed(options => result = options); - return result; - } - - public void Execute() - { - Debug.Assert(_options.SourcePath != null, "_options.SourceDirectory != null"); - Debug.Assert(_options.DestinationDirectory != null, "_options.DestinationDirectory != null"); - - _cameraDirectoryCopier.CopyCameraFiles( - _options.SourcePath, - _options.DestinationDirectory, - _cancellationTokenSource.Token); - } - private ConsoleCancelEventHandler Abort() { return (_, _) => @@ -125,60 +172,5 @@ private ConsoleCancelEventHandler Abort() Thread.Sleep(1000); }; } - - private void PrintReport() - { - var printErrors = _options.TryContinueOnError; - _report.PrintReport(printErrors); - } - - /// - /// Command line options. - /// - public sealed class Options - { - public Options( - string? sourcePath, - string? destinationDirectory, - bool dryRun, - bool tryContinueOnError, - bool moveMode, - bool skipDateSubDirectory) - { - SourcePath = sourcePath; - DestinationDirectory = destinationDirectory; - DryRun = dryRun; - TryContinueOnError = tryContinueOnError; - MoveMode = moveMode; - SkipDateSubDirectory = skipDateSubDirectory; - } - - [Option('s', "src-path", Required = true, - HelpText = "Path to a camera file (image or video) or a directory containing camera files. " + - "All sub-directories will be scanned as well.")] - public string? SourcePath { get; } - - [Option('d', "dest-dir", Required = true, - HelpText = "Destination directory root path where files will be copied into auto-created" + - " sub-directories named after file creation date (e.g. 2019_08_22/).")] - public string? DestinationDirectory { get; } - - [Option('n', "dry-run", Required = false, Default = false, - HelpText = "If present, no actual files will be copied. " + - "The output will contain information about source and destination paths.")] - public bool DryRun { get; } - - [Option('k', "keep-going", Required = false, Default = false, - HelpText = "Try to continue operation when errors for individual files occur.")] - public bool TryContinueOnError { get; } - - [Option('m', "move", Required = false, Default = false, - HelpText = "Move files instead of just copying them.")] - public bool MoveMode { get; } - - [Option("skip-date-subdir", Required = false, Default = false, - HelpText = "Do not create date sub-directories in destination directory.")] - public bool SkipDateSubDirectory { get; } - } } } \ No newline at end of file diff --git a/CameraUtility/Reporting/CountingCameraFileCopierDecorator.cs b/CameraUtility/Reporting/CountingCameraFileCopierDecorator.cs deleted file mode 100755 index 8add933..0000000 --- a/CameraUtility/Reporting/CountingCameraFileCopierDecorator.cs +++ /dev/null @@ -1,35 +0,0 @@ -using System; - -namespace CameraUtility.Reporting -{ - internal sealed class CountingCameraFileCopierDecorator - : ICameraFileCopier - { - private readonly ICameraFileCopier _decorated; - private readonly Report _report; - - public CountingCameraFileCopierDecorator( - ICameraFileCopier decorated, - Report report) - { - _decorated = decorated; - _report = report; - } - - void ICameraFileCopier.ExecuteCopyFile( - string cameraFilePath, - string destinationDirectoryRoot) - { - try - { - _decorated.ExecuteCopyFile(cameraFilePath, destinationDirectoryRoot); - _report.IncrementNumberOfFilesWithValidMetadata(); - } - catch (Exception exception) - { - _report.AddExceptionForFile(cameraFilePath, exception); - throw; - } - } - } -} \ No newline at end of file diff --git a/CameraUtility/Reporting/CountingCameraFilesFinderDecorator.cs b/CameraUtility/Reporting/CountingCameraFilesFinderDecorator.cs deleted file mode 100755 index bb68b15..0000000 --- a/CameraUtility/Reporting/CountingCameraFilesFinderDecorator.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System.Collections.Generic; -using System.Linq; - -namespace CameraUtility.Reporting -{ - internal sealed class CountingCameraFilesFinderDecorator - : ICameraFilesFinder - { - private readonly ICameraFilesFinder _decorated; - private readonly Report _report; - - - internal CountingCameraFilesFinderDecorator( - ICameraFilesFinder decorated, - Report report) - { - _decorated = decorated; - _report = report; - } - - - - IEnumerable ICameraFilesFinder.FindCameraFiles( - string path) - { - var result = _decorated.FindCameraFiles(path).ToList(); - _report.AddNumberOfFilesFoundIn(path, result.Count); - return result; - } - } -} \ No newline at end of file diff --git a/CameraUtility/Reporting/CountingFileSystemDecorator.cs b/CameraUtility/Reporting/CountingFileSystemDecorator.cs deleted file mode 100755 index 3ef76a2..0000000 --- a/CameraUtility/Reporting/CountingFileSystemDecorator.cs +++ /dev/null @@ -1,86 +0,0 @@ -using System.Collections.Generic; -using CameraUtility.FileSystemIsolation; - -namespace CameraUtility.Reporting -{ - internal sealed class CountingFileSystemDecorator - : IFileSystem - { - private readonly IFileSystem _decorated; - private readonly Report _report; - - public CountingFileSystemDecorator( - IFileSystem decorated, - Report report) - { - _decorated = decorated; - _report = report; - } - - IEnumerable IFileSystem.GetFiles(string directory, string searchMask) - { - return _decorated.GetFiles(directory, searchMask); - } - - bool IFileSystem.CreateDirectoryIfNotExists( - string path, - bool pretend) - { - return _decorated.CreateDirectoryIfNotExists(path, pretend); - } - - bool IFileSystem.CopyFileIfDoesNotExist( - string source, - string destination, - bool pretend) - { - var copied = _decorated.CopyFileIfDoesNotExist(source, destination, pretend); - ProcessReport(copied, pretend, source, destination); - return copied; - } - - bool IFileSystem.MoveFileIfDoesNotExist( - string source, - string destination, - bool pretend) - { - var moved = _decorated.MoveFileIfDoesNotExist(source, destination, pretend); - ProcessReport(moved, pretend, source, destination); - return moved; - } - - private void ProcessReport( - bool copied, - bool pretend, - string source, - string destination) - { - if (copied) - { - if (!pretend) - { - _report.IncrementNumberOfFilesCopied(); - } - } - else - { - _report.AddSkippedFile(source, destination); - } - } - - string IFileSystem.CombinePaths(params string[] paths) - { - return _decorated.CombinePaths(paths); - } - - bool IFileSystem.Exists(string path) - { - return _decorated.Exists(path); - } - - bool IFileSystem.IsDirectory(string path) - { - return _decorated.IsDirectory(path); - } - } -} \ No newline at end of file diff --git a/CameraUtility/Reporting/Report.cs b/CameraUtility/Reporting/Report.cs deleted file mode 100755 index e6c6d95..0000000 --- a/CameraUtility/Reporting/Report.cs +++ /dev/null @@ -1,155 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace CameraUtility.Reporting -{ - /// - /// Collects information about number of files processed. - /// - internal sealed class Report - { - private readonly CopyOrMoveMode _copyOrMoveMode; - - /// - /// Accumulates list of errors for printing the final summary. - /// - private readonly List _errors = new List(); - - /// - /// Files found per directory. Each execution of will add - /// to the overall sum of processed files. - /// - /// - /// This is not thread safe. If this class is used concurrently, ConcurrentDictionary should be used. - /// - private readonly Dictionary _filesFound = new Dictionary(); - - /// - /// List of files that already exist in the destination. - /// - private readonly HashSet<(string source, string destination)> _filesSkipped = - new HashSet<(string, string)>(); - - /// - /// Changes the summary message to indicate if program is copying or moving files. - /// - /// - public Report( - CopyOrMoveMode copyOrMoveMode) - { - _copyOrMoveMode = copyOrMoveMode; - } - - private int FilesFound => _filesFound.Values.Sum(); - - private int FilesMetadataRead { get; set; } - private int FilesCopied { get; set; } - - internal void AddNumberOfFilesFoundIn( - string directory, - int numberOfFiles) - { - _filesFound.Add(directory, numberOfFiles); - } - - internal void IncrementNumberOfFilesWithValidMetadata() - { - FilesMetadataRead++; - } - - internal void IncrementNumberOfFilesCopied() - { - FilesCopied++; - } - - internal void AddExceptionForFile( - string fileName, - Exception exception) - { - _errors.Add($"{fileName}: {exception.Message}"); - } - - internal void AddSkippedFile( - string sourceFileName, - string destinationFileName) - { - if (string.IsNullOrWhiteSpace(sourceFileName)) - { - throw new ArgumentException("Value cannot be null or whitespace.", nameof(sourceFileName)); - } - - if (string.IsNullOrWhiteSpace(destinationFileName)) - { - throw new ArgumentException("Value cannot be null or whitespace.", nameof(destinationFileName)); - } - - _filesSkipped.Add((sourceFileName, destinationFileName)); - } - - internal void PrintReport(bool printErrors = true) - { - var currentColor = Console.ForegroundColor; - try - { - PrintSkippedFiles(); - if (printErrors) - { - PrintErrors(); - } - - PrintSummary(); - } - finally - { - Console.ForegroundColor = currentColor; - } - } - - private void PrintSkippedFiles() - { - if (!_filesSkipped.Any()) - { - return; - } - - Console.ForegroundColor = ConsoleColor.Yellow; - Console.WriteLine($"Skipped {_filesSkipped.Count} because they already exist at the destination"); - foreach (var (source, destination) in _filesSkipped) - { - Console.WriteLine($"{source} exists as {destination}"); - } - } - - private void PrintErrors() - { - if (!_errors.Any()) - { - return; - } - - Console.ForegroundColor = ConsoleColor.Red; - Console.WriteLine("Following errors occurred:"); - foreach (var error in _errors) - { - Console.WriteLine(error); - } - } - - private void PrintSummary() - { - Console.ForegroundColor = _errors.Any() ? ConsoleColor.Red : ConsoleColor.Green; - var copiedOrMoved = _copyOrMoveMode switch - { - CopyOrMoveMode.Copy => "Copied", - CopyOrMoveMode.Move => "Moved", - _ => throw new ArgumentOutOfRangeException() - }; - Console.WriteLine( - $"Found {FilesFound} camera files. " + - $"Processed {FilesMetadataRead}. " + - $"Skipped {_filesSkipped.Count}. " + - $"{copiedOrMoved} {FilesCopied}."); - } - } -} \ No newline at end of file diff --git a/CameraUtility/Utils/FileNameUtil.cs b/CameraUtility/Utils/FileNameUtil.cs index e17a677..e514f6c 100755 --- a/CameraUtility/Utils/FileNameUtil.cs +++ b/CameraUtility/Utils/FileNameUtil.cs @@ -1,19 +1,11 @@ -using System; - -namespace CameraUtility.Utils +namespace CameraUtility.Utils { - public static class FileNameUtil + internal static class FileNameUtil { - public static string GetExtension(string imagePath) + internal static string GetExtension(string imagePath) { - try - { - return imagePath.Substring(imagePath.LastIndexOf('.')); - } - catch (ArgumentOutOfRangeException) - { - return string.Empty; - } + var extensionIndex = imagePath.LastIndexOf('.'); + return extensionIndex < 0 ? string.Empty : imagePath[extensionIndex..]; } } } \ No newline at end of file diff --git a/README.md b/README.md index e87d90a..2abf851 100644 --- a/README.md +++ b/README.md @@ -69,28 +69,41 @@ Currently camera-util finds files of following types: ## How to Use -These are the command line options: -``` - -s, --src-path Required. Path to a camera file (image or video) or a directory containing camera files. All - sub-directories will be scanned as well. - - -d, --dest-dir Required. Destination directory root path where files will be copied into auto-created - sub-directories named after file creation date (e.g. 2019_08_22/). - - -n, --dry-run (Default: false) If present, no actual files will be copied. The output will contain - information about source and destination paths. +There are two sub commands to use: `copy` and `move`: - -k, --keep-going (Default: false) Try to continue operation when errors for individual files occur. - - -m, --move (Default: false) Move files instead of just copying them. +``` +Usage: + camera-utility [options] [command] - --skip-date-subdir (Default: false) Do not create date sub-directories in destination directory. +Options: + --version Show version information + -?, -h, --help Show help and usage information - --help Display this help screen. +Commands: + copy Copy files + move Move files +``` - --version Display version information. +Both commands take the following options: ``` +Options: + -s, --src-path (REQUIRED) Path to a camera file (image or video) or a directory containing camera + files. When a directory is specified, all sub-directories will be + scanned as well. + -d, --dst-dir (REQUIRED) Destination directory root path where files will be copied or moved + into auto-created sub-directories named after file creation date (e.g. + 2019_08_22/), unless --skip-date-subdir option is present. + -n, --dry-run If present, no actual files will be transferred. The output will + contain information about source and destination paths. + -k, --keep-going Try to continue operation when errors for individual files occur. + --skip-date-subdir Do not create date sub-directories in destination directory. + -?, -h, --help Show help and usage information +``` + +The `--src-path` and `--dst-dir` are the only required +options. `--dry-run`, `--keep-going` and `--skip-date-subdir` can be +added in any combination. By default (if `--keep-going` is not used), the application will bail out on first error, e.g. if it cannot read file's EXIF metadata (this @@ -106,6 +119,24 @@ the files have valid metadata. The application is written in .NET Core, I have successfully used in on both Windows and Linux. + +### Examples + +``` +camera-utility copy --src-path /source/directory/path --dst-path /destination/path --dry-run --keep-going +``` + +``` +camera-utility move --src-path /source/directory/path --dst-path /destination/path --keep-going +``` + +``` +camera-utility copy --src-path /source/file.jpg --dst-path /destination/path --skip-date-subdir +``` + +etc. + + ## Things to Do * Force-overwrite mode.