diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..1ff0c42
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,63 @@
+###############################################################################
+# Set default behavior to automatically normalize line endings.
+###############################################################################
+* text=auto
+
+###############################################################################
+# Set default behavior for command prompt diff.
+#
+# This is need for earlier builds of msysgit that does not have it on by
+# default for csharp files.
+# Note: This is only used by command line
+###############################################################################
+#*.cs diff=csharp
+
+###############################################################################
+# Set the merge driver for project and solution files
+#
+# Merging from the command prompt will add diff markers to the files if there
+# are conflicts (Merging from VS is not affected by the settings below, in VS
+# the diff markers are never inserted). Diff markers may cause the following
+# file extensions to fail to load in VS. An alternative would be to treat
+# these files as binary and thus will always conflict and require user
+# intervention with every merge. To do so, just uncomment the entries below
+###############################################################################
+#*.sln merge=binary
+#*.csproj merge=binary
+#*.vbproj merge=binary
+#*.vcxproj merge=binary
+#*.vcproj merge=binary
+#*.dbproj merge=binary
+#*.fsproj merge=binary
+#*.lsproj merge=binary
+#*.wixproj merge=binary
+#*.modelproj merge=binary
+#*.sqlproj merge=binary
+#*.wwaproj merge=binary
+
+###############################################################################
+# behavior for image files
+#
+# image files are treated as binary by default.
+###############################################################################
+#*.jpg binary
+#*.png binary
+#*.gif binary
+
+###############################################################################
+# diff behavior for common document formats
+#
+# Convert binary document formats to text before diffing them. This feature
+# is only available from the command line. Turn it on by uncommenting the
+# entries below.
+###############################################################################
+#*.doc diff=astextplain
+#*.DOC diff=astextplain
+#*.docx diff=astextplain
+#*.DOCX diff=astextplain
+#*.dot diff=astextplain
+#*.DOT diff=astextplain
+#*.pdf diff=astextplain
+#*.PDF diff=astextplain
+#*.rtf diff=astextplain
+#*.RTF diff=astextplain
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9491a2f
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,363 @@
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+##
+## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
+
+# User-specific files
+*.rsuser
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# User-specific files (MonoDevelop/Xamarin Studio)
+*.userprefs
+
+# Mono auto generated files
+mono_crash.*
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+[Ww][Ii][Nn]32/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Oo]ut/
+[Ll]og/
+[Ll]ogs/
+
+# Visual Studio 2015/2017 cache/options directory
+.vs/
+# Uncomment if you have tasks that create the project's static files in wwwroot
+#wwwroot/
+
+# Visual Studio 2017 auto generated files
+Generated\ Files/
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# NUnit
+*.VisualState.xml
+TestResult.xml
+nunit-*.xml
+
+# Build Results of an ATL Project
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+
+# Benchmark Results
+BenchmarkDotNet.Artifacts/
+
+# .NET Core
+project.lock.json
+project.fragment.lock.json
+artifacts/
+
+# ASP.NET Scaffolding
+ScaffoldingReadMe.txt
+
+# StyleCop
+StyleCopReport.xml
+
+# Files built by Visual Studio
+*_i.c
+*_p.c
+*_h.h
+*.ilk
+*.meta
+*.obj
+*.iobj
+*.pch
+*.pdb
+*.ipdb
+*.pgc
+*.pgd
+*.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*_wpftmp.csproj
+*.log
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Chutzpah Test files
+_Chutzpah*
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opendb
+*.opensdf
+*.sdf
+*.cachefile
+*.VC.db
+*.VC.VC.opendb
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+*.sap
+
+# Visual Studio Trace Files
+*.e2e
+
+# TFS 2012 Local Workspace
+$tf/
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# TeamCity is a build add-in
+_TeamCity*
+
+# DotCover is a Code Coverage Tool
+*.dotCover
+
+# AxoCover is a Code Coverage Tool
+.axoCover/*
+!.axoCover/settings.json
+
+# Coverlet is a free, cross platform Code Coverage Tool
+coverage*.json
+coverage*.xml
+coverage*.info
+
+# Visual Studio code coverage results
+*.coverage
+*.coveragexml
+
+# NCrunch
+_NCrunch_*
+.*crunch*.local.xml
+nCrunchTemp_*
+
+# MightyMoose
+*.mm.*
+AutoTest.Net/
+
+# Web workbench (sass)
+.sass-cache/
+
+# Installshield output folder
+[Ee]xpress/
+
+# DocProject is a documentation generator add-in
+DocProject/buildhelp/
+DocProject/Help/*.HxT
+DocProject/Help/*.HxC
+DocProject/Help/*.hhc
+DocProject/Help/*.hhk
+DocProject/Help/*.hhp
+DocProject/Help/Html2
+DocProject/Help/html
+
+# Click-Once directory
+publish/
+
+# Publish Web Output
+*.[Pp]ublish.xml
+*.azurePubxml
+# Note: Comment the next line if you want to checkin your web deploy settings,
+# but database connection strings (with potential passwords) will be unencrypted
+*.pubxml
+*.publishproj
+
+# Microsoft Azure Web App publish settings. Comment the next line if you want to
+# checkin your Azure Web App publish settings, but sensitive information contained
+# in these scripts will be unencrypted
+PublishScripts/
+
+# NuGet Packages
+*.nupkg
+# NuGet Symbol Packages
+*.snupkg
+# The packages folder can be ignored because of Package Restore
+**/[Pp]ackages/*
+# except build/, which is used as an MSBuild target.
+!**/[Pp]ackages/build/
+# Uncomment if necessary however generally it will be regenerated when needed
+#!**/[Pp]ackages/repositories.config
+# NuGet v3's project.json files produces more ignorable files
+*.nuget.props
+*.nuget.targets
+
+# Microsoft Azure Build Output
+csx/
+*.build.csdef
+
+# Microsoft Azure Emulator
+ecf/
+rcf/
+
+# Windows Store app package directories and files
+AppPackages/
+BundleArtifacts/
+Package.StoreAssociation.xml
+_pkginfo.txt
+*.appx
+*.appxbundle
+*.appxupload
+
+# Visual Studio cache files
+# files ending in .cache can be ignored
+*.[Cc]ache
+# but keep track of directories ending in .cache
+!?*.[Cc]ache/
+
+# Others
+ClientBin/
+~$*
+*~
+*.dbmdl
+*.dbproj.schemaview
+*.jfm
+*.pfx
+*.publishsettings
+orleans.codegen.cs
+
+# Including strong name files can present a security risk
+# (https://github.com/github/gitignore/pull/2483#issue-259490424)
+#*.snk
+
+# Since there are multiple workflows, uncomment next line to ignore bower_components
+# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
+#bower_components/
+
+# RIA/Silverlight projects
+Generated_Code/
+
+# Backup & report files from converting an old project file
+# to a newer Visual Studio version. Backup files are not needed,
+# because we have git ;-)
+_UpgradeReport_Files/
+Backup*/
+UpgradeLog*.XML
+UpgradeLog*.htm
+ServiceFabricBackup/
+*.rptproj.bak
+
+# SQL Server files
+*.mdf
+*.ldf
+*.ndf
+
+# Business Intelligence projects
+*.rdl.data
+*.bim.layout
+*.bim_*.settings
+*.rptproj.rsuser
+*- [Bb]ackup.rdl
+*- [Bb]ackup ([0-9]).rdl
+*- [Bb]ackup ([0-9][0-9]).rdl
+
+# Microsoft Fakes
+FakesAssemblies/
+
+# GhostDoc plugin setting file
+*.GhostDoc.xml
+
+# Node.js Tools for Visual Studio
+.ntvs_analysis.dat
+node_modules/
+
+# Visual Studio 6 build log
+*.plg
+
+# Visual Studio 6 workspace options file
+*.opt
+
+# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
+*.vbw
+
+# Visual Studio LightSwitch build output
+**/*.HTMLClient/GeneratedArtifacts
+**/*.DesktopClient/GeneratedArtifacts
+**/*.DesktopClient/ModelManifest.xml
+**/*.Server/GeneratedArtifacts
+**/*.Server/ModelManifest.xml
+_Pvt_Extensions
+
+# Paket dependency manager
+.paket/paket.exe
+paket-files/
+
+# FAKE - F# Make
+.fake/
+
+# CodeRush personal settings
+.cr/personal
+
+# Python Tools for Visual Studio (PTVS)
+__pycache__/
+*.pyc
+
+# Cake - Uncomment if you are using it
+# tools/**
+# !tools/packages.config
+
+# Tabs Studio
+*.tss
+
+# Telerik's JustMock configuration file
+*.jmconfig
+
+# BizTalk build output
+*.btp.cs
+*.btm.cs
+*.odx.cs
+*.xsd.cs
+
+# OpenCover UI analysis results
+OpenCover/
+
+# Azure Stream Analytics local run output
+ASALocalRun/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
+# NVidia Nsight GPU debugger configuration file
+*.nvuser
+
+# MFractors (Xamarin productivity tool) working folder
+.mfractor/
+
+# Local History for Visual Studio
+.localhistory/
+
+# BeatPulse healthcheck temp database
+healthchecksdb
+
+# Backup folder for Package Reference Convert tool in Visual Studio 2017
+MigrationBackup/
+
+# Ionide (cross platform F# VS Code tools) working folder
+.ionide/
+
+# Fody - auto-generated XML schema
+FodyWeavers.xsd
\ No newline at end of file
diff --git a/ImageComparison.sln b/ImageComparison.sln
new file mode 100644
index 0000000..33b6d5b
--- /dev/null
+++ b/ImageComparison.sln
@@ -0,0 +1,41 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.5.33424.131
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ImageComparisonGUI", "ImageComparisonGUI\ImageComparisonGUI.csproj", "{2EB9DEEC-D4A0-47B0-B491-E2AEE3C20303}"
+EndProject
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ImageComparison", "ImageComparison\ImageComparison.csproj", "{A79455D8-BD6D-419A-AB94-73C4BD060B93}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {2EB9DEEC-D4A0-47B0-B491-E2AEE3C20303}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2EB9DEEC-D4A0-47B0-B491-E2AEE3C20303}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2EB9DEEC-D4A0-47B0-B491-E2AEE3C20303}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {2EB9DEEC-D4A0-47B0-B491-E2AEE3C20303}.Debug|x64.Build.0 = Debug|Any CPU
+ {2EB9DEEC-D4A0-47B0-B491-E2AEE3C20303}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {2EB9DEEC-D4A0-47B0-B491-E2AEE3C20303}.Release|Any CPU.Build.0 = Release|Any CPU
+ {2EB9DEEC-D4A0-47B0-B491-E2AEE3C20303}.Release|x64.ActiveCfg = Release|Any CPU
+ {2EB9DEEC-D4A0-47B0-B491-E2AEE3C20303}.Release|x64.Build.0 = Release|Any CPU
+ {A79455D8-BD6D-419A-AB94-73C4BD060B93}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A79455D8-BD6D-419A-AB94-73C4BD060B93}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A79455D8-BD6D-419A-AB94-73C4BD060B93}.Debug|x64.ActiveCfg = Debug|x64
+ {A79455D8-BD6D-419A-AB94-73C4BD060B93}.Debug|x64.Build.0 = Debug|x64
+ {A79455D8-BD6D-419A-AB94-73C4BD060B93}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A79455D8-BD6D-419A-AB94-73C4BD060B93}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A79455D8-BD6D-419A-AB94-73C4BD060B93}.Release|x64.ActiveCfg = Release|x64
+ {A79455D8-BD6D-419A-AB94-73C4BD060B93}.Release|x64.Build.0 = Release|x64
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {D5D12733-192D-4503-B06C-9DC05333AE02}
+ EndGlobalSection
+EndGlobal
diff --git a/ImageComparison/ImageComparison.csproj b/ImageComparison/ImageComparison.csproj
new file mode 100644
index 0000000..78e2896
--- /dev/null
+++ b/ImageComparison/ImageComparison.csproj
@@ -0,0 +1,17 @@
+
+
+
+ Exe
+ net7.0
+ enable
+ enable
+ AnyCPU;x64
+
+
+
+
+
+
+
+
+
diff --git a/ImageComparison/Models/CacheItem.cs b/ImageComparison/Models/CacheItem.cs
new file mode 100644
index 0000000..055e5e6
--- /dev/null
+++ b/ImageComparison/Models/CacheItem.cs
@@ -0,0 +1,30 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace ImageComparison.Models
+{
+ public class CacheItem
+ {
+ public string path;
+ public ulong scantime;
+ public ulong size;
+ public byte[]? hash;
+
+ public ulong[] hashArray {
+ get {
+ if (hash == null)
+ return Array.Empty();
+
+ ulong[] result = new ulong[(int)Math.Ceiling((decimal)hash.Length / 8)];
+
+ for (int i = 0; i * 8 < hash.Length; i++)
+ result[i] = BitConverter.ToUInt64(hash, i * 8);
+
+ return result;
+ }
+ }
+ }
+}
diff --git a/ImageComparison/Models/DeleteAction.cs b/ImageComparison/Models/DeleteAction.cs
new file mode 100644
index 0000000..099af78
--- /dev/null
+++ b/ImageComparison/Models/DeleteAction.cs
@@ -0,0 +1,9 @@
+namespace ImageComparison.Models
+{
+ public enum DeleteAction
+ {
+ Delete,
+ RecycleBin,
+ Move
+ }
+}
diff --git a/ImageComparison/Models/ImageAnalysis.cs b/ImageComparison/Models/ImageAnalysis.cs
new file mode 100644
index 0000000..3750253
--- /dev/null
+++ b/ImageComparison/Models/ImageAnalysis.cs
@@ -0,0 +1,22 @@
+namespace ImageComparison.Models
+{
+ public class ImageAnalysis
+ {
+ public FileInfo Image { get; set; }
+ public ulong[] Hash { get; set; }
+
+ public byte[] HashBlob {
+ get {
+ byte[] blob = new byte[Hash.Length * 8];
+
+ for (int i = 0; i < Hash.Length; i++) {
+ byte[] bytes = BitConverter.GetBytes(Hash[i]);
+ for(int j = 0; j < bytes.Length; j++)
+ blob[i * 8 + j] = bytes[j];
+ }
+
+ return blob;
+ }
+ }
+ }
+}
diff --git a/ImageComparison/Models/ImageMatch.cs b/ImageComparison/Models/ImageMatch.cs
new file mode 100644
index 0000000..4c0eba3
--- /dev/null
+++ b/ImageComparison/Models/ImageMatch.cs
@@ -0,0 +1,9 @@
+namespace ImageComparison.Models
+{
+ public class ImageMatch
+ {
+ public ImageAnalysis Image1 { get; set; }
+ public ImageAnalysis Image2 { get; set; }
+ public short Similarity { get; set; }
+ }
+}
diff --git a/ImageComparison/Models/NoMatch.cs b/ImageComparison/Models/NoMatch.cs
new file mode 100644
index 0000000..643835d
--- /dev/null
+++ b/ImageComparison/Models/NoMatch.cs
@@ -0,0 +1,14 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace ImageComparison.Models
+{
+ public class NoMatch
+ {
+ public string a;
+ public string b;
+ }
+}
diff --git a/ImageComparison/Models/SearchMode.cs b/ImageComparison/Models/SearchMode.cs
new file mode 100644
index 0000000..6be42fa
--- /dev/null
+++ b/ImageComparison/Models/SearchMode.cs
@@ -0,0 +1,11 @@
+namespace ImageComparison.Models
+{
+ public enum SearchMode
+ {
+ All,
+ Inclusive,
+ Exclusive,
+ ListInclusive,
+ ListExclusive
+ }
+}
diff --git a/ImageComparison/Program.cs b/ImageComparison/Program.cs
new file mode 100644
index 0000000..3751555
--- /dev/null
+++ b/ImageComparison/Program.cs
@@ -0,0 +1,2 @@
+// See https://aka.ms/new-console-template for more information
+Console.WriteLine("Hello, World!");
diff --git a/ImageComparison/Services/CacheService.cs b/ImageComparison/Services/CacheService.cs
new file mode 100644
index 0000000..353fbcf
--- /dev/null
+++ b/ImageComparison/Services/CacheService.cs
@@ -0,0 +1,167 @@
+using Dapper;
+using ImageComparison.Models;
+using Microsoft.Data.Sqlite;
+
+namespace ImageComparison.Services
+{
+ public static class CacheService
+ {
+ private static SqliteConnection connection;
+
+ public static void Init()
+ {
+ try
+ {
+ Directory.CreateDirectory(FileService.DataDirectory);
+ connection = new SqliteConnection($"Data Source={Path.Combine(FileService.DataDirectory, "Cache.db")}");
+ connection.Open();
+ connection.Execute("CREATE TABLE IF NOT EXISTS file (path TEXT NOT NULL COLLATE NOCASE, scantime INTEGER NOT NULL, size INTEGER, hashtype TEXT, hash BLOB, UNIQUE(path, hashtype))");
+ connection.Execute("CREATE TABLE IF NOT EXISTS nomatch (a TEXT NOT NULL COLLATE NOCASE, b TEXT NOT NULL COLLATE NOCASE, UNIQUE(a, b))");
+ connection.Execute("CREATE INDEX IF NOT EXISTS idxf_ht ON file(hashtype)");
+ connection.Close();
+ } catch { }
+ }
+
+ public static List GetImages(string hashtype)
+ {
+ List images = new();
+
+ try
+ {
+ connection.Open();
+
+ images = connection.Query("SELECT path, scantime, size, hash FROM file WHERE hashtype = @Hashtype", new { Hashtype = hashtype }).ToList();
+ } catch { }
+
+ connection.Close();
+
+ return images;
+ }
+
+ public static void UpdateImages(List images, string hashtype, ulong scantime)
+ {
+ try
+ {
+ connection.Open();
+ using (SqliteTransaction transaction = connection.BeginTransaction())
+ using (SqliteCommand command = connection.CreateCommand())
+ {
+ command.CommandText = "INSERT INTO file (path, scantime, size, hashtype, hash) VALUES (@Path, @Scantime, @Size, @Hashtype, @Hash) ON CONFLICT(path, hashtype) DO UPDATE SET scantime=@Scantime, size=@Size, hash=@Hash";
+ command.Parameters.AddWithValue("@Path", "C:\\");
+ command.Parameters.AddWithValue("@Scantime", 0);
+ command.Parameters.AddWithValue("@Size", 0);
+ command.Parameters.AddWithValue("@Hashtype", "");
+ command.Parameters.AddWithValue("@Hash", Array.Empty());
+
+ images.ForEach(image =>
+ {
+ command.Parameters["@Path"].Value = image.Image.FullName;
+ command.Parameters["@Scantime"].Value = scantime;
+ command.Parameters["@Size"].Value = image.Image.Length;
+ command.Parameters["@Hashtype"].Value = hashtype;
+ command.Parameters["@Hash"].Value = image.HashBlob;
+ command.ExecuteNonQuery();
+ });
+
+ transaction.Commit();
+ }
+ }
+ catch { }
+
+ connection.Close();
+ }
+
+ public static void AddNoMatch(string a, string b)
+ {
+ try
+ {
+ int order = string.Compare(a, b);
+ if (order == 0)
+ return;
+ else if (order < 0)
+ (b, a) = (a, b);
+
+ connection.Open();
+ connection.Execute("INSERT INTO nomatch (a, b) VALUES (@a, @b) ON CONFLICT(a, b) DO NOTHING", new { a, b });
+ }
+ catch { }
+
+ connection.Close();
+ }
+
+ public static void AddNoMatches(List nomatches)
+ {
+ try
+ {
+ connection.Open();
+ using (SqliteTransaction transaction = connection.BeginTransaction())
+ using (SqliteCommand command = connection.CreateCommand())
+ {
+ command.CommandText = "INSERT INTO nomatch (a, b) VALUES (@a, @b) ON CONFLICT(a, b) DO NOTHING";
+ command.Parameters.AddWithValue("@a", "");
+ command.Parameters.AddWithValue("@b", "");
+
+ nomatches.ForEach(nomatch =>
+ {
+ string a, b;
+ int order = string.Compare(nomatch.Image1.Image.FullName, nomatch.Image2.Image.FullName);
+ if (order == 0)
+ return;
+ else if (order < 0)
+ (b, a) = (nomatch.Image1.Image.FullName, nomatch.Image2.Image.FullName);
+ else
+ (a, b) = (nomatch.Image1.Image.FullName, nomatch.Image2.Image.FullName);
+
+ command.Parameters["@a"].Value = a;
+ command.Parameters["@b"].Value = b;
+ command.ExecuteNonQuery();
+ });
+
+ transaction.Commit();
+ }
+ }
+ catch { }
+
+ connection.Close();
+ }
+
+ public static List GetNoMatches()
+ {
+ List nomatches = new();
+
+ try
+ {
+ connection.Open();
+
+ nomatches = connection.Query("SELECT * FROM nomatch").ToList();
+ }
+ catch { }
+
+ connection.Close();
+
+ return nomatches;
+ }
+
+ public static void ClearImageCache()
+ {
+ try
+ {
+ connection.Open();
+ connection.Execute("DELETE FROM file");
+ } catch { }
+
+ connection.Close();
+ }
+
+ public static void ClearNoMatchCache()
+ {
+ try
+ {
+ connection.Open();
+ connection.Execute("DELETE FROM nomatch");
+ } catch { }
+
+ connection.Close();
+ }
+ }
+}
diff --git a/ImageComparison/Services/CompareService.cs b/ImageComparison/Services/CompareService.cs
new file mode 100644
index 0000000..9ccad41
--- /dev/null
+++ b/ImageComparison/Services/CompareService.cs
@@ -0,0 +1,235 @@
+using ImageComparison.Models;
+using System.Timers;
+using System.Collections.Concurrent;
+
+namespace ImageComparison.Services
+{
+ public class ImageComparerEventArgs
+ {
+ public int Current;
+ public int Target;
+ }
+
+ public static class CompareService
+ {
+ public readonly static string[] SupportedFileTypes = { ".bmp", ".dib", ".jpg", ".jpeg", ".jpe", ".png", ".pbm", ".pgm", ".ppm", ".sr", ".ras", ".tiff", ".tif", ".exr", ".jp2" };
+
+ public static event EventHandler OnProgress = delegate {};
+
+ public static List> AnalyseImages(List> searchLocations, int hashDetail, bool hashBothDirections, List? cachedAnalysis, CancellationToken token = new())
+ {
+ cachedAnalysis ??= new List();
+
+ List> analysed = new();
+
+ using (System.Timers.Timer ProgressTimer = new())
+ {
+ int target = searchLocations.SelectMany(i => i).Count();
+
+ //dont overload cpu with too many threads, leave one core free
+ int threadCount = Environment.ProcessorCount > 1 ? Environment.ProcessorCount - 1 : 1;
+
+ //update caller with current progress with events
+ ProgressTimer.Interval = 500;
+ ProgressTimer.AutoReset = true;
+ ProgressTimer.Elapsed += (object? source, ElapsedEventArgs e) =>
+ {
+ OnProgress.Invoke(null, new ImageComparerEventArgs()
+ {
+ Current = analysed.SelectMany(a => a).Count(),
+ Target = target
+ });
+ };
+ ProgressTimer.Start();
+
+ //keep searchLocations separate for later comparisons depending on search mode
+ searchLocations.ForEach(location =>
+ {
+ ConcurrentBag locationAnalysis = new();
+ analysed.Add(locationAnalysis);
+
+ Parallel.ForEach(location, new(){ MaxDegreeOfParallelism = threadCount }, file =>
+ {
+ if (token.IsCancellationRequested)
+ return;
+
+ try
+ {
+ CacheItem? cachedImage = cachedAnalysis.FirstOrDefault(c => c.path == file.FullName);
+ locationAnalysis.Add(new()
+ {
+ Image = file,
+ Hash = cachedImage != null && cachedImage.hash != null && cachedImage.scantime > (ulong)(file.LastWriteTime - new DateTime(1970, 1, 1, 0, 0, 0, 0)).TotalSeconds ? cachedImage.hashArray : CalculateHash(file.FullName, hashDetail, hashBothDirections)
+ });
+ }
+ catch (Exception e) { }
+ });
+ });
+
+ ProgressTimer.Stop();
+
+ OnProgress.Invoke(null, new ImageComparerEventArgs()
+ {
+ Current = target,
+ Target = target
+ });
+ }
+
+ return analysed.Select(a => a.ToList()).ToList();
+ }
+
+ public static List SearchForDuplicates(List> analysedLocations, int matchThreashold, SearchMode mode, List? nomatches, CancellationToken token = new())
+ {
+ nomatches ??= new();
+
+ switch(mode)
+ {
+ case SearchMode.ListExclusive:
+ //calculate if many locations or many files within each location given
+ double filesPerLocation = ((double)analysedLocations.Count * analysedLocations.SelectMany(i => i).Count()) / analysedLocations.Count;
+
+ //dont overload cpu with too many threads, leave one core free
+ int threadCount = Environment.ProcessorCount > 1 ? Environment.ProcessorCount - 1 : 1;
+
+ ConcurrentBag comparisons = new();
+
+ //Run in parallel if more locations than file per location, else run files within locations in parallel
+ Parallel.ForEach(analysedLocations, new() { MaxDegreeOfParallelism = filesPerLocation <= analysedLocations.Count ? threadCount : 1 }, (images, state, currentLocation) =>
+ {
+ if (token.IsCancellationRequested)
+ return;
+
+ Parallel.ForEach(images, new() { MaxDegreeOfParallelism = filesPerLocation > analysedLocations.Count ? threadCount : 1 }, (image) =>
+ {
+ for(int location = (int)currentLocation + 1; location < analysedLocations.Count; location++)
+ {
+
+ analysedLocations[location].ForEach(comparer =>
+ {
+ if (token.IsCancellationRequested)
+ return;
+
+ short similarity = CalculateSimilarity(image.Hash, comparer.Hash);
+ if (similarity >= matchThreashold && !IsNoMatch(nomatches, image.Image.FullName, comparer.Image.FullName))
+ {
+ comparisons.Add(new()
+ {
+ Image1 = image,
+ Image2 = comparer,
+ Similarity = similarity
+ });
+ }
+ });
+ }
+ });
+ });
+
+ return SortMatches(comparisons);
+ case SearchMode.ListInclusive:
+ return analysedLocations
+ .SelectMany(location => SearchForDuplicates(location, matchThreashold, nomatches, token))
+ .ToList();
+ case SearchMode.Exclusive:
+ return SearchForDuplicates(
+ analysedLocations
+ .SelectMany(location =>
+ location
+ .GroupBy(directory => directory.Image.DirectoryName)
+ .Select(image => image.ToList())
+ .ToList()
+ )
+ .ToList(),
+ matchThreashold,
+ SearchMode.ListExclusive,
+ nomatches,
+ token);
+ case SearchMode.Inclusive:
+ return analysedLocations
+ .SelectMany(images => {
+ return images
+ .GroupBy(image => image.Image.DirectoryName)
+ .SelectMany(directory => SearchForDuplicates(directory.ToList(), matchThreashold, nomatches, token));
+ })
+ .ToList();
+ default:
+ return SearchForDuplicates(analysedLocations.SelectMany(images => images).ToList(), matchThreashold, nomatches, token);
+ }
+ }
+
+ public static List SearchForDuplicates(List images, int matchThreashold, List nomatches, CancellationToken token = new())
+ {
+ nomatches ??= new();
+
+ ConcurrentBag comparisons = new();
+
+ //dont overload cpu with too many threads, leave one core free
+ int threadCount = Environment.ProcessorCount > 1 ? Environment.ProcessorCount - 1 : 1;
+
+ Parallel.ForEach(images, new() { MaxDegreeOfParallelism = threadCount }, (image, state, index) =>
+ {
+ for (int i = Convert.ToInt32(index) + 1; i < images.Count; i++)
+ {
+
+ if (token.IsCancellationRequested)
+ return;
+
+ short similarity = CalculateSimilarity(image.Hash, images[i].Hash);
+ if (similarity >= matchThreashold && !IsNoMatch(nomatches, image.Image.FullName, images[i].Image.FullName))
+ {
+ comparisons.Add(new()
+ {
+ Image1 = image,
+ Image2 = images[i],
+ Similarity = similarity
+ });
+ }
+ }
+ });
+
+ return SortMatches(comparisons);
+ }
+
+ //Calculate Hash Values by ImageHash (Dr. Neal Krawetz algorithms)
+ private static ulong[] CalculateHash(string file, int detail, bool bothDirections)
+ {
+ using (Stream stream = File.OpenRead(file))
+ {
+ return HashService.DHash(Image.Load(stream), detail, bothDirections);
+ }
+ }
+
+ private static short CalculateSimilarity(ulong[] hash1, ulong[] hash2)
+ {
+ return HashService.Similarity(hash1, hash2);
+ }
+
+ private static List SortMatches(ConcurrentBag comparisons)
+ {
+ List matches = comparisons.ToList();
+ matches.Sort((a,b) =>
+ {
+ int result = b.Similarity - a.Similarity;
+ if (result == 0)
+ return string.Compare(a.Image1.Image.FullName, b.Image1.Image.FullName);
+
+ return result;
+ });
+
+ return matches;
+ }
+
+ private static bool IsNoMatch(List nomatches, string a, string b)
+ {
+ if(nomatches.Count == 0)
+ return false;
+
+ int order = string.Compare(a, b);
+ if (order == 0)
+ return true;
+ else if (order < 0)
+ (b, a) = (a, b);
+
+ return nomatches.Any(n => n.a == a && n.b == b);
+ }
+ }
+}
diff --git a/ImageComparison/Services/FileService.cs b/ImageComparison/Services/FileService.cs
new file mode 100644
index 0000000..4f52cec
--- /dev/null
+++ b/ImageComparison/Services/FileService.cs
@@ -0,0 +1,83 @@
+using ImageComparison.Models;
+using Microsoft.VisualBasic.FileIO;
+using System.Collections.Immutable;
+
+namespace ImageComparison.Services
+{
+ public static class FileService
+ {
+ public static readonly string DataDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "DerEffi", "ImageComparison");
+
+ public static void DeleteFile(string path, DeleteAction deleteAction = DeleteAction.Delete, string target = "Duplicates\\", bool relativeTarget = true)
+ {
+ if (path == null || !File.Exists(path))
+ throw new FileNotFoundException();
+
+ switch(deleteAction)
+ {
+ case DeleteAction.Delete:
+ File.Delete(path);
+ break;
+ case DeleteAction.RecycleBin:
+ FileSystem.DeleteFile(path, UIOption.OnlyErrorDialogs, RecycleOption.SendToRecycleBin);
+ break;
+ case DeleteAction.Move:
+
+ if (string.IsNullOrEmpty(Path.GetDirectoryName(path)))
+ throw new DirectoryNotFoundException();
+
+ string targetPath = relativeTarget ? Path.Combine(Path.GetDirectoryName(path), target) : target;
+
+ if (File.Exists(targetPath))
+ throw new IOException();
+
+ if(!Directory.Exists(targetPath))
+ Directory.CreateDirectory(targetPath);
+
+ string targetFile = Path.Combine(targetPath + Path.GetFileName(path));
+ int counter = 0;
+ while (File.Exists(targetFile))
+ {
+ targetFile = Path.Combine(targetPath, Path.GetFileNameWithoutExtension(path) + "-" + ++counter + Path.GetExtension(path));
+ }
+
+ File.Move(path, targetFile);
+
+ break;
+ }
+ }
+
+ public static List> GetProcessableFiles(string[] searchLocations, bool searchSubdirectories)
+ {
+ List> directories = new();
+
+ foreach(string location in searchLocations)
+ {
+ List directory = new();
+
+ if (string.IsNullOrEmpty(location) || !Directory.Exists(location))
+ continue;
+
+ try
+ {
+ List current = Directory
+ .GetFiles(location, $"*.*", System.IO.SearchOption.TopDirectoryOnly)
+ .Where(path => CompareService.SupportedFileTypes.Any(ext => path.ToLower().EndsWith(ext)))
+ .Select(path => new FileInfo(path))
+ .ToList();
+
+ if(current.Count != 0)
+ directory.AddRange(current);
+
+ if(searchSubdirectories)
+ directory.AddRange(GetProcessableFiles(Directory.GetDirectories(location), true).SelectMany(i => i));
+ } catch { }
+
+ if (directory.Count != 0)
+ directories.Add(directory);
+ }
+
+ return directories;
+ }
+ }
+}
diff --git a/ImageComparison/Services/HashService.cs b/ImageComparison/Services/HashService.cs
new file mode 100644
index 0000000..9f561f3
--- /dev/null
+++ b/ImageComparison/Services/HashService.cs
@@ -0,0 +1,105 @@
+using SixLabors.ImageSharp.PixelFormats;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.Processing;
+using System.Diagnostics.CodeAnalysis;
+using System.Collections;
+using System.Numerics;
+using ImageComparison.Models;
+
+namespace ImageComparison.Services
+{
+ public static class HashService
+ {
+ public static int Version = 1;
+
+ public static ulong[] DHash(Image image, int detail = 8, bool bothDirections = false)
+ {
+ if (image == null)
+ {
+ throw new ArgumentNullException(nameof(image));
+ }
+
+ int bothDirectionsNumber = Convert.ToInt32(bothDirections);
+
+ image.Mutate(ctx => ctx
+ .AutoOrient()
+ .Resize(detail + 1, detail + bothDirectionsNumber)
+ .Grayscale(GrayscaleMode.Bt601));
+
+ int pixelCount = detail * detail * (bothDirectionsNumber + 1);
+ int currentHashIndex = 0;
+ ulong[] hash = new ulong[(int)Math.Ceiling((double)pixelCount / 64)]; //reserve number of ulongs to hold bits of pixel comparisons
+
+ image.ProcessPixelRows((imageAccessor) =>
+ {
+ ulong mask = 1UL << 63;
+ Span lastRow = bothDirections ? imageAccessor.GetRowSpan(0) : null;
+
+ for (var y = bothDirectionsNumber; y < detail + bothDirectionsNumber; y++)
+ {
+ Span row = imageAccessor.GetRowSpan(y);
+ Rgba32 leftPixel = row[0];
+
+ for (var index = 1; index < detail + 1; index++)
+ {
+ //if current ulong is full, switch to next and reset mask
+ if (mask == 0)
+ {
+ currentHashIndex++;
+ mask = 1UL << 63;
+ }
+
+ Rgba32 rightPixel = row[index];
+ if (leftPixel.R < rightPixel.R)
+ {
+ hash[currentHashIndex] |= mask;
+ }
+
+ leftPixel = rightPixel;
+ mask >>= 1;
+
+ if(bothDirections)
+ {
+ if(rightPixel.R < lastRow[index].R)
+ hash[currentHashIndex] |= mask;
+ mask >>= 1;
+ }
+ }
+
+ if (bothDirections)
+ lastRow = row;
+ }
+
+ });
+
+ return hash;
+ }
+
+ public static short Similarity(ulong[] hash1, ulong[] hash2)
+ {
+ if((hash2 == null) || hash1.Length != hash2.Length)
+ throw new ArgumentOutOfRangeException(nameof(hash2));
+
+ int hashLength = hash2.Length * 64;
+
+ return Convert.ToInt16(Math.Floor((double)(hashLength - HammingDistance(hash1, hash2)) * 10000 / hashLength));
+ }
+
+ private static int HammingDistance(ulong[] hash1, ulong[] hash2)
+ {
+ int bitcount = 0;
+ for(int i = 0; i < hash1.Length; i++)
+ {
+ bitcount += HammingWeight(hash1[i] ^ hash2[i]);
+ }
+ return bitcount;
+ }
+
+ private static int HammingWeight(ulong i)
+ {
+ i -= ((i >> 1) & 0x5555555555555555UL);
+ i = (i & 0x3333333333333333UL) + ((i >> 2) & 0x3333333333333333UL);
+ return (int)(unchecked(((i + (i >> 4)) & 0xF0F0F0F0F0F0F0FUL) * 0x101010101010101UL) >> 56);
+ }
+ }
+}
diff --git a/ImageComparisonGUI/.gitignore b/ImageComparisonGUI/.gitignore
new file mode 100644
index 0000000..8afdcb6
--- /dev/null
+++ b/ImageComparisonGUI/.gitignore
@@ -0,0 +1,454 @@
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+##
+## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
+
+# User-specific files
+*.rsuser
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# User-specific files (MonoDevelop/Xamarin Studio)
+*.userprefs
+
+# Mono auto generated files
+mono_crash.*
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+[Ww][Ii][Nn]32/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Ll]og/
+[Ll]ogs/
+
+# Visual Studio 2015/2017 cache/options directory
+.vs/
+# Uncomment if you have tasks that create the project's static files in wwwroot
+#wwwroot/
+
+# Visual Studio 2017 auto generated files
+Generated\ Files/
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# NUnit
+*.VisualState.xml
+TestResult.xml
+nunit-*.xml
+
+# Build Results of an ATL Project
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+
+# Benchmark Results
+BenchmarkDotNet.Artifacts/
+
+# .NET Core
+project.lock.json
+project.fragment.lock.json
+artifacts/
+
+# Tye
+.tye/
+
+# ASP.NET Scaffolding
+ScaffoldingReadMe.txt
+
+# StyleCop
+StyleCopReport.xml
+
+# Files built by Visual Studio
+*_i.c
+*_p.c
+*_h.h
+*.ilk
+*.meta
+*.obj
+*.iobj
+*.pch
+*.pdb
+*.ipdb
+*.pgc
+*.pgd
+*.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*_wpftmp.csproj
+*.log
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Chutzpah Test files
+_Chutzpah*
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opendb
+*.opensdf
+*.sdf
+*.cachefile
+*.VC.db
+*.VC.VC.opendb
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+*.sap
+
+# Visual Studio Trace Files
+*.e2e
+
+# TFS 2012 Local Workspace
+$tf/
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# TeamCity is a build add-in
+_TeamCity*
+
+# DotCover is a Code Coverage Tool
+*.dotCover
+
+# AxoCover is a Code Coverage Tool
+.axoCover/*
+!.axoCover/settings.json
+
+# Coverlet is a free, cross platform Code Coverage Tool
+coverage*.json
+coverage*.xml
+coverage*.info
+
+# Visual Studio code coverage results
+*.coverage
+*.coveragexml
+
+# NCrunch
+_NCrunch_*
+.*crunch*.local.xml
+nCrunchTemp_*
+
+# MightyMoose
+*.mm.*
+AutoTest.Net/
+
+# Web workbench (sass)
+.sass-cache/
+
+# Installshield output folder
+[Ee]xpress/
+
+# DocProject is a documentation generator add-in
+DocProject/buildhelp/
+DocProject/Help/*.HxT
+DocProject/Help/*.HxC
+DocProject/Help/*.hhc
+DocProject/Help/*.hhk
+DocProject/Help/*.hhp
+DocProject/Help/Html2
+DocProject/Help/html
+
+# Click-Once directory
+publish/
+
+# Publish Web Output
+*.[Pp]ublish.xml
+*.azurePubxml
+# Note: Comment the next line if you want to checkin your web deploy settings,
+# but database connection strings (with potential passwords) will be unencrypted
+*.pubxml
+*.publishproj
+
+# Microsoft Azure Web App publish settings. Comment the next line if you want to
+# checkin your Azure Web App publish settings, but sensitive information contained
+# in these scripts will be unencrypted
+PublishScripts/
+
+# NuGet Packages
+*.nupkg
+# NuGet Symbol Packages
+*.snupkg
+# The packages folder can be ignored because of Package Restore
+**/[Pp]ackages/*
+# except build/, which is used as an MSBuild target.
+!**/[Pp]ackages/build/
+# Uncomment if necessary however generally it will be regenerated when needed
+#!**/[Pp]ackages/repositories.config
+# NuGet v3's project.json files produces more ignorable files
+*.nuget.props
+*.nuget.targets
+
+# Microsoft Azure Build Output
+csx/
+*.build.csdef
+
+# Microsoft Azure Emulator
+ecf/
+rcf/
+
+# Windows Store app package directories and files
+AppPackages/
+BundleArtifacts/
+Package.StoreAssociation.xml
+_pkginfo.txt
+*.appx
+*.appxbundle
+*.appxupload
+
+# Visual Studio cache files
+# files ending in .cache can be ignored
+*.[Cc]ache
+# but keep track of directories ending in .cache
+!?*.[Cc]ache/
+
+# Others
+ClientBin/
+~$*
+*~
+*.dbmdl
+*.dbproj.schemaview
+*.jfm
+*.pfx
+*.publishsettings
+orleans.codegen.cs
+
+# Including strong name files can present a security risk
+# (https://github.com/github/gitignore/pull/2483#issue-259490424)
+#*.snk
+
+# Since there are multiple workflows, uncomment next line to ignore bower_components
+# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
+#bower_components/
+
+# RIA/Silverlight projects
+Generated_Code/
+
+# Backup & report files from converting an old project file
+# to a newer Visual Studio version. Backup files are not needed,
+# because we have git ;-)
+_UpgradeReport_Files/
+Backup*/
+UpgradeLog*.XML
+UpgradeLog*.htm
+ServiceFabricBackup/
+*.rptproj.bak
+
+# SQL Server files
+*.mdf
+*.ldf
+*.ndf
+
+# Business Intelligence projects
+*.rdl.data
+*.bim.layout
+*.bim_*.settings
+*.rptproj.rsuser
+*- [Bb]ackup.rdl
+*- [Bb]ackup ([0-9]).rdl
+*- [Bb]ackup ([0-9][0-9]).rdl
+
+# Microsoft Fakes
+FakesAssemblies/
+
+# GhostDoc plugin setting file
+*.GhostDoc.xml
+
+# Node.js Tools for Visual Studio
+.ntvs_analysis.dat
+node_modules/
+
+# Visual Studio 6 build log
+*.plg
+
+# Visual Studio 6 workspace options file
+*.opt
+
+# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
+*.vbw
+
+# Visual Studio LightSwitch build output
+**/*.HTMLClient/GeneratedArtifacts
+**/*.DesktopClient/GeneratedArtifacts
+**/*.DesktopClient/ModelManifest.xml
+**/*.Server/GeneratedArtifacts
+**/*.Server/ModelManifest.xml
+_Pvt_Extensions
+
+# Paket dependency manager
+.paket/paket.exe
+paket-files/
+
+# FAKE - F# Make
+.fake/
+
+# CodeRush personal settings
+.cr/personal
+
+# Python Tools for Visual Studio (PTVS)
+__pycache__/
+*.pyc
+
+# Cake - Uncomment if you are using it
+# tools/**
+# !tools/packages.config
+
+# Tabs Studio
+*.tss
+
+# Telerik's JustMock configuration file
+*.jmconfig
+
+# BizTalk build output
+*.btp.cs
+*.btm.cs
+*.odx.cs
+*.xsd.cs
+
+# OpenCover UI analysis results
+OpenCover/
+
+# Azure Stream Analytics local run output
+ASALocalRun/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
+# NVidia Nsight GPU debugger configuration file
+*.nvuser
+
+# MFractors (Xamarin productivity tool) working folder
+.mfractor/
+
+# Local History for Visual Studio
+.localhistory/
+
+# BeatPulse healthcheck temp database
+healthchecksdb
+
+# Backup folder for Package Reference Convert tool in Visual Studio 2017
+MigrationBackup/
+
+# Ionide (cross platform F# VS Code tools) working folder
+.ionide/
+
+# Fody - auto-generated XML schema
+FodyWeavers.xsd
+
+##
+## Visual studio for Mac
+##
+
+
+# globs
+Makefile.in
+*.userprefs
+*.usertasks
+config.make
+config.status
+aclocal.m4
+install-sh
+autom4te.cache/
+*.tar.gz
+tarballs/
+test-results/
+
+# Mac bundle stuff
+*.dmg
+*.app
+
+# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore
+# Windows thumbnail cache files
+Thumbs.db
+ehthumbs.db
+ehthumbs_vista.db
+
+# Dump file
+*.stackdump
+
+# Folder config file
+[Dd]esktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows Installer files
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# Windows shortcuts
+*.lnk
+
+# JetBrains Rider
+.idea/
+*.sln.iml
+
+##
+## Visual Studio Code
+##
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
diff --git a/ImageComparisonGUI/App.axaml b/ImageComparisonGUI/App.axaml
new file mode 100644
index 0000000..08908e8
--- /dev/null
+++ b/ImageComparisonGUI/App.axaml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ImageComparisonGUI/App.axaml.cs b/ImageComparisonGUI/App.axaml.cs
new file mode 100644
index 0000000..8b9a2d6
--- /dev/null
+++ b/ImageComparisonGUI/App.axaml.cs
@@ -0,0 +1,31 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Data.Core;
+using Avalonia.Data.Core.Plugins;
+using Avalonia.Markup.Xaml;
+using ImageComparisonGUI.Services;
+using ImageComparisonGUI.Views;
+
+namespace ImageComparisonGUI
+{
+ public partial class App : Application
+ {
+ public override void Initialize()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ {
+ // Line below is needed to remove Avalonia data validation.
+ // Without this line you will get duplicate validations from both Avalonia and CT
+ ExpressionObserver.DataValidators.RemoveAll(x => x is DataAnnotationsValidationPlugin);
+ desktop.MainWindow = new MainWindow();
+ }
+
+ base.OnFrameworkInitializationCompleted();
+ }
+ }
+}
\ No newline at end of file
diff --git a/ImageComparisonGUI/Assets/gallery.ico b/ImageComparisonGUI/Assets/gallery.ico
new file mode 100644
index 0000000..4906298
Binary files /dev/null and b/ImageComparisonGUI/Assets/gallery.ico differ
diff --git a/ImageComparisonGUI/Assets/gallery.svg b/ImageComparisonGUI/Assets/gallery.svg
new file mode 100644
index 0000000..5a41536
--- /dev/null
+++ b/ImageComparisonGUI/Assets/gallery.svg
@@ -0,0 +1,82 @@
+
+
+
+
diff --git a/ImageComparisonGUI/ImageComparisonGUI.csproj b/ImageComparisonGUI/ImageComparisonGUI.csproj
new file mode 100644
index 0000000..e8bf4c6
--- /dev/null
+++ b/ImageComparisonGUI/ImageComparisonGUI.csproj
@@ -0,0 +1,55 @@
+
+
+ WinExe
+ net7.0
+ enable
+ true
+ app.manifest
+ ImageComparisonGUI.Program
+ DerEffi.$(AssemblyName)
+ Image Comparison
+ DerEffi
+ https://github.com/DerEffi/image-comparison
+ https://github.com/DerEffi/image-comparison
+ git
+ 2023.6.0.0
+ Assets\gallery.ico
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ AdjustablesPage.axaml
+
+
+ HotkeysPage.axaml
+
+
+
diff --git a/ImageComparisonGUI/Models/Hotkey.cs b/ImageComparisonGUI/Models/Hotkey.cs
new file mode 100644
index 0000000..ea63996
--- /dev/null
+++ b/ImageComparisonGUI/Models/Hotkey.cs
@@ -0,0 +1,28 @@
+using Avalonia.Input;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Converters;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace ImageComparisonGUI.Models
+{
+ public class Hotkey
+ {
+ [JsonConverter(typeof(StringEnumConverter))]
+ public Key Key = Key.None;
+
+ [JsonConverter(typeof(StringEnumConverter))]
+ public KeyModifiers Modifiers = KeyModifiers.None;
+
+ [JsonConverter(typeof(StringEnumConverter))]
+ public HotkeyTarget Target = HotkeyTarget.None;
+
+ public Hotkey Clone()
+ {
+ return (Hotkey)MemberwiseClone();
+ }
+ }
+}
diff --git a/ImageComparisonGUI/Models/HotkeyEventArgs.cs b/ImageComparisonGUI/Models/HotkeyEventArgs.cs
new file mode 100644
index 0000000..527ca86
--- /dev/null
+++ b/ImageComparisonGUI/Models/HotkeyEventArgs.cs
@@ -0,0 +1,14 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace ImageComparisonGUI.Models
+{
+ public class HotkeyEventArgs
+ {
+ public Hotkey PressedHotkey = new();
+ public string SelectedPage = "";
+ }
+}
diff --git a/ImageComparisonGUI/Models/HotkeyTarget.cs b/ImageComparisonGUI/Models/HotkeyTarget.cs
new file mode 100644
index 0000000..55ecfc7
--- /dev/null
+++ b/ImageComparisonGUI/Models/HotkeyTarget.cs
@@ -0,0 +1,21 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace ImageComparisonGUI.Models
+{
+ public enum HotkeyTarget
+ {
+ None,
+ SearchNoMatch,
+ SearchPrevious,
+ SearchDeleteLeft,
+ SearchDeleteRight,
+ SearchDeleteBoth,
+ SearchAuto,
+ SearchAbort,
+ SearchStart
+ }
+}
diff --git a/ImageComparisonGUI/Models/Profile.cs b/ImageComparisonGUI/Models/Profile.cs
new file mode 100644
index 0000000..ae22b9b
--- /dev/null
+++ b/ImageComparisonGUI/Models/Profile.cs
@@ -0,0 +1,8 @@
+namespace ImageComparisonGUI.Models
+{
+ public class Profile
+ {
+ public string Name;
+ public Settings Settings = new();
+ }
+}
diff --git a/ImageComparisonGUI/Models/Settings.cs b/ImageComparisonGUI/Models/Settings.cs
new file mode 100644
index 0000000..dfda35b
--- /dev/null
+++ b/ImageComparisonGUI/Models/Settings.cs
@@ -0,0 +1,113 @@
+using Avalonia.Input;
+using ImageComparison.Models;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Converters;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using static Dapper.SqlMapper;
+
+namespace ImageComparisonGUI.Models
+{
+ public class Settings
+ {
+ //Cache settings
+ public bool CacheNoMatch = true;
+ public bool CacheImages = true;
+
+ //Processing settings
+ public int MatchThreashold = 7500;
+ public int HashDetail = 20;
+ public bool HashBothDirections = true;
+
+ //Location settings
+ public string[] SearchLocations = Array.Empty();
+ [JsonConverter(typeof(StringEnumConverter))]
+ public SearchMode SearchMode = SearchMode.All;
+ public bool SearchSubdirectories = true;
+
+ //Deletion settings
+ [JsonConverter(typeof(StringEnumConverter))]
+ public DeleteAction DeleteAction = DeleteAction.RecycleBin;
+ public string DeleteTarget = "Duplicates\\";
+ public bool RelativeDeleteTarget = true;
+
+ public List Hotkeys = new() {
+ new() {
+ Key = Key.S,
+ Modifiers = KeyModifiers.None,
+ Target = HotkeyTarget.SearchAuto
+ },
+ new() {
+ Key = Key.N,
+ Modifiers = KeyModifiers.None,
+ Target = HotkeyTarget.SearchNoMatch
+ },
+ new() {
+ Key = Key.A,
+ Modifiers = KeyModifiers.Control,
+ Target = HotkeyTarget.SearchAbort
+ },
+ new() {
+ Key = Key.Z,
+ Modifiers = KeyModifiers.Control,
+ Target = HotkeyTarget.SearchPrevious
+ },
+ new() {
+ Key = Key.Enter,
+ Modifiers = KeyModifiers.None,
+ Target = HotkeyTarget.SearchStart
+ },
+ };
+
+ ///
+ /// Deepcopy the Settings object
+ ///
+ /// The new unrelated Settings object
+ public Settings Clone()
+ {
+ this.DistinguishHotkeys();
+ return JsonConvert.DeserializeObject(JsonConvert.SerializeObject(this)) ?? new();
+ }
+
+ ///
+ /// Parse string json representation of settings to Settings object
+ ///
+ /// Read json string representation of settings from i.e. a file
+ /// Settings object parsed from given content or null
+ public static Settings? Parse(string content)
+ {
+ try
+ {
+ Settings? settings = JsonConvert.DeserializeObject(content, new JsonSerializerSettings() { ObjectCreationHandling = ObjectCreationHandling.Replace });
+ if (settings != null)
+ {
+ settings.DistinguishHotkeys();
+ }
+ return settings;
+ } catch { }
+
+ return null;
+ }
+
+ ///
+ /// Get settings as text (byte content) for saving
+ ///
+ /// UTF8 encoded text as byte array
+ public byte[] GetContent()
+ {
+ this.DistinguishHotkeys();
+ return new UTF8Encoding(true).GetBytes(JsonConvert.SerializeObject(this, Formatting.Indented));
+ }
+
+ ///
+ /// Removes double Hotkey targets and double key combinations in place
+ ///
+ public void DistinguishHotkeys()
+ {
+ this.Hotkeys.RemoveAll(h => h.Key == Key.None);
+ this.Hotkeys = this.Hotkeys.DistinctBy(h => h.Target).DistinctBy(h => $"{h.Modifiers}-{h.Key}").ToList();
+ }
+ }
+}
diff --git a/ImageComparisonGUI/Pages/AboutPage.axaml b/ImageComparisonGUI/Pages/AboutPage.axaml
new file mode 100644
index 0000000..9a952a9
--- /dev/null
+++ b/ImageComparisonGUI/Pages/AboutPage.axaml
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ImageComparisonGUI/Pages/AboutPage.axaml.cs b/ImageComparisonGUI/Pages/AboutPage.axaml.cs
new file mode 100644
index 0000000..1804207
--- /dev/null
+++ b/ImageComparisonGUI/Pages/AboutPage.axaml.cs
@@ -0,0 +1,18 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using Avalonia.Markup.Xaml;
+using CommunityToolkit.Mvvm.Input;
+using ImageComparisonGUI.Services;
+using ImageComparisonGUI.ViewModels;
+
+namespace ImageComparisonGUI.Pages
+{
+ public partial class AboutPage : UserControl
+ {
+ public AboutPage()
+ {
+ InitializeComponent();
+ DataContext = new AboutPageViewModel();
+ }
+ }
+}
diff --git a/ImageComparisonGUI/Pages/AdjustablesPage.axaml b/ImageComparisonGUI/Pages/AdjustablesPage.axaml
new file mode 100644
index 0000000..c75428c
--- /dev/null
+++ b/ImageComparisonGUI/Pages/AdjustablesPage.axaml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ImageComparisonGUI/Pages/AdjustablesPage.axaml.cs b/ImageComparisonGUI/Pages/AdjustablesPage.axaml.cs
new file mode 100644
index 0000000..f8442a5
--- /dev/null
+++ b/ImageComparisonGUI/Pages/AdjustablesPage.axaml.cs
@@ -0,0 +1,17 @@
+using Avalonia.Controls;
+using ImageComparisonGUI.ViewModels;
+
+namespace ImageComparisonGUI.Pages
+{
+ public partial class AdjustablesPage : UserControl
+ {
+ public AdjustablesPage()
+ {
+ InitializeComponent();
+
+ Slider matchThreasholdSlider = this.Find("MatchThreasholdSlider");
+ Slider hashDetailSlider = this.Find("HashDetailSlider");
+ DataContext = new AdjustablesPageViewModel(matchThreasholdSlider, hashDetailSlider);
+ }
+ }
+}
diff --git a/ImageComparisonGUI/Pages/CachePage.axaml b/ImageComparisonGUI/Pages/CachePage.axaml
new file mode 100644
index 0000000..70a0951
--- /dev/null
+++ b/ImageComparisonGUI/Pages/CachePage.axaml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ImageComparisonGUI/Pages/CachePage.axaml.cs b/ImageComparisonGUI/Pages/CachePage.axaml.cs
new file mode 100644
index 0000000..499eb07
--- /dev/null
+++ b/ImageComparisonGUI/Pages/CachePage.axaml.cs
@@ -0,0 +1,14 @@
+using Avalonia.Controls;
+using ImageComparisonGUI.ViewModels;
+
+namespace ImageComparisonGUI.Pages
+{
+ public partial class CachePage : UserControl
+ {
+ public CachePage()
+ {
+ InitializeComponent();
+ DataContext = new CachePageViewModel();
+ }
+ }
+}
diff --git a/ImageComparisonGUI/Pages/HotkeysPage.axaml b/ImageComparisonGUI/Pages/HotkeysPage.axaml
new file mode 100644
index 0000000..d20b338
--- /dev/null
+++ b/ImageComparisonGUI/Pages/HotkeysPage.axaml
@@ -0,0 +1,97 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ImageComparisonGUI/Pages/HotkeysPage.axaml.cs b/ImageComparisonGUI/Pages/HotkeysPage.axaml.cs
new file mode 100644
index 0000000..21d577a
--- /dev/null
+++ b/ImageComparisonGUI/Pages/HotkeysPage.axaml.cs
@@ -0,0 +1,14 @@
+using Avalonia.Controls;
+using ImageComparisonGUI.ViewModels;
+
+namespace ImageComparisonGUI.Pages
+{
+ public partial class HotkeysPage : UserControl
+ {
+ public HotkeysPage()
+ {
+ InitializeComponent();
+ DataContext = new HotkeysPageViewModel();
+ }
+ }
+}
diff --git a/ImageComparisonGUI/Pages/LocationsPage.axaml b/ImageComparisonGUI/Pages/LocationsPage.axaml
new file mode 100644
index 0000000..efba77b
--- /dev/null
+++ b/ImageComparisonGUI/Pages/LocationsPage.axaml
@@ -0,0 +1,51 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ImageComparisonGUI/Pages/LocationsPage.axaml.cs b/ImageComparisonGUI/Pages/LocationsPage.axaml.cs
new file mode 100644
index 0000000..a32c9a8
--- /dev/null
+++ b/ImageComparisonGUI/Pages/LocationsPage.axaml.cs
@@ -0,0 +1,14 @@
+using Avalonia.Controls;
+using ImageComparisonGUI.ViewModels;
+
+namespace ImageComparisonGUI.Pages
+{
+ public partial class LocationsPage : UserControl
+ {
+ public LocationsPage()
+ {
+ InitializeComponent();
+ DataContext = new LocationsPageViewModel();
+ }
+ }
+}
diff --git a/ImageComparisonGUI/Pages/ProfilesPage.axaml b/ImageComparisonGUI/Pages/ProfilesPage.axaml
new file mode 100644
index 0000000..8c137ad
--- /dev/null
+++ b/ImageComparisonGUI/Pages/ProfilesPage.axaml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ImageComparisonGUI/Pages/ProfilesPage.axaml.cs b/ImageComparisonGUI/Pages/ProfilesPage.axaml.cs
new file mode 100644
index 0000000..ec6b1a3
--- /dev/null
+++ b/ImageComparisonGUI/Pages/ProfilesPage.axaml.cs
@@ -0,0 +1,14 @@
+using Avalonia.Controls;
+using ImageComparisonGUI.ViewModels;
+
+namespace ImageComparisonGUI.Pages
+{
+ public partial class ProfilesPage : UserControl
+ {
+ public ProfilesPage()
+ {
+ InitializeComponent();
+ DataContext = new ProfilesPageViewModel();
+ }
+ }
+}
diff --git a/ImageComparisonGUI/Pages/SearchPage.axaml b/ImageComparisonGUI/Pages/SearchPage.axaml
new file mode 100644
index 0000000..4b1e043
--- /dev/null
+++ b/ImageComparisonGUI/Pages/SearchPage.axaml
@@ -0,0 +1,137 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ImageComparisonGUI/Pages/SearchPage.axaml.cs b/ImageComparisonGUI/Pages/SearchPage.axaml.cs
new file mode 100644
index 0000000..be27c70
--- /dev/null
+++ b/ImageComparisonGUI/Pages/SearchPage.axaml.cs
@@ -0,0 +1,17 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+using Avalonia.Interactivity;
+using ImageComparisonGUI.ViewModels;
+using System;
+
+namespace ImageComparisonGUI.Pages
+{
+ public partial class SearchPage : UserControl
+ {
+ public SearchPage()
+ {
+ InitializeComponent();
+ DataContext = new SearchPageViewModel(this);
+ }
+ }
+}
diff --git a/ImageComparisonGUI/Program.cs b/ImageComparisonGUI/Program.cs
new file mode 100644
index 0000000..7bafa67
--- /dev/null
+++ b/ImageComparisonGUI/Program.cs
@@ -0,0 +1,22 @@
+using Avalonia;
+using System;
+
+namespace ImageComparisonGUI
+{
+ internal 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()
+ .UsePlatformDetect()
+ .With(new Win32PlatformOptions { UseWindowsUIComposition = true })
+ .LogToTrace();
+ }
+}
\ No newline at end of file
diff --git a/ImageComparisonGUI/Roots.xml b/ImageComparisonGUI/Roots.xml
new file mode 100644
index 0000000..7e9379f
--- /dev/null
+++ b/ImageComparisonGUI/Roots.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/ImageComparisonGUI/Services/ConfigService.cs b/ImageComparisonGUI/Services/ConfigService.cs
new file mode 100644
index 0000000..3348b4d
--- /dev/null
+++ b/ImageComparisonGUI/Services/ConfigService.cs
@@ -0,0 +1,257 @@
+using ImageComparison.Models;
+using ImageComparison.Services;
+using ImageComparisonGUI.Models;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+
+namespace ImageComparisonGUI.Services
+{
+ public static class ConfigService
+ {
+ public static event EventHandler OnUpdate = delegate { };
+
+ private static readonly string settingsFileName = "settings.json";
+ public static readonly string ProfilesDirectory = "Profiles";
+ private static Settings settings = new();
+ private static List profiles = new List();
+ public static List Profiles { get => profiles.Select(p => p.Name).ToList(); }
+
+ public static bool IsLocked { get; private set; } = false;
+
+ //Cache settings
+ public static bool FillNoMatchCache { get; private set; } = false;
+ public static bool CacheNoMatch { get => settings.CacheNoMatch; }
+ public static bool CacheImages { get => settings.CacheImages; }
+
+ //Processing settings
+ public static int MatchThreashold { get => settings.MatchThreashold; }
+ public static int HashDetail { get => settings.HashDetail; }
+ public static bool HashBothDirections { get => settings.HashBothDirections; }
+
+ //Location settings
+ public static string[] SearchLocations { get => settings.SearchLocations; }
+ public static SearchMode SearchMode { get => settings.SearchMode; }
+ public static bool SearchSubdirectories { get => settings.SearchSubdirectories; }
+
+ //Deletion settings
+ public static DeleteAction DeleteAction { get => settings.DeleteAction; }
+ public static string DeleteTarget { get => settings.DeleteTarget; }
+ public static bool RelativeDeleteTarget { get => settings.RelativeDeleteTarget; }
+
+ public static List Hotkeys { get { settings.DistinguishHotkeys(); return settings.Hotkeys; } }
+
+ public static void Init()
+ {
+ LoadConfig();
+ LoadProfiles();
+ }
+
+ public static void Lock()
+ {
+ IsLocked = true;
+ OnUpdate.Invoke(null, EventArgs.Empty);
+ }
+
+ public static void Unlock()
+ {
+ IsLocked = false;
+ OnUpdate.Invoke(null, EventArgs.Empty);
+ }
+
+ public static void UpdateDeleteAction(DeleteAction action, string? target, bool? relativeTarget)
+ {
+ if (!IsLocked)
+ {
+ if (action == DeleteAction.Move)
+ {
+ if (target == null)
+ throw new ArgumentNullException(nameof(target));
+
+ bool targetIsRelative = relativeTarget.HasValue ? relativeTarget.Value : RelativeDeleteTarget;
+ if (targetIsRelative)
+ {
+ while (target.StartsWith("."))
+ target = target.Substring(1);
+
+ while (target.StartsWith("/") || target.StartsWith("\\"))
+ target = target.Substring(1);
+ }
+ else if (!Path.IsPathRooted(target))
+ throw new DirectoryNotFoundException();
+
+ settings.DeleteTarget = target;
+ settings.RelativeDeleteTarget = targetIsRelative;
+ }
+
+ settings.DeleteAction = action;
+
+ SaveConfig();
+ }
+
+ OnUpdate.Invoke(null, EventArgs.Empty);
+ }
+
+ public static void UpdateSearchLocations(SearchMode mode, string[] locations, bool recursive)
+ {
+ if(!IsLocked) {
+ settings.SearchMode = mode;
+ settings.SearchLocations = locations;
+ settings.SearchSubdirectories = recursive;
+
+ SaveConfig();
+ }
+
+ OnUpdate.Invoke(null, EventArgs.Empty);
+ }
+
+ public static void UpdateAdjustables(int matchThreashold, int hashDetail, bool hashBothDirections)
+ {
+ if (!IsLocked)
+ {
+ settings.MatchThreashold = matchThreashold;
+ settings.HashBothDirections = hashBothDirections;
+ settings.HashDetail = hashDetail;
+
+ SaveConfig();
+ }
+
+ OnUpdate.Invoke(null, EventArgs.Empty);
+ }
+
+ public static void UpdateHotkeys(List hotkeys)
+ {
+ settings.Hotkeys = hotkeys.ToList();
+ settings.DistinguishHotkeys();
+
+ SaveConfig();
+
+ OnUpdate.Invoke(null, EventArgs.Empty);
+ }
+
+ public static void UpdateCache(bool cacheImages, bool cacheNoMatch, bool fillNoMatchCache)
+ {
+ settings.CacheImages = cacheImages;
+ settings.CacheNoMatch = cacheNoMatch;
+ FillNoMatchCache = fillNoMatchCache;
+
+ SaveConfig();
+
+ OnUpdate.Invoke(null, EventArgs.Empty);
+ }
+
+ private static void SaveConfig()
+ {
+ try
+ {
+ SaveConfig(Path.Combine(FileService.DataDirectory, settingsFileName));
+ }
+ catch (Exception) { }
+ }
+
+ private static void SaveConfig(string filePath)
+ {
+ try
+ {
+ string? dir = Path.GetDirectoryName(filePath);
+ if (dir != null) {
+ Directory.CreateDirectory(dir);
+ using FileStream stream = File.Create(filePath);
+ byte[] content = settings.GetContent();
+ stream.Write(content, 0, content.Length);
+ stream.Close();
+ }
+ } catch(Exception) { }
+ }
+
+ private static void LoadConfig()
+ {
+ try
+ {
+ string settingsFile = Path.Combine(FileService.DataDirectory, settingsFileName);
+ Settings? loadedSettings = LoadProfileFromFile(settingsFile);
+ if (loadedSettings != null)
+ {
+ settings = loadedSettings;
+ OnUpdate.Invoke(null, EventArgs.Empty);
+ }
+
+ } catch(Exception) { }
+ }
+
+ private static Settings? LoadProfileFromFile(string filePath)
+ {
+ if (File.Exists(filePath))
+ {
+ Settings? loadedSettings = Settings.Parse(File.ReadAllText(filePath));
+ if (loadedSettings != null && loadedSettings.GetType() == typeof(Settings))
+ {
+ return loadedSettings;
+ }
+ }
+ return null;
+ }
+
+ private static void LoadProfiles()
+ {
+ try
+ {
+ string profilesDir = Path.Combine(FileService.DataDirectory, ProfilesDirectory);
+ Directory.CreateDirectory(profilesDir);
+ profiles = Directory
+ .GetFiles(profilesDir, "*.json")
+ .Select(file => new Profile() { Name = Path.GetFileNameWithoutExtension(file), Settings = LoadProfileFromFile(file) })
+ .Where(profile => profile.Settings != null)
+ .ToList();
+
+ OnUpdate.Invoke(null, EventArgs.Empty);
+ } catch(Exception) { }
+ }
+
+ public static void LoadProfile(string name)
+ {
+ try
+ {
+ Settings? profile = profiles.Where(p => p.Name == name).Select(p => p.Settings).FirstOrDefault();
+ if(profile != null)
+ {
+ settings = profile.Clone();
+ }
+
+ OnUpdate.Invoke(null, EventArgs.Empty);
+ }
+ catch(Exception) { }
+ }
+
+ public static void RemoveProfile(string name)
+ {
+ try
+ {
+ string file = Path.Combine(FileService.DataDirectory, ProfilesDirectory, name + ".json");
+ if (File.Exists(file))
+ {
+ File.Delete(file);
+ }
+
+ LoadProfiles();
+ }
+ catch (Exception) { }
+ }
+
+ public static void SaveConfigAsProfile(string name)
+ {
+ try
+ {
+ name = new string(name.Where(c => !Path.GetInvalidFileNameChars().Contains(c)).ToArray());
+ if (string.IsNullOrEmpty(name))
+ throw new ArgumentException();
+
+ SaveConfig(Path.Combine(FileService.DataDirectory, ProfilesDirectory, name + ".json"));
+
+ LoadProfiles();
+
+ } catch(Exception) { }
+ }
+ }
+}
diff --git a/ImageComparisonGUI/Services/Converter.cs b/ImageComparisonGUI/Services/Converter.cs
new file mode 100644
index 0000000..55b8292
--- /dev/null
+++ b/ImageComparisonGUI/Services/Converter.cs
@@ -0,0 +1,165 @@
+using Avalonia.Collections;
+using Avalonia.Data;
+using Avalonia.Data.Converters;
+using Avalonia.Media.Imaging;
+using ImageComparisonGUI.Models;
+using System;
+using System.Collections;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+
+namespace ImageComparisonGUI.Services
+{
+ ///
+ ///
+ /// Converts a string path to a bitmap asset.
+ ///
+ ///
+ /// The asset must be in the same assembly as the program. If it isn't,
+ /// specify "avares:///" in front of the path to the asset.
+ ///
+ ///
+ public class BitmapAssetValueConverter : IValueConverter
+ {
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (value == null)
+ return null;
+
+ if (value is string path && targetType.IsAssignableFrom(typeof(Bitmap)))
+ {
+ try
+ {
+ return new Bitmap(path);
+ } catch(Exception)
+ {
+ return null;
+ }
+ }
+
+ throw new NotSupportedException();
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotSupportedException();
+ }
+ }
+
+
+ public class EnumToBooleanConverter : IValueConverter
+ {
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ return value?.Equals(parameter);
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ return value?.Equals(true) == true ? parameter : BindingOperations.DoNothing;
+ }
+ }
+
+ public class NotEnumToBooleanConverter : IValueConverter
+ {
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ return !value?.Equals(parameter);
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotSupportedException();
+ }
+ }
+
+ public class ReadableFilesizeConverter : IValueConverter
+ {
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (value == null)
+ return null;
+
+ if (value is long size)
+ {
+ if (size < 1000)
+ {
+ return $"{size} B";
+ }
+ else if (size < 1000000)
+ {
+ return $"{size >> 10} KB";
+ }
+ else if (size < 10000000)
+ {
+ return $"{decimal.Divide(size, 1000000):0.00} MB";
+ }
+ else if (size < 100000000)
+ {
+ return $"{(decimal.Divide(size, 1000000)):0.0} MB";
+ }
+ else
+ {
+ return $"{size >> 20} MB";
+ }
+ }
+
+ throw new NotSupportedException();
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotSupportedException();
+ }
+ }
+
+ public class SimilarityConverter : IValueConverter
+ {
+ public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ if (value == null)
+ return null;
+
+ return string.Format("{0:0.0}", System.Convert.ToDouble(value) / 100);
+
+ throw new NotSupportedException();
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotSupportedException();
+ }
+ }
+
+ public class DisplayHotkeyConverter : IValueConverter
+ {
+ public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
+ {
+ if (value == null || parameter == null)
+ return null;
+
+ if(value is AvaloniaList hotkeys && parameter is string needle && !string.IsNullOrEmpty(needle))
+ {
+ HotkeyTarget target;
+ if (Enum.TryParse(needle, out target)) {
+ Hotkey? hotkey = hotkeys.FirstOrDefault(h => h.Target == target);
+ if (hotkey != null)
+ {
+ string modifiers = hotkey.Modifiers.ToString().Replace("None", "").Replace(", ", " + ");
+ if (!string.IsNullOrEmpty(modifiers))
+ modifiers += " + ";
+ return $"{modifiers}{hotkey.Key}";
+ }
+ }
+ }
+
+ return "None";
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
+ {
+ throw new NotSupportedException();
+ }
+ }
+}
diff --git a/ImageComparisonGUI/Services/HotkeyService.cs b/ImageComparisonGUI/Services/HotkeyService.cs
new file mode 100644
index 0000000..7965405
--- /dev/null
+++ b/ImageComparisonGUI/Services/HotkeyService.cs
@@ -0,0 +1,59 @@
+using Avalonia.Controls;
+using Avalonia.Input;
+using ImageComparisonGUI.Models;
+using System;
+using System.Linq;
+
+namespace ImageComparisonGUI.Services
+{
+ public static class HotkeyService
+ {
+ public static event EventHandler OnHotkey = delegate { };
+ private static string selectedPage = "";
+
+ public static void OnKeyInput(object? sender, KeyEventArgs e)
+ {
+ //Don't invoke on modifier key only
+ if((int)e.Key >= 116 && (int)e.Key <= 121)
+ return;
+
+ Hotkey? hotkey = ConfigService.Hotkeys.FirstOrDefault(h => h.Modifiers == e.KeyModifiers && h.Key == e.Key);
+
+ if (hotkey != null)
+ {
+ OnHotkey(null, new()
+ {
+ PressedHotkey = hotkey,
+ SelectedPage = selectedPage
+ });
+ } else if(selectedPage == "Hotkeys") {
+ OnHotkey(null, new()
+ {
+ PressedHotkey = new()
+ {
+ Key = e.Key,
+ Modifiers = e.KeyModifiers
+ },
+ SelectedPage = selectedPage
+ });
+ }
+ }
+
+ public static void OnPageSelection(object? sender, SelectionChangedEventArgs e)
+ {
+ if(e.AddedItems.Count != 0 && e.AddedItems[0] is TabItem tabItem)
+ {
+ selectedPage = tabItem.Header?.ToString() ?? "";
+ } else
+ {
+ selectedPage = "";
+ }
+ }
+
+ public static void OnPageSelection(object? tab)
+ {
+ if(tab is TabItem tabItem)
+ selectedPage = tabItem.Header?.ToString() ?? "";
+ }
+ }
+}
diff --git a/ImageComparisonGUI/Services/UrlService.cs b/ImageComparisonGUI/Services/UrlService.cs
new file mode 100644
index 0000000..dcca0aa
--- /dev/null
+++ b/ImageComparisonGUI/Services/UrlService.cs
@@ -0,0 +1,44 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Runtime.InteropServices;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace ImageComparisonGUI.Services
+{
+ public static class UrlService
+ {
+ private static bool IsValidUrl(string url)
+ {
+ if (string.IsNullOrWhiteSpace(url)) return false;
+ if (!Uri.IsWellFormedUriString(url, UriKind.Absolute)) return false;
+ if (!Uri.TryCreate(url, UriKind.Absolute, out var tmp)) return false;
+ return tmp.Scheme == Uri.UriSchemeHttp || tmp.Scheme == Uri.UriSchemeHttps;
+ }
+
+ public static void OpenUrl(this string url)
+ {
+ if (!IsValidUrl(url)) throw new InvalidDataException("invalid url: " + url);
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ using var proc = new Process { StartInfo = { UseShellExecute = true, FileName = url } };
+ proc.Start();
+
+ return;
+ }
+
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
+ {
+ Process.Start("x-www-browser", url);
+ return;
+ }
+
+ if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) throw new InvalidDataException("invalid url: " + url);
+ Process.Start("open", url);
+ return;
+ }
+ }
+}
diff --git a/ImageComparisonGUI/Styles/SideBar.axaml b/ImageComparisonGUI/Styles/SideBar.axaml
new file mode 100644
index 0000000..1f36227
--- /dev/null
+++ b/ImageComparisonGUI/Styles/SideBar.axaml
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ImageComparisonGUI/Styles/Styles.axaml b/ImageComparisonGUI/Styles/Styles.axaml
new file mode 100644
index 0000000..88af2c7
--- /dev/null
+++ b/ImageComparisonGUI/Styles/Styles.axaml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ImageComparisonGUI/ViewLocator.cs b/ImageComparisonGUI/ViewLocator.cs
new file mode 100644
index 0000000..37f3264
--- /dev/null
+++ b/ImageComparisonGUI/ViewLocator.cs
@@ -0,0 +1,27 @@
+using System;
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using ImageComparisonGUI.ViewModels;
+
+namespace ImageComparisonGUI;
+
+public class ViewLocator : IDataTemplate
+{
+ public IControl Build(object data)
+ {
+ var name = data.GetType().FullName!.Replace("ViewModel", "View");
+ var type = Type.GetType(name);
+
+ if (type != null)
+ {
+ return (Control)Activator.CreateInstance(type)!;
+ }
+
+ return new TextBlock { Text = "Not Found: " + name };
+ }
+
+ public bool Match(object data)
+ {
+ return data is ViewModelBase;
+ }
+}
\ No newline at end of file
diff --git a/ImageComparisonGUI/ViewModels/AboutPageViewModel.cs b/ImageComparisonGUI/ViewModels/AboutPageViewModel.cs
new file mode 100644
index 0000000..2967441
--- /dev/null
+++ b/ImageComparisonGUI/ViewModels/AboutPageViewModel.cs
@@ -0,0 +1,30 @@
+using Avalonia.Interactivity;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using ImageComparisonGUI.Services;
+using System;
+using System.Reflection;
+
+namespace ImageComparisonGUI.ViewModels;
+
+public partial class AboutPageViewModel : ViewModelBase
+{
+ [ObservableProperty]
+ private string versionText = "0.0.0.0";
+
+ public AboutPageViewModel()
+ {
+ AssemblyName name = Assembly.GetExecutingAssembly().GetName();
+ versionText = $"{name.Name} - Version {name.Version}";
+ }
+
+ [RelayCommand]
+ private static void OpenHyperlink(string url)
+ {
+ try
+ {
+ UrlService.OpenUrl(url);
+ }
+ catch (Exception) { }
+ }
+}
diff --git a/ImageComparisonGUI/ViewModels/AdjustablesPageViewModel.cs b/ImageComparisonGUI/ViewModels/AdjustablesPageViewModel.cs
new file mode 100644
index 0000000..0629a36
--- /dev/null
+++ b/ImageComparisonGUI/ViewModels/AdjustablesPageViewModel.cs
@@ -0,0 +1,44 @@
+using Avalonia.Collections;
+using Avalonia.Controls;
+using Avalonia.Controls.Selection;
+using Avalonia.Interactivity;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using ImageComparison.Models;
+using ImageComparisonGUI.Services;
+using ImageComparisonGUI.Views;
+using System;
+using System.Linq;
+
+namespace ImageComparisonGUI.ViewModels;
+
+public partial class AdjustablesPageViewModel : ViewModelBase
+{
+ [ObservableProperty] private bool configLocked = ConfigService.IsLocked;
+ [ObservableProperty] private bool hashBothDirections = ConfigService.HashBothDirections;
+ [ObservableProperty] private int hashDetail = ConfigService.HashDetail;
+ [ObservableProperty] private int matchThreashold = ConfigService.MatchThreashold;
+
+ public AdjustablesPageViewModel(Slider matchThreasholdSlider, Slider hashDetailSlider)
+ {
+ matchThreasholdSlider.LostFocus += (object? sender, RoutedEventArgs e) => Save();
+ hashDetailSlider.LostFocus += (object? sender, RoutedEventArgs e) => Save();
+
+ ConfigService.OnUpdate += OnConfigUpdate;
+ }
+
+ [RelayCommand]
+ private void Save()
+ {
+ MatchThreashold -= MatchThreashold % 10;
+ ConfigService.UpdateAdjustables(MatchThreashold, HashDetail, HashBothDirections);
+ }
+
+ public void OnConfigUpdate(object? sender, EventArgs e)
+ {
+ HashBothDirections = ConfigService.HashBothDirections;
+ HashDetail = ConfigService.HashDetail;
+ MatchThreashold = ConfigService.MatchThreashold;
+ ConfigLocked = ConfigService.IsLocked;
+ }
+}
diff --git a/ImageComparisonGUI/ViewModels/CachePageViewModel.cs b/ImageComparisonGUI/ViewModels/CachePageViewModel.cs
new file mode 100644
index 0000000..3fdd2d6
--- /dev/null
+++ b/ImageComparisonGUI/ViewModels/CachePageViewModel.cs
@@ -0,0 +1,48 @@
+using Avalonia.Interactivity;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using ImageComparison.Services;
+using ImageComparisonGUI.Services;
+using System;
+using System.Reflection;
+
+namespace ImageComparisonGUI.ViewModels;
+
+public partial class CachePageViewModel : ViewModelBase
+{
+ [ObservableProperty] private bool cacheImages = ConfigService.CacheImages;
+ [ObservableProperty] private bool cacheNoMatch = ConfigService.CacheNoMatch;
+ [ObservableProperty] private bool fillNoMatchCache = ConfigService.FillNoMatchCache;
+ [ObservableProperty] private bool configLocked = ConfigService.IsLocked;
+
+ public CachePageViewModel()
+ {
+ ConfigService.OnUpdate += OnConfigUpdate;
+ }
+
+ [RelayCommand]
+ public void ClearImageCache()
+ {
+ CacheService.ClearImageCache();
+ }
+
+ [RelayCommand]
+ public void ClearNoMatchCache()
+ {
+ CacheService.ClearNoMatchCache();
+ }
+
+ [RelayCommand]
+ public void Save()
+ {
+ ConfigService.UpdateCache(CacheImages, CacheNoMatch, FillNoMatchCache);
+ }
+
+ public void OnConfigUpdate(object? sender, EventArgs e)
+ {
+ CacheImages = ConfigService.CacheImages;
+ CacheNoMatch = ConfigService.CacheNoMatch;
+ FillNoMatchCache = ConfigService.FillNoMatchCache;
+ ConfigLocked = ConfigService.IsLocked;
+ }
+}
diff --git a/ImageComparisonGUI/ViewModels/HotkeysPageViewModel.cs b/ImageComparisonGUI/ViewModels/HotkeysPageViewModel.cs
new file mode 100644
index 0000000..40faf63
--- /dev/null
+++ b/ImageComparisonGUI/ViewModels/HotkeysPageViewModel.cs
@@ -0,0 +1,91 @@
+using Avalonia.Collections;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using ImageComparisonGUI.Models;
+using ImageComparisonGUI.Services;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace ImageComparisonGUI.ViewModels
+{
+ public partial class HotkeysPageViewModel : ViewModelBase
+ {
+ [ObservableProperty] private AvaloniaList hotkeys = new(ConfigService.Hotkeys);
+ [ObservableProperty] private HotkeyTarget listenTarget = HotkeyTarget.None;
+
+ public HotkeysPageViewModel() {
+ ConfigService.OnUpdate += OnConfigUpdate;
+ HotkeyService.OnHotkey += OnKeyInput;
+ }
+
+ public void Save()
+ {
+
+ }
+
+ public void OnKeyInput(object? sender, HotkeyEventArgs e)
+ {
+ if (ListenTarget != HotkeyTarget.None)
+ {
+ if (e.PressedHotkey.Key == Avalonia.Input.Key.Escape)
+ {
+ ListenTarget = HotkeyTarget.None;
+ }
+ else if (e.PressedHotkey.Key != Avalonia.Input.Key.None)
+ {
+ List hotkeyList = Hotkeys.ToList();
+ Hotkey? hotkey = hotkeyList.FirstOrDefault(h => h.Target == ListenTarget);
+ if (hotkey == null) {
+ hotkey = new Hotkey() { Target = ListenTarget };
+ hotkeyList.Add(hotkey);
+ }
+ hotkey.Key = e.PressedHotkey.Key;
+ hotkey.Modifiers = e.PressedHotkey.Modifiers;
+
+ ListenTarget = HotkeyTarget.None;
+
+ Hotkeys = new(hotkeyList);
+ ConfigService.UpdateHotkeys(Hotkeys.ToList());
+ }
+ }
+ }
+
+ public void OnConfigUpdate(object? sender, EventArgs e)
+ {
+ Hotkeys = new(ConfigService.Hotkeys);
+ }
+
+ [RelayCommand]
+ private void Remove(string? needle)
+ {
+ HotkeyTarget target;
+ if (Enum.TryParse(needle, out target))
+ {
+ List hotkeyList = Hotkeys.ToList();
+ Hotkey? hotkey = hotkeyList.FirstOrDefault(h => h.Target == target);
+ if (hotkey != null)
+ {
+ hotkeyList.Remove(hotkey);
+ Hotkeys = new(hotkeyList);
+ ConfigService.UpdateHotkeys(Hotkeys.ToList());
+ }
+ }
+ }
+
+ [RelayCommand]
+ private void ListenForHotkey(string needle)
+ {
+ HotkeyTarget target;
+ if (Enum.TryParse(needle, out target))
+ {
+ ListenTarget = target;
+ } else
+ {
+ ListenTarget = HotkeyTarget.None;
+ }
+ }
+ }
+}
diff --git a/ImageComparisonGUI/ViewModels/LocationsPageViewModel.cs b/ImageComparisonGUI/ViewModels/LocationsPageViewModel.cs
new file mode 100644
index 0000000..b096ef8
--- /dev/null
+++ b/ImageComparisonGUI/ViewModels/LocationsPageViewModel.cs
@@ -0,0 +1,72 @@
+using Avalonia.Collections;
+using Avalonia.Controls;
+using Avalonia.Controls.Selection;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using ImageComparison.Models;
+using ImageComparisonGUI.Services;
+using ImageComparisonGUI.Views;
+using System;
+using System.Linq;
+
+namespace ImageComparisonGUI.ViewModels;
+
+public partial class LocationsPageViewModel : ViewModelBase
+{
+ [ObservableProperty] private AvaloniaList searchLocations = new(ConfigService.SearchLocations);
+ [ObservableProperty] private int? selectedSearchLocation = null;
+ [ObservableProperty] private SearchMode selectedSearchMode = ConfigService.SearchMode;
+ [ObservableProperty] private bool recursive = ConfigService.SearchSubdirectories;
+ [ObservableProperty] private bool configLocked = ConfigService.IsLocked;
+
+ public SelectionModel SearchLocationsSelection { get; } = new();
+
+ public LocationsPageViewModel()
+ {
+ SearchLocationsSelection.SelectionChanged += SearchLocationsSelectionChanged;
+
+ ConfigService.OnUpdate += OnConfigUpdate;
+ }
+
+ private void SearchLocationsSelectionChanged(object? sender, SelectionModelSelectionChangedEventArgs e)
+ {
+ SelectedSearchLocation = e.SelectedItems.Count != 0 ? e.SelectedIndexes[0] : null;
+ }
+
+ [RelayCommand]
+ public void RemoveSearchLocation()
+ {
+ if (SelectedSearchLocation != null)
+ {
+ SearchLocations.RemoveAt(SelectedSearchLocation ?? 0);
+ ConfigService.UpdateSearchLocations(SelectedSearchMode, SearchLocations.ToArray(), recursive);
+ }
+ }
+
+ [RelayCommand]
+ public async void AddSearchLocation()
+ {
+ OpenFolderDialog dialog = new OpenFolderDialog();
+ string? location = await dialog.ShowAsync(MainWindow.Instance);
+ if (location != null && !SearchLocations.Contains(location))
+ {
+ SearchLocations.Add(location);
+ ConfigService.UpdateSearchLocations(SelectedSearchMode, SearchLocations.ToArray(), recursive);
+ }
+ }
+
+ [RelayCommand]
+ public void Save()
+ {
+ ConfigService.UpdateSearchLocations(SelectedSearchMode, SearchLocations.ToArray(), recursive);
+ }
+
+ public void OnConfigUpdate(object? sender, EventArgs e)
+ {
+ SearchLocations = new(ConfigService.SearchLocations);
+ SelectedSearchLocation = null;
+ SelectedSearchMode = ConfigService.SearchMode;
+ Recursive = ConfigService.SearchSubdirectories;
+ ConfigLocked = ConfigService.IsLocked;
+ }
+}
diff --git a/ImageComparisonGUI/ViewModels/MainWindowViewModel.cs b/ImageComparisonGUI/ViewModels/MainWindowViewModel.cs
new file mode 100644
index 0000000..d062a9b
--- /dev/null
+++ b/ImageComparisonGUI/ViewModels/MainWindowViewModel.cs
@@ -0,0 +1,41 @@
+using Avalonia;
+using Avalonia.Controls;
+using CommunityToolkit.Mvvm.ComponentModel;
+using ImageComparison.Services;
+using ImageComparisonGUI.Services;
+using ImageComparisonGUI.Views;
+using SkiaSharp;
+using System;
+using System.Reflection;
+
+namespace ImageComparisonGUI.ViewModels;
+
+public partial class MainWindowViewModel : ViewModelBase
+{
+ [ObservableProperty]
+ private double tabWidth = 0;
+ [ObservableProperty]
+ private double tabHeight = 0;
+
+ public MainWindowViewModel(MainWindow window, DirectProperty ClientSizeProperty)
+ {
+ ConfigService.Init();
+ CacheService.Init();
+
+ ClientSizeProperty.Changed.Subscribe(size => Resize(size.NewValue.Value.Width, size.NewValue.Value.Height));
+ window.Opened += (object? sender, EventArgs e) => { Resize(window.ClientSize.Width, window.ClientSize.Height); };
+ window.KeyDown += HotkeyService.OnKeyInput;
+ TabControl tabs = window.Find("TabControl");
+ if (tabs != null)
+ {
+ HotkeyService.OnPageSelection(tabs.SelectedItem);
+ tabs.SelectionChanged += HotkeyService.OnPageSelection;
+ }
+ }
+
+ public void Resize(double width, double height)
+ {
+ TabWidth = width - 300;
+ TabHeight = height - 140;
+ }
+}
diff --git a/ImageComparisonGUI/ViewModels/ProfilesPageViewModel.cs b/ImageComparisonGUI/ViewModels/ProfilesPageViewModel.cs
new file mode 100644
index 0000000..bea94ff
--- /dev/null
+++ b/ImageComparisonGUI/ViewModels/ProfilesPageViewModel.cs
@@ -0,0 +1,80 @@
+using Avalonia.Collections;
+using Avalonia.Controls.Selection;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using ImageComparison.Services;
+using ImageComparisonGUI.Services;
+using System;
+using System.Diagnostics;
+using System.IO;
+
+namespace ImageComparisonGUI.ViewModels;
+
+public partial class ProfilesPageViewModel : ViewModelBase
+{
+ [ObservableProperty] private AvaloniaList profiles = new(ConfigService.Profiles);
+ [ObservableProperty] private int? selectedProfile = null;
+ [ObservableProperty] private bool configLocked = ConfigService.IsLocked;
+ [ObservableProperty] private string newProfileName = "";
+
+ public SelectionModel ProfilesSelection { get; } = new();
+
+ public ProfilesPageViewModel()
+ {
+ ProfilesSelection.SelectionChanged += ProfilesSelectionChanged;
+
+ ConfigService.OnUpdate += OnConfigUpdate;
+ }
+
+ private void ProfilesSelectionChanged(object? sender, SelectionModelSelectionChangedEventArgs e)
+ {
+ SelectedProfile = e.SelectedItems.Count != 0 ? e.SelectedIndexes[0] : null;
+
+ if(SelectedProfile != null && SelectedProfile < profiles.Count)
+ {
+ NewProfileName = Profiles[(int)SelectedProfile];
+ }
+ }
+
+ [RelayCommand]
+ public void RemoveProfile()
+ {
+ if (SelectedProfile != null && SelectedProfile < profiles.Count)
+ {
+ ConfigService.RemoveProfile(Profiles[(int)SelectedProfile]);
+ }
+ }
+
+ [RelayCommand]
+ public void AddProfile()
+ {
+ if (!string.IsNullOrEmpty(NewProfileName))
+ {
+ ConfigService.SaveConfigAsProfile(NewProfileName);
+ }
+ }
+
+ [RelayCommand]
+ public void LoadProfile()
+ {
+ if (SelectedProfile != null && SelectedProfile < profiles.Count)
+ {
+ ConfigService.LoadProfile(Profiles[(int)SelectedProfile]);
+ }
+ }
+
+ [RelayCommand]
+ public void OpenProfileDirectory()
+ {
+ string profileDirectory = Path.Combine(FileService.DataDirectory, ConfigService.ProfilesDirectory);
+ if(!Directory.Exists(profileDirectory))
+ Directory.CreateDirectory(profileDirectory);
+ Process.Start("explorer", $"\"{profileDirectory}\"");
+ }
+
+ public void OnConfigUpdate(object? sender, EventArgs e)
+ {
+ Profiles = new(ConfigService.Profiles);
+ ConfigLocked = ConfigService.IsLocked;
+ }
+}
diff --git a/ImageComparisonGUI/ViewModels/SearchPageViewModel.cs b/ImageComparisonGUI/ViewModels/SearchPageViewModel.cs
new file mode 100644
index 0000000..bca429b
--- /dev/null
+++ b/ImageComparisonGUI/ViewModels/SearchPageViewModel.cs
@@ -0,0 +1,291 @@
+using Avalonia.Controls;
+using Avalonia.Interactivity;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using ImageComparison.Models;
+using ImageComparison.Services;
+using ImageComparisonGUI.Models;
+using ImageComparisonGUI.Pages;
+using ImageComparisonGUI.Services;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace ImageComparisonGUI.ViewModels;
+
+public partial class SearchPageViewModel : ViewModelBase
+{
+ private CancellationTokenSource ComparerTaskToken = new();
+ private Task? ComparerTask = null;
+ private List Matches = new();
+ private int displayedMatchIndex = 0;
+
+ #region Observables
+
+ [ObservableProperty] private ImageMatch displayedMatch = new();
+ [ObservableProperty] private bool idle = true;
+ [ObservableProperty] private bool searching = false;
+ [ObservableProperty] private bool displaying = false;
+ [ObservableProperty] private string statusText = "";
+ [ObservableProperty] private string imageCountText = "";
+ [ObservableProperty] private int percentComplete = 0;
+
+ #endregion
+
+ public SearchPageViewModel(SearchPage userControl)
+ {
+ CompareService.OnProgress += OnProgress;
+
+ Button leftImageButton = userControl.Find