├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml ├── stale.yml └── workflows │ ├── ci.yml │ └── codeql-analysis.yml ├── .gitignore ├── Directory.Build.props ├── Image Sort.svg ├── Image-Sort-Screenshot.gif ├── Image-Sort-Screenshot.png ├── Image-Sort.sln ├── LICENSE ├── README.md ├── appveyor.yml ├── docs ├── CNAME ├── English_get-it-from-MS_InvariantCulture_Default.png ├── _config.yml ├── google1896f4f871f455a4.html ├── help.md ├── help │ └── help.de.md ├── index.md ├── privacy_policy.md ├── ror.xml ├── sitemap.html └── sitemap.xml ├── src ├── ImageSort.Localization │ ├── ImageSort.Localization.csproj │ ├── KeyBindingNames.Designer.cs │ ├── KeyBindingNames.de-DE.resx │ ├── KeyBindingNames.resx │ ├── Text.Designer.cs │ ├── Text.de-DE.resx │ └── Text.resx ├── ImageSort.MSIX │ ├── ImageSort.MSIX.wapproj │ ├── Images │ │ ├── LargeTile.scale-100.png │ │ ├── LargeTile.scale-125.png │ │ ├── LargeTile.scale-150.png │ │ ├── LargeTile.scale-200.png │ │ ├── LargeTile.scale-400.png │ │ ├── LockScreenLogo.scale-200.png │ │ ├── SmallTile.scale-100.png │ │ ├── SmallTile.scale-125.png │ │ ├── SmallTile.scale-150.png │ │ ├── SmallTile.scale-200.png │ │ ├── SmallTile.scale-400.png │ │ ├── SplashScreen.scale-100.png │ │ ├── SplashScreen.scale-125.png │ │ ├── SplashScreen.scale-150.png │ │ ├── SplashScreen.scale-200.png │ │ ├── SplashScreen.scale-400.png │ │ ├── Square150x150Logo.scale-100.png │ │ ├── Square150x150Logo.scale-125.png │ │ ├── Square150x150Logo.scale-150.png │ │ ├── Square150x150Logo.scale-200.png │ │ ├── Square150x150Logo.scale-400.png │ │ ├── Square44x44Logo.altform-lightunplated_targetsize-16.png │ │ ├── Square44x44Logo.altform-lightunplated_targetsize-24.png │ │ ├── Square44x44Logo.altform-lightunplated_targetsize-256.png │ │ ├── Square44x44Logo.altform-lightunplated_targetsize-32.png │ │ ├── Square44x44Logo.altform-lightunplated_targetsize-48.png │ │ ├── Square44x44Logo.altform-unplated_targetsize-16.png │ │ ├── Square44x44Logo.altform-unplated_targetsize-256.png │ │ ├── Square44x44Logo.altform-unplated_targetsize-32.png │ │ ├── Square44x44Logo.altform-unplated_targetsize-48.png │ │ ├── Square44x44Logo.scale-100.png │ │ ├── Square44x44Logo.scale-125.png │ │ ├── Square44x44Logo.scale-150.png │ │ ├── Square44x44Logo.scale-200.png │ │ ├── Square44x44Logo.scale-400.png │ │ ├── Square44x44Logo.targetsize-16.png │ │ ├── Square44x44Logo.targetsize-24.png │ │ ├── Square44x44Logo.targetsize-24_altform-unplated.png │ │ ├── Square44x44Logo.targetsize-256.png │ │ ├── Square44x44Logo.targetsize-32.png │ │ ├── Square44x44Logo.targetsize-48.png │ │ ├── StoreLogo.backup.png │ │ ├── StoreLogo.scale-100.png │ │ ├── StoreLogo.scale-125.png │ │ ├── StoreLogo.scale-150.png │ │ ├── StoreLogo.scale-200.png │ │ ├── StoreLogo.scale-400.png │ │ ├── Wide310x150Logo.scale-100.png │ │ ├── Wide310x150Logo.scale-125.png │ │ ├── Wide310x150Logo.scale-150.png │ │ ├── Wide310x150Logo.scale-200.png │ │ └── Wide310x150Logo.scale-400.png │ └── Package.appxmanifest ├── ImageSort.WPF │ ├── App.xaml │ ├── App.xaml.cs │ ├── Converters │ │ ├── PathToBitmapImageConverter.cs │ │ └── PathToFilenameConverter.cs │ ├── FileSystem │ │ ├── FileOperationApiWrapper.cs │ │ ├── ImageLoading.cs │ │ └── RecycleBin.cs │ ├── FolderIcons │ │ └── ShellFileLoader.cs │ ├── Icons │ │ ├── Image Sort.ico │ │ └── Image Sort.png │ ├── ImageSort.WPF.csproj │ ├── Interop.Shell32.dll │ ├── MainWindow.xaml │ ├── MainWindow.xaml.cs │ ├── SettingsManagement │ │ ├── GeneralSettingsGroupView.xaml │ │ ├── GeneralSettingsGroupView.xaml.cs │ │ ├── GeneralSettingsGroupViewModel.cs │ │ ├── MetadataPanelSettings.cs │ │ ├── PinnedFolderSettingsViewModel.cs │ │ ├── SettingsHelper.cs │ │ ├── SettingsView.xaml │ │ ├── SettingsView.xaml.cs │ │ ├── ShortCutManagement │ │ │ ├── Hotkey.cs │ │ │ ├── HotkeyEditor.xaml │ │ │ ├── HotkeyEditor.xaml.cs │ │ │ ├── HotkeyEditorControl.xaml │ │ │ ├── HotkeyEditorControl.xaml.cs │ │ │ ├── KeyBindingsSettingsGroupView.xaml │ │ │ ├── KeyBindingsSettingsGroupView.xaml.cs │ │ │ └── KeybindingsSettingsGroupViewModel.cs │ │ └── WindowPosition │ │ │ └── WindowPositionSettingsViewModel.cs │ ├── Styles │ │ ├── Controls.xaml │ │ ├── GroupBox.xaml │ │ └── HotkeyEditor.xaml │ └── Views │ │ ├── ActionsView.xaml │ │ ├── ActionsView.xaml.cs │ │ ├── Credits │ │ ├── CreditsWindow.xaml │ │ ├── CreditsWindow.xaml.cs │ │ ├── UsedLibraryView.xaml │ │ └── UsedLibraryView.xaml.cs │ │ ├── FolderTreeItemView.xaml │ │ ├── FolderTreeItemView.xaml.cs │ │ ├── FoldersView.xaml │ │ ├── FoldersView.xaml.cs │ │ ├── ImagesView.xaml │ │ ├── ImagesView.xaml.cs │ │ ├── InputBox.xaml │ │ ├── InputBox.xaml.cs │ │ ├── Metadata │ │ ├── MetadataFieldView.xaml │ │ ├── MetadataFieldView.xaml.cs │ │ ├── MetadataSectionView.xaml │ │ ├── MetadataSectionView.xaml.cs │ │ ├── MetadataView.xaml │ │ └── MetadataView.xaml.cs │ │ └── WindowHelper.cs ├── ImageSort.WindowsSetup │ ├── Image Sort.ico │ ├── ImageSort.WindowsSetup.wixproj │ ├── License.rtf │ ├── Product.wxs │ ├── delete_explorer_context_menu.bat │ └── exclude-imagesort.exe.xslt ├── ImageSort.WindowsUpdater │ ├── GitHubUpdateFetcher.cs │ ├── ImageSort.WindowsUpdater.csproj │ └── InstallerRunner.cs └── ImageSort │ ├── Actions │ ├── DeleteAction.cs │ ├── IReversibleAction.cs │ ├── MoveAction.cs │ └── RenameAction.cs │ ├── DependencyManagement │ └── DependencyRegistrationHelper.cs │ ├── FileSystem │ ├── FileRestorationNotPossibleException.cs │ ├── FullAccessFileSystem.cs │ ├── FullAccessFileSystemMetadataExtractor.cs │ ├── IFileSystem.cs │ ├── IMetadataExtractor.cs │ └── IRecycleBin.cs │ ├── Helpers │ ├── PathHelper.cs │ └── StringHelper.cs │ ├── ImageSort.csproj │ ├── SettingsManagement │ ├── SettingsGroupViewModelBase.cs │ └── SettingsViewModel.cs │ └── ViewModels │ ├── ActionsViewModel.cs │ ├── FolderTreeItemViewModel.cs │ ├── FoldersViewModel.cs │ ├── ImagesViewModel.cs │ ├── MainViewModel.cs │ └── Metadata │ ├── MetadataFieldViewModel.cs │ ├── MetadataFieldViewModelFactory.cs │ ├── MetadataSectionViewModel.cs │ ├── MetadataSectionViewModelFactory.cs │ └── MetadataViewModel.cs └── tests ├── ImageSort.UnitTests ├── Actions │ ├── DeleteActionTests.cs │ ├── MoveActionTests.cs │ └── RenameActionTests.cs ├── ImageSort.UnitTests.csproj ├── SettingsManagement │ ├── SettingsGroupViewModelBaseTests.cs │ └── SettingsViewModelTests.cs └── ViewModels │ ├── ActionsViewModelTest.cs │ ├── FolderTreeItemViewModelTests.cs │ ├── FoldersViewModelTests.cs │ ├── ImagesViewModelTests.cs │ ├── MainViewModelTests.cs │ └── MetadataViewModelTests.cs └── ImageSort.WPF.UiTests ├── AppCollection.cs ├── AppFixture.cs ├── ControlHelper.cs ├── FileActionsTests.cs ├── FolderActionsTests.cs ├── ImageSort.WPF.UiTests.csproj ├── MockState ├── Subfolder 1 │ ├── Subsubfolder │ │ └── mock in subsubfolder.jpg │ └── mock in subfolder.jpg ├── Subfolder 2 │ └── mock in subfolder 2.jpg ├── mock 1.jpg ├── mock 2.jpg ├── mock 3.jpg ├── mock 4.png ├── mock 5.jpg ├── mock 6.jpg └── mock 7.jpg ├── SearchTests.cs ├── SetupTeardownHelper.cs └── WindowHelper.cs /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | # All files 4 | [*] 5 | indent_style = space 6 | 7 | # Xml files 8 | [*.xml] 9 | indent_size = 2 10 | 11 | [*.cs] 12 | csharp_style_namespace_declarations=file_scoped:suggestion -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report unintended and unwanted behavior 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. Windows 7/8/10] 28 | - Architecture: [x86/x64] 29 | - Version: [e.g. 2.0.0, 2.0.0-preview.5] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: nuget 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 10 8 | groups: 9 | reactiveui: 10 | patterns: 11 | - "ReactiveUI*" 12 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: wontfix 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Image Sort CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | release: 7 | types: [ created ] 8 | 9 | jobs: 10 | build: 11 | runs-on: windows-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | with: 15 | fetch-depth: 0 16 | - name: Setup .NET Core 17 | uses: actions/setup-dotnet@v1 18 | with: 19 | dotnet-version: '8.0.x' 20 | - name: GitVersion 21 | uses: gittools/actions/gitversion/setup@v0.10.2 22 | with: 23 | versionSpec: '5.x' 24 | includePrerelease: true 25 | - name: Determine Version 26 | id: gitversion 27 | uses: gittools/actions/gitversion/execute@v0 28 | - name: Build 29 | run: dotnet build --configuration Release 30 | - name: Unit Tests 31 | run: dotnet test .\tests\ImageSort.UnitTests\ImageSort.UnitTests.csproj --configuration Release --no-build 32 | # UI tests are disabled due to changes of the windows runner that broke them 33 | #- name: UI Tests 34 | # run: dotnet test .\tests\ImageSort.WPF.UiTests\ImageSort.WPF.UiTests.csproj --configuration Release --no-build 35 | - name: Build WIX project 36 | if: ${{ github.event_name == 'release' }} 37 | env: 38 | GITHUB_ACTIONS_CI: true 39 | run: | 40 | cd .\src\ImageSort.WindowsSetup\; 41 | dotnet gitversion /updatewixversionfile | Out-String; 42 | dotnet tool install --global wix 43 | dotnet build -c Release -r win-x86 -p:Platform=x86 44 | dotnet build -c Release -r win-x64 -p:Platform=x64 45 | dotnet build -c Release -r win-arm64 -p:Platform=ARM64 46 | cd ..\..; 47 | - name: Upload x86 MSI file 48 | id: upload-x86-msi-file 49 | uses: actions/upload-release-asset@v1.0.2 50 | if: ${{ github.event_name == 'release' }} 51 | with: 52 | asset_path: .\artifacts\x86\ImageSort.x86.msi 53 | asset_name: ImageSort.x86.msi 54 | asset_content_type: application/octet-stream 55 | upload_url: ${{ github.event.release.upload_url }} 56 | env: 57 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 58 | TIME: ${{ github.run_number }} 59 | - name: Upload x64 MSI file 60 | id: upload-x64-msi-file 61 | uses: actions/upload-release-asset@v1.0.2 62 | if: ${{ github.event_name == 'release' }} 63 | with: 64 | asset_path: .\artifacts\x64\ImageSort.x64.msi 65 | asset_name: ImageSort.x64.msi 66 | asset_content_type: application/octet-stream 67 | upload_url: ${{ github.event.release.upload_url }} 68 | env: 69 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 70 | TIME: ${{ github.run_number }} 71 | - name: Upload ARM64 MSI file 72 | id: upload-arm64-msi-file 73 | uses: actions/upload-release-asset@v1.0.2 74 | if: ${{ github.event_name == 'release' }} 75 | with: 76 | asset_path: .\artifacts\ARM64\ImageSort.ARM64.msi 77 | asset_name: ImageSort.ARM64.msi 78 | asset_content_type: application/octet-stream 79 | upload_url: ${{ github.event.release.upload_url }} 80 | env: 81 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 82 | TIME: ${{ github.run_number }} 83 | - name: Install winget-create 84 | if: ${{ github.event_name == 'release' && !github.event.release.prerelease }} 85 | run: choco install wingetcreate 86 | - name: Install .NET 6 87 | uses: actions/setup-dotnet@v3 88 | if: ${{ github.event_name == 'release' && !github.event.release.prerelease }} 89 | with: 90 | dotnet-version: '6.0.x' 91 | - name: Update and submit Winget manifest 92 | if: ${{ github.event_name == 'release' && !github.event.release.prerelease }} 93 | run: | 94 | wingetcreate.exe update --submit --token ${{ secrets.PAT_WINGET }} --urls "${{ steps.upload-x86-msi-file.outputs.browser_download_url }}" "${{ steps.upload-x64-msi-file.outputs.browser_download_url }}" "${{ steps.upload-arm64-msi-file.outputs.browser_download_url }}" --version "${{ steps.gitversion.outputs.assemblySemVer }}" Lolle2000la.ImageSort 95 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '16 2 * * 0' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | 28 | strategy: 29 | fail-fast: false 30 | matrix: 31 | language: [ 'csharp' ] 32 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 33 | # Learn more: 34 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 35 | 36 | steps: 37 | - name: Checkout repository 38 | uses: actions/checkout@v2 39 | 40 | # Initializes the CodeQL tools for scanning. 41 | - name: Initialize CodeQL 42 | uses: github/codeql-action/init@v1 43 | with: 44 | languages: ${{ matrix.language }} 45 | # If you wish to specify custom queries, you can do so here or in a config file. 46 | # By default, queries listed here will override any specified in a config file. 47 | # Prefix the list here with "+" to use these queries and those in the config file. 48 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 49 | 50 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 51 | # If this step fails, then you should remove it and run the build manually (see below) 52 | - name: Autobuild 53 | uses: github/codeql-action/autobuild@v1 54 | 55 | # ℹ️ Command-line programs to run using the OS shell. 56 | # 📚 https://git.io/JvXDl 57 | 58 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 59 | # and modify them (or add more) to build your code if your project 60 | # uses a compiled language 61 | 62 | #- run: | 63 | # make bootstrap 64 | # make release 65 | 66 | - name: Perform CodeQL Analysis 67 | uses: github/codeql-action/analyze@v1 68 | -------------------------------------------------------------------------------- /Directory.Build.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | true 4 | 5 | -------------------------------------------------------------------------------- /Image-Sort-Screenshot.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/Image-Sort-Screenshot.gif -------------------------------------------------------------------------------- /Image-Sort-Screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/Image-Sort-Screenshot.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Lolle2000la 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | image: Visual Studio 2022 2 | configuration: Release 3 | 4 | 5 | cache: 6 | - 'C:\ProgramData\chocolatey\bin -> appveyor.yml' 7 | - 'C:\ProgramData\chocolatey\lib -> appveyor.yml' 8 | 9 | install: 10 | - choco install gitversion.portable -pre -y 11 | 12 | before_build: 13 | - dotnet restore 14 | - ps: gitversion /l console /output buildserver /updateAssemblyInfo 15 | 16 | build: 17 | project: Image-Sort.sln 18 | parallel: true 19 | verbosity: minimal 20 | 21 | environment: 22 | IGNORE_NORMALISATION_GIT_HEAD_MOVE: "1" 23 | 24 | test_script: 25 | - cmd: dotnet test "C:\projects\image-sort\tests\ImageSort.UnitTests\ImageSort.UnitTests.csproj" --configuration Release --no-build 26 | 27 | after_test: 28 | - ps: >- 29 | if ($env:APPVEYOR_REPO_TAG -eq "true") { 30 | cd .\src\ImageSort.WindowsSetup\; 31 | gitversion /updatewixversionfile | Out-String; 32 | MSBuild /p:Configuration=Release /p:Platform=x64 /p:OutputPath=..\..\artifacts\x64 /p:BuildProjectReferences=false | Out-String; 33 | MSBuild /p:Configuration=Release /p:Platform=x86 /p:OutputPath=..\..\artifacts\x86 /p:BuildProjectReferences=false | Out-String; 34 | cd ..\..; 35 | } 36 | 37 | artifacts: 38 | - path: '.\artifacts\**\*.msi' 39 | name: Image Sort Installer 40 | type: File 41 | 42 | #deploy: 43 | # - provider: GitHub 44 | # release: Image Sort v$(appveyor_build_version) 45 | # tag: $(APPVEYOR_REPO_TAG_NAME) 46 | # auth_token: 47 | # secure: Gka4zhwdCzAkR5hGyS988om/2MSNTP+BZDilT87BtI1jA7NYq9x0BB1cBUO3gPKa 48 | # artifact: Image Sort Installer 49 | # on: 50 | # APPVEYOR_REPO_TAG: true 51 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | imagesort.org -------------------------------------------------------------------------------- /docs/English_get-it-from-MS_InvariantCulture_Default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/docs/English_get-it-from-MS_InvariantCulture_Default.png -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-slate 2 | title: Image Sort - a free app for sorting your folders 3 | description: Image Sort helps you sort your big and messy folders with speed, not friction. 4 | social_image_path: https://github.com/Lolle2000la/Image-Sort/blob/master/Image%20sort.UI/Image_Sort.png 5 | social_logo_path: https://github.com/Lolle2000la/Image-Sort/blob/master/Image%20sort.UI/Image_Sort.png 6 | -------------------------------------------------------------------------------- /docs/google1896f4f871f455a4.html: -------------------------------------------------------------------------------- 1 | google-site-verification: google1896f4f871f455a4.html -------------------------------------------------------------------------------- /docs/privacy_policy.md: -------------------------------------------------------------------------------- 1 | ## Privacy Policy 2 | 3 | Lolle2000la built the Image Sort app as an Open Source app. This SERVICE is provided by Lolle2000la at no cost and is intended for use as is. 4 | 5 | This page is used to inform website visitors regarding my policies with the collection, use, and disclosure of Personal Information if anyone decided to use my Service. 6 | 7 | If you choose to use my Service, then you agree to the collection and use of information in relation to this policy. The Personal Information that I collect is used for providing and improving the Service. I will not use or share your information with anyone except as described in this Privacy Policy. 8 | 9 | The terms used in this Privacy Policy have the same meanings as in our Terms and Conditions, which is accessible at Image Sort unless otherwise defined in this Privacy Policy. 10 | 11 | **Information Collection and Use** 12 | 13 | For a better experience, while using our Service, I may require you to provide us with certain personally identifiable information. The information that I request is retained on your device and is not collected by me in any way 14 | 15 | The app does use third party services that may collect information used to identify you. 16 | 17 | **Log Data** 18 | 19 | I want to inform you that whenever you use my Service, in a case of an error in the app I collect data and information (through third party products) on your phone called Log Data. This Log Data may include information such as your device Internet Protocol (“IP”) address, device name, operating system version, the configuration of the app when utilizing my Service, the time and date of your use of the Service, and other statistics. 20 | 21 | **Cookies** 22 | 23 | Cookies are files with a small amount of data that are commonly used as anonymous unique identifiers. These are sent to your browser from the websites that you visit and are stored on your device's internal memory. 24 | 25 | This Service does not use these “cookies” explicitly. However, the app may use third party code and libraries that use “cookies” to collect information and improve their services. You have the option to either accept or refuse these cookies and know when a cookie is being sent to your device. If you choose to refuse our cookies, you may not be able to use some portions of this Service. 26 | 27 | **Service Providers** 28 | 29 | I may employ third-party companies and individuals due to the following reasons: 30 | 31 | - To facilitate our Service; 32 | - To provide the Service on our behalf; 33 | - To perform Service-related services; or 34 | - To assist us in analyzing how our Service is used. 35 | 36 | I want to inform users of this Service that these third parties have access to your Personal Information. The reason is to perform the tasks assigned to them on our behalf. However, they are obligated not to disclose or use the information for any other purpose. 37 | 38 | **Security** 39 | 40 | I value your trust in providing us your Personal Information, thus we are striving to use commercially acceptable means of protecting it. But remember that no method of transmission over the internet, or method of electronic storage is 100% secure and reliable, and I cannot guarantee its absolute security. 41 | 42 | **Links to Other Sites** 43 | 44 | This Service may contain links to other sites. If you click on a third-party link, you will be directed to that site. Note that these external sites are not operated by me. Therefore, I strongly advise you to review the Privacy Policy of these websites. I have no control over and assume no responsibility for the content, privacy policies, or practices of any third-party sites or services. 45 | 46 | **Children’s Privacy** 47 | 48 | These Services do not address anyone under the age of 13. I do not knowingly collect personally identifiable information from children under 13. In the case I discover that a child under 13 has provided me with personal information, I immediately delete this from our servers. If you are a parent or guardian and you are aware that your child has provided us with personal information, please contact me so that I will be able to do necessary actions. 49 | 50 | **Changes to This Privacy Policy** 51 | 52 | I may update our Privacy Policy from time to time. Thus, you are advised to review this page periodically for any changes. I will notify you of any changes by posting the new Privacy Policy on this page. These changes are effective immediately after they are posted on this page. 53 | 54 | **Contact Us** 55 | 56 | If you have any questions or suggestions about my Privacy Policy, do not hesitate to contact me. 57 | 58 | This privacy policy page was created at [privacypolicytemplate.net](https://privacypolicytemplate.net) and modified/generated by [App Privacy Policy Generator](https://app-privacy-policy-generator.firebaseapp.com/) 59 | -------------------------------------------------------------------------------- /docs/ror.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ROR Sitemap for https://imagesort.org/ 5 | https://imagesort.org/ 6 | 7 | 8 | https://imagesort.org/ 9 | Image Sort - a free app for sorting your folders | Image Sort helps you sort your big and messy folders. It’s tiny too, just about 3 megabytes. 10 | Image Sort helps you sort your big and messy folders. It’s tiny too, just about 3 megabytes. 11 | 12 | 0 13 | sitemap 14 | 15 | 16 | https://imagesort.org/help.html 17 | Getting started | Image Sort - a free app for sorting your folders 18 | Image Sort helps you sort your big and messy folders. It’s tiny too, just about 3 megabytes. 19 | 20 | 1 21 | sitemap 22 | 23 | 24 | https://imagesort.org/privacy_policy.html 25 | Privacy Policy | Image Sort - a free app for sorting your folders 26 | Image Sort helps you sort your big and messy folders. It’s tiny too, just about 3 megabytes. 27 | 28 | 1 29 | sitemap 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /docs/sitemap.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | imagesort.org Site Map - Generated by www.xml-sitemaps.com 6 | 10 | 112 | 113 | 114 | 115 |
116 | 117 |

118 | Last updated: 2018, June 10
120 | Total pages: %TOTALURLS%
122 | imagesort.org Homepage 123 |

124 |
125 |
126 | 155 | 164 |
165 | 172 | 173 | 174 | -------------------------------------------------------------------------------- /docs/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 11 | https://imagesort.org/ 12 | 2018-06-10T14:05:50+00:00 13 | 1.00 14 | 15 | 16 | https://imagesort.org/help.html 17 | 2018-06-10T14:05:50+00:00 18 | 0.80 19 | 20 | 21 | https://imagesort.org/privacy_policy.html 22 | 2018-06-10T14:05:50+00:00 23 | 0.80 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/ImageSort.Localization/ImageSort.Localization.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | Debug;Release;MSIX 6 | win-x86;win-x64;win-arm64 7 | 8 | 9 | 10 | 11 | True 12 | True 13 | KeyBindingNames.resx 14 | 15 | 16 | Text.resx 17 | True 18 | True 19 | 20 | 21 | True 22 | True 23 | Text.resx 24 | 25 | 26 | 27 | 28 | 29 | PublicResXFileCodeGenerator 30 | KeyBindingNames.Designer.cs 31 | 32 | 33 | Text.Designer.cs 34 | PublicResXFileCodeGenerator 35 | 36 | 37 | PublicResXFileCodeGenerator 38 | Text.Designer.cs 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/LargeTile.scale-100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/LargeTile.scale-100.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/LargeTile.scale-125.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/LargeTile.scale-125.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/LargeTile.scale-150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/LargeTile.scale-150.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/LargeTile.scale-200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/LargeTile.scale-200.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/LargeTile.scale-400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/LargeTile.scale-400.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/LockScreenLogo.scale-200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/LockScreenLogo.scale-200.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/SmallTile.scale-100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/SmallTile.scale-100.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/SmallTile.scale-125.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/SmallTile.scale-125.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/SmallTile.scale-150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/SmallTile.scale-150.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/SmallTile.scale-200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/SmallTile.scale-200.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/SmallTile.scale-400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/SmallTile.scale-400.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/SplashScreen.scale-100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/SplashScreen.scale-100.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/SplashScreen.scale-125.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/SplashScreen.scale-125.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/SplashScreen.scale-150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/SplashScreen.scale-150.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/SplashScreen.scale-200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/SplashScreen.scale-200.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/SplashScreen.scale-400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/SplashScreen.scale-400.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/Square150x150Logo.scale-100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/Square150x150Logo.scale-100.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/Square150x150Logo.scale-125.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/Square150x150Logo.scale-125.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/Square150x150Logo.scale-150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/Square150x150Logo.scale-150.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/Square150x150Logo.scale-200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/Square150x150Logo.scale-200.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/Square150x150Logo.scale-400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/Square150x150Logo.scale-400.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/Square44x44Logo.altform-lightunplated_targetsize-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/Square44x44Logo.altform-lightunplated_targetsize-16.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/Square44x44Logo.altform-lightunplated_targetsize-24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/Square44x44Logo.altform-lightunplated_targetsize-24.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/Square44x44Logo.altform-lightunplated_targetsize-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/Square44x44Logo.altform-lightunplated_targetsize-256.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/Square44x44Logo.altform-lightunplated_targetsize-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/Square44x44Logo.altform-lightunplated_targetsize-32.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/Square44x44Logo.altform-lightunplated_targetsize-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/Square44x44Logo.altform-lightunplated_targetsize-48.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/Square44x44Logo.altform-unplated_targetsize-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/Square44x44Logo.altform-unplated_targetsize-16.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/Square44x44Logo.altform-unplated_targetsize-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/Square44x44Logo.altform-unplated_targetsize-256.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/Square44x44Logo.altform-unplated_targetsize-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/Square44x44Logo.altform-unplated_targetsize-32.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/Square44x44Logo.altform-unplated_targetsize-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/Square44x44Logo.altform-unplated_targetsize-48.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/Square44x44Logo.scale-100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/Square44x44Logo.scale-100.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/Square44x44Logo.scale-125.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/Square44x44Logo.scale-125.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/Square44x44Logo.scale-150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/Square44x44Logo.scale-150.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/Square44x44Logo.scale-200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/Square44x44Logo.scale-200.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/Square44x44Logo.scale-400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/Square44x44Logo.scale-400.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/Square44x44Logo.targetsize-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/Square44x44Logo.targetsize-16.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/Square44x44Logo.targetsize-24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/Square44x44Logo.targetsize-24.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/Square44x44Logo.targetsize-24_altform-unplated.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/Square44x44Logo.targetsize-24_altform-unplated.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/Square44x44Logo.targetsize-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/Square44x44Logo.targetsize-256.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/Square44x44Logo.targetsize-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/Square44x44Logo.targetsize-32.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/Square44x44Logo.targetsize-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/Square44x44Logo.targetsize-48.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/StoreLogo.backup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/StoreLogo.backup.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/StoreLogo.scale-100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/StoreLogo.scale-100.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/StoreLogo.scale-125.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/StoreLogo.scale-125.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/StoreLogo.scale-150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/StoreLogo.scale-150.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/StoreLogo.scale-200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/StoreLogo.scale-200.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/StoreLogo.scale-400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/StoreLogo.scale-400.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/Wide310x150Logo.scale-100.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/Wide310x150Logo.scale-100.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/Wide310x150Logo.scale-125.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/Wide310x150Logo.scale-125.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/Wide310x150Logo.scale-150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/Wide310x150Logo.scale-150.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/Wide310x150Logo.scale-200.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/Wide310x150Logo.scale-200.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Images/Wide310x150Logo.scale-400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.MSIX/Images/Wide310x150Logo.scale-400.png -------------------------------------------------------------------------------- /src/ImageSort.MSIX/Package.appxmanifest: -------------------------------------------------------------------------------- 1 |  2 | 3 | 8 | 9 | 13 | 14 | 15 | Image sort 16 | Lolle2000la 17 | Images\StoreLogo.png 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 33 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /src/ImageSort.WPF/App.xaml: -------------------------------------------------------------------------------- 1 |  5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/ImageSort.WPF/Converters/PathToBitmapImageConverter.cs: -------------------------------------------------------------------------------- 1 | using ImageSort.SettingsManagement; 2 | using ImageSort.WPF.FileSystem; 3 | using ImageSort.WPF.SettingsManagement; 4 | using Splat; 5 | using System; 6 | using System.Collections.Generic; 7 | using System.Diagnostics.CodeAnalysis; 8 | using System.Globalization; 9 | using System.IO; 10 | using System.Linq; 11 | using System.Windows.Data; 12 | using System.Windows.Media.Imaging; 13 | 14 | namespace ImageSort.WPF.Converters; 15 | 16 | [ValueConversion(typeof(string), typeof(BitmapImage))] 17 | internal class PathToBitmapImageConverter : IValueConverter 18 | { 19 | public int? LoadWidth { get; set; } = null; 20 | public bool ForGifThumbnails { get; set; } = false; 21 | 22 | private GeneralSettingsGroupViewModel generalSettings = Locator.Current.GetService>() 23 | .Select(s => s as GeneralSettingsGroupViewModel) 24 | .First(s => s != null); 25 | 26 | [SuppressMessage("Design", "CA1031:Do not catch general exception types", 27 | Justification = "The app should not crash just because some exception happened")] 28 | public object Convert(object value, Type targetType, object parameter, CultureInfo culture) 29 | { 30 | if (value == null) return null; 31 | if (ForGifThumbnails && (!generalSettings.AnimateGifThumbnails || !generalSettings.AnimateGifs)) return null; // prevent gifs from loading when disabled. 32 | 33 | if (value is string path) 34 | { 35 | if (ForGifThumbnails && Path.GetExtension(path).ToUpperInvariant() != ".GIF") return null; 36 | return ImageLoading.GetImageFromPath(path); 37 | } 38 | 39 | return null; 40 | } 41 | 42 | public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) 43 | { 44 | throw new NotImplementedException(); 45 | } 46 | } -------------------------------------------------------------------------------- /src/ImageSort.WPF/Converters/PathToFilenameConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Globalization; 3 | using System.IO; 4 | using System.Windows.Data; 5 | 6 | namespace ImageSort.WPF.Converters; 7 | 8 | [ValueConversion(typeof(string), typeof(string))] 9 | internal class PathToFilenameConverter : IValueConverter 10 | { 11 | public object Convert(object value, Type targetType, object parameter, CultureInfo culture) 12 | { 13 | if (value == null) return ""; 14 | 15 | if (value is string path) return Path.GetFileName(path); 16 | 17 | return ""; 18 | } 19 | 20 | public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) 21 | { 22 | throw new NotImplementedException(); 23 | } 24 | } -------------------------------------------------------------------------------- /src/ImageSort.WPF/FileSystem/ImageLoading.cs: -------------------------------------------------------------------------------- 1 | using ImageSort.Localization; 2 | using LazyCache; 3 | using LazyCache.Providers; 4 | using Microsoft.Extensions.Caching.Memory; 5 | using System; 6 | using System.Diagnostics; 7 | using System.Globalization; 8 | using System.IO; 9 | using System.Windows; 10 | using System.Windows.Media; 11 | using System.Windows.Media.Imaging; 12 | 13 | namespace ImageSort.WPF.FileSystem; 14 | 15 | internal static class ImageLoading 16 | { 17 | static IAppCache cache = new CachingService( 18 | new MemoryCacheProvider( 19 | new MemoryCache( 20 | new MemoryCacheOptions() 21 | { 22 | SizeLimit = 20, // limit the maximum number of 23 | }))); 24 | 25 | static MemoryCacheEntryOptions options = new MemoryCacheEntryOptions() 26 | { 27 | Size = 1, // the same unit must be used, as MemoryCache itself knows no units. Here, 1 equals 1 element. 28 | }; 29 | 30 | public static ImageSource GetImageFromPath(string path) 31 | { 32 | if (path == null) return null; 33 | 34 | return cache.GetOrAdd(path, () => 35 | { 36 | try 37 | { 38 | var bitmapImage = new BitmapImage(); 39 | Rotation rotation = GetImageOrientation(path); 40 | 41 | bitmapImage.BeginInit(); 42 | bitmapImage.CacheOption = BitmapCacheOption.OnLoad; 43 | bitmapImage.UriSource = new Uri(path); 44 | bitmapImage.Rotation = rotation; 45 | bitmapImage.EndInit(); 46 | 47 | if (bitmapImage.Width <= 0 || bitmapImage.Height <= 0) 48 | throw new BadImageFormatException($"Image {Path.GetFileName(path)} has invalid dimensions.", path); 49 | 50 | return bitmapImage; 51 | } 52 | catch (Exception ex) 53 | { 54 | var textDrawing = new GeometryDrawing 55 | { 56 | Geometry = new GeometryGroup 57 | { 58 | Children = new GeometryCollection(new[] 59 | { 60 | new FormattedText(Text.CouldNotLoadImageErrorText 61 | .Replace("{ErrorMessage}", ex.Message, StringComparison.OrdinalIgnoreCase) 62 | .Replace("{FileName}", Path.GetFileName(path), 63 | StringComparison.OrdinalIgnoreCase), 64 | CultureInfo.CurrentCulture, 65 | FlowDirection.LeftToRight, 66 | new Typeface("Segoe UI"), 67 | 16, 68 | Brushes.Black, 1) 69 | .BuildGeometry(new Point(8, 8)) 70 | }) 71 | }, 72 | Brush = Brushes.Black, 73 | Pen = new Pen(Brushes.White, 0.5) 74 | }; 75 | 76 | return new DrawingImage(textDrawing); 77 | } 78 | }, options); 79 | } 80 | 81 | // Required for some images to be displayed in their correct orientation. See https://github.com/Lolle2000la/Image-Sort/issues/445 82 | // Solution taken from StackOverflow user Lâm Quang Minh (https://stackoverflow.com/a/63627972/7147000) 83 | private static Rotation GetImageOrientation(string path) 84 | { 85 | const string _orientationQuery = "System.Photo.Orientation"; 86 | Rotation rotation = Rotation.Rotate0; 87 | using (FileStream fileStream = new FileStream(path, FileMode.Open, FileAccess.Read)) 88 | { 89 | BitmapFrame bitmapFrame = BitmapFrame.Create(fileStream, BitmapCreateOptions.DelayCreation, BitmapCacheOption.None); 90 | BitmapMetadata bitmapMetadata = bitmapFrame.Metadata as BitmapMetadata; 91 | 92 | if ((bitmapMetadata != null) && (bitmapMetadata.ContainsQuery(_orientationQuery))) 93 | { 94 | object o = bitmapMetadata.GetQuery(_orientationQuery); 95 | 96 | if (o != null) 97 | { 98 | switch ((ushort)o) 99 | { 100 | case 6: 101 | { 102 | rotation = Rotation.Rotate90; 103 | } 104 | break; 105 | case 3: 106 | { 107 | rotation = Rotation.Rotate180; 108 | } 109 | break; 110 | case 8: 111 | { 112 | rotation = Rotation.Rotate270; 113 | } 114 | break; 115 | } 116 | } 117 | } 118 | } 119 | 120 | return rotation; 121 | } 122 | } -------------------------------------------------------------------------------- /src/ImageSort.WPF/FileSystem/RecycleBin.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Reactive.Disposables; 4 | using ImageSort.FileSystem; 5 | using ImageSort.Helpers; 6 | using Shell32; 7 | 8 | namespace ImageSort.WPF.FileSystem; 9 | 10 | internal class RecycleBin : IRecycleBin 11 | { 12 | private readonly Shell shell = new Shell(); 13 | 14 | public IDisposable Send(string path, bool confirmationNeeded = false) 15 | { 16 | var success = false; 17 | 18 | if (confirmationNeeded) 19 | success = FileOperationApiWrapper.Send(path, 20 | FileOperationApiWrapper.FileOperationFlags.FOF_ALLOWUNDO 21 | | FileOperationApiWrapper.FileOperationFlags.FOF_WANTNUKEWARNING); 22 | else 23 | success = FileOperationApiWrapper.Send(path, 24 | FileOperationApiWrapper.FileOperationFlags.FOF_ALLOWUNDO 25 | | FileOperationApiWrapper.FileOperationFlags.FOF_NOCONFIRMATION 26 | | FileOperationApiWrapper.FileOperationFlags.FOF_WANTNUKEWARNING); 27 | 28 | if (!success) throw new IOException($"Could not delete {Path.GetFileName(path)}"); 29 | 30 | return Disposable.Create(path, RestoreFileFromRecycleBin); 31 | } 32 | 33 | private void RestoreFileFromRecycleBin(string path) 34 | { 35 | var recycler = shell.NameSpace(10); 36 | 37 | foreach (FolderItem item in recycler.Items()) 38 | { 39 | var fileName = recycler.GetDetailsOf(item, 0); 40 | 41 | if (string.IsNullOrEmpty(Path.GetExtension(fileName))) fileName += Path.GetExtension(item.Path); 42 | 43 | var filePath = recycler.GetDetailsOf(item, 1); 44 | 45 | if (path.PathEquals(Path.Combine(filePath, fileName))) 46 | { 47 | Restore(item); 48 | return; 49 | } 50 | } 51 | 52 | throw new FileNotFoundException(null, path); 53 | } 54 | 55 | private void Restore(FolderItem item) 56 | { 57 | var itemVerbs = item.Verbs(); 58 | 59 | itemVerbs.Item(0).DoIt(); 60 | } 61 | } -------------------------------------------------------------------------------- /src/ImageSort.WPF/Icons/Image Sort.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.WPF/Icons/Image Sort.ico -------------------------------------------------------------------------------- /src/ImageSort.WPF/Icons/Image Sort.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.WPF/Icons/Image Sort.png -------------------------------------------------------------------------------- /src/ImageSort.WPF/ImageSort.WPF.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | WinExe 6 | net8.0-windows 7 | true 8 | Image Sort 9 | Debug;Release;MSIX 10 | AnyCPU;x86;x64;ARM64 11 | win-x86;win-x64;win-arm64 12 | true 13 | 14 | 15 | 16 | true 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | All 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | DO_NOT_INCLUDE_UPDATER 41 | 42 | 43 | 44 | 45 | Interop.Shell32.dll 46 | 47 | 48 | 49 | 50 | false 51 | Icons\Image Sort.ico 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /src/ImageSort.WPF/Interop.Shell32.dll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.WPF/Interop.Shell32.dll -------------------------------------------------------------------------------- /src/ImageSort.WPF/MainWindow.xaml: -------------------------------------------------------------------------------- 1 |  13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/ImageSort.WPF/Views/InputBox.xaml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Windows; 3 | using AdonisUI.Controls; 4 | 5 | namespace ImageSort.WPF.Views; 6 | 7 | /// 8 | /// Interaction logic for InputBox.xaml 9 | /// 10 | public partial class InputBox : AdonisWindow 11 | { 12 | public InputBox(string question, string title) 13 | { 14 | InitializeComponent(); 15 | Question.Text = question; 16 | Title = title; 17 | } 18 | 19 | public string Answer => AnswerBox.Text; 20 | 21 | private void btnDialogOk_Click(object sender, RoutedEventArgs e) 22 | { 23 | DialogResult = true; 24 | } 25 | 26 | private void Window_ContentRendered(object sender, EventArgs e) 27 | { 28 | AnswerBox.SelectAll(); 29 | AnswerBox.Focus(); 30 | } 31 | } -------------------------------------------------------------------------------- /src/ImageSort.WPF/Views/Metadata/MetadataFieldView.xaml: -------------------------------------------------------------------------------- 1 |  12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/ImageSort.WPF/Views/Metadata/MetadataFieldView.xaml.cs: -------------------------------------------------------------------------------- 1 | using ImageSort.ViewModels.Metadata; 2 | using ReactiveUI; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Reactive.Disposables; 7 | using System.Text; 8 | using System.Threading.Tasks; 9 | using System.Windows; 10 | using System.Windows.Controls; 11 | using System.Windows.Data; 12 | using System.Windows.Documents; 13 | using System.Windows.Input; 14 | using System.Windows.Media; 15 | using System.Windows.Media.Imaging; 16 | using System.Windows.Navigation; 17 | using System.Windows.Shapes; 18 | 19 | namespace ImageSort.WPF.Views.Metadata; 20 | 21 | /// 22 | /// Interaction logic for MetadataSection.xaml 23 | /// 24 | public partial class MetadataFieldView : ReactiveUserControl 25 | { 26 | public MetadataFieldView() 27 | { 28 | InitializeComponent(); 29 | 30 | this.WhenActivated(disposableRegistration => 31 | { 32 | this.OneWayBind(ViewModel, 33 | vm => vm.Name, 34 | view => view.FieldName.Text) 35 | .DisposeWith(disposableRegistration); 36 | 37 | this.OneWayBind(ViewModel, 38 | vm => vm.Value, 39 | view => view.FieldValue.Text) 40 | .DisposeWith(disposableRegistration); 41 | }); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/ImageSort.WPF/Views/Metadata/MetadataSectionView.xaml: -------------------------------------------------------------------------------- 1 |  13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/ImageSort.WPF/Views/Metadata/MetadataSectionView.xaml.cs: -------------------------------------------------------------------------------- 1 | using ImageSort.ViewModels.Metadata; 2 | using ReactiveUI; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Reactive.Disposables; 7 | using System.Text; 8 | using System.Threading.Tasks; 9 | using System.Windows; 10 | using System.Windows.Controls; 11 | using System.Windows.Data; 12 | using System.Windows.Documents; 13 | using System.Windows.Input; 14 | using System.Windows.Media; 15 | using System.Windows.Media.Imaging; 16 | using System.Windows.Navigation; 17 | using System.Windows.Shapes; 18 | 19 | namespace ImageSort.WPF.Views.Metadata; 20 | 21 | /// 22 | /// Interaction logic for MetadataSection.xaml 23 | /// 24 | public partial class MetadataSectionView : ReactiveUserControl 25 | { 26 | public MetadataSectionView() 27 | { 28 | InitializeComponent(); 29 | 30 | this.WhenActivated(disposableRegistration => 31 | { 32 | this.OneWayBind(ViewModel, 33 | vm => vm.Title, 34 | view => view.Titel.Text) 35 | .DisposeWith(disposableRegistration); 36 | 37 | this.OneWayBind(ViewModel, 38 | vm => vm.Fields, 39 | view => view.Fields.ItemsSource) 40 | .DisposeWith(disposableRegistration); 41 | }); 42 | } 43 | 44 | private void Fields_PreviewMouseWheel_BubbleUpToParent(object sender, MouseWheelEventArgs e) 45 | { 46 | if (!e.Handled) 47 | { 48 | var eventArg = new MouseWheelEventArgs(e.MouseDevice, e.Timestamp, e.Delta) 49 | { 50 | RoutedEvent = UIElement.MouseWheelEvent, 51 | Source = sender 52 | }; 53 | var parent = ((Control)sender).Parent as UIElement; 54 | parent.RaiseEvent(eventArg); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/ImageSort.WPF/Views/Metadata/MetadataView.xaml: -------------------------------------------------------------------------------- 1 |  13 | 14 | 15 | 16 | 17 | 18 | 19 | 24 | 25 | 26 | 27 | 28 | 29 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/ImageSort.WPF/Views/Metadata/MetadataView.xaml.cs: -------------------------------------------------------------------------------- 1 | using ImageSort.SettingsManagement; 2 | using ImageSort.ViewModels.Metadata; 3 | using ImageSort.WPF.SettingsManagement; 4 | using ReactiveUI; 5 | using Splat; 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Linq; 9 | using System.Reactive.Disposables; 10 | using System.Reactive.Linq; 11 | using System.Text; 12 | using System.Threading.Tasks; 13 | using System.Windows; 14 | using System.Windows.Controls; 15 | using System.Windows.Data; 16 | using System.Windows.Documents; 17 | using System.Windows.Input; 18 | using System.Windows.Media; 19 | using System.Windows.Media.Imaging; 20 | using System.Windows.Navigation; 21 | using System.Windows.Shapes; 22 | 23 | namespace ImageSort.WPF.Views.Metadata; 24 | 25 | /// 26 | /// Interaction logic for MetadataView.xaml 27 | /// 28 | public partial class MetadataView : ReactiveUserControl 29 | { 30 | public MetadataView() 31 | { 32 | InitializeComponent(); 33 | 34 | this.WhenActivated(disposableRegistration => 35 | { 36 | var metadataSettings = Locator.Current.GetService>() 37 | .Select(s => s as MetadataPanelSettings) 38 | .First(s => s != null); 39 | 40 | ViewModel.IsExpanded = metadataSettings.IsExpanded; 41 | 42 | ViewModel.WhenAnyValue(vm => vm.IsExpanded) 43 | .Subscribe(isExpanded => metadataSettings.IsExpanded = isExpanded) 44 | .DisposeWith(disposableRegistration); 45 | 46 | this.Bind(ViewModel, 47 | vm => vm.IsExpanded, 48 | view => view.ShowMetadataButton.IsChecked) 49 | .DisposeWith(disposableRegistration); 50 | 51 | this.OneWayBind(ViewModel, 52 | vm => vm.IsExpanded, 53 | view => view.MetadataArea.Visibility, 54 | isExpanded => isExpanded ? Visibility.Visible : Visibility.Collapsed) 55 | .DisposeWith(disposableRegistration); 56 | 57 | this.OneWayBind(ViewModel, 58 | vm => vm.Metadata.Type, 59 | view => view.IsEnabled, 60 | type => type is MetadataResultType.Success) 61 | .DisposeWith(disposableRegistration); 62 | 63 | this.OneWayBind(ViewModel, 64 | vm => vm.Metadata.Type, 65 | view => view.Visibility, 66 | type => type is MetadataResultType.Success ? Visibility.Visible : Visibility.Collapsed) 67 | .DisposeWith(disposableRegistration); 68 | 69 | this.OneWayBind(ViewModel, 70 | vm => vm.SectionViewModels, 71 | view => view.Directories.ItemsSource) 72 | .DisposeWith(disposableRegistration); 73 | }); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/ImageSort.WPF/Views/WindowHelper.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Windows; 4 | using System.Windows.Forms; 5 | using ImageSort.SettingsManagement; 6 | using ImageSort.WPF.SettingsManagement.WindowPosition; 7 | using Splat; 8 | 9 | namespace ImageSort.WPF.Views; 10 | 11 | internal static class WindowHelper 12 | { 13 | private static WindowPositionSettingsViewModel GetWindowPostion() where TWindow : Window 14 | { 15 | return Locator.Current.GetService>() 16 | .OfType>() 17 | .FirstOrDefault(); 18 | } 19 | 20 | public static void RestoreWindowState(this TWindow window) where TWindow : Window 21 | { 22 | var windowPosition = GetWindowPostion(); 23 | 24 | if (windowPosition == null) return; 25 | 26 | var screenCount = Screen.AllScreens.Length; 27 | 28 | // ensure when the number of screen was changed the window will still be visible 29 | if (windowPosition.ScreenCount != screenCount) 30 | { 31 | windowPosition.ScreenCount = screenCount; 32 | } 33 | else 34 | { 35 | window.Left = windowPosition.Left; 36 | window.Top = windowPosition.Top; 37 | } 38 | 39 | window.WindowState = windowPosition.IsMaximized ? WindowState.Maximized : WindowState.Normal; 40 | window.Height = windowPosition.Height; 41 | window.Width = windowPosition.Width; 42 | } 43 | 44 | public static void SaveWindowState(this TWindow window) where TWindow : Window 45 | { 46 | var windowPosition = GetWindowPostion(); 47 | 48 | if (windowPosition == null) return; 49 | 50 | windowPosition.IsMaximized = window.WindowState == WindowState.Maximized; 51 | 52 | if (window.WindowState == WindowState.Maximized) window.WindowState = WindowState.Normal; 53 | 54 | windowPosition.Left = (int) window.Left; 55 | windowPosition.Top = (int) window.Top; 56 | windowPosition.Height = (int) window.Height; 57 | windowPosition.Width = (int) window.Width; 58 | 59 | // record the screen count at the time. 60 | windowPosition.ScreenCount = Screen.AllScreens.Length; 61 | } 62 | } -------------------------------------------------------------------------------- /src/ImageSort.WindowsSetup/Image Sort.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/src/ImageSort.WindowsSetup/Image Sort.ico -------------------------------------------------------------------------------- /src/ImageSort.WindowsSetup/ImageSort.WindowsSetup.wixproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | false 5 | 6 | 7 | 8 | 9 | ImageSort.$(Platform) 10 | $(Platform) 11 | win-arm64 12 | win-$(Platform) 13 | RuntimeIdentifier=$(RuntimeIdentifier); 14 | 15 | 16 | ..\..\artifacts\$(Platform) 17 | 18 | 19 | Debug 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | ImageSort.WPF 40 | {ff41f5a7-55c5-47bd-8a6d-d8eb86e9c967} 41 | True 42 | True 43 | Binaries;Content;Satellites 44 | INSTALLFOLDER 45 | True 46 | net8.0-windows 47 | $(RuntimeIdentifier) 48 | 49 | 50 | 51 | True 52 | 53 | 54 | 55 | true 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /src/ImageSort.WindowsSetup/delete_explorer_context_menu.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | setlocal enabledelayedexpansion 3 | 4 | for /f "tokens=*" %%G in ('reg query HKEY_USERS') do ( 5 | set "userSID=%%G" 6 | if not "!userSID!"=="HKEY_USERS" ( 7 | reg delete "!userSID!\Software\Classes\Directory\shell\ImageSort" /f 8 | reg delete "!userSID!\Software\Classes\Drive\shell\ImageSort" /f 9 | reg delete "!userSID!\Software\Classes\Folder\shell\ImageSort" /f 10 | ) 11 | ) 12 | 13 | endlocal 14 | -------------------------------------------------------------------------------- /src/ImageSort.WindowsSetup/exclude-imagesort.exe.xslt: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/ImageSort.WindowsUpdater/GitHubUpdateFetcher.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Net.Http; 5 | using System.Reflection; 6 | using System.Threading.Tasks; 7 | using Octokit; 8 | using Semver; 9 | 10 | namespace ImageSort.WindowsUpdater; 11 | 12 | public class GitHubUpdateFetcher 13 | { 14 | private readonly GitHubClient client; 15 | 16 | public GitHubUpdateFetcher(GitHubClient client) 17 | { 18 | this.client = client; 19 | } 20 | 21 | public async Task<(bool, Release)> TryGetLatestReleaseAsync(bool allowPrerelease = false) 22 | { 23 | var assembly = Assembly.GetAssembly(typeof(GitHubUpdateFetcher)); 24 | var gitVersionInformationType = assembly.GetType("GitVersionInformation"); 25 | var versionTag = 26 | (string) gitVersionInformationType?.GetFields().First(f => f.Name == "SemVer").GetValue(null); 27 | var version = SemVersion.Parse(versionTag); 28 | 29 | Release latestFitting; 30 | 31 | try 32 | { 33 | var releases = await client.Repository.Release.GetAll("Lolle2000la", "Image-Sort"); 34 | 35 | latestFitting = releases 36 | .FirstOrDefault(release => 37 | { 38 | var prereleaseCondition = allowPrerelease || !release.Prerelease; 39 | 40 | var firstIndexOfV = release.TagName.IndexOf('v', StringComparison.OrdinalIgnoreCase); 41 | 42 | var releaseVersion = SemVersion.Parse(release.TagName.Substring(firstIndexOfV + 1)); 43 | 44 | var isNewVersion = version.CompareByPrecedence(releaseVersion) < 0; 45 | 46 | return prereleaseCondition && isNewVersion; 47 | }); 48 | } 49 | catch 50 | { 51 | latestFitting = null; 52 | } 53 | 54 | return (latestFitting != null, latestFitting); 55 | } 56 | 57 | public bool TryGetInstallerFromRelease(Release release, out ReleaseAsset installer) 58 | { 59 | if (release == null) throw new ArgumentNullException(nameof(release)); 60 | 61 | var is64bit = Environment.Is64BitProcess; 62 | 63 | installer = release.Assets 64 | .FirstOrDefault(asset => asset.Name 65 | .Equals($"ImageSort.{(is64bit ? "x64" : "x86")}.msi", StringComparison.OrdinalIgnoreCase)); 66 | 67 | return installer != null; 68 | } 69 | 70 | public async Task GetStreamFromAssetAsync(ReleaseAsset asset) 71 | { 72 | using var httpClient = new HttpClient(); 73 | 74 | httpClient.DefaultRequestHeaders.Add("User-Agent", "Image-Sort"); 75 | 76 | try 77 | { 78 | return await httpClient.GetStreamAsync(asset.BrowserDownloadUrl); 79 | } 80 | catch (HttpRequestException) 81 | { 82 | return null; 83 | } 84 | } 85 | } -------------------------------------------------------------------------------- /src/ImageSort.WindowsUpdater/ImageSort.WindowsUpdater.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | Debug;Release;MSIX 6 | AnyCPU;x86;x64;ARM64 7 | win-x86;win-x64;win-arm64 8 | 9 | 10 | 11 | 12 | All 13 | 14 | 15 | 16 | 17 | 18 | 19 | false 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /src/ImageSort.WindowsUpdater/InstallerRunner.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.IO; 4 | using System.Threading.Tasks; 5 | 6 | namespace ImageSort.WindowsUpdater; 7 | 8 | public static class InstallerRunner 9 | { 10 | public static async Task RunAsync(Stream installer) 11 | { 12 | if (installer == null) throw new ArgumentNullException(nameof(installer)); 13 | 14 | var tempFolder = Path.Combine(Path.GetTempPath(), "Image Sort"); 15 | var setupPath = Path.Combine(tempFolder, $"ImageSort.{(Environment.Is64BitProcess ? "x64" : "x86")}.msi"); 16 | 17 | if (!Directory.Exists(tempFolder)) Directory.CreateDirectory(tempFolder); 18 | 19 | var fs = File.Create(Path.Combine(tempFolder, setupPath)); 20 | 21 | await installer.CopyToAsync(fs); 22 | 23 | await fs.DisposeAsync(); 24 | 25 | RunSetup(setupPath); 26 | 27 | Environment.Exit(0); 28 | } 29 | 30 | private static void RunSetup(string path) 31 | { 32 | var processStartInfo = new ProcessStartInfo("msiexec", 33 | $"/i \"{path}\" TARGETDIR=\"{AppDomain.CurrentDomain.BaseDirectory}\" /passive AUTOSTART=1") 34 | { 35 | Verb = "runas", 36 | UseShellExecute = true 37 | }; 38 | 39 | Process.Start(processStartInfo); 40 | } 41 | 42 | public static void CleanUpInstaller() 43 | { 44 | var setupPath = Path.Combine(Path.GetTempPath(), "Image Sort", 45 | $"ImageSort.{(Environment.Is64BitProcess ? "x64" : "x86")}.msi"); 46 | 47 | if (File.Exists(setupPath)) File.Delete(setupPath); 48 | } 49 | } -------------------------------------------------------------------------------- /src/ImageSort/Actions/DeleteAction.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using ImageSort.FileSystem; 4 | using ImageSort.Localization; 5 | 6 | namespace ImageSort.Actions; 7 | 8 | /// 9 | /// Deletes a file. Only a file, not a directory. 10 | /// 11 | public class DeleteAction : IReversibleAction 12 | { 13 | private readonly Action notifyAct; 14 | private readonly Action notifyRevert; 15 | private readonly string oldPath; 16 | private readonly IRecycleBin recycleBin; 17 | private IDisposable deletedFile; 18 | 19 | public DeleteAction(string path, IFileSystem fileSystem, IRecycleBin recycleBin, 20 | Action notifyAct = null, Action notifyRevert = null) 21 | { 22 | if (path == null) throw new ArgumentNullException(nameof(path)); 23 | if (fileSystem == null) throw new ArgumentNullException(nameof(fileSystem)); 24 | if (recycleBin == null) throw new ArgumentNullException(nameof(recycleBin)); 25 | if (!fileSystem.FileExists(path)) throw new FileNotFoundException(null, path); 26 | 27 | this.notifyAct = notifyAct; 28 | this.notifyRevert = notifyRevert; 29 | 30 | oldPath = path; 31 | this.recycleBin = recycleBin; 32 | } 33 | 34 | public string DisplayName => Text.DeleteActionMessage 35 | .Replace("{FileName}", Path.GetFileName(oldPath), StringComparison.OrdinalIgnoreCase); 36 | 37 | public void Act() 38 | { 39 | if (deletedFile == null) deletedFile = recycleBin.Send(oldPath); 40 | 41 | notifyAct?.Invoke(oldPath); 42 | } 43 | 44 | public void Revert() 45 | { 46 | deletedFile?.Dispose(); 47 | 48 | deletedFile = null; 49 | 50 | notifyRevert?.Invoke(oldPath); 51 | } 52 | } -------------------------------------------------------------------------------- /src/ImageSort/Actions/IReversibleAction.cs: -------------------------------------------------------------------------------- 1 | namespace ImageSort.Actions; 2 | 3 | public interface IReversibleAction 4 | { 5 | string DisplayName { get; } 6 | void Act(); 7 | 8 | void Revert(); 9 | } -------------------------------------------------------------------------------- /src/ImageSort/Actions/MoveAction.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using ImageSort.FileSystem; 4 | using ImageSort.Localization; 5 | 6 | namespace ImageSort.Actions; 7 | 8 | public class MoveAction : IReversibleAction 9 | { 10 | private readonly IFileSystem fileSystem; 11 | private readonly string oldDestination; 12 | private readonly string newDestination; 13 | private readonly Action notifyAct; 14 | private readonly Action notifyRevert; 15 | 16 | public MoveAction(string file, string toFolder, IFileSystem fileSystem, 17 | Action notifyAct = null, Action notifyRevert = null) 18 | { 19 | if (file == null) throw new ArgumentNullException(nameof(file)); 20 | if (toFolder == null) throw new ArgumentNullException(nameof(toFolder)); 21 | if (fileSystem == null) throw new ArgumentNullException(nameof(fileSystem)); 22 | if (!fileSystem.FileExists(file)) throw new FileNotFoundException(null, file); 23 | if (!fileSystem.DirectoryExists(toFolder)) 24 | throw new DirectoryNotFoundException( 25 | Text.DirectoryNotFoundExceptionMessage.Replace("{Directory}", toFolder, 26 | StringComparison.OrdinalIgnoreCase)); 27 | 28 | this.fileSystem = fileSystem; 29 | 30 | this.notifyAct = notifyAct; 31 | this.notifyRevert = notifyRevert; 32 | 33 | // ensure absolute paths, there are weird windows path limit bugs 34 | file = Path.GetFullPath(file); 35 | toFolder = Path.GetFullPath(toFolder); 36 | 37 | oldDestination = file; 38 | newDestination = Path.Combine(toFolder, Path.GetFileName(file)); 39 | } 40 | 41 | public string DisplayName => Text.MoveActionMessage 42 | .Replace("{FileName}", Path.GetFileName(oldDestination), StringComparison.OrdinalIgnoreCase) 43 | .Replace("{Directory}", Path.GetDirectoryName(newDestination), StringComparison.OrdinalIgnoreCase); 44 | 45 | public void Act() 46 | { 47 | fileSystem.Move(oldDestination, newDestination); 48 | 49 | notifyAct?.Invoke(oldDestination, newDestination); 50 | } 51 | 52 | public void Revert() 53 | { 54 | fileSystem.Move(newDestination, oldDestination); 55 | 56 | notifyRevert?.Invoke(newDestination, oldDestination); 57 | } 58 | } -------------------------------------------------------------------------------- /src/ImageSort/Actions/RenameAction.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using ImageSort.FileSystem; 4 | using ImageSort.Localization; 5 | 6 | namespace ImageSort.Actions; 7 | 8 | public class RenameAction : IReversibleAction 9 | { 10 | private readonly IFileSystem fileSystem; 11 | private readonly string newPath; 12 | private readonly string oldPath; 13 | private readonly Action notifyAct; 14 | private readonly Action notifyRevert; 15 | 16 | public RenameAction(string path, string newName, IFileSystem fileSystem, 17 | Action notifyAct = null, Action notifyRevert = null) 18 | { 19 | if (path == null) throw new ArgumentNullException(nameof(path)); 20 | if (newName == null) throw new ArgumentNullException(nameof(newName)); 21 | if (fileSystem == null) throw new ArgumentNullException(nameof(fileSystem)); 22 | if (!fileSystem.FileExists(path)) throw new FileNotFoundException(null, path); 23 | 24 | oldPath = path = Path.GetFullPath(path); 25 | newPath = Path.Combine(Path.GetDirectoryName(path), newName + Path.GetExtension(path)); 26 | 27 | if (fileSystem.FileExists(newPath)) 28 | throw new IOException( 29 | Text.FileAlreadyExistsExceptionMessage.Replace("{FileName}", newName, 30 | StringComparison.OrdinalIgnoreCase)); 31 | 32 | this.fileSystem = fileSystem; 33 | 34 | this.notifyAct = notifyAct; 35 | this.notifyRevert = notifyRevert; 36 | } 37 | 38 | public string DisplayName => Text.RenameActionMessage 39 | .Replace("{OldFileName}", Path.GetFileName(oldPath), StringComparison.OrdinalIgnoreCase) 40 | .Replace("{NewFileName}", Path.GetFileName(newPath), StringComparison.OrdinalIgnoreCase); 41 | 42 | public void Act() 43 | { 44 | fileSystem.Move(oldPath, newPath); 45 | 46 | notifyAct?.Invoke(oldPath, newPath); 47 | } 48 | 49 | public void Revert() 50 | { 51 | fileSystem.Move(newPath, oldPath); 52 | 53 | notifyRevert?.Invoke(newPath, oldPath); 54 | } 55 | } -------------------------------------------------------------------------------- /src/ImageSort/DependencyManagement/DependencyRegistrationHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using ImageSort.FileSystem; 5 | using ImageSort.SettingsManagement; 6 | using ImageSort.ViewModels.Metadata; 7 | using Splat; 8 | 9 | namespace ImageSort.DependencyManagement; 10 | 11 | public static class DependencyRegistrationHelper 12 | { 13 | public static void RegisterManditoryDependencies(this IMutableDependencyResolver dependencyResolver) 14 | { 15 | dependencyResolver.Register(() => new FullAccessFileSystem()); 16 | dependencyResolver.Register(() => new FileSystemWatcher()); 17 | dependencyResolver.Register(() => new FullAccessFileSystemMetadataExtractor()); 18 | dependencyResolver.Register(() => new MetadataSectionViewModelFactory(new MetadataFieldViewModelFactory())); 19 | } 20 | 21 | /// 22 | /// Registers settings and gives the possibility to registrate custom ones. 23 | /// 24 | /// 25 | /// Allows for registration of custom settings. 26 | /// Simply add them to the and hold onto the added instances. 27 | /// 28 | public static void RegisterSettings(this IMutableDependencyResolver dependencyResolver, 29 | Action> registration = null) 30 | { 31 | var settings = new List(); 32 | 33 | var userSettings = new List(); 34 | registration?.Invoke(userSettings); 35 | 36 | if (userSettings.Count > 0) settings.AddRange(userSettings); 37 | 38 | dependencyResolver.RegisterConstant>(settings); 39 | } 40 | } -------------------------------------------------------------------------------- /src/ImageSort/FileSystem/FileRestorationNotPossibleException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace ImageSort.FileSystem; 4 | 5 | public class FileRestorationNotPossibleException : Exception 6 | { 7 | public FileRestorationNotPossibleException() 8 | { 9 | } 10 | 11 | public FileRestorationNotPossibleException(string message) : base(message) 12 | { 13 | } 14 | 15 | public FileRestorationNotPossibleException(string message, Exception innerException) 16 | : base(message, innerException) 17 | { 18 | } 19 | } -------------------------------------------------------------------------------- /src/ImageSort/FileSystem/FullAccessFileSystem.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.IO; 3 | using System.Linq; 4 | 5 | namespace ImageSort.FileSystem; 6 | 7 | public class FullAccessFileSystem : IFileSystem 8 | { 9 | public bool IsFolderEmpty(string path) => !Directory.EnumerateDirectories(path, "*", SearchOption.TopDirectoryOnly).Any(); 10 | 11 | public IEnumerable GetSubFolders(string path) => Directory.EnumerateDirectories(path, "*", SearchOption.TopDirectoryOnly); 12 | 13 | public IEnumerable GetFiles(string folder) => Directory.EnumerateFiles(folder, "*", SearchOption.TopDirectoryOnly); 14 | } -------------------------------------------------------------------------------- /src/ImageSort/FileSystem/FullAccessFileSystemMetadataExtractor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using MetadataExtractor; 7 | 8 | namespace ImageSort.FileSystem; 9 | 10 | public class FullAccessFileSystemMetadataExtractor : IMetadataExtractor 11 | { 12 | public Dictionary> Extract(string x) 13 | { 14 | var dict = new Dictionary>(); 15 | var directories = ImageMetadataReader.ReadMetadata(x); 16 | foreach (var directory in directories) 17 | { 18 | var subDict = new Dictionary(); 19 | foreach (var tag in directory.Tags) 20 | { 21 | subDict.Add(tag.Name, tag.Description); 22 | } 23 | dict.Add(directory.Name, subDict); 24 | } 25 | return dict; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/ImageSort/FileSystem/IFileSystem.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.IO; 3 | 4 | namespace ImageSort.FileSystem; 5 | 6 | public interface IFileSystem 7 | { 8 | IEnumerable GetSubFolders(string path); 9 | 10 | IEnumerable GetFiles(string folder); 11 | 12 | bool IsFolderEmpty(string path); 13 | 14 | bool FileExists(string path) => File.Exists(path); 15 | 16 | bool DirectoryExists(string path) => Directory.Exists(path); 17 | 18 | void Move(string source, string destination) => File.Move(source, destination); 19 | 20 | void CreateFolder(string path) => Directory.CreateDirectory(path); 21 | } -------------------------------------------------------------------------------- /src/ImageSort/FileSystem/IMetadataExtractor.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace ImageSort.FileSystem; 4 | 5 | public interface IMetadataExtractor 6 | { 7 | Dictionary> Extract(string x); 8 | } -------------------------------------------------------------------------------- /src/ImageSort/FileSystem/IRecycleBin.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace ImageSort.FileSystem; 4 | 5 | public interface IRecycleBin 6 | { 7 | /// 8 | /// Sends the file or folder at the given path to the recycle bin. 9 | /// 10 | /// The path to the file or folder that should be send to the recycle bin. 11 | /// Should ask for user confirmation before sending to recycle bin. 12 | /// The will, when disposed, restore the file. 13 | /// 14 | /// The handle to the deleted file will throw a 15 | /// 16 | /// when it cannot restore the file. 17 | /// 18 | IDisposable Send(string path, bool confirmationNeeded = false); 19 | } -------------------------------------------------------------------------------- /src/ImageSort/Helpers/PathHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | 4 | namespace ImageSort.Helpers; 5 | 6 | public static class PathHelper 7 | { 8 | public static bool PathEquals(this string path1, string path2) 9 | { 10 | return Path.GetFullPath(path1).Equals(Path.GetFullPath(path2), StringComparison.OrdinalIgnoreCase); 11 | } 12 | } -------------------------------------------------------------------------------- /src/ImageSort/Helpers/StringHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | 4 | namespace ImageSort.Helpers; 5 | 6 | internal static class StringHelper 7 | { 8 | public static bool EndsWithAny( 9 | this string @string, 10 | StringComparison comparisonType, 11 | params string[] atEnd) 12 | { 13 | return atEnd.Any(end => @string.EndsWith(end, comparisonType)); 14 | } 15 | } -------------------------------------------------------------------------------- /src/ImageSort/ImageSort.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | Debug;Release;MSIX 6 | win-x86;win-x64;win-arm64 7 | 8 | 9 | 10 | 11 | 12 | 13 | All 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | false 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/ImageSort/SettingsManagement/SettingsGroupViewModelBase.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using ReactiveUI; 5 | 6 | namespace ImageSort.SettingsManagement; 7 | 8 | public abstract class SettingsGroupViewModelBase : ReactiveObject 9 | { 10 | protected SettingsGroupViewModelBase() 11 | { 12 | Changed.Subscribe(args => 13 | { 14 | SettingsStore[args.PropertyName] = 15 | args.Sender?.GetType().GetProperty(args.PropertyName)?.GetValue(args.Sender); 16 | }); 17 | } 18 | 19 | /// 20 | /// Used for storage. Should not be changed EVER once set. It must also be unique. 21 | /// 22 | public abstract string Name { get; } 23 | 24 | public abstract string Header { get; } 25 | public virtual bool IsVisible => true; 26 | public Dictionary SettingsStore { get; } = new Dictionary(); 27 | 28 | public void UpdatePropertiesFromStore() 29 | { 30 | var properties = GetType().GetProperties(); 31 | 32 | foreach (var property in properties) 33 | { 34 | if (!SettingsStore.TryGetValue(property.Name, out var setting)) continue; 35 | 36 | if (setting is object[] objects) 37 | property.SetValue(this, objects.OfType()); 38 | else 39 | property.SetValue(this, 40 | typeof(Enum).IsAssignableFrom(property.PropertyType) 41 | ? Enum.ToObject(property.PropertyType, setting) 42 | : setting); 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /src/ImageSort/SettingsManagement/SettingsViewModel.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using ReactiveUI; 5 | using Splat; 6 | 7 | namespace ImageSort.SettingsManagement; 8 | 9 | public class SettingsViewModel : ReactiveObject 10 | { 11 | public IEnumerable SettingsGroups { get; } 12 | 13 | public SettingsViewModel(IEnumerable settingsGroups = null) 14 | { 15 | SettingsGroups = settingsGroups ?? Locator.Current.GetService>(); 16 | } 17 | 18 | public TGroup GetGroup() where TGroup : SettingsGroupViewModelBase 19 | { 20 | return SettingsGroups.OfType() 21 | .FirstOrDefault(); 22 | } 23 | 24 | public Dictionary> AsDictionary() 25 | { 26 | return SettingsGroups.ToDictionary(@group => @group.Name, @group => @group.SettingsStore); 27 | } 28 | 29 | public void RestoreFromDictionary(Dictionary> dictionary) 30 | { 31 | if (dictionary == null) throw new ArgumentNullException(nameof(dictionary)); 32 | 33 | foreach (var (group, store) in dictionary) 34 | { 35 | var settingsGroup = SettingsGroups.FirstOrDefault(g => g.Name == group); 36 | 37 | if (settingsGroup == null) continue; 38 | 39 | foreach (var (storeKey, storeValue) in store) settingsGroup.SettingsStore[storeKey] = storeValue; 40 | 41 | settingsGroup.UpdatePropertiesFromStore(); 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /src/ImageSort/ViewModels/ActionsViewModel.cs: -------------------------------------------------------------------------------- 1 | using ImageSort.Actions; 2 | using ImageSort.Localization; 3 | using ReactiveUI; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Reactive; 7 | using System.Reactive.Linq; 8 | 9 | namespace ImageSort.ViewModels; 10 | 11 | public class ActionsViewModel : ReactiveObject 12 | { 13 | private readonly Stack done = new Stack(); 14 | private readonly Stack undone = new Stack(); 15 | 16 | private readonly ObservableAsPropertyHelper lastDone; 17 | public string LastDone => lastDone.Value; 18 | 19 | private readonly ObservableAsPropertyHelper lastUndone; 20 | public string LastUndone => lastUndone.Value; 21 | 22 | public Interaction NotifyUserOfError { get; } = new Interaction(); 23 | 24 | public ReactiveCommand Execute { get; } 25 | public ReactiveCommand Undo { get; } 26 | public ReactiveCommand Redo { get; } 27 | public ReactiveCommand Clear { get; } 28 | 29 | public ActionsViewModel() 30 | { 31 | Execute = ReactiveCommand.CreateFromTask(async action => 32 | { 33 | try 34 | { 35 | action.Act(); 36 | } 37 | catch (Exception ex) 38 | { 39 | await NotifyUserOfError.Handle(Text.CouldNotActErrorText 40 | .Replace("{ErrorMessage}", ex.Message, StringComparison.OrdinalIgnoreCase) 41 | .Replace("{ActMessage}", action.DisplayName, StringComparison.OrdinalIgnoreCase)); 42 | 43 | return; 44 | } 45 | 46 | done.Push(action); 47 | 48 | undone.Clear(); 49 | }); 50 | 51 | Undo = ReactiveCommand.CreateFromTask(async () => 52 | { 53 | if (done.TryPop(out var action)) 54 | { 55 | try 56 | { 57 | action.Revert(); 58 | } 59 | catch (Exception ex) 60 | { 61 | await NotifyUserOfError.Handle(Text.CouldNotUndoErrorText 62 | .Replace("{ErrorMessage}", ex.Message, StringComparison.OrdinalIgnoreCase) 63 | .Replace("{ActMessage}", action.DisplayName, StringComparison.OrdinalIgnoreCase)); 64 | 65 | return; 66 | } 67 | 68 | undone.Push(action); 69 | } 70 | }); 71 | 72 | Redo = ReactiveCommand.CreateFromTask(async () => 73 | { 74 | if (undone.TryPop(out var action)) 75 | { 76 | try 77 | { 78 | action.Act(); 79 | } 80 | catch (Exception ex) 81 | { 82 | await NotifyUserOfError.Handle(Text.CouldNotRedoErrorText 83 | .Replace("{ErrorMessage}", ex.Message, StringComparison.OrdinalIgnoreCase) 84 | .Replace("{ActMessage}", action.DisplayName, StringComparison.OrdinalIgnoreCase)); 85 | 86 | return; 87 | } 88 | 89 | done.Push(action); 90 | } 91 | }); 92 | 93 | Clear = ReactiveCommand.Create(() => 94 | { 95 | done.Clear(); 96 | undone.Clear(); 97 | }); 98 | 99 | var historyChanges = Execute.Merge(Undo).Merge(Redo).Merge(Clear); 100 | 101 | lastDone = historyChanges 102 | .Select(_ => 103 | { 104 | if (done.TryPeek(out var action)) return action.DisplayName; 105 | 106 | return null; 107 | }) 108 | .ToProperty(this, vm => vm.LastDone); 109 | 110 | lastUndone = historyChanges 111 | .Select(_ => 112 | { 113 | if (undone.TryPeek(out var action)) return action.DisplayName; 114 | 115 | return null; 116 | }) 117 | .ToProperty(this, vm => vm.LastUndone); 118 | } 119 | } -------------------------------------------------------------------------------- /src/ImageSort/ViewModels/Metadata/MetadataFieldViewModel.cs: -------------------------------------------------------------------------------- 1 | using ReactiveUI; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Text; 6 | using System.Threading.Tasks; 7 | 8 | namespace ImageSort.ViewModels.Metadata; 9 | public class MetadataFieldViewModel : ReactiveObject 10 | { 11 | private string _name; 12 | public string Name 13 | { 14 | get => _name; 15 | set => this.RaiseAndSetIfChanged(ref _name, value); 16 | } 17 | 18 | private string _value; 19 | public string Value 20 | { 21 | get => _value; 22 | set => this.RaiseAndSetIfChanged(ref _value, value); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/ImageSort/ViewModels/Metadata/MetadataFieldViewModelFactory.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace ImageSort.ViewModels.Metadata; 8 | public class MetadataFieldViewModelFactory 9 | { 10 | public MetadataFieldViewModel Create(string name, string value) 11 | { 12 | return new() 13 | { 14 | Name = name, 15 | Value = value 16 | }; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/ImageSort/ViewModels/Metadata/MetadataSectionViewModel.cs: -------------------------------------------------------------------------------- 1 | using ReactiveUI; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Reactive.Linq; 6 | using System.Text; 7 | using System.Threading.Tasks; 8 | 9 | namespace ImageSort.ViewModels.Metadata; 10 | public class MetadataSectionViewModel : ReactiveObject 11 | { 12 | private string _title; 13 | public string Title 14 | { 15 | get => _title; 16 | set => this.RaiseAndSetIfChanged(ref _title, value); 17 | } 18 | 19 | private Dictionary _fields; 20 | public Dictionary Fields 21 | { 22 | get => _fields; 23 | set => this.RaiseAndSetIfChanged(ref _fields, value); 24 | } 25 | 26 | private ObservableAsPropertyHelper> _fieldViewModels; 27 | public IEnumerable FieldViewModels => _fieldViewModels.Value; 28 | 29 | public MetadataSectionViewModel(MetadataFieldViewModelFactory fieldViewModelFactory) 30 | { 31 | _fieldViewModels = this.WhenAnyValue(x => x.Fields) 32 | .Select(f => f?.Select(x => fieldViewModelFactory.Create(x.Key, x.Value))) 33 | .Where(f => f != null) 34 | .ToProperty(this, x => x.FieldViewModels); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/ImageSort/ViewModels/Metadata/MetadataSectionViewModelFactory.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | 7 | namespace ImageSort.ViewModels.Metadata; 8 | public class MetadataSectionViewModelFactory 9 | { 10 | private readonly MetadataFieldViewModelFactory fieldViewModelFactory; 11 | 12 | public MetadataSectionViewModelFactory(MetadataFieldViewModelFactory fieldViewModelFactory) 13 | { 14 | this.fieldViewModelFactory = fieldViewModelFactory; 15 | } 16 | 17 | public MetadataSectionViewModel Create(string title, Dictionary fields) 18 | { 19 | return new(fieldViewModelFactory) 20 | { 21 | Title = title, 22 | Fields = fields 23 | }; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/ImageSort/ViewModels/Metadata/MetadataViewModel.cs: -------------------------------------------------------------------------------- 1 | using ImageSort.FileSystem; 2 | using ReactiveUI; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Reactive; 7 | using System.Reactive.Linq; 8 | using System.Runtime.InteropServices; 9 | using System.Text; 10 | using System.Threading.Tasks; 11 | 12 | namespace ImageSort.ViewModels.Metadata; 13 | 14 | public class MetadataViewModel : ReactiveObject 15 | { 16 | private readonly IMetadataExtractor extractor; 17 | private readonly IFileSystem fileSystem; 18 | 19 | private string _imagePath; 20 | public string ImagePath 21 | { 22 | get => _imagePath; 23 | set => this.RaiseAndSetIfChanged(ref _imagePath, value); 24 | } 25 | 26 | private ObservableAsPropertyHelper _metadata; 27 | public MetadataResult Metadata => _metadata.Value; 28 | 29 | private ObservableAsPropertyHelper> _sectionViewModels; 30 | public IEnumerable SectionViewModels => _sectionViewModels.Value; 31 | 32 | private bool _isExpanded = true; 33 | public bool IsExpanded 34 | { 35 | get => _isExpanded; 36 | set => this.RaiseAndSetIfChanged(ref _isExpanded, value); 37 | } 38 | 39 | public ReactiveCommand ToggleIsExpanded { get; } 40 | 41 | public MetadataViewModel(IMetadataExtractor extractor, IFileSystem fileSystem, MetadataSectionViewModelFactory metadataSectionFactory) 42 | { 43 | this.extractor = extractor; 44 | this.fileSystem = fileSystem; 45 | 46 | _metadata = this.WhenAnyValue(x => x.ImagePath, x => x.IsExpanded) 47 | .Where(x => x.Item2) // only load metadata if the panel is expanded 48 | .Select(x => x.Item1) 49 | .Select(ExtractSafely) 50 | .ToProperty(this, x => x.Metadata); 51 | 52 | _sectionViewModels = this.WhenAnyValue(x => x.Metadata) 53 | .Where(x => x.Type == MetadataResultType.Success) 54 | .Select(m => m.Metadata.OrderBy(x => x.Key)) 55 | .Select(m => m.Select(d => metadataSectionFactory.Create(d.Key, d.Value))) 56 | .ToProperty(this, x => x.SectionViewModels); 57 | 58 | ToggleIsExpanded = ReactiveCommand.Create(() => 59 | { 60 | IsExpanded = !IsExpanded; 61 | }); 62 | } 63 | 64 | private MetadataResult ExtractSafely(string path) 65 | { 66 | try 67 | { 68 | if (fileSystem.FileExists(path)) 69 | { 70 | return new() 71 | { 72 | Type = MetadataResultType.Success, 73 | Metadata = extractor.Extract(path) 74 | }; 75 | } 76 | else 77 | { 78 | return new MetadataResult() 79 | { 80 | Type = MetadataResultType.FileDoesNotExist 81 | }; 82 | } 83 | } 84 | #pragma warning disable CA1031 // Do not catch general exception types 85 | // since we don't want an exception to take down the application and instead pass it on, we catch all of them here 86 | catch (Exception ex) 87 | { 88 | return new MetadataResult() 89 | { 90 | Type = MetadataResultType.UnexpectedError, 91 | Exception = ex 92 | }; 93 | } 94 | #pragma warning restore CA1031 // Do not catch general exception types 95 | } 96 | } 97 | 98 | public record MetadataResult 99 | { 100 | public MetadataResultType Type { get; init; } 101 | public Dictionary> Metadata { get; init; } 102 | public Exception Exception { get; init; } 103 | } 104 | 105 | public enum MetadataResultType 106 | { 107 | Success, 108 | FileDoesNotExist, 109 | UnexpectedError 110 | } 111 | -------------------------------------------------------------------------------- /tests/ImageSort.UnitTests/Actions/DeleteActionTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using ImageSort.Actions; 4 | using ImageSort.FileSystem; 5 | using NSubstitute; 6 | using Xunit; 7 | 8 | namespace ImageSort.UnitTests.Actions; 9 | 10 | public class DeleteActionTests 11 | { 12 | [Fact(DisplayName = "Deletes and Restores the file correctly.")] 13 | public void DeletesAndRestoresTheFileCorrectly() 14 | { 15 | const string fileToDelete = @"C:\Some File.png"; 16 | 17 | var fsMock = Substitute.For(); 18 | var recycleBinMock = Substitute.For(); 19 | var fileRestorerMock = Substitute.For(); 20 | 21 | fsMock.FileExists(fileToDelete).Returns(true); 22 | 23 | recycleBinMock.Send(fileToDelete, false).Returns(fileRestorerMock); 24 | 25 | var deleteAction = new DeleteAction(fileToDelete, fsMock, recycleBinMock); 26 | 27 | fsMock.Received().FileExists(fileToDelete); 28 | 29 | deleteAction.Act(); 30 | 31 | recycleBinMock.Received().Send(fileToDelete, false); 32 | 33 | deleteAction.Revert(); 34 | 35 | fileRestorerMock.Received().Dispose(); 36 | } 37 | 38 | [Fact(DisplayName = "Throws when the file to delete does not exist")] 39 | public void ThrowsWhenTheFileDoesNotExist() 40 | { 41 | const string fileThatDoesntExist = @"C:\Fictional File.fake"; 42 | 43 | var fsMock = Substitute.For(); 44 | var recycleBinMock = Substitute.For(); 45 | 46 | fsMock.FileExists(fileThatDoesntExist).Returns(false); 47 | 48 | Assert.Throws(() => 49 | new DeleteAction(fileThatDoesntExist, fsMock, recycleBinMock)); 50 | 51 | fsMock.Received().FileExists(fileThatDoesntExist); 52 | } 53 | } -------------------------------------------------------------------------------- /tests/ImageSort.UnitTests/Actions/MoveActionTests.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using ImageSort.Actions; 3 | using ImageSort.FileSystem; 4 | using NSubstitute; 5 | using Xunit; 6 | 7 | namespace ImageSort.UnitTests.Actions; 8 | 9 | public class MoveActionTests 10 | { 11 | [Fact(DisplayName = "File gets moved correctly and caller gets notified of change.")] 12 | public void FileGetsMovedCorrectly() 13 | { 14 | const string oldPath = @"C:\SomeFile.png"; 15 | const string newFolder = @"C:\SomeOtherFolder\SomeOtherFolderAsWell\"; 16 | const string newPath = newFolder + "SomeFile.png"; 17 | 18 | var notifedOfAction = false; 19 | var notifiedOfReversion = false; 20 | 21 | var fsMock = Substitute.For(); 22 | 23 | fsMock.FileExists(oldPath).Returns(true); 24 | fsMock.DirectoryExists(newFolder).Returns(true); 25 | 26 | var moveAction = new MoveAction(oldPath, newFolder, fsMock, 27 | (f, t) => notifedOfAction = true, 28 | (f, t) => notifiedOfReversion = true); 29 | 30 | fsMock.Received().FileExists(oldPath); 31 | fsMock.Received().DirectoryExists(newFolder); 32 | 33 | moveAction.Act(); 34 | 35 | fsMock.Received().Move(oldPath, newPath); 36 | 37 | moveAction.Revert(); 38 | 39 | fsMock.Received().Move(newPath, oldPath); 40 | 41 | Assert.True(notifedOfAction, "The caller should be notified when an action acts."); 42 | Assert.True(notifiedOfReversion, "The caller should be notified when an action is reverted."); 43 | } 44 | 45 | [Fact(DisplayName = "Handles file or directory not existing correctly.")] 46 | public void HandlesFileOrDirectoryNotExisting() 47 | { 48 | const string existingDirectory = @"C:\SomeRealDirectory"; 49 | const string existingFile = @"C:\SomeRealFile.tif"; 50 | const string fakeDirectory = @"C:\DirectoryThatDoesntExist"; 51 | const string fakeFile = @"C:\SomeFakeFile.gif"; 52 | 53 | var fsMock = Substitute.For(); 54 | 55 | fsMock.DirectoryExists(existingDirectory).Returns(true); 56 | fsMock.FileExists(existingFile).Returns(true); 57 | fsMock.DirectoryExists(fakeDirectory).Returns(false); 58 | fsMock.FileExists(fakeFile).Returns(false); 59 | 60 | Assert.Throws(() => new MoveAction(fakeFile, existingDirectory, fsMock)); 61 | 62 | Assert.Throws(() => new MoveAction(existingFile, fakeDirectory, fsMock)); 63 | } 64 | } -------------------------------------------------------------------------------- /tests/ImageSort.UnitTests/Actions/RenameActionTests.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using ImageSort.Actions; 3 | using ImageSort.FileSystem; 4 | using NSubstitute; 5 | using Xunit; 6 | 7 | namespace ImageSort.UnitTests.Actions; 8 | 9 | public class RenameActionTests 10 | { 11 | [Fact(DisplayName = "Can rename files and undo")] 12 | public void CanRenameFilesAndUndo() 13 | { 14 | const string oldPath = @"C:\my-image.png"; 15 | const string newFileName = "my-renamed-image"; 16 | const string newPath = @"C:\my-renamed-image.png"; 17 | 18 | var canAct = false; 19 | var canRevert = false; 20 | 21 | var fsMock = Substitute.For(); 22 | 23 | fsMock.FileExists(oldPath).Returns(true); 24 | fsMock.FileExists(newPath).Returns(false); 25 | 26 | var renameAction = new RenameAction(oldPath, newFileName, fsMock, 27 | (o, n) => canAct = true, (n, o) => canRevert = true); 28 | 29 | renameAction.Act(); 30 | 31 | fsMock.Received().Move(oldPath, newPath); 32 | 33 | renameAction.Revert(); 34 | 35 | fsMock.Received().Move(newPath, oldPath); 36 | 37 | Assert.True(canAct); 38 | Assert.True(canRevert); 39 | } 40 | 41 | [Fact(DisplayName = "Throws when the file doesn't exist or the renamed path is already used.")] 42 | public void ThrowsWhenFileDoesNotExistOrNewPathIsAlreadyUsed() 43 | { 44 | const string oldPath = @"C:\my-image.png"; 45 | const string invalidOldPath = @"C:\invalid.gif"; 46 | const string newFileName = "my-renamed-image"; 47 | const string newPath = @"C:\my-renamed-image.png"; 48 | const string alreadyExistingName = @"already-exists"; 49 | const string alreadyExistingPath = @"C:\already-exists.png"; 50 | 51 | var fsMock = Substitute.For(); 52 | 53 | fsMock.FileExists(oldPath).Returns(true); 54 | fsMock.FileExists(newPath).Returns(false); 55 | fsMock.FileExists(invalidOldPath).Returns(false); 56 | fsMock.FileExists(alreadyExistingPath).Returns(true); 57 | 58 | Assert.Throws(() => new RenameAction(invalidOldPath, newFileName, fsMock)); 59 | 60 | Assert.Throws(() => new RenameAction(oldPath, alreadyExistingName, fsMock)); 61 | 62 | fsMock.Received().FileExists(invalidOldPath); 63 | fsMock.Received().FileExists(alreadyExistingPath); 64 | } 65 | } -------------------------------------------------------------------------------- /tests/ImageSort.UnitTests/ImageSort.UnitTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | 6 | false 7 | 8 | Debug;Release;MSIX 9 | 10 | 11 | 12 | 13 | 14 | 15 | all 16 | runtime; build; native; contentfiles; analyzers; buildtransitive 17 | 18 | 19 | 20 | 21 | 22 | all 23 | runtime; build; native; contentfiles; analyzers; buildtransitive 24 | 25 | 26 | all 27 | runtime; build; native; contentfiles; analyzers; buildtransitive 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /tests/ImageSort.UnitTests/SettingsManagement/SettingsGroupViewModelBaseTests.cs: -------------------------------------------------------------------------------- 1 | using ImageSort.SettingsManagement; 2 | using ReactiveUI; 3 | using Xunit; 4 | 5 | namespace ImageSort.UnitTests.SettingsManagement; 6 | 7 | public class SettingsGroupViewModelBaseTests 8 | { 9 | [Fact(DisplayName = "Saves changed properties in settings storage")] 10 | public void SavesChangedProperties() 11 | { 12 | var testSettingsGroup = new TestSettingsGroup(); 13 | 14 | Assert.False(testSettingsGroup.SettingsStore.TryGetValue("TestProperty", out var _)); 15 | Assert.False(testSettingsGroup.SettingsStore.TryGetValue("TestString", out var _)); 16 | 17 | testSettingsGroup.TestProperty = true; 18 | testSettingsGroup.TestString = "first test value"; 19 | 20 | Assert.True((bool) testSettingsGroup.SettingsStore["TestProperty"]); 21 | Assert.Equal("first test value", (string) testSettingsGroup.SettingsStore["TestString"]); 22 | 23 | testSettingsGroup.TestProperty = false; 24 | testSettingsGroup.TestString = "second test value"; 25 | 26 | Assert.False((bool) testSettingsGroup.SettingsStore["TestProperty"]); 27 | Assert.Equal("second test value", (string) testSettingsGroup.SettingsStore["TestString"]); 28 | 29 | testSettingsGroup.TestString = null; 30 | 31 | Assert.Null(testSettingsGroup.SettingsStore["TestString"]); 32 | } 33 | 34 | [Fact(DisplayName = "Updates properties based on the what is in store")] 35 | public void UpdatesPropertiesBasedOnWhatIsStored() 36 | { 37 | var testSettingsGroup = new TestSettingsGroup 38 | { 39 | TestProperty = false, 40 | TestString = "test value" 41 | }; 42 | 43 | testSettingsGroup.SettingsStore["TestProperty"] = true; 44 | testSettingsGroup.SettingsStore["TestString"] = "new test value"; 45 | 46 | testSettingsGroup.UpdatePropertiesFromStore(); 47 | 48 | Assert.True(testSettingsGroup.TestProperty); 49 | Assert.Equal("new test value", testSettingsGroup.TestString); 50 | } 51 | 52 | private class TestSettingsGroup : SettingsGroupViewModelBase 53 | { 54 | private bool _testProperty; 55 | 56 | private string _testString; 57 | 58 | public bool TestProperty 59 | { 60 | get => _testProperty; 61 | set => this.RaiseAndSetIfChanged(ref _testProperty, value); 62 | } 63 | 64 | public string TestString 65 | { 66 | get => _testString; 67 | set => this.RaiseAndSetIfChanged(ref _testString, value); 68 | } 69 | 70 | public override string Name => "TestGroup"; 71 | 72 | public override string Header => "Test Group"; 73 | } 74 | } -------------------------------------------------------------------------------- /tests/ImageSort.UnitTests/SettingsManagement/SettingsViewModelTests.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using ImageSort.SettingsManagement; 3 | using Xunit; 4 | 5 | namespace ImageSort.UnitTests.SettingsManagement; 6 | 7 | public class SettingsViewModelTests 8 | { 9 | [Fact(DisplayName = "Can retrieve a specific settings group")] 10 | public void CanRetrieveSpecificSettingsGroup() 11 | { 12 | var firstGroup = new FirstGroupMock(); 13 | var secondGroup = new SecondGroupMock(); 14 | 15 | var settingsGroups = new SettingsGroupViewModelBase[] 16 | { 17 | firstGroup, 18 | secondGroup 19 | }; 20 | 21 | var settingsVM = new SettingsViewModel(settingsGroups); 22 | 23 | Assert.Equal(firstGroup, settingsVM.GetGroup()); 24 | Assert.Equal(secondGroup, settingsVM.GetGroup()); 25 | } 26 | 27 | [Fact(DisplayName = "Correctly converts settings into a dictionary")] 28 | public void CorrectlyConvertsToDictionary() 29 | { 30 | var firstGroup = new FirstGroupMock(); 31 | var secondGroup = new SecondGroupMock(); 32 | 33 | var settingsGroups = new SettingsGroupViewModelBase[] 34 | { 35 | firstGroup, 36 | secondGroup 37 | }; 38 | 39 | var settingsVM = new SettingsViewModel(settingsGroups); 40 | 41 | var settingsDict = settingsVM.AsDictionary(); 42 | 43 | Assert.Equal("fake value 2", settingsDict[secondGroup.Name]["some_setting"]); 44 | 45 | Assert.Equal("fake value 1", settingsDict[firstGroup.Name]["some_setting"]); 46 | } 47 | 48 | [Fact(DisplayName = "Correctly restores settings from a dictionary")] 49 | public void CorrectlyRestoresFromDictionary() 50 | { 51 | var firstGroup = new FirstGroupMock(); 52 | var secondGroup = new SecondGroupMock(); 53 | 54 | var settingsGroups = new SettingsGroupViewModelBase[] 55 | { 56 | firstGroup, 57 | secondGroup 58 | }; 59 | 60 | var settingsVM = new SettingsViewModel(settingsGroups); 61 | 62 | const string fakeValue1 = "some other fake value 1"; 63 | const string fakeValue2 = "some other fake value 2"; 64 | 65 | settingsVM.RestoreFromDictionary(new Dictionary> 66 | { 67 | { 68 | firstGroup.Name, new Dictionary 69 | { 70 | {"some_setting", fakeValue1} 71 | } 72 | }, 73 | { 74 | secondGroup.Name, new Dictionary 75 | { 76 | {"some_setting", fakeValue2} 77 | } 78 | } 79 | }); 80 | 81 | Assert.Equal(fakeValue1, firstGroup.SettingsStore["some_setting"]); 82 | 83 | Assert.Equal(fakeValue2, secondGroup.SettingsStore["some_setting"]); 84 | } 85 | 86 | private class FirstGroupMock : SettingsGroupViewModelBase 87 | { 88 | public FirstGroupMock() 89 | { 90 | SettingsStore["some_setting"] = "fake value 1"; 91 | } 92 | 93 | public override string Name => "FirstGroup"; 94 | public override string Header => "First group"; 95 | } 96 | 97 | private class SecondGroupMock : SettingsGroupViewModelBase 98 | { 99 | public SecondGroupMock() 100 | { 101 | SettingsStore["some_setting"] = "fake value 2"; 102 | } 103 | 104 | public override string Name => "SecondGroup"; 105 | public override string Header => "Second group"; 106 | } 107 | } -------------------------------------------------------------------------------- /tests/ImageSort.UnitTests/ViewModels/ActionsViewModelTest.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics.CodeAnalysis; 3 | using System.Reactive; 4 | using System.Reactive.Linq; 5 | using System.Threading.Tasks; 6 | using ImageSort.Actions; 7 | using ImageSort.ViewModels; 8 | using NSubstitute; 9 | using Xunit; 10 | 11 | namespace ImageSort.UnitTests.ViewModels; 12 | 13 | public class ActionsViewModelTest 14 | { 15 | [Fact(DisplayName = 16 | "Executes an action, adds it to the history, allows it to be undone and allows it to be redone and makes the last un-/done visible, also checking if clearing works.")] 17 | public async Task WorksCorrectly() 18 | { 19 | const string actionDisplayName = "Test action display name"; 20 | 21 | var actionsVM = new ActionsViewModel(); 22 | 23 | var actionMock = Substitute.For(); 24 | 25 | actionMock.DisplayName.Returns(actionDisplayName); 26 | 27 | await actionsVM.Execute.Execute(actionMock); 28 | 29 | Assert.Equal(actionDisplayName, actionsVM.LastDone); 30 | 31 | await actionsVM.Undo.Execute(); 32 | 33 | await Task.Delay(1); // without this the variables do not get updated for some reason. 34 | 35 | Assert.Equal(actionDisplayName, actionsVM.LastUndone); 36 | Assert.NotEqual(actionDisplayName, actionsVM.LastDone); 37 | 38 | await actionsVM.Redo.Execute(); 39 | 40 | await Task.Delay(1); // without this the variables do not get updated for some reason. 41 | 42 | Assert.Equal(actionDisplayName, actionsVM.LastDone); 43 | Assert.NotEqual(actionDisplayName, actionsVM.LastUndone); 44 | 45 | actionMock.Received(2).Act(); 46 | actionMock.Received(1).Revert(); 47 | 48 | // make sure clearing works 49 | await actionsVM.Clear.Execute(); 50 | 51 | Assert.Null(actionsVM.LastDone); 52 | Assert.Null(actionsVM.LastUndone); 53 | } 54 | 55 | [Fact(DisplayName = "Notifies user of errors during acting, undo and redo")] 56 | [SuppressMessage("Globalization", "CA1303:Do not pass literals as localized parameters", 57 | Justification = "Unit tests do not require localization for exception messages.")] 58 | public async Task NotifiesUserOfErrors() 59 | { 60 | // configure an action that fails when executed 61 | var failingActMock = Substitute.For(); 62 | 63 | failingActMock.When(a => a.Act()).Do(x => { throw new Exception("Act doesn't work"); }); 64 | 65 | // configure an action that fails on reversion (on undo) 66 | var failingRevertMock = Substitute.For(); 67 | 68 | failingRevertMock.When(a => a.Revert()).Do(x => { throw new Exception("Revert doesn't work"); }); 69 | 70 | // configure an action that fails on the second time being executed (on redo) 71 | var failingActOnUndoMock = Substitute.For(); 72 | 73 | var timesCalled = 0; 74 | 75 | failingActOnUndoMock.When(a => a.Act()).Do(x => 76 | { 77 | timesCalled = timesCalled switch 78 | { 79 | 0 => 1, 80 | _ => throw new Exception("Act doesn't work") 81 | }; 82 | }); 83 | 84 | var actionsVM = new ActionsViewModel(); 85 | 86 | var timesFailureWasReported = 0; 87 | 88 | actionsVM.NotifyUserOfError.RegisterHandler(ic => 89 | { 90 | timesFailureWasReported++; 91 | 92 | ic.SetOutput(Unit.Default); 93 | }); 94 | 95 | // fails on execute 96 | await actionsVM.Execute.Execute(failingActMock); 97 | 98 | // fails on undo 99 | await actionsVM.Execute.Execute(failingRevertMock); 100 | await actionsVM.Undo.Execute(); 101 | 102 | // fails on redo 103 | await actionsVM.Execute.Execute(failingActOnUndoMock); 104 | await actionsVM.Undo.Execute(); 105 | await actionsVM.Redo.Execute(); 106 | 107 | Assert.Equal(3, timesFailureWasReported); 108 | } 109 | } -------------------------------------------------------------------------------- /tests/ImageSort.UnitTests/ViewModels/FolderTreeItemViewModelTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Reactive.Linq; 6 | using System.Threading.Tasks; 7 | using ImageSort.FileSystem; 8 | using ImageSort.ViewModels; 9 | using NSubstitute; 10 | using ReactiveUI; 11 | using Xunit; 12 | 13 | namespace ImageSort.UnitTests.ViewModels; 14 | 15 | public class FolderTreeItemViewModelTests 16 | { 17 | [Fact(DisplayName = "Obtains the child folders of the current folder correctly")] 18 | public void ObtainsChildrenCorrectly() 19 | { 20 | const string path = @"C:\current folder"; 21 | 22 | var resultingPaths = 23 | new[] 24 | { 25 | @"\folder 1", 26 | @"\folder 2", 27 | @"\folder 3" 28 | } 29 | .Select(sub => path + sub); // make the (mock) subfolders absolute paths. 30 | 31 | var fsMock = Substitute.For(); 32 | 33 | fsMock.GetSubFolders(path).Returns(resultingPaths); 34 | 35 | var folderTreeItem = new FolderTreeItemViewModel(fsMock, backgroundScheduler: RxApp.MainThreadScheduler) 36 | { 37 | Path = path, 38 | IsVisible = true 39 | }; 40 | 41 | fsMock.Received().GetSubFolders(path); 42 | 43 | while (folderTreeItem.Children.Count == 0) {} 44 | 45 | Assert.Equal(resultingPaths, folderTreeItem.Children.Select(vm => vm.Path).ToArray()); 46 | } 47 | 48 | [Fact(DisplayName = 49 | "Handles an tried access to an unauthorized file (UnauthorizedAccessException) gracefully.")] 50 | public void HandlesUnauthorizedAccessExceptionGracefully() 51 | { 52 | const string pathToUnauthorisedFolder = @"C:\UnauthorizedFolder"; 53 | 54 | var fsMock = Substitute.For(); 55 | 56 | fsMock.GetSubFolders(pathToUnauthorisedFolder).Returns(x => throw new UnauthorizedAccessException()); 57 | 58 | var folderTreeItem = new FolderTreeItemViewModel(fsMock) 59 | { 60 | Path = pathToUnauthorisedFolder 61 | }; 62 | } 63 | 64 | [Fact(DisplayName = "Can create folders and adds them to the children.")] 65 | public async Task CanCreateFolders() 66 | { 67 | const string currentFolder = @"C:\current_folder"; 68 | var subfolders = new[] 69 | { 70 | "sub1", "sub2", "sub3" 71 | }.Select(s => Path.Combine(currentFolder, s)); 72 | const string addedFolder = currentFolder + @"\new_ sub"; 73 | var result = new List(); 74 | result.AddRange(subfolders); 75 | result.Add(addedFolder); 76 | 77 | var fsMock = Substitute.For(); 78 | 79 | fsMock.GetSubFolders(currentFolder).Returns(subfolders); 80 | fsMock.CreateFolder(addedFolder); 81 | 82 | var folderTreeItem = new FolderTreeItemViewModel(fsMock, backgroundScheduler: RxApp.MainThreadScheduler) 83 | { 84 | Path = currentFolder, 85 | IsVisible = true 86 | }; 87 | 88 | await folderTreeItem.CreateFolder.Execute(addedFolder); 89 | // verify that no second folder is created when a folder already exists 90 | await folderTreeItem.CreateFolder.Execute(addedFolder); 91 | 92 | fsMock.Received().CreateFolder(addedFolder); 93 | 94 | Assert.Equal(result.OrderBy(p => p), folderTreeItem.Children.Select(f => f.Path).OrderBy(p => p)); 95 | } 96 | 97 | [Fact(DisplayName = "Do not load subfolders when not visible")] 98 | public void DoNotLoadSubfoldersWhenNotVisible() 99 | { 100 | const string path = @"C:\current folder"; 101 | 102 | var resultingPaths = 103 | new[] 104 | { 105 | @"\folder 1", 106 | @"\folder 2", 107 | @"\folder 3" 108 | } 109 | .Select(sub => path + sub); // make the (mock) subfolders absolute paths. 110 | 111 | var fsMock = Substitute.For(); 112 | 113 | fsMock.GetSubFolders(path).Returns(resultingPaths); 114 | 115 | var folderTreeItem = new FolderTreeItemViewModel(fsMock, backgroundScheduler: RxApp.MainThreadScheduler) 116 | { 117 | Path = path, 118 | IsVisible = false 119 | }; 120 | 121 | fsMock.DidNotReceive().GetSubFolders(path); 122 | 123 | Assert.Empty(folderTreeItem.Children.Select(vm => vm.Path).ToArray()); 124 | } 125 | } -------------------------------------------------------------------------------- /tests/ImageSort.UnitTests/ViewModels/MetadataViewModelTests.cs: -------------------------------------------------------------------------------- 1 | using ImageSort.FileSystem; 2 | using ImageSort.ViewModels.Metadata; 3 | using NSubstitute; 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using System.Text; 8 | using System.Threading.Tasks; 9 | using Xunit; 10 | 11 | namespace ImageSort.UnitTests.ViewModels; 12 | 13 | public class MetadataViewModelTests 14 | { 15 | private readonly MetadataViewModel metadataViewModel; 16 | 17 | private readonly IFileSystem fileSystem = Substitute.For(); 18 | private readonly IMetadataExtractor metadataExtractor = Substitute.For(); 19 | 20 | public MetadataViewModelTests() 21 | { 22 | metadataViewModel = new(metadataExtractor, fileSystem, new MetadataSectionViewModelFactory(new MetadataFieldViewModelFactory())); 23 | } 24 | 25 | [Fact(DisplayName = "MetadataViewModel should extract metadata from image")] 26 | public void ExtractsMetadataWhenPathIsSet() 27 | { 28 | // setup mocks and view model 29 | string thisFileExists = "C:\\test.jpg"; 30 | var extractableMetadata = new Dictionary>(){ 31 | { "test", new Dictionary(){ 32 | { "test", "test" }, 33 | } 34 | } 35 | }; 36 | 37 | fileSystem.FileExists(thisFileExists).Returns(true); 38 | 39 | metadataExtractor.Extract(thisFileExists).Returns(extractableMetadata); 40 | 41 | // this should cause the extraction of metadata 42 | metadataViewModel.ImagePath = thisFileExists; 43 | 44 | Assert.Equal(MetadataResultType.Success, metadataViewModel.Metadata.Type); 45 | Assert.Equal(extractableMetadata, metadataViewModel.Metadata.Metadata); 46 | 47 | fileSystem.Received().FileExists(thisFileExists); 48 | metadataExtractor.Received().Extract(thisFileExists); 49 | } 50 | 51 | [Fact(DisplayName = "MetadataViewModel should not extract metadata from image when file does not exist")] 52 | public void DoesNotExtractMetadataWhenPathIsSetAndFileDoesNotExist() 53 | { 54 | // setup mocks and view model 55 | string thisFileDoesNotExist = "C:\\test2.jpg"; 56 | 57 | fileSystem.FileExists(thisFileDoesNotExist).Returns(false); 58 | 59 | metadataViewModel.ImagePath = thisFileDoesNotExist; 60 | 61 | Assert.Equal(MetadataResultType.FileDoesNotExist, metadataViewModel.Metadata.Type); 62 | Assert.Null(metadataViewModel.Metadata.Metadata); 63 | 64 | fileSystem.Received().FileExists(thisFileDoesNotExist); 65 | metadataExtractor.DidNotReceive().Extract(thisFileDoesNotExist); 66 | } 67 | 68 | [Fact(DisplayName = "Correctly reports unhandled exceptions that occur when trying to extract metadata")] 69 | public void CorrectlyReportsIssuesWithTheExtractionOfMetadata() 70 | { 71 | // setup mocks and view model 72 | string thisFileHasInvalidMetadata = "C:\\test3.jpg"; 73 | Exception invalidMetadata = new("Invalid metadata could not be loaded"); 74 | 75 | fileSystem.FileExists(thisFileHasInvalidMetadata).Returns(true); 76 | 77 | metadataExtractor.Extract(thisFileHasInvalidMetadata).Returns(x => throw invalidMetadata); 78 | 79 | metadataViewModel.ImagePath = thisFileHasInvalidMetadata; 80 | 81 | Assert.Equal(MetadataResultType.UnexpectedError, metadataViewModel.Metadata.Type); 82 | Assert.Null(metadataViewModel.Metadata.Metadata); 83 | Assert.Equal(invalidMetadata, metadataViewModel.Metadata.Exception); 84 | 85 | fileSystem.Received().FileExists(thisFileHasInvalidMetadata); 86 | metadataExtractor.Received().Extract(thisFileHasInvalidMetadata); 87 | } 88 | 89 | [Fact(DisplayName = "Correctly creates metadata sections from extracted metadata")] 90 | public void CorrectlyCreatesMetadataSectionsFromExtractedMetadata() 91 | { 92 | // setup mocks and view model 93 | string thisFileHasMetadata = "C:\\test4.jpg"; 94 | var extractableMetadata = new Dictionary>(){ 95 | { "test", new Dictionary(){ 96 | { "test", "test" }, 97 | } 98 | } 99 | }; 100 | 101 | fileSystem.FileExists(thisFileHasMetadata).Returns(true); 102 | 103 | metadataExtractor.Extract(thisFileHasMetadata).Returns(extractableMetadata); 104 | 105 | metadataViewModel.ImagePath = thisFileHasMetadata; 106 | 107 | Assert.Equal(1, metadataViewModel.SectionViewModels.Count()); 108 | Assert.Equal("test", metadataViewModel.SectionViewModels.First().Title); 109 | Assert.Equal(extractableMetadata["test"], metadataViewModel.SectionViewModels.First().Fields); 110 | 111 | fileSystem.Received().FileExists(thisFileHasMetadata); 112 | metadataExtractor.Received().Extract(thisFileHasMetadata); 113 | } 114 | } -------------------------------------------------------------------------------- /tests/ImageSort.WPF.UiTests/AppCollection.cs: -------------------------------------------------------------------------------- 1 | using Xunit; 2 | 3 | namespace ImageSort.WPF.UiTests; 4 | 5 | [CollectionDefinition("App collection", DisableParallelization = true)] 6 | public class AppCollection : ICollectionFixture 7 | { 8 | } -------------------------------------------------------------------------------- /tests/ImageSort.WPF.UiTests/AppFixture.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using FlaUI.Core; 3 | using FlaUI.Core.AutomationElements; 4 | using FlaUI.UIA3; 5 | using Debug = System.Diagnostics.Debug; 6 | 7 | namespace ImageSort.WPF.UiTests; 8 | 9 | public class AppFixture : IDisposable 10 | { 11 | public AppFixture() 12 | { 13 | (CurrentPath, App, Automation, MainWindow) = SetupTeardownHelper.Setup(); 14 | 15 | if (MainWindow == null || App == null || Automation == null || CurrentPath == null) 16 | Debug.WriteLine("Could not setup app fixture: One of the instances returned by setup is null"); 17 | } 18 | 19 | private string CurrentPath { get; } 20 | public Application App { get; } 21 | public UIA3Automation Automation { get; } 22 | public Window MainWindow { get; } 23 | 24 | public void Dispose() 25 | { 26 | SetupTeardownHelper.TearDown(CurrentPath, App, Automation); 27 | } 28 | 29 | internal void Deconstruct(out string currentPath, out Application app, out UIA3Automation automation, 30 | out Window mainWindow) 31 | { 32 | currentPath = CurrentPath; 33 | app = App; 34 | automation = Automation; 35 | mainWindow = MainWindow; 36 | } 37 | } -------------------------------------------------------------------------------- /tests/ImageSort.WPF.UiTests/ControlHelper.cs: -------------------------------------------------------------------------------- 1 | using FlaUI.Core; 2 | using FlaUI.Core.AutomationElements; 3 | 4 | namespace ImageSort.WPF.UiTests; 5 | 6 | internal static class ControlHelper 7 | { 8 | public static Application App { get; set; } 9 | public static Window MainWindow { get; set; } 10 | 11 | public static void ClickButton(this AutomationElement element, string automationId) 12 | { 13 | element.FindFirstDescendant(cf => cf.ByAutomationId(automationId))?.AsButton().Click(); 14 | 15 | App.WaitWhileBusy(); 16 | MainWindow.WaitUntilClickable(); 17 | } 18 | } -------------------------------------------------------------------------------- /tests/ImageSort.WPF.UiTests/FileActionsTests.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Linq; 3 | using FlaUI.Core; 4 | using FlaUI.Core.AutomationElements; 5 | using FlaUI.Core.Input; 6 | using FlaUI.Core.WindowsAPI; 7 | using Xunit; 8 | 9 | [assembly: CollectionBehavior(DisableTestParallelization = true)] 10 | 11 | namespace ImageSort.WPF.UiTests; 12 | 13 | [Collection("App collection")] 14 | public class FileActionsTests 15 | { 16 | private readonly Application app; 17 | private readonly string currentPath; 18 | private readonly Window mainWindow; 19 | 20 | public FileActionsTests(AppFixture appFixture) 21 | { 22 | (currentPath, app, _, mainWindow) = appFixture; 23 | } 24 | 25 | [Fact(DisplayName = "Can move image, undo and redo")] 26 | public void CanMoveImages() 27 | { 28 | var oldLocation = mainWindow.GetSelectedImage(); 29 | var newLocation = Path.Combine(Directory.GetDirectories(currentPath)[0], Path.GetFileName(oldLocation)); 30 | 31 | Assert.True(File.Exists(oldLocation)); 32 | Assert.False(File.Exists(newLocation)); 33 | 34 | // select folder 35 | Keyboard.Press(VirtualKeyShort.KEY_D); 36 | 37 | Keyboard.Press(VirtualKeyShort.KEY_S); 38 | 39 | app.WaitWhileBusy(); 40 | 41 | var selectedImage = mainWindow.GetSelectedImage(); 42 | 43 | // move image 44 | mainWindow.ClickButton("Move"); 45 | 46 | app.WaitWhileBusy(); 47 | 48 | Assert.False(File.Exists(oldLocation)); 49 | Assert.True(File.Exists(newLocation)); 50 | 51 | // undo 52 | mainWindow.ClickButton("Undo"); 53 | 54 | // make sure the image is not added back twice, for example by the FileSystemWatcher in addition to the code itself 55 | Assert.Single(mainWindow.GetImages().Where(i => i == selectedImage)); 56 | 57 | Assert.True(File.Exists(oldLocation)); 58 | Assert.False(File.Exists(newLocation)); 59 | 60 | // redo 61 | mainWindow.ClickButton("Redo"); 62 | 63 | Assert.False(File.Exists(oldLocation)); 64 | Assert.True(File.Exists(newLocation)); 65 | 66 | // clean-up 67 | mainWindow.ClickButton("Undo"); 68 | 69 | // unselect folder 70 | Keyboard.Press(VirtualKeyShort.KEY_A); 71 | Keyboard.Press(VirtualKeyShort.KEY_A); 72 | } 73 | 74 | [Fact(DisplayName = "Can delete images")] 75 | public void CanDeleteImages() 76 | { 77 | var file = mainWindow.GetSelectedImage(); 78 | 79 | Assert.True(File.Exists(file)); 80 | 81 | // delete image 82 | mainWindow.ClickButton("Delete"); 83 | 84 | Assert.False(File.Exists(file)); 85 | 86 | // clean-up 87 | mainWindow.ClickButton("Undo"); 88 | 89 | // make sure the image is not added back twice, for example by the FileSystemWatcher in addition to the code itself 90 | Assert.Single(mainWindow.GetImages().Where(i => i == file)); 91 | 92 | Assert.True(File.Exists(file)); 93 | } 94 | } -------------------------------------------------------------------------------- /tests/ImageSort.WPF.UiTests/FolderActionsTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using FlaUI.Core; 4 | using FlaUI.Core.AutomationElements; 5 | using FlaUI.Core.Input; 6 | using FlaUI.Core.Tools; 7 | using FlaUI.Core.WindowsAPI; 8 | using Xunit; 9 | 10 | namespace ImageSort.WPF.UiTests; 11 | 12 | [Collection("App collection")] 13 | public class FolderActionsTests 14 | { 15 | private readonly Application app; 16 | private readonly string currentPath; 17 | private readonly Window mainWindow; 18 | 19 | public FolderActionsTests(AppFixture appFixture) 20 | { 21 | (currentPath, app, _, mainWindow) = appFixture; 22 | } 23 | 24 | [Fact(DisplayName = "Can create folders and reacts to its deletion")] 25 | public void CanCreateFolders() 26 | { 27 | const string newFolderName = "new folder"; 28 | var newFolderPath = Path.Combine(currentPath, newFolderName); 29 | 30 | mainWindow.ClickButton("CreateFolder"); 31 | 32 | Keyboard.Type(newFolderName); 33 | Keyboard.Press(VirtualKeyShort.ENTER); 34 | 35 | app.WaitWhileBusy(); 36 | mainWindow.WaitUntilClickable(); 37 | 38 | Assert.True(Retry.WhileFalse(() => Directory.Exists(newFolderPath), timeout: TimeSpan.FromSeconds(5), interval: TimeSpan.FromMilliseconds(50)).Result); 39 | 40 | Assert.True(Directory.Exists(newFolderPath)); 41 | 42 | // clean-up 43 | Directory.Delete(newFolderPath); 44 | } 45 | } -------------------------------------------------------------------------------- /tests/ImageSort.WPF.UiTests/ImageSort.WPF.UiTests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0-windows 5 | 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | all 15 | runtime; build; native; contentfiles; analyzers; buildtransitive 16 | 17 | 18 | all 19 | runtime; build; native; contentfiles; analyzers; buildtransitive 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | <_CopyItems Include="$(ProjectDir)\..\..\src\ImageSort.WPF\bin\$(Configuration)\net8.0-windows\*.*" /> 30 | 31 | 32 | 33 | 34 | 35 | 36 | PreserveNewest 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /tests/ImageSort.WPF.UiTests/MockState/Subfolder 1/Subsubfolder/mock in subsubfolder.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/tests/ImageSort.WPF.UiTests/MockState/Subfolder 1/Subsubfolder/mock in subsubfolder.jpg -------------------------------------------------------------------------------- /tests/ImageSort.WPF.UiTests/MockState/Subfolder 1/mock in subfolder.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/tests/ImageSort.WPF.UiTests/MockState/Subfolder 1/mock in subfolder.jpg -------------------------------------------------------------------------------- /tests/ImageSort.WPF.UiTests/MockState/Subfolder 2/mock in subfolder 2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/tests/ImageSort.WPF.UiTests/MockState/Subfolder 2/mock in subfolder 2.jpg -------------------------------------------------------------------------------- /tests/ImageSort.WPF.UiTests/MockState/mock 1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/tests/ImageSort.WPF.UiTests/MockState/mock 1.jpg -------------------------------------------------------------------------------- /tests/ImageSort.WPF.UiTests/MockState/mock 2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/tests/ImageSort.WPF.UiTests/MockState/mock 2.jpg -------------------------------------------------------------------------------- /tests/ImageSort.WPF.UiTests/MockState/mock 3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/tests/ImageSort.WPF.UiTests/MockState/mock 3.jpg -------------------------------------------------------------------------------- /tests/ImageSort.WPF.UiTests/MockState/mock 4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/tests/ImageSort.WPF.UiTests/MockState/mock 4.png -------------------------------------------------------------------------------- /tests/ImageSort.WPF.UiTests/MockState/mock 5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/tests/ImageSort.WPF.UiTests/MockState/mock 5.jpg -------------------------------------------------------------------------------- /tests/ImageSort.WPF.UiTests/MockState/mock 6.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/tests/ImageSort.WPF.UiTests/MockState/mock 6.jpg -------------------------------------------------------------------------------- /tests/ImageSort.WPF.UiTests/MockState/mock 7.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Lolle2000la/Image-Sort/27156aa0069ad74af8ea5a83b95d0d4958fc2e82/tests/ImageSort.WPF.UiTests/MockState/mock 7.jpg -------------------------------------------------------------------------------- /tests/ImageSort.WPF.UiTests/SearchTests.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using System.Linq; 3 | using FlaUI.Core.AutomationElements; 4 | using Xunit; 5 | 6 | namespace ImageSort.WPF.UiTests; 7 | 8 | [Collection("App collection")] 9 | public class SearchTests 10 | { 11 | private readonly Window mainWindow; 12 | 13 | public SearchTests(AppFixture appFixture) 14 | { 15 | (_, _, _, mainWindow) = appFixture; 16 | } 17 | 18 | [Fact(DisplayName = "Filters out images correctly")] 19 | public void FiltersOutImagesCorrectly() 20 | { 21 | var search = mainWindow.FindFirstDescendant(cf => cf.ByAutomationId("SearchTerm")).AsTextBox(); 22 | search.Focus(); 23 | search.Text = ".jpg"; 24 | 25 | var images = mainWindow.GetImages() 26 | .Select(n => Path.GetFileName(n)); 27 | 28 | Assert.DoesNotContain("mock 4.png", images); 29 | } 30 | } -------------------------------------------------------------------------------- /tests/ImageSort.WPF.UiTests/SetupTeardownHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.IO; 4 | using FlaUI.Core; 5 | using FlaUI.Core.AutomationElements; 6 | using FlaUI.Core.Tools; 7 | using FlaUI.UIA3; 8 | 9 | namespace ImageSort.WPF.UiTests; 10 | 11 | internal static class SetupTeardownHelper 12 | { 13 | public static (string, Application, UIA3Automation, Window) Setup() 14 | { 15 | var currentPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Temporary State", 16 | Guid.NewGuid().ToString()); 17 | 18 | CopyFolder(Path.GetFullPath("MockState"), currentPath); 19 | 20 | // ensure previous config file is removed 21 | if (File.Exists(@".\ui_test_config.json")) File.Delete(@".\ui_test_config.json"); 22 | 23 | var procInfo = new ProcessStartInfo("Image Sort.exe", $"\"{currentPath}\""); 24 | 25 | // ensures the app is not affected by and does not affect global config file 26 | procInfo.EnvironmentVariables.Add("UI_TEST", "true"); 27 | 28 | var app = Application.Launch(procInfo); 29 | var automation = new UIA3Automation(); 30 | 31 | app.WaitWhileBusy(); 32 | 33 | var mainWindow = Retry.WhileNull(() => 34 | { 35 | var allWindows = app.GetAllTopLevelWindows(automation); 36 | 37 | if (allWindows.Length > 0) return allWindows[0]; 38 | 39 | return null; 40 | }, TimeSpan.FromSeconds(30), null, true).Result; 41 | 42 | app.WaitWhileBusy(); 43 | app.WaitWhileMainHandleIsMissing(); 44 | mainWindow.WaitUntilClickable(); 45 | 46 | mainWindow.Focus(); 47 | 48 | while (currentPath == null || app == null || automation == null || mainWindow == null) 49 | { 50 | } 51 | 52 | ControlHelper.App = app; 53 | ControlHelper.MainWindow = mainWindow; 54 | 55 | return (currentPath, app, automation, mainWindow); 56 | } 57 | 58 | public static void TearDown(string currentPath, Application app, UIA3Automation automation) 59 | { 60 | app.Close(); 61 | automation.Dispose(); 62 | app.Dispose(); 63 | 64 | Directory.Delete(currentPath, true); 65 | } 66 | 67 | private static void CopyFolder(string sourceFolder, string destFolder) 68 | { 69 | if (!Directory.Exists(destFolder)) Directory.CreateDirectory(destFolder); 70 | 71 | var files = Directory.GetFiles(sourceFolder); 72 | 73 | foreach (var file in files) 74 | { 75 | var name = Path.GetFileName(file); 76 | var dest = Path.Combine(destFolder, name); 77 | File.Copy(file, dest); 78 | } 79 | 80 | var folders = Directory.GetDirectories(sourceFolder); 81 | 82 | foreach (var folder in folders) 83 | { 84 | var name = Path.GetFileName(folder); 85 | var dest = Path.Combine(destFolder, name); 86 | CopyFolder(folder, dest); 87 | } 88 | } 89 | } -------------------------------------------------------------------------------- /tests/ImageSort.WPF.UiTests/WindowHelper.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using FlaUI.Core.AutomationElements; 4 | 5 | namespace ImageSort.WPF.UiTests; 6 | 7 | internal static class WindowHelper 8 | { 9 | private static ListBox GetImagesBox(this Window mainWindow) 10 | { 11 | return mainWindow 12 | .FindFirstDescendant(cf => cf.ByAutomationId("Images")) 13 | .FindFirstDescendant(cf => cf.ByAutomationId("Images")).AsListBox(); 14 | } 15 | 16 | public static IEnumerable GetImages(this Window mainWindow) 17 | { 18 | return mainWindow 19 | .GetImagesBox() 20 | .FindAllChildren() 21 | .Select(e => e.AsListBoxItem()) 22 | .Select(e => e.Name); 23 | } 24 | 25 | public static string GetSelectedImage(this Window mainWindow) 26 | { 27 | return mainWindow 28 | .GetImagesBox() 29 | .SelectedItem.Name; 30 | } 31 | } --------------------------------------------------------------------------------