Compare commits

...

3 Commits

Author SHA1 Message Date
3e40ab9eec Working on the gui 2023-11-04 12:08:53 +01:00
52f07cf547 Add output folder selector 2023-09-27 22:47:19 +02:00
43a2d756dc Add gui for photo renamer 2023-09-11 22:23:34 +02:00
18 changed files with 439 additions and 78 deletions

View File

@ -1,15 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.0" />
<PackageReference Include="MSTest.TestAdapter" Version="2.2.10" />
<PackageReference Include="MSTest.TestFramework" Version="2.2.10" />
<PackageReference Include="coverlet.collector" Version="3.2.0">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.7.2" />
<PackageReference Include="MSTest.TestAdapter" Version="3.1.1" />
<PackageReference Include="MSTest.TestFramework" Version="3.1.1" />
<PackageReference Include="coverlet.collector" Version="6.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@ -7,6 +7,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PhotoRenamer", "PhotoRename
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PhotoRenamer.Test", "PhotoRenamer.Test\PhotoRenamer.Test.csproj", "{07F827BC-CF99-4E81-A5C7-54085C97FC10}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PhotoRenamerGui", "PhotoRenamerGui\PhotoRenamerGui.csproj", "{733313FE-599D-49A4-A909-BBDBFB15E27D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -21,6 +23,10 @@ Global
{07F827BC-CF99-4E81-A5C7-54085C97FC10}.Debug|Any CPU.Build.0 = Debug|Any CPU
{07F827BC-CF99-4E81-A5C7-54085C97FC10}.Release|Any CPU.ActiveCfg = Release|Any CPU
{07F827BC-CF99-4E81-A5C7-54085C97FC10}.Release|Any CPU.Build.0 = Release|Any CPU
{733313FE-599D-49A4-A909-BBDBFB15E27D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{733313FE-599D-49A4-A909-BBDBFB15E27D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{733313FE-599D-49A4-A909-BBDBFB15E27D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{733313FE-599D-49A4-A909-BBDBFB15E27D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View File

@ -12,7 +12,12 @@ public static class FilesHelper
var fileExt = Path.GetExtension(file);
foreach (var supportedFileExtension in SupportedFileExtensions)
{
if (fileExt.Equals(supportedFileExtension, StringComparison.InvariantCultureIgnoreCase))
if (
fileExt.Equals(
supportedFileExtension,
StringComparison.InvariantCultureIgnoreCase
)
)
yield return file;
}
}

View File

@ -0,0 +1,25 @@
namespace PhotoRenamer;
public static class FolderExtension
{
public static string CreateFolderStructureByDate(string targetRoot, DateOnly dateTime)
{
var folder = BuildTargetDirectoryByDate(targetRoot, dateTime);
if (!Directory.Exists(folder))
{
Directory.CreateDirectory(folder);
}
return folder;
}
public static string BuildTargetDirectoryByDate(string targetRoot, DateOnly dateTime)
{
return Path.Combine(targetRoot, dateTime.Year.ToString(), dateTime.Month.ToString("D2"));
}
public static string[] GetAllFiles(string sourceRoot)
{
return Directory.GetFiles(sourceRoot, "*", SearchOption.AllDirectories);
}
}

View File

@ -0,0 +1,61 @@
using MetadataExtractor;
using MetadataExtractor.Formats.Exif;
using MetadataExtractor.Formats.QuickTime;
using Serilog;
namespace PhotoRenamer;
public static class MediaFileExtension
{
public static DateTime GetDateTimeFromSourceFile(string file)
{
DateTime dateTime;
try
{
var metadata = ImageMetadataReader.ReadMetadata(file);
dateTime =
GetDateTimeFromExif(metadata)
?? GetDateTimeFromMp4(metadata)
?? GetDateTimeFromLastWrite(file);
}
catch (ImageProcessingException e)
{
Log.Error(e, $"Error reading file information from {file}");
dateTime = GetDateTimeFromLastWrite(file);
}
return dateTime;
}
private static DateTime GetDateTimeFromLastWrite(string file)
{
var creationTime = File.GetLastWriteTime(file);
return creationTime;
}
private static DateTime? GetDateTimeFromExif(
IEnumerable<MetadataExtractor.Directory> directories
)
{
return
directories
.OfType<ExifIfd0Directory>()
.FirstOrDefault()
?.TryGetDateTime(ExifDirectoryBase.TagDateTime, out var dateTime) == true
? dateTime
: null;
}
private static DateTime? GetDateTimeFromMp4(
IEnumerable<MetadataExtractor.Directory> directories
)
{
return
directories
.OfType<QuickTimeMovieHeaderDirectory>()
.FirstOrDefault()
?.TryGetDateTime(QuickTimeMovieHeaderDirectory.TagCreated, out var dateTime) == true
? dateTime
: null;
}
}

View File

@ -2,27 +2,21 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<PropertyGroup>
<PublishAot>true</PublishAot>
<UseSystemResourceKeys>true</UseSystemResourceKeys>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="PublishAotCompressed" Version="1.0.0" />
<PackageReference Include="MetadataExtractor" Version="2.7.2" />
<PackageReference Include="PublishAotCompressed" Version="1.0.2" />
<PackageReference Include="MetadataExtractor" Version="2.8.1" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.CommandLine" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="7.0.0" />
<PackageReference Include="Serilog" Version="2.12.0" />
<PackageReference Include="Serilog" Version="3.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="4.1.0" />
<PackageReference Include="Spectre.Console" Version="0.45.0" />
<PackageReference Include="Spectre.Console" Version="0.47.0" />
</ItemGroup>
<ItemGroup>

View File

@ -7,12 +7,12 @@ internal static class Program
{
private static int Main(string[] args)
{
Log.Logger = new LoggerConfiguration().WriteTo.Console().CreateLogger();
var configuration = CreateHostBuilder(args).Build();
foreach (var (key, value) in configuration.AsEnumerable())
{
Console.WriteLine($"{{{key}: {value}}}");
Log.Information($"{{{key}: {value}}}");
}
Log.Logger = new LoggerConfiguration().WriteTo.Console().CreateLogger();
try
{
var renamer = new Renamer(configuration);
@ -28,6 +28,10 @@ internal static class Program
public static IConfigurationBuilder CreateHostBuilder(string[] args) =>
new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile(AppDomain.CurrentDomain.BaseDirectory + "\\appsettings.json", optional: true, reloadOnChange: true)
.AddJsonFile(
AppDomain.CurrentDomain.BaseDirectory + "\\appsettings.json",
optional: true,
reloadOnChange: true
)
.AddCommandLine(args);
}

View File

@ -1,11 +1,6 @@
using System.Runtime.InteropServices.JavaScript;
using MetadataExtractor;
using MetadataExtractor.Formats.Exif;
using MetadataExtractor.Formats.QuickTime;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Configuration;
using Serilog;
using Spectre.Console;
using Directory = System.IO.Directory;
namespace PhotoRenamer;
@ -17,15 +12,21 @@ internal class Renamer
public Renamer(IConfiguration configuration)
{
_sourcePath = Path.GetFullPath(configuration["Source"]);
_targetPath = Path.GetFullPath(configuration["Target"]);
_sourcePath = Path.GetFullPath(
configuration["Source"]
?? throw new NotSupportedException("Source directory needs to be set.")
);
_targetPath = Path.GetFullPath(
configuration["Target"]
?? throw new NotSupportedException("Target directory needs to be set.")
);
Log.Information($"Source path: {_sourcePath}");
Log.Information($"Target path: {_targetPath}");
}
public async Task<int> RunAsync()
{
var files = Directory.GetFiles(_sourcePath, "*", SearchOption.AllDirectories);
var files = FolderExtension.GetAllFiles(_sourcePath);
_currentCount = 0;
var po = new ParallelOptions { MaxDegreeOfParallelism = 3 };
await AnsiConsole
@ -37,24 +38,22 @@ internal class Renamer
return 0;
}
private async ValueTask Body(string file, ProgressContext progressContext, CancellationToken token)
private async ValueTask Body(
string file,
ProgressContext progressContext,
CancellationToken token
)
{
if (token.IsCancellationRequested)
return;
var task = progressContext.AddTask($"[green]{file}[/]");
try
{
DateTime dateTime;
try
{
var directories = ImageMetadataReader.ReadMetadata(file);
dateTime = GetDateTimeFromExif(directories) ?? GetDateTimeFromMp4(directories) ?? GetDateTimeFromLastWrite(file);
}
catch (ImageProcessingException e)
{
Log.Error(e, $"Error reading file information from {file}");
dateTime = GetDateTimeFromLastWrite(file);
}
var folder = CreateFolder(dateTime);
var dateTime = MediaFileExtension.GetDateTimeFromSourceFile(file);
var folder = FolderExtension.CreateFolderStructureByDate(
_targetPath,
DateOnly.FromDateTime(dateTime)
);
var progress = new Progress<double>(v => task.Value = v * 100);
await CopyFileAsync(folder, file, progress);
@ -70,22 +69,6 @@ internal class Renamer
}
}
private static DateTime GetDateTimeFromLastWrite(string file)
{
var creationTime = File.GetLastWriteTime(file);
return creationTime;
}
private static DateTime? GetDateTimeFromExif(IEnumerable<MetadataExtractor.Directory> directories)
{
return directories.OfType<ExifIfd0Directory>().FirstOrDefault()?.TryGetDateTime(ExifDirectoryBase.TagDateTime, out var dateTime) == true ? dateTime : null;
}
private static DateTime? GetDateTimeFromMp4(IEnumerable<MetadataExtractor.Directory> directories)
{
return directories.OfType<QuickTimeMovieHeaderDirectory>().FirstOrDefault()?.TryGetDateTime(QuickTimeMovieHeaderDirectory.TagCreated, out var dateTime) == true ? dateTime : null;
}
private static void ResetTimes(FileSystemInfo destination, FileSystemInfo source)
{
destination.LastWriteTime = source.LastWriteTime;
@ -94,7 +77,11 @@ internal class Renamer
destination.CreationTimeUtc = source.CreationTimeUtc;
}
private static async Task CopyFileAsync(string folder, string file, IProgress<double>? progress = null)
private static async Task CopyFileAsync(
string folder,
string file,
IProgress<double>? progress = null
)
{
var destination = new FileInfo(Path.Combine(folder, Path.GetFileName(file)));
var source = new FileInfo(file);
@ -104,22 +91,21 @@ internal class Renamer
return;
}
await using var sourceStream = File.Open(source.FullName, FileMode.Open, FileAccess.Read, FileShare.Read);
await using var targetStream = File.Open(destination.FullName, FileMode.Create, FileAccess.ReadWrite, FileShare.ReadWrite);
await using var sourceStream = File.Open(
source.FullName,
FileMode.Open,
FileAccess.Read,
FileShare.Read
);
await using var targetStream = File.Open(
destination.FullName,
FileMode.Create,
FileAccess.ReadWrite,
FileShare.ReadWrite
);
await sourceStream.CopyToAsync(targetStream, 81920, progress: progress);
ResetTimes(destination, source);
}
private string CreateFolder(DateTime dateTime)
{
var folder = Path.Combine(_targetPath, dateTime.Year.ToString(), dateTime.Month.ToString("D2"));
if (!Directory.Exists(folder))
{
Directory.CreateDirectory(folder);
}
return folder;
}
}

View File

@ -2,7 +2,13 @@
public static class StreamExtensions
{
public static async Task CopyToAsync(this Stream source, Stream destination, int bufferSize, IProgress<double>? progress = null, CancellationToken cancellationToken = default)
public static async Task CopyToAsync(
this Stream source,
Stream destination,
int bufferSize,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default
)
{
if (source == null)
throw new ArgumentNullException(nameof(source));
@ -18,9 +24,14 @@ public static class StreamExtensions
var buffer = new byte[bufferSize];
long totalBytesRead = 0;
int bytesRead;
while ((bytesRead = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) != 0)
while (
(bytesRead = await source.ReadAsync(buffer, cancellationToken).ConfigureAwait(false))
!= 0
)
{
await destination.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken).ConfigureAwait(false);
await destination
.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken)
.ConfigureAwait(false);
totalBytesRead += bytesRead;
progress?.Report(totalBytesRead / (double)source.Length);
}

View File

@ -1,4 +1,4 @@
{
"Source": "C:\\Users\\Holger\\Nextcloud\\DCIM",
"Target": "C:\\Users\\Holger\\Nextcloud\\DCIM"
"Source": "/Volumes/EOS_DIGITAL/DCIM/100CANON",
"Target": "/Users/holger/Nextcloud/SofortUploadCamera"
}

12
PhotoRenamerGui/App.axaml Normal file
View File

@ -0,0 +1,12 @@
<Application
RequestedThemeVariant="Default"
x:Class="PhotoRenamerGui.App"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- "Default" ThemeVariant follows system theme variant. "Dark" or "Light" are other available options. -->
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml" />
</Application.Styles>
</Application>

View File

@ -0,0 +1,23 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
namespace PhotoRenamerGui;
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow();
}
base.OnFrameworkInitializationCompleted();
}
}

View File

@ -0,0 +1,79 @@
<Window
Title="PhotoRenamerGui"
d:DesignHeight="450"
d:DesignWidth="800"
mc:Ignorable="d"
x:Class="PhotoRenamerGui.MainWindow"
x:DataType="photoRenamerGui:MainWindowViewModel"
xmlns="https://github.com/avaloniaui"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:photoRenamerGui="clr-namespace:PhotoRenamerGui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Window.DataContext>
<photoRenamerGui:MainWindowViewModel />
</Window.DataContext>
<Grid ColumnDefinitions="Auto,*,Auto" RowDefinitions="Auto,Auto,*">
<Grid.Styles>
<Style Selector=":is(TemplatedControl)">
<Setter Property="Margin" Value="0,5" />
</Style>
</Grid.Styles>
<Label
Content="Input Folder"
Grid.Column="0"
Grid.Row="0"
VerticalAlignment="Center"
VerticalContentAlignment="Center" />
<Label
Content="Output Folder"
Grid.Column="0"
Grid.Row="1"
VerticalAlignment="Center"
VerticalContentAlignment="Center" />
<TextBox
Grid.Column="1"
Grid.Row="0"
Text="{Binding InputFolder}"
Watermark="Input">
<TextBox.InnerRightContent>
<Button
Command="{Binding SelectInputFolderCommand}"
Content="..."
Margin="0,5,5,5"
VerticalAlignment="Stretch" />
</TextBox.InnerRightContent>
</TextBox>
<TextBox
Grid.Column="1"
Grid.Row="1"
Text="{Binding OutputFolder}"
Watermark="Output">
<TextBox.InnerRightContent>
<Button
Command="{Binding SelectOutputFolderCommand}"
Content="..."
Margin="0,5,5,5"
VerticalAlignment="Stretch" />
</TextBox.InnerRightContent>
</TextBox>
<DataGrid
AutoGenerateColumns="False"
BorderBrush="Gray"
BorderThickness="1"
Grid.Column="0"
Grid.ColumnSpan="2"
Grid.Row="2"
GridLinesVisibility="All"
IsReadOnly="True"
ItemsSource="{Binding MediaFiles}">
<DataGrid.Columns>
<DataGridCheckBoxColumn Binding="{Binding IsSelected}" />
<DataGridTextColumn Binding="{Binding SourceDirectory}" Header="Source" />
<DataGridTextColumn Binding="{Binding TargetDirectory}" Header="Target" />
</DataGrid.Columns>
</DataGrid>
</Grid>
</Window>

View File

@ -0,0 +1,11 @@
using Avalonia.Controls;
namespace PhotoRenamerGui;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
}

View File

@ -0,0 +1,78 @@
using System;
using System.Collections.ObjectModel;
using System.IO;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Platform.Storage;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PhotoRenamer;
namespace PhotoRenamerGui;
public partial class MainWindowViewModel : ObservableObject
{
[ObservableProperty] private string? _inputFolder;
[ObservableProperty] private string? _outputFolder;
public ObservableCollection<MediaFile> MediaFiles { get; } = new();
[RelayCommand]
private async Task SelectInputFolderAsync()
{
InputFolder = await SearchFolderAsync();
}
[RelayCommand]
private async Task SelectOutputFolderAsync()
{
OutputFolder = await SearchFolderAsync();
}
private static async Task<string?> SearchFolderAsync()
{
var storageProvider = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)
?.MainWindow?
.StorageProvider;
if (storageProvider is null) return "<not accessible>";
var result = await storageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions());
return result.Count > 0 ? result[0].TryGetLocalPath() : null;
}
partial void OnInputFolderChanged(string? value)
{
UpdatePreviewList();
}
partial void OnOutputFolderChanged(string? value)
{
UpdatePreviewList();
}
private void UpdatePreviewList()
{
MediaFiles.Clear();
if (string.IsNullOrWhiteSpace(InputFolder)) return;
if (string.IsNullOrWhiteSpace(OutputFolder)) return;
var allFiles = FolderExtension.GetAllFiles(InputFolder);
foreach (var file in allFiles)
{
var source = Path.GetRelativePath(InputFolder, file);
var date = MediaFileExtension.GetDateTimeFromSourceFile(file);
var target = FolderExtension.BuildTargetDirectoryByDate(OutputFolder, DateOnly.FromDateTime(date));
MediaFiles.Add(new MediaFile(true, source, Path.GetRelativePath(OutputFolder, target)));
}
//TODO
}
}
public class MediaFile(bool isSelected, string? sourceDirectory, string? targetDirectory) : ObservableObject
{
public bool IsSelected { get=> isSelected;
set => SetProperty(ref isSelected, value);
}
public string? SourceDirectory { get; } = sourceDirectory;
public string? TargetDirectory { get; } = targetDirectory;
}

View File

@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AvaloniaUseCompiledBindingsByDefault>true</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.0.5" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.0.5" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.5" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.5" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.5" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.5" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.2.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PhotoRenamer\PhotoRenamer.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,21 @@
using Avalonia;
using System;
namespace PhotoRenamerGui;
class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
}

View File

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<!-- This manifest is used on Windows only.
Don't remove it as it might cause problems with window transparency and embeded controls.
For more details visit https://learn.microsoft.com/en-us/windows/win32/sbscs/application-manifests -->
<assemblyIdentity version="1.0.0.0" name="PhotoRenamerGui.Desktop"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on
and is designed to work with. Uncomment the appropriate elements
and Windows will automatically select the most compatible environment. -->
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
</assembly>