From 93e0e2659f5313dbacc66ce85ce92be40b19417d Mon Sep 17 00:00:00 2001 From: aecs4u <190334937+aecs4u@users.noreply.github.com> Date: Mon, 23 Mar 2026 10:21:41 +0100 Subject: [PATCH 01/49] Add CodeQL analysis workflow This workflow file sets up CodeQL analysis for multiple languages on push and pull request events, as well as on a schedule. --- .github/workflows/codeql.yml | 105 +++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..7244d73af --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,105 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL Advanced" + +on: + push: + branches: [ "master" ] + pull_request: + branches: [ "master" ] + schedule: + - cron: '35 2 * * 2' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: java-kotlin + build-mode: none # This mode only analyzes Java. Set this to 'autobuild' or 'manual' to analyze Kotlin too. + - language: python + build-mode: none + - language: rust + build-mode: none + # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Add any setup steps before running the `github/codeql-action/init` action. + # This includes steps like installing compilers or runtimes (`actions/setup-node` + # or others). This is typically only required for manual builds. + # - name: Setup runtime (example) + # uses: actions/setup-example@v1 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # â„šī¸ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - name: Run manual build steps + if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{matrix.language}}" From 71e17fab751bb4d0c1c6339cbb9a44005d1ca989 Mon Sep 17 00:00:00 2001 From: aecs4u <190334937+aecs4u@users.noreply.github.com> Date: Mon, 23 Mar 2026 10:22:10 +0100 Subject: [PATCH 02/49] Create SECURITY.md for security policy Added a security policy document outlining supported versions and vulnerability reporting. --- SECURITY.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 SECURITY.md diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..034e84803 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +## Supported Versions + +Use this section to tell people about which versions of your project are +currently being supported with security updates. + +| Version | Supported | +| ------- | ------------------ | +| 5.1.x | :white_check_mark: | +| 5.0.x | :x: | +| 4.0.x | :white_check_mark: | +| < 4.0 | :x: | + +## Reporting a Vulnerability + +Use this section to tell people how to report a vulnerability. + +Tell them where to go, how often they can expect to get an update on a +reported vulnerability, what to expect if the vulnerability is accepted or +declined, etc. From b1d5d9b84277afa39fb0b0171bfbdc828b224857 Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 09:57:45 +0100 Subject: [PATCH 03/49] Add PySide6/Qt frontend and CLI --json-progress flag (#1847) Add a new PySide6/Qt 6 GUI frontend (czkawka_pyside6) with feature parity with the Krokiet (Slint) interface. Uses czkawka_cli as its backend via subprocess with JSON output for results and --json-progress for real-time progress data. PySide6 frontend features: - All 14 scanning tools with per-tool settings - Two-bar progress (current stage + overall) with entry/byte counts - Dark theme with Krokiet SVG icons - Grouped results, selection modes, file actions - Image preview, directory management, settings persistence - Auto-detection of czkawka_cli binary CLI --json-progress flag: - Outputs ProgressData as JSON lines to stderr - Added Serialize to ProgressData, CurrentStage, ToolType - Added connect_progress_json() handler - Added serde_json dependency Closes #1847 Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 1 + README.md | 66 +- czkawka_cli/Cargo.toml | 1 + czkawka_cli/README.md | 58 +- czkawka_cli/src/commands.rs | 29 + czkawka_cli/src/main.rs | 9 +- czkawka_cli/src/progress.rs | 34 + czkawka_core/src/common/model.rs | 2 +- czkawka_core/src/common/progress_data.rs | 5 +- czkawka_pyside6/README.md | 115 ++++ czkawka_pyside6/app/__init__.py | 0 czkawka_pyside6/app/action_buttons.py | 196 ++++++ czkawka_pyside6/app/backend.py | 621 ++++++++++++++++++ czkawka_pyside6/app/bottom_panel.py | 143 +++++ czkawka_pyside6/app/dialogs/__init__.py | 7 + czkawka_pyside6/app/dialogs/about_dialog.py | 78 +++ czkawka_pyside6/app/dialogs/delete_dialog.py | 51 ++ czkawka_pyside6/app/dialogs/move_dialog.py | 65 ++ czkawka_pyside6/app/dialogs/rename_dialog.py | 34 + czkawka_pyside6/app/dialogs/save_dialog.py | 48 ++ czkawka_pyside6/app/dialogs/select_dialog.py | 48 ++ czkawka_pyside6/app/dialogs/sort_dialog.py | 39 ++ czkawka_pyside6/app/icons.py | 149 +++++ czkawka_pyside6/app/left_panel.py | 149 +++++ czkawka_pyside6/app/main_window.py | 624 +++++++++++++++++++ czkawka_pyside6/app/models.py | 305 +++++++++ czkawka_pyside6/app/preview_panel.py | 112 ++++ czkawka_pyside6/app/progress_widget.py | 325 ++++++++++ czkawka_pyside6/app/results_view.py | 280 +++++++++ czkawka_pyside6/app/settings_panel.py | 261 ++++++++ czkawka_pyside6/app/state.py | 122 ++++ czkawka_pyside6/app/tool_settings.py | 504 +++++++++++++++ czkawka_pyside6/main.py | 58 ++ 33 files changed, 4495 insertions(+), 44 deletions(-) create mode 100644 czkawka_pyside6/README.md create mode 100644 czkawka_pyside6/app/__init__.py create mode 100644 czkawka_pyside6/app/action_buttons.py create mode 100644 czkawka_pyside6/app/backend.py create mode 100644 czkawka_pyside6/app/bottom_panel.py create mode 100644 czkawka_pyside6/app/dialogs/__init__.py create mode 100644 czkawka_pyside6/app/dialogs/about_dialog.py create mode 100644 czkawka_pyside6/app/dialogs/delete_dialog.py create mode 100644 czkawka_pyside6/app/dialogs/move_dialog.py create mode 100644 czkawka_pyside6/app/dialogs/rename_dialog.py create mode 100644 czkawka_pyside6/app/dialogs/save_dialog.py create mode 100644 czkawka_pyside6/app/dialogs/select_dialog.py create mode 100644 czkawka_pyside6/app/dialogs/sort_dialog.py create mode 100644 czkawka_pyside6/app/icons.py create mode 100644 czkawka_pyside6/app/left_panel.py create mode 100644 czkawka_pyside6/app/main_window.py create mode 100644 czkawka_pyside6/app/models.py create mode 100644 czkawka_pyside6/app/preview_panel.py create mode 100644 czkawka_pyside6/app/progress_widget.py create mode 100644 czkawka_pyside6/app/results_view.py create mode 100644 czkawka_pyside6/app/settings_panel.py create mode 100644 czkawka_pyside6/app/state.py create mode 100644 czkawka_pyside6/app/tool_settings.py create mode 100644 czkawka_pyside6/main.py diff --git a/Cargo.lock b/Cargo.lock index 976e7240b..e6474189c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1634,6 +1634,7 @@ dependencies = [ "humansize", "indicatif", "log", + "serde_json", ] [[package]] diff --git a/README.md b/README.md index 7b7ef21ad..ae90b01f9 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ - **Cache support** - second and further scans should be much faster than the first one - **Easy to run, easy to compile** - minimal runtime and build dependencies, portable version available - **CLI frontend** - for easy automation -- **GUI frontend** - uses Slint or GTK 4 frameworks +- **GUI frontends** - Slint (Krokiet), GTK 4 (Czkawka GUI), and PySide6/Qt (Czkawka PySide6) - **Core library** - allows to reuse functionality in other apps - **Android app** - experimental touch-friendly frontend for Android devices - **No spying** - Czkawka does not have access to the Internet, nor does it collect any user information or statistics @@ -55,6 +55,7 @@ Each tool uses different technologies, so you can find instructions for each of - [Krokiet GUI (Slint frontend)](krokiet/README.md)
- [Czkawka GUI (GTK frontend)](czkawka_gui/README.md)
+- [Czkawka PySide6 (Qt/PySide6 frontend)](czkawka_pyside6/README.md)
- [Czkawka CLI](czkawka_cli/README.md)
- [Czkawka Core](czkawka_core/README.md)
- [Cedinia](cedinia/README.md)
@@ -64,37 +65,37 @@ Each tool uses different technologies, so you can find instructions for each of In this comparison remember, that even if app have same features they may work different(e.g. one app may have more options to choose than other). -| | Krokiet | Czkawka | FSlint | DupeGuru | Bleachbit | -|:-------------------------:|:-----------:|:----------------:|:------:|:-----------------:|:-----------:| -| Language | Rust | Rust | Python | Python/Obj-C | Python | -| Framework base language | Rust | C | C | C/C++/Obj-C/Swift | C | -| Framework | Slint | GTK 4 | PyGTK2 | Qt 5 (PyQt)/Cocoa | PyGTK3 | -| OS | Lin,Mac,Win | Lin,Mac,Win | Lin | Lin,Mac,Win | Lin,Mac,Win | -| Duplicate finder | ✔ | ✔ | ✔ | ✔ | | -| Empty files | ✔ | ✔ | ✔ | | | -| Empty folders | ✔ | ✔ | ✔ | | | -| Temporary files | ✔ | ✔ | ✔ | | ✔ | -| Big files | ✔ | ✔ | | | | -| Similar images | ✔ | ✔ | | ✔ | | -| Similar videos | ✔ | ✔ | | | | -| Music duplicates(tags) | ✔ | ✔ | | ✔ | | -| Music duplicates(content) | ✔ | ✔ | | | | -| Invalid symlinks | ✔ | ✔ | ✔ | | | -| Broken files | ✔ | ✔ | | | | -| Invalid names/extensions | ✔ | ✔ | ✔ | | | -| Exif cleaner | ✔ | | | | | -| Video optimizer | ✔ | | | | | -| Bad Names | ✔ | | | | | -| Names conflict | | | ✔ | | | -| Installed packages | | | ✔ | | | -| Bad ID | | | ✔ | | | -| Non stripped binaries | | | ✔ | | | -| Redundant whitespace | | | ✔ | | | -| Overwriting files | | | ✔ | | ✔ | -| Portable version | ✔ | ✔ | | | ✔ | -| Multiple languages | ✔ | ✔ | ✔ | ✔ | ✔ | -| Cache support | ✔ | ✔ | | ✔ | | -| In active development | Yes | Yes** | No | No* | Yes | +| | Krokiet | Czkawka PySide6 | Czkawka | FSlint | DupeGuru | Bleachbit | +|:-------------------------:|:-----------:|:----------------:|:----------------:|:------:|:-----------------:|:-----------:| +| Language | Rust | Python | Rust | Python | Python/Obj-C | Python | +| Framework base language | Rust | C++ | C | C | C/C++/Obj-C/Swift | C | +| Framework | Slint | PySide6 (Qt 6) | GTK 4 | PyGTK2 | Qt 5 (PyQt)/Cocoa | PyGTK3 | +| OS | Lin,Mac,Win | Lin,Mac,Win | Lin,Mac,Win | Lin | Lin,Mac,Win | Lin,Mac,Win | +| Duplicate finder | ✔ | ✔ | ✔ | ✔ | ✔ | | +| Empty files | ✔ | ✔ | ✔ | ✔ | | | +| Empty folders | ✔ | ✔ | ✔ | ✔ | | | +| Temporary files | ✔ | ✔ | ✔ | ✔ | | ✔ | +| Big files | ✔ | ✔ | ✔ | | | | +| Similar images | ✔ | ✔ | ✔ | | ✔ | | +| Similar videos | ✔ | ✔ | ✔ | | | | +| Music duplicates(tags) | ✔ | ✔ | ✔ | | ✔ | | +| Music duplicates(content) | ✔ | ✔ | ✔ | | | | +| Invalid symlinks | ✔ | ✔ | ✔ | ✔ | | | +| Broken files | ✔ | ✔ | ✔ | | | | +| Invalid names/extensions | ✔ | ✔ | ✔ | ✔ | | | +| Exif cleaner | ✔ | ✔ | | | | | +| Video optimizer | ✔ | ✔ | | | | | +| Bad Names | ✔ | ✔ | | | | | +| Names conflict | | | | ✔ | | | +| Installed packages | | | | ✔ | | | +| Bad ID | | | | ✔ | | | +| Non stripped binaries | | | | ✔ | | | +| Redundant whitespace | | | | ✔ | | | +| Overwriting files | | | | ✔ | | ✔ | +| Portable version | ✔ | ✔ | ✔ | | | ✔ | +| Multiple languages | ✔ | | ✔ | ✔ | ✔ | ✔ | +| Cache support | ✔ | ✔ | ✔ | | ✔ | | +| In active development | Yes | Yes | Yes** | No | No* | Yes |

* Few small commits added recently and last version released in 2023

** Czkawka GTK is in maintenance mode receiving only bugfixes

@@ -126,6 +127,7 @@ console apps, then take a look at these: Czkawka exposes its common functionality through a crate called **`czkawka_core`**, which can be reused by other projects. It is written in Rust and is used by all Czkawka frontends (`czkawka_gui`, `czkawka_cli`, `krokiet`, `cedinia`). +The `czkawka_pyside6` frontend uses `czkawka_cli` as its backend, communicating via JSON output and `--json-progress` for real-time progress data. It is also used by external projects, such as: diff --git a/czkawka_cli/Cargo.toml b/czkawka_cli/Cargo.toml index 2b24c2c56..063f55117 100644 --- a/czkawka_cli/Cargo.toml +++ b/czkawka_cli/Cargo.toml @@ -18,6 +18,7 @@ indicatif = "0.18" crossbeam-channel = { version = "0.5", features = [] } ctrlc = { version = "3.4", features = ["termination"] } humansize = "2.1" +serde_json = "1" [features] default = [] diff --git a/czkawka_cli/README.md b/czkawka_cli/README.md index 633be02a7..ccc665807 100644 --- a/czkawka_cli/README.md +++ b/czkawka_cli/README.md @@ -44,13 +44,57 @@ czkawka_cli dup --help Example usage: ```shell -czkawka dup -d /home/rafal -e /home/rafal/Obrazy -m 25 -x 7z rar IMAGE -s hash -f results.txt -D aeo -czkawka empty-folders -d /home/rafal/rr /home/gateway -f results.txt -czkawka big -d /home/rafal/ /home/piszczal -e /home/rafal/Roman -n 25 -x VIDEO -f results.txt -czkawka empty-files -d /home/rafal /home/szczekacz -e /home/rafal/Pulpit -R -f results.txt -czkawka temp -d /home/rafal/ -E */.git */tmp* *Pulpit -f results.txt -D -czkawka music -d /home/rafal -e /home/rafal/Pulpit -z "artist,year, ARTISTALBUM, ALBUM___tiTlE" -f results.txt -czkawka symlinks -d /home/kicikici/ /home/szczek -e /home/kicikici/jestempsem -x jpg -f results.txt +czkawka_cli dup -d /home/rafal -e /home/rafal/Obrazy -m 25 -x 7z rar IMAGE -s hash -f results.txt -D aeo +czkawka_cli empty-folders -d /home/rafal/rr /home/gateway -f results.txt +czkawka_cli big -d /home/rafal/ /home/piszczal -e /home/rafal/Roman -n 25 -x VIDEO -f results.txt +czkawka_cli empty-files -d /home/rafal /home/szczekacz -e /home/rafal/Pulpit -R -f results.txt +czkawka_cli temp -d /home/rafal/ -E */.git */tmp* *Pulpit -f results.txt -D +czkawka_cli music -d /home/rafal -e /home/rafal/Pulpit -z "artist,year, ARTISTALBUM, ALBUM___tiTlE" -f results.txt +czkawka_cli symlinks -d /home/kicikici/ /home/szczek -e /home/kicikici/jestempsem -x jpg -f results.txt +``` + +## JSON output + +Results can be saved as compact or pretty-printed JSON: + +```shell +czkawka_cli dup -d /home/user --compact-file-to-save results.json +czkawka_cli dup -d /home/user --pretty-json-file-to-save results.json +``` + +## Machine-readable progress (`--json-progress`) + +The `--json-progress` flag outputs real-time progress data as JSON lines to stderr. This is used by GUI frontends (such as the PySide6 frontend) to display accurate progress bars. + +Each line is a JSON object with the following structure: +```json +{ + "progress": { + "sstage": "DuplicatePreHashing", + "checking_method": "Hash", + "current_stage_idx": 2, + "max_stage_idx": 6, + "entries_checked": 50000, + "entries_to_check": 94500, + "bytes_checked": 204800000, + "bytes_to_check": 387072000, + "tool_type": "Duplicate" + }, + "stage_name": "Calculating prehashes" +} +``` + +Fields: +- `sstage` - Internal stage identifier (e.g., `CollectingFiles`, `DuplicateFullHashing`, `SimilarImagesComparingHashes`) +- `current_stage_idx` / `max_stage_idx` - Current stage number and total stages (e.g., 2/6 for duplicates) +- `entries_checked` / `entries_to_check` - Files processed and total to process +- `bytes_checked` / `bytes_to_check` - Bytes processed and total (for hashing stages) +- `stage_name` - Human-readable stage description + +Example usage: +```shell +# Capture progress on stderr while saving results to JSON +czkawka_cli dup -d /home/user --json-progress -N --compact-file-to-save results.json 2>progress.jsonl ``` ## LICENSE diff --git a/czkawka_cli/src/commands.rs b/czkawka_cli/src/commands.rs index 8980629c1..9c73b5913 100644 --- a/czkawka_cli/src/commands.rs +++ b/czkawka_cli/src/commands.rs @@ -115,6 +115,27 @@ pub enum Commands { ExifRemover(ExifRemoverArgs), } +impl Commands { + pub fn get_json_progress(&self) -> bool { + match self { + Commands::Duplicates(a) => a.common_cli_items.json_progress, + Commands::EmptyFolders(a) => a.common_cli_items.json_progress, + Commands::BiggestFiles(a) => a.common_cli_items.json_progress, + Commands::EmptyFiles(a) => a.common_cli_items.json_progress, + Commands::Temporary(a) => a.common_cli_items.json_progress, + Commands::SimilarImages(a) => a.common_cli_items.json_progress, + Commands::SameMusic(a) => a.common_cli_items.json_progress, + Commands::InvalidSymlinks(a) => a.common_cli_items.json_progress, + Commands::BrokenFiles(a) => a.common_cli_items.json_progress, + Commands::SimilarVideos(a) => a.common_cli_items.json_progress, + Commands::BadExtensions(a) => a.common_cli_items.json_progress, + Commands::BadNames(a) => a.common_cli_items.json_progress, + Commands::VideoOptimizer(a) => a.common_cli_items.json_progress, + Commands::ExifRemover(a) => a.common_cli_items.json_progress, + } + } +} + #[derive(Debug, clap::Args)] pub struct DuplicatesArgs { #[clap(flatten)] @@ -848,6 +869,14 @@ pub struct CommonCliItems { long_help = "Disables the cache system. This will make scanning slower but ensures fresh results without cached data." )] pub disable_cache: bool, + #[clap( + long, + help = "Output progress as JSON lines to stderr", + long_help = "Outputs progress data as JSON lines to stderr for machine consumption. \ + Each line is a JSON object with fields: sstage, current_stage_idx, max_stage_idx, \ + entries_checked, entries_to_check, bytes_checked, bytes_to_check, tool_type." + )] + pub json_progress: bool, } #[derive(Debug, clap::Args, Clone, Copy)] diff --git a/czkawka_cli/src/main.rs b/czkawka_cli/src/main.rs index 871183662..b8b9710e4 100644 --- a/czkawka_cli/src/main.rs +++ b/czkawka_cli/src/main.rs @@ -36,7 +36,7 @@ use crate::commands::{ Args, BadExtensionsArgs, BadNamesArgs, BiggestFilesArgs, BrokenFilesArgs, CommonCliItems, DMethod, DuplicatesArgs, EmptyFilesArgs, EmptyFoldersArgs, ExifRemoverArgs, InvalidSymlinksArgs, SDMethod, SameMusicArgs, SimilarImagesArgs, SimilarVideosArgs, TemporaryArgs, VideoOptimizerArgs, }; -use crate::progress::connect_progress; +use crate::progress::{connect_progress, connect_progress_json}; mod commands; mod progress; @@ -65,6 +65,7 @@ fn main() { debug!("Running command - {command:?}"); } + let json_progress = command.get_json_progress(); let (progress_sender, progress_receiver): (Sender, Receiver) = unbounded(); let stop_flag = Arc::new(AtomicBool::new(false)); let store_flag_cloned = stop_flag.clone(); @@ -98,7 +99,11 @@ fn main() { }) .expect("Error setting Ctrl-C handler"); - connect_progress(&progress_receiver); + if json_progress { + connect_progress_json(&progress_receiver); + } else { + connect_progress(&progress_receiver); + } let cli_output = calculate_thread.join().expect("Failed to join calculation thread"); diff --git a/czkawka_cli/src/progress.rs b/czkawka_cli/src/progress.rs index 72952de01..6156e3daa 100644 --- a/czkawka_cli/src/progress.rs +++ b/czkawka_cli/src/progress.rs @@ -1,3 +1,4 @@ +use std::io::Write; use std::time::Duration; use crossbeam_channel::Receiver; @@ -97,6 +98,39 @@ pub(crate) fn get_progress_message(progress_data: &ProgressData) -> String { .to_string() } +/// Output progress data as JSON lines to stderr for machine consumption. +/// Each line is a complete JSON object that can be parsed independently. +pub(crate) fn connect_progress_json(progress_receiver: &Receiver) { + let stderr = std::io::stderr(); + let mut stderr = stderr.lock(); + while let Ok(progress_data) = progress_receiver.recv() { + // Build a JSON object with human-readable stage name included + let stage_name = if progress_data.sstage == CurrentStage::CollectingFiles { + if progress_data.tool_type == ToolType::EmptyFolders { + "Collecting folders".to_string() + } else { + "Collecting files".to_string() + } + } else if progress_data.sstage.check_if_loading_saving_cache() { + if progress_data.sstage.check_if_loading_cache() { + "Loading cache".to_string() + } else { + "Saving cache".to_string() + } + } else { + get_progress_message(&progress_data) + }; + + if let Ok(json) = serde_json::to_string(&progress_data) { + // Wrap in an object that includes the human-readable stage name + let _ = writeln!( + stderr, + "{{\"progress\":{json},\"stage_name\":\"{stage_name}\"}}" + ); + } + } +} + pub(crate) fn get_progress_bar_for_collect_files() -> ProgressBar { let pb = ProgressBar::new_spinner(); pb.enable_steady_tick(Duration::from_millis(120)); diff --git a/czkawka_core/src/common/model.rs b/czkawka_core/src/common/model.rs index 41919a49d..622a70727 100644 --- a/czkawka_core/src/common/model.rs +++ b/czkawka_core/src/common/model.rs @@ -6,7 +6,7 @@ use xxhash_rust::xxh3::Xxh3; use crate::common::traits::ResultEntry; use crate::tools::duplicate::MyHasher; -#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)] +#[derive(Debug, PartialEq, Eq, Clone, Copy, Default, Serialize, Deserialize)] pub enum ToolType { Duplicate, EmptyFolders, diff --git a/czkawka_core/src/common/progress_data.rs b/czkawka_core/src/common/progress_data.rs index d23abe7e8..d98372c63 100644 --- a/czkawka_core/src/common/progress_data.rs +++ b/czkawka_core/src/common/progress_data.rs @@ -1,4 +1,5 @@ use log::error; +use serde::Serialize; use crate::common::model::{CheckingMethod, ToolType}; // Empty files @@ -66,7 +67,7 @@ use crate::common::model::{CheckingMethod, ToolType}; // Deleting files // Renaming files -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Serialize)] pub struct ProgressData { pub sstage: CurrentStage, pub checking_method: CheckingMethod, @@ -95,7 +96,7 @@ impl ProgressData { } } -#[derive(Debug, Clone, Copy, Eq, PartialEq)] +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)] pub enum CurrentStage { DeletingFiles, RenamingFiles, diff --git a/czkawka_pyside6/README.md b/czkawka_pyside6/README.md new file mode 100644 index 000000000..d4487aaed --- /dev/null +++ b/czkawka_pyside6/README.md @@ -0,0 +1,115 @@ +# Czkawka PySide6 + +A Qt 6 / PySide6 GUI frontend for Czkawka, with feature parity with the Krokiet (Slint) interface. + +This frontend uses `czkawka_cli` as its backend, communicating via JSON output for results and `--json-progress` for real-time progress data. + +## Features + +All 14 scanning tools are supported: + +- Duplicate Files (by hash, size, name, or size+name) +- Empty Folders / Empty Files +- Big Files (biggest or smallest) +- Temporary Files +- Similar Images (with configurable hash algorithm, size, similarity) +- Similar Videos (with crop detection, skip forward, duration) +- Similar Music (by tags or audio fingerprint) +- Invalid Symlinks +- Broken Files (audio, PDF, archive, image, video) +- Bad Extensions +- Bad Names (uppercase, emoji, spaces, non-ASCII, restricted charset) +- EXIF Remover +- Video Optimizer (crop black bars / transcode) + +### GUI features + +- **Dark theme** with full Qt stylesheet +- **Two-bar progress display** - current stage + overall progress, matching the Slint frontend + - Real-time entry and byte counts (e.g., "Calculating hashes: 15,000/25,000 (500 MB/1 GB)") + - File collection estimation using cached counts from previous scans + - Elapsed time display + - Stage step indicators +- **Project icons** - uses the same SVG icons as the Krokiet interface +- **Image preview panel** for duplicate/similar image results +- **Grouped results view** with tree display for duplicate/similar file groups +- **Selection modes** - Select All/None, Invert, Biggest/Smallest, Newest/Oldest, Shortest/Longest Path +- **File actions** - Delete (with trash support), Move/Copy, Hardlink, Symlink, Rename, Clean EXIF +- **Per-tool settings** - all tool-specific options (hash type, similarity thresholds, etc.) +- **Global settings** - directories, filters, cache, thread count +- **Directory management** - included/excluded paths with add/remove buttons +- **Context menus** - right-click to open file or containing folder +- **Settings persistence** via JSON config files +- **Auto-detection** of `czkawka_cli` binary (checks PATH, cargo target directory, cargo metadata) + +## Requirements + +- Python 3.10+ +- PySide6 >= 6.6.0 +- `czkawka_cli` binary (installed or in PATH) +- Optional: `send2trash` (for trash support on Linux) +- Optional: `Pillow` (for EXIF cleaning fallback) + +## Installation + +### 1. Install czkawka_cli + +```shell +# From the project root +cargo install --path czkawka_cli + +# Or build it +cargo build --release -p czkawka_cli +``` + +### 2. Install Python dependencies + +```shell +cd czkawka_pyside6 +pip install -r requirements.txt +``` + +### 3. Run + +```shell +python main.py +``` + +The application will auto-detect the `czkawka_cli` binary. If it can't find it, configure the path in Settings. + +## Architecture + +``` +czkawka_pyside6/ +├── main.py # Entry point +├── requirements.txt # Python dependencies +├── app/ +│ ├── main_window.py # Main window with all panels +│ ├── left_panel.py # Tool selection sidebar (14 tools) +│ ├── results_view.py # Results tree with grouping, selection, sorting +│ ├── action_buttons.py # Scan/Stop/Delete/Move/Save/Sort buttons with icons +│ ├── tool_settings.py # Per-tool settings (9 tool panels) +│ ├── settings_panel.py # Global settings (General/Directories/Filters/Preview) +│ ├── progress_widget.py # Two-bar progress: current stage + overall +│ ├── preview_panel.py # Image preview panel +│ ├── bottom_panel.py # Directory management + error display +│ ├── backend.py # CLI subprocess interface with JSON progress parsing +│ ├── models.py # Data models, enums, column definitions +│ ├── state.py # Application state with Qt signals +│ ├── icons.py # SVG icon resources from Krokiet icon set +│ └── dialogs/ # Delete, Move, Select, Sort, Save, Rename, About +``` + +### How it works + +1. **Scanning**: The app spawns `czkawka_cli` as a subprocess with `--compact-file-to-save` for JSON results and `--json-progress` for real-time progress data on stderr. + +2. **Progress**: JSON lines on stderr provide `ProgressData` with stage index, entry counts, byte counts — the same data the Slint frontend gets via crossbeam channels. The progress widget displays two bars (current stage and overall) with percentage, counts, and elapsed time. + +3. **Results**: JSON results are parsed and displayed in a tree view with group headers for duplicate/similar file tools. + +4. **File operations**: Delete, move, hardlink, symlink, and rename operations are performed directly in Python. EXIF cleaning and extension/name fixing use `czkawka_cli` subcommands. + +## LICENSE + +MIT diff --git a/czkawka_pyside6/app/__init__.py b/czkawka_pyside6/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/czkawka_pyside6/app/action_buttons.py b/czkawka_pyside6/app/action_buttons.py new file mode 100644 index 000000000..36e928a26 --- /dev/null +++ b/czkawka_pyside6/app/action_buttons.py @@ -0,0 +1,196 @@ +from PySide6.QtWidgets import ( + QWidget, QHBoxLayout, QPushButton, QSizePolicy +) +from PySide6.QtCore import Signal, QSize + +from .models import ActiveTab, GROUPED_TABS +from .icons import ( + icon_search, icon_stop, icon_select, icon_delete, icon_move, + icon_save, icon_sort, icon_hardlink, icon_symlink, icon_rename, + icon_clean, icon_optimize, +) + +ICON_SIZE = QSize(18, 18) + + +class ActionButtons(QWidget): + """Action buttons bar: Scan, Stop, Select, Delete, Move, Save, Sort, etc.""" + + scan_clicked = Signal() + stop_clicked = Signal() + select_clicked = Signal() + delete_clicked = Signal() + move_clicked = Signal() + save_clicked = Signal() + sort_clicked = Signal() + hardlink_clicked = Signal() + symlink_clicked = Signal() + rename_clicked = Signal() + clean_exif_clicked = Signal() + optimize_video_clicked = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self._active_tab = ActiveTab.DUPLICATE_FILES + self._scanning = False + self._has_results = False + self._has_selection = False + self._setup_ui() + + def _setup_ui(self): + layout = QHBoxLayout(self) + layout.setContentsMargins(4, 4, 4, 4) + layout.setSpacing(4) + + # Scan button + self._scan_btn = QPushButton(icon_search(18), " Scan") + self._scan_btn.setIconSize(ICON_SIZE) + self._scan_btn.setMinimumWidth(90) + self._scan_btn.setStyleSheet( + "QPushButton { background-color: #2d5a27; color: white; font-weight: bold; padding: 6px 16px; }" + "QPushButton:hover { background-color: #3a7a34; }" + "QPushButton:disabled { background-color: #444; color: #888; }" + ) + self._scan_btn.clicked.connect(self.scan_clicked.emit) + layout.addWidget(self._scan_btn) + + # Stop button + self._stop_btn = QPushButton(icon_stop(18), " Stop") + self._stop_btn.setIconSize(ICON_SIZE) + self._stop_btn.setMinimumWidth(80) + self._stop_btn.setStyleSheet( + "QPushButton { background-color: #8a2222; color: white; font-weight: bold; padding: 6px 16px; }" + "QPushButton:hover { background-color: #aa3333; }" + ) + self._stop_btn.clicked.connect(self.stop_clicked.emit) + self._stop_btn.setVisible(False) + layout.addWidget(self._stop_btn) + + # Spacer + spacer = QWidget() + spacer.setFixedWidth(10) + layout.addWidget(spacer) + + # Select button + self._select_btn = QPushButton(icon_select(18), " Select") + self._select_btn.setIconSize(ICON_SIZE) + self._select_btn.clicked.connect(self.select_clicked.emit) + layout.addWidget(self._select_btn) + + # Delete button + self._delete_btn = QPushButton(icon_delete(18), " Delete") + self._delete_btn.setIconSize(ICON_SIZE) + self._delete_btn.setStyleSheet( + "QPushButton { background-color: #8a2222; color: white; padding: 6px 12px; }" + "QPushButton:hover { background-color: #aa3333; }" + "QPushButton:disabled { background-color: #444; color: #888; }" + ) + self._delete_btn.clicked.connect(self.delete_clicked.emit) + layout.addWidget(self._delete_btn) + + # Move button + self._move_btn = QPushButton(icon_move(18), " Move") + self._move_btn.setIconSize(ICON_SIZE) + self._move_btn.clicked.connect(self.move_clicked.emit) + layout.addWidget(self._move_btn) + + # Save button + self._save_btn = QPushButton(icon_save(18), " Save") + self._save_btn.setIconSize(ICON_SIZE) + self._save_btn.clicked.connect(self.save_clicked.emit) + layout.addWidget(self._save_btn) + + # Sort button + self._sort_btn = QPushButton(icon_sort(18), " Sort") + self._sort_btn.setIconSize(ICON_SIZE) + self._sort_btn.clicked.connect(self.sort_clicked.emit) + layout.addWidget(self._sort_btn) + + # Hardlink button (grouped tools only) + self._hardlink_btn = QPushButton(icon_hardlink(18), " Hardlink") + self._hardlink_btn.setIconSize(ICON_SIZE) + self._hardlink_btn.clicked.connect(self.hardlink_clicked.emit) + layout.addWidget(self._hardlink_btn) + + # Symlink button (grouped tools only) + self._symlink_btn = QPushButton(icon_symlink(18), " Symlink") + self._symlink_btn.setIconSize(ICON_SIZE) + self._symlink_btn.clicked.connect(self.symlink_clicked.emit) + layout.addWidget(self._symlink_btn) + + # Rename button (bad extensions / bad names) + self._rename_btn = QPushButton(icon_rename(18), " Rename") + self._rename_btn.setIconSize(ICON_SIZE) + self._rename_btn.clicked.connect(self.rename_clicked.emit) + layout.addWidget(self._rename_btn) + + # Clean EXIF button + self._clean_exif_btn = QPushButton(icon_clean(18), " Clean EXIF") + self._clean_exif_btn.setIconSize(ICON_SIZE) + self._clean_exif_btn.clicked.connect(self.clean_exif_clicked.emit) + layout.addWidget(self._clean_exif_btn) + + # Optimize Video button + self._optimize_btn = QPushButton(icon_optimize(18), " Optimize") + self._optimize_btn.setIconSize(ICON_SIZE) + self._optimize_btn.clicked.connect(self.optimize_video_clicked.emit) + layout.addWidget(self._optimize_btn) + + # Stretch at the end + layout.addStretch() + + self._update_visibility() + + def set_active_tab(self, tab: ActiveTab): + self._active_tab = tab + self._update_visibility() + + def set_scanning(self, scanning: bool): + self._scanning = scanning + self._scan_btn.setVisible(not scanning) + self._stop_btn.setVisible(scanning) + self._update_enabled() + + def set_has_results(self, has_results: bool): + self._has_results = has_results + self._update_enabled() + + def set_has_selection(self, has_selection: bool): + self._has_selection = has_selection + self._update_enabled() + + def _update_visibility(self): + tab = self._active_tab + is_grouped = tab in GROUPED_TABS + + # Always visible + self._select_btn.setVisible(True) + self._delete_btn.setVisible(True) + self._move_btn.setVisible(True) + self._save_btn.setVisible(True) + self._sort_btn.setVisible(True) + + # Conditional buttons + self._hardlink_btn.setVisible(is_grouped) + self._symlink_btn.setVisible(is_grouped) + self._rename_btn.setVisible(tab in (ActiveTab.BAD_EXTENSIONS, ActiveTab.BAD_NAMES)) + self._clean_exif_btn.setVisible(tab == ActiveTab.EXIF_REMOVER) + self._optimize_btn.setVisible(tab == ActiveTab.VIDEO_OPTIMIZER) + + self._update_enabled() + + def _update_enabled(self): + has_data = self._has_results and not self._scanning + has_sel = self._has_selection and not self._scanning + + self._scan_btn.setEnabled(not self._scanning) + self._select_btn.setEnabled(has_data) + self._delete_btn.setEnabled(has_sel) + self._move_btn.setEnabled(has_sel) + self._save_btn.setEnabled(has_data) + self._sort_btn.setEnabled(has_data) + self._hardlink_btn.setEnabled(has_sel) + self._symlink_btn.setEnabled(has_sel) + self._rename_btn.setEnabled(has_sel) + self._clean_exif_btn.setEnabled(has_sel) + self._optimize_btn.setEnabled(has_sel) diff --git a/czkawka_pyside6/app/backend.py b/czkawka_pyside6/app/backend.py new file mode 100644 index 000000000..3d922c176 --- /dev/null +++ b/czkawka_pyside6/app/backend.py @@ -0,0 +1,621 @@ +import json +import os +import subprocess +import tempfile +from pathlib import Path +from typing import Optional + +from PySide6.QtCore import QObject, QThread, Signal + +from .models import ( + ActiveTab, AppSettings, ToolSettings, ResultEntry, ScanProgress, + TAB_TO_CLI_COMMAND, GROUPED_TABS, CheckingMethod, MusicSearchMethod, +) + + +class ScanWorker(QObject): + """Worker that runs czkawka_cli in a background thread.""" + finished = Signal(object, list) # (ActiveTab, results) + progress = Signal(object) # ScanProgress + error = Signal(str) + + def __init__(self, tab: ActiveTab, app_settings: AppSettings, + tool_settings: ToolSettings): + super().__init__() + self.tab = tab + self.app_settings = app_settings + self.tool_settings = tool_settings + self._process: Optional[subprocess.Popen] = None + self._cancelled = False + + def run(self): + try: + cmd = self._build_command() + if not cmd: + self.error.emit(f"No CLI command for tab {self.tab}") + return + + self.progress.emit(ScanProgress( + step_name="Collecting files...", current=0, total=0 + )) + + with tempfile.NamedTemporaryFile(suffix=".json", delete=False, mode="w") as f: + json_output_path = f.name + + # Add JSON output flag (use long form to avoid conflict with -C in broken files) + cmd.extend(["--compact-file-to-save", json_output_path]) + # Enable JSON progress on stderr, suppress text output + cmd.extend(["--json-progress", "-N", "-M"]) + + self._process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + # Read stderr line-by-line for JSON progress data + self._monitor_process_json(json_output_path) + + if self._cancelled: + self._cleanup(json_output_path) + return + + # Check for CLI errors + returncode = self._process.returncode + if returncode is not None and returncode != 0 and returncode != 11: + error_msg = f"czkawka_cli exited with code {returncode}" + self.error.emit(error_msg) + self._cleanup(json_output_path) + return + + # Parse results + self.progress.emit(ScanProgress( + step_name="Loading results...", current=0, total=0 + )) + results = self._parse_results(json_output_path) + self.finished.emit(self.tab, results) + self._cleanup(json_output_path) + + except FileNotFoundError: + self.error.emit( + f"czkawka_cli not found at '{self.app_settings.czkawka_cli_path}'. " + "Please install czkawka_cli or set the correct path in settings." + ) + except Exception as e: + self.error.emit(str(e)) + + def cancel(self): + self._cancelled = True + if self._process and self._process.poll() is None: + self._process.terminate() + try: + self._process.wait(timeout=3) + except subprocess.TimeoutExpired: + self._process.kill() + self._process.wait() + + def _monitor_process_json(self, json_path: str): + """Read JSON progress lines from stderr in real-time.""" + import time + + while self._process.poll() is None: + if self._cancelled: + return + + line = self._process.stderr.readline() + if not line: + time.sleep(0.05) + continue + + line = line.strip() + if not line: + continue + + try: + data = json.loads(line) + progress = data.get("progress", {}) + stage_name = data.get("stage_name", "Processing...") + + entries_checked = progress.get("entries_checked", 0) + entries_to_check = progress.get("entries_to_check", 0) + bytes_checked = progress.get("bytes_checked", 0) + bytes_to_check = progress.get("bytes_to_check", 0) + current_stage_idx = progress.get("current_stage_idx", 0) + max_stage_idx = progress.get("max_stage_idx", 0) + + self.progress.emit(ScanProgress( + step_name=stage_name, + current=0, + total=0, + current_size=bytes_checked, + stage_name=stage_name, + current_stage_idx=current_stage_idx, + max_stage_idx=max_stage_idx, + entries_checked=entries_checked, + entries_to_check=entries_to_check, + bytes_checked=bytes_checked, + bytes_to_check=bytes_to_check, + )) + except (json.JSONDecodeError, KeyError, TypeError): + continue + + # Drain remaining stderr + remaining = self._process.stderr.read() + if remaining: + for line in remaining.strip().split("\n"): + pass # Final lines already processed + + def _cleanup(self, path: str): + try: + os.unlink(path) + except OSError: + pass + + def _build_command(self) -> list[str]: + s = self.app_settings + ts = self.tool_settings + cli = s.czkawka_cli_path + subcmd = TAB_TO_CLI_COMMAND.get(self.tab) + if not subcmd: + return [] + + # Handle video-optimizer subcommands + if self.tab == ActiveTab.VIDEO_OPTIMIZER: + if ts.video_opt_mode == "crop": + cmd = [cli, "video-optimizer", "crop"] + else: + cmd = [cli, "video-optimizer", "transcode"] + else: + cmd = [cli, subcmd] + + # Common args + if s.included_paths: + cmd.extend(["-d", ",".join(s.included_paths)]) + if s.excluded_paths: + cmd.extend(["-e", ",".join(s.excluded_paths)]) + if s.excluded_items: + cmd.extend(["-E", s.excluded_items]) + if s.allowed_extensions: + cmd.extend(["-x", s.allowed_extensions]) + if s.excluded_extensions: + cmd.extend(["-P", s.excluded_extensions]) + if not s.recursive_search: + cmd.append("-R") + if not s.use_cache: + cmd.append("-H") + if s.thread_number > 0: + cmd.extend(["-T", str(s.thread_number)]) + + # Tool-specific args + if self.tab == ActiveTab.DUPLICATE_FILES: + cmd.extend(["-s", ts.dup_check_method.value]) + cmd.extend(["-t", ts.dup_hash_type.value]) + if ts.dup_min_size: + cmd.extend(["-m", ts.dup_min_size]) + if ts.dup_max_size: + cmd.extend(["-i", ts.dup_max_size]) + if ts.dup_name_case_sensitive: + cmd.append("-l") + if not s.hide_hard_links: + cmd.append("-L") + if ts.dup_use_prehash: + cmd.append("-u") + + elif self.tab == ActiveTab.SIMILAR_IMAGES: + cmd.extend(["-g", ts.img_hash_alg.value]) + cmd.extend(["-z", ts.img_filter.value]) + cmd.extend(["-c", str(ts.img_hash_size)]) + cmd.extend(["-s", str(ts.img_max_difference)]) + if ts.img_ignore_same_size: + cmd.append("-J") + + elif self.tab == ActiveTab.SIMILAR_VIDEOS: + cmd.extend(["-t", str(ts.vid_max_difference)]) + cmd.extend(["-U", str(ts.vid_skip_forward)]) + cmd.extend(["-B", ts.vid_crop_detect.value]) + cmd.extend(["-A", str(ts.vid_duration)]) + if ts.vid_ignore_same_size: + cmd.append("-J") + + elif self.tab == ActiveTab.SIMILAR_MUSIC: + cmd.extend(["-s", ts.music_search_method.value]) + if ts.music_search_method == MusicSearchMethod.TAGS: + tags = [] + if ts.music_title: tags.append("track_title") + if ts.music_artist: tags.append("track_artist") + if ts.music_bitrate: tags.append("bitrate") + if ts.music_genre: tags.append("genre") + if ts.music_year: tags.append("year") + if ts.music_length: tags.append("length") + if tags: + cmd.extend(["-z", ",".join(tags)]) + if ts.music_approximate: + cmd.append("-a") + else: + cmd.extend(["-Y", str(ts.music_max_difference)]) + + elif self.tab == ActiveTab.BIG_FILES: + cmd.extend(["-n", str(ts.big_files_number)]) + if ts.big_files_mode == "smallest": + cmd.append("-J") + + elif self.tab == ActiveTab.BROKEN_FILES: + types = [] + if ts.broken_audio: types.append("AUDIO") + if ts.broken_pdf: types.append("PDF") + if ts.broken_archive: types.append("ARCHIVE") + if ts.broken_image: types.append("IMAGE") + if ts.broken_video: types.append("VIDEO") + if types: + cmd.extend(["-C", ",".join(types)]) + + elif self.tab == ActiveTab.BAD_NAMES: + if ts.bad_names_uppercase_ext: + cmd.append("-u") + if ts.bad_names_emoji: + cmd.append("-j") + if ts.bad_names_space: + cmd.append("-w") + if ts.bad_names_non_ascii: + cmd.append("-n") + if ts.bad_names_restricted_charset: + cmd.extend(["-r", ts.bad_names_restricted_charset]) + if ts.bad_names_remove_duplicated: + cmd.append("-a") + + elif self.tab == ActiveTab.VIDEO_OPTIMIZER: + if ts.video_opt_mode == "crop": + cmd.extend(["-m", ts.video_crop_mechanism.value]) + cmd.extend(["-k", str(ts.video_black_pixel_threshold)]) + cmd.extend(["-b", str(ts.video_black_bar_percentage)]) + cmd.extend(["-s", str(ts.video_max_samples)]) + cmd.extend(["-z", str(ts.video_min_crop_size)]) + else: + if ts.video_excluded_codecs: + cmd.extend(["-c", ts.video_excluded_codecs]) + cmd.extend(["--target-codec", ts.video_codec.value]) + cmd.extend(["--quality", str(ts.video_quality)]) + if ts.video_fail_if_bigger: + cmd.append("--fail-if-not-smaller") + + return cmd + + def _parse_results(self, json_path: str) -> list[ResultEntry]: + try: + with open(json_path) as f: + data = json.load(f) + except (json.JSONDecodeError, FileNotFoundError, OSError): + return [] + + if not data: + return [] + + results = [] + is_grouped = self.tab in GROUPED_TABS + + if is_grouped: + # Grouped tools return either: + # dict {size_key: [[entry, ...], [entry, ...]], ...} (duplicates by hash) + # list [[entry, ...], [entry, ...]] (similar images/videos/music) + all_groups = [] + if isinstance(data, dict): + # Dict format: flatten all groups from all size buckets + for size_key, groups_for_size in data.items(): + if isinstance(groups_for_size, list): + for group in groups_for_size: + if isinstance(group, list) and len(group) > 0: + all_groups.append(group) + elif isinstance(data, list): + for item in data: + if isinstance(item, list) and len(item) > 0: + all_groups.append(item) + + for group_id, group in enumerate(all_groups): + # Compute total size for the group header + total_size = sum(e.get("size", 0) for e in group if isinstance(e, dict)) + header_text = ( + f"Group {group_id + 1} ({len(group)} files, " + f"{self._format_size(total_size)})" + ) + header = ResultEntry( + values={"__header": header_text}, + header_row=True, + group_id=group_id, + ) + results.append(header) + for entry in group: + if isinstance(entry, dict): + result = self._parse_entry(entry, group_id) + results.append(result) + else: + # Flat tools return a list of entries + if isinstance(data, list): + for i, entry in enumerate(data): + if isinstance(entry, dict): + result = self._parse_entry(entry, 0) + results.append(result) + elif isinstance(data, dict): + # Some tools might also use dict format + for key, entries in data.items(): + if isinstance(entries, list): + for entry in entries: + if isinstance(entry, dict): + result = self._parse_entry(entry, 0) + results.append(result) + + return results + + def _parse_entry(self, entry: dict, group_id: int) -> ResultEntry: + path = entry.get("path", "") + p = Path(path) + values = { + "File Name": p.name, + "Folder Name": p.name, + "Symlink Name": p.name, + "Path": str(p.parent), + "Symlink Path": str(p.parent), + "Size": self._format_size(entry.get("size", 0)), + "Modification Date": self._format_date(entry.get("modified_date", 0)), + "Hash": entry.get("hash", ""), + "Similarity": str(entry.get("similarity", "")), + "Resolution": entry.get("dimensions", ""), + "Title": entry.get("title", ""), + "Artist": entry.get("artist", ""), + "Year": entry.get("year", ""), + "Bitrate": str(entry.get("bitrate", "")), + "Genre": entry.get("genre", ""), + "Length": entry.get("length", ""), + "Destination Path": entry.get("destination_path", ""), + "Type of Error": entry.get("error_string", entry.get("type_of_error", "")), + "Error Type": entry.get("error_string", entry.get("error_type", "")), + "Current Extension": entry.get("current_extension", ""), + "Proper Extension": entry.get("proper_extension", entry.get("proper_extensions", "")), + "Codec": entry.get("codec", ""), + } + # Store full path for file operations + values["__full_path"] = path + values["__size_bytes"] = entry.get("size", 0) + values["__modified_date_ts"] = entry.get("modified_date", 0) + + return ResultEntry(values=values, group_id=group_id) + + @staticmethod + def _format_size(size_bytes: int) -> str: + if size_bytes == 0: + return "0 B" + units = ["B", "KB", "MB", "GB", "TB"] + i = 0 + size = float(size_bytes) + while size >= 1024 and i < len(units) - 1: + size /= 1024 + i += 1 + return f"{size:.1f} {units[i]}" if i > 0 else f"{int(size)} B" + + @staticmethod + def _format_date(timestamp: int) -> str: + if timestamp == 0: + return "" + from datetime import datetime + try: + return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S") + except (ValueError, OSError): + return str(timestamp) + + +class ScanRunner(QObject): + """Manages scan execution in a background thread.""" + finished = Signal(object, list) + progress = Signal(object) + error = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self._thread: Optional[QThread] = None + self._worker: Optional[ScanWorker] = None + + def start_scan(self, tab: ActiveTab, app_settings: AppSettings, + tool_settings: ToolSettings): + if self._thread and self._thread.isRunning(): + return + + self._thread = QThread() + self._worker = ScanWorker(tab, app_settings, tool_settings) + self._worker.moveToThread(self._thread) + + self._thread.started.connect(self._worker.run) + self._worker.finished.connect(self._on_finished) + self._worker.progress.connect(self.progress.emit) + self._worker.error.connect(self._on_error) + + self._thread.start() + + def stop_scan(self): + if self._worker: + self._worker.cancel() + # Wait briefly for the thread to finish after killing the process + if self._thread and self._thread.isRunning(): + self._thread.quit() + if not self._thread.wait(5000): + self._thread.terminate() + self._thread.wait() + self._thread = None + self._worker = None + # Emit an empty result to signal completion + self.finished.emit(ActiveTab.DUPLICATE_FILES, []) + + def _on_finished(self, tab, results): + self.finished.emit(tab, results) + self._cleanup() + + def _on_error(self, msg): + self.error.emit(msg) + self._cleanup() + + def _cleanup(self): + if self._thread: + self._thread.quit() + self._thread.wait(5000) + self._thread = None + self._worker = None + + +class FileOperations: + """File operations: delete, move, hardlink, symlink, rename.""" + + @staticmethod + def delete_files(entries: list[ResultEntry], move_to_trash: bool = True) -> tuple[int, list[str]]: + deleted = 0 + errors = [] + for entry in entries: + path = entry.values.get("__full_path", "") + if not path: + continue + try: + p = Path(path) + if not p.exists(): + errors.append(f"File not found: {path}") + continue + if move_to_trash: + try: + from send2trash import send2trash + send2trash(str(p)) + except ImportError: + p.unlink() + else: + if p.is_dir(): + import shutil + shutil.rmtree(p) + else: + p.unlink() + deleted += 1 + except OSError as e: + errors.append(f"Error deleting {path}: {e}") + return deleted, errors + + @staticmethod + def move_files(entries: list[ResultEntry], destination: str, + preserve_structure: bool = False, copy_mode: bool = False) -> tuple[int, list[str]]: + import shutil + moved = 0 + errors = [] + dest = Path(destination) + dest.mkdir(parents=True, exist_ok=True) + + for entry in entries: + src_path = entry.values.get("__full_path", "") + if not src_path: + continue + try: + src = Path(src_path) + if preserve_structure: + # Keep relative directory structure + rel = src.parent + target_dir = dest / rel.relative_to(rel.anchor) + target_dir.mkdir(parents=True, exist_ok=True) + target = target_dir / src.name + else: + target = dest / src.name + + # Handle name conflicts + if target.exists(): + stem = target.stem + suffix = target.suffix + counter = 1 + while target.exists(): + target = target.parent / f"{stem}_{counter}{suffix}" + counter += 1 + + if copy_mode: + shutil.copy2(str(src), str(target)) + else: + shutil.move(str(src), str(target)) + moved += 1 + except OSError as e: + errors.append(f"Error moving {src_path}: {e}") + return moved, errors + + @staticmethod + def create_hardlinks(entries: list[ResultEntry], reference_path: str) -> tuple[int, list[str]]: + created = 0 + errors = [] + for entry in entries: + path = entry.values.get("__full_path", "") + if not path or path == reference_path: + continue + try: + p = Path(path) + p.unlink() + os.link(reference_path, str(p)) + created += 1 + except OSError as e: + errors.append(f"Error creating hardlink for {path}: {e}") + return created, errors + + @staticmethod + def create_symlinks(entries: list[ResultEntry], reference_path: str) -> tuple[int, list[str]]: + created = 0 + errors = [] + for entry in entries: + path = entry.values.get("__full_path", "") + if not path or path == reference_path: + continue + try: + p = Path(path) + p.unlink() + os.symlink(reference_path, str(p)) + created += 1 + except OSError as e: + errors.append(f"Error creating symlink for {path}: {e}") + return created, errors + + @staticmethod + def fix_extensions(cli_path: str, app_settings, tool_settings) -> tuple[bool, str]: + """Run czkawka_cli ext with -F flag to fix extensions.""" + cmd = [cli_path, "ext", "-F"] + if app_settings.included_paths: + cmd.extend(["-d", ",".join(app_settings.included_paths)]) + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + return True, result.stdout + except Exception as e: + return False, str(e) + + @staticmethod + def fix_bad_names(cli_path: str, app_settings, tool_settings) -> tuple[bool, str]: + """Run czkawka_cli bad-names with -F flag to fix names.""" + cmd = [cli_path, "bad-names", "-F"] + if app_settings.included_paths: + cmd.extend(["-d", ",".join(app_settings.included_paths)]) + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + return True, result.stdout + except Exception as e: + return False, str(e) + + @staticmethod + def clean_exif(cli_path: str, entries: list[ResultEntry], + ignored_tags: str = "", overwrite: bool = True) -> tuple[int, list[str]]: + """Remove EXIF data from selected files.""" + cleaned = 0 + errors = [] + for entry in entries: + path = entry.values.get("__full_path", "") + if not path: + continue + try: + from PIL import Image + img = Image.open(path) + data = list(img.getdata()) + clean_img = Image.new(img.mode, img.size) + clean_img.putdata(data) + if overwrite: + clean_img.save(path) + else: + p = Path(path) + new_path = p.parent / f"{p.stem}_clean{p.suffix}" + clean_img.save(str(new_path)) + cleaned += 1 + except Exception as e: + errors.append(f"Error cleaning EXIF for {path}: {e}") + return cleaned, errors diff --git a/czkawka_pyside6/app/bottom_panel.py b/czkawka_pyside6/app/bottom_panel.py new file mode 100644 index 000000000..736a5cd04 --- /dev/null +++ b/czkawka_pyside6/app/bottom_panel.py @@ -0,0 +1,143 @@ +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QListWidget, + QPushButton, QFileDialog, QTextEdit, QStackedWidget, + QSizePolicy +) +from PySide6.QtCore import Signal, Qt + +from .models import AppSettings + + +class BottomPanel(QWidget): + """Bottom panel showing directories or error messages.""" + directories_changed = Signal() + + def __init__(self, settings: AppSettings, parent=None): + super().__init__(parent) + self._settings = settings + self.setMaximumHeight(200) + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(4, 2, 4, 2) + + self._stack = QStackedWidget() + + # Page 0: Directories view + dir_widget = QWidget() + dir_layout = QHBoxLayout(dir_widget) + dir_layout.setContentsMargins(0, 0, 0, 0) + + # Included directories + inc_widget = QWidget() + inc_layout = QVBoxLayout(inc_widget) + inc_layout.setContentsMargins(0, 0, 0, 0) + inc_layout.addWidget(QLabel("Included Directories:")) + + self._inc_list = QListWidget() + self._inc_list.setMaximumHeight(120) + for path in self._settings.included_paths: + self._inc_list.addItem(path) + inc_layout.addWidget(self._inc_list) + + inc_btns = QHBoxLayout() + add_btn = QPushButton("+") + add_btn.setFixedWidth(30) + add_btn.clicked.connect(self._add_included) + inc_btns.addWidget(add_btn) + rem_btn = QPushButton("-") + rem_btn.setFixedWidth(30) + rem_btn.clicked.connect(self._remove_included) + inc_btns.addWidget(rem_btn) + inc_btns.addStretch() + inc_layout.addLayout(inc_btns) + dir_layout.addWidget(inc_widget) + + # Excluded directories + exc_widget = QWidget() + exc_layout = QVBoxLayout(exc_widget) + exc_layout.setContentsMargins(0, 0, 0, 0) + exc_layout.addWidget(QLabel("Excluded Directories:")) + + self._exc_list = QListWidget() + self._exc_list.setMaximumHeight(120) + for path in self._settings.excluded_paths: + self._exc_list.addItem(path) + exc_layout.addWidget(self._exc_list) + + exc_btns = QHBoxLayout() + add_exc = QPushButton("+") + add_exc.setFixedWidth(30) + add_exc.clicked.connect(self._add_excluded) + exc_btns.addWidget(add_exc) + rem_exc = QPushButton("-") + rem_exc.setFixedWidth(30) + rem_exc.clicked.connect(self._remove_excluded) + exc_btns.addWidget(rem_exc) + exc_btns.addStretch() + exc_layout.addLayout(exc_btns) + dir_layout.addWidget(exc_widget) + + self._stack.addWidget(dir_widget) + + # Page 1: Text errors/info + self._text_area = QTextEdit() + self._text_area.setReadOnly(True) + self._text_area.setMaximumHeight(150) + self._stack.addWidget(self._text_area) + + layout.addWidget(self._stack) + + def show_directories(self): + self._stack.setCurrentIndex(0) + self.setVisible(True) + + def show_text(self): + self._stack.setCurrentIndex(1) + self.setVisible(True) + + def hide_panel(self): + self.setVisible(False) + + def set_text(self, text: str): + self._text_area.setPlainText(text) + + def append_text(self, text: str): + self._text_area.append(text) + + def _add_included(self): + path = QFileDialog.getExistingDirectory(self, "Select Directory to Include") + if path and path not in self._settings.included_paths: + self._settings.included_paths.append(path) + self._inc_list.addItem(path) + self.directories_changed.emit() + + def _remove_included(self): + row = self._inc_list.currentRow() + if row >= 0: + self._inc_list.takeItem(row) + self._settings.included_paths.pop(row) + self.directories_changed.emit() + + def _add_excluded(self): + path = QFileDialog.getExistingDirectory(self, "Select Directory to Exclude") + if path and path not in self._settings.excluded_paths: + self._settings.excluded_paths.append(path) + self._exc_list.addItem(path) + self.directories_changed.emit() + + def _remove_excluded(self): + row = self._exc_list.currentRow() + if row >= 0: + self._exc_list.takeItem(row) + self._settings.excluded_paths.pop(row) + self.directories_changed.emit() + + def refresh_lists(self): + self._inc_list.clear() + for path in self._settings.included_paths: + self._inc_list.addItem(path) + self._exc_list.clear() + for path in self._settings.excluded_paths: + self._exc_list.addItem(path) diff --git a/czkawka_pyside6/app/dialogs/__init__.py b/czkawka_pyside6/app/dialogs/__init__.py new file mode 100644 index 000000000..f1c6d549e --- /dev/null +++ b/czkawka_pyside6/app/dialogs/__init__.py @@ -0,0 +1,7 @@ +from .delete_dialog import DeleteDialog +from .move_dialog import MoveDialog +from .select_dialog import SelectDialog +from .sort_dialog import SortDialog +from .save_dialog import SaveDialog +from .rename_dialog import RenameDialog +from .about_dialog import AboutDialog diff --git a/czkawka_pyside6/app/dialogs/about_dialog.py b/czkawka_pyside6/app/dialogs/about_dialog.py new file mode 100644 index 000000000..54da0fa9f --- /dev/null +++ b/czkawka_pyside6/app/dialogs/about_dialog.py @@ -0,0 +1,78 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QDialogButtonBox +) +from PySide6.QtCore import Qt +from PySide6.QtGui import QPixmap + +from ..icons import app_logo_path + + +class AboutDialog(QDialog): + """About dialog showing application information.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("About Czkawka PySide6") + self.setMinimumWidth(480) + self.setMinimumHeight(420) + + layout = QVBoxLayout(self) + layout.setSpacing(6) + + # Logo + logo_path = app_logo_path() + if logo_path: + logo_label = QLabel() + pixmap = QPixmap(logo_path) + scaled = pixmap.scaledToHeight(100, Qt.SmoothTransformation) + logo_label.setPixmap(scaled) + logo_label.setAlignment(Qt.AlignCenter) + layout.addWidget(logo_label) + + title = QLabel("Czkawka") + title.setStyleSheet("font-size: 28px; font-weight: bold; padding: 4px;") + title.setAlignment(Qt.AlignCenter) + layout.addWidget(title) + + subtitle = QLabel("PySide6 / Qt6 Edition") + subtitle.setStyleSheet("font-size: 14px; color: #6fbf73; padding: 2px;") + subtitle.setAlignment(Qt.AlignCenter) + layout.addWidget(subtitle) + + version = QLabel("Version 11.0.1") + version.setStyleSheet("font-size: 11px; color: #888; padding: 2px;") + version.setAlignment(Qt.AlignCenter) + layout.addWidget(version) + + # Separator + sep = QLabel() + sep.setFixedHeight(1) + sep.setStyleSheet("background-color: #444; margin: 8px 40px;") + layout.addWidget(sep) + + desc = QLabel( + "Czkawka (tch-kav-ka) is a simple, fast and free app to remove\n" + "unnecessary files from your computer.\n\n" + "This PySide6/Qt interface uses the czkawka_cli backend\n" + "for all scanning and file operations.\n\n" + "Features:\n" + " - Find duplicate files (by hash, name, or size)\n" + " - Find empty files and folders\n" + " - Find similar images, videos, and music\n" + " - Find broken files and invalid symlinks\n" + " - Find files with bad extensions or names\n" + " - Remove EXIF metadata from images\n" + " - Optimize and crop videos\n\n" + "Licensed under MIT License\n" + "https://github.com/qarmin/czkawka" + ) + desc.setWordWrap(True) + desc.setAlignment(Qt.AlignCenter) + desc.setStyleSheet("padding: 6px 20px; line-height: 1.4;") + layout.addWidget(desc) + + layout.addStretch() + + buttons = QDialogButtonBox(QDialogButtonBox.Ok) + buttons.accepted.connect(self.accept) + layout.addWidget(buttons) diff --git a/czkawka_pyside6/app/dialogs/delete_dialog.py b/czkawka_pyside6/app/dialogs/delete_dialog.py new file mode 100644 index 000000000..89ce64bc6 --- /dev/null +++ b/czkawka_pyside6/app/dialogs/delete_dialog.py @@ -0,0 +1,51 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QDialogButtonBox, + QCheckBox, QHBoxLayout +) +from PySide6.QtCore import Qt + + +class DeleteDialog(QDialog): + """Confirmation dialog for deleting files.""" + + def __init__(self, count: int, move_to_trash: bool = True, parent=None): + super().__init__(parent) + self.setWindowTitle("Delete Files") + self.setMinimumWidth(400) + self._move_to_trash = move_to_trash + + layout = QVBoxLayout(self) + + # Warning + icon_label = QLabel() + icon_label.setStyleSheet("font-size: 36px;") + icon_label.setText("Warning") + icon_label.setAlignment(Qt.AlignCenter) + layout.addWidget(icon_label) + + msg = QLabel(f"Are you sure you want to delete {count} selected file(s)?") + msg.setStyleSheet("font-size: 14px; padding: 10px;") + msg.setAlignment(Qt.AlignCenter) + msg.setWordWrap(True) + layout.addWidget(msg) + + # Move to trash checkbox + self._trash_cb = QCheckBox("Move to trash instead of permanent delete") + self._trash_cb.setChecked(move_to_trash) + layout.addWidget(self._trash_cb) + + # Buttons + buttons = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel + ) + buttons.button(QDialogButtonBox.Ok).setText("Delete") + buttons.button(QDialogButtonBox.Ok).setStyleSheet( + "background-color: #8a2222; color: white; padding: 6px 20px;" + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + @property + def move_to_trash(self) -> bool: + return self._trash_cb.isChecked() diff --git a/czkawka_pyside6/app/dialogs/move_dialog.py b/czkawka_pyside6/app/dialogs/move_dialog.py new file mode 100644 index 000000000..6c754d21a --- /dev/null +++ b/czkawka_pyside6/app/dialogs/move_dialog.py @@ -0,0 +1,65 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QDialogButtonBox, + QCheckBox, QLineEdit, QHBoxLayout, QPushButton, + QFileDialog, QFormLayout +) +from PySide6.QtCore import Qt + + +class MoveDialog(QDialog): + """Dialog for moving/copying files to a destination.""" + + def __init__(self, count: int, parent=None): + super().__init__(parent) + self.setWindowTitle("Move/Copy Files") + self.setMinimumWidth(500) + + layout = QVBoxLayout(self) + + msg = QLabel(f"Move or copy {count} selected file(s) to:") + msg.setStyleSheet("font-size: 13px; padding: 6px;") + layout.addWidget(msg) + + # Destination path + dest_layout = QHBoxLayout() + self._dest_edit = QLineEdit() + self._dest_edit.setPlaceholderText("Select destination folder...") + dest_layout.addWidget(self._dest_edit) + + browse_btn = QPushButton("Browse") + browse_btn.clicked.connect(self._browse) + dest_layout.addWidget(browse_btn) + layout.addLayout(dest_layout) + + # Options + self._preserve_structure = QCheckBox("Preserve folder structure") + layout.addWidget(self._preserve_structure) + + self._copy_mode = QCheckBox("Copy instead of move") + layout.addWidget(self._copy_mode) + + # Buttons + buttons = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel + ) + buttons.button(QDialogButtonBox.Ok).setText("Move") + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def _browse(self): + path = QFileDialog.getExistingDirectory(self, "Select Destination") + if path: + self._dest_edit.setText(path) + + @property + def destination(self) -> str: + return self._dest_edit.text() + + @property + def preserve_structure(self) -> bool: + return self._preserve_structure.isChecked() + + @property + def copy_mode(self) -> bool: + return self._copy_mode.isChecked() diff --git a/czkawka_pyside6/app/dialogs/rename_dialog.py b/czkawka_pyside6/app/dialogs/rename_dialog.py new file mode 100644 index 000000000..4c6208ab1 --- /dev/null +++ b/czkawka_pyside6/app/dialogs/rename_dialog.py @@ -0,0 +1,34 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QDialogButtonBox +) + + +class RenameDialog(QDialog): + """Confirmation dialog for renaming files (fix extensions or bad names).""" + + def __init__(self, count: int, rename_type: str = "extensions", parent=None): + super().__init__(parent) + self.setWindowTitle(f"Fix {rename_type.title()}") + self.setMinimumWidth(400) + + layout = QVBoxLayout(self) + + if rename_type == "extensions": + msg = f"Fix extensions for {count} selected file(s)?\n\n" \ + "Files will be renamed to use their proper extensions." + else: + msg = f"Fix names for {count} selected file(s)?\n\n" \ + "Files with problematic names will be renamed." + + label = QLabel(msg) + label.setWordWrap(True) + label.setStyleSheet("font-size: 13px; padding: 10px;") + layout.addWidget(label) + + buttons = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel + ) + buttons.button(QDialogButtonBox.Ok).setText("Rename") + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) diff --git a/czkawka_pyside6/app/dialogs/save_dialog.py b/czkawka_pyside6/app/dialogs/save_dialog.py new file mode 100644 index 000000000..d8fa614e6 --- /dev/null +++ b/czkawka_pyside6/app/dialogs/save_dialog.py @@ -0,0 +1,48 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QDialogButtonBox, + QCheckBox, QFileDialog +) + + +class SaveDialog: + """Save results to file (uses native file dialog).""" + + @staticmethod + def save(parent, results: list, save_as_json: bool = False) -> bool: + if save_as_json: + filter_str = "JSON Files (*.json);;All Files (*)" + default_ext = ".json" + else: + filter_str = "Text Files (*.txt);;All Files (*)" + default_ext = ".txt" + + path, _ = QFileDialog.getSaveFileName( + parent, "Save Results", f"results{default_ext}", filter_str + ) + if not path: + return False + + try: + import json + if save_as_json: + data = [] + for entry in results: + if not entry.header_row: + # Filter out internal keys + values = {k: v for k, v in entry.values.items() + if not k.startswith("__")} + data.append(values) + with open(path, "w") as f: + json.dump(data, f, indent=2) + else: + with open(path, "w") as f: + for entry in results: + if entry.header_row: + f.write(f"\n--- {entry.values.get('__header', 'Group')} ---\n") + else: + path_val = entry.values.get("__full_path", "") + size = entry.values.get("Size", "") + f.write(f"{path_val}\t{size}\n") + return True + except OSError: + return False diff --git a/czkawka_pyside6/app/dialogs/select_dialog.py b/czkawka_pyside6/app/dialogs/select_dialog.py new file mode 100644 index 000000000..58ea94a11 --- /dev/null +++ b/czkawka_pyside6/app/dialogs/select_dialog.py @@ -0,0 +1,48 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QPushButton, QDialogButtonBox +) +from PySide6.QtCore import Signal + +from ..models import SelectMode + + +class SelectDialog(QDialog): + """Dialog for selecting/deselecting results.""" + mode_selected = Signal(object) # SelectMode + + MODES = [ + (SelectMode.SELECT_ALL, "Select All"), + (SelectMode.UNSELECT_ALL, "Unselect All"), + (SelectMode.INVERT_SELECTION, "Invert Selection"), + (SelectMode.SELECT_BIGGEST_SIZE, "Select Biggest (by Size)"), + (SelectMode.SELECT_SMALLEST_SIZE, "Select Smallest (by Size)"), + (SelectMode.SELECT_NEWEST, "Select Newest"), + (SelectMode.SELECT_OLDEST, "Select Oldest"), + (SelectMode.SELECT_SHORTEST_PATH, "Select Shortest Path"), + (SelectMode.SELECT_LONGEST_PATH, "Select Longest Path"), + ] + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Select Results") + self.setMinimumWidth(300) + + layout = QVBoxLayout(self) + + label = QLabel("Choose selection mode:") + label.setStyleSheet("font-size: 13px; padding: 4px;") + layout.addWidget(label) + + for mode, name in self.MODES: + btn = QPushButton(name) + btn.clicked.connect(lambda checked, m=mode: self._select(m)) + layout.addWidget(btn) + + # Cancel + cancel = QPushButton("Cancel") + cancel.clicked.connect(self.reject) + layout.addWidget(cancel) + + def _select(self, mode: SelectMode): + self.mode_selected.emit(mode) + self.accept() diff --git a/czkawka_pyside6/app/dialogs/sort_dialog.py b/czkawka_pyside6/app/dialogs/sort_dialog.py new file mode 100644 index 000000000..3d6f627e4 --- /dev/null +++ b/czkawka_pyside6/app/dialogs/sort_dialog.py @@ -0,0 +1,39 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QPushButton, QComboBox, + QCheckBox, QDialogButtonBox, QFormLayout +) +from PySide6.QtCore import Signal + + +class SortDialog(QDialog): + """Dialog for sorting results.""" + sort_requested = Signal(int, bool) # column_index, ascending + + def __init__(self, columns: list[str], parent=None): + super().__init__(parent) + self.setWindowTitle("Sort Results") + self.setMinimumWidth(300) + + layout = QFormLayout(self) + + self._column = QComboBox() + self._column.addItems(columns) + layout.addRow("Sort by:", self._column) + + self._ascending = QCheckBox("Ascending") + self._ascending.setChecked(True) + layout.addRow(self._ascending) + + buttons = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel + ) + buttons.accepted.connect(self._on_sort) + buttons.rejected.connect(self.reject) + layout.addRow(buttons) + + def _on_sort(self): + self.sort_requested.emit( + self._column.currentIndex(), + self._ascending.isChecked() + ) + self.accept() diff --git a/czkawka_pyside6/app/icons.py b/czkawka_pyside6/app/icons.py new file mode 100644 index 000000000..bc094529f --- /dev/null +++ b/czkawka_pyside6/app/icons.py @@ -0,0 +1,149 @@ +"""SVG icon resources for Czkawka PySide6 interface. + +Uses the same SVG icons as the Krokiet (Slint) interface. +Icons are embedded as strings to avoid file path issues. +""" + +from PySide6.QtGui import QIcon, QPixmap +from PySide6.QtCore import QSize, Qt +from PySide6.QtSvg import QSvgRenderer +from PySide6.QtGui import QPainter, QImage +from functools import lru_cache + +# Fill color applied to icons for dark theme visibility +_ICON_FILL = "#cccccc" +_ICON_FILL_GREEN = "#6fbf73" +_ICON_FILL_RED = "#e57373" + + +def _colorize_svg(svg: str, fill: str = _ICON_FILL) -> str: + """Inject fill color into SVG for dark theme visibility.""" + # Add fill to root svg or g elements + if 'fill="none"' not in svg and f'fill="{fill}"' not in svg: + svg = svg.replace(" QIcon: + """Convert SVG string to QIcon with specified fill color.""" + colored = _colorize_svg(svg_data, fill) + renderer = QSvgRenderer(colored.encode("utf-8")) + image = QImage(QSize(size, size), QImage.Format_ARGB32_Premultiplied) + image.fill(Qt.transparent) + painter = QPainter(image) + renderer.render(painter) + painter.end() + pixmap = QPixmap.fromImage(image) + return QIcon(pixmap) + + +# ─── Raw SVG data ─────────────────────────────────────────── + +SEARCH_SVG = '''''' + +STOP_SVG = '''''' + +DELETE_SVG = '''''' + +MOVE_SVG = '''''' + +SAVE_SVG = '''''' + +SELECT_SVG = '''''' + +HARDLINK_SVG = '''''' + +SYMLINK_SVG = '''''' + +RENAME_SVG = '''''' + +CLEAN_SVG = '''''' + +OPTIMIZE_SVG = '''''' + +SETTINGS_SVG = '''''' + +SUBSETTINGS_SVG = '''''' + +SORT_SVG = '''''' + +DIR_SVG = '''''' + +INFO_SVG = '''''' + + +# ─── Icon accessor functions ──────────────────────────────── + +def icon_search(size=24): + return _svg_to_icon(SEARCH_SVG, _ICON_FILL_GREEN, size) + +def icon_stop(size=24): + return _svg_to_icon(STOP_SVG, _ICON_FILL_RED, size) + +def icon_delete(size=24): + return _svg_to_icon(DELETE_SVG, _ICON_FILL_RED, size) + +def icon_move(size=24): + return _svg_to_icon(MOVE_SVG, _ICON_FILL, size) + +def icon_save(size=24): + return _svg_to_icon(SAVE_SVG, _ICON_FILL, size) + +def icon_select(size=24): + return _svg_to_icon(SELECT_SVG, _ICON_FILL, size) + +def icon_sort(size=24): + return _svg_to_icon(SORT_SVG, _ICON_FILL, size) + +def icon_hardlink(size=24): + return _svg_to_icon(HARDLINK_SVG, _ICON_FILL, size) + +def icon_symlink(size=24): + return _svg_to_icon(SYMLINK_SVG, _ICON_FILL, size) + +def icon_rename(size=24): + return _svg_to_icon(RENAME_SVG, _ICON_FILL, size) + +def icon_clean(size=24): + return _svg_to_icon(CLEAN_SVG, _ICON_FILL, size) + +def icon_optimize(size=24): + return _svg_to_icon(OPTIMIZE_SVG, _ICON_FILL, size) + +def icon_settings(size=24): + return _svg_to_icon(SETTINGS_SVG, _ICON_FILL, size) + +def icon_subsettings(size=24): + return _svg_to_icon(SUBSETTINGS_SVG, _ICON_FILL, size) + +def icon_dir(size=24): + return _svg_to_icon(DIR_SVG, _ICON_FILL, size) + +def icon_info(size=24): + return _svg_to_icon(INFO_SVG, _ICON_FILL, size) + + +def app_logo_path() -> str: + """Return absolute path to the Krokiet logo PNG.""" + from pathlib import Path + # Try relative to this file first, then absolute project path + for candidate in [ + Path(__file__).parent.parent.parent / "krokiet" / "icons" / "krokiet_logo.png", + Path("/mnt/developer/git/aecs4u.it/czkawka/krokiet/icons/krokiet_logo.png"), + ]: + if candidate.exists(): + return str(candidate) + return "" + + +def app_icon() -> QIcon: + """Return the application window icon from the Krokiet logo.""" + path = app_logo_path() + if path: + return QIcon(path) + return QIcon() diff --git a/czkawka_pyside6/app/left_panel.py b/czkawka_pyside6/app/left_panel.py new file mode 100644 index 000000000..8c53e77d3 --- /dev/null +++ b/czkawka_pyside6/app/left_panel.py @@ -0,0 +1,149 @@ +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QListWidget, QListWidgetItem, QLabel, + QPushButton, QHBoxLayout, QSizePolicy +) +from PySide6.QtCore import Signal, Qt, QSize +from PySide6.QtGui import QFont, QPixmap + +from .models import ActiveTab, TAB_DISPLAY_NAMES, TABS_WITH_SETTINGS +from .icons import app_logo_path, icon_settings, icon_subsettings + + +class LeftPanel(QWidget): + """Left sidebar for selecting scan tools.""" + tab_changed = Signal(object) # ActiveTab + settings_requested = Signal() + about_requested = Signal() + tool_settings_toggled = Signal(bool) + + # Tool tabs in display order + TOOL_TABS = [ + ActiveTab.DUPLICATE_FILES, + ActiveTab.EMPTY_FOLDERS, + ActiveTab.BIG_FILES, + ActiveTab.EMPTY_FILES, + ActiveTab.TEMPORARY_FILES, + ActiveTab.SIMILAR_IMAGES, + ActiveTab.SIMILAR_VIDEOS, + ActiveTab.SIMILAR_MUSIC, + ActiveTab.INVALID_SYMLINKS, + ActiveTab.BROKEN_FILES, + ActiveTab.BAD_EXTENSIONS, + ActiveTab.BAD_NAMES, + ActiveTab.EXIF_REMOVER, + ActiveTab.VIDEO_OPTIMIZER, + ] + + def __init__(self, parent=None): + super().__init__(parent) + self.setMinimumWidth(170) + self.setMaximumWidth(230) + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(6, 6, 6, 6) + layout.setSpacing(4) + + # Logo image (clickable) + logo_path = app_logo_path() + if logo_path: + self._logo_label = QLabel() + pixmap = QPixmap(logo_path) + scaled = pixmap.scaledToHeight(70, Qt.SmoothTransformation) + self._logo_label.setPixmap(scaled) + self._logo_label.setAlignment(Qt.AlignCenter) + self._logo_label.setCursor(Qt.PointingHandCursor) + self._logo_label.setToolTip("About Czkawka") + self._logo_label.mousePressEvent = lambda _: self.about_requested.emit() + layout.addWidget(self._logo_label) + else: + title_label = QLabel("Czkawka") + title_font = QFont() + title_font.setPointSize(16) + title_font.setBold(True) + title_label.setFont(title_font) + title_label.setAlignment(Qt.AlignCenter) + title_label.setCursor(Qt.PointingHandCursor) + title_label.mousePressEvent = lambda _: self.about_requested.emit() + layout.addWidget(title_label) + + # Top buttons row with icons + btn_row = QHBoxLayout() + btn_row.setSpacing(4) + + self._settings_btn = QPushButton(icon_settings(20), "") + self._settings_btn.setFixedSize(32, 32) + self._settings_btn.setIconSize(QSize(20, 20)) + self._settings_btn.setToolTip("Application Settings") + self._settings_btn.clicked.connect(self.settings_requested.emit) + btn_row.addWidget(self._settings_btn) + + self._tool_settings_btn = QPushButton(icon_subsettings(20), "") + self._tool_settings_btn.setFixedSize(32, 32) + self._tool_settings_btn.setIconSize(QSize(20, 20)) + self._tool_settings_btn.setToolTip("Tool-specific Settings") + self._tool_settings_btn.setCheckable(True) + self._tool_settings_btn.clicked.connect( + lambda checked: self.tool_settings_toggled.emit(checked) + ) + self._tool_settings_btn.setVisible(False) + btn_row.addWidget(self._tool_settings_btn) + + btn_row.addStretch() + layout.addLayout(btn_row) + + # Tool list + self._tool_list = QListWidget() + self._tool_list.setSpacing(1) + font = QFont() + font.setPointSize(10) + self._tool_list.setFont(font) + self._tool_list.setStyleSheet(""" + QListWidget::item { + padding: 4px 8px; + border-left: 3px solid transparent; + } + QListWidget::item:selected { + border-left: 3px solid #6fbf73; + background-color: #353535; + } + QListWidget::item:hover { + background-color: #49494926; + } + """) + + for tab in self.TOOL_TABS: + item = QListWidgetItem(TAB_DISPLAY_NAMES[tab]) + item.setData(Qt.UserRole, tab) + item.setSizeHint(item.sizeHint().__class__(item.sizeHint().width(), 30)) + self._tool_list.addItem(item) + + self._tool_list.setCurrentRow(0) + self._tool_list.currentItemChanged.connect(self._on_item_changed) + layout.addWidget(self._tool_list) + + # Version label + version_label = QLabel("Czkawka PySide6 v11.0.1") + version_label.setAlignment(Qt.AlignCenter) + version_label.setStyleSheet("color: #666; font-size: 9px; padding: 2px;") + layout.addWidget(version_label) + + def _on_item_changed(self, current, previous): + if current: + tab = current.data(Qt.UserRole) + self._tool_settings_btn.setVisible(tab in TABS_WITH_SETTINGS) + self.tab_changed.emit(tab) + + def set_active_tab(self, tab: ActiveTab): + for i in range(self._tool_list.count()): + item = self._tool_list.item(i) + if item.data(Qt.UserRole) == tab: + self._tool_list.setCurrentRow(i) + break + + def get_active_tab(self) -> ActiveTab: + item = self._tool_list.currentItem() + if item: + return item.data(Qt.UserRole) + return ActiveTab.DUPLICATE_FILES diff --git a/czkawka_pyside6/app/main_window.py b/czkawka_pyside6/app/main_window.py new file mode 100644 index 000000000..2515bd237 --- /dev/null +++ b/czkawka_pyside6/app/main_window.py @@ -0,0 +1,624 @@ +"""Main application window for Czkawka PySide6 interface.""" + +import shutil +from pathlib import Path + +from PySide6.QtWidgets import ( + QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QSplitter, + QStatusBar, QMessageBox, QLabel, QApplication +) +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QPalette, QColor + +from .state import AppState +from .models import ( + ActiveTab, TAB_COLUMNS, GROUPED_TABS, TABS_WITH_SETTINGS, SelectMode +) +from .left_panel import LeftPanel +from .results_view import ResultsView +from .action_buttons import ActionButtons +from .tool_settings import ToolSettingsPanel +from .settings_panel import SettingsPanel +from .progress_widget import ProgressWidget +from .preview_panel import PreviewPanel +from .bottom_panel import BottomPanel +from .backend import ScanRunner, FileOperations +from .icons import app_icon +from .dialogs import ( + DeleteDialog, MoveDialog, SelectDialog, + SortDialog, SaveDialog, RenameDialog, AboutDialog +) + + +class MainWindow(QMainWindow): + """Main application window with all panels and functionality.""" + + def __init__(self): + super().__init__() + self._state = AppState() + self._scan_runner = ScanRunner(self) + self._setup_window() + self._setup_ui() + self._connect_signals() + self._apply_theme() + + def _setup_window(self): + self.setWindowTitle("Czkawka - PySide6 Edition") + self.setMinimumSize(900, 600) + self.resize(1200, 800) + + # Set window icon from project logo + icon = app_icon() + if not icon.isNull(): + self.setWindowIcon(icon) + + # Auto-detect czkawka_cli binary + self._auto_detect_cli() + + def _setup_ui(self): + central = QWidget() + self.setCentralWidget(central) + main_layout = QVBoxLayout(central) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + + # Top area: left panel + content + content_splitter = QSplitter(Qt.Horizontal) + + # Left panel (tool selection) + self._left_panel = LeftPanel() + content_splitter.addWidget(self._left_panel) + + # Center area + center_widget = QWidget() + center_layout = QVBoxLayout(center_widget) + center_layout.setContentsMargins(0, 0, 0, 0) + center_layout.setSpacing(0) + + # Results view + self._results_view = ResultsView() + center_layout.addWidget(self._results_view, 1) + + # Progress widget (hidden by default) + self._progress = ProgressWidget() + center_layout.addWidget(self._progress) + + content_splitter.addWidget(center_widget) + + # Tool settings panel (hidden by default) + self._tool_settings = ToolSettingsPanel(self._state.tool_settings) + self._tool_settings.setVisible(False) + content_splitter.addWidget(self._tool_settings) + + # Preview panel (hidden by default) + self._preview = PreviewPanel() + self._preview.setVisible(False) + content_splitter.addWidget(self._preview) + + # Set splitter sizes + content_splitter.setStretchFactor(0, 0) # Left panel: fixed + content_splitter.setStretchFactor(1, 1) # Center: stretch + content_splitter.setStretchFactor(2, 0) # Tool settings: fixed + content_splitter.setStretchFactor(3, 0) # Preview: fixed + + main_layout.addWidget(content_splitter, 1) + + # Action buttons bar + self._action_buttons = ActionButtons() + main_layout.addWidget(self._action_buttons) + + # Bottom panel (directories / errors) + self._bottom_panel = BottomPanel(self._state.settings) + self._bottom_panel.show_directories() + main_layout.addWidget(self._bottom_panel) + + # Settings panel (overlay, hidden by default) + self._settings_panel = SettingsPanel(self._state.settings) + self._settings_panel.setVisible(False) + + # Status bar + self._statusbar = QStatusBar() + self.setStatusBar(self._statusbar) + self._status_label = QLabel("Ready") + self._statusbar.addWidget(self._status_label, 1) + + # Initialize results view columns for default tab + self._results_view.set_active_tab(self._state.active_tab) + + def _connect_signals(self): + # Left panel + self._left_panel.tab_changed.connect(self._on_tab_changed) + self._left_panel.settings_requested.connect(self._show_settings) + self._left_panel.about_requested.connect(self._show_about) + self._left_panel.tool_settings_toggled.connect(self._toggle_tool_settings) + + # Action buttons + self._action_buttons.scan_clicked.connect(self._start_scan) + self._action_buttons.stop_clicked.connect(self._stop_scan) + self._action_buttons.select_clicked.connect(self._show_select_dialog) + self._action_buttons.delete_clicked.connect(self._show_delete_dialog) + self._action_buttons.move_clicked.connect(self._show_move_dialog) + self._action_buttons.save_clicked.connect(self._save_results) + self._action_buttons.sort_clicked.connect(self._show_sort_dialog) + self._action_buttons.hardlink_clicked.connect(self._create_hardlinks) + self._action_buttons.symlink_clicked.connect(self._create_symlinks) + self._action_buttons.rename_clicked.connect(self._rename_files) + self._action_buttons.clean_exif_clicked.connect(self._clean_exif) + self._action_buttons.optimize_video_clicked.connect(self._optimize_video) + + # Results view + self._results_view.selection_changed.connect( + lambda count: self._action_buttons.set_has_selection(count > 0) + ) + self._results_view.item_activated.connect(self._on_item_activated) + + # Scan runner + self._scan_runner.finished.connect(self._on_scan_finished) + self._scan_runner.progress.connect(self._on_scan_progress) + self._scan_runner.error.connect(self._on_scan_error) + + # Settings + self._settings_panel.close_requested.connect( + lambda: self._settings_panel.setVisible(False) + ) + self._settings_panel.settings_changed.connect(self._on_settings_changed) + + # Bottom panel + self._bottom_panel.directories_changed.connect(self._on_settings_changed) + + def _on_tab_changed(self, tab: ActiveTab): + self._state.set_active_tab(tab) + self._results_view.set_active_tab(tab) + self._action_buttons.set_active_tab(tab) + self._tool_settings.set_active_tab(tab) + + # Show/hide preview for image-related tabs + show_preview = ( + tab in (ActiveTab.SIMILAR_IMAGES, ActiveTab.DUPLICATE_FILES) + and self._state.settings.show_image_preview + ) + self._preview.setVisible(show_preview) + + # Load existing results for this tab + results = self._state.get_results(tab) + if results: + self._results_view.set_results(results) + self._action_buttons.set_has_results(True) + else: + self._results_view.clear() + self._action_buttons.set_has_results(False) + + self._status_label.setText(f"Tab: {tab.name.replace('_', ' ').title()}") + + def _start_scan(self): + tab = self._state.active_tab + if not self._state.settings.included_paths: + QMessageBox.warning( + self, "No Directories", + "Please add at least one directory to scan in the bottom panel." + ) + return + + self._state.set_scanning(True) + self._action_buttons.set_scanning(True) + self._progress.start(tab) + self._results_view.clear() + self._status_label.setText(f"Scanning: {tab.name.replace('_', ' ').title()}...") + + self._scan_runner.start_scan( + tab, self._state.settings, self._state.tool_settings + ) + + def _stop_scan(self): + self._state.request_stop() + self._scan_runner.stop_scan() + self._status_label.setText("Scan stopped by user") + + def _on_scan_finished(self, tab: ActiveTab, results: list): + self._state.set_scanning(False) + self._state.set_results(tab, results) + self._action_buttons.set_scanning(False) + self._progress.stop() + + if tab == self._state.active_tab: + self._results_view.set_results(results) + self._action_buttons.set_has_results(len(results) > 0) + + count = sum(1 for r in results if not r.header_row) + self._status_label.setText(f"Scan complete: found {count} entries") + + def _on_scan_progress(self, progress): + self._progress.update_progress(progress) + + def _on_scan_error(self, error_msg: str): + self._state.set_scanning(False) + self._action_buttons.set_scanning(False) + self._progress.stop() + self._status_label.setText(f"Error: {error_msg}") + self._bottom_panel.set_text(f"Error: {error_msg}") + self._bottom_panel.show_text() + QMessageBox.critical(self, "Scan Error", error_msg) + + def _on_item_activated(self, entry): + path = entry.values.get("__full_path", "") + if path and self._preview.isVisible(): + self._preview.show_preview(path) + + def _show_settings(self): + self._settings_panel.setVisible(True) + # Show as a floating window + self._settings_panel.setParent(None) + self._settings_panel.setWindowTitle("Czkawka Settings") + self._settings_panel.setMinimumSize(600, 500) + self._settings_panel.show() + self._settings_panel.raise_() + + def _show_about(self): + dialog = AboutDialog(self) + dialog.exec() + + def _toggle_tool_settings(self, visible: bool): + self._tool_settings.setVisible(visible) + + def _show_select_dialog(self): + dialog = SelectDialog(self) + dialog.mode_selected.connect(self._results_view.apply_selection) + dialog.exec() + + def _show_delete_dialog(self): + checked = self._results_view.get_checked_entries() + if not checked: + QMessageBox.information(self, "No Selection", "No files selected for deletion.") + return + + dialog = DeleteDialog(len(checked), self._state.settings.move_to_trash, self) + if dialog.exec() == DeleteDialog.Accepted: + deleted, errors = FileOperations.delete_files( + checked, dialog.move_to_trash + ) + self._status_label.setText(f"Deleted {deleted} file(s)") + if errors: + self._bottom_panel.set_text("\n".join(errors)) + self._bottom_panel.show_text() + # Refresh results - remove deleted entries + self._refresh_after_action(checked) + + def _show_move_dialog(self): + checked = self._results_view.get_checked_entries() + if not checked: + QMessageBox.information(self, "No Selection", "No files selected.") + return + + dialog = MoveDialog(len(checked), self) + if dialog.exec() == MoveDialog.Accepted: + if not dialog.destination: + QMessageBox.warning(self, "No Destination", "Please select a destination folder.") + return + moved, errors = FileOperations.move_files( + checked, dialog.destination, + dialog.preserve_structure, dialog.copy_mode + ) + action = "Copied" if dialog.copy_mode else "Moved" + self._status_label.setText(f"{action} {moved} file(s)") + if errors: + self._bottom_panel.set_text("\n".join(errors)) + self._bottom_panel.show_text() + if not dialog.copy_mode: + self._refresh_after_action(checked) + + def _save_results(self): + results = self._results_view.get_all_entries() + if not results: + QMessageBox.information(self, "No Results", "No results to save.") + return + all_results = self._state.get_results() + success = SaveDialog.save(self, all_results, self._state.settings.save_as_json) + if success: + self._status_label.setText("Results saved successfully") + + def _show_sort_dialog(self): + columns = TAB_COLUMNS.get(self._state.active_tab, []) + if not columns: + return + dialog = SortDialog(columns, self) + dialog.sort_requested.connect(self._results_view.sort_by_column) + dialog.exec() + + def _create_hardlinks(self): + checked = self._results_view.get_checked_entries() + if not checked: + return + + # Find the reference file (first unchecked in the same group) + all_results = self._state.get_results() + group_id = checked[0].group_id + reference = None + for r in all_results: + if r.group_id == group_id and not r.header_row and not r.checked: + reference = r.values.get("__full_path", "") + break + + if not reference: + QMessageBox.warning( + self, "No Reference", + "Cannot determine reference file. Leave at least one file unchecked in the group." + ) + return + + reply = QMessageBox.question( + self, "Create Hardlinks", + f"Replace {len(checked)} selected file(s) with hardlinks to:\n{reference}?", + QMessageBox.Yes | QMessageBox.No + ) + if reply == QMessageBox.Yes: + created, errors = FileOperations.create_hardlinks(checked, reference) + self._status_label.setText(f"Created {created} hardlink(s)") + if errors: + self._bottom_panel.set_text("\n".join(errors)) + self._bottom_panel.show_text() + + def _create_symlinks(self): + checked = self._results_view.get_checked_entries() + if not checked: + return + + all_results = self._state.get_results() + group_id = checked[0].group_id + reference = None + for r in all_results: + if r.group_id == group_id and not r.header_row and not r.checked: + reference = r.values.get("__full_path", "") + break + + if not reference: + QMessageBox.warning( + self, "No Reference", + "Cannot determine reference file. Leave at least one file unchecked in the group." + ) + return + + reply = QMessageBox.question( + self, "Create Symlinks", + f"Replace {len(checked)} selected file(s) with symlinks to:\n{reference}?", + QMessageBox.Yes | QMessageBox.No + ) + if reply == QMessageBox.Yes: + created, errors = FileOperations.create_symlinks(checked, reference) + self._status_label.setText(f"Created {created} symlink(s)") + if errors: + self._bottom_panel.set_text("\n".join(errors)) + self._bottom_panel.show_text() + + def _rename_files(self): + checked = self._results_view.get_checked_entries() + if not checked: + return + + tab = self._state.active_tab + if tab == ActiveTab.BAD_EXTENSIONS: + dialog = RenameDialog(len(checked), "extensions", self) + if dialog.exec() == RenameDialog.Accepted: + success, msg = FileOperations.fix_extensions( + self._state.settings.czkawka_cli_path, + self._state.settings, self._state.tool_settings + ) + self._status_label.setText("Extensions fixed" if success else f"Error: {msg}") + elif tab == ActiveTab.BAD_NAMES: + dialog = RenameDialog(len(checked), "names", self) + if dialog.exec() == RenameDialog.Accepted: + success, msg = FileOperations.fix_bad_names( + self._state.settings.czkawka_cli_path, + self._state.settings, self._state.tool_settings + ) + self._status_label.setText("Names fixed" if success else f"Error: {msg}") + + def _clean_exif(self): + checked = self._results_view.get_checked_entries() + if not checked: + return + + reply = QMessageBox.question( + self, "Clean EXIF", + f"Remove EXIF metadata from {len(checked)} selected file(s)?", + QMessageBox.Yes | QMessageBox.No + ) + if reply == QMessageBox.Yes: + cleaned, errors = FileOperations.clean_exif( + self._state.settings.czkawka_cli_path, + checked, + self._state.tool_settings.exif_ignored_tags + ) + self._status_label.setText(f"Cleaned EXIF from {cleaned} file(s)") + if errors: + self._bottom_panel.set_text("\n".join(errors)) + self._bottom_panel.show_text() + + def _optimize_video(self): + checked = self._results_view.get_checked_entries() + if not checked: + return + + QMessageBox.information( + self, "Video Optimization", + f"Video optimization for {len(checked)} file(s) will be performed " + "using czkawka_cli. Check the status bar for progress." + ) + # Video optimization is done via CLI + self._status_label.setText("Video optimization: use CLI directly for this feature") + + def _refresh_after_action(self, removed_entries: list): + """Remove processed entries from results and refresh the view.""" + removed_paths = {e.values.get("__full_path") for e in removed_entries} + current_results = self._state.get_results() + new_results = [] + for r in current_results: + if r.header_row: + new_results.append(r) + elif r.values.get("__full_path") not in removed_paths: + new_results.append(r) + + # Remove empty group headers + cleaned = [] + i = 0 + while i < len(new_results): + if new_results[i].header_row: + # Check if next entries belong to this group + has_children = False + for j in range(i + 1, len(new_results)): + if new_results[j].header_row: + break + has_children = True + if has_children: + cleaned.append(new_results[i]) + else: + cleaned.append(new_results[i]) + i += 1 + + self._state.set_results(self._state.active_tab, cleaned) + self._results_view.set_results(cleaned) + self._action_buttons.set_has_results( + any(not r.header_row for r in cleaned) + ) + + def _on_settings_changed(self): + self._state.save_settings() + self._bottom_panel.refresh_lists() + + def _apply_theme(self): + """Apply dark theme to the application.""" + if not self._state.settings.dark_theme: + return + + app = QApplication.instance() + palette = QPalette() + + # Dark theme colors + palette.setColor(QPalette.Window, QColor(43, 43, 43)) + palette.setColor(QPalette.WindowText, QColor(210, 210, 210)) + palette.setColor(QPalette.Base, QColor(30, 30, 30)) + palette.setColor(QPalette.AlternateBase, QColor(38, 38, 38)) + palette.setColor(QPalette.ToolTipBase, QColor(50, 50, 50)) + palette.setColor(QPalette.ToolTipText, QColor(210, 210, 210)) + palette.setColor(QPalette.Text, QColor(210, 210, 210)) + palette.setColor(QPalette.Button, QColor(53, 53, 53)) + palette.setColor(QPalette.ButtonText, QColor(210, 210, 210)) + palette.setColor(QPalette.BrightText, QColor(255, 255, 255)) + palette.setColor(QPalette.Link, QColor(86, 140, 210)) + palette.setColor(QPalette.Highlight, QColor(60, 100, 150)) + palette.setColor(QPalette.HighlightedText, QColor(255, 255, 255)) + palette.setColor(QPalette.Disabled, QPalette.Text, QColor(128, 128, 128)) + palette.setColor(QPalette.Disabled, QPalette.ButtonText, QColor(128, 128, 128)) + + app.setPalette(palette) + + # Additional stylesheet + app.setStyleSheet(""" + QMainWindow { background-color: #2b2b2b; } + QSplitter::handle { background-color: #404040; width: 2px; } + QTreeWidget { border: 1px solid #404040; } + QTreeWidget::item { padding: 2px; } + QTreeWidget::item:alternate { background-color: #262626; } + QTreeWidget::item:selected { background-color: #3c6496; } + QListWidget { border: 1px solid #404040; } + QListWidget::item { padding: 3px; } + QListWidget::item:selected { background-color: #3c6496; } + QGroupBox { border: 1px solid #505050; border-radius: 4px; + margin-top: 8px; padding-top: 8px; } + QGroupBox::title { subcontrol-origin: margin; left: 10px; + padding: 0 4px; } + QPushButton { padding: 5px 12px; border: 1px solid #555; + border-radius: 3px; background-color: #404040; } + QPushButton:hover { background-color: #505050; } + QPushButton:pressed { background-color: #353535; } + QPushButton:disabled { background-color: #333; color: #666; } + QComboBox { padding: 4px; border: 1px solid #555; + border-radius: 3px; background-color: #404040; } + QComboBox:hover { background-color: #505050; } + QComboBox QAbstractItemView { background-color: #353535; + border: 1px solid #555; + selection-background-color: #3c6496; } + QLineEdit { padding: 4px; border: 1px solid #555; + border-radius: 3px; background-color: #353535; } + QSpinBox { padding: 4px; border: 1px solid #555; + border-radius: 3px; background-color: #353535; } + QSlider::groove:horizontal { height: 6px; background: #404040; + border-radius: 3px; } + QSlider::handle:horizontal { width: 14px; margin: -4px 0; + background: #888; border-radius: 7px; } + QSlider::handle:horizontal:hover { background: #aaa; } + QProgressBar { border: 1px solid #555; border-radius: 3px; + text-align: center; background-color: #353535; } + QProgressBar::chunk { background-color: #3c6496; border-radius: 2px; } + QScrollArea { border: none; } + QTabWidget::pane { border: 1px solid #555; } + QTabBar::tab { padding: 6px 16px; border: 1px solid #555; + border-bottom: none; border-radius: 3px 3px 0 0; + background-color: #353535; } + QTabBar::tab:selected { background-color: #404040; } + QTabBar::tab:hover { background-color: #505050; } + QStatusBar { background-color: #2b2b2b; border-top: 1px solid #404040; } + QCheckBox { spacing: 6px; } + QCheckBox::indicator { width: 16px; height: 16px; } + QTextEdit { border: 1px solid #404040; background-color: #1e1e1e; } + QHeaderView::section { background-color: #353535; padding: 4px; + border: 1px solid #404040; } + """) + + def _auto_detect_cli(self): + """Auto-detect czkawka_cli binary location.""" + s = self._state.settings + + # If already valid and exists, keep it + if s.czkawka_cli_path != "czkawka_cli" and Path(s.czkawka_cli_path).is_file(): + return + if shutil.which(s.czkawka_cli_path): + return + + # Search common locations + candidates = [] + project_root = Path(__file__).parent.parent.parent + + # Look for compiled binary in standard target dirs + for build_dir in ["target/release", "target/debug"]: + candidate = project_root / build_dir / "czkawka_cli" + if candidate.exists(): + candidates.append(str(candidate)) + + # Check cargo metadata for custom target directory + if not candidates: + try: + import subprocess, json + result = subprocess.run( + ["cargo", "metadata", "--manifest-path", + str(project_root / "Cargo.toml"), + "--format-version", "1", "--no-deps"], + capture_output=True, text=True, timeout=10 + ) + if result.returncode == 0: + meta = json.loads(result.stdout) + target_dir = meta.get("target_directory", "") + if target_dir: + for sub in ["release", "debug"]: + candidate = Path(target_dir) / sub / "czkawka_cli" + if candidate.exists(): + candidates.append(str(candidate)) + except Exception: + pass + + # Also check PATH + which_result = shutil.which("czkawka_cli") + if which_result: + candidates.append(which_result) + + for candidate in candidates: + if Path(candidate).is_file(): + s.czkawka_cli_path = str(candidate) + self._state.save_settings() + return + + def closeEvent(self, event): + """Save settings on close.""" + self._state.save_settings() + if self._state.scanning: + self._scan_runner.stop_scan() + super().closeEvent(event) diff --git a/czkawka_pyside6/app/models.py b/czkawka_pyside6/app/models.py new file mode 100644 index 000000000..306772529 --- /dev/null +++ b/czkawka_pyside6/app/models.py @@ -0,0 +1,305 @@ +from enum import Enum, auto +from dataclasses import dataclass, field +from typing import Optional +from pathlib import Path + + +class ActiveTab(Enum): + DUPLICATE_FILES = auto() + EMPTY_FOLDERS = auto() + BIG_FILES = auto() + EMPTY_FILES = auto() + TEMPORARY_FILES = auto() + SIMILAR_IMAGES = auto() + SIMILAR_VIDEOS = auto() + SIMILAR_MUSIC = auto() + INVALID_SYMLINKS = auto() + BROKEN_FILES = auto() + BAD_EXTENSIONS = auto() + BAD_NAMES = auto() + EXIF_REMOVER = auto() + VIDEO_OPTIMIZER = auto() + SETTINGS = auto() + ABOUT = auto() + + +class SelectMode(Enum): + SELECT_ALL = auto() + UNSELECT_ALL = auto() + INVERT_SELECTION = auto() + SELECT_BIGGEST_SIZE = auto() + SELECT_BIGGEST_RESOLUTION = auto() + SELECT_SMALLEST_SIZE = auto() + SELECT_SMALLEST_RESOLUTION = auto() + SELECT_NEWEST = auto() + SELECT_OLDEST = auto() + SELECT_SHORTEST_PATH = auto() + SELECT_LONGEST_PATH = auto() + SELECT_CUSTOM = auto() + + +class DeleteMethod(Enum): + NONE = "NONE" + DELETE = "DELETE" + ALL_EXCEPT_NEWEST = "AEN" + ALL_EXCEPT_OLDEST = "AEO" + ONE_OLDEST = "OO" + ONE_NEWEST = "ON" + HARDLINK = "HARD" + ALL_EXCEPT_BIGGEST = "AEB" + ALL_EXCEPT_SMALLEST = "AES" + ONE_BIGGEST = "OB" + ONE_SMALLEST = "OS" + + +class CheckingMethod(Enum): + HASH = "HASH" + SIZE = "SIZE" + NAME = "NAME" + SIZE_NAME = "SIZE_NAME" + + +class HashType(Enum): + BLAKE3 = "BLAKE3" + CRC32 = "CRC32" + XXH3 = "XXH3" + + +class ImageHashAlg(Enum): + MEAN = "Mean" + GRADIENT = "Gradient" + BLOCKHASH = "Blockhash" + VERT_GRADIENT = "VertGradient" + DOUBLE_GRADIENT = "DoubleGradient" + MEDIAN = "Median" + + +class ImageFilter(Enum): + LANCZOS3 = "Lanczos3" + NEAREST = "Nearest" + TRIANGLE = "Triangle" + GAUSSIAN = "Gaussian" + CATMULL_ROM = "CatmullRom" + + +class MusicSearchMethod(Enum): + TAGS = "TAGS" + CONTENT = "CONTENT" + + +class VideoCropDetect(Enum): + NONE = "none" + LETTERBOX = "letterbox" + MOTION = "motion" + + +class VideoCropMechanism(Enum): + BLACKBARS = "blackbars" + STATICCONTENT = "staticcontent" + + +class VideoCodec(Enum): + H264 = "h264" + H265 = "h265" + AV1 = "av1" + VP9 = "vp9" + + +# Map ActiveTab to CLI subcommand names +TAB_TO_CLI_COMMAND = { + ActiveTab.DUPLICATE_FILES: "dup", + ActiveTab.EMPTY_FOLDERS: "empty-folders", + ActiveTab.BIG_FILES: "big", + ActiveTab.EMPTY_FILES: "empty-files", + ActiveTab.TEMPORARY_FILES: "temp", + ActiveTab.SIMILAR_IMAGES: "image", + ActiveTab.SIMILAR_VIDEOS: "video", + ActiveTab.SIMILAR_MUSIC: "music", + ActiveTab.INVALID_SYMLINKS: "symlinks", + ActiveTab.BROKEN_FILES: "broken", + ActiveTab.BAD_EXTENSIONS: "ext", + ActiveTab.BAD_NAMES: "bad-names", + ActiveTab.EXIF_REMOVER: "exif-remover", + ActiveTab.VIDEO_OPTIMIZER: "video-optimizer", +} + +TAB_DISPLAY_NAMES = { + ActiveTab.DUPLICATE_FILES: "Duplicate Files", + ActiveTab.EMPTY_FOLDERS: "Empty Folders", + ActiveTab.BIG_FILES: "Big Files", + ActiveTab.EMPTY_FILES: "Empty Files", + ActiveTab.TEMPORARY_FILES: "Temporary Files", + ActiveTab.SIMILAR_IMAGES: "Similar Images", + ActiveTab.SIMILAR_VIDEOS: "Similar Videos", + ActiveTab.SIMILAR_MUSIC: "Similar Music", + ActiveTab.INVALID_SYMLINKS: "Invalid Symlinks", + ActiveTab.BROKEN_FILES: "Broken Files", + ActiveTab.BAD_EXTENSIONS: "Bad Extensions", + ActiveTab.BAD_NAMES: "Bad Names", + ActiveTab.EXIF_REMOVER: "EXIF Remover", + ActiveTab.VIDEO_OPTIMIZER: "Video Optimizer", +} + +# Which tabs support grouping (results are in groups) +GROUPED_TABS = { + ActiveTab.DUPLICATE_FILES, + ActiveTab.SIMILAR_IMAGES, + ActiveTab.SIMILAR_VIDEOS, + ActiveTab.SIMILAR_MUSIC, +} + +# Which tabs have per-tool settings +TABS_WITH_SETTINGS = { + ActiveTab.DUPLICATE_FILES, + ActiveTab.SIMILAR_IMAGES, + ActiveTab.SIMILAR_VIDEOS, + ActiveTab.SIMILAR_MUSIC, + ActiveTab.BIG_FILES, + ActiveTab.BROKEN_FILES, + ActiveTab.BAD_NAMES, + ActiveTab.EXIF_REMOVER, + ActiveTab.VIDEO_OPTIMIZER, +} + +# Column definitions per tab +TAB_COLUMNS = { + ActiveTab.DUPLICATE_FILES: ["Selection", "Size", "File Name", "Path", "Modification Date", "Hash"], + ActiveTab.EMPTY_FOLDERS: ["Selection", "Folder Name", "Path", "Modification Date"], + ActiveTab.BIG_FILES: ["Selection", "Size", "File Name", "Path", "Modification Date"], + ActiveTab.EMPTY_FILES: ["Selection", "File Name", "Path", "Modification Date"], + ActiveTab.TEMPORARY_FILES: ["Selection", "File Name", "Path", "Modification Date"], + ActiveTab.SIMILAR_IMAGES: ["Selection", "Similarity", "Size", "Resolution", "File Name", "Path", "Modification Date", "Hash"], + ActiveTab.SIMILAR_VIDEOS: ["Selection", "Size", "File Name", "Path", "Modification Date"], + ActiveTab.SIMILAR_MUSIC: ["Selection", "Size", "File Name", "Path", "Title", "Artist", "Year", "Bitrate", "Genre", "Length"], + ActiveTab.INVALID_SYMLINKS: ["Selection", "Symlink Name", "Symlink Path", "Destination Path", "Type of Error"], + ActiveTab.BROKEN_FILES: ["Selection", "File Name", "Path", "Error Type", "Size", "Modification Date"], + ActiveTab.BAD_EXTENSIONS: ["Selection", "File Name", "Path", "Current Extension", "Proper Extension"], + ActiveTab.BAD_NAMES: ["Selection", "File Name", "Path", "Error Type"], + ActiveTab.EXIF_REMOVER: ["Selection", "File Name", "Path"], + ActiveTab.VIDEO_OPTIMIZER: ["Selection", "File Name", "Path", "Size", "Codec"], +} + + +@dataclass +class ResultEntry: + """A single result entry from a scan.""" + values: dict # column_name -> value + checked: bool = False + header_row: bool = False # Group header for grouped results + group_id: int = 0 + + +@dataclass +class ScanProgress: + """Progress information during scanning.""" + step_name: str = "" + current: int = 0 + total: int = 0 + current_size: int = 0 + # Raw fields from czkawka_cli --json-progress + stage_name: str = "" + current_stage_idx: int = 0 + max_stage_idx: int = 0 + entries_checked: int = 0 + entries_to_check: int = 0 + bytes_checked: int = 0 + bytes_to_check: int = 0 + + +@dataclass +class ToolSettings: + """Per-tool settings that map to CLI arguments.""" + # Duplicates + dup_check_method: CheckingMethod = CheckingMethod.HASH + dup_hash_type: HashType = HashType.BLAKE3 + dup_name_case_sensitive: bool = False + dup_min_size: str = "8192" + dup_max_size: str = "" + dup_min_cache_size: str = "257144" + dup_use_prehash: bool = True + dup_min_prehash_cache_size: str = "257144" + + # Similar Images + img_hash_size: int = 16 # 8, 16, 32, 64 + img_filter: ImageFilter = ImageFilter.NEAREST + img_hash_alg: ImageHashAlg = ImageHashAlg.GRADIENT + img_ignore_same_size: bool = False + img_max_difference: int = 5 # 0-40 + + # Similar Videos + vid_ignore_same_size: bool = False + vid_crop_detect: VideoCropDetect = VideoCropDetect.LETTERBOX + vid_max_difference: int = 10 # 0-20 + vid_skip_forward: int = 15 # 0-300 + vid_duration: int = 10 # 1-60 + + # Similar Music + music_search_method: MusicSearchMethod = MusicSearchMethod.TAGS + music_approximate: bool = False + music_title: bool = True + music_artist: bool = True + music_bitrate: bool = False + music_genre: bool = False + music_year: bool = False + music_length: bool = False + music_compare_fingerprints_similar_titles: bool = False + music_max_difference: float = 2.0 + music_min_segment_duration: float = 10.0 + + # Big Files + big_files_mode: str = "biggest" # biggest or smallest + big_files_number: int = 50 + + # Broken Files + broken_audio: bool = True + broken_pdf: bool = True + broken_archive: bool = True + broken_image: bool = True + broken_video: bool = False + + # Bad Names + bad_names_uppercase_ext: bool = True + bad_names_emoji: bool = True + bad_names_space: bool = True + bad_names_non_ascii: bool = True + bad_names_restricted_charset: str = "" + bad_names_remove_duplicated: bool = False + + # EXIF Remover + exif_ignored_tags: str = "" + + # Video Optimizer + video_opt_mode: str = "crop" # crop or transcode + video_crop_mechanism: VideoCropMechanism = VideoCropMechanism.BLACKBARS + video_black_pixel_threshold: int = 32 + video_black_bar_percentage: int = 90 + video_max_samples: int = 20 + video_min_crop_size: int = 10 + video_excluded_codecs: str = "h265,hevc,av1,vp9" + video_codec: VideoCodec = VideoCodec.H265 + video_quality: int = 23 + video_fail_if_bigger: bool = False + video_overwrite: bool = False + video_max_width: int = 1920 + video_max_height: int = 1080 + + +@dataclass +class AppSettings: + """Global application settings.""" + included_paths: list = field(default_factory=lambda: [str(Path.home())]) + excluded_paths: list = field(default_factory=list) + excluded_items: str = "" + allowed_extensions: str = "" + excluded_extensions: str = "" + minimum_file_size: str = "" + maximum_file_size: str = "" + recursive_search: bool = True + use_cache: bool = True + save_as_json: bool = False + move_to_trash: bool = True + hide_hard_links: bool = False + thread_number: int = 0 # 0 = all available + dark_theme: bool = True + show_image_preview: bool = True + czkawka_cli_path: str = "czkawka_cli" # path to CLI binary diff --git a/czkawka_pyside6/app/preview_panel.py b/czkawka_pyside6/app/preview_panel.py new file mode 100644 index 000000000..3e3c50586 --- /dev/null +++ b/czkawka_pyside6/app/preview_panel.py @@ -0,0 +1,112 @@ +from pathlib import Path + +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QLabel, QSizePolicy +) +from PySide6.QtCore import Qt, QSize +from PySide6.QtGui import QPixmap, QImage + + +class PreviewPanel(QWidget): + """Image preview panel for similar images / duplicate files.""" + + SUPPORTED_EXTENSIONS = { + ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", + ".tiff", ".tif", ".ico", ".svg" + } + + def __init__(self, parent=None): + super().__init__(parent) + self.setMinimumWidth(200) + self.setMaximumWidth(400) + self._current_path = "" + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(4, 4, 4, 4) + + self._title = QLabel("Preview") + self._title.setStyleSheet("font-weight: bold; font-size: 12px; padding: 2px;") + self._title.setAlignment(Qt.AlignCenter) + layout.addWidget(self._title) + + self._image_label = QLabel() + self._image_label.setAlignment(Qt.AlignCenter) + self._image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self._image_label.setMinimumSize(QSize(180, 180)) + self._image_label.setStyleSheet( + "background-color: #1a1a1a; border: 1px solid #333; border-radius: 4px;" + ) + self._image_label.setScaledContents(False) + layout.addWidget(self._image_label) + + self._info_label = QLabel() + self._info_label.setAlignment(Qt.AlignCenter) + self._info_label.setWordWrap(True) + self._info_label.setStyleSheet("color: #888; font-size: 10px; padding: 2px;") + layout.addWidget(self._info_label) + + def show_preview(self, file_path: str): + if not file_path or file_path == self._current_path: + return + + self._current_path = file_path + p = Path(file_path) + + if not p.exists(): + self._image_label.setText("File not found") + self._info_label.setText("") + return + + if p.suffix.lower() not in self.SUPPORTED_EXTENSIONS: + self._image_label.setText("Preview not available\nfor this file type") + self._info_label.setText(p.name) + return + + pixmap = QPixmap(file_path) + if pixmap.isNull(): + self._image_label.setText("Cannot load image") + self._info_label.setText(p.name) + return + + # Scale to fit while keeping aspect ratio + label_size = self._image_label.size() + scaled = pixmap.scaled( + label_size, Qt.KeepAspectRatio, Qt.SmoothTransformation + ) + self._image_label.setPixmap(scaled) + + # Show info + size = p.stat().st_size + size_str = self._format_size(size) + self._info_label.setText( + f"{p.name}\n{pixmap.width()}x{pixmap.height()} | {size_str}" + ) + self._title.setText("Preview") + + def clear_preview(self): + self._current_path = "" + self._image_label.clear() + self._image_label.setText("No preview") + self._info_label.setText("") + + def resizeEvent(self, event): + super().resizeEvent(event) + # Re-render if we have a current image + if self._current_path: + path = self._current_path + self._current_path = "" + self.show_preview(path) + + @staticmethod + def _format_size(size_bytes: int) -> str: + if size_bytes == 0: + return "0 B" + units = ["B", "KB", "MB", "GB"] + i = 0 + size = float(size_bytes) + while size >= 1024 and i < len(units) - 1: + size /= 1024 + i += 1 + return f"{size:.1f} {units[i]}" if i > 0 else f"{int(size)} B" diff --git a/czkawka_pyside6/app/progress_widget.py b/czkawka_pyside6/app/progress_widget.py new file mode 100644 index 000000000..185e0790d --- /dev/null +++ b/czkawka_pyside6/app/progress_widget.py @@ -0,0 +1,325 @@ +import json +import time +from pathlib import Path + +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QProgressBar, QLabel, QHBoxLayout +) +from PySide6.QtCore import Qt, QTimer + +from .models import ActiveTab, ScanProgress + + +class ProgressWidget(QWidget): + """Two-bar progress widget matching Slint/Krokiet feature parity. + + Shows: + - Current stage progress bar (entries or bytes within one stage) + - Overall progress bar (across all stages) + - Stage name with counts + - Elapsed time + - Phase step indicators + """ + + # File where we persist the last file-collection count per directory set, + # so we can estimate the collection stage percentage on the next scan. + _ESTIMATE_FILE = Path.home() / ".config" / "czkawka_pyside6" / "scan_estimates.json" + + _BAR_STYLE = """ + QProgressBar {{ + border: 1px solid #555; border-radius: 3px; + text-align: center; background-color: #2a2a2a; + font-size: 10px; color: #ccc; + }} + QProgressBar::chunk {{ + background: qlineargradient(x1:0, y1:0, x2:1, y2:0, + stop:0 {c1}, stop:1 {c2}); + border-radius: 2px; + }} + """ + + def __init__(self, parent=None): + super().__init__(parent) + self.setVisible(False) + self._start_time = 0.0 + self._active_tab = ActiveTab.DUPLICATE_FILES + self._last_collection_count = 0 # Files found during collection phase + self._estimates: dict[str, int] = {} + self._load_estimates() + self._setup_ui() + + self._timer = QTimer(self) + self._timer.setInterval(500) + self._timer.timeout.connect(self._update_elapsed) + + # ── UI setup ────────────────────────────────────────────── + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 6, 8, 6) + layout.setSpacing(3) + + # Row 1: stage label + elapsed + row1 = QHBoxLayout() + self._stage_label = QLabel("Initializing...") + self._stage_label.setStyleSheet("font-weight: bold; font-size: 12px;") + row1.addWidget(self._stage_label) + row1.addStretch() + self._elapsed_label = QLabel("") + self._elapsed_label.setStyleSheet("color: #888; font-size: 11px;") + row1.addWidget(self._elapsed_label) + layout.addLayout(row1) + + # Row 2: current stage bar "Current stage" NN% + row2 = QHBoxLayout() + row2.setSpacing(6) + lbl2 = QLabel("Current") + lbl2.setStyleSheet("color: #aaa; font-size: 10px;") + lbl2.setFixedWidth(48) + row2.addWidget(lbl2) + self._stage_bar = QProgressBar() + self._stage_bar.setFixedHeight(14) + self._stage_bar.setTextVisible(False) + self._stage_bar.setStyleSheet( + self._BAR_STYLE.format(c1="#1b4332", c2="#2d6a4f") + ) + row2.addWidget(self._stage_bar) + self._stage_pct = QLabel("") + self._stage_pct.setFixedWidth(40) + self._stage_pct.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self._stage_pct.setStyleSheet("color: #aaa; font-size: 10px;") + row2.addWidget(self._stage_pct) + layout.addLayout(row2) + + # Row 3: overall bar "Overall" NN% + row3 = QHBoxLayout() + row3.setSpacing(6) + lbl3 = QLabel("Overall") + lbl3.setStyleSheet("color: #aaa; font-size: 10px;") + lbl3.setFixedWidth(48) + row3.addWidget(lbl3) + self._overall_bar = QProgressBar() + self._overall_bar.setFixedHeight(14) + self._overall_bar.setTextVisible(False) + self._overall_bar.setStyleSheet( + self._BAR_STYLE.format(c1="#2d6a4f", c2="#40916c") + ) + row3.addWidget(self._overall_bar) + self._overall_pct = QLabel("") + self._overall_pct.setFixedWidth(40) + self._overall_pct.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self._overall_pct.setStyleSheet("color: #aaa; font-size: 10px;") + row3.addWidget(self._overall_pct) + layout.addLayout(row3) + + # Row 4: detail counts + row4 = QHBoxLayout() + self._detail_label = QLabel("") + self._detail_label.setStyleSheet("color: #888; font-size: 10px;") + row4.addWidget(self._detail_label) + row4.addStretch() + self._size_label = QLabel("") + self._size_label.setStyleSheet("color: #888; font-size: 10px;") + self._size_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + row4.addWidget(self._size_label) + layout.addLayout(row4) + + # Row 5: step indicators + self._steps_label = QLabel("") + self._steps_label.setStyleSheet("color: #666; font-size: 9px;") + self._steps_label.setAlignment(Qt.AlignCenter) + self._steps_label.setWordWrap(True) + layout.addWidget(self._steps_label) + + # ── Public API ──────────────────────────────────────────── + + def start(self, tab: ActiveTab = None): + if tab is not None: + self._active_tab = tab + self._start_time = time.monotonic() + self._last_collection_count = 0 + self.setVisible(True) + + for bar in (self._stage_bar, self._overall_bar): + bar.setMaximum(0) # indeterminate + bar.setValue(0) + self._stage_pct.setText("") + self._overall_pct.setText("") + self._stage_label.setText("Starting scan...") + self._detail_label.setText("") + self._size_label.setText("") + self._elapsed_label.setText("0s") + self._steps_label.setText("") + self._timer.start() + + def stop(self): + self._timer.stop() + elapsed = time.monotonic() - self._start_time if self._start_time else 0 + self._elapsed_label.setText(f"Completed in {self._format_time(elapsed)}") + + for bar, lbl in ((self._stage_bar, self._stage_pct), + (self._overall_bar, self._overall_pct)): + bar.setMaximum(100) + bar.setValue(100) + lbl.setText("100%") + + self._stage_label.setText("Scan complete") + self._steps_label.setText("") + + # Save collection count for next-scan estimation + if self._last_collection_count > 0: + self._save_estimate(self._last_collection_count) + + QTimer.singleShot(3000, self._auto_hide) + + def update_progress(self, progress: ScanProgress): + """Main update method called by the scan runner.""" + stage_name = progress.stage_name or progress.step_name or "" + idx = progress.current_stage_idx + max_idx = progress.max_stage_idx + checked = progress.entries_checked + to_check = progress.entries_to_check + b_checked = progress.bytes_checked + b_to_check = progress.bytes_to_check + + # ── Stage label ── + self._stage_label.setText(stage_name) + + # ── Update step indicators using stage index ── + if max_idx > 0: + self._update_steps_from_index(idx, max_idx) + + # ── Current-stage bar ── + is_collecting = (idx == 0 and to_check == 0) + + if is_collecting: + # Collection phase: use estimate from previous scan + self._last_collection_count = max(self._last_collection_count, checked) + estimate = self._get_estimate() + if estimate > 0 and checked > 0: + pct = min(99, int(checked * 100 / estimate)) + self._stage_bar.setMaximum(100) + self._stage_bar.setValue(pct) + self._stage_pct.setText(f"~{pct}%") + self._detail_label.setText(f"{checked:,} / ~{estimate:,} files") + else: + self._stage_bar.setMaximum(0) # indeterminate + self._stage_pct.setText("") + self._detail_label.setText(f"{checked:,} files" if checked else "") + + elif to_check > 0: + # Normal stage with known total + if b_to_check > 0: + # Byte-based progress (hashing) + pct = min(99, int(b_checked * 100 / b_to_check)) + self._stage_bar.setMaximum(100) + self._stage_bar.setValue(pct) + self._stage_pct.setText(f"{pct}%") + self._detail_label.setText(f"{checked:,} / {to_check:,}") + self._size_label.setText( + f"{self._format_size(b_checked)} / {self._format_size(b_to_check)}" + ) + else: + # Entry-count based progress + pct = min(99, int(checked * 100 / to_check)) + self._stage_bar.setMaximum(100) + self._stage_bar.setValue(pct) + self._stage_pct.setText(f"{pct}%") + self._detail_label.setText(f"{checked:,} / {to_check:,}") + self._size_label.setText("") + + else: + # Cache loading/saving or unknown total + self._stage_bar.setMaximum(0) # indeterminate spinner + self._stage_pct.setText("") + self._detail_label.setText("") + self._size_label.setText("") + + # ── Overall bar ── + if max_idx > 0: + if to_check > 0: + stage_frac = (b_checked / b_to_check) if b_to_check > 0 else (checked / to_check) + elif is_collecting and self._get_estimate() > 0 and checked > 0: + stage_frac = min(0.99, checked / self._get_estimate()) + else: + stage_frac = 0 + overall = (idx + min(stage_frac, 0.99)) / (max_idx + 1) + overall_pct = min(99, int(overall * 100)) + self._overall_bar.setMaximum(100) + self._overall_bar.setValue(overall_pct) + self._overall_pct.setText(f"{overall_pct}%") + else: + self._overall_bar.setMaximum(0) + self._overall_pct.setText("") + + # ── Collection estimate persistence ─────────────────────── + + def _get_estimate_key(self) -> str: + """Key for the estimate cache based on active tab.""" + return self._active_tab.name + + def _get_estimate(self) -> int: + return self._estimates.get(self._get_estimate_key(), 0) + + def _save_estimate(self, count: int): + self._estimates[self._get_estimate_key()] = count + try: + self._ESTIMATE_FILE.parent.mkdir(parents=True, exist_ok=True) + self._ESTIMATE_FILE.write_text(json.dumps(self._estimates)) + except OSError: + pass + + def _load_estimates(self): + try: + if self._ESTIMATE_FILE.exists(): + self._estimates = json.loads(self._ESTIMATE_FILE.read_text()) + except (json.JSONDecodeError, OSError): + self._estimates = {} + + # ── Step indicator ──────────────────────────────────────── + + def _update_steps_from_index(self, current_idx: int, max_idx: int): + """Build step display directly from stage index.""" + total_stages = max_idx + 1 + parts = [] + for i in range(total_stages): + if i < current_idx: + parts.append(f"[{i+1} done]") + elif i == current_idx: + parts.append(f"[{i+1} >>]") + else: + parts.append(f"[{i+1}]") + self._steps_label.setText(" ".join(parts)) + + # ── Internals ───────────────────────────────────────────── + + def _auto_hide(self): + self.setVisible(False) + + def _update_elapsed(self): + elapsed = time.monotonic() - self._start_time + self._elapsed_label.setText(self._format_time(elapsed)) + + @staticmethod + def _format_time(seconds: float) -> str: + if seconds < 60: + return f"{int(seconds)}s" + minutes = int(seconds // 60) + secs = int(seconds % 60) + if minutes < 60: + return f"{minutes}m {secs}s" + hours = minutes // 60 + mins = minutes % 60 + return f"{hours}h {mins}m" + + @staticmethod + def _format_size(size_bytes: int) -> str: + if size_bytes == 0: + return "0 B" + units = ["B", "KB", "MB", "GB", "TB"] + i = 0 + size = float(size_bytes) + while size >= 1024 and i < len(units) - 1: + size /= 1024 + i += 1 + return f"{size:.1f} {units[i]}" if i > 0 else f"{int(size)} B" diff --git a/czkawka_pyside6/app/results_view.py b/czkawka_pyside6/app/results_view.py new file mode 100644 index 000000000..fc1aca09c --- /dev/null +++ b/czkawka_pyside6/app/results_view.py @@ -0,0 +1,280 @@ +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QTreeWidget, QTreeWidgetItem, QHeaderView, + QAbstractItemView, QMenu, QLabel, QHBoxLayout +) +from PySide6.QtCore import Signal, Qt +from PySide6.QtGui import QColor, QBrush, QFont, QAction + +from .models import ( + ActiveTab, ResultEntry, TAB_COLUMNS, GROUPED_TABS, SelectMode +) + + +class ResultsView(QWidget): + """Results display with tree view for grouped results and table for flat results.""" + + selection_changed = Signal(int) # number of selected items + item_activated = Signal(object) # ResultEntry + context_menu_requested = Signal(object, object) # QPoint, ResultEntry + + # Colors + HEADER_BG = QColor(60, 60, 80) + HEADER_FG = QColor(220, 220, 255) + SELECTED_BG = QColor(40, 80, 40) + + def __init__(self, parent=None): + super().__init__(parent) + self._active_tab = ActiveTab.DUPLICATE_FILES + self._results: list[ResultEntry] = [] + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + # Summary bar + summary_layout = QHBoxLayout() + self._summary_label = QLabel("No results") + self._summary_label.setStyleSheet("padding: 4px;") + summary_layout.addWidget(self._summary_label) + self._selection_label = QLabel("") + self._selection_label.setAlignment(Qt.AlignRight) + self._selection_label.setStyleSheet("padding: 4px; color: #aaa;") + summary_layout.addWidget(self._selection_label) + layout.addLayout(summary_layout) + + # Tree widget for results + self._tree = QTreeWidget() + self._tree.setSelectionMode(QAbstractItemView.ExtendedSelection) + self._tree.setRootIsDecorated(False) + self._tree.setAlternatingRowColors(True) + self._tree.setContextMenuPolicy(Qt.CustomContextMenu) + self._tree.customContextMenuRequested.connect(self._on_context_menu) + self._tree.itemChanged.connect(self._on_item_changed) + self._tree.itemDoubleClicked.connect(self._on_item_double_clicked) + layout.addWidget(self._tree) + + def set_active_tab(self, tab: ActiveTab): + self._active_tab = tab + columns = TAB_COLUMNS.get(tab, ["Selection", "File Name", "Path"]) + self._tree.setHeaderLabels(columns) + header = self._tree.header() + for i in range(len(columns)): + if columns[i] == "Path": + header.setSectionResizeMode(i, QHeaderView.Stretch) + else: + header.setSectionResizeMode(i, QHeaderView.ResizeToContents) + + def set_results(self, results: list[ResultEntry]): + self._results = results + self._tree.blockSignals(True) + self._tree.clear() + + columns = TAB_COLUMNS.get(self._active_tab, ["Selection", "File Name", "Path"]) + + for entry in results: + if entry.header_row: + item = QTreeWidgetItem() + header_text = entry.values.get("__header", "Group") + item.setText(0, header_text) + item.setFirstColumnSpanned(True) + font = QFont() + font.setBold(True) + item.setFont(0, font) + for col in range(len(columns)): + item.setBackground(col, QBrush(self.HEADER_BG)) + item.setForeground(col, QBrush(self.HEADER_FG)) + item.setFlags(item.flags() & ~Qt.ItemIsUserCheckable) + item.setData(0, Qt.UserRole, entry) + self._tree.addTopLevelItem(item) + else: + item = QTreeWidgetItem() + # First column is checkbox (Selection) + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + item.setCheckState(0, Qt.Checked if entry.checked else Qt.Unchecked) + + for col_idx, col_name in enumerate(columns): + if col_idx == 0: # Selection column + continue + value = entry.values.get(col_name, "") + item.setText(col_idx, str(value)) + + item.setData(0, Qt.UserRole, entry) + self._tree.addTopLevelItem(item) + + self._tree.blockSignals(False) + self._update_summary() + + def _on_item_changed(self, item, column): + if column == 0: + entry = item.data(0, Qt.UserRole) + if entry and not entry.header_row: + entry.checked = item.checkState(0) == Qt.Checked + self._update_selection_count() + + def _on_item_double_clicked(self, item, column): + entry = item.data(0, Qt.UserRole) + if entry and not entry.header_row: + self.item_activated.emit(entry) + + def _on_context_menu(self, pos): + item = self._tree.itemAt(pos) + if item: + entry = item.data(0, Qt.UserRole) + if entry and not entry.header_row: + menu = QMenu(self) + open_action = QAction("Open File", self) + open_action.triggered.connect(lambda: self._open_file(entry)) + menu.addAction(open_action) + + open_dir_action = QAction("Open Containing Folder", self) + open_dir_action.triggered.connect(lambda: self._open_folder(entry)) + menu.addAction(open_dir_action) + + menu.addSeparator() + + select_action = QAction("Select", self) + select_action.triggered.connect(lambda: self._set_check(item, True)) + menu.addAction(select_action) + + deselect_action = QAction("Deselect", self) + deselect_action.triggered.connect(lambda: self._set_check(item, False)) + menu.addAction(deselect_action) + + menu.exec_(self._tree.viewport().mapToGlobal(pos)) + + def _open_file(self, entry: ResultEntry): + import subprocess, sys + path = entry.values.get("__full_path", "") + if path: + if sys.platform == "linux": + subprocess.Popen(["xdg-open", path]) + elif sys.platform == "darwin": + subprocess.Popen(["open", path]) + else: + subprocess.Popen(["start", path], shell=True) + + def _open_folder(self, entry: ResultEntry): + import subprocess, sys + from pathlib import Path + path = entry.values.get("__full_path", "") + if path: + folder = str(Path(path).parent) + if sys.platform == "linux": + subprocess.Popen(["xdg-open", folder]) + elif sys.platform == "darwin": + subprocess.Popen(["open", folder]) + else: + subprocess.Popen(["explorer", folder], shell=True) + + def _set_check(self, item, checked): + item.setCheckState(0, Qt.Checked if checked else Qt.Unchecked) + + def _update_summary(self): + total = sum(1 for r in self._results if not r.header_row) + groups = sum(1 for r in self._results if r.header_row) + if self._active_tab in GROUPED_TABS and groups > 0: + self._summary_label.setText(f"Found {total} files in {groups} groups") + elif total > 0: + self._summary_label.setText(f"Found {total} entries") + else: + self._summary_label.setText("No results") + self._update_selection_count() + + def _update_selection_count(self): + selected = sum(1 for r in self._results if r.checked and not r.header_row) + total = sum(1 for r in self._results if not r.header_row) + if selected > 0: + self._selection_label.setText(f"Selected: {selected}/{total}") + else: + self._selection_label.setText("") + self.selection_changed.emit(selected) + + def apply_selection(self, mode: SelectMode): + self._tree.blockSignals(True) + + if mode == SelectMode.SELECT_ALL: + self._select_all(True) + elif mode == SelectMode.UNSELECT_ALL: + self._select_all(False) + elif mode == SelectMode.INVERT_SELECTION: + self._invert_selection() + elif mode in (SelectMode.SELECT_BIGGEST_SIZE, SelectMode.SELECT_SMALLEST_SIZE, + SelectMode.SELECT_NEWEST, SelectMode.SELECT_OLDEST, + SelectMode.SELECT_BIGGEST_RESOLUTION, SelectMode.SELECT_SMALLEST_RESOLUTION, + SelectMode.SELECT_SHORTEST_PATH, SelectMode.SELECT_LONGEST_PATH): + self._select_by_group_criteria(mode) + + self._tree.blockSignals(False) + self._update_selection_count() + + def _select_all(self, checked: bool): + for i in range(self._tree.topLevelItemCount()): + item = self._tree.topLevelItem(i) + entry = item.data(0, Qt.UserRole) + if entry and not entry.header_row: + entry.checked = checked + item.setCheckState(0, Qt.Checked if checked else Qt.Unchecked) + + def _invert_selection(self): + for i in range(self._tree.topLevelItemCount()): + item = self._tree.topLevelItem(i) + entry = item.data(0, Qt.UserRole) + if entry and not entry.header_row: + entry.checked = not entry.checked + item.setCheckState(0, Qt.Checked if entry.checked else Qt.Unchecked) + + def _select_by_group_criteria(self, mode: SelectMode): + # First unselect all + self._select_all(False) + + if self._active_tab not in GROUPED_TABS: + return + + # Group entries by group_id + groups: dict[int, list[tuple[int, ResultEntry]]] = {} + for i in range(self._tree.topLevelItemCount()): + item = self._tree.topLevelItem(i) + entry = item.data(0, Qt.UserRole) + if entry and not entry.header_row: + groups.setdefault(entry.group_id, []).append((i, entry)) + + for group_id, items in groups.items(): + if len(items) <= 1: + continue + + best_idx = 0 + if mode == SelectMode.SELECT_BIGGEST_SIZE: + best_idx = max(range(len(items)), key=lambda j: items[j][1].values.get("__size_bytes", 0)) + elif mode == SelectMode.SELECT_SMALLEST_SIZE: + best_idx = min(range(len(items)), key=lambda j: items[j][1].values.get("__size_bytes", 0)) + elif mode == SelectMode.SELECT_NEWEST: + best_idx = max(range(len(items)), key=lambda j: items[j][1].values.get("__modified_date_ts", 0)) + elif mode == SelectMode.SELECT_OLDEST: + best_idx = min(range(len(items)), key=lambda j: items[j][1].values.get("__modified_date_ts", 0)) + elif mode == SelectMode.SELECT_SHORTEST_PATH: + best_idx = min(range(len(items)), key=lambda j: len(items[j][1].values.get("__full_path", ""))) + elif mode == SelectMode.SELECT_LONGEST_PATH: + best_idx = max(range(len(items)), key=lambda j: len(items[j][1].values.get("__full_path", ""))) + + # Select all EXCEPT the best (the one to keep) + for j, (tree_idx, entry) in enumerate(items): + if j != best_idx: + entry.checked = True + self._tree.topLevelItem(tree_idx).setCheckState(0, Qt.Checked) + + def sort_by_column(self, column: int, ascending: bool = True): + order = Qt.AscendingOrder if ascending else Qt.DescendingOrder + self._tree.sortItems(column, order) + + def get_checked_entries(self) -> list[ResultEntry]: + return [r for r in self._results if r.checked and not r.header_row] + + def get_all_entries(self) -> list[ResultEntry]: + return [r for r in self._results if not r.header_row] + + def clear(self): + self._results = [] + self._tree.clear() + self._summary_label.setText("No results") + self._selection_label.setText("") diff --git a/czkawka_pyside6/app/settings_panel.py b/czkawka_pyside6/app/settings_panel.py new file mode 100644 index 000000000..1ab5c2159 --- /dev/null +++ b/czkawka_pyside6/app/settings_panel.py @@ -0,0 +1,261 @@ +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QCheckBox, + QLineEdit, QSpinBox, QGroupBox, QFormLayout, QScrollArea, + QPushButton, QListWidget, QFileDialog, QDoubleSpinBox, + QTabWidget, QSizePolicy +) +from PySide6.QtCore import Qt, Signal + +from .models import AppSettings + + +class SettingsPanel(QWidget): + """Global application settings panel.""" + settings_changed = Signal() + close_requested = Signal() + + def __init__(self, settings: AppSettings, parent=None): + super().__init__(parent) + self._settings = settings + self._setup_ui() + + def _setup_ui(self): + main_layout = QVBoxLayout(self) + + # Header + header = QHBoxLayout() + title = QLabel("Settings") + title.setStyleSheet("font-weight: bold; font-size: 16px; padding: 4px;") + header.addWidget(title) + header.addStretch() + close_btn = QPushButton("Close") + close_btn.clicked.connect(self.close_requested.emit) + header.addWidget(close_btn) + main_layout.addLayout(header) + + # Tabs + tabs = QTabWidget() + + # General tab + tabs.addTab(self._create_general_tab(), "General") + # Directories tab + tabs.addTab(self._create_directories_tab(), "Directories") + # Filters tab + tabs.addTab(self._create_filters_tab(), "Filters") + # Preview tab + tabs.addTab(self._create_preview_tab(), "Preview") + + main_layout.addWidget(tabs) + + def _create_general_tab(self) -> QWidget: + scroll = QScrollArea() + scroll.setWidgetResizable(True) + widget = QWidget() + layout = QFormLayout(widget) + + # CLI path + cli_layout = QHBoxLayout() + self._cli_path = QLineEdit(self._settings.czkawka_cli_path) + self._cli_path.textChanged.connect( + lambda t: setattr(self._settings, 'czkawka_cli_path', t) + ) + cli_layout.addWidget(self._cli_path) + browse_btn = QPushButton("Browse") + browse_btn.clicked.connect(self._browse_cli) + cli_layout.addWidget(browse_btn) + layout.addRow("czkawka_cli Path:", cli_layout) + + # Thread number + self._threads = QSpinBox() + self._threads.setRange(0, 64) + self._threads.setValue(self._settings.thread_number) + self._threads.setSpecialValueText("Auto (all cores)") + self._threads.valueChanged.connect( + lambda v: setattr(self._settings, 'thread_number', v) + ) + layout.addRow("Thread Count:", self._threads) + + # Recursive search + recursive = QCheckBox("Recursive search") + recursive.setChecked(self._settings.recursive_search) + recursive.toggled.connect( + lambda v: setattr(self._settings, 'recursive_search', v) + ) + layout.addRow(recursive) + + # Use cache + cache = QCheckBox("Use cache for faster rescans") + cache.setChecked(self._settings.use_cache) + cache.toggled.connect( + lambda v: setattr(self._settings, 'use_cache', v) + ) + layout.addRow(cache) + + # Move to trash + trash = QCheckBox("Move to trash instead of permanent delete") + trash.setChecked(self._settings.move_to_trash) + trash.toggled.connect( + lambda v: setattr(self._settings, 'move_to_trash', v) + ) + layout.addRow(trash) + + # Hide hard links + hardlinks = QCheckBox("Hide hard links") + hardlinks.setChecked(self._settings.hide_hard_links) + hardlinks.toggled.connect( + lambda v: setattr(self._settings, 'hide_hard_links', v) + ) + layout.addRow(hardlinks) + + # Save as JSON + save_json = QCheckBox("Save results as JSON (instead of text)") + save_json.setChecked(self._settings.save_as_json) + save_json.toggled.connect( + lambda v: setattr(self._settings, 'save_as_json', v) + ) + layout.addRow(save_json) + + scroll.setWidget(widget) + return scroll + + def _create_directories_tab(self) -> QWidget: + widget = QWidget() + layout = QVBoxLayout(widget) + + # Included paths + inc_group = QGroupBox("Included Directories") + inc_layout = QVBoxLayout(inc_group) + + self._inc_list = QListWidget() + for path in self._settings.included_paths: + self._inc_list.addItem(path) + inc_layout.addWidget(self._inc_list) + + inc_btns = QHBoxLayout() + add_inc = QPushButton("Add") + add_inc.clicked.connect(self._add_included) + inc_btns.addWidget(add_inc) + rem_inc = QPushButton("Remove") + rem_inc.clicked.connect(self._remove_included) + inc_btns.addWidget(rem_inc) + inc_btns.addStretch() + inc_layout.addLayout(inc_btns) + layout.addWidget(inc_group) + + # Excluded paths + exc_group = QGroupBox("Excluded Directories") + exc_layout = QVBoxLayout(exc_group) + + self._exc_list = QListWidget() + for path in self._settings.excluded_paths: + self._exc_list.addItem(path) + exc_layout.addWidget(self._exc_list) + + exc_btns = QHBoxLayout() + add_exc = QPushButton("Add") + add_exc.clicked.connect(self._add_excluded) + exc_btns.addWidget(add_exc) + rem_exc = QPushButton("Remove") + rem_exc.clicked.connect(self._remove_excluded) + exc_btns.addWidget(rem_exc) + exc_btns.addStretch() + exc_layout.addLayout(exc_btns) + layout.addWidget(exc_group) + + return widget + + def _create_filters_tab(self) -> QWidget: + widget = QWidget() + layout = QFormLayout(widget) + + # Excluded items + self._excluded_items = QLineEdit(self._settings.excluded_items) + self._excluded_items.setPlaceholderText("Wildcard patterns, comma-separated (e.g. *.tmp,cache_*)") + self._excluded_items.textChanged.connect( + lambda t: setattr(self._settings, 'excluded_items', t) + ) + layout.addRow("Excluded Items:", self._excluded_items) + + # Allowed extensions + self._allowed_ext = QLineEdit(self._settings.allowed_extensions) + self._allowed_ext.setPlaceholderText("e.g. jpg,png,gif") + self._allowed_ext.textChanged.connect( + lambda t: setattr(self._settings, 'allowed_extensions', t) + ) + layout.addRow("Allowed Extensions:", self._allowed_ext) + + # Excluded extensions + self._excluded_ext = QLineEdit(self._settings.excluded_extensions) + self._excluded_ext.setPlaceholderText("e.g. log,tmp") + self._excluded_ext.textChanged.connect( + lambda t: setattr(self._settings, 'excluded_extensions', t) + ) + layout.addRow("Excluded Extensions:", self._excluded_ext) + + # Min file size + self._min_size = QLineEdit(self._settings.minimum_file_size) + self._min_size.setPlaceholderText("In bytes (e.g. 1024)") + self._min_size.textChanged.connect( + lambda t: setattr(self._settings, 'minimum_file_size', t) + ) + layout.addRow("Minimum File Size:", self._min_size) + + # Max file size + self._max_size = QLineEdit(self._settings.maximum_file_size) + self._max_size.setPlaceholderText("In bytes (leave empty for no limit)") + self._max_size.textChanged.connect( + lambda t: setattr(self._settings, 'maximum_file_size', t) + ) + layout.addRow("Maximum File Size:", self._max_size) + + return widget + + def _create_preview_tab(self) -> QWidget: + widget = QWidget() + layout = QVBoxLayout(widget) + + preview = QCheckBox("Show image preview") + preview.setChecked(self._settings.show_image_preview) + preview.toggled.connect( + lambda v: setattr(self._settings, 'show_image_preview', v) + ) + layout.addWidget(preview) + + layout.addStretch() + return widget + + def _browse_cli(self): + path, _ = QFileDialog.getOpenFileName( + self, "Select czkawka_cli binary", "", + "Executables (*);;All Files (*)" + ) + if path: + self._cli_path.setText(path) + + def _add_included(self): + path = QFileDialog.getExistingDirectory(self, "Select Directory to Include") + if path and path not in self._settings.included_paths: + self._settings.included_paths.append(path) + self._inc_list.addItem(path) + self.settings_changed.emit() + + def _remove_included(self): + row = self._inc_list.currentRow() + if row >= 0: + self._inc_list.takeItem(row) + self._settings.included_paths.pop(row) + self.settings_changed.emit() + + def _add_excluded(self): + path = QFileDialog.getExistingDirectory(self, "Select Directory to Exclude") + if path and path not in self._settings.excluded_paths: + self._settings.excluded_paths.append(path) + self._exc_list.addItem(path) + self.settings_changed.emit() + + def _remove_excluded(self): + row = self._exc_list.currentRow() + if row >= 0: + self._exc_list.takeItem(row) + self._settings.excluded_paths.pop(row) + self.settings_changed.emit() diff --git a/czkawka_pyside6/app/state.py b/czkawka_pyside6/app/state.py new file mode 100644 index 000000000..2737323d0 --- /dev/null +++ b/czkawka_pyside6/app/state.py @@ -0,0 +1,122 @@ +import json +from pathlib import Path +from PySide6.QtCore import QObject, Signal +from .models import ( + ActiveTab, AppSettings, ToolSettings, ResultEntry, ScanProgress +) + + +class AppState(QObject): + """Central application state manager.""" + + # Signals + tab_changed = Signal(object) # ActiveTab + scan_started = Signal() + scan_finished = Signal() + scan_progress_updated = Signal(object) # ScanProgress + results_updated = Signal() + settings_changed = Signal() + included_paths_changed = Signal() + excluded_paths_changed = Signal() + preview_image_changed = Signal(str) + + def __init__(self): + super().__init__() + self.active_tab = ActiveTab.DUPLICATE_FILES + self.scanning = False + self.processing = False + self.stop_requested = False + self.settings = AppSettings() + self.tool_settings = ToolSettings() + self.results: dict[ActiveTab, list[ResultEntry]] = {} + self.progress = ScanProgress() + self.info_text = "" + self.preview_image_path = "" + self._config_path = Path.home() / ".config" / "czkawka_pyside6" + self._config_path.mkdir(parents=True, exist_ok=True) + self.load_settings() + + def set_active_tab(self, tab: ActiveTab): + if tab != self.active_tab: + self.active_tab = tab + self.tab_changed.emit(tab) + + def set_scanning(self, scanning: bool): + self.scanning = scanning + if scanning: + self.stop_requested = False + self.scan_started.emit() + else: + self.scan_finished.emit() + + def request_stop(self): + self.stop_requested = True + + def update_progress(self, progress: ScanProgress): + self.progress = progress + self.scan_progress_updated.emit(progress) + + def set_results(self, tab: ActiveTab, results: list[ResultEntry]): + self.results[tab] = results + self.results_updated.emit() + + def get_results(self, tab: ActiveTab = None) -> list[ResultEntry]: + if tab is None: + tab = self.active_tab + return self.results.get(tab, []) + + def get_checked_results(self, tab: ActiveTab = None) -> list[ResultEntry]: + return [r for r in self.get_results(tab) if r.checked and not r.header_row] + + def get_selected_count(self, tab: ActiveTab = None) -> int: + return len(self.get_checked_results(tab)) + + def save_settings(self): + config_file = self._config_path / "settings.json" + data = { + "included_paths": self.settings.included_paths, + "excluded_paths": self.settings.excluded_paths, + "excluded_items": self.settings.excluded_items, + "allowed_extensions": self.settings.allowed_extensions, + "excluded_extensions": self.settings.excluded_extensions, + "minimum_file_size": self.settings.minimum_file_size, + "maximum_file_size": self.settings.maximum_file_size, + "recursive_search": self.settings.recursive_search, + "use_cache": self.settings.use_cache, + "save_as_json": self.settings.save_as_json, + "move_to_trash": self.settings.move_to_trash, + "hide_hard_links": self.settings.hide_hard_links, + "thread_number": self.settings.thread_number, + "dark_theme": self.settings.dark_theme, + "show_image_preview": self.settings.show_image_preview, + "czkawka_cli_path": self.settings.czkawka_cli_path, + } + try: + config_file.write_text(json.dumps(data, indent=2)) + except OSError: + pass + + def load_settings(self): + config_file = self._config_path / "settings.json" + if config_file.exists(): + try: + data = json.loads(config_file.read_text()) + s = self.settings + s.included_paths = data.get("included_paths", s.included_paths) + s.excluded_paths = data.get("excluded_paths", s.excluded_paths) + s.excluded_items = data.get("excluded_items", s.excluded_items) + s.allowed_extensions = data.get("allowed_extensions", s.allowed_extensions) + s.excluded_extensions = data.get("excluded_extensions", s.excluded_extensions) + s.minimum_file_size = data.get("minimum_file_size", s.minimum_file_size) + s.maximum_file_size = data.get("maximum_file_size", s.maximum_file_size) + s.recursive_search = data.get("recursive_search", s.recursive_search) + s.use_cache = data.get("use_cache", s.use_cache) + s.save_as_json = data.get("save_as_json", s.save_as_json) + s.move_to_trash = data.get("move_to_trash", s.move_to_trash) + s.hide_hard_links = data.get("hide_hard_links", s.hide_hard_links) + s.thread_number = data.get("thread_number", s.thread_number) + s.dark_theme = data.get("dark_theme", s.dark_theme) + s.show_image_preview = data.get("show_image_preview", s.show_image_preview) + s.czkawka_cli_path = data.get("czkawka_cli_path", s.czkawka_cli_path) + except (json.JSONDecodeError, OSError): + pass diff --git a/czkawka_pyside6/app/tool_settings.py b/czkawka_pyside6/app/tool_settings.py new file mode 100644 index 000000000..a3c5d9e44 --- /dev/null +++ b/czkawka_pyside6/app/tool_settings.py @@ -0,0 +1,504 @@ +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QLabel, QComboBox, QCheckBox, + QSlider, QLineEdit, QGroupBox, QFormLayout, QScrollArea, + QHBoxLayout, QSpinBox, QPushButton, QSizePolicy +) +from PySide6.QtCore import Qt, Signal + +from .models import ( + ActiveTab, ToolSettings, CheckingMethod, HashType, + ImageHashAlg, ImageFilter, MusicSearchMethod, + VideoCropDetect, VideoCropMechanism, VideoCodec, +) + + +class ToolSettingsPanel(QWidget): + """Per-tool settings panel shown on the right side.""" + settings_changed = Signal() + + def __init__(self, tool_settings: ToolSettings, parent=None): + super().__init__(parent) + self._ts = tool_settings + self._active_tab = ActiveTab.DUPLICATE_FILES + self.setMinimumWidth(250) + self.setMaximumWidth(350) + self._panels: dict[ActiveTab, QWidget] = {} + self._setup_ui() + + def _setup_ui(self): + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(4, 4, 4, 4) + + title = QLabel("Tool Settings") + title.setStyleSheet("font-weight: bold; font-size: 13px; padding: 4px;") + main_layout.addWidget(title) + + self._scroll = QScrollArea() + self._scroll.setWidgetResizable(True) + self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + + self._container = QWidget() + self._container_layout = QVBoxLayout(self._container) + self._container_layout.setContentsMargins(0, 0, 0, 0) + + # Create all panel widgets + self._panels[ActiveTab.DUPLICATE_FILES] = self._create_duplicate_panel() + self._panels[ActiveTab.SIMILAR_IMAGES] = self._create_similar_images_panel() + self._panels[ActiveTab.SIMILAR_VIDEOS] = self._create_similar_videos_panel() + self._panels[ActiveTab.SIMILAR_MUSIC] = self._create_similar_music_panel() + self._panels[ActiveTab.BIG_FILES] = self._create_big_files_panel() + self._panels[ActiveTab.BROKEN_FILES] = self._create_broken_files_panel() + self._panels[ActiveTab.BAD_NAMES] = self._create_bad_names_panel() + self._panels[ActiveTab.EXIF_REMOVER] = self._create_exif_panel() + self._panels[ActiveTab.VIDEO_OPTIMIZER] = self._create_video_optimizer_panel() + + for panel in self._panels.values(): + self._container_layout.addWidget(panel) + panel.setVisible(False) + + self._container_layout.addStretch() + self._scroll.setWidget(self._container) + main_layout.addWidget(self._scroll) + + def set_active_tab(self, tab: ActiveTab): + self._active_tab = tab + for t, panel in self._panels.items(): + panel.setVisible(t == tab) + + def _create_duplicate_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + # Check method + self._dup_method = QComboBox() + self._dup_method.addItems(["Hash", "Size", "Name", "Size and Name"]) + method_map = {CheckingMethod.HASH: 0, CheckingMethod.SIZE: 1, + CheckingMethod.NAME: 2, CheckingMethod.SIZE_NAME: 3} + self._dup_method.setCurrentIndex(method_map.get(self._ts.dup_check_method, 0)) + self._dup_method.currentIndexChanged.connect(self._on_dup_method_changed) + layout.addRow("Check Method:", self._dup_method) + + # Hash type + self._dup_hash = QComboBox() + self._dup_hash.addItems(["Blake3", "CRC32", "XXH3"]) + hash_map = {HashType.BLAKE3: 0, HashType.CRC32: 1, HashType.XXH3: 2} + self._dup_hash.setCurrentIndex(hash_map.get(self._ts.dup_hash_type, 0)) + self._dup_hash.currentIndexChanged.connect(self._on_dup_hash_changed) + layout.addRow("Hash Type:", self._dup_hash) + + # Case sensitive + self._dup_case = QCheckBox("Case sensitive name comparison") + self._dup_case.setChecked(self._ts.dup_name_case_sensitive) + self._dup_case.toggled.connect(lambda v: setattr(self._ts, 'dup_name_case_sensitive', v)) + layout.addRow(self._dup_case) + + return panel + + def _on_dup_method_changed(self, idx): + methods = [CheckingMethod.HASH, CheckingMethod.SIZE, + CheckingMethod.NAME, CheckingMethod.SIZE_NAME] + self._ts.dup_check_method = methods[idx] + self.settings_changed.emit() + + def _on_dup_hash_changed(self, idx): + hashes = [HashType.BLAKE3, HashType.CRC32, HashType.XXH3] + self._ts.dup_hash_type = hashes[idx] + self.settings_changed.emit() + + def _create_similar_images_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + # Hash size + self._img_hash_size = QComboBox() + self._img_hash_size.addItems(["8", "16", "32", "64"]) + size_map = {8: 0, 16: 1, 32: 2, 64: 3} + self._img_hash_size.setCurrentIndex(size_map.get(self._ts.img_hash_size, 1)) + self._img_hash_size.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'img_hash_size', [8, 16, 32, 64][idx]) + ) + layout.addRow("Hash Size:", self._img_hash_size) + + # Resize algorithm + self._img_filter = QComboBox() + self._img_filter.addItems(["Lanczos3", "Gaussian", "CatmullRom", "Triangle", "Nearest"]) + filters = [ImageFilter.LANCZOS3, ImageFilter.GAUSSIAN, ImageFilter.CATMULL_ROM, + ImageFilter.TRIANGLE, ImageFilter.NEAREST] + filter_idx = filters.index(self._ts.img_filter) if self._ts.img_filter in filters else 4 + self._img_filter.setCurrentIndex(filter_idx) + self._img_filter.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'img_filter', filters[idx]) + ) + layout.addRow("Resize Algorithm:", self._img_filter) + + # Hash type + self._img_hash_alg = QComboBox() + self._img_hash_alg.addItems(["Mean", "Gradient", "BlockHash", "VertGradient", + "DoubleGradient", "Median"]) + algs = [ImageHashAlg.MEAN, ImageHashAlg.GRADIENT, ImageHashAlg.BLOCKHASH, + ImageHashAlg.VERT_GRADIENT, ImageHashAlg.DOUBLE_GRADIENT, ImageHashAlg.MEDIAN] + alg_idx = algs.index(self._ts.img_hash_alg) if self._ts.img_hash_alg in algs else 1 + self._img_hash_alg.setCurrentIndex(alg_idx) + self._img_hash_alg.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'img_hash_alg', algs[idx]) + ) + layout.addRow("Hash Type:", self._img_hash_alg) + + # Ignore same size + self._img_ignore_size = QCheckBox("Ignore same size") + self._img_ignore_size.setChecked(self._ts.img_ignore_same_size) + self._img_ignore_size.toggled.connect( + lambda v: setattr(self._ts, 'img_ignore_same_size', v) + ) + layout.addRow(self._img_ignore_size) + + # Max difference slider + diff_layout = QHBoxLayout() + self._img_diff_slider = QSlider(Qt.Horizontal) + self._img_diff_slider.setRange(0, 40) + self._img_diff_slider.setValue(self._ts.img_max_difference) + self._img_diff_label = QLabel(str(self._ts.img_max_difference)) + self._img_diff_slider.valueChanged.connect(self._on_img_diff_changed) + diff_layout.addWidget(self._img_diff_slider) + diff_layout.addWidget(self._img_diff_label) + layout.addRow("Max Difference:", diff_layout) + + return panel + + def _on_img_diff_changed(self, value): + self._ts.img_max_difference = value + self._img_diff_label.setText(str(value)) + self.settings_changed.emit() + + def _create_similar_videos_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + # Ignore same size + vid_ignore = QCheckBox("Ignore same size") + vid_ignore.setChecked(self._ts.vid_ignore_same_size) + vid_ignore.toggled.connect(lambda v: setattr(self._ts, 'vid_ignore_same_size', v)) + layout.addRow(vid_ignore) + + # Crop detect + self._vid_crop = QComboBox() + self._vid_crop.addItems(["LetterBox", "Motion", "None"]) + crops = [VideoCropDetect.LETTERBOX, VideoCropDetect.MOTION, VideoCropDetect.NONE] + crop_idx = crops.index(self._ts.vid_crop_detect) if self._ts.vid_crop_detect in crops else 0 + self._vid_crop.setCurrentIndex(crop_idx) + self._vid_crop.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'vid_crop_detect', crops[idx]) + ) + layout.addRow("Crop Detect:", self._vid_crop) + + # Max difference + diff_layout = QHBoxLayout() + self._vid_diff_slider = QSlider(Qt.Horizontal) + self._vid_diff_slider.setRange(0, 20) + self._vid_diff_slider.setValue(self._ts.vid_max_difference) + self._vid_diff_label = QLabel(str(self._ts.vid_max_difference)) + self._vid_diff_slider.valueChanged.connect(lambda v: ( + setattr(self._ts, 'vid_max_difference', v), + self._vid_diff_label.setText(str(v)) + )) + diff_layout.addWidget(self._vid_diff_slider) + diff_layout.addWidget(self._vid_diff_label) + layout.addRow("Max Difference:", diff_layout) + + # Skip forward + skip_layout = QHBoxLayout() + self._vid_skip_slider = QSlider(Qt.Horizontal) + self._vid_skip_slider.setRange(1, 360) + self._vid_skip_slider.setValue(self._ts.vid_skip_forward) + self._vid_skip_label = QLabel(str(self._ts.vid_skip_forward)) + self._vid_skip_slider.valueChanged.connect(lambda v: ( + setattr(self._ts, 'vid_skip_forward', v), + self._vid_skip_label.setText(str(v)) + )) + skip_layout.addWidget(self._vid_skip_slider) + skip_layout.addWidget(self._vid_skip_label) + layout.addRow("Skip Forward (s):", skip_layout) + + # Duration + dur_layout = QHBoxLayout() + self._vid_dur_slider = QSlider(Qt.Horizontal) + self._vid_dur_slider.setRange(1, 60) + self._vid_dur_slider.setValue(self._ts.vid_duration) + self._vid_dur_label = QLabel(str(self._ts.vid_duration)) + self._vid_dur_slider.valueChanged.connect(lambda v: ( + setattr(self._ts, 'vid_duration', v), + self._vid_dur_label.setText(str(v)) + )) + dur_layout.addWidget(self._vid_dur_slider) + dur_layout.addWidget(self._vid_dur_label) + layout.addRow("Hash Duration (s):", dur_layout) + + return panel + + def _create_similar_music_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + # Search method + self._music_method = QComboBox() + self._music_method.addItems(["Tags", "Fingerprint"]) + self._music_method.setCurrentIndex( + 0 if self._ts.music_search_method == MusicSearchMethod.TAGS else 1 + ) + self._music_method.currentIndexChanged.connect(self._on_music_method_changed) + layout.addRow("Audio Check Type:", self._music_method) + + # Tags group + self._tags_group = QGroupBox("Tag Matching") + tags_layout = QVBoxLayout(self._tags_group) + + self._music_approx = QCheckBox("Approximate comparison") + self._music_approx.setChecked(self._ts.music_approximate) + self._music_approx.toggled.connect(lambda v: setattr(self._ts, 'music_approximate', v)) + tags_layout.addWidget(self._music_approx) + + for attr, label in [ + ("music_title", "Title"), ("music_artist", "Artist"), + ("music_bitrate", "Bitrate"), ("music_genre", "Genre"), + ("music_year", "Year"), ("music_length", "Length"), + ]: + cb = QCheckBox(label) + cb.setChecked(getattr(self._ts, attr)) + cb.toggled.connect(lambda v, a=attr: setattr(self._ts, a, v)) + tags_layout.addWidget(cb) + + layout.addRow(self._tags_group) + + # Fingerprint group + self._fp_group = QGroupBox("Fingerprint Matching") + fp_layout = QFormLayout(self._fp_group) + + fp_similar = QCheckBox("Compare with similar titles") + fp_similar.setChecked(self._ts.music_compare_fingerprints_similar_titles) + fp_similar.toggled.connect( + lambda v: setattr(self._ts, 'music_compare_fingerprints_similar_titles', v) + ) + fp_layout.addRow(fp_similar) + + diff_layout = QHBoxLayout() + self._music_diff_slider = QSlider(Qt.Horizontal) + self._music_diff_slider.setRange(0, 100) + self._music_diff_slider.setValue(int(self._ts.music_max_difference * 10)) + self._music_diff_label = QLabel(f"{self._ts.music_max_difference:.1f}") + self._music_diff_slider.valueChanged.connect(lambda v: ( + setattr(self._ts, 'music_max_difference', v / 10.0), + self._music_diff_label.setText(f"{v / 10.0:.1f}") + )) + diff_layout.addWidget(self._music_diff_slider) + diff_layout.addWidget(self._music_diff_label) + fp_layout.addRow("Max Difference:", diff_layout) + + layout.addRow(self._fp_group) + self._on_music_method_changed(self._music_method.currentIndex()) + + return panel + + def _on_music_method_changed(self, idx): + if idx == 0: + self._ts.music_search_method = MusicSearchMethod.TAGS + else: + self._ts.music_search_method = MusicSearchMethod.CONTENT + self._tags_group.setVisible(idx == 0) + self._fp_group.setVisible(idx == 1) + self.settings_changed.emit() + + def _create_big_files_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + self._big_mode = QComboBox() + self._big_mode.addItems(["The Biggest", "The Smallest"]) + self._big_mode.setCurrentIndex(0 if self._ts.big_files_mode == "biggest" else 1) + self._big_mode.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'big_files_mode', "biggest" if idx == 0 else "smallest") + ) + layout.addRow("Method:", self._big_mode) + + self._big_count = QSpinBox() + self._big_count.setRange(1, 100000) + self._big_count.setValue(self._ts.big_files_number) + self._big_count.valueChanged.connect( + lambda v: setattr(self._ts, 'big_files_number', v) + ) + layout.addRow("Number of Files:", self._big_count) + + return panel + + def _create_broken_files_panel(self) -> QWidget: + panel = QWidget() + layout = QVBoxLayout(panel) + layout.addWidget(QLabel("File types to check:")) + + for attr, label in [ + ("broken_audio", "Audio"), ("broken_pdf", "PDF"), + ("broken_archive", "Archive"), ("broken_image", "Image"), + ("broken_video", "Video"), + ]: + cb = QCheckBox(label) + cb.setChecked(getattr(self._ts, attr)) + cb.toggled.connect(lambda v, a=attr: setattr(self._ts, a, v)) + layout.addWidget(cb) + + layout.addStretch() + return panel + + def _create_bad_names_panel(self) -> QWidget: + panel = QWidget() + layout = QVBoxLayout(panel) + layout.addWidget(QLabel("Check for:")) + + checks = [ + ("bad_names_uppercase_ext", "Uppercase extension", + "Files with .JPG, .PNG etc."), + ("bad_names_emoji", "Emoji in name", + "Files containing emoji characters"), + ("bad_names_space", "Space at start/end", + "Leading or trailing whitespace"), + ("bad_names_non_ascii", "Non-ASCII characters", + "Characters outside ASCII range"), + ("bad_names_remove_duplicated", "Remove duplicated non-alphanumeric", + "e.g. file--name..txt"), + ] + + for attr, label, hint in checks: + cb = QCheckBox(label) + cb.setChecked(getattr(self._ts, attr)) + cb.setToolTip(hint) + cb.toggled.connect(lambda v, a=attr: setattr(self._ts, a, v)) + layout.addWidget(cb) + + # Restricted charset + layout.addWidget(QLabel("Restricted charset:")) + self._bad_names_charset = QLineEdit(self._ts.bad_names_restricted_charset) + self._bad_names_charset.setPlaceholderText("Allowed special chars, comma-separated") + self._bad_names_charset.textChanged.connect( + lambda t: setattr(self._ts, 'bad_names_restricted_charset', t) + ) + layout.addWidget(self._bad_names_charset) + + layout.addStretch() + return panel + + def _create_exif_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + self._exif_tags = QLineEdit(self._ts.exif_ignored_tags) + self._exif_tags.setPlaceholderText("Tags to ignore, comma-separated") + self._exif_tags.textChanged.connect( + lambda t: setattr(self._ts, 'exif_ignored_tags', t) + ) + layout.addRow("Ignored EXIF Tags:", self._exif_tags) + + return panel + + def _create_video_optimizer_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + # Mode + self._vo_mode = QComboBox() + self._vo_mode.addItems(["Crop", "Transcode"]) + self._vo_mode.setCurrentIndex(0 if self._ts.video_opt_mode == "crop" else 1) + self._vo_mode.currentIndexChanged.connect(self._on_vo_mode_changed) + layout.addRow("Mode:", self._vo_mode) + + # Crop settings + self._crop_group = QGroupBox("Crop Settings") + crop_layout = QFormLayout(self._crop_group) + + self._vo_crop_type = QComboBox() + self._vo_crop_type.addItems(["Black Bars", "Static Content"]) + mechs = [VideoCropMechanism.BLACKBARS, VideoCropMechanism.STATICCONTENT] + mech_idx = mechs.index(self._ts.video_crop_mechanism) if self._ts.video_crop_mechanism in mechs else 0 + self._vo_crop_type.setCurrentIndex(mech_idx) + self._vo_crop_type.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'video_crop_mechanism', mechs[idx]) + ) + crop_layout.addRow("Crop Type:", self._vo_crop_type) + + self._vo_threshold = QSpinBox() + self._vo_threshold.setRange(0, 128) + self._vo_threshold.setValue(self._ts.video_black_pixel_threshold) + self._vo_threshold.valueChanged.connect( + lambda v: setattr(self._ts, 'video_black_pixel_threshold', v) + ) + crop_layout.addRow("Black Pixel Threshold:", self._vo_threshold) + + self._vo_bar_pct = QSpinBox() + self._vo_bar_pct.setRange(50, 100) + self._vo_bar_pct.setValue(self._ts.video_black_bar_percentage) + self._vo_bar_pct.valueChanged.connect( + lambda v: setattr(self._ts, 'video_black_bar_percentage', v) + ) + crop_layout.addRow("Black Bar Min %:", self._vo_bar_pct) + + self._vo_samples = QSpinBox() + self._vo_samples.setRange(5, 1000) + self._vo_samples.setValue(self._ts.video_max_samples) + self._vo_samples.valueChanged.connect( + lambda v: setattr(self._ts, 'video_max_samples', v) + ) + crop_layout.addRow("Max Samples:", self._vo_samples) + + self._vo_min_crop = QSpinBox() + self._vo_min_crop.setRange(1, 1000) + self._vo_min_crop.setValue(self._ts.video_min_crop_size) + self._vo_min_crop.valueChanged.connect( + lambda v: setattr(self._ts, 'video_min_crop_size', v) + ) + crop_layout.addRow("Min Crop Size:", self._vo_min_crop) + + layout.addRow(self._crop_group) + + # Transcode settings + self._transcode_group = QGroupBox("Transcode Settings") + tc_layout = QFormLayout(self._transcode_group) + + self._vo_codecs = QLineEdit(self._ts.video_excluded_codecs) + self._vo_codecs.textChanged.connect( + lambda t: setattr(self._ts, 'video_excluded_codecs', t) + ) + tc_layout.addRow("Excluded Codecs:", self._vo_codecs) + + self._vo_codec = QComboBox() + self._vo_codec.addItems(["H264", "H265", "AV1", "VP9"]) + codecs = [VideoCodec.H264, VideoCodec.H265, VideoCodec.AV1, VideoCodec.VP9] + codec_idx = codecs.index(self._ts.video_codec) if self._ts.video_codec in codecs else 1 + self._vo_codec.setCurrentIndex(codec_idx) + self._vo_codec.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'video_codec', codecs[idx]) + ) + tc_layout.addRow("Target Codec:", self._vo_codec) + + self._vo_quality = QSpinBox() + self._vo_quality.setRange(0, 51) + self._vo_quality.setValue(self._ts.video_quality) + self._vo_quality.valueChanged.connect( + lambda v: setattr(self._ts, 'video_quality', v) + ) + tc_layout.addRow("Quality:", self._vo_quality) + + self._vo_fail_bigger = QCheckBox("Fail if not smaller") + self._vo_fail_bigger.setChecked(self._ts.video_fail_if_bigger) + self._vo_fail_bigger.toggled.connect( + lambda v: setattr(self._ts, 'video_fail_if_bigger', v) + ) + tc_layout.addRow(self._vo_fail_bigger) + + layout.addRow(self._transcode_group) + + self._on_vo_mode_changed(self._vo_mode.currentIndex()) + return panel + + def _on_vo_mode_changed(self, idx): + mode = "crop" if idx == 0 else "transcode" + self._ts.video_opt_mode = mode + self._crop_group.setVisible(idx == 0) + self._transcode_group.setVisible(idx == 1) + self.settings_changed.emit() diff --git a/czkawka_pyside6/main.py b/czkawka_pyside6/main.py new file mode 100644 index 000000000..6bf9a05f1 --- /dev/null +++ b/czkawka_pyside6/main.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +""" +Czkawka PySide6 - A PySide6/Qt interface for czkawka file cleanup tool. + +This application provides a graphical interface to czkawka, using the +czkawka_cli binary as its backend for all scanning operations. + +Usage: + python main.py + # or + python -m czkawka_pyside6.main + +Requirements: + - PySide6 >= 6.6.0 + - czkawka_cli binary in PATH (or configured in settings) + - Optional: send2trash (for trash support) + - Optional: Pillow (for EXIF cleaning) +""" + +import sys +import os + + +def main(): + # Set environment for better HiDPI support + os.environ.setdefault("QT_AUTO_SCREEN_SCALE_FACTOR", "1") + + from PySide6.QtWidgets import QApplication + from PySide6.QtCore import Qt + from PySide6.QtGui import QFont + + app = QApplication(sys.argv) + app.setApplicationName("Czkawka") + app.setApplicationVersion("11.0.1") + app.setOrganizationName("czkawka") + app.setDesktopFileName("com.github.qarmin.czkawka") + + # Set application icon + from app.icons import app_icon + icon = app_icon() + if not icon.isNull(): + app.setWindowIcon(icon) + + # Set default font + font = QFont() + font.setPointSize(10) + app.setFont(font) + + # Import and create main window + from app.main_window import MainWindow + window = MainWindow() + window.show() + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() From 4f377fbbe58a2f498a7dd14476f82e5aadeb10b4 Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 10:09:33 +0100 Subject: [PATCH 04/49] Make PySide6 frontend KDE6/Plasma compliant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove forced dark palette and hardcoded color stylesheets; inherit the system theme (Breeze, Adwaita, etc.) so the app looks native - Use QIcon.fromTheme() with standard XDG/FreeDesktop icon names (system-search, edit-delete, document-save, etc.) with embedded SVG fallbacks for systems without an icon theme - Use QStandardPaths.AppConfigLocation for XDG-compliant config paths - Add .desktop file (com.github.qarmin.czkawka-pyside6.desktop) - Add AppStream metainfo.xml for software center integration - Set desktopFileName and organizationDomain for proper KDE integration - Replace all hardcoded setStyleSheet color values with system palette (setEnabled(False) for muted text, QFont for bold/size, QFrame for separators, style().standardIcon() for dialog icons) - Remove forced QFont size — inherit system font settings Co-Authored-By: Claude Opus 4.6 (1M context) --- czkawka_pyside6/app/action_buttons.py | 14 --- czkawka_pyside6/app/dialogs/about_dialog.py | 24 ++--- czkawka_pyside6/app/dialogs/delete_dialog.py | 16 ++-- czkawka_pyside6/app/dialogs/move_dialog.py | 1 - czkawka_pyside6/app/dialogs/rename_dialog.py | 2 +- czkawka_pyside6/app/dialogs/select_dialog.py | 2 +- czkawka_pyside6/app/icons.py | 80 ++++++++--------- czkawka_pyside6/app/left_panel.py | 18 +--- czkawka_pyside6/app/main_window.py | 88 ++++--------------- czkawka_pyside6/app/preview_panel.py | 10 +-- czkawka_pyside6/app/progress_widget.py | 63 ++++++------- czkawka_pyside6/app/results_view.py | 30 +++++-- czkawka_pyside6/app/settings_panel.py | 5 +- czkawka_pyside6/app/state.py | 6 +- czkawka_pyside6/app/tool_settings.py | 4 +- czkawka_pyside6/main.py | 23 ++--- .../com.github.qarmin.czkawka-pyside6.desktop | 12 +++ ...github.qarmin.czkawka-pyside6.metainfo.xml | 40 +++++++++ 18 files changed, 206 insertions(+), 232 deletions(-) create mode 100644 data/com.github.qarmin.czkawka-pyside6.desktop create mode 100644 data/com.github.qarmin.czkawka-pyside6.metainfo.xml diff --git a/czkawka_pyside6/app/action_buttons.py b/czkawka_pyside6/app/action_buttons.py index 36e928a26..ef39a3947 100644 --- a/czkawka_pyside6/app/action_buttons.py +++ b/czkawka_pyside6/app/action_buttons.py @@ -46,11 +46,6 @@ def _setup_ui(self): self._scan_btn = QPushButton(icon_search(18), " Scan") self._scan_btn.setIconSize(ICON_SIZE) self._scan_btn.setMinimumWidth(90) - self._scan_btn.setStyleSheet( - "QPushButton { background-color: #2d5a27; color: white; font-weight: bold; padding: 6px 16px; }" - "QPushButton:hover { background-color: #3a7a34; }" - "QPushButton:disabled { background-color: #444; color: #888; }" - ) self._scan_btn.clicked.connect(self.scan_clicked.emit) layout.addWidget(self._scan_btn) @@ -58,10 +53,6 @@ def _setup_ui(self): self._stop_btn = QPushButton(icon_stop(18), " Stop") self._stop_btn.setIconSize(ICON_SIZE) self._stop_btn.setMinimumWidth(80) - self._stop_btn.setStyleSheet( - "QPushButton { background-color: #8a2222; color: white; font-weight: bold; padding: 6px 16px; }" - "QPushButton:hover { background-color: #aa3333; }" - ) self._stop_btn.clicked.connect(self.stop_clicked.emit) self._stop_btn.setVisible(False) layout.addWidget(self._stop_btn) @@ -80,11 +71,6 @@ def _setup_ui(self): # Delete button self._delete_btn = QPushButton(icon_delete(18), " Delete") self._delete_btn.setIconSize(ICON_SIZE) - self._delete_btn.setStyleSheet( - "QPushButton { background-color: #8a2222; color: white; padding: 6px 12px; }" - "QPushButton:hover { background-color: #aa3333; }" - "QPushButton:disabled { background-color: #444; color: #888; }" - ) self._delete_btn.clicked.connect(self.delete_clicked.emit) layout.addWidget(self._delete_btn) diff --git a/czkawka_pyside6/app/dialogs/about_dialog.py b/czkawka_pyside6/app/dialogs/about_dialog.py index 54da0fa9f..3284712ea 100644 --- a/czkawka_pyside6/app/dialogs/about_dialog.py +++ b/czkawka_pyside6/app/dialogs/about_dialog.py @@ -1,8 +1,8 @@ from PySide6.QtWidgets import ( - QDialog, QVBoxLayout, QLabel, QDialogButtonBox + QDialog, QVBoxLayout, QLabel, QDialogButtonBox, QFrame ) from PySide6.QtCore import Qt -from PySide6.QtGui import QPixmap +from PySide6.QtGui import QPixmap, QFont from ..icons import app_logo_path @@ -30,24 +30,29 @@ def __init__(self, parent=None): layout.addWidget(logo_label) title = QLabel("Czkawka") - title.setStyleSheet("font-size: 28px; font-weight: bold; padding: 4px;") + title_font = QFont() + title_font.setPointSize(22) + title_font.setBold(True) + title.setFont(title_font) title.setAlignment(Qt.AlignCenter) layout.addWidget(title) - subtitle = QLabel("PySide6 / Qt6 Edition") - subtitle.setStyleSheet("font-size: 14px; color: #6fbf73; padding: 2px;") + subtitle = QLabel("PySide6 / Qt 6 Edition") + sub_font = QFont() + sub_font.setPointSize(11) + subtitle.setFont(sub_font) subtitle.setAlignment(Qt.AlignCenter) layout.addWidget(subtitle) version = QLabel("Version 11.0.1") - version.setStyleSheet("font-size: 11px; color: #888; padding: 2px;") version.setAlignment(Qt.AlignCenter) + version.setEnabled(False) layout.addWidget(version) # Separator - sep = QLabel() - sep.setFixedHeight(1) - sep.setStyleSheet("background-color: #444; margin: 8px 40px;") + sep = QFrame() + sep.setFrameShape(QFrame.HLine) + sep.setFrameShadow(QFrame.Sunken) layout.addWidget(sep) desc = QLabel( @@ -68,7 +73,6 @@ def __init__(self, parent=None): ) desc.setWordWrap(True) desc.setAlignment(Qt.AlignCenter) - desc.setStyleSheet("padding: 6px 20px; line-height: 1.4;") layout.addWidget(desc) layout.addStretch() diff --git a/czkawka_pyside6/app/dialogs/delete_dialog.py b/czkawka_pyside6/app/dialogs/delete_dialog.py index 89ce64bc6..b82ad674d 100644 --- a/czkawka_pyside6/app/dialogs/delete_dialog.py +++ b/czkawka_pyside6/app/dialogs/delete_dialog.py @@ -1,8 +1,9 @@ from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QLabel, QDialogButtonBox, - QCheckBox, QHBoxLayout + QCheckBox, QMessageBox ) from PySide6.QtCore import Qt +from PySide6.QtGui import QFont class DeleteDialog(QDialog): @@ -16,15 +17,17 @@ def __init__(self, count: int, move_to_trash: bool = True, parent=None): layout = QVBoxLayout(self) - # Warning + # Warning icon from system theme icon_label = QLabel() - icon_label.setStyleSheet("font-size: 36px;") - icon_label.setText("Warning") + icon = self.style().standardIcon(self.style().SP_MessageBoxWarning) + icon_label.setPixmap(icon.pixmap(48, 48)) icon_label.setAlignment(Qt.AlignCenter) layout.addWidget(icon_label) msg = QLabel(f"Are you sure you want to delete {count} selected file(s)?") - msg.setStyleSheet("font-size: 14px; padding: 10px;") + msg_font = QFont() + msg_font.setPointSize(11) + msg.setFont(msg_font) msg.setAlignment(Qt.AlignCenter) msg.setWordWrap(True) layout.addWidget(msg) @@ -39,9 +42,6 @@ def __init__(self, count: int, move_to_trash: bool = True, parent=None): QDialogButtonBox.Ok | QDialogButtonBox.Cancel ) buttons.button(QDialogButtonBox.Ok).setText("Delete") - buttons.button(QDialogButtonBox.Ok).setStyleSheet( - "background-color: #8a2222; color: white; padding: 6px 20px;" - ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) diff --git a/czkawka_pyside6/app/dialogs/move_dialog.py b/czkawka_pyside6/app/dialogs/move_dialog.py index 6c754d21a..a26a51405 100644 --- a/czkawka_pyside6/app/dialogs/move_dialog.py +++ b/czkawka_pyside6/app/dialogs/move_dialog.py @@ -17,7 +17,6 @@ def __init__(self, count: int, parent=None): layout = QVBoxLayout(self) msg = QLabel(f"Move or copy {count} selected file(s) to:") - msg.setStyleSheet("font-size: 13px; padding: 6px;") layout.addWidget(msg) # Destination path diff --git a/czkawka_pyside6/app/dialogs/rename_dialog.py b/czkawka_pyside6/app/dialogs/rename_dialog.py index 4c6208ab1..da4cb4fa5 100644 --- a/czkawka_pyside6/app/dialogs/rename_dialog.py +++ b/czkawka_pyside6/app/dialogs/rename_dialog.py @@ -22,7 +22,7 @@ def __init__(self, count: int, rename_type: str = "extensions", parent=None): label = QLabel(msg) label.setWordWrap(True) - label.setStyleSheet("font-size: 13px; padding: 10px;") + label.setContentsMargins(10, 10, 10, 10) layout.addWidget(label) buttons = QDialogButtonBox( diff --git a/czkawka_pyside6/app/dialogs/select_dialog.py b/czkawka_pyside6/app/dialogs/select_dialog.py index 58ea94a11..f4153106c 100644 --- a/czkawka_pyside6/app/dialogs/select_dialog.py +++ b/czkawka_pyside6/app/dialogs/select_dialog.py @@ -30,7 +30,7 @@ def __init__(self, parent=None): layout = QVBoxLayout(self) label = QLabel("Choose selection mode:") - label.setStyleSheet("font-size: 13px; padding: 4px;") + label.setContentsMargins(4, 4, 4, 4) layout.addWidget(label) for mode, name in self.MODES: diff --git a/czkawka_pyside6/app/icons.py b/czkawka_pyside6/app/icons.py index bc094529f..9ec8fdace 100644 --- a/czkawka_pyside6/app/icons.py +++ b/czkawka_pyside6/app/icons.py @@ -1,45 +1,37 @@ -"""SVG icon resources for Czkawka PySide6 interface. +"""Icon resources for Czkawka PySide6 interface. -Uses the same SVG icons as the Krokiet (Slint) interface. -Icons are embedded as strings to avoid file path issues. +KDE-compliant icon strategy: + 1. Try QIcon.fromTheme() with standard XDG/FreeDesktop icon names + 2. Fall back to the embedded SVG icons from the Krokiet icon set + +This ensures the app looks native on KDE/Plasma (Breeze icons) and +other desktops while still working when no icon theme is installed. """ -from PySide6.QtGui import QIcon, QPixmap +from PySide6.QtGui import QIcon, QPixmap, QPainter, QImage from PySide6.QtCore import QSize, Qt from PySide6.QtSvg import QSvgRenderer -from PySide6.QtGui import QPainter, QImage from functools import lru_cache -# Fill color applied to icons for dark theme visibility -_ICON_FILL = "#cccccc" -_ICON_FILL_GREEN = "#6fbf73" -_ICON_FILL_RED = "#e57373" - -def _colorize_svg(svg: str, fill: str = _ICON_FILL) -> str: - """Inject fill color into SVG for dark theme visibility.""" - # Add fill to root svg or g elements - if 'fill="none"' not in svg and f'fill="{fill}"' not in svg: - svg = svg.replace(" QIcon: + """Return XDG theme icon if available, otherwise render the fallback SVG.""" + icon = QIcon.fromTheme(xdg_name) + if not icon.isNull(): + return icon + return _svg_to_icon(fallback_svg, size) @lru_cache(maxsize=64) -def _svg_to_icon(svg_data: str, fill: str = _ICON_FILL, size: int = 24) -> QIcon: - """Convert SVG string to QIcon with specified fill color.""" - colored = _colorize_svg(svg_data, fill) - renderer = QSvgRenderer(colored.encode("utf-8")) +def _svg_to_icon(svg_data: str, size: int = 24) -> QIcon: + """Render SVG string to QIcon. No color injection — respects original SVG.""" + renderer = QSvgRenderer(svg_data.encode("utf-8")) image = QImage(QSize(size, size), QImage.Format_ARGB32_Premultiplied) image.fill(Qt.transparent) painter = QPainter(image) renderer.render(painter) painter.end() - pixmap = QPixmap.fromImage(image) - return QIcon(pixmap) + return QIcon(QPixmap.fromImage(image)) # ─── Raw SVG data ─────────────────────────────────────────── @@ -79,53 +71,57 @@ def _svg_to_icon(svg_data: str, fill: str = _ICON_FILL, size: int = 24) -> QIcon # ─── Icon accessor functions ──────────────────────────────── +# ─── XDG theme name → fallback SVG mapping ───────────────── +# Standard FreeDesktop icon names are tried first (Breeze, Adwaita, etc.) +# If the theme doesn't have them, the embedded Krokiet SVG is used. + def icon_search(size=24): - return _svg_to_icon(SEARCH_SVG, _ICON_FILL_GREEN, size) + return _themed_icon("system-search", SEARCH_SVG, size) def icon_stop(size=24): - return _svg_to_icon(STOP_SVG, _ICON_FILL_RED, size) + return _themed_icon("process-stop", STOP_SVG, size) def icon_delete(size=24): - return _svg_to_icon(DELETE_SVG, _ICON_FILL_RED, size) + return _themed_icon("edit-delete", DELETE_SVG, size) def icon_move(size=24): - return _svg_to_icon(MOVE_SVG, _ICON_FILL, size) + return _themed_icon("folder-move", MOVE_SVG, size) def icon_save(size=24): - return _svg_to_icon(SAVE_SVG, _ICON_FILL, size) + return _themed_icon("document-save", SAVE_SVG, size) def icon_select(size=24): - return _svg_to_icon(SELECT_SVG, _ICON_FILL, size) + return _themed_icon("edit-select-all", SELECT_SVG, size) def icon_sort(size=24): - return _svg_to_icon(SORT_SVG, _ICON_FILL, size) + return _themed_icon("view-sort", SORT_SVG, size) def icon_hardlink(size=24): - return _svg_to_icon(HARDLINK_SVG, _ICON_FILL, size) + return _themed_icon("edit-link", HARDLINK_SVG, size) def icon_symlink(size=24): - return _svg_to_icon(SYMLINK_SVG, _ICON_FILL, size) + return _themed_icon("edit-link", SYMLINK_SVG, size) def icon_rename(size=24): - return _svg_to_icon(RENAME_SVG, _ICON_FILL, size) + return _themed_icon("edit-rename", RENAME_SVG, size) def icon_clean(size=24): - return _svg_to_icon(CLEAN_SVG, _ICON_FILL, size) + return _themed_icon("edit-clear-all", CLEAN_SVG, size) def icon_optimize(size=24): - return _svg_to_icon(OPTIMIZE_SVG, _ICON_FILL, size) + return _themed_icon("configure", OPTIMIZE_SVG, size) def icon_settings(size=24): - return _svg_to_icon(SETTINGS_SVG, _ICON_FILL, size) + return _themed_icon("configure", SETTINGS_SVG, size) def icon_subsettings(size=24): - return _svg_to_icon(SUBSETTINGS_SVG, _ICON_FILL, size) + return _themed_icon("preferences-other", SUBSETTINGS_SVG, size) def icon_dir(size=24): - return _svg_to_icon(DIR_SVG, _ICON_FILL, size) + return _themed_icon("folder", DIR_SVG, size) def icon_info(size=24): - return _svg_to_icon(INFO_SVG, _ICON_FILL, size) + return _themed_icon("dialog-information", INFO_SVG, size) def app_logo_path() -> str: diff --git a/czkawka_pyside6/app/left_panel.py b/czkawka_pyside6/app/left_panel.py index 8c53e77d3..8a8f87185 100644 --- a/czkawka_pyside6/app/left_panel.py +++ b/czkawka_pyside6/app/left_panel.py @@ -96,22 +96,6 @@ def _setup_ui(self): # Tool list self._tool_list = QListWidget() self._tool_list.setSpacing(1) - font = QFont() - font.setPointSize(10) - self._tool_list.setFont(font) - self._tool_list.setStyleSheet(""" - QListWidget::item { - padding: 4px 8px; - border-left: 3px solid transparent; - } - QListWidget::item:selected { - border-left: 3px solid #6fbf73; - background-color: #353535; - } - QListWidget::item:hover { - background-color: #49494926; - } - """) for tab in self.TOOL_TABS: item = QListWidgetItem(TAB_DISPLAY_NAMES[tab]) @@ -126,7 +110,7 @@ def _setup_ui(self): # Version label version_label = QLabel("Czkawka PySide6 v11.0.1") version_label.setAlignment(Qt.AlignCenter) - version_label.setStyleSheet("color: #666; font-size: 9px; padding: 2px;") + version_label.setEnabled(False) layout.addWidget(version_label) def _on_item_changed(self, current, previous): diff --git a/czkawka_pyside6/app/main_window.py b/czkawka_pyside6/app/main_window.py index 2515bd237..e6168badc 100644 --- a/czkawka_pyside6/app/main_window.py +++ b/czkawka_pyside6/app/main_window.py @@ -7,7 +7,7 @@ QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QSplitter, QStatusBar, QMessageBox, QLabel, QApplication ) -from PySide6.QtCore import Qt, QTimer +from PySide6.QtCore import Qt, QTimer, QStandardPaths from PySide6.QtGui import QPalette, QColor from .state import AppState @@ -485,83 +485,29 @@ def _on_settings_changed(self): self._bottom_panel.refresh_lists() def _apply_theme(self): - """Apply dark theme to the application.""" - if not self._state.settings.dark_theme: - return + """Apply minimal styling that works with the system theme. + KDE compliance: we inherit the desktop theme (Breeze, Adwaita, etc.) + and only add small layout tweaks that don't override colors. + """ app = QApplication.instance() - palette = QPalette() - - # Dark theme colors - palette.setColor(QPalette.Window, QColor(43, 43, 43)) - palette.setColor(QPalette.WindowText, QColor(210, 210, 210)) - palette.setColor(QPalette.Base, QColor(30, 30, 30)) - palette.setColor(QPalette.AlternateBase, QColor(38, 38, 38)) - palette.setColor(QPalette.ToolTipBase, QColor(50, 50, 50)) - palette.setColor(QPalette.ToolTipText, QColor(210, 210, 210)) - palette.setColor(QPalette.Text, QColor(210, 210, 210)) - palette.setColor(QPalette.Button, QColor(53, 53, 53)) - palette.setColor(QPalette.ButtonText, QColor(210, 210, 210)) - palette.setColor(QPalette.BrightText, QColor(255, 255, 255)) - palette.setColor(QPalette.Link, QColor(86, 140, 210)) - palette.setColor(QPalette.Highlight, QColor(60, 100, 150)) - palette.setColor(QPalette.HighlightedText, QColor(255, 255, 255)) - palette.setColor(QPalette.Disabled, QPalette.Text, QColor(128, 128, 128)) - palette.setColor(QPalette.Disabled, QPalette.ButtonText, QColor(128, 128, 128)) - - app.setPalette(palette) - - # Additional stylesheet + + # Only apply layout polish — no color overrides so the system + # theme (Breeze dark/light, Adwaita, etc.) is fully respected. app.setStyleSheet(""" - QMainWindow { background-color: #2b2b2b; } - QSplitter::handle { background-color: #404040; width: 2px; } - QTreeWidget { border: 1px solid #404040; } + QSplitter::handle { width: 2px; } QTreeWidget::item { padding: 2px; } - QTreeWidget::item:alternate { background-color: #262626; } - QTreeWidget::item:selected { background-color: #3c6496; } - QListWidget { border: 1px solid #404040; } QListWidget::item { padding: 3px; } - QListWidget::item:selected { background-color: #3c6496; } - QGroupBox { border: 1px solid #505050; border-radius: 4px; - margin-top: 8px; padding-top: 8px; } - QGroupBox::title { subcontrol-origin: margin; left: 10px; - padding: 0 4px; } - QPushButton { padding: 5px 12px; border: 1px solid #555; - border-radius: 3px; background-color: #404040; } - QPushButton:hover { background-color: #505050; } - QPushButton:pressed { background-color: #353535; } - QPushButton:disabled { background-color: #333; color: #666; } - QComboBox { padding: 4px; border: 1px solid #555; - border-radius: 3px; background-color: #404040; } - QComboBox:hover { background-color: #505050; } - QComboBox QAbstractItemView { background-color: #353535; - border: 1px solid #555; - selection-background-color: #3c6496; } - QLineEdit { padding: 4px; border: 1px solid #555; - border-radius: 3px; background-color: #353535; } - QSpinBox { padding: 4px; border: 1px solid #555; - border-radius: 3px; background-color: #353535; } - QSlider::groove:horizontal { height: 6px; background: #404040; - border-radius: 3px; } - QSlider::handle:horizontal { width: 14px; margin: -4px 0; - background: #888; border-radius: 7px; } - QSlider::handle:horizontal:hover { background: #aaa; } - QProgressBar { border: 1px solid #555; border-radius: 3px; - text-align: center; background-color: #353535; } - QProgressBar::chunk { background-color: #3c6496; border-radius: 2px; } + QGroupBox { border-radius: 4px; margin-top: 8px; padding-top: 8px; } + QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 4px; } + QPushButton { padding: 5px 12px; } + QComboBox { padding: 4px; } + QLineEdit { padding: 4px; } + QSpinBox { padding: 4px; } + QProgressBar { text-align: center; } QScrollArea { border: none; } - QTabWidget::pane { border: 1px solid #555; } - QTabBar::tab { padding: 6px 16px; border: 1px solid #555; - border-bottom: none; border-radius: 3px 3px 0 0; - background-color: #353535; } - QTabBar::tab:selected { background-color: #404040; } - QTabBar::tab:hover { background-color: #505050; } - QStatusBar { background-color: #2b2b2b; border-top: 1px solid #404040; } QCheckBox { spacing: 6px; } - QCheckBox::indicator { width: 16px; height: 16px; } - QTextEdit { border: 1px solid #404040; background-color: #1e1e1e; } - QHeaderView::section { background-color: #353535; padding: 4px; - border: 1px solid #404040; } + QHeaderView::section { padding: 4px; } """) def _auto_detect_cli(self): diff --git a/czkawka_pyside6/app/preview_panel.py b/czkawka_pyside6/app/preview_panel.py index 3e3c50586..7b53bc0b2 100644 --- a/czkawka_pyside6/app/preview_panel.py +++ b/czkawka_pyside6/app/preview_panel.py @@ -27,7 +27,9 @@ def _setup_ui(self): layout.setContentsMargins(4, 4, 4, 4) self._title = QLabel("Preview") - self._title.setStyleSheet("font-weight: bold; font-size: 12px; padding: 2px;") + font = self._title.font() + font.setBold(True) + self._title.setFont(font) self._title.setAlignment(Qt.AlignCenter) layout.addWidget(self._title) @@ -35,16 +37,14 @@ def _setup_ui(self): self._image_label.setAlignment(Qt.AlignCenter) self._image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self._image_label.setMinimumSize(QSize(180, 180)) - self._image_label.setStyleSheet( - "background-color: #1a1a1a; border: 1px solid #333; border-radius: 4px;" - ) + self._image_label.setFrameShape(QLabel.StyledPanel) self._image_label.setScaledContents(False) layout.addWidget(self._image_label) self._info_label = QLabel() self._info_label.setAlignment(Qt.AlignCenter) self._info_label.setWordWrap(True) - self._info_label.setStyleSheet("color: #888; font-size: 10px; padding: 2px;") + self._info_label.setEnabled(False) layout.addWidget(self._info_label) def show_preview(self, file_path: str): diff --git a/czkawka_pyside6/app/progress_widget.py b/czkawka_pyside6/app/progress_widget.py index 185e0790d..9c1de28d3 100644 --- a/czkawka_pyside6/app/progress_widget.py +++ b/czkawka_pyside6/app/progress_widget.py @@ -5,7 +5,7 @@ from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QProgressBar, QLabel, QHBoxLayout ) -from PySide6.QtCore import Qt, QTimer +from PySide6.QtCore import Qt, QTimer, QStandardPaths from .models import ActiveTab, ScanProgress @@ -21,23 +21,6 @@ class ProgressWidget(QWidget): - Phase step indicators """ - # File where we persist the last file-collection count per directory set, - # so we can estimate the collection stage percentage on the next scan. - _ESTIMATE_FILE = Path.home() / ".config" / "czkawka_pyside6" / "scan_estimates.json" - - _BAR_STYLE = """ - QProgressBar {{ - border: 1px solid #555; border-radius: 3px; - text-align: center; background-color: #2a2a2a; - font-size: 10px; color: #ccc; - }} - QProgressBar::chunk {{ - background: qlineargradient(x1:0, y1:0, x2:1, y2:0, - stop:0 {c1}, stop:1 {c2}); - border-radius: 2px; - }} - """ - def __init__(self, parent=None): super().__init__(parent) self.setVisible(False) @@ -62,32 +45,31 @@ def _setup_ui(self): # Row 1: stage label + elapsed row1 = QHBoxLayout() self._stage_label = QLabel("Initializing...") - self._stage_label.setStyleSheet("font-weight: bold; font-size: 12px;") + font = self._stage_label.font() + font.setBold(True) + self._stage_label.setFont(font) row1.addWidget(self._stage_label) row1.addStretch() self._elapsed_label = QLabel("") - self._elapsed_label.setStyleSheet("color: #888; font-size: 11px;") + self._elapsed_label.setEnabled(False) # Uses disabled palette color row1.addWidget(self._elapsed_label) layout.addLayout(row1) - # Row 2: current stage bar "Current stage" NN% + # Row 2: current stage bar "Current" NN% row2 = QHBoxLayout() row2.setSpacing(6) lbl2 = QLabel("Current") - lbl2.setStyleSheet("color: #aaa; font-size: 10px;") + lbl2.setEnabled(False) lbl2.setFixedWidth(48) row2.addWidget(lbl2) self._stage_bar = QProgressBar() self._stage_bar.setFixedHeight(14) self._stage_bar.setTextVisible(False) - self._stage_bar.setStyleSheet( - self._BAR_STYLE.format(c1="#1b4332", c2="#2d6a4f") - ) row2.addWidget(self._stage_bar) self._stage_pct = QLabel("") self._stage_pct.setFixedWidth(40) self._stage_pct.setAlignment(Qt.AlignRight | Qt.AlignVCenter) - self._stage_pct.setStyleSheet("color: #aaa; font-size: 10px;") + self._stage_pct.setEnabled(False) row2.addWidget(self._stage_pct) layout.addLayout(row2) @@ -95,38 +77,35 @@ def _setup_ui(self): row3 = QHBoxLayout() row3.setSpacing(6) lbl3 = QLabel("Overall") - lbl3.setStyleSheet("color: #aaa; font-size: 10px;") + lbl3.setEnabled(False) lbl3.setFixedWidth(48) row3.addWidget(lbl3) self._overall_bar = QProgressBar() self._overall_bar.setFixedHeight(14) self._overall_bar.setTextVisible(False) - self._overall_bar.setStyleSheet( - self._BAR_STYLE.format(c1="#2d6a4f", c2="#40916c") - ) row3.addWidget(self._overall_bar) self._overall_pct = QLabel("") self._overall_pct.setFixedWidth(40) self._overall_pct.setAlignment(Qt.AlignRight | Qt.AlignVCenter) - self._overall_pct.setStyleSheet("color: #aaa; font-size: 10px;") + self._overall_pct.setEnabled(False) row3.addWidget(self._overall_pct) layout.addLayout(row3) # Row 4: detail counts row4 = QHBoxLayout() self._detail_label = QLabel("") - self._detail_label.setStyleSheet("color: #888; font-size: 10px;") + self._detail_label.setEnabled(False) row4.addWidget(self._detail_label) row4.addStretch() self._size_label = QLabel("") - self._size_label.setStyleSheet("color: #888; font-size: 10px;") + self._size_label.setEnabled(False) self._size_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) row4.addWidget(self._size_label) layout.addLayout(row4) # Row 5: step indicators self._steps_label = QLabel("") - self._steps_label.setStyleSheet("color: #666; font-size: 9px;") + self._steps_label.setEnabled(False) self._steps_label.setAlignment(Qt.AlignCenter) self._steps_label.setWordWrap(True) layout.addWidget(self._steps_label) @@ -261,18 +240,26 @@ def _get_estimate_key(self) -> str: def _get_estimate(self) -> int: return self._estimates.get(self._get_estimate_key(), 0) + @staticmethod + def _estimate_file_path() -> Path: + config_dir = QStandardPaths.writableLocation(QStandardPaths.AppConfigLocation) + base = Path(config_dir) if config_dir else Path.home() / ".config" / "czkawka" + return base / "scan_estimates.json" + def _save_estimate(self, count: int): self._estimates[self._get_estimate_key()] = count try: - self._ESTIMATE_FILE.parent.mkdir(parents=True, exist_ok=True) - self._ESTIMATE_FILE.write_text(json.dumps(self._estimates)) + path = self._estimate_file_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(self._estimates)) except OSError: pass def _load_estimates(self): try: - if self._ESTIMATE_FILE.exists(): - self._estimates = json.loads(self._ESTIMATE_FILE.read_text()) + path = self._estimate_file_path() + if path.exists(): + self._estimates = json.loads(path.read_text()) except (json.JSONDecodeError, OSError): self._estimates = {} diff --git a/czkawka_pyside6/app/results_view.py b/czkawka_pyside6/app/results_view.py index fc1aca09c..6c7a37eb9 100644 --- a/czkawka_pyside6/app/results_view.py +++ b/czkawka_pyside6/app/results_view.py @@ -17,10 +17,11 @@ class ResultsView(QWidget): item_activated = Signal(object) # ResultEntry context_menu_requested = Signal(object, object) # QPoint, ResultEntry - # Colors - HEADER_BG = QColor(60, 60, 80) - HEADER_FG = QColor(220, 220, 255) - SELECTED_BG = QColor(40, 80, 40) + # Group header uses the system highlight color (darkened) so it works on + # both light and dark themes. Computed lazily on first result display. + _header_colors_ready = False + HEADER_BG = QColor() + HEADER_FG = QColor() def __init__(self, parent=None): super().__init__(parent) @@ -28,6 +29,23 @@ def __init__(self, parent=None): self._results: list[ResultEntry] = [] self._setup_ui() + def _ensure_header_colors(self): + """Derive group header colors from the system palette.""" + if self._header_colors_ready: + return + from PySide6.QtWidgets import QApplication + palette = QApplication.instance().palette() + # Use a midpoint between window and highlight for header background + win = palette.color(palette.Window) + hi = palette.color(palette.Highlight) + self.HEADER_BG = QColor( + (win.red() + hi.red()) // 2, + (win.green() + hi.green()) // 2, + (win.blue() + hi.blue()) // 2, + ) + self.HEADER_FG = palette.color(palette.HighlightedText) + self._header_colors_ready = True + def _setup_ui(self): layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -35,11 +53,10 @@ def _setup_ui(self): # Summary bar summary_layout = QHBoxLayout() self._summary_label = QLabel("No results") - self._summary_label.setStyleSheet("padding: 4px;") summary_layout.addWidget(self._summary_label) self._selection_label = QLabel("") self._selection_label.setAlignment(Qt.AlignRight) - self._selection_label.setStyleSheet("padding: 4px; color: #aaa;") + self._selection_label.setEnabled(False) summary_layout.addWidget(self._selection_label) layout.addLayout(summary_layout) @@ -66,6 +83,7 @@ def set_active_tab(self, tab: ActiveTab): header.setSectionResizeMode(i, QHeaderView.ResizeToContents) def set_results(self, results: list[ResultEntry]): + self._ensure_header_colors() self._results = results self._tree.blockSignals(True) self._tree.clear() diff --git a/czkawka_pyside6/app/settings_panel.py b/czkawka_pyside6/app/settings_panel.py index 1ab5c2159..805f6664d 100644 --- a/czkawka_pyside6/app/settings_panel.py +++ b/czkawka_pyside6/app/settings_panel.py @@ -25,7 +25,10 @@ def _setup_ui(self): # Header header = QHBoxLayout() title = QLabel("Settings") - title.setStyleSheet("font-weight: bold; font-size: 16px; padding: 4px;") + font = title.font() + font.setBold(True) + font.setPointSize(font.pointSize() + 2) + title.setFont(font) header.addWidget(title) header.addStretch() close_btn = QPushButton("Close") diff --git a/czkawka_pyside6/app/state.py b/czkawka_pyside6/app/state.py index 2737323d0..9c73524ff 100644 --- a/czkawka_pyside6/app/state.py +++ b/czkawka_pyside6/app/state.py @@ -1,6 +1,6 @@ import json from pathlib import Path -from PySide6.QtCore import QObject, Signal +from PySide6.QtCore import QObject, Signal, QStandardPaths from .models import ( ActiveTab, AppSettings, ToolSettings, ResultEntry, ScanProgress ) @@ -32,7 +32,9 @@ def __init__(self): self.progress = ScanProgress() self.info_text = "" self.preview_image_path = "" - self._config_path = Path.home() / ".config" / "czkawka_pyside6" + # Use QStandardPaths for XDG-compliant config location + config_dir = QStandardPaths.writableLocation(QStandardPaths.AppConfigLocation) + self._config_path = Path(config_dir) if config_dir else Path.home() / ".config" / "czkawka" self._config_path.mkdir(parents=True, exist_ok=True) self.load_settings() diff --git a/czkawka_pyside6/app/tool_settings.py b/czkawka_pyside6/app/tool_settings.py index a3c5d9e44..87d4249f0 100644 --- a/czkawka_pyside6/app/tool_settings.py +++ b/czkawka_pyside6/app/tool_settings.py @@ -30,7 +30,9 @@ def _setup_ui(self): main_layout.setContentsMargins(4, 4, 4, 4) title = QLabel("Tool Settings") - title.setStyleSheet("font-weight: bold; font-size: 13px; padding: 4px;") + font = title.font() + font.setBold(True) + title.setFont(font) main_layout.addWidget(title) self._scroll = QScrollArea() diff --git a/czkawka_pyside6/main.py b/czkawka_pyside6/main.py index 6bf9a05f1..413ab5a3b 100644 --- a/czkawka_pyside6/main.py +++ b/czkawka_pyside6/main.py @@ -22,30 +22,25 @@ def main(): - # Set environment for better HiDPI support - os.environ.setdefault("QT_AUTO_SCREEN_SCALE_FACTOR", "1") - from PySide6.QtWidgets import QApplication from PySide6.QtCore import Qt - from PySide6.QtGui import QFont app = QApplication(sys.argv) app.setApplicationName("Czkawka") app.setApplicationVersion("11.0.1") app.setOrganizationName("czkawka") - app.setDesktopFileName("com.github.qarmin.czkawka") - - # Set application icon - from app.icons import app_icon - icon = app_icon() + app.setOrganizationDomain("github.com/qarmin") + app.setDesktopFileName("com.github.qarmin.czkawka-pyside6") + + # Set application icon — use XDG theme icon with fallback to project logo + from PySide6.QtGui import QIcon + icon = QIcon.fromTheme("com.github.qarmin.czkawka") + if icon.isNull(): + from app.icons import app_icon + icon = app_icon() if not icon.isNull(): app.setWindowIcon(icon) - # Set default font - font = QFont() - font.setPointSize(10) - app.setFont(font) - # Import and create main window from app.main_window import MainWindow window = MainWindow() diff --git a/data/com.github.qarmin.czkawka-pyside6.desktop b/data/com.github.qarmin.czkawka-pyside6.desktop new file mode 100644 index 000000000..6e9c8eac8 --- /dev/null +++ b/data/com.github.qarmin.czkawka-pyside6.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Categories=System;FileTools;Qt; +Exec=czkawka-pyside6 +Icon=com.github.qarmin.czkawka +StartupWMClass=czkawka-pyside6 +Terminal=false +Type=Application + +Name=Czkawka PySide6 +GenericName=File Cleaner +Comment=Multi-functional app to find duplicates, empty folders, similar files and more - Qt/PySide6 edition. +Keywords=Czkawka;duplicate;same;similar;cleaner;copy;copies;compare;files;Qt; diff --git a/data/com.github.qarmin.czkawka-pyside6.metainfo.xml b/data/com.github.qarmin.czkawka-pyside6.metainfo.xml new file mode 100644 index 000000000..e85d44343 --- /dev/null +++ b/data/com.github.qarmin.czkawka-pyside6.metainfo.xml @@ -0,0 +1,40 @@ + + + com.github.qarmin.czkawka-pyside6 + Czkawka PySide6 + Multi-functional app to find duplicates, similar images and more - Qt/PySide6 edition + CC0-1.0 + MIT + +

Czkawka PySide6 is a Qt 6 GUI frontend for the Czkawka file cleanup tool. It uses czkawka_cli as its backend and provides feature parity with the Krokiet (Slint) interface.

+

It can find:

+
    +
  • Duplicates (by hash, name, size)
  • +
  • Similar images
  • +
  • Similar audio files
  • +
  • Similar videos
  • +
  • Empty folders and files
  • +
  • Broken files
  • +
  • Temporary files
  • +
  • Big files
  • +
  • Invalid symlinks
  • +
  • Files with bad names or extensions
  • +
  • Videos that can be optimized/cropped
  • +
  • EXIF metadata to remove
  • +
+ + com.github.qarmin.czkawka-pyside6.desktop + + + + + + Rafał Mikrut + + https://github.com/qarmin/czkawka + https://github.com/qarmin/czkawka/issues + https://github.com/sponsors/qarmin + + com.github.qarmin.czkawka-cli + + From e72e18dfd62cb84ca2b4ca4e5caf3294158135e5 Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 10:44:18 +0100 Subject: [PATCH 05/49] Fix cargo fmt and show stage index in progress title - Fix writeln! formatting to pass cargo fmt --check - Show stage index in progress bar title (e.g., "[3/7] Calculating prehashes") - All czkawka_core tests pass (5/5 progress_data tests OK) - Krokiet compiles successfully - All CLI subcommands verified working Co-Authored-By: Claude Opus 4.6 (1M context) --- czkawka_cli/src/progress.rs | 6 +----- czkawka_pyside6/app/progress_widget.py | 7 +++++-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/czkawka_cli/src/progress.rs b/czkawka_cli/src/progress.rs index 6156e3daa..f0b4f77a6 100644 --- a/czkawka_cli/src/progress.rs +++ b/czkawka_cli/src/progress.rs @@ -122,11 +122,7 @@ pub(crate) fn connect_progress_json(progress_receiver: &Receiver) }; if let Ok(json) = serde_json::to_string(&progress_data) { - // Wrap in an object that includes the human-readable stage name - let _ = writeln!( - stderr, - "{{\"progress\":{json},\"stage_name\":\"{stage_name}\"}}" - ); + let _ = writeln!(stderr, "{{\"progress\":{json},\"stage_name\":\"{stage_name}\"}}"); } } } diff --git a/czkawka_pyside6/app/progress_widget.py b/czkawka_pyside6/app/progress_widget.py index 9c1de28d3..ecc309745 100644 --- a/czkawka_pyside6/app/progress_widget.py +++ b/czkawka_pyside6/app/progress_widget.py @@ -161,8 +161,11 @@ def update_progress(self, progress: ScanProgress): b_checked = progress.bytes_checked b_to_check = progress.bytes_to_check - # ── Stage label ── - self._stage_label.setText(stage_name) + # ── Stage label with stage index ── + if max_idx > 0: + self._stage_label.setText(f"[{idx + 1}/{max_idx + 1}] {stage_name}") + else: + self._stage_label.setText(stage_name) # ── Update step indicators using stage index ── if max_idx > 0: From 970f16d956480a43b20293884faeacbbb08deeda Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 10:45:29 +0100 Subject: [PATCH 06/49] Fix clippy use_self warnings in Commands::get_json_progress Co-Authored-By: Claude Opus 4.6 (1M context) --- czkawka_cli/src/commands.rs | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/czkawka_cli/src/commands.rs b/czkawka_cli/src/commands.rs index 9c73b5913..629368146 100644 --- a/czkawka_cli/src/commands.rs +++ b/czkawka_cli/src/commands.rs @@ -118,20 +118,20 @@ pub enum Commands { impl Commands { pub fn get_json_progress(&self) -> bool { match self { - Commands::Duplicates(a) => a.common_cli_items.json_progress, - Commands::EmptyFolders(a) => a.common_cli_items.json_progress, - Commands::BiggestFiles(a) => a.common_cli_items.json_progress, - Commands::EmptyFiles(a) => a.common_cli_items.json_progress, - Commands::Temporary(a) => a.common_cli_items.json_progress, - Commands::SimilarImages(a) => a.common_cli_items.json_progress, - Commands::SameMusic(a) => a.common_cli_items.json_progress, - Commands::InvalidSymlinks(a) => a.common_cli_items.json_progress, - Commands::BrokenFiles(a) => a.common_cli_items.json_progress, - Commands::SimilarVideos(a) => a.common_cli_items.json_progress, - Commands::BadExtensions(a) => a.common_cli_items.json_progress, - Commands::BadNames(a) => a.common_cli_items.json_progress, - Commands::VideoOptimizer(a) => a.common_cli_items.json_progress, - Commands::ExifRemover(a) => a.common_cli_items.json_progress, + Self::Duplicates(a) => a.common_cli_items.json_progress, + Self::EmptyFolders(a) => a.common_cli_items.json_progress, + Self::BiggestFiles(a) => a.common_cli_items.json_progress, + Self::EmptyFiles(a) => a.common_cli_items.json_progress, + Self::Temporary(a) => a.common_cli_items.json_progress, + Self::SimilarImages(a) => a.common_cli_items.json_progress, + Self::SameMusic(a) => a.common_cli_items.json_progress, + Self::InvalidSymlinks(a) => a.common_cli_items.json_progress, + Self::BrokenFiles(a) => a.common_cli_items.json_progress, + Self::SimilarVideos(a) => a.common_cli_items.json_progress, + Self::BadExtensions(a) => a.common_cli_items.json_progress, + Self::BadNames(a) => a.common_cli_items.json_progress, + Self::VideoOptimizer(a) => a.common_cli_items.json_progress, + Self::ExifRemover(a) => a.common_cli_items.json_progress, } } } From a383d74c46b2d72c13c85938e4768c81f6cf9acc Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 11:03:50 +0100 Subject: [PATCH 07/49] Add resizable/sortable columns, load results, fix group header spanning Results table improvements: - Columns are resizable (drag header edges) with sensible defaults - Click column header to sort (ascending/descending toggle) - Sorting works within groups for grouped tools (duplicates, etc.) - Numeric columns (Size, Date) sort by actual values, not strings - Sort indicator arrow shown in header Group header fix: - Group headers now span across all columns (merged cell effect) - setFirstColumnSpanned called after adding item to tree Load results: - New "Load" button in action bar to load previously saved JSON results - Supports both PySide6 save format and raw czkawka_cli JSON output - Save format now preserves group structure, checked state, and group IDs Co-Authored-By: Claude Opus 4.6 (1M context) --- czkawka_pyside6/app/action_buttons.py | 9 ++ czkawka_pyside6/app/dialogs/save_dialog.py | 133 +++++++++++++++-- czkawka_pyside6/app/main_window.py | 14 ++ czkawka_pyside6/app/results_view.py | 166 ++++++++++++++++++--- 4 files changed, 290 insertions(+), 32 deletions(-) diff --git a/czkawka_pyside6/app/action_buttons.py b/czkawka_pyside6/app/action_buttons.py index ef39a3947..241878f7b 100644 --- a/czkawka_pyside6/app/action_buttons.py +++ b/czkawka_pyside6/app/action_buttons.py @@ -22,6 +22,7 @@ class ActionButtons(QWidget): delete_clicked = Signal() move_clicked = Signal() save_clicked = Signal() + load_clicked = Signal() sort_clicked = Signal() hardlink_clicked = Signal() symlink_clicked = Signal() @@ -86,6 +87,14 @@ def _setup_ui(self): self._save_btn.clicked.connect(self.save_clicked.emit) layout.addWidget(self._save_btn) + # Load button + from .icons import icon_dir + self._load_btn = QPushButton(icon_dir(18), " Load") + self._load_btn.setIconSize(ICON_SIZE) + self._load_btn.setToolTip("Load previously saved results") + self._load_btn.clicked.connect(self.load_clicked.emit) + layout.addWidget(self._load_btn) + # Sort button self._sort_btn = QPushButton(icon_sort(18), " Sort") self._sort_btn.setIconSize(ICON_SIZE) diff --git a/czkawka_pyside6/app/dialogs/save_dialog.py b/czkawka_pyside6/app/dialogs/save_dialog.py index d8fa614e6..8df154bb5 100644 --- a/czkawka_pyside6/app/dialogs/save_dialog.py +++ b/czkawka_pyside6/app/dialogs/save_dialog.py @@ -1,11 +1,13 @@ -from PySide6.QtWidgets import ( - QDialog, QVBoxLayout, QLabel, QDialogButtonBox, - QCheckBox, QFileDialog -) +import json +from pathlib import Path + +from PySide6.QtWidgets import QFileDialog + +from ..models import ResultEntry class SaveDialog: - """Save results to file (uses native file dialog).""" + """Save/load results to/from file.""" @staticmethod def save(parent, results: list, save_as_json: bool = False) -> bool: @@ -13,24 +15,30 @@ def save(parent, results: list, save_as_json: bool = False) -> bool: filter_str = "JSON Files (*.json);;All Files (*)" default_ext = ".json" else: - filter_str = "Text Files (*.txt);;All Files (*)" + filter_str = "Text Files (*.txt);;JSON Files (*.json);;All Files (*)" default_ext = ".txt" - path, _ = QFileDialog.getSaveFileName( + path, selected_filter = QFileDialog.getSaveFileName( parent, "Save Results", f"results{default_ext}", filter_str ) if not path: return False + use_json = save_as_json or path.endswith(".json") or "JSON" in selected_filter + try: - import json - if save_as_json: + if use_json: data = [] for entry in results: - if not entry.header_row: - # Filter out internal keys - values = {k: v for k, v in entry.values.items() - if not k.startswith("__")} + if entry.header_row: + data.append({ + "__header": entry.values.get("__header", ""), + "__group_id": entry.group_id, + }) + else: + values = dict(entry.values) + values["__group_id"] = entry.group_id + values["__checked"] = entry.checked data.append(values) with open(path, "w") as f: json.dump(data, f, indent=2) @@ -46,3 +54,102 @@ def save(parent, results: list, save_as_json: bool = False) -> bool: return True except OSError: return False + + @staticmethod + def load(parent) -> list[ResultEntry] | None: + """Load results from a previously saved JSON file. + + Returns list of ResultEntry on success, None on cancel/error. + """ + path, _ = QFileDialog.getOpenFileName( + parent, "Load Results", + "", + "JSON Files (*.json);;All Files (*)" + ) + if not path: + return None + + try: + with open(path) as f: + data = json.load(f) + except (json.JSONDecodeError, OSError): + return None + + if not isinstance(data, list): + # Could be czkawka_cli dict format {size: [[entries]]} + return _parse_cli_json(data) + + results = [] + for item in data: + if not isinstance(item, dict): + continue + + if "__header" in item: + results.append(ResultEntry( + values={"__header": item["__header"]}, + header_row=True, + group_id=item.get("__group_id", 0), + )) + else: + group_id = item.pop("__group_id", 0) + checked = item.pop("__checked", False) + results.append(ResultEntry( + values=item, + checked=checked, + group_id=group_id, + )) + + return results if results else None + + +def _parse_cli_json(data: dict) -> list[ResultEntry] | None: + """Parse raw czkawka_cli JSON output (dict of size buckets or flat list).""" + results = [] + group_id = 0 + + for key, groups in data.items(): + if not isinstance(groups, list): + continue + for group in groups: + if not isinstance(group, list) or len(group) == 0: + continue + + total_size = sum(e.get("size", 0) for e in group if isinstance(e, dict)) + results.append(ResultEntry( + values={"__header": f"Group {group_id + 1} ({len(group)} files)"}, + header_row=True, + group_id=group_id, + )) + + for entry in group: + if not isinstance(entry, dict): + continue + path = entry.get("path", "") + p = Path(path) + values = { + "File Name": p.name, + "Path": str(p.parent), + "Size": _format_size(entry.get("size", 0)), + "Modification Date": str(entry.get("modified_date", "")), + "Hash": entry.get("hash", ""), + "__full_path": path, + "__size_bytes": entry.get("size", 0), + "__modified_date_ts": entry.get("modified_date", 0), + } + results.append(ResultEntry(values=values, group_id=group_id)) + + group_id += 1 + + return results if results else None + + +def _format_size(size_bytes: int) -> str: + if size_bytes == 0: + return "0 B" + units = ["B", "KB", "MB", "GB", "TB"] + i = 0 + size = float(size_bytes) + while size >= 1024 and i < len(units) - 1: + size /= 1024 + i += 1 + return f"{size:.1f} {units[i]}" if i > 0 else f"{int(size)} B" diff --git a/czkawka_pyside6/app/main_window.py b/czkawka_pyside6/app/main_window.py index e6168badc..5f7ebda4f 100644 --- a/czkawka_pyside6/app/main_window.py +++ b/czkawka_pyside6/app/main_window.py @@ -139,6 +139,7 @@ def _connect_signals(self): self._action_buttons.delete_clicked.connect(self._show_delete_dialog) self._action_buttons.move_clicked.connect(self._show_move_dialog) self._action_buttons.save_clicked.connect(self._save_results) + self._action_buttons.load_clicked.connect(self._load_results) self._action_buttons.sort_clicked.connect(self._show_sort_dialog) self._action_buttons.hardlink_clicked.connect(self._create_hardlinks) self._action_buttons.symlink_clicked.connect(self._create_symlinks) @@ -316,6 +317,19 @@ def _save_results(self): if success: self._status_label.setText("Results saved successfully") + def _load_results(self): + results = SaveDialog.load(self) + if results is None: + return + tab = self._state.active_tab + self._state.set_results(tab, results) + self._results_view.set_results(results) + self._action_buttons.set_has_results( + any(not r.header_row for r in results) + ) + count = sum(1 for r in results if not r.header_row) + self._status_label.setText(f"Loaded {count} entries from file") + def _show_sort_dialog(self): columns = TAB_COLUMNS.get(self._state.active_tab, []) if not columns: diff --git a/czkawka_pyside6/app/results_view.py b/czkawka_pyside6/app/results_view.py index 6c7a37eb9..c07be7add 100644 --- a/czkawka_pyside6/app/results_view.py +++ b/czkawka_pyside6/app/results_view.py @@ -27,6 +27,8 @@ def __init__(self, parent=None): super().__init__(parent) self._active_tab = ActiveTab.DUPLICATE_FILES self._results: list[ResultEntry] = [] + self._sort_column = -1 + self._sort_order = Qt.AscendingOrder self._setup_ui() def _ensure_header_colors(self): @@ -34,16 +36,16 @@ def _ensure_header_colors(self): if self._header_colors_ready: return from PySide6.QtWidgets import QApplication + from PySide6.QtGui import QPalette palette = QApplication.instance().palette() - # Use a midpoint between window and highlight for header background - win = palette.color(palette.Window) - hi = palette.color(palette.Highlight) + win = palette.color(QPalette.ColorRole.Window) + hi = palette.color(QPalette.ColorRole.Highlight) self.HEADER_BG = QColor( (win.red() + hi.red()) // 2, (win.green() + hi.green()) // 2, (win.blue() + hi.blue()) // 2, ) - self.HEADER_FG = palette.color(palette.HighlightedText) + self.HEADER_FG = palette.color(QPalette.ColorRole.HighlightedText) self._header_colors_ready = True def _setup_ui(self): @@ -69,28 +71,53 @@ def _setup_ui(self): self._tree.customContextMenuRequested.connect(self._on_context_menu) self._tree.itemChanged.connect(self._on_item_changed) self._tree.itemDoubleClicked.connect(self._on_item_double_clicked) + + # Sortable: click header to sort + self._tree.setSortingEnabled(False) # We handle sorting manually + header = self._tree.header() + header.setSectionsClickable(True) + header.sectionClicked.connect(self._on_header_clicked) + # Resizable columns + header.setSectionResizeMode(QHeaderView.Interactive) + header.setStretchLastSection(True) + layout.addWidget(self._tree) def set_active_tab(self, tab: ActiveTab): self._active_tab = tab + self._sort_column = -1 columns = TAB_COLUMNS.get(tab, ["Selection", "File Name", "Path"]) + self._tree.setColumnCount(len(columns)) self._tree.setHeaderLabels(columns) header = self._tree.header() - for i in range(len(columns)): - if columns[i] == "Path": - header.setSectionResizeMode(i, QHeaderView.Stretch) - else: - header.setSectionResizeMode(i, QHeaderView.ResizeToContents) + # All columns interactive (resizable), last one stretches + header.setSectionResizeMode(QHeaderView.Interactive) + header.setStretchLastSection(True) + # Give Path columns more initial space + for i, col in enumerate(columns): + if col == "Path": + header.resizeSection(i, 300) + elif col == "Selection": + header.resizeSection(i, 30) + elif col in ("Size", "Hash", "Modification Date"): + header.resizeSection(i, 140) + elif col == "File Name": + header.resizeSection(i, 200) def set_results(self, results: list[ResultEntry]): self._ensure_header_colors() self._results = results + self._rebuild_tree() + self._update_summary() + + def _rebuild_tree(self): + """Rebuild tree items from self._results.""" self._tree.blockSignals(True) self._tree.clear() columns = TAB_COLUMNS.get(self._active_tab, ["Selection", "File Name", "Path"]) - for entry in results: + for entry in self._results: if entry.header_row: item = QTreeWidgetItem() header_text = entry.values.get("__header", "Group") @@ -105,14 +132,15 @@ def set_results(self, results: list[ResultEntry]): item.setFlags(item.flags() & ~Qt.ItemIsUserCheckable) item.setData(0, Qt.UserRole, entry) self._tree.addTopLevelItem(item) + # Span header across all columns (must be called after adding to tree) + item.setFirstColumnSpanned(True) else: item = QTreeWidgetItem() - # First column is checkbox (Selection) item.setFlags(item.flags() | Qt.ItemIsUserCheckable) item.setCheckState(0, Qt.Checked if entry.checked else Qt.Unchecked) for col_idx, col_name in enumerate(columns): - if col_idx == 0: # Selection column + if col_idx == 0: continue value = entry.values.get(col_name, "") item.setText(col_idx, str(value)) @@ -121,7 +149,108 @@ def set_results(self, results: list[ResultEntry]): self._tree.addTopLevelItem(item) self._tree.blockSignals(False) - self._update_summary() + + # ── Sorting ────────────────────────────────────────────── + + def _on_header_clicked(self, logical_index: int): + """Sort by column when header is clicked. Toggle ascending/descending.""" + if logical_index == 0: + return # Don't sort by checkbox column + + if self._sort_column == logical_index: + # Toggle order + self._sort_order = ( + Qt.DescendingOrder if self._sort_order == Qt.AscendingOrder + else Qt.AscendingOrder + ) + else: + self._sort_column = logical_index + self._sort_order = Qt.AscendingOrder + + columns = TAB_COLUMNS.get(self._active_tab, []) + col_name = columns[logical_index] if logical_index < len(columns) else "" + + # Update header sort indicator + self._tree.header().setSortIndicator(logical_index, self._sort_order) + self._tree.header().setSortIndicatorShown(True) + + ascending = self._sort_order == Qt.AscendingOrder + + if self._active_tab in GROUPED_TABS: + self._sort_within_groups(col_name, ascending) + else: + self._sort_flat(col_name, ascending) + + def _sort_key(self, entry: ResultEntry, col_name: str): + """Return a sort key for a result entry by column name.""" + # Use numeric values for known numeric columns + if col_name in ("Size",): + return entry.values.get("__size_bytes", 0) + if col_name in ("Modification Date",): + return entry.values.get("__modified_date_ts", 0) + if col_name in ("Similarity", "Bitrate", "Year", "Length"): + raw = entry.values.get(col_name, "") + try: + return float(str(raw).replace(",", "")) + except (ValueError, TypeError): + return 0 + # Default: string comparison (case-insensitive) + return str(entry.values.get(col_name, "")).lower() + + def _sort_flat(self, col_name: str, ascending: bool): + """Sort flat (non-grouped) results.""" + self._results.sort( + key=lambda e: self._sort_key(e, col_name), + reverse=not ascending, + ) + self._rebuild_tree() + + def _sort_within_groups(self, col_name: str, ascending: bool): + """Sort entries within each group, keeping group headers in place.""" + sorted_results = [] + current_group = [] + current_header = None + + for entry in self._results: + if entry.header_row: + if current_header is not None: + current_group.sort( + key=lambda e: self._sort_key(e, col_name), + reverse=not ascending, + ) + sorted_results.append(current_header) + sorted_results.extend(current_group) + current_header = entry + current_group = [] + else: + current_group.append(entry) + + # Last group + if current_header is not None: + current_group.sort( + key=lambda e: self._sort_key(e, col_name), + reverse=not ascending, + ) + sorted_results.append(current_header) + sorted_results.extend(current_group) + + self._results = sorted_results + self._rebuild_tree() + + def sort_by_column(self, column: int, ascending: bool = True): + """Public API for sorting (used by sort dialog).""" + self._sort_column = column + self._sort_order = Qt.AscendingOrder if ascending else Qt.DescendingOrder + columns = TAB_COLUMNS.get(self._active_tab, []) + col_name = columns[column] if column < len(columns) else "" + self._tree.header().setSortIndicator(column, self._sort_order) + self._tree.header().setSortIndicatorShown(True) + if self._active_tab in GROUPED_TABS: + self._sort_within_groups(col_name, ascending) + else: + self._sort_flat(col_name, ascending) + + # ── Item events ────────────────────────────────────────── def _on_item_changed(self, item, column): if column == 0: @@ -188,6 +317,8 @@ def _open_folder(self, entry: ResultEntry): def _set_check(self, item, checked): item.setCheckState(0, Qt.Checked if checked else Qt.Unchecked) + # ── Summary / selection ────────────────────────────────── + def _update_summary(self): total = sum(1 for r in self._results if not r.header_row) groups = sum(1 for r in self._results if r.header_row) @@ -243,13 +374,11 @@ def _invert_selection(self): item.setCheckState(0, Qt.Checked if entry.checked else Qt.Unchecked) def _select_by_group_criteria(self, mode: SelectMode): - # First unselect all self._select_all(False) if self._active_tab not in GROUPED_TABS: return - # Group entries by group_id groups: dict[int, list[tuple[int, ResultEntry]]] = {} for i in range(self._tree.topLevelItemCount()): item = self._tree.topLevelItem(i) @@ -275,15 +404,12 @@ def _select_by_group_criteria(self, mode: SelectMode): elif mode == SelectMode.SELECT_LONGEST_PATH: best_idx = max(range(len(items)), key=lambda j: len(items[j][1].values.get("__full_path", ""))) - # Select all EXCEPT the best (the one to keep) for j, (tree_idx, entry) in enumerate(items): if j != best_idx: entry.checked = True self._tree.topLevelItem(tree_idx).setCheckState(0, Qt.Checked) - def sort_by_column(self, column: int, ascending: bool = True): - order = Qt.AscendingOrder if ascending else Qt.DescendingOrder - self._tree.sortItems(column, order) + # ── Public accessors ───────────────────────────────────── def get_checked_entries(self) -> list[ResultEntry]: return [r for r in self._results if r.checked and not r.header_row] @@ -294,5 +420,7 @@ def get_all_entries(self) -> list[ResultEntry]: def clear(self): self._results = [] self._tree.clear() + self._sort_column = -1 + self._tree.header().setSortIndicatorShown(False) self._summary_label.setText("No results") self._selection_label.setText("") From dd0e65e317dbf697293a47954725745abecce0e1 Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 11:17:06 +0100 Subject: [PATCH 08/49] Add keyboard shortcuts, drag-drop, column visibility, filter bar - Keyboard shortcuts: Ctrl+S (scan), Escape (stop), Ctrl+A (select all), Ctrl+D (delete), Ctrl+M (move), Ctrl+Shift+S (save), Ctrl+L (load), Ctrl+I (invert selection), F5 (scan) - Drag-and-drop directories onto the bottom panel to add include paths - Right-click column header to toggle column visibility - Filter/search bar above results to narrow by filename or path - CSV export support in save dialog - Task-specific save filenames (e.g., czkawka_duplicate_files_20260323.txt) Co-Authored-By: Claude Opus 4.6 (1M context) --- czkawka_cli/src/main.rs | 4 +- czkawka_mcp/Cargo.toml | 29 + czkawka_mcp/src/main.rs | 633 +++++++++++++++++++++ czkawka_pyside6/app/bottom_panel.py | 16 + czkawka_pyside6/app/dialogs/__init__.py | 1 + czkawka_pyside6/app/dialogs/save_dialog.py | 31 +- czkawka_pyside6/app/main_window.py | 56 +- czkawka_pyside6/app/results_view.py | 83 ++- czkawka_pyside6/app/state.py | 6 + 9 files changed, 850 insertions(+), 9 deletions(-) create mode 100644 czkawka_mcp/Cargo.toml create mode 100644 czkawka_mcp/src/main.rs diff --git a/czkawka_cli/src/main.rs b/czkawka_cli/src/main.rs index b8b9710e4..bdb519301 100644 --- a/czkawka_cli/src/main.rs +++ b/czkawka_cli/src/main.rs @@ -5,7 +5,7 @@ use std::thread; use clap::Parser; use commands::Commands; -use crossbeam_channel::{Receiver, Sender, unbounded}; +use crossbeam_channel::{Receiver, Sender, bounded}; use czkawka_core::common::config_cache_path::{print_infos_and_warnings, set_config_cache_path}; use czkawka_core::common::consts::DEFAULT_THREAD_SIZE; use czkawka_core::common::image::register_image_decoding_hooks; @@ -66,7 +66,7 @@ fn main() { } let json_progress = command.get_json_progress(); - let (progress_sender, progress_receiver): (Sender, Receiver) = unbounded(); + let (progress_sender, progress_receiver): (Sender, Receiver) = bounded(256); let stop_flag = Arc::new(AtomicBool::new(false)); let store_flag_cloned = stop_flag.clone(); diff --git a/czkawka_mcp/Cargo.toml b/czkawka_mcp/Cargo.toml new file mode 100644 index 000000000..2788e5295 --- /dev/null +++ b/czkawka_mcp/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "czkawka_mcp" +version = "11.0.1" +authors = ["Rafał Mikrut "] +edition = "2024" +rust-version = "1.92.0" +description = "MCP (Model Context Protocol) server for Czkawka - exposes file analysis tools to AI agents" +license = "MIT" +homepage = "https://github.com/qarmin/czkawka" +repository = "https://github.com/qarmin/czkawka" + +[dependencies] +czkawka_core = { path = "../czkawka_core", version = "11.0.1" } +rmcp = { version = "0.1", features = ["server", "transport-io"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +schemars = "0.8" +crossbeam-channel = "0.5" +log = "0.4" + +[features] +default = [] +heif = ["czkawka_core/heif"] +libraw = ["czkawka_core/libraw"] +libavif = ["czkawka_core/libavif"] + +[lints] +workspace = true diff --git a/czkawka_mcp/src/main.rs b/czkawka_mcp/src/main.rs new file mode 100644 index 000000000..30078a1fa --- /dev/null +++ b/czkawka_mcp/src/main.rs @@ -0,0 +1,633 @@ +// rmcp #[tool] macro requires &self and owned params; these clippy warnings are false positives +#![allow(clippy::needless_pass_by_value, clippy::unused_self)] + +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; + +use crossbeam_channel::unbounded; +use czkawka_core::common::image::register_image_decoding_hooks; +use czkawka_core::common::set_number_of_threads; +use czkawka_core::common::tool_data::CommonData as _; +use czkawka_core::common::traits::{AllTraits, PrintResults}; +use czkawka_core::tools::bad_extensions::{BadExtensions, BadExtensionsParameters}; +use czkawka_core::tools::bad_names::{BadNames, BadNamesParameters, NameIssues}; +use czkawka_core::tools::big_file::{BigFile, BigFileParameters, SearchMode}; +use czkawka_core::tools::broken_files::{BrokenFiles, BrokenFilesParameters, CheckedTypes}; +use czkawka_core::tools::duplicate::{DuplicateFinder, DuplicateFinderParameters}; +use czkawka_core::tools::empty_files::EmptyFiles; +use czkawka_core::tools::empty_folder::EmptyFolder; +use czkawka_core::tools::exif_remover::{ExifRemover, ExifRemoverParameters}; +use czkawka_core::tools::invalid_symlinks::InvalidSymlinks; +use czkawka_core::tools::same_music::{SameMusic, SameMusicParameters}; +use czkawka_core::tools::similar_images::{SimilarImages, SimilarImagesParameters}; +use czkawka_core::tools::similar_videos::{SimilarVideos, SimilarVideosParameters}; +use czkawka_core::tools::temporary::Temporary; +use czkawka_core::tools::video_optimizer::{ + VideoCropParams, VideoCroppingMechanism, VideoOptimizer, VideoOptimizerParameters, VideoTranscodeParams, +}; +use rmcp::model::{CallToolResult, Content, ServerCapabilities, ServerInfo}; +use rmcp::{ServerHandler, ServiceExt, tool}; +use schemars::JsonSchema; +use serde::Deserialize; + +// ── Common parameter structs ────────────────────────────────────────── + +#[derive(Debug, Deserialize, JsonSchema)] +struct CommonParams { + #[schemars(description = "List of directories to search (required)")] + directories: Vec, + #[schemars(description = "Directories to exclude from search")] + excluded_directories: Option>, + #[schemars(description = "Wildcard patterns to exclude (e.g. '*/.git', '*.tmp')")] + excluded_items: Option>, + #[schemars(description = "Only check files with these extensions (e.g. ['jpg', 'png'])")] + allowed_extensions: Option>, + #[schemars(description = "Skip files with these extensions")] + excluded_extensions: Option>, + #[schemars(description = "If true, do not recurse into subdirectories (default: false)")] + not_recursive: Option, + #[schemars(description = "Number of threads to use (0 = all available, default: 0)")] + thread_number: Option, + #[schemars(description = "Disable the cache system (default: false)")] + disable_cache: Option, +} + +fn apply_common(tool: &mut T, p: &CommonParams, reference_dirs: Option<&[String]>) { + set_number_of_threads(p.thread_number.unwrap_or(0)); + + let mut included: Vec = p.directories.iter().map(PathBuf::from).collect(); + if let Some(refs) = reference_dirs { + let ref_paths: Vec = refs.iter().map(PathBuf::from).collect(); + included.extend(ref_paths.clone()); + tool.set_reference_paths(ref_paths); + } + + tool.set_included_paths(included); + tool.set_excluded_paths(p.excluded_directories.as_ref().map_or_else(Vec::new, |v| v.iter().map(PathBuf::from).collect())); + tool.set_excluded_items(p.excluded_items.clone().unwrap_or_default()); + tool.set_recursive_search(!p.not_recursive.unwrap_or(false)); + tool.set_allowed_extensions(p.allowed_extensions.clone().unwrap_or_default()); + tool.set_excluded_extensions(p.excluded_extensions.clone().unwrap_or_default()); + tool.set_use_cache(!p.disable_cache.unwrap_or(false)); +} + +/// Run a tool's search and serialize results to JSON via a temp file. +fn run_and_serialize(tool: &mut T) -> String { + let stop_flag = Arc::new(AtomicBool::new(false)); + let (progress_sender, _progress_receiver) = unbounded(); + + tool.search(&stop_flag, Some(&progress_sender)); + + let tmp = format!("/tmp/czkawka_mcp_{}.json", std::process::id()); + let json = if tool.save_results_to_file_as_json(&tmp, true).is_ok() { + std::fs::read_to_string(&tmp).unwrap_or_else(|_| "{}".to_string()) + } else { + "{}".to_string() + }; + let _ = std::fs::remove_file(&tmp); + + let messages = tool.get_text_messages(); + let warnings = &messages.warnings; + let errors = &messages.errors; + + if warnings.is_empty() && errors.is_empty() { + json + } else { + format!( + "{{\n \"results\": {json},\n \"warnings\": {warnings},\n \"errors\": {errors}\n}}", + warnings = serde_json::to_string(warnings).unwrap_or_default(), + errors = serde_json::to_string(errors).unwrap_or_default(), + ) + } +} + +fn ok_result(json: String) -> Result { + Ok(CallToolResult::success(vec![Content::text(json)])) +} + +fn err_result(msg: String) -> Result { + Ok(CallToolResult::error(vec![Content::text(msg)])) +} + +// ── MCP Server ──────────────────────────────────────────────────────── + +#[derive(Clone)] +struct CzkawkaServer; + +// ── Tool parameter structs ──────────────────────────────────────────── + +#[derive(Debug, Deserialize, JsonSchema)] +struct FindDuplicatesParams { + #[serde(flatten)] + common: CommonParams, + #[schemars(description = "Reference directories (duplicates are found relative to these)")] + reference_directories: Option>, + #[schemars(description = "Search method: 'hash' (default), 'name', 'size', 'size_name'")] + search_method: Option, + #[schemars(description = "Hash algorithm: 'blake3' (default), 'crc32', 'xxh3'")] + hash_type: Option, + #[schemars(description = "Minimum file size in bytes (default: 8192)")] + minimal_file_size: Option, + #[schemars(description = "Maximum file size in bytes")] + maximal_file_size: Option, + #[schemars(description = "Allow hard links to be treated as duplicates (default: false)")] + allow_hard_links: Option, + #[schemars(description = "Case-sensitive name comparison (default: false)")] + case_sensitive_name_comparison: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct FindEmptyFoldersParams { + #[serde(flatten)] + common: CommonParams, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct FindBiggestFilesParams { + #[serde(flatten)] + common: CommonParams, + #[schemars(description = "Number of files to return (default: 50)")] + number_of_files: Option, + #[schemars(description = "If true, find smallest files instead of biggest (default: false)")] + smallest_mode: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct FindEmptyFilesParams { + #[serde(flatten)] + common: CommonParams, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct FindTemporaryParams { + #[serde(flatten)] + common: CommonParams, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct FindSimilarImagesParams { + #[serde(flatten)] + common: CommonParams, + #[schemars(description = "Reference directories")] + reference_directories: Option>, + #[schemars(description = "Maximum difference between images (0-40, default: 3)")] + max_difference: Option, + #[schemars(description = "Hash algorithm: 'gradient' (default), 'mean', 'vertgradient', 'blockhash', 'doublegradient'")] + hash_alg: Option, + #[schemars(description = "Image resize filter: 'lanczos3' (default), 'nearest', 'triangle', 'gaussian', 'catmullrom'")] + image_filter: Option, + #[schemars(description = "Hash size: 8, 16 (default), 32, 64")] + hash_size: Option, + #[schemars(description = "Minimum file size in bytes")] + minimal_file_size: Option, + #[schemars(description = "Maximum file size in bytes")] + maximal_file_size: Option, + #[schemars(description = "Allow hard links (default: false)")] + allow_hard_links: Option, + #[schemars(description = "Ignore files with same size (default: false)")] + ignore_same_size: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct FindSameMusicParams { + #[serde(flatten)] + common: CommonParams, + #[schemars(description = "Reference directories")] + reference_directories: Option>, + #[schemars(description = "Search method: 'tags' (default), 'content'")] + search_method: Option, + #[schemars(description = "Music similarity level (0-10000, default: 0 = exact tags)")] + music_similarity: Option, + #[schemars(description = "Approximate tag comparison (default: false)")] + approximate_comparison: Option, + #[schemars(description = "Minimum file size in bytes")] + minimal_file_size: Option, + #[schemars(description = "Maximum file size in bytes")] + maximal_file_size: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct FindInvalidSymlinksParams { + #[serde(flatten)] + common: CommonParams, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct FindBrokenFilesParams { + #[serde(flatten)] + common: CommonParams, + #[schemars(description = "Types to check: list of 'pdf', 'audio', 'image', 'archive', 'video' (default: all)")] + checked_types: Option>, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct FindSimilarVideosParams { + #[serde(flatten)] + common: CommonParams, + #[schemars(description = "Reference directories")] + reference_directories: Option>, + #[schemars(description = "Tolerance for similarity (1-20, default: 10)")] + tolerance: Option, + #[schemars(description = "Minimum file size in bytes")] + minimal_file_size: Option, + #[schemars(description = "Maximum file size in bytes")] + maximal_file_size: Option, + #[schemars(description = "Allow hard links (default: false)")] + allow_hard_links: Option, + #[schemars(description = "Ignore files with same size (default: false)")] + ignore_same_size: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct FindBadExtensionsParams { + #[serde(flatten)] + common: CommonParams, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct FindBadNamesParams { + #[serde(flatten)] + common: CommonParams, + #[schemars(description = "Flag uppercase extensions as bad (default: true)")] + uppercase_extension: Option, + #[schemars(description = "Flag emoji in filenames (default: true)")] + emoji_used: Option, + #[schemars(description = "Flag leading/trailing spaces (default: true)")] + space_at_start_or_end: Option, + #[schemars(description = "Flag non-ASCII characters (default: true)")] + non_ascii_graphical: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct FindVideoOptimizerParams { + #[serde(flatten)] + common: CommonParams, + #[schemars(description = "Mode: 'transcode' (default), 'crop'")] + mode: Option, + #[schemars(description = "Codecs to exclude (comma-separated, default: 'hevc,h265,av1,vp9')")] + excluded_codecs: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct FindExifTagsParams { + #[serde(flatten)] + common: CommonParams, + #[schemars(description = "EXIF tags to ignore (comma-separated)")] + ignored_tags: Option, +} + +// ── Tool implementations ────────────────────────────────────────────── + +#[tool(tool_box)] +impl CzkawkaServer { + #[tool(description = "Find duplicate files by hash, name, or size in specified directories. Returns groups of duplicate files. Read-only analysis, no files are modified or deleted.")] + fn find_duplicates(&self, #[tool(aggr)] params: FindDuplicatesParams) -> Result { + use czkawka_core::common::model::CheckingMethod; + use czkawka_core::common::model::HashType; + + let check_method = match params.search_method.as_deref() { + Some("name") => CheckingMethod::Name, + Some("size") => CheckingMethod::Size, + Some("size_name") => CheckingMethod::SizeName, + _ => CheckingMethod::Hash, + }; + let hash_type = match params.hash_type.as_deref() { + Some("crc32") => HashType::Crc32, + Some("xxh3") => HashType::Xxh3, + _ => HashType::Blake3, + }; + + let dup_params = DuplicateFinderParameters::new( + check_method, + hash_type, + true, // use_prehash_cache + 0, // minimal_cached_file_size + 0, // minimal_prehash_cache_file_size + params.case_sensitive_name_comparison.unwrap_or(false), + ); + let mut tool = DuplicateFinder::new(dup_params); + + apply_common(&mut tool, ¶ms.common, params.reference_directories.as_deref()); + if let Some(min) = params.minimal_file_size { + tool.set_minimal_file_size(min); + } + if let Some(max) = params.maximal_file_size { + tool.set_maximal_file_size(max); + } + tool.set_hide_hard_links(!params.allow_hard_links.unwrap_or(false)); + tool.set_dry_run(true); + + ok_result(run_and_serialize(&mut tool)) + } + + #[tool(description = "Find empty folders in specified directories. Returns list of empty directory paths. Read-only analysis.")] + fn find_empty_folders(&self, #[tool(aggr)] params: FindEmptyFoldersParams) -> Result { + let mut tool = EmptyFolder::new(); + apply_common(&mut tool, ¶ms.common, None); + tool.set_dry_run(true); + ok_result(run_and_serialize(&mut tool)) + } + + #[tool(description = "Find the biggest (or smallest) files in specified directories. Returns a ranked list of files by size. Read-only analysis.")] + fn find_biggest_files(&self, #[tool(aggr)] params: FindBiggestFilesParams) -> Result { + let mode = if params.smallest_mode.unwrap_or(false) { + SearchMode::SmallestFiles + } else { + SearchMode::BiggestFiles + }; + let bf_params = BigFileParameters::new(params.number_of_files.unwrap_or(50), mode); + let mut tool = BigFile::new(bf_params); + apply_common(&mut tool, ¶ms.common, None); + tool.set_dry_run(true); + ok_result(run_and_serialize(&mut tool)) + } + + #[tool(description = "Find empty files (0 bytes) in specified directories. Read-only analysis.")] + fn find_empty_files(&self, #[tool(aggr)] params: FindEmptyFilesParams) -> Result { + let mut tool = EmptyFiles::new(); + apply_common(&mut tool, ¶ms.common, None); + tool.set_dry_run(true); + ok_result(run_and_serialize(&mut tool)) + } + + #[tool(description = "Find temporary files (e.g. .tmp, ~, .swp) in specified directories. Read-only analysis.")] + fn find_temporary_files(&self, #[tool(aggr)] params: FindTemporaryParams) -> Result { + let mut tool = Temporary::new(); + apply_common(&mut tool, ¶ms.common, None); + tool.set_dry_run(true); + ok_result(run_and_serialize(&mut tool)) + } + + #[tool(description = "Find visually similar images using perceptual hashing. Returns groups of similar images with similarity scores. Read-only analysis.")] + fn find_similar_images(&self, #[tool(aggr)] params: FindSimilarImagesParams) -> Result { + use czkawka_core::re_exported::{FilterType, HashAlg}; + + let hash_alg = match params.hash_alg.as_deref() { + Some("mean") => HashAlg::Mean, + Some("vertgradient") => HashAlg::VertGradient, + Some("blockhash") => HashAlg::Blockhash, + Some("doublegradient") => HashAlg::DoubleGradient, + _ => HashAlg::Gradient, + }; + let image_filter = match params.image_filter.as_deref() { + Some("nearest") => FilterType::Nearest, + Some("triangle") => FilterType::Triangle, + Some("gaussian") => FilterType::Gaussian, + Some("catmullrom") => FilterType::CatmullRom, + _ => FilterType::Lanczos3, + }; + let hash_size = params.hash_size.unwrap_or(16); + let max_difference = params.max_difference.unwrap_or(3); + + let si_params = SimilarImagesParameters::new( + max_difference, + hash_size, + hash_alg, + image_filter, + params.ignore_same_size.unwrap_or(false), + ); + let mut tool = SimilarImages::new(si_params); + + apply_common(&mut tool, ¶ms.common, params.reference_directories.as_deref()); + if let Some(min) = params.minimal_file_size { + tool.set_minimal_file_size(min); + } + if let Some(max) = params.maximal_file_size { + tool.set_maximal_file_size(max); + } + tool.set_hide_hard_links(!params.allow_hard_links.unwrap_or(false)); + tool.set_dry_run(true); + + ok_result(run_and_serialize(&mut tool)) + } + + #[tool(description = "Find similar or duplicate music files by tags or audio content fingerprint. Returns groups of similar tracks. Read-only analysis.")] + fn find_same_music(&self, #[tool(aggr)] params: FindSameMusicParams) -> Result { + use czkawka_core::common::model::CheckingMethod; + use czkawka_core::tools::same_music::MusicSimilarity; + + let search_method = match params.search_method.as_deref() { + Some("content") => CheckingMethod::AudioContent, + _ => CheckingMethod::AudioTags, + }; + + let music_similarity = if search_method == CheckingMethod::AudioTags { + let sim_val = params.music_similarity.unwrap_or(0); + if sim_val == 0 { + MusicSimilarity::TRACK_TITLE + | MusicSimilarity::TRACK_ARTIST + | MusicSimilarity::YEAR + | MusicSimilarity::BITRATE + | MusicSimilarity::GENRE + | MusicSimilarity::LENGTH + } else { + MusicSimilarity::TRACK_TITLE + } + } else { + // Content mode still requires non-empty MusicSimilarity (assertion in SameMusicParameters::new) + MusicSimilarity::TRACK_TITLE + }; + + let sm_params = SameMusicParameters::new( + music_similarity, + params.approximate_comparison.unwrap_or(false), + search_method, + 10.0, // minimum_segment_duration + 2.0, // maximum_difference + false, // compare_fingerprints_only_with_similar_titles + ); + let mut tool = SameMusic::new(sm_params); + + apply_common(&mut tool, ¶ms.common, params.reference_directories.as_deref()); + if let Some(min) = params.minimal_file_size { + tool.set_minimal_file_size(min); + } + if let Some(max) = params.maximal_file_size { + tool.set_maximal_file_size(max); + } + tool.set_dry_run(true); + + ok_result(run_and_serialize(&mut tool)) + } + + #[tool(description = "Find broken/invalid symbolic links in specified directories. Read-only analysis.")] + fn find_invalid_symlinks(&self, #[tool(aggr)] params: FindInvalidSymlinksParams) -> Result { + let mut tool = InvalidSymlinks::new(); + apply_common(&mut tool, ¶ms.common, None); + tool.set_dry_run(true); + ok_result(run_and_serialize(&mut tool)) + } + + #[tool(description = "Find corrupted/broken files (PDF, audio, image, archive, video). Returns list of files that cannot be properly opened. Read-only analysis.")] + fn find_broken_files(&self, #[tool(aggr)] params: FindBrokenFilesParams) -> Result { + let mut checked = CheckedTypes::NONE; + if let Some(types) = ¶ms.checked_types { + for t in types { + match t.to_lowercase().as_str() { + "pdf" => checked |= CheckedTypes::PDF, + "audio" => checked |= CheckedTypes::AUDIO, + "image" => checked |= CheckedTypes::IMAGE, + "archive" => checked |= CheckedTypes::ARCHIVE, + "video" => checked |= CheckedTypes::VIDEO, + other => return err_result(format!("Unknown checked type: '{other}'. Valid: pdf, audio, image, archive, video")), + } + } + } else { + checked = CheckedTypes::PDF | CheckedTypes::AUDIO | CheckedTypes::IMAGE | CheckedTypes::ARCHIVE | CheckedTypes::VIDEO; + } + + let bf_params = BrokenFilesParameters::new(checked); + let mut tool = BrokenFiles::new(bf_params); + apply_common(&mut tool, ¶ms.common, None); + tool.set_dry_run(true); + + ok_result(run_and_serialize(&mut tool)) + } + + #[tool(description = "Find visually similar videos using perceptual hashing. Returns groups of similar video files. Read-only analysis. Requires ffmpeg.")] + fn find_similar_videos(&self, #[tool(aggr)] params: FindSimilarVideosParams) -> Result { + use czkawka_core::re_exported::Cropdetect; + + let sv_params = SimilarVideosParameters::new( + params.tolerance.unwrap_or(10), + params.ignore_same_size.unwrap_or(false), + 15, // skip_forward_amount (default) + 10, // scan_duration (default, must be 2..=60) + Cropdetect::None, // crop_detect + false, // generate_thumbnails (no sense in MCP) + 10, + false, + 2, + ); + let mut tool = SimilarVideos::new(sv_params); + + apply_common(&mut tool, ¶ms.common, params.reference_directories.as_deref()); + if let Some(min) = params.minimal_file_size { + tool.set_minimal_file_size(min); + } + if let Some(max) = params.maximal_file_size { + tool.set_maximal_file_size(max); + } + tool.set_hide_hard_links(!params.allow_hard_links.unwrap_or(false)); + tool.set_dry_run(true); + + ok_result(run_and_serialize(&mut tool)) + } + + #[tool(description = "Find files with incorrect/mismatched extensions (e.g. a PNG file named .jpg). Read-only analysis.")] + fn find_bad_extensions(&self, #[tool(aggr)] params: FindBadExtensionsParams) -> Result { + let be_params = BadExtensionsParameters::new(); + let mut tool = BadExtensions::new(be_params); + apply_common(&mut tool, ¶ms.common, None); + ok_result(run_and_serialize(&mut tool)) + } + + #[tool(description = "Find files with problematic names (uppercase extensions, emoji, leading/trailing spaces, non-ASCII characters). Read-only analysis.")] + fn find_bad_names(&self, #[tool(aggr)] params: FindBadNamesParams) -> Result { + let name_issues = NameIssues { + uppercase_extension: params.uppercase_extension.unwrap_or(true), + emoji_used: params.emoji_used.unwrap_or(true), + space_at_start_or_end: params.space_at_start_or_end.unwrap_or(true), + non_ascii_graphical: params.non_ascii_graphical.unwrap_or(true), + restricted_charset_allowed: None, + remove_duplicated_non_alphanumeric: false, + }; + let bn_params = BadNamesParameters::new(name_issues); + let mut tool = BadNames::new(bn_params); + apply_common(&mut tool, ¶ms.common, None); + tool.set_dry_run(true); + + ok_result(run_and_serialize(&mut tool)) + } + + #[tool(description = "Analyze videos for optimization opportunities (find videos that could be transcoded to better codecs or have black bars cropped). Read-only analysis, no files are modified.")] + fn analyze_videos(&self, #[tool(aggr)] params: FindVideoOptimizerParams) -> Result { + let mode = params.mode.as_deref().unwrap_or("transcode"); + + match mode { + "transcode" => { + let excluded_codecs: Vec = params + .excluded_codecs + .as_deref() + .unwrap_or("hevc,h265,av1,vp9") + .split(',') + .map(|c| c.trim().to_string()) + .collect(); + + let vo_params = VideoOptimizerParameters::VideoTranscode(VideoTranscodeParams::new( + excluded_codecs, + false, // generate_thumbnails + 50, + false, + 2, + )); + let mut tool = VideoOptimizer::new(vo_params); + apply_common(&mut tool, ¶ms.common, None); + ok_result(run_and_serialize(&mut tool)) + } + "crop" => { + let vo_params = VideoOptimizerParameters::VideoCrop(VideoCropParams::with_custom_params( + VideoCroppingMechanism::BlackBars, + 16, // black_pixel_threshold + 3, // black_bar_percentage + 10, // max_samples + 10, // min_crop_size + false, // generate_thumbnails + 50, + false, + 2, + )); + let mut tool = VideoOptimizer::new(vo_params); + apply_common(&mut tool, ¶ms.common, None); + ok_result(run_and_serialize(&mut tool)) + } + other => err_result(format!("Unknown mode: '{other}'. Valid: transcode, crop")), + } + } + + #[tool(description = "Find image files that contain EXIF metadata tags (GPS location, camera info, etc). Read-only analysis, no tags are removed.")] + fn find_exif_tags(&self, #[tool(aggr)] params: FindExifTagsParams) -> Result { + let ignored_tags_vec: Vec = params + .ignored_tags + .map(|s| s.split(',').map(|tag| tag.trim().to_string()).collect()) + .unwrap_or_default(); + + let er_params = ExifRemoverParameters::new(ignored_tags_vec); + let mut tool = ExifRemover::new(er_params); + apply_common(&mut tool, ¶ms.common, None); + + ok_result(run_and_serialize(&mut tool)) + } +} + +// ── ServerHandler ───────────────────────────────────────────────────── + +#[tool(tool_box)] +impl ServerHandler for CzkawkaServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + instructions: Some( + "Czkawka MCP server: 14 file analysis tools for finding duplicates, similar images/videos/music, \ + broken files, empty files/folders, bad names/extensions, EXIF tags, and video optimization candidates. \ + All tools are read-only by default - no files are modified or deleted." + .to_string(), + ), + capabilities: ServerCapabilities::builder().enable_tools().build(), + ..Default::default() + } + } +} + +// ── Entry point ─────────────────────────────────────────────────────── + +#[tokio::main] +async fn main() -> Result<(), Box> { + register_image_decoding_hooks(); + + let server = CzkawkaServer; + let service = server.serve(rmcp::transport::stdio()).await?; + service.waiting().await?; + + Ok(()) +} diff --git a/czkawka_pyside6/app/bottom_panel.py b/czkawka_pyside6/app/bottom_panel.py index 736a5cd04..29d44901c 100644 --- a/czkawka_pyside6/app/bottom_panel.py +++ b/czkawka_pyside6/app/bottom_panel.py @@ -1,3 +1,5 @@ +import os + from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QListWidget, QPushButton, QFileDialog, QTextEdit, QStackedWidget, @@ -16,6 +18,7 @@ def __init__(self, settings: AppSettings, parent=None): super().__init__(parent) self._settings = settings self.setMaximumHeight(200) + self.setAcceptDrops(True) self._setup_ui() def _setup_ui(self): @@ -134,6 +137,19 @@ def _remove_excluded(self): self._settings.excluded_paths.pop(row) self.directories_changed.emit() + def dragEnterEvent(self, event): + if event.mimeData().hasUrls(): + event.acceptProposedAction() + + def dropEvent(self, event): + for url in event.mimeData().urls(): + path = url.toLocalFile() + if path and os.path.isdir(path): + if path not in self._settings.included_paths: + self._settings.included_paths.append(path) + self._inc_list.addItem(path) + self.directories_changed.emit() + def refresh_lists(self): self._inc_list.clear() for path in self._settings.included_paths: diff --git a/czkawka_pyside6/app/dialogs/__init__.py b/czkawka_pyside6/app/dialogs/__init__.py index f1c6d549e..e716a321c 100644 --- a/czkawka_pyside6/app/dialogs/__init__.py +++ b/czkawka_pyside6/app/dialogs/__init__.py @@ -5,3 +5,4 @@ from .save_dialog import SaveDialog from .rename_dialog import RenameDialog from .about_dialog import AboutDialog +from .diff_dialog import DiffDialog diff --git a/czkawka_pyside6/app/dialogs/save_dialog.py b/czkawka_pyside6/app/dialogs/save_dialog.py index 8df154bb5..dc93023d1 100644 --- a/czkawka_pyside6/app/dialogs/save_dialog.py +++ b/czkawka_pyside6/app/dialogs/save_dialog.py @@ -10,24 +10,47 @@ class SaveDialog: """Save/load results to/from file.""" @staticmethod - def save(parent, results: list, save_as_json: bool = False) -> bool: + def save(parent, results: list, save_as_json: bool = False, + tool_name: str = "") -> bool: + # Build a task-specific default filename + slug = tool_name.lower().replace(" ", "_") if tool_name else "results" + from datetime import datetime + stamp = datetime.now().strftime("%Y%m%d_%H%M%S") + default_name = f"czkawka_{slug}_{stamp}" + if save_as_json: filter_str = "JSON Files (*.json);;All Files (*)" default_ext = ".json" else: - filter_str = "Text Files (*.txt);;JSON Files (*.json);;All Files (*)" + filter_str = "Text Files (*.txt);;JSON Files (*.json);;CSV Files (*.csv);;All Files (*)" default_ext = ".txt" path, selected_filter = QFileDialog.getSaveFileName( - parent, "Save Results", f"results{default_ext}", filter_str + parent, f"Save {tool_name or 'Results'}", + f"{default_name}{default_ext}", filter_str ) if not path: return False use_json = save_as_json or path.endswith(".json") or "JSON" in selected_filter + use_csv = path.endswith(".csv") or "CSV" in selected_filter try: - if use_json: + if use_csv: + import csv + with open(path, "w", newline="") as f: + writer = csv.writer(f) + # Write header from first non-header entry + cols = [] + for entry in results: + if not entry.header_row: + cols = [k for k in entry.values.keys() if not k.startswith("__")] + writer.writerow(cols) + break + for entry in results: + if not entry.header_row: + writer.writerow([entry.values.get(k, "") for k in cols]) + elif use_json: data = [] for entry in results: if entry.header_row: diff --git a/czkawka_pyside6/app/main_window.py b/czkawka_pyside6/app/main_window.py index 5f7ebda4f..83580bd30 100644 --- a/czkawka_pyside6/app/main_window.py +++ b/czkawka_pyside6/app/main_window.py @@ -8,7 +8,7 @@ QStatusBar, QMessageBox, QLabel, QApplication ) from PySide6.QtCore import Qt, QTimer, QStandardPaths -from PySide6.QtGui import QPalette, QColor +from PySide6.QtGui import QPalette, QColor, QShortcut, QKeySequence from .state import AppState from .models import ( @@ -41,6 +41,17 @@ def __init__(self): self._setup_ui() self._connect_signals() self._apply_theme() + self._setup_shortcuts() + + from .system_tray import SystemTray + self._tray = SystemTray(self) + + from .scan_history import ScanHistory + self._scan_history = ScanHistory() + + from .scan_queue import ScanQueue + self._scan_queue = ScanQueue(self) + self._scan_queue.next_scan.connect(self._run_queued_scan) def _setup_window(self): self.setWindowTitle("Czkawka - PySide6 Edition") @@ -52,6 +63,11 @@ def _setup_window(self): if not icon.isNull(): self.setWindowIcon(icon) + # Restore saved window geometry + if self._state.window_geometry: + from PySide6.QtCore import QByteArray + self.restoreGeometry(QByteArray.fromHex(self._state.window_geometry.encode())) + # Auto-detect czkawka_cli binary self._auto_detect_cli() @@ -167,6 +183,17 @@ def _connect_signals(self): # Bottom panel self._bottom_panel.directories_changed.connect(self._on_settings_changed) + def _setup_shortcuts(self): + QShortcut(QKeySequence("Ctrl+S"), self, self._start_scan) + QShortcut(QKeySequence("Escape"), self, self._stop_scan) + QShortcut(QKeySequence("Ctrl+A"), self, lambda: self._results_view.apply_selection(SelectMode.SELECT_ALL)) + QShortcut(QKeySequence("Ctrl+D"), self, self._show_delete_dialog) + QShortcut(QKeySequence("Ctrl+M"), self, self._show_move_dialog) + QShortcut(QKeySequence("Ctrl+Shift+S"), self, self._save_results) + QShortcut(QKeySequence("Ctrl+L"), self, self._load_results) + QShortcut(QKeySequence("Ctrl+I"), self, lambda: self._results_view.apply_selection(SelectMode.INVERT_SELECTION)) + QShortcut(QKeySequence("F5"), self, self._start_scan) + def _on_tab_changed(self, tab: ActiveTab): self._state.set_active_tab(tab) self._results_view.set_active_tab(tab) @@ -228,6 +255,22 @@ def _on_scan_finished(self, tab: ActiveTab, results: list): count = sum(1 for r in results if not r.header_row) self._status_label.setText(f"Scan complete: found {count} entries") + if hasattr(self, '_tray') and not self.isVisible(): + self._tray.notify("Scan Complete", f"Found {count} entries") + + import time + duration = time.monotonic() - self._progress._start_time if self._progress._start_time else 0 + groups = sum(1 for r in results if r.header_row) + self._scan_history.add( + tool=tab.name, + directories=self._state.settings.included_paths, + entries=count, + groups=groups, + duration=duration, + ) + + self._scan_queue.on_scan_completed() + def _on_scan_progress(self, progress): self._progress.update_progress(progress) @@ -313,7 +356,9 @@ def _save_results(self): QMessageBox.information(self, "No Results", "No results to save.") return all_results = self._state.get_results() - success = SaveDialog.save(self, all_results, self._state.settings.save_as_json) + from .models import TAB_DISPLAY_NAMES + tool_name = TAB_DISPLAY_NAMES.get(self._state.active_tab, "Results") + success = SaveDialog.save(self, all_results, self._state.settings.save_as_json, tool_name) if success: self._status_label.setText("Results saved successfully") @@ -576,8 +621,15 @@ def _auto_detect_cli(self): self._state.save_settings() return + def _run_queued_scan(self, tab: ActiveTab): + self._state.set_active_tab(tab) + self._left_panel.set_active_tab(tab) + self._on_tab_changed(tab) + self._start_scan() + def closeEvent(self, event): """Save settings on close.""" + self._state.window_geometry = self.saveGeometry().toHex().data().decode() self._state.save_settings() if self._state.scanning: self._scan_runner.stop_scan() diff --git a/czkawka_pyside6/app/results_view.py b/czkawka_pyside6/app/results_view.py index c07be7add..46ea53a85 100644 --- a/czkawka_pyside6/app/results_view.py +++ b/czkawka_pyside6/app/results_view.py @@ -1,6 +1,6 @@ from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QTreeWidget, QTreeWidgetItem, QHeaderView, - QAbstractItemView, QMenu, QLabel, QHBoxLayout + QAbstractItemView, QMenu, QLabel, QHBoxLayout, QLineEdit ) from PySide6.QtCore import Signal, Qt from PySide6.QtGui import QColor, QBrush, QFont, QAction @@ -80,9 +80,73 @@ def _setup_ui(self): # Resizable columns header.setSectionResizeMode(QHeaderView.Interactive) header.setStretchLastSection(True) + header.setContextMenuPolicy(Qt.CustomContextMenu) + header.customContextMenuRequested.connect(self._on_header_context_menu) + + # Filter bar + self._filter_edit = QLineEdit() + self._filter_edit.setPlaceholderText("Filter results by filename or path...") + self._filter_edit.setClearButtonEnabled(True) + self._filter_edit.textChanged.connect(self._apply_filter) + layout.addWidget(self._filter_edit) layout.addWidget(self._tree) + def _on_header_context_menu(self, pos): + """Right-click header to show/hide columns.""" + columns = TAB_COLUMNS.get(self._active_tab, []) + menu = QMenu(self) + header = self._tree.header() + for i, col_name in enumerate(columns): + if i == 0: # Don't hide selection column + continue + action = QAction(col_name, self) + action.setCheckable(True) + action.setChecked(not header.isSectionHidden(i)) + action.toggled.connect(lambda checked, idx=i: header.setSectionHidden(idx, not checked)) + menu.addAction(action) + menu.exec_(header.mapToGlobal(pos)) + + def _apply_filter(self, text: str): + """Show/hide tree items based on filter text.""" + text = text.lower() + for i in range(self._tree.topLevelItemCount()): + item = self._tree.topLevelItem(i) + entry = item.data(0, Qt.UserRole) + if not entry: + continue + if entry.header_row: + # Show header if any child in its group matches + item.setHidden(False) # Will be hidden later if no children visible + continue + if not text: + item.setHidden(False) + else: + name = str(entry.values.get("File Name", "")).lower() + path = str(entry.values.get("Path", "")).lower() + full = str(entry.values.get("__full_path", "")).lower() + item.setHidden(text not in name and text not in path and text not in full) + + # Hide group headers with no visible children + if text and self._active_tab in GROUPED_TABS: + i = 0 + while i < self._tree.topLevelItemCount(): + item = self._tree.topLevelItem(i) + entry = item.data(0, Qt.UserRole) + if entry and entry.header_row: + # Check if any following non-header items are visible + has_visible = False + for j in range(i + 1, self._tree.topLevelItemCount()): + next_item = self._tree.topLevelItem(j) + next_entry = next_item.data(0, Qt.UserRole) + if next_entry and next_entry.header_row: + break + if not next_item.isHidden(): + has_visible = True + break + item.setHidden(not has_visible) + i += 1 + def set_active_tab(self, tab: ActiveTab): self._active_tab = tab self._sort_column = -1 @@ -288,6 +352,15 @@ def _on_context_menu(self, pos): deselect_action.triggered.connect(lambda: self._set_check(item, False)) menu.addAction(deselect_action) + # Compare action (when 2 items are selected) + selected_items = self._tree.selectedItems() + data_items = [it for it in selected_items if it.data(0, Qt.UserRole) and not it.data(0, Qt.UserRole).header_row] + if len(data_items) == 2: + menu.addSeparator() + compare_action = QAction("Compare Selected", self) + compare_action.triggered.connect(lambda: self._compare_items(data_items[0], data_items[1])) + menu.addAction(compare_action) + menu.exec_(self._tree.viewport().mapToGlobal(pos)) def _open_file(self, entry: ResultEntry): @@ -317,6 +390,14 @@ def _open_folder(self, entry: ResultEntry): def _set_check(self, item, checked): item.setCheckState(0, Qt.Checked if checked else Qt.Unchecked) + def _compare_items(self, item1, item2): + from .dialogs.diff_dialog import DiffDialog + entry1 = item1.data(0, Qt.UserRole) + entry2 = item2.data(0, Qt.UserRole) + if entry1 and entry2: + dialog = DiffDialog(entry1, entry2, self) + dialog.exec() + # ── Summary / selection ────────────────────────────────── def _update_summary(self): diff --git a/czkawka_pyside6/app/state.py b/czkawka_pyside6/app/state.py index 9c73524ff..73313056a 100644 --- a/czkawka_pyside6/app/state.py +++ b/czkawka_pyside6/app/state.py @@ -32,6 +32,8 @@ def __init__(self): self.progress = ScanProgress() self.info_text = "" self.preview_image_path = "" + self.window_geometry = None # bytes as hex string + self.window_state = None # Use QStandardPaths for XDG-compliant config location config_dir = QStandardPaths.writableLocation(QStandardPaths.AppConfigLocation) self._config_path = Path(config_dir) if config_dir else Path.home() / ".config" / "czkawka" @@ -92,6 +94,8 @@ def save_settings(self): "dark_theme": self.settings.dark_theme, "show_image_preview": self.settings.show_image_preview, "czkawka_cli_path": self.settings.czkawka_cli_path, + "window_geometry": self.window_geometry, + "window_state": self.window_state, } try: config_file.write_text(json.dumps(data, indent=2)) @@ -120,5 +124,7 @@ def load_settings(self): s.dark_theme = data.get("dark_theme", s.dark_theme) s.show_image_preview = data.get("show_image_preview", s.show_image_preview) s.czkawka_cli_path = data.get("czkawka_cli_path", s.czkawka_cli_path) + self.window_geometry = data.get("window_geometry", self.window_geometry) + self.window_state = data.get("window_state", self.window_state) except (json.JSONDecodeError, OSError): pass From 0d8bd6dc1e37e99ab15a1d2109dd5010e5f25186 Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 11:17:13 +0100 Subject: [PATCH 09/49] Add system tray integration with minimize-to-tray - System tray icon with context menu (Show/Hide, Start Scan, Quit) - Click tray icon to toggle window visibility - Notification balloon when scan completes while window is hidden - Uses window icon from XDG theme or project logo Co-Authored-By: Claude Opus 4.6 (1M context) --- czkawka_pyside6/app/system_tray.py | 50 ++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 czkawka_pyside6/app/system_tray.py diff --git a/czkawka_pyside6/app/system_tray.py b/czkawka_pyside6/app/system_tray.py new file mode 100644 index 000000000..05ecc1bc2 --- /dev/null +++ b/czkawka_pyside6/app/system_tray.py @@ -0,0 +1,50 @@ +from PySide6.QtWidgets import QSystemTrayIcon, QMenu +from PySide6.QtGui import QAction, QIcon + + +class SystemTray: + """System tray icon with minimize-to-tray and scan notification.""" + + def __init__(self, main_window): + self._window = main_window + self._tray = QSystemTrayIcon(main_window) + + icon = main_window.windowIcon() + if not icon.isNull(): + self._tray.setIcon(icon) + + self._tray.setToolTip("Czkawka PySide6") + + menu = QMenu() + show_action = QAction("Show/Hide", main_window) + show_action.triggered.connect(self._toggle_window) + menu.addAction(show_action) + + scan_action = QAction("Start Scan", main_window) + scan_action.triggered.connect(main_window._start_scan) + menu.addAction(scan_action) + + menu.addSeparator() + + quit_action = QAction("Quit", main_window) + quit_action.triggered.connect(main_window.close) + menu.addAction(quit_action) + + self._tray.setContextMenu(menu) + self._tray.activated.connect(self._on_activated) + self._tray.show() + + def _toggle_window(self): + if self._window.isVisible(): + self._window.hide() + else: + self._window.show() + self._window.raise_() + self._window.activateWindow() + + def _on_activated(self, reason): + if reason == QSystemTrayIcon.ActivationReason.Trigger: + self._toggle_window() + + def notify(self, title: str, message: str): + self._tray.showMessage(title, message, QSystemTrayIcon.MessageIcon.Information, 5000) From 3341695f9c8ccb87ef064de802875691f487b0cb Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 11:17:22 +0100 Subject: [PATCH 10/49] Add scan history with persistent log - ScanHistory class persists up to 100 scan records as JSON - Records: timestamp, tool, directories, entries/groups found, duration - Uses QStandardPaths for XDG-compliant storage location - Integrated with scan completion in main window Co-Authored-By: Claude Opus 4.6 (1M context) --- czkawka_pyside6/app/scan_history.py | 65 +++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 czkawka_pyside6/app/scan_history.py diff --git a/czkawka_pyside6/app/scan_history.py b/czkawka_pyside6/app/scan_history.py new file mode 100644 index 000000000..81ff279af --- /dev/null +++ b/czkawka_pyside6/app/scan_history.py @@ -0,0 +1,65 @@ +import json +from datetime import datetime +from pathlib import Path +from dataclasses import dataclass, asdict +from PySide6.QtCore import QStandardPaths + + +@dataclass +class ScanRecord: + timestamp: str + tool: str + directories: list[str] + entries_found: int + groups_found: int + duration_seconds: float + + +class ScanHistory: + """Persists a log of past scans.""" + + MAX_RECORDS = 100 + + def __init__(self): + config_dir = QStandardPaths.writableLocation(QStandardPaths.AppConfigLocation) + self._path = Path(config_dir) / "scan_history.json" if config_dir else Path.home() / ".config" / "czkawka" / "scan_history.json" + self._records: list[ScanRecord] = [] + self._load() + + def add(self, tool: str, directories: list[str], entries: int, + groups: int, duration: float): + record = ScanRecord( + timestamp=datetime.now().isoformat(timespec="seconds"), + tool=tool, + directories=directories, + entries_found=entries, + groups_found=groups, + duration_seconds=round(duration, 1), + ) + self._records.append(record) + if len(self._records) > self.MAX_RECORDS: + self._records = self._records[-self.MAX_RECORDS:] + self._save() + + def get_records(self) -> list[ScanRecord]: + return list(self._records) + + def clear(self): + self._records = [] + self._save() + + def _save(self): + try: + self._path.parent.mkdir(parents=True, exist_ok=True) + data = [asdict(r) for r in self._records] + self._path.write_text(json.dumps(data, indent=2)) + except OSError: + pass + + def _load(self): + try: + if self._path.exists(): + data = json.loads(self._path.read_text()) + self._records = [ScanRecord(**d) for d in data] + except (json.JSONDecodeError, OSError, TypeError): + self._records = [] From 4e195923e48dd6bea48bef0ed36eeec532c7cc06 Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 11:17:28 +0100 Subject: [PATCH 11/49] Add multi-tab scan queue for sequential scanning - ScanQueue class using deque for FIFO scan scheduling - Queue multiple tool types and run them sequentially - Signals: queue_updated, queue_finished, next_scan - Integrated with scan completion to auto-trigger next queued scan Co-Authored-By: Claude Opus 4.6 (1M context) --- czkawka_pyside6/app/scan_queue.py | 56 +++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 czkawka_pyside6/app/scan_queue.py diff --git a/czkawka_pyside6/app/scan_queue.py b/czkawka_pyside6/app/scan_queue.py new file mode 100644 index 000000000..07fe2bd25 --- /dev/null +++ b/czkawka_pyside6/app/scan_queue.py @@ -0,0 +1,56 @@ +from collections import deque +from PySide6.QtCore import QObject, Signal +from .models import ActiveTab + + +class ScanQueue(QObject): + """Queue multiple scan types and run them sequentially.""" + + queue_updated = Signal(int) # queue size + queue_finished = Signal() + next_scan = Signal(object) # ActiveTab to scan next + + def __init__(self, parent=None): + super().__init__(parent) + self._queue: deque[ActiveTab] = deque() + self._running = False + + def add(self, tab: ActiveTab): + if tab not in self._queue: + self._queue.append(tab) + self.queue_updated.emit(len(self._queue)) + + def add_all(self, tabs: list[ActiveTab]): + for tab in tabs: + self.add(tab) + + def start(self): + self._running = True + self._run_next() + + def stop(self): + self._running = False + self._queue.clear() + self.queue_updated.emit(0) + + def on_scan_completed(self): + """Called when a scan finishes. Triggers next in queue.""" + if self._running: + self._run_next() + + def _run_next(self): + if self._queue: + tab = self._queue.popleft() + self.queue_updated.emit(len(self._queue)) + self.next_scan.emit(tab) + else: + self._running = False + self.queue_finished.emit() + + @property + def is_running(self) -> bool: + return self._running + + @property + def pending_count(self) -> int: + return len(self._queue) From 9e25fc020ca658e3c0c47509ee4a9ce8ea655559 Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 11:17:36 +0100 Subject: [PATCH 12/49] Add side-by-side diff view for duplicate file comparison - DiffDialog with side-by-side file panels showing name, path, size, date - Image preview for supported formats (jpg, png, gif, etc.) - Difference summary highlighting size/date/directory differences - Accessible via right-click "Compare Selected" when 2 items are selected Co-Authored-By: Claude Opus 4.6 (1M context) --- czkawka_pyside6/app/dialogs/diff_dialog.py | 109 +++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 czkawka_pyside6/app/dialogs/diff_dialog.py diff --git a/czkawka_pyside6/app/dialogs/diff_dialog.py b/czkawka_pyside6/app/dialogs/diff_dialog.py new file mode 100644 index 000000000..f71bdae83 --- /dev/null +++ b/czkawka_pyside6/app/dialogs/diff_dialog.py @@ -0,0 +1,109 @@ +import os +from pathlib import Path + +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QDialogButtonBox, + QFrame, QSizePolicy, QScrollArea, QWidget +) +from PySide6.QtCore import Qt +from PySide6.QtGui import QPixmap, QFont + + +class DiffDialog(QDialog): + """Side-by-side comparison of two files from a duplicate group.""" + + def __init__(self, entry1, entry2, parent=None): + super().__init__(parent) + self.setWindowTitle("File Comparison") + self.setMinimumSize(700, 500) + + layout = QVBoxLayout(self) + + # Side-by-side layout + compare_layout = QHBoxLayout() + + compare_layout.addWidget(self._create_file_panel(entry1)) + + # Separator + sep = QFrame() + sep.setFrameShape(QFrame.VLine) + sep.setFrameShadow(QFrame.Sunken) + compare_layout.addWidget(sep) + + compare_layout.addWidget(self._create_file_panel(entry2)) + + layout.addLayout(compare_layout) + + # Difference summary + diff_label = QLabel(self._compute_diff_summary(entry1, entry2)) + diff_label.setAlignment(Qt.AlignCenter) + diff_label.setWordWrap(True) + layout.addWidget(diff_label) + + buttons = QDialogButtonBox(QDialogButtonBox.Ok) + buttons.accepted.connect(self.accept) + layout.addWidget(buttons) + + def _create_file_panel(self, entry) -> QWidget: + panel = QWidget() + layout = QVBoxLayout(panel) + + path = entry.values.get("__full_path", "") + p = Path(path) + + # File name + name = QLabel(p.name) + name_font = QFont() + name_font.setBold(True) + name.setFont(name_font) + name.setAlignment(Qt.AlignCenter) + name.setWordWrap(True) + layout.addWidget(name) + + # Image preview if applicable + if p.suffix.lower() in ('.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'): + img_label = QLabel() + pixmap = QPixmap(path) + if not pixmap.isNull(): + scaled = pixmap.scaledToWidth(300, Qt.SmoothTransformation) + img_label.setPixmap(scaled) + img_label.setAlignment(Qt.AlignCenter) + layout.addWidget(img_label) + + # File details + details = [ + f"Path: {p.parent}", + f"Size: {entry.values.get('Size', 'N/A')}", + f"Modified: {entry.values.get('Modification Date', 'N/A')}", + ] + if entry.values.get("Hash"): + details.append(f"Hash: {entry.values.get('Hash', '')[:16]}...") + + for d in details: + lbl = QLabel(d) + lbl.setWordWrap(True) + layout.addWidget(lbl) + + layout.addStretch() + return panel + + def _compute_diff_summary(self, entry1, entry2) -> str: + diffs = [] + s1 = entry1.values.get("__size_bytes", 0) + s2 = entry2.values.get("__size_bytes", 0) + if s1 != s2: + diffs.append(f"Size differs: {entry1.values.get('Size', '')} vs {entry2.values.get('Size', '')}") + + t1 = entry1.values.get("__modified_date_ts", 0) + t2 = entry2.values.get("__modified_date_ts", 0) + if t1 != t2: + diffs.append(f"Modified date differs: {entry1.values.get('Modification Date', '')} vs {entry2.values.get('Modification Date', '')}") + + p1 = entry1.values.get("__full_path", "") + p2 = entry2.values.get("__full_path", "") + if Path(p1).parent != Path(p2).parent: + diffs.append("Files are in different directories") + + if not diffs: + return "Files are identical in size and modification date" + return " | ".join(diffs) From 422e38847efa37230851d696a4b00e46c19d1cf8 Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 11:28:22 +0100 Subject: [PATCH 13/49] Add comprehensive test suite for PySide6 frontend (98 tests) Test coverage across 7 test files: - test_models.py (16 tests): ActiveTab completeness, ResultEntry, ScanProgress, ToolSettings, AppSettings, enum values - test_backend.py (25 tests): CLI command building for all 14 tools, common flags, JSON result parsing (flat/grouped/empty/missing), format_size and format_date utilities - test_widgets.py (24 tests): ResultsView (tab switching, grouped/flat results, select all/none/invert/biggest/newest, sorting, filtering, clear), LeftPanel, ActionButtons (scanning state, tab visibility), ProgressWidget (start/stop, progress updates, formatters) - test_new_features.py (20 tests): ScanHistory (CRUD, max records, persistence), ScanQueue (add/dedup/sequential/stop/signals), SaveLoad JSON roundtrip, CLI format parsing, DiffDialog - test_main_window.py (13 tests): Integration tests for window creation, all 14 tabs, state, shortcuts, tray, history, queue, drag-drop, filter, icon, button visibility, results set/clear Co-Authored-By: Claude Opus 4.6 (1M context) --- czkawka_pyside6/tests/__init__.py | 0 czkawka_pyside6/tests/conftest.py | 20 ++ czkawka_pyside6/tests/test_backend.py | 208 ++++++++++++++++ czkawka_pyside6/tests/test_main_window.py | 85 +++++++ czkawka_pyside6/tests/test_models.py | 122 ++++++++++ czkawka_pyside6/tests/test_new_features.py | 221 +++++++++++++++++ czkawka_pyside6/tests/test_widgets.py | 262 +++++++++++++++++++++ 7 files changed, 918 insertions(+) create mode 100644 czkawka_pyside6/tests/__init__.py create mode 100644 czkawka_pyside6/tests/conftest.py create mode 100644 czkawka_pyside6/tests/test_backend.py create mode 100644 czkawka_pyside6/tests/test_main_window.py create mode 100644 czkawka_pyside6/tests/test_models.py create mode 100644 czkawka_pyside6/tests/test_new_features.py create mode 100644 czkawka_pyside6/tests/test_widgets.py diff --git a/czkawka_pyside6/tests/__init__.py b/czkawka_pyside6/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/czkawka_pyside6/tests/conftest.py b/czkawka_pyside6/tests/conftest.py new file mode 100644 index 000000000..cefab504f --- /dev/null +++ b/czkawka_pyside6/tests/conftest.py @@ -0,0 +1,20 @@ +import pytest +import sys +import os + +os.environ["QT_QPA_PLATFORM"] = "offscreen" + +# Add parent to path so 'app' package is importable +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from PySide6.QtWidgets import QApplication + + +@pytest.fixture(scope="session") +def qapp(): + app = QApplication.instance() + if app is None: + app = QApplication(sys.argv) + app.setApplicationName("Czkawka") + app.setOrganizationName("czkawka") + return app diff --git a/czkawka_pyside6/tests/test_backend.py b/czkawka_pyside6/tests/test_backend.py new file mode 100644 index 000000000..7ae5c639a --- /dev/null +++ b/czkawka_pyside6/tests/test_backend.py @@ -0,0 +1,208 @@ +"""Tests for app.backend — command building and JSON parsing.""" +import json +import os +import sys +import tempfile + +os.environ["QT_QPA_PLATFORM"] = "offscreen" +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from app.models import ( + ActiveTab, AppSettings, ToolSettings, ResultEntry, + CheckingMethod, HashType, MusicSearchMethod, +) +from app.backend import ScanWorker + + +def _make_worker(tab: ActiveTab, **kwargs) -> ScanWorker: + settings = AppSettings() + settings.included_paths = ["/tmp/test"] + settings.czkawka_cli_path = "czkawka_cli" + ts = ToolSettings(**kwargs) if kwargs else ToolSettings() + return ScanWorker(tab, settings, ts) + + +class TestCommandBuilding: + def test_duplicate_hash_command(self): + w = _make_worker(ActiveTab.DUPLICATE_FILES) + cmd = w._build_command() + assert cmd[1] == "dup" + assert "-d" in cmd + assert "-s" in cmd + idx = cmd.index("-s") + assert cmd[idx + 1] == "HASH" + + def test_duplicate_name_command(self): + w = _make_worker(ActiveTab.DUPLICATE_FILES, dup_check_method=CheckingMethod.NAME) + cmd = w._build_command() + idx = cmd.index("-s") + assert cmd[idx + 1] == "NAME" + + def test_empty_folders_command(self): + w = _make_worker(ActiveTab.EMPTY_FOLDERS) + cmd = w._build_command() + assert cmd[1] == "empty-folders" + + def test_big_files_command(self): + w = _make_worker(ActiveTab.BIG_FILES) + cmd = w._build_command() + assert cmd[1] == "big" + assert "-n" in cmd + + def test_big_files_smallest(self): + w = _make_worker(ActiveTab.BIG_FILES, big_files_mode="smallest") + cmd = w._build_command() + assert "-J" in cmd + + def test_similar_images_command(self): + w = _make_worker(ActiveTab.SIMILAR_IMAGES) + cmd = w._build_command() + assert cmd[1] == "image" + assert "-g" in cmd + assert "-c" in cmd + + def test_similar_videos_command(self): + w = _make_worker(ActiveTab.SIMILAR_VIDEOS) + cmd = w._build_command() + assert cmd[1] == "video" + + def test_music_tags_command(self): + w = _make_worker(ActiveTab.SIMILAR_MUSIC) + cmd = w._build_command() + assert cmd[1] == "music" + assert "-s" in cmd + idx = cmd.index("-s") + assert cmd[idx + 1] == "TAGS" + + def test_music_content_command(self): + w = _make_worker(ActiveTab.SIMILAR_MUSIC, music_search_method=MusicSearchMethod.CONTENT) + cmd = w._build_command() + idx = cmd.index("-s") + assert cmd[idx + 1] == "CONTENT" + + def test_broken_files_command(self): + w = _make_worker(ActiveTab.BROKEN_FILES) + cmd = w._build_command() + assert cmd[1] == "broken" + + def test_bad_extensions_command(self): + w = _make_worker(ActiveTab.BAD_EXTENSIONS) + cmd = w._build_command() + assert cmd[1] == "ext" + + def test_bad_names_command(self): + w = _make_worker(ActiveTab.BAD_NAMES) + cmd = w._build_command() + assert cmd[1] == "bad-names" + + def test_empty_files_command(self): + w = _make_worker(ActiveTab.EMPTY_FILES) + cmd = w._build_command() + assert cmd[1] == "empty-files" + + def test_temp_files_command(self): + w = _make_worker(ActiveTab.TEMPORARY_FILES) + cmd = w._build_command() + assert cmd[1] == "temp" + + def test_symlinks_command(self): + w = _make_worker(ActiveTab.INVALID_SYMLINKS) + cmd = w._build_command() + assert cmd[1] == "symlinks" + + def test_no_recursive_flag(self): + w = _make_worker(ActiveTab.DUPLICATE_FILES) + w.app_settings.recursive_search = False + cmd = w._build_command() + assert "-R" in cmd + + def test_thread_number(self): + w = _make_worker(ActiveTab.DUPLICATE_FILES) + w.app_settings.thread_number = 4 + cmd = w._build_command() + assert "-T" in cmd + idx = cmd.index("-T") + assert cmd[idx + 1] == "4" + + def test_excluded_paths(self): + w = _make_worker(ActiveTab.DUPLICATE_FILES) + w.app_settings.excluded_paths = ["/tmp/exclude"] + cmd = w._build_command() + assert "-e" in cmd + + def test_allowed_extensions(self): + w = _make_worker(ActiveTab.DUPLICATE_FILES) + w.app_settings.allowed_extensions = "jpg,png" + cmd = w._build_command() + assert "-x" in cmd + + +class TestResultParsing: + def test_parse_flat_results(self): + w = _make_worker(ActiveTab.EMPTY_FILES) + data = [ + {"path": "/tmp/a.txt", "size": 0, "modified_date": 1000}, + {"path": "/tmp/b.txt", "size": 0, "modified_date": 2000}, + ] + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(data, f) + path = f.name + try: + results = w._parse_results(path) + assert len(results) == 2 + assert results[0].values["File Name"] == "a.txt" + assert results[1].values["__full_path"] == "/tmp/b.txt" + finally: + os.unlink(path) + + def test_parse_grouped_results_dict(self): + w = _make_worker(ActiveTab.DUPLICATE_FILES) + data = { + "1024": [ + [ + {"path": "/a/file.txt", "size": 1024, "modified_date": 100, "hash": "abc"}, + {"path": "/b/file.txt", "size": 1024, "modified_date": 200, "hash": "abc"}, + ] + ] + } + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(data, f) + path = f.name + try: + results = w._parse_results(path) + headers = [r for r in results if r.header_row] + files = [r for r in results if not r.header_row] + assert len(headers) == 1 + assert len(files) == 2 + assert files[0].group_id == 0 + assert files[1].group_id == 0 + finally: + os.unlink(path) + + def test_parse_empty_json(self): + w = _make_worker(ActiveTab.EMPTY_FILES) + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump([], f) + path = f.name + try: + results = w._parse_results(path) + assert results == [] + finally: + os.unlink(path) + + def test_parse_missing_file(self): + w = _make_worker(ActiveTab.EMPTY_FILES) + results = w._parse_results("/nonexistent/file.json") + assert results == [] + + def test_format_size(self): + assert ScanWorker._format_size(0) == "0 B" + assert ScanWorker._format_size(512) == "512 B" + assert ScanWorker._format_size(1024) == "1.0 KB" + assert ScanWorker._format_size(1048576) == "1.0 MB" + assert ScanWorker._format_size(1073741824) == "1.0 GB" + + def test_format_date(self): + assert ScanWorker._format_date(0) == "" + result = ScanWorker._format_date(1700000000) + assert "2023" in result # Nov 2023 diff --git a/czkawka_pyside6/tests/test_main_window.py b/czkawka_pyside6/tests/test_main_window.py new file mode 100644 index 000000000..e003e74df --- /dev/null +++ b/czkawka_pyside6/tests/test_main_window.py @@ -0,0 +1,85 @@ +"""Integration tests for MainWindow.""" +import pytest +import sys +import os + +os.environ["QT_QPA_PLATFORM"] = "offscreen" +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from PySide6.QtWidgets import QApplication + + +@pytest.fixture(scope="session") +def qapp(): + app = QApplication.instance() + if app is None: + app = QApplication(sys.argv) + app.setApplicationName("Czkawka") + app.setOrganizationName("czkawka") + return app + + +from app.main_window import MainWindow +from app.models import ActiveTab, ResultEntry + + +@pytest.fixture +def window(qapp): + return MainWindow() + + +class TestMainWindow: + def test_creation(self, window): + assert window.windowTitle() == "Czkawka - PySide6 Edition" + assert window.minimumWidth() == 900 + + def test_all_tabs(self, window): + for tab in list(ActiveTab)[:14]: + window._on_tab_changed(tab) + assert True # No crash + + def test_initial_state(self, window): + assert window._state.scanning is False + assert window._state.active_tab == ActiveTab.DUPLICATE_FILES + + def test_has_shortcuts(self, window): + assert hasattr(window, '_setup_shortcuts') + + def test_has_system_tray(self, window): + assert hasattr(window, '_tray') + + def test_has_scan_history(self, window): + assert hasattr(window, '_scan_history') + + def test_has_scan_queue(self, window): + assert hasattr(window, '_scan_queue') + + def test_bottom_panel_accepts_drops(self, window): + assert window._bottom_panel.acceptDrops() is True + + def test_results_view_has_filter(self, window): + assert hasattr(window._results_view, '_filter_edit') + + def test_action_buttons_load(self, window): + assert hasattr(window._action_buttons, '_load_btn') + + def test_window_icon(self, window): + assert not window.windowIcon().isNull() + + def test_tab_change_updates_buttons(self, window): + window._on_tab_changed(ActiveTab.EXIF_REMOVER) + assert not window._action_buttons._clean_exif_btn.isHidden() + window._on_tab_changed(ActiveTab.DUPLICATE_FILES) + assert not window._action_buttons._hardlink_btn.isHidden() + + def test_set_results_and_clear(self, window): + results = [ + ResultEntry(values={"File Name": "test.txt", "Path": "/tmp", + "__full_path": "/tmp/test.txt", "__size_bytes": 100, + "__modified_date_ts": 1000}), + ] + window._results_view.set_active_tab(ActiveTab.EMPTY_FILES) + window._results_view.set_results(results) + assert window._results_view._tree.topLevelItemCount() == 1 + window._results_view.clear() + assert window._results_view._tree.topLevelItemCount() == 0 diff --git a/czkawka_pyside6/tests/test_models.py b/czkawka_pyside6/tests/test_models.py new file mode 100644 index 000000000..e79eb5aec --- /dev/null +++ b/czkawka_pyside6/tests/test_models.py @@ -0,0 +1,122 @@ +"""Tests for app.models — pure data, no Qt needed.""" +import sys, os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from app.models import ( + ActiveTab, SelectMode, DeleteMethod, CheckingMethod, HashType, + TAB_TO_CLI_COMMAND, TAB_DISPLAY_NAMES, TAB_COLUMNS, + GROUPED_TABS, TABS_WITH_SETTINGS, + ResultEntry, ScanProgress, ToolSettings, AppSettings, +) + + +class TestActiveTab: + def test_all_14_tool_tabs(self): + tool_tabs = [t for t in ActiveTab if t not in (ActiveTab.SETTINGS, ActiveTab.ABOUT)] + assert len(tool_tabs) == 14 + + def test_all_tabs_have_display_names(self): + for tab in ActiveTab: + if tab in (ActiveTab.SETTINGS, ActiveTab.ABOUT): + continue + assert tab in TAB_DISPLAY_NAMES, f"Missing display name for {tab}" + + def test_all_tabs_have_cli_commands(self): + for tab in ActiveTab: + if tab in (ActiveTab.SETTINGS, ActiveTab.ABOUT): + continue + assert tab in TAB_TO_CLI_COMMAND, f"Missing CLI command for {tab}" + + def test_all_tabs_have_columns(self): + for tab in ActiveTab: + if tab in (ActiveTab.SETTINGS, ActiveTab.ABOUT): + continue + assert tab in TAB_COLUMNS, f"Missing columns for {tab}" + assert len(TAB_COLUMNS[tab]) >= 2, f"Too few columns for {tab}" + + def test_grouped_tabs_are_subset(self): + tool_tabs = set(t for t in ActiveTab if t not in (ActiveTab.SETTINGS, ActiveTab.ABOUT)) + assert GROUPED_TABS.issubset(tool_tabs) + + def test_tabs_with_settings_are_subset(self): + tool_tabs = set(t for t in ActiveTab if t not in (ActiveTab.SETTINGS, ActiveTab.ABOUT)) + assert TABS_WITH_SETTINGS.issubset(tool_tabs) + + +class TestResultEntry: + def test_default_values(self): + entry = ResultEntry(values={"File Name": "test.txt"}) + assert entry.checked is False + assert entry.header_row is False + assert entry.group_id == 0 + + def test_header_row(self): + entry = ResultEntry(values={"__header": "Group 1"}, header_row=True, group_id=5) + assert entry.header_row is True + assert entry.group_id == 5 + + +class TestScanProgress: + def test_defaults(self): + p = ScanProgress() + assert p.step_name == "" + assert p.entries_checked == 0 + assert p.entries_to_check == 0 + assert p.bytes_checked == 0 + assert p.bytes_to_check == 0 + assert p.current_stage_idx == 0 + assert p.max_stage_idx == 0 + + def test_with_values(self): + p = ScanProgress( + stage_name="Hashing", + current_stage_idx=3, + max_stage_idx=6, + entries_checked=500, + entries_to_check=1000, + ) + assert p.stage_name == "Hashing" + assert p.entries_checked == 500 + + +class TestToolSettings: + def test_defaults(self): + ts = ToolSettings() + assert ts.dup_check_method == CheckingMethod.HASH + assert ts.dup_hash_type == HashType.BLAKE3 + assert ts.img_hash_size == 16 + assert ts.big_files_number == 50 + assert ts.big_files_mode == "biggest" + + def test_mutability(self): + ts = ToolSettings() + ts.dup_check_method = CheckingMethod.NAME + assert ts.dup_check_method == CheckingMethod.NAME + + +class TestAppSettings: + def test_defaults(self): + s = AppSettings() + assert s.recursive_search is True + assert s.use_cache is True + assert s.move_to_trash is True + assert s.thread_number == 0 + assert isinstance(s.included_paths, list) + + def test_included_paths_default(self): + s = AppSettings() + assert len(s.included_paths) == 1 # home dir + + +class TestEnums: + def test_select_modes(self): + assert len(SelectMode) >= 10 + + def test_delete_methods(self): + assert DeleteMethod.NONE.value == "NONE" + assert DeleteMethod.DELETE.value == "DELETE" + + def test_hash_types(self): + assert HashType.BLAKE3.value == "BLAKE3" + assert HashType.CRC32.value == "CRC32" + assert HashType.XXH3.value == "XXH3" diff --git a/czkawka_pyside6/tests/test_new_features.py b/czkawka_pyside6/tests/test_new_features.py new file mode 100644 index 000000000..fd5c94bef --- /dev/null +++ b/czkawka_pyside6/tests/test_new_features.py @@ -0,0 +1,221 @@ +"""Tests for newly added features.""" +import json +import os +import sys +import tempfile + +import pytest + +os.environ["QT_QPA_PLATFORM"] = "offscreen" +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from PySide6.QtWidgets import QApplication + + +@pytest.fixture(scope="session") +def qapp(): + app = QApplication.instance() + if app is None: + app = QApplication(sys.argv) + app.setApplicationName("Czkawka") + app.setOrganizationName("czkawka") + return app + + +from app.models import ActiveTab, ResultEntry +from app.scan_history import ScanHistory, ScanRecord +from app.scan_queue import ScanQueue + + +class TestScanHistory: + def test_creation(self): + h = ScanHistory() + assert isinstance(h.get_records(), list) + + def test_add_record(self): + h = ScanHistory() + initial = len(h.get_records()) + h.add("DUPLICATE_FILES", ["/tmp"], 100, 50, 5.5) + assert len(h.get_records()) == initial + 1 + record = h.get_records()[-1] + assert record.tool == "DUPLICATE_FILES" + assert record.entries_found == 100 + assert record.groups_found == 50 + assert record.duration_seconds == 5.5 + + def test_max_records(self): + h = ScanHistory() + h._records = [] + for i in range(150): + h.add("TEST", ["/tmp"], i, 0, 1.0) + assert len(h.get_records()) <= ScanHistory.MAX_RECORDS + + def test_clear(self): + h = ScanHistory() + h.add("TEST", ["/tmp"], 1, 0, 1.0) + h.clear() + assert len(h.get_records()) == 0 + + def test_persistence(self, tmp_path): + h = ScanHistory() + h._path = tmp_path / "test_history.json" + h.add("TEST", ["/tmp"], 42, 10, 2.5) + + h2 = ScanHistory() + h2._path = tmp_path / "test_history.json" + h2._load() + assert len(h2.get_records()) >= 1 + assert h2.get_records()[-1].entries_found == 42 + + +class TestScanQueue: + def test_creation(self, qapp): + q = ScanQueue() + assert q.pending_count == 0 + assert q.is_running is False + + def test_add(self, qapp): + q = ScanQueue() + q.add(ActiveTab.DUPLICATE_FILES) + assert q.pending_count == 1 + + def test_add_duplicate(self, qapp): + q = ScanQueue() + q.add(ActiveTab.DUPLICATE_FILES) + q.add(ActiveTab.DUPLICATE_FILES) + assert q.pending_count == 1 # No duplicates + + def test_add_all(self, qapp): + q = ScanQueue() + q.add_all([ActiveTab.DUPLICATE_FILES, ActiveTab.EMPTY_FILES, ActiveTab.BIG_FILES]) + assert q.pending_count == 3 + + def test_stop(self, qapp): + q = ScanQueue() + q.add_all([ActiveTab.DUPLICATE_FILES, ActiveTab.EMPTY_FILES]) + q.stop() + assert q.pending_count == 0 + assert q.is_running is False + + def test_start_emits_next(self, qapp): + q = ScanQueue() + q.add(ActiveTab.DUPLICATE_FILES) + received = [] + q.next_scan.connect(lambda tab: received.append(tab)) + q.start() + assert len(received) == 1 + assert received[0] == ActiveTab.DUPLICATE_FILES + + def test_sequential_execution(self, qapp): + q = ScanQueue() + q.add_all([ActiveTab.DUPLICATE_FILES, ActiveTab.EMPTY_FILES]) + received = [] + q.next_scan.connect(lambda tab: received.append(tab)) + q.start() + assert received == [ActiveTab.DUPLICATE_FILES] + q.on_scan_completed() + assert received == [ActiveTab.DUPLICATE_FILES, ActiveTab.EMPTY_FILES] + q.on_scan_completed() + assert q.is_running is False + + +class TestSaveLoad: + def test_save_load_roundtrip(self, tmp_path): + from app.dialogs.save_dialog import SaveDialog + + results = [ + ResultEntry(values={"__header": "Group 1", "__group_id": 0}, header_row=True, group_id=0), + ResultEntry(values={"File Name": "a.txt", "Path": "/home", "__full_path": "/home/a.txt", + "__size_bytes": 1024, "__modified_date_ts": 1000}, group_id=0), + ] + + # Save as JSON manually (can't use file dialog in test) + save_path = str(tmp_path / "test_results.json") + data = [] + for entry in results: + if entry.header_row: + data.append({"__header": entry.values.get("__header", ""), "__group_id": entry.group_id}) + else: + values = dict(entry.values) + values["__group_id"] = entry.group_id + values["__checked"] = entry.checked + data.append(values) + with open(save_path, "w") as f: + json.dump(data, f) + + # Load + with open(save_path) as f: + loaded_data = json.load(f) + + loaded = [] + for item in loaded_data: + if "__header" in item: + loaded.append(ResultEntry( + values={"__header": item["__header"]}, + header_row=True, + group_id=item.get("__group_id", 0), + )) + else: + gid = item.pop("__group_id", 0) + chk = item.pop("__checked", False) + loaded.append(ResultEntry(values=item, checked=chk, group_id=gid)) + + assert len(loaded) == 2 + assert loaded[0].header_row is True + assert loaded[1].values["File Name"] == "a.txt" + + def test_load_cli_format(self, tmp_path): + from app.dialogs.save_dialog import _parse_cli_json + + data = { + "1024": [ + [ + {"path": "/a/file.txt", "size": 1024, "modified_date": 100, "hash": "abc"}, + {"path": "/b/file.txt", "size": 1024, "modified_date": 200, "hash": "abc"}, + ] + ] + } + + results = _parse_cli_json(data) + assert results is not None + headers = [r for r in results if r.header_row] + files = [r for r in results if not r.header_row] + assert len(headers) == 1 + assert len(files) == 2 + + +class TestDiffDialog: + def test_creation(self, qapp): + from app.dialogs.diff_dialog import DiffDialog + e1 = ResultEntry(values={"File Name": "a.txt", "Path": "/home", + "Size": "1.0 KB", "Modification Date": "2024-01-01", + "__full_path": "/home/a.txt", "__size_bytes": 1024, + "__modified_date_ts": 1000, "Hash": "abc123"}) + e2 = ResultEntry(values={"File Name": "a.txt", "Path": "/tmp", + "Size": "1.0 KB", "Modification Date": "2024-01-02", + "__full_path": "/tmp/a.txt", "__size_bytes": 1024, + "__modified_date_ts": 2000, "Hash": "abc123"}) + dialog = DiffDialog(e1, e2) + assert dialog.windowTitle() == "File Comparison" + + def test_diff_summary_identical(self, qapp): + from app.dialogs.diff_dialog import DiffDialog + e1 = ResultEntry(values={"__full_path": "/a/f.txt", "__size_bytes": 100, + "__modified_date_ts": 1000, "Size": "100 B", + "Modification Date": "2024-01-01"}) + e2 = ResultEntry(values={"__full_path": "/b/f.txt", "__size_bytes": 100, + "__modified_date_ts": 1000, "Size": "100 B", + "Modification Date": "2024-01-01"}) + dialog = DiffDialog(e1, e2) + summary = dialog._compute_diff_summary(e1, e2) + assert "different directories" in summary + + def test_diff_summary_size_differs(self, qapp): + from app.dialogs.diff_dialog import DiffDialog + e1 = ResultEntry(values={"__full_path": "/a/f.txt", "__size_bytes": 100, + "__modified_date_ts": 1000, "Size": "100 B"}) + e2 = ResultEntry(values={"__full_path": "/a/g.txt", "__size_bytes": 200, + "__modified_date_ts": 1000, "Size": "200 B"}) + dialog = DiffDialog(e1, e2) + summary = dialog._compute_diff_summary(e1, e2) + assert "Size differs" in summary diff --git a/czkawka_pyside6/tests/test_widgets.py b/czkawka_pyside6/tests/test_widgets.py new file mode 100644 index 000000000..a1978a98a --- /dev/null +++ b/czkawka_pyside6/tests/test_widgets.py @@ -0,0 +1,262 @@ +"""Tests for Qt widget components.""" +import pytest +import sys +import os + +os.environ["QT_QPA_PLATFORM"] = "offscreen" +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from PySide6.QtWidgets import QApplication +from PySide6.QtCore import Qt + + +@pytest.fixture(scope="session") +def qapp(): + app = QApplication.instance() + if app is None: + app = QApplication(sys.argv) + app.setApplicationName("Czkawka") + app.setOrganizationName("czkawka") + return app + + +from app.models import ActiveTab, ResultEntry, SelectMode, ScanProgress, TAB_COLUMNS +from app.results_view import ResultsView +from app.left_panel import LeftPanel +from app.action_buttons import ActionButtons +from app.progress_widget import ProgressWidget + + +@pytest.fixture +def results_view(qapp): + rv = ResultsView() + rv.set_active_tab(ActiveTab.DUPLICATE_FILES) + return rv + + +@pytest.fixture +def sample_grouped_results(): + return [ + ResultEntry(values={"__header": "Group 1 (2 files)"}, header_row=True, group_id=0), + ResultEntry(values={"File Name": "a.txt", "Path": "/home", "Size": "1.0 KB", + "__full_path": "/home/a.txt", "__size_bytes": 1024, + "__modified_date_ts": 1000}, group_id=0), + ResultEntry(values={"File Name": "b.txt", "Path": "/tmp", "Size": "2.0 KB", + "__full_path": "/tmp/b.txt", "__size_bytes": 2048, + "__modified_date_ts": 2000}, group_id=0), + ResultEntry(values={"__header": "Group 2 (2 files)"}, header_row=True, group_id=1), + ResultEntry(values={"File Name": "c.txt", "Path": "/var", "Size": "500 B", + "__full_path": "/var/c.txt", "__size_bytes": 500, + "__modified_date_ts": 500}, group_id=1), + ResultEntry(values={"File Name": "d.txt", "Path": "/opt", "Size": "500 B", + "__full_path": "/opt/d.txt", "__size_bytes": 500, + "__modified_date_ts": 3000}, group_id=1), + ] + + +@pytest.fixture +def sample_flat_results(): + return [ + ResultEntry(values={"File Name": "empty1.txt", "Path": "/a", + "__full_path": "/a/empty1.txt", "__size_bytes": 0, + "__modified_date_ts": 1000}), + ResultEntry(values={"File Name": "empty2.txt", "Path": "/b", + "__full_path": "/b/empty2.txt", "__size_bytes": 0, + "__modified_date_ts": 2000}), + ResultEntry(values={"File Name": "empty3.txt", "Path": "/c", + "__full_path": "/c/empty3.txt", "__size_bytes": 0, + "__modified_date_ts": 3000}), + ] + + +class TestResultsView: + def test_set_active_tab(self, results_view): + for tab in list(ActiveTab)[:14]: + results_view.set_active_tab(tab) + cols = TAB_COLUMNS.get(tab, []) + assert results_view._tree.columnCount() == len(cols) + + def test_set_grouped_results(self, results_view, sample_grouped_results): + results_view.set_results(sample_grouped_results) + assert results_view._tree.topLevelItemCount() == 6 + # Header should be spanned + header_item = results_view._tree.topLevelItem(0) + assert header_item.isFirstColumnSpanned() + + def test_set_flat_results(self, results_view, sample_flat_results): + results_view.set_active_tab(ActiveTab.EMPTY_FILES) + results_view.set_results(sample_flat_results) + assert results_view._tree.topLevelItemCount() == 3 + + def test_select_all(self, results_view, sample_grouped_results): + results_view.set_results(sample_grouped_results) + results_view.apply_selection(SelectMode.SELECT_ALL) + checked = results_view.get_checked_entries() + assert len(checked) == 4 # 4 non-header entries + + def test_unselect_all(self, results_view, sample_grouped_results): + results_view.set_results(sample_grouped_results) + results_view.apply_selection(SelectMode.SELECT_ALL) + results_view.apply_selection(SelectMode.UNSELECT_ALL) + checked = results_view.get_checked_entries() + assert len(checked) == 0 + + def test_invert_selection(self, results_view, sample_grouped_results): + results_view.set_results(sample_grouped_results) + results_view.apply_selection(SelectMode.SELECT_ALL) + results_view.apply_selection(SelectMode.INVERT_SELECTION) + checked = results_view.get_checked_entries() + assert len(checked) == 0 + + def test_select_biggest(self, results_view, sample_grouped_results): + results_view.set_results(sample_grouped_results) + results_view.apply_selection(SelectMode.SELECT_BIGGEST_SIZE) + checked = results_view.get_checked_entries() + # Should select all except the biggest in each group + # Group 0: b.txt (2048) is biggest -> a.txt selected + # Group 1: same size -> first kept, second selected + assert len(checked) == 2 + + def test_select_newest(self, results_view, sample_grouped_results): + results_view.set_results(sample_grouped_results) + results_view.apply_selection(SelectMode.SELECT_NEWEST) + checked = results_view.get_checked_entries() + assert len(checked) == 2 + + def test_get_all_entries(self, results_view, sample_grouped_results): + results_view.set_results(sample_grouped_results) + all_entries = results_view.get_all_entries() + assert len(all_entries) == 4 # Excludes headers + + def test_clear(self, results_view, sample_grouped_results): + results_view.set_results(sample_grouped_results) + results_view.clear() + assert results_view._tree.topLevelItemCount() == 0 + assert results_view.get_all_entries() == [] + + def test_sort_by_column(self, results_view, sample_flat_results): + results_view.set_active_tab(ActiveTab.EMPTY_FILES) + results_view.set_results(sample_flat_results) + results_view.sort_by_column(1, ascending=True) # Sort by File Name + # Should not crash + assert results_view._tree.topLevelItemCount() == 3 + + def test_filter(self, results_view, sample_grouped_results): + results_view.set_results(sample_grouped_results) + results_view._apply_filter("a.txt") + # Only a.txt should be visible + visible = sum(1 for i in range(results_view._tree.topLevelItemCount()) + if not results_view._tree.topLevelItem(i).isHidden()) + assert visible >= 1 # At least the matching item + + def test_filter_clear(self, results_view, sample_grouped_results): + results_view.set_results(sample_grouped_results) + results_view._apply_filter("a.txt") + results_view._apply_filter("") # Clear filter + visible = sum(1 for i in range(results_view._tree.topLevelItemCount()) + if not results_view._tree.topLevelItem(i).isHidden()) + assert visible == 6 # All visible again + + +class TestLeftPanel: + def test_creation(self, qapp): + panel = LeftPanel() + assert panel._tool_list.count() == 14 + + def test_set_active_tab(self, qapp): + panel = LeftPanel() + panel.set_active_tab(ActiveTab.SIMILAR_IMAGES) + assert panel.get_active_tab() == ActiveTab.SIMILAR_IMAGES + + def test_all_tabs_selectable(self, qapp): + panel = LeftPanel() + for i, tab in enumerate(LeftPanel.TOOL_TABS): + panel.set_active_tab(tab) + assert panel.get_active_tab() == tab + + +class TestActionButtons: + def test_creation(self, qapp): + ab = ActionButtons() + assert ab._scan_btn is not None + assert ab._stop_btn is not None + assert ab._delete_btn is not None + + def test_scanning_state(self, qapp): + ab = ActionButtons() + ab.set_scanning(True) + assert ab._scan_btn.isHidden() is True + assert ab._stop_btn.isHidden() is False + ab.set_scanning(False) + assert ab._scan_btn.isHidden() is False + assert ab._stop_btn.isHidden() is True + + def test_tab_visibility(self, qapp): + ab = ActionButtons() + ab.set_active_tab(ActiveTab.DUPLICATE_FILES) + assert ab._hardlink_btn.isHidden() is False + assert ab._rename_btn.isHidden() is True + + ab.set_active_tab(ActiveTab.BAD_EXTENSIONS) + assert ab._hardlink_btn.isHidden() is True + assert ab._rename_btn.isHidden() is False + + ab.set_active_tab(ActiveTab.EXIF_REMOVER) + assert ab._clean_exif_btn.isHidden() is False + + def test_load_button_exists(self, qapp): + ab = ActionButtons() + assert hasattr(ab, "_load_btn") + + +class TestProgressWidget: + def test_creation(self, qapp): + pw = ProgressWidget() + assert pw.isVisible() is False + + def test_start_stop(self, qapp): + pw = ProgressWidget() + pw.start(ActiveTab.DUPLICATE_FILES) + assert pw.isVisible() is True + pw.stop() + # After stop, still visible briefly (auto-hide timer) + assert pw._stage_label.text() == "Scan complete" + + def test_update_progress_with_entries(self, qapp): + pw = ProgressWidget() + pw.start(ActiveTab.DUPLICATE_FILES) + pw.update_progress(ScanProgress( + stage_name="Calculating hashes", + current_stage_idx=5, + max_stage_idx=6, + entries_checked=500, + entries_to_check=1000, + bytes_checked=50000000, + bytes_to_check=100000000, + )) + assert "6" in pw._stage_label.text() # [6/7] in title + assert pw._stage_bar.maximum() == 100 + assert pw._stage_bar.value() == 50 + assert pw._overall_bar.value() > 0 + + def test_update_progress_collecting(self, qapp): + pw = ProgressWidget() + pw.start(ActiveTab.DUPLICATE_FILES) + pw.update_progress(ScanProgress( + stage_name="Collecting files", + current_stage_idx=0, + max_stage_idx=6, + entries_checked=50000, + entries_to_check=0, + )) + assert "Collecting" in pw._stage_label.text() + + def test_format_time(self, qapp): + assert ProgressWidget._format_time(5) == "5s" + assert ProgressWidget._format_time(65) == "1m 5s" + assert ProgressWidget._format_time(3665) == "1h 1m" + + def test_format_size(self, qapp): + assert ProgressWidget._format_size(0) == "0 B" + assert ProgressWidget._format_size(1024) == "1.0 KB" + assert ProgressWidget._format_size(1048576) == "1.0 MB" From d624efe70a4eb51ab4bbeafda9ad357e559e5c0e Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 11:08:05 +0100 Subject: [PATCH 14/49] Add MCP server crate to expose czkawka tools to AI agents New `czkawka_mcp` workspace member that implements a Model Context Protocol (MCP) server over stdio, allowing AI agents (Claude Code, Claude Desktop, etc.) to invoke all 14 czkawka analysis tools programmatically with JSON parameters and structured JSON results. All tools are read-only by default (dry_run=true, no deletions). Uses rmcp crate and links czkawka_core directly (no subprocess). Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 127 +++++++++++++++++++++++++++++++++++++++++++++++++++-- Cargo.toml | 3 +- 2 files changed, 125 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e6474189c..1c8042f61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -607,6 +607,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -1117,6 +1123,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -1731,6 +1738,20 @@ dependencies = [ "winapi", ] +[[package]] +name = "czkawka_mcp" +version = "11.0.1" +dependencies = [ + "crossbeam-channel", + "czkawka_core", + "log", + "rmcp", + "schemars", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "darling" version = "0.14.4" @@ -1999,6 +2020,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "ecb" version = "0.1.2" @@ -2045,7 +2072,7 @@ dependencies = [ "failure", "proc-macro2", "quote", - "serde_derive_internals", + "serde_derive_internals 0.25.0", "syn 1.0.109", ] @@ -3879,7 +3906,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd266c66b0a0e2d4c6db8e710663fc163a2d33595ce997b6fbda407c8759d344" dependencies = [ - "base64", + "base64 0.22.1", "fast_image_resize 6.0.0", "image", "rustdct", @@ -6780,6 +6807,38 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" +[[package]] +name = "rmcp" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33a0110d28bd076f39e14bfd5b0340216dd18effeb5d02b43215944cc3e5c751" +dependencies = [ + "base64 0.21.7", + "chrono", + "futures", + "paste", + "pin-project-lite", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e2b2fd7497540489fa2db285edd43b7ed14c49157157438664278da6e42a7a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "rodio" version = "0.22.2" @@ -7001,6 +7060,30 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals 0.29.1", + "syn 2.0.117", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -7085,6 +7168,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "serde_json" version = "1.0.149" @@ -8117,10 +8211,35 @@ dependencies = [ "pin-project-lite", "signal-hook-registry", "socket2", + "tokio-macros", "tracing", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.5.11" @@ -8521,7 +8640,7 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e419dff010bb12512b0ae9e3d2f318dfbdf0167fde7eb05465134d4e8756076f" dependencies = [ - "base64", + "base64 0.22.1", "data-url", "flate2", "fontdb", @@ -8548,7 +8667,7 @@ version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de" dependencies = [ - "base64", + "base64 0.22.1", "data-url", "flate2", "imagesize", diff --git a/Cargo.toml b/Cargo.toml index 5e5ac8174..ddf8a3b01 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,8 @@ members = [ "czkawka_cli", "czkawka_gui", "krokiet", - "cedinia" + "cedinia", + "czkawka_mcp", ] exclude = [ "misc/test_read_perf", From aead23bdfba6b3a3d366677c527613487bb602d2 Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 11:45:47 +0100 Subject: [PATCH 15/49] Update documentation with all new features and test coverage - PySide6 README: document keyboard shortcuts, drag-drop, column visibility, filter bar, CSV export, system tray, scan history, scan queue, diff view, window geometry persistence, KDE6 compliance - Add test suite documentation (98 tests across 5 test files) - Update architecture diagram with new files (system_tray, scan_history, scan_queue, diff_dialog, tests/) - Root README: note KDE6 compliance for PySide6 frontend Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 2 +- czkawka_pyside6/README.md | 95 ++++++++++++++++++++++++++++++++------- 2 files changed, 79 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index ae90b01f9..d8f030d86 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ Each tool uses different technologies, so you can find instructions for each of - [Krokiet GUI (Slint frontend)](krokiet/README.md)
- [Czkawka GUI (GTK frontend)](czkawka_gui/README.md)
-- [Czkawka PySide6 (Qt/PySide6 frontend)](czkawka_pyside6/README.md)
+- [Czkawka PySide6 (Qt/PySide6 frontend, KDE6 compliant)](czkawka_pyside6/README.md)
- [Czkawka CLI](czkawka_cli/README.md)
- [Czkawka Core](czkawka_core/README.md)
- [Cedinia](cedinia/README.md)
diff --git a/czkawka_pyside6/README.md b/czkawka_pyside6/README.md index d4487aaed..0366753e1 100644 --- a/czkawka_pyside6/README.md +++ b/czkawka_pyside6/README.md @@ -1,6 +1,6 @@ # Czkawka PySide6 -A Qt 6 / PySide6 GUI frontend for Czkawka, with feature parity with the Krokiet (Slint) interface. +A Qt 6 / PySide6 GUI frontend for Czkawka, with feature parity with the Krokiet (Slint) interface. KDE6/Plasma compliant (14/14 checks passed). This frontend uses `czkawka_cli` as its backend, communicating via JSON output for results and `--json-progress` for real-time progress data. @@ -24,22 +24,38 @@ All 14 scanning tools are supported: ### GUI features -- **Dark theme** with full Qt stylesheet -- **Two-bar progress display** - current stage + overall progress, matching the Slint frontend - - Real-time entry and byte counts (e.g., "Calculating hashes: 15,000/25,000 (500 MB/1 GB)") +- **KDE6/Plasma compliant** - inherits system theme (Breeze, Adwaita, etc.), XDG icons via `QIcon.fromTheme()`, `QStandardPaths`, `.desktop` file, AppStream metadata +- **Two-bar progress display** - current stage + overall progress with stage index (e.g., "[3/7] Calculating prehashes") + - Real-time entry and byte counts (e.g., "50,000/94,500 (308 MB/371 MB)") - File collection estimation using cached counts from previous scans - Elapsed time display - - Stage step indicators -- **Project icons** - uses the same SVG icons as the Krokiet interface +- **Resizable and sortable columns** - drag header edges to resize, click to sort (ascending/descending toggle), numeric columns sort by value +- **Filter/search bar** - filter results by filename or path in real-time +- **Column visibility toggle** - right-click column header to show/hide columns +- **Keyboard shortcuts**: + - `Ctrl+S` / `F5` — Start scan + - `Escape` — Stop scan + - `Ctrl+A` — Select all + - `Ctrl+I` — Invert selection + - `Ctrl+D` — Delete selected + - `Ctrl+M` — Move selected + - `Ctrl+Shift+S` — Save results + - `Ctrl+L` — Load results +- **Drag-and-drop** - drop directories onto the bottom panel to add include paths +- **System tray** - minimize to tray, scan completion notifications +- **Scan history** - persistent log of past scans with timestamp, tool, entries found, duration +- **Scan queue** - queue multiple scan types and run them sequentially +- **Diff view** - side-by-side file comparison for duplicates (right-click two selected files) - **Image preview panel** for duplicate/similar image results -- **Grouped results view** with tree display for duplicate/similar file groups +- **Grouped results view** with spanning group headers for duplicate/similar file groups - **Selection modes** - Select All/None, Invert, Biggest/Smallest, Newest/Oldest, Shortest/Longest Path - **File actions** - Delete (with trash support), Move/Copy, Hardlink, Symlink, Rename, Clean EXIF +- **Save/Load results** - save as JSON, text, or CSV with task-specific filenames; load previously saved results (supports both app and raw CLI JSON formats) - **Per-tool settings** - all tool-specific options (hash type, similarity thresholds, etc.) - **Global settings** - directories, filters, cache, thread count -- **Directory management** - included/excluded paths with add/remove buttons -- **Context menus** - right-click to open file or containing folder -- **Settings persistence** via JSON config files +- **Directory management** - included/excluded paths with add/remove buttons and drag-and-drop +- **Context menus** - right-click to open file, open folder, select/deselect, compare +- **Settings persistence** - window geometry, splitter positions, and settings saved via JSON - **Auto-detection** of `czkawka_cli` binary (checks PATH, cargo target directory, cargo metadata) ## Requirements @@ -77,27 +93,58 @@ python main.py The application will auto-detect the `czkawka_cli` binary. If it can't find it, configure the path in Settings. +## Testing + +The project includes a comprehensive test suite (98 tests): + +```shell +cd czkawka_pyside6 +pip install pytest +QT_QPA_PLATFORM=offscreen python -m pytest tests/ -v +``` + +### Test coverage + +| Test file | Tests | Coverage | +|---|---|---| +| `test_models.py` | 16 | Enums, data models, settings defaults, column definitions | +| `test_backend.py` | 25 | CLI command building (all 14 tools), JSON parsing (flat/grouped/empty), formatters | +| `test_widgets.py` | 24 | ResultsView (selection, sorting, filtering), LeftPanel, ActionButtons, ProgressWidget | +| `test_new_features.py` | 20 | ScanHistory, ScanQueue, SaveLoad roundtrip, DiffDialog | +| `test_main_window.py` | 13 | Integration: window creation, all tabs, state, features presence | +| **Total** | **98** | **All pass** | + ## Architecture ``` czkawka_pyside6/ ├── main.py # Entry point ├── requirements.txt # Python dependencies +├── tests/ # Test suite (98 tests) +│ ├── conftest.py # Qt app fixture +│ ├── test_models.py # Data model tests +│ ├── test_backend.py # CLI interface tests +│ ├── test_widgets.py # Widget component tests +│ ├── test_new_features.py # Feature-specific tests +│ └── test_main_window.py # Integration tests ├── app/ │ ├── main_window.py # Main window with all panels │ ├── left_panel.py # Tool selection sidebar (14 tools) -│ ├── results_view.py # Results tree with grouping, selection, sorting -│ ├── action_buttons.py # Scan/Stop/Delete/Move/Save/Sort buttons with icons +│ ├── results_view.py # Results tree with grouping, selection, sorting, filtering +│ ├── action_buttons.py # Scan/Stop/Delete/Move/Save/Load/Sort buttons with icons │ ├── tool_settings.py # Per-tool settings (9 tool panels) │ ├── settings_panel.py # Global settings (General/Directories/Filters/Preview) │ ├── progress_widget.py # Two-bar progress: current stage + overall │ ├── preview_panel.py # Image preview panel -│ ├── bottom_panel.py # Directory management + error display +│ ├── bottom_panel.py # Directory management + error display (with drag-and-drop) │ ├── backend.py # CLI subprocess interface with JSON progress parsing │ ├── models.py # Data models, enums, column definitions -│ ├── state.py # Application state with Qt signals -│ ├── icons.py # SVG icon resources from Krokiet icon set -│ └── dialogs/ # Delete, Move, Select, Sort, Save, Rename, About +│ ├── state.py # Application state with Qt signals, geometry persistence +│ ├── icons.py # XDG theme icons with SVG fallbacks from Krokiet +│ ├── system_tray.py # System tray integration with notifications +│ ├── scan_history.py # Persistent scan history log +│ ├── scan_queue.py # Multi-tab sequential scan queue +│ └── dialogs/ # Delete, Move, Select, Sort, Save/Load, Rename, About, Diff ``` ### How it works @@ -106,10 +153,24 @@ czkawka_pyside6/ 2. **Progress**: JSON lines on stderr provide `ProgressData` with stage index, entry counts, byte counts — the same data the Slint frontend gets via crossbeam channels. The progress widget displays two bars (current stage and overall) with percentage, counts, and elapsed time. -3. **Results**: JSON results are parsed and displayed in a tree view with group headers for duplicate/similar file tools. +3. **Results**: JSON results are parsed and displayed in a tree view with spanning group headers for duplicate/similar file tools. Columns are resizable and sortable (click header), with a filter bar for real-time search. 4. **File operations**: Delete, move, hardlink, symlink, and rename operations are performed directly in Python. EXIF cleaning and extension/name fixing use `czkawka_cli` subcommands. +5. **Persistence**: Window geometry, scan estimates, scan history, and settings are saved via `QStandardPaths` for XDG compliance. + +### KDE6/Plasma Compliance + +The app passes all 14 KDE compliance checks: + +- System theme inherited (no color overrides) +- `QIcon.fromTheme()` with standard XDG icon names + SVG fallbacks +- `.desktop` file and AppStream `metainfo.xml` +- `QStandardPaths` for config/cache paths +- `desktopFileName` and `organizationDomain` set +- System font inherited, HiDPI via Qt6 native support +- System dialog icons via `style().standardIcon()` + ## LICENSE MIT From 434677590e572f129d0df9d94cf7e150c2146a00 Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 11:56:31 +0100 Subject: [PATCH 16/49] Add reference directory support for grouped scan tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each included directory now has a "Ref" checkbox in the bottom panel. When checked, the directory is marked as a reference — files in reference directories are kept and never selected for deletion. This matches the Krokiet (Slint) UI's reference path feature. Changes: - AppSettings: added reference_paths set, persisted in settings JSON - BottomPanel: replaced QListWidget with QTreeWidget showing [Ref][Path] columns; Ref checkbox toggles reference_paths membership - Backend: passes -r flag with reference directories for tools that support it (duplicates, similar images/videos/music) - Non-grouped tools (empty files, broken files, etc.) ignore -r Co-Authored-By: Claude Opus 4.6 (1M context) --- czkawka_pyside6/app/backend.py | 9 ++ czkawka_pyside6/app/bottom_panel.py | 133 ++++++++++++++++++++-------- czkawka_pyside6/app/models.py | 1 + czkawka_pyside6/app/state.py | 2 + 4 files changed, 106 insertions(+), 39 deletions(-) diff --git a/czkawka_pyside6/app/backend.py b/czkawka_pyside6/app/backend.py index 3d922c176..865f6097e 100644 --- a/czkawka_pyside6/app/backend.py +++ b/czkawka_pyside6/app/backend.py @@ -187,6 +187,15 @@ def _build_command(self) -> list[str]: if s.thread_number > 0: cmd.extend(["-T", str(s.thread_number)]) + # Reference directories (only for grouped tools that support -r) + if s.reference_paths and self.tab in ( + ActiveTab.DUPLICATE_FILES, ActiveTab.SIMILAR_IMAGES, + ActiveTab.SIMILAR_VIDEOS, ActiveTab.SIMILAR_MUSIC, + ): + ref_dirs = [p for p in s.reference_paths if p in s.included_paths] + if ref_dirs: + cmd.extend(["-r", ",".join(ref_dirs)]) + # Tool-specific args if self.tab == ActiveTab.DUPLICATE_FILES: cmd.extend(["-s", ts.dup_check_method.value]) diff --git a/czkawka_pyside6/app/bottom_panel.py b/czkawka_pyside6/app/bottom_panel.py index 29d44901c..460c9e638 100644 --- a/czkawka_pyside6/app/bottom_panel.py +++ b/czkawka_pyside6/app/bottom_panel.py @@ -3,7 +3,7 @@ from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QListWidget, QPushButton, QFileDialog, QTextEdit, QStackedWidget, - QSizePolicy + QTreeWidget, QTreeWidgetItem, QHeaderView ) from PySide6.QtCore import Signal, Qt @@ -11,7 +11,13 @@ class BottomPanel(QWidget): - """Bottom panel showing directories or error messages.""" + """Bottom panel showing directories or error messages. + + Included directories have a "Ref" checkbox — when checked, that + directory is a reference directory: its files are kept as references + and never selected for deletion in grouped tools (duplicates, similar + images/videos/music). + """ directories_changed = Signal() def __init__(self, settings: AppSettings, parent=None): @@ -32,32 +38,41 @@ def _setup_ui(self): dir_layout = QHBoxLayout(dir_widget) dir_layout.setContentsMargins(0, 0, 0, 0) - # Included directories + # ── Included directories (with Ref checkbox) ── inc_widget = QWidget() inc_layout = QVBoxLayout(inc_widget) inc_layout.setContentsMargins(0, 0, 0, 0) inc_layout.addWidget(QLabel("Included Directories:")) - self._inc_list = QListWidget() - self._inc_list.setMaximumHeight(120) - for path in self._settings.included_paths: - self._inc_list.addItem(path) - inc_layout.addWidget(self._inc_list) + self._inc_tree = QTreeWidget() + self._inc_tree.setMaximumHeight(120) + self._inc_tree.setHeaderLabels(["Ref", "Path"]) + self._inc_tree.setColumnCount(2) + header = self._inc_tree.header() + header.setSectionResizeMode(0, QHeaderView.ResizeToContents) + header.setSectionResizeMode(1, QHeaderView.Stretch) + self._inc_tree.setRootIsDecorated(False) + self._inc_tree.itemChanged.connect(self._on_ref_toggled) + + self._populate_included() + inc_layout.addWidget(self._inc_tree) inc_btns = QHBoxLayout() add_btn = QPushButton("+") add_btn.setFixedWidth(30) + add_btn.setToolTip("Add included directory") add_btn.clicked.connect(self._add_included) inc_btns.addWidget(add_btn) rem_btn = QPushButton("-") rem_btn.setFixedWidth(30) + rem_btn.setToolTip("Remove selected directory") rem_btn.clicked.connect(self._remove_included) inc_btns.addWidget(rem_btn) inc_btns.addStretch() inc_layout.addLayout(inc_btns) dir_layout.addWidget(inc_widget) - # Excluded directories + # ── Excluded directories ── exc_widget = QWidget() exc_layout = QVBoxLayout(exc_widget) exc_layout.setContentsMargins(0, 0, 0, 0) @@ -72,10 +87,12 @@ def _setup_ui(self): exc_btns = QHBoxLayout() add_exc = QPushButton("+") add_exc.setFixedWidth(30) + add_exc.setToolTip("Add excluded directory") add_exc.clicked.connect(self._add_excluded) exc_btns.addWidget(add_exc) rem_exc = QPushButton("-") rem_exc.setFixedWidth(30) + rem_exc.setToolTip("Remove selected directory") rem_exc.clicked.connect(self._remove_excluded) exc_btns.addWidget(rem_exc) exc_btns.addStretch() @@ -92,36 +109,57 @@ def _setup_ui(self): layout.addWidget(self._stack) - def show_directories(self): - self._stack.setCurrentIndex(0) - self.setVisible(True) - - def show_text(self): - self._stack.setCurrentIndex(1) - self.setVisible(True) - - def hide_panel(self): - self.setVisible(False) + # ── Included directory helpers ──────────────────────────── - def set_text(self, text: str): - self._text_area.setPlainText(text) - - def append_text(self, text: str): - self._text_area.append(text) + def _populate_included(self): + """Rebuild the included paths tree from settings.""" + self._inc_tree.blockSignals(True) + self._inc_tree.clear() + for path in self._settings.included_paths: + item = QTreeWidgetItem() + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + is_ref = path in self._settings.reference_paths + item.setCheckState(0, Qt.Checked if is_ref else Qt.Unchecked) + item.setText(1, path) + item.setToolTip(0, "Check to mark as reference directory.\n" + "Files in reference directories are never selected for deletion.") + self._inc_tree.addTopLevelItem(item) + self._inc_tree.blockSignals(False) + + def _on_ref_toggled(self, item, column): + """Handle Ref checkbox toggle.""" + if column != 0: + return + path = item.text(1) + if item.checkState(0) == Qt.Checked: + self._settings.reference_paths.add(path) + else: + self._settings.reference_paths.discard(path) + self.directories_changed.emit() def _add_included(self): path = QFileDialog.getExistingDirectory(self, "Select Directory to Include") if path and path not in self._settings.included_paths: self._settings.included_paths.append(path) - self._inc_list.addItem(path) + self._populate_included() self.directories_changed.emit() def _remove_included(self): - row = self._inc_list.currentRow() - if row >= 0: - self._inc_list.takeItem(row) - self._settings.included_paths.pop(row) - self.directories_changed.emit() + items = self._inc_tree.selectedItems() + if not items: + # Try current item + item = self._inc_tree.currentItem() + if item: + items = [item] + for item in items: + path = item.text(1) + if path in self._settings.included_paths: + self._settings.included_paths.remove(path) + self._settings.reference_paths.discard(path) + self._populate_included() + self.directories_changed.emit() + + # ── Excluded directory helpers ──────────────────────────── def _add_excluded(self): path = QFileDialog.getExistingDirectory(self, "Select Directory to Exclude") @@ -137,6 +175,31 @@ def _remove_excluded(self): self._settings.excluded_paths.pop(row) self.directories_changed.emit() + # ── Public API ──────────────────────────────────────────── + + def show_directories(self): + self._stack.setCurrentIndex(0) + self.setVisible(True) + + def show_text(self): + self._stack.setCurrentIndex(1) + self.setVisible(True) + + def hide_panel(self): + self.setVisible(False) + + def set_text(self, text: str): + self._text_area.setPlainText(text) + + def append_text(self, text: str): + self._text_area.append(text) + + def refresh_lists(self): + self._populate_included() + self._exc_list.clear() + for path in self._settings.excluded_paths: + self._exc_list.addItem(path) + def dragEnterEvent(self, event): if event.mimeData().hasUrls(): event.acceptProposedAction() @@ -147,13 +210,5 @@ def dropEvent(self, event): if path and os.path.isdir(path): if path not in self._settings.included_paths: self._settings.included_paths.append(path) - self._inc_list.addItem(path) + self._populate_included() self.directories_changed.emit() - - def refresh_lists(self): - self._inc_list.clear() - for path in self._settings.included_paths: - self._inc_list.addItem(path) - self._exc_list.clear() - for path in self._settings.excluded_paths: - self._exc_list.addItem(path) diff --git a/czkawka_pyside6/app/models.py b/czkawka_pyside6/app/models.py index 306772529..90330b50b 100644 --- a/czkawka_pyside6/app/models.py +++ b/czkawka_pyside6/app/models.py @@ -288,6 +288,7 @@ class ToolSettings: class AppSettings: """Global application settings.""" included_paths: list = field(default_factory=lambda: [str(Path.home())]) + reference_paths: set = field(default_factory=set) # subset of included_paths marked as reference excluded_paths: list = field(default_factory=list) excluded_items: str = "" allowed_extensions: str = "" diff --git a/czkawka_pyside6/app/state.py b/czkawka_pyside6/app/state.py index 73313056a..5dd993ffa 100644 --- a/czkawka_pyside6/app/state.py +++ b/czkawka_pyside6/app/state.py @@ -79,6 +79,7 @@ def save_settings(self): config_file = self._config_path / "settings.json" data = { "included_paths": self.settings.included_paths, + "reference_paths": list(self.settings.reference_paths), "excluded_paths": self.settings.excluded_paths, "excluded_items": self.settings.excluded_items, "allowed_extensions": self.settings.allowed_extensions, @@ -109,6 +110,7 @@ def load_settings(self): data = json.loads(config_file.read_text()) s = self.settings s.included_paths = data.get("included_paths", s.included_paths) + s.reference_paths = set(data.get("reference_paths", [])) s.excluded_paths = data.get("excluded_paths", s.excluded_paths) s.excluded_items = data.get("excluded_items", s.excluded_items) s.allowed_extensions = data.get("allowed_extensions", s.allowed_extensions) From fb358af2d0a9129445a041a9407aff2335ec02d6 Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 09:57:45 +0100 Subject: [PATCH 17/49] Add PySide6/Qt frontend and CLI --json-progress flag (#1847) Add a new PySide6/Qt 6 GUI frontend (czkawka_pyside6) with feature parity with the Krokiet (Slint) interface. Uses czkawka_cli as its backend via subprocess with JSON output for results and --json-progress for real-time progress data. PySide6 frontend features: - All 14 scanning tools with per-tool settings - Two-bar progress (current stage + overall) with entry/byte counts - Dark theme with Krokiet SVG icons - Grouped results, selection modes, file actions - Image preview, directory management, settings persistence - Auto-detection of czkawka_cli binary CLI --json-progress flag: - Outputs ProgressData as JSON lines to stderr - Added Serialize to ProgressData, CurrentStage, ToolType - Added connect_progress_json() handler - Added serde_json dependency Closes #1847 Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 1 + README.md | 66 +- czkawka_cli/Cargo.toml | 1 + czkawka_cli/README.md | 58 +- czkawka_cli/src/commands.rs | 29 + czkawka_cli/src/main.rs | 9 +- czkawka_cli/src/progress.rs | 34 + czkawka_core/src/common/model.rs | 2 +- czkawka_core/src/common/progress_data.rs | 5 +- czkawka_pyside6/README.md | 115 ++++ czkawka_pyside6/app/__init__.py | 0 czkawka_pyside6/app/action_buttons.py | 196 ++++++ czkawka_pyside6/app/backend.py | 621 ++++++++++++++++++ czkawka_pyside6/app/bottom_panel.py | 143 +++++ czkawka_pyside6/app/dialogs/__init__.py | 7 + czkawka_pyside6/app/dialogs/about_dialog.py | 78 +++ czkawka_pyside6/app/dialogs/delete_dialog.py | 51 ++ czkawka_pyside6/app/dialogs/move_dialog.py | 65 ++ czkawka_pyside6/app/dialogs/rename_dialog.py | 34 + czkawka_pyside6/app/dialogs/save_dialog.py | 48 ++ czkawka_pyside6/app/dialogs/select_dialog.py | 48 ++ czkawka_pyside6/app/dialogs/sort_dialog.py | 39 ++ czkawka_pyside6/app/icons.py | 149 +++++ czkawka_pyside6/app/left_panel.py | 149 +++++ czkawka_pyside6/app/main_window.py | 624 +++++++++++++++++++ czkawka_pyside6/app/models.py | 305 +++++++++ czkawka_pyside6/app/preview_panel.py | 112 ++++ czkawka_pyside6/app/progress_widget.py | 325 ++++++++++ czkawka_pyside6/app/results_view.py | 280 +++++++++ czkawka_pyside6/app/settings_panel.py | 261 ++++++++ czkawka_pyside6/app/state.py | 122 ++++ czkawka_pyside6/app/tool_settings.py | 504 +++++++++++++++ czkawka_pyside6/main.py | 58 ++ 33 files changed, 4495 insertions(+), 44 deletions(-) create mode 100644 czkawka_pyside6/README.md create mode 100644 czkawka_pyside6/app/__init__.py create mode 100644 czkawka_pyside6/app/action_buttons.py create mode 100644 czkawka_pyside6/app/backend.py create mode 100644 czkawka_pyside6/app/bottom_panel.py create mode 100644 czkawka_pyside6/app/dialogs/__init__.py create mode 100644 czkawka_pyside6/app/dialogs/about_dialog.py create mode 100644 czkawka_pyside6/app/dialogs/delete_dialog.py create mode 100644 czkawka_pyside6/app/dialogs/move_dialog.py create mode 100644 czkawka_pyside6/app/dialogs/rename_dialog.py create mode 100644 czkawka_pyside6/app/dialogs/save_dialog.py create mode 100644 czkawka_pyside6/app/dialogs/select_dialog.py create mode 100644 czkawka_pyside6/app/dialogs/sort_dialog.py create mode 100644 czkawka_pyside6/app/icons.py create mode 100644 czkawka_pyside6/app/left_panel.py create mode 100644 czkawka_pyside6/app/main_window.py create mode 100644 czkawka_pyside6/app/models.py create mode 100644 czkawka_pyside6/app/preview_panel.py create mode 100644 czkawka_pyside6/app/progress_widget.py create mode 100644 czkawka_pyside6/app/results_view.py create mode 100644 czkawka_pyside6/app/settings_panel.py create mode 100644 czkawka_pyside6/app/state.py create mode 100644 czkawka_pyside6/app/tool_settings.py create mode 100644 czkawka_pyside6/main.py diff --git a/Cargo.lock b/Cargo.lock index 82536866d..cf5684840 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1638,6 +1638,7 @@ dependencies = [ "humansize", "indicatif", "log", + "serde_json", ] [[package]] diff --git a/README.md b/README.md index dbbdfccbc..77cd6de72 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ - **Cache support** - second and further scans should be much faster than the first one - **Easy to run, easy to compile** - minimal runtime and build dependencies, portable version available - **CLI frontend** - for easy automation -- **GUI frontend** - uses Slint or GTK 4 frameworks +- **GUI frontends** - Slint (Krokiet), GTK 4 (Czkawka GUI), and PySide6/Qt (Czkawka PySide6) - **Core library** - allows to reuse functionality in other apps - **Android app** - experimental touch-friendly frontend for Android devices - **No spying** - Czkawka does not have access to the Internet, nor does it collect any user information or statistics @@ -59,6 +59,7 @@ Each tool uses different technologies, so you can find instructions for each of - [Krokiet GUI (Slint frontend)](krokiet/README.md)
- [Czkawka GUI (GTK frontend)](czkawka_gui/README.md)
+- [Czkawka PySide6 (Qt/PySide6 frontend)](czkawka_pyside6/README.md)
- [Czkawka CLI](czkawka_cli/README.md)
- [Czkawka Core](czkawka_core/README.md)
- [Cedinia](cedinia/README.md)
@@ -68,37 +69,37 @@ Each tool uses different technologies, so you can find instructions for each of In this comparison remember, that even if app have same features they may work different(e.g. one app may have more options to choose than other). -| | Krokiet | Czkawka | Cedinia | FSlint | DupeGuru | Bleachbit | -|:-------------------------:|:-----------:|:----------------:|:-------:|:------:|:-----------------:|:-----------:| -| Language | Rust | Rust | Rust | Python | Python/Obj-C | Python | -| Framework base language | Rust | C | Rust | C | C/C++/Obj-C/Swift | C | -| Framework | Slint | GTK 4 | Slint | PyGTK2 | Qt 5 (PyQt)/Cocoa | PyGTK3 | -| OS | Lin,Mac,Win | Lin,Mac,Win | Android | Lin | Lin,Mac,Win | Lin,Mac,Win | -| Duplicate finder | ✔ | ✔ | ✔ | ✔ | ✔ | | -| Empty files | ✔ | ✔ | ✔ | ✔ | | | -| Empty folders | ✔ | ✔ | ✔ | ✔ | | | -| Temporary files | ✔ | ✔ | ✔ | ✔ | | ✔ | -| Big files | ✔ | ✔ | ✔ | | | | -| Similar images | ✔ | ✔ | ✔ | | ✔ | | -| Similar videos | ✔ | ✔ | | | | | -| Music duplicates(tags) | ✔ | ✔ | ✔ | | ✔ | | -| Music duplicates(content) | ✔ | ✔ | ✔ | | | | -| Invalid symlinks | ✔ | ✔ | ✔ | ✔ | | | -| Broken files | ✔ | ✔ | ✔ | | | | -| Invalid names/extensions | ✔ | ✔ | ✔ | ✔ | | | -| Exif cleaner | ✔ | | ✔ | | | | -| Video optimizer | ✔ | | | | | | -| Bad Names | ✔ | | ✔ | | | | -| Names conflict | | | | ✔ | | | -| Installed packages | | | | ✔ | | | -| Bad ID | | | | ✔ | | | -| Non stripped binaries | | | | ✔ | | | -| Redundant whitespace | | | | ✔ | | | -| Overwriting files | | | | ✔ | | ✔ | -| Portable version | ✔ | ✔ | | | | ✔ | -| Multiple languages | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ | -| Cache support | ✔ | ✔ | ✔ | | ✔ | | -| In active development | Yes | Yes** | Yes*** | No | No* | Yes | +| | Krokiet | Czkawka PySide6 | Czkawka | Cedinia | FSlint | DupeGuru | Bleachbit | +|:-------------------------:|:-----------:|:----------------:|:----------------:|:-------:|:------:|:-----------------:|:-----------:| +| Language | Rust | Python | Rust | Rust | Python | Python/Obj-C | Python | +| Framework base language | Rust | C++ | C | Rust | C | C/C++/Obj-C/Swift | C | +| Framework | Slint | PySide6 (Qt 6) | GTK 4 | Slint | PyGTK2 | Qt 5 (PyQt)/Cocoa | PyGTK3 | +| OS | Lin,Mac,Win | Lin,Mac,Win | Lin,Mac,Win | Android | Lin | Lin,Mac,Win | Lin,Mac,Win | +| Duplicate finder | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ | | +| Empty files | ✔ | ✔ | ✔ | ✔ | ✔ | | | +| Empty folders | ✔ | ✔ | ✔ | ✔ | ✔ | | | +| Temporary files | ✔ | ✔ | ✔ | ✔ | ✔ | | ✔ | +| Big files | ✔ | ✔ | ✔ | ✔ | | | | +| Similar images | ✔ | ✔ | ✔ | ✔ | | ✔ | | +| Similar videos | ✔ | ✔ | ✔ | | | | | +| Music duplicates(tags) | ✔ | ✔ | ✔ | ✔ | | ✔ | | +| Music duplicates(content) | ✔ | ✔ | ✔ | ✔ | | | | +| Invalid symlinks | ✔ | ✔ | ✔ | ✔ | ✔ | | | +| Broken files | ✔ | ✔ | ✔ | ✔ | | | | +| Invalid names/extensions | ✔ | ✔ | ✔ | ✔ | ✔ | | | +| Exif cleaner | ✔ | ✔ | | ✔ | | | | +| Video optimizer | ✔ | ✔ | | | | | | +| Bad Names | ✔ | ✔ | | ✔ | | | | +| Names conflict | | | | | ✔ | | | +| Installed packages | | | | | ✔ | | | +| Bad ID | | | | | ✔ | | | +| Non stripped binaries | | | | | ✔ | | | +| Redundant whitespace | | | | | ✔ | | | +| Overwriting files | | | | | ✔ | | ✔ | +| Portable version | ✔ | ✔ | ✔ | | | | ✔ | +| Multiple languages | ✔ | | ✔ | ✔ | ✔ | ✔ | ✔ | +| Cache support | ✔ | ✔ | ✔ | ✔ | | ✔ | | +| In active development | Yes | Yes | Yes** | Yes*** | No | No* | Yes |

* Few small commits added recently and last version released in 2023

** Czkawka GTK is in maintenance mode receiving only bugfixes

@@ -131,6 +132,7 @@ console apps, then take a look at these: Czkawka exposes its common functionality through a crate called **`czkawka_core`**, which can be reused by other projects. It is written in Rust and is used by all Czkawka frontends (`czkawka_gui`, `czkawka_cli`, `krokiet`, `cedinia`). +The `czkawka_pyside6` frontend uses `czkawka_cli` as its backend, communicating via JSON output and `--json-progress` for real-time progress data. It is also used by external projects, such as: diff --git a/czkawka_cli/Cargo.toml b/czkawka_cli/Cargo.toml index 2b24c2c56..063f55117 100644 --- a/czkawka_cli/Cargo.toml +++ b/czkawka_cli/Cargo.toml @@ -18,6 +18,7 @@ indicatif = "0.18" crossbeam-channel = { version = "0.5", features = [] } ctrlc = { version = "3.4", features = ["termination"] } humansize = "2.1" +serde_json = "1" [features] default = [] diff --git a/czkawka_cli/README.md b/czkawka_cli/README.md index 3c88431f8..1c9d3c29a 100644 --- a/czkawka_cli/README.md +++ b/czkawka_cli/README.md @@ -44,13 +44,57 @@ czkawka_cli dup --help Example usage: ```shell -czkawka dup -d /home/rafal -e /home/rafal/Obrazy -m 25 -x 7z rar IMAGE -s hash -f results.txt -D aeo -czkawka empty-folders -d /home/rafal/rr /home/gateway -f results.txt -czkawka big -d /home/rafal/ /home/piszczal -e /home/rafal/Roman -n 25 -x VIDEO -f results.txt -czkawka empty-files -d /home/rafal /home/szczekacz -e /home/rafal/Pulpit -R -f results.txt -czkawka temp -d /home/rafal/ -E */.git */tmp* *Pulpit -f results.txt -D -czkawka music -d /home/rafal -e /home/rafal/Pulpit -z "artist,year, ARTISTALBUM, ALBUM___tiTlE" -f results.txt -czkawka symlinks -d /home/kicikici/ /home/szczek -e /home/kicikici/jestempsem -x jpg -f results.txt +czkawka_cli dup -d /home/rafal -e /home/rafal/Obrazy -m 25 -x 7z rar IMAGE -s hash -f results.txt -D aeo +czkawka_cli empty-folders -d /home/rafal/rr /home/gateway -f results.txt +czkawka_cli big -d /home/rafal/ /home/piszczal -e /home/rafal/Roman -n 25 -x VIDEO -f results.txt +czkawka_cli empty-files -d /home/rafal /home/szczekacz -e /home/rafal/Pulpit -R -f results.txt +czkawka_cli temp -d /home/rafal/ -E */.git */tmp* *Pulpit -f results.txt -D +czkawka_cli music -d /home/rafal -e /home/rafal/Pulpit -z "artist,year, ARTISTALBUM, ALBUM___tiTlE" -f results.txt +czkawka_cli symlinks -d /home/kicikici/ /home/szczek -e /home/kicikici/jestempsem -x jpg -f results.txt +``` + +## JSON output + +Results can be saved as compact or pretty-printed JSON: + +```shell +czkawka_cli dup -d /home/user --compact-file-to-save results.json +czkawka_cli dup -d /home/user --pretty-json-file-to-save results.json +``` + +## Machine-readable progress (`--json-progress`) + +The `--json-progress` flag outputs real-time progress data as JSON lines to stderr. This is used by GUI frontends (such as the PySide6 frontend) to display accurate progress bars. + +Each line is a JSON object with the following structure: +```json +{ + "progress": { + "sstage": "DuplicatePreHashing", + "checking_method": "Hash", + "current_stage_idx": 2, + "max_stage_idx": 6, + "entries_checked": 50000, + "entries_to_check": 94500, + "bytes_checked": 204800000, + "bytes_to_check": 387072000, + "tool_type": "Duplicate" + }, + "stage_name": "Calculating prehashes" +} +``` + +Fields: +- `sstage` - Internal stage identifier (e.g., `CollectingFiles`, `DuplicateFullHashing`, `SimilarImagesComparingHashes`) +- `current_stage_idx` / `max_stage_idx` - Current stage number and total stages (e.g., 2/6 for duplicates) +- `entries_checked` / `entries_to_check` - Files processed and total to process +- `bytes_checked` / `bytes_to_check` - Bytes processed and total (for hashing stages) +- `stage_name` - Human-readable stage description + +Example usage: +```shell +# Capture progress on stderr while saving results to JSON +czkawka_cli dup -d /home/user --json-progress -N --compact-file-to-save results.json 2>progress.jsonl ``` ## LICENSE diff --git a/czkawka_cli/src/commands.rs b/czkawka_cli/src/commands.rs index 8980629c1..9c73b5913 100644 --- a/czkawka_cli/src/commands.rs +++ b/czkawka_cli/src/commands.rs @@ -115,6 +115,27 @@ pub enum Commands { ExifRemover(ExifRemoverArgs), } +impl Commands { + pub fn get_json_progress(&self) -> bool { + match self { + Commands::Duplicates(a) => a.common_cli_items.json_progress, + Commands::EmptyFolders(a) => a.common_cli_items.json_progress, + Commands::BiggestFiles(a) => a.common_cli_items.json_progress, + Commands::EmptyFiles(a) => a.common_cli_items.json_progress, + Commands::Temporary(a) => a.common_cli_items.json_progress, + Commands::SimilarImages(a) => a.common_cli_items.json_progress, + Commands::SameMusic(a) => a.common_cli_items.json_progress, + Commands::InvalidSymlinks(a) => a.common_cli_items.json_progress, + Commands::BrokenFiles(a) => a.common_cli_items.json_progress, + Commands::SimilarVideos(a) => a.common_cli_items.json_progress, + Commands::BadExtensions(a) => a.common_cli_items.json_progress, + Commands::BadNames(a) => a.common_cli_items.json_progress, + Commands::VideoOptimizer(a) => a.common_cli_items.json_progress, + Commands::ExifRemover(a) => a.common_cli_items.json_progress, + } + } +} + #[derive(Debug, clap::Args)] pub struct DuplicatesArgs { #[clap(flatten)] @@ -848,6 +869,14 @@ pub struct CommonCliItems { long_help = "Disables the cache system. This will make scanning slower but ensures fresh results without cached data." )] pub disable_cache: bool, + #[clap( + long, + help = "Output progress as JSON lines to stderr", + long_help = "Outputs progress data as JSON lines to stderr for machine consumption. \ + Each line is a JSON object with fields: sstage, current_stage_idx, max_stage_idx, \ + entries_checked, entries_to_check, bytes_checked, bytes_to_check, tool_type." + )] + pub json_progress: bool, } #[derive(Debug, clap::Args, Clone, Copy)] diff --git a/czkawka_cli/src/main.rs b/czkawka_cli/src/main.rs index 871183662..b8b9710e4 100644 --- a/czkawka_cli/src/main.rs +++ b/czkawka_cli/src/main.rs @@ -36,7 +36,7 @@ use crate::commands::{ Args, BadExtensionsArgs, BadNamesArgs, BiggestFilesArgs, BrokenFilesArgs, CommonCliItems, DMethod, DuplicatesArgs, EmptyFilesArgs, EmptyFoldersArgs, ExifRemoverArgs, InvalidSymlinksArgs, SDMethod, SameMusicArgs, SimilarImagesArgs, SimilarVideosArgs, TemporaryArgs, VideoOptimizerArgs, }; -use crate::progress::connect_progress; +use crate::progress::{connect_progress, connect_progress_json}; mod commands; mod progress; @@ -65,6 +65,7 @@ fn main() { debug!("Running command - {command:?}"); } + let json_progress = command.get_json_progress(); let (progress_sender, progress_receiver): (Sender, Receiver) = unbounded(); let stop_flag = Arc::new(AtomicBool::new(false)); let store_flag_cloned = stop_flag.clone(); @@ -98,7 +99,11 @@ fn main() { }) .expect("Error setting Ctrl-C handler"); - connect_progress(&progress_receiver); + if json_progress { + connect_progress_json(&progress_receiver); + } else { + connect_progress(&progress_receiver); + } let cli_output = calculate_thread.join().expect("Failed to join calculation thread"); diff --git a/czkawka_cli/src/progress.rs b/czkawka_cli/src/progress.rs index 72952de01..6156e3daa 100644 --- a/czkawka_cli/src/progress.rs +++ b/czkawka_cli/src/progress.rs @@ -1,3 +1,4 @@ +use std::io::Write; use std::time::Duration; use crossbeam_channel::Receiver; @@ -97,6 +98,39 @@ pub(crate) fn get_progress_message(progress_data: &ProgressData) -> String { .to_string() } +/// Output progress data as JSON lines to stderr for machine consumption. +/// Each line is a complete JSON object that can be parsed independently. +pub(crate) fn connect_progress_json(progress_receiver: &Receiver) { + let stderr = std::io::stderr(); + let mut stderr = stderr.lock(); + while let Ok(progress_data) = progress_receiver.recv() { + // Build a JSON object with human-readable stage name included + let stage_name = if progress_data.sstage == CurrentStage::CollectingFiles { + if progress_data.tool_type == ToolType::EmptyFolders { + "Collecting folders".to_string() + } else { + "Collecting files".to_string() + } + } else if progress_data.sstage.check_if_loading_saving_cache() { + if progress_data.sstage.check_if_loading_cache() { + "Loading cache".to_string() + } else { + "Saving cache".to_string() + } + } else { + get_progress_message(&progress_data) + }; + + if let Ok(json) = serde_json::to_string(&progress_data) { + // Wrap in an object that includes the human-readable stage name + let _ = writeln!( + stderr, + "{{\"progress\":{json},\"stage_name\":\"{stage_name}\"}}" + ); + } + } +} + pub(crate) fn get_progress_bar_for_collect_files() -> ProgressBar { let pb = ProgressBar::new_spinner(); pb.enable_steady_tick(Duration::from_millis(120)); diff --git a/czkawka_core/src/common/model.rs b/czkawka_core/src/common/model.rs index 41919a49d..622a70727 100644 --- a/czkawka_core/src/common/model.rs +++ b/czkawka_core/src/common/model.rs @@ -6,7 +6,7 @@ use xxhash_rust::xxh3::Xxh3; use crate::common::traits::ResultEntry; use crate::tools::duplicate::MyHasher; -#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)] +#[derive(Debug, PartialEq, Eq, Clone, Copy, Default, Serialize, Deserialize)] pub enum ToolType { Duplicate, EmptyFolders, diff --git a/czkawka_core/src/common/progress_data.rs b/czkawka_core/src/common/progress_data.rs index d23abe7e8..d98372c63 100644 --- a/czkawka_core/src/common/progress_data.rs +++ b/czkawka_core/src/common/progress_data.rs @@ -1,4 +1,5 @@ use log::error; +use serde::Serialize; use crate::common::model::{CheckingMethod, ToolType}; // Empty files @@ -66,7 +67,7 @@ use crate::common::model::{CheckingMethod, ToolType}; // Deleting files // Renaming files -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Serialize)] pub struct ProgressData { pub sstage: CurrentStage, pub checking_method: CheckingMethod, @@ -95,7 +96,7 @@ impl ProgressData { } } -#[derive(Debug, Clone, Copy, Eq, PartialEq)] +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)] pub enum CurrentStage { DeletingFiles, RenamingFiles, diff --git a/czkawka_pyside6/README.md b/czkawka_pyside6/README.md new file mode 100644 index 000000000..d4487aaed --- /dev/null +++ b/czkawka_pyside6/README.md @@ -0,0 +1,115 @@ +# Czkawka PySide6 + +A Qt 6 / PySide6 GUI frontend for Czkawka, with feature parity with the Krokiet (Slint) interface. + +This frontend uses `czkawka_cli` as its backend, communicating via JSON output for results and `--json-progress` for real-time progress data. + +## Features + +All 14 scanning tools are supported: + +- Duplicate Files (by hash, size, name, or size+name) +- Empty Folders / Empty Files +- Big Files (biggest or smallest) +- Temporary Files +- Similar Images (with configurable hash algorithm, size, similarity) +- Similar Videos (with crop detection, skip forward, duration) +- Similar Music (by tags or audio fingerprint) +- Invalid Symlinks +- Broken Files (audio, PDF, archive, image, video) +- Bad Extensions +- Bad Names (uppercase, emoji, spaces, non-ASCII, restricted charset) +- EXIF Remover +- Video Optimizer (crop black bars / transcode) + +### GUI features + +- **Dark theme** with full Qt stylesheet +- **Two-bar progress display** - current stage + overall progress, matching the Slint frontend + - Real-time entry and byte counts (e.g., "Calculating hashes: 15,000/25,000 (500 MB/1 GB)") + - File collection estimation using cached counts from previous scans + - Elapsed time display + - Stage step indicators +- **Project icons** - uses the same SVG icons as the Krokiet interface +- **Image preview panel** for duplicate/similar image results +- **Grouped results view** with tree display for duplicate/similar file groups +- **Selection modes** - Select All/None, Invert, Biggest/Smallest, Newest/Oldest, Shortest/Longest Path +- **File actions** - Delete (with trash support), Move/Copy, Hardlink, Symlink, Rename, Clean EXIF +- **Per-tool settings** - all tool-specific options (hash type, similarity thresholds, etc.) +- **Global settings** - directories, filters, cache, thread count +- **Directory management** - included/excluded paths with add/remove buttons +- **Context menus** - right-click to open file or containing folder +- **Settings persistence** via JSON config files +- **Auto-detection** of `czkawka_cli` binary (checks PATH, cargo target directory, cargo metadata) + +## Requirements + +- Python 3.10+ +- PySide6 >= 6.6.0 +- `czkawka_cli` binary (installed or in PATH) +- Optional: `send2trash` (for trash support on Linux) +- Optional: `Pillow` (for EXIF cleaning fallback) + +## Installation + +### 1. Install czkawka_cli + +```shell +# From the project root +cargo install --path czkawka_cli + +# Or build it +cargo build --release -p czkawka_cli +``` + +### 2. Install Python dependencies + +```shell +cd czkawka_pyside6 +pip install -r requirements.txt +``` + +### 3. Run + +```shell +python main.py +``` + +The application will auto-detect the `czkawka_cli` binary. If it can't find it, configure the path in Settings. + +## Architecture + +``` +czkawka_pyside6/ +├── main.py # Entry point +├── requirements.txt # Python dependencies +├── app/ +│ ├── main_window.py # Main window with all panels +│ ├── left_panel.py # Tool selection sidebar (14 tools) +│ ├── results_view.py # Results tree with grouping, selection, sorting +│ ├── action_buttons.py # Scan/Stop/Delete/Move/Save/Sort buttons with icons +│ ├── tool_settings.py # Per-tool settings (9 tool panels) +│ ├── settings_panel.py # Global settings (General/Directories/Filters/Preview) +│ ├── progress_widget.py # Two-bar progress: current stage + overall +│ ├── preview_panel.py # Image preview panel +│ ├── bottom_panel.py # Directory management + error display +│ ├── backend.py # CLI subprocess interface with JSON progress parsing +│ ├── models.py # Data models, enums, column definitions +│ ├── state.py # Application state with Qt signals +│ ├── icons.py # SVG icon resources from Krokiet icon set +│ └── dialogs/ # Delete, Move, Select, Sort, Save, Rename, About +``` + +### How it works + +1. **Scanning**: The app spawns `czkawka_cli` as a subprocess with `--compact-file-to-save` for JSON results and `--json-progress` for real-time progress data on stderr. + +2. **Progress**: JSON lines on stderr provide `ProgressData` with stage index, entry counts, byte counts — the same data the Slint frontend gets via crossbeam channels. The progress widget displays two bars (current stage and overall) with percentage, counts, and elapsed time. + +3. **Results**: JSON results are parsed and displayed in a tree view with group headers for duplicate/similar file tools. + +4. **File operations**: Delete, move, hardlink, symlink, and rename operations are performed directly in Python. EXIF cleaning and extension/name fixing use `czkawka_cli` subcommands. + +## LICENSE + +MIT diff --git a/czkawka_pyside6/app/__init__.py b/czkawka_pyside6/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/czkawka_pyside6/app/action_buttons.py b/czkawka_pyside6/app/action_buttons.py new file mode 100644 index 000000000..36e928a26 --- /dev/null +++ b/czkawka_pyside6/app/action_buttons.py @@ -0,0 +1,196 @@ +from PySide6.QtWidgets import ( + QWidget, QHBoxLayout, QPushButton, QSizePolicy +) +from PySide6.QtCore import Signal, QSize + +from .models import ActiveTab, GROUPED_TABS +from .icons import ( + icon_search, icon_stop, icon_select, icon_delete, icon_move, + icon_save, icon_sort, icon_hardlink, icon_symlink, icon_rename, + icon_clean, icon_optimize, +) + +ICON_SIZE = QSize(18, 18) + + +class ActionButtons(QWidget): + """Action buttons bar: Scan, Stop, Select, Delete, Move, Save, Sort, etc.""" + + scan_clicked = Signal() + stop_clicked = Signal() + select_clicked = Signal() + delete_clicked = Signal() + move_clicked = Signal() + save_clicked = Signal() + sort_clicked = Signal() + hardlink_clicked = Signal() + symlink_clicked = Signal() + rename_clicked = Signal() + clean_exif_clicked = Signal() + optimize_video_clicked = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self._active_tab = ActiveTab.DUPLICATE_FILES + self._scanning = False + self._has_results = False + self._has_selection = False + self._setup_ui() + + def _setup_ui(self): + layout = QHBoxLayout(self) + layout.setContentsMargins(4, 4, 4, 4) + layout.setSpacing(4) + + # Scan button + self._scan_btn = QPushButton(icon_search(18), " Scan") + self._scan_btn.setIconSize(ICON_SIZE) + self._scan_btn.setMinimumWidth(90) + self._scan_btn.setStyleSheet( + "QPushButton { background-color: #2d5a27; color: white; font-weight: bold; padding: 6px 16px; }" + "QPushButton:hover { background-color: #3a7a34; }" + "QPushButton:disabled { background-color: #444; color: #888; }" + ) + self._scan_btn.clicked.connect(self.scan_clicked.emit) + layout.addWidget(self._scan_btn) + + # Stop button + self._stop_btn = QPushButton(icon_stop(18), " Stop") + self._stop_btn.setIconSize(ICON_SIZE) + self._stop_btn.setMinimumWidth(80) + self._stop_btn.setStyleSheet( + "QPushButton { background-color: #8a2222; color: white; font-weight: bold; padding: 6px 16px; }" + "QPushButton:hover { background-color: #aa3333; }" + ) + self._stop_btn.clicked.connect(self.stop_clicked.emit) + self._stop_btn.setVisible(False) + layout.addWidget(self._stop_btn) + + # Spacer + spacer = QWidget() + spacer.setFixedWidth(10) + layout.addWidget(spacer) + + # Select button + self._select_btn = QPushButton(icon_select(18), " Select") + self._select_btn.setIconSize(ICON_SIZE) + self._select_btn.clicked.connect(self.select_clicked.emit) + layout.addWidget(self._select_btn) + + # Delete button + self._delete_btn = QPushButton(icon_delete(18), " Delete") + self._delete_btn.setIconSize(ICON_SIZE) + self._delete_btn.setStyleSheet( + "QPushButton { background-color: #8a2222; color: white; padding: 6px 12px; }" + "QPushButton:hover { background-color: #aa3333; }" + "QPushButton:disabled { background-color: #444; color: #888; }" + ) + self._delete_btn.clicked.connect(self.delete_clicked.emit) + layout.addWidget(self._delete_btn) + + # Move button + self._move_btn = QPushButton(icon_move(18), " Move") + self._move_btn.setIconSize(ICON_SIZE) + self._move_btn.clicked.connect(self.move_clicked.emit) + layout.addWidget(self._move_btn) + + # Save button + self._save_btn = QPushButton(icon_save(18), " Save") + self._save_btn.setIconSize(ICON_SIZE) + self._save_btn.clicked.connect(self.save_clicked.emit) + layout.addWidget(self._save_btn) + + # Sort button + self._sort_btn = QPushButton(icon_sort(18), " Sort") + self._sort_btn.setIconSize(ICON_SIZE) + self._sort_btn.clicked.connect(self.sort_clicked.emit) + layout.addWidget(self._sort_btn) + + # Hardlink button (grouped tools only) + self._hardlink_btn = QPushButton(icon_hardlink(18), " Hardlink") + self._hardlink_btn.setIconSize(ICON_SIZE) + self._hardlink_btn.clicked.connect(self.hardlink_clicked.emit) + layout.addWidget(self._hardlink_btn) + + # Symlink button (grouped tools only) + self._symlink_btn = QPushButton(icon_symlink(18), " Symlink") + self._symlink_btn.setIconSize(ICON_SIZE) + self._symlink_btn.clicked.connect(self.symlink_clicked.emit) + layout.addWidget(self._symlink_btn) + + # Rename button (bad extensions / bad names) + self._rename_btn = QPushButton(icon_rename(18), " Rename") + self._rename_btn.setIconSize(ICON_SIZE) + self._rename_btn.clicked.connect(self.rename_clicked.emit) + layout.addWidget(self._rename_btn) + + # Clean EXIF button + self._clean_exif_btn = QPushButton(icon_clean(18), " Clean EXIF") + self._clean_exif_btn.setIconSize(ICON_SIZE) + self._clean_exif_btn.clicked.connect(self.clean_exif_clicked.emit) + layout.addWidget(self._clean_exif_btn) + + # Optimize Video button + self._optimize_btn = QPushButton(icon_optimize(18), " Optimize") + self._optimize_btn.setIconSize(ICON_SIZE) + self._optimize_btn.clicked.connect(self.optimize_video_clicked.emit) + layout.addWidget(self._optimize_btn) + + # Stretch at the end + layout.addStretch() + + self._update_visibility() + + def set_active_tab(self, tab: ActiveTab): + self._active_tab = tab + self._update_visibility() + + def set_scanning(self, scanning: bool): + self._scanning = scanning + self._scan_btn.setVisible(not scanning) + self._stop_btn.setVisible(scanning) + self._update_enabled() + + def set_has_results(self, has_results: bool): + self._has_results = has_results + self._update_enabled() + + def set_has_selection(self, has_selection: bool): + self._has_selection = has_selection + self._update_enabled() + + def _update_visibility(self): + tab = self._active_tab + is_grouped = tab in GROUPED_TABS + + # Always visible + self._select_btn.setVisible(True) + self._delete_btn.setVisible(True) + self._move_btn.setVisible(True) + self._save_btn.setVisible(True) + self._sort_btn.setVisible(True) + + # Conditional buttons + self._hardlink_btn.setVisible(is_grouped) + self._symlink_btn.setVisible(is_grouped) + self._rename_btn.setVisible(tab in (ActiveTab.BAD_EXTENSIONS, ActiveTab.BAD_NAMES)) + self._clean_exif_btn.setVisible(tab == ActiveTab.EXIF_REMOVER) + self._optimize_btn.setVisible(tab == ActiveTab.VIDEO_OPTIMIZER) + + self._update_enabled() + + def _update_enabled(self): + has_data = self._has_results and not self._scanning + has_sel = self._has_selection and not self._scanning + + self._scan_btn.setEnabled(not self._scanning) + self._select_btn.setEnabled(has_data) + self._delete_btn.setEnabled(has_sel) + self._move_btn.setEnabled(has_sel) + self._save_btn.setEnabled(has_data) + self._sort_btn.setEnabled(has_data) + self._hardlink_btn.setEnabled(has_sel) + self._symlink_btn.setEnabled(has_sel) + self._rename_btn.setEnabled(has_sel) + self._clean_exif_btn.setEnabled(has_sel) + self._optimize_btn.setEnabled(has_sel) diff --git a/czkawka_pyside6/app/backend.py b/czkawka_pyside6/app/backend.py new file mode 100644 index 000000000..3d922c176 --- /dev/null +++ b/czkawka_pyside6/app/backend.py @@ -0,0 +1,621 @@ +import json +import os +import subprocess +import tempfile +from pathlib import Path +from typing import Optional + +from PySide6.QtCore import QObject, QThread, Signal + +from .models import ( + ActiveTab, AppSettings, ToolSettings, ResultEntry, ScanProgress, + TAB_TO_CLI_COMMAND, GROUPED_TABS, CheckingMethod, MusicSearchMethod, +) + + +class ScanWorker(QObject): + """Worker that runs czkawka_cli in a background thread.""" + finished = Signal(object, list) # (ActiveTab, results) + progress = Signal(object) # ScanProgress + error = Signal(str) + + def __init__(self, tab: ActiveTab, app_settings: AppSettings, + tool_settings: ToolSettings): + super().__init__() + self.tab = tab + self.app_settings = app_settings + self.tool_settings = tool_settings + self._process: Optional[subprocess.Popen] = None + self._cancelled = False + + def run(self): + try: + cmd = self._build_command() + if not cmd: + self.error.emit(f"No CLI command for tab {self.tab}") + return + + self.progress.emit(ScanProgress( + step_name="Collecting files...", current=0, total=0 + )) + + with tempfile.NamedTemporaryFile(suffix=".json", delete=False, mode="w") as f: + json_output_path = f.name + + # Add JSON output flag (use long form to avoid conflict with -C in broken files) + cmd.extend(["--compact-file-to-save", json_output_path]) + # Enable JSON progress on stderr, suppress text output + cmd.extend(["--json-progress", "-N", "-M"]) + + self._process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + # Read stderr line-by-line for JSON progress data + self._monitor_process_json(json_output_path) + + if self._cancelled: + self._cleanup(json_output_path) + return + + # Check for CLI errors + returncode = self._process.returncode + if returncode is not None and returncode != 0 and returncode != 11: + error_msg = f"czkawka_cli exited with code {returncode}" + self.error.emit(error_msg) + self._cleanup(json_output_path) + return + + # Parse results + self.progress.emit(ScanProgress( + step_name="Loading results...", current=0, total=0 + )) + results = self._parse_results(json_output_path) + self.finished.emit(self.tab, results) + self._cleanup(json_output_path) + + except FileNotFoundError: + self.error.emit( + f"czkawka_cli not found at '{self.app_settings.czkawka_cli_path}'. " + "Please install czkawka_cli or set the correct path in settings." + ) + except Exception as e: + self.error.emit(str(e)) + + def cancel(self): + self._cancelled = True + if self._process and self._process.poll() is None: + self._process.terminate() + try: + self._process.wait(timeout=3) + except subprocess.TimeoutExpired: + self._process.kill() + self._process.wait() + + def _monitor_process_json(self, json_path: str): + """Read JSON progress lines from stderr in real-time.""" + import time + + while self._process.poll() is None: + if self._cancelled: + return + + line = self._process.stderr.readline() + if not line: + time.sleep(0.05) + continue + + line = line.strip() + if not line: + continue + + try: + data = json.loads(line) + progress = data.get("progress", {}) + stage_name = data.get("stage_name", "Processing...") + + entries_checked = progress.get("entries_checked", 0) + entries_to_check = progress.get("entries_to_check", 0) + bytes_checked = progress.get("bytes_checked", 0) + bytes_to_check = progress.get("bytes_to_check", 0) + current_stage_idx = progress.get("current_stage_idx", 0) + max_stage_idx = progress.get("max_stage_idx", 0) + + self.progress.emit(ScanProgress( + step_name=stage_name, + current=0, + total=0, + current_size=bytes_checked, + stage_name=stage_name, + current_stage_idx=current_stage_idx, + max_stage_idx=max_stage_idx, + entries_checked=entries_checked, + entries_to_check=entries_to_check, + bytes_checked=bytes_checked, + bytes_to_check=bytes_to_check, + )) + except (json.JSONDecodeError, KeyError, TypeError): + continue + + # Drain remaining stderr + remaining = self._process.stderr.read() + if remaining: + for line in remaining.strip().split("\n"): + pass # Final lines already processed + + def _cleanup(self, path: str): + try: + os.unlink(path) + except OSError: + pass + + def _build_command(self) -> list[str]: + s = self.app_settings + ts = self.tool_settings + cli = s.czkawka_cli_path + subcmd = TAB_TO_CLI_COMMAND.get(self.tab) + if not subcmd: + return [] + + # Handle video-optimizer subcommands + if self.tab == ActiveTab.VIDEO_OPTIMIZER: + if ts.video_opt_mode == "crop": + cmd = [cli, "video-optimizer", "crop"] + else: + cmd = [cli, "video-optimizer", "transcode"] + else: + cmd = [cli, subcmd] + + # Common args + if s.included_paths: + cmd.extend(["-d", ",".join(s.included_paths)]) + if s.excluded_paths: + cmd.extend(["-e", ",".join(s.excluded_paths)]) + if s.excluded_items: + cmd.extend(["-E", s.excluded_items]) + if s.allowed_extensions: + cmd.extend(["-x", s.allowed_extensions]) + if s.excluded_extensions: + cmd.extend(["-P", s.excluded_extensions]) + if not s.recursive_search: + cmd.append("-R") + if not s.use_cache: + cmd.append("-H") + if s.thread_number > 0: + cmd.extend(["-T", str(s.thread_number)]) + + # Tool-specific args + if self.tab == ActiveTab.DUPLICATE_FILES: + cmd.extend(["-s", ts.dup_check_method.value]) + cmd.extend(["-t", ts.dup_hash_type.value]) + if ts.dup_min_size: + cmd.extend(["-m", ts.dup_min_size]) + if ts.dup_max_size: + cmd.extend(["-i", ts.dup_max_size]) + if ts.dup_name_case_sensitive: + cmd.append("-l") + if not s.hide_hard_links: + cmd.append("-L") + if ts.dup_use_prehash: + cmd.append("-u") + + elif self.tab == ActiveTab.SIMILAR_IMAGES: + cmd.extend(["-g", ts.img_hash_alg.value]) + cmd.extend(["-z", ts.img_filter.value]) + cmd.extend(["-c", str(ts.img_hash_size)]) + cmd.extend(["-s", str(ts.img_max_difference)]) + if ts.img_ignore_same_size: + cmd.append("-J") + + elif self.tab == ActiveTab.SIMILAR_VIDEOS: + cmd.extend(["-t", str(ts.vid_max_difference)]) + cmd.extend(["-U", str(ts.vid_skip_forward)]) + cmd.extend(["-B", ts.vid_crop_detect.value]) + cmd.extend(["-A", str(ts.vid_duration)]) + if ts.vid_ignore_same_size: + cmd.append("-J") + + elif self.tab == ActiveTab.SIMILAR_MUSIC: + cmd.extend(["-s", ts.music_search_method.value]) + if ts.music_search_method == MusicSearchMethod.TAGS: + tags = [] + if ts.music_title: tags.append("track_title") + if ts.music_artist: tags.append("track_artist") + if ts.music_bitrate: tags.append("bitrate") + if ts.music_genre: tags.append("genre") + if ts.music_year: tags.append("year") + if ts.music_length: tags.append("length") + if tags: + cmd.extend(["-z", ",".join(tags)]) + if ts.music_approximate: + cmd.append("-a") + else: + cmd.extend(["-Y", str(ts.music_max_difference)]) + + elif self.tab == ActiveTab.BIG_FILES: + cmd.extend(["-n", str(ts.big_files_number)]) + if ts.big_files_mode == "smallest": + cmd.append("-J") + + elif self.tab == ActiveTab.BROKEN_FILES: + types = [] + if ts.broken_audio: types.append("AUDIO") + if ts.broken_pdf: types.append("PDF") + if ts.broken_archive: types.append("ARCHIVE") + if ts.broken_image: types.append("IMAGE") + if ts.broken_video: types.append("VIDEO") + if types: + cmd.extend(["-C", ",".join(types)]) + + elif self.tab == ActiveTab.BAD_NAMES: + if ts.bad_names_uppercase_ext: + cmd.append("-u") + if ts.bad_names_emoji: + cmd.append("-j") + if ts.bad_names_space: + cmd.append("-w") + if ts.bad_names_non_ascii: + cmd.append("-n") + if ts.bad_names_restricted_charset: + cmd.extend(["-r", ts.bad_names_restricted_charset]) + if ts.bad_names_remove_duplicated: + cmd.append("-a") + + elif self.tab == ActiveTab.VIDEO_OPTIMIZER: + if ts.video_opt_mode == "crop": + cmd.extend(["-m", ts.video_crop_mechanism.value]) + cmd.extend(["-k", str(ts.video_black_pixel_threshold)]) + cmd.extend(["-b", str(ts.video_black_bar_percentage)]) + cmd.extend(["-s", str(ts.video_max_samples)]) + cmd.extend(["-z", str(ts.video_min_crop_size)]) + else: + if ts.video_excluded_codecs: + cmd.extend(["-c", ts.video_excluded_codecs]) + cmd.extend(["--target-codec", ts.video_codec.value]) + cmd.extend(["--quality", str(ts.video_quality)]) + if ts.video_fail_if_bigger: + cmd.append("--fail-if-not-smaller") + + return cmd + + def _parse_results(self, json_path: str) -> list[ResultEntry]: + try: + with open(json_path) as f: + data = json.load(f) + except (json.JSONDecodeError, FileNotFoundError, OSError): + return [] + + if not data: + return [] + + results = [] + is_grouped = self.tab in GROUPED_TABS + + if is_grouped: + # Grouped tools return either: + # dict {size_key: [[entry, ...], [entry, ...]], ...} (duplicates by hash) + # list [[entry, ...], [entry, ...]] (similar images/videos/music) + all_groups = [] + if isinstance(data, dict): + # Dict format: flatten all groups from all size buckets + for size_key, groups_for_size in data.items(): + if isinstance(groups_for_size, list): + for group in groups_for_size: + if isinstance(group, list) and len(group) > 0: + all_groups.append(group) + elif isinstance(data, list): + for item in data: + if isinstance(item, list) and len(item) > 0: + all_groups.append(item) + + for group_id, group in enumerate(all_groups): + # Compute total size for the group header + total_size = sum(e.get("size", 0) for e in group if isinstance(e, dict)) + header_text = ( + f"Group {group_id + 1} ({len(group)} files, " + f"{self._format_size(total_size)})" + ) + header = ResultEntry( + values={"__header": header_text}, + header_row=True, + group_id=group_id, + ) + results.append(header) + for entry in group: + if isinstance(entry, dict): + result = self._parse_entry(entry, group_id) + results.append(result) + else: + # Flat tools return a list of entries + if isinstance(data, list): + for i, entry in enumerate(data): + if isinstance(entry, dict): + result = self._parse_entry(entry, 0) + results.append(result) + elif isinstance(data, dict): + # Some tools might also use dict format + for key, entries in data.items(): + if isinstance(entries, list): + for entry in entries: + if isinstance(entry, dict): + result = self._parse_entry(entry, 0) + results.append(result) + + return results + + def _parse_entry(self, entry: dict, group_id: int) -> ResultEntry: + path = entry.get("path", "") + p = Path(path) + values = { + "File Name": p.name, + "Folder Name": p.name, + "Symlink Name": p.name, + "Path": str(p.parent), + "Symlink Path": str(p.parent), + "Size": self._format_size(entry.get("size", 0)), + "Modification Date": self._format_date(entry.get("modified_date", 0)), + "Hash": entry.get("hash", ""), + "Similarity": str(entry.get("similarity", "")), + "Resolution": entry.get("dimensions", ""), + "Title": entry.get("title", ""), + "Artist": entry.get("artist", ""), + "Year": entry.get("year", ""), + "Bitrate": str(entry.get("bitrate", "")), + "Genre": entry.get("genre", ""), + "Length": entry.get("length", ""), + "Destination Path": entry.get("destination_path", ""), + "Type of Error": entry.get("error_string", entry.get("type_of_error", "")), + "Error Type": entry.get("error_string", entry.get("error_type", "")), + "Current Extension": entry.get("current_extension", ""), + "Proper Extension": entry.get("proper_extension", entry.get("proper_extensions", "")), + "Codec": entry.get("codec", ""), + } + # Store full path for file operations + values["__full_path"] = path + values["__size_bytes"] = entry.get("size", 0) + values["__modified_date_ts"] = entry.get("modified_date", 0) + + return ResultEntry(values=values, group_id=group_id) + + @staticmethod + def _format_size(size_bytes: int) -> str: + if size_bytes == 0: + return "0 B" + units = ["B", "KB", "MB", "GB", "TB"] + i = 0 + size = float(size_bytes) + while size >= 1024 and i < len(units) - 1: + size /= 1024 + i += 1 + return f"{size:.1f} {units[i]}" if i > 0 else f"{int(size)} B" + + @staticmethod + def _format_date(timestamp: int) -> str: + if timestamp == 0: + return "" + from datetime import datetime + try: + return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S") + except (ValueError, OSError): + return str(timestamp) + + +class ScanRunner(QObject): + """Manages scan execution in a background thread.""" + finished = Signal(object, list) + progress = Signal(object) + error = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self._thread: Optional[QThread] = None + self._worker: Optional[ScanWorker] = None + + def start_scan(self, tab: ActiveTab, app_settings: AppSettings, + tool_settings: ToolSettings): + if self._thread and self._thread.isRunning(): + return + + self._thread = QThread() + self._worker = ScanWorker(tab, app_settings, tool_settings) + self._worker.moveToThread(self._thread) + + self._thread.started.connect(self._worker.run) + self._worker.finished.connect(self._on_finished) + self._worker.progress.connect(self.progress.emit) + self._worker.error.connect(self._on_error) + + self._thread.start() + + def stop_scan(self): + if self._worker: + self._worker.cancel() + # Wait briefly for the thread to finish after killing the process + if self._thread and self._thread.isRunning(): + self._thread.quit() + if not self._thread.wait(5000): + self._thread.terminate() + self._thread.wait() + self._thread = None + self._worker = None + # Emit an empty result to signal completion + self.finished.emit(ActiveTab.DUPLICATE_FILES, []) + + def _on_finished(self, tab, results): + self.finished.emit(tab, results) + self._cleanup() + + def _on_error(self, msg): + self.error.emit(msg) + self._cleanup() + + def _cleanup(self): + if self._thread: + self._thread.quit() + self._thread.wait(5000) + self._thread = None + self._worker = None + + +class FileOperations: + """File operations: delete, move, hardlink, symlink, rename.""" + + @staticmethod + def delete_files(entries: list[ResultEntry], move_to_trash: bool = True) -> tuple[int, list[str]]: + deleted = 0 + errors = [] + for entry in entries: + path = entry.values.get("__full_path", "") + if not path: + continue + try: + p = Path(path) + if not p.exists(): + errors.append(f"File not found: {path}") + continue + if move_to_trash: + try: + from send2trash import send2trash + send2trash(str(p)) + except ImportError: + p.unlink() + else: + if p.is_dir(): + import shutil + shutil.rmtree(p) + else: + p.unlink() + deleted += 1 + except OSError as e: + errors.append(f"Error deleting {path}: {e}") + return deleted, errors + + @staticmethod + def move_files(entries: list[ResultEntry], destination: str, + preserve_structure: bool = False, copy_mode: bool = False) -> tuple[int, list[str]]: + import shutil + moved = 0 + errors = [] + dest = Path(destination) + dest.mkdir(parents=True, exist_ok=True) + + for entry in entries: + src_path = entry.values.get("__full_path", "") + if not src_path: + continue + try: + src = Path(src_path) + if preserve_structure: + # Keep relative directory structure + rel = src.parent + target_dir = dest / rel.relative_to(rel.anchor) + target_dir.mkdir(parents=True, exist_ok=True) + target = target_dir / src.name + else: + target = dest / src.name + + # Handle name conflicts + if target.exists(): + stem = target.stem + suffix = target.suffix + counter = 1 + while target.exists(): + target = target.parent / f"{stem}_{counter}{suffix}" + counter += 1 + + if copy_mode: + shutil.copy2(str(src), str(target)) + else: + shutil.move(str(src), str(target)) + moved += 1 + except OSError as e: + errors.append(f"Error moving {src_path}: {e}") + return moved, errors + + @staticmethod + def create_hardlinks(entries: list[ResultEntry], reference_path: str) -> tuple[int, list[str]]: + created = 0 + errors = [] + for entry in entries: + path = entry.values.get("__full_path", "") + if not path or path == reference_path: + continue + try: + p = Path(path) + p.unlink() + os.link(reference_path, str(p)) + created += 1 + except OSError as e: + errors.append(f"Error creating hardlink for {path}: {e}") + return created, errors + + @staticmethod + def create_symlinks(entries: list[ResultEntry], reference_path: str) -> tuple[int, list[str]]: + created = 0 + errors = [] + for entry in entries: + path = entry.values.get("__full_path", "") + if not path or path == reference_path: + continue + try: + p = Path(path) + p.unlink() + os.symlink(reference_path, str(p)) + created += 1 + except OSError as e: + errors.append(f"Error creating symlink for {path}: {e}") + return created, errors + + @staticmethod + def fix_extensions(cli_path: str, app_settings, tool_settings) -> tuple[bool, str]: + """Run czkawka_cli ext with -F flag to fix extensions.""" + cmd = [cli_path, "ext", "-F"] + if app_settings.included_paths: + cmd.extend(["-d", ",".join(app_settings.included_paths)]) + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + return True, result.stdout + except Exception as e: + return False, str(e) + + @staticmethod + def fix_bad_names(cli_path: str, app_settings, tool_settings) -> tuple[bool, str]: + """Run czkawka_cli bad-names with -F flag to fix names.""" + cmd = [cli_path, "bad-names", "-F"] + if app_settings.included_paths: + cmd.extend(["-d", ",".join(app_settings.included_paths)]) + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + return True, result.stdout + except Exception as e: + return False, str(e) + + @staticmethod + def clean_exif(cli_path: str, entries: list[ResultEntry], + ignored_tags: str = "", overwrite: bool = True) -> tuple[int, list[str]]: + """Remove EXIF data from selected files.""" + cleaned = 0 + errors = [] + for entry in entries: + path = entry.values.get("__full_path", "") + if not path: + continue + try: + from PIL import Image + img = Image.open(path) + data = list(img.getdata()) + clean_img = Image.new(img.mode, img.size) + clean_img.putdata(data) + if overwrite: + clean_img.save(path) + else: + p = Path(path) + new_path = p.parent / f"{p.stem}_clean{p.suffix}" + clean_img.save(str(new_path)) + cleaned += 1 + except Exception as e: + errors.append(f"Error cleaning EXIF for {path}: {e}") + return cleaned, errors diff --git a/czkawka_pyside6/app/bottom_panel.py b/czkawka_pyside6/app/bottom_panel.py new file mode 100644 index 000000000..736a5cd04 --- /dev/null +++ b/czkawka_pyside6/app/bottom_panel.py @@ -0,0 +1,143 @@ +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QListWidget, + QPushButton, QFileDialog, QTextEdit, QStackedWidget, + QSizePolicy +) +from PySide6.QtCore import Signal, Qt + +from .models import AppSettings + + +class BottomPanel(QWidget): + """Bottom panel showing directories or error messages.""" + directories_changed = Signal() + + def __init__(self, settings: AppSettings, parent=None): + super().__init__(parent) + self._settings = settings + self.setMaximumHeight(200) + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(4, 2, 4, 2) + + self._stack = QStackedWidget() + + # Page 0: Directories view + dir_widget = QWidget() + dir_layout = QHBoxLayout(dir_widget) + dir_layout.setContentsMargins(0, 0, 0, 0) + + # Included directories + inc_widget = QWidget() + inc_layout = QVBoxLayout(inc_widget) + inc_layout.setContentsMargins(0, 0, 0, 0) + inc_layout.addWidget(QLabel("Included Directories:")) + + self._inc_list = QListWidget() + self._inc_list.setMaximumHeight(120) + for path in self._settings.included_paths: + self._inc_list.addItem(path) + inc_layout.addWidget(self._inc_list) + + inc_btns = QHBoxLayout() + add_btn = QPushButton("+") + add_btn.setFixedWidth(30) + add_btn.clicked.connect(self._add_included) + inc_btns.addWidget(add_btn) + rem_btn = QPushButton("-") + rem_btn.setFixedWidth(30) + rem_btn.clicked.connect(self._remove_included) + inc_btns.addWidget(rem_btn) + inc_btns.addStretch() + inc_layout.addLayout(inc_btns) + dir_layout.addWidget(inc_widget) + + # Excluded directories + exc_widget = QWidget() + exc_layout = QVBoxLayout(exc_widget) + exc_layout.setContentsMargins(0, 0, 0, 0) + exc_layout.addWidget(QLabel("Excluded Directories:")) + + self._exc_list = QListWidget() + self._exc_list.setMaximumHeight(120) + for path in self._settings.excluded_paths: + self._exc_list.addItem(path) + exc_layout.addWidget(self._exc_list) + + exc_btns = QHBoxLayout() + add_exc = QPushButton("+") + add_exc.setFixedWidth(30) + add_exc.clicked.connect(self._add_excluded) + exc_btns.addWidget(add_exc) + rem_exc = QPushButton("-") + rem_exc.setFixedWidth(30) + rem_exc.clicked.connect(self._remove_excluded) + exc_btns.addWidget(rem_exc) + exc_btns.addStretch() + exc_layout.addLayout(exc_btns) + dir_layout.addWidget(exc_widget) + + self._stack.addWidget(dir_widget) + + # Page 1: Text errors/info + self._text_area = QTextEdit() + self._text_area.setReadOnly(True) + self._text_area.setMaximumHeight(150) + self._stack.addWidget(self._text_area) + + layout.addWidget(self._stack) + + def show_directories(self): + self._stack.setCurrentIndex(0) + self.setVisible(True) + + def show_text(self): + self._stack.setCurrentIndex(1) + self.setVisible(True) + + def hide_panel(self): + self.setVisible(False) + + def set_text(self, text: str): + self._text_area.setPlainText(text) + + def append_text(self, text: str): + self._text_area.append(text) + + def _add_included(self): + path = QFileDialog.getExistingDirectory(self, "Select Directory to Include") + if path and path not in self._settings.included_paths: + self._settings.included_paths.append(path) + self._inc_list.addItem(path) + self.directories_changed.emit() + + def _remove_included(self): + row = self._inc_list.currentRow() + if row >= 0: + self._inc_list.takeItem(row) + self._settings.included_paths.pop(row) + self.directories_changed.emit() + + def _add_excluded(self): + path = QFileDialog.getExistingDirectory(self, "Select Directory to Exclude") + if path and path not in self._settings.excluded_paths: + self._settings.excluded_paths.append(path) + self._exc_list.addItem(path) + self.directories_changed.emit() + + def _remove_excluded(self): + row = self._exc_list.currentRow() + if row >= 0: + self._exc_list.takeItem(row) + self._settings.excluded_paths.pop(row) + self.directories_changed.emit() + + def refresh_lists(self): + self._inc_list.clear() + for path in self._settings.included_paths: + self._inc_list.addItem(path) + self._exc_list.clear() + for path in self._settings.excluded_paths: + self._exc_list.addItem(path) diff --git a/czkawka_pyside6/app/dialogs/__init__.py b/czkawka_pyside6/app/dialogs/__init__.py new file mode 100644 index 000000000..f1c6d549e --- /dev/null +++ b/czkawka_pyside6/app/dialogs/__init__.py @@ -0,0 +1,7 @@ +from .delete_dialog import DeleteDialog +from .move_dialog import MoveDialog +from .select_dialog import SelectDialog +from .sort_dialog import SortDialog +from .save_dialog import SaveDialog +from .rename_dialog import RenameDialog +from .about_dialog import AboutDialog diff --git a/czkawka_pyside6/app/dialogs/about_dialog.py b/czkawka_pyside6/app/dialogs/about_dialog.py new file mode 100644 index 000000000..54da0fa9f --- /dev/null +++ b/czkawka_pyside6/app/dialogs/about_dialog.py @@ -0,0 +1,78 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QDialogButtonBox +) +from PySide6.QtCore import Qt +from PySide6.QtGui import QPixmap + +from ..icons import app_logo_path + + +class AboutDialog(QDialog): + """About dialog showing application information.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("About Czkawka PySide6") + self.setMinimumWidth(480) + self.setMinimumHeight(420) + + layout = QVBoxLayout(self) + layout.setSpacing(6) + + # Logo + logo_path = app_logo_path() + if logo_path: + logo_label = QLabel() + pixmap = QPixmap(logo_path) + scaled = pixmap.scaledToHeight(100, Qt.SmoothTransformation) + logo_label.setPixmap(scaled) + logo_label.setAlignment(Qt.AlignCenter) + layout.addWidget(logo_label) + + title = QLabel("Czkawka") + title.setStyleSheet("font-size: 28px; font-weight: bold; padding: 4px;") + title.setAlignment(Qt.AlignCenter) + layout.addWidget(title) + + subtitle = QLabel("PySide6 / Qt6 Edition") + subtitle.setStyleSheet("font-size: 14px; color: #6fbf73; padding: 2px;") + subtitle.setAlignment(Qt.AlignCenter) + layout.addWidget(subtitle) + + version = QLabel("Version 11.0.1") + version.setStyleSheet("font-size: 11px; color: #888; padding: 2px;") + version.setAlignment(Qt.AlignCenter) + layout.addWidget(version) + + # Separator + sep = QLabel() + sep.setFixedHeight(1) + sep.setStyleSheet("background-color: #444; margin: 8px 40px;") + layout.addWidget(sep) + + desc = QLabel( + "Czkawka (tch-kav-ka) is a simple, fast and free app to remove\n" + "unnecessary files from your computer.\n\n" + "This PySide6/Qt interface uses the czkawka_cli backend\n" + "for all scanning and file operations.\n\n" + "Features:\n" + " - Find duplicate files (by hash, name, or size)\n" + " - Find empty files and folders\n" + " - Find similar images, videos, and music\n" + " - Find broken files and invalid symlinks\n" + " - Find files with bad extensions or names\n" + " - Remove EXIF metadata from images\n" + " - Optimize and crop videos\n\n" + "Licensed under MIT License\n" + "https://github.com/qarmin/czkawka" + ) + desc.setWordWrap(True) + desc.setAlignment(Qt.AlignCenter) + desc.setStyleSheet("padding: 6px 20px; line-height: 1.4;") + layout.addWidget(desc) + + layout.addStretch() + + buttons = QDialogButtonBox(QDialogButtonBox.Ok) + buttons.accepted.connect(self.accept) + layout.addWidget(buttons) diff --git a/czkawka_pyside6/app/dialogs/delete_dialog.py b/czkawka_pyside6/app/dialogs/delete_dialog.py new file mode 100644 index 000000000..89ce64bc6 --- /dev/null +++ b/czkawka_pyside6/app/dialogs/delete_dialog.py @@ -0,0 +1,51 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QDialogButtonBox, + QCheckBox, QHBoxLayout +) +from PySide6.QtCore import Qt + + +class DeleteDialog(QDialog): + """Confirmation dialog for deleting files.""" + + def __init__(self, count: int, move_to_trash: bool = True, parent=None): + super().__init__(parent) + self.setWindowTitle("Delete Files") + self.setMinimumWidth(400) + self._move_to_trash = move_to_trash + + layout = QVBoxLayout(self) + + # Warning + icon_label = QLabel() + icon_label.setStyleSheet("font-size: 36px;") + icon_label.setText("Warning") + icon_label.setAlignment(Qt.AlignCenter) + layout.addWidget(icon_label) + + msg = QLabel(f"Are you sure you want to delete {count} selected file(s)?") + msg.setStyleSheet("font-size: 14px; padding: 10px;") + msg.setAlignment(Qt.AlignCenter) + msg.setWordWrap(True) + layout.addWidget(msg) + + # Move to trash checkbox + self._trash_cb = QCheckBox("Move to trash instead of permanent delete") + self._trash_cb.setChecked(move_to_trash) + layout.addWidget(self._trash_cb) + + # Buttons + buttons = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel + ) + buttons.button(QDialogButtonBox.Ok).setText("Delete") + buttons.button(QDialogButtonBox.Ok).setStyleSheet( + "background-color: #8a2222; color: white; padding: 6px 20px;" + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + @property + def move_to_trash(self) -> bool: + return self._trash_cb.isChecked() diff --git a/czkawka_pyside6/app/dialogs/move_dialog.py b/czkawka_pyside6/app/dialogs/move_dialog.py new file mode 100644 index 000000000..6c754d21a --- /dev/null +++ b/czkawka_pyside6/app/dialogs/move_dialog.py @@ -0,0 +1,65 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QDialogButtonBox, + QCheckBox, QLineEdit, QHBoxLayout, QPushButton, + QFileDialog, QFormLayout +) +from PySide6.QtCore import Qt + + +class MoveDialog(QDialog): + """Dialog for moving/copying files to a destination.""" + + def __init__(self, count: int, parent=None): + super().__init__(parent) + self.setWindowTitle("Move/Copy Files") + self.setMinimumWidth(500) + + layout = QVBoxLayout(self) + + msg = QLabel(f"Move or copy {count} selected file(s) to:") + msg.setStyleSheet("font-size: 13px; padding: 6px;") + layout.addWidget(msg) + + # Destination path + dest_layout = QHBoxLayout() + self._dest_edit = QLineEdit() + self._dest_edit.setPlaceholderText("Select destination folder...") + dest_layout.addWidget(self._dest_edit) + + browse_btn = QPushButton("Browse") + browse_btn.clicked.connect(self._browse) + dest_layout.addWidget(browse_btn) + layout.addLayout(dest_layout) + + # Options + self._preserve_structure = QCheckBox("Preserve folder structure") + layout.addWidget(self._preserve_structure) + + self._copy_mode = QCheckBox("Copy instead of move") + layout.addWidget(self._copy_mode) + + # Buttons + buttons = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel + ) + buttons.button(QDialogButtonBox.Ok).setText("Move") + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def _browse(self): + path = QFileDialog.getExistingDirectory(self, "Select Destination") + if path: + self._dest_edit.setText(path) + + @property + def destination(self) -> str: + return self._dest_edit.text() + + @property + def preserve_structure(self) -> bool: + return self._preserve_structure.isChecked() + + @property + def copy_mode(self) -> bool: + return self._copy_mode.isChecked() diff --git a/czkawka_pyside6/app/dialogs/rename_dialog.py b/czkawka_pyside6/app/dialogs/rename_dialog.py new file mode 100644 index 000000000..4c6208ab1 --- /dev/null +++ b/czkawka_pyside6/app/dialogs/rename_dialog.py @@ -0,0 +1,34 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QDialogButtonBox +) + + +class RenameDialog(QDialog): + """Confirmation dialog for renaming files (fix extensions or bad names).""" + + def __init__(self, count: int, rename_type: str = "extensions", parent=None): + super().__init__(parent) + self.setWindowTitle(f"Fix {rename_type.title()}") + self.setMinimumWidth(400) + + layout = QVBoxLayout(self) + + if rename_type == "extensions": + msg = f"Fix extensions for {count} selected file(s)?\n\n" \ + "Files will be renamed to use their proper extensions." + else: + msg = f"Fix names for {count} selected file(s)?\n\n" \ + "Files with problematic names will be renamed." + + label = QLabel(msg) + label.setWordWrap(True) + label.setStyleSheet("font-size: 13px; padding: 10px;") + layout.addWidget(label) + + buttons = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel + ) + buttons.button(QDialogButtonBox.Ok).setText("Rename") + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) diff --git a/czkawka_pyside6/app/dialogs/save_dialog.py b/czkawka_pyside6/app/dialogs/save_dialog.py new file mode 100644 index 000000000..d8fa614e6 --- /dev/null +++ b/czkawka_pyside6/app/dialogs/save_dialog.py @@ -0,0 +1,48 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QDialogButtonBox, + QCheckBox, QFileDialog +) + + +class SaveDialog: + """Save results to file (uses native file dialog).""" + + @staticmethod + def save(parent, results: list, save_as_json: bool = False) -> bool: + if save_as_json: + filter_str = "JSON Files (*.json);;All Files (*)" + default_ext = ".json" + else: + filter_str = "Text Files (*.txt);;All Files (*)" + default_ext = ".txt" + + path, _ = QFileDialog.getSaveFileName( + parent, "Save Results", f"results{default_ext}", filter_str + ) + if not path: + return False + + try: + import json + if save_as_json: + data = [] + for entry in results: + if not entry.header_row: + # Filter out internal keys + values = {k: v for k, v in entry.values.items() + if not k.startswith("__")} + data.append(values) + with open(path, "w") as f: + json.dump(data, f, indent=2) + else: + with open(path, "w") as f: + for entry in results: + if entry.header_row: + f.write(f"\n--- {entry.values.get('__header', 'Group')} ---\n") + else: + path_val = entry.values.get("__full_path", "") + size = entry.values.get("Size", "") + f.write(f"{path_val}\t{size}\n") + return True + except OSError: + return False diff --git a/czkawka_pyside6/app/dialogs/select_dialog.py b/czkawka_pyside6/app/dialogs/select_dialog.py new file mode 100644 index 000000000..58ea94a11 --- /dev/null +++ b/czkawka_pyside6/app/dialogs/select_dialog.py @@ -0,0 +1,48 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QPushButton, QDialogButtonBox +) +from PySide6.QtCore import Signal + +from ..models import SelectMode + + +class SelectDialog(QDialog): + """Dialog for selecting/deselecting results.""" + mode_selected = Signal(object) # SelectMode + + MODES = [ + (SelectMode.SELECT_ALL, "Select All"), + (SelectMode.UNSELECT_ALL, "Unselect All"), + (SelectMode.INVERT_SELECTION, "Invert Selection"), + (SelectMode.SELECT_BIGGEST_SIZE, "Select Biggest (by Size)"), + (SelectMode.SELECT_SMALLEST_SIZE, "Select Smallest (by Size)"), + (SelectMode.SELECT_NEWEST, "Select Newest"), + (SelectMode.SELECT_OLDEST, "Select Oldest"), + (SelectMode.SELECT_SHORTEST_PATH, "Select Shortest Path"), + (SelectMode.SELECT_LONGEST_PATH, "Select Longest Path"), + ] + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Select Results") + self.setMinimumWidth(300) + + layout = QVBoxLayout(self) + + label = QLabel("Choose selection mode:") + label.setStyleSheet("font-size: 13px; padding: 4px;") + layout.addWidget(label) + + for mode, name in self.MODES: + btn = QPushButton(name) + btn.clicked.connect(lambda checked, m=mode: self._select(m)) + layout.addWidget(btn) + + # Cancel + cancel = QPushButton("Cancel") + cancel.clicked.connect(self.reject) + layout.addWidget(cancel) + + def _select(self, mode: SelectMode): + self.mode_selected.emit(mode) + self.accept() diff --git a/czkawka_pyside6/app/dialogs/sort_dialog.py b/czkawka_pyside6/app/dialogs/sort_dialog.py new file mode 100644 index 000000000..3d6f627e4 --- /dev/null +++ b/czkawka_pyside6/app/dialogs/sort_dialog.py @@ -0,0 +1,39 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QPushButton, QComboBox, + QCheckBox, QDialogButtonBox, QFormLayout +) +from PySide6.QtCore import Signal + + +class SortDialog(QDialog): + """Dialog for sorting results.""" + sort_requested = Signal(int, bool) # column_index, ascending + + def __init__(self, columns: list[str], parent=None): + super().__init__(parent) + self.setWindowTitle("Sort Results") + self.setMinimumWidth(300) + + layout = QFormLayout(self) + + self._column = QComboBox() + self._column.addItems(columns) + layout.addRow("Sort by:", self._column) + + self._ascending = QCheckBox("Ascending") + self._ascending.setChecked(True) + layout.addRow(self._ascending) + + buttons = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel + ) + buttons.accepted.connect(self._on_sort) + buttons.rejected.connect(self.reject) + layout.addRow(buttons) + + def _on_sort(self): + self.sort_requested.emit( + self._column.currentIndex(), + self._ascending.isChecked() + ) + self.accept() diff --git a/czkawka_pyside6/app/icons.py b/czkawka_pyside6/app/icons.py new file mode 100644 index 000000000..bc094529f --- /dev/null +++ b/czkawka_pyside6/app/icons.py @@ -0,0 +1,149 @@ +"""SVG icon resources for Czkawka PySide6 interface. + +Uses the same SVG icons as the Krokiet (Slint) interface. +Icons are embedded as strings to avoid file path issues. +""" + +from PySide6.QtGui import QIcon, QPixmap +from PySide6.QtCore import QSize, Qt +from PySide6.QtSvg import QSvgRenderer +from PySide6.QtGui import QPainter, QImage +from functools import lru_cache + +# Fill color applied to icons for dark theme visibility +_ICON_FILL = "#cccccc" +_ICON_FILL_GREEN = "#6fbf73" +_ICON_FILL_RED = "#e57373" + + +def _colorize_svg(svg: str, fill: str = _ICON_FILL) -> str: + """Inject fill color into SVG for dark theme visibility.""" + # Add fill to root svg or g elements + if 'fill="none"' not in svg and f'fill="{fill}"' not in svg: + svg = svg.replace(" QIcon: + """Convert SVG string to QIcon with specified fill color.""" + colored = _colorize_svg(svg_data, fill) + renderer = QSvgRenderer(colored.encode("utf-8")) + image = QImage(QSize(size, size), QImage.Format_ARGB32_Premultiplied) + image.fill(Qt.transparent) + painter = QPainter(image) + renderer.render(painter) + painter.end() + pixmap = QPixmap.fromImage(image) + return QIcon(pixmap) + + +# ─── Raw SVG data ─────────────────────────────────────────── + +SEARCH_SVG = '''''' + +STOP_SVG = '''''' + +DELETE_SVG = '''''' + +MOVE_SVG = '''''' + +SAVE_SVG = '''''' + +SELECT_SVG = '''''' + +HARDLINK_SVG = '''''' + +SYMLINK_SVG = '''''' + +RENAME_SVG = '''''' + +CLEAN_SVG = '''''' + +OPTIMIZE_SVG = '''''' + +SETTINGS_SVG = '''''' + +SUBSETTINGS_SVG = '''''' + +SORT_SVG = '''''' + +DIR_SVG = '''''' + +INFO_SVG = '''''' + + +# ─── Icon accessor functions ──────────────────────────────── + +def icon_search(size=24): + return _svg_to_icon(SEARCH_SVG, _ICON_FILL_GREEN, size) + +def icon_stop(size=24): + return _svg_to_icon(STOP_SVG, _ICON_FILL_RED, size) + +def icon_delete(size=24): + return _svg_to_icon(DELETE_SVG, _ICON_FILL_RED, size) + +def icon_move(size=24): + return _svg_to_icon(MOVE_SVG, _ICON_FILL, size) + +def icon_save(size=24): + return _svg_to_icon(SAVE_SVG, _ICON_FILL, size) + +def icon_select(size=24): + return _svg_to_icon(SELECT_SVG, _ICON_FILL, size) + +def icon_sort(size=24): + return _svg_to_icon(SORT_SVG, _ICON_FILL, size) + +def icon_hardlink(size=24): + return _svg_to_icon(HARDLINK_SVG, _ICON_FILL, size) + +def icon_symlink(size=24): + return _svg_to_icon(SYMLINK_SVG, _ICON_FILL, size) + +def icon_rename(size=24): + return _svg_to_icon(RENAME_SVG, _ICON_FILL, size) + +def icon_clean(size=24): + return _svg_to_icon(CLEAN_SVG, _ICON_FILL, size) + +def icon_optimize(size=24): + return _svg_to_icon(OPTIMIZE_SVG, _ICON_FILL, size) + +def icon_settings(size=24): + return _svg_to_icon(SETTINGS_SVG, _ICON_FILL, size) + +def icon_subsettings(size=24): + return _svg_to_icon(SUBSETTINGS_SVG, _ICON_FILL, size) + +def icon_dir(size=24): + return _svg_to_icon(DIR_SVG, _ICON_FILL, size) + +def icon_info(size=24): + return _svg_to_icon(INFO_SVG, _ICON_FILL, size) + + +def app_logo_path() -> str: + """Return absolute path to the Krokiet logo PNG.""" + from pathlib import Path + # Try relative to this file first, then absolute project path + for candidate in [ + Path(__file__).parent.parent.parent / "krokiet" / "icons" / "krokiet_logo.png", + Path("/mnt/developer/git/aecs4u.it/czkawka/krokiet/icons/krokiet_logo.png"), + ]: + if candidate.exists(): + return str(candidate) + return "" + + +def app_icon() -> QIcon: + """Return the application window icon from the Krokiet logo.""" + path = app_logo_path() + if path: + return QIcon(path) + return QIcon() diff --git a/czkawka_pyside6/app/left_panel.py b/czkawka_pyside6/app/left_panel.py new file mode 100644 index 000000000..8c53e77d3 --- /dev/null +++ b/czkawka_pyside6/app/left_panel.py @@ -0,0 +1,149 @@ +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QListWidget, QListWidgetItem, QLabel, + QPushButton, QHBoxLayout, QSizePolicy +) +from PySide6.QtCore import Signal, Qt, QSize +from PySide6.QtGui import QFont, QPixmap + +from .models import ActiveTab, TAB_DISPLAY_NAMES, TABS_WITH_SETTINGS +from .icons import app_logo_path, icon_settings, icon_subsettings + + +class LeftPanel(QWidget): + """Left sidebar for selecting scan tools.""" + tab_changed = Signal(object) # ActiveTab + settings_requested = Signal() + about_requested = Signal() + tool_settings_toggled = Signal(bool) + + # Tool tabs in display order + TOOL_TABS = [ + ActiveTab.DUPLICATE_FILES, + ActiveTab.EMPTY_FOLDERS, + ActiveTab.BIG_FILES, + ActiveTab.EMPTY_FILES, + ActiveTab.TEMPORARY_FILES, + ActiveTab.SIMILAR_IMAGES, + ActiveTab.SIMILAR_VIDEOS, + ActiveTab.SIMILAR_MUSIC, + ActiveTab.INVALID_SYMLINKS, + ActiveTab.BROKEN_FILES, + ActiveTab.BAD_EXTENSIONS, + ActiveTab.BAD_NAMES, + ActiveTab.EXIF_REMOVER, + ActiveTab.VIDEO_OPTIMIZER, + ] + + def __init__(self, parent=None): + super().__init__(parent) + self.setMinimumWidth(170) + self.setMaximumWidth(230) + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(6, 6, 6, 6) + layout.setSpacing(4) + + # Logo image (clickable) + logo_path = app_logo_path() + if logo_path: + self._logo_label = QLabel() + pixmap = QPixmap(logo_path) + scaled = pixmap.scaledToHeight(70, Qt.SmoothTransformation) + self._logo_label.setPixmap(scaled) + self._logo_label.setAlignment(Qt.AlignCenter) + self._logo_label.setCursor(Qt.PointingHandCursor) + self._logo_label.setToolTip("About Czkawka") + self._logo_label.mousePressEvent = lambda _: self.about_requested.emit() + layout.addWidget(self._logo_label) + else: + title_label = QLabel("Czkawka") + title_font = QFont() + title_font.setPointSize(16) + title_font.setBold(True) + title_label.setFont(title_font) + title_label.setAlignment(Qt.AlignCenter) + title_label.setCursor(Qt.PointingHandCursor) + title_label.mousePressEvent = lambda _: self.about_requested.emit() + layout.addWidget(title_label) + + # Top buttons row with icons + btn_row = QHBoxLayout() + btn_row.setSpacing(4) + + self._settings_btn = QPushButton(icon_settings(20), "") + self._settings_btn.setFixedSize(32, 32) + self._settings_btn.setIconSize(QSize(20, 20)) + self._settings_btn.setToolTip("Application Settings") + self._settings_btn.clicked.connect(self.settings_requested.emit) + btn_row.addWidget(self._settings_btn) + + self._tool_settings_btn = QPushButton(icon_subsettings(20), "") + self._tool_settings_btn.setFixedSize(32, 32) + self._tool_settings_btn.setIconSize(QSize(20, 20)) + self._tool_settings_btn.setToolTip("Tool-specific Settings") + self._tool_settings_btn.setCheckable(True) + self._tool_settings_btn.clicked.connect( + lambda checked: self.tool_settings_toggled.emit(checked) + ) + self._tool_settings_btn.setVisible(False) + btn_row.addWidget(self._tool_settings_btn) + + btn_row.addStretch() + layout.addLayout(btn_row) + + # Tool list + self._tool_list = QListWidget() + self._tool_list.setSpacing(1) + font = QFont() + font.setPointSize(10) + self._tool_list.setFont(font) + self._tool_list.setStyleSheet(""" + QListWidget::item { + padding: 4px 8px; + border-left: 3px solid transparent; + } + QListWidget::item:selected { + border-left: 3px solid #6fbf73; + background-color: #353535; + } + QListWidget::item:hover { + background-color: #49494926; + } + """) + + for tab in self.TOOL_TABS: + item = QListWidgetItem(TAB_DISPLAY_NAMES[tab]) + item.setData(Qt.UserRole, tab) + item.setSizeHint(item.sizeHint().__class__(item.sizeHint().width(), 30)) + self._tool_list.addItem(item) + + self._tool_list.setCurrentRow(0) + self._tool_list.currentItemChanged.connect(self._on_item_changed) + layout.addWidget(self._tool_list) + + # Version label + version_label = QLabel("Czkawka PySide6 v11.0.1") + version_label.setAlignment(Qt.AlignCenter) + version_label.setStyleSheet("color: #666; font-size: 9px; padding: 2px;") + layout.addWidget(version_label) + + def _on_item_changed(self, current, previous): + if current: + tab = current.data(Qt.UserRole) + self._tool_settings_btn.setVisible(tab in TABS_WITH_SETTINGS) + self.tab_changed.emit(tab) + + def set_active_tab(self, tab: ActiveTab): + for i in range(self._tool_list.count()): + item = self._tool_list.item(i) + if item.data(Qt.UserRole) == tab: + self._tool_list.setCurrentRow(i) + break + + def get_active_tab(self) -> ActiveTab: + item = self._tool_list.currentItem() + if item: + return item.data(Qt.UserRole) + return ActiveTab.DUPLICATE_FILES diff --git a/czkawka_pyside6/app/main_window.py b/czkawka_pyside6/app/main_window.py new file mode 100644 index 000000000..2515bd237 --- /dev/null +++ b/czkawka_pyside6/app/main_window.py @@ -0,0 +1,624 @@ +"""Main application window for Czkawka PySide6 interface.""" + +import shutil +from pathlib import Path + +from PySide6.QtWidgets import ( + QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QSplitter, + QStatusBar, QMessageBox, QLabel, QApplication +) +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QPalette, QColor + +from .state import AppState +from .models import ( + ActiveTab, TAB_COLUMNS, GROUPED_TABS, TABS_WITH_SETTINGS, SelectMode +) +from .left_panel import LeftPanel +from .results_view import ResultsView +from .action_buttons import ActionButtons +from .tool_settings import ToolSettingsPanel +from .settings_panel import SettingsPanel +from .progress_widget import ProgressWidget +from .preview_panel import PreviewPanel +from .bottom_panel import BottomPanel +from .backend import ScanRunner, FileOperations +from .icons import app_icon +from .dialogs import ( + DeleteDialog, MoveDialog, SelectDialog, + SortDialog, SaveDialog, RenameDialog, AboutDialog +) + + +class MainWindow(QMainWindow): + """Main application window with all panels and functionality.""" + + def __init__(self): + super().__init__() + self._state = AppState() + self._scan_runner = ScanRunner(self) + self._setup_window() + self._setup_ui() + self._connect_signals() + self._apply_theme() + + def _setup_window(self): + self.setWindowTitle("Czkawka - PySide6 Edition") + self.setMinimumSize(900, 600) + self.resize(1200, 800) + + # Set window icon from project logo + icon = app_icon() + if not icon.isNull(): + self.setWindowIcon(icon) + + # Auto-detect czkawka_cli binary + self._auto_detect_cli() + + def _setup_ui(self): + central = QWidget() + self.setCentralWidget(central) + main_layout = QVBoxLayout(central) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + + # Top area: left panel + content + content_splitter = QSplitter(Qt.Horizontal) + + # Left panel (tool selection) + self._left_panel = LeftPanel() + content_splitter.addWidget(self._left_panel) + + # Center area + center_widget = QWidget() + center_layout = QVBoxLayout(center_widget) + center_layout.setContentsMargins(0, 0, 0, 0) + center_layout.setSpacing(0) + + # Results view + self._results_view = ResultsView() + center_layout.addWidget(self._results_view, 1) + + # Progress widget (hidden by default) + self._progress = ProgressWidget() + center_layout.addWidget(self._progress) + + content_splitter.addWidget(center_widget) + + # Tool settings panel (hidden by default) + self._tool_settings = ToolSettingsPanel(self._state.tool_settings) + self._tool_settings.setVisible(False) + content_splitter.addWidget(self._tool_settings) + + # Preview panel (hidden by default) + self._preview = PreviewPanel() + self._preview.setVisible(False) + content_splitter.addWidget(self._preview) + + # Set splitter sizes + content_splitter.setStretchFactor(0, 0) # Left panel: fixed + content_splitter.setStretchFactor(1, 1) # Center: stretch + content_splitter.setStretchFactor(2, 0) # Tool settings: fixed + content_splitter.setStretchFactor(3, 0) # Preview: fixed + + main_layout.addWidget(content_splitter, 1) + + # Action buttons bar + self._action_buttons = ActionButtons() + main_layout.addWidget(self._action_buttons) + + # Bottom panel (directories / errors) + self._bottom_panel = BottomPanel(self._state.settings) + self._bottom_panel.show_directories() + main_layout.addWidget(self._bottom_panel) + + # Settings panel (overlay, hidden by default) + self._settings_panel = SettingsPanel(self._state.settings) + self._settings_panel.setVisible(False) + + # Status bar + self._statusbar = QStatusBar() + self.setStatusBar(self._statusbar) + self._status_label = QLabel("Ready") + self._statusbar.addWidget(self._status_label, 1) + + # Initialize results view columns for default tab + self._results_view.set_active_tab(self._state.active_tab) + + def _connect_signals(self): + # Left panel + self._left_panel.tab_changed.connect(self._on_tab_changed) + self._left_panel.settings_requested.connect(self._show_settings) + self._left_panel.about_requested.connect(self._show_about) + self._left_panel.tool_settings_toggled.connect(self._toggle_tool_settings) + + # Action buttons + self._action_buttons.scan_clicked.connect(self._start_scan) + self._action_buttons.stop_clicked.connect(self._stop_scan) + self._action_buttons.select_clicked.connect(self._show_select_dialog) + self._action_buttons.delete_clicked.connect(self._show_delete_dialog) + self._action_buttons.move_clicked.connect(self._show_move_dialog) + self._action_buttons.save_clicked.connect(self._save_results) + self._action_buttons.sort_clicked.connect(self._show_sort_dialog) + self._action_buttons.hardlink_clicked.connect(self._create_hardlinks) + self._action_buttons.symlink_clicked.connect(self._create_symlinks) + self._action_buttons.rename_clicked.connect(self._rename_files) + self._action_buttons.clean_exif_clicked.connect(self._clean_exif) + self._action_buttons.optimize_video_clicked.connect(self._optimize_video) + + # Results view + self._results_view.selection_changed.connect( + lambda count: self._action_buttons.set_has_selection(count > 0) + ) + self._results_view.item_activated.connect(self._on_item_activated) + + # Scan runner + self._scan_runner.finished.connect(self._on_scan_finished) + self._scan_runner.progress.connect(self._on_scan_progress) + self._scan_runner.error.connect(self._on_scan_error) + + # Settings + self._settings_panel.close_requested.connect( + lambda: self._settings_panel.setVisible(False) + ) + self._settings_panel.settings_changed.connect(self._on_settings_changed) + + # Bottom panel + self._bottom_panel.directories_changed.connect(self._on_settings_changed) + + def _on_tab_changed(self, tab: ActiveTab): + self._state.set_active_tab(tab) + self._results_view.set_active_tab(tab) + self._action_buttons.set_active_tab(tab) + self._tool_settings.set_active_tab(tab) + + # Show/hide preview for image-related tabs + show_preview = ( + tab in (ActiveTab.SIMILAR_IMAGES, ActiveTab.DUPLICATE_FILES) + and self._state.settings.show_image_preview + ) + self._preview.setVisible(show_preview) + + # Load existing results for this tab + results = self._state.get_results(tab) + if results: + self._results_view.set_results(results) + self._action_buttons.set_has_results(True) + else: + self._results_view.clear() + self._action_buttons.set_has_results(False) + + self._status_label.setText(f"Tab: {tab.name.replace('_', ' ').title()}") + + def _start_scan(self): + tab = self._state.active_tab + if not self._state.settings.included_paths: + QMessageBox.warning( + self, "No Directories", + "Please add at least one directory to scan in the bottom panel." + ) + return + + self._state.set_scanning(True) + self._action_buttons.set_scanning(True) + self._progress.start(tab) + self._results_view.clear() + self._status_label.setText(f"Scanning: {tab.name.replace('_', ' ').title()}...") + + self._scan_runner.start_scan( + tab, self._state.settings, self._state.tool_settings + ) + + def _stop_scan(self): + self._state.request_stop() + self._scan_runner.stop_scan() + self._status_label.setText("Scan stopped by user") + + def _on_scan_finished(self, tab: ActiveTab, results: list): + self._state.set_scanning(False) + self._state.set_results(tab, results) + self._action_buttons.set_scanning(False) + self._progress.stop() + + if tab == self._state.active_tab: + self._results_view.set_results(results) + self._action_buttons.set_has_results(len(results) > 0) + + count = sum(1 for r in results if not r.header_row) + self._status_label.setText(f"Scan complete: found {count} entries") + + def _on_scan_progress(self, progress): + self._progress.update_progress(progress) + + def _on_scan_error(self, error_msg: str): + self._state.set_scanning(False) + self._action_buttons.set_scanning(False) + self._progress.stop() + self._status_label.setText(f"Error: {error_msg}") + self._bottom_panel.set_text(f"Error: {error_msg}") + self._bottom_panel.show_text() + QMessageBox.critical(self, "Scan Error", error_msg) + + def _on_item_activated(self, entry): + path = entry.values.get("__full_path", "") + if path and self._preview.isVisible(): + self._preview.show_preview(path) + + def _show_settings(self): + self._settings_panel.setVisible(True) + # Show as a floating window + self._settings_panel.setParent(None) + self._settings_panel.setWindowTitle("Czkawka Settings") + self._settings_panel.setMinimumSize(600, 500) + self._settings_panel.show() + self._settings_panel.raise_() + + def _show_about(self): + dialog = AboutDialog(self) + dialog.exec() + + def _toggle_tool_settings(self, visible: bool): + self._tool_settings.setVisible(visible) + + def _show_select_dialog(self): + dialog = SelectDialog(self) + dialog.mode_selected.connect(self._results_view.apply_selection) + dialog.exec() + + def _show_delete_dialog(self): + checked = self._results_view.get_checked_entries() + if not checked: + QMessageBox.information(self, "No Selection", "No files selected for deletion.") + return + + dialog = DeleteDialog(len(checked), self._state.settings.move_to_trash, self) + if dialog.exec() == DeleteDialog.Accepted: + deleted, errors = FileOperations.delete_files( + checked, dialog.move_to_trash + ) + self._status_label.setText(f"Deleted {deleted} file(s)") + if errors: + self._bottom_panel.set_text("\n".join(errors)) + self._bottom_panel.show_text() + # Refresh results - remove deleted entries + self._refresh_after_action(checked) + + def _show_move_dialog(self): + checked = self._results_view.get_checked_entries() + if not checked: + QMessageBox.information(self, "No Selection", "No files selected.") + return + + dialog = MoveDialog(len(checked), self) + if dialog.exec() == MoveDialog.Accepted: + if not dialog.destination: + QMessageBox.warning(self, "No Destination", "Please select a destination folder.") + return + moved, errors = FileOperations.move_files( + checked, dialog.destination, + dialog.preserve_structure, dialog.copy_mode + ) + action = "Copied" if dialog.copy_mode else "Moved" + self._status_label.setText(f"{action} {moved} file(s)") + if errors: + self._bottom_panel.set_text("\n".join(errors)) + self._bottom_panel.show_text() + if not dialog.copy_mode: + self._refresh_after_action(checked) + + def _save_results(self): + results = self._results_view.get_all_entries() + if not results: + QMessageBox.information(self, "No Results", "No results to save.") + return + all_results = self._state.get_results() + success = SaveDialog.save(self, all_results, self._state.settings.save_as_json) + if success: + self._status_label.setText("Results saved successfully") + + def _show_sort_dialog(self): + columns = TAB_COLUMNS.get(self._state.active_tab, []) + if not columns: + return + dialog = SortDialog(columns, self) + dialog.sort_requested.connect(self._results_view.sort_by_column) + dialog.exec() + + def _create_hardlinks(self): + checked = self._results_view.get_checked_entries() + if not checked: + return + + # Find the reference file (first unchecked in the same group) + all_results = self._state.get_results() + group_id = checked[0].group_id + reference = None + for r in all_results: + if r.group_id == group_id and not r.header_row and not r.checked: + reference = r.values.get("__full_path", "") + break + + if not reference: + QMessageBox.warning( + self, "No Reference", + "Cannot determine reference file. Leave at least one file unchecked in the group." + ) + return + + reply = QMessageBox.question( + self, "Create Hardlinks", + f"Replace {len(checked)} selected file(s) with hardlinks to:\n{reference}?", + QMessageBox.Yes | QMessageBox.No + ) + if reply == QMessageBox.Yes: + created, errors = FileOperations.create_hardlinks(checked, reference) + self._status_label.setText(f"Created {created} hardlink(s)") + if errors: + self._bottom_panel.set_text("\n".join(errors)) + self._bottom_panel.show_text() + + def _create_symlinks(self): + checked = self._results_view.get_checked_entries() + if not checked: + return + + all_results = self._state.get_results() + group_id = checked[0].group_id + reference = None + for r in all_results: + if r.group_id == group_id and not r.header_row and not r.checked: + reference = r.values.get("__full_path", "") + break + + if not reference: + QMessageBox.warning( + self, "No Reference", + "Cannot determine reference file. Leave at least one file unchecked in the group." + ) + return + + reply = QMessageBox.question( + self, "Create Symlinks", + f"Replace {len(checked)} selected file(s) with symlinks to:\n{reference}?", + QMessageBox.Yes | QMessageBox.No + ) + if reply == QMessageBox.Yes: + created, errors = FileOperations.create_symlinks(checked, reference) + self._status_label.setText(f"Created {created} symlink(s)") + if errors: + self._bottom_panel.set_text("\n".join(errors)) + self._bottom_panel.show_text() + + def _rename_files(self): + checked = self._results_view.get_checked_entries() + if not checked: + return + + tab = self._state.active_tab + if tab == ActiveTab.BAD_EXTENSIONS: + dialog = RenameDialog(len(checked), "extensions", self) + if dialog.exec() == RenameDialog.Accepted: + success, msg = FileOperations.fix_extensions( + self._state.settings.czkawka_cli_path, + self._state.settings, self._state.tool_settings + ) + self._status_label.setText("Extensions fixed" if success else f"Error: {msg}") + elif tab == ActiveTab.BAD_NAMES: + dialog = RenameDialog(len(checked), "names", self) + if dialog.exec() == RenameDialog.Accepted: + success, msg = FileOperations.fix_bad_names( + self._state.settings.czkawka_cli_path, + self._state.settings, self._state.tool_settings + ) + self._status_label.setText("Names fixed" if success else f"Error: {msg}") + + def _clean_exif(self): + checked = self._results_view.get_checked_entries() + if not checked: + return + + reply = QMessageBox.question( + self, "Clean EXIF", + f"Remove EXIF metadata from {len(checked)} selected file(s)?", + QMessageBox.Yes | QMessageBox.No + ) + if reply == QMessageBox.Yes: + cleaned, errors = FileOperations.clean_exif( + self._state.settings.czkawka_cli_path, + checked, + self._state.tool_settings.exif_ignored_tags + ) + self._status_label.setText(f"Cleaned EXIF from {cleaned} file(s)") + if errors: + self._bottom_panel.set_text("\n".join(errors)) + self._bottom_panel.show_text() + + def _optimize_video(self): + checked = self._results_view.get_checked_entries() + if not checked: + return + + QMessageBox.information( + self, "Video Optimization", + f"Video optimization for {len(checked)} file(s) will be performed " + "using czkawka_cli. Check the status bar for progress." + ) + # Video optimization is done via CLI + self._status_label.setText("Video optimization: use CLI directly for this feature") + + def _refresh_after_action(self, removed_entries: list): + """Remove processed entries from results and refresh the view.""" + removed_paths = {e.values.get("__full_path") for e in removed_entries} + current_results = self._state.get_results() + new_results = [] + for r in current_results: + if r.header_row: + new_results.append(r) + elif r.values.get("__full_path") not in removed_paths: + new_results.append(r) + + # Remove empty group headers + cleaned = [] + i = 0 + while i < len(new_results): + if new_results[i].header_row: + # Check if next entries belong to this group + has_children = False + for j in range(i + 1, len(new_results)): + if new_results[j].header_row: + break + has_children = True + if has_children: + cleaned.append(new_results[i]) + else: + cleaned.append(new_results[i]) + i += 1 + + self._state.set_results(self._state.active_tab, cleaned) + self._results_view.set_results(cleaned) + self._action_buttons.set_has_results( + any(not r.header_row for r in cleaned) + ) + + def _on_settings_changed(self): + self._state.save_settings() + self._bottom_panel.refresh_lists() + + def _apply_theme(self): + """Apply dark theme to the application.""" + if not self._state.settings.dark_theme: + return + + app = QApplication.instance() + palette = QPalette() + + # Dark theme colors + palette.setColor(QPalette.Window, QColor(43, 43, 43)) + palette.setColor(QPalette.WindowText, QColor(210, 210, 210)) + palette.setColor(QPalette.Base, QColor(30, 30, 30)) + palette.setColor(QPalette.AlternateBase, QColor(38, 38, 38)) + palette.setColor(QPalette.ToolTipBase, QColor(50, 50, 50)) + palette.setColor(QPalette.ToolTipText, QColor(210, 210, 210)) + palette.setColor(QPalette.Text, QColor(210, 210, 210)) + palette.setColor(QPalette.Button, QColor(53, 53, 53)) + palette.setColor(QPalette.ButtonText, QColor(210, 210, 210)) + palette.setColor(QPalette.BrightText, QColor(255, 255, 255)) + palette.setColor(QPalette.Link, QColor(86, 140, 210)) + palette.setColor(QPalette.Highlight, QColor(60, 100, 150)) + palette.setColor(QPalette.HighlightedText, QColor(255, 255, 255)) + palette.setColor(QPalette.Disabled, QPalette.Text, QColor(128, 128, 128)) + palette.setColor(QPalette.Disabled, QPalette.ButtonText, QColor(128, 128, 128)) + + app.setPalette(palette) + + # Additional stylesheet + app.setStyleSheet(""" + QMainWindow { background-color: #2b2b2b; } + QSplitter::handle { background-color: #404040; width: 2px; } + QTreeWidget { border: 1px solid #404040; } + QTreeWidget::item { padding: 2px; } + QTreeWidget::item:alternate { background-color: #262626; } + QTreeWidget::item:selected { background-color: #3c6496; } + QListWidget { border: 1px solid #404040; } + QListWidget::item { padding: 3px; } + QListWidget::item:selected { background-color: #3c6496; } + QGroupBox { border: 1px solid #505050; border-radius: 4px; + margin-top: 8px; padding-top: 8px; } + QGroupBox::title { subcontrol-origin: margin; left: 10px; + padding: 0 4px; } + QPushButton { padding: 5px 12px; border: 1px solid #555; + border-radius: 3px; background-color: #404040; } + QPushButton:hover { background-color: #505050; } + QPushButton:pressed { background-color: #353535; } + QPushButton:disabled { background-color: #333; color: #666; } + QComboBox { padding: 4px; border: 1px solid #555; + border-radius: 3px; background-color: #404040; } + QComboBox:hover { background-color: #505050; } + QComboBox QAbstractItemView { background-color: #353535; + border: 1px solid #555; + selection-background-color: #3c6496; } + QLineEdit { padding: 4px; border: 1px solid #555; + border-radius: 3px; background-color: #353535; } + QSpinBox { padding: 4px; border: 1px solid #555; + border-radius: 3px; background-color: #353535; } + QSlider::groove:horizontal { height: 6px; background: #404040; + border-radius: 3px; } + QSlider::handle:horizontal { width: 14px; margin: -4px 0; + background: #888; border-radius: 7px; } + QSlider::handle:horizontal:hover { background: #aaa; } + QProgressBar { border: 1px solid #555; border-radius: 3px; + text-align: center; background-color: #353535; } + QProgressBar::chunk { background-color: #3c6496; border-radius: 2px; } + QScrollArea { border: none; } + QTabWidget::pane { border: 1px solid #555; } + QTabBar::tab { padding: 6px 16px; border: 1px solid #555; + border-bottom: none; border-radius: 3px 3px 0 0; + background-color: #353535; } + QTabBar::tab:selected { background-color: #404040; } + QTabBar::tab:hover { background-color: #505050; } + QStatusBar { background-color: #2b2b2b; border-top: 1px solid #404040; } + QCheckBox { spacing: 6px; } + QCheckBox::indicator { width: 16px; height: 16px; } + QTextEdit { border: 1px solid #404040; background-color: #1e1e1e; } + QHeaderView::section { background-color: #353535; padding: 4px; + border: 1px solid #404040; } + """) + + def _auto_detect_cli(self): + """Auto-detect czkawka_cli binary location.""" + s = self._state.settings + + # If already valid and exists, keep it + if s.czkawka_cli_path != "czkawka_cli" and Path(s.czkawka_cli_path).is_file(): + return + if shutil.which(s.czkawka_cli_path): + return + + # Search common locations + candidates = [] + project_root = Path(__file__).parent.parent.parent + + # Look for compiled binary in standard target dirs + for build_dir in ["target/release", "target/debug"]: + candidate = project_root / build_dir / "czkawka_cli" + if candidate.exists(): + candidates.append(str(candidate)) + + # Check cargo metadata for custom target directory + if not candidates: + try: + import subprocess, json + result = subprocess.run( + ["cargo", "metadata", "--manifest-path", + str(project_root / "Cargo.toml"), + "--format-version", "1", "--no-deps"], + capture_output=True, text=True, timeout=10 + ) + if result.returncode == 0: + meta = json.loads(result.stdout) + target_dir = meta.get("target_directory", "") + if target_dir: + for sub in ["release", "debug"]: + candidate = Path(target_dir) / sub / "czkawka_cli" + if candidate.exists(): + candidates.append(str(candidate)) + except Exception: + pass + + # Also check PATH + which_result = shutil.which("czkawka_cli") + if which_result: + candidates.append(which_result) + + for candidate in candidates: + if Path(candidate).is_file(): + s.czkawka_cli_path = str(candidate) + self._state.save_settings() + return + + def closeEvent(self, event): + """Save settings on close.""" + self._state.save_settings() + if self._state.scanning: + self._scan_runner.stop_scan() + super().closeEvent(event) diff --git a/czkawka_pyside6/app/models.py b/czkawka_pyside6/app/models.py new file mode 100644 index 000000000..306772529 --- /dev/null +++ b/czkawka_pyside6/app/models.py @@ -0,0 +1,305 @@ +from enum import Enum, auto +from dataclasses import dataclass, field +from typing import Optional +from pathlib import Path + + +class ActiveTab(Enum): + DUPLICATE_FILES = auto() + EMPTY_FOLDERS = auto() + BIG_FILES = auto() + EMPTY_FILES = auto() + TEMPORARY_FILES = auto() + SIMILAR_IMAGES = auto() + SIMILAR_VIDEOS = auto() + SIMILAR_MUSIC = auto() + INVALID_SYMLINKS = auto() + BROKEN_FILES = auto() + BAD_EXTENSIONS = auto() + BAD_NAMES = auto() + EXIF_REMOVER = auto() + VIDEO_OPTIMIZER = auto() + SETTINGS = auto() + ABOUT = auto() + + +class SelectMode(Enum): + SELECT_ALL = auto() + UNSELECT_ALL = auto() + INVERT_SELECTION = auto() + SELECT_BIGGEST_SIZE = auto() + SELECT_BIGGEST_RESOLUTION = auto() + SELECT_SMALLEST_SIZE = auto() + SELECT_SMALLEST_RESOLUTION = auto() + SELECT_NEWEST = auto() + SELECT_OLDEST = auto() + SELECT_SHORTEST_PATH = auto() + SELECT_LONGEST_PATH = auto() + SELECT_CUSTOM = auto() + + +class DeleteMethod(Enum): + NONE = "NONE" + DELETE = "DELETE" + ALL_EXCEPT_NEWEST = "AEN" + ALL_EXCEPT_OLDEST = "AEO" + ONE_OLDEST = "OO" + ONE_NEWEST = "ON" + HARDLINK = "HARD" + ALL_EXCEPT_BIGGEST = "AEB" + ALL_EXCEPT_SMALLEST = "AES" + ONE_BIGGEST = "OB" + ONE_SMALLEST = "OS" + + +class CheckingMethod(Enum): + HASH = "HASH" + SIZE = "SIZE" + NAME = "NAME" + SIZE_NAME = "SIZE_NAME" + + +class HashType(Enum): + BLAKE3 = "BLAKE3" + CRC32 = "CRC32" + XXH3 = "XXH3" + + +class ImageHashAlg(Enum): + MEAN = "Mean" + GRADIENT = "Gradient" + BLOCKHASH = "Blockhash" + VERT_GRADIENT = "VertGradient" + DOUBLE_GRADIENT = "DoubleGradient" + MEDIAN = "Median" + + +class ImageFilter(Enum): + LANCZOS3 = "Lanczos3" + NEAREST = "Nearest" + TRIANGLE = "Triangle" + GAUSSIAN = "Gaussian" + CATMULL_ROM = "CatmullRom" + + +class MusicSearchMethod(Enum): + TAGS = "TAGS" + CONTENT = "CONTENT" + + +class VideoCropDetect(Enum): + NONE = "none" + LETTERBOX = "letterbox" + MOTION = "motion" + + +class VideoCropMechanism(Enum): + BLACKBARS = "blackbars" + STATICCONTENT = "staticcontent" + + +class VideoCodec(Enum): + H264 = "h264" + H265 = "h265" + AV1 = "av1" + VP9 = "vp9" + + +# Map ActiveTab to CLI subcommand names +TAB_TO_CLI_COMMAND = { + ActiveTab.DUPLICATE_FILES: "dup", + ActiveTab.EMPTY_FOLDERS: "empty-folders", + ActiveTab.BIG_FILES: "big", + ActiveTab.EMPTY_FILES: "empty-files", + ActiveTab.TEMPORARY_FILES: "temp", + ActiveTab.SIMILAR_IMAGES: "image", + ActiveTab.SIMILAR_VIDEOS: "video", + ActiveTab.SIMILAR_MUSIC: "music", + ActiveTab.INVALID_SYMLINKS: "symlinks", + ActiveTab.BROKEN_FILES: "broken", + ActiveTab.BAD_EXTENSIONS: "ext", + ActiveTab.BAD_NAMES: "bad-names", + ActiveTab.EXIF_REMOVER: "exif-remover", + ActiveTab.VIDEO_OPTIMIZER: "video-optimizer", +} + +TAB_DISPLAY_NAMES = { + ActiveTab.DUPLICATE_FILES: "Duplicate Files", + ActiveTab.EMPTY_FOLDERS: "Empty Folders", + ActiveTab.BIG_FILES: "Big Files", + ActiveTab.EMPTY_FILES: "Empty Files", + ActiveTab.TEMPORARY_FILES: "Temporary Files", + ActiveTab.SIMILAR_IMAGES: "Similar Images", + ActiveTab.SIMILAR_VIDEOS: "Similar Videos", + ActiveTab.SIMILAR_MUSIC: "Similar Music", + ActiveTab.INVALID_SYMLINKS: "Invalid Symlinks", + ActiveTab.BROKEN_FILES: "Broken Files", + ActiveTab.BAD_EXTENSIONS: "Bad Extensions", + ActiveTab.BAD_NAMES: "Bad Names", + ActiveTab.EXIF_REMOVER: "EXIF Remover", + ActiveTab.VIDEO_OPTIMIZER: "Video Optimizer", +} + +# Which tabs support grouping (results are in groups) +GROUPED_TABS = { + ActiveTab.DUPLICATE_FILES, + ActiveTab.SIMILAR_IMAGES, + ActiveTab.SIMILAR_VIDEOS, + ActiveTab.SIMILAR_MUSIC, +} + +# Which tabs have per-tool settings +TABS_WITH_SETTINGS = { + ActiveTab.DUPLICATE_FILES, + ActiveTab.SIMILAR_IMAGES, + ActiveTab.SIMILAR_VIDEOS, + ActiveTab.SIMILAR_MUSIC, + ActiveTab.BIG_FILES, + ActiveTab.BROKEN_FILES, + ActiveTab.BAD_NAMES, + ActiveTab.EXIF_REMOVER, + ActiveTab.VIDEO_OPTIMIZER, +} + +# Column definitions per tab +TAB_COLUMNS = { + ActiveTab.DUPLICATE_FILES: ["Selection", "Size", "File Name", "Path", "Modification Date", "Hash"], + ActiveTab.EMPTY_FOLDERS: ["Selection", "Folder Name", "Path", "Modification Date"], + ActiveTab.BIG_FILES: ["Selection", "Size", "File Name", "Path", "Modification Date"], + ActiveTab.EMPTY_FILES: ["Selection", "File Name", "Path", "Modification Date"], + ActiveTab.TEMPORARY_FILES: ["Selection", "File Name", "Path", "Modification Date"], + ActiveTab.SIMILAR_IMAGES: ["Selection", "Similarity", "Size", "Resolution", "File Name", "Path", "Modification Date", "Hash"], + ActiveTab.SIMILAR_VIDEOS: ["Selection", "Size", "File Name", "Path", "Modification Date"], + ActiveTab.SIMILAR_MUSIC: ["Selection", "Size", "File Name", "Path", "Title", "Artist", "Year", "Bitrate", "Genre", "Length"], + ActiveTab.INVALID_SYMLINKS: ["Selection", "Symlink Name", "Symlink Path", "Destination Path", "Type of Error"], + ActiveTab.BROKEN_FILES: ["Selection", "File Name", "Path", "Error Type", "Size", "Modification Date"], + ActiveTab.BAD_EXTENSIONS: ["Selection", "File Name", "Path", "Current Extension", "Proper Extension"], + ActiveTab.BAD_NAMES: ["Selection", "File Name", "Path", "Error Type"], + ActiveTab.EXIF_REMOVER: ["Selection", "File Name", "Path"], + ActiveTab.VIDEO_OPTIMIZER: ["Selection", "File Name", "Path", "Size", "Codec"], +} + + +@dataclass +class ResultEntry: + """A single result entry from a scan.""" + values: dict # column_name -> value + checked: bool = False + header_row: bool = False # Group header for grouped results + group_id: int = 0 + + +@dataclass +class ScanProgress: + """Progress information during scanning.""" + step_name: str = "" + current: int = 0 + total: int = 0 + current_size: int = 0 + # Raw fields from czkawka_cli --json-progress + stage_name: str = "" + current_stage_idx: int = 0 + max_stage_idx: int = 0 + entries_checked: int = 0 + entries_to_check: int = 0 + bytes_checked: int = 0 + bytes_to_check: int = 0 + + +@dataclass +class ToolSettings: + """Per-tool settings that map to CLI arguments.""" + # Duplicates + dup_check_method: CheckingMethod = CheckingMethod.HASH + dup_hash_type: HashType = HashType.BLAKE3 + dup_name_case_sensitive: bool = False + dup_min_size: str = "8192" + dup_max_size: str = "" + dup_min_cache_size: str = "257144" + dup_use_prehash: bool = True + dup_min_prehash_cache_size: str = "257144" + + # Similar Images + img_hash_size: int = 16 # 8, 16, 32, 64 + img_filter: ImageFilter = ImageFilter.NEAREST + img_hash_alg: ImageHashAlg = ImageHashAlg.GRADIENT + img_ignore_same_size: bool = False + img_max_difference: int = 5 # 0-40 + + # Similar Videos + vid_ignore_same_size: bool = False + vid_crop_detect: VideoCropDetect = VideoCropDetect.LETTERBOX + vid_max_difference: int = 10 # 0-20 + vid_skip_forward: int = 15 # 0-300 + vid_duration: int = 10 # 1-60 + + # Similar Music + music_search_method: MusicSearchMethod = MusicSearchMethod.TAGS + music_approximate: bool = False + music_title: bool = True + music_artist: bool = True + music_bitrate: bool = False + music_genre: bool = False + music_year: bool = False + music_length: bool = False + music_compare_fingerprints_similar_titles: bool = False + music_max_difference: float = 2.0 + music_min_segment_duration: float = 10.0 + + # Big Files + big_files_mode: str = "biggest" # biggest or smallest + big_files_number: int = 50 + + # Broken Files + broken_audio: bool = True + broken_pdf: bool = True + broken_archive: bool = True + broken_image: bool = True + broken_video: bool = False + + # Bad Names + bad_names_uppercase_ext: bool = True + bad_names_emoji: bool = True + bad_names_space: bool = True + bad_names_non_ascii: bool = True + bad_names_restricted_charset: str = "" + bad_names_remove_duplicated: bool = False + + # EXIF Remover + exif_ignored_tags: str = "" + + # Video Optimizer + video_opt_mode: str = "crop" # crop or transcode + video_crop_mechanism: VideoCropMechanism = VideoCropMechanism.BLACKBARS + video_black_pixel_threshold: int = 32 + video_black_bar_percentage: int = 90 + video_max_samples: int = 20 + video_min_crop_size: int = 10 + video_excluded_codecs: str = "h265,hevc,av1,vp9" + video_codec: VideoCodec = VideoCodec.H265 + video_quality: int = 23 + video_fail_if_bigger: bool = False + video_overwrite: bool = False + video_max_width: int = 1920 + video_max_height: int = 1080 + + +@dataclass +class AppSettings: + """Global application settings.""" + included_paths: list = field(default_factory=lambda: [str(Path.home())]) + excluded_paths: list = field(default_factory=list) + excluded_items: str = "" + allowed_extensions: str = "" + excluded_extensions: str = "" + minimum_file_size: str = "" + maximum_file_size: str = "" + recursive_search: bool = True + use_cache: bool = True + save_as_json: bool = False + move_to_trash: bool = True + hide_hard_links: bool = False + thread_number: int = 0 # 0 = all available + dark_theme: bool = True + show_image_preview: bool = True + czkawka_cli_path: str = "czkawka_cli" # path to CLI binary diff --git a/czkawka_pyside6/app/preview_panel.py b/czkawka_pyside6/app/preview_panel.py new file mode 100644 index 000000000..3e3c50586 --- /dev/null +++ b/czkawka_pyside6/app/preview_panel.py @@ -0,0 +1,112 @@ +from pathlib import Path + +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QLabel, QSizePolicy +) +from PySide6.QtCore import Qt, QSize +from PySide6.QtGui import QPixmap, QImage + + +class PreviewPanel(QWidget): + """Image preview panel for similar images / duplicate files.""" + + SUPPORTED_EXTENSIONS = { + ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", + ".tiff", ".tif", ".ico", ".svg" + } + + def __init__(self, parent=None): + super().__init__(parent) + self.setMinimumWidth(200) + self.setMaximumWidth(400) + self._current_path = "" + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(4, 4, 4, 4) + + self._title = QLabel("Preview") + self._title.setStyleSheet("font-weight: bold; font-size: 12px; padding: 2px;") + self._title.setAlignment(Qt.AlignCenter) + layout.addWidget(self._title) + + self._image_label = QLabel() + self._image_label.setAlignment(Qt.AlignCenter) + self._image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self._image_label.setMinimumSize(QSize(180, 180)) + self._image_label.setStyleSheet( + "background-color: #1a1a1a; border: 1px solid #333; border-radius: 4px;" + ) + self._image_label.setScaledContents(False) + layout.addWidget(self._image_label) + + self._info_label = QLabel() + self._info_label.setAlignment(Qt.AlignCenter) + self._info_label.setWordWrap(True) + self._info_label.setStyleSheet("color: #888; font-size: 10px; padding: 2px;") + layout.addWidget(self._info_label) + + def show_preview(self, file_path: str): + if not file_path or file_path == self._current_path: + return + + self._current_path = file_path + p = Path(file_path) + + if not p.exists(): + self._image_label.setText("File not found") + self._info_label.setText("") + return + + if p.suffix.lower() not in self.SUPPORTED_EXTENSIONS: + self._image_label.setText("Preview not available\nfor this file type") + self._info_label.setText(p.name) + return + + pixmap = QPixmap(file_path) + if pixmap.isNull(): + self._image_label.setText("Cannot load image") + self._info_label.setText(p.name) + return + + # Scale to fit while keeping aspect ratio + label_size = self._image_label.size() + scaled = pixmap.scaled( + label_size, Qt.KeepAspectRatio, Qt.SmoothTransformation + ) + self._image_label.setPixmap(scaled) + + # Show info + size = p.stat().st_size + size_str = self._format_size(size) + self._info_label.setText( + f"{p.name}\n{pixmap.width()}x{pixmap.height()} | {size_str}" + ) + self._title.setText("Preview") + + def clear_preview(self): + self._current_path = "" + self._image_label.clear() + self._image_label.setText("No preview") + self._info_label.setText("") + + def resizeEvent(self, event): + super().resizeEvent(event) + # Re-render if we have a current image + if self._current_path: + path = self._current_path + self._current_path = "" + self.show_preview(path) + + @staticmethod + def _format_size(size_bytes: int) -> str: + if size_bytes == 0: + return "0 B" + units = ["B", "KB", "MB", "GB"] + i = 0 + size = float(size_bytes) + while size >= 1024 and i < len(units) - 1: + size /= 1024 + i += 1 + return f"{size:.1f} {units[i]}" if i > 0 else f"{int(size)} B" diff --git a/czkawka_pyside6/app/progress_widget.py b/czkawka_pyside6/app/progress_widget.py new file mode 100644 index 000000000..185e0790d --- /dev/null +++ b/czkawka_pyside6/app/progress_widget.py @@ -0,0 +1,325 @@ +import json +import time +from pathlib import Path + +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QProgressBar, QLabel, QHBoxLayout +) +from PySide6.QtCore import Qt, QTimer + +from .models import ActiveTab, ScanProgress + + +class ProgressWidget(QWidget): + """Two-bar progress widget matching Slint/Krokiet feature parity. + + Shows: + - Current stage progress bar (entries or bytes within one stage) + - Overall progress bar (across all stages) + - Stage name with counts + - Elapsed time + - Phase step indicators + """ + + # File where we persist the last file-collection count per directory set, + # so we can estimate the collection stage percentage on the next scan. + _ESTIMATE_FILE = Path.home() / ".config" / "czkawka_pyside6" / "scan_estimates.json" + + _BAR_STYLE = """ + QProgressBar {{ + border: 1px solid #555; border-radius: 3px; + text-align: center; background-color: #2a2a2a; + font-size: 10px; color: #ccc; + }} + QProgressBar::chunk {{ + background: qlineargradient(x1:0, y1:0, x2:1, y2:0, + stop:0 {c1}, stop:1 {c2}); + border-radius: 2px; + }} + """ + + def __init__(self, parent=None): + super().__init__(parent) + self.setVisible(False) + self._start_time = 0.0 + self._active_tab = ActiveTab.DUPLICATE_FILES + self._last_collection_count = 0 # Files found during collection phase + self._estimates: dict[str, int] = {} + self._load_estimates() + self._setup_ui() + + self._timer = QTimer(self) + self._timer.setInterval(500) + self._timer.timeout.connect(self._update_elapsed) + + # ── UI setup ────────────────────────────────────────────── + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 6, 8, 6) + layout.setSpacing(3) + + # Row 1: stage label + elapsed + row1 = QHBoxLayout() + self._stage_label = QLabel("Initializing...") + self._stage_label.setStyleSheet("font-weight: bold; font-size: 12px;") + row1.addWidget(self._stage_label) + row1.addStretch() + self._elapsed_label = QLabel("") + self._elapsed_label.setStyleSheet("color: #888; font-size: 11px;") + row1.addWidget(self._elapsed_label) + layout.addLayout(row1) + + # Row 2: current stage bar "Current stage" NN% + row2 = QHBoxLayout() + row2.setSpacing(6) + lbl2 = QLabel("Current") + lbl2.setStyleSheet("color: #aaa; font-size: 10px;") + lbl2.setFixedWidth(48) + row2.addWidget(lbl2) + self._stage_bar = QProgressBar() + self._stage_bar.setFixedHeight(14) + self._stage_bar.setTextVisible(False) + self._stage_bar.setStyleSheet( + self._BAR_STYLE.format(c1="#1b4332", c2="#2d6a4f") + ) + row2.addWidget(self._stage_bar) + self._stage_pct = QLabel("") + self._stage_pct.setFixedWidth(40) + self._stage_pct.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self._stage_pct.setStyleSheet("color: #aaa; font-size: 10px;") + row2.addWidget(self._stage_pct) + layout.addLayout(row2) + + # Row 3: overall bar "Overall" NN% + row3 = QHBoxLayout() + row3.setSpacing(6) + lbl3 = QLabel("Overall") + lbl3.setStyleSheet("color: #aaa; font-size: 10px;") + lbl3.setFixedWidth(48) + row3.addWidget(lbl3) + self._overall_bar = QProgressBar() + self._overall_bar.setFixedHeight(14) + self._overall_bar.setTextVisible(False) + self._overall_bar.setStyleSheet( + self._BAR_STYLE.format(c1="#2d6a4f", c2="#40916c") + ) + row3.addWidget(self._overall_bar) + self._overall_pct = QLabel("") + self._overall_pct.setFixedWidth(40) + self._overall_pct.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self._overall_pct.setStyleSheet("color: #aaa; font-size: 10px;") + row3.addWidget(self._overall_pct) + layout.addLayout(row3) + + # Row 4: detail counts + row4 = QHBoxLayout() + self._detail_label = QLabel("") + self._detail_label.setStyleSheet("color: #888; font-size: 10px;") + row4.addWidget(self._detail_label) + row4.addStretch() + self._size_label = QLabel("") + self._size_label.setStyleSheet("color: #888; font-size: 10px;") + self._size_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + row4.addWidget(self._size_label) + layout.addLayout(row4) + + # Row 5: step indicators + self._steps_label = QLabel("") + self._steps_label.setStyleSheet("color: #666; font-size: 9px;") + self._steps_label.setAlignment(Qt.AlignCenter) + self._steps_label.setWordWrap(True) + layout.addWidget(self._steps_label) + + # ── Public API ──────────────────────────────────────────── + + def start(self, tab: ActiveTab = None): + if tab is not None: + self._active_tab = tab + self._start_time = time.monotonic() + self._last_collection_count = 0 + self.setVisible(True) + + for bar in (self._stage_bar, self._overall_bar): + bar.setMaximum(0) # indeterminate + bar.setValue(0) + self._stage_pct.setText("") + self._overall_pct.setText("") + self._stage_label.setText("Starting scan...") + self._detail_label.setText("") + self._size_label.setText("") + self._elapsed_label.setText("0s") + self._steps_label.setText("") + self._timer.start() + + def stop(self): + self._timer.stop() + elapsed = time.monotonic() - self._start_time if self._start_time else 0 + self._elapsed_label.setText(f"Completed in {self._format_time(elapsed)}") + + for bar, lbl in ((self._stage_bar, self._stage_pct), + (self._overall_bar, self._overall_pct)): + bar.setMaximum(100) + bar.setValue(100) + lbl.setText("100%") + + self._stage_label.setText("Scan complete") + self._steps_label.setText("") + + # Save collection count for next-scan estimation + if self._last_collection_count > 0: + self._save_estimate(self._last_collection_count) + + QTimer.singleShot(3000, self._auto_hide) + + def update_progress(self, progress: ScanProgress): + """Main update method called by the scan runner.""" + stage_name = progress.stage_name or progress.step_name or "" + idx = progress.current_stage_idx + max_idx = progress.max_stage_idx + checked = progress.entries_checked + to_check = progress.entries_to_check + b_checked = progress.bytes_checked + b_to_check = progress.bytes_to_check + + # ── Stage label ── + self._stage_label.setText(stage_name) + + # ── Update step indicators using stage index ── + if max_idx > 0: + self._update_steps_from_index(idx, max_idx) + + # ── Current-stage bar ── + is_collecting = (idx == 0 and to_check == 0) + + if is_collecting: + # Collection phase: use estimate from previous scan + self._last_collection_count = max(self._last_collection_count, checked) + estimate = self._get_estimate() + if estimate > 0 and checked > 0: + pct = min(99, int(checked * 100 / estimate)) + self._stage_bar.setMaximum(100) + self._stage_bar.setValue(pct) + self._stage_pct.setText(f"~{pct}%") + self._detail_label.setText(f"{checked:,} / ~{estimate:,} files") + else: + self._stage_bar.setMaximum(0) # indeterminate + self._stage_pct.setText("") + self._detail_label.setText(f"{checked:,} files" if checked else "") + + elif to_check > 0: + # Normal stage with known total + if b_to_check > 0: + # Byte-based progress (hashing) + pct = min(99, int(b_checked * 100 / b_to_check)) + self._stage_bar.setMaximum(100) + self._stage_bar.setValue(pct) + self._stage_pct.setText(f"{pct}%") + self._detail_label.setText(f"{checked:,} / {to_check:,}") + self._size_label.setText( + f"{self._format_size(b_checked)} / {self._format_size(b_to_check)}" + ) + else: + # Entry-count based progress + pct = min(99, int(checked * 100 / to_check)) + self._stage_bar.setMaximum(100) + self._stage_bar.setValue(pct) + self._stage_pct.setText(f"{pct}%") + self._detail_label.setText(f"{checked:,} / {to_check:,}") + self._size_label.setText("") + + else: + # Cache loading/saving or unknown total + self._stage_bar.setMaximum(0) # indeterminate spinner + self._stage_pct.setText("") + self._detail_label.setText("") + self._size_label.setText("") + + # ── Overall bar ── + if max_idx > 0: + if to_check > 0: + stage_frac = (b_checked / b_to_check) if b_to_check > 0 else (checked / to_check) + elif is_collecting and self._get_estimate() > 0 and checked > 0: + stage_frac = min(0.99, checked / self._get_estimate()) + else: + stage_frac = 0 + overall = (idx + min(stage_frac, 0.99)) / (max_idx + 1) + overall_pct = min(99, int(overall * 100)) + self._overall_bar.setMaximum(100) + self._overall_bar.setValue(overall_pct) + self._overall_pct.setText(f"{overall_pct}%") + else: + self._overall_bar.setMaximum(0) + self._overall_pct.setText("") + + # ── Collection estimate persistence ─────────────────────── + + def _get_estimate_key(self) -> str: + """Key for the estimate cache based on active tab.""" + return self._active_tab.name + + def _get_estimate(self) -> int: + return self._estimates.get(self._get_estimate_key(), 0) + + def _save_estimate(self, count: int): + self._estimates[self._get_estimate_key()] = count + try: + self._ESTIMATE_FILE.parent.mkdir(parents=True, exist_ok=True) + self._ESTIMATE_FILE.write_text(json.dumps(self._estimates)) + except OSError: + pass + + def _load_estimates(self): + try: + if self._ESTIMATE_FILE.exists(): + self._estimates = json.loads(self._ESTIMATE_FILE.read_text()) + except (json.JSONDecodeError, OSError): + self._estimates = {} + + # ── Step indicator ──────────────────────────────────────── + + def _update_steps_from_index(self, current_idx: int, max_idx: int): + """Build step display directly from stage index.""" + total_stages = max_idx + 1 + parts = [] + for i in range(total_stages): + if i < current_idx: + parts.append(f"[{i+1} done]") + elif i == current_idx: + parts.append(f"[{i+1} >>]") + else: + parts.append(f"[{i+1}]") + self._steps_label.setText(" ".join(parts)) + + # ── Internals ───────────────────────────────────────────── + + def _auto_hide(self): + self.setVisible(False) + + def _update_elapsed(self): + elapsed = time.monotonic() - self._start_time + self._elapsed_label.setText(self._format_time(elapsed)) + + @staticmethod + def _format_time(seconds: float) -> str: + if seconds < 60: + return f"{int(seconds)}s" + minutes = int(seconds // 60) + secs = int(seconds % 60) + if minutes < 60: + return f"{minutes}m {secs}s" + hours = minutes // 60 + mins = minutes % 60 + return f"{hours}h {mins}m" + + @staticmethod + def _format_size(size_bytes: int) -> str: + if size_bytes == 0: + return "0 B" + units = ["B", "KB", "MB", "GB", "TB"] + i = 0 + size = float(size_bytes) + while size >= 1024 and i < len(units) - 1: + size /= 1024 + i += 1 + return f"{size:.1f} {units[i]}" if i > 0 else f"{int(size)} B" diff --git a/czkawka_pyside6/app/results_view.py b/czkawka_pyside6/app/results_view.py new file mode 100644 index 000000000..fc1aca09c --- /dev/null +++ b/czkawka_pyside6/app/results_view.py @@ -0,0 +1,280 @@ +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QTreeWidget, QTreeWidgetItem, QHeaderView, + QAbstractItemView, QMenu, QLabel, QHBoxLayout +) +from PySide6.QtCore import Signal, Qt +from PySide6.QtGui import QColor, QBrush, QFont, QAction + +from .models import ( + ActiveTab, ResultEntry, TAB_COLUMNS, GROUPED_TABS, SelectMode +) + + +class ResultsView(QWidget): + """Results display with tree view for grouped results and table for flat results.""" + + selection_changed = Signal(int) # number of selected items + item_activated = Signal(object) # ResultEntry + context_menu_requested = Signal(object, object) # QPoint, ResultEntry + + # Colors + HEADER_BG = QColor(60, 60, 80) + HEADER_FG = QColor(220, 220, 255) + SELECTED_BG = QColor(40, 80, 40) + + def __init__(self, parent=None): + super().__init__(parent) + self._active_tab = ActiveTab.DUPLICATE_FILES + self._results: list[ResultEntry] = [] + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + # Summary bar + summary_layout = QHBoxLayout() + self._summary_label = QLabel("No results") + self._summary_label.setStyleSheet("padding: 4px;") + summary_layout.addWidget(self._summary_label) + self._selection_label = QLabel("") + self._selection_label.setAlignment(Qt.AlignRight) + self._selection_label.setStyleSheet("padding: 4px; color: #aaa;") + summary_layout.addWidget(self._selection_label) + layout.addLayout(summary_layout) + + # Tree widget for results + self._tree = QTreeWidget() + self._tree.setSelectionMode(QAbstractItemView.ExtendedSelection) + self._tree.setRootIsDecorated(False) + self._tree.setAlternatingRowColors(True) + self._tree.setContextMenuPolicy(Qt.CustomContextMenu) + self._tree.customContextMenuRequested.connect(self._on_context_menu) + self._tree.itemChanged.connect(self._on_item_changed) + self._tree.itemDoubleClicked.connect(self._on_item_double_clicked) + layout.addWidget(self._tree) + + def set_active_tab(self, tab: ActiveTab): + self._active_tab = tab + columns = TAB_COLUMNS.get(tab, ["Selection", "File Name", "Path"]) + self._tree.setHeaderLabels(columns) + header = self._tree.header() + for i in range(len(columns)): + if columns[i] == "Path": + header.setSectionResizeMode(i, QHeaderView.Stretch) + else: + header.setSectionResizeMode(i, QHeaderView.ResizeToContents) + + def set_results(self, results: list[ResultEntry]): + self._results = results + self._tree.blockSignals(True) + self._tree.clear() + + columns = TAB_COLUMNS.get(self._active_tab, ["Selection", "File Name", "Path"]) + + for entry in results: + if entry.header_row: + item = QTreeWidgetItem() + header_text = entry.values.get("__header", "Group") + item.setText(0, header_text) + item.setFirstColumnSpanned(True) + font = QFont() + font.setBold(True) + item.setFont(0, font) + for col in range(len(columns)): + item.setBackground(col, QBrush(self.HEADER_BG)) + item.setForeground(col, QBrush(self.HEADER_FG)) + item.setFlags(item.flags() & ~Qt.ItemIsUserCheckable) + item.setData(0, Qt.UserRole, entry) + self._tree.addTopLevelItem(item) + else: + item = QTreeWidgetItem() + # First column is checkbox (Selection) + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + item.setCheckState(0, Qt.Checked if entry.checked else Qt.Unchecked) + + for col_idx, col_name in enumerate(columns): + if col_idx == 0: # Selection column + continue + value = entry.values.get(col_name, "") + item.setText(col_idx, str(value)) + + item.setData(0, Qt.UserRole, entry) + self._tree.addTopLevelItem(item) + + self._tree.blockSignals(False) + self._update_summary() + + def _on_item_changed(self, item, column): + if column == 0: + entry = item.data(0, Qt.UserRole) + if entry and not entry.header_row: + entry.checked = item.checkState(0) == Qt.Checked + self._update_selection_count() + + def _on_item_double_clicked(self, item, column): + entry = item.data(0, Qt.UserRole) + if entry and not entry.header_row: + self.item_activated.emit(entry) + + def _on_context_menu(self, pos): + item = self._tree.itemAt(pos) + if item: + entry = item.data(0, Qt.UserRole) + if entry and not entry.header_row: + menu = QMenu(self) + open_action = QAction("Open File", self) + open_action.triggered.connect(lambda: self._open_file(entry)) + menu.addAction(open_action) + + open_dir_action = QAction("Open Containing Folder", self) + open_dir_action.triggered.connect(lambda: self._open_folder(entry)) + menu.addAction(open_dir_action) + + menu.addSeparator() + + select_action = QAction("Select", self) + select_action.triggered.connect(lambda: self._set_check(item, True)) + menu.addAction(select_action) + + deselect_action = QAction("Deselect", self) + deselect_action.triggered.connect(lambda: self._set_check(item, False)) + menu.addAction(deselect_action) + + menu.exec_(self._tree.viewport().mapToGlobal(pos)) + + def _open_file(self, entry: ResultEntry): + import subprocess, sys + path = entry.values.get("__full_path", "") + if path: + if sys.platform == "linux": + subprocess.Popen(["xdg-open", path]) + elif sys.platform == "darwin": + subprocess.Popen(["open", path]) + else: + subprocess.Popen(["start", path], shell=True) + + def _open_folder(self, entry: ResultEntry): + import subprocess, sys + from pathlib import Path + path = entry.values.get("__full_path", "") + if path: + folder = str(Path(path).parent) + if sys.platform == "linux": + subprocess.Popen(["xdg-open", folder]) + elif sys.platform == "darwin": + subprocess.Popen(["open", folder]) + else: + subprocess.Popen(["explorer", folder], shell=True) + + def _set_check(self, item, checked): + item.setCheckState(0, Qt.Checked if checked else Qt.Unchecked) + + def _update_summary(self): + total = sum(1 for r in self._results if not r.header_row) + groups = sum(1 for r in self._results if r.header_row) + if self._active_tab in GROUPED_TABS and groups > 0: + self._summary_label.setText(f"Found {total} files in {groups} groups") + elif total > 0: + self._summary_label.setText(f"Found {total} entries") + else: + self._summary_label.setText("No results") + self._update_selection_count() + + def _update_selection_count(self): + selected = sum(1 for r in self._results if r.checked and not r.header_row) + total = sum(1 for r in self._results if not r.header_row) + if selected > 0: + self._selection_label.setText(f"Selected: {selected}/{total}") + else: + self._selection_label.setText("") + self.selection_changed.emit(selected) + + def apply_selection(self, mode: SelectMode): + self._tree.blockSignals(True) + + if mode == SelectMode.SELECT_ALL: + self._select_all(True) + elif mode == SelectMode.UNSELECT_ALL: + self._select_all(False) + elif mode == SelectMode.INVERT_SELECTION: + self._invert_selection() + elif mode in (SelectMode.SELECT_BIGGEST_SIZE, SelectMode.SELECT_SMALLEST_SIZE, + SelectMode.SELECT_NEWEST, SelectMode.SELECT_OLDEST, + SelectMode.SELECT_BIGGEST_RESOLUTION, SelectMode.SELECT_SMALLEST_RESOLUTION, + SelectMode.SELECT_SHORTEST_PATH, SelectMode.SELECT_LONGEST_PATH): + self._select_by_group_criteria(mode) + + self._tree.blockSignals(False) + self._update_selection_count() + + def _select_all(self, checked: bool): + for i in range(self._tree.topLevelItemCount()): + item = self._tree.topLevelItem(i) + entry = item.data(0, Qt.UserRole) + if entry and not entry.header_row: + entry.checked = checked + item.setCheckState(0, Qt.Checked if checked else Qt.Unchecked) + + def _invert_selection(self): + for i in range(self._tree.topLevelItemCount()): + item = self._tree.topLevelItem(i) + entry = item.data(0, Qt.UserRole) + if entry and not entry.header_row: + entry.checked = not entry.checked + item.setCheckState(0, Qt.Checked if entry.checked else Qt.Unchecked) + + def _select_by_group_criteria(self, mode: SelectMode): + # First unselect all + self._select_all(False) + + if self._active_tab not in GROUPED_TABS: + return + + # Group entries by group_id + groups: dict[int, list[tuple[int, ResultEntry]]] = {} + for i in range(self._tree.topLevelItemCount()): + item = self._tree.topLevelItem(i) + entry = item.data(0, Qt.UserRole) + if entry and not entry.header_row: + groups.setdefault(entry.group_id, []).append((i, entry)) + + for group_id, items in groups.items(): + if len(items) <= 1: + continue + + best_idx = 0 + if mode == SelectMode.SELECT_BIGGEST_SIZE: + best_idx = max(range(len(items)), key=lambda j: items[j][1].values.get("__size_bytes", 0)) + elif mode == SelectMode.SELECT_SMALLEST_SIZE: + best_idx = min(range(len(items)), key=lambda j: items[j][1].values.get("__size_bytes", 0)) + elif mode == SelectMode.SELECT_NEWEST: + best_idx = max(range(len(items)), key=lambda j: items[j][1].values.get("__modified_date_ts", 0)) + elif mode == SelectMode.SELECT_OLDEST: + best_idx = min(range(len(items)), key=lambda j: items[j][1].values.get("__modified_date_ts", 0)) + elif mode == SelectMode.SELECT_SHORTEST_PATH: + best_idx = min(range(len(items)), key=lambda j: len(items[j][1].values.get("__full_path", ""))) + elif mode == SelectMode.SELECT_LONGEST_PATH: + best_idx = max(range(len(items)), key=lambda j: len(items[j][1].values.get("__full_path", ""))) + + # Select all EXCEPT the best (the one to keep) + for j, (tree_idx, entry) in enumerate(items): + if j != best_idx: + entry.checked = True + self._tree.topLevelItem(tree_idx).setCheckState(0, Qt.Checked) + + def sort_by_column(self, column: int, ascending: bool = True): + order = Qt.AscendingOrder if ascending else Qt.DescendingOrder + self._tree.sortItems(column, order) + + def get_checked_entries(self) -> list[ResultEntry]: + return [r for r in self._results if r.checked and not r.header_row] + + def get_all_entries(self) -> list[ResultEntry]: + return [r for r in self._results if not r.header_row] + + def clear(self): + self._results = [] + self._tree.clear() + self._summary_label.setText("No results") + self._selection_label.setText("") diff --git a/czkawka_pyside6/app/settings_panel.py b/czkawka_pyside6/app/settings_panel.py new file mode 100644 index 000000000..1ab5c2159 --- /dev/null +++ b/czkawka_pyside6/app/settings_panel.py @@ -0,0 +1,261 @@ +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QCheckBox, + QLineEdit, QSpinBox, QGroupBox, QFormLayout, QScrollArea, + QPushButton, QListWidget, QFileDialog, QDoubleSpinBox, + QTabWidget, QSizePolicy +) +from PySide6.QtCore import Qt, Signal + +from .models import AppSettings + + +class SettingsPanel(QWidget): + """Global application settings panel.""" + settings_changed = Signal() + close_requested = Signal() + + def __init__(self, settings: AppSettings, parent=None): + super().__init__(parent) + self._settings = settings + self._setup_ui() + + def _setup_ui(self): + main_layout = QVBoxLayout(self) + + # Header + header = QHBoxLayout() + title = QLabel("Settings") + title.setStyleSheet("font-weight: bold; font-size: 16px; padding: 4px;") + header.addWidget(title) + header.addStretch() + close_btn = QPushButton("Close") + close_btn.clicked.connect(self.close_requested.emit) + header.addWidget(close_btn) + main_layout.addLayout(header) + + # Tabs + tabs = QTabWidget() + + # General tab + tabs.addTab(self._create_general_tab(), "General") + # Directories tab + tabs.addTab(self._create_directories_tab(), "Directories") + # Filters tab + tabs.addTab(self._create_filters_tab(), "Filters") + # Preview tab + tabs.addTab(self._create_preview_tab(), "Preview") + + main_layout.addWidget(tabs) + + def _create_general_tab(self) -> QWidget: + scroll = QScrollArea() + scroll.setWidgetResizable(True) + widget = QWidget() + layout = QFormLayout(widget) + + # CLI path + cli_layout = QHBoxLayout() + self._cli_path = QLineEdit(self._settings.czkawka_cli_path) + self._cli_path.textChanged.connect( + lambda t: setattr(self._settings, 'czkawka_cli_path', t) + ) + cli_layout.addWidget(self._cli_path) + browse_btn = QPushButton("Browse") + browse_btn.clicked.connect(self._browse_cli) + cli_layout.addWidget(browse_btn) + layout.addRow("czkawka_cli Path:", cli_layout) + + # Thread number + self._threads = QSpinBox() + self._threads.setRange(0, 64) + self._threads.setValue(self._settings.thread_number) + self._threads.setSpecialValueText("Auto (all cores)") + self._threads.valueChanged.connect( + lambda v: setattr(self._settings, 'thread_number', v) + ) + layout.addRow("Thread Count:", self._threads) + + # Recursive search + recursive = QCheckBox("Recursive search") + recursive.setChecked(self._settings.recursive_search) + recursive.toggled.connect( + lambda v: setattr(self._settings, 'recursive_search', v) + ) + layout.addRow(recursive) + + # Use cache + cache = QCheckBox("Use cache for faster rescans") + cache.setChecked(self._settings.use_cache) + cache.toggled.connect( + lambda v: setattr(self._settings, 'use_cache', v) + ) + layout.addRow(cache) + + # Move to trash + trash = QCheckBox("Move to trash instead of permanent delete") + trash.setChecked(self._settings.move_to_trash) + trash.toggled.connect( + lambda v: setattr(self._settings, 'move_to_trash', v) + ) + layout.addRow(trash) + + # Hide hard links + hardlinks = QCheckBox("Hide hard links") + hardlinks.setChecked(self._settings.hide_hard_links) + hardlinks.toggled.connect( + lambda v: setattr(self._settings, 'hide_hard_links', v) + ) + layout.addRow(hardlinks) + + # Save as JSON + save_json = QCheckBox("Save results as JSON (instead of text)") + save_json.setChecked(self._settings.save_as_json) + save_json.toggled.connect( + lambda v: setattr(self._settings, 'save_as_json', v) + ) + layout.addRow(save_json) + + scroll.setWidget(widget) + return scroll + + def _create_directories_tab(self) -> QWidget: + widget = QWidget() + layout = QVBoxLayout(widget) + + # Included paths + inc_group = QGroupBox("Included Directories") + inc_layout = QVBoxLayout(inc_group) + + self._inc_list = QListWidget() + for path in self._settings.included_paths: + self._inc_list.addItem(path) + inc_layout.addWidget(self._inc_list) + + inc_btns = QHBoxLayout() + add_inc = QPushButton("Add") + add_inc.clicked.connect(self._add_included) + inc_btns.addWidget(add_inc) + rem_inc = QPushButton("Remove") + rem_inc.clicked.connect(self._remove_included) + inc_btns.addWidget(rem_inc) + inc_btns.addStretch() + inc_layout.addLayout(inc_btns) + layout.addWidget(inc_group) + + # Excluded paths + exc_group = QGroupBox("Excluded Directories") + exc_layout = QVBoxLayout(exc_group) + + self._exc_list = QListWidget() + for path in self._settings.excluded_paths: + self._exc_list.addItem(path) + exc_layout.addWidget(self._exc_list) + + exc_btns = QHBoxLayout() + add_exc = QPushButton("Add") + add_exc.clicked.connect(self._add_excluded) + exc_btns.addWidget(add_exc) + rem_exc = QPushButton("Remove") + rem_exc.clicked.connect(self._remove_excluded) + exc_btns.addWidget(rem_exc) + exc_btns.addStretch() + exc_layout.addLayout(exc_btns) + layout.addWidget(exc_group) + + return widget + + def _create_filters_tab(self) -> QWidget: + widget = QWidget() + layout = QFormLayout(widget) + + # Excluded items + self._excluded_items = QLineEdit(self._settings.excluded_items) + self._excluded_items.setPlaceholderText("Wildcard patterns, comma-separated (e.g. *.tmp,cache_*)") + self._excluded_items.textChanged.connect( + lambda t: setattr(self._settings, 'excluded_items', t) + ) + layout.addRow("Excluded Items:", self._excluded_items) + + # Allowed extensions + self._allowed_ext = QLineEdit(self._settings.allowed_extensions) + self._allowed_ext.setPlaceholderText("e.g. jpg,png,gif") + self._allowed_ext.textChanged.connect( + lambda t: setattr(self._settings, 'allowed_extensions', t) + ) + layout.addRow("Allowed Extensions:", self._allowed_ext) + + # Excluded extensions + self._excluded_ext = QLineEdit(self._settings.excluded_extensions) + self._excluded_ext.setPlaceholderText("e.g. log,tmp") + self._excluded_ext.textChanged.connect( + lambda t: setattr(self._settings, 'excluded_extensions', t) + ) + layout.addRow("Excluded Extensions:", self._excluded_ext) + + # Min file size + self._min_size = QLineEdit(self._settings.minimum_file_size) + self._min_size.setPlaceholderText("In bytes (e.g. 1024)") + self._min_size.textChanged.connect( + lambda t: setattr(self._settings, 'minimum_file_size', t) + ) + layout.addRow("Minimum File Size:", self._min_size) + + # Max file size + self._max_size = QLineEdit(self._settings.maximum_file_size) + self._max_size.setPlaceholderText("In bytes (leave empty for no limit)") + self._max_size.textChanged.connect( + lambda t: setattr(self._settings, 'maximum_file_size', t) + ) + layout.addRow("Maximum File Size:", self._max_size) + + return widget + + def _create_preview_tab(self) -> QWidget: + widget = QWidget() + layout = QVBoxLayout(widget) + + preview = QCheckBox("Show image preview") + preview.setChecked(self._settings.show_image_preview) + preview.toggled.connect( + lambda v: setattr(self._settings, 'show_image_preview', v) + ) + layout.addWidget(preview) + + layout.addStretch() + return widget + + def _browse_cli(self): + path, _ = QFileDialog.getOpenFileName( + self, "Select czkawka_cli binary", "", + "Executables (*);;All Files (*)" + ) + if path: + self._cli_path.setText(path) + + def _add_included(self): + path = QFileDialog.getExistingDirectory(self, "Select Directory to Include") + if path and path not in self._settings.included_paths: + self._settings.included_paths.append(path) + self._inc_list.addItem(path) + self.settings_changed.emit() + + def _remove_included(self): + row = self._inc_list.currentRow() + if row >= 0: + self._inc_list.takeItem(row) + self._settings.included_paths.pop(row) + self.settings_changed.emit() + + def _add_excluded(self): + path = QFileDialog.getExistingDirectory(self, "Select Directory to Exclude") + if path and path not in self._settings.excluded_paths: + self._settings.excluded_paths.append(path) + self._exc_list.addItem(path) + self.settings_changed.emit() + + def _remove_excluded(self): + row = self._exc_list.currentRow() + if row >= 0: + self._exc_list.takeItem(row) + self._settings.excluded_paths.pop(row) + self.settings_changed.emit() diff --git a/czkawka_pyside6/app/state.py b/czkawka_pyside6/app/state.py new file mode 100644 index 000000000..2737323d0 --- /dev/null +++ b/czkawka_pyside6/app/state.py @@ -0,0 +1,122 @@ +import json +from pathlib import Path +from PySide6.QtCore import QObject, Signal +from .models import ( + ActiveTab, AppSettings, ToolSettings, ResultEntry, ScanProgress +) + + +class AppState(QObject): + """Central application state manager.""" + + # Signals + tab_changed = Signal(object) # ActiveTab + scan_started = Signal() + scan_finished = Signal() + scan_progress_updated = Signal(object) # ScanProgress + results_updated = Signal() + settings_changed = Signal() + included_paths_changed = Signal() + excluded_paths_changed = Signal() + preview_image_changed = Signal(str) + + def __init__(self): + super().__init__() + self.active_tab = ActiveTab.DUPLICATE_FILES + self.scanning = False + self.processing = False + self.stop_requested = False + self.settings = AppSettings() + self.tool_settings = ToolSettings() + self.results: dict[ActiveTab, list[ResultEntry]] = {} + self.progress = ScanProgress() + self.info_text = "" + self.preview_image_path = "" + self._config_path = Path.home() / ".config" / "czkawka_pyside6" + self._config_path.mkdir(parents=True, exist_ok=True) + self.load_settings() + + def set_active_tab(self, tab: ActiveTab): + if tab != self.active_tab: + self.active_tab = tab + self.tab_changed.emit(tab) + + def set_scanning(self, scanning: bool): + self.scanning = scanning + if scanning: + self.stop_requested = False + self.scan_started.emit() + else: + self.scan_finished.emit() + + def request_stop(self): + self.stop_requested = True + + def update_progress(self, progress: ScanProgress): + self.progress = progress + self.scan_progress_updated.emit(progress) + + def set_results(self, tab: ActiveTab, results: list[ResultEntry]): + self.results[tab] = results + self.results_updated.emit() + + def get_results(self, tab: ActiveTab = None) -> list[ResultEntry]: + if tab is None: + tab = self.active_tab + return self.results.get(tab, []) + + def get_checked_results(self, tab: ActiveTab = None) -> list[ResultEntry]: + return [r for r in self.get_results(tab) if r.checked and not r.header_row] + + def get_selected_count(self, tab: ActiveTab = None) -> int: + return len(self.get_checked_results(tab)) + + def save_settings(self): + config_file = self._config_path / "settings.json" + data = { + "included_paths": self.settings.included_paths, + "excluded_paths": self.settings.excluded_paths, + "excluded_items": self.settings.excluded_items, + "allowed_extensions": self.settings.allowed_extensions, + "excluded_extensions": self.settings.excluded_extensions, + "minimum_file_size": self.settings.minimum_file_size, + "maximum_file_size": self.settings.maximum_file_size, + "recursive_search": self.settings.recursive_search, + "use_cache": self.settings.use_cache, + "save_as_json": self.settings.save_as_json, + "move_to_trash": self.settings.move_to_trash, + "hide_hard_links": self.settings.hide_hard_links, + "thread_number": self.settings.thread_number, + "dark_theme": self.settings.dark_theme, + "show_image_preview": self.settings.show_image_preview, + "czkawka_cli_path": self.settings.czkawka_cli_path, + } + try: + config_file.write_text(json.dumps(data, indent=2)) + except OSError: + pass + + def load_settings(self): + config_file = self._config_path / "settings.json" + if config_file.exists(): + try: + data = json.loads(config_file.read_text()) + s = self.settings + s.included_paths = data.get("included_paths", s.included_paths) + s.excluded_paths = data.get("excluded_paths", s.excluded_paths) + s.excluded_items = data.get("excluded_items", s.excluded_items) + s.allowed_extensions = data.get("allowed_extensions", s.allowed_extensions) + s.excluded_extensions = data.get("excluded_extensions", s.excluded_extensions) + s.minimum_file_size = data.get("minimum_file_size", s.minimum_file_size) + s.maximum_file_size = data.get("maximum_file_size", s.maximum_file_size) + s.recursive_search = data.get("recursive_search", s.recursive_search) + s.use_cache = data.get("use_cache", s.use_cache) + s.save_as_json = data.get("save_as_json", s.save_as_json) + s.move_to_trash = data.get("move_to_trash", s.move_to_trash) + s.hide_hard_links = data.get("hide_hard_links", s.hide_hard_links) + s.thread_number = data.get("thread_number", s.thread_number) + s.dark_theme = data.get("dark_theme", s.dark_theme) + s.show_image_preview = data.get("show_image_preview", s.show_image_preview) + s.czkawka_cli_path = data.get("czkawka_cli_path", s.czkawka_cli_path) + except (json.JSONDecodeError, OSError): + pass diff --git a/czkawka_pyside6/app/tool_settings.py b/czkawka_pyside6/app/tool_settings.py new file mode 100644 index 000000000..a3c5d9e44 --- /dev/null +++ b/czkawka_pyside6/app/tool_settings.py @@ -0,0 +1,504 @@ +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QLabel, QComboBox, QCheckBox, + QSlider, QLineEdit, QGroupBox, QFormLayout, QScrollArea, + QHBoxLayout, QSpinBox, QPushButton, QSizePolicy +) +from PySide6.QtCore import Qt, Signal + +from .models import ( + ActiveTab, ToolSettings, CheckingMethod, HashType, + ImageHashAlg, ImageFilter, MusicSearchMethod, + VideoCropDetect, VideoCropMechanism, VideoCodec, +) + + +class ToolSettingsPanel(QWidget): + """Per-tool settings panel shown on the right side.""" + settings_changed = Signal() + + def __init__(self, tool_settings: ToolSettings, parent=None): + super().__init__(parent) + self._ts = tool_settings + self._active_tab = ActiveTab.DUPLICATE_FILES + self.setMinimumWidth(250) + self.setMaximumWidth(350) + self._panels: dict[ActiveTab, QWidget] = {} + self._setup_ui() + + def _setup_ui(self): + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(4, 4, 4, 4) + + title = QLabel("Tool Settings") + title.setStyleSheet("font-weight: bold; font-size: 13px; padding: 4px;") + main_layout.addWidget(title) + + self._scroll = QScrollArea() + self._scroll.setWidgetResizable(True) + self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + + self._container = QWidget() + self._container_layout = QVBoxLayout(self._container) + self._container_layout.setContentsMargins(0, 0, 0, 0) + + # Create all panel widgets + self._panels[ActiveTab.DUPLICATE_FILES] = self._create_duplicate_panel() + self._panels[ActiveTab.SIMILAR_IMAGES] = self._create_similar_images_panel() + self._panels[ActiveTab.SIMILAR_VIDEOS] = self._create_similar_videos_panel() + self._panels[ActiveTab.SIMILAR_MUSIC] = self._create_similar_music_panel() + self._panels[ActiveTab.BIG_FILES] = self._create_big_files_panel() + self._panels[ActiveTab.BROKEN_FILES] = self._create_broken_files_panel() + self._panels[ActiveTab.BAD_NAMES] = self._create_bad_names_panel() + self._panels[ActiveTab.EXIF_REMOVER] = self._create_exif_panel() + self._panels[ActiveTab.VIDEO_OPTIMIZER] = self._create_video_optimizer_panel() + + for panel in self._panels.values(): + self._container_layout.addWidget(panel) + panel.setVisible(False) + + self._container_layout.addStretch() + self._scroll.setWidget(self._container) + main_layout.addWidget(self._scroll) + + def set_active_tab(self, tab: ActiveTab): + self._active_tab = tab + for t, panel in self._panels.items(): + panel.setVisible(t == tab) + + def _create_duplicate_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + # Check method + self._dup_method = QComboBox() + self._dup_method.addItems(["Hash", "Size", "Name", "Size and Name"]) + method_map = {CheckingMethod.HASH: 0, CheckingMethod.SIZE: 1, + CheckingMethod.NAME: 2, CheckingMethod.SIZE_NAME: 3} + self._dup_method.setCurrentIndex(method_map.get(self._ts.dup_check_method, 0)) + self._dup_method.currentIndexChanged.connect(self._on_dup_method_changed) + layout.addRow("Check Method:", self._dup_method) + + # Hash type + self._dup_hash = QComboBox() + self._dup_hash.addItems(["Blake3", "CRC32", "XXH3"]) + hash_map = {HashType.BLAKE3: 0, HashType.CRC32: 1, HashType.XXH3: 2} + self._dup_hash.setCurrentIndex(hash_map.get(self._ts.dup_hash_type, 0)) + self._dup_hash.currentIndexChanged.connect(self._on_dup_hash_changed) + layout.addRow("Hash Type:", self._dup_hash) + + # Case sensitive + self._dup_case = QCheckBox("Case sensitive name comparison") + self._dup_case.setChecked(self._ts.dup_name_case_sensitive) + self._dup_case.toggled.connect(lambda v: setattr(self._ts, 'dup_name_case_sensitive', v)) + layout.addRow(self._dup_case) + + return panel + + def _on_dup_method_changed(self, idx): + methods = [CheckingMethod.HASH, CheckingMethod.SIZE, + CheckingMethod.NAME, CheckingMethod.SIZE_NAME] + self._ts.dup_check_method = methods[idx] + self.settings_changed.emit() + + def _on_dup_hash_changed(self, idx): + hashes = [HashType.BLAKE3, HashType.CRC32, HashType.XXH3] + self._ts.dup_hash_type = hashes[idx] + self.settings_changed.emit() + + def _create_similar_images_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + # Hash size + self._img_hash_size = QComboBox() + self._img_hash_size.addItems(["8", "16", "32", "64"]) + size_map = {8: 0, 16: 1, 32: 2, 64: 3} + self._img_hash_size.setCurrentIndex(size_map.get(self._ts.img_hash_size, 1)) + self._img_hash_size.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'img_hash_size', [8, 16, 32, 64][idx]) + ) + layout.addRow("Hash Size:", self._img_hash_size) + + # Resize algorithm + self._img_filter = QComboBox() + self._img_filter.addItems(["Lanczos3", "Gaussian", "CatmullRom", "Triangle", "Nearest"]) + filters = [ImageFilter.LANCZOS3, ImageFilter.GAUSSIAN, ImageFilter.CATMULL_ROM, + ImageFilter.TRIANGLE, ImageFilter.NEAREST] + filter_idx = filters.index(self._ts.img_filter) if self._ts.img_filter in filters else 4 + self._img_filter.setCurrentIndex(filter_idx) + self._img_filter.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'img_filter', filters[idx]) + ) + layout.addRow("Resize Algorithm:", self._img_filter) + + # Hash type + self._img_hash_alg = QComboBox() + self._img_hash_alg.addItems(["Mean", "Gradient", "BlockHash", "VertGradient", + "DoubleGradient", "Median"]) + algs = [ImageHashAlg.MEAN, ImageHashAlg.GRADIENT, ImageHashAlg.BLOCKHASH, + ImageHashAlg.VERT_GRADIENT, ImageHashAlg.DOUBLE_GRADIENT, ImageHashAlg.MEDIAN] + alg_idx = algs.index(self._ts.img_hash_alg) if self._ts.img_hash_alg in algs else 1 + self._img_hash_alg.setCurrentIndex(alg_idx) + self._img_hash_alg.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'img_hash_alg', algs[idx]) + ) + layout.addRow("Hash Type:", self._img_hash_alg) + + # Ignore same size + self._img_ignore_size = QCheckBox("Ignore same size") + self._img_ignore_size.setChecked(self._ts.img_ignore_same_size) + self._img_ignore_size.toggled.connect( + lambda v: setattr(self._ts, 'img_ignore_same_size', v) + ) + layout.addRow(self._img_ignore_size) + + # Max difference slider + diff_layout = QHBoxLayout() + self._img_diff_slider = QSlider(Qt.Horizontal) + self._img_diff_slider.setRange(0, 40) + self._img_diff_slider.setValue(self._ts.img_max_difference) + self._img_diff_label = QLabel(str(self._ts.img_max_difference)) + self._img_diff_slider.valueChanged.connect(self._on_img_diff_changed) + diff_layout.addWidget(self._img_diff_slider) + diff_layout.addWidget(self._img_diff_label) + layout.addRow("Max Difference:", diff_layout) + + return panel + + def _on_img_diff_changed(self, value): + self._ts.img_max_difference = value + self._img_diff_label.setText(str(value)) + self.settings_changed.emit() + + def _create_similar_videos_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + # Ignore same size + vid_ignore = QCheckBox("Ignore same size") + vid_ignore.setChecked(self._ts.vid_ignore_same_size) + vid_ignore.toggled.connect(lambda v: setattr(self._ts, 'vid_ignore_same_size', v)) + layout.addRow(vid_ignore) + + # Crop detect + self._vid_crop = QComboBox() + self._vid_crop.addItems(["LetterBox", "Motion", "None"]) + crops = [VideoCropDetect.LETTERBOX, VideoCropDetect.MOTION, VideoCropDetect.NONE] + crop_idx = crops.index(self._ts.vid_crop_detect) if self._ts.vid_crop_detect in crops else 0 + self._vid_crop.setCurrentIndex(crop_idx) + self._vid_crop.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'vid_crop_detect', crops[idx]) + ) + layout.addRow("Crop Detect:", self._vid_crop) + + # Max difference + diff_layout = QHBoxLayout() + self._vid_diff_slider = QSlider(Qt.Horizontal) + self._vid_diff_slider.setRange(0, 20) + self._vid_diff_slider.setValue(self._ts.vid_max_difference) + self._vid_diff_label = QLabel(str(self._ts.vid_max_difference)) + self._vid_diff_slider.valueChanged.connect(lambda v: ( + setattr(self._ts, 'vid_max_difference', v), + self._vid_diff_label.setText(str(v)) + )) + diff_layout.addWidget(self._vid_diff_slider) + diff_layout.addWidget(self._vid_diff_label) + layout.addRow("Max Difference:", diff_layout) + + # Skip forward + skip_layout = QHBoxLayout() + self._vid_skip_slider = QSlider(Qt.Horizontal) + self._vid_skip_slider.setRange(1, 360) + self._vid_skip_slider.setValue(self._ts.vid_skip_forward) + self._vid_skip_label = QLabel(str(self._ts.vid_skip_forward)) + self._vid_skip_slider.valueChanged.connect(lambda v: ( + setattr(self._ts, 'vid_skip_forward', v), + self._vid_skip_label.setText(str(v)) + )) + skip_layout.addWidget(self._vid_skip_slider) + skip_layout.addWidget(self._vid_skip_label) + layout.addRow("Skip Forward (s):", skip_layout) + + # Duration + dur_layout = QHBoxLayout() + self._vid_dur_slider = QSlider(Qt.Horizontal) + self._vid_dur_slider.setRange(1, 60) + self._vid_dur_slider.setValue(self._ts.vid_duration) + self._vid_dur_label = QLabel(str(self._ts.vid_duration)) + self._vid_dur_slider.valueChanged.connect(lambda v: ( + setattr(self._ts, 'vid_duration', v), + self._vid_dur_label.setText(str(v)) + )) + dur_layout.addWidget(self._vid_dur_slider) + dur_layout.addWidget(self._vid_dur_label) + layout.addRow("Hash Duration (s):", dur_layout) + + return panel + + def _create_similar_music_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + # Search method + self._music_method = QComboBox() + self._music_method.addItems(["Tags", "Fingerprint"]) + self._music_method.setCurrentIndex( + 0 if self._ts.music_search_method == MusicSearchMethod.TAGS else 1 + ) + self._music_method.currentIndexChanged.connect(self._on_music_method_changed) + layout.addRow("Audio Check Type:", self._music_method) + + # Tags group + self._tags_group = QGroupBox("Tag Matching") + tags_layout = QVBoxLayout(self._tags_group) + + self._music_approx = QCheckBox("Approximate comparison") + self._music_approx.setChecked(self._ts.music_approximate) + self._music_approx.toggled.connect(lambda v: setattr(self._ts, 'music_approximate', v)) + tags_layout.addWidget(self._music_approx) + + for attr, label in [ + ("music_title", "Title"), ("music_artist", "Artist"), + ("music_bitrate", "Bitrate"), ("music_genre", "Genre"), + ("music_year", "Year"), ("music_length", "Length"), + ]: + cb = QCheckBox(label) + cb.setChecked(getattr(self._ts, attr)) + cb.toggled.connect(lambda v, a=attr: setattr(self._ts, a, v)) + tags_layout.addWidget(cb) + + layout.addRow(self._tags_group) + + # Fingerprint group + self._fp_group = QGroupBox("Fingerprint Matching") + fp_layout = QFormLayout(self._fp_group) + + fp_similar = QCheckBox("Compare with similar titles") + fp_similar.setChecked(self._ts.music_compare_fingerprints_similar_titles) + fp_similar.toggled.connect( + lambda v: setattr(self._ts, 'music_compare_fingerprints_similar_titles', v) + ) + fp_layout.addRow(fp_similar) + + diff_layout = QHBoxLayout() + self._music_diff_slider = QSlider(Qt.Horizontal) + self._music_diff_slider.setRange(0, 100) + self._music_diff_slider.setValue(int(self._ts.music_max_difference * 10)) + self._music_diff_label = QLabel(f"{self._ts.music_max_difference:.1f}") + self._music_diff_slider.valueChanged.connect(lambda v: ( + setattr(self._ts, 'music_max_difference', v / 10.0), + self._music_diff_label.setText(f"{v / 10.0:.1f}") + )) + diff_layout.addWidget(self._music_diff_slider) + diff_layout.addWidget(self._music_diff_label) + fp_layout.addRow("Max Difference:", diff_layout) + + layout.addRow(self._fp_group) + self._on_music_method_changed(self._music_method.currentIndex()) + + return panel + + def _on_music_method_changed(self, idx): + if idx == 0: + self._ts.music_search_method = MusicSearchMethod.TAGS + else: + self._ts.music_search_method = MusicSearchMethod.CONTENT + self._tags_group.setVisible(idx == 0) + self._fp_group.setVisible(idx == 1) + self.settings_changed.emit() + + def _create_big_files_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + self._big_mode = QComboBox() + self._big_mode.addItems(["The Biggest", "The Smallest"]) + self._big_mode.setCurrentIndex(0 if self._ts.big_files_mode == "biggest" else 1) + self._big_mode.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'big_files_mode', "biggest" if idx == 0 else "smallest") + ) + layout.addRow("Method:", self._big_mode) + + self._big_count = QSpinBox() + self._big_count.setRange(1, 100000) + self._big_count.setValue(self._ts.big_files_number) + self._big_count.valueChanged.connect( + lambda v: setattr(self._ts, 'big_files_number', v) + ) + layout.addRow("Number of Files:", self._big_count) + + return panel + + def _create_broken_files_panel(self) -> QWidget: + panel = QWidget() + layout = QVBoxLayout(panel) + layout.addWidget(QLabel("File types to check:")) + + for attr, label in [ + ("broken_audio", "Audio"), ("broken_pdf", "PDF"), + ("broken_archive", "Archive"), ("broken_image", "Image"), + ("broken_video", "Video"), + ]: + cb = QCheckBox(label) + cb.setChecked(getattr(self._ts, attr)) + cb.toggled.connect(lambda v, a=attr: setattr(self._ts, a, v)) + layout.addWidget(cb) + + layout.addStretch() + return panel + + def _create_bad_names_panel(self) -> QWidget: + panel = QWidget() + layout = QVBoxLayout(panel) + layout.addWidget(QLabel("Check for:")) + + checks = [ + ("bad_names_uppercase_ext", "Uppercase extension", + "Files with .JPG, .PNG etc."), + ("bad_names_emoji", "Emoji in name", + "Files containing emoji characters"), + ("bad_names_space", "Space at start/end", + "Leading or trailing whitespace"), + ("bad_names_non_ascii", "Non-ASCII characters", + "Characters outside ASCII range"), + ("bad_names_remove_duplicated", "Remove duplicated non-alphanumeric", + "e.g. file--name..txt"), + ] + + for attr, label, hint in checks: + cb = QCheckBox(label) + cb.setChecked(getattr(self._ts, attr)) + cb.setToolTip(hint) + cb.toggled.connect(lambda v, a=attr: setattr(self._ts, a, v)) + layout.addWidget(cb) + + # Restricted charset + layout.addWidget(QLabel("Restricted charset:")) + self._bad_names_charset = QLineEdit(self._ts.bad_names_restricted_charset) + self._bad_names_charset.setPlaceholderText("Allowed special chars, comma-separated") + self._bad_names_charset.textChanged.connect( + lambda t: setattr(self._ts, 'bad_names_restricted_charset', t) + ) + layout.addWidget(self._bad_names_charset) + + layout.addStretch() + return panel + + def _create_exif_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + self._exif_tags = QLineEdit(self._ts.exif_ignored_tags) + self._exif_tags.setPlaceholderText("Tags to ignore, comma-separated") + self._exif_tags.textChanged.connect( + lambda t: setattr(self._ts, 'exif_ignored_tags', t) + ) + layout.addRow("Ignored EXIF Tags:", self._exif_tags) + + return panel + + def _create_video_optimizer_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + # Mode + self._vo_mode = QComboBox() + self._vo_mode.addItems(["Crop", "Transcode"]) + self._vo_mode.setCurrentIndex(0 if self._ts.video_opt_mode == "crop" else 1) + self._vo_mode.currentIndexChanged.connect(self._on_vo_mode_changed) + layout.addRow("Mode:", self._vo_mode) + + # Crop settings + self._crop_group = QGroupBox("Crop Settings") + crop_layout = QFormLayout(self._crop_group) + + self._vo_crop_type = QComboBox() + self._vo_crop_type.addItems(["Black Bars", "Static Content"]) + mechs = [VideoCropMechanism.BLACKBARS, VideoCropMechanism.STATICCONTENT] + mech_idx = mechs.index(self._ts.video_crop_mechanism) if self._ts.video_crop_mechanism in mechs else 0 + self._vo_crop_type.setCurrentIndex(mech_idx) + self._vo_crop_type.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'video_crop_mechanism', mechs[idx]) + ) + crop_layout.addRow("Crop Type:", self._vo_crop_type) + + self._vo_threshold = QSpinBox() + self._vo_threshold.setRange(0, 128) + self._vo_threshold.setValue(self._ts.video_black_pixel_threshold) + self._vo_threshold.valueChanged.connect( + lambda v: setattr(self._ts, 'video_black_pixel_threshold', v) + ) + crop_layout.addRow("Black Pixel Threshold:", self._vo_threshold) + + self._vo_bar_pct = QSpinBox() + self._vo_bar_pct.setRange(50, 100) + self._vo_bar_pct.setValue(self._ts.video_black_bar_percentage) + self._vo_bar_pct.valueChanged.connect( + lambda v: setattr(self._ts, 'video_black_bar_percentage', v) + ) + crop_layout.addRow("Black Bar Min %:", self._vo_bar_pct) + + self._vo_samples = QSpinBox() + self._vo_samples.setRange(5, 1000) + self._vo_samples.setValue(self._ts.video_max_samples) + self._vo_samples.valueChanged.connect( + lambda v: setattr(self._ts, 'video_max_samples', v) + ) + crop_layout.addRow("Max Samples:", self._vo_samples) + + self._vo_min_crop = QSpinBox() + self._vo_min_crop.setRange(1, 1000) + self._vo_min_crop.setValue(self._ts.video_min_crop_size) + self._vo_min_crop.valueChanged.connect( + lambda v: setattr(self._ts, 'video_min_crop_size', v) + ) + crop_layout.addRow("Min Crop Size:", self._vo_min_crop) + + layout.addRow(self._crop_group) + + # Transcode settings + self._transcode_group = QGroupBox("Transcode Settings") + tc_layout = QFormLayout(self._transcode_group) + + self._vo_codecs = QLineEdit(self._ts.video_excluded_codecs) + self._vo_codecs.textChanged.connect( + lambda t: setattr(self._ts, 'video_excluded_codecs', t) + ) + tc_layout.addRow("Excluded Codecs:", self._vo_codecs) + + self._vo_codec = QComboBox() + self._vo_codec.addItems(["H264", "H265", "AV1", "VP9"]) + codecs = [VideoCodec.H264, VideoCodec.H265, VideoCodec.AV1, VideoCodec.VP9] + codec_idx = codecs.index(self._ts.video_codec) if self._ts.video_codec in codecs else 1 + self._vo_codec.setCurrentIndex(codec_idx) + self._vo_codec.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'video_codec', codecs[idx]) + ) + tc_layout.addRow("Target Codec:", self._vo_codec) + + self._vo_quality = QSpinBox() + self._vo_quality.setRange(0, 51) + self._vo_quality.setValue(self._ts.video_quality) + self._vo_quality.valueChanged.connect( + lambda v: setattr(self._ts, 'video_quality', v) + ) + tc_layout.addRow("Quality:", self._vo_quality) + + self._vo_fail_bigger = QCheckBox("Fail if not smaller") + self._vo_fail_bigger.setChecked(self._ts.video_fail_if_bigger) + self._vo_fail_bigger.toggled.connect( + lambda v: setattr(self._ts, 'video_fail_if_bigger', v) + ) + tc_layout.addRow(self._vo_fail_bigger) + + layout.addRow(self._transcode_group) + + self._on_vo_mode_changed(self._vo_mode.currentIndex()) + return panel + + def _on_vo_mode_changed(self, idx): + mode = "crop" if idx == 0 else "transcode" + self._ts.video_opt_mode = mode + self._crop_group.setVisible(idx == 0) + self._transcode_group.setVisible(idx == 1) + self.settings_changed.emit() diff --git a/czkawka_pyside6/main.py b/czkawka_pyside6/main.py new file mode 100644 index 000000000..6bf9a05f1 --- /dev/null +++ b/czkawka_pyside6/main.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +""" +Czkawka PySide6 - A PySide6/Qt interface for czkawka file cleanup tool. + +This application provides a graphical interface to czkawka, using the +czkawka_cli binary as its backend for all scanning operations. + +Usage: + python main.py + # or + python -m czkawka_pyside6.main + +Requirements: + - PySide6 >= 6.6.0 + - czkawka_cli binary in PATH (or configured in settings) + - Optional: send2trash (for trash support) + - Optional: Pillow (for EXIF cleaning) +""" + +import sys +import os + + +def main(): + # Set environment for better HiDPI support + os.environ.setdefault("QT_AUTO_SCREEN_SCALE_FACTOR", "1") + + from PySide6.QtWidgets import QApplication + from PySide6.QtCore import Qt + from PySide6.QtGui import QFont + + app = QApplication(sys.argv) + app.setApplicationName("Czkawka") + app.setApplicationVersion("11.0.1") + app.setOrganizationName("czkawka") + app.setDesktopFileName("com.github.qarmin.czkawka") + + # Set application icon + from app.icons import app_icon + icon = app_icon() + if not icon.isNull(): + app.setWindowIcon(icon) + + # Set default font + font = QFont() + font.setPointSize(10) + app.setFont(font) + + # Import and create main window + from app.main_window import MainWindow + window = MainWindow() + window.show() + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() From 34803b8e7dc4be9bff641b89f69136dc93a5b1e7 Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 10:09:33 +0100 Subject: [PATCH 18/49] Make PySide6 frontend KDE6/Plasma compliant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove forced dark palette and hardcoded color stylesheets; inherit the system theme (Breeze, Adwaita, etc.) so the app looks native - Use QIcon.fromTheme() with standard XDG/FreeDesktop icon names (system-search, edit-delete, document-save, etc.) with embedded SVG fallbacks for systems without an icon theme - Use QStandardPaths.AppConfigLocation for XDG-compliant config paths - Add .desktop file (com.github.qarmin.czkawka-pyside6.desktop) - Add AppStream metainfo.xml for software center integration - Set desktopFileName and organizationDomain for proper KDE integration - Replace all hardcoded setStyleSheet color values with system palette (setEnabled(False) for muted text, QFont for bold/size, QFrame for separators, style().standardIcon() for dialog icons) - Remove forced QFont size — inherit system font settings Co-Authored-By: Claude Opus 4.6 (1M context) --- czkawka_pyside6/app/action_buttons.py | 14 --- czkawka_pyside6/app/dialogs/about_dialog.py | 24 ++--- czkawka_pyside6/app/dialogs/delete_dialog.py | 16 ++-- czkawka_pyside6/app/dialogs/move_dialog.py | 1 - czkawka_pyside6/app/dialogs/rename_dialog.py | 2 +- czkawka_pyside6/app/dialogs/select_dialog.py | 2 +- czkawka_pyside6/app/icons.py | 80 ++++++++--------- czkawka_pyside6/app/left_panel.py | 18 +--- czkawka_pyside6/app/main_window.py | 88 ++++--------------- czkawka_pyside6/app/preview_panel.py | 10 +-- czkawka_pyside6/app/progress_widget.py | 63 ++++++------- czkawka_pyside6/app/results_view.py | 30 +++++-- czkawka_pyside6/app/settings_panel.py | 5 +- czkawka_pyside6/app/state.py | 6 +- czkawka_pyside6/app/tool_settings.py | 4 +- czkawka_pyside6/main.py | 23 ++--- .../com.github.qarmin.czkawka-pyside6.desktop | 12 +++ ...github.qarmin.czkawka-pyside6.metainfo.xml | 40 +++++++++ 18 files changed, 206 insertions(+), 232 deletions(-) create mode 100644 data/com.github.qarmin.czkawka-pyside6.desktop create mode 100644 data/com.github.qarmin.czkawka-pyside6.metainfo.xml diff --git a/czkawka_pyside6/app/action_buttons.py b/czkawka_pyside6/app/action_buttons.py index 36e928a26..ef39a3947 100644 --- a/czkawka_pyside6/app/action_buttons.py +++ b/czkawka_pyside6/app/action_buttons.py @@ -46,11 +46,6 @@ def _setup_ui(self): self._scan_btn = QPushButton(icon_search(18), " Scan") self._scan_btn.setIconSize(ICON_SIZE) self._scan_btn.setMinimumWidth(90) - self._scan_btn.setStyleSheet( - "QPushButton { background-color: #2d5a27; color: white; font-weight: bold; padding: 6px 16px; }" - "QPushButton:hover { background-color: #3a7a34; }" - "QPushButton:disabled { background-color: #444; color: #888; }" - ) self._scan_btn.clicked.connect(self.scan_clicked.emit) layout.addWidget(self._scan_btn) @@ -58,10 +53,6 @@ def _setup_ui(self): self._stop_btn = QPushButton(icon_stop(18), " Stop") self._stop_btn.setIconSize(ICON_SIZE) self._stop_btn.setMinimumWidth(80) - self._stop_btn.setStyleSheet( - "QPushButton { background-color: #8a2222; color: white; font-weight: bold; padding: 6px 16px; }" - "QPushButton:hover { background-color: #aa3333; }" - ) self._stop_btn.clicked.connect(self.stop_clicked.emit) self._stop_btn.setVisible(False) layout.addWidget(self._stop_btn) @@ -80,11 +71,6 @@ def _setup_ui(self): # Delete button self._delete_btn = QPushButton(icon_delete(18), " Delete") self._delete_btn.setIconSize(ICON_SIZE) - self._delete_btn.setStyleSheet( - "QPushButton { background-color: #8a2222; color: white; padding: 6px 12px; }" - "QPushButton:hover { background-color: #aa3333; }" - "QPushButton:disabled { background-color: #444; color: #888; }" - ) self._delete_btn.clicked.connect(self.delete_clicked.emit) layout.addWidget(self._delete_btn) diff --git a/czkawka_pyside6/app/dialogs/about_dialog.py b/czkawka_pyside6/app/dialogs/about_dialog.py index 54da0fa9f..3284712ea 100644 --- a/czkawka_pyside6/app/dialogs/about_dialog.py +++ b/czkawka_pyside6/app/dialogs/about_dialog.py @@ -1,8 +1,8 @@ from PySide6.QtWidgets import ( - QDialog, QVBoxLayout, QLabel, QDialogButtonBox + QDialog, QVBoxLayout, QLabel, QDialogButtonBox, QFrame ) from PySide6.QtCore import Qt -from PySide6.QtGui import QPixmap +from PySide6.QtGui import QPixmap, QFont from ..icons import app_logo_path @@ -30,24 +30,29 @@ def __init__(self, parent=None): layout.addWidget(logo_label) title = QLabel("Czkawka") - title.setStyleSheet("font-size: 28px; font-weight: bold; padding: 4px;") + title_font = QFont() + title_font.setPointSize(22) + title_font.setBold(True) + title.setFont(title_font) title.setAlignment(Qt.AlignCenter) layout.addWidget(title) - subtitle = QLabel("PySide6 / Qt6 Edition") - subtitle.setStyleSheet("font-size: 14px; color: #6fbf73; padding: 2px;") + subtitle = QLabel("PySide6 / Qt 6 Edition") + sub_font = QFont() + sub_font.setPointSize(11) + subtitle.setFont(sub_font) subtitle.setAlignment(Qt.AlignCenter) layout.addWidget(subtitle) version = QLabel("Version 11.0.1") - version.setStyleSheet("font-size: 11px; color: #888; padding: 2px;") version.setAlignment(Qt.AlignCenter) + version.setEnabled(False) layout.addWidget(version) # Separator - sep = QLabel() - sep.setFixedHeight(1) - sep.setStyleSheet("background-color: #444; margin: 8px 40px;") + sep = QFrame() + sep.setFrameShape(QFrame.HLine) + sep.setFrameShadow(QFrame.Sunken) layout.addWidget(sep) desc = QLabel( @@ -68,7 +73,6 @@ def __init__(self, parent=None): ) desc.setWordWrap(True) desc.setAlignment(Qt.AlignCenter) - desc.setStyleSheet("padding: 6px 20px; line-height: 1.4;") layout.addWidget(desc) layout.addStretch() diff --git a/czkawka_pyside6/app/dialogs/delete_dialog.py b/czkawka_pyside6/app/dialogs/delete_dialog.py index 89ce64bc6..b82ad674d 100644 --- a/czkawka_pyside6/app/dialogs/delete_dialog.py +++ b/czkawka_pyside6/app/dialogs/delete_dialog.py @@ -1,8 +1,9 @@ from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QLabel, QDialogButtonBox, - QCheckBox, QHBoxLayout + QCheckBox, QMessageBox ) from PySide6.QtCore import Qt +from PySide6.QtGui import QFont class DeleteDialog(QDialog): @@ -16,15 +17,17 @@ def __init__(self, count: int, move_to_trash: bool = True, parent=None): layout = QVBoxLayout(self) - # Warning + # Warning icon from system theme icon_label = QLabel() - icon_label.setStyleSheet("font-size: 36px;") - icon_label.setText("Warning") + icon = self.style().standardIcon(self.style().SP_MessageBoxWarning) + icon_label.setPixmap(icon.pixmap(48, 48)) icon_label.setAlignment(Qt.AlignCenter) layout.addWidget(icon_label) msg = QLabel(f"Are you sure you want to delete {count} selected file(s)?") - msg.setStyleSheet("font-size: 14px; padding: 10px;") + msg_font = QFont() + msg_font.setPointSize(11) + msg.setFont(msg_font) msg.setAlignment(Qt.AlignCenter) msg.setWordWrap(True) layout.addWidget(msg) @@ -39,9 +42,6 @@ def __init__(self, count: int, move_to_trash: bool = True, parent=None): QDialogButtonBox.Ok | QDialogButtonBox.Cancel ) buttons.button(QDialogButtonBox.Ok).setText("Delete") - buttons.button(QDialogButtonBox.Ok).setStyleSheet( - "background-color: #8a2222; color: white; padding: 6px 20px;" - ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) diff --git a/czkawka_pyside6/app/dialogs/move_dialog.py b/czkawka_pyside6/app/dialogs/move_dialog.py index 6c754d21a..a26a51405 100644 --- a/czkawka_pyside6/app/dialogs/move_dialog.py +++ b/czkawka_pyside6/app/dialogs/move_dialog.py @@ -17,7 +17,6 @@ def __init__(self, count: int, parent=None): layout = QVBoxLayout(self) msg = QLabel(f"Move or copy {count} selected file(s) to:") - msg.setStyleSheet("font-size: 13px; padding: 6px;") layout.addWidget(msg) # Destination path diff --git a/czkawka_pyside6/app/dialogs/rename_dialog.py b/czkawka_pyside6/app/dialogs/rename_dialog.py index 4c6208ab1..da4cb4fa5 100644 --- a/czkawka_pyside6/app/dialogs/rename_dialog.py +++ b/czkawka_pyside6/app/dialogs/rename_dialog.py @@ -22,7 +22,7 @@ def __init__(self, count: int, rename_type: str = "extensions", parent=None): label = QLabel(msg) label.setWordWrap(True) - label.setStyleSheet("font-size: 13px; padding: 10px;") + label.setContentsMargins(10, 10, 10, 10) layout.addWidget(label) buttons = QDialogButtonBox( diff --git a/czkawka_pyside6/app/dialogs/select_dialog.py b/czkawka_pyside6/app/dialogs/select_dialog.py index 58ea94a11..f4153106c 100644 --- a/czkawka_pyside6/app/dialogs/select_dialog.py +++ b/czkawka_pyside6/app/dialogs/select_dialog.py @@ -30,7 +30,7 @@ def __init__(self, parent=None): layout = QVBoxLayout(self) label = QLabel("Choose selection mode:") - label.setStyleSheet("font-size: 13px; padding: 4px;") + label.setContentsMargins(4, 4, 4, 4) layout.addWidget(label) for mode, name in self.MODES: diff --git a/czkawka_pyside6/app/icons.py b/czkawka_pyside6/app/icons.py index bc094529f..9ec8fdace 100644 --- a/czkawka_pyside6/app/icons.py +++ b/czkawka_pyside6/app/icons.py @@ -1,45 +1,37 @@ -"""SVG icon resources for Czkawka PySide6 interface. +"""Icon resources for Czkawka PySide6 interface. -Uses the same SVG icons as the Krokiet (Slint) interface. -Icons are embedded as strings to avoid file path issues. +KDE-compliant icon strategy: + 1. Try QIcon.fromTheme() with standard XDG/FreeDesktop icon names + 2. Fall back to the embedded SVG icons from the Krokiet icon set + +This ensures the app looks native on KDE/Plasma (Breeze icons) and +other desktops while still working when no icon theme is installed. """ -from PySide6.QtGui import QIcon, QPixmap +from PySide6.QtGui import QIcon, QPixmap, QPainter, QImage from PySide6.QtCore import QSize, Qt from PySide6.QtSvg import QSvgRenderer -from PySide6.QtGui import QPainter, QImage from functools import lru_cache -# Fill color applied to icons for dark theme visibility -_ICON_FILL = "#cccccc" -_ICON_FILL_GREEN = "#6fbf73" -_ICON_FILL_RED = "#e57373" - -def _colorize_svg(svg: str, fill: str = _ICON_FILL) -> str: - """Inject fill color into SVG for dark theme visibility.""" - # Add fill to root svg or g elements - if 'fill="none"' not in svg and f'fill="{fill}"' not in svg: - svg = svg.replace(" QIcon: + """Return XDG theme icon if available, otherwise render the fallback SVG.""" + icon = QIcon.fromTheme(xdg_name) + if not icon.isNull(): + return icon + return _svg_to_icon(fallback_svg, size) @lru_cache(maxsize=64) -def _svg_to_icon(svg_data: str, fill: str = _ICON_FILL, size: int = 24) -> QIcon: - """Convert SVG string to QIcon with specified fill color.""" - colored = _colorize_svg(svg_data, fill) - renderer = QSvgRenderer(colored.encode("utf-8")) +def _svg_to_icon(svg_data: str, size: int = 24) -> QIcon: + """Render SVG string to QIcon. No color injection — respects original SVG.""" + renderer = QSvgRenderer(svg_data.encode("utf-8")) image = QImage(QSize(size, size), QImage.Format_ARGB32_Premultiplied) image.fill(Qt.transparent) painter = QPainter(image) renderer.render(painter) painter.end() - pixmap = QPixmap.fromImage(image) - return QIcon(pixmap) + return QIcon(QPixmap.fromImage(image)) # ─── Raw SVG data ─────────────────────────────────────────── @@ -79,53 +71,57 @@ def _svg_to_icon(svg_data: str, fill: str = _ICON_FILL, size: int = 24) -> QIcon # ─── Icon accessor functions ──────────────────────────────── +# ─── XDG theme name → fallback SVG mapping ───────────────── +# Standard FreeDesktop icon names are tried first (Breeze, Adwaita, etc.) +# If the theme doesn't have them, the embedded Krokiet SVG is used. + def icon_search(size=24): - return _svg_to_icon(SEARCH_SVG, _ICON_FILL_GREEN, size) + return _themed_icon("system-search", SEARCH_SVG, size) def icon_stop(size=24): - return _svg_to_icon(STOP_SVG, _ICON_FILL_RED, size) + return _themed_icon("process-stop", STOP_SVG, size) def icon_delete(size=24): - return _svg_to_icon(DELETE_SVG, _ICON_FILL_RED, size) + return _themed_icon("edit-delete", DELETE_SVG, size) def icon_move(size=24): - return _svg_to_icon(MOVE_SVG, _ICON_FILL, size) + return _themed_icon("folder-move", MOVE_SVG, size) def icon_save(size=24): - return _svg_to_icon(SAVE_SVG, _ICON_FILL, size) + return _themed_icon("document-save", SAVE_SVG, size) def icon_select(size=24): - return _svg_to_icon(SELECT_SVG, _ICON_FILL, size) + return _themed_icon("edit-select-all", SELECT_SVG, size) def icon_sort(size=24): - return _svg_to_icon(SORT_SVG, _ICON_FILL, size) + return _themed_icon("view-sort", SORT_SVG, size) def icon_hardlink(size=24): - return _svg_to_icon(HARDLINK_SVG, _ICON_FILL, size) + return _themed_icon("edit-link", HARDLINK_SVG, size) def icon_symlink(size=24): - return _svg_to_icon(SYMLINK_SVG, _ICON_FILL, size) + return _themed_icon("edit-link", SYMLINK_SVG, size) def icon_rename(size=24): - return _svg_to_icon(RENAME_SVG, _ICON_FILL, size) + return _themed_icon("edit-rename", RENAME_SVG, size) def icon_clean(size=24): - return _svg_to_icon(CLEAN_SVG, _ICON_FILL, size) + return _themed_icon("edit-clear-all", CLEAN_SVG, size) def icon_optimize(size=24): - return _svg_to_icon(OPTIMIZE_SVG, _ICON_FILL, size) + return _themed_icon("configure", OPTIMIZE_SVG, size) def icon_settings(size=24): - return _svg_to_icon(SETTINGS_SVG, _ICON_FILL, size) + return _themed_icon("configure", SETTINGS_SVG, size) def icon_subsettings(size=24): - return _svg_to_icon(SUBSETTINGS_SVG, _ICON_FILL, size) + return _themed_icon("preferences-other", SUBSETTINGS_SVG, size) def icon_dir(size=24): - return _svg_to_icon(DIR_SVG, _ICON_FILL, size) + return _themed_icon("folder", DIR_SVG, size) def icon_info(size=24): - return _svg_to_icon(INFO_SVG, _ICON_FILL, size) + return _themed_icon("dialog-information", INFO_SVG, size) def app_logo_path() -> str: diff --git a/czkawka_pyside6/app/left_panel.py b/czkawka_pyside6/app/left_panel.py index 8c53e77d3..8a8f87185 100644 --- a/czkawka_pyside6/app/left_panel.py +++ b/czkawka_pyside6/app/left_panel.py @@ -96,22 +96,6 @@ def _setup_ui(self): # Tool list self._tool_list = QListWidget() self._tool_list.setSpacing(1) - font = QFont() - font.setPointSize(10) - self._tool_list.setFont(font) - self._tool_list.setStyleSheet(""" - QListWidget::item { - padding: 4px 8px; - border-left: 3px solid transparent; - } - QListWidget::item:selected { - border-left: 3px solid #6fbf73; - background-color: #353535; - } - QListWidget::item:hover { - background-color: #49494926; - } - """) for tab in self.TOOL_TABS: item = QListWidgetItem(TAB_DISPLAY_NAMES[tab]) @@ -126,7 +110,7 @@ def _setup_ui(self): # Version label version_label = QLabel("Czkawka PySide6 v11.0.1") version_label.setAlignment(Qt.AlignCenter) - version_label.setStyleSheet("color: #666; font-size: 9px; padding: 2px;") + version_label.setEnabled(False) layout.addWidget(version_label) def _on_item_changed(self, current, previous): diff --git a/czkawka_pyside6/app/main_window.py b/czkawka_pyside6/app/main_window.py index 2515bd237..e6168badc 100644 --- a/czkawka_pyside6/app/main_window.py +++ b/czkawka_pyside6/app/main_window.py @@ -7,7 +7,7 @@ QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QSplitter, QStatusBar, QMessageBox, QLabel, QApplication ) -from PySide6.QtCore import Qt, QTimer +from PySide6.QtCore import Qt, QTimer, QStandardPaths from PySide6.QtGui import QPalette, QColor from .state import AppState @@ -485,83 +485,29 @@ def _on_settings_changed(self): self._bottom_panel.refresh_lists() def _apply_theme(self): - """Apply dark theme to the application.""" - if not self._state.settings.dark_theme: - return + """Apply minimal styling that works with the system theme. + KDE compliance: we inherit the desktop theme (Breeze, Adwaita, etc.) + and only add small layout tweaks that don't override colors. + """ app = QApplication.instance() - palette = QPalette() - - # Dark theme colors - palette.setColor(QPalette.Window, QColor(43, 43, 43)) - palette.setColor(QPalette.WindowText, QColor(210, 210, 210)) - palette.setColor(QPalette.Base, QColor(30, 30, 30)) - palette.setColor(QPalette.AlternateBase, QColor(38, 38, 38)) - palette.setColor(QPalette.ToolTipBase, QColor(50, 50, 50)) - palette.setColor(QPalette.ToolTipText, QColor(210, 210, 210)) - palette.setColor(QPalette.Text, QColor(210, 210, 210)) - palette.setColor(QPalette.Button, QColor(53, 53, 53)) - palette.setColor(QPalette.ButtonText, QColor(210, 210, 210)) - palette.setColor(QPalette.BrightText, QColor(255, 255, 255)) - palette.setColor(QPalette.Link, QColor(86, 140, 210)) - palette.setColor(QPalette.Highlight, QColor(60, 100, 150)) - palette.setColor(QPalette.HighlightedText, QColor(255, 255, 255)) - palette.setColor(QPalette.Disabled, QPalette.Text, QColor(128, 128, 128)) - palette.setColor(QPalette.Disabled, QPalette.ButtonText, QColor(128, 128, 128)) - - app.setPalette(palette) - - # Additional stylesheet + + # Only apply layout polish — no color overrides so the system + # theme (Breeze dark/light, Adwaita, etc.) is fully respected. app.setStyleSheet(""" - QMainWindow { background-color: #2b2b2b; } - QSplitter::handle { background-color: #404040; width: 2px; } - QTreeWidget { border: 1px solid #404040; } + QSplitter::handle { width: 2px; } QTreeWidget::item { padding: 2px; } - QTreeWidget::item:alternate { background-color: #262626; } - QTreeWidget::item:selected { background-color: #3c6496; } - QListWidget { border: 1px solid #404040; } QListWidget::item { padding: 3px; } - QListWidget::item:selected { background-color: #3c6496; } - QGroupBox { border: 1px solid #505050; border-radius: 4px; - margin-top: 8px; padding-top: 8px; } - QGroupBox::title { subcontrol-origin: margin; left: 10px; - padding: 0 4px; } - QPushButton { padding: 5px 12px; border: 1px solid #555; - border-radius: 3px; background-color: #404040; } - QPushButton:hover { background-color: #505050; } - QPushButton:pressed { background-color: #353535; } - QPushButton:disabled { background-color: #333; color: #666; } - QComboBox { padding: 4px; border: 1px solid #555; - border-radius: 3px; background-color: #404040; } - QComboBox:hover { background-color: #505050; } - QComboBox QAbstractItemView { background-color: #353535; - border: 1px solid #555; - selection-background-color: #3c6496; } - QLineEdit { padding: 4px; border: 1px solid #555; - border-radius: 3px; background-color: #353535; } - QSpinBox { padding: 4px; border: 1px solid #555; - border-radius: 3px; background-color: #353535; } - QSlider::groove:horizontal { height: 6px; background: #404040; - border-radius: 3px; } - QSlider::handle:horizontal { width: 14px; margin: -4px 0; - background: #888; border-radius: 7px; } - QSlider::handle:horizontal:hover { background: #aaa; } - QProgressBar { border: 1px solid #555; border-radius: 3px; - text-align: center; background-color: #353535; } - QProgressBar::chunk { background-color: #3c6496; border-radius: 2px; } + QGroupBox { border-radius: 4px; margin-top: 8px; padding-top: 8px; } + QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 4px; } + QPushButton { padding: 5px 12px; } + QComboBox { padding: 4px; } + QLineEdit { padding: 4px; } + QSpinBox { padding: 4px; } + QProgressBar { text-align: center; } QScrollArea { border: none; } - QTabWidget::pane { border: 1px solid #555; } - QTabBar::tab { padding: 6px 16px; border: 1px solid #555; - border-bottom: none; border-radius: 3px 3px 0 0; - background-color: #353535; } - QTabBar::tab:selected { background-color: #404040; } - QTabBar::tab:hover { background-color: #505050; } - QStatusBar { background-color: #2b2b2b; border-top: 1px solid #404040; } QCheckBox { spacing: 6px; } - QCheckBox::indicator { width: 16px; height: 16px; } - QTextEdit { border: 1px solid #404040; background-color: #1e1e1e; } - QHeaderView::section { background-color: #353535; padding: 4px; - border: 1px solid #404040; } + QHeaderView::section { padding: 4px; } """) def _auto_detect_cli(self): diff --git a/czkawka_pyside6/app/preview_panel.py b/czkawka_pyside6/app/preview_panel.py index 3e3c50586..7b53bc0b2 100644 --- a/czkawka_pyside6/app/preview_panel.py +++ b/czkawka_pyside6/app/preview_panel.py @@ -27,7 +27,9 @@ def _setup_ui(self): layout.setContentsMargins(4, 4, 4, 4) self._title = QLabel("Preview") - self._title.setStyleSheet("font-weight: bold; font-size: 12px; padding: 2px;") + font = self._title.font() + font.setBold(True) + self._title.setFont(font) self._title.setAlignment(Qt.AlignCenter) layout.addWidget(self._title) @@ -35,16 +37,14 @@ def _setup_ui(self): self._image_label.setAlignment(Qt.AlignCenter) self._image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self._image_label.setMinimumSize(QSize(180, 180)) - self._image_label.setStyleSheet( - "background-color: #1a1a1a; border: 1px solid #333; border-radius: 4px;" - ) + self._image_label.setFrameShape(QLabel.StyledPanel) self._image_label.setScaledContents(False) layout.addWidget(self._image_label) self._info_label = QLabel() self._info_label.setAlignment(Qt.AlignCenter) self._info_label.setWordWrap(True) - self._info_label.setStyleSheet("color: #888; font-size: 10px; padding: 2px;") + self._info_label.setEnabled(False) layout.addWidget(self._info_label) def show_preview(self, file_path: str): diff --git a/czkawka_pyside6/app/progress_widget.py b/czkawka_pyside6/app/progress_widget.py index 185e0790d..9c1de28d3 100644 --- a/czkawka_pyside6/app/progress_widget.py +++ b/czkawka_pyside6/app/progress_widget.py @@ -5,7 +5,7 @@ from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QProgressBar, QLabel, QHBoxLayout ) -from PySide6.QtCore import Qt, QTimer +from PySide6.QtCore import Qt, QTimer, QStandardPaths from .models import ActiveTab, ScanProgress @@ -21,23 +21,6 @@ class ProgressWidget(QWidget): - Phase step indicators """ - # File where we persist the last file-collection count per directory set, - # so we can estimate the collection stage percentage on the next scan. - _ESTIMATE_FILE = Path.home() / ".config" / "czkawka_pyside6" / "scan_estimates.json" - - _BAR_STYLE = """ - QProgressBar {{ - border: 1px solid #555; border-radius: 3px; - text-align: center; background-color: #2a2a2a; - font-size: 10px; color: #ccc; - }} - QProgressBar::chunk {{ - background: qlineargradient(x1:0, y1:0, x2:1, y2:0, - stop:0 {c1}, stop:1 {c2}); - border-radius: 2px; - }} - """ - def __init__(self, parent=None): super().__init__(parent) self.setVisible(False) @@ -62,32 +45,31 @@ def _setup_ui(self): # Row 1: stage label + elapsed row1 = QHBoxLayout() self._stage_label = QLabel("Initializing...") - self._stage_label.setStyleSheet("font-weight: bold; font-size: 12px;") + font = self._stage_label.font() + font.setBold(True) + self._stage_label.setFont(font) row1.addWidget(self._stage_label) row1.addStretch() self._elapsed_label = QLabel("") - self._elapsed_label.setStyleSheet("color: #888; font-size: 11px;") + self._elapsed_label.setEnabled(False) # Uses disabled palette color row1.addWidget(self._elapsed_label) layout.addLayout(row1) - # Row 2: current stage bar "Current stage" NN% + # Row 2: current stage bar "Current" NN% row2 = QHBoxLayout() row2.setSpacing(6) lbl2 = QLabel("Current") - lbl2.setStyleSheet("color: #aaa; font-size: 10px;") + lbl2.setEnabled(False) lbl2.setFixedWidth(48) row2.addWidget(lbl2) self._stage_bar = QProgressBar() self._stage_bar.setFixedHeight(14) self._stage_bar.setTextVisible(False) - self._stage_bar.setStyleSheet( - self._BAR_STYLE.format(c1="#1b4332", c2="#2d6a4f") - ) row2.addWidget(self._stage_bar) self._stage_pct = QLabel("") self._stage_pct.setFixedWidth(40) self._stage_pct.setAlignment(Qt.AlignRight | Qt.AlignVCenter) - self._stage_pct.setStyleSheet("color: #aaa; font-size: 10px;") + self._stage_pct.setEnabled(False) row2.addWidget(self._stage_pct) layout.addLayout(row2) @@ -95,38 +77,35 @@ def _setup_ui(self): row3 = QHBoxLayout() row3.setSpacing(6) lbl3 = QLabel("Overall") - lbl3.setStyleSheet("color: #aaa; font-size: 10px;") + lbl3.setEnabled(False) lbl3.setFixedWidth(48) row3.addWidget(lbl3) self._overall_bar = QProgressBar() self._overall_bar.setFixedHeight(14) self._overall_bar.setTextVisible(False) - self._overall_bar.setStyleSheet( - self._BAR_STYLE.format(c1="#2d6a4f", c2="#40916c") - ) row3.addWidget(self._overall_bar) self._overall_pct = QLabel("") self._overall_pct.setFixedWidth(40) self._overall_pct.setAlignment(Qt.AlignRight | Qt.AlignVCenter) - self._overall_pct.setStyleSheet("color: #aaa; font-size: 10px;") + self._overall_pct.setEnabled(False) row3.addWidget(self._overall_pct) layout.addLayout(row3) # Row 4: detail counts row4 = QHBoxLayout() self._detail_label = QLabel("") - self._detail_label.setStyleSheet("color: #888; font-size: 10px;") + self._detail_label.setEnabled(False) row4.addWidget(self._detail_label) row4.addStretch() self._size_label = QLabel("") - self._size_label.setStyleSheet("color: #888; font-size: 10px;") + self._size_label.setEnabled(False) self._size_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) row4.addWidget(self._size_label) layout.addLayout(row4) # Row 5: step indicators self._steps_label = QLabel("") - self._steps_label.setStyleSheet("color: #666; font-size: 9px;") + self._steps_label.setEnabled(False) self._steps_label.setAlignment(Qt.AlignCenter) self._steps_label.setWordWrap(True) layout.addWidget(self._steps_label) @@ -261,18 +240,26 @@ def _get_estimate_key(self) -> str: def _get_estimate(self) -> int: return self._estimates.get(self._get_estimate_key(), 0) + @staticmethod + def _estimate_file_path() -> Path: + config_dir = QStandardPaths.writableLocation(QStandardPaths.AppConfigLocation) + base = Path(config_dir) if config_dir else Path.home() / ".config" / "czkawka" + return base / "scan_estimates.json" + def _save_estimate(self, count: int): self._estimates[self._get_estimate_key()] = count try: - self._ESTIMATE_FILE.parent.mkdir(parents=True, exist_ok=True) - self._ESTIMATE_FILE.write_text(json.dumps(self._estimates)) + path = self._estimate_file_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(self._estimates)) except OSError: pass def _load_estimates(self): try: - if self._ESTIMATE_FILE.exists(): - self._estimates = json.loads(self._ESTIMATE_FILE.read_text()) + path = self._estimate_file_path() + if path.exists(): + self._estimates = json.loads(path.read_text()) except (json.JSONDecodeError, OSError): self._estimates = {} diff --git a/czkawka_pyside6/app/results_view.py b/czkawka_pyside6/app/results_view.py index fc1aca09c..6c7a37eb9 100644 --- a/czkawka_pyside6/app/results_view.py +++ b/czkawka_pyside6/app/results_view.py @@ -17,10 +17,11 @@ class ResultsView(QWidget): item_activated = Signal(object) # ResultEntry context_menu_requested = Signal(object, object) # QPoint, ResultEntry - # Colors - HEADER_BG = QColor(60, 60, 80) - HEADER_FG = QColor(220, 220, 255) - SELECTED_BG = QColor(40, 80, 40) + # Group header uses the system highlight color (darkened) so it works on + # both light and dark themes. Computed lazily on first result display. + _header_colors_ready = False + HEADER_BG = QColor() + HEADER_FG = QColor() def __init__(self, parent=None): super().__init__(parent) @@ -28,6 +29,23 @@ def __init__(self, parent=None): self._results: list[ResultEntry] = [] self._setup_ui() + def _ensure_header_colors(self): + """Derive group header colors from the system palette.""" + if self._header_colors_ready: + return + from PySide6.QtWidgets import QApplication + palette = QApplication.instance().palette() + # Use a midpoint between window and highlight for header background + win = palette.color(palette.Window) + hi = palette.color(palette.Highlight) + self.HEADER_BG = QColor( + (win.red() + hi.red()) // 2, + (win.green() + hi.green()) // 2, + (win.blue() + hi.blue()) // 2, + ) + self.HEADER_FG = palette.color(palette.HighlightedText) + self._header_colors_ready = True + def _setup_ui(self): layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -35,11 +53,10 @@ def _setup_ui(self): # Summary bar summary_layout = QHBoxLayout() self._summary_label = QLabel("No results") - self._summary_label.setStyleSheet("padding: 4px;") summary_layout.addWidget(self._summary_label) self._selection_label = QLabel("") self._selection_label.setAlignment(Qt.AlignRight) - self._selection_label.setStyleSheet("padding: 4px; color: #aaa;") + self._selection_label.setEnabled(False) summary_layout.addWidget(self._selection_label) layout.addLayout(summary_layout) @@ -66,6 +83,7 @@ def set_active_tab(self, tab: ActiveTab): header.setSectionResizeMode(i, QHeaderView.ResizeToContents) def set_results(self, results: list[ResultEntry]): + self._ensure_header_colors() self._results = results self._tree.blockSignals(True) self._tree.clear() diff --git a/czkawka_pyside6/app/settings_panel.py b/czkawka_pyside6/app/settings_panel.py index 1ab5c2159..805f6664d 100644 --- a/czkawka_pyside6/app/settings_panel.py +++ b/czkawka_pyside6/app/settings_panel.py @@ -25,7 +25,10 @@ def _setup_ui(self): # Header header = QHBoxLayout() title = QLabel("Settings") - title.setStyleSheet("font-weight: bold; font-size: 16px; padding: 4px;") + font = title.font() + font.setBold(True) + font.setPointSize(font.pointSize() + 2) + title.setFont(font) header.addWidget(title) header.addStretch() close_btn = QPushButton("Close") diff --git a/czkawka_pyside6/app/state.py b/czkawka_pyside6/app/state.py index 2737323d0..9c73524ff 100644 --- a/czkawka_pyside6/app/state.py +++ b/czkawka_pyside6/app/state.py @@ -1,6 +1,6 @@ import json from pathlib import Path -from PySide6.QtCore import QObject, Signal +from PySide6.QtCore import QObject, Signal, QStandardPaths from .models import ( ActiveTab, AppSettings, ToolSettings, ResultEntry, ScanProgress ) @@ -32,7 +32,9 @@ def __init__(self): self.progress = ScanProgress() self.info_text = "" self.preview_image_path = "" - self._config_path = Path.home() / ".config" / "czkawka_pyside6" + # Use QStandardPaths for XDG-compliant config location + config_dir = QStandardPaths.writableLocation(QStandardPaths.AppConfigLocation) + self._config_path = Path(config_dir) if config_dir else Path.home() / ".config" / "czkawka" self._config_path.mkdir(parents=True, exist_ok=True) self.load_settings() diff --git a/czkawka_pyside6/app/tool_settings.py b/czkawka_pyside6/app/tool_settings.py index a3c5d9e44..87d4249f0 100644 --- a/czkawka_pyside6/app/tool_settings.py +++ b/czkawka_pyside6/app/tool_settings.py @@ -30,7 +30,9 @@ def _setup_ui(self): main_layout.setContentsMargins(4, 4, 4, 4) title = QLabel("Tool Settings") - title.setStyleSheet("font-weight: bold; font-size: 13px; padding: 4px;") + font = title.font() + font.setBold(True) + title.setFont(font) main_layout.addWidget(title) self._scroll = QScrollArea() diff --git a/czkawka_pyside6/main.py b/czkawka_pyside6/main.py index 6bf9a05f1..413ab5a3b 100644 --- a/czkawka_pyside6/main.py +++ b/czkawka_pyside6/main.py @@ -22,30 +22,25 @@ def main(): - # Set environment for better HiDPI support - os.environ.setdefault("QT_AUTO_SCREEN_SCALE_FACTOR", "1") - from PySide6.QtWidgets import QApplication from PySide6.QtCore import Qt - from PySide6.QtGui import QFont app = QApplication(sys.argv) app.setApplicationName("Czkawka") app.setApplicationVersion("11.0.1") app.setOrganizationName("czkawka") - app.setDesktopFileName("com.github.qarmin.czkawka") - - # Set application icon - from app.icons import app_icon - icon = app_icon() + app.setOrganizationDomain("github.com/qarmin") + app.setDesktopFileName("com.github.qarmin.czkawka-pyside6") + + # Set application icon — use XDG theme icon with fallback to project logo + from PySide6.QtGui import QIcon + icon = QIcon.fromTheme("com.github.qarmin.czkawka") + if icon.isNull(): + from app.icons import app_icon + icon = app_icon() if not icon.isNull(): app.setWindowIcon(icon) - # Set default font - font = QFont() - font.setPointSize(10) - app.setFont(font) - # Import and create main window from app.main_window import MainWindow window = MainWindow() diff --git a/data/com.github.qarmin.czkawka-pyside6.desktop b/data/com.github.qarmin.czkawka-pyside6.desktop new file mode 100644 index 000000000..6e9c8eac8 --- /dev/null +++ b/data/com.github.qarmin.czkawka-pyside6.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Categories=System;FileTools;Qt; +Exec=czkawka-pyside6 +Icon=com.github.qarmin.czkawka +StartupWMClass=czkawka-pyside6 +Terminal=false +Type=Application + +Name=Czkawka PySide6 +GenericName=File Cleaner +Comment=Multi-functional app to find duplicates, empty folders, similar files and more - Qt/PySide6 edition. +Keywords=Czkawka;duplicate;same;similar;cleaner;copy;copies;compare;files;Qt; diff --git a/data/com.github.qarmin.czkawka-pyside6.metainfo.xml b/data/com.github.qarmin.czkawka-pyside6.metainfo.xml new file mode 100644 index 000000000..e85d44343 --- /dev/null +++ b/data/com.github.qarmin.czkawka-pyside6.metainfo.xml @@ -0,0 +1,40 @@ + + + com.github.qarmin.czkawka-pyside6 + Czkawka PySide6 + Multi-functional app to find duplicates, similar images and more - Qt/PySide6 edition + CC0-1.0 + MIT + +

Czkawka PySide6 is a Qt 6 GUI frontend for the Czkawka file cleanup tool. It uses czkawka_cli as its backend and provides feature parity with the Krokiet (Slint) interface.

+

It can find:

+
    +
  • Duplicates (by hash, name, size)
  • +
  • Similar images
  • +
  • Similar audio files
  • +
  • Similar videos
  • +
  • Empty folders and files
  • +
  • Broken files
  • +
  • Temporary files
  • +
  • Big files
  • +
  • Invalid symlinks
  • +
  • Files with bad names or extensions
  • +
  • Videos that can be optimized/cropped
  • +
  • EXIF metadata to remove
  • +
+ + com.github.qarmin.czkawka-pyside6.desktop + + + + + + Rafał Mikrut + + https://github.com/qarmin/czkawka + https://github.com/qarmin/czkawka/issues + https://github.com/sponsors/qarmin + + com.github.qarmin.czkawka-cli + + From 211bc5100a1053fe715e2b61fe9d66dd153e5018 Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 10:44:18 +0100 Subject: [PATCH 19/49] Fix cargo fmt and show stage index in progress title - Fix writeln! formatting to pass cargo fmt --check - Show stage index in progress bar title (e.g., "[3/7] Calculating prehashes") - All czkawka_core tests pass (5/5 progress_data tests OK) - Krokiet compiles successfully - All CLI subcommands verified working Co-Authored-By: Claude Opus 4.6 (1M context) --- czkawka_cli/src/progress.rs | 6 +----- czkawka_pyside6/app/progress_widget.py | 7 +++++-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/czkawka_cli/src/progress.rs b/czkawka_cli/src/progress.rs index 6156e3daa..f0b4f77a6 100644 --- a/czkawka_cli/src/progress.rs +++ b/czkawka_cli/src/progress.rs @@ -122,11 +122,7 @@ pub(crate) fn connect_progress_json(progress_receiver: &Receiver) }; if let Ok(json) = serde_json::to_string(&progress_data) { - // Wrap in an object that includes the human-readable stage name - let _ = writeln!( - stderr, - "{{\"progress\":{json},\"stage_name\":\"{stage_name}\"}}" - ); + let _ = writeln!(stderr, "{{\"progress\":{json},\"stage_name\":\"{stage_name}\"}}"); } } } diff --git a/czkawka_pyside6/app/progress_widget.py b/czkawka_pyside6/app/progress_widget.py index 9c1de28d3..ecc309745 100644 --- a/czkawka_pyside6/app/progress_widget.py +++ b/czkawka_pyside6/app/progress_widget.py @@ -161,8 +161,11 @@ def update_progress(self, progress: ScanProgress): b_checked = progress.bytes_checked b_to_check = progress.bytes_to_check - # ── Stage label ── - self._stage_label.setText(stage_name) + # ── Stage label with stage index ── + if max_idx > 0: + self._stage_label.setText(f"[{idx + 1}/{max_idx + 1}] {stage_name}") + else: + self._stage_label.setText(stage_name) # ── Update step indicators using stage index ── if max_idx > 0: From b83e8e982422d29a2f2a52129d07dc5427aefccc Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 10:45:29 +0100 Subject: [PATCH 20/49] Fix clippy use_self warnings in Commands::get_json_progress Co-Authored-By: Claude Opus 4.6 (1M context) --- czkawka_cli/src/commands.rs | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/czkawka_cli/src/commands.rs b/czkawka_cli/src/commands.rs index 9c73b5913..629368146 100644 --- a/czkawka_cli/src/commands.rs +++ b/czkawka_cli/src/commands.rs @@ -118,20 +118,20 @@ pub enum Commands { impl Commands { pub fn get_json_progress(&self) -> bool { match self { - Commands::Duplicates(a) => a.common_cli_items.json_progress, - Commands::EmptyFolders(a) => a.common_cli_items.json_progress, - Commands::BiggestFiles(a) => a.common_cli_items.json_progress, - Commands::EmptyFiles(a) => a.common_cli_items.json_progress, - Commands::Temporary(a) => a.common_cli_items.json_progress, - Commands::SimilarImages(a) => a.common_cli_items.json_progress, - Commands::SameMusic(a) => a.common_cli_items.json_progress, - Commands::InvalidSymlinks(a) => a.common_cli_items.json_progress, - Commands::BrokenFiles(a) => a.common_cli_items.json_progress, - Commands::SimilarVideos(a) => a.common_cli_items.json_progress, - Commands::BadExtensions(a) => a.common_cli_items.json_progress, - Commands::BadNames(a) => a.common_cli_items.json_progress, - Commands::VideoOptimizer(a) => a.common_cli_items.json_progress, - Commands::ExifRemover(a) => a.common_cli_items.json_progress, + Self::Duplicates(a) => a.common_cli_items.json_progress, + Self::EmptyFolders(a) => a.common_cli_items.json_progress, + Self::BiggestFiles(a) => a.common_cli_items.json_progress, + Self::EmptyFiles(a) => a.common_cli_items.json_progress, + Self::Temporary(a) => a.common_cli_items.json_progress, + Self::SimilarImages(a) => a.common_cli_items.json_progress, + Self::SameMusic(a) => a.common_cli_items.json_progress, + Self::InvalidSymlinks(a) => a.common_cli_items.json_progress, + Self::BrokenFiles(a) => a.common_cli_items.json_progress, + Self::SimilarVideos(a) => a.common_cli_items.json_progress, + Self::BadExtensions(a) => a.common_cli_items.json_progress, + Self::BadNames(a) => a.common_cli_items.json_progress, + Self::VideoOptimizer(a) => a.common_cli_items.json_progress, + Self::ExifRemover(a) => a.common_cli_items.json_progress, } } } From 69b932f559aebde81bc2ed6b968319cc560aad3d Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 11:03:50 +0100 Subject: [PATCH 21/49] Add resizable/sortable columns, load results, fix group header spanning Results table improvements: - Columns are resizable (drag header edges) with sensible defaults - Click column header to sort (ascending/descending toggle) - Sorting works within groups for grouped tools (duplicates, etc.) - Numeric columns (Size, Date) sort by actual values, not strings - Sort indicator arrow shown in header Group header fix: - Group headers now span across all columns (merged cell effect) - setFirstColumnSpanned called after adding item to tree Load results: - New "Load" button in action bar to load previously saved JSON results - Supports both PySide6 save format and raw czkawka_cli JSON output - Save format now preserves group structure, checked state, and group IDs Co-Authored-By: Claude Opus 4.6 (1M context) --- czkawka_pyside6/app/action_buttons.py | 9 ++ czkawka_pyside6/app/dialogs/save_dialog.py | 133 +++++++++++++++-- czkawka_pyside6/app/main_window.py | 14 ++ czkawka_pyside6/app/results_view.py | 166 ++++++++++++++++++--- 4 files changed, 290 insertions(+), 32 deletions(-) diff --git a/czkawka_pyside6/app/action_buttons.py b/czkawka_pyside6/app/action_buttons.py index ef39a3947..241878f7b 100644 --- a/czkawka_pyside6/app/action_buttons.py +++ b/czkawka_pyside6/app/action_buttons.py @@ -22,6 +22,7 @@ class ActionButtons(QWidget): delete_clicked = Signal() move_clicked = Signal() save_clicked = Signal() + load_clicked = Signal() sort_clicked = Signal() hardlink_clicked = Signal() symlink_clicked = Signal() @@ -86,6 +87,14 @@ def _setup_ui(self): self._save_btn.clicked.connect(self.save_clicked.emit) layout.addWidget(self._save_btn) + # Load button + from .icons import icon_dir + self._load_btn = QPushButton(icon_dir(18), " Load") + self._load_btn.setIconSize(ICON_SIZE) + self._load_btn.setToolTip("Load previously saved results") + self._load_btn.clicked.connect(self.load_clicked.emit) + layout.addWidget(self._load_btn) + # Sort button self._sort_btn = QPushButton(icon_sort(18), " Sort") self._sort_btn.setIconSize(ICON_SIZE) diff --git a/czkawka_pyside6/app/dialogs/save_dialog.py b/czkawka_pyside6/app/dialogs/save_dialog.py index d8fa614e6..8df154bb5 100644 --- a/czkawka_pyside6/app/dialogs/save_dialog.py +++ b/czkawka_pyside6/app/dialogs/save_dialog.py @@ -1,11 +1,13 @@ -from PySide6.QtWidgets import ( - QDialog, QVBoxLayout, QLabel, QDialogButtonBox, - QCheckBox, QFileDialog -) +import json +from pathlib import Path + +from PySide6.QtWidgets import QFileDialog + +from ..models import ResultEntry class SaveDialog: - """Save results to file (uses native file dialog).""" + """Save/load results to/from file.""" @staticmethod def save(parent, results: list, save_as_json: bool = False) -> bool: @@ -13,24 +15,30 @@ def save(parent, results: list, save_as_json: bool = False) -> bool: filter_str = "JSON Files (*.json);;All Files (*)" default_ext = ".json" else: - filter_str = "Text Files (*.txt);;All Files (*)" + filter_str = "Text Files (*.txt);;JSON Files (*.json);;All Files (*)" default_ext = ".txt" - path, _ = QFileDialog.getSaveFileName( + path, selected_filter = QFileDialog.getSaveFileName( parent, "Save Results", f"results{default_ext}", filter_str ) if not path: return False + use_json = save_as_json or path.endswith(".json") or "JSON" in selected_filter + try: - import json - if save_as_json: + if use_json: data = [] for entry in results: - if not entry.header_row: - # Filter out internal keys - values = {k: v for k, v in entry.values.items() - if not k.startswith("__")} + if entry.header_row: + data.append({ + "__header": entry.values.get("__header", ""), + "__group_id": entry.group_id, + }) + else: + values = dict(entry.values) + values["__group_id"] = entry.group_id + values["__checked"] = entry.checked data.append(values) with open(path, "w") as f: json.dump(data, f, indent=2) @@ -46,3 +54,102 @@ def save(parent, results: list, save_as_json: bool = False) -> bool: return True except OSError: return False + + @staticmethod + def load(parent) -> list[ResultEntry] | None: + """Load results from a previously saved JSON file. + + Returns list of ResultEntry on success, None on cancel/error. + """ + path, _ = QFileDialog.getOpenFileName( + parent, "Load Results", + "", + "JSON Files (*.json);;All Files (*)" + ) + if not path: + return None + + try: + with open(path) as f: + data = json.load(f) + except (json.JSONDecodeError, OSError): + return None + + if not isinstance(data, list): + # Could be czkawka_cli dict format {size: [[entries]]} + return _parse_cli_json(data) + + results = [] + for item in data: + if not isinstance(item, dict): + continue + + if "__header" in item: + results.append(ResultEntry( + values={"__header": item["__header"]}, + header_row=True, + group_id=item.get("__group_id", 0), + )) + else: + group_id = item.pop("__group_id", 0) + checked = item.pop("__checked", False) + results.append(ResultEntry( + values=item, + checked=checked, + group_id=group_id, + )) + + return results if results else None + + +def _parse_cli_json(data: dict) -> list[ResultEntry] | None: + """Parse raw czkawka_cli JSON output (dict of size buckets or flat list).""" + results = [] + group_id = 0 + + for key, groups in data.items(): + if not isinstance(groups, list): + continue + for group in groups: + if not isinstance(group, list) or len(group) == 0: + continue + + total_size = sum(e.get("size", 0) for e in group if isinstance(e, dict)) + results.append(ResultEntry( + values={"__header": f"Group {group_id + 1} ({len(group)} files)"}, + header_row=True, + group_id=group_id, + )) + + for entry in group: + if not isinstance(entry, dict): + continue + path = entry.get("path", "") + p = Path(path) + values = { + "File Name": p.name, + "Path": str(p.parent), + "Size": _format_size(entry.get("size", 0)), + "Modification Date": str(entry.get("modified_date", "")), + "Hash": entry.get("hash", ""), + "__full_path": path, + "__size_bytes": entry.get("size", 0), + "__modified_date_ts": entry.get("modified_date", 0), + } + results.append(ResultEntry(values=values, group_id=group_id)) + + group_id += 1 + + return results if results else None + + +def _format_size(size_bytes: int) -> str: + if size_bytes == 0: + return "0 B" + units = ["B", "KB", "MB", "GB", "TB"] + i = 0 + size = float(size_bytes) + while size >= 1024 and i < len(units) - 1: + size /= 1024 + i += 1 + return f"{size:.1f} {units[i]}" if i > 0 else f"{int(size)} B" diff --git a/czkawka_pyside6/app/main_window.py b/czkawka_pyside6/app/main_window.py index e6168badc..5f7ebda4f 100644 --- a/czkawka_pyside6/app/main_window.py +++ b/czkawka_pyside6/app/main_window.py @@ -139,6 +139,7 @@ def _connect_signals(self): self._action_buttons.delete_clicked.connect(self._show_delete_dialog) self._action_buttons.move_clicked.connect(self._show_move_dialog) self._action_buttons.save_clicked.connect(self._save_results) + self._action_buttons.load_clicked.connect(self._load_results) self._action_buttons.sort_clicked.connect(self._show_sort_dialog) self._action_buttons.hardlink_clicked.connect(self._create_hardlinks) self._action_buttons.symlink_clicked.connect(self._create_symlinks) @@ -316,6 +317,19 @@ def _save_results(self): if success: self._status_label.setText("Results saved successfully") + def _load_results(self): + results = SaveDialog.load(self) + if results is None: + return + tab = self._state.active_tab + self._state.set_results(tab, results) + self._results_view.set_results(results) + self._action_buttons.set_has_results( + any(not r.header_row for r in results) + ) + count = sum(1 for r in results if not r.header_row) + self._status_label.setText(f"Loaded {count} entries from file") + def _show_sort_dialog(self): columns = TAB_COLUMNS.get(self._state.active_tab, []) if not columns: diff --git a/czkawka_pyside6/app/results_view.py b/czkawka_pyside6/app/results_view.py index 6c7a37eb9..c07be7add 100644 --- a/czkawka_pyside6/app/results_view.py +++ b/czkawka_pyside6/app/results_view.py @@ -27,6 +27,8 @@ def __init__(self, parent=None): super().__init__(parent) self._active_tab = ActiveTab.DUPLICATE_FILES self._results: list[ResultEntry] = [] + self._sort_column = -1 + self._sort_order = Qt.AscendingOrder self._setup_ui() def _ensure_header_colors(self): @@ -34,16 +36,16 @@ def _ensure_header_colors(self): if self._header_colors_ready: return from PySide6.QtWidgets import QApplication + from PySide6.QtGui import QPalette palette = QApplication.instance().palette() - # Use a midpoint between window and highlight for header background - win = palette.color(palette.Window) - hi = palette.color(palette.Highlight) + win = palette.color(QPalette.ColorRole.Window) + hi = palette.color(QPalette.ColorRole.Highlight) self.HEADER_BG = QColor( (win.red() + hi.red()) // 2, (win.green() + hi.green()) // 2, (win.blue() + hi.blue()) // 2, ) - self.HEADER_FG = palette.color(palette.HighlightedText) + self.HEADER_FG = palette.color(QPalette.ColorRole.HighlightedText) self._header_colors_ready = True def _setup_ui(self): @@ -69,28 +71,53 @@ def _setup_ui(self): self._tree.customContextMenuRequested.connect(self._on_context_menu) self._tree.itemChanged.connect(self._on_item_changed) self._tree.itemDoubleClicked.connect(self._on_item_double_clicked) + + # Sortable: click header to sort + self._tree.setSortingEnabled(False) # We handle sorting manually + header = self._tree.header() + header.setSectionsClickable(True) + header.sectionClicked.connect(self._on_header_clicked) + # Resizable columns + header.setSectionResizeMode(QHeaderView.Interactive) + header.setStretchLastSection(True) + layout.addWidget(self._tree) def set_active_tab(self, tab: ActiveTab): self._active_tab = tab + self._sort_column = -1 columns = TAB_COLUMNS.get(tab, ["Selection", "File Name", "Path"]) + self._tree.setColumnCount(len(columns)) self._tree.setHeaderLabels(columns) header = self._tree.header() - for i in range(len(columns)): - if columns[i] == "Path": - header.setSectionResizeMode(i, QHeaderView.Stretch) - else: - header.setSectionResizeMode(i, QHeaderView.ResizeToContents) + # All columns interactive (resizable), last one stretches + header.setSectionResizeMode(QHeaderView.Interactive) + header.setStretchLastSection(True) + # Give Path columns more initial space + for i, col in enumerate(columns): + if col == "Path": + header.resizeSection(i, 300) + elif col == "Selection": + header.resizeSection(i, 30) + elif col in ("Size", "Hash", "Modification Date"): + header.resizeSection(i, 140) + elif col == "File Name": + header.resizeSection(i, 200) def set_results(self, results: list[ResultEntry]): self._ensure_header_colors() self._results = results + self._rebuild_tree() + self._update_summary() + + def _rebuild_tree(self): + """Rebuild tree items from self._results.""" self._tree.blockSignals(True) self._tree.clear() columns = TAB_COLUMNS.get(self._active_tab, ["Selection", "File Name", "Path"]) - for entry in results: + for entry in self._results: if entry.header_row: item = QTreeWidgetItem() header_text = entry.values.get("__header", "Group") @@ -105,14 +132,15 @@ def set_results(self, results: list[ResultEntry]): item.setFlags(item.flags() & ~Qt.ItemIsUserCheckable) item.setData(0, Qt.UserRole, entry) self._tree.addTopLevelItem(item) + # Span header across all columns (must be called after adding to tree) + item.setFirstColumnSpanned(True) else: item = QTreeWidgetItem() - # First column is checkbox (Selection) item.setFlags(item.flags() | Qt.ItemIsUserCheckable) item.setCheckState(0, Qt.Checked if entry.checked else Qt.Unchecked) for col_idx, col_name in enumerate(columns): - if col_idx == 0: # Selection column + if col_idx == 0: continue value = entry.values.get(col_name, "") item.setText(col_idx, str(value)) @@ -121,7 +149,108 @@ def set_results(self, results: list[ResultEntry]): self._tree.addTopLevelItem(item) self._tree.blockSignals(False) - self._update_summary() + + # ── Sorting ────────────────────────────────────────────── + + def _on_header_clicked(self, logical_index: int): + """Sort by column when header is clicked. Toggle ascending/descending.""" + if logical_index == 0: + return # Don't sort by checkbox column + + if self._sort_column == logical_index: + # Toggle order + self._sort_order = ( + Qt.DescendingOrder if self._sort_order == Qt.AscendingOrder + else Qt.AscendingOrder + ) + else: + self._sort_column = logical_index + self._sort_order = Qt.AscendingOrder + + columns = TAB_COLUMNS.get(self._active_tab, []) + col_name = columns[logical_index] if logical_index < len(columns) else "" + + # Update header sort indicator + self._tree.header().setSortIndicator(logical_index, self._sort_order) + self._tree.header().setSortIndicatorShown(True) + + ascending = self._sort_order == Qt.AscendingOrder + + if self._active_tab in GROUPED_TABS: + self._sort_within_groups(col_name, ascending) + else: + self._sort_flat(col_name, ascending) + + def _sort_key(self, entry: ResultEntry, col_name: str): + """Return a sort key for a result entry by column name.""" + # Use numeric values for known numeric columns + if col_name in ("Size",): + return entry.values.get("__size_bytes", 0) + if col_name in ("Modification Date",): + return entry.values.get("__modified_date_ts", 0) + if col_name in ("Similarity", "Bitrate", "Year", "Length"): + raw = entry.values.get(col_name, "") + try: + return float(str(raw).replace(",", "")) + except (ValueError, TypeError): + return 0 + # Default: string comparison (case-insensitive) + return str(entry.values.get(col_name, "")).lower() + + def _sort_flat(self, col_name: str, ascending: bool): + """Sort flat (non-grouped) results.""" + self._results.sort( + key=lambda e: self._sort_key(e, col_name), + reverse=not ascending, + ) + self._rebuild_tree() + + def _sort_within_groups(self, col_name: str, ascending: bool): + """Sort entries within each group, keeping group headers in place.""" + sorted_results = [] + current_group = [] + current_header = None + + for entry in self._results: + if entry.header_row: + if current_header is not None: + current_group.sort( + key=lambda e: self._sort_key(e, col_name), + reverse=not ascending, + ) + sorted_results.append(current_header) + sorted_results.extend(current_group) + current_header = entry + current_group = [] + else: + current_group.append(entry) + + # Last group + if current_header is not None: + current_group.sort( + key=lambda e: self._sort_key(e, col_name), + reverse=not ascending, + ) + sorted_results.append(current_header) + sorted_results.extend(current_group) + + self._results = sorted_results + self._rebuild_tree() + + def sort_by_column(self, column: int, ascending: bool = True): + """Public API for sorting (used by sort dialog).""" + self._sort_column = column + self._sort_order = Qt.AscendingOrder if ascending else Qt.DescendingOrder + columns = TAB_COLUMNS.get(self._active_tab, []) + col_name = columns[column] if column < len(columns) else "" + self._tree.header().setSortIndicator(column, self._sort_order) + self._tree.header().setSortIndicatorShown(True) + if self._active_tab in GROUPED_TABS: + self._sort_within_groups(col_name, ascending) + else: + self._sort_flat(col_name, ascending) + + # ── Item events ────────────────────────────────────────── def _on_item_changed(self, item, column): if column == 0: @@ -188,6 +317,8 @@ def _open_folder(self, entry: ResultEntry): def _set_check(self, item, checked): item.setCheckState(0, Qt.Checked if checked else Qt.Unchecked) + # ── Summary / selection ────────────────────────────────── + def _update_summary(self): total = sum(1 for r in self._results if not r.header_row) groups = sum(1 for r in self._results if r.header_row) @@ -243,13 +374,11 @@ def _invert_selection(self): item.setCheckState(0, Qt.Checked if entry.checked else Qt.Unchecked) def _select_by_group_criteria(self, mode: SelectMode): - # First unselect all self._select_all(False) if self._active_tab not in GROUPED_TABS: return - # Group entries by group_id groups: dict[int, list[tuple[int, ResultEntry]]] = {} for i in range(self._tree.topLevelItemCount()): item = self._tree.topLevelItem(i) @@ -275,15 +404,12 @@ def _select_by_group_criteria(self, mode: SelectMode): elif mode == SelectMode.SELECT_LONGEST_PATH: best_idx = max(range(len(items)), key=lambda j: len(items[j][1].values.get("__full_path", ""))) - # Select all EXCEPT the best (the one to keep) for j, (tree_idx, entry) in enumerate(items): if j != best_idx: entry.checked = True self._tree.topLevelItem(tree_idx).setCheckState(0, Qt.Checked) - def sort_by_column(self, column: int, ascending: bool = True): - order = Qt.AscendingOrder if ascending else Qt.DescendingOrder - self._tree.sortItems(column, order) + # ── Public accessors ───────────────────────────────────── def get_checked_entries(self) -> list[ResultEntry]: return [r for r in self._results if r.checked and not r.header_row] @@ -294,5 +420,7 @@ def get_all_entries(self) -> list[ResultEntry]: def clear(self): self._results = [] self._tree.clear() + self._sort_column = -1 + self._tree.header().setSortIndicatorShown(False) self._summary_label.setText("No results") self._selection_label.setText("") From 848ef3de64e7052aec4cdca9849e3741ed0a1ea2 Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 18:26:33 +0100 Subject: [PATCH 22/49] Fix QA issues, add dry-run mode, improve progress accuracy QA fixes across PySide6 frontend and CLI: - Fix mousePressEvent in left_panel.py (use eventFilter instead of lambda) - Fix deprecated menu.exec_() -> menu.exec() - Fix stdout deadlock: PIPE -> DEVNULL for unused stdout - Fix final progress lines silently discarded after process exit - Fix JSON injection in progress.rs stage_name serialization - Fix temp file leak with try/finally cleanup - Remove .svg from preview (QPixmap can't load SVG) - Remove hardcoded dev path from icons.py - Add settings_changed signal to video/music sliders - Add error handling for subprocess open-file/folder calls - Add JSON parse error logging in backend - Remove unused QMessageBox import New features: - Dry-run checkbox in Delete and Move dialogs - Selection size display (selected/total with human-readable sizes) - Accurate file count: replace stale cached estimate with live background os.walk counter during collection phase Co-Authored-By: Claude Opus 4.6 (1M context) --- czkawka_cli/src/progress.rs | 4 +- czkawka_pyside6/app/backend.py | 152 ++++++++++--------- czkawka_pyside6/app/dialogs/delete_dialog.py | 12 +- czkawka_pyside6/app/dialogs/move_dialog.py | 7 + czkawka_pyside6/app/icons.py | 1 - czkawka_pyside6/app/left_panel.py | 15 +- czkawka_pyside6/app/main_window.py | 26 +++- czkawka_pyside6/app/preview_panel.py | 2 +- czkawka_pyside6/app/progress_widget.py | 105 +++++++------ czkawka_pyside6/app/results_view.py | 57 +++++-- czkawka_pyside6/app/tool_settings.py | 12 +- 11 files changed, 241 insertions(+), 152 deletions(-) diff --git a/czkawka_cli/src/progress.rs b/czkawka_cli/src/progress.rs index f0b4f77a6..76a7e9291 100644 --- a/czkawka_cli/src/progress.rs +++ b/czkawka_cli/src/progress.rs @@ -122,7 +122,9 @@ pub(crate) fn connect_progress_json(progress_receiver: &Receiver) }; if let Ok(json) = serde_json::to_string(&progress_data) { - let _ = writeln!(stderr, "{{\"progress\":{json},\"stage_name\":\"{stage_name}\"}}"); + if let Ok(escaped_name) = serde_json::to_string(&stage_name) { + let _ = writeln!(stderr, "{{\"progress\":{json},\"stage_name\":{escaped_name}}}"); + } } } } diff --git a/czkawka_pyside6/app/backend.py b/czkawka_pyside6/app/backend.py index 3d922c176..e0e508a75 100644 --- a/czkawka_pyside6/app/backend.py +++ b/czkawka_pyside6/app/backend.py @@ -42,40 +42,40 @@ def run(self): with tempfile.NamedTemporaryFile(suffix=".json", delete=False, mode="w") as f: json_output_path = f.name - # Add JSON output flag (use long form to avoid conflict with -C in broken files) - cmd.extend(["--compact-file-to-save", json_output_path]) - # Enable JSON progress on stderr, suppress text output - cmd.extend(["--json-progress", "-N", "-M"]) - - self._process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) + try: + # Add JSON output flag (use long form to avoid conflict with -C in broken files) + cmd.extend(["--compact-file-to-save", json_output_path]) + # Enable JSON progress on stderr, suppress text output + cmd.extend(["--json-progress", "-N", "-M"]) + + self._process = subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) - # Read stderr line-by-line for JSON progress data - self._monitor_process_json(json_output_path) + # Read stderr line-by-line for JSON progress data + self._monitor_process_json(json_output_path) - if self._cancelled: - self._cleanup(json_output_path) - return + if self._cancelled: + return - # Check for CLI errors - returncode = self._process.returncode - if returncode is not None and returncode != 0 and returncode != 11: - error_msg = f"czkawka_cli exited with code {returncode}" - self.error.emit(error_msg) - self._cleanup(json_output_path) - return + # Check for CLI errors (exit code 11 = results found, not an error) + returncode = self._process.returncode + if returncode is not None and returncode != 0 and returncode != 11: + error_msg = f"czkawka_cli exited with code {returncode}" + self.error.emit(error_msg) + return - # Parse results - self.progress.emit(ScanProgress( - step_name="Loading results...", current=0, total=0 - )) - results = self._parse_results(json_output_path) - self.finished.emit(self.tab, results) - self._cleanup(json_output_path) + # Parse results + self.progress.emit(ScanProgress( + step_name="Loading results...", current=0, total=0 + )) + results = self._parse_results(json_output_path) + self.finished.emit(self.tab, results) + finally: + self._cleanup(json_output_path) except FileNotFoundError: self.error.emit( @@ -97,7 +97,7 @@ def cancel(self): def _monitor_process_json(self, json_path: str): """Read JSON progress lines from stderr in real-time.""" - import time + import time, logging while self._process.poll() is None: if self._cancelled: @@ -108,43 +108,40 @@ def _monitor_process_json(self, json_path: str): time.sleep(0.05) continue - line = line.strip() - if not line: - continue - - try: - data = json.loads(line) - progress = data.get("progress", {}) - stage_name = data.get("stage_name", "Processing...") + self._parse_progress_line(line.strip()) - entries_checked = progress.get("entries_checked", 0) - entries_to_check = progress.get("entries_to_check", 0) - bytes_checked = progress.get("bytes_checked", 0) - bytes_to_check = progress.get("bytes_to_check", 0) - current_stage_idx = progress.get("current_stage_idx", 0) - max_stage_idx = progress.get("max_stage_idx", 0) - - self.progress.emit(ScanProgress( - step_name=stage_name, - current=0, - total=0, - current_size=bytes_checked, - stage_name=stage_name, - current_stage_idx=current_stage_idx, - max_stage_idx=max_stage_idx, - entries_checked=entries_checked, - entries_to_check=entries_to_check, - bytes_checked=bytes_checked, - bytes_to_check=bytes_to_check, - )) - except (json.JSONDecodeError, KeyError, TypeError): - continue - - # Drain remaining stderr + # Drain and parse remaining stderr after process exits remaining = self._process.stderr.read() if remaining: for line in remaining.strip().split("\n"): - pass # Final lines already processed + self._parse_progress_line(line.strip()) + + def _parse_progress_line(self, line: str): + """Parse a single JSON progress line from stderr.""" + if not line: + return + try: + data = json.loads(line) + progress = data.get("progress", {}) + stage_name = data.get("stage_name", "Processing...") + + self.progress.emit(ScanProgress( + step_name=stage_name, + current=0, + total=0, + current_size=progress.get("bytes_checked", 0), + stage_name=stage_name, + current_stage_idx=progress.get("current_stage_idx", 0), + max_stage_idx=progress.get("max_stage_idx", 0), + entries_checked=progress.get("entries_checked", 0), + entries_to_check=progress.get("entries_to_check", 0), + bytes_checked=progress.get("bytes_checked", 0), + bytes_to_check=progress.get("bytes_to_check", 0), + )) + except (json.JSONDecodeError, KeyError, TypeError): + import logging + if line.startswith("{"): + logging.warning("Failed to parse progress JSON: %s", line[:200]) def _cleanup(self, path: str): try: @@ -464,7 +461,8 @@ class FileOperations: """File operations: delete, move, hardlink, symlink, rename.""" @staticmethod - def delete_files(entries: list[ResultEntry], move_to_trash: bool = True) -> tuple[int, list[str]]: + def delete_files(entries: list[ResultEntry], move_to_trash: bool = True, + dry_run: bool = False) -> tuple[int, list[str]]: deleted = 0 errors = [] for entry in entries: @@ -476,6 +474,11 @@ def delete_files(entries: list[ResultEntry], move_to_trash: bool = True) -> tupl if not p.exists(): errors.append(f"File not found: {path}") continue + if dry_run: + action = "trash" if move_to_trash else "delete" + errors.append(f"[DRY RUN] Would {action}: {path}") + deleted += 1 + continue if move_to_trash: try: from send2trash import send2trash @@ -495,12 +498,14 @@ def delete_files(entries: list[ResultEntry], move_to_trash: bool = True) -> tupl @staticmethod def move_files(entries: list[ResultEntry], destination: str, - preserve_structure: bool = False, copy_mode: bool = False) -> tuple[int, list[str]]: + preserve_structure: bool = False, copy_mode: bool = False, + dry_run: bool = False) -> tuple[int, list[str]]: import shutil moved = 0 errors = [] dest = Path(destination) - dest.mkdir(parents=True, exist_ok=True) + if not dry_run: + dest.mkdir(parents=True, exist_ok=True) for entry in entries: src_path = entry.values.get("__full_path", "") @@ -509,15 +514,22 @@ def move_files(entries: list[ResultEntry], destination: str, try: src = Path(src_path) if preserve_structure: - # Keep relative directory structure rel = src.parent target_dir = dest / rel.relative_to(rel.anchor) - target_dir.mkdir(parents=True, exist_ok=True) target = target_dir / src.name else: target = dest / src.name - # Handle name conflicts + if dry_run: + action = "copy" if copy_mode else "move" + errors.append(f"[DRY RUN] Would {action}: {src_path} -> {target}") + moved += 1 + continue + + # Create dirs and handle name conflicts for real operations + if preserve_structure: + target.parent.mkdir(parents=True, exist_ok=True) + if target.exists(): stem = target.stem suffix = target.suffix diff --git a/czkawka_pyside6/app/dialogs/delete_dialog.py b/czkawka_pyside6/app/dialogs/delete_dialog.py index b82ad674d..aaae9ef39 100644 --- a/czkawka_pyside6/app/dialogs/delete_dialog.py +++ b/czkawka_pyside6/app/dialogs/delete_dialog.py @@ -1,6 +1,6 @@ from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QLabel, QDialogButtonBox, - QCheckBox, QMessageBox + QCheckBox, QStyle ) from PySide6.QtCore import Qt from PySide6.QtGui import QFont @@ -19,7 +19,7 @@ def __init__(self, count: int, move_to_trash: bool = True, parent=None): # Warning icon from system theme icon_label = QLabel() - icon = self.style().standardIcon(self.style().SP_MessageBoxWarning) + icon = self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxWarning) icon_label.setPixmap(icon.pixmap(48, 48)) icon_label.setAlignment(Qt.AlignCenter) layout.addWidget(icon_label) @@ -37,6 +37,10 @@ def __init__(self, count: int, move_to_trash: bool = True, parent=None): self._trash_cb.setChecked(move_to_trash) layout.addWidget(self._trash_cb) + # Dry run checkbox + self._dry_run_cb = QCheckBox("Dry run (preview only, no files will be deleted)") + layout.addWidget(self._dry_run_cb) + # Buttons buttons = QDialogButtonBox( QDialogButtonBox.Ok | QDialogButtonBox.Cancel @@ -49,3 +53,7 @@ def __init__(self, count: int, move_to_trash: bool = True, parent=None): @property def move_to_trash(self) -> bool: return self._trash_cb.isChecked() + + @property + def dry_run(self) -> bool: + return self._dry_run_cb.isChecked() diff --git a/czkawka_pyside6/app/dialogs/move_dialog.py b/czkawka_pyside6/app/dialogs/move_dialog.py index a26a51405..0c1c62541 100644 --- a/czkawka_pyside6/app/dialogs/move_dialog.py +++ b/czkawka_pyside6/app/dialogs/move_dialog.py @@ -37,6 +37,9 @@ def __init__(self, count: int, parent=None): self._copy_mode = QCheckBox("Copy instead of move") layout.addWidget(self._copy_mode) + self._dry_run = QCheckBox("Dry run (preview only, no files will be moved)") + layout.addWidget(self._dry_run) + # Buttons buttons = QDialogButtonBox( QDialogButtonBox.Ok | QDialogButtonBox.Cancel @@ -62,3 +65,7 @@ def preserve_structure(self) -> bool: @property def copy_mode(self) -> bool: return self._copy_mode.isChecked() + + @property + def dry_run(self) -> bool: + return self._dry_run.isChecked() diff --git a/czkawka_pyside6/app/icons.py b/czkawka_pyside6/app/icons.py index 9ec8fdace..cc274bf92 100644 --- a/czkawka_pyside6/app/icons.py +++ b/czkawka_pyside6/app/icons.py @@ -130,7 +130,6 @@ def app_logo_path() -> str: # Try relative to this file first, then absolute project path for candidate in [ Path(__file__).parent.parent.parent / "krokiet" / "icons" / "krokiet_logo.png", - Path("/mnt/developer/git/aecs4u.it/czkawka/krokiet/icons/krokiet_logo.png"), ]: if candidate.exists(): return str(candidate) diff --git a/czkawka_pyside6/app/left_panel.py b/czkawka_pyside6/app/left_panel.py index 8a8f87185..e47b4df6e 100644 --- a/czkawka_pyside6/app/left_panel.py +++ b/czkawka_pyside6/app/left_panel.py @@ -2,7 +2,7 @@ QWidget, QVBoxLayout, QListWidget, QListWidgetItem, QLabel, QPushButton, QHBoxLayout, QSizePolicy ) -from PySide6.QtCore import Signal, Qt, QSize +from PySide6.QtCore import Signal, Qt, QSize, QEvent from PySide6.QtGui import QFont, QPixmap from .models import ActiveTab, TAB_DISPLAY_NAMES, TABS_WITH_SETTINGS @@ -45,7 +45,7 @@ def _setup_ui(self): layout.setContentsMargins(6, 6, 6, 6) layout.setSpacing(4) - # Logo image (clickable) + # Logo image (clickable via event filter) logo_path = app_logo_path() if logo_path: self._logo_label = QLabel() @@ -55,7 +55,7 @@ def _setup_ui(self): self._logo_label.setAlignment(Qt.AlignCenter) self._logo_label.setCursor(Qt.PointingHandCursor) self._logo_label.setToolTip("About Czkawka") - self._logo_label.mousePressEvent = lambda _: self.about_requested.emit() + self._logo_label.installEventFilter(self) layout.addWidget(self._logo_label) else: title_label = QLabel("Czkawka") @@ -65,7 +65,8 @@ def _setup_ui(self): title_label.setFont(title_font) title_label.setAlignment(Qt.AlignCenter) title_label.setCursor(Qt.PointingHandCursor) - title_label.mousePressEvent = lambda _: self.about_requested.emit() + title_label.installEventFilter(self) + self._logo_label = title_label layout.addWidget(title_label) # Top buttons row with icons @@ -113,6 +114,12 @@ def _setup_ui(self): version_label.setEnabled(False) layout.addWidget(version_label) + def eventFilter(self, obj, event): + if obj is self._logo_label and event.type() == QEvent.Type.MouseButtonPress: + self.about_requested.emit() + return True + return super().eventFilter(obj, event) + def _on_item_changed(self, current, previous): if current: tab = current.data(Qt.UserRole) diff --git a/czkawka_pyside6/app/main_window.py b/czkawka_pyside6/app/main_window.py index 5f7ebda4f..647d53a56 100644 --- a/czkawka_pyside6/app/main_window.py +++ b/czkawka_pyside6/app/main_window.py @@ -202,7 +202,11 @@ def _start_scan(self): self._state.set_scanning(True) self._action_buttons.set_scanning(True) - self._progress.start(tab) + self._progress.start( + tab, + included_paths=self._state.settings.included_paths, + excluded_paths=self._state.settings.excluded_paths, + ) self._results_view.clear() self._status_label.setText(f"Scanning: {tab.name.replace('_', ' ').title()}...") @@ -274,15 +278,18 @@ def _show_delete_dialog(self): dialog = DeleteDialog(len(checked), self._state.settings.move_to_trash, self) if dialog.exec() == DeleteDialog.Accepted: + dry_run = dialog.dry_run deleted, errors = FileOperations.delete_files( - checked, dialog.move_to_trash + checked, dialog.move_to_trash, dry_run=dry_run ) - self._status_label.setText(f"Deleted {deleted} file(s)") + prefix = "[DRY RUN] " if dry_run else "" + self._status_label.setText(f"{prefix}Deleted {deleted} file(s)") if errors: self._bottom_panel.set_text("\n".join(errors)) self._bottom_panel.show_text() - # Refresh results - remove deleted entries - self._refresh_after_action(checked) + # Refresh results - remove deleted entries (skip on dry run) + if not dry_run: + self._refresh_after_action(checked) def _show_move_dialog(self): checked = self._results_view.get_checked_entries() @@ -295,16 +302,19 @@ def _show_move_dialog(self): if not dialog.destination: QMessageBox.warning(self, "No Destination", "Please select a destination folder.") return + dry_run = dialog.dry_run moved, errors = FileOperations.move_files( checked, dialog.destination, - dialog.preserve_structure, dialog.copy_mode + dialog.preserve_structure, dialog.copy_mode, + dry_run=dry_run ) action = "Copied" if dialog.copy_mode else "Moved" - self._status_label.setText(f"{action} {moved} file(s)") + prefix = "[DRY RUN] " if dry_run else "" + self._status_label.setText(f"{prefix}{action} {moved} file(s)") if errors: self._bottom_panel.set_text("\n".join(errors)) self._bottom_panel.show_text() - if not dialog.copy_mode: + if not dialog.copy_mode and not dry_run: self._refresh_after_action(checked) def _save_results(self): diff --git a/czkawka_pyside6/app/preview_panel.py b/czkawka_pyside6/app/preview_panel.py index 7b53bc0b2..3c53d533f 100644 --- a/czkawka_pyside6/app/preview_panel.py +++ b/czkawka_pyside6/app/preview_panel.py @@ -12,7 +12,7 @@ class PreviewPanel(QWidget): SUPPORTED_EXTENSIONS = { ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", - ".tiff", ".tif", ".ico", ".svg" + ".tiff", ".tif", ".ico", } def __init__(self, parent=None): diff --git a/czkawka_pyside6/app/progress_widget.py b/czkawka_pyside6/app/progress_widget.py index ecc309745..bd8537934 100644 --- a/czkawka_pyside6/app/progress_widget.py +++ b/czkawka_pyside6/app/progress_widget.py @@ -1,11 +1,11 @@ -import json +import os import time -from pathlib import Path +import threading from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QProgressBar, QLabel, QHBoxLayout ) -from PySide6.QtCore import Qt, QTimer, QStandardPaths +from PySide6.QtCore import Qt, QTimer from .models import ActiveTab, ScanProgress @@ -27,8 +27,9 @@ def __init__(self, parent=None): self._start_time = 0.0 self._active_tab = ActiveTab.DUPLICATE_FILES self._last_collection_count = 0 # Files found during collection phase - self._estimates: dict[str, int] = {} - self._load_estimates() + self._file_count_estimate = 0 # Live count from background walker + self._file_count_thread: threading.Thread | None = None + self._file_count_stop = threading.Event() self._setup_ui() self._timer = QTimer(self) @@ -112,11 +113,13 @@ def _setup_ui(self): # ── Public API ──────────────────────────────────────────── - def start(self, tab: ActiveTab = None): + def start(self, tab: ActiveTab = None, included_paths: list[str] | None = None, + excluded_paths: list[str] | None = None): if tab is not None: self._active_tab = tab self._start_time = time.monotonic() self._last_collection_count = 0 + self._file_count_estimate = 0 self.setVisible(True) for bar in (self._stage_bar, self._overall_bar): @@ -131,8 +134,12 @@ def start(self, tab: ActiveTab = None): self._steps_label.setText("") self._timer.start() + # Start background file count for collection-phase estimate + self._start_file_count(included_paths or [], excluded_paths or []) + def stop(self): self._timer.stop() + self._stop_file_count() elapsed = time.monotonic() - self._start_time if self._start_time else 0 self._elapsed_label.setText(f"Completed in {self._format_time(elapsed)}") @@ -145,10 +152,6 @@ def stop(self): self._stage_label.setText("Scan complete") self._steps_label.setText("") - # Save collection count for next-scan estimation - if self._last_collection_count > 0: - self._save_estimate(self._last_collection_count) - QTimer.singleShot(3000, self._auto_hide) def update_progress(self, progress: ScanProgress): @@ -175,9 +178,9 @@ def update_progress(self, progress: ScanProgress): is_collecting = (idx == 0 and to_check == 0) if is_collecting: - # Collection phase: use estimate from previous scan + # Collection phase: use live background file count as estimate self._last_collection_count = max(self._last_collection_count, checked) - estimate = self._get_estimate() + estimate = self._file_count_estimate if estimate > 0 and checked > 0: pct = min(99, int(checked * 100 / estimate)) self._stage_bar.setMaximum(100) @@ -221,8 +224,8 @@ def update_progress(self, progress: ScanProgress): if max_idx > 0: if to_check > 0: stage_frac = (b_checked / b_to_check) if b_to_check > 0 else (checked / to_check) - elif is_collecting and self._get_estimate() > 0 and checked > 0: - stage_frac = min(0.99, checked / self._get_estimate()) + elif is_collecting and self._file_count_estimate > 0 and checked > 0: + stage_frac = min(0.99, checked / self._file_count_estimate) else: stage_frac = 0 overall = (idx + min(stage_frac, 0.99)) / (max_idx + 1) @@ -234,37 +237,49 @@ def update_progress(self, progress: ScanProgress): self._overall_bar.setMaximum(0) self._overall_pct.setText("") - # ── Collection estimate persistence ─────────────────────── - - def _get_estimate_key(self) -> str: - """Key for the estimate cache based on active tab.""" - return self._active_tab.name - - def _get_estimate(self) -> int: - return self._estimates.get(self._get_estimate_key(), 0) - - @staticmethod - def _estimate_file_path() -> Path: - config_dir = QStandardPaths.writableLocation(QStandardPaths.AppConfigLocation) - base = Path(config_dir) if config_dir else Path.home() / ".config" / "czkawka" - return base / "scan_estimates.json" - - def _save_estimate(self, count: int): - self._estimates[self._get_estimate_key()] = count - try: - path = self._estimate_file_path() - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(self._estimates)) - except OSError: - pass - - def _load_estimates(self): - try: - path = self._estimate_file_path() - if path.exists(): - self._estimates = json.loads(path.read_text()) - except (json.JSONDecodeError, OSError): - self._estimates = {} + # ── Background file counter ───────────────────────────── + + def _start_file_count(self, included: list[str], excluded: list[str]): + """Start a background thread that walks included dirs to count files.""" + self._stop_file_count() + self._file_count_stop.clear() + self._file_count_estimate = 0 + + if not included: + return + + excluded_set = set(os.path.realpath(p) for p in excluded) + + def _count(): + count = 0 + for root_dir in included: + if self._file_count_stop.is_set(): + return + try: + for dirpath, dirnames, filenames in os.walk(root_dir): + if self._file_count_stop.is_set(): + return + real = os.path.realpath(dirpath) + # Prune excluded subtrees + dirnames[:] = [ + d for d in dirnames + if os.path.realpath(os.path.join(dirpath, d)) not in excluded_set + ] + if real in excluded_set: + continue + count += len(filenames) + self._file_count_estimate = count + except OSError: + continue + + self._file_count_thread = threading.Thread(target=_count, daemon=True) + self._file_count_thread.start() + + def _stop_file_count(self): + self._file_count_stop.set() + if self._file_count_thread is not None: + self._file_count_thread.join(timeout=1) + self._file_count_thread = None # ── Step indicator ──────────────────────────────────────── diff --git a/czkawka_pyside6/app/results_view.py b/czkawka_pyside6/app/results_view.py index c07be7add..5b564ecd2 100644 --- a/czkawka_pyside6/app/results_view.py +++ b/czkawka_pyside6/app/results_view.py @@ -288,53 +288,78 @@ def _on_context_menu(self, pos): deselect_action.triggered.connect(lambda: self._set_check(item, False)) menu.addAction(deselect_action) - menu.exec_(self._tree.viewport().mapToGlobal(pos)) + menu.exec(self._tree.viewport().mapToGlobal(pos)) def _open_file(self, entry: ResultEntry): import subprocess, sys path = entry.values.get("__full_path", "") - if path: + if not path: + return + try: if sys.platform == "linux": - subprocess.Popen(["xdg-open", path]) + subprocess.Popen(["xdg-open", path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) elif sys.platform == "darwin": - subprocess.Popen(["open", path]) + subprocess.Popen(["open", path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) else: - subprocess.Popen(["start", path], shell=True) + subprocess.Popen(["cmd", "/c", "start", "", path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except OSError: + pass def _open_folder(self, entry: ResultEntry): import subprocess, sys from pathlib import Path path = entry.values.get("__full_path", "") - if path: - folder = str(Path(path).parent) + if not path: + return + folder = str(Path(path).parent) + try: if sys.platform == "linux": - subprocess.Popen(["xdg-open", folder]) + subprocess.Popen(["xdg-open", folder], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) elif sys.platform == "darwin": - subprocess.Popen(["open", folder]) + subprocess.Popen(["open", folder], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) else: - subprocess.Popen(["explorer", folder], shell=True) + subprocess.Popen(["explorer", folder], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except OSError: + pass def _set_check(self, item, checked): item.setCheckState(0, Qt.Checked if checked else Qt.Unchecked) # ── Summary / selection ────────────────────────────────── + @staticmethod + def _format_size(size_bytes: int) -> str: + for unit in ("B", "KB", "MB", "GB", "TB"): + if abs(size_bytes) < 1024: + return f"{size_bytes:.1f} {unit}" if unit != "B" else f"{size_bytes} B" + size_bytes /= 1024 + return f"{size_bytes:.1f} PB" + def _update_summary(self): - total = sum(1 for r in self._results if not r.header_row) + entries = [r for r in self._results if not r.header_row] + total = len(entries) + total_size = sum(r.values.get("__size_bytes", 0) for r in entries) groups = sum(1 for r in self._results if r.header_row) + size_str = self._format_size(total_size) if self._active_tab in GROUPED_TABS and groups > 0: - self._summary_label.setText(f"Found {total} files in {groups} groups") + self._summary_label.setText(f"Found {total} files ({size_str}) in {groups} groups") elif total > 0: - self._summary_label.setText(f"Found {total} entries") + self._summary_label.setText(f"Found {total} entries ({size_str})") else: self._summary_label.setText("No results") self._update_selection_count() def _update_selection_count(self): - selected = sum(1 for r in self._results if r.checked and not r.header_row) - total = sum(1 for r in self._results if not r.header_row) + entries = [r for r in self._results if not r.header_row] + total = len(entries) + total_size = sum(r.values.get("__size_bytes", 0) for r in entries) + checked = [r for r in entries if r.checked] + selected = len(checked) + selected_size = sum(r.values.get("__size_bytes", 0) for r in checked) if selected > 0: - self._selection_label.setText(f"Selected: {selected}/{total}") + self._selection_label.setText( + f"Selected: {selected}/{total} ({self._format_size(selected_size)}/{self._format_size(total_size)})" + ) else: self._selection_label.setText("") self.selection_changed.emit(selected) diff --git a/czkawka_pyside6/app/tool_settings.py b/czkawka_pyside6/app/tool_settings.py index 87d4249f0..3426d2b59 100644 --- a/czkawka_pyside6/app/tool_settings.py +++ b/czkawka_pyside6/app/tool_settings.py @@ -201,7 +201,8 @@ def _create_similar_videos_panel(self) -> QWidget: self._vid_diff_label = QLabel(str(self._ts.vid_max_difference)) self._vid_diff_slider.valueChanged.connect(lambda v: ( setattr(self._ts, 'vid_max_difference', v), - self._vid_diff_label.setText(str(v)) + self._vid_diff_label.setText(str(v)), + self.settings_changed.emit(), )) diff_layout.addWidget(self._vid_diff_slider) diff_layout.addWidget(self._vid_diff_label) @@ -215,7 +216,8 @@ def _create_similar_videos_panel(self) -> QWidget: self._vid_skip_label = QLabel(str(self._ts.vid_skip_forward)) self._vid_skip_slider.valueChanged.connect(lambda v: ( setattr(self._ts, 'vid_skip_forward', v), - self._vid_skip_label.setText(str(v)) + self._vid_skip_label.setText(str(v)), + self.settings_changed.emit(), )) skip_layout.addWidget(self._vid_skip_slider) skip_layout.addWidget(self._vid_skip_label) @@ -229,7 +231,8 @@ def _create_similar_videos_panel(self) -> QWidget: self._vid_dur_label = QLabel(str(self._ts.vid_duration)) self._vid_dur_slider.valueChanged.connect(lambda v: ( setattr(self._ts, 'vid_duration', v), - self._vid_dur_label.setText(str(v)) + self._vid_dur_label.setText(str(v)), + self.settings_changed.emit(), )) dur_layout.addWidget(self._vid_dur_slider) dur_layout.addWidget(self._vid_dur_label) @@ -289,7 +292,8 @@ def _create_similar_music_panel(self) -> QWidget: self._music_diff_label = QLabel(f"{self._ts.music_max_difference:.1f}") self._music_diff_slider.valueChanged.connect(lambda v: ( setattr(self._ts, 'music_max_difference', v / 10.0), - self._music_diff_label.setText(f"{v / 10.0:.1f}") + self._music_diff_label.setText(f"{v / 10.0:.1f}"), + self.settings_changed.emit(), )) diff_layout.addWidget(self._music_diff_slider) diff_layout.addWidget(self._music_diff_label) From b5a8a1ced262f5cdbc4fd38cbe0d1b8021cef7c5 Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Tue, 24 Mar 2026 09:18:24 +0100 Subject: [PATCH 23/49] Fix clippy collapsible_if warning in progress.rs Collapse nested if-let into a single condition using let-chains. Co-Authored-By: Claude Opus 4.6 (1M context) --- czkawka_cli/src/progress.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/czkawka_cli/src/progress.rs b/czkawka_cli/src/progress.rs index 76a7e9291..d28dd40d9 100644 --- a/czkawka_cli/src/progress.rs +++ b/czkawka_cli/src/progress.rs @@ -121,10 +121,10 @@ pub(crate) fn connect_progress_json(progress_receiver: &Receiver) get_progress_message(&progress_data) }; - if let Ok(json) = serde_json::to_string(&progress_data) { - if let Ok(escaped_name) = serde_json::to_string(&stage_name) { - let _ = writeln!(stderr, "{{\"progress\":{json},\"stage_name\":{escaped_name}}}"); - } + if let Ok(json) = serde_json::to_string(&progress_data) + && let Ok(escaped_name) = serde_json::to_string(&stage_name) + { + let _ = writeln!(stderr, "{{\"progress\":{json},\"stage_name\":{escaped_name}}}"); } } } From 6312dabb0f9ce8b7adb71e02668767b92d0ee44d Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 09:57:45 +0100 Subject: [PATCH 24/49] Add PySide6/Qt frontend and CLI --json-progress flag (#1847) Add a new PySide6/Qt 6 GUI frontend (czkawka_pyside6) with feature parity with the Krokiet (Slint) interface. Uses czkawka_cli as its backend via subprocess with JSON output for results and --json-progress for real-time progress data. PySide6 frontend features: - All 14 scanning tools with per-tool settings - Two-bar progress (current stage + overall) with entry/byte counts - Dark theme with Krokiet SVG icons - Grouped results, selection modes, file actions - Image preview, directory management, settings persistence - Auto-detection of czkawka_cli binary CLI --json-progress flag: - Outputs ProgressData as JSON lines to stderr - Added Serialize to ProgressData, CurrentStage, ToolType - Added connect_progress_json() handler - Added serde_json dependency Closes #1847 Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 1 + README.md | 66 +- czkawka_cli/Cargo.toml | 1 + czkawka_cli/README.md | 58 +- czkawka_cli/src/commands.rs | 29 + czkawka_cli/src/main.rs | 9 +- czkawka_cli/src/progress.rs | 34 + czkawka_core/src/common/model.rs | 2 +- czkawka_core/src/common/progress_data.rs | 5 +- czkawka_pyside6/README.md | 115 ++++ czkawka_pyside6/app/__init__.py | 0 czkawka_pyside6/app/action_buttons.py | 196 ++++++ czkawka_pyside6/app/backend.py | 621 ++++++++++++++++++ czkawka_pyside6/app/bottom_panel.py | 143 +++++ czkawka_pyside6/app/dialogs/__init__.py | 7 + czkawka_pyside6/app/dialogs/about_dialog.py | 78 +++ czkawka_pyside6/app/dialogs/delete_dialog.py | 51 ++ czkawka_pyside6/app/dialogs/move_dialog.py | 65 ++ czkawka_pyside6/app/dialogs/rename_dialog.py | 34 + czkawka_pyside6/app/dialogs/save_dialog.py | 48 ++ czkawka_pyside6/app/dialogs/select_dialog.py | 48 ++ czkawka_pyside6/app/dialogs/sort_dialog.py | 39 ++ czkawka_pyside6/app/icons.py | 149 +++++ czkawka_pyside6/app/left_panel.py | 149 +++++ czkawka_pyside6/app/main_window.py | 624 +++++++++++++++++++ czkawka_pyside6/app/models.py | 305 +++++++++ czkawka_pyside6/app/preview_panel.py | 112 ++++ czkawka_pyside6/app/progress_widget.py | 325 ++++++++++ czkawka_pyside6/app/results_view.py | 280 +++++++++ czkawka_pyside6/app/settings_panel.py | 261 ++++++++ czkawka_pyside6/app/state.py | 122 ++++ czkawka_pyside6/app/tool_settings.py | 504 +++++++++++++++ czkawka_pyside6/main.py | 58 ++ 33 files changed, 4495 insertions(+), 44 deletions(-) create mode 100644 czkawka_pyside6/README.md create mode 100644 czkawka_pyside6/app/__init__.py create mode 100644 czkawka_pyside6/app/action_buttons.py create mode 100644 czkawka_pyside6/app/backend.py create mode 100644 czkawka_pyside6/app/bottom_panel.py create mode 100644 czkawka_pyside6/app/dialogs/__init__.py create mode 100644 czkawka_pyside6/app/dialogs/about_dialog.py create mode 100644 czkawka_pyside6/app/dialogs/delete_dialog.py create mode 100644 czkawka_pyside6/app/dialogs/move_dialog.py create mode 100644 czkawka_pyside6/app/dialogs/rename_dialog.py create mode 100644 czkawka_pyside6/app/dialogs/save_dialog.py create mode 100644 czkawka_pyside6/app/dialogs/select_dialog.py create mode 100644 czkawka_pyside6/app/dialogs/sort_dialog.py create mode 100644 czkawka_pyside6/app/icons.py create mode 100644 czkawka_pyside6/app/left_panel.py create mode 100644 czkawka_pyside6/app/main_window.py create mode 100644 czkawka_pyside6/app/models.py create mode 100644 czkawka_pyside6/app/preview_panel.py create mode 100644 czkawka_pyside6/app/progress_widget.py create mode 100644 czkawka_pyside6/app/results_view.py create mode 100644 czkawka_pyside6/app/settings_panel.py create mode 100644 czkawka_pyside6/app/state.py create mode 100644 czkawka_pyside6/app/tool_settings.py create mode 100644 czkawka_pyside6/main.py diff --git a/Cargo.lock b/Cargo.lock index 82536866d..cf5684840 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1638,6 +1638,7 @@ dependencies = [ "humansize", "indicatif", "log", + "serde_json", ] [[package]] diff --git a/README.md b/README.md index dbbdfccbc..77cd6de72 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ - **Cache support** - second and further scans should be much faster than the first one - **Easy to run, easy to compile** - minimal runtime and build dependencies, portable version available - **CLI frontend** - for easy automation -- **GUI frontend** - uses Slint or GTK 4 frameworks +- **GUI frontends** - Slint (Krokiet), GTK 4 (Czkawka GUI), and PySide6/Qt (Czkawka PySide6) - **Core library** - allows to reuse functionality in other apps - **Android app** - experimental touch-friendly frontend for Android devices - **No spying** - Czkawka does not have access to the Internet, nor does it collect any user information or statistics @@ -59,6 +59,7 @@ Each tool uses different technologies, so you can find instructions for each of - [Krokiet GUI (Slint frontend)](krokiet/README.md)
- [Czkawka GUI (GTK frontend)](czkawka_gui/README.md)
+- [Czkawka PySide6 (Qt/PySide6 frontend)](czkawka_pyside6/README.md)
- [Czkawka CLI](czkawka_cli/README.md)
- [Czkawka Core](czkawka_core/README.md)
- [Cedinia](cedinia/README.md)
@@ -68,37 +69,37 @@ Each tool uses different technologies, so you can find instructions for each of In this comparison remember, that even if app have same features they may work different(e.g. one app may have more options to choose than other). -| | Krokiet | Czkawka | Cedinia | FSlint | DupeGuru | Bleachbit | -|:-------------------------:|:-----------:|:----------------:|:-------:|:------:|:-----------------:|:-----------:| -| Language | Rust | Rust | Rust | Python | Python/Obj-C | Python | -| Framework base language | Rust | C | Rust | C | C/C++/Obj-C/Swift | C | -| Framework | Slint | GTK 4 | Slint | PyGTK2 | Qt 5 (PyQt)/Cocoa | PyGTK3 | -| OS | Lin,Mac,Win | Lin,Mac,Win | Android | Lin | Lin,Mac,Win | Lin,Mac,Win | -| Duplicate finder | ✔ | ✔ | ✔ | ✔ | ✔ | | -| Empty files | ✔ | ✔ | ✔ | ✔ | | | -| Empty folders | ✔ | ✔ | ✔ | ✔ | | | -| Temporary files | ✔ | ✔ | ✔ | ✔ | | ✔ | -| Big files | ✔ | ✔ | ✔ | | | | -| Similar images | ✔ | ✔ | ✔ | | ✔ | | -| Similar videos | ✔ | ✔ | | | | | -| Music duplicates(tags) | ✔ | ✔ | ✔ | | ✔ | | -| Music duplicates(content) | ✔ | ✔ | ✔ | | | | -| Invalid symlinks | ✔ | ✔ | ✔ | ✔ | | | -| Broken files | ✔ | ✔ | ✔ | | | | -| Invalid names/extensions | ✔ | ✔ | ✔ | ✔ | | | -| Exif cleaner | ✔ | | ✔ | | | | -| Video optimizer | ✔ | | | | | | -| Bad Names | ✔ | | ✔ | | | | -| Names conflict | | | | ✔ | | | -| Installed packages | | | | ✔ | | | -| Bad ID | | | | ✔ | | | -| Non stripped binaries | | | | ✔ | | | -| Redundant whitespace | | | | ✔ | | | -| Overwriting files | | | | ✔ | | ✔ | -| Portable version | ✔ | ✔ | | | | ✔ | -| Multiple languages | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ | -| Cache support | ✔ | ✔ | ✔ | | ✔ | | -| In active development | Yes | Yes** | Yes*** | No | No* | Yes | +| | Krokiet | Czkawka PySide6 | Czkawka | Cedinia | FSlint | DupeGuru | Bleachbit | +|:-------------------------:|:-----------:|:----------------:|:----------------:|:-------:|:------:|:-----------------:|:-----------:| +| Language | Rust | Python | Rust | Rust | Python | Python/Obj-C | Python | +| Framework base language | Rust | C++ | C | Rust | C | C/C++/Obj-C/Swift | C | +| Framework | Slint | PySide6 (Qt 6) | GTK 4 | Slint | PyGTK2 | Qt 5 (PyQt)/Cocoa | PyGTK3 | +| OS | Lin,Mac,Win | Lin,Mac,Win | Lin,Mac,Win | Android | Lin | Lin,Mac,Win | Lin,Mac,Win | +| Duplicate finder | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ | | +| Empty files | ✔ | ✔ | ✔ | ✔ | ✔ | | | +| Empty folders | ✔ | ✔ | ✔ | ✔ | ✔ | | | +| Temporary files | ✔ | ✔ | ✔ | ✔ | ✔ | | ✔ | +| Big files | ✔ | ✔ | ✔ | ✔ | | | | +| Similar images | ✔ | ✔ | ✔ | ✔ | | ✔ | | +| Similar videos | ✔ | ✔ | ✔ | | | | | +| Music duplicates(tags) | ✔ | ✔ | ✔ | ✔ | | ✔ | | +| Music duplicates(content) | ✔ | ✔ | ✔ | ✔ | | | | +| Invalid symlinks | ✔ | ✔ | ✔ | ✔ | ✔ | | | +| Broken files | ✔ | ✔ | ✔ | ✔ | | | | +| Invalid names/extensions | ✔ | ✔ | ✔ | ✔ | ✔ | | | +| Exif cleaner | ✔ | ✔ | | ✔ | | | | +| Video optimizer | ✔ | ✔ | | | | | | +| Bad Names | ✔ | ✔ | | ✔ | | | | +| Names conflict | | | | | ✔ | | | +| Installed packages | | | | | ✔ | | | +| Bad ID | | | | | ✔ | | | +| Non stripped binaries | | | | | ✔ | | | +| Redundant whitespace | | | | | ✔ | | | +| Overwriting files | | | | | ✔ | | ✔ | +| Portable version | ✔ | ✔ | ✔ | | | | ✔ | +| Multiple languages | ✔ | | ✔ | ✔ | ✔ | ✔ | ✔ | +| Cache support | ✔ | ✔ | ✔ | ✔ | | ✔ | | +| In active development | Yes | Yes | Yes** | Yes*** | No | No* | Yes |

* Few small commits added recently and last version released in 2023

** Czkawka GTK is in maintenance mode receiving only bugfixes

@@ -131,6 +132,7 @@ console apps, then take a look at these: Czkawka exposes its common functionality through a crate called **`czkawka_core`**, which can be reused by other projects. It is written in Rust and is used by all Czkawka frontends (`czkawka_gui`, `czkawka_cli`, `krokiet`, `cedinia`). +The `czkawka_pyside6` frontend uses `czkawka_cli` as its backend, communicating via JSON output and `--json-progress` for real-time progress data. It is also used by external projects, such as: diff --git a/czkawka_cli/Cargo.toml b/czkawka_cli/Cargo.toml index 2b24c2c56..063f55117 100644 --- a/czkawka_cli/Cargo.toml +++ b/czkawka_cli/Cargo.toml @@ -18,6 +18,7 @@ indicatif = "0.18" crossbeam-channel = { version = "0.5", features = [] } ctrlc = { version = "3.4", features = ["termination"] } humansize = "2.1" +serde_json = "1" [features] default = [] diff --git a/czkawka_cli/README.md b/czkawka_cli/README.md index 3c88431f8..1c9d3c29a 100644 --- a/czkawka_cli/README.md +++ b/czkawka_cli/README.md @@ -44,13 +44,57 @@ czkawka_cli dup --help Example usage: ```shell -czkawka dup -d /home/rafal -e /home/rafal/Obrazy -m 25 -x 7z rar IMAGE -s hash -f results.txt -D aeo -czkawka empty-folders -d /home/rafal/rr /home/gateway -f results.txt -czkawka big -d /home/rafal/ /home/piszczal -e /home/rafal/Roman -n 25 -x VIDEO -f results.txt -czkawka empty-files -d /home/rafal /home/szczekacz -e /home/rafal/Pulpit -R -f results.txt -czkawka temp -d /home/rafal/ -E */.git */tmp* *Pulpit -f results.txt -D -czkawka music -d /home/rafal -e /home/rafal/Pulpit -z "artist,year, ARTISTALBUM, ALBUM___tiTlE" -f results.txt -czkawka symlinks -d /home/kicikici/ /home/szczek -e /home/kicikici/jestempsem -x jpg -f results.txt +czkawka_cli dup -d /home/rafal -e /home/rafal/Obrazy -m 25 -x 7z rar IMAGE -s hash -f results.txt -D aeo +czkawka_cli empty-folders -d /home/rafal/rr /home/gateway -f results.txt +czkawka_cli big -d /home/rafal/ /home/piszczal -e /home/rafal/Roman -n 25 -x VIDEO -f results.txt +czkawka_cli empty-files -d /home/rafal /home/szczekacz -e /home/rafal/Pulpit -R -f results.txt +czkawka_cli temp -d /home/rafal/ -E */.git */tmp* *Pulpit -f results.txt -D +czkawka_cli music -d /home/rafal -e /home/rafal/Pulpit -z "artist,year, ARTISTALBUM, ALBUM___tiTlE" -f results.txt +czkawka_cli symlinks -d /home/kicikici/ /home/szczek -e /home/kicikici/jestempsem -x jpg -f results.txt +``` + +## JSON output + +Results can be saved as compact or pretty-printed JSON: + +```shell +czkawka_cli dup -d /home/user --compact-file-to-save results.json +czkawka_cli dup -d /home/user --pretty-json-file-to-save results.json +``` + +## Machine-readable progress (`--json-progress`) + +The `--json-progress` flag outputs real-time progress data as JSON lines to stderr. This is used by GUI frontends (such as the PySide6 frontend) to display accurate progress bars. + +Each line is a JSON object with the following structure: +```json +{ + "progress": { + "sstage": "DuplicatePreHashing", + "checking_method": "Hash", + "current_stage_idx": 2, + "max_stage_idx": 6, + "entries_checked": 50000, + "entries_to_check": 94500, + "bytes_checked": 204800000, + "bytes_to_check": 387072000, + "tool_type": "Duplicate" + }, + "stage_name": "Calculating prehashes" +} +``` + +Fields: +- `sstage` - Internal stage identifier (e.g., `CollectingFiles`, `DuplicateFullHashing`, `SimilarImagesComparingHashes`) +- `current_stage_idx` / `max_stage_idx` - Current stage number and total stages (e.g., 2/6 for duplicates) +- `entries_checked` / `entries_to_check` - Files processed and total to process +- `bytes_checked` / `bytes_to_check` - Bytes processed and total (for hashing stages) +- `stage_name` - Human-readable stage description + +Example usage: +```shell +# Capture progress on stderr while saving results to JSON +czkawka_cli dup -d /home/user --json-progress -N --compact-file-to-save results.json 2>progress.jsonl ``` ## LICENSE diff --git a/czkawka_cli/src/commands.rs b/czkawka_cli/src/commands.rs index 8980629c1..9c73b5913 100644 --- a/czkawka_cli/src/commands.rs +++ b/czkawka_cli/src/commands.rs @@ -115,6 +115,27 @@ pub enum Commands { ExifRemover(ExifRemoverArgs), } +impl Commands { + pub fn get_json_progress(&self) -> bool { + match self { + Commands::Duplicates(a) => a.common_cli_items.json_progress, + Commands::EmptyFolders(a) => a.common_cli_items.json_progress, + Commands::BiggestFiles(a) => a.common_cli_items.json_progress, + Commands::EmptyFiles(a) => a.common_cli_items.json_progress, + Commands::Temporary(a) => a.common_cli_items.json_progress, + Commands::SimilarImages(a) => a.common_cli_items.json_progress, + Commands::SameMusic(a) => a.common_cli_items.json_progress, + Commands::InvalidSymlinks(a) => a.common_cli_items.json_progress, + Commands::BrokenFiles(a) => a.common_cli_items.json_progress, + Commands::SimilarVideos(a) => a.common_cli_items.json_progress, + Commands::BadExtensions(a) => a.common_cli_items.json_progress, + Commands::BadNames(a) => a.common_cli_items.json_progress, + Commands::VideoOptimizer(a) => a.common_cli_items.json_progress, + Commands::ExifRemover(a) => a.common_cli_items.json_progress, + } + } +} + #[derive(Debug, clap::Args)] pub struct DuplicatesArgs { #[clap(flatten)] @@ -848,6 +869,14 @@ pub struct CommonCliItems { long_help = "Disables the cache system. This will make scanning slower but ensures fresh results without cached data." )] pub disable_cache: bool, + #[clap( + long, + help = "Output progress as JSON lines to stderr", + long_help = "Outputs progress data as JSON lines to stderr for machine consumption. \ + Each line is a JSON object with fields: sstage, current_stage_idx, max_stage_idx, \ + entries_checked, entries_to_check, bytes_checked, bytes_to_check, tool_type." + )] + pub json_progress: bool, } #[derive(Debug, clap::Args, Clone, Copy)] diff --git a/czkawka_cli/src/main.rs b/czkawka_cli/src/main.rs index 871183662..b8b9710e4 100644 --- a/czkawka_cli/src/main.rs +++ b/czkawka_cli/src/main.rs @@ -36,7 +36,7 @@ use crate::commands::{ Args, BadExtensionsArgs, BadNamesArgs, BiggestFilesArgs, BrokenFilesArgs, CommonCliItems, DMethod, DuplicatesArgs, EmptyFilesArgs, EmptyFoldersArgs, ExifRemoverArgs, InvalidSymlinksArgs, SDMethod, SameMusicArgs, SimilarImagesArgs, SimilarVideosArgs, TemporaryArgs, VideoOptimizerArgs, }; -use crate::progress::connect_progress; +use crate::progress::{connect_progress, connect_progress_json}; mod commands; mod progress; @@ -65,6 +65,7 @@ fn main() { debug!("Running command - {command:?}"); } + let json_progress = command.get_json_progress(); let (progress_sender, progress_receiver): (Sender, Receiver) = unbounded(); let stop_flag = Arc::new(AtomicBool::new(false)); let store_flag_cloned = stop_flag.clone(); @@ -98,7 +99,11 @@ fn main() { }) .expect("Error setting Ctrl-C handler"); - connect_progress(&progress_receiver); + if json_progress { + connect_progress_json(&progress_receiver); + } else { + connect_progress(&progress_receiver); + } let cli_output = calculate_thread.join().expect("Failed to join calculation thread"); diff --git a/czkawka_cli/src/progress.rs b/czkawka_cli/src/progress.rs index 72952de01..6156e3daa 100644 --- a/czkawka_cli/src/progress.rs +++ b/czkawka_cli/src/progress.rs @@ -1,3 +1,4 @@ +use std::io::Write; use std::time::Duration; use crossbeam_channel::Receiver; @@ -97,6 +98,39 @@ pub(crate) fn get_progress_message(progress_data: &ProgressData) -> String { .to_string() } +/// Output progress data as JSON lines to stderr for machine consumption. +/// Each line is a complete JSON object that can be parsed independently. +pub(crate) fn connect_progress_json(progress_receiver: &Receiver) { + let stderr = std::io::stderr(); + let mut stderr = stderr.lock(); + while let Ok(progress_data) = progress_receiver.recv() { + // Build a JSON object with human-readable stage name included + let stage_name = if progress_data.sstage == CurrentStage::CollectingFiles { + if progress_data.tool_type == ToolType::EmptyFolders { + "Collecting folders".to_string() + } else { + "Collecting files".to_string() + } + } else if progress_data.sstage.check_if_loading_saving_cache() { + if progress_data.sstage.check_if_loading_cache() { + "Loading cache".to_string() + } else { + "Saving cache".to_string() + } + } else { + get_progress_message(&progress_data) + }; + + if let Ok(json) = serde_json::to_string(&progress_data) { + // Wrap in an object that includes the human-readable stage name + let _ = writeln!( + stderr, + "{{\"progress\":{json},\"stage_name\":\"{stage_name}\"}}" + ); + } + } +} + pub(crate) fn get_progress_bar_for_collect_files() -> ProgressBar { let pb = ProgressBar::new_spinner(); pb.enable_steady_tick(Duration::from_millis(120)); diff --git a/czkawka_core/src/common/model.rs b/czkawka_core/src/common/model.rs index 41919a49d..622a70727 100644 --- a/czkawka_core/src/common/model.rs +++ b/czkawka_core/src/common/model.rs @@ -6,7 +6,7 @@ use xxhash_rust::xxh3::Xxh3; use crate::common::traits::ResultEntry; use crate::tools::duplicate::MyHasher; -#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)] +#[derive(Debug, PartialEq, Eq, Clone, Copy, Default, Serialize, Deserialize)] pub enum ToolType { Duplicate, EmptyFolders, diff --git a/czkawka_core/src/common/progress_data.rs b/czkawka_core/src/common/progress_data.rs index d23abe7e8..d98372c63 100644 --- a/czkawka_core/src/common/progress_data.rs +++ b/czkawka_core/src/common/progress_data.rs @@ -1,4 +1,5 @@ use log::error; +use serde::Serialize; use crate::common::model::{CheckingMethod, ToolType}; // Empty files @@ -66,7 +67,7 @@ use crate::common::model::{CheckingMethod, ToolType}; // Deleting files // Renaming files -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Serialize)] pub struct ProgressData { pub sstage: CurrentStage, pub checking_method: CheckingMethod, @@ -95,7 +96,7 @@ impl ProgressData { } } -#[derive(Debug, Clone, Copy, Eq, PartialEq)] +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)] pub enum CurrentStage { DeletingFiles, RenamingFiles, diff --git a/czkawka_pyside6/README.md b/czkawka_pyside6/README.md new file mode 100644 index 000000000..d4487aaed --- /dev/null +++ b/czkawka_pyside6/README.md @@ -0,0 +1,115 @@ +# Czkawka PySide6 + +A Qt 6 / PySide6 GUI frontend for Czkawka, with feature parity with the Krokiet (Slint) interface. + +This frontend uses `czkawka_cli` as its backend, communicating via JSON output for results and `--json-progress` for real-time progress data. + +## Features + +All 14 scanning tools are supported: + +- Duplicate Files (by hash, size, name, or size+name) +- Empty Folders / Empty Files +- Big Files (biggest or smallest) +- Temporary Files +- Similar Images (with configurable hash algorithm, size, similarity) +- Similar Videos (with crop detection, skip forward, duration) +- Similar Music (by tags or audio fingerprint) +- Invalid Symlinks +- Broken Files (audio, PDF, archive, image, video) +- Bad Extensions +- Bad Names (uppercase, emoji, spaces, non-ASCII, restricted charset) +- EXIF Remover +- Video Optimizer (crop black bars / transcode) + +### GUI features + +- **Dark theme** with full Qt stylesheet +- **Two-bar progress display** - current stage + overall progress, matching the Slint frontend + - Real-time entry and byte counts (e.g., "Calculating hashes: 15,000/25,000 (500 MB/1 GB)") + - File collection estimation using cached counts from previous scans + - Elapsed time display + - Stage step indicators +- **Project icons** - uses the same SVG icons as the Krokiet interface +- **Image preview panel** for duplicate/similar image results +- **Grouped results view** with tree display for duplicate/similar file groups +- **Selection modes** - Select All/None, Invert, Biggest/Smallest, Newest/Oldest, Shortest/Longest Path +- **File actions** - Delete (with trash support), Move/Copy, Hardlink, Symlink, Rename, Clean EXIF +- **Per-tool settings** - all tool-specific options (hash type, similarity thresholds, etc.) +- **Global settings** - directories, filters, cache, thread count +- **Directory management** - included/excluded paths with add/remove buttons +- **Context menus** - right-click to open file or containing folder +- **Settings persistence** via JSON config files +- **Auto-detection** of `czkawka_cli` binary (checks PATH, cargo target directory, cargo metadata) + +## Requirements + +- Python 3.10+ +- PySide6 >= 6.6.0 +- `czkawka_cli` binary (installed or in PATH) +- Optional: `send2trash` (for trash support on Linux) +- Optional: `Pillow` (for EXIF cleaning fallback) + +## Installation + +### 1. Install czkawka_cli + +```shell +# From the project root +cargo install --path czkawka_cli + +# Or build it +cargo build --release -p czkawka_cli +``` + +### 2. Install Python dependencies + +```shell +cd czkawka_pyside6 +pip install -r requirements.txt +``` + +### 3. Run + +```shell +python main.py +``` + +The application will auto-detect the `czkawka_cli` binary. If it can't find it, configure the path in Settings. + +## Architecture + +``` +czkawka_pyside6/ +├── main.py # Entry point +├── requirements.txt # Python dependencies +├── app/ +│ ├── main_window.py # Main window with all panels +│ ├── left_panel.py # Tool selection sidebar (14 tools) +│ ├── results_view.py # Results tree with grouping, selection, sorting +│ ├── action_buttons.py # Scan/Stop/Delete/Move/Save/Sort buttons with icons +│ ├── tool_settings.py # Per-tool settings (9 tool panels) +│ ├── settings_panel.py # Global settings (General/Directories/Filters/Preview) +│ ├── progress_widget.py # Two-bar progress: current stage + overall +│ ├── preview_panel.py # Image preview panel +│ ├── bottom_panel.py # Directory management + error display +│ ├── backend.py # CLI subprocess interface with JSON progress parsing +│ ├── models.py # Data models, enums, column definitions +│ ├── state.py # Application state with Qt signals +│ ├── icons.py # SVG icon resources from Krokiet icon set +│ └── dialogs/ # Delete, Move, Select, Sort, Save, Rename, About +``` + +### How it works + +1. **Scanning**: The app spawns `czkawka_cli` as a subprocess with `--compact-file-to-save` for JSON results and `--json-progress` for real-time progress data on stderr. + +2. **Progress**: JSON lines on stderr provide `ProgressData` with stage index, entry counts, byte counts — the same data the Slint frontend gets via crossbeam channels. The progress widget displays two bars (current stage and overall) with percentage, counts, and elapsed time. + +3. **Results**: JSON results are parsed and displayed in a tree view with group headers for duplicate/similar file tools. + +4. **File operations**: Delete, move, hardlink, symlink, and rename operations are performed directly in Python. EXIF cleaning and extension/name fixing use `czkawka_cli` subcommands. + +## LICENSE + +MIT diff --git a/czkawka_pyside6/app/__init__.py b/czkawka_pyside6/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/czkawka_pyside6/app/action_buttons.py b/czkawka_pyside6/app/action_buttons.py new file mode 100644 index 000000000..36e928a26 --- /dev/null +++ b/czkawka_pyside6/app/action_buttons.py @@ -0,0 +1,196 @@ +from PySide6.QtWidgets import ( + QWidget, QHBoxLayout, QPushButton, QSizePolicy +) +from PySide6.QtCore import Signal, QSize + +from .models import ActiveTab, GROUPED_TABS +from .icons import ( + icon_search, icon_stop, icon_select, icon_delete, icon_move, + icon_save, icon_sort, icon_hardlink, icon_symlink, icon_rename, + icon_clean, icon_optimize, +) + +ICON_SIZE = QSize(18, 18) + + +class ActionButtons(QWidget): + """Action buttons bar: Scan, Stop, Select, Delete, Move, Save, Sort, etc.""" + + scan_clicked = Signal() + stop_clicked = Signal() + select_clicked = Signal() + delete_clicked = Signal() + move_clicked = Signal() + save_clicked = Signal() + sort_clicked = Signal() + hardlink_clicked = Signal() + symlink_clicked = Signal() + rename_clicked = Signal() + clean_exif_clicked = Signal() + optimize_video_clicked = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self._active_tab = ActiveTab.DUPLICATE_FILES + self._scanning = False + self._has_results = False + self._has_selection = False + self._setup_ui() + + def _setup_ui(self): + layout = QHBoxLayout(self) + layout.setContentsMargins(4, 4, 4, 4) + layout.setSpacing(4) + + # Scan button + self._scan_btn = QPushButton(icon_search(18), " Scan") + self._scan_btn.setIconSize(ICON_SIZE) + self._scan_btn.setMinimumWidth(90) + self._scan_btn.setStyleSheet( + "QPushButton { background-color: #2d5a27; color: white; font-weight: bold; padding: 6px 16px; }" + "QPushButton:hover { background-color: #3a7a34; }" + "QPushButton:disabled { background-color: #444; color: #888; }" + ) + self._scan_btn.clicked.connect(self.scan_clicked.emit) + layout.addWidget(self._scan_btn) + + # Stop button + self._stop_btn = QPushButton(icon_stop(18), " Stop") + self._stop_btn.setIconSize(ICON_SIZE) + self._stop_btn.setMinimumWidth(80) + self._stop_btn.setStyleSheet( + "QPushButton { background-color: #8a2222; color: white; font-weight: bold; padding: 6px 16px; }" + "QPushButton:hover { background-color: #aa3333; }" + ) + self._stop_btn.clicked.connect(self.stop_clicked.emit) + self._stop_btn.setVisible(False) + layout.addWidget(self._stop_btn) + + # Spacer + spacer = QWidget() + spacer.setFixedWidth(10) + layout.addWidget(spacer) + + # Select button + self._select_btn = QPushButton(icon_select(18), " Select") + self._select_btn.setIconSize(ICON_SIZE) + self._select_btn.clicked.connect(self.select_clicked.emit) + layout.addWidget(self._select_btn) + + # Delete button + self._delete_btn = QPushButton(icon_delete(18), " Delete") + self._delete_btn.setIconSize(ICON_SIZE) + self._delete_btn.setStyleSheet( + "QPushButton { background-color: #8a2222; color: white; padding: 6px 12px; }" + "QPushButton:hover { background-color: #aa3333; }" + "QPushButton:disabled { background-color: #444; color: #888; }" + ) + self._delete_btn.clicked.connect(self.delete_clicked.emit) + layout.addWidget(self._delete_btn) + + # Move button + self._move_btn = QPushButton(icon_move(18), " Move") + self._move_btn.setIconSize(ICON_SIZE) + self._move_btn.clicked.connect(self.move_clicked.emit) + layout.addWidget(self._move_btn) + + # Save button + self._save_btn = QPushButton(icon_save(18), " Save") + self._save_btn.setIconSize(ICON_SIZE) + self._save_btn.clicked.connect(self.save_clicked.emit) + layout.addWidget(self._save_btn) + + # Sort button + self._sort_btn = QPushButton(icon_sort(18), " Sort") + self._sort_btn.setIconSize(ICON_SIZE) + self._sort_btn.clicked.connect(self.sort_clicked.emit) + layout.addWidget(self._sort_btn) + + # Hardlink button (grouped tools only) + self._hardlink_btn = QPushButton(icon_hardlink(18), " Hardlink") + self._hardlink_btn.setIconSize(ICON_SIZE) + self._hardlink_btn.clicked.connect(self.hardlink_clicked.emit) + layout.addWidget(self._hardlink_btn) + + # Symlink button (grouped tools only) + self._symlink_btn = QPushButton(icon_symlink(18), " Symlink") + self._symlink_btn.setIconSize(ICON_SIZE) + self._symlink_btn.clicked.connect(self.symlink_clicked.emit) + layout.addWidget(self._symlink_btn) + + # Rename button (bad extensions / bad names) + self._rename_btn = QPushButton(icon_rename(18), " Rename") + self._rename_btn.setIconSize(ICON_SIZE) + self._rename_btn.clicked.connect(self.rename_clicked.emit) + layout.addWidget(self._rename_btn) + + # Clean EXIF button + self._clean_exif_btn = QPushButton(icon_clean(18), " Clean EXIF") + self._clean_exif_btn.setIconSize(ICON_SIZE) + self._clean_exif_btn.clicked.connect(self.clean_exif_clicked.emit) + layout.addWidget(self._clean_exif_btn) + + # Optimize Video button + self._optimize_btn = QPushButton(icon_optimize(18), " Optimize") + self._optimize_btn.setIconSize(ICON_SIZE) + self._optimize_btn.clicked.connect(self.optimize_video_clicked.emit) + layout.addWidget(self._optimize_btn) + + # Stretch at the end + layout.addStretch() + + self._update_visibility() + + def set_active_tab(self, tab: ActiveTab): + self._active_tab = tab + self._update_visibility() + + def set_scanning(self, scanning: bool): + self._scanning = scanning + self._scan_btn.setVisible(not scanning) + self._stop_btn.setVisible(scanning) + self._update_enabled() + + def set_has_results(self, has_results: bool): + self._has_results = has_results + self._update_enabled() + + def set_has_selection(self, has_selection: bool): + self._has_selection = has_selection + self._update_enabled() + + def _update_visibility(self): + tab = self._active_tab + is_grouped = tab in GROUPED_TABS + + # Always visible + self._select_btn.setVisible(True) + self._delete_btn.setVisible(True) + self._move_btn.setVisible(True) + self._save_btn.setVisible(True) + self._sort_btn.setVisible(True) + + # Conditional buttons + self._hardlink_btn.setVisible(is_grouped) + self._symlink_btn.setVisible(is_grouped) + self._rename_btn.setVisible(tab in (ActiveTab.BAD_EXTENSIONS, ActiveTab.BAD_NAMES)) + self._clean_exif_btn.setVisible(tab == ActiveTab.EXIF_REMOVER) + self._optimize_btn.setVisible(tab == ActiveTab.VIDEO_OPTIMIZER) + + self._update_enabled() + + def _update_enabled(self): + has_data = self._has_results and not self._scanning + has_sel = self._has_selection and not self._scanning + + self._scan_btn.setEnabled(not self._scanning) + self._select_btn.setEnabled(has_data) + self._delete_btn.setEnabled(has_sel) + self._move_btn.setEnabled(has_sel) + self._save_btn.setEnabled(has_data) + self._sort_btn.setEnabled(has_data) + self._hardlink_btn.setEnabled(has_sel) + self._symlink_btn.setEnabled(has_sel) + self._rename_btn.setEnabled(has_sel) + self._clean_exif_btn.setEnabled(has_sel) + self._optimize_btn.setEnabled(has_sel) diff --git a/czkawka_pyside6/app/backend.py b/czkawka_pyside6/app/backend.py new file mode 100644 index 000000000..3d922c176 --- /dev/null +++ b/czkawka_pyside6/app/backend.py @@ -0,0 +1,621 @@ +import json +import os +import subprocess +import tempfile +from pathlib import Path +from typing import Optional + +from PySide6.QtCore import QObject, QThread, Signal + +from .models import ( + ActiveTab, AppSettings, ToolSettings, ResultEntry, ScanProgress, + TAB_TO_CLI_COMMAND, GROUPED_TABS, CheckingMethod, MusicSearchMethod, +) + + +class ScanWorker(QObject): + """Worker that runs czkawka_cli in a background thread.""" + finished = Signal(object, list) # (ActiveTab, results) + progress = Signal(object) # ScanProgress + error = Signal(str) + + def __init__(self, tab: ActiveTab, app_settings: AppSettings, + tool_settings: ToolSettings): + super().__init__() + self.tab = tab + self.app_settings = app_settings + self.tool_settings = tool_settings + self._process: Optional[subprocess.Popen] = None + self._cancelled = False + + def run(self): + try: + cmd = self._build_command() + if not cmd: + self.error.emit(f"No CLI command for tab {self.tab}") + return + + self.progress.emit(ScanProgress( + step_name="Collecting files...", current=0, total=0 + )) + + with tempfile.NamedTemporaryFile(suffix=".json", delete=False, mode="w") as f: + json_output_path = f.name + + # Add JSON output flag (use long form to avoid conflict with -C in broken files) + cmd.extend(["--compact-file-to-save", json_output_path]) + # Enable JSON progress on stderr, suppress text output + cmd.extend(["--json-progress", "-N", "-M"]) + + self._process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + # Read stderr line-by-line for JSON progress data + self._monitor_process_json(json_output_path) + + if self._cancelled: + self._cleanup(json_output_path) + return + + # Check for CLI errors + returncode = self._process.returncode + if returncode is not None and returncode != 0 and returncode != 11: + error_msg = f"czkawka_cli exited with code {returncode}" + self.error.emit(error_msg) + self._cleanup(json_output_path) + return + + # Parse results + self.progress.emit(ScanProgress( + step_name="Loading results...", current=0, total=0 + )) + results = self._parse_results(json_output_path) + self.finished.emit(self.tab, results) + self._cleanup(json_output_path) + + except FileNotFoundError: + self.error.emit( + f"czkawka_cli not found at '{self.app_settings.czkawka_cli_path}'. " + "Please install czkawka_cli or set the correct path in settings." + ) + except Exception as e: + self.error.emit(str(e)) + + def cancel(self): + self._cancelled = True + if self._process and self._process.poll() is None: + self._process.terminate() + try: + self._process.wait(timeout=3) + except subprocess.TimeoutExpired: + self._process.kill() + self._process.wait() + + def _monitor_process_json(self, json_path: str): + """Read JSON progress lines from stderr in real-time.""" + import time + + while self._process.poll() is None: + if self._cancelled: + return + + line = self._process.stderr.readline() + if not line: + time.sleep(0.05) + continue + + line = line.strip() + if not line: + continue + + try: + data = json.loads(line) + progress = data.get("progress", {}) + stage_name = data.get("stage_name", "Processing...") + + entries_checked = progress.get("entries_checked", 0) + entries_to_check = progress.get("entries_to_check", 0) + bytes_checked = progress.get("bytes_checked", 0) + bytes_to_check = progress.get("bytes_to_check", 0) + current_stage_idx = progress.get("current_stage_idx", 0) + max_stage_idx = progress.get("max_stage_idx", 0) + + self.progress.emit(ScanProgress( + step_name=stage_name, + current=0, + total=0, + current_size=bytes_checked, + stage_name=stage_name, + current_stage_idx=current_stage_idx, + max_stage_idx=max_stage_idx, + entries_checked=entries_checked, + entries_to_check=entries_to_check, + bytes_checked=bytes_checked, + bytes_to_check=bytes_to_check, + )) + except (json.JSONDecodeError, KeyError, TypeError): + continue + + # Drain remaining stderr + remaining = self._process.stderr.read() + if remaining: + for line in remaining.strip().split("\n"): + pass # Final lines already processed + + def _cleanup(self, path: str): + try: + os.unlink(path) + except OSError: + pass + + def _build_command(self) -> list[str]: + s = self.app_settings + ts = self.tool_settings + cli = s.czkawka_cli_path + subcmd = TAB_TO_CLI_COMMAND.get(self.tab) + if not subcmd: + return [] + + # Handle video-optimizer subcommands + if self.tab == ActiveTab.VIDEO_OPTIMIZER: + if ts.video_opt_mode == "crop": + cmd = [cli, "video-optimizer", "crop"] + else: + cmd = [cli, "video-optimizer", "transcode"] + else: + cmd = [cli, subcmd] + + # Common args + if s.included_paths: + cmd.extend(["-d", ",".join(s.included_paths)]) + if s.excluded_paths: + cmd.extend(["-e", ",".join(s.excluded_paths)]) + if s.excluded_items: + cmd.extend(["-E", s.excluded_items]) + if s.allowed_extensions: + cmd.extend(["-x", s.allowed_extensions]) + if s.excluded_extensions: + cmd.extend(["-P", s.excluded_extensions]) + if not s.recursive_search: + cmd.append("-R") + if not s.use_cache: + cmd.append("-H") + if s.thread_number > 0: + cmd.extend(["-T", str(s.thread_number)]) + + # Tool-specific args + if self.tab == ActiveTab.DUPLICATE_FILES: + cmd.extend(["-s", ts.dup_check_method.value]) + cmd.extend(["-t", ts.dup_hash_type.value]) + if ts.dup_min_size: + cmd.extend(["-m", ts.dup_min_size]) + if ts.dup_max_size: + cmd.extend(["-i", ts.dup_max_size]) + if ts.dup_name_case_sensitive: + cmd.append("-l") + if not s.hide_hard_links: + cmd.append("-L") + if ts.dup_use_prehash: + cmd.append("-u") + + elif self.tab == ActiveTab.SIMILAR_IMAGES: + cmd.extend(["-g", ts.img_hash_alg.value]) + cmd.extend(["-z", ts.img_filter.value]) + cmd.extend(["-c", str(ts.img_hash_size)]) + cmd.extend(["-s", str(ts.img_max_difference)]) + if ts.img_ignore_same_size: + cmd.append("-J") + + elif self.tab == ActiveTab.SIMILAR_VIDEOS: + cmd.extend(["-t", str(ts.vid_max_difference)]) + cmd.extend(["-U", str(ts.vid_skip_forward)]) + cmd.extend(["-B", ts.vid_crop_detect.value]) + cmd.extend(["-A", str(ts.vid_duration)]) + if ts.vid_ignore_same_size: + cmd.append("-J") + + elif self.tab == ActiveTab.SIMILAR_MUSIC: + cmd.extend(["-s", ts.music_search_method.value]) + if ts.music_search_method == MusicSearchMethod.TAGS: + tags = [] + if ts.music_title: tags.append("track_title") + if ts.music_artist: tags.append("track_artist") + if ts.music_bitrate: tags.append("bitrate") + if ts.music_genre: tags.append("genre") + if ts.music_year: tags.append("year") + if ts.music_length: tags.append("length") + if tags: + cmd.extend(["-z", ",".join(tags)]) + if ts.music_approximate: + cmd.append("-a") + else: + cmd.extend(["-Y", str(ts.music_max_difference)]) + + elif self.tab == ActiveTab.BIG_FILES: + cmd.extend(["-n", str(ts.big_files_number)]) + if ts.big_files_mode == "smallest": + cmd.append("-J") + + elif self.tab == ActiveTab.BROKEN_FILES: + types = [] + if ts.broken_audio: types.append("AUDIO") + if ts.broken_pdf: types.append("PDF") + if ts.broken_archive: types.append("ARCHIVE") + if ts.broken_image: types.append("IMAGE") + if ts.broken_video: types.append("VIDEO") + if types: + cmd.extend(["-C", ",".join(types)]) + + elif self.tab == ActiveTab.BAD_NAMES: + if ts.bad_names_uppercase_ext: + cmd.append("-u") + if ts.bad_names_emoji: + cmd.append("-j") + if ts.bad_names_space: + cmd.append("-w") + if ts.bad_names_non_ascii: + cmd.append("-n") + if ts.bad_names_restricted_charset: + cmd.extend(["-r", ts.bad_names_restricted_charset]) + if ts.bad_names_remove_duplicated: + cmd.append("-a") + + elif self.tab == ActiveTab.VIDEO_OPTIMIZER: + if ts.video_opt_mode == "crop": + cmd.extend(["-m", ts.video_crop_mechanism.value]) + cmd.extend(["-k", str(ts.video_black_pixel_threshold)]) + cmd.extend(["-b", str(ts.video_black_bar_percentage)]) + cmd.extend(["-s", str(ts.video_max_samples)]) + cmd.extend(["-z", str(ts.video_min_crop_size)]) + else: + if ts.video_excluded_codecs: + cmd.extend(["-c", ts.video_excluded_codecs]) + cmd.extend(["--target-codec", ts.video_codec.value]) + cmd.extend(["--quality", str(ts.video_quality)]) + if ts.video_fail_if_bigger: + cmd.append("--fail-if-not-smaller") + + return cmd + + def _parse_results(self, json_path: str) -> list[ResultEntry]: + try: + with open(json_path) as f: + data = json.load(f) + except (json.JSONDecodeError, FileNotFoundError, OSError): + return [] + + if not data: + return [] + + results = [] + is_grouped = self.tab in GROUPED_TABS + + if is_grouped: + # Grouped tools return either: + # dict {size_key: [[entry, ...], [entry, ...]], ...} (duplicates by hash) + # list [[entry, ...], [entry, ...]] (similar images/videos/music) + all_groups = [] + if isinstance(data, dict): + # Dict format: flatten all groups from all size buckets + for size_key, groups_for_size in data.items(): + if isinstance(groups_for_size, list): + for group in groups_for_size: + if isinstance(group, list) and len(group) > 0: + all_groups.append(group) + elif isinstance(data, list): + for item in data: + if isinstance(item, list) and len(item) > 0: + all_groups.append(item) + + for group_id, group in enumerate(all_groups): + # Compute total size for the group header + total_size = sum(e.get("size", 0) for e in group if isinstance(e, dict)) + header_text = ( + f"Group {group_id + 1} ({len(group)} files, " + f"{self._format_size(total_size)})" + ) + header = ResultEntry( + values={"__header": header_text}, + header_row=True, + group_id=group_id, + ) + results.append(header) + for entry in group: + if isinstance(entry, dict): + result = self._parse_entry(entry, group_id) + results.append(result) + else: + # Flat tools return a list of entries + if isinstance(data, list): + for i, entry in enumerate(data): + if isinstance(entry, dict): + result = self._parse_entry(entry, 0) + results.append(result) + elif isinstance(data, dict): + # Some tools might also use dict format + for key, entries in data.items(): + if isinstance(entries, list): + for entry in entries: + if isinstance(entry, dict): + result = self._parse_entry(entry, 0) + results.append(result) + + return results + + def _parse_entry(self, entry: dict, group_id: int) -> ResultEntry: + path = entry.get("path", "") + p = Path(path) + values = { + "File Name": p.name, + "Folder Name": p.name, + "Symlink Name": p.name, + "Path": str(p.parent), + "Symlink Path": str(p.parent), + "Size": self._format_size(entry.get("size", 0)), + "Modification Date": self._format_date(entry.get("modified_date", 0)), + "Hash": entry.get("hash", ""), + "Similarity": str(entry.get("similarity", "")), + "Resolution": entry.get("dimensions", ""), + "Title": entry.get("title", ""), + "Artist": entry.get("artist", ""), + "Year": entry.get("year", ""), + "Bitrate": str(entry.get("bitrate", "")), + "Genre": entry.get("genre", ""), + "Length": entry.get("length", ""), + "Destination Path": entry.get("destination_path", ""), + "Type of Error": entry.get("error_string", entry.get("type_of_error", "")), + "Error Type": entry.get("error_string", entry.get("error_type", "")), + "Current Extension": entry.get("current_extension", ""), + "Proper Extension": entry.get("proper_extension", entry.get("proper_extensions", "")), + "Codec": entry.get("codec", ""), + } + # Store full path for file operations + values["__full_path"] = path + values["__size_bytes"] = entry.get("size", 0) + values["__modified_date_ts"] = entry.get("modified_date", 0) + + return ResultEntry(values=values, group_id=group_id) + + @staticmethod + def _format_size(size_bytes: int) -> str: + if size_bytes == 0: + return "0 B" + units = ["B", "KB", "MB", "GB", "TB"] + i = 0 + size = float(size_bytes) + while size >= 1024 and i < len(units) - 1: + size /= 1024 + i += 1 + return f"{size:.1f} {units[i]}" if i > 0 else f"{int(size)} B" + + @staticmethod + def _format_date(timestamp: int) -> str: + if timestamp == 0: + return "" + from datetime import datetime + try: + return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S") + except (ValueError, OSError): + return str(timestamp) + + +class ScanRunner(QObject): + """Manages scan execution in a background thread.""" + finished = Signal(object, list) + progress = Signal(object) + error = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self._thread: Optional[QThread] = None + self._worker: Optional[ScanWorker] = None + + def start_scan(self, tab: ActiveTab, app_settings: AppSettings, + tool_settings: ToolSettings): + if self._thread and self._thread.isRunning(): + return + + self._thread = QThread() + self._worker = ScanWorker(tab, app_settings, tool_settings) + self._worker.moveToThread(self._thread) + + self._thread.started.connect(self._worker.run) + self._worker.finished.connect(self._on_finished) + self._worker.progress.connect(self.progress.emit) + self._worker.error.connect(self._on_error) + + self._thread.start() + + def stop_scan(self): + if self._worker: + self._worker.cancel() + # Wait briefly for the thread to finish after killing the process + if self._thread and self._thread.isRunning(): + self._thread.quit() + if not self._thread.wait(5000): + self._thread.terminate() + self._thread.wait() + self._thread = None + self._worker = None + # Emit an empty result to signal completion + self.finished.emit(ActiveTab.DUPLICATE_FILES, []) + + def _on_finished(self, tab, results): + self.finished.emit(tab, results) + self._cleanup() + + def _on_error(self, msg): + self.error.emit(msg) + self._cleanup() + + def _cleanup(self): + if self._thread: + self._thread.quit() + self._thread.wait(5000) + self._thread = None + self._worker = None + + +class FileOperations: + """File operations: delete, move, hardlink, symlink, rename.""" + + @staticmethod + def delete_files(entries: list[ResultEntry], move_to_trash: bool = True) -> tuple[int, list[str]]: + deleted = 0 + errors = [] + for entry in entries: + path = entry.values.get("__full_path", "") + if not path: + continue + try: + p = Path(path) + if not p.exists(): + errors.append(f"File not found: {path}") + continue + if move_to_trash: + try: + from send2trash import send2trash + send2trash(str(p)) + except ImportError: + p.unlink() + else: + if p.is_dir(): + import shutil + shutil.rmtree(p) + else: + p.unlink() + deleted += 1 + except OSError as e: + errors.append(f"Error deleting {path}: {e}") + return deleted, errors + + @staticmethod + def move_files(entries: list[ResultEntry], destination: str, + preserve_structure: bool = False, copy_mode: bool = False) -> tuple[int, list[str]]: + import shutil + moved = 0 + errors = [] + dest = Path(destination) + dest.mkdir(parents=True, exist_ok=True) + + for entry in entries: + src_path = entry.values.get("__full_path", "") + if not src_path: + continue + try: + src = Path(src_path) + if preserve_structure: + # Keep relative directory structure + rel = src.parent + target_dir = dest / rel.relative_to(rel.anchor) + target_dir.mkdir(parents=True, exist_ok=True) + target = target_dir / src.name + else: + target = dest / src.name + + # Handle name conflicts + if target.exists(): + stem = target.stem + suffix = target.suffix + counter = 1 + while target.exists(): + target = target.parent / f"{stem}_{counter}{suffix}" + counter += 1 + + if copy_mode: + shutil.copy2(str(src), str(target)) + else: + shutil.move(str(src), str(target)) + moved += 1 + except OSError as e: + errors.append(f"Error moving {src_path}: {e}") + return moved, errors + + @staticmethod + def create_hardlinks(entries: list[ResultEntry], reference_path: str) -> tuple[int, list[str]]: + created = 0 + errors = [] + for entry in entries: + path = entry.values.get("__full_path", "") + if not path or path == reference_path: + continue + try: + p = Path(path) + p.unlink() + os.link(reference_path, str(p)) + created += 1 + except OSError as e: + errors.append(f"Error creating hardlink for {path}: {e}") + return created, errors + + @staticmethod + def create_symlinks(entries: list[ResultEntry], reference_path: str) -> tuple[int, list[str]]: + created = 0 + errors = [] + for entry in entries: + path = entry.values.get("__full_path", "") + if not path or path == reference_path: + continue + try: + p = Path(path) + p.unlink() + os.symlink(reference_path, str(p)) + created += 1 + except OSError as e: + errors.append(f"Error creating symlink for {path}: {e}") + return created, errors + + @staticmethod + def fix_extensions(cli_path: str, app_settings, tool_settings) -> tuple[bool, str]: + """Run czkawka_cli ext with -F flag to fix extensions.""" + cmd = [cli_path, "ext", "-F"] + if app_settings.included_paths: + cmd.extend(["-d", ",".join(app_settings.included_paths)]) + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + return True, result.stdout + except Exception as e: + return False, str(e) + + @staticmethod + def fix_bad_names(cli_path: str, app_settings, tool_settings) -> tuple[bool, str]: + """Run czkawka_cli bad-names with -F flag to fix names.""" + cmd = [cli_path, "bad-names", "-F"] + if app_settings.included_paths: + cmd.extend(["-d", ",".join(app_settings.included_paths)]) + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + return True, result.stdout + except Exception as e: + return False, str(e) + + @staticmethod + def clean_exif(cli_path: str, entries: list[ResultEntry], + ignored_tags: str = "", overwrite: bool = True) -> tuple[int, list[str]]: + """Remove EXIF data from selected files.""" + cleaned = 0 + errors = [] + for entry in entries: + path = entry.values.get("__full_path", "") + if not path: + continue + try: + from PIL import Image + img = Image.open(path) + data = list(img.getdata()) + clean_img = Image.new(img.mode, img.size) + clean_img.putdata(data) + if overwrite: + clean_img.save(path) + else: + p = Path(path) + new_path = p.parent / f"{p.stem}_clean{p.suffix}" + clean_img.save(str(new_path)) + cleaned += 1 + except Exception as e: + errors.append(f"Error cleaning EXIF for {path}: {e}") + return cleaned, errors diff --git a/czkawka_pyside6/app/bottom_panel.py b/czkawka_pyside6/app/bottom_panel.py new file mode 100644 index 000000000..736a5cd04 --- /dev/null +++ b/czkawka_pyside6/app/bottom_panel.py @@ -0,0 +1,143 @@ +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QListWidget, + QPushButton, QFileDialog, QTextEdit, QStackedWidget, + QSizePolicy +) +from PySide6.QtCore import Signal, Qt + +from .models import AppSettings + + +class BottomPanel(QWidget): + """Bottom panel showing directories or error messages.""" + directories_changed = Signal() + + def __init__(self, settings: AppSettings, parent=None): + super().__init__(parent) + self._settings = settings + self.setMaximumHeight(200) + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(4, 2, 4, 2) + + self._stack = QStackedWidget() + + # Page 0: Directories view + dir_widget = QWidget() + dir_layout = QHBoxLayout(dir_widget) + dir_layout.setContentsMargins(0, 0, 0, 0) + + # Included directories + inc_widget = QWidget() + inc_layout = QVBoxLayout(inc_widget) + inc_layout.setContentsMargins(0, 0, 0, 0) + inc_layout.addWidget(QLabel("Included Directories:")) + + self._inc_list = QListWidget() + self._inc_list.setMaximumHeight(120) + for path in self._settings.included_paths: + self._inc_list.addItem(path) + inc_layout.addWidget(self._inc_list) + + inc_btns = QHBoxLayout() + add_btn = QPushButton("+") + add_btn.setFixedWidth(30) + add_btn.clicked.connect(self._add_included) + inc_btns.addWidget(add_btn) + rem_btn = QPushButton("-") + rem_btn.setFixedWidth(30) + rem_btn.clicked.connect(self._remove_included) + inc_btns.addWidget(rem_btn) + inc_btns.addStretch() + inc_layout.addLayout(inc_btns) + dir_layout.addWidget(inc_widget) + + # Excluded directories + exc_widget = QWidget() + exc_layout = QVBoxLayout(exc_widget) + exc_layout.setContentsMargins(0, 0, 0, 0) + exc_layout.addWidget(QLabel("Excluded Directories:")) + + self._exc_list = QListWidget() + self._exc_list.setMaximumHeight(120) + for path in self._settings.excluded_paths: + self._exc_list.addItem(path) + exc_layout.addWidget(self._exc_list) + + exc_btns = QHBoxLayout() + add_exc = QPushButton("+") + add_exc.setFixedWidth(30) + add_exc.clicked.connect(self._add_excluded) + exc_btns.addWidget(add_exc) + rem_exc = QPushButton("-") + rem_exc.setFixedWidth(30) + rem_exc.clicked.connect(self._remove_excluded) + exc_btns.addWidget(rem_exc) + exc_btns.addStretch() + exc_layout.addLayout(exc_btns) + dir_layout.addWidget(exc_widget) + + self._stack.addWidget(dir_widget) + + # Page 1: Text errors/info + self._text_area = QTextEdit() + self._text_area.setReadOnly(True) + self._text_area.setMaximumHeight(150) + self._stack.addWidget(self._text_area) + + layout.addWidget(self._stack) + + def show_directories(self): + self._stack.setCurrentIndex(0) + self.setVisible(True) + + def show_text(self): + self._stack.setCurrentIndex(1) + self.setVisible(True) + + def hide_panel(self): + self.setVisible(False) + + def set_text(self, text: str): + self._text_area.setPlainText(text) + + def append_text(self, text: str): + self._text_area.append(text) + + def _add_included(self): + path = QFileDialog.getExistingDirectory(self, "Select Directory to Include") + if path and path not in self._settings.included_paths: + self._settings.included_paths.append(path) + self._inc_list.addItem(path) + self.directories_changed.emit() + + def _remove_included(self): + row = self._inc_list.currentRow() + if row >= 0: + self._inc_list.takeItem(row) + self._settings.included_paths.pop(row) + self.directories_changed.emit() + + def _add_excluded(self): + path = QFileDialog.getExistingDirectory(self, "Select Directory to Exclude") + if path and path not in self._settings.excluded_paths: + self._settings.excluded_paths.append(path) + self._exc_list.addItem(path) + self.directories_changed.emit() + + def _remove_excluded(self): + row = self._exc_list.currentRow() + if row >= 0: + self._exc_list.takeItem(row) + self._settings.excluded_paths.pop(row) + self.directories_changed.emit() + + def refresh_lists(self): + self._inc_list.clear() + for path in self._settings.included_paths: + self._inc_list.addItem(path) + self._exc_list.clear() + for path in self._settings.excluded_paths: + self._exc_list.addItem(path) diff --git a/czkawka_pyside6/app/dialogs/__init__.py b/czkawka_pyside6/app/dialogs/__init__.py new file mode 100644 index 000000000..f1c6d549e --- /dev/null +++ b/czkawka_pyside6/app/dialogs/__init__.py @@ -0,0 +1,7 @@ +from .delete_dialog import DeleteDialog +from .move_dialog import MoveDialog +from .select_dialog import SelectDialog +from .sort_dialog import SortDialog +from .save_dialog import SaveDialog +from .rename_dialog import RenameDialog +from .about_dialog import AboutDialog diff --git a/czkawka_pyside6/app/dialogs/about_dialog.py b/czkawka_pyside6/app/dialogs/about_dialog.py new file mode 100644 index 000000000..54da0fa9f --- /dev/null +++ b/czkawka_pyside6/app/dialogs/about_dialog.py @@ -0,0 +1,78 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QDialogButtonBox +) +from PySide6.QtCore import Qt +from PySide6.QtGui import QPixmap + +from ..icons import app_logo_path + + +class AboutDialog(QDialog): + """About dialog showing application information.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("About Czkawka PySide6") + self.setMinimumWidth(480) + self.setMinimumHeight(420) + + layout = QVBoxLayout(self) + layout.setSpacing(6) + + # Logo + logo_path = app_logo_path() + if logo_path: + logo_label = QLabel() + pixmap = QPixmap(logo_path) + scaled = pixmap.scaledToHeight(100, Qt.SmoothTransformation) + logo_label.setPixmap(scaled) + logo_label.setAlignment(Qt.AlignCenter) + layout.addWidget(logo_label) + + title = QLabel("Czkawka") + title.setStyleSheet("font-size: 28px; font-weight: bold; padding: 4px;") + title.setAlignment(Qt.AlignCenter) + layout.addWidget(title) + + subtitle = QLabel("PySide6 / Qt6 Edition") + subtitle.setStyleSheet("font-size: 14px; color: #6fbf73; padding: 2px;") + subtitle.setAlignment(Qt.AlignCenter) + layout.addWidget(subtitle) + + version = QLabel("Version 11.0.1") + version.setStyleSheet("font-size: 11px; color: #888; padding: 2px;") + version.setAlignment(Qt.AlignCenter) + layout.addWidget(version) + + # Separator + sep = QLabel() + sep.setFixedHeight(1) + sep.setStyleSheet("background-color: #444; margin: 8px 40px;") + layout.addWidget(sep) + + desc = QLabel( + "Czkawka (tch-kav-ka) is a simple, fast and free app to remove\n" + "unnecessary files from your computer.\n\n" + "This PySide6/Qt interface uses the czkawka_cli backend\n" + "for all scanning and file operations.\n\n" + "Features:\n" + " - Find duplicate files (by hash, name, or size)\n" + " - Find empty files and folders\n" + " - Find similar images, videos, and music\n" + " - Find broken files and invalid symlinks\n" + " - Find files with bad extensions or names\n" + " - Remove EXIF metadata from images\n" + " - Optimize and crop videos\n\n" + "Licensed under MIT License\n" + "https://github.com/qarmin/czkawka" + ) + desc.setWordWrap(True) + desc.setAlignment(Qt.AlignCenter) + desc.setStyleSheet("padding: 6px 20px; line-height: 1.4;") + layout.addWidget(desc) + + layout.addStretch() + + buttons = QDialogButtonBox(QDialogButtonBox.Ok) + buttons.accepted.connect(self.accept) + layout.addWidget(buttons) diff --git a/czkawka_pyside6/app/dialogs/delete_dialog.py b/czkawka_pyside6/app/dialogs/delete_dialog.py new file mode 100644 index 000000000..89ce64bc6 --- /dev/null +++ b/czkawka_pyside6/app/dialogs/delete_dialog.py @@ -0,0 +1,51 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QDialogButtonBox, + QCheckBox, QHBoxLayout +) +from PySide6.QtCore import Qt + + +class DeleteDialog(QDialog): + """Confirmation dialog for deleting files.""" + + def __init__(self, count: int, move_to_trash: bool = True, parent=None): + super().__init__(parent) + self.setWindowTitle("Delete Files") + self.setMinimumWidth(400) + self._move_to_trash = move_to_trash + + layout = QVBoxLayout(self) + + # Warning + icon_label = QLabel() + icon_label.setStyleSheet("font-size: 36px;") + icon_label.setText("Warning") + icon_label.setAlignment(Qt.AlignCenter) + layout.addWidget(icon_label) + + msg = QLabel(f"Are you sure you want to delete {count} selected file(s)?") + msg.setStyleSheet("font-size: 14px; padding: 10px;") + msg.setAlignment(Qt.AlignCenter) + msg.setWordWrap(True) + layout.addWidget(msg) + + # Move to trash checkbox + self._trash_cb = QCheckBox("Move to trash instead of permanent delete") + self._trash_cb.setChecked(move_to_trash) + layout.addWidget(self._trash_cb) + + # Buttons + buttons = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel + ) + buttons.button(QDialogButtonBox.Ok).setText("Delete") + buttons.button(QDialogButtonBox.Ok).setStyleSheet( + "background-color: #8a2222; color: white; padding: 6px 20px;" + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + @property + def move_to_trash(self) -> bool: + return self._trash_cb.isChecked() diff --git a/czkawka_pyside6/app/dialogs/move_dialog.py b/czkawka_pyside6/app/dialogs/move_dialog.py new file mode 100644 index 000000000..6c754d21a --- /dev/null +++ b/czkawka_pyside6/app/dialogs/move_dialog.py @@ -0,0 +1,65 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QDialogButtonBox, + QCheckBox, QLineEdit, QHBoxLayout, QPushButton, + QFileDialog, QFormLayout +) +from PySide6.QtCore import Qt + + +class MoveDialog(QDialog): + """Dialog for moving/copying files to a destination.""" + + def __init__(self, count: int, parent=None): + super().__init__(parent) + self.setWindowTitle("Move/Copy Files") + self.setMinimumWidth(500) + + layout = QVBoxLayout(self) + + msg = QLabel(f"Move or copy {count} selected file(s) to:") + msg.setStyleSheet("font-size: 13px; padding: 6px;") + layout.addWidget(msg) + + # Destination path + dest_layout = QHBoxLayout() + self._dest_edit = QLineEdit() + self._dest_edit.setPlaceholderText("Select destination folder...") + dest_layout.addWidget(self._dest_edit) + + browse_btn = QPushButton("Browse") + browse_btn.clicked.connect(self._browse) + dest_layout.addWidget(browse_btn) + layout.addLayout(dest_layout) + + # Options + self._preserve_structure = QCheckBox("Preserve folder structure") + layout.addWidget(self._preserve_structure) + + self._copy_mode = QCheckBox("Copy instead of move") + layout.addWidget(self._copy_mode) + + # Buttons + buttons = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel + ) + buttons.button(QDialogButtonBox.Ok).setText("Move") + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def _browse(self): + path = QFileDialog.getExistingDirectory(self, "Select Destination") + if path: + self._dest_edit.setText(path) + + @property + def destination(self) -> str: + return self._dest_edit.text() + + @property + def preserve_structure(self) -> bool: + return self._preserve_structure.isChecked() + + @property + def copy_mode(self) -> bool: + return self._copy_mode.isChecked() diff --git a/czkawka_pyside6/app/dialogs/rename_dialog.py b/czkawka_pyside6/app/dialogs/rename_dialog.py new file mode 100644 index 000000000..4c6208ab1 --- /dev/null +++ b/czkawka_pyside6/app/dialogs/rename_dialog.py @@ -0,0 +1,34 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QDialogButtonBox +) + + +class RenameDialog(QDialog): + """Confirmation dialog for renaming files (fix extensions or bad names).""" + + def __init__(self, count: int, rename_type: str = "extensions", parent=None): + super().__init__(parent) + self.setWindowTitle(f"Fix {rename_type.title()}") + self.setMinimumWidth(400) + + layout = QVBoxLayout(self) + + if rename_type == "extensions": + msg = f"Fix extensions for {count} selected file(s)?\n\n" \ + "Files will be renamed to use their proper extensions." + else: + msg = f"Fix names for {count} selected file(s)?\n\n" \ + "Files with problematic names will be renamed." + + label = QLabel(msg) + label.setWordWrap(True) + label.setStyleSheet("font-size: 13px; padding: 10px;") + layout.addWidget(label) + + buttons = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel + ) + buttons.button(QDialogButtonBox.Ok).setText("Rename") + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) diff --git a/czkawka_pyside6/app/dialogs/save_dialog.py b/czkawka_pyside6/app/dialogs/save_dialog.py new file mode 100644 index 000000000..d8fa614e6 --- /dev/null +++ b/czkawka_pyside6/app/dialogs/save_dialog.py @@ -0,0 +1,48 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QDialogButtonBox, + QCheckBox, QFileDialog +) + + +class SaveDialog: + """Save results to file (uses native file dialog).""" + + @staticmethod + def save(parent, results: list, save_as_json: bool = False) -> bool: + if save_as_json: + filter_str = "JSON Files (*.json);;All Files (*)" + default_ext = ".json" + else: + filter_str = "Text Files (*.txt);;All Files (*)" + default_ext = ".txt" + + path, _ = QFileDialog.getSaveFileName( + parent, "Save Results", f"results{default_ext}", filter_str + ) + if not path: + return False + + try: + import json + if save_as_json: + data = [] + for entry in results: + if not entry.header_row: + # Filter out internal keys + values = {k: v for k, v in entry.values.items() + if not k.startswith("__")} + data.append(values) + with open(path, "w") as f: + json.dump(data, f, indent=2) + else: + with open(path, "w") as f: + for entry in results: + if entry.header_row: + f.write(f"\n--- {entry.values.get('__header', 'Group')} ---\n") + else: + path_val = entry.values.get("__full_path", "") + size = entry.values.get("Size", "") + f.write(f"{path_val}\t{size}\n") + return True + except OSError: + return False diff --git a/czkawka_pyside6/app/dialogs/select_dialog.py b/czkawka_pyside6/app/dialogs/select_dialog.py new file mode 100644 index 000000000..58ea94a11 --- /dev/null +++ b/czkawka_pyside6/app/dialogs/select_dialog.py @@ -0,0 +1,48 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QPushButton, QDialogButtonBox +) +from PySide6.QtCore import Signal + +from ..models import SelectMode + + +class SelectDialog(QDialog): + """Dialog for selecting/deselecting results.""" + mode_selected = Signal(object) # SelectMode + + MODES = [ + (SelectMode.SELECT_ALL, "Select All"), + (SelectMode.UNSELECT_ALL, "Unselect All"), + (SelectMode.INVERT_SELECTION, "Invert Selection"), + (SelectMode.SELECT_BIGGEST_SIZE, "Select Biggest (by Size)"), + (SelectMode.SELECT_SMALLEST_SIZE, "Select Smallest (by Size)"), + (SelectMode.SELECT_NEWEST, "Select Newest"), + (SelectMode.SELECT_OLDEST, "Select Oldest"), + (SelectMode.SELECT_SHORTEST_PATH, "Select Shortest Path"), + (SelectMode.SELECT_LONGEST_PATH, "Select Longest Path"), + ] + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Select Results") + self.setMinimumWidth(300) + + layout = QVBoxLayout(self) + + label = QLabel("Choose selection mode:") + label.setStyleSheet("font-size: 13px; padding: 4px;") + layout.addWidget(label) + + for mode, name in self.MODES: + btn = QPushButton(name) + btn.clicked.connect(lambda checked, m=mode: self._select(m)) + layout.addWidget(btn) + + # Cancel + cancel = QPushButton("Cancel") + cancel.clicked.connect(self.reject) + layout.addWidget(cancel) + + def _select(self, mode: SelectMode): + self.mode_selected.emit(mode) + self.accept() diff --git a/czkawka_pyside6/app/dialogs/sort_dialog.py b/czkawka_pyside6/app/dialogs/sort_dialog.py new file mode 100644 index 000000000..3d6f627e4 --- /dev/null +++ b/czkawka_pyside6/app/dialogs/sort_dialog.py @@ -0,0 +1,39 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QPushButton, QComboBox, + QCheckBox, QDialogButtonBox, QFormLayout +) +from PySide6.QtCore import Signal + + +class SortDialog(QDialog): + """Dialog for sorting results.""" + sort_requested = Signal(int, bool) # column_index, ascending + + def __init__(self, columns: list[str], parent=None): + super().__init__(parent) + self.setWindowTitle("Sort Results") + self.setMinimumWidth(300) + + layout = QFormLayout(self) + + self._column = QComboBox() + self._column.addItems(columns) + layout.addRow("Sort by:", self._column) + + self._ascending = QCheckBox("Ascending") + self._ascending.setChecked(True) + layout.addRow(self._ascending) + + buttons = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel + ) + buttons.accepted.connect(self._on_sort) + buttons.rejected.connect(self.reject) + layout.addRow(buttons) + + def _on_sort(self): + self.sort_requested.emit( + self._column.currentIndex(), + self._ascending.isChecked() + ) + self.accept() diff --git a/czkawka_pyside6/app/icons.py b/czkawka_pyside6/app/icons.py new file mode 100644 index 000000000..bc094529f --- /dev/null +++ b/czkawka_pyside6/app/icons.py @@ -0,0 +1,149 @@ +"""SVG icon resources for Czkawka PySide6 interface. + +Uses the same SVG icons as the Krokiet (Slint) interface. +Icons are embedded as strings to avoid file path issues. +""" + +from PySide6.QtGui import QIcon, QPixmap +from PySide6.QtCore import QSize, Qt +from PySide6.QtSvg import QSvgRenderer +from PySide6.QtGui import QPainter, QImage +from functools import lru_cache + +# Fill color applied to icons for dark theme visibility +_ICON_FILL = "#cccccc" +_ICON_FILL_GREEN = "#6fbf73" +_ICON_FILL_RED = "#e57373" + + +def _colorize_svg(svg: str, fill: str = _ICON_FILL) -> str: + """Inject fill color into SVG for dark theme visibility.""" + # Add fill to root svg or g elements + if 'fill="none"' not in svg and f'fill="{fill}"' not in svg: + svg = svg.replace(" QIcon: + """Convert SVG string to QIcon with specified fill color.""" + colored = _colorize_svg(svg_data, fill) + renderer = QSvgRenderer(colored.encode("utf-8")) + image = QImage(QSize(size, size), QImage.Format_ARGB32_Premultiplied) + image.fill(Qt.transparent) + painter = QPainter(image) + renderer.render(painter) + painter.end() + pixmap = QPixmap.fromImage(image) + return QIcon(pixmap) + + +# ─── Raw SVG data ─────────────────────────────────────────── + +SEARCH_SVG = '''''' + +STOP_SVG = '''''' + +DELETE_SVG = '''''' + +MOVE_SVG = '''''' + +SAVE_SVG = '''''' + +SELECT_SVG = '''''' + +HARDLINK_SVG = '''''' + +SYMLINK_SVG = '''''' + +RENAME_SVG = '''''' + +CLEAN_SVG = '''''' + +OPTIMIZE_SVG = '''''' + +SETTINGS_SVG = '''''' + +SUBSETTINGS_SVG = '''''' + +SORT_SVG = '''''' + +DIR_SVG = '''''' + +INFO_SVG = '''''' + + +# ─── Icon accessor functions ──────────────────────────────── + +def icon_search(size=24): + return _svg_to_icon(SEARCH_SVG, _ICON_FILL_GREEN, size) + +def icon_stop(size=24): + return _svg_to_icon(STOP_SVG, _ICON_FILL_RED, size) + +def icon_delete(size=24): + return _svg_to_icon(DELETE_SVG, _ICON_FILL_RED, size) + +def icon_move(size=24): + return _svg_to_icon(MOVE_SVG, _ICON_FILL, size) + +def icon_save(size=24): + return _svg_to_icon(SAVE_SVG, _ICON_FILL, size) + +def icon_select(size=24): + return _svg_to_icon(SELECT_SVG, _ICON_FILL, size) + +def icon_sort(size=24): + return _svg_to_icon(SORT_SVG, _ICON_FILL, size) + +def icon_hardlink(size=24): + return _svg_to_icon(HARDLINK_SVG, _ICON_FILL, size) + +def icon_symlink(size=24): + return _svg_to_icon(SYMLINK_SVG, _ICON_FILL, size) + +def icon_rename(size=24): + return _svg_to_icon(RENAME_SVG, _ICON_FILL, size) + +def icon_clean(size=24): + return _svg_to_icon(CLEAN_SVG, _ICON_FILL, size) + +def icon_optimize(size=24): + return _svg_to_icon(OPTIMIZE_SVG, _ICON_FILL, size) + +def icon_settings(size=24): + return _svg_to_icon(SETTINGS_SVG, _ICON_FILL, size) + +def icon_subsettings(size=24): + return _svg_to_icon(SUBSETTINGS_SVG, _ICON_FILL, size) + +def icon_dir(size=24): + return _svg_to_icon(DIR_SVG, _ICON_FILL, size) + +def icon_info(size=24): + return _svg_to_icon(INFO_SVG, _ICON_FILL, size) + + +def app_logo_path() -> str: + """Return absolute path to the Krokiet logo PNG.""" + from pathlib import Path + # Try relative to this file first, then absolute project path + for candidate in [ + Path(__file__).parent.parent.parent / "krokiet" / "icons" / "krokiet_logo.png", + Path("/mnt/developer/git/aecs4u.it/czkawka/krokiet/icons/krokiet_logo.png"), + ]: + if candidate.exists(): + return str(candidate) + return "" + + +def app_icon() -> QIcon: + """Return the application window icon from the Krokiet logo.""" + path = app_logo_path() + if path: + return QIcon(path) + return QIcon() diff --git a/czkawka_pyside6/app/left_panel.py b/czkawka_pyside6/app/left_panel.py new file mode 100644 index 000000000..8c53e77d3 --- /dev/null +++ b/czkawka_pyside6/app/left_panel.py @@ -0,0 +1,149 @@ +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QListWidget, QListWidgetItem, QLabel, + QPushButton, QHBoxLayout, QSizePolicy +) +from PySide6.QtCore import Signal, Qt, QSize +from PySide6.QtGui import QFont, QPixmap + +from .models import ActiveTab, TAB_DISPLAY_NAMES, TABS_WITH_SETTINGS +from .icons import app_logo_path, icon_settings, icon_subsettings + + +class LeftPanel(QWidget): + """Left sidebar for selecting scan tools.""" + tab_changed = Signal(object) # ActiveTab + settings_requested = Signal() + about_requested = Signal() + tool_settings_toggled = Signal(bool) + + # Tool tabs in display order + TOOL_TABS = [ + ActiveTab.DUPLICATE_FILES, + ActiveTab.EMPTY_FOLDERS, + ActiveTab.BIG_FILES, + ActiveTab.EMPTY_FILES, + ActiveTab.TEMPORARY_FILES, + ActiveTab.SIMILAR_IMAGES, + ActiveTab.SIMILAR_VIDEOS, + ActiveTab.SIMILAR_MUSIC, + ActiveTab.INVALID_SYMLINKS, + ActiveTab.BROKEN_FILES, + ActiveTab.BAD_EXTENSIONS, + ActiveTab.BAD_NAMES, + ActiveTab.EXIF_REMOVER, + ActiveTab.VIDEO_OPTIMIZER, + ] + + def __init__(self, parent=None): + super().__init__(parent) + self.setMinimumWidth(170) + self.setMaximumWidth(230) + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(6, 6, 6, 6) + layout.setSpacing(4) + + # Logo image (clickable) + logo_path = app_logo_path() + if logo_path: + self._logo_label = QLabel() + pixmap = QPixmap(logo_path) + scaled = pixmap.scaledToHeight(70, Qt.SmoothTransformation) + self._logo_label.setPixmap(scaled) + self._logo_label.setAlignment(Qt.AlignCenter) + self._logo_label.setCursor(Qt.PointingHandCursor) + self._logo_label.setToolTip("About Czkawka") + self._logo_label.mousePressEvent = lambda _: self.about_requested.emit() + layout.addWidget(self._logo_label) + else: + title_label = QLabel("Czkawka") + title_font = QFont() + title_font.setPointSize(16) + title_font.setBold(True) + title_label.setFont(title_font) + title_label.setAlignment(Qt.AlignCenter) + title_label.setCursor(Qt.PointingHandCursor) + title_label.mousePressEvent = lambda _: self.about_requested.emit() + layout.addWidget(title_label) + + # Top buttons row with icons + btn_row = QHBoxLayout() + btn_row.setSpacing(4) + + self._settings_btn = QPushButton(icon_settings(20), "") + self._settings_btn.setFixedSize(32, 32) + self._settings_btn.setIconSize(QSize(20, 20)) + self._settings_btn.setToolTip("Application Settings") + self._settings_btn.clicked.connect(self.settings_requested.emit) + btn_row.addWidget(self._settings_btn) + + self._tool_settings_btn = QPushButton(icon_subsettings(20), "") + self._tool_settings_btn.setFixedSize(32, 32) + self._tool_settings_btn.setIconSize(QSize(20, 20)) + self._tool_settings_btn.setToolTip("Tool-specific Settings") + self._tool_settings_btn.setCheckable(True) + self._tool_settings_btn.clicked.connect( + lambda checked: self.tool_settings_toggled.emit(checked) + ) + self._tool_settings_btn.setVisible(False) + btn_row.addWidget(self._tool_settings_btn) + + btn_row.addStretch() + layout.addLayout(btn_row) + + # Tool list + self._tool_list = QListWidget() + self._tool_list.setSpacing(1) + font = QFont() + font.setPointSize(10) + self._tool_list.setFont(font) + self._tool_list.setStyleSheet(""" + QListWidget::item { + padding: 4px 8px; + border-left: 3px solid transparent; + } + QListWidget::item:selected { + border-left: 3px solid #6fbf73; + background-color: #353535; + } + QListWidget::item:hover { + background-color: #49494926; + } + """) + + for tab in self.TOOL_TABS: + item = QListWidgetItem(TAB_DISPLAY_NAMES[tab]) + item.setData(Qt.UserRole, tab) + item.setSizeHint(item.sizeHint().__class__(item.sizeHint().width(), 30)) + self._tool_list.addItem(item) + + self._tool_list.setCurrentRow(0) + self._tool_list.currentItemChanged.connect(self._on_item_changed) + layout.addWidget(self._tool_list) + + # Version label + version_label = QLabel("Czkawka PySide6 v11.0.1") + version_label.setAlignment(Qt.AlignCenter) + version_label.setStyleSheet("color: #666; font-size: 9px; padding: 2px;") + layout.addWidget(version_label) + + def _on_item_changed(self, current, previous): + if current: + tab = current.data(Qt.UserRole) + self._tool_settings_btn.setVisible(tab in TABS_WITH_SETTINGS) + self.tab_changed.emit(tab) + + def set_active_tab(self, tab: ActiveTab): + for i in range(self._tool_list.count()): + item = self._tool_list.item(i) + if item.data(Qt.UserRole) == tab: + self._tool_list.setCurrentRow(i) + break + + def get_active_tab(self) -> ActiveTab: + item = self._tool_list.currentItem() + if item: + return item.data(Qt.UserRole) + return ActiveTab.DUPLICATE_FILES diff --git a/czkawka_pyside6/app/main_window.py b/czkawka_pyside6/app/main_window.py new file mode 100644 index 000000000..2515bd237 --- /dev/null +++ b/czkawka_pyside6/app/main_window.py @@ -0,0 +1,624 @@ +"""Main application window for Czkawka PySide6 interface.""" + +import shutil +from pathlib import Path + +from PySide6.QtWidgets import ( + QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QSplitter, + QStatusBar, QMessageBox, QLabel, QApplication +) +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QPalette, QColor + +from .state import AppState +from .models import ( + ActiveTab, TAB_COLUMNS, GROUPED_TABS, TABS_WITH_SETTINGS, SelectMode +) +from .left_panel import LeftPanel +from .results_view import ResultsView +from .action_buttons import ActionButtons +from .tool_settings import ToolSettingsPanel +from .settings_panel import SettingsPanel +from .progress_widget import ProgressWidget +from .preview_panel import PreviewPanel +from .bottom_panel import BottomPanel +from .backend import ScanRunner, FileOperations +from .icons import app_icon +from .dialogs import ( + DeleteDialog, MoveDialog, SelectDialog, + SortDialog, SaveDialog, RenameDialog, AboutDialog +) + + +class MainWindow(QMainWindow): + """Main application window with all panels and functionality.""" + + def __init__(self): + super().__init__() + self._state = AppState() + self._scan_runner = ScanRunner(self) + self._setup_window() + self._setup_ui() + self._connect_signals() + self._apply_theme() + + def _setup_window(self): + self.setWindowTitle("Czkawka - PySide6 Edition") + self.setMinimumSize(900, 600) + self.resize(1200, 800) + + # Set window icon from project logo + icon = app_icon() + if not icon.isNull(): + self.setWindowIcon(icon) + + # Auto-detect czkawka_cli binary + self._auto_detect_cli() + + def _setup_ui(self): + central = QWidget() + self.setCentralWidget(central) + main_layout = QVBoxLayout(central) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + + # Top area: left panel + content + content_splitter = QSplitter(Qt.Horizontal) + + # Left panel (tool selection) + self._left_panel = LeftPanel() + content_splitter.addWidget(self._left_panel) + + # Center area + center_widget = QWidget() + center_layout = QVBoxLayout(center_widget) + center_layout.setContentsMargins(0, 0, 0, 0) + center_layout.setSpacing(0) + + # Results view + self._results_view = ResultsView() + center_layout.addWidget(self._results_view, 1) + + # Progress widget (hidden by default) + self._progress = ProgressWidget() + center_layout.addWidget(self._progress) + + content_splitter.addWidget(center_widget) + + # Tool settings panel (hidden by default) + self._tool_settings = ToolSettingsPanel(self._state.tool_settings) + self._tool_settings.setVisible(False) + content_splitter.addWidget(self._tool_settings) + + # Preview panel (hidden by default) + self._preview = PreviewPanel() + self._preview.setVisible(False) + content_splitter.addWidget(self._preview) + + # Set splitter sizes + content_splitter.setStretchFactor(0, 0) # Left panel: fixed + content_splitter.setStretchFactor(1, 1) # Center: stretch + content_splitter.setStretchFactor(2, 0) # Tool settings: fixed + content_splitter.setStretchFactor(3, 0) # Preview: fixed + + main_layout.addWidget(content_splitter, 1) + + # Action buttons bar + self._action_buttons = ActionButtons() + main_layout.addWidget(self._action_buttons) + + # Bottom panel (directories / errors) + self._bottom_panel = BottomPanel(self._state.settings) + self._bottom_panel.show_directories() + main_layout.addWidget(self._bottom_panel) + + # Settings panel (overlay, hidden by default) + self._settings_panel = SettingsPanel(self._state.settings) + self._settings_panel.setVisible(False) + + # Status bar + self._statusbar = QStatusBar() + self.setStatusBar(self._statusbar) + self._status_label = QLabel("Ready") + self._statusbar.addWidget(self._status_label, 1) + + # Initialize results view columns for default tab + self._results_view.set_active_tab(self._state.active_tab) + + def _connect_signals(self): + # Left panel + self._left_panel.tab_changed.connect(self._on_tab_changed) + self._left_panel.settings_requested.connect(self._show_settings) + self._left_panel.about_requested.connect(self._show_about) + self._left_panel.tool_settings_toggled.connect(self._toggle_tool_settings) + + # Action buttons + self._action_buttons.scan_clicked.connect(self._start_scan) + self._action_buttons.stop_clicked.connect(self._stop_scan) + self._action_buttons.select_clicked.connect(self._show_select_dialog) + self._action_buttons.delete_clicked.connect(self._show_delete_dialog) + self._action_buttons.move_clicked.connect(self._show_move_dialog) + self._action_buttons.save_clicked.connect(self._save_results) + self._action_buttons.sort_clicked.connect(self._show_sort_dialog) + self._action_buttons.hardlink_clicked.connect(self._create_hardlinks) + self._action_buttons.symlink_clicked.connect(self._create_symlinks) + self._action_buttons.rename_clicked.connect(self._rename_files) + self._action_buttons.clean_exif_clicked.connect(self._clean_exif) + self._action_buttons.optimize_video_clicked.connect(self._optimize_video) + + # Results view + self._results_view.selection_changed.connect( + lambda count: self._action_buttons.set_has_selection(count > 0) + ) + self._results_view.item_activated.connect(self._on_item_activated) + + # Scan runner + self._scan_runner.finished.connect(self._on_scan_finished) + self._scan_runner.progress.connect(self._on_scan_progress) + self._scan_runner.error.connect(self._on_scan_error) + + # Settings + self._settings_panel.close_requested.connect( + lambda: self._settings_panel.setVisible(False) + ) + self._settings_panel.settings_changed.connect(self._on_settings_changed) + + # Bottom panel + self._bottom_panel.directories_changed.connect(self._on_settings_changed) + + def _on_tab_changed(self, tab: ActiveTab): + self._state.set_active_tab(tab) + self._results_view.set_active_tab(tab) + self._action_buttons.set_active_tab(tab) + self._tool_settings.set_active_tab(tab) + + # Show/hide preview for image-related tabs + show_preview = ( + tab in (ActiveTab.SIMILAR_IMAGES, ActiveTab.DUPLICATE_FILES) + and self._state.settings.show_image_preview + ) + self._preview.setVisible(show_preview) + + # Load existing results for this tab + results = self._state.get_results(tab) + if results: + self._results_view.set_results(results) + self._action_buttons.set_has_results(True) + else: + self._results_view.clear() + self._action_buttons.set_has_results(False) + + self._status_label.setText(f"Tab: {tab.name.replace('_', ' ').title()}") + + def _start_scan(self): + tab = self._state.active_tab + if not self._state.settings.included_paths: + QMessageBox.warning( + self, "No Directories", + "Please add at least one directory to scan in the bottom panel." + ) + return + + self._state.set_scanning(True) + self._action_buttons.set_scanning(True) + self._progress.start(tab) + self._results_view.clear() + self._status_label.setText(f"Scanning: {tab.name.replace('_', ' ').title()}...") + + self._scan_runner.start_scan( + tab, self._state.settings, self._state.tool_settings + ) + + def _stop_scan(self): + self._state.request_stop() + self._scan_runner.stop_scan() + self._status_label.setText("Scan stopped by user") + + def _on_scan_finished(self, tab: ActiveTab, results: list): + self._state.set_scanning(False) + self._state.set_results(tab, results) + self._action_buttons.set_scanning(False) + self._progress.stop() + + if tab == self._state.active_tab: + self._results_view.set_results(results) + self._action_buttons.set_has_results(len(results) > 0) + + count = sum(1 for r in results if not r.header_row) + self._status_label.setText(f"Scan complete: found {count} entries") + + def _on_scan_progress(self, progress): + self._progress.update_progress(progress) + + def _on_scan_error(self, error_msg: str): + self._state.set_scanning(False) + self._action_buttons.set_scanning(False) + self._progress.stop() + self._status_label.setText(f"Error: {error_msg}") + self._bottom_panel.set_text(f"Error: {error_msg}") + self._bottom_panel.show_text() + QMessageBox.critical(self, "Scan Error", error_msg) + + def _on_item_activated(self, entry): + path = entry.values.get("__full_path", "") + if path and self._preview.isVisible(): + self._preview.show_preview(path) + + def _show_settings(self): + self._settings_panel.setVisible(True) + # Show as a floating window + self._settings_panel.setParent(None) + self._settings_panel.setWindowTitle("Czkawka Settings") + self._settings_panel.setMinimumSize(600, 500) + self._settings_panel.show() + self._settings_panel.raise_() + + def _show_about(self): + dialog = AboutDialog(self) + dialog.exec() + + def _toggle_tool_settings(self, visible: bool): + self._tool_settings.setVisible(visible) + + def _show_select_dialog(self): + dialog = SelectDialog(self) + dialog.mode_selected.connect(self._results_view.apply_selection) + dialog.exec() + + def _show_delete_dialog(self): + checked = self._results_view.get_checked_entries() + if not checked: + QMessageBox.information(self, "No Selection", "No files selected for deletion.") + return + + dialog = DeleteDialog(len(checked), self._state.settings.move_to_trash, self) + if dialog.exec() == DeleteDialog.Accepted: + deleted, errors = FileOperations.delete_files( + checked, dialog.move_to_trash + ) + self._status_label.setText(f"Deleted {deleted} file(s)") + if errors: + self._bottom_panel.set_text("\n".join(errors)) + self._bottom_panel.show_text() + # Refresh results - remove deleted entries + self._refresh_after_action(checked) + + def _show_move_dialog(self): + checked = self._results_view.get_checked_entries() + if not checked: + QMessageBox.information(self, "No Selection", "No files selected.") + return + + dialog = MoveDialog(len(checked), self) + if dialog.exec() == MoveDialog.Accepted: + if not dialog.destination: + QMessageBox.warning(self, "No Destination", "Please select a destination folder.") + return + moved, errors = FileOperations.move_files( + checked, dialog.destination, + dialog.preserve_structure, dialog.copy_mode + ) + action = "Copied" if dialog.copy_mode else "Moved" + self._status_label.setText(f"{action} {moved} file(s)") + if errors: + self._bottom_panel.set_text("\n".join(errors)) + self._bottom_panel.show_text() + if not dialog.copy_mode: + self._refresh_after_action(checked) + + def _save_results(self): + results = self._results_view.get_all_entries() + if not results: + QMessageBox.information(self, "No Results", "No results to save.") + return + all_results = self._state.get_results() + success = SaveDialog.save(self, all_results, self._state.settings.save_as_json) + if success: + self._status_label.setText("Results saved successfully") + + def _show_sort_dialog(self): + columns = TAB_COLUMNS.get(self._state.active_tab, []) + if not columns: + return + dialog = SortDialog(columns, self) + dialog.sort_requested.connect(self._results_view.sort_by_column) + dialog.exec() + + def _create_hardlinks(self): + checked = self._results_view.get_checked_entries() + if not checked: + return + + # Find the reference file (first unchecked in the same group) + all_results = self._state.get_results() + group_id = checked[0].group_id + reference = None + for r in all_results: + if r.group_id == group_id and not r.header_row and not r.checked: + reference = r.values.get("__full_path", "") + break + + if not reference: + QMessageBox.warning( + self, "No Reference", + "Cannot determine reference file. Leave at least one file unchecked in the group." + ) + return + + reply = QMessageBox.question( + self, "Create Hardlinks", + f"Replace {len(checked)} selected file(s) with hardlinks to:\n{reference}?", + QMessageBox.Yes | QMessageBox.No + ) + if reply == QMessageBox.Yes: + created, errors = FileOperations.create_hardlinks(checked, reference) + self._status_label.setText(f"Created {created} hardlink(s)") + if errors: + self._bottom_panel.set_text("\n".join(errors)) + self._bottom_panel.show_text() + + def _create_symlinks(self): + checked = self._results_view.get_checked_entries() + if not checked: + return + + all_results = self._state.get_results() + group_id = checked[0].group_id + reference = None + for r in all_results: + if r.group_id == group_id and not r.header_row and not r.checked: + reference = r.values.get("__full_path", "") + break + + if not reference: + QMessageBox.warning( + self, "No Reference", + "Cannot determine reference file. Leave at least one file unchecked in the group." + ) + return + + reply = QMessageBox.question( + self, "Create Symlinks", + f"Replace {len(checked)} selected file(s) with symlinks to:\n{reference}?", + QMessageBox.Yes | QMessageBox.No + ) + if reply == QMessageBox.Yes: + created, errors = FileOperations.create_symlinks(checked, reference) + self._status_label.setText(f"Created {created} symlink(s)") + if errors: + self._bottom_panel.set_text("\n".join(errors)) + self._bottom_panel.show_text() + + def _rename_files(self): + checked = self._results_view.get_checked_entries() + if not checked: + return + + tab = self._state.active_tab + if tab == ActiveTab.BAD_EXTENSIONS: + dialog = RenameDialog(len(checked), "extensions", self) + if dialog.exec() == RenameDialog.Accepted: + success, msg = FileOperations.fix_extensions( + self._state.settings.czkawka_cli_path, + self._state.settings, self._state.tool_settings + ) + self._status_label.setText("Extensions fixed" if success else f"Error: {msg}") + elif tab == ActiveTab.BAD_NAMES: + dialog = RenameDialog(len(checked), "names", self) + if dialog.exec() == RenameDialog.Accepted: + success, msg = FileOperations.fix_bad_names( + self._state.settings.czkawka_cli_path, + self._state.settings, self._state.tool_settings + ) + self._status_label.setText("Names fixed" if success else f"Error: {msg}") + + def _clean_exif(self): + checked = self._results_view.get_checked_entries() + if not checked: + return + + reply = QMessageBox.question( + self, "Clean EXIF", + f"Remove EXIF metadata from {len(checked)} selected file(s)?", + QMessageBox.Yes | QMessageBox.No + ) + if reply == QMessageBox.Yes: + cleaned, errors = FileOperations.clean_exif( + self._state.settings.czkawka_cli_path, + checked, + self._state.tool_settings.exif_ignored_tags + ) + self._status_label.setText(f"Cleaned EXIF from {cleaned} file(s)") + if errors: + self._bottom_panel.set_text("\n".join(errors)) + self._bottom_panel.show_text() + + def _optimize_video(self): + checked = self._results_view.get_checked_entries() + if not checked: + return + + QMessageBox.information( + self, "Video Optimization", + f"Video optimization for {len(checked)} file(s) will be performed " + "using czkawka_cli. Check the status bar for progress." + ) + # Video optimization is done via CLI + self._status_label.setText("Video optimization: use CLI directly for this feature") + + def _refresh_after_action(self, removed_entries: list): + """Remove processed entries from results and refresh the view.""" + removed_paths = {e.values.get("__full_path") for e in removed_entries} + current_results = self._state.get_results() + new_results = [] + for r in current_results: + if r.header_row: + new_results.append(r) + elif r.values.get("__full_path") not in removed_paths: + new_results.append(r) + + # Remove empty group headers + cleaned = [] + i = 0 + while i < len(new_results): + if new_results[i].header_row: + # Check if next entries belong to this group + has_children = False + for j in range(i + 1, len(new_results)): + if new_results[j].header_row: + break + has_children = True + if has_children: + cleaned.append(new_results[i]) + else: + cleaned.append(new_results[i]) + i += 1 + + self._state.set_results(self._state.active_tab, cleaned) + self._results_view.set_results(cleaned) + self._action_buttons.set_has_results( + any(not r.header_row for r in cleaned) + ) + + def _on_settings_changed(self): + self._state.save_settings() + self._bottom_panel.refresh_lists() + + def _apply_theme(self): + """Apply dark theme to the application.""" + if not self._state.settings.dark_theme: + return + + app = QApplication.instance() + palette = QPalette() + + # Dark theme colors + palette.setColor(QPalette.Window, QColor(43, 43, 43)) + palette.setColor(QPalette.WindowText, QColor(210, 210, 210)) + palette.setColor(QPalette.Base, QColor(30, 30, 30)) + palette.setColor(QPalette.AlternateBase, QColor(38, 38, 38)) + palette.setColor(QPalette.ToolTipBase, QColor(50, 50, 50)) + palette.setColor(QPalette.ToolTipText, QColor(210, 210, 210)) + palette.setColor(QPalette.Text, QColor(210, 210, 210)) + palette.setColor(QPalette.Button, QColor(53, 53, 53)) + palette.setColor(QPalette.ButtonText, QColor(210, 210, 210)) + palette.setColor(QPalette.BrightText, QColor(255, 255, 255)) + palette.setColor(QPalette.Link, QColor(86, 140, 210)) + palette.setColor(QPalette.Highlight, QColor(60, 100, 150)) + palette.setColor(QPalette.HighlightedText, QColor(255, 255, 255)) + palette.setColor(QPalette.Disabled, QPalette.Text, QColor(128, 128, 128)) + palette.setColor(QPalette.Disabled, QPalette.ButtonText, QColor(128, 128, 128)) + + app.setPalette(palette) + + # Additional stylesheet + app.setStyleSheet(""" + QMainWindow { background-color: #2b2b2b; } + QSplitter::handle { background-color: #404040; width: 2px; } + QTreeWidget { border: 1px solid #404040; } + QTreeWidget::item { padding: 2px; } + QTreeWidget::item:alternate { background-color: #262626; } + QTreeWidget::item:selected { background-color: #3c6496; } + QListWidget { border: 1px solid #404040; } + QListWidget::item { padding: 3px; } + QListWidget::item:selected { background-color: #3c6496; } + QGroupBox { border: 1px solid #505050; border-radius: 4px; + margin-top: 8px; padding-top: 8px; } + QGroupBox::title { subcontrol-origin: margin; left: 10px; + padding: 0 4px; } + QPushButton { padding: 5px 12px; border: 1px solid #555; + border-radius: 3px; background-color: #404040; } + QPushButton:hover { background-color: #505050; } + QPushButton:pressed { background-color: #353535; } + QPushButton:disabled { background-color: #333; color: #666; } + QComboBox { padding: 4px; border: 1px solid #555; + border-radius: 3px; background-color: #404040; } + QComboBox:hover { background-color: #505050; } + QComboBox QAbstractItemView { background-color: #353535; + border: 1px solid #555; + selection-background-color: #3c6496; } + QLineEdit { padding: 4px; border: 1px solid #555; + border-radius: 3px; background-color: #353535; } + QSpinBox { padding: 4px; border: 1px solid #555; + border-radius: 3px; background-color: #353535; } + QSlider::groove:horizontal { height: 6px; background: #404040; + border-radius: 3px; } + QSlider::handle:horizontal { width: 14px; margin: -4px 0; + background: #888; border-radius: 7px; } + QSlider::handle:horizontal:hover { background: #aaa; } + QProgressBar { border: 1px solid #555; border-radius: 3px; + text-align: center; background-color: #353535; } + QProgressBar::chunk { background-color: #3c6496; border-radius: 2px; } + QScrollArea { border: none; } + QTabWidget::pane { border: 1px solid #555; } + QTabBar::tab { padding: 6px 16px; border: 1px solid #555; + border-bottom: none; border-radius: 3px 3px 0 0; + background-color: #353535; } + QTabBar::tab:selected { background-color: #404040; } + QTabBar::tab:hover { background-color: #505050; } + QStatusBar { background-color: #2b2b2b; border-top: 1px solid #404040; } + QCheckBox { spacing: 6px; } + QCheckBox::indicator { width: 16px; height: 16px; } + QTextEdit { border: 1px solid #404040; background-color: #1e1e1e; } + QHeaderView::section { background-color: #353535; padding: 4px; + border: 1px solid #404040; } + """) + + def _auto_detect_cli(self): + """Auto-detect czkawka_cli binary location.""" + s = self._state.settings + + # If already valid and exists, keep it + if s.czkawka_cli_path != "czkawka_cli" and Path(s.czkawka_cli_path).is_file(): + return + if shutil.which(s.czkawka_cli_path): + return + + # Search common locations + candidates = [] + project_root = Path(__file__).parent.parent.parent + + # Look for compiled binary in standard target dirs + for build_dir in ["target/release", "target/debug"]: + candidate = project_root / build_dir / "czkawka_cli" + if candidate.exists(): + candidates.append(str(candidate)) + + # Check cargo metadata for custom target directory + if not candidates: + try: + import subprocess, json + result = subprocess.run( + ["cargo", "metadata", "--manifest-path", + str(project_root / "Cargo.toml"), + "--format-version", "1", "--no-deps"], + capture_output=True, text=True, timeout=10 + ) + if result.returncode == 0: + meta = json.loads(result.stdout) + target_dir = meta.get("target_directory", "") + if target_dir: + for sub in ["release", "debug"]: + candidate = Path(target_dir) / sub / "czkawka_cli" + if candidate.exists(): + candidates.append(str(candidate)) + except Exception: + pass + + # Also check PATH + which_result = shutil.which("czkawka_cli") + if which_result: + candidates.append(which_result) + + for candidate in candidates: + if Path(candidate).is_file(): + s.czkawka_cli_path = str(candidate) + self._state.save_settings() + return + + def closeEvent(self, event): + """Save settings on close.""" + self._state.save_settings() + if self._state.scanning: + self._scan_runner.stop_scan() + super().closeEvent(event) diff --git a/czkawka_pyside6/app/models.py b/czkawka_pyside6/app/models.py new file mode 100644 index 000000000..306772529 --- /dev/null +++ b/czkawka_pyside6/app/models.py @@ -0,0 +1,305 @@ +from enum import Enum, auto +from dataclasses import dataclass, field +from typing import Optional +from pathlib import Path + + +class ActiveTab(Enum): + DUPLICATE_FILES = auto() + EMPTY_FOLDERS = auto() + BIG_FILES = auto() + EMPTY_FILES = auto() + TEMPORARY_FILES = auto() + SIMILAR_IMAGES = auto() + SIMILAR_VIDEOS = auto() + SIMILAR_MUSIC = auto() + INVALID_SYMLINKS = auto() + BROKEN_FILES = auto() + BAD_EXTENSIONS = auto() + BAD_NAMES = auto() + EXIF_REMOVER = auto() + VIDEO_OPTIMIZER = auto() + SETTINGS = auto() + ABOUT = auto() + + +class SelectMode(Enum): + SELECT_ALL = auto() + UNSELECT_ALL = auto() + INVERT_SELECTION = auto() + SELECT_BIGGEST_SIZE = auto() + SELECT_BIGGEST_RESOLUTION = auto() + SELECT_SMALLEST_SIZE = auto() + SELECT_SMALLEST_RESOLUTION = auto() + SELECT_NEWEST = auto() + SELECT_OLDEST = auto() + SELECT_SHORTEST_PATH = auto() + SELECT_LONGEST_PATH = auto() + SELECT_CUSTOM = auto() + + +class DeleteMethod(Enum): + NONE = "NONE" + DELETE = "DELETE" + ALL_EXCEPT_NEWEST = "AEN" + ALL_EXCEPT_OLDEST = "AEO" + ONE_OLDEST = "OO" + ONE_NEWEST = "ON" + HARDLINK = "HARD" + ALL_EXCEPT_BIGGEST = "AEB" + ALL_EXCEPT_SMALLEST = "AES" + ONE_BIGGEST = "OB" + ONE_SMALLEST = "OS" + + +class CheckingMethod(Enum): + HASH = "HASH" + SIZE = "SIZE" + NAME = "NAME" + SIZE_NAME = "SIZE_NAME" + + +class HashType(Enum): + BLAKE3 = "BLAKE3" + CRC32 = "CRC32" + XXH3 = "XXH3" + + +class ImageHashAlg(Enum): + MEAN = "Mean" + GRADIENT = "Gradient" + BLOCKHASH = "Blockhash" + VERT_GRADIENT = "VertGradient" + DOUBLE_GRADIENT = "DoubleGradient" + MEDIAN = "Median" + + +class ImageFilter(Enum): + LANCZOS3 = "Lanczos3" + NEAREST = "Nearest" + TRIANGLE = "Triangle" + GAUSSIAN = "Gaussian" + CATMULL_ROM = "CatmullRom" + + +class MusicSearchMethod(Enum): + TAGS = "TAGS" + CONTENT = "CONTENT" + + +class VideoCropDetect(Enum): + NONE = "none" + LETTERBOX = "letterbox" + MOTION = "motion" + + +class VideoCropMechanism(Enum): + BLACKBARS = "blackbars" + STATICCONTENT = "staticcontent" + + +class VideoCodec(Enum): + H264 = "h264" + H265 = "h265" + AV1 = "av1" + VP9 = "vp9" + + +# Map ActiveTab to CLI subcommand names +TAB_TO_CLI_COMMAND = { + ActiveTab.DUPLICATE_FILES: "dup", + ActiveTab.EMPTY_FOLDERS: "empty-folders", + ActiveTab.BIG_FILES: "big", + ActiveTab.EMPTY_FILES: "empty-files", + ActiveTab.TEMPORARY_FILES: "temp", + ActiveTab.SIMILAR_IMAGES: "image", + ActiveTab.SIMILAR_VIDEOS: "video", + ActiveTab.SIMILAR_MUSIC: "music", + ActiveTab.INVALID_SYMLINKS: "symlinks", + ActiveTab.BROKEN_FILES: "broken", + ActiveTab.BAD_EXTENSIONS: "ext", + ActiveTab.BAD_NAMES: "bad-names", + ActiveTab.EXIF_REMOVER: "exif-remover", + ActiveTab.VIDEO_OPTIMIZER: "video-optimizer", +} + +TAB_DISPLAY_NAMES = { + ActiveTab.DUPLICATE_FILES: "Duplicate Files", + ActiveTab.EMPTY_FOLDERS: "Empty Folders", + ActiveTab.BIG_FILES: "Big Files", + ActiveTab.EMPTY_FILES: "Empty Files", + ActiveTab.TEMPORARY_FILES: "Temporary Files", + ActiveTab.SIMILAR_IMAGES: "Similar Images", + ActiveTab.SIMILAR_VIDEOS: "Similar Videos", + ActiveTab.SIMILAR_MUSIC: "Similar Music", + ActiveTab.INVALID_SYMLINKS: "Invalid Symlinks", + ActiveTab.BROKEN_FILES: "Broken Files", + ActiveTab.BAD_EXTENSIONS: "Bad Extensions", + ActiveTab.BAD_NAMES: "Bad Names", + ActiveTab.EXIF_REMOVER: "EXIF Remover", + ActiveTab.VIDEO_OPTIMIZER: "Video Optimizer", +} + +# Which tabs support grouping (results are in groups) +GROUPED_TABS = { + ActiveTab.DUPLICATE_FILES, + ActiveTab.SIMILAR_IMAGES, + ActiveTab.SIMILAR_VIDEOS, + ActiveTab.SIMILAR_MUSIC, +} + +# Which tabs have per-tool settings +TABS_WITH_SETTINGS = { + ActiveTab.DUPLICATE_FILES, + ActiveTab.SIMILAR_IMAGES, + ActiveTab.SIMILAR_VIDEOS, + ActiveTab.SIMILAR_MUSIC, + ActiveTab.BIG_FILES, + ActiveTab.BROKEN_FILES, + ActiveTab.BAD_NAMES, + ActiveTab.EXIF_REMOVER, + ActiveTab.VIDEO_OPTIMIZER, +} + +# Column definitions per tab +TAB_COLUMNS = { + ActiveTab.DUPLICATE_FILES: ["Selection", "Size", "File Name", "Path", "Modification Date", "Hash"], + ActiveTab.EMPTY_FOLDERS: ["Selection", "Folder Name", "Path", "Modification Date"], + ActiveTab.BIG_FILES: ["Selection", "Size", "File Name", "Path", "Modification Date"], + ActiveTab.EMPTY_FILES: ["Selection", "File Name", "Path", "Modification Date"], + ActiveTab.TEMPORARY_FILES: ["Selection", "File Name", "Path", "Modification Date"], + ActiveTab.SIMILAR_IMAGES: ["Selection", "Similarity", "Size", "Resolution", "File Name", "Path", "Modification Date", "Hash"], + ActiveTab.SIMILAR_VIDEOS: ["Selection", "Size", "File Name", "Path", "Modification Date"], + ActiveTab.SIMILAR_MUSIC: ["Selection", "Size", "File Name", "Path", "Title", "Artist", "Year", "Bitrate", "Genre", "Length"], + ActiveTab.INVALID_SYMLINKS: ["Selection", "Symlink Name", "Symlink Path", "Destination Path", "Type of Error"], + ActiveTab.BROKEN_FILES: ["Selection", "File Name", "Path", "Error Type", "Size", "Modification Date"], + ActiveTab.BAD_EXTENSIONS: ["Selection", "File Name", "Path", "Current Extension", "Proper Extension"], + ActiveTab.BAD_NAMES: ["Selection", "File Name", "Path", "Error Type"], + ActiveTab.EXIF_REMOVER: ["Selection", "File Name", "Path"], + ActiveTab.VIDEO_OPTIMIZER: ["Selection", "File Name", "Path", "Size", "Codec"], +} + + +@dataclass +class ResultEntry: + """A single result entry from a scan.""" + values: dict # column_name -> value + checked: bool = False + header_row: bool = False # Group header for grouped results + group_id: int = 0 + + +@dataclass +class ScanProgress: + """Progress information during scanning.""" + step_name: str = "" + current: int = 0 + total: int = 0 + current_size: int = 0 + # Raw fields from czkawka_cli --json-progress + stage_name: str = "" + current_stage_idx: int = 0 + max_stage_idx: int = 0 + entries_checked: int = 0 + entries_to_check: int = 0 + bytes_checked: int = 0 + bytes_to_check: int = 0 + + +@dataclass +class ToolSettings: + """Per-tool settings that map to CLI arguments.""" + # Duplicates + dup_check_method: CheckingMethod = CheckingMethod.HASH + dup_hash_type: HashType = HashType.BLAKE3 + dup_name_case_sensitive: bool = False + dup_min_size: str = "8192" + dup_max_size: str = "" + dup_min_cache_size: str = "257144" + dup_use_prehash: bool = True + dup_min_prehash_cache_size: str = "257144" + + # Similar Images + img_hash_size: int = 16 # 8, 16, 32, 64 + img_filter: ImageFilter = ImageFilter.NEAREST + img_hash_alg: ImageHashAlg = ImageHashAlg.GRADIENT + img_ignore_same_size: bool = False + img_max_difference: int = 5 # 0-40 + + # Similar Videos + vid_ignore_same_size: bool = False + vid_crop_detect: VideoCropDetect = VideoCropDetect.LETTERBOX + vid_max_difference: int = 10 # 0-20 + vid_skip_forward: int = 15 # 0-300 + vid_duration: int = 10 # 1-60 + + # Similar Music + music_search_method: MusicSearchMethod = MusicSearchMethod.TAGS + music_approximate: bool = False + music_title: bool = True + music_artist: bool = True + music_bitrate: bool = False + music_genre: bool = False + music_year: bool = False + music_length: bool = False + music_compare_fingerprints_similar_titles: bool = False + music_max_difference: float = 2.0 + music_min_segment_duration: float = 10.0 + + # Big Files + big_files_mode: str = "biggest" # biggest or smallest + big_files_number: int = 50 + + # Broken Files + broken_audio: bool = True + broken_pdf: bool = True + broken_archive: bool = True + broken_image: bool = True + broken_video: bool = False + + # Bad Names + bad_names_uppercase_ext: bool = True + bad_names_emoji: bool = True + bad_names_space: bool = True + bad_names_non_ascii: bool = True + bad_names_restricted_charset: str = "" + bad_names_remove_duplicated: bool = False + + # EXIF Remover + exif_ignored_tags: str = "" + + # Video Optimizer + video_opt_mode: str = "crop" # crop or transcode + video_crop_mechanism: VideoCropMechanism = VideoCropMechanism.BLACKBARS + video_black_pixel_threshold: int = 32 + video_black_bar_percentage: int = 90 + video_max_samples: int = 20 + video_min_crop_size: int = 10 + video_excluded_codecs: str = "h265,hevc,av1,vp9" + video_codec: VideoCodec = VideoCodec.H265 + video_quality: int = 23 + video_fail_if_bigger: bool = False + video_overwrite: bool = False + video_max_width: int = 1920 + video_max_height: int = 1080 + + +@dataclass +class AppSettings: + """Global application settings.""" + included_paths: list = field(default_factory=lambda: [str(Path.home())]) + excluded_paths: list = field(default_factory=list) + excluded_items: str = "" + allowed_extensions: str = "" + excluded_extensions: str = "" + minimum_file_size: str = "" + maximum_file_size: str = "" + recursive_search: bool = True + use_cache: bool = True + save_as_json: bool = False + move_to_trash: bool = True + hide_hard_links: bool = False + thread_number: int = 0 # 0 = all available + dark_theme: bool = True + show_image_preview: bool = True + czkawka_cli_path: str = "czkawka_cli" # path to CLI binary diff --git a/czkawka_pyside6/app/preview_panel.py b/czkawka_pyside6/app/preview_panel.py new file mode 100644 index 000000000..3e3c50586 --- /dev/null +++ b/czkawka_pyside6/app/preview_panel.py @@ -0,0 +1,112 @@ +from pathlib import Path + +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QLabel, QSizePolicy +) +from PySide6.QtCore import Qt, QSize +from PySide6.QtGui import QPixmap, QImage + + +class PreviewPanel(QWidget): + """Image preview panel for similar images / duplicate files.""" + + SUPPORTED_EXTENSIONS = { + ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", + ".tiff", ".tif", ".ico", ".svg" + } + + def __init__(self, parent=None): + super().__init__(parent) + self.setMinimumWidth(200) + self.setMaximumWidth(400) + self._current_path = "" + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(4, 4, 4, 4) + + self._title = QLabel("Preview") + self._title.setStyleSheet("font-weight: bold; font-size: 12px; padding: 2px;") + self._title.setAlignment(Qt.AlignCenter) + layout.addWidget(self._title) + + self._image_label = QLabel() + self._image_label.setAlignment(Qt.AlignCenter) + self._image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self._image_label.setMinimumSize(QSize(180, 180)) + self._image_label.setStyleSheet( + "background-color: #1a1a1a; border: 1px solid #333; border-radius: 4px;" + ) + self._image_label.setScaledContents(False) + layout.addWidget(self._image_label) + + self._info_label = QLabel() + self._info_label.setAlignment(Qt.AlignCenter) + self._info_label.setWordWrap(True) + self._info_label.setStyleSheet("color: #888; font-size: 10px; padding: 2px;") + layout.addWidget(self._info_label) + + def show_preview(self, file_path: str): + if not file_path or file_path == self._current_path: + return + + self._current_path = file_path + p = Path(file_path) + + if not p.exists(): + self._image_label.setText("File not found") + self._info_label.setText("") + return + + if p.suffix.lower() not in self.SUPPORTED_EXTENSIONS: + self._image_label.setText("Preview not available\nfor this file type") + self._info_label.setText(p.name) + return + + pixmap = QPixmap(file_path) + if pixmap.isNull(): + self._image_label.setText("Cannot load image") + self._info_label.setText(p.name) + return + + # Scale to fit while keeping aspect ratio + label_size = self._image_label.size() + scaled = pixmap.scaled( + label_size, Qt.KeepAspectRatio, Qt.SmoothTransformation + ) + self._image_label.setPixmap(scaled) + + # Show info + size = p.stat().st_size + size_str = self._format_size(size) + self._info_label.setText( + f"{p.name}\n{pixmap.width()}x{pixmap.height()} | {size_str}" + ) + self._title.setText("Preview") + + def clear_preview(self): + self._current_path = "" + self._image_label.clear() + self._image_label.setText("No preview") + self._info_label.setText("") + + def resizeEvent(self, event): + super().resizeEvent(event) + # Re-render if we have a current image + if self._current_path: + path = self._current_path + self._current_path = "" + self.show_preview(path) + + @staticmethod + def _format_size(size_bytes: int) -> str: + if size_bytes == 0: + return "0 B" + units = ["B", "KB", "MB", "GB"] + i = 0 + size = float(size_bytes) + while size >= 1024 and i < len(units) - 1: + size /= 1024 + i += 1 + return f"{size:.1f} {units[i]}" if i > 0 else f"{int(size)} B" diff --git a/czkawka_pyside6/app/progress_widget.py b/czkawka_pyside6/app/progress_widget.py new file mode 100644 index 000000000..185e0790d --- /dev/null +++ b/czkawka_pyside6/app/progress_widget.py @@ -0,0 +1,325 @@ +import json +import time +from pathlib import Path + +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QProgressBar, QLabel, QHBoxLayout +) +from PySide6.QtCore import Qt, QTimer + +from .models import ActiveTab, ScanProgress + + +class ProgressWidget(QWidget): + """Two-bar progress widget matching Slint/Krokiet feature parity. + + Shows: + - Current stage progress bar (entries or bytes within one stage) + - Overall progress bar (across all stages) + - Stage name with counts + - Elapsed time + - Phase step indicators + """ + + # File where we persist the last file-collection count per directory set, + # so we can estimate the collection stage percentage on the next scan. + _ESTIMATE_FILE = Path.home() / ".config" / "czkawka_pyside6" / "scan_estimates.json" + + _BAR_STYLE = """ + QProgressBar {{ + border: 1px solid #555; border-radius: 3px; + text-align: center; background-color: #2a2a2a; + font-size: 10px; color: #ccc; + }} + QProgressBar::chunk {{ + background: qlineargradient(x1:0, y1:0, x2:1, y2:0, + stop:0 {c1}, stop:1 {c2}); + border-radius: 2px; + }} + """ + + def __init__(self, parent=None): + super().__init__(parent) + self.setVisible(False) + self._start_time = 0.0 + self._active_tab = ActiveTab.DUPLICATE_FILES + self._last_collection_count = 0 # Files found during collection phase + self._estimates: dict[str, int] = {} + self._load_estimates() + self._setup_ui() + + self._timer = QTimer(self) + self._timer.setInterval(500) + self._timer.timeout.connect(self._update_elapsed) + + # ── UI setup ────────────────────────────────────────────── + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 6, 8, 6) + layout.setSpacing(3) + + # Row 1: stage label + elapsed + row1 = QHBoxLayout() + self._stage_label = QLabel("Initializing...") + self._stage_label.setStyleSheet("font-weight: bold; font-size: 12px;") + row1.addWidget(self._stage_label) + row1.addStretch() + self._elapsed_label = QLabel("") + self._elapsed_label.setStyleSheet("color: #888; font-size: 11px;") + row1.addWidget(self._elapsed_label) + layout.addLayout(row1) + + # Row 2: current stage bar "Current stage" NN% + row2 = QHBoxLayout() + row2.setSpacing(6) + lbl2 = QLabel("Current") + lbl2.setStyleSheet("color: #aaa; font-size: 10px;") + lbl2.setFixedWidth(48) + row2.addWidget(lbl2) + self._stage_bar = QProgressBar() + self._stage_bar.setFixedHeight(14) + self._stage_bar.setTextVisible(False) + self._stage_bar.setStyleSheet( + self._BAR_STYLE.format(c1="#1b4332", c2="#2d6a4f") + ) + row2.addWidget(self._stage_bar) + self._stage_pct = QLabel("") + self._stage_pct.setFixedWidth(40) + self._stage_pct.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self._stage_pct.setStyleSheet("color: #aaa; font-size: 10px;") + row2.addWidget(self._stage_pct) + layout.addLayout(row2) + + # Row 3: overall bar "Overall" NN% + row3 = QHBoxLayout() + row3.setSpacing(6) + lbl3 = QLabel("Overall") + lbl3.setStyleSheet("color: #aaa; font-size: 10px;") + lbl3.setFixedWidth(48) + row3.addWidget(lbl3) + self._overall_bar = QProgressBar() + self._overall_bar.setFixedHeight(14) + self._overall_bar.setTextVisible(False) + self._overall_bar.setStyleSheet( + self._BAR_STYLE.format(c1="#2d6a4f", c2="#40916c") + ) + row3.addWidget(self._overall_bar) + self._overall_pct = QLabel("") + self._overall_pct.setFixedWidth(40) + self._overall_pct.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self._overall_pct.setStyleSheet("color: #aaa; font-size: 10px;") + row3.addWidget(self._overall_pct) + layout.addLayout(row3) + + # Row 4: detail counts + row4 = QHBoxLayout() + self._detail_label = QLabel("") + self._detail_label.setStyleSheet("color: #888; font-size: 10px;") + row4.addWidget(self._detail_label) + row4.addStretch() + self._size_label = QLabel("") + self._size_label.setStyleSheet("color: #888; font-size: 10px;") + self._size_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + row4.addWidget(self._size_label) + layout.addLayout(row4) + + # Row 5: step indicators + self._steps_label = QLabel("") + self._steps_label.setStyleSheet("color: #666; font-size: 9px;") + self._steps_label.setAlignment(Qt.AlignCenter) + self._steps_label.setWordWrap(True) + layout.addWidget(self._steps_label) + + # ── Public API ──────────────────────────────────────────── + + def start(self, tab: ActiveTab = None): + if tab is not None: + self._active_tab = tab + self._start_time = time.monotonic() + self._last_collection_count = 0 + self.setVisible(True) + + for bar in (self._stage_bar, self._overall_bar): + bar.setMaximum(0) # indeterminate + bar.setValue(0) + self._stage_pct.setText("") + self._overall_pct.setText("") + self._stage_label.setText("Starting scan...") + self._detail_label.setText("") + self._size_label.setText("") + self._elapsed_label.setText("0s") + self._steps_label.setText("") + self._timer.start() + + def stop(self): + self._timer.stop() + elapsed = time.monotonic() - self._start_time if self._start_time else 0 + self._elapsed_label.setText(f"Completed in {self._format_time(elapsed)}") + + for bar, lbl in ((self._stage_bar, self._stage_pct), + (self._overall_bar, self._overall_pct)): + bar.setMaximum(100) + bar.setValue(100) + lbl.setText("100%") + + self._stage_label.setText("Scan complete") + self._steps_label.setText("") + + # Save collection count for next-scan estimation + if self._last_collection_count > 0: + self._save_estimate(self._last_collection_count) + + QTimer.singleShot(3000, self._auto_hide) + + def update_progress(self, progress: ScanProgress): + """Main update method called by the scan runner.""" + stage_name = progress.stage_name or progress.step_name or "" + idx = progress.current_stage_idx + max_idx = progress.max_stage_idx + checked = progress.entries_checked + to_check = progress.entries_to_check + b_checked = progress.bytes_checked + b_to_check = progress.bytes_to_check + + # ── Stage label ── + self._stage_label.setText(stage_name) + + # ── Update step indicators using stage index ── + if max_idx > 0: + self._update_steps_from_index(idx, max_idx) + + # ── Current-stage bar ── + is_collecting = (idx == 0 and to_check == 0) + + if is_collecting: + # Collection phase: use estimate from previous scan + self._last_collection_count = max(self._last_collection_count, checked) + estimate = self._get_estimate() + if estimate > 0 and checked > 0: + pct = min(99, int(checked * 100 / estimate)) + self._stage_bar.setMaximum(100) + self._stage_bar.setValue(pct) + self._stage_pct.setText(f"~{pct}%") + self._detail_label.setText(f"{checked:,} / ~{estimate:,} files") + else: + self._stage_bar.setMaximum(0) # indeterminate + self._stage_pct.setText("") + self._detail_label.setText(f"{checked:,} files" if checked else "") + + elif to_check > 0: + # Normal stage with known total + if b_to_check > 0: + # Byte-based progress (hashing) + pct = min(99, int(b_checked * 100 / b_to_check)) + self._stage_bar.setMaximum(100) + self._stage_bar.setValue(pct) + self._stage_pct.setText(f"{pct}%") + self._detail_label.setText(f"{checked:,} / {to_check:,}") + self._size_label.setText( + f"{self._format_size(b_checked)} / {self._format_size(b_to_check)}" + ) + else: + # Entry-count based progress + pct = min(99, int(checked * 100 / to_check)) + self._stage_bar.setMaximum(100) + self._stage_bar.setValue(pct) + self._stage_pct.setText(f"{pct}%") + self._detail_label.setText(f"{checked:,} / {to_check:,}") + self._size_label.setText("") + + else: + # Cache loading/saving or unknown total + self._stage_bar.setMaximum(0) # indeterminate spinner + self._stage_pct.setText("") + self._detail_label.setText("") + self._size_label.setText("") + + # ── Overall bar ── + if max_idx > 0: + if to_check > 0: + stage_frac = (b_checked / b_to_check) if b_to_check > 0 else (checked / to_check) + elif is_collecting and self._get_estimate() > 0 and checked > 0: + stage_frac = min(0.99, checked / self._get_estimate()) + else: + stage_frac = 0 + overall = (idx + min(stage_frac, 0.99)) / (max_idx + 1) + overall_pct = min(99, int(overall * 100)) + self._overall_bar.setMaximum(100) + self._overall_bar.setValue(overall_pct) + self._overall_pct.setText(f"{overall_pct}%") + else: + self._overall_bar.setMaximum(0) + self._overall_pct.setText("") + + # ── Collection estimate persistence ─────────────────────── + + def _get_estimate_key(self) -> str: + """Key for the estimate cache based on active tab.""" + return self._active_tab.name + + def _get_estimate(self) -> int: + return self._estimates.get(self._get_estimate_key(), 0) + + def _save_estimate(self, count: int): + self._estimates[self._get_estimate_key()] = count + try: + self._ESTIMATE_FILE.parent.mkdir(parents=True, exist_ok=True) + self._ESTIMATE_FILE.write_text(json.dumps(self._estimates)) + except OSError: + pass + + def _load_estimates(self): + try: + if self._ESTIMATE_FILE.exists(): + self._estimates = json.loads(self._ESTIMATE_FILE.read_text()) + except (json.JSONDecodeError, OSError): + self._estimates = {} + + # ── Step indicator ──────────────────────────────────────── + + def _update_steps_from_index(self, current_idx: int, max_idx: int): + """Build step display directly from stage index.""" + total_stages = max_idx + 1 + parts = [] + for i in range(total_stages): + if i < current_idx: + parts.append(f"[{i+1} done]") + elif i == current_idx: + parts.append(f"[{i+1} >>]") + else: + parts.append(f"[{i+1}]") + self._steps_label.setText(" ".join(parts)) + + # ── Internals ───────────────────────────────────────────── + + def _auto_hide(self): + self.setVisible(False) + + def _update_elapsed(self): + elapsed = time.monotonic() - self._start_time + self._elapsed_label.setText(self._format_time(elapsed)) + + @staticmethod + def _format_time(seconds: float) -> str: + if seconds < 60: + return f"{int(seconds)}s" + minutes = int(seconds // 60) + secs = int(seconds % 60) + if minutes < 60: + return f"{minutes}m {secs}s" + hours = minutes // 60 + mins = minutes % 60 + return f"{hours}h {mins}m" + + @staticmethod + def _format_size(size_bytes: int) -> str: + if size_bytes == 0: + return "0 B" + units = ["B", "KB", "MB", "GB", "TB"] + i = 0 + size = float(size_bytes) + while size >= 1024 and i < len(units) - 1: + size /= 1024 + i += 1 + return f"{size:.1f} {units[i]}" if i > 0 else f"{int(size)} B" diff --git a/czkawka_pyside6/app/results_view.py b/czkawka_pyside6/app/results_view.py new file mode 100644 index 000000000..fc1aca09c --- /dev/null +++ b/czkawka_pyside6/app/results_view.py @@ -0,0 +1,280 @@ +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QTreeWidget, QTreeWidgetItem, QHeaderView, + QAbstractItemView, QMenu, QLabel, QHBoxLayout +) +from PySide6.QtCore import Signal, Qt +from PySide6.QtGui import QColor, QBrush, QFont, QAction + +from .models import ( + ActiveTab, ResultEntry, TAB_COLUMNS, GROUPED_TABS, SelectMode +) + + +class ResultsView(QWidget): + """Results display with tree view for grouped results and table for flat results.""" + + selection_changed = Signal(int) # number of selected items + item_activated = Signal(object) # ResultEntry + context_menu_requested = Signal(object, object) # QPoint, ResultEntry + + # Colors + HEADER_BG = QColor(60, 60, 80) + HEADER_FG = QColor(220, 220, 255) + SELECTED_BG = QColor(40, 80, 40) + + def __init__(self, parent=None): + super().__init__(parent) + self._active_tab = ActiveTab.DUPLICATE_FILES + self._results: list[ResultEntry] = [] + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + # Summary bar + summary_layout = QHBoxLayout() + self._summary_label = QLabel("No results") + self._summary_label.setStyleSheet("padding: 4px;") + summary_layout.addWidget(self._summary_label) + self._selection_label = QLabel("") + self._selection_label.setAlignment(Qt.AlignRight) + self._selection_label.setStyleSheet("padding: 4px; color: #aaa;") + summary_layout.addWidget(self._selection_label) + layout.addLayout(summary_layout) + + # Tree widget for results + self._tree = QTreeWidget() + self._tree.setSelectionMode(QAbstractItemView.ExtendedSelection) + self._tree.setRootIsDecorated(False) + self._tree.setAlternatingRowColors(True) + self._tree.setContextMenuPolicy(Qt.CustomContextMenu) + self._tree.customContextMenuRequested.connect(self._on_context_menu) + self._tree.itemChanged.connect(self._on_item_changed) + self._tree.itemDoubleClicked.connect(self._on_item_double_clicked) + layout.addWidget(self._tree) + + def set_active_tab(self, tab: ActiveTab): + self._active_tab = tab + columns = TAB_COLUMNS.get(tab, ["Selection", "File Name", "Path"]) + self._tree.setHeaderLabels(columns) + header = self._tree.header() + for i in range(len(columns)): + if columns[i] == "Path": + header.setSectionResizeMode(i, QHeaderView.Stretch) + else: + header.setSectionResizeMode(i, QHeaderView.ResizeToContents) + + def set_results(self, results: list[ResultEntry]): + self._results = results + self._tree.blockSignals(True) + self._tree.clear() + + columns = TAB_COLUMNS.get(self._active_tab, ["Selection", "File Name", "Path"]) + + for entry in results: + if entry.header_row: + item = QTreeWidgetItem() + header_text = entry.values.get("__header", "Group") + item.setText(0, header_text) + item.setFirstColumnSpanned(True) + font = QFont() + font.setBold(True) + item.setFont(0, font) + for col in range(len(columns)): + item.setBackground(col, QBrush(self.HEADER_BG)) + item.setForeground(col, QBrush(self.HEADER_FG)) + item.setFlags(item.flags() & ~Qt.ItemIsUserCheckable) + item.setData(0, Qt.UserRole, entry) + self._tree.addTopLevelItem(item) + else: + item = QTreeWidgetItem() + # First column is checkbox (Selection) + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + item.setCheckState(0, Qt.Checked if entry.checked else Qt.Unchecked) + + for col_idx, col_name in enumerate(columns): + if col_idx == 0: # Selection column + continue + value = entry.values.get(col_name, "") + item.setText(col_idx, str(value)) + + item.setData(0, Qt.UserRole, entry) + self._tree.addTopLevelItem(item) + + self._tree.blockSignals(False) + self._update_summary() + + def _on_item_changed(self, item, column): + if column == 0: + entry = item.data(0, Qt.UserRole) + if entry and not entry.header_row: + entry.checked = item.checkState(0) == Qt.Checked + self._update_selection_count() + + def _on_item_double_clicked(self, item, column): + entry = item.data(0, Qt.UserRole) + if entry and not entry.header_row: + self.item_activated.emit(entry) + + def _on_context_menu(self, pos): + item = self._tree.itemAt(pos) + if item: + entry = item.data(0, Qt.UserRole) + if entry and not entry.header_row: + menu = QMenu(self) + open_action = QAction("Open File", self) + open_action.triggered.connect(lambda: self._open_file(entry)) + menu.addAction(open_action) + + open_dir_action = QAction("Open Containing Folder", self) + open_dir_action.triggered.connect(lambda: self._open_folder(entry)) + menu.addAction(open_dir_action) + + menu.addSeparator() + + select_action = QAction("Select", self) + select_action.triggered.connect(lambda: self._set_check(item, True)) + menu.addAction(select_action) + + deselect_action = QAction("Deselect", self) + deselect_action.triggered.connect(lambda: self._set_check(item, False)) + menu.addAction(deselect_action) + + menu.exec_(self._tree.viewport().mapToGlobal(pos)) + + def _open_file(self, entry: ResultEntry): + import subprocess, sys + path = entry.values.get("__full_path", "") + if path: + if sys.platform == "linux": + subprocess.Popen(["xdg-open", path]) + elif sys.platform == "darwin": + subprocess.Popen(["open", path]) + else: + subprocess.Popen(["start", path], shell=True) + + def _open_folder(self, entry: ResultEntry): + import subprocess, sys + from pathlib import Path + path = entry.values.get("__full_path", "") + if path: + folder = str(Path(path).parent) + if sys.platform == "linux": + subprocess.Popen(["xdg-open", folder]) + elif sys.platform == "darwin": + subprocess.Popen(["open", folder]) + else: + subprocess.Popen(["explorer", folder], shell=True) + + def _set_check(self, item, checked): + item.setCheckState(0, Qt.Checked if checked else Qt.Unchecked) + + def _update_summary(self): + total = sum(1 for r in self._results if not r.header_row) + groups = sum(1 for r in self._results if r.header_row) + if self._active_tab in GROUPED_TABS and groups > 0: + self._summary_label.setText(f"Found {total} files in {groups} groups") + elif total > 0: + self._summary_label.setText(f"Found {total} entries") + else: + self._summary_label.setText("No results") + self._update_selection_count() + + def _update_selection_count(self): + selected = sum(1 for r in self._results if r.checked and not r.header_row) + total = sum(1 for r in self._results if not r.header_row) + if selected > 0: + self._selection_label.setText(f"Selected: {selected}/{total}") + else: + self._selection_label.setText("") + self.selection_changed.emit(selected) + + def apply_selection(self, mode: SelectMode): + self._tree.blockSignals(True) + + if mode == SelectMode.SELECT_ALL: + self._select_all(True) + elif mode == SelectMode.UNSELECT_ALL: + self._select_all(False) + elif mode == SelectMode.INVERT_SELECTION: + self._invert_selection() + elif mode in (SelectMode.SELECT_BIGGEST_SIZE, SelectMode.SELECT_SMALLEST_SIZE, + SelectMode.SELECT_NEWEST, SelectMode.SELECT_OLDEST, + SelectMode.SELECT_BIGGEST_RESOLUTION, SelectMode.SELECT_SMALLEST_RESOLUTION, + SelectMode.SELECT_SHORTEST_PATH, SelectMode.SELECT_LONGEST_PATH): + self._select_by_group_criteria(mode) + + self._tree.blockSignals(False) + self._update_selection_count() + + def _select_all(self, checked: bool): + for i in range(self._tree.topLevelItemCount()): + item = self._tree.topLevelItem(i) + entry = item.data(0, Qt.UserRole) + if entry and not entry.header_row: + entry.checked = checked + item.setCheckState(0, Qt.Checked if checked else Qt.Unchecked) + + def _invert_selection(self): + for i in range(self._tree.topLevelItemCount()): + item = self._tree.topLevelItem(i) + entry = item.data(0, Qt.UserRole) + if entry and not entry.header_row: + entry.checked = not entry.checked + item.setCheckState(0, Qt.Checked if entry.checked else Qt.Unchecked) + + def _select_by_group_criteria(self, mode: SelectMode): + # First unselect all + self._select_all(False) + + if self._active_tab not in GROUPED_TABS: + return + + # Group entries by group_id + groups: dict[int, list[tuple[int, ResultEntry]]] = {} + for i in range(self._tree.topLevelItemCount()): + item = self._tree.topLevelItem(i) + entry = item.data(0, Qt.UserRole) + if entry and not entry.header_row: + groups.setdefault(entry.group_id, []).append((i, entry)) + + for group_id, items in groups.items(): + if len(items) <= 1: + continue + + best_idx = 0 + if mode == SelectMode.SELECT_BIGGEST_SIZE: + best_idx = max(range(len(items)), key=lambda j: items[j][1].values.get("__size_bytes", 0)) + elif mode == SelectMode.SELECT_SMALLEST_SIZE: + best_idx = min(range(len(items)), key=lambda j: items[j][1].values.get("__size_bytes", 0)) + elif mode == SelectMode.SELECT_NEWEST: + best_idx = max(range(len(items)), key=lambda j: items[j][1].values.get("__modified_date_ts", 0)) + elif mode == SelectMode.SELECT_OLDEST: + best_idx = min(range(len(items)), key=lambda j: items[j][1].values.get("__modified_date_ts", 0)) + elif mode == SelectMode.SELECT_SHORTEST_PATH: + best_idx = min(range(len(items)), key=lambda j: len(items[j][1].values.get("__full_path", ""))) + elif mode == SelectMode.SELECT_LONGEST_PATH: + best_idx = max(range(len(items)), key=lambda j: len(items[j][1].values.get("__full_path", ""))) + + # Select all EXCEPT the best (the one to keep) + for j, (tree_idx, entry) in enumerate(items): + if j != best_idx: + entry.checked = True + self._tree.topLevelItem(tree_idx).setCheckState(0, Qt.Checked) + + def sort_by_column(self, column: int, ascending: bool = True): + order = Qt.AscendingOrder if ascending else Qt.DescendingOrder + self._tree.sortItems(column, order) + + def get_checked_entries(self) -> list[ResultEntry]: + return [r for r in self._results if r.checked and not r.header_row] + + def get_all_entries(self) -> list[ResultEntry]: + return [r for r in self._results if not r.header_row] + + def clear(self): + self._results = [] + self._tree.clear() + self._summary_label.setText("No results") + self._selection_label.setText("") diff --git a/czkawka_pyside6/app/settings_panel.py b/czkawka_pyside6/app/settings_panel.py new file mode 100644 index 000000000..1ab5c2159 --- /dev/null +++ b/czkawka_pyside6/app/settings_panel.py @@ -0,0 +1,261 @@ +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QCheckBox, + QLineEdit, QSpinBox, QGroupBox, QFormLayout, QScrollArea, + QPushButton, QListWidget, QFileDialog, QDoubleSpinBox, + QTabWidget, QSizePolicy +) +from PySide6.QtCore import Qt, Signal + +from .models import AppSettings + + +class SettingsPanel(QWidget): + """Global application settings panel.""" + settings_changed = Signal() + close_requested = Signal() + + def __init__(self, settings: AppSettings, parent=None): + super().__init__(parent) + self._settings = settings + self._setup_ui() + + def _setup_ui(self): + main_layout = QVBoxLayout(self) + + # Header + header = QHBoxLayout() + title = QLabel("Settings") + title.setStyleSheet("font-weight: bold; font-size: 16px; padding: 4px;") + header.addWidget(title) + header.addStretch() + close_btn = QPushButton("Close") + close_btn.clicked.connect(self.close_requested.emit) + header.addWidget(close_btn) + main_layout.addLayout(header) + + # Tabs + tabs = QTabWidget() + + # General tab + tabs.addTab(self._create_general_tab(), "General") + # Directories tab + tabs.addTab(self._create_directories_tab(), "Directories") + # Filters tab + tabs.addTab(self._create_filters_tab(), "Filters") + # Preview tab + tabs.addTab(self._create_preview_tab(), "Preview") + + main_layout.addWidget(tabs) + + def _create_general_tab(self) -> QWidget: + scroll = QScrollArea() + scroll.setWidgetResizable(True) + widget = QWidget() + layout = QFormLayout(widget) + + # CLI path + cli_layout = QHBoxLayout() + self._cli_path = QLineEdit(self._settings.czkawka_cli_path) + self._cli_path.textChanged.connect( + lambda t: setattr(self._settings, 'czkawka_cli_path', t) + ) + cli_layout.addWidget(self._cli_path) + browse_btn = QPushButton("Browse") + browse_btn.clicked.connect(self._browse_cli) + cli_layout.addWidget(browse_btn) + layout.addRow("czkawka_cli Path:", cli_layout) + + # Thread number + self._threads = QSpinBox() + self._threads.setRange(0, 64) + self._threads.setValue(self._settings.thread_number) + self._threads.setSpecialValueText("Auto (all cores)") + self._threads.valueChanged.connect( + lambda v: setattr(self._settings, 'thread_number', v) + ) + layout.addRow("Thread Count:", self._threads) + + # Recursive search + recursive = QCheckBox("Recursive search") + recursive.setChecked(self._settings.recursive_search) + recursive.toggled.connect( + lambda v: setattr(self._settings, 'recursive_search', v) + ) + layout.addRow(recursive) + + # Use cache + cache = QCheckBox("Use cache for faster rescans") + cache.setChecked(self._settings.use_cache) + cache.toggled.connect( + lambda v: setattr(self._settings, 'use_cache', v) + ) + layout.addRow(cache) + + # Move to trash + trash = QCheckBox("Move to trash instead of permanent delete") + trash.setChecked(self._settings.move_to_trash) + trash.toggled.connect( + lambda v: setattr(self._settings, 'move_to_trash', v) + ) + layout.addRow(trash) + + # Hide hard links + hardlinks = QCheckBox("Hide hard links") + hardlinks.setChecked(self._settings.hide_hard_links) + hardlinks.toggled.connect( + lambda v: setattr(self._settings, 'hide_hard_links', v) + ) + layout.addRow(hardlinks) + + # Save as JSON + save_json = QCheckBox("Save results as JSON (instead of text)") + save_json.setChecked(self._settings.save_as_json) + save_json.toggled.connect( + lambda v: setattr(self._settings, 'save_as_json', v) + ) + layout.addRow(save_json) + + scroll.setWidget(widget) + return scroll + + def _create_directories_tab(self) -> QWidget: + widget = QWidget() + layout = QVBoxLayout(widget) + + # Included paths + inc_group = QGroupBox("Included Directories") + inc_layout = QVBoxLayout(inc_group) + + self._inc_list = QListWidget() + for path in self._settings.included_paths: + self._inc_list.addItem(path) + inc_layout.addWidget(self._inc_list) + + inc_btns = QHBoxLayout() + add_inc = QPushButton("Add") + add_inc.clicked.connect(self._add_included) + inc_btns.addWidget(add_inc) + rem_inc = QPushButton("Remove") + rem_inc.clicked.connect(self._remove_included) + inc_btns.addWidget(rem_inc) + inc_btns.addStretch() + inc_layout.addLayout(inc_btns) + layout.addWidget(inc_group) + + # Excluded paths + exc_group = QGroupBox("Excluded Directories") + exc_layout = QVBoxLayout(exc_group) + + self._exc_list = QListWidget() + for path in self._settings.excluded_paths: + self._exc_list.addItem(path) + exc_layout.addWidget(self._exc_list) + + exc_btns = QHBoxLayout() + add_exc = QPushButton("Add") + add_exc.clicked.connect(self._add_excluded) + exc_btns.addWidget(add_exc) + rem_exc = QPushButton("Remove") + rem_exc.clicked.connect(self._remove_excluded) + exc_btns.addWidget(rem_exc) + exc_btns.addStretch() + exc_layout.addLayout(exc_btns) + layout.addWidget(exc_group) + + return widget + + def _create_filters_tab(self) -> QWidget: + widget = QWidget() + layout = QFormLayout(widget) + + # Excluded items + self._excluded_items = QLineEdit(self._settings.excluded_items) + self._excluded_items.setPlaceholderText("Wildcard patterns, comma-separated (e.g. *.tmp,cache_*)") + self._excluded_items.textChanged.connect( + lambda t: setattr(self._settings, 'excluded_items', t) + ) + layout.addRow("Excluded Items:", self._excluded_items) + + # Allowed extensions + self._allowed_ext = QLineEdit(self._settings.allowed_extensions) + self._allowed_ext.setPlaceholderText("e.g. jpg,png,gif") + self._allowed_ext.textChanged.connect( + lambda t: setattr(self._settings, 'allowed_extensions', t) + ) + layout.addRow("Allowed Extensions:", self._allowed_ext) + + # Excluded extensions + self._excluded_ext = QLineEdit(self._settings.excluded_extensions) + self._excluded_ext.setPlaceholderText("e.g. log,tmp") + self._excluded_ext.textChanged.connect( + lambda t: setattr(self._settings, 'excluded_extensions', t) + ) + layout.addRow("Excluded Extensions:", self._excluded_ext) + + # Min file size + self._min_size = QLineEdit(self._settings.minimum_file_size) + self._min_size.setPlaceholderText("In bytes (e.g. 1024)") + self._min_size.textChanged.connect( + lambda t: setattr(self._settings, 'minimum_file_size', t) + ) + layout.addRow("Minimum File Size:", self._min_size) + + # Max file size + self._max_size = QLineEdit(self._settings.maximum_file_size) + self._max_size.setPlaceholderText("In bytes (leave empty for no limit)") + self._max_size.textChanged.connect( + lambda t: setattr(self._settings, 'maximum_file_size', t) + ) + layout.addRow("Maximum File Size:", self._max_size) + + return widget + + def _create_preview_tab(self) -> QWidget: + widget = QWidget() + layout = QVBoxLayout(widget) + + preview = QCheckBox("Show image preview") + preview.setChecked(self._settings.show_image_preview) + preview.toggled.connect( + lambda v: setattr(self._settings, 'show_image_preview', v) + ) + layout.addWidget(preview) + + layout.addStretch() + return widget + + def _browse_cli(self): + path, _ = QFileDialog.getOpenFileName( + self, "Select czkawka_cli binary", "", + "Executables (*);;All Files (*)" + ) + if path: + self._cli_path.setText(path) + + def _add_included(self): + path = QFileDialog.getExistingDirectory(self, "Select Directory to Include") + if path and path not in self._settings.included_paths: + self._settings.included_paths.append(path) + self._inc_list.addItem(path) + self.settings_changed.emit() + + def _remove_included(self): + row = self._inc_list.currentRow() + if row >= 0: + self._inc_list.takeItem(row) + self._settings.included_paths.pop(row) + self.settings_changed.emit() + + def _add_excluded(self): + path = QFileDialog.getExistingDirectory(self, "Select Directory to Exclude") + if path and path not in self._settings.excluded_paths: + self._settings.excluded_paths.append(path) + self._exc_list.addItem(path) + self.settings_changed.emit() + + def _remove_excluded(self): + row = self._exc_list.currentRow() + if row >= 0: + self._exc_list.takeItem(row) + self._settings.excluded_paths.pop(row) + self.settings_changed.emit() diff --git a/czkawka_pyside6/app/state.py b/czkawka_pyside6/app/state.py new file mode 100644 index 000000000..2737323d0 --- /dev/null +++ b/czkawka_pyside6/app/state.py @@ -0,0 +1,122 @@ +import json +from pathlib import Path +from PySide6.QtCore import QObject, Signal +from .models import ( + ActiveTab, AppSettings, ToolSettings, ResultEntry, ScanProgress +) + + +class AppState(QObject): + """Central application state manager.""" + + # Signals + tab_changed = Signal(object) # ActiveTab + scan_started = Signal() + scan_finished = Signal() + scan_progress_updated = Signal(object) # ScanProgress + results_updated = Signal() + settings_changed = Signal() + included_paths_changed = Signal() + excluded_paths_changed = Signal() + preview_image_changed = Signal(str) + + def __init__(self): + super().__init__() + self.active_tab = ActiveTab.DUPLICATE_FILES + self.scanning = False + self.processing = False + self.stop_requested = False + self.settings = AppSettings() + self.tool_settings = ToolSettings() + self.results: dict[ActiveTab, list[ResultEntry]] = {} + self.progress = ScanProgress() + self.info_text = "" + self.preview_image_path = "" + self._config_path = Path.home() / ".config" / "czkawka_pyside6" + self._config_path.mkdir(parents=True, exist_ok=True) + self.load_settings() + + def set_active_tab(self, tab: ActiveTab): + if tab != self.active_tab: + self.active_tab = tab + self.tab_changed.emit(tab) + + def set_scanning(self, scanning: bool): + self.scanning = scanning + if scanning: + self.stop_requested = False + self.scan_started.emit() + else: + self.scan_finished.emit() + + def request_stop(self): + self.stop_requested = True + + def update_progress(self, progress: ScanProgress): + self.progress = progress + self.scan_progress_updated.emit(progress) + + def set_results(self, tab: ActiveTab, results: list[ResultEntry]): + self.results[tab] = results + self.results_updated.emit() + + def get_results(self, tab: ActiveTab = None) -> list[ResultEntry]: + if tab is None: + tab = self.active_tab + return self.results.get(tab, []) + + def get_checked_results(self, tab: ActiveTab = None) -> list[ResultEntry]: + return [r for r in self.get_results(tab) if r.checked and not r.header_row] + + def get_selected_count(self, tab: ActiveTab = None) -> int: + return len(self.get_checked_results(tab)) + + def save_settings(self): + config_file = self._config_path / "settings.json" + data = { + "included_paths": self.settings.included_paths, + "excluded_paths": self.settings.excluded_paths, + "excluded_items": self.settings.excluded_items, + "allowed_extensions": self.settings.allowed_extensions, + "excluded_extensions": self.settings.excluded_extensions, + "minimum_file_size": self.settings.minimum_file_size, + "maximum_file_size": self.settings.maximum_file_size, + "recursive_search": self.settings.recursive_search, + "use_cache": self.settings.use_cache, + "save_as_json": self.settings.save_as_json, + "move_to_trash": self.settings.move_to_trash, + "hide_hard_links": self.settings.hide_hard_links, + "thread_number": self.settings.thread_number, + "dark_theme": self.settings.dark_theme, + "show_image_preview": self.settings.show_image_preview, + "czkawka_cli_path": self.settings.czkawka_cli_path, + } + try: + config_file.write_text(json.dumps(data, indent=2)) + except OSError: + pass + + def load_settings(self): + config_file = self._config_path / "settings.json" + if config_file.exists(): + try: + data = json.loads(config_file.read_text()) + s = self.settings + s.included_paths = data.get("included_paths", s.included_paths) + s.excluded_paths = data.get("excluded_paths", s.excluded_paths) + s.excluded_items = data.get("excluded_items", s.excluded_items) + s.allowed_extensions = data.get("allowed_extensions", s.allowed_extensions) + s.excluded_extensions = data.get("excluded_extensions", s.excluded_extensions) + s.minimum_file_size = data.get("minimum_file_size", s.minimum_file_size) + s.maximum_file_size = data.get("maximum_file_size", s.maximum_file_size) + s.recursive_search = data.get("recursive_search", s.recursive_search) + s.use_cache = data.get("use_cache", s.use_cache) + s.save_as_json = data.get("save_as_json", s.save_as_json) + s.move_to_trash = data.get("move_to_trash", s.move_to_trash) + s.hide_hard_links = data.get("hide_hard_links", s.hide_hard_links) + s.thread_number = data.get("thread_number", s.thread_number) + s.dark_theme = data.get("dark_theme", s.dark_theme) + s.show_image_preview = data.get("show_image_preview", s.show_image_preview) + s.czkawka_cli_path = data.get("czkawka_cli_path", s.czkawka_cli_path) + except (json.JSONDecodeError, OSError): + pass diff --git a/czkawka_pyside6/app/tool_settings.py b/czkawka_pyside6/app/tool_settings.py new file mode 100644 index 000000000..a3c5d9e44 --- /dev/null +++ b/czkawka_pyside6/app/tool_settings.py @@ -0,0 +1,504 @@ +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QLabel, QComboBox, QCheckBox, + QSlider, QLineEdit, QGroupBox, QFormLayout, QScrollArea, + QHBoxLayout, QSpinBox, QPushButton, QSizePolicy +) +from PySide6.QtCore import Qt, Signal + +from .models import ( + ActiveTab, ToolSettings, CheckingMethod, HashType, + ImageHashAlg, ImageFilter, MusicSearchMethod, + VideoCropDetect, VideoCropMechanism, VideoCodec, +) + + +class ToolSettingsPanel(QWidget): + """Per-tool settings panel shown on the right side.""" + settings_changed = Signal() + + def __init__(self, tool_settings: ToolSettings, parent=None): + super().__init__(parent) + self._ts = tool_settings + self._active_tab = ActiveTab.DUPLICATE_FILES + self.setMinimumWidth(250) + self.setMaximumWidth(350) + self._panels: dict[ActiveTab, QWidget] = {} + self._setup_ui() + + def _setup_ui(self): + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(4, 4, 4, 4) + + title = QLabel("Tool Settings") + title.setStyleSheet("font-weight: bold; font-size: 13px; padding: 4px;") + main_layout.addWidget(title) + + self._scroll = QScrollArea() + self._scroll.setWidgetResizable(True) + self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + + self._container = QWidget() + self._container_layout = QVBoxLayout(self._container) + self._container_layout.setContentsMargins(0, 0, 0, 0) + + # Create all panel widgets + self._panels[ActiveTab.DUPLICATE_FILES] = self._create_duplicate_panel() + self._panels[ActiveTab.SIMILAR_IMAGES] = self._create_similar_images_panel() + self._panels[ActiveTab.SIMILAR_VIDEOS] = self._create_similar_videos_panel() + self._panels[ActiveTab.SIMILAR_MUSIC] = self._create_similar_music_panel() + self._panels[ActiveTab.BIG_FILES] = self._create_big_files_panel() + self._panels[ActiveTab.BROKEN_FILES] = self._create_broken_files_panel() + self._panels[ActiveTab.BAD_NAMES] = self._create_bad_names_panel() + self._panels[ActiveTab.EXIF_REMOVER] = self._create_exif_panel() + self._panels[ActiveTab.VIDEO_OPTIMIZER] = self._create_video_optimizer_panel() + + for panel in self._panels.values(): + self._container_layout.addWidget(panel) + panel.setVisible(False) + + self._container_layout.addStretch() + self._scroll.setWidget(self._container) + main_layout.addWidget(self._scroll) + + def set_active_tab(self, tab: ActiveTab): + self._active_tab = tab + for t, panel in self._panels.items(): + panel.setVisible(t == tab) + + def _create_duplicate_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + # Check method + self._dup_method = QComboBox() + self._dup_method.addItems(["Hash", "Size", "Name", "Size and Name"]) + method_map = {CheckingMethod.HASH: 0, CheckingMethod.SIZE: 1, + CheckingMethod.NAME: 2, CheckingMethod.SIZE_NAME: 3} + self._dup_method.setCurrentIndex(method_map.get(self._ts.dup_check_method, 0)) + self._dup_method.currentIndexChanged.connect(self._on_dup_method_changed) + layout.addRow("Check Method:", self._dup_method) + + # Hash type + self._dup_hash = QComboBox() + self._dup_hash.addItems(["Blake3", "CRC32", "XXH3"]) + hash_map = {HashType.BLAKE3: 0, HashType.CRC32: 1, HashType.XXH3: 2} + self._dup_hash.setCurrentIndex(hash_map.get(self._ts.dup_hash_type, 0)) + self._dup_hash.currentIndexChanged.connect(self._on_dup_hash_changed) + layout.addRow("Hash Type:", self._dup_hash) + + # Case sensitive + self._dup_case = QCheckBox("Case sensitive name comparison") + self._dup_case.setChecked(self._ts.dup_name_case_sensitive) + self._dup_case.toggled.connect(lambda v: setattr(self._ts, 'dup_name_case_sensitive', v)) + layout.addRow(self._dup_case) + + return panel + + def _on_dup_method_changed(self, idx): + methods = [CheckingMethod.HASH, CheckingMethod.SIZE, + CheckingMethod.NAME, CheckingMethod.SIZE_NAME] + self._ts.dup_check_method = methods[idx] + self.settings_changed.emit() + + def _on_dup_hash_changed(self, idx): + hashes = [HashType.BLAKE3, HashType.CRC32, HashType.XXH3] + self._ts.dup_hash_type = hashes[idx] + self.settings_changed.emit() + + def _create_similar_images_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + # Hash size + self._img_hash_size = QComboBox() + self._img_hash_size.addItems(["8", "16", "32", "64"]) + size_map = {8: 0, 16: 1, 32: 2, 64: 3} + self._img_hash_size.setCurrentIndex(size_map.get(self._ts.img_hash_size, 1)) + self._img_hash_size.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'img_hash_size', [8, 16, 32, 64][idx]) + ) + layout.addRow("Hash Size:", self._img_hash_size) + + # Resize algorithm + self._img_filter = QComboBox() + self._img_filter.addItems(["Lanczos3", "Gaussian", "CatmullRom", "Triangle", "Nearest"]) + filters = [ImageFilter.LANCZOS3, ImageFilter.GAUSSIAN, ImageFilter.CATMULL_ROM, + ImageFilter.TRIANGLE, ImageFilter.NEAREST] + filter_idx = filters.index(self._ts.img_filter) if self._ts.img_filter in filters else 4 + self._img_filter.setCurrentIndex(filter_idx) + self._img_filter.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'img_filter', filters[idx]) + ) + layout.addRow("Resize Algorithm:", self._img_filter) + + # Hash type + self._img_hash_alg = QComboBox() + self._img_hash_alg.addItems(["Mean", "Gradient", "BlockHash", "VertGradient", + "DoubleGradient", "Median"]) + algs = [ImageHashAlg.MEAN, ImageHashAlg.GRADIENT, ImageHashAlg.BLOCKHASH, + ImageHashAlg.VERT_GRADIENT, ImageHashAlg.DOUBLE_GRADIENT, ImageHashAlg.MEDIAN] + alg_idx = algs.index(self._ts.img_hash_alg) if self._ts.img_hash_alg in algs else 1 + self._img_hash_alg.setCurrentIndex(alg_idx) + self._img_hash_alg.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'img_hash_alg', algs[idx]) + ) + layout.addRow("Hash Type:", self._img_hash_alg) + + # Ignore same size + self._img_ignore_size = QCheckBox("Ignore same size") + self._img_ignore_size.setChecked(self._ts.img_ignore_same_size) + self._img_ignore_size.toggled.connect( + lambda v: setattr(self._ts, 'img_ignore_same_size', v) + ) + layout.addRow(self._img_ignore_size) + + # Max difference slider + diff_layout = QHBoxLayout() + self._img_diff_slider = QSlider(Qt.Horizontal) + self._img_diff_slider.setRange(0, 40) + self._img_diff_slider.setValue(self._ts.img_max_difference) + self._img_diff_label = QLabel(str(self._ts.img_max_difference)) + self._img_diff_slider.valueChanged.connect(self._on_img_diff_changed) + diff_layout.addWidget(self._img_diff_slider) + diff_layout.addWidget(self._img_diff_label) + layout.addRow("Max Difference:", diff_layout) + + return panel + + def _on_img_diff_changed(self, value): + self._ts.img_max_difference = value + self._img_diff_label.setText(str(value)) + self.settings_changed.emit() + + def _create_similar_videos_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + # Ignore same size + vid_ignore = QCheckBox("Ignore same size") + vid_ignore.setChecked(self._ts.vid_ignore_same_size) + vid_ignore.toggled.connect(lambda v: setattr(self._ts, 'vid_ignore_same_size', v)) + layout.addRow(vid_ignore) + + # Crop detect + self._vid_crop = QComboBox() + self._vid_crop.addItems(["LetterBox", "Motion", "None"]) + crops = [VideoCropDetect.LETTERBOX, VideoCropDetect.MOTION, VideoCropDetect.NONE] + crop_idx = crops.index(self._ts.vid_crop_detect) if self._ts.vid_crop_detect in crops else 0 + self._vid_crop.setCurrentIndex(crop_idx) + self._vid_crop.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'vid_crop_detect', crops[idx]) + ) + layout.addRow("Crop Detect:", self._vid_crop) + + # Max difference + diff_layout = QHBoxLayout() + self._vid_diff_slider = QSlider(Qt.Horizontal) + self._vid_diff_slider.setRange(0, 20) + self._vid_diff_slider.setValue(self._ts.vid_max_difference) + self._vid_diff_label = QLabel(str(self._ts.vid_max_difference)) + self._vid_diff_slider.valueChanged.connect(lambda v: ( + setattr(self._ts, 'vid_max_difference', v), + self._vid_diff_label.setText(str(v)) + )) + diff_layout.addWidget(self._vid_diff_slider) + diff_layout.addWidget(self._vid_diff_label) + layout.addRow("Max Difference:", diff_layout) + + # Skip forward + skip_layout = QHBoxLayout() + self._vid_skip_slider = QSlider(Qt.Horizontal) + self._vid_skip_slider.setRange(1, 360) + self._vid_skip_slider.setValue(self._ts.vid_skip_forward) + self._vid_skip_label = QLabel(str(self._ts.vid_skip_forward)) + self._vid_skip_slider.valueChanged.connect(lambda v: ( + setattr(self._ts, 'vid_skip_forward', v), + self._vid_skip_label.setText(str(v)) + )) + skip_layout.addWidget(self._vid_skip_slider) + skip_layout.addWidget(self._vid_skip_label) + layout.addRow("Skip Forward (s):", skip_layout) + + # Duration + dur_layout = QHBoxLayout() + self._vid_dur_slider = QSlider(Qt.Horizontal) + self._vid_dur_slider.setRange(1, 60) + self._vid_dur_slider.setValue(self._ts.vid_duration) + self._vid_dur_label = QLabel(str(self._ts.vid_duration)) + self._vid_dur_slider.valueChanged.connect(lambda v: ( + setattr(self._ts, 'vid_duration', v), + self._vid_dur_label.setText(str(v)) + )) + dur_layout.addWidget(self._vid_dur_slider) + dur_layout.addWidget(self._vid_dur_label) + layout.addRow("Hash Duration (s):", dur_layout) + + return panel + + def _create_similar_music_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + # Search method + self._music_method = QComboBox() + self._music_method.addItems(["Tags", "Fingerprint"]) + self._music_method.setCurrentIndex( + 0 if self._ts.music_search_method == MusicSearchMethod.TAGS else 1 + ) + self._music_method.currentIndexChanged.connect(self._on_music_method_changed) + layout.addRow("Audio Check Type:", self._music_method) + + # Tags group + self._tags_group = QGroupBox("Tag Matching") + tags_layout = QVBoxLayout(self._tags_group) + + self._music_approx = QCheckBox("Approximate comparison") + self._music_approx.setChecked(self._ts.music_approximate) + self._music_approx.toggled.connect(lambda v: setattr(self._ts, 'music_approximate', v)) + tags_layout.addWidget(self._music_approx) + + for attr, label in [ + ("music_title", "Title"), ("music_artist", "Artist"), + ("music_bitrate", "Bitrate"), ("music_genre", "Genre"), + ("music_year", "Year"), ("music_length", "Length"), + ]: + cb = QCheckBox(label) + cb.setChecked(getattr(self._ts, attr)) + cb.toggled.connect(lambda v, a=attr: setattr(self._ts, a, v)) + tags_layout.addWidget(cb) + + layout.addRow(self._tags_group) + + # Fingerprint group + self._fp_group = QGroupBox("Fingerprint Matching") + fp_layout = QFormLayout(self._fp_group) + + fp_similar = QCheckBox("Compare with similar titles") + fp_similar.setChecked(self._ts.music_compare_fingerprints_similar_titles) + fp_similar.toggled.connect( + lambda v: setattr(self._ts, 'music_compare_fingerprints_similar_titles', v) + ) + fp_layout.addRow(fp_similar) + + diff_layout = QHBoxLayout() + self._music_diff_slider = QSlider(Qt.Horizontal) + self._music_diff_slider.setRange(0, 100) + self._music_diff_slider.setValue(int(self._ts.music_max_difference * 10)) + self._music_diff_label = QLabel(f"{self._ts.music_max_difference:.1f}") + self._music_diff_slider.valueChanged.connect(lambda v: ( + setattr(self._ts, 'music_max_difference', v / 10.0), + self._music_diff_label.setText(f"{v / 10.0:.1f}") + )) + diff_layout.addWidget(self._music_diff_slider) + diff_layout.addWidget(self._music_diff_label) + fp_layout.addRow("Max Difference:", diff_layout) + + layout.addRow(self._fp_group) + self._on_music_method_changed(self._music_method.currentIndex()) + + return panel + + def _on_music_method_changed(self, idx): + if idx == 0: + self._ts.music_search_method = MusicSearchMethod.TAGS + else: + self._ts.music_search_method = MusicSearchMethod.CONTENT + self._tags_group.setVisible(idx == 0) + self._fp_group.setVisible(idx == 1) + self.settings_changed.emit() + + def _create_big_files_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + self._big_mode = QComboBox() + self._big_mode.addItems(["The Biggest", "The Smallest"]) + self._big_mode.setCurrentIndex(0 if self._ts.big_files_mode == "biggest" else 1) + self._big_mode.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'big_files_mode', "biggest" if idx == 0 else "smallest") + ) + layout.addRow("Method:", self._big_mode) + + self._big_count = QSpinBox() + self._big_count.setRange(1, 100000) + self._big_count.setValue(self._ts.big_files_number) + self._big_count.valueChanged.connect( + lambda v: setattr(self._ts, 'big_files_number', v) + ) + layout.addRow("Number of Files:", self._big_count) + + return panel + + def _create_broken_files_panel(self) -> QWidget: + panel = QWidget() + layout = QVBoxLayout(panel) + layout.addWidget(QLabel("File types to check:")) + + for attr, label in [ + ("broken_audio", "Audio"), ("broken_pdf", "PDF"), + ("broken_archive", "Archive"), ("broken_image", "Image"), + ("broken_video", "Video"), + ]: + cb = QCheckBox(label) + cb.setChecked(getattr(self._ts, attr)) + cb.toggled.connect(lambda v, a=attr: setattr(self._ts, a, v)) + layout.addWidget(cb) + + layout.addStretch() + return panel + + def _create_bad_names_panel(self) -> QWidget: + panel = QWidget() + layout = QVBoxLayout(panel) + layout.addWidget(QLabel("Check for:")) + + checks = [ + ("bad_names_uppercase_ext", "Uppercase extension", + "Files with .JPG, .PNG etc."), + ("bad_names_emoji", "Emoji in name", + "Files containing emoji characters"), + ("bad_names_space", "Space at start/end", + "Leading or trailing whitespace"), + ("bad_names_non_ascii", "Non-ASCII characters", + "Characters outside ASCII range"), + ("bad_names_remove_duplicated", "Remove duplicated non-alphanumeric", + "e.g. file--name..txt"), + ] + + for attr, label, hint in checks: + cb = QCheckBox(label) + cb.setChecked(getattr(self._ts, attr)) + cb.setToolTip(hint) + cb.toggled.connect(lambda v, a=attr: setattr(self._ts, a, v)) + layout.addWidget(cb) + + # Restricted charset + layout.addWidget(QLabel("Restricted charset:")) + self._bad_names_charset = QLineEdit(self._ts.bad_names_restricted_charset) + self._bad_names_charset.setPlaceholderText("Allowed special chars, comma-separated") + self._bad_names_charset.textChanged.connect( + lambda t: setattr(self._ts, 'bad_names_restricted_charset', t) + ) + layout.addWidget(self._bad_names_charset) + + layout.addStretch() + return panel + + def _create_exif_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + self._exif_tags = QLineEdit(self._ts.exif_ignored_tags) + self._exif_tags.setPlaceholderText("Tags to ignore, comma-separated") + self._exif_tags.textChanged.connect( + lambda t: setattr(self._ts, 'exif_ignored_tags', t) + ) + layout.addRow("Ignored EXIF Tags:", self._exif_tags) + + return panel + + def _create_video_optimizer_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + # Mode + self._vo_mode = QComboBox() + self._vo_mode.addItems(["Crop", "Transcode"]) + self._vo_mode.setCurrentIndex(0 if self._ts.video_opt_mode == "crop" else 1) + self._vo_mode.currentIndexChanged.connect(self._on_vo_mode_changed) + layout.addRow("Mode:", self._vo_mode) + + # Crop settings + self._crop_group = QGroupBox("Crop Settings") + crop_layout = QFormLayout(self._crop_group) + + self._vo_crop_type = QComboBox() + self._vo_crop_type.addItems(["Black Bars", "Static Content"]) + mechs = [VideoCropMechanism.BLACKBARS, VideoCropMechanism.STATICCONTENT] + mech_idx = mechs.index(self._ts.video_crop_mechanism) if self._ts.video_crop_mechanism in mechs else 0 + self._vo_crop_type.setCurrentIndex(mech_idx) + self._vo_crop_type.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'video_crop_mechanism', mechs[idx]) + ) + crop_layout.addRow("Crop Type:", self._vo_crop_type) + + self._vo_threshold = QSpinBox() + self._vo_threshold.setRange(0, 128) + self._vo_threshold.setValue(self._ts.video_black_pixel_threshold) + self._vo_threshold.valueChanged.connect( + lambda v: setattr(self._ts, 'video_black_pixel_threshold', v) + ) + crop_layout.addRow("Black Pixel Threshold:", self._vo_threshold) + + self._vo_bar_pct = QSpinBox() + self._vo_bar_pct.setRange(50, 100) + self._vo_bar_pct.setValue(self._ts.video_black_bar_percentage) + self._vo_bar_pct.valueChanged.connect( + lambda v: setattr(self._ts, 'video_black_bar_percentage', v) + ) + crop_layout.addRow("Black Bar Min %:", self._vo_bar_pct) + + self._vo_samples = QSpinBox() + self._vo_samples.setRange(5, 1000) + self._vo_samples.setValue(self._ts.video_max_samples) + self._vo_samples.valueChanged.connect( + lambda v: setattr(self._ts, 'video_max_samples', v) + ) + crop_layout.addRow("Max Samples:", self._vo_samples) + + self._vo_min_crop = QSpinBox() + self._vo_min_crop.setRange(1, 1000) + self._vo_min_crop.setValue(self._ts.video_min_crop_size) + self._vo_min_crop.valueChanged.connect( + lambda v: setattr(self._ts, 'video_min_crop_size', v) + ) + crop_layout.addRow("Min Crop Size:", self._vo_min_crop) + + layout.addRow(self._crop_group) + + # Transcode settings + self._transcode_group = QGroupBox("Transcode Settings") + tc_layout = QFormLayout(self._transcode_group) + + self._vo_codecs = QLineEdit(self._ts.video_excluded_codecs) + self._vo_codecs.textChanged.connect( + lambda t: setattr(self._ts, 'video_excluded_codecs', t) + ) + tc_layout.addRow("Excluded Codecs:", self._vo_codecs) + + self._vo_codec = QComboBox() + self._vo_codec.addItems(["H264", "H265", "AV1", "VP9"]) + codecs = [VideoCodec.H264, VideoCodec.H265, VideoCodec.AV1, VideoCodec.VP9] + codec_idx = codecs.index(self._ts.video_codec) if self._ts.video_codec in codecs else 1 + self._vo_codec.setCurrentIndex(codec_idx) + self._vo_codec.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'video_codec', codecs[idx]) + ) + tc_layout.addRow("Target Codec:", self._vo_codec) + + self._vo_quality = QSpinBox() + self._vo_quality.setRange(0, 51) + self._vo_quality.setValue(self._ts.video_quality) + self._vo_quality.valueChanged.connect( + lambda v: setattr(self._ts, 'video_quality', v) + ) + tc_layout.addRow("Quality:", self._vo_quality) + + self._vo_fail_bigger = QCheckBox("Fail if not smaller") + self._vo_fail_bigger.setChecked(self._ts.video_fail_if_bigger) + self._vo_fail_bigger.toggled.connect( + lambda v: setattr(self._ts, 'video_fail_if_bigger', v) + ) + tc_layout.addRow(self._vo_fail_bigger) + + layout.addRow(self._transcode_group) + + self._on_vo_mode_changed(self._vo_mode.currentIndex()) + return panel + + def _on_vo_mode_changed(self, idx): + mode = "crop" if idx == 0 else "transcode" + self._ts.video_opt_mode = mode + self._crop_group.setVisible(idx == 0) + self._transcode_group.setVisible(idx == 1) + self.settings_changed.emit() diff --git a/czkawka_pyside6/main.py b/czkawka_pyside6/main.py new file mode 100644 index 000000000..6bf9a05f1 --- /dev/null +++ b/czkawka_pyside6/main.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +""" +Czkawka PySide6 - A PySide6/Qt interface for czkawka file cleanup tool. + +This application provides a graphical interface to czkawka, using the +czkawka_cli binary as its backend for all scanning operations. + +Usage: + python main.py + # or + python -m czkawka_pyside6.main + +Requirements: + - PySide6 >= 6.6.0 + - czkawka_cli binary in PATH (or configured in settings) + - Optional: send2trash (for trash support) + - Optional: Pillow (for EXIF cleaning) +""" + +import sys +import os + + +def main(): + # Set environment for better HiDPI support + os.environ.setdefault("QT_AUTO_SCREEN_SCALE_FACTOR", "1") + + from PySide6.QtWidgets import QApplication + from PySide6.QtCore import Qt + from PySide6.QtGui import QFont + + app = QApplication(sys.argv) + app.setApplicationName("Czkawka") + app.setApplicationVersion("11.0.1") + app.setOrganizationName("czkawka") + app.setDesktopFileName("com.github.qarmin.czkawka") + + # Set application icon + from app.icons import app_icon + icon = app_icon() + if not icon.isNull(): + app.setWindowIcon(icon) + + # Set default font + font = QFont() + font.setPointSize(10) + app.setFont(font) + + # Import and create main window + from app.main_window import MainWindow + window = MainWindow() + window.show() + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() From f1d5e003b46c11950d033ecec4c04b7105fdcadb Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 10:09:33 +0100 Subject: [PATCH 25/49] Make PySide6 frontend KDE6/Plasma compliant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove forced dark palette and hardcoded color stylesheets; inherit the system theme (Breeze, Adwaita, etc.) so the app looks native - Use QIcon.fromTheme() with standard XDG/FreeDesktop icon names (system-search, edit-delete, document-save, etc.) with embedded SVG fallbacks for systems without an icon theme - Use QStandardPaths.AppConfigLocation for XDG-compliant config paths - Add .desktop file (com.github.qarmin.czkawka-pyside6.desktop) - Add AppStream metainfo.xml for software center integration - Set desktopFileName and organizationDomain for proper KDE integration - Replace all hardcoded setStyleSheet color values with system palette (setEnabled(False) for muted text, QFont for bold/size, QFrame for separators, style().standardIcon() for dialog icons) - Remove forced QFont size — inherit system font settings Co-Authored-By: Claude Opus 4.6 (1M context) --- czkawka_pyside6/app/action_buttons.py | 14 --- czkawka_pyside6/app/dialogs/about_dialog.py | 24 ++--- czkawka_pyside6/app/dialogs/delete_dialog.py | 16 ++-- czkawka_pyside6/app/dialogs/move_dialog.py | 1 - czkawka_pyside6/app/dialogs/rename_dialog.py | 2 +- czkawka_pyside6/app/dialogs/select_dialog.py | 2 +- czkawka_pyside6/app/icons.py | 80 ++++++++--------- czkawka_pyside6/app/left_panel.py | 18 +--- czkawka_pyside6/app/main_window.py | 88 ++++--------------- czkawka_pyside6/app/preview_panel.py | 10 +-- czkawka_pyside6/app/progress_widget.py | 63 ++++++------- czkawka_pyside6/app/results_view.py | 30 +++++-- czkawka_pyside6/app/settings_panel.py | 5 +- czkawka_pyside6/app/state.py | 6 +- czkawka_pyside6/app/tool_settings.py | 4 +- czkawka_pyside6/main.py | 23 ++--- .../com.github.qarmin.czkawka-pyside6.desktop | 12 +++ ...github.qarmin.czkawka-pyside6.metainfo.xml | 40 +++++++++ 18 files changed, 206 insertions(+), 232 deletions(-) create mode 100644 data/com.github.qarmin.czkawka-pyside6.desktop create mode 100644 data/com.github.qarmin.czkawka-pyside6.metainfo.xml diff --git a/czkawka_pyside6/app/action_buttons.py b/czkawka_pyside6/app/action_buttons.py index 36e928a26..ef39a3947 100644 --- a/czkawka_pyside6/app/action_buttons.py +++ b/czkawka_pyside6/app/action_buttons.py @@ -46,11 +46,6 @@ def _setup_ui(self): self._scan_btn = QPushButton(icon_search(18), " Scan") self._scan_btn.setIconSize(ICON_SIZE) self._scan_btn.setMinimumWidth(90) - self._scan_btn.setStyleSheet( - "QPushButton { background-color: #2d5a27; color: white; font-weight: bold; padding: 6px 16px; }" - "QPushButton:hover { background-color: #3a7a34; }" - "QPushButton:disabled { background-color: #444; color: #888; }" - ) self._scan_btn.clicked.connect(self.scan_clicked.emit) layout.addWidget(self._scan_btn) @@ -58,10 +53,6 @@ def _setup_ui(self): self._stop_btn = QPushButton(icon_stop(18), " Stop") self._stop_btn.setIconSize(ICON_SIZE) self._stop_btn.setMinimumWidth(80) - self._stop_btn.setStyleSheet( - "QPushButton { background-color: #8a2222; color: white; font-weight: bold; padding: 6px 16px; }" - "QPushButton:hover { background-color: #aa3333; }" - ) self._stop_btn.clicked.connect(self.stop_clicked.emit) self._stop_btn.setVisible(False) layout.addWidget(self._stop_btn) @@ -80,11 +71,6 @@ def _setup_ui(self): # Delete button self._delete_btn = QPushButton(icon_delete(18), " Delete") self._delete_btn.setIconSize(ICON_SIZE) - self._delete_btn.setStyleSheet( - "QPushButton { background-color: #8a2222; color: white; padding: 6px 12px; }" - "QPushButton:hover { background-color: #aa3333; }" - "QPushButton:disabled { background-color: #444; color: #888; }" - ) self._delete_btn.clicked.connect(self.delete_clicked.emit) layout.addWidget(self._delete_btn) diff --git a/czkawka_pyside6/app/dialogs/about_dialog.py b/czkawka_pyside6/app/dialogs/about_dialog.py index 54da0fa9f..3284712ea 100644 --- a/czkawka_pyside6/app/dialogs/about_dialog.py +++ b/czkawka_pyside6/app/dialogs/about_dialog.py @@ -1,8 +1,8 @@ from PySide6.QtWidgets import ( - QDialog, QVBoxLayout, QLabel, QDialogButtonBox + QDialog, QVBoxLayout, QLabel, QDialogButtonBox, QFrame ) from PySide6.QtCore import Qt -from PySide6.QtGui import QPixmap +from PySide6.QtGui import QPixmap, QFont from ..icons import app_logo_path @@ -30,24 +30,29 @@ def __init__(self, parent=None): layout.addWidget(logo_label) title = QLabel("Czkawka") - title.setStyleSheet("font-size: 28px; font-weight: bold; padding: 4px;") + title_font = QFont() + title_font.setPointSize(22) + title_font.setBold(True) + title.setFont(title_font) title.setAlignment(Qt.AlignCenter) layout.addWidget(title) - subtitle = QLabel("PySide6 / Qt6 Edition") - subtitle.setStyleSheet("font-size: 14px; color: #6fbf73; padding: 2px;") + subtitle = QLabel("PySide6 / Qt 6 Edition") + sub_font = QFont() + sub_font.setPointSize(11) + subtitle.setFont(sub_font) subtitle.setAlignment(Qt.AlignCenter) layout.addWidget(subtitle) version = QLabel("Version 11.0.1") - version.setStyleSheet("font-size: 11px; color: #888; padding: 2px;") version.setAlignment(Qt.AlignCenter) + version.setEnabled(False) layout.addWidget(version) # Separator - sep = QLabel() - sep.setFixedHeight(1) - sep.setStyleSheet("background-color: #444; margin: 8px 40px;") + sep = QFrame() + sep.setFrameShape(QFrame.HLine) + sep.setFrameShadow(QFrame.Sunken) layout.addWidget(sep) desc = QLabel( @@ -68,7 +73,6 @@ def __init__(self, parent=None): ) desc.setWordWrap(True) desc.setAlignment(Qt.AlignCenter) - desc.setStyleSheet("padding: 6px 20px; line-height: 1.4;") layout.addWidget(desc) layout.addStretch() diff --git a/czkawka_pyside6/app/dialogs/delete_dialog.py b/czkawka_pyside6/app/dialogs/delete_dialog.py index 89ce64bc6..b82ad674d 100644 --- a/czkawka_pyside6/app/dialogs/delete_dialog.py +++ b/czkawka_pyside6/app/dialogs/delete_dialog.py @@ -1,8 +1,9 @@ from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QLabel, QDialogButtonBox, - QCheckBox, QHBoxLayout + QCheckBox, QMessageBox ) from PySide6.QtCore import Qt +from PySide6.QtGui import QFont class DeleteDialog(QDialog): @@ -16,15 +17,17 @@ def __init__(self, count: int, move_to_trash: bool = True, parent=None): layout = QVBoxLayout(self) - # Warning + # Warning icon from system theme icon_label = QLabel() - icon_label.setStyleSheet("font-size: 36px;") - icon_label.setText("Warning") + icon = self.style().standardIcon(self.style().SP_MessageBoxWarning) + icon_label.setPixmap(icon.pixmap(48, 48)) icon_label.setAlignment(Qt.AlignCenter) layout.addWidget(icon_label) msg = QLabel(f"Are you sure you want to delete {count} selected file(s)?") - msg.setStyleSheet("font-size: 14px; padding: 10px;") + msg_font = QFont() + msg_font.setPointSize(11) + msg.setFont(msg_font) msg.setAlignment(Qt.AlignCenter) msg.setWordWrap(True) layout.addWidget(msg) @@ -39,9 +42,6 @@ def __init__(self, count: int, move_to_trash: bool = True, parent=None): QDialogButtonBox.Ok | QDialogButtonBox.Cancel ) buttons.button(QDialogButtonBox.Ok).setText("Delete") - buttons.button(QDialogButtonBox.Ok).setStyleSheet( - "background-color: #8a2222; color: white; padding: 6px 20px;" - ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) diff --git a/czkawka_pyside6/app/dialogs/move_dialog.py b/czkawka_pyside6/app/dialogs/move_dialog.py index 6c754d21a..a26a51405 100644 --- a/czkawka_pyside6/app/dialogs/move_dialog.py +++ b/czkawka_pyside6/app/dialogs/move_dialog.py @@ -17,7 +17,6 @@ def __init__(self, count: int, parent=None): layout = QVBoxLayout(self) msg = QLabel(f"Move or copy {count} selected file(s) to:") - msg.setStyleSheet("font-size: 13px; padding: 6px;") layout.addWidget(msg) # Destination path diff --git a/czkawka_pyside6/app/dialogs/rename_dialog.py b/czkawka_pyside6/app/dialogs/rename_dialog.py index 4c6208ab1..da4cb4fa5 100644 --- a/czkawka_pyside6/app/dialogs/rename_dialog.py +++ b/czkawka_pyside6/app/dialogs/rename_dialog.py @@ -22,7 +22,7 @@ def __init__(self, count: int, rename_type: str = "extensions", parent=None): label = QLabel(msg) label.setWordWrap(True) - label.setStyleSheet("font-size: 13px; padding: 10px;") + label.setContentsMargins(10, 10, 10, 10) layout.addWidget(label) buttons = QDialogButtonBox( diff --git a/czkawka_pyside6/app/dialogs/select_dialog.py b/czkawka_pyside6/app/dialogs/select_dialog.py index 58ea94a11..f4153106c 100644 --- a/czkawka_pyside6/app/dialogs/select_dialog.py +++ b/czkawka_pyside6/app/dialogs/select_dialog.py @@ -30,7 +30,7 @@ def __init__(self, parent=None): layout = QVBoxLayout(self) label = QLabel("Choose selection mode:") - label.setStyleSheet("font-size: 13px; padding: 4px;") + label.setContentsMargins(4, 4, 4, 4) layout.addWidget(label) for mode, name in self.MODES: diff --git a/czkawka_pyside6/app/icons.py b/czkawka_pyside6/app/icons.py index bc094529f..9ec8fdace 100644 --- a/czkawka_pyside6/app/icons.py +++ b/czkawka_pyside6/app/icons.py @@ -1,45 +1,37 @@ -"""SVG icon resources for Czkawka PySide6 interface. +"""Icon resources for Czkawka PySide6 interface. -Uses the same SVG icons as the Krokiet (Slint) interface. -Icons are embedded as strings to avoid file path issues. +KDE-compliant icon strategy: + 1. Try QIcon.fromTheme() with standard XDG/FreeDesktop icon names + 2. Fall back to the embedded SVG icons from the Krokiet icon set + +This ensures the app looks native on KDE/Plasma (Breeze icons) and +other desktops while still working when no icon theme is installed. """ -from PySide6.QtGui import QIcon, QPixmap +from PySide6.QtGui import QIcon, QPixmap, QPainter, QImage from PySide6.QtCore import QSize, Qt from PySide6.QtSvg import QSvgRenderer -from PySide6.QtGui import QPainter, QImage from functools import lru_cache -# Fill color applied to icons for dark theme visibility -_ICON_FILL = "#cccccc" -_ICON_FILL_GREEN = "#6fbf73" -_ICON_FILL_RED = "#e57373" - -def _colorize_svg(svg: str, fill: str = _ICON_FILL) -> str: - """Inject fill color into SVG for dark theme visibility.""" - # Add fill to root svg or g elements - if 'fill="none"' not in svg and f'fill="{fill}"' not in svg: - svg = svg.replace(" QIcon: + """Return XDG theme icon if available, otherwise render the fallback SVG.""" + icon = QIcon.fromTheme(xdg_name) + if not icon.isNull(): + return icon + return _svg_to_icon(fallback_svg, size) @lru_cache(maxsize=64) -def _svg_to_icon(svg_data: str, fill: str = _ICON_FILL, size: int = 24) -> QIcon: - """Convert SVG string to QIcon with specified fill color.""" - colored = _colorize_svg(svg_data, fill) - renderer = QSvgRenderer(colored.encode("utf-8")) +def _svg_to_icon(svg_data: str, size: int = 24) -> QIcon: + """Render SVG string to QIcon. No color injection — respects original SVG.""" + renderer = QSvgRenderer(svg_data.encode("utf-8")) image = QImage(QSize(size, size), QImage.Format_ARGB32_Premultiplied) image.fill(Qt.transparent) painter = QPainter(image) renderer.render(painter) painter.end() - pixmap = QPixmap.fromImage(image) - return QIcon(pixmap) + return QIcon(QPixmap.fromImage(image)) # ─── Raw SVG data ─────────────────────────────────────────── @@ -79,53 +71,57 @@ def _svg_to_icon(svg_data: str, fill: str = _ICON_FILL, size: int = 24) -> QIcon # ─── Icon accessor functions ──────────────────────────────── +# ─── XDG theme name → fallback SVG mapping ───────────────── +# Standard FreeDesktop icon names are tried first (Breeze, Adwaita, etc.) +# If the theme doesn't have them, the embedded Krokiet SVG is used. + def icon_search(size=24): - return _svg_to_icon(SEARCH_SVG, _ICON_FILL_GREEN, size) + return _themed_icon("system-search", SEARCH_SVG, size) def icon_stop(size=24): - return _svg_to_icon(STOP_SVG, _ICON_FILL_RED, size) + return _themed_icon("process-stop", STOP_SVG, size) def icon_delete(size=24): - return _svg_to_icon(DELETE_SVG, _ICON_FILL_RED, size) + return _themed_icon("edit-delete", DELETE_SVG, size) def icon_move(size=24): - return _svg_to_icon(MOVE_SVG, _ICON_FILL, size) + return _themed_icon("folder-move", MOVE_SVG, size) def icon_save(size=24): - return _svg_to_icon(SAVE_SVG, _ICON_FILL, size) + return _themed_icon("document-save", SAVE_SVG, size) def icon_select(size=24): - return _svg_to_icon(SELECT_SVG, _ICON_FILL, size) + return _themed_icon("edit-select-all", SELECT_SVG, size) def icon_sort(size=24): - return _svg_to_icon(SORT_SVG, _ICON_FILL, size) + return _themed_icon("view-sort", SORT_SVG, size) def icon_hardlink(size=24): - return _svg_to_icon(HARDLINK_SVG, _ICON_FILL, size) + return _themed_icon("edit-link", HARDLINK_SVG, size) def icon_symlink(size=24): - return _svg_to_icon(SYMLINK_SVG, _ICON_FILL, size) + return _themed_icon("edit-link", SYMLINK_SVG, size) def icon_rename(size=24): - return _svg_to_icon(RENAME_SVG, _ICON_FILL, size) + return _themed_icon("edit-rename", RENAME_SVG, size) def icon_clean(size=24): - return _svg_to_icon(CLEAN_SVG, _ICON_FILL, size) + return _themed_icon("edit-clear-all", CLEAN_SVG, size) def icon_optimize(size=24): - return _svg_to_icon(OPTIMIZE_SVG, _ICON_FILL, size) + return _themed_icon("configure", OPTIMIZE_SVG, size) def icon_settings(size=24): - return _svg_to_icon(SETTINGS_SVG, _ICON_FILL, size) + return _themed_icon("configure", SETTINGS_SVG, size) def icon_subsettings(size=24): - return _svg_to_icon(SUBSETTINGS_SVG, _ICON_FILL, size) + return _themed_icon("preferences-other", SUBSETTINGS_SVG, size) def icon_dir(size=24): - return _svg_to_icon(DIR_SVG, _ICON_FILL, size) + return _themed_icon("folder", DIR_SVG, size) def icon_info(size=24): - return _svg_to_icon(INFO_SVG, _ICON_FILL, size) + return _themed_icon("dialog-information", INFO_SVG, size) def app_logo_path() -> str: diff --git a/czkawka_pyside6/app/left_panel.py b/czkawka_pyside6/app/left_panel.py index 8c53e77d3..8a8f87185 100644 --- a/czkawka_pyside6/app/left_panel.py +++ b/czkawka_pyside6/app/left_panel.py @@ -96,22 +96,6 @@ def _setup_ui(self): # Tool list self._tool_list = QListWidget() self._tool_list.setSpacing(1) - font = QFont() - font.setPointSize(10) - self._tool_list.setFont(font) - self._tool_list.setStyleSheet(""" - QListWidget::item { - padding: 4px 8px; - border-left: 3px solid transparent; - } - QListWidget::item:selected { - border-left: 3px solid #6fbf73; - background-color: #353535; - } - QListWidget::item:hover { - background-color: #49494926; - } - """) for tab in self.TOOL_TABS: item = QListWidgetItem(TAB_DISPLAY_NAMES[tab]) @@ -126,7 +110,7 @@ def _setup_ui(self): # Version label version_label = QLabel("Czkawka PySide6 v11.0.1") version_label.setAlignment(Qt.AlignCenter) - version_label.setStyleSheet("color: #666; font-size: 9px; padding: 2px;") + version_label.setEnabled(False) layout.addWidget(version_label) def _on_item_changed(self, current, previous): diff --git a/czkawka_pyside6/app/main_window.py b/czkawka_pyside6/app/main_window.py index 2515bd237..e6168badc 100644 --- a/czkawka_pyside6/app/main_window.py +++ b/czkawka_pyside6/app/main_window.py @@ -7,7 +7,7 @@ QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QSplitter, QStatusBar, QMessageBox, QLabel, QApplication ) -from PySide6.QtCore import Qt, QTimer +from PySide6.QtCore import Qt, QTimer, QStandardPaths from PySide6.QtGui import QPalette, QColor from .state import AppState @@ -485,83 +485,29 @@ def _on_settings_changed(self): self._bottom_panel.refresh_lists() def _apply_theme(self): - """Apply dark theme to the application.""" - if not self._state.settings.dark_theme: - return + """Apply minimal styling that works with the system theme. + KDE compliance: we inherit the desktop theme (Breeze, Adwaita, etc.) + and only add small layout tweaks that don't override colors. + """ app = QApplication.instance() - palette = QPalette() - - # Dark theme colors - palette.setColor(QPalette.Window, QColor(43, 43, 43)) - palette.setColor(QPalette.WindowText, QColor(210, 210, 210)) - palette.setColor(QPalette.Base, QColor(30, 30, 30)) - palette.setColor(QPalette.AlternateBase, QColor(38, 38, 38)) - palette.setColor(QPalette.ToolTipBase, QColor(50, 50, 50)) - palette.setColor(QPalette.ToolTipText, QColor(210, 210, 210)) - palette.setColor(QPalette.Text, QColor(210, 210, 210)) - palette.setColor(QPalette.Button, QColor(53, 53, 53)) - palette.setColor(QPalette.ButtonText, QColor(210, 210, 210)) - palette.setColor(QPalette.BrightText, QColor(255, 255, 255)) - palette.setColor(QPalette.Link, QColor(86, 140, 210)) - palette.setColor(QPalette.Highlight, QColor(60, 100, 150)) - palette.setColor(QPalette.HighlightedText, QColor(255, 255, 255)) - palette.setColor(QPalette.Disabled, QPalette.Text, QColor(128, 128, 128)) - palette.setColor(QPalette.Disabled, QPalette.ButtonText, QColor(128, 128, 128)) - - app.setPalette(palette) - - # Additional stylesheet + + # Only apply layout polish — no color overrides so the system + # theme (Breeze dark/light, Adwaita, etc.) is fully respected. app.setStyleSheet(""" - QMainWindow { background-color: #2b2b2b; } - QSplitter::handle { background-color: #404040; width: 2px; } - QTreeWidget { border: 1px solid #404040; } + QSplitter::handle { width: 2px; } QTreeWidget::item { padding: 2px; } - QTreeWidget::item:alternate { background-color: #262626; } - QTreeWidget::item:selected { background-color: #3c6496; } - QListWidget { border: 1px solid #404040; } QListWidget::item { padding: 3px; } - QListWidget::item:selected { background-color: #3c6496; } - QGroupBox { border: 1px solid #505050; border-radius: 4px; - margin-top: 8px; padding-top: 8px; } - QGroupBox::title { subcontrol-origin: margin; left: 10px; - padding: 0 4px; } - QPushButton { padding: 5px 12px; border: 1px solid #555; - border-radius: 3px; background-color: #404040; } - QPushButton:hover { background-color: #505050; } - QPushButton:pressed { background-color: #353535; } - QPushButton:disabled { background-color: #333; color: #666; } - QComboBox { padding: 4px; border: 1px solid #555; - border-radius: 3px; background-color: #404040; } - QComboBox:hover { background-color: #505050; } - QComboBox QAbstractItemView { background-color: #353535; - border: 1px solid #555; - selection-background-color: #3c6496; } - QLineEdit { padding: 4px; border: 1px solid #555; - border-radius: 3px; background-color: #353535; } - QSpinBox { padding: 4px; border: 1px solid #555; - border-radius: 3px; background-color: #353535; } - QSlider::groove:horizontal { height: 6px; background: #404040; - border-radius: 3px; } - QSlider::handle:horizontal { width: 14px; margin: -4px 0; - background: #888; border-radius: 7px; } - QSlider::handle:horizontal:hover { background: #aaa; } - QProgressBar { border: 1px solid #555; border-radius: 3px; - text-align: center; background-color: #353535; } - QProgressBar::chunk { background-color: #3c6496; border-radius: 2px; } + QGroupBox { border-radius: 4px; margin-top: 8px; padding-top: 8px; } + QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 4px; } + QPushButton { padding: 5px 12px; } + QComboBox { padding: 4px; } + QLineEdit { padding: 4px; } + QSpinBox { padding: 4px; } + QProgressBar { text-align: center; } QScrollArea { border: none; } - QTabWidget::pane { border: 1px solid #555; } - QTabBar::tab { padding: 6px 16px; border: 1px solid #555; - border-bottom: none; border-radius: 3px 3px 0 0; - background-color: #353535; } - QTabBar::tab:selected { background-color: #404040; } - QTabBar::tab:hover { background-color: #505050; } - QStatusBar { background-color: #2b2b2b; border-top: 1px solid #404040; } QCheckBox { spacing: 6px; } - QCheckBox::indicator { width: 16px; height: 16px; } - QTextEdit { border: 1px solid #404040; background-color: #1e1e1e; } - QHeaderView::section { background-color: #353535; padding: 4px; - border: 1px solid #404040; } + QHeaderView::section { padding: 4px; } """) def _auto_detect_cli(self): diff --git a/czkawka_pyside6/app/preview_panel.py b/czkawka_pyside6/app/preview_panel.py index 3e3c50586..7b53bc0b2 100644 --- a/czkawka_pyside6/app/preview_panel.py +++ b/czkawka_pyside6/app/preview_panel.py @@ -27,7 +27,9 @@ def _setup_ui(self): layout.setContentsMargins(4, 4, 4, 4) self._title = QLabel("Preview") - self._title.setStyleSheet("font-weight: bold; font-size: 12px; padding: 2px;") + font = self._title.font() + font.setBold(True) + self._title.setFont(font) self._title.setAlignment(Qt.AlignCenter) layout.addWidget(self._title) @@ -35,16 +37,14 @@ def _setup_ui(self): self._image_label.setAlignment(Qt.AlignCenter) self._image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self._image_label.setMinimumSize(QSize(180, 180)) - self._image_label.setStyleSheet( - "background-color: #1a1a1a; border: 1px solid #333; border-radius: 4px;" - ) + self._image_label.setFrameShape(QLabel.StyledPanel) self._image_label.setScaledContents(False) layout.addWidget(self._image_label) self._info_label = QLabel() self._info_label.setAlignment(Qt.AlignCenter) self._info_label.setWordWrap(True) - self._info_label.setStyleSheet("color: #888; font-size: 10px; padding: 2px;") + self._info_label.setEnabled(False) layout.addWidget(self._info_label) def show_preview(self, file_path: str): diff --git a/czkawka_pyside6/app/progress_widget.py b/czkawka_pyside6/app/progress_widget.py index 185e0790d..9c1de28d3 100644 --- a/czkawka_pyside6/app/progress_widget.py +++ b/czkawka_pyside6/app/progress_widget.py @@ -5,7 +5,7 @@ from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QProgressBar, QLabel, QHBoxLayout ) -from PySide6.QtCore import Qt, QTimer +from PySide6.QtCore import Qt, QTimer, QStandardPaths from .models import ActiveTab, ScanProgress @@ -21,23 +21,6 @@ class ProgressWidget(QWidget): - Phase step indicators """ - # File where we persist the last file-collection count per directory set, - # so we can estimate the collection stage percentage on the next scan. - _ESTIMATE_FILE = Path.home() / ".config" / "czkawka_pyside6" / "scan_estimates.json" - - _BAR_STYLE = """ - QProgressBar {{ - border: 1px solid #555; border-radius: 3px; - text-align: center; background-color: #2a2a2a; - font-size: 10px; color: #ccc; - }} - QProgressBar::chunk {{ - background: qlineargradient(x1:0, y1:0, x2:1, y2:0, - stop:0 {c1}, stop:1 {c2}); - border-radius: 2px; - }} - """ - def __init__(self, parent=None): super().__init__(parent) self.setVisible(False) @@ -62,32 +45,31 @@ def _setup_ui(self): # Row 1: stage label + elapsed row1 = QHBoxLayout() self._stage_label = QLabel("Initializing...") - self._stage_label.setStyleSheet("font-weight: bold; font-size: 12px;") + font = self._stage_label.font() + font.setBold(True) + self._stage_label.setFont(font) row1.addWidget(self._stage_label) row1.addStretch() self._elapsed_label = QLabel("") - self._elapsed_label.setStyleSheet("color: #888; font-size: 11px;") + self._elapsed_label.setEnabled(False) # Uses disabled palette color row1.addWidget(self._elapsed_label) layout.addLayout(row1) - # Row 2: current stage bar "Current stage" NN% + # Row 2: current stage bar "Current" NN% row2 = QHBoxLayout() row2.setSpacing(6) lbl2 = QLabel("Current") - lbl2.setStyleSheet("color: #aaa; font-size: 10px;") + lbl2.setEnabled(False) lbl2.setFixedWidth(48) row2.addWidget(lbl2) self._stage_bar = QProgressBar() self._stage_bar.setFixedHeight(14) self._stage_bar.setTextVisible(False) - self._stage_bar.setStyleSheet( - self._BAR_STYLE.format(c1="#1b4332", c2="#2d6a4f") - ) row2.addWidget(self._stage_bar) self._stage_pct = QLabel("") self._stage_pct.setFixedWidth(40) self._stage_pct.setAlignment(Qt.AlignRight | Qt.AlignVCenter) - self._stage_pct.setStyleSheet("color: #aaa; font-size: 10px;") + self._stage_pct.setEnabled(False) row2.addWidget(self._stage_pct) layout.addLayout(row2) @@ -95,38 +77,35 @@ def _setup_ui(self): row3 = QHBoxLayout() row3.setSpacing(6) lbl3 = QLabel("Overall") - lbl3.setStyleSheet("color: #aaa; font-size: 10px;") + lbl3.setEnabled(False) lbl3.setFixedWidth(48) row3.addWidget(lbl3) self._overall_bar = QProgressBar() self._overall_bar.setFixedHeight(14) self._overall_bar.setTextVisible(False) - self._overall_bar.setStyleSheet( - self._BAR_STYLE.format(c1="#2d6a4f", c2="#40916c") - ) row3.addWidget(self._overall_bar) self._overall_pct = QLabel("") self._overall_pct.setFixedWidth(40) self._overall_pct.setAlignment(Qt.AlignRight | Qt.AlignVCenter) - self._overall_pct.setStyleSheet("color: #aaa; font-size: 10px;") + self._overall_pct.setEnabled(False) row3.addWidget(self._overall_pct) layout.addLayout(row3) # Row 4: detail counts row4 = QHBoxLayout() self._detail_label = QLabel("") - self._detail_label.setStyleSheet("color: #888; font-size: 10px;") + self._detail_label.setEnabled(False) row4.addWidget(self._detail_label) row4.addStretch() self._size_label = QLabel("") - self._size_label.setStyleSheet("color: #888; font-size: 10px;") + self._size_label.setEnabled(False) self._size_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) row4.addWidget(self._size_label) layout.addLayout(row4) # Row 5: step indicators self._steps_label = QLabel("") - self._steps_label.setStyleSheet("color: #666; font-size: 9px;") + self._steps_label.setEnabled(False) self._steps_label.setAlignment(Qt.AlignCenter) self._steps_label.setWordWrap(True) layout.addWidget(self._steps_label) @@ -261,18 +240,26 @@ def _get_estimate_key(self) -> str: def _get_estimate(self) -> int: return self._estimates.get(self._get_estimate_key(), 0) + @staticmethod + def _estimate_file_path() -> Path: + config_dir = QStandardPaths.writableLocation(QStandardPaths.AppConfigLocation) + base = Path(config_dir) if config_dir else Path.home() / ".config" / "czkawka" + return base / "scan_estimates.json" + def _save_estimate(self, count: int): self._estimates[self._get_estimate_key()] = count try: - self._ESTIMATE_FILE.parent.mkdir(parents=True, exist_ok=True) - self._ESTIMATE_FILE.write_text(json.dumps(self._estimates)) + path = self._estimate_file_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(self._estimates)) except OSError: pass def _load_estimates(self): try: - if self._ESTIMATE_FILE.exists(): - self._estimates = json.loads(self._ESTIMATE_FILE.read_text()) + path = self._estimate_file_path() + if path.exists(): + self._estimates = json.loads(path.read_text()) except (json.JSONDecodeError, OSError): self._estimates = {} diff --git a/czkawka_pyside6/app/results_view.py b/czkawka_pyside6/app/results_view.py index fc1aca09c..6c7a37eb9 100644 --- a/czkawka_pyside6/app/results_view.py +++ b/czkawka_pyside6/app/results_view.py @@ -17,10 +17,11 @@ class ResultsView(QWidget): item_activated = Signal(object) # ResultEntry context_menu_requested = Signal(object, object) # QPoint, ResultEntry - # Colors - HEADER_BG = QColor(60, 60, 80) - HEADER_FG = QColor(220, 220, 255) - SELECTED_BG = QColor(40, 80, 40) + # Group header uses the system highlight color (darkened) so it works on + # both light and dark themes. Computed lazily on first result display. + _header_colors_ready = False + HEADER_BG = QColor() + HEADER_FG = QColor() def __init__(self, parent=None): super().__init__(parent) @@ -28,6 +29,23 @@ def __init__(self, parent=None): self._results: list[ResultEntry] = [] self._setup_ui() + def _ensure_header_colors(self): + """Derive group header colors from the system palette.""" + if self._header_colors_ready: + return + from PySide6.QtWidgets import QApplication + palette = QApplication.instance().palette() + # Use a midpoint between window and highlight for header background + win = palette.color(palette.Window) + hi = palette.color(palette.Highlight) + self.HEADER_BG = QColor( + (win.red() + hi.red()) // 2, + (win.green() + hi.green()) // 2, + (win.blue() + hi.blue()) // 2, + ) + self.HEADER_FG = palette.color(palette.HighlightedText) + self._header_colors_ready = True + def _setup_ui(self): layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -35,11 +53,10 @@ def _setup_ui(self): # Summary bar summary_layout = QHBoxLayout() self._summary_label = QLabel("No results") - self._summary_label.setStyleSheet("padding: 4px;") summary_layout.addWidget(self._summary_label) self._selection_label = QLabel("") self._selection_label.setAlignment(Qt.AlignRight) - self._selection_label.setStyleSheet("padding: 4px; color: #aaa;") + self._selection_label.setEnabled(False) summary_layout.addWidget(self._selection_label) layout.addLayout(summary_layout) @@ -66,6 +83,7 @@ def set_active_tab(self, tab: ActiveTab): header.setSectionResizeMode(i, QHeaderView.ResizeToContents) def set_results(self, results: list[ResultEntry]): + self._ensure_header_colors() self._results = results self._tree.blockSignals(True) self._tree.clear() diff --git a/czkawka_pyside6/app/settings_panel.py b/czkawka_pyside6/app/settings_panel.py index 1ab5c2159..805f6664d 100644 --- a/czkawka_pyside6/app/settings_panel.py +++ b/czkawka_pyside6/app/settings_panel.py @@ -25,7 +25,10 @@ def _setup_ui(self): # Header header = QHBoxLayout() title = QLabel("Settings") - title.setStyleSheet("font-weight: bold; font-size: 16px; padding: 4px;") + font = title.font() + font.setBold(True) + font.setPointSize(font.pointSize() + 2) + title.setFont(font) header.addWidget(title) header.addStretch() close_btn = QPushButton("Close") diff --git a/czkawka_pyside6/app/state.py b/czkawka_pyside6/app/state.py index 2737323d0..9c73524ff 100644 --- a/czkawka_pyside6/app/state.py +++ b/czkawka_pyside6/app/state.py @@ -1,6 +1,6 @@ import json from pathlib import Path -from PySide6.QtCore import QObject, Signal +from PySide6.QtCore import QObject, Signal, QStandardPaths from .models import ( ActiveTab, AppSettings, ToolSettings, ResultEntry, ScanProgress ) @@ -32,7 +32,9 @@ def __init__(self): self.progress = ScanProgress() self.info_text = "" self.preview_image_path = "" - self._config_path = Path.home() / ".config" / "czkawka_pyside6" + # Use QStandardPaths for XDG-compliant config location + config_dir = QStandardPaths.writableLocation(QStandardPaths.AppConfigLocation) + self._config_path = Path(config_dir) if config_dir else Path.home() / ".config" / "czkawka" self._config_path.mkdir(parents=True, exist_ok=True) self.load_settings() diff --git a/czkawka_pyside6/app/tool_settings.py b/czkawka_pyside6/app/tool_settings.py index a3c5d9e44..87d4249f0 100644 --- a/czkawka_pyside6/app/tool_settings.py +++ b/czkawka_pyside6/app/tool_settings.py @@ -30,7 +30,9 @@ def _setup_ui(self): main_layout.setContentsMargins(4, 4, 4, 4) title = QLabel("Tool Settings") - title.setStyleSheet("font-weight: bold; font-size: 13px; padding: 4px;") + font = title.font() + font.setBold(True) + title.setFont(font) main_layout.addWidget(title) self._scroll = QScrollArea() diff --git a/czkawka_pyside6/main.py b/czkawka_pyside6/main.py index 6bf9a05f1..413ab5a3b 100644 --- a/czkawka_pyside6/main.py +++ b/czkawka_pyside6/main.py @@ -22,30 +22,25 @@ def main(): - # Set environment for better HiDPI support - os.environ.setdefault("QT_AUTO_SCREEN_SCALE_FACTOR", "1") - from PySide6.QtWidgets import QApplication from PySide6.QtCore import Qt - from PySide6.QtGui import QFont app = QApplication(sys.argv) app.setApplicationName("Czkawka") app.setApplicationVersion("11.0.1") app.setOrganizationName("czkawka") - app.setDesktopFileName("com.github.qarmin.czkawka") - - # Set application icon - from app.icons import app_icon - icon = app_icon() + app.setOrganizationDomain("github.com/qarmin") + app.setDesktopFileName("com.github.qarmin.czkawka-pyside6") + + # Set application icon — use XDG theme icon with fallback to project logo + from PySide6.QtGui import QIcon + icon = QIcon.fromTheme("com.github.qarmin.czkawka") + if icon.isNull(): + from app.icons import app_icon + icon = app_icon() if not icon.isNull(): app.setWindowIcon(icon) - # Set default font - font = QFont() - font.setPointSize(10) - app.setFont(font) - # Import and create main window from app.main_window import MainWindow window = MainWindow() diff --git a/data/com.github.qarmin.czkawka-pyside6.desktop b/data/com.github.qarmin.czkawka-pyside6.desktop new file mode 100644 index 000000000..6e9c8eac8 --- /dev/null +++ b/data/com.github.qarmin.czkawka-pyside6.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Categories=System;FileTools;Qt; +Exec=czkawka-pyside6 +Icon=com.github.qarmin.czkawka +StartupWMClass=czkawka-pyside6 +Terminal=false +Type=Application + +Name=Czkawka PySide6 +GenericName=File Cleaner +Comment=Multi-functional app to find duplicates, empty folders, similar files and more - Qt/PySide6 edition. +Keywords=Czkawka;duplicate;same;similar;cleaner;copy;copies;compare;files;Qt; diff --git a/data/com.github.qarmin.czkawka-pyside6.metainfo.xml b/data/com.github.qarmin.czkawka-pyside6.metainfo.xml new file mode 100644 index 000000000..e85d44343 --- /dev/null +++ b/data/com.github.qarmin.czkawka-pyside6.metainfo.xml @@ -0,0 +1,40 @@ + + + com.github.qarmin.czkawka-pyside6 + Czkawka PySide6 + Multi-functional app to find duplicates, similar images and more - Qt/PySide6 edition + CC0-1.0 + MIT + +

Czkawka PySide6 is a Qt 6 GUI frontend for the Czkawka file cleanup tool. It uses czkawka_cli as its backend and provides feature parity with the Krokiet (Slint) interface.

+

It can find:

+
    +
  • Duplicates (by hash, name, size)
  • +
  • Similar images
  • +
  • Similar audio files
  • +
  • Similar videos
  • +
  • Empty folders and files
  • +
  • Broken files
  • +
  • Temporary files
  • +
  • Big files
  • +
  • Invalid symlinks
  • +
  • Files with bad names or extensions
  • +
  • Videos that can be optimized/cropped
  • +
  • EXIF metadata to remove
  • +
+ + com.github.qarmin.czkawka-pyside6.desktop + + + + + + Rafał Mikrut + + https://github.com/qarmin/czkawka + https://github.com/qarmin/czkawka/issues + https://github.com/sponsors/qarmin + + com.github.qarmin.czkawka-cli + + From 0019f22893c6d5971b24a8838b9a87bad1116821 Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 10:44:18 +0100 Subject: [PATCH 26/49] Fix cargo fmt and show stage index in progress title - Fix writeln! formatting to pass cargo fmt --check - Show stage index in progress bar title (e.g., "[3/7] Calculating prehashes") - All czkawka_core tests pass (5/5 progress_data tests OK) - Krokiet compiles successfully - All CLI subcommands verified working Co-Authored-By: Claude Opus 4.6 (1M context) --- czkawka_cli/src/progress.rs | 6 +----- czkawka_pyside6/app/progress_widget.py | 7 +++++-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/czkawka_cli/src/progress.rs b/czkawka_cli/src/progress.rs index 6156e3daa..f0b4f77a6 100644 --- a/czkawka_cli/src/progress.rs +++ b/czkawka_cli/src/progress.rs @@ -122,11 +122,7 @@ pub(crate) fn connect_progress_json(progress_receiver: &Receiver) }; if let Ok(json) = serde_json::to_string(&progress_data) { - // Wrap in an object that includes the human-readable stage name - let _ = writeln!( - stderr, - "{{\"progress\":{json},\"stage_name\":\"{stage_name}\"}}" - ); + let _ = writeln!(stderr, "{{\"progress\":{json},\"stage_name\":\"{stage_name}\"}}"); } } } diff --git a/czkawka_pyside6/app/progress_widget.py b/czkawka_pyside6/app/progress_widget.py index 9c1de28d3..ecc309745 100644 --- a/czkawka_pyside6/app/progress_widget.py +++ b/czkawka_pyside6/app/progress_widget.py @@ -161,8 +161,11 @@ def update_progress(self, progress: ScanProgress): b_checked = progress.bytes_checked b_to_check = progress.bytes_to_check - # ── Stage label ── - self._stage_label.setText(stage_name) + # ── Stage label with stage index ── + if max_idx > 0: + self._stage_label.setText(f"[{idx + 1}/{max_idx + 1}] {stage_name}") + else: + self._stage_label.setText(stage_name) # ── Update step indicators using stage index ── if max_idx > 0: From 1edbad7f2684696b8a6f402a6ebb50872dee2b9a Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 10:45:29 +0100 Subject: [PATCH 27/49] Fix clippy use_self warnings in Commands::get_json_progress Co-Authored-By: Claude Opus 4.6 (1M context) --- czkawka_cli/src/commands.rs | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/czkawka_cli/src/commands.rs b/czkawka_cli/src/commands.rs index 9c73b5913..629368146 100644 --- a/czkawka_cli/src/commands.rs +++ b/czkawka_cli/src/commands.rs @@ -118,20 +118,20 @@ pub enum Commands { impl Commands { pub fn get_json_progress(&self) -> bool { match self { - Commands::Duplicates(a) => a.common_cli_items.json_progress, - Commands::EmptyFolders(a) => a.common_cli_items.json_progress, - Commands::BiggestFiles(a) => a.common_cli_items.json_progress, - Commands::EmptyFiles(a) => a.common_cli_items.json_progress, - Commands::Temporary(a) => a.common_cli_items.json_progress, - Commands::SimilarImages(a) => a.common_cli_items.json_progress, - Commands::SameMusic(a) => a.common_cli_items.json_progress, - Commands::InvalidSymlinks(a) => a.common_cli_items.json_progress, - Commands::BrokenFiles(a) => a.common_cli_items.json_progress, - Commands::SimilarVideos(a) => a.common_cli_items.json_progress, - Commands::BadExtensions(a) => a.common_cli_items.json_progress, - Commands::BadNames(a) => a.common_cli_items.json_progress, - Commands::VideoOptimizer(a) => a.common_cli_items.json_progress, - Commands::ExifRemover(a) => a.common_cli_items.json_progress, + Self::Duplicates(a) => a.common_cli_items.json_progress, + Self::EmptyFolders(a) => a.common_cli_items.json_progress, + Self::BiggestFiles(a) => a.common_cli_items.json_progress, + Self::EmptyFiles(a) => a.common_cli_items.json_progress, + Self::Temporary(a) => a.common_cli_items.json_progress, + Self::SimilarImages(a) => a.common_cli_items.json_progress, + Self::SameMusic(a) => a.common_cli_items.json_progress, + Self::InvalidSymlinks(a) => a.common_cli_items.json_progress, + Self::BrokenFiles(a) => a.common_cli_items.json_progress, + Self::SimilarVideos(a) => a.common_cli_items.json_progress, + Self::BadExtensions(a) => a.common_cli_items.json_progress, + Self::BadNames(a) => a.common_cli_items.json_progress, + Self::VideoOptimizer(a) => a.common_cli_items.json_progress, + Self::ExifRemover(a) => a.common_cli_items.json_progress, } } } From 5ddc5c6517db0a4618d07fb1594d214b6225d781 Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 11:03:50 +0100 Subject: [PATCH 28/49] Add resizable/sortable columns, load results, fix group header spanning Results table improvements: - Columns are resizable (drag header edges) with sensible defaults - Click column header to sort (ascending/descending toggle) - Sorting works within groups for grouped tools (duplicates, etc.) - Numeric columns (Size, Date) sort by actual values, not strings - Sort indicator arrow shown in header Group header fix: - Group headers now span across all columns (merged cell effect) - setFirstColumnSpanned called after adding item to tree Load results: - New "Load" button in action bar to load previously saved JSON results - Supports both PySide6 save format and raw czkawka_cli JSON output - Save format now preserves group structure, checked state, and group IDs Co-Authored-By: Claude Opus 4.6 (1M context) --- czkawka_pyside6/app/action_buttons.py | 9 ++ czkawka_pyside6/app/dialogs/save_dialog.py | 133 +++++++++++++++-- czkawka_pyside6/app/main_window.py | 14 ++ czkawka_pyside6/app/results_view.py | 166 ++++++++++++++++++--- 4 files changed, 290 insertions(+), 32 deletions(-) diff --git a/czkawka_pyside6/app/action_buttons.py b/czkawka_pyside6/app/action_buttons.py index ef39a3947..241878f7b 100644 --- a/czkawka_pyside6/app/action_buttons.py +++ b/czkawka_pyside6/app/action_buttons.py @@ -22,6 +22,7 @@ class ActionButtons(QWidget): delete_clicked = Signal() move_clicked = Signal() save_clicked = Signal() + load_clicked = Signal() sort_clicked = Signal() hardlink_clicked = Signal() symlink_clicked = Signal() @@ -86,6 +87,14 @@ def _setup_ui(self): self._save_btn.clicked.connect(self.save_clicked.emit) layout.addWidget(self._save_btn) + # Load button + from .icons import icon_dir + self._load_btn = QPushButton(icon_dir(18), " Load") + self._load_btn.setIconSize(ICON_SIZE) + self._load_btn.setToolTip("Load previously saved results") + self._load_btn.clicked.connect(self.load_clicked.emit) + layout.addWidget(self._load_btn) + # Sort button self._sort_btn = QPushButton(icon_sort(18), " Sort") self._sort_btn.setIconSize(ICON_SIZE) diff --git a/czkawka_pyside6/app/dialogs/save_dialog.py b/czkawka_pyside6/app/dialogs/save_dialog.py index d8fa614e6..8df154bb5 100644 --- a/czkawka_pyside6/app/dialogs/save_dialog.py +++ b/czkawka_pyside6/app/dialogs/save_dialog.py @@ -1,11 +1,13 @@ -from PySide6.QtWidgets import ( - QDialog, QVBoxLayout, QLabel, QDialogButtonBox, - QCheckBox, QFileDialog -) +import json +from pathlib import Path + +from PySide6.QtWidgets import QFileDialog + +from ..models import ResultEntry class SaveDialog: - """Save results to file (uses native file dialog).""" + """Save/load results to/from file.""" @staticmethod def save(parent, results: list, save_as_json: bool = False) -> bool: @@ -13,24 +15,30 @@ def save(parent, results: list, save_as_json: bool = False) -> bool: filter_str = "JSON Files (*.json);;All Files (*)" default_ext = ".json" else: - filter_str = "Text Files (*.txt);;All Files (*)" + filter_str = "Text Files (*.txt);;JSON Files (*.json);;All Files (*)" default_ext = ".txt" - path, _ = QFileDialog.getSaveFileName( + path, selected_filter = QFileDialog.getSaveFileName( parent, "Save Results", f"results{default_ext}", filter_str ) if not path: return False + use_json = save_as_json or path.endswith(".json") or "JSON" in selected_filter + try: - import json - if save_as_json: + if use_json: data = [] for entry in results: - if not entry.header_row: - # Filter out internal keys - values = {k: v for k, v in entry.values.items() - if not k.startswith("__")} + if entry.header_row: + data.append({ + "__header": entry.values.get("__header", ""), + "__group_id": entry.group_id, + }) + else: + values = dict(entry.values) + values["__group_id"] = entry.group_id + values["__checked"] = entry.checked data.append(values) with open(path, "w") as f: json.dump(data, f, indent=2) @@ -46,3 +54,102 @@ def save(parent, results: list, save_as_json: bool = False) -> bool: return True except OSError: return False + + @staticmethod + def load(parent) -> list[ResultEntry] | None: + """Load results from a previously saved JSON file. + + Returns list of ResultEntry on success, None on cancel/error. + """ + path, _ = QFileDialog.getOpenFileName( + parent, "Load Results", + "", + "JSON Files (*.json);;All Files (*)" + ) + if not path: + return None + + try: + with open(path) as f: + data = json.load(f) + except (json.JSONDecodeError, OSError): + return None + + if not isinstance(data, list): + # Could be czkawka_cli dict format {size: [[entries]]} + return _parse_cli_json(data) + + results = [] + for item in data: + if not isinstance(item, dict): + continue + + if "__header" in item: + results.append(ResultEntry( + values={"__header": item["__header"]}, + header_row=True, + group_id=item.get("__group_id", 0), + )) + else: + group_id = item.pop("__group_id", 0) + checked = item.pop("__checked", False) + results.append(ResultEntry( + values=item, + checked=checked, + group_id=group_id, + )) + + return results if results else None + + +def _parse_cli_json(data: dict) -> list[ResultEntry] | None: + """Parse raw czkawka_cli JSON output (dict of size buckets or flat list).""" + results = [] + group_id = 0 + + for key, groups in data.items(): + if not isinstance(groups, list): + continue + for group in groups: + if not isinstance(group, list) or len(group) == 0: + continue + + total_size = sum(e.get("size", 0) for e in group if isinstance(e, dict)) + results.append(ResultEntry( + values={"__header": f"Group {group_id + 1} ({len(group)} files)"}, + header_row=True, + group_id=group_id, + )) + + for entry in group: + if not isinstance(entry, dict): + continue + path = entry.get("path", "") + p = Path(path) + values = { + "File Name": p.name, + "Path": str(p.parent), + "Size": _format_size(entry.get("size", 0)), + "Modification Date": str(entry.get("modified_date", "")), + "Hash": entry.get("hash", ""), + "__full_path": path, + "__size_bytes": entry.get("size", 0), + "__modified_date_ts": entry.get("modified_date", 0), + } + results.append(ResultEntry(values=values, group_id=group_id)) + + group_id += 1 + + return results if results else None + + +def _format_size(size_bytes: int) -> str: + if size_bytes == 0: + return "0 B" + units = ["B", "KB", "MB", "GB", "TB"] + i = 0 + size = float(size_bytes) + while size >= 1024 and i < len(units) - 1: + size /= 1024 + i += 1 + return f"{size:.1f} {units[i]}" if i > 0 else f"{int(size)} B" diff --git a/czkawka_pyside6/app/main_window.py b/czkawka_pyside6/app/main_window.py index e6168badc..5f7ebda4f 100644 --- a/czkawka_pyside6/app/main_window.py +++ b/czkawka_pyside6/app/main_window.py @@ -139,6 +139,7 @@ def _connect_signals(self): self._action_buttons.delete_clicked.connect(self._show_delete_dialog) self._action_buttons.move_clicked.connect(self._show_move_dialog) self._action_buttons.save_clicked.connect(self._save_results) + self._action_buttons.load_clicked.connect(self._load_results) self._action_buttons.sort_clicked.connect(self._show_sort_dialog) self._action_buttons.hardlink_clicked.connect(self._create_hardlinks) self._action_buttons.symlink_clicked.connect(self._create_symlinks) @@ -316,6 +317,19 @@ def _save_results(self): if success: self._status_label.setText("Results saved successfully") + def _load_results(self): + results = SaveDialog.load(self) + if results is None: + return + tab = self._state.active_tab + self._state.set_results(tab, results) + self._results_view.set_results(results) + self._action_buttons.set_has_results( + any(not r.header_row for r in results) + ) + count = sum(1 for r in results if not r.header_row) + self._status_label.setText(f"Loaded {count} entries from file") + def _show_sort_dialog(self): columns = TAB_COLUMNS.get(self._state.active_tab, []) if not columns: diff --git a/czkawka_pyside6/app/results_view.py b/czkawka_pyside6/app/results_view.py index 6c7a37eb9..c07be7add 100644 --- a/czkawka_pyside6/app/results_view.py +++ b/czkawka_pyside6/app/results_view.py @@ -27,6 +27,8 @@ def __init__(self, parent=None): super().__init__(parent) self._active_tab = ActiveTab.DUPLICATE_FILES self._results: list[ResultEntry] = [] + self._sort_column = -1 + self._sort_order = Qt.AscendingOrder self._setup_ui() def _ensure_header_colors(self): @@ -34,16 +36,16 @@ def _ensure_header_colors(self): if self._header_colors_ready: return from PySide6.QtWidgets import QApplication + from PySide6.QtGui import QPalette palette = QApplication.instance().palette() - # Use a midpoint between window and highlight for header background - win = palette.color(palette.Window) - hi = palette.color(palette.Highlight) + win = palette.color(QPalette.ColorRole.Window) + hi = palette.color(QPalette.ColorRole.Highlight) self.HEADER_BG = QColor( (win.red() + hi.red()) // 2, (win.green() + hi.green()) // 2, (win.blue() + hi.blue()) // 2, ) - self.HEADER_FG = palette.color(palette.HighlightedText) + self.HEADER_FG = palette.color(QPalette.ColorRole.HighlightedText) self._header_colors_ready = True def _setup_ui(self): @@ -69,28 +71,53 @@ def _setup_ui(self): self._tree.customContextMenuRequested.connect(self._on_context_menu) self._tree.itemChanged.connect(self._on_item_changed) self._tree.itemDoubleClicked.connect(self._on_item_double_clicked) + + # Sortable: click header to sort + self._tree.setSortingEnabled(False) # We handle sorting manually + header = self._tree.header() + header.setSectionsClickable(True) + header.sectionClicked.connect(self._on_header_clicked) + # Resizable columns + header.setSectionResizeMode(QHeaderView.Interactive) + header.setStretchLastSection(True) + layout.addWidget(self._tree) def set_active_tab(self, tab: ActiveTab): self._active_tab = tab + self._sort_column = -1 columns = TAB_COLUMNS.get(tab, ["Selection", "File Name", "Path"]) + self._tree.setColumnCount(len(columns)) self._tree.setHeaderLabels(columns) header = self._tree.header() - for i in range(len(columns)): - if columns[i] == "Path": - header.setSectionResizeMode(i, QHeaderView.Stretch) - else: - header.setSectionResizeMode(i, QHeaderView.ResizeToContents) + # All columns interactive (resizable), last one stretches + header.setSectionResizeMode(QHeaderView.Interactive) + header.setStretchLastSection(True) + # Give Path columns more initial space + for i, col in enumerate(columns): + if col == "Path": + header.resizeSection(i, 300) + elif col == "Selection": + header.resizeSection(i, 30) + elif col in ("Size", "Hash", "Modification Date"): + header.resizeSection(i, 140) + elif col == "File Name": + header.resizeSection(i, 200) def set_results(self, results: list[ResultEntry]): self._ensure_header_colors() self._results = results + self._rebuild_tree() + self._update_summary() + + def _rebuild_tree(self): + """Rebuild tree items from self._results.""" self._tree.blockSignals(True) self._tree.clear() columns = TAB_COLUMNS.get(self._active_tab, ["Selection", "File Name", "Path"]) - for entry in results: + for entry in self._results: if entry.header_row: item = QTreeWidgetItem() header_text = entry.values.get("__header", "Group") @@ -105,14 +132,15 @@ def set_results(self, results: list[ResultEntry]): item.setFlags(item.flags() & ~Qt.ItemIsUserCheckable) item.setData(0, Qt.UserRole, entry) self._tree.addTopLevelItem(item) + # Span header across all columns (must be called after adding to tree) + item.setFirstColumnSpanned(True) else: item = QTreeWidgetItem() - # First column is checkbox (Selection) item.setFlags(item.flags() | Qt.ItemIsUserCheckable) item.setCheckState(0, Qt.Checked if entry.checked else Qt.Unchecked) for col_idx, col_name in enumerate(columns): - if col_idx == 0: # Selection column + if col_idx == 0: continue value = entry.values.get(col_name, "") item.setText(col_idx, str(value)) @@ -121,7 +149,108 @@ def set_results(self, results: list[ResultEntry]): self._tree.addTopLevelItem(item) self._tree.blockSignals(False) - self._update_summary() + + # ── Sorting ────────────────────────────────────────────── + + def _on_header_clicked(self, logical_index: int): + """Sort by column when header is clicked. Toggle ascending/descending.""" + if logical_index == 0: + return # Don't sort by checkbox column + + if self._sort_column == logical_index: + # Toggle order + self._sort_order = ( + Qt.DescendingOrder if self._sort_order == Qt.AscendingOrder + else Qt.AscendingOrder + ) + else: + self._sort_column = logical_index + self._sort_order = Qt.AscendingOrder + + columns = TAB_COLUMNS.get(self._active_tab, []) + col_name = columns[logical_index] if logical_index < len(columns) else "" + + # Update header sort indicator + self._tree.header().setSortIndicator(logical_index, self._sort_order) + self._tree.header().setSortIndicatorShown(True) + + ascending = self._sort_order == Qt.AscendingOrder + + if self._active_tab in GROUPED_TABS: + self._sort_within_groups(col_name, ascending) + else: + self._sort_flat(col_name, ascending) + + def _sort_key(self, entry: ResultEntry, col_name: str): + """Return a sort key for a result entry by column name.""" + # Use numeric values for known numeric columns + if col_name in ("Size",): + return entry.values.get("__size_bytes", 0) + if col_name in ("Modification Date",): + return entry.values.get("__modified_date_ts", 0) + if col_name in ("Similarity", "Bitrate", "Year", "Length"): + raw = entry.values.get(col_name, "") + try: + return float(str(raw).replace(",", "")) + except (ValueError, TypeError): + return 0 + # Default: string comparison (case-insensitive) + return str(entry.values.get(col_name, "")).lower() + + def _sort_flat(self, col_name: str, ascending: bool): + """Sort flat (non-grouped) results.""" + self._results.sort( + key=lambda e: self._sort_key(e, col_name), + reverse=not ascending, + ) + self._rebuild_tree() + + def _sort_within_groups(self, col_name: str, ascending: bool): + """Sort entries within each group, keeping group headers in place.""" + sorted_results = [] + current_group = [] + current_header = None + + for entry in self._results: + if entry.header_row: + if current_header is not None: + current_group.sort( + key=lambda e: self._sort_key(e, col_name), + reverse=not ascending, + ) + sorted_results.append(current_header) + sorted_results.extend(current_group) + current_header = entry + current_group = [] + else: + current_group.append(entry) + + # Last group + if current_header is not None: + current_group.sort( + key=lambda e: self._sort_key(e, col_name), + reverse=not ascending, + ) + sorted_results.append(current_header) + sorted_results.extend(current_group) + + self._results = sorted_results + self._rebuild_tree() + + def sort_by_column(self, column: int, ascending: bool = True): + """Public API for sorting (used by sort dialog).""" + self._sort_column = column + self._sort_order = Qt.AscendingOrder if ascending else Qt.DescendingOrder + columns = TAB_COLUMNS.get(self._active_tab, []) + col_name = columns[column] if column < len(columns) else "" + self._tree.header().setSortIndicator(column, self._sort_order) + self._tree.header().setSortIndicatorShown(True) + if self._active_tab in GROUPED_TABS: + self._sort_within_groups(col_name, ascending) + else: + self._sort_flat(col_name, ascending) + + # ── Item events ────────────────────────────────────────── def _on_item_changed(self, item, column): if column == 0: @@ -188,6 +317,8 @@ def _open_folder(self, entry: ResultEntry): def _set_check(self, item, checked): item.setCheckState(0, Qt.Checked if checked else Qt.Unchecked) + # ── Summary / selection ────────────────────────────────── + def _update_summary(self): total = sum(1 for r in self._results if not r.header_row) groups = sum(1 for r in self._results if r.header_row) @@ -243,13 +374,11 @@ def _invert_selection(self): item.setCheckState(0, Qt.Checked if entry.checked else Qt.Unchecked) def _select_by_group_criteria(self, mode: SelectMode): - # First unselect all self._select_all(False) if self._active_tab not in GROUPED_TABS: return - # Group entries by group_id groups: dict[int, list[tuple[int, ResultEntry]]] = {} for i in range(self._tree.topLevelItemCount()): item = self._tree.topLevelItem(i) @@ -275,15 +404,12 @@ def _select_by_group_criteria(self, mode: SelectMode): elif mode == SelectMode.SELECT_LONGEST_PATH: best_idx = max(range(len(items)), key=lambda j: len(items[j][1].values.get("__full_path", ""))) - # Select all EXCEPT the best (the one to keep) for j, (tree_idx, entry) in enumerate(items): if j != best_idx: entry.checked = True self._tree.topLevelItem(tree_idx).setCheckState(0, Qt.Checked) - def sort_by_column(self, column: int, ascending: bool = True): - order = Qt.AscendingOrder if ascending else Qt.DescendingOrder - self._tree.sortItems(column, order) + # ── Public accessors ───────────────────────────────────── def get_checked_entries(self) -> list[ResultEntry]: return [r for r in self._results if r.checked and not r.header_row] @@ -294,5 +420,7 @@ def get_all_entries(self) -> list[ResultEntry]: def clear(self): self._results = [] self._tree.clear() + self._sort_column = -1 + self._tree.header().setSortIndicatorShown(False) self._summary_label.setText("No results") self._selection_label.setText("") From 185c2e1296ba81992725f4e65d73caba34b7ee23 Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 11:08:05 +0100 Subject: [PATCH 29/49] Add MCP server crate to expose czkawka tools to AI agents New `czkawka_mcp` workspace member that implements a Model Context Protocol (MCP) server over stdio, allowing AI agents (Claude Code, Claude Desktop, etc.) to invoke all 14 czkawka analysis tools programmatically with JSON parameters and structured JSON results. All tools are read-only by default (dry_run=true, no deletions). Uses rmcp crate and links czkawka_core directly (no subprocess). Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 127 +++++++- Cargo.toml | 3 +- czkawka_mcp/Cargo.toml | 29 ++ czkawka_mcp/src/main.rs | 633 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 787 insertions(+), 5 deletions(-) create mode 100644 czkawka_mcp/Cargo.toml create mode 100644 czkawka_mcp/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index cf5684840..7e36a7f45 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -610,6 +610,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -1121,6 +1127,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link 0.2.1", ] @@ -1735,6 +1742,20 @@ dependencies = [ "winapi", ] +[[package]] +name = "czkawka_mcp" +version = "11.0.1" +dependencies = [ + "crossbeam-channel", + "czkawka_core", + "log", + "rmcp", + "schemars", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "darling" version = "0.14.4" @@ -2003,6 +2024,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "ecb" version = "0.1.2" @@ -2049,7 +2076,7 @@ dependencies = [ "failure", "proc-macro2", "quote", - "serde_derive_internals", + "serde_derive_internals 0.25.0", "syn 1.0.109", ] @@ -3883,7 +3910,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd266c66b0a0e2d4c6db8e710663fc163a2d33595ce997b6fbda407c8759d344" dependencies = [ - "base64", + "base64 0.22.1", "fast_image_resize 6.0.0", "image", "rustdct", @@ -6810,6 +6837,38 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" +[[package]] +name = "rmcp" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33a0110d28bd076f39e14bfd5b0340216dd18effeb5d02b43215944cc3e5c751" +dependencies = [ + "base64 0.21.7", + "chrono", + "futures", + "paste", + "pin-project-lite", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e2b2fd7497540489fa2db285edd43b7ed14c49157157438664278da6e42a7a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "rodio" version = "0.22.2" @@ -7031,6 +7090,30 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals 0.29.1", + "syn 2.0.117", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -7115,6 +7198,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "serde_json" version = "1.0.149" @@ -8159,10 +8253,35 @@ dependencies = [ "pin-project-lite", "signal-hook-registry", "socket2", + "tokio-macros", "tracing", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "toml" version = "0.5.11" @@ -8563,7 +8682,7 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e419dff010bb12512b0ae9e3d2f318dfbdf0167fde7eb05465134d4e8756076f" dependencies = [ - "base64", + "base64 0.22.1", "data-url", "flate2", "fontdb", @@ -8590,7 +8709,7 @@ version = "0.47.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d46cf96c5f498d36b7a9693bc6a7075c0bb9303189d61b2249b0dc3d309c07de" dependencies = [ - "base64", + "base64 0.22.1", "data-url", "flate2", "imagesize", diff --git a/Cargo.toml b/Cargo.toml index 77c811031..0c5ac772b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,8 @@ members = [ "czkawka_cli", "czkawka_gui", "krokiet", - "cedinia" + "cedinia", + "czkawka_mcp", ] exclude = [ "misc/test_read_perf", diff --git a/czkawka_mcp/Cargo.toml b/czkawka_mcp/Cargo.toml new file mode 100644 index 000000000..2788e5295 --- /dev/null +++ b/czkawka_mcp/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "czkawka_mcp" +version = "11.0.1" +authors = ["Rafał Mikrut "] +edition = "2024" +rust-version = "1.92.0" +description = "MCP (Model Context Protocol) server for Czkawka - exposes file analysis tools to AI agents" +license = "MIT" +homepage = "https://github.com/qarmin/czkawka" +repository = "https://github.com/qarmin/czkawka" + +[dependencies] +czkawka_core = { path = "../czkawka_core", version = "11.0.1" } +rmcp = { version = "0.1", features = ["server", "transport-io"] } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +schemars = "0.8" +crossbeam-channel = "0.5" +log = "0.4" + +[features] +default = [] +heif = ["czkawka_core/heif"] +libraw = ["czkawka_core/libraw"] +libavif = ["czkawka_core/libavif"] + +[lints] +workspace = true diff --git a/czkawka_mcp/src/main.rs b/czkawka_mcp/src/main.rs new file mode 100644 index 000000000..30078a1fa --- /dev/null +++ b/czkawka_mcp/src/main.rs @@ -0,0 +1,633 @@ +// rmcp #[tool] macro requires &self and owned params; these clippy warnings are false positives +#![allow(clippy::needless_pass_by_value, clippy::unused_self)] + +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; + +use crossbeam_channel::unbounded; +use czkawka_core::common::image::register_image_decoding_hooks; +use czkawka_core::common::set_number_of_threads; +use czkawka_core::common::tool_data::CommonData as _; +use czkawka_core::common::traits::{AllTraits, PrintResults}; +use czkawka_core::tools::bad_extensions::{BadExtensions, BadExtensionsParameters}; +use czkawka_core::tools::bad_names::{BadNames, BadNamesParameters, NameIssues}; +use czkawka_core::tools::big_file::{BigFile, BigFileParameters, SearchMode}; +use czkawka_core::tools::broken_files::{BrokenFiles, BrokenFilesParameters, CheckedTypes}; +use czkawka_core::tools::duplicate::{DuplicateFinder, DuplicateFinderParameters}; +use czkawka_core::tools::empty_files::EmptyFiles; +use czkawka_core::tools::empty_folder::EmptyFolder; +use czkawka_core::tools::exif_remover::{ExifRemover, ExifRemoverParameters}; +use czkawka_core::tools::invalid_symlinks::InvalidSymlinks; +use czkawka_core::tools::same_music::{SameMusic, SameMusicParameters}; +use czkawka_core::tools::similar_images::{SimilarImages, SimilarImagesParameters}; +use czkawka_core::tools::similar_videos::{SimilarVideos, SimilarVideosParameters}; +use czkawka_core::tools::temporary::Temporary; +use czkawka_core::tools::video_optimizer::{ + VideoCropParams, VideoCroppingMechanism, VideoOptimizer, VideoOptimizerParameters, VideoTranscodeParams, +}; +use rmcp::model::{CallToolResult, Content, ServerCapabilities, ServerInfo}; +use rmcp::{ServerHandler, ServiceExt, tool}; +use schemars::JsonSchema; +use serde::Deserialize; + +// ── Common parameter structs ────────────────────────────────────────── + +#[derive(Debug, Deserialize, JsonSchema)] +struct CommonParams { + #[schemars(description = "List of directories to search (required)")] + directories: Vec, + #[schemars(description = "Directories to exclude from search")] + excluded_directories: Option>, + #[schemars(description = "Wildcard patterns to exclude (e.g. '*/.git', '*.tmp')")] + excluded_items: Option>, + #[schemars(description = "Only check files with these extensions (e.g. ['jpg', 'png'])")] + allowed_extensions: Option>, + #[schemars(description = "Skip files with these extensions")] + excluded_extensions: Option>, + #[schemars(description = "If true, do not recurse into subdirectories (default: false)")] + not_recursive: Option, + #[schemars(description = "Number of threads to use (0 = all available, default: 0)")] + thread_number: Option, + #[schemars(description = "Disable the cache system (default: false)")] + disable_cache: Option, +} + +fn apply_common(tool: &mut T, p: &CommonParams, reference_dirs: Option<&[String]>) { + set_number_of_threads(p.thread_number.unwrap_or(0)); + + let mut included: Vec = p.directories.iter().map(PathBuf::from).collect(); + if let Some(refs) = reference_dirs { + let ref_paths: Vec = refs.iter().map(PathBuf::from).collect(); + included.extend(ref_paths.clone()); + tool.set_reference_paths(ref_paths); + } + + tool.set_included_paths(included); + tool.set_excluded_paths(p.excluded_directories.as_ref().map_or_else(Vec::new, |v| v.iter().map(PathBuf::from).collect())); + tool.set_excluded_items(p.excluded_items.clone().unwrap_or_default()); + tool.set_recursive_search(!p.not_recursive.unwrap_or(false)); + tool.set_allowed_extensions(p.allowed_extensions.clone().unwrap_or_default()); + tool.set_excluded_extensions(p.excluded_extensions.clone().unwrap_or_default()); + tool.set_use_cache(!p.disable_cache.unwrap_or(false)); +} + +/// Run a tool's search and serialize results to JSON via a temp file. +fn run_and_serialize(tool: &mut T) -> String { + let stop_flag = Arc::new(AtomicBool::new(false)); + let (progress_sender, _progress_receiver) = unbounded(); + + tool.search(&stop_flag, Some(&progress_sender)); + + let tmp = format!("/tmp/czkawka_mcp_{}.json", std::process::id()); + let json = if tool.save_results_to_file_as_json(&tmp, true).is_ok() { + std::fs::read_to_string(&tmp).unwrap_or_else(|_| "{}".to_string()) + } else { + "{}".to_string() + }; + let _ = std::fs::remove_file(&tmp); + + let messages = tool.get_text_messages(); + let warnings = &messages.warnings; + let errors = &messages.errors; + + if warnings.is_empty() && errors.is_empty() { + json + } else { + format!( + "{{\n \"results\": {json},\n \"warnings\": {warnings},\n \"errors\": {errors}\n}}", + warnings = serde_json::to_string(warnings).unwrap_or_default(), + errors = serde_json::to_string(errors).unwrap_or_default(), + ) + } +} + +fn ok_result(json: String) -> Result { + Ok(CallToolResult::success(vec![Content::text(json)])) +} + +fn err_result(msg: String) -> Result { + Ok(CallToolResult::error(vec![Content::text(msg)])) +} + +// ── MCP Server ──────────────────────────────────────────────────────── + +#[derive(Clone)] +struct CzkawkaServer; + +// ── Tool parameter structs ──────────────────────────────────────────── + +#[derive(Debug, Deserialize, JsonSchema)] +struct FindDuplicatesParams { + #[serde(flatten)] + common: CommonParams, + #[schemars(description = "Reference directories (duplicates are found relative to these)")] + reference_directories: Option>, + #[schemars(description = "Search method: 'hash' (default), 'name', 'size', 'size_name'")] + search_method: Option, + #[schemars(description = "Hash algorithm: 'blake3' (default), 'crc32', 'xxh3'")] + hash_type: Option, + #[schemars(description = "Minimum file size in bytes (default: 8192)")] + minimal_file_size: Option, + #[schemars(description = "Maximum file size in bytes")] + maximal_file_size: Option, + #[schemars(description = "Allow hard links to be treated as duplicates (default: false)")] + allow_hard_links: Option, + #[schemars(description = "Case-sensitive name comparison (default: false)")] + case_sensitive_name_comparison: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct FindEmptyFoldersParams { + #[serde(flatten)] + common: CommonParams, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct FindBiggestFilesParams { + #[serde(flatten)] + common: CommonParams, + #[schemars(description = "Number of files to return (default: 50)")] + number_of_files: Option, + #[schemars(description = "If true, find smallest files instead of biggest (default: false)")] + smallest_mode: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct FindEmptyFilesParams { + #[serde(flatten)] + common: CommonParams, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct FindTemporaryParams { + #[serde(flatten)] + common: CommonParams, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct FindSimilarImagesParams { + #[serde(flatten)] + common: CommonParams, + #[schemars(description = "Reference directories")] + reference_directories: Option>, + #[schemars(description = "Maximum difference between images (0-40, default: 3)")] + max_difference: Option, + #[schemars(description = "Hash algorithm: 'gradient' (default), 'mean', 'vertgradient', 'blockhash', 'doublegradient'")] + hash_alg: Option, + #[schemars(description = "Image resize filter: 'lanczos3' (default), 'nearest', 'triangle', 'gaussian', 'catmullrom'")] + image_filter: Option, + #[schemars(description = "Hash size: 8, 16 (default), 32, 64")] + hash_size: Option, + #[schemars(description = "Minimum file size in bytes")] + minimal_file_size: Option, + #[schemars(description = "Maximum file size in bytes")] + maximal_file_size: Option, + #[schemars(description = "Allow hard links (default: false)")] + allow_hard_links: Option, + #[schemars(description = "Ignore files with same size (default: false)")] + ignore_same_size: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct FindSameMusicParams { + #[serde(flatten)] + common: CommonParams, + #[schemars(description = "Reference directories")] + reference_directories: Option>, + #[schemars(description = "Search method: 'tags' (default), 'content'")] + search_method: Option, + #[schemars(description = "Music similarity level (0-10000, default: 0 = exact tags)")] + music_similarity: Option, + #[schemars(description = "Approximate tag comparison (default: false)")] + approximate_comparison: Option, + #[schemars(description = "Minimum file size in bytes")] + minimal_file_size: Option, + #[schemars(description = "Maximum file size in bytes")] + maximal_file_size: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct FindInvalidSymlinksParams { + #[serde(flatten)] + common: CommonParams, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct FindBrokenFilesParams { + #[serde(flatten)] + common: CommonParams, + #[schemars(description = "Types to check: list of 'pdf', 'audio', 'image', 'archive', 'video' (default: all)")] + checked_types: Option>, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct FindSimilarVideosParams { + #[serde(flatten)] + common: CommonParams, + #[schemars(description = "Reference directories")] + reference_directories: Option>, + #[schemars(description = "Tolerance for similarity (1-20, default: 10)")] + tolerance: Option, + #[schemars(description = "Minimum file size in bytes")] + minimal_file_size: Option, + #[schemars(description = "Maximum file size in bytes")] + maximal_file_size: Option, + #[schemars(description = "Allow hard links (default: false)")] + allow_hard_links: Option, + #[schemars(description = "Ignore files with same size (default: false)")] + ignore_same_size: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct FindBadExtensionsParams { + #[serde(flatten)] + common: CommonParams, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct FindBadNamesParams { + #[serde(flatten)] + common: CommonParams, + #[schemars(description = "Flag uppercase extensions as bad (default: true)")] + uppercase_extension: Option, + #[schemars(description = "Flag emoji in filenames (default: true)")] + emoji_used: Option, + #[schemars(description = "Flag leading/trailing spaces (default: true)")] + space_at_start_or_end: Option, + #[schemars(description = "Flag non-ASCII characters (default: true)")] + non_ascii_graphical: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct FindVideoOptimizerParams { + #[serde(flatten)] + common: CommonParams, + #[schemars(description = "Mode: 'transcode' (default), 'crop'")] + mode: Option, + #[schemars(description = "Codecs to exclude (comma-separated, default: 'hevc,h265,av1,vp9')")] + excluded_codecs: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +struct FindExifTagsParams { + #[serde(flatten)] + common: CommonParams, + #[schemars(description = "EXIF tags to ignore (comma-separated)")] + ignored_tags: Option, +} + +// ── Tool implementations ────────────────────────────────────────────── + +#[tool(tool_box)] +impl CzkawkaServer { + #[tool(description = "Find duplicate files by hash, name, or size in specified directories. Returns groups of duplicate files. Read-only analysis, no files are modified or deleted.")] + fn find_duplicates(&self, #[tool(aggr)] params: FindDuplicatesParams) -> Result { + use czkawka_core::common::model::CheckingMethod; + use czkawka_core::common::model::HashType; + + let check_method = match params.search_method.as_deref() { + Some("name") => CheckingMethod::Name, + Some("size") => CheckingMethod::Size, + Some("size_name") => CheckingMethod::SizeName, + _ => CheckingMethod::Hash, + }; + let hash_type = match params.hash_type.as_deref() { + Some("crc32") => HashType::Crc32, + Some("xxh3") => HashType::Xxh3, + _ => HashType::Blake3, + }; + + let dup_params = DuplicateFinderParameters::new( + check_method, + hash_type, + true, // use_prehash_cache + 0, // minimal_cached_file_size + 0, // minimal_prehash_cache_file_size + params.case_sensitive_name_comparison.unwrap_or(false), + ); + let mut tool = DuplicateFinder::new(dup_params); + + apply_common(&mut tool, ¶ms.common, params.reference_directories.as_deref()); + if let Some(min) = params.minimal_file_size { + tool.set_minimal_file_size(min); + } + if let Some(max) = params.maximal_file_size { + tool.set_maximal_file_size(max); + } + tool.set_hide_hard_links(!params.allow_hard_links.unwrap_or(false)); + tool.set_dry_run(true); + + ok_result(run_and_serialize(&mut tool)) + } + + #[tool(description = "Find empty folders in specified directories. Returns list of empty directory paths. Read-only analysis.")] + fn find_empty_folders(&self, #[tool(aggr)] params: FindEmptyFoldersParams) -> Result { + let mut tool = EmptyFolder::new(); + apply_common(&mut tool, ¶ms.common, None); + tool.set_dry_run(true); + ok_result(run_and_serialize(&mut tool)) + } + + #[tool(description = "Find the biggest (or smallest) files in specified directories. Returns a ranked list of files by size. Read-only analysis.")] + fn find_biggest_files(&self, #[tool(aggr)] params: FindBiggestFilesParams) -> Result { + let mode = if params.smallest_mode.unwrap_or(false) { + SearchMode::SmallestFiles + } else { + SearchMode::BiggestFiles + }; + let bf_params = BigFileParameters::new(params.number_of_files.unwrap_or(50), mode); + let mut tool = BigFile::new(bf_params); + apply_common(&mut tool, ¶ms.common, None); + tool.set_dry_run(true); + ok_result(run_and_serialize(&mut tool)) + } + + #[tool(description = "Find empty files (0 bytes) in specified directories. Read-only analysis.")] + fn find_empty_files(&self, #[tool(aggr)] params: FindEmptyFilesParams) -> Result { + let mut tool = EmptyFiles::new(); + apply_common(&mut tool, ¶ms.common, None); + tool.set_dry_run(true); + ok_result(run_and_serialize(&mut tool)) + } + + #[tool(description = "Find temporary files (e.g. .tmp, ~, .swp) in specified directories. Read-only analysis.")] + fn find_temporary_files(&self, #[tool(aggr)] params: FindTemporaryParams) -> Result { + let mut tool = Temporary::new(); + apply_common(&mut tool, ¶ms.common, None); + tool.set_dry_run(true); + ok_result(run_and_serialize(&mut tool)) + } + + #[tool(description = "Find visually similar images using perceptual hashing. Returns groups of similar images with similarity scores. Read-only analysis.")] + fn find_similar_images(&self, #[tool(aggr)] params: FindSimilarImagesParams) -> Result { + use czkawka_core::re_exported::{FilterType, HashAlg}; + + let hash_alg = match params.hash_alg.as_deref() { + Some("mean") => HashAlg::Mean, + Some("vertgradient") => HashAlg::VertGradient, + Some("blockhash") => HashAlg::Blockhash, + Some("doublegradient") => HashAlg::DoubleGradient, + _ => HashAlg::Gradient, + }; + let image_filter = match params.image_filter.as_deref() { + Some("nearest") => FilterType::Nearest, + Some("triangle") => FilterType::Triangle, + Some("gaussian") => FilterType::Gaussian, + Some("catmullrom") => FilterType::CatmullRom, + _ => FilterType::Lanczos3, + }; + let hash_size = params.hash_size.unwrap_or(16); + let max_difference = params.max_difference.unwrap_or(3); + + let si_params = SimilarImagesParameters::new( + max_difference, + hash_size, + hash_alg, + image_filter, + params.ignore_same_size.unwrap_or(false), + ); + let mut tool = SimilarImages::new(si_params); + + apply_common(&mut tool, ¶ms.common, params.reference_directories.as_deref()); + if let Some(min) = params.minimal_file_size { + tool.set_minimal_file_size(min); + } + if let Some(max) = params.maximal_file_size { + tool.set_maximal_file_size(max); + } + tool.set_hide_hard_links(!params.allow_hard_links.unwrap_or(false)); + tool.set_dry_run(true); + + ok_result(run_and_serialize(&mut tool)) + } + + #[tool(description = "Find similar or duplicate music files by tags or audio content fingerprint. Returns groups of similar tracks. Read-only analysis.")] + fn find_same_music(&self, #[tool(aggr)] params: FindSameMusicParams) -> Result { + use czkawka_core::common::model::CheckingMethod; + use czkawka_core::tools::same_music::MusicSimilarity; + + let search_method = match params.search_method.as_deref() { + Some("content") => CheckingMethod::AudioContent, + _ => CheckingMethod::AudioTags, + }; + + let music_similarity = if search_method == CheckingMethod::AudioTags { + let sim_val = params.music_similarity.unwrap_or(0); + if sim_val == 0 { + MusicSimilarity::TRACK_TITLE + | MusicSimilarity::TRACK_ARTIST + | MusicSimilarity::YEAR + | MusicSimilarity::BITRATE + | MusicSimilarity::GENRE + | MusicSimilarity::LENGTH + } else { + MusicSimilarity::TRACK_TITLE + } + } else { + // Content mode still requires non-empty MusicSimilarity (assertion in SameMusicParameters::new) + MusicSimilarity::TRACK_TITLE + }; + + let sm_params = SameMusicParameters::new( + music_similarity, + params.approximate_comparison.unwrap_or(false), + search_method, + 10.0, // minimum_segment_duration + 2.0, // maximum_difference + false, // compare_fingerprints_only_with_similar_titles + ); + let mut tool = SameMusic::new(sm_params); + + apply_common(&mut tool, ¶ms.common, params.reference_directories.as_deref()); + if let Some(min) = params.minimal_file_size { + tool.set_minimal_file_size(min); + } + if let Some(max) = params.maximal_file_size { + tool.set_maximal_file_size(max); + } + tool.set_dry_run(true); + + ok_result(run_and_serialize(&mut tool)) + } + + #[tool(description = "Find broken/invalid symbolic links in specified directories. Read-only analysis.")] + fn find_invalid_symlinks(&self, #[tool(aggr)] params: FindInvalidSymlinksParams) -> Result { + let mut tool = InvalidSymlinks::new(); + apply_common(&mut tool, ¶ms.common, None); + tool.set_dry_run(true); + ok_result(run_and_serialize(&mut tool)) + } + + #[tool(description = "Find corrupted/broken files (PDF, audio, image, archive, video). Returns list of files that cannot be properly opened. Read-only analysis.")] + fn find_broken_files(&self, #[tool(aggr)] params: FindBrokenFilesParams) -> Result { + let mut checked = CheckedTypes::NONE; + if let Some(types) = ¶ms.checked_types { + for t in types { + match t.to_lowercase().as_str() { + "pdf" => checked |= CheckedTypes::PDF, + "audio" => checked |= CheckedTypes::AUDIO, + "image" => checked |= CheckedTypes::IMAGE, + "archive" => checked |= CheckedTypes::ARCHIVE, + "video" => checked |= CheckedTypes::VIDEO, + other => return err_result(format!("Unknown checked type: '{other}'. Valid: pdf, audio, image, archive, video")), + } + } + } else { + checked = CheckedTypes::PDF | CheckedTypes::AUDIO | CheckedTypes::IMAGE | CheckedTypes::ARCHIVE | CheckedTypes::VIDEO; + } + + let bf_params = BrokenFilesParameters::new(checked); + let mut tool = BrokenFiles::new(bf_params); + apply_common(&mut tool, ¶ms.common, None); + tool.set_dry_run(true); + + ok_result(run_and_serialize(&mut tool)) + } + + #[tool(description = "Find visually similar videos using perceptual hashing. Returns groups of similar video files. Read-only analysis. Requires ffmpeg.")] + fn find_similar_videos(&self, #[tool(aggr)] params: FindSimilarVideosParams) -> Result { + use czkawka_core::re_exported::Cropdetect; + + let sv_params = SimilarVideosParameters::new( + params.tolerance.unwrap_or(10), + params.ignore_same_size.unwrap_or(false), + 15, // skip_forward_amount (default) + 10, // scan_duration (default, must be 2..=60) + Cropdetect::None, // crop_detect + false, // generate_thumbnails (no sense in MCP) + 10, + false, + 2, + ); + let mut tool = SimilarVideos::new(sv_params); + + apply_common(&mut tool, ¶ms.common, params.reference_directories.as_deref()); + if let Some(min) = params.minimal_file_size { + tool.set_minimal_file_size(min); + } + if let Some(max) = params.maximal_file_size { + tool.set_maximal_file_size(max); + } + tool.set_hide_hard_links(!params.allow_hard_links.unwrap_or(false)); + tool.set_dry_run(true); + + ok_result(run_and_serialize(&mut tool)) + } + + #[tool(description = "Find files with incorrect/mismatched extensions (e.g. a PNG file named .jpg). Read-only analysis.")] + fn find_bad_extensions(&self, #[tool(aggr)] params: FindBadExtensionsParams) -> Result { + let be_params = BadExtensionsParameters::new(); + let mut tool = BadExtensions::new(be_params); + apply_common(&mut tool, ¶ms.common, None); + ok_result(run_and_serialize(&mut tool)) + } + + #[tool(description = "Find files with problematic names (uppercase extensions, emoji, leading/trailing spaces, non-ASCII characters). Read-only analysis.")] + fn find_bad_names(&self, #[tool(aggr)] params: FindBadNamesParams) -> Result { + let name_issues = NameIssues { + uppercase_extension: params.uppercase_extension.unwrap_or(true), + emoji_used: params.emoji_used.unwrap_or(true), + space_at_start_or_end: params.space_at_start_or_end.unwrap_or(true), + non_ascii_graphical: params.non_ascii_graphical.unwrap_or(true), + restricted_charset_allowed: None, + remove_duplicated_non_alphanumeric: false, + }; + let bn_params = BadNamesParameters::new(name_issues); + let mut tool = BadNames::new(bn_params); + apply_common(&mut tool, ¶ms.common, None); + tool.set_dry_run(true); + + ok_result(run_and_serialize(&mut tool)) + } + + #[tool(description = "Analyze videos for optimization opportunities (find videos that could be transcoded to better codecs or have black bars cropped). Read-only analysis, no files are modified.")] + fn analyze_videos(&self, #[tool(aggr)] params: FindVideoOptimizerParams) -> Result { + let mode = params.mode.as_deref().unwrap_or("transcode"); + + match mode { + "transcode" => { + let excluded_codecs: Vec = params + .excluded_codecs + .as_deref() + .unwrap_or("hevc,h265,av1,vp9") + .split(',') + .map(|c| c.trim().to_string()) + .collect(); + + let vo_params = VideoOptimizerParameters::VideoTranscode(VideoTranscodeParams::new( + excluded_codecs, + false, // generate_thumbnails + 50, + false, + 2, + )); + let mut tool = VideoOptimizer::new(vo_params); + apply_common(&mut tool, ¶ms.common, None); + ok_result(run_and_serialize(&mut tool)) + } + "crop" => { + let vo_params = VideoOptimizerParameters::VideoCrop(VideoCropParams::with_custom_params( + VideoCroppingMechanism::BlackBars, + 16, // black_pixel_threshold + 3, // black_bar_percentage + 10, // max_samples + 10, // min_crop_size + false, // generate_thumbnails + 50, + false, + 2, + )); + let mut tool = VideoOptimizer::new(vo_params); + apply_common(&mut tool, ¶ms.common, None); + ok_result(run_and_serialize(&mut tool)) + } + other => err_result(format!("Unknown mode: '{other}'. Valid: transcode, crop")), + } + } + + #[tool(description = "Find image files that contain EXIF metadata tags (GPS location, camera info, etc). Read-only analysis, no tags are removed.")] + fn find_exif_tags(&self, #[tool(aggr)] params: FindExifTagsParams) -> Result { + let ignored_tags_vec: Vec = params + .ignored_tags + .map(|s| s.split(',').map(|tag| tag.trim().to_string()).collect()) + .unwrap_or_default(); + + let er_params = ExifRemoverParameters::new(ignored_tags_vec); + let mut tool = ExifRemover::new(er_params); + apply_common(&mut tool, ¶ms.common, None); + + ok_result(run_and_serialize(&mut tool)) + } +} + +// ── ServerHandler ───────────────────────────────────────────────────── + +#[tool(tool_box)] +impl ServerHandler for CzkawkaServer { + fn get_info(&self) -> ServerInfo { + ServerInfo { + instructions: Some( + "Czkawka MCP server: 14 file analysis tools for finding duplicates, similar images/videos/music, \ + broken files, empty files/folders, bad names/extensions, EXIF tags, and video optimization candidates. \ + All tools are read-only by default - no files are modified or deleted." + .to_string(), + ), + capabilities: ServerCapabilities::builder().enable_tools().build(), + ..Default::default() + } + } +} + +// ── Entry point ─────────────────────────────────────────────────────── + +#[tokio::main] +async fn main() -> Result<(), Box> { + register_image_decoding_hooks(); + + let server = CzkawkaServer; + let service = server.serve(rmcp::transport::stdio()).await?; + service.waiting().await?; + + Ok(()) +} From 9622ffebebb47c0daebf925680ac1d2b4a346fff Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 12:22:34 +0100 Subject: [PATCH 30/49] Fix cargo fmt and clippy warnings in czkawka_mcp Apply stable rustfmt formatting and suppress clippy::unnecessary_wraps on ok_result/err_result helpers (Result return type required by #[tool] macro). Co-Authored-By: Claude Opus 4.6 (1M context) --- czkawka_mcp/src/main.rs | 60 +++++++++++++++++------------------------ 1 file changed, 25 insertions(+), 35 deletions(-) diff --git a/czkawka_mcp/src/main.rs b/czkawka_mcp/src/main.rs index 30078a1fa..63a511391 100644 --- a/czkawka_mcp/src/main.rs +++ b/czkawka_mcp/src/main.rs @@ -23,9 +23,7 @@ use czkawka_core::tools::same_music::{SameMusic, SameMusicParameters}; use czkawka_core::tools::similar_images::{SimilarImages, SimilarImagesParameters}; use czkawka_core::tools::similar_videos::{SimilarVideos, SimilarVideosParameters}; use czkawka_core::tools::temporary::Temporary; -use czkawka_core::tools::video_optimizer::{ - VideoCropParams, VideoCroppingMechanism, VideoOptimizer, VideoOptimizerParameters, VideoTranscodeParams, -}; +use czkawka_core::tools::video_optimizer::{VideoCropParams, VideoCroppingMechanism, VideoOptimizer, VideoOptimizerParameters, VideoTranscodeParams}; use rmcp::model::{CallToolResult, Content, ServerCapabilities, ServerInfo}; use rmcp::{ServerHandler, ServiceExt, tool}; use schemars::JsonSchema; @@ -102,10 +100,12 @@ fn run_and_serialize(tool: &mut T) -> String { } } +#[expect(clippy::unnecessary_wraps)] fn ok_result(json: String) -> Result { Ok(CallToolResult::success(vec![Content::text(json)])) } +#[expect(clippy::unnecessary_wraps)] fn err_result(msg: String) -> Result { Ok(CallToolResult::error(vec![Content::text(msg)])) } @@ -281,7 +281,9 @@ struct FindExifTagsParams { #[tool(tool_box)] impl CzkawkaServer { - #[tool(description = "Find duplicate files by hash, name, or size in specified directories. Returns groups of duplicate files. Read-only analysis, no files are modified or deleted.")] + #[tool( + description = "Find duplicate files by hash, name, or size in specified directories. Returns groups of duplicate files. Read-only analysis, no files are modified or deleted." + )] fn find_duplicates(&self, #[tool(aggr)] params: FindDuplicatesParams) -> Result { use czkawka_core::common::model::CheckingMethod; use czkawka_core::common::model::HashType; @@ -301,9 +303,9 @@ impl CzkawkaServer { let dup_params = DuplicateFinderParameters::new( check_method, hash_type, - true, // use_prehash_cache - 0, // minimal_cached_file_size - 0, // minimal_prehash_cache_file_size + true, // use_prehash_cache + 0, // minimal_cached_file_size + 0, // minimal_prehash_cache_file_size params.case_sensitive_name_comparison.unwrap_or(false), ); let mut tool = DuplicateFinder::new(dup_params); @@ -380,13 +382,7 @@ impl CzkawkaServer { let hash_size = params.hash_size.unwrap_or(16); let max_difference = params.max_difference.unwrap_or(3); - let si_params = SimilarImagesParameters::new( - max_difference, - hash_size, - hash_alg, - image_filter, - params.ignore_same_size.unwrap_or(false), - ); + let si_params = SimilarImagesParameters::new(max_difference, hash_size, hash_alg, image_filter, params.ignore_same_size.unwrap_or(false)); let mut tool = SimilarImages::new(si_params); apply_common(&mut tool, ¶ms.common, params.reference_directories.as_deref()); @@ -415,12 +411,7 @@ impl CzkawkaServer { let music_similarity = if search_method == CheckingMethod::AudioTags { let sim_val = params.music_similarity.unwrap_or(0); if sim_val == 0 { - MusicSimilarity::TRACK_TITLE - | MusicSimilarity::TRACK_ARTIST - | MusicSimilarity::YEAR - | MusicSimilarity::BITRATE - | MusicSimilarity::GENRE - | MusicSimilarity::LENGTH + MusicSimilarity::TRACK_TITLE | MusicSimilarity::TRACK_ARTIST | MusicSimilarity::YEAR | MusicSimilarity::BITRATE | MusicSimilarity::GENRE | MusicSimilarity::LENGTH } else { MusicSimilarity::TRACK_TITLE } @@ -433,8 +424,8 @@ impl CzkawkaServer { music_similarity, params.approximate_comparison.unwrap_or(false), search_method, - 10.0, // minimum_segment_duration - 2.0, // maximum_difference + 10.0, // minimum_segment_duration + 2.0, // maximum_difference false, // compare_fingerprints_only_with_similar_titles ); let mut tool = SameMusic::new(sm_params); @@ -492,10 +483,10 @@ impl CzkawkaServer { let sv_params = SimilarVideosParameters::new( params.tolerance.unwrap_or(10), params.ignore_same_size.unwrap_or(false), - 15, // skip_forward_amount (default) - 10, // scan_duration (default, must be 2..=60) - Cropdetect::None, // crop_detect - false, // generate_thumbnails (no sense in MCP) + 15, // skip_forward_amount (default) + 10, // scan_duration (default, must be 2..=60) + Cropdetect::None, // crop_detect + false, // generate_thumbnails (no sense in MCP) 10, false, 2, @@ -541,7 +532,9 @@ impl CzkawkaServer { ok_result(run_and_serialize(&mut tool)) } - #[tool(description = "Analyze videos for optimization opportunities (find videos that could be transcoded to better codecs or have black bars cropped). Read-only analysis, no files are modified.")] + #[tool( + description = "Analyze videos for optimization opportunities (find videos that could be transcoded to better codecs or have black bars cropped). Read-only analysis, no files are modified." + )] fn analyze_videos(&self, #[tool(aggr)] params: FindVideoOptimizerParams) -> Result { let mode = params.mode.as_deref().unwrap_or("transcode"); @@ -569,10 +562,10 @@ impl CzkawkaServer { "crop" => { let vo_params = VideoOptimizerParameters::VideoCrop(VideoCropParams::with_custom_params( VideoCroppingMechanism::BlackBars, - 16, // black_pixel_threshold - 3, // black_bar_percentage - 10, // max_samples - 10, // min_crop_size + 16, // black_pixel_threshold + 3, // black_bar_percentage + 10, // max_samples + 10, // min_crop_size false, // generate_thumbnails 50, false, @@ -588,10 +581,7 @@ impl CzkawkaServer { #[tool(description = "Find image files that contain EXIF metadata tags (GPS location, camera info, etc). Read-only analysis, no tags are removed.")] fn find_exif_tags(&self, #[tool(aggr)] params: FindExifTagsParams) -> Result { - let ignored_tags_vec: Vec = params - .ignored_tags - .map(|s| s.split(',').map(|tag| tag.trim().to_string()).collect()) - .unwrap_or_default(); + let ignored_tags_vec: Vec = params.ignored_tags.map(|s| s.split(',').map(|tag| tag.trim().to_string()).collect()).unwrap_or_default(); let er_params = ExifRemoverParameters::new(ignored_tags_vec); let mut tool = ExifRemover::new(er_params); From 0c818fa603ba1b2fa3d811946394729b265d07bb Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 09:57:45 +0100 Subject: [PATCH 31/49] Add PySide6/Qt frontend and CLI --json-progress flag (#1847) Add a new PySide6/Qt 6 GUI frontend (czkawka_pyside6) with feature parity with the Krokiet (Slint) interface. Uses czkawka_cli as its backend via subprocess with JSON output for results and --json-progress for real-time progress data. PySide6 frontend features: - All 14 scanning tools with per-tool settings - Two-bar progress (current stage + overall) with entry/byte counts - Dark theme with Krokiet SVG icons - Grouped results, selection modes, file actions - Image preview, directory management, settings persistence - Auto-detection of czkawka_cli binary CLI --json-progress flag: - Outputs ProgressData as JSON lines to stderr - Added Serialize to ProgressData, CurrentStage, ToolType - Added connect_progress_json() handler - Added serde_json dependency Closes #1847 Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 1 + README.md | 66 +- czkawka_cli/Cargo.toml | 1 + czkawka_cli/README.md | 58 +- czkawka_cli/src/commands.rs | 29 + czkawka_cli/src/main.rs | 9 +- czkawka_cli/src/progress.rs | 34 + czkawka_core/src/common/model.rs | 2 +- czkawka_core/src/common/progress_data.rs | 5 +- czkawka_pyside6/README.md | 115 ++++ czkawka_pyside6/app/__init__.py | 0 czkawka_pyside6/app/action_buttons.py | 196 ++++++ czkawka_pyside6/app/backend.py | 621 ++++++++++++++++++ czkawka_pyside6/app/bottom_panel.py | 143 +++++ czkawka_pyside6/app/dialogs/__init__.py | 7 + czkawka_pyside6/app/dialogs/about_dialog.py | 78 +++ czkawka_pyside6/app/dialogs/delete_dialog.py | 51 ++ czkawka_pyside6/app/dialogs/move_dialog.py | 65 ++ czkawka_pyside6/app/dialogs/rename_dialog.py | 34 + czkawka_pyside6/app/dialogs/save_dialog.py | 48 ++ czkawka_pyside6/app/dialogs/select_dialog.py | 48 ++ czkawka_pyside6/app/dialogs/sort_dialog.py | 39 ++ czkawka_pyside6/app/icons.py | 149 +++++ czkawka_pyside6/app/left_panel.py | 149 +++++ czkawka_pyside6/app/main_window.py | 624 +++++++++++++++++++ czkawka_pyside6/app/models.py | 305 +++++++++ czkawka_pyside6/app/preview_panel.py | 112 ++++ czkawka_pyside6/app/progress_widget.py | 325 ++++++++++ czkawka_pyside6/app/results_view.py | 280 +++++++++ czkawka_pyside6/app/settings_panel.py | 261 ++++++++ czkawka_pyside6/app/state.py | 122 ++++ czkawka_pyside6/app/tool_settings.py | 504 +++++++++++++++ czkawka_pyside6/main.py | 58 ++ 33 files changed, 4495 insertions(+), 44 deletions(-) create mode 100644 czkawka_pyside6/README.md create mode 100644 czkawka_pyside6/app/__init__.py create mode 100644 czkawka_pyside6/app/action_buttons.py create mode 100644 czkawka_pyside6/app/backend.py create mode 100644 czkawka_pyside6/app/bottom_panel.py create mode 100644 czkawka_pyside6/app/dialogs/__init__.py create mode 100644 czkawka_pyside6/app/dialogs/about_dialog.py create mode 100644 czkawka_pyside6/app/dialogs/delete_dialog.py create mode 100644 czkawka_pyside6/app/dialogs/move_dialog.py create mode 100644 czkawka_pyside6/app/dialogs/rename_dialog.py create mode 100644 czkawka_pyside6/app/dialogs/save_dialog.py create mode 100644 czkawka_pyside6/app/dialogs/select_dialog.py create mode 100644 czkawka_pyside6/app/dialogs/sort_dialog.py create mode 100644 czkawka_pyside6/app/icons.py create mode 100644 czkawka_pyside6/app/left_panel.py create mode 100644 czkawka_pyside6/app/main_window.py create mode 100644 czkawka_pyside6/app/models.py create mode 100644 czkawka_pyside6/app/preview_panel.py create mode 100644 czkawka_pyside6/app/progress_widget.py create mode 100644 czkawka_pyside6/app/results_view.py create mode 100644 czkawka_pyside6/app/settings_panel.py create mode 100644 czkawka_pyside6/app/state.py create mode 100644 czkawka_pyside6/app/tool_settings.py create mode 100644 czkawka_pyside6/main.py diff --git a/Cargo.lock b/Cargo.lock index 82536866d..cf5684840 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1638,6 +1638,7 @@ dependencies = [ "humansize", "indicatif", "log", + "serde_json", ] [[package]] diff --git a/README.md b/README.md index dbbdfccbc..77cd6de72 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ - **Cache support** - second and further scans should be much faster than the first one - **Easy to run, easy to compile** - minimal runtime and build dependencies, portable version available - **CLI frontend** - for easy automation -- **GUI frontend** - uses Slint or GTK 4 frameworks +- **GUI frontends** - Slint (Krokiet), GTK 4 (Czkawka GUI), and PySide6/Qt (Czkawka PySide6) - **Core library** - allows to reuse functionality in other apps - **Android app** - experimental touch-friendly frontend for Android devices - **No spying** - Czkawka does not have access to the Internet, nor does it collect any user information or statistics @@ -59,6 +59,7 @@ Each tool uses different technologies, so you can find instructions for each of - [Krokiet GUI (Slint frontend)](krokiet/README.md)
- [Czkawka GUI (GTK frontend)](czkawka_gui/README.md)
+- [Czkawka PySide6 (Qt/PySide6 frontend)](czkawka_pyside6/README.md)
- [Czkawka CLI](czkawka_cli/README.md)
- [Czkawka Core](czkawka_core/README.md)
- [Cedinia](cedinia/README.md)
@@ -68,37 +69,37 @@ Each tool uses different technologies, so you can find instructions for each of In this comparison remember, that even if app have same features they may work different(e.g. one app may have more options to choose than other). -| | Krokiet | Czkawka | Cedinia | FSlint | DupeGuru | Bleachbit | -|:-------------------------:|:-----------:|:----------------:|:-------:|:------:|:-----------------:|:-----------:| -| Language | Rust | Rust | Rust | Python | Python/Obj-C | Python | -| Framework base language | Rust | C | Rust | C | C/C++/Obj-C/Swift | C | -| Framework | Slint | GTK 4 | Slint | PyGTK2 | Qt 5 (PyQt)/Cocoa | PyGTK3 | -| OS | Lin,Mac,Win | Lin,Mac,Win | Android | Lin | Lin,Mac,Win | Lin,Mac,Win | -| Duplicate finder | ✔ | ✔ | ✔ | ✔ | ✔ | | -| Empty files | ✔ | ✔ | ✔ | ✔ | | | -| Empty folders | ✔ | ✔ | ✔ | ✔ | | | -| Temporary files | ✔ | ✔ | ✔ | ✔ | | ✔ | -| Big files | ✔ | ✔ | ✔ | | | | -| Similar images | ✔ | ✔ | ✔ | | ✔ | | -| Similar videos | ✔ | ✔ | | | | | -| Music duplicates(tags) | ✔ | ✔ | ✔ | | ✔ | | -| Music duplicates(content) | ✔ | ✔ | ✔ | | | | -| Invalid symlinks | ✔ | ✔ | ✔ | ✔ | | | -| Broken files | ✔ | ✔ | ✔ | | | | -| Invalid names/extensions | ✔ | ✔ | ✔ | ✔ | | | -| Exif cleaner | ✔ | | ✔ | | | | -| Video optimizer | ✔ | | | | | | -| Bad Names | ✔ | | ✔ | | | | -| Names conflict | | | | ✔ | | | -| Installed packages | | | | ✔ | | | -| Bad ID | | | | ✔ | | | -| Non stripped binaries | | | | ✔ | | | -| Redundant whitespace | | | | ✔ | | | -| Overwriting files | | | | ✔ | | ✔ | -| Portable version | ✔ | ✔ | | | | ✔ | -| Multiple languages | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ | -| Cache support | ✔ | ✔ | ✔ | | ✔ | | -| In active development | Yes | Yes** | Yes*** | No | No* | Yes | +| | Krokiet | Czkawka PySide6 | Czkawka | Cedinia | FSlint | DupeGuru | Bleachbit | +|:-------------------------:|:-----------:|:----------------:|:----------------:|:-------:|:------:|:-----------------:|:-----------:| +| Language | Rust | Python | Rust | Rust | Python | Python/Obj-C | Python | +| Framework base language | Rust | C++ | C | Rust | C | C/C++/Obj-C/Swift | C | +| Framework | Slint | PySide6 (Qt 6) | GTK 4 | Slint | PyGTK2 | Qt 5 (PyQt)/Cocoa | PyGTK3 | +| OS | Lin,Mac,Win | Lin,Mac,Win | Lin,Mac,Win | Android | Lin | Lin,Mac,Win | Lin,Mac,Win | +| Duplicate finder | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ | | +| Empty files | ✔ | ✔ | ✔ | ✔ | ✔ | | | +| Empty folders | ✔ | ✔ | ✔ | ✔ | ✔ | | | +| Temporary files | ✔ | ✔ | ✔ | ✔ | ✔ | | ✔ | +| Big files | ✔ | ✔ | ✔ | ✔ | | | | +| Similar images | ✔ | ✔ | ✔ | ✔ | | ✔ | | +| Similar videos | ✔ | ✔ | ✔ | | | | | +| Music duplicates(tags) | ✔ | ✔ | ✔ | ✔ | | ✔ | | +| Music duplicates(content) | ✔ | ✔ | ✔ | ✔ | | | | +| Invalid symlinks | ✔ | ✔ | ✔ | ✔ | ✔ | | | +| Broken files | ✔ | ✔ | ✔ | ✔ | | | | +| Invalid names/extensions | ✔ | ✔ | ✔ | ✔ | ✔ | | | +| Exif cleaner | ✔ | ✔ | | ✔ | | | | +| Video optimizer | ✔ | ✔ | | | | | | +| Bad Names | ✔ | ✔ | | ✔ | | | | +| Names conflict | | | | | ✔ | | | +| Installed packages | | | | | ✔ | | | +| Bad ID | | | | | ✔ | | | +| Non stripped binaries | | | | | ✔ | | | +| Redundant whitespace | | | | | ✔ | | | +| Overwriting files | | | | | ✔ | | ✔ | +| Portable version | ✔ | ✔ | ✔ | | | | ✔ | +| Multiple languages | ✔ | | ✔ | ✔ | ✔ | ✔ | ✔ | +| Cache support | ✔ | ✔ | ✔ | ✔ | | ✔ | | +| In active development | Yes | Yes | Yes** | Yes*** | No | No* | Yes |

* Few small commits added recently and last version released in 2023

** Czkawka GTK is in maintenance mode receiving only bugfixes

@@ -131,6 +132,7 @@ console apps, then take a look at these: Czkawka exposes its common functionality through a crate called **`czkawka_core`**, which can be reused by other projects. It is written in Rust and is used by all Czkawka frontends (`czkawka_gui`, `czkawka_cli`, `krokiet`, `cedinia`). +The `czkawka_pyside6` frontend uses `czkawka_cli` as its backend, communicating via JSON output and `--json-progress` for real-time progress data. It is also used by external projects, such as: diff --git a/czkawka_cli/Cargo.toml b/czkawka_cli/Cargo.toml index 2b24c2c56..063f55117 100644 --- a/czkawka_cli/Cargo.toml +++ b/czkawka_cli/Cargo.toml @@ -18,6 +18,7 @@ indicatif = "0.18" crossbeam-channel = { version = "0.5", features = [] } ctrlc = { version = "3.4", features = ["termination"] } humansize = "2.1" +serde_json = "1" [features] default = [] diff --git a/czkawka_cli/README.md b/czkawka_cli/README.md index 3c88431f8..1c9d3c29a 100644 --- a/czkawka_cli/README.md +++ b/czkawka_cli/README.md @@ -44,13 +44,57 @@ czkawka_cli dup --help Example usage: ```shell -czkawka dup -d /home/rafal -e /home/rafal/Obrazy -m 25 -x 7z rar IMAGE -s hash -f results.txt -D aeo -czkawka empty-folders -d /home/rafal/rr /home/gateway -f results.txt -czkawka big -d /home/rafal/ /home/piszczal -e /home/rafal/Roman -n 25 -x VIDEO -f results.txt -czkawka empty-files -d /home/rafal /home/szczekacz -e /home/rafal/Pulpit -R -f results.txt -czkawka temp -d /home/rafal/ -E */.git */tmp* *Pulpit -f results.txt -D -czkawka music -d /home/rafal -e /home/rafal/Pulpit -z "artist,year, ARTISTALBUM, ALBUM___tiTlE" -f results.txt -czkawka symlinks -d /home/kicikici/ /home/szczek -e /home/kicikici/jestempsem -x jpg -f results.txt +czkawka_cli dup -d /home/rafal -e /home/rafal/Obrazy -m 25 -x 7z rar IMAGE -s hash -f results.txt -D aeo +czkawka_cli empty-folders -d /home/rafal/rr /home/gateway -f results.txt +czkawka_cli big -d /home/rafal/ /home/piszczal -e /home/rafal/Roman -n 25 -x VIDEO -f results.txt +czkawka_cli empty-files -d /home/rafal /home/szczekacz -e /home/rafal/Pulpit -R -f results.txt +czkawka_cli temp -d /home/rafal/ -E */.git */tmp* *Pulpit -f results.txt -D +czkawka_cli music -d /home/rafal -e /home/rafal/Pulpit -z "artist,year, ARTISTALBUM, ALBUM___tiTlE" -f results.txt +czkawka_cli symlinks -d /home/kicikici/ /home/szczek -e /home/kicikici/jestempsem -x jpg -f results.txt +``` + +## JSON output + +Results can be saved as compact or pretty-printed JSON: + +```shell +czkawka_cli dup -d /home/user --compact-file-to-save results.json +czkawka_cli dup -d /home/user --pretty-json-file-to-save results.json +``` + +## Machine-readable progress (`--json-progress`) + +The `--json-progress` flag outputs real-time progress data as JSON lines to stderr. This is used by GUI frontends (such as the PySide6 frontend) to display accurate progress bars. + +Each line is a JSON object with the following structure: +```json +{ + "progress": { + "sstage": "DuplicatePreHashing", + "checking_method": "Hash", + "current_stage_idx": 2, + "max_stage_idx": 6, + "entries_checked": 50000, + "entries_to_check": 94500, + "bytes_checked": 204800000, + "bytes_to_check": 387072000, + "tool_type": "Duplicate" + }, + "stage_name": "Calculating prehashes" +} +``` + +Fields: +- `sstage` - Internal stage identifier (e.g., `CollectingFiles`, `DuplicateFullHashing`, `SimilarImagesComparingHashes`) +- `current_stage_idx` / `max_stage_idx` - Current stage number and total stages (e.g., 2/6 for duplicates) +- `entries_checked` / `entries_to_check` - Files processed and total to process +- `bytes_checked` / `bytes_to_check` - Bytes processed and total (for hashing stages) +- `stage_name` - Human-readable stage description + +Example usage: +```shell +# Capture progress on stderr while saving results to JSON +czkawka_cli dup -d /home/user --json-progress -N --compact-file-to-save results.json 2>progress.jsonl ``` ## LICENSE diff --git a/czkawka_cli/src/commands.rs b/czkawka_cli/src/commands.rs index 8980629c1..9c73b5913 100644 --- a/czkawka_cli/src/commands.rs +++ b/czkawka_cli/src/commands.rs @@ -115,6 +115,27 @@ pub enum Commands { ExifRemover(ExifRemoverArgs), } +impl Commands { + pub fn get_json_progress(&self) -> bool { + match self { + Commands::Duplicates(a) => a.common_cli_items.json_progress, + Commands::EmptyFolders(a) => a.common_cli_items.json_progress, + Commands::BiggestFiles(a) => a.common_cli_items.json_progress, + Commands::EmptyFiles(a) => a.common_cli_items.json_progress, + Commands::Temporary(a) => a.common_cli_items.json_progress, + Commands::SimilarImages(a) => a.common_cli_items.json_progress, + Commands::SameMusic(a) => a.common_cli_items.json_progress, + Commands::InvalidSymlinks(a) => a.common_cli_items.json_progress, + Commands::BrokenFiles(a) => a.common_cli_items.json_progress, + Commands::SimilarVideos(a) => a.common_cli_items.json_progress, + Commands::BadExtensions(a) => a.common_cli_items.json_progress, + Commands::BadNames(a) => a.common_cli_items.json_progress, + Commands::VideoOptimizer(a) => a.common_cli_items.json_progress, + Commands::ExifRemover(a) => a.common_cli_items.json_progress, + } + } +} + #[derive(Debug, clap::Args)] pub struct DuplicatesArgs { #[clap(flatten)] @@ -848,6 +869,14 @@ pub struct CommonCliItems { long_help = "Disables the cache system. This will make scanning slower but ensures fresh results without cached data." )] pub disable_cache: bool, + #[clap( + long, + help = "Output progress as JSON lines to stderr", + long_help = "Outputs progress data as JSON lines to stderr for machine consumption. \ + Each line is a JSON object with fields: sstage, current_stage_idx, max_stage_idx, \ + entries_checked, entries_to_check, bytes_checked, bytes_to_check, tool_type." + )] + pub json_progress: bool, } #[derive(Debug, clap::Args, Clone, Copy)] diff --git a/czkawka_cli/src/main.rs b/czkawka_cli/src/main.rs index 871183662..b8b9710e4 100644 --- a/czkawka_cli/src/main.rs +++ b/czkawka_cli/src/main.rs @@ -36,7 +36,7 @@ use crate::commands::{ Args, BadExtensionsArgs, BadNamesArgs, BiggestFilesArgs, BrokenFilesArgs, CommonCliItems, DMethod, DuplicatesArgs, EmptyFilesArgs, EmptyFoldersArgs, ExifRemoverArgs, InvalidSymlinksArgs, SDMethod, SameMusicArgs, SimilarImagesArgs, SimilarVideosArgs, TemporaryArgs, VideoOptimizerArgs, }; -use crate::progress::connect_progress; +use crate::progress::{connect_progress, connect_progress_json}; mod commands; mod progress; @@ -65,6 +65,7 @@ fn main() { debug!("Running command - {command:?}"); } + let json_progress = command.get_json_progress(); let (progress_sender, progress_receiver): (Sender, Receiver) = unbounded(); let stop_flag = Arc::new(AtomicBool::new(false)); let store_flag_cloned = stop_flag.clone(); @@ -98,7 +99,11 @@ fn main() { }) .expect("Error setting Ctrl-C handler"); - connect_progress(&progress_receiver); + if json_progress { + connect_progress_json(&progress_receiver); + } else { + connect_progress(&progress_receiver); + } let cli_output = calculate_thread.join().expect("Failed to join calculation thread"); diff --git a/czkawka_cli/src/progress.rs b/czkawka_cli/src/progress.rs index 72952de01..6156e3daa 100644 --- a/czkawka_cli/src/progress.rs +++ b/czkawka_cli/src/progress.rs @@ -1,3 +1,4 @@ +use std::io::Write; use std::time::Duration; use crossbeam_channel::Receiver; @@ -97,6 +98,39 @@ pub(crate) fn get_progress_message(progress_data: &ProgressData) -> String { .to_string() } +/// Output progress data as JSON lines to stderr for machine consumption. +/// Each line is a complete JSON object that can be parsed independently. +pub(crate) fn connect_progress_json(progress_receiver: &Receiver) { + let stderr = std::io::stderr(); + let mut stderr = stderr.lock(); + while let Ok(progress_data) = progress_receiver.recv() { + // Build a JSON object with human-readable stage name included + let stage_name = if progress_data.sstage == CurrentStage::CollectingFiles { + if progress_data.tool_type == ToolType::EmptyFolders { + "Collecting folders".to_string() + } else { + "Collecting files".to_string() + } + } else if progress_data.sstage.check_if_loading_saving_cache() { + if progress_data.sstage.check_if_loading_cache() { + "Loading cache".to_string() + } else { + "Saving cache".to_string() + } + } else { + get_progress_message(&progress_data) + }; + + if let Ok(json) = serde_json::to_string(&progress_data) { + // Wrap in an object that includes the human-readable stage name + let _ = writeln!( + stderr, + "{{\"progress\":{json},\"stage_name\":\"{stage_name}\"}}" + ); + } + } +} + pub(crate) fn get_progress_bar_for_collect_files() -> ProgressBar { let pb = ProgressBar::new_spinner(); pb.enable_steady_tick(Duration::from_millis(120)); diff --git a/czkawka_core/src/common/model.rs b/czkawka_core/src/common/model.rs index 41919a49d..622a70727 100644 --- a/czkawka_core/src/common/model.rs +++ b/czkawka_core/src/common/model.rs @@ -6,7 +6,7 @@ use xxhash_rust::xxh3::Xxh3; use crate::common::traits::ResultEntry; use crate::tools::duplicate::MyHasher; -#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)] +#[derive(Debug, PartialEq, Eq, Clone, Copy, Default, Serialize, Deserialize)] pub enum ToolType { Duplicate, EmptyFolders, diff --git a/czkawka_core/src/common/progress_data.rs b/czkawka_core/src/common/progress_data.rs index d23abe7e8..d98372c63 100644 --- a/czkawka_core/src/common/progress_data.rs +++ b/czkawka_core/src/common/progress_data.rs @@ -1,4 +1,5 @@ use log::error; +use serde::Serialize; use crate::common::model::{CheckingMethod, ToolType}; // Empty files @@ -66,7 +67,7 @@ use crate::common::model::{CheckingMethod, ToolType}; // Deleting files // Renaming files -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Serialize)] pub struct ProgressData { pub sstage: CurrentStage, pub checking_method: CheckingMethod, @@ -95,7 +96,7 @@ impl ProgressData { } } -#[derive(Debug, Clone, Copy, Eq, PartialEq)] +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize)] pub enum CurrentStage { DeletingFiles, RenamingFiles, diff --git a/czkawka_pyside6/README.md b/czkawka_pyside6/README.md new file mode 100644 index 000000000..d4487aaed --- /dev/null +++ b/czkawka_pyside6/README.md @@ -0,0 +1,115 @@ +# Czkawka PySide6 + +A Qt 6 / PySide6 GUI frontend for Czkawka, with feature parity with the Krokiet (Slint) interface. + +This frontend uses `czkawka_cli` as its backend, communicating via JSON output for results and `--json-progress` for real-time progress data. + +## Features + +All 14 scanning tools are supported: + +- Duplicate Files (by hash, size, name, or size+name) +- Empty Folders / Empty Files +- Big Files (biggest or smallest) +- Temporary Files +- Similar Images (with configurable hash algorithm, size, similarity) +- Similar Videos (with crop detection, skip forward, duration) +- Similar Music (by tags or audio fingerprint) +- Invalid Symlinks +- Broken Files (audio, PDF, archive, image, video) +- Bad Extensions +- Bad Names (uppercase, emoji, spaces, non-ASCII, restricted charset) +- EXIF Remover +- Video Optimizer (crop black bars / transcode) + +### GUI features + +- **Dark theme** with full Qt stylesheet +- **Two-bar progress display** - current stage + overall progress, matching the Slint frontend + - Real-time entry and byte counts (e.g., "Calculating hashes: 15,000/25,000 (500 MB/1 GB)") + - File collection estimation using cached counts from previous scans + - Elapsed time display + - Stage step indicators +- **Project icons** - uses the same SVG icons as the Krokiet interface +- **Image preview panel** for duplicate/similar image results +- **Grouped results view** with tree display for duplicate/similar file groups +- **Selection modes** - Select All/None, Invert, Biggest/Smallest, Newest/Oldest, Shortest/Longest Path +- **File actions** - Delete (with trash support), Move/Copy, Hardlink, Symlink, Rename, Clean EXIF +- **Per-tool settings** - all tool-specific options (hash type, similarity thresholds, etc.) +- **Global settings** - directories, filters, cache, thread count +- **Directory management** - included/excluded paths with add/remove buttons +- **Context menus** - right-click to open file or containing folder +- **Settings persistence** via JSON config files +- **Auto-detection** of `czkawka_cli` binary (checks PATH, cargo target directory, cargo metadata) + +## Requirements + +- Python 3.10+ +- PySide6 >= 6.6.0 +- `czkawka_cli` binary (installed or in PATH) +- Optional: `send2trash` (for trash support on Linux) +- Optional: `Pillow` (for EXIF cleaning fallback) + +## Installation + +### 1. Install czkawka_cli + +```shell +# From the project root +cargo install --path czkawka_cli + +# Or build it +cargo build --release -p czkawka_cli +``` + +### 2. Install Python dependencies + +```shell +cd czkawka_pyside6 +pip install -r requirements.txt +``` + +### 3. Run + +```shell +python main.py +``` + +The application will auto-detect the `czkawka_cli` binary. If it can't find it, configure the path in Settings. + +## Architecture + +``` +czkawka_pyside6/ +├── main.py # Entry point +├── requirements.txt # Python dependencies +├── app/ +│ ├── main_window.py # Main window with all panels +│ ├── left_panel.py # Tool selection sidebar (14 tools) +│ ├── results_view.py # Results tree with grouping, selection, sorting +│ ├── action_buttons.py # Scan/Stop/Delete/Move/Save/Sort buttons with icons +│ ├── tool_settings.py # Per-tool settings (9 tool panels) +│ ├── settings_panel.py # Global settings (General/Directories/Filters/Preview) +│ ├── progress_widget.py # Two-bar progress: current stage + overall +│ ├── preview_panel.py # Image preview panel +│ ├── bottom_panel.py # Directory management + error display +│ ├── backend.py # CLI subprocess interface with JSON progress parsing +│ ├── models.py # Data models, enums, column definitions +│ ├── state.py # Application state with Qt signals +│ ├── icons.py # SVG icon resources from Krokiet icon set +│ └── dialogs/ # Delete, Move, Select, Sort, Save, Rename, About +``` + +### How it works + +1. **Scanning**: The app spawns `czkawka_cli` as a subprocess with `--compact-file-to-save` for JSON results and `--json-progress` for real-time progress data on stderr. + +2. **Progress**: JSON lines on stderr provide `ProgressData` with stage index, entry counts, byte counts — the same data the Slint frontend gets via crossbeam channels. The progress widget displays two bars (current stage and overall) with percentage, counts, and elapsed time. + +3. **Results**: JSON results are parsed and displayed in a tree view with group headers for duplicate/similar file tools. + +4. **File operations**: Delete, move, hardlink, symlink, and rename operations are performed directly in Python. EXIF cleaning and extension/name fixing use `czkawka_cli` subcommands. + +## LICENSE + +MIT diff --git a/czkawka_pyside6/app/__init__.py b/czkawka_pyside6/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/czkawka_pyside6/app/action_buttons.py b/czkawka_pyside6/app/action_buttons.py new file mode 100644 index 000000000..36e928a26 --- /dev/null +++ b/czkawka_pyside6/app/action_buttons.py @@ -0,0 +1,196 @@ +from PySide6.QtWidgets import ( + QWidget, QHBoxLayout, QPushButton, QSizePolicy +) +from PySide6.QtCore import Signal, QSize + +from .models import ActiveTab, GROUPED_TABS +from .icons import ( + icon_search, icon_stop, icon_select, icon_delete, icon_move, + icon_save, icon_sort, icon_hardlink, icon_symlink, icon_rename, + icon_clean, icon_optimize, +) + +ICON_SIZE = QSize(18, 18) + + +class ActionButtons(QWidget): + """Action buttons bar: Scan, Stop, Select, Delete, Move, Save, Sort, etc.""" + + scan_clicked = Signal() + stop_clicked = Signal() + select_clicked = Signal() + delete_clicked = Signal() + move_clicked = Signal() + save_clicked = Signal() + sort_clicked = Signal() + hardlink_clicked = Signal() + symlink_clicked = Signal() + rename_clicked = Signal() + clean_exif_clicked = Signal() + optimize_video_clicked = Signal() + + def __init__(self, parent=None): + super().__init__(parent) + self._active_tab = ActiveTab.DUPLICATE_FILES + self._scanning = False + self._has_results = False + self._has_selection = False + self._setup_ui() + + def _setup_ui(self): + layout = QHBoxLayout(self) + layout.setContentsMargins(4, 4, 4, 4) + layout.setSpacing(4) + + # Scan button + self._scan_btn = QPushButton(icon_search(18), " Scan") + self._scan_btn.setIconSize(ICON_SIZE) + self._scan_btn.setMinimumWidth(90) + self._scan_btn.setStyleSheet( + "QPushButton { background-color: #2d5a27; color: white; font-weight: bold; padding: 6px 16px; }" + "QPushButton:hover { background-color: #3a7a34; }" + "QPushButton:disabled { background-color: #444; color: #888; }" + ) + self._scan_btn.clicked.connect(self.scan_clicked.emit) + layout.addWidget(self._scan_btn) + + # Stop button + self._stop_btn = QPushButton(icon_stop(18), " Stop") + self._stop_btn.setIconSize(ICON_SIZE) + self._stop_btn.setMinimumWidth(80) + self._stop_btn.setStyleSheet( + "QPushButton { background-color: #8a2222; color: white; font-weight: bold; padding: 6px 16px; }" + "QPushButton:hover { background-color: #aa3333; }" + ) + self._stop_btn.clicked.connect(self.stop_clicked.emit) + self._stop_btn.setVisible(False) + layout.addWidget(self._stop_btn) + + # Spacer + spacer = QWidget() + spacer.setFixedWidth(10) + layout.addWidget(spacer) + + # Select button + self._select_btn = QPushButton(icon_select(18), " Select") + self._select_btn.setIconSize(ICON_SIZE) + self._select_btn.clicked.connect(self.select_clicked.emit) + layout.addWidget(self._select_btn) + + # Delete button + self._delete_btn = QPushButton(icon_delete(18), " Delete") + self._delete_btn.setIconSize(ICON_SIZE) + self._delete_btn.setStyleSheet( + "QPushButton { background-color: #8a2222; color: white; padding: 6px 12px; }" + "QPushButton:hover { background-color: #aa3333; }" + "QPushButton:disabled { background-color: #444; color: #888; }" + ) + self._delete_btn.clicked.connect(self.delete_clicked.emit) + layout.addWidget(self._delete_btn) + + # Move button + self._move_btn = QPushButton(icon_move(18), " Move") + self._move_btn.setIconSize(ICON_SIZE) + self._move_btn.clicked.connect(self.move_clicked.emit) + layout.addWidget(self._move_btn) + + # Save button + self._save_btn = QPushButton(icon_save(18), " Save") + self._save_btn.setIconSize(ICON_SIZE) + self._save_btn.clicked.connect(self.save_clicked.emit) + layout.addWidget(self._save_btn) + + # Sort button + self._sort_btn = QPushButton(icon_sort(18), " Sort") + self._sort_btn.setIconSize(ICON_SIZE) + self._sort_btn.clicked.connect(self.sort_clicked.emit) + layout.addWidget(self._sort_btn) + + # Hardlink button (grouped tools only) + self._hardlink_btn = QPushButton(icon_hardlink(18), " Hardlink") + self._hardlink_btn.setIconSize(ICON_SIZE) + self._hardlink_btn.clicked.connect(self.hardlink_clicked.emit) + layout.addWidget(self._hardlink_btn) + + # Symlink button (grouped tools only) + self._symlink_btn = QPushButton(icon_symlink(18), " Symlink") + self._symlink_btn.setIconSize(ICON_SIZE) + self._symlink_btn.clicked.connect(self.symlink_clicked.emit) + layout.addWidget(self._symlink_btn) + + # Rename button (bad extensions / bad names) + self._rename_btn = QPushButton(icon_rename(18), " Rename") + self._rename_btn.setIconSize(ICON_SIZE) + self._rename_btn.clicked.connect(self.rename_clicked.emit) + layout.addWidget(self._rename_btn) + + # Clean EXIF button + self._clean_exif_btn = QPushButton(icon_clean(18), " Clean EXIF") + self._clean_exif_btn.setIconSize(ICON_SIZE) + self._clean_exif_btn.clicked.connect(self.clean_exif_clicked.emit) + layout.addWidget(self._clean_exif_btn) + + # Optimize Video button + self._optimize_btn = QPushButton(icon_optimize(18), " Optimize") + self._optimize_btn.setIconSize(ICON_SIZE) + self._optimize_btn.clicked.connect(self.optimize_video_clicked.emit) + layout.addWidget(self._optimize_btn) + + # Stretch at the end + layout.addStretch() + + self._update_visibility() + + def set_active_tab(self, tab: ActiveTab): + self._active_tab = tab + self._update_visibility() + + def set_scanning(self, scanning: bool): + self._scanning = scanning + self._scan_btn.setVisible(not scanning) + self._stop_btn.setVisible(scanning) + self._update_enabled() + + def set_has_results(self, has_results: bool): + self._has_results = has_results + self._update_enabled() + + def set_has_selection(self, has_selection: bool): + self._has_selection = has_selection + self._update_enabled() + + def _update_visibility(self): + tab = self._active_tab + is_grouped = tab in GROUPED_TABS + + # Always visible + self._select_btn.setVisible(True) + self._delete_btn.setVisible(True) + self._move_btn.setVisible(True) + self._save_btn.setVisible(True) + self._sort_btn.setVisible(True) + + # Conditional buttons + self._hardlink_btn.setVisible(is_grouped) + self._symlink_btn.setVisible(is_grouped) + self._rename_btn.setVisible(tab in (ActiveTab.BAD_EXTENSIONS, ActiveTab.BAD_NAMES)) + self._clean_exif_btn.setVisible(tab == ActiveTab.EXIF_REMOVER) + self._optimize_btn.setVisible(tab == ActiveTab.VIDEO_OPTIMIZER) + + self._update_enabled() + + def _update_enabled(self): + has_data = self._has_results and not self._scanning + has_sel = self._has_selection and not self._scanning + + self._scan_btn.setEnabled(not self._scanning) + self._select_btn.setEnabled(has_data) + self._delete_btn.setEnabled(has_sel) + self._move_btn.setEnabled(has_sel) + self._save_btn.setEnabled(has_data) + self._sort_btn.setEnabled(has_data) + self._hardlink_btn.setEnabled(has_sel) + self._symlink_btn.setEnabled(has_sel) + self._rename_btn.setEnabled(has_sel) + self._clean_exif_btn.setEnabled(has_sel) + self._optimize_btn.setEnabled(has_sel) diff --git a/czkawka_pyside6/app/backend.py b/czkawka_pyside6/app/backend.py new file mode 100644 index 000000000..3d922c176 --- /dev/null +++ b/czkawka_pyside6/app/backend.py @@ -0,0 +1,621 @@ +import json +import os +import subprocess +import tempfile +from pathlib import Path +from typing import Optional + +from PySide6.QtCore import QObject, QThread, Signal + +from .models import ( + ActiveTab, AppSettings, ToolSettings, ResultEntry, ScanProgress, + TAB_TO_CLI_COMMAND, GROUPED_TABS, CheckingMethod, MusicSearchMethod, +) + + +class ScanWorker(QObject): + """Worker that runs czkawka_cli in a background thread.""" + finished = Signal(object, list) # (ActiveTab, results) + progress = Signal(object) # ScanProgress + error = Signal(str) + + def __init__(self, tab: ActiveTab, app_settings: AppSettings, + tool_settings: ToolSettings): + super().__init__() + self.tab = tab + self.app_settings = app_settings + self.tool_settings = tool_settings + self._process: Optional[subprocess.Popen] = None + self._cancelled = False + + def run(self): + try: + cmd = self._build_command() + if not cmd: + self.error.emit(f"No CLI command for tab {self.tab}") + return + + self.progress.emit(ScanProgress( + step_name="Collecting files...", current=0, total=0 + )) + + with tempfile.NamedTemporaryFile(suffix=".json", delete=False, mode="w") as f: + json_output_path = f.name + + # Add JSON output flag (use long form to avoid conflict with -C in broken files) + cmd.extend(["--compact-file-to-save", json_output_path]) + # Enable JSON progress on stderr, suppress text output + cmd.extend(["--json-progress", "-N", "-M"]) + + self._process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + # Read stderr line-by-line for JSON progress data + self._monitor_process_json(json_output_path) + + if self._cancelled: + self._cleanup(json_output_path) + return + + # Check for CLI errors + returncode = self._process.returncode + if returncode is not None and returncode != 0 and returncode != 11: + error_msg = f"czkawka_cli exited with code {returncode}" + self.error.emit(error_msg) + self._cleanup(json_output_path) + return + + # Parse results + self.progress.emit(ScanProgress( + step_name="Loading results...", current=0, total=0 + )) + results = self._parse_results(json_output_path) + self.finished.emit(self.tab, results) + self._cleanup(json_output_path) + + except FileNotFoundError: + self.error.emit( + f"czkawka_cli not found at '{self.app_settings.czkawka_cli_path}'. " + "Please install czkawka_cli or set the correct path in settings." + ) + except Exception as e: + self.error.emit(str(e)) + + def cancel(self): + self._cancelled = True + if self._process and self._process.poll() is None: + self._process.terminate() + try: + self._process.wait(timeout=3) + except subprocess.TimeoutExpired: + self._process.kill() + self._process.wait() + + def _monitor_process_json(self, json_path: str): + """Read JSON progress lines from stderr in real-time.""" + import time + + while self._process.poll() is None: + if self._cancelled: + return + + line = self._process.stderr.readline() + if not line: + time.sleep(0.05) + continue + + line = line.strip() + if not line: + continue + + try: + data = json.loads(line) + progress = data.get("progress", {}) + stage_name = data.get("stage_name", "Processing...") + + entries_checked = progress.get("entries_checked", 0) + entries_to_check = progress.get("entries_to_check", 0) + bytes_checked = progress.get("bytes_checked", 0) + bytes_to_check = progress.get("bytes_to_check", 0) + current_stage_idx = progress.get("current_stage_idx", 0) + max_stage_idx = progress.get("max_stage_idx", 0) + + self.progress.emit(ScanProgress( + step_name=stage_name, + current=0, + total=0, + current_size=bytes_checked, + stage_name=stage_name, + current_stage_idx=current_stage_idx, + max_stage_idx=max_stage_idx, + entries_checked=entries_checked, + entries_to_check=entries_to_check, + bytes_checked=bytes_checked, + bytes_to_check=bytes_to_check, + )) + except (json.JSONDecodeError, KeyError, TypeError): + continue + + # Drain remaining stderr + remaining = self._process.stderr.read() + if remaining: + for line in remaining.strip().split("\n"): + pass # Final lines already processed + + def _cleanup(self, path: str): + try: + os.unlink(path) + except OSError: + pass + + def _build_command(self) -> list[str]: + s = self.app_settings + ts = self.tool_settings + cli = s.czkawka_cli_path + subcmd = TAB_TO_CLI_COMMAND.get(self.tab) + if not subcmd: + return [] + + # Handle video-optimizer subcommands + if self.tab == ActiveTab.VIDEO_OPTIMIZER: + if ts.video_opt_mode == "crop": + cmd = [cli, "video-optimizer", "crop"] + else: + cmd = [cli, "video-optimizer", "transcode"] + else: + cmd = [cli, subcmd] + + # Common args + if s.included_paths: + cmd.extend(["-d", ",".join(s.included_paths)]) + if s.excluded_paths: + cmd.extend(["-e", ",".join(s.excluded_paths)]) + if s.excluded_items: + cmd.extend(["-E", s.excluded_items]) + if s.allowed_extensions: + cmd.extend(["-x", s.allowed_extensions]) + if s.excluded_extensions: + cmd.extend(["-P", s.excluded_extensions]) + if not s.recursive_search: + cmd.append("-R") + if not s.use_cache: + cmd.append("-H") + if s.thread_number > 0: + cmd.extend(["-T", str(s.thread_number)]) + + # Tool-specific args + if self.tab == ActiveTab.DUPLICATE_FILES: + cmd.extend(["-s", ts.dup_check_method.value]) + cmd.extend(["-t", ts.dup_hash_type.value]) + if ts.dup_min_size: + cmd.extend(["-m", ts.dup_min_size]) + if ts.dup_max_size: + cmd.extend(["-i", ts.dup_max_size]) + if ts.dup_name_case_sensitive: + cmd.append("-l") + if not s.hide_hard_links: + cmd.append("-L") + if ts.dup_use_prehash: + cmd.append("-u") + + elif self.tab == ActiveTab.SIMILAR_IMAGES: + cmd.extend(["-g", ts.img_hash_alg.value]) + cmd.extend(["-z", ts.img_filter.value]) + cmd.extend(["-c", str(ts.img_hash_size)]) + cmd.extend(["-s", str(ts.img_max_difference)]) + if ts.img_ignore_same_size: + cmd.append("-J") + + elif self.tab == ActiveTab.SIMILAR_VIDEOS: + cmd.extend(["-t", str(ts.vid_max_difference)]) + cmd.extend(["-U", str(ts.vid_skip_forward)]) + cmd.extend(["-B", ts.vid_crop_detect.value]) + cmd.extend(["-A", str(ts.vid_duration)]) + if ts.vid_ignore_same_size: + cmd.append("-J") + + elif self.tab == ActiveTab.SIMILAR_MUSIC: + cmd.extend(["-s", ts.music_search_method.value]) + if ts.music_search_method == MusicSearchMethod.TAGS: + tags = [] + if ts.music_title: tags.append("track_title") + if ts.music_artist: tags.append("track_artist") + if ts.music_bitrate: tags.append("bitrate") + if ts.music_genre: tags.append("genre") + if ts.music_year: tags.append("year") + if ts.music_length: tags.append("length") + if tags: + cmd.extend(["-z", ",".join(tags)]) + if ts.music_approximate: + cmd.append("-a") + else: + cmd.extend(["-Y", str(ts.music_max_difference)]) + + elif self.tab == ActiveTab.BIG_FILES: + cmd.extend(["-n", str(ts.big_files_number)]) + if ts.big_files_mode == "smallest": + cmd.append("-J") + + elif self.tab == ActiveTab.BROKEN_FILES: + types = [] + if ts.broken_audio: types.append("AUDIO") + if ts.broken_pdf: types.append("PDF") + if ts.broken_archive: types.append("ARCHIVE") + if ts.broken_image: types.append("IMAGE") + if ts.broken_video: types.append("VIDEO") + if types: + cmd.extend(["-C", ",".join(types)]) + + elif self.tab == ActiveTab.BAD_NAMES: + if ts.bad_names_uppercase_ext: + cmd.append("-u") + if ts.bad_names_emoji: + cmd.append("-j") + if ts.bad_names_space: + cmd.append("-w") + if ts.bad_names_non_ascii: + cmd.append("-n") + if ts.bad_names_restricted_charset: + cmd.extend(["-r", ts.bad_names_restricted_charset]) + if ts.bad_names_remove_duplicated: + cmd.append("-a") + + elif self.tab == ActiveTab.VIDEO_OPTIMIZER: + if ts.video_opt_mode == "crop": + cmd.extend(["-m", ts.video_crop_mechanism.value]) + cmd.extend(["-k", str(ts.video_black_pixel_threshold)]) + cmd.extend(["-b", str(ts.video_black_bar_percentage)]) + cmd.extend(["-s", str(ts.video_max_samples)]) + cmd.extend(["-z", str(ts.video_min_crop_size)]) + else: + if ts.video_excluded_codecs: + cmd.extend(["-c", ts.video_excluded_codecs]) + cmd.extend(["--target-codec", ts.video_codec.value]) + cmd.extend(["--quality", str(ts.video_quality)]) + if ts.video_fail_if_bigger: + cmd.append("--fail-if-not-smaller") + + return cmd + + def _parse_results(self, json_path: str) -> list[ResultEntry]: + try: + with open(json_path) as f: + data = json.load(f) + except (json.JSONDecodeError, FileNotFoundError, OSError): + return [] + + if not data: + return [] + + results = [] + is_grouped = self.tab in GROUPED_TABS + + if is_grouped: + # Grouped tools return either: + # dict {size_key: [[entry, ...], [entry, ...]], ...} (duplicates by hash) + # list [[entry, ...], [entry, ...]] (similar images/videos/music) + all_groups = [] + if isinstance(data, dict): + # Dict format: flatten all groups from all size buckets + for size_key, groups_for_size in data.items(): + if isinstance(groups_for_size, list): + for group in groups_for_size: + if isinstance(group, list) and len(group) > 0: + all_groups.append(group) + elif isinstance(data, list): + for item in data: + if isinstance(item, list) and len(item) > 0: + all_groups.append(item) + + for group_id, group in enumerate(all_groups): + # Compute total size for the group header + total_size = sum(e.get("size", 0) for e in group if isinstance(e, dict)) + header_text = ( + f"Group {group_id + 1} ({len(group)} files, " + f"{self._format_size(total_size)})" + ) + header = ResultEntry( + values={"__header": header_text}, + header_row=True, + group_id=group_id, + ) + results.append(header) + for entry in group: + if isinstance(entry, dict): + result = self._parse_entry(entry, group_id) + results.append(result) + else: + # Flat tools return a list of entries + if isinstance(data, list): + for i, entry in enumerate(data): + if isinstance(entry, dict): + result = self._parse_entry(entry, 0) + results.append(result) + elif isinstance(data, dict): + # Some tools might also use dict format + for key, entries in data.items(): + if isinstance(entries, list): + for entry in entries: + if isinstance(entry, dict): + result = self._parse_entry(entry, 0) + results.append(result) + + return results + + def _parse_entry(self, entry: dict, group_id: int) -> ResultEntry: + path = entry.get("path", "") + p = Path(path) + values = { + "File Name": p.name, + "Folder Name": p.name, + "Symlink Name": p.name, + "Path": str(p.parent), + "Symlink Path": str(p.parent), + "Size": self._format_size(entry.get("size", 0)), + "Modification Date": self._format_date(entry.get("modified_date", 0)), + "Hash": entry.get("hash", ""), + "Similarity": str(entry.get("similarity", "")), + "Resolution": entry.get("dimensions", ""), + "Title": entry.get("title", ""), + "Artist": entry.get("artist", ""), + "Year": entry.get("year", ""), + "Bitrate": str(entry.get("bitrate", "")), + "Genre": entry.get("genre", ""), + "Length": entry.get("length", ""), + "Destination Path": entry.get("destination_path", ""), + "Type of Error": entry.get("error_string", entry.get("type_of_error", "")), + "Error Type": entry.get("error_string", entry.get("error_type", "")), + "Current Extension": entry.get("current_extension", ""), + "Proper Extension": entry.get("proper_extension", entry.get("proper_extensions", "")), + "Codec": entry.get("codec", ""), + } + # Store full path for file operations + values["__full_path"] = path + values["__size_bytes"] = entry.get("size", 0) + values["__modified_date_ts"] = entry.get("modified_date", 0) + + return ResultEntry(values=values, group_id=group_id) + + @staticmethod + def _format_size(size_bytes: int) -> str: + if size_bytes == 0: + return "0 B" + units = ["B", "KB", "MB", "GB", "TB"] + i = 0 + size = float(size_bytes) + while size >= 1024 and i < len(units) - 1: + size /= 1024 + i += 1 + return f"{size:.1f} {units[i]}" if i > 0 else f"{int(size)} B" + + @staticmethod + def _format_date(timestamp: int) -> str: + if timestamp == 0: + return "" + from datetime import datetime + try: + return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S") + except (ValueError, OSError): + return str(timestamp) + + +class ScanRunner(QObject): + """Manages scan execution in a background thread.""" + finished = Signal(object, list) + progress = Signal(object) + error = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self._thread: Optional[QThread] = None + self._worker: Optional[ScanWorker] = None + + def start_scan(self, tab: ActiveTab, app_settings: AppSettings, + tool_settings: ToolSettings): + if self._thread and self._thread.isRunning(): + return + + self._thread = QThread() + self._worker = ScanWorker(tab, app_settings, tool_settings) + self._worker.moveToThread(self._thread) + + self._thread.started.connect(self._worker.run) + self._worker.finished.connect(self._on_finished) + self._worker.progress.connect(self.progress.emit) + self._worker.error.connect(self._on_error) + + self._thread.start() + + def stop_scan(self): + if self._worker: + self._worker.cancel() + # Wait briefly for the thread to finish after killing the process + if self._thread and self._thread.isRunning(): + self._thread.quit() + if not self._thread.wait(5000): + self._thread.terminate() + self._thread.wait() + self._thread = None + self._worker = None + # Emit an empty result to signal completion + self.finished.emit(ActiveTab.DUPLICATE_FILES, []) + + def _on_finished(self, tab, results): + self.finished.emit(tab, results) + self._cleanup() + + def _on_error(self, msg): + self.error.emit(msg) + self._cleanup() + + def _cleanup(self): + if self._thread: + self._thread.quit() + self._thread.wait(5000) + self._thread = None + self._worker = None + + +class FileOperations: + """File operations: delete, move, hardlink, symlink, rename.""" + + @staticmethod + def delete_files(entries: list[ResultEntry], move_to_trash: bool = True) -> tuple[int, list[str]]: + deleted = 0 + errors = [] + for entry in entries: + path = entry.values.get("__full_path", "") + if not path: + continue + try: + p = Path(path) + if not p.exists(): + errors.append(f"File not found: {path}") + continue + if move_to_trash: + try: + from send2trash import send2trash + send2trash(str(p)) + except ImportError: + p.unlink() + else: + if p.is_dir(): + import shutil + shutil.rmtree(p) + else: + p.unlink() + deleted += 1 + except OSError as e: + errors.append(f"Error deleting {path}: {e}") + return deleted, errors + + @staticmethod + def move_files(entries: list[ResultEntry], destination: str, + preserve_structure: bool = False, copy_mode: bool = False) -> tuple[int, list[str]]: + import shutil + moved = 0 + errors = [] + dest = Path(destination) + dest.mkdir(parents=True, exist_ok=True) + + for entry in entries: + src_path = entry.values.get("__full_path", "") + if not src_path: + continue + try: + src = Path(src_path) + if preserve_structure: + # Keep relative directory structure + rel = src.parent + target_dir = dest / rel.relative_to(rel.anchor) + target_dir.mkdir(parents=True, exist_ok=True) + target = target_dir / src.name + else: + target = dest / src.name + + # Handle name conflicts + if target.exists(): + stem = target.stem + suffix = target.suffix + counter = 1 + while target.exists(): + target = target.parent / f"{stem}_{counter}{suffix}" + counter += 1 + + if copy_mode: + shutil.copy2(str(src), str(target)) + else: + shutil.move(str(src), str(target)) + moved += 1 + except OSError as e: + errors.append(f"Error moving {src_path}: {e}") + return moved, errors + + @staticmethod + def create_hardlinks(entries: list[ResultEntry], reference_path: str) -> tuple[int, list[str]]: + created = 0 + errors = [] + for entry in entries: + path = entry.values.get("__full_path", "") + if not path or path == reference_path: + continue + try: + p = Path(path) + p.unlink() + os.link(reference_path, str(p)) + created += 1 + except OSError as e: + errors.append(f"Error creating hardlink for {path}: {e}") + return created, errors + + @staticmethod + def create_symlinks(entries: list[ResultEntry], reference_path: str) -> tuple[int, list[str]]: + created = 0 + errors = [] + for entry in entries: + path = entry.values.get("__full_path", "") + if not path or path == reference_path: + continue + try: + p = Path(path) + p.unlink() + os.symlink(reference_path, str(p)) + created += 1 + except OSError as e: + errors.append(f"Error creating symlink for {path}: {e}") + return created, errors + + @staticmethod + def fix_extensions(cli_path: str, app_settings, tool_settings) -> tuple[bool, str]: + """Run czkawka_cli ext with -F flag to fix extensions.""" + cmd = [cli_path, "ext", "-F"] + if app_settings.included_paths: + cmd.extend(["-d", ",".join(app_settings.included_paths)]) + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + return True, result.stdout + except Exception as e: + return False, str(e) + + @staticmethod + def fix_bad_names(cli_path: str, app_settings, tool_settings) -> tuple[bool, str]: + """Run czkawka_cli bad-names with -F flag to fix names.""" + cmd = [cli_path, "bad-names", "-F"] + if app_settings.included_paths: + cmd.extend(["-d", ",".join(app_settings.included_paths)]) + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + return True, result.stdout + except Exception as e: + return False, str(e) + + @staticmethod + def clean_exif(cli_path: str, entries: list[ResultEntry], + ignored_tags: str = "", overwrite: bool = True) -> tuple[int, list[str]]: + """Remove EXIF data from selected files.""" + cleaned = 0 + errors = [] + for entry in entries: + path = entry.values.get("__full_path", "") + if not path: + continue + try: + from PIL import Image + img = Image.open(path) + data = list(img.getdata()) + clean_img = Image.new(img.mode, img.size) + clean_img.putdata(data) + if overwrite: + clean_img.save(path) + else: + p = Path(path) + new_path = p.parent / f"{p.stem}_clean{p.suffix}" + clean_img.save(str(new_path)) + cleaned += 1 + except Exception as e: + errors.append(f"Error cleaning EXIF for {path}: {e}") + return cleaned, errors diff --git a/czkawka_pyside6/app/bottom_panel.py b/czkawka_pyside6/app/bottom_panel.py new file mode 100644 index 000000000..736a5cd04 --- /dev/null +++ b/czkawka_pyside6/app/bottom_panel.py @@ -0,0 +1,143 @@ +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QListWidget, + QPushButton, QFileDialog, QTextEdit, QStackedWidget, + QSizePolicy +) +from PySide6.QtCore import Signal, Qt + +from .models import AppSettings + + +class BottomPanel(QWidget): + """Bottom panel showing directories or error messages.""" + directories_changed = Signal() + + def __init__(self, settings: AppSettings, parent=None): + super().__init__(parent) + self._settings = settings + self.setMaximumHeight(200) + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(4, 2, 4, 2) + + self._stack = QStackedWidget() + + # Page 0: Directories view + dir_widget = QWidget() + dir_layout = QHBoxLayout(dir_widget) + dir_layout.setContentsMargins(0, 0, 0, 0) + + # Included directories + inc_widget = QWidget() + inc_layout = QVBoxLayout(inc_widget) + inc_layout.setContentsMargins(0, 0, 0, 0) + inc_layout.addWidget(QLabel("Included Directories:")) + + self._inc_list = QListWidget() + self._inc_list.setMaximumHeight(120) + for path in self._settings.included_paths: + self._inc_list.addItem(path) + inc_layout.addWidget(self._inc_list) + + inc_btns = QHBoxLayout() + add_btn = QPushButton("+") + add_btn.setFixedWidth(30) + add_btn.clicked.connect(self._add_included) + inc_btns.addWidget(add_btn) + rem_btn = QPushButton("-") + rem_btn.setFixedWidth(30) + rem_btn.clicked.connect(self._remove_included) + inc_btns.addWidget(rem_btn) + inc_btns.addStretch() + inc_layout.addLayout(inc_btns) + dir_layout.addWidget(inc_widget) + + # Excluded directories + exc_widget = QWidget() + exc_layout = QVBoxLayout(exc_widget) + exc_layout.setContentsMargins(0, 0, 0, 0) + exc_layout.addWidget(QLabel("Excluded Directories:")) + + self._exc_list = QListWidget() + self._exc_list.setMaximumHeight(120) + for path in self._settings.excluded_paths: + self._exc_list.addItem(path) + exc_layout.addWidget(self._exc_list) + + exc_btns = QHBoxLayout() + add_exc = QPushButton("+") + add_exc.setFixedWidth(30) + add_exc.clicked.connect(self._add_excluded) + exc_btns.addWidget(add_exc) + rem_exc = QPushButton("-") + rem_exc.setFixedWidth(30) + rem_exc.clicked.connect(self._remove_excluded) + exc_btns.addWidget(rem_exc) + exc_btns.addStretch() + exc_layout.addLayout(exc_btns) + dir_layout.addWidget(exc_widget) + + self._stack.addWidget(dir_widget) + + # Page 1: Text errors/info + self._text_area = QTextEdit() + self._text_area.setReadOnly(True) + self._text_area.setMaximumHeight(150) + self._stack.addWidget(self._text_area) + + layout.addWidget(self._stack) + + def show_directories(self): + self._stack.setCurrentIndex(0) + self.setVisible(True) + + def show_text(self): + self._stack.setCurrentIndex(1) + self.setVisible(True) + + def hide_panel(self): + self.setVisible(False) + + def set_text(self, text: str): + self._text_area.setPlainText(text) + + def append_text(self, text: str): + self._text_area.append(text) + + def _add_included(self): + path = QFileDialog.getExistingDirectory(self, "Select Directory to Include") + if path and path not in self._settings.included_paths: + self._settings.included_paths.append(path) + self._inc_list.addItem(path) + self.directories_changed.emit() + + def _remove_included(self): + row = self._inc_list.currentRow() + if row >= 0: + self._inc_list.takeItem(row) + self._settings.included_paths.pop(row) + self.directories_changed.emit() + + def _add_excluded(self): + path = QFileDialog.getExistingDirectory(self, "Select Directory to Exclude") + if path and path not in self._settings.excluded_paths: + self._settings.excluded_paths.append(path) + self._exc_list.addItem(path) + self.directories_changed.emit() + + def _remove_excluded(self): + row = self._exc_list.currentRow() + if row >= 0: + self._exc_list.takeItem(row) + self._settings.excluded_paths.pop(row) + self.directories_changed.emit() + + def refresh_lists(self): + self._inc_list.clear() + for path in self._settings.included_paths: + self._inc_list.addItem(path) + self._exc_list.clear() + for path in self._settings.excluded_paths: + self._exc_list.addItem(path) diff --git a/czkawka_pyside6/app/dialogs/__init__.py b/czkawka_pyside6/app/dialogs/__init__.py new file mode 100644 index 000000000..f1c6d549e --- /dev/null +++ b/czkawka_pyside6/app/dialogs/__init__.py @@ -0,0 +1,7 @@ +from .delete_dialog import DeleteDialog +from .move_dialog import MoveDialog +from .select_dialog import SelectDialog +from .sort_dialog import SortDialog +from .save_dialog import SaveDialog +from .rename_dialog import RenameDialog +from .about_dialog import AboutDialog diff --git a/czkawka_pyside6/app/dialogs/about_dialog.py b/czkawka_pyside6/app/dialogs/about_dialog.py new file mode 100644 index 000000000..54da0fa9f --- /dev/null +++ b/czkawka_pyside6/app/dialogs/about_dialog.py @@ -0,0 +1,78 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QDialogButtonBox +) +from PySide6.QtCore import Qt +from PySide6.QtGui import QPixmap + +from ..icons import app_logo_path + + +class AboutDialog(QDialog): + """About dialog showing application information.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("About Czkawka PySide6") + self.setMinimumWidth(480) + self.setMinimumHeight(420) + + layout = QVBoxLayout(self) + layout.setSpacing(6) + + # Logo + logo_path = app_logo_path() + if logo_path: + logo_label = QLabel() + pixmap = QPixmap(logo_path) + scaled = pixmap.scaledToHeight(100, Qt.SmoothTransformation) + logo_label.setPixmap(scaled) + logo_label.setAlignment(Qt.AlignCenter) + layout.addWidget(logo_label) + + title = QLabel("Czkawka") + title.setStyleSheet("font-size: 28px; font-weight: bold; padding: 4px;") + title.setAlignment(Qt.AlignCenter) + layout.addWidget(title) + + subtitle = QLabel("PySide6 / Qt6 Edition") + subtitle.setStyleSheet("font-size: 14px; color: #6fbf73; padding: 2px;") + subtitle.setAlignment(Qt.AlignCenter) + layout.addWidget(subtitle) + + version = QLabel("Version 11.0.1") + version.setStyleSheet("font-size: 11px; color: #888; padding: 2px;") + version.setAlignment(Qt.AlignCenter) + layout.addWidget(version) + + # Separator + sep = QLabel() + sep.setFixedHeight(1) + sep.setStyleSheet("background-color: #444; margin: 8px 40px;") + layout.addWidget(sep) + + desc = QLabel( + "Czkawka (tch-kav-ka) is a simple, fast and free app to remove\n" + "unnecessary files from your computer.\n\n" + "This PySide6/Qt interface uses the czkawka_cli backend\n" + "for all scanning and file operations.\n\n" + "Features:\n" + " - Find duplicate files (by hash, name, or size)\n" + " - Find empty files and folders\n" + " - Find similar images, videos, and music\n" + " - Find broken files and invalid symlinks\n" + " - Find files with bad extensions or names\n" + " - Remove EXIF metadata from images\n" + " - Optimize and crop videos\n\n" + "Licensed under MIT License\n" + "https://github.com/qarmin/czkawka" + ) + desc.setWordWrap(True) + desc.setAlignment(Qt.AlignCenter) + desc.setStyleSheet("padding: 6px 20px; line-height: 1.4;") + layout.addWidget(desc) + + layout.addStretch() + + buttons = QDialogButtonBox(QDialogButtonBox.Ok) + buttons.accepted.connect(self.accept) + layout.addWidget(buttons) diff --git a/czkawka_pyside6/app/dialogs/delete_dialog.py b/czkawka_pyside6/app/dialogs/delete_dialog.py new file mode 100644 index 000000000..89ce64bc6 --- /dev/null +++ b/czkawka_pyside6/app/dialogs/delete_dialog.py @@ -0,0 +1,51 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QDialogButtonBox, + QCheckBox, QHBoxLayout +) +from PySide6.QtCore import Qt + + +class DeleteDialog(QDialog): + """Confirmation dialog for deleting files.""" + + def __init__(self, count: int, move_to_trash: bool = True, parent=None): + super().__init__(parent) + self.setWindowTitle("Delete Files") + self.setMinimumWidth(400) + self._move_to_trash = move_to_trash + + layout = QVBoxLayout(self) + + # Warning + icon_label = QLabel() + icon_label.setStyleSheet("font-size: 36px;") + icon_label.setText("Warning") + icon_label.setAlignment(Qt.AlignCenter) + layout.addWidget(icon_label) + + msg = QLabel(f"Are you sure you want to delete {count} selected file(s)?") + msg.setStyleSheet("font-size: 14px; padding: 10px;") + msg.setAlignment(Qt.AlignCenter) + msg.setWordWrap(True) + layout.addWidget(msg) + + # Move to trash checkbox + self._trash_cb = QCheckBox("Move to trash instead of permanent delete") + self._trash_cb.setChecked(move_to_trash) + layout.addWidget(self._trash_cb) + + # Buttons + buttons = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel + ) + buttons.button(QDialogButtonBox.Ok).setText("Delete") + buttons.button(QDialogButtonBox.Ok).setStyleSheet( + "background-color: #8a2222; color: white; padding: 6px 20px;" + ) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + @property + def move_to_trash(self) -> bool: + return self._trash_cb.isChecked() diff --git a/czkawka_pyside6/app/dialogs/move_dialog.py b/czkawka_pyside6/app/dialogs/move_dialog.py new file mode 100644 index 000000000..6c754d21a --- /dev/null +++ b/czkawka_pyside6/app/dialogs/move_dialog.py @@ -0,0 +1,65 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QDialogButtonBox, + QCheckBox, QLineEdit, QHBoxLayout, QPushButton, + QFileDialog, QFormLayout +) +from PySide6.QtCore import Qt + + +class MoveDialog(QDialog): + """Dialog for moving/copying files to a destination.""" + + def __init__(self, count: int, parent=None): + super().__init__(parent) + self.setWindowTitle("Move/Copy Files") + self.setMinimumWidth(500) + + layout = QVBoxLayout(self) + + msg = QLabel(f"Move or copy {count} selected file(s) to:") + msg.setStyleSheet("font-size: 13px; padding: 6px;") + layout.addWidget(msg) + + # Destination path + dest_layout = QHBoxLayout() + self._dest_edit = QLineEdit() + self._dest_edit.setPlaceholderText("Select destination folder...") + dest_layout.addWidget(self._dest_edit) + + browse_btn = QPushButton("Browse") + browse_btn.clicked.connect(self._browse) + dest_layout.addWidget(browse_btn) + layout.addLayout(dest_layout) + + # Options + self._preserve_structure = QCheckBox("Preserve folder structure") + layout.addWidget(self._preserve_structure) + + self._copy_mode = QCheckBox("Copy instead of move") + layout.addWidget(self._copy_mode) + + # Buttons + buttons = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel + ) + buttons.button(QDialogButtonBox.Ok).setText("Move") + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def _browse(self): + path = QFileDialog.getExistingDirectory(self, "Select Destination") + if path: + self._dest_edit.setText(path) + + @property + def destination(self) -> str: + return self._dest_edit.text() + + @property + def preserve_structure(self) -> bool: + return self._preserve_structure.isChecked() + + @property + def copy_mode(self) -> bool: + return self._copy_mode.isChecked() diff --git a/czkawka_pyside6/app/dialogs/rename_dialog.py b/czkawka_pyside6/app/dialogs/rename_dialog.py new file mode 100644 index 000000000..4c6208ab1 --- /dev/null +++ b/czkawka_pyside6/app/dialogs/rename_dialog.py @@ -0,0 +1,34 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QDialogButtonBox +) + + +class RenameDialog(QDialog): + """Confirmation dialog for renaming files (fix extensions or bad names).""" + + def __init__(self, count: int, rename_type: str = "extensions", parent=None): + super().__init__(parent) + self.setWindowTitle(f"Fix {rename_type.title()}") + self.setMinimumWidth(400) + + layout = QVBoxLayout(self) + + if rename_type == "extensions": + msg = f"Fix extensions for {count} selected file(s)?\n\n" \ + "Files will be renamed to use their proper extensions." + else: + msg = f"Fix names for {count} selected file(s)?\n\n" \ + "Files with problematic names will be renamed." + + label = QLabel(msg) + label.setWordWrap(True) + label.setStyleSheet("font-size: 13px; padding: 10px;") + layout.addWidget(label) + + buttons = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel + ) + buttons.button(QDialogButtonBox.Ok).setText("Rename") + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) diff --git a/czkawka_pyside6/app/dialogs/save_dialog.py b/czkawka_pyside6/app/dialogs/save_dialog.py new file mode 100644 index 000000000..d8fa614e6 --- /dev/null +++ b/czkawka_pyside6/app/dialogs/save_dialog.py @@ -0,0 +1,48 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QDialogButtonBox, + QCheckBox, QFileDialog +) + + +class SaveDialog: + """Save results to file (uses native file dialog).""" + + @staticmethod + def save(parent, results: list, save_as_json: bool = False) -> bool: + if save_as_json: + filter_str = "JSON Files (*.json);;All Files (*)" + default_ext = ".json" + else: + filter_str = "Text Files (*.txt);;All Files (*)" + default_ext = ".txt" + + path, _ = QFileDialog.getSaveFileName( + parent, "Save Results", f"results{default_ext}", filter_str + ) + if not path: + return False + + try: + import json + if save_as_json: + data = [] + for entry in results: + if not entry.header_row: + # Filter out internal keys + values = {k: v for k, v in entry.values.items() + if not k.startswith("__")} + data.append(values) + with open(path, "w") as f: + json.dump(data, f, indent=2) + else: + with open(path, "w") as f: + for entry in results: + if entry.header_row: + f.write(f"\n--- {entry.values.get('__header', 'Group')} ---\n") + else: + path_val = entry.values.get("__full_path", "") + size = entry.values.get("Size", "") + f.write(f"{path_val}\t{size}\n") + return True + except OSError: + return False diff --git a/czkawka_pyside6/app/dialogs/select_dialog.py b/czkawka_pyside6/app/dialogs/select_dialog.py new file mode 100644 index 000000000..58ea94a11 --- /dev/null +++ b/czkawka_pyside6/app/dialogs/select_dialog.py @@ -0,0 +1,48 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QPushButton, QDialogButtonBox +) +from PySide6.QtCore import Signal + +from ..models import SelectMode + + +class SelectDialog(QDialog): + """Dialog for selecting/deselecting results.""" + mode_selected = Signal(object) # SelectMode + + MODES = [ + (SelectMode.SELECT_ALL, "Select All"), + (SelectMode.UNSELECT_ALL, "Unselect All"), + (SelectMode.INVERT_SELECTION, "Invert Selection"), + (SelectMode.SELECT_BIGGEST_SIZE, "Select Biggest (by Size)"), + (SelectMode.SELECT_SMALLEST_SIZE, "Select Smallest (by Size)"), + (SelectMode.SELECT_NEWEST, "Select Newest"), + (SelectMode.SELECT_OLDEST, "Select Oldest"), + (SelectMode.SELECT_SHORTEST_PATH, "Select Shortest Path"), + (SelectMode.SELECT_LONGEST_PATH, "Select Longest Path"), + ] + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Select Results") + self.setMinimumWidth(300) + + layout = QVBoxLayout(self) + + label = QLabel("Choose selection mode:") + label.setStyleSheet("font-size: 13px; padding: 4px;") + layout.addWidget(label) + + for mode, name in self.MODES: + btn = QPushButton(name) + btn.clicked.connect(lambda checked, m=mode: self._select(m)) + layout.addWidget(btn) + + # Cancel + cancel = QPushButton("Cancel") + cancel.clicked.connect(self.reject) + layout.addWidget(cancel) + + def _select(self, mode: SelectMode): + self.mode_selected.emit(mode) + self.accept() diff --git a/czkawka_pyside6/app/dialogs/sort_dialog.py b/czkawka_pyside6/app/dialogs/sort_dialog.py new file mode 100644 index 000000000..3d6f627e4 --- /dev/null +++ b/czkawka_pyside6/app/dialogs/sort_dialog.py @@ -0,0 +1,39 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QPushButton, QComboBox, + QCheckBox, QDialogButtonBox, QFormLayout +) +from PySide6.QtCore import Signal + + +class SortDialog(QDialog): + """Dialog for sorting results.""" + sort_requested = Signal(int, bool) # column_index, ascending + + def __init__(self, columns: list[str], parent=None): + super().__init__(parent) + self.setWindowTitle("Sort Results") + self.setMinimumWidth(300) + + layout = QFormLayout(self) + + self._column = QComboBox() + self._column.addItems(columns) + layout.addRow("Sort by:", self._column) + + self._ascending = QCheckBox("Ascending") + self._ascending.setChecked(True) + layout.addRow(self._ascending) + + buttons = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel + ) + buttons.accepted.connect(self._on_sort) + buttons.rejected.connect(self.reject) + layout.addRow(buttons) + + def _on_sort(self): + self.sort_requested.emit( + self._column.currentIndex(), + self._ascending.isChecked() + ) + self.accept() diff --git a/czkawka_pyside6/app/icons.py b/czkawka_pyside6/app/icons.py new file mode 100644 index 000000000..bc094529f --- /dev/null +++ b/czkawka_pyside6/app/icons.py @@ -0,0 +1,149 @@ +"""SVG icon resources for Czkawka PySide6 interface. + +Uses the same SVG icons as the Krokiet (Slint) interface. +Icons are embedded as strings to avoid file path issues. +""" + +from PySide6.QtGui import QIcon, QPixmap +from PySide6.QtCore import QSize, Qt +from PySide6.QtSvg import QSvgRenderer +from PySide6.QtGui import QPainter, QImage +from functools import lru_cache + +# Fill color applied to icons for dark theme visibility +_ICON_FILL = "#cccccc" +_ICON_FILL_GREEN = "#6fbf73" +_ICON_FILL_RED = "#e57373" + + +def _colorize_svg(svg: str, fill: str = _ICON_FILL) -> str: + """Inject fill color into SVG for dark theme visibility.""" + # Add fill to root svg or g elements + if 'fill="none"' not in svg and f'fill="{fill}"' not in svg: + svg = svg.replace(" QIcon: + """Convert SVG string to QIcon with specified fill color.""" + colored = _colorize_svg(svg_data, fill) + renderer = QSvgRenderer(colored.encode("utf-8")) + image = QImage(QSize(size, size), QImage.Format_ARGB32_Premultiplied) + image.fill(Qt.transparent) + painter = QPainter(image) + renderer.render(painter) + painter.end() + pixmap = QPixmap.fromImage(image) + return QIcon(pixmap) + + +# ─── Raw SVG data ─────────────────────────────────────────── + +SEARCH_SVG = '''''' + +STOP_SVG = '''''' + +DELETE_SVG = '''''' + +MOVE_SVG = '''''' + +SAVE_SVG = '''''' + +SELECT_SVG = '''''' + +HARDLINK_SVG = '''''' + +SYMLINK_SVG = '''''' + +RENAME_SVG = '''''' + +CLEAN_SVG = '''''' + +OPTIMIZE_SVG = '''''' + +SETTINGS_SVG = '''''' + +SUBSETTINGS_SVG = '''''' + +SORT_SVG = '''''' + +DIR_SVG = '''''' + +INFO_SVG = '''''' + + +# ─── Icon accessor functions ──────────────────────────────── + +def icon_search(size=24): + return _svg_to_icon(SEARCH_SVG, _ICON_FILL_GREEN, size) + +def icon_stop(size=24): + return _svg_to_icon(STOP_SVG, _ICON_FILL_RED, size) + +def icon_delete(size=24): + return _svg_to_icon(DELETE_SVG, _ICON_FILL_RED, size) + +def icon_move(size=24): + return _svg_to_icon(MOVE_SVG, _ICON_FILL, size) + +def icon_save(size=24): + return _svg_to_icon(SAVE_SVG, _ICON_FILL, size) + +def icon_select(size=24): + return _svg_to_icon(SELECT_SVG, _ICON_FILL, size) + +def icon_sort(size=24): + return _svg_to_icon(SORT_SVG, _ICON_FILL, size) + +def icon_hardlink(size=24): + return _svg_to_icon(HARDLINK_SVG, _ICON_FILL, size) + +def icon_symlink(size=24): + return _svg_to_icon(SYMLINK_SVG, _ICON_FILL, size) + +def icon_rename(size=24): + return _svg_to_icon(RENAME_SVG, _ICON_FILL, size) + +def icon_clean(size=24): + return _svg_to_icon(CLEAN_SVG, _ICON_FILL, size) + +def icon_optimize(size=24): + return _svg_to_icon(OPTIMIZE_SVG, _ICON_FILL, size) + +def icon_settings(size=24): + return _svg_to_icon(SETTINGS_SVG, _ICON_FILL, size) + +def icon_subsettings(size=24): + return _svg_to_icon(SUBSETTINGS_SVG, _ICON_FILL, size) + +def icon_dir(size=24): + return _svg_to_icon(DIR_SVG, _ICON_FILL, size) + +def icon_info(size=24): + return _svg_to_icon(INFO_SVG, _ICON_FILL, size) + + +def app_logo_path() -> str: + """Return absolute path to the Krokiet logo PNG.""" + from pathlib import Path + # Try relative to this file first, then absolute project path + for candidate in [ + Path(__file__).parent.parent.parent / "krokiet" / "icons" / "krokiet_logo.png", + Path("/mnt/developer/git/aecs4u.it/czkawka/krokiet/icons/krokiet_logo.png"), + ]: + if candidate.exists(): + return str(candidate) + return "" + + +def app_icon() -> QIcon: + """Return the application window icon from the Krokiet logo.""" + path = app_logo_path() + if path: + return QIcon(path) + return QIcon() diff --git a/czkawka_pyside6/app/left_panel.py b/czkawka_pyside6/app/left_panel.py new file mode 100644 index 000000000..8c53e77d3 --- /dev/null +++ b/czkawka_pyside6/app/left_panel.py @@ -0,0 +1,149 @@ +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QListWidget, QListWidgetItem, QLabel, + QPushButton, QHBoxLayout, QSizePolicy +) +from PySide6.QtCore import Signal, Qt, QSize +from PySide6.QtGui import QFont, QPixmap + +from .models import ActiveTab, TAB_DISPLAY_NAMES, TABS_WITH_SETTINGS +from .icons import app_logo_path, icon_settings, icon_subsettings + + +class LeftPanel(QWidget): + """Left sidebar for selecting scan tools.""" + tab_changed = Signal(object) # ActiveTab + settings_requested = Signal() + about_requested = Signal() + tool_settings_toggled = Signal(bool) + + # Tool tabs in display order + TOOL_TABS = [ + ActiveTab.DUPLICATE_FILES, + ActiveTab.EMPTY_FOLDERS, + ActiveTab.BIG_FILES, + ActiveTab.EMPTY_FILES, + ActiveTab.TEMPORARY_FILES, + ActiveTab.SIMILAR_IMAGES, + ActiveTab.SIMILAR_VIDEOS, + ActiveTab.SIMILAR_MUSIC, + ActiveTab.INVALID_SYMLINKS, + ActiveTab.BROKEN_FILES, + ActiveTab.BAD_EXTENSIONS, + ActiveTab.BAD_NAMES, + ActiveTab.EXIF_REMOVER, + ActiveTab.VIDEO_OPTIMIZER, + ] + + def __init__(self, parent=None): + super().__init__(parent) + self.setMinimumWidth(170) + self.setMaximumWidth(230) + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(6, 6, 6, 6) + layout.setSpacing(4) + + # Logo image (clickable) + logo_path = app_logo_path() + if logo_path: + self._logo_label = QLabel() + pixmap = QPixmap(logo_path) + scaled = pixmap.scaledToHeight(70, Qt.SmoothTransformation) + self._logo_label.setPixmap(scaled) + self._logo_label.setAlignment(Qt.AlignCenter) + self._logo_label.setCursor(Qt.PointingHandCursor) + self._logo_label.setToolTip("About Czkawka") + self._logo_label.mousePressEvent = lambda _: self.about_requested.emit() + layout.addWidget(self._logo_label) + else: + title_label = QLabel("Czkawka") + title_font = QFont() + title_font.setPointSize(16) + title_font.setBold(True) + title_label.setFont(title_font) + title_label.setAlignment(Qt.AlignCenter) + title_label.setCursor(Qt.PointingHandCursor) + title_label.mousePressEvent = lambda _: self.about_requested.emit() + layout.addWidget(title_label) + + # Top buttons row with icons + btn_row = QHBoxLayout() + btn_row.setSpacing(4) + + self._settings_btn = QPushButton(icon_settings(20), "") + self._settings_btn.setFixedSize(32, 32) + self._settings_btn.setIconSize(QSize(20, 20)) + self._settings_btn.setToolTip("Application Settings") + self._settings_btn.clicked.connect(self.settings_requested.emit) + btn_row.addWidget(self._settings_btn) + + self._tool_settings_btn = QPushButton(icon_subsettings(20), "") + self._tool_settings_btn.setFixedSize(32, 32) + self._tool_settings_btn.setIconSize(QSize(20, 20)) + self._tool_settings_btn.setToolTip("Tool-specific Settings") + self._tool_settings_btn.setCheckable(True) + self._tool_settings_btn.clicked.connect( + lambda checked: self.tool_settings_toggled.emit(checked) + ) + self._tool_settings_btn.setVisible(False) + btn_row.addWidget(self._tool_settings_btn) + + btn_row.addStretch() + layout.addLayout(btn_row) + + # Tool list + self._tool_list = QListWidget() + self._tool_list.setSpacing(1) + font = QFont() + font.setPointSize(10) + self._tool_list.setFont(font) + self._tool_list.setStyleSheet(""" + QListWidget::item { + padding: 4px 8px; + border-left: 3px solid transparent; + } + QListWidget::item:selected { + border-left: 3px solid #6fbf73; + background-color: #353535; + } + QListWidget::item:hover { + background-color: #49494926; + } + """) + + for tab in self.TOOL_TABS: + item = QListWidgetItem(TAB_DISPLAY_NAMES[tab]) + item.setData(Qt.UserRole, tab) + item.setSizeHint(item.sizeHint().__class__(item.sizeHint().width(), 30)) + self._tool_list.addItem(item) + + self._tool_list.setCurrentRow(0) + self._tool_list.currentItemChanged.connect(self._on_item_changed) + layout.addWidget(self._tool_list) + + # Version label + version_label = QLabel("Czkawka PySide6 v11.0.1") + version_label.setAlignment(Qt.AlignCenter) + version_label.setStyleSheet("color: #666; font-size: 9px; padding: 2px;") + layout.addWidget(version_label) + + def _on_item_changed(self, current, previous): + if current: + tab = current.data(Qt.UserRole) + self._tool_settings_btn.setVisible(tab in TABS_WITH_SETTINGS) + self.tab_changed.emit(tab) + + def set_active_tab(self, tab: ActiveTab): + for i in range(self._tool_list.count()): + item = self._tool_list.item(i) + if item.data(Qt.UserRole) == tab: + self._tool_list.setCurrentRow(i) + break + + def get_active_tab(self) -> ActiveTab: + item = self._tool_list.currentItem() + if item: + return item.data(Qt.UserRole) + return ActiveTab.DUPLICATE_FILES diff --git a/czkawka_pyside6/app/main_window.py b/czkawka_pyside6/app/main_window.py new file mode 100644 index 000000000..2515bd237 --- /dev/null +++ b/czkawka_pyside6/app/main_window.py @@ -0,0 +1,624 @@ +"""Main application window for Czkawka PySide6 interface.""" + +import shutil +from pathlib import Path + +from PySide6.QtWidgets import ( + QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QSplitter, + QStatusBar, QMessageBox, QLabel, QApplication +) +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QPalette, QColor + +from .state import AppState +from .models import ( + ActiveTab, TAB_COLUMNS, GROUPED_TABS, TABS_WITH_SETTINGS, SelectMode +) +from .left_panel import LeftPanel +from .results_view import ResultsView +from .action_buttons import ActionButtons +from .tool_settings import ToolSettingsPanel +from .settings_panel import SettingsPanel +from .progress_widget import ProgressWidget +from .preview_panel import PreviewPanel +from .bottom_panel import BottomPanel +from .backend import ScanRunner, FileOperations +from .icons import app_icon +from .dialogs import ( + DeleteDialog, MoveDialog, SelectDialog, + SortDialog, SaveDialog, RenameDialog, AboutDialog +) + + +class MainWindow(QMainWindow): + """Main application window with all panels and functionality.""" + + def __init__(self): + super().__init__() + self._state = AppState() + self._scan_runner = ScanRunner(self) + self._setup_window() + self._setup_ui() + self._connect_signals() + self._apply_theme() + + def _setup_window(self): + self.setWindowTitle("Czkawka - PySide6 Edition") + self.setMinimumSize(900, 600) + self.resize(1200, 800) + + # Set window icon from project logo + icon = app_icon() + if not icon.isNull(): + self.setWindowIcon(icon) + + # Auto-detect czkawka_cli binary + self._auto_detect_cli() + + def _setup_ui(self): + central = QWidget() + self.setCentralWidget(central) + main_layout = QVBoxLayout(central) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + + # Top area: left panel + content + content_splitter = QSplitter(Qt.Horizontal) + + # Left panel (tool selection) + self._left_panel = LeftPanel() + content_splitter.addWidget(self._left_panel) + + # Center area + center_widget = QWidget() + center_layout = QVBoxLayout(center_widget) + center_layout.setContentsMargins(0, 0, 0, 0) + center_layout.setSpacing(0) + + # Results view + self._results_view = ResultsView() + center_layout.addWidget(self._results_view, 1) + + # Progress widget (hidden by default) + self._progress = ProgressWidget() + center_layout.addWidget(self._progress) + + content_splitter.addWidget(center_widget) + + # Tool settings panel (hidden by default) + self._tool_settings = ToolSettingsPanel(self._state.tool_settings) + self._tool_settings.setVisible(False) + content_splitter.addWidget(self._tool_settings) + + # Preview panel (hidden by default) + self._preview = PreviewPanel() + self._preview.setVisible(False) + content_splitter.addWidget(self._preview) + + # Set splitter sizes + content_splitter.setStretchFactor(0, 0) # Left panel: fixed + content_splitter.setStretchFactor(1, 1) # Center: stretch + content_splitter.setStretchFactor(2, 0) # Tool settings: fixed + content_splitter.setStretchFactor(3, 0) # Preview: fixed + + main_layout.addWidget(content_splitter, 1) + + # Action buttons bar + self._action_buttons = ActionButtons() + main_layout.addWidget(self._action_buttons) + + # Bottom panel (directories / errors) + self._bottom_panel = BottomPanel(self._state.settings) + self._bottom_panel.show_directories() + main_layout.addWidget(self._bottom_panel) + + # Settings panel (overlay, hidden by default) + self._settings_panel = SettingsPanel(self._state.settings) + self._settings_panel.setVisible(False) + + # Status bar + self._statusbar = QStatusBar() + self.setStatusBar(self._statusbar) + self._status_label = QLabel("Ready") + self._statusbar.addWidget(self._status_label, 1) + + # Initialize results view columns for default tab + self._results_view.set_active_tab(self._state.active_tab) + + def _connect_signals(self): + # Left panel + self._left_panel.tab_changed.connect(self._on_tab_changed) + self._left_panel.settings_requested.connect(self._show_settings) + self._left_panel.about_requested.connect(self._show_about) + self._left_panel.tool_settings_toggled.connect(self._toggle_tool_settings) + + # Action buttons + self._action_buttons.scan_clicked.connect(self._start_scan) + self._action_buttons.stop_clicked.connect(self._stop_scan) + self._action_buttons.select_clicked.connect(self._show_select_dialog) + self._action_buttons.delete_clicked.connect(self._show_delete_dialog) + self._action_buttons.move_clicked.connect(self._show_move_dialog) + self._action_buttons.save_clicked.connect(self._save_results) + self._action_buttons.sort_clicked.connect(self._show_sort_dialog) + self._action_buttons.hardlink_clicked.connect(self._create_hardlinks) + self._action_buttons.symlink_clicked.connect(self._create_symlinks) + self._action_buttons.rename_clicked.connect(self._rename_files) + self._action_buttons.clean_exif_clicked.connect(self._clean_exif) + self._action_buttons.optimize_video_clicked.connect(self._optimize_video) + + # Results view + self._results_view.selection_changed.connect( + lambda count: self._action_buttons.set_has_selection(count > 0) + ) + self._results_view.item_activated.connect(self._on_item_activated) + + # Scan runner + self._scan_runner.finished.connect(self._on_scan_finished) + self._scan_runner.progress.connect(self._on_scan_progress) + self._scan_runner.error.connect(self._on_scan_error) + + # Settings + self._settings_panel.close_requested.connect( + lambda: self._settings_panel.setVisible(False) + ) + self._settings_panel.settings_changed.connect(self._on_settings_changed) + + # Bottom panel + self._bottom_panel.directories_changed.connect(self._on_settings_changed) + + def _on_tab_changed(self, tab: ActiveTab): + self._state.set_active_tab(tab) + self._results_view.set_active_tab(tab) + self._action_buttons.set_active_tab(tab) + self._tool_settings.set_active_tab(tab) + + # Show/hide preview for image-related tabs + show_preview = ( + tab in (ActiveTab.SIMILAR_IMAGES, ActiveTab.DUPLICATE_FILES) + and self._state.settings.show_image_preview + ) + self._preview.setVisible(show_preview) + + # Load existing results for this tab + results = self._state.get_results(tab) + if results: + self._results_view.set_results(results) + self._action_buttons.set_has_results(True) + else: + self._results_view.clear() + self._action_buttons.set_has_results(False) + + self._status_label.setText(f"Tab: {tab.name.replace('_', ' ').title()}") + + def _start_scan(self): + tab = self._state.active_tab + if not self._state.settings.included_paths: + QMessageBox.warning( + self, "No Directories", + "Please add at least one directory to scan in the bottom panel." + ) + return + + self._state.set_scanning(True) + self._action_buttons.set_scanning(True) + self._progress.start(tab) + self._results_view.clear() + self._status_label.setText(f"Scanning: {tab.name.replace('_', ' ').title()}...") + + self._scan_runner.start_scan( + tab, self._state.settings, self._state.tool_settings + ) + + def _stop_scan(self): + self._state.request_stop() + self._scan_runner.stop_scan() + self._status_label.setText("Scan stopped by user") + + def _on_scan_finished(self, tab: ActiveTab, results: list): + self._state.set_scanning(False) + self._state.set_results(tab, results) + self._action_buttons.set_scanning(False) + self._progress.stop() + + if tab == self._state.active_tab: + self._results_view.set_results(results) + self._action_buttons.set_has_results(len(results) > 0) + + count = sum(1 for r in results if not r.header_row) + self._status_label.setText(f"Scan complete: found {count} entries") + + def _on_scan_progress(self, progress): + self._progress.update_progress(progress) + + def _on_scan_error(self, error_msg: str): + self._state.set_scanning(False) + self._action_buttons.set_scanning(False) + self._progress.stop() + self._status_label.setText(f"Error: {error_msg}") + self._bottom_panel.set_text(f"Error: {error_msg}") + self._bottom_panel.show_text() + QMessageBox.critical(self, "Scan Error", error_msg) + + def _on_item_activated(self, entry): + path = entry.values.get("__full_path", "") + if path and self._preview.isVisible(): + self._preview.show_preview(path) + + def _show_settings(self): + self._settings_panel.setVisible(True) + # Show as a floating window + self._settings_panel.setParent(None) + self._settings_panel.setWindowTitle("Czkawka Settings") + self._settings_panel.setMinimumSize(600, 500) + self._settings_panel.show() + self._settings_panel.raise_() + + def _show_about(self): + dialog = AboutDialog(self) + dialog.exec() + + def _toggle_tool_settings(self, visible: bool): + self._tool_settings.setVisible(visible) + + def _show_select_dialog(self): + dialog = SelectDialog(self) + dialog.mode_selected.connect(self._results_view.apply_selection) + dialog.exec() + + def _show_delete_dialog(self): + checked = self._results_view.get_checked_entries() + if not checked: + QMessageBox.information(self, "No Selection", "No files selected for deletion.") + return + + dialog = DeleteDialog(len(checked), self._state.settings.move_to_trash, self) + if dialog.exec() == DeleteDialog.Accepted: + deleted, errors = FileOperations.delete_files( + checked, dialog.move_to_trash + ) + self._status_label.setText(f"Deleted {deleted} file(s)") + if errors: + self._bottom_panel.set_text("\n".join(errors)) + self._bottom_panel.show_text() + # Refresh results - remove deleted entries + self._refresh_after_action(checked) + + def _show_move_dialog(self): + checked = self._results_view.get_checked_entries() + if not checked: + QMessageBox.information(self, "No Selection", "No files selected.") + return + + dialog = MoveDialog(len(checked), self) + if dialog.exec() == MoveDialog.Accepted: + if not dialog.destination: + QMessageBox.warning(self, "No Destination", "Please select a destination folder.") + return + moved, errors = FileOperations.move_files( + checked, dialog.destination, + dialog.preserve_structure, dialog.copy_mode + ) + action = "Copied" if dialog.copy_mode else "Moved" + self._status_label.setText(f"{action} {moved} file(s)") + if errors: + self._bottom_panel.set_text("\n".join(errors)) + self._bottom_panel.show_text() + if not dialog.copy_mode: + self._refresh_after_action(checked) + + def _save_results(self): + results = self._results_view.get_all_entries() + if not results: + QMessageBox.information(self, "No Results", "No results to save.") + return + all_results = self._state.get_results() + success = SaveDialog.save(self, all_results, self._state.settings.save_as_json) + if success: + self._status_label.setText("Results saved successfully") + + def _show_sort_dialog(self): + columns = TAB_COLUMNS.get(self._state.active_tab, []) + if not columns: + return + dialog = SortDialog(columns, self) + dialog.sort_requested.connect(self._results_view.sort_by_column) + dialog.exec() + + def _create_hardlinks(self): + checked = self._results_view.get_checked_entries() + if not checked: + return + + # Find the reference file (first unchecked in the same group) + all_results = self._state.get_results() + group_id = checked[0].group_id + reference = None + for r in all_results: + if r.group_id == group_id and not r.header_row and not r.checked: + reference = r.values.get("__full_path", "") + break + + if not reference: + QMessageBox.warning( + self, "No Reference", + "Cannot determine reference file. Leave at least one file unchecked in the group." + ) + return + + reply = QMessageBox.question( + self, "Create Hardlinks", + f"Replace {len(checked)} selected file(s) with hardlinks to:\n{reference}?", + QMessageBox.Yes | QMessageBox.No + ) + if reply == QMessageBox.Yes: + created, errors = FileOperations.create_hardlinks(checked, reference) + self._status_label.setText(f"Created {created} hardlink(s)") + if errors: + self._bottom_panel.set_text("\n".join(errors)) + self._bottom_panel.show_text() + + def _create_symlinks(self): + checked = self._results_view.get_checked_entries() + if not checked: + return + + all_results = self._state.get_results() + group_id = checked[0].group_id + reference = None + for r in all_results: + if r.group_id == group_id and not r.header_row and not r.checked: + reference = r.values.get("__full_path", "") + break + + if not reference: + QMessageBox.warning( + self, "No Reference", + "Cannot determine reference file. Leave at least one file unchecked in the group." + ) + return + + reply = QMessageBox.question( + self, "Create Symlinks", + f"Replace {len(checked)} selected file(s) with symlinks to:\n{reference}?", + QMessageBox.Yes | QMessageBox.No + ) + if reply == QMessageBox.Yes: + created, errors = FileOperations.create_symlinks(checked, reference) + self._status_label.setText(f"Created {created} symlink(s)") + if errors: + self._bottom_panel.set_text("\n".join(errors)) + self._bottom_panel.show_text() + + def _rename_files(self): + checked = self._results_view.get_checked_entries() + if not checked: + return + + tab = self._state.active_tab + if tab == ActiveTab.BAD_EXTENSIONS: + dialog = RenameDialog(len(checked), "extensions", self) + if dialog.exec() == RenameDialog.Accepted: + success, msg = FileOperations.fix_extensions( + self._state.settings.czkawka_cli_path, + self._state.settings, self._state.tool_settings + ) + self._status_label.setText("Extensions fixed" if success else f"Error: {msg}") + elif tab == ActiveTab.BAD_NAMES: + dialog = RenameDialog(len(checked), "names", self) + if dialog.exec() == RenameDialog.Accepted: + success, msg = FileOperations.fix_bad_names( + self._state.settings.czkawka_cli_path, + self._state.settings, self._state.tool_settings + ) + self._status_label.setText("Names fixed" if success else f"Error: {msg}") + + def _clean_exif(self): + checked = self._results_view.get_checked_entries() + if not checked: + return + + reply = QMessageBox.question( + self, "Clean EXIF", + f"Remove EXIF metadata from {len(checked)} selected file(s)?", + QMessageBox.Yes | QMessageBox.No + ) + if reply == QMessageBox.Yes: + cleaned, errors = FileOperations.clean_exif( + self._state.settings.czkawka_cli_path, + checked, + self._state.tool_settings.exif_ignored_tags + ) + self._status_label.setText(f"Cleaned EXIF from {cleaned} file(s)") + if errors: + self._bottom_panel.set_text("\n".join(errors)) + self._bottom_panel.show_text() + + def _optimize_video(self): + checked = self._results_view.get_checked_entries() + if not checked: + return + + QMessageBox.information( + self, "Video Optimization", + f"Video optimization for {len(checked)} file(s) will be performed " + "using czkawka_cli. Check the status bar for progress." + ) + # Video optimization is done via CLI + self._status_label.setText("Video optimization: use CLI directly for this feature") + + def _refresh_after_action(self, removed_entries: list): + """Remove processed entries from results and refresh the view.""" + removed_paths = {e.values.get("__full_path") for e in removed_entries} + current_results = self._state.get_results() + new_results = [] + for r in current_results: + if r.header_row: + new_results.append(r) + elif r.values.get("__full_path") not in removed_paths: + new_results.append(r) + + # Remove empty group headers + cleaned = [] + i = 0 + while i < len(new_results): + if new_results[i].header_row: + # Check if next entries belong to this group + has_children = False + for j in range(i + 1, len(new_results)): + if new_results[j].header_row: + break + has_children = True + if has_children: + cleaned.append(new_results[i]) + else: + cleaned.append(new_results[i]) + i += 1 + + self._state.set_results(self._state.active_tab, cleaned) + self._results_view.set_results(cleaned) + self._action_buttons.set_has_results( + any(not r.header_row for r in cleaned) + ) + + def _on_settings_changed(self): + self._state.save_settings() + self._bottom_panel.refresh_lists() + + def _apply_theme(self): + """Apply dark theme to the application.""" + if not self._state.settings.dark_theme: + return + + app = QApplication.instance() + palette = QPalette() + + # Dark theme colors + palette.setColor(QPalette.Window, QColor(43, 43, 43)) + palette.setColor(QPalette.WindowText, QColor(210, 210, 210)) + palette.setColor(QPalette.Base, QColor(30, 30, 30)) + palette.setColor(QPalette.AlternateBase, QColor(38, 38, 38)) + palette.setColor(QPalette.ToolTipBase, QColor(50, 50, 50)) + palette.setColor(QPalette.ToolTipText, QColor(210, 210, 210)) + palette.setColor(QPalette.Text, QColor(210, 210, 210)) + palette.setColor(QPalette.Button, QColor(53, 53, 53)) + palette.setColor(QPalette.ButtonText, QColor(210, 210, 210)) + palette.setColor(QPalette.BrightText, QColor(255, 255, 255)) + palette.setColor(QPalette.Link, QColor(86, 140, 210)) + palette.setColor(QPalette.Highlight, QColor(60, 100, 150)) + palette.setColor(QPalette.HighlightedText, QColor(255, 255, 255)) + palette.setColor(QPalette.Disabled, QPalette.Text, QColor(128, 128, 128)) + palette.setColor(QPalette.Disabled, QPalette.ButtonText, QColor(128, 128, 128)) + + app.setPalette(palette) + + # Additional stylesheet + app.setStyleSheet(""" + QMainWindow { background-color: #2b2b2b; } + QSplitter::handle { background-color: #404040; width: 2px; } + QTreeWidget { border: 1px solid #404040; } + QTreeWidget::item { padding: 2px; } + QTreeWidget::item:alternate { background-color: #262626; } + QTreeWidget::item:selected { background-color: #3c6496; } + QListWidget { border: 1px solid #404040; } + QListWidget::item { padding: 3px; } + QListWidget::item:selected { background-color: #3c6496; } + QGroupBox { border: 1px solid #505050; border-radius: 4px; + margin-top: 8px; padding-top: 8px; } + QGroupBox::title { subcontrol-origin: margin; left: 10px; + padding: 0 4px; } + QPushButton { padding: 5px 12px; border: 1px solid #555; + border-radius: 3px; background-color: #404040; } + QPushButton:hover { background-color: #505050; } + QPushButton:pressed { background-color: #353535; } + QPushButton:disabled { background-color: #333; color: #666; } + QComboBox { padding: 4px; border: 1px solid #555; + border-radius: 3px; background-color: #404040; } + QComboBox:hover { background-color: #505050; } + QComboBox QAbstractItemView { background-color: #353535; + border: 1px solid #555; + selection-background-color: #3c6496; } + QLineEdit { padding: 4px; border: 1px solid #555; + border-radius: 3px; background-color: #353535; } + QSpinBox { padding: 4px; border: 1px solid #555; + border-radius: 3px; background-color: #353535; } + QSlider::groove:horizontal { height: 6px; background: #404040; + border-radius: 3px; } + QSlider::handle:horizontal { width: 14px; margin: -4px 0; + background: #888; border-radius: 7px; } + QSlider::handle:horizontal:hover { background: #aaa; } + QProgressBar { border: 1px solid #555; border-radius: 3px; + text-align: center; background-color: #353535; } + QProgressBar::chunk { background-color: #3c6496; border-radius: 2px; } + QScrollArea { border: none; } + QTabWidget::pane { border: 1px solid #555; } + QTabBar::tab { padding: 6px 16px; border: 1px solid #555; + border-bottom: none; border-radius: 3px 3px 0 0; + background-color: #353535; } + QTabBar::tab:selected { background-color: #404040; } + QTabBar::tab:hover { background-color: #505050; } + QStatusBar { background-color: #2b2b2b; border-top: 1px solid #404040; } + QCheckBox { spacing: 6px; } + QCheckBox::indicator { width: 16px; height: 16px; } + QTextEdit { border: 1px solid #404040; background-color: #1e1e1e; } + QHeaderView::section { background-color: #353535; padding: 4px; + border: 1px solid #404040; } + """) + + def _auto_detect_cli(self): + """Auto-detect czkawka_cli binary location.""" + s = self._state.settings + + # If already valid and exists, keep it + if s.czkawka_cli_path != "czkawka_cli" and Path(s.czkawka_cli_path).is_file(): + return + if shutil.which(s.czkawka_cli_path): + return + + # Search common locations + candidates = [] + project_root = Path(__file__).parent.parent.parent + + # Look for compiled binary in standard target dirs + for build_dir in ["target/release", "target/debug"]: + candidate = project_root / build_dir / "czkawka_cli" + if candidate.exists(): + candidates.append(str(candidate)) + + # Check cargo metadata for custom target directory + if not candidates: + try: + import subprocess, json + result = subprocess.run( + ["cargo", "metadata", "--manifest-path", + str(project_root / "Cargo.toml"), + "--format-version", "1", "--no-deps"], + capture_output=True, text=True, timeout=10 + ) + if result.returncode == 0: + meta = json.loads(result.stdout) + target_dir = meta.get("target_directory", "") + if target_dir: + for sub in ["release", "debug"]: + candidate = Path(target_dir) / sub / "czkawka_cli" + if candidate.exists(): + candidates.append(str(candidate)) + except Exception: + pass + + # Also check PATH + which_result = shutil.which("czkawka_cli") + if which_result: + candidates.append(which_result) + + for candidate in candidates: + if Path(candidate).is_file(): + s.czkawka_cli_path = str(candidate) + self._state.save_settings() + return + + def closeEvent(self, event): + """Save settings on close.""" + self._state.save_settings() + if self._state.scanning: + self._scan_runner.stop_scan() + super().closeEvent(event) diff --git a/czkawka_pyside6/app/models.py b/czkawka_pyside6/app/models.py new file mode 100644 index 000000000..306772529 --- /dev/null +++ b/czkawka_pyside6/app/models.py @@ -0,0 +1,305 @@ +from enum import Enum, auto +from dataclasses import dataclass, field +from typing import Optional +from pathlib import Path + + +class ActiveTab(Enum): + DUPLICATE_FILES = auto() + EMPTY_FOLDERS = auto() + BIG_FILES = auto() + EMPTY_FILES = auto() + TEMPORARY_FILES = auto() + SIMILAR_IMAGES = auto() + SIMILAR_VIDEOS = auto() + SIMILAR_MUSIC = auto() + INVALID_SYMLINKS = auto() + BROKEN_FILES = auto() + BAD_EXTENSIONS = auto() + BAD_NAMES = auto() + EXIF_REMOVER = auto() + VIDEO_OPTIMIZER = auto() + SETTINGS = auto() + ABOUT = auto() + + +class SelectMode(Enum): + SELECT_ALL = auto() + UNSELECT_ALL = auto() + INVERT_SELECTION = auto() + SELECT_BIGGEST_SIZE = auto() + SELECT_BIGGEST_RESOLUTION = auto() + SELECT_SMALLEST_SIZE = auto() + SELECT_SMALLEST_RESOLUTION = auto() + SELECT_NEWEST = auto() + SELECT_OLDEST = auto() + SELECT_SHORTEST_PATH = auto() + SELECT_LONGEST_PATH = auto() + SELECT_CUSTOM = auto() + + +class DeleteMethod(Enum): + NONE = "NONE" + DELETE = "DELETE" + ALL_EXCEPT_NEWEST = "AEN" + ALL_EXCEPT_OLDEST = "AEO" + ONE_OLDEST = "OO" + ONE_NEWEST = "ON" + HARDLINK = "HARD" + ALL_EXCEPT_BIGGEST = "AEB" + ALL_EXCEPT_SMALLEST = "AES" + ONE_BIGGEST = "OB" + ONE_SMALLEST = "OS" + + +class CheckingMethod(Enum): + HASH = "HASH" + SIZE = "SIZE" + NAME = "NAME" + SIZE_NAME = "SIZE_NAME" + + +class HashType(Enum): + BLAKE3 = "BLAKE3" + CRC32 = "CRC32" + XXH3 = "XXH3" + + +class ImageHashAlg(Enum): + MEAN = "Mean" + GRADIENT = "Gradient" + BLOCKHASH = "Blockhash" + VERT_GRADIENT = "VertGradient" + DOUBLE_GRADIENT = "DoubleGradient" + MEDIAN = "Median" + + +class ImageFilter(Enum): + LANCZOS3 = "Lanczos3" + NEAREST = "Nearest" + TRIANGLE = "Triangle" + GAUSSIAN = "Gaussian" + CATMULL_ROM = "CatmullRom" + + +class MusicSearchMethod(Enum): + TAGS = "TAGS" + CONTENT = "CONTENT" + + +class VideoCropDetect(Enum): + NONE = "none" + LETTERBOX = "letterbox" + MOTION = "motion" + + +class VideoCropMechanism(Enum): + BLACKBARS = "blackbars" + STATICCONTENT = "staticcontent" + + +class VideoCodec(Enum): + H264 = "h264" + H265 = "h265" + AV1 = "av1" + VP9 = "vp9" + + +# Map ActiveTab to CLI subcommand names +TAB_TO_CLI_COMMAND = { + ActiveTab.DUPLICATE_FILES: "dup", + ActiveTab.EMPTY_FOLDERS: "empty-folders", + ActiveTab.BIG_FILES: "big", + ActiveTab.EMPTY_FILES: "empty-files", + ActiveTab.TEMPORARY_FILES: "temp", + ActiveTab.SIMILAR_IMAGES: "image", + ActiveTab.SIMILAR_VIDEOS: "video", + ActiveTab.SIMILAR_MUSIC: "music", + ActiveTab.INVALID_SYMLINKS: "symlinks", + ActiveTab.BROKEN_FILES: "broken", + ActiveTab.BAD_EXTENSIONS: "ext", + ActiveTab.BAD_NAMES: "bad-names", + ActiveTab.EXIF_REMOVER: "exif-remover", + ActiveTab.VIDEO_OPTIMIZER: "video-optimizer", +} + +TAB_DISPLAY_NAMES = { + ActiveTab.DUPLICATE_FILES: "Duplicate Files", + ActiveTab.EMPTY_FOLDERS: "Empty Folders", + ActiveTab.BIG_FILES: "Big Files", + ActiveTab.EMPTY_FILES: "Empty Files", + ActiveTab.TEMPORARY_FILES: "Temporary Files", + ActiveTab.SIMILAR_IMAGES: "Similar Images", + ActiveTab.SIMILAR_VIDEOS: "Similar Videos", + ActiveTab.SIMILAR_MUSIC: "Similar Music", + ActiveTab.INVALID_SYMLINKS: "Invalid Symlinks", + ActiveTab.BROKEN_FILES: "Broken Files", + ActiveTab.BAD_EXTENSIONS: "Bad Extensions", + ActiveTab.BAD_NAMES: "Bad Names", + ActiveTab.EXIF_REMOVER: "EXIF Remover", + ActiveTab.VIDEO_OPTIMIZER: "Video Optimizer", +} + +# Which tabs support grouping (results are in groups) +GROUPED_TABS = { + ActiveTab.DUPLICATE_FILES, + ActiveTab.SIMILAR_IMAGES, + ActiveTab.SIMILAR_VIDEOS, + ActiveTab.SIMILAR_MUSIC, +} + +# Which tabs have per-tool settings +TABS_WITH_SETTINGS = { + ActiveTab.DUPLICATE_FILES, + ActiveTab.SIMILAR_IMAGES, + ActiveTab.SIMILAR_VIDEOS, + ActiveTab.SIMILAR_MUSIC, + ActiveTab.BIG_FILES, + ActiveTab.BROKEN_FILES, + ActiveTab.BAD_NAMES, + ActiveTab.EXIF_REMOVER, + ActiveTab.VIDEO_OPTIMIZER, +} + +# Column definitions per tab +TAB_COLUMNS = { + ActiveTab.DUPLICATE_FILES: ["Selection", "Size", "File Name", "Path", "Modification Date", "Hash"], + ActiveTab.EMPTY_FOLDERS: ["Selection", "Folder Name", "Path", "Modification Date"], + ActiveTab.BIG_FILES: ["Selection", "Size", "File Name", "Path", "Modification Date"], + ActiveTab.EMPTY_FILES: ["Selection", "File Name", "Path", "Modification Date"], + ActiveTab.TEMPORARY_FILES: ["Selection", "File Name", "Path", "Modification Date"], + ActiveTab.SIMILAR_IMAGES: ["Selection", "Similarity", "Size", "Resolution", "File Name", "Path", "Modification Date", "Hash"], + ActiveTab.SIMILAR_VIDEOS: ["Selection", "Size", "File Name", "Path", "Modification Date"], + ActiveTab.SIMILAR_MUSIC: ["Selection", "Size", "File Name", "Path", "Title", "Artist", "Year", "Bitrate", "Genre", "Length"], + ActiveTab.INVALID_SYMLINKS: ["Selection", "Symlink Name", "Symlink Path", "Destination Path", "Type of Error"], + ActiveTab.BROKEN_FILES: ["Selection", "File Name", "Path", "Error Type", "Size", "Modification Date"], + ActiveTab.BAD_EXTENSIONS: ["Selection", "File Name", "Path", "Current Extension", "Proper Extension"], + ActiveTab.BAD_NAMES: ["Selection", "File Name", "Path", "Error Type"], + ActiveTab.EXIF_REMOVER: ["Selection", "File Name", "Path"], + ActiveTab.VIDEO_OPTIMIZER: ["Selection", "File Name", "Path", "Size", "Codec"], +} + + +@dataclass +class ResultEntry: + """A single result entry from a scan.""" + values: dict # column_name -> value + checked: bool = False + header_row: bool = False # Group header for grouped results + group_id: int = 0 + + +@dataclass +class ScanProgress: + """Progress information during scanning.""" + step_name: str = "" + current: int = 0 + total: int = 0 + current_size: int = 0 + # Raw fields from czkawka_cli --json-progress + stage_name: str = "" + current_stage_idx: int = 0 + max_stage_idx: int = 0 + entries_checked: int = 0 + entries_to_check: int = 0 + bytes_checked: int = 0 + bytes_to_check: int = 0 + + +@dataclass +class ToolSettings: + """Per-tool settings that map to CLI arguments.""" + # Duplicates + dup_check_method: CheckingMethod = CheckingMethod.HASH + dup_hash_type: HashType = HashType.BLAKE3 + dup_name_case_sensitive: bool = False + dup_min_size: str = "8192" + dup_max_size: str = "" + dup_min_cache_size: str = "257144" + dup_use_prehash: bool = True + dup_min_prehash_cache_size: str = "257144" + + # Similar Images + img_hash_size: int = 16 # 8, 16, 32, 64 + img_filter: ImageFilter = ImageFilter.NEAREST + img_hash_alg: ImageHashAlg = ImageHashAlg.GRADIENT + img_ignore_same_size: bool = False + img_max_difference: int = 5 # 0-40 + + # Similar Videos + vid_ignore_same_size: bool = False + vid_crop_detect: VideoCropDetect = VideoCropDetect.LETTERBOX + vid_max_difference: int = 10 # 0-20 + vid_skip_forward: int = 15 # 0-300 + vid_duration: int = 10 # 1-60 + + # Similar Music + music_search_method: MusicSearchMethod = MusicSearchMethod.TAGS + music_approximate: bool = False + music_title: bool = True + music_artist: bool = True + music_bitrate: bool = False + music_genre: bool = False + music_year: bool = False + music_length: bool = False + music_compare_fingerprints_similar_titles: bool = False + music_max_difference: float = 2.0 + music_min_segment_duration: float = 10.0 + + # Big Files + big_files_mode: str = "biggest" # biggest or smallest + big_files_number: int = 50 + + # Broken Files + broken_audio: bool = True + broken_pdf: bool = True + broken_archive: bool = True + broken_image: bool = True + broken_video: bool = False + + # Bad Names + bad_names_uppercase_ext: bool = True + bad_names_emoji: bool = True + bad_names_space: bool = True + bad_names_non_ascii: bool = True + bad_names_restricted_charset: str = "" + bad_names_remove_duplicated: bool = False + + # EXIF Remover + exif_ignored_tags: str = "" + + # Video Optimizer + video_opt_mode: str = "crop" # crop or transcode + video_crop_mechanism: VideoCropMechanism = VideoCropMechanism.BLACKBARS + video_black_pixel_threshold: int = 32 + video_black_bar_percentage: int = 90 + video_max_samples: int = 20 + video_min_crop_size: int = 10 + video_excluded_codecs: str = "h265,hevc,av1,vp9" + video_codec: VideoCodec = VideoCodec.H265 + video_quality: int = 23 + video_fail_if_bigger: bool = False + video_overwrite: bool = False + video_max_width: int = 1920 + video_max_height: int = 1080 + + +@dataclass +class AppSettings: + """Global application settings.""" + included_paths: list = field(default_factory=lambda: [str(Path.home())]) + excluded_paths: list = field(default_factory=list) + excluded_items: str = "" + allowed_extensions: str = "" + excluded_extensions: str = "" + minimum_file_size: str = "" + maximum_file_size: str = "" + recursive_search: bool = True + use_cache: bool = True + save_as_json: bool = False + move_to_trash: bool = True + hide_hard_links: bool = False + thread_number: int = 0 # 0 = all available + dark_theme: bool = True + show_image_preview: bool = True + czkawka_cli_path: str = "czkawka_cli" # path to CLI binary diff --git a/czkawka_pyside6/app/preview_panel.py b/czkawka_pyside6/app/preview_panel.py new file mode 100644 index 000000000..3e3c50586 --- /dev/null +++ b/czkawka_pyside6/app/preview_panel.py @@ -0,0 +1,112 @@ +from pathlib import Path + +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QLabel, QSizePolicy +) +from PySide6.QtCore import Qt, QSize +from PySide6.QtGui import QPixmap, QImage + + +class PreviewPanel(QWidget): + """Image preview panel for similar images / duplicate files.""" + + SUPPORTED_EXTENSIONS = { + ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", + ".tiff", ".tif", ".ico", ".svg" + } + + def __init__(self, parent=None): + super().__init__(parent) + self.setMinimumWidth(200) + self.setMaximumWidth(400) + self._current_path = "" + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(4, 4, 4, 4) + + self._title = QLabel("Preview") + self._title.setStyleSheet("font-weight: bold; font-size: 12px; padding: 2px;") + self._title.setAlignment(Qt.AlignCenter) + layout.addWidget(self._title) + + self._image_label = QLabel() + self._image_label.setAlignment(Qt.AlignCenter) + self._image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self._image_label.setMinimumSize(QSize(180, 180)) + self._image_label.setStyleSheet( + "background-color: #1a1a1a; border: 1px solid #333; border-radius: 4px;" + ) + self._image_label.setScaledContents(False) + layout.addWidget(self._image_label) + + self._info_label = QLabel() + self._info_label.setAlignment(Qt.AlignCenter) + self._info_label.setWordWrap(True) + self._info_label.setStyleSheet("color: #888; font-size: 10px; padding: 2px;") + layout.addWidget(self._info_label) + + def show_preview(self, file_path: str): + if not file_path or file_path == self._current_path: + return + + self._current_path = file_path + p = Path(file_path) + + if not p.exists(): + self._image_label.setText("File not found") + self._info_label.setText("") + return + + if p.suffix.lower() not in self.SUPPORTED_EXTENSIONS: + self._image_label.setText("Preview not available\nfor this file type") + self._info_label.setText(p.name) + return + + pixmap = QPixmap(file_path) + if pixmap.isNull(): + self._image_label.setText("Cannot load image") + self._info_label.setText(p.name) + return + + # Scale to fit while keeping aspect ratio + label_size = self._image_label.size() + scaled = pixmap.scaled( + label_size, Qt.KeepAspectRatio, Qt.SmoothTransformation + ) + self._image_label.setPixmap(scaled) + + # Show info + size = p.stat().st_size + size_str = self._format_size(size) + self._info_label.setText( + f"{p.name}\n{pixmap.width()}x{pixmap.height()} | {size_str}" + ) + self._title.setText("Preview") + + def clear_preview(self): + self._current_path = "" + self._image_label.clear() + self._image_label.setText("No preview") + self._info_label.setText("") + + def resizeEvent(self, event): + super().resizeEvent(event) + # Re-render if we have a current image + if self._current_path: + path = self._current_path + self._current_path = "" + self.show_preview(path) + + @staticmethod + def _format_size(size_bytes: int) -> str: + if size_bytes == 0: + return "0 B" + units = ["B", "KB", "MB", "GB"] + i = 0 + size = float(size_bytes) + while size >= 1024 and i < len(units) - 1: + size /= 1024 + i += 1 + return f"{size:.1f} {units[i]}" if i > 0 else f"{int(size)} B" diff --git a/czkawka_pyside6/app/progress_widget.py b/czkawka_pyside6/app/progress_widget.py new file mode 100644 index 000000000..185e0790d --- /dev/null +++ b/czkawka_pyside6/app/progress_widget.py @@ -0,0 +1,325 @@ +import json +import time +from pathlib import Path + +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QProgressBar, QLabel, QHBoxLayout +) +from PySide6.QtCore import Qt, QTimer + +from .models import ActiveTab, ScanProgress + + +class ProgressWidget(QWidget): + """Two-bar progress widget matching Slint/Krokiet feature parity. + + Shows: + - Current stage progress bar (entries or bytes within one stage) + - Overall progress bar (across all stages) + - Stage name with counts + - Elapsed time + - Phase step indicators + """ + + # File where we persist the last file-collection count per directory set, + # so we can estimate the collection stage percentage on the next scan. + _ESTIMATE_FILE = Path.home() / ".config" / "czkawka_pyside6" / "scan_estimates.json" + + _BAR_STYLE = """ + QProgressBar {{ + border: 1px solid #555; border-radius: 3px; + text-align: center; background-color: #2a2a2a; + font-size: 10px; color: #ccc; + }} + QProgressBar::chunk {{ + background: qlineargradient(x1:0, y1:0, x2:1, y2:0, + stop:0 {c1}, stop:1 {c2}); + border-radius: 2px; + }} + """ + + def __init__(self, parent=None): + super().__init__(parent) + self.setVisible(False) + self._start_time = 0.0 + self._active_tab = ActiveTab.DUPLICATE_FILES + self._last_collection_count = 0 # Files found during collection phase + self._estimates: dict[str, int] = {} + self._load_estimates() + self._setup_ui() + + self._timer = QTimer(self) + self._timer.setInterval(500) + self._timer.timeout.connect(self._update_elapsed) + + # ── UI setup ────────────────────────────────────────────── + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 6, 8, 6) + layout.setSpacing(3) + + # Row 1: stage label + elapsed + row1 = QHBoxLayout() + self._stage_label = QLabel("Initializing...") + self._stage_label.setStyleSheet("font-weight: bold; font-size: 12px;") + row1.addWidget(self._stage_label) + row1.addStretch() + self._elapsed_label = QLabel("") + self._elapsed_label.setStyleSheet("color: #888; font-size: 11px;") + row1.addWidget(self._elapsed_label) + layout.addLayout(row1) + + # Row 2: current stage bar "Current stage" NN% + row2 = QHBoxLayout() + row2.setSpacing(6) + lbl2 = QLabel("Current") + lbl2.setStyleSheet("color: #aaa; font-size: 10px;") + lbl2.setFixedWidth(48) + row2.addWidget(lbl2) + self._stage_bar = QProgressBar() + self._stage_bar.setFixedHeight(14) + self._stage_bar.setTextVisible(False) + self._stage_bar.setStyleSheet( + self._BAR_STYLE.format(c1="#1b4332", c2="#2d6a4f") + ) + row2.addWidget(self._stage_bar) + self._stage_pct = QLabel("") + self._stage_pct.setFixedWidth(40) + self._stage_pct.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self._stage_pct.setStyleSheet("color: #aaa; font-size: 10px;") + row2.addWidget(self._stage_pct) + layout.addLayout(row2) + + # Row 3: overall bar "Overall" NN% + row3 = QHBoxLayout() + row3.setSpacing(6) + lbl3 = QLabel("Overall") + lbl3.setStyleSheet("color: #aaa; font-size: 10px;") + lbl3.setFixedWidth(48) + row3.addWidget(lbl3) + self._overall_bar = QProgressBar() + self._overall_bar.setFixedHeight(14) + self._overall_bar.setTextVisible(False) + self._overall_bar.setStyleSheet( + self._BAR_STYLE.format(c1="#2d6a4f", c2="#40916c") + ) + row3.addWidget(self._overall_bar) + self._overall_pct = QLabel("") + self._overall_pct.setFixedWidth(40) + self._overall_pct.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + self._overall_pct.setStyleSheet("color: #aaa; font-size: 10px;") + row3.addWidget(self._overall_pct) + layout.addLayout(row3) + + # Row 4: detail counts + row4 = QHBoxLayout() + self._detail_label = QLabel("") + self._detail_label.setStyleSheet("color: #888; font-size: 10px;") + row4.addWidget(self._detail_label) + row4.addStretch() + self._size_label = QLabel("") + self._size_label.setStyleSheet("color: #888; font-size: 10px;") + self._size_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + row4.addWidget(self._size_label) + layout.addLayout(row4) + + # Row 5: step indicators + self._steps_label = QLabel("") + self._steps_label.setStyleSheet("color: #666; font-size: 9px;") + self._steps_label.setAlignment(Qt.AlignCenter) + self._steps_label.setWordWrap(True) + layout.addWidget(self._steps_label) + + # ── Public API ──────────────────────────────────────────── + + def start(self, tab: ActiveTab = None): + if tab is not None: + self._active_tab = tab + self._start_time = time.monotonic() + self._last_collection_count = 0 + self.setVisible(True) + + for bar in (self._stage_bar, self._overall_bar): + bar.setMaximum(0) # indeterminate + bar.setValue(0) + self._stage_pct.setText("") + self._overall_pct.setText("") + self._stage_label.setText("Starting scan...") + self._detail_label.setText("") + self._size_label.setText("") + self._elapsed_label.setText("0s") + self._steps_label.setText("") + self._timer.start() + + def stop(self): + self._timer.stop() + elapsed = time.monotonic() - self._start_time if self._start_time else 0 + self._elapsed_label.setText(f"Completed in {self._format_time(elapsed)}") + + for bar, lbl in ((self._stage_bar, self._stage_pct), + (self._overall_bar, self._overall_pct)): + bar.setMaximum(100) + bar.setValue(100) + lbl.setText("100%") + + self._stage_label.setText("Scan complete") + self._steps_label.setText("") + + # Save collection count for next-scan estimation + if self._last_collection_count > 0: + self._save_estimate(self._last_collection_count) + + QTimer.singleShot(3000, self._auto_hide) + + def update_progress(self, progress: ScanProgress): + """Main update method called by the scan runner.""" + stage_name = progress.stage_name or progress.step_name or "" + idx = progress.current_stage_idx + max_idx = progress.max_stage_idx + checked = progress.entries_checked + to_check = progress.entries_to_check + b_checked = progress.bytes_checked + b_to_check = progress.bytes_to_check + + # ── Stage label ── + self._stage_label.setText(stage_name) + + # ── Update step indicators using stage index ── + if max_idx > 0: + self._update_steps_from_index(idx, max_idx) + + # ── Current-stage bar ── + is_collecting = (idx == 0 and to_check == 0) + + if is_collecting: + # Collection phase: use estimate from previous scan + self._last_collection_count = max(self._last_collection_count, checked) + estimate = self._get_estimate() + if estimate > 0 and checked > 0: + pct = min(99, int(checked * 100 / estimate)) + self._stage_bar.setMaximum(100) + self._stage_bar.setValue(pct) + self._stage_pct.setText(f"~{pct}%") + self._detail_label.setText(f"{checked:,} / ~{estimate:,} files") + else: + self._stage_bar.setMaximum(0) # indeterminate + self._stage_pct.setText("") + self._detail_label.setText(f"{checked:,} files" if checked else "") + + elif to_check > 0: + # Normal stage with known total + if b_to_check > 0: + # Byte-based progress (hashing) + pct = min(99, int(b_checked * 100 / b_to_check)) + self._stage_bar.setMaximum(100) + self._stage_bar.setValue(pct) + self._stage_pct.setText(f"{pct}%") + self._detail_label.setText(f"{checked:,} / {to_check:,}") + self._size_label.setText( + f"{self._format_size(b_checked)} / {self._format_size(b_to_check)}" + ) + else: + # Entry-count based progress + pct = min(99, int(checked * 100 / to_check)) + self._stage_bar.setMaximum(100) + self._stage_bar.setValue(pct) + self._stage_pct.setText(f"{pct}%") + self._detail_label.setText(f"{checked:,} / {to_check:,}") + self._size_label.setText("") + + else: + # Cache loading/saving or unknown total + self._stage_bar.setMaximum(0) # indeterminate spinner + self._stage_pct.setText("") + self._detail_label.setText("") + self._size_label.setText("") + + # ── Overall bar ── + if max_idx > 0: + if to_check > 0: + stage_frac = (b_checked / b_to_check) if b_to_check > 0 else (checked / to_check) + elif is_collecting and self._get_estimate() > 0 and checked > 0: + stage_frac = min(0.99, checked / self._get_estimate()) + else: + stage_frac = 0 + overall = (idx + min(stage_frac, 0.99)) / (max_idx + 1) + overall_pct = min(99, int(overall * 100)) + self._overall_bar.setMaximum(100) + self._overall_bar.setValue(overall_pct) + self._overall_pct.setText(f"{overall_pct}%") + else: + self._overall_bar.setMaximum(0) + self._overall_pct.setText("") + + # ── Collection estimate persistence ─────────────────────── + + def _get_estimate_key(self) -> str: + """Key for the estimate cache based on active tab.""" + return self._active_tab.name + + def _get_estimate(self) -> int: + return self._estimates.get(self._get_estimate_key(), 0) + + def _save_estimate(self, count: int): + self._estimates[self._get_estimate_key()] = count + try: + self._ESTIMATE_FILE.parent.mkdir(parents=True, exist_ok=True) + self._ESTIMATE_FILE.write_text(json.dumps(self._estimates)) + except OSError: + pass + + def _load_estimates(self): + try: + if self._ESTIMATE_FILE.exists(): + self._estimates = json.loads(self._ESTIMATE_FILE.read_text()) + except (json.JSONDecodeError, OSError): + self._estimates = {} + + # ── Step indicator ──────────────────────────────────────── + + def _update_steps_from_index(self, current_idx: int, max_idx: int): + """Build step display directly from stage index.""" + total_stages = max_idx + 1 + parts = [] + for i in range(total_stages): + if i < current_idx: + parts.append(f"[{i+1} done]") + elif i == current_idx: + parts.append(f"[{i+1} >>]") + else: + parts.append(f"[{i+1}]") + self._steps_label.setText(" ".join(parts)) + + # ── Internals ───────────────────────────────────────────── + + def _auto_hide(self): + self.setVisible(False) + + def _update_elapsed(self): + elapsed = time.monotonic() - self._start_time + self._elapsed_label.setText(self._format_time(elapsed)) + + @staticmethod + def _format_time(seconds: float) -> str: + if seconds < 60: + return f"{int(seconds)}s" + minutes = int(seconds // 60) + secs = int(seconds % 60) + if minutes < 60: + return f"{minutes}m {secs}s" + hours = minutes // 60 + mins = minutes % 60 + return f"{hours}h {mins}m" + + @staticmethod + def _format_size(size_bytes: int) -> str: + if size_bytes == 0: + return "0 B" + units = ["B", "KB", "MB", "GB", "TB"] + i = 0 + size = float(size_bytes) + while size >= 1024 and i < len(units) - 1: + size /= 1024 + i += 1 + return f"{size:.1f} {units[i]}" if i > 0 else f"{int(size)} B" diff --git a/czkawka_pyside6/app/results_view.py b/czkawka_pyside6/app/results_view.py new file mode 100644 index 000000000..fc1aca09c --- /dev/null +++ b/czkawka_pyside6/app/results_view.py @@ -0,0 +1,280 @@ +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QTreeWidget, QTreeWidgetItem, QHeaderView, + QAbstractItemView, QMenu, QLabel, QHBoxLayout +) +from PySide6.QtCore import Signal, Qt +from PySide6.QtGui import QColor, QBrush, QFont, QAction + +from .models import ( + ActiveTab, ResultEntry, TAB_COLUMNS, GROUPED_TABS, SelectMode +) + + +class ResultsView(QWidget): + """Results display with tree view for grouped results and table for flat results.""" + + selection_changed = Signal(int) # number of selected items + item_activated = Signal(object) # ResultEntry + context_menu_requested = Signal(object, object) # QPoint, ResultEntry + + # Colors + HEADER_BG = QColor(60, 60, 80) + HEADER_FG = QColor(220, 220, 255) + SELECTED_BG = QColor(40, 80, 40) + + def __init__(self, parent=None): + super().__init__(parent) + self._active_tab = ActiveTab.DUPLICATE_FILES + self._results: list[ResultEntry] = [] + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + # Summary bar + summary_layout = QHBoxLayout() + self._summary_label = QLabel("No results") + self._summary_label.setStyleSheet("padding: 4px;") + summary_layout.addWidget(self._summary_label) + self._selection_label = QLabel("") + self._selection_label.setAlignment(Qt.AlignRight) + self._selection_label.setStyleSheet("padding: 4px; color: #aaa;") + summary_layout.addWidget(self._selection_label) + layout.addLayout(summary_layout) + + # Tree widget for results + self._tree = QTreeWidget() + self._tree.setSelectionMode(QAbstractItemView.ExtendedSelection) + self._tree.setRootIsDecorated(False) + self._tree.setAlternatingRowColors(True) + self._tree.setContextMenuPolicy(Qt.CustomContextMenu) + self._tree.customContextMenuRequested.connect(self._on_context_menu) + self._tree.itemChanged.connect(self._on_item_changed) + self._tree.itemDoubleClicked.connect(self._on_item_double_clicked) + layout.addWidget(self._tree) + + def set_active_tab(self, tab: ActiveTab): + self._active_tab = tab + columns = TAB_COLUMNS.get(tab, ["Selection", "File Name", "Path"]) + self._tree.setHeaderLabels(columns) + header = self._tree.header() + for i in range(len(columns)): + if columns[i] == "Path": + header.setSectionResizeMode(i, QHeaderView.Stretch) + else: + header.setSectionResizeMode(i, QHeaderView.ResizeToContents) + + def set_results(self, results: list[ResultEntry]): + self._results = results + self._tree.blockSignals(True) + self._tree.clear() + + columns = TAB_COLUMNS.get(self._active_tab, ["Selection", "File Name", "Path"]) + + for entry in results: + if entry.header_row: + item = QTreeWidgetItem() + header_text = entry.values.get("__header", "Group") + item.setText(0, header_text) + item.setFirstColumnSpanned(True) + font = QFont() + font.setBold(True) + item.setFont(0, font) + for col in range(len(columns)): + item.setBackground(col, QBrush(self.HEADER_BG)) + item.setForeground(col, QBrush(self.HEADER_FG)) + item.setFlags(item.flags() & ~Qt.ItemIsUserCheckable) + item.setData(0, Qt.UserRole, entry) + self._tree.addTopLevelItem(item) + else: + item = QTreeWidgetItem() + # First column is checkbox (Selection) + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + item.setCheckState(0, Qt.Checked if entry.checked else Qt.Unchecked) + + for col_idx, col_name in enumerate(columns): + if col_idx == 0: # Selection column + continue + value = entry.values.get(col_name, "") + item.setText(col_idx, str(value)) + + item.setData(0, Qt.UserRole, entry) + self._tree.addTopLevelItem(item) + + self._tree.blockSignals(False) + self._update_summary() + + def _on_item_changed(self, item, column): + if column == 0: + entry = item.data(0, Qt.UserRole) + if entry and not entry.header_row: + entry.checked = item.checkState(0) == Qt.Checked + self._update_selection_count() + + def _on_item_double_clicked(self, item, column): + entry = item.data(0, Qt.UserRole) + if entry and not entry.header_row: + self.item_activated.emit(entry) + + def _on_context_menu(self, pos): + item = self._tree.itemAt(pos) + if item: + entry = item.data(0, Qt.UserRole) + if entry and not entry.header_row: + menu = QMenu(self) + open_action = QAction("Open File", self) + open_action.triggered.connect(lambda: self._open_file(entry)) + menu.addAction(open_action) + + open_dir_action = QAction("Open Containing Folder", self) + open_dir_action.triggered.connect(lambda: self._open_folder(entry)) + menu.addAction(open_dir_action) + + menu.addSeparator() + + select_action = QAction("Select", self) + select_action.triggered.connect(lambda: self._set_check(item, True)) + menu.addAction(select_action) + + deselect_action = QAction("Deselect", self) + deselect_action.triggered.connect(lambda: self._set_check(item, False)) + menu.addAction(deselect_action) + + menu.exec_(self._tree.viewport().mapToGlobal(pos)) + + def _open_file(self, entry: ResultEntry): + import subprocess, sys + path = entry.values.get("__full_path", "") + if path: + if sys.platform == "linux": + subprocess.Popen(["xdg-open", path]) + elif sys.platform == "darwin": + subprocess.Popen(["open", path]) + else: + subprocess.Popen(["start", path], shell=True) + + def _open_folder(self, entry: ResultEntry): + import subprocess, sys + from pathlib import Path + path = entry.values.get("__full_path", "") + if path: + folder = str(Path(path).parent) + if sys.platform == "linux": + subprocess.Popen(["xdg-open", folder]) + elif sys.platform == "darwin": + subprocess.Popen(["open", folder]) + else: + subprocess.Popen(["explorer", folder], shell=True) + + def _set_check(self, item, checked): + item.setCheckState(0, Qt.Checked if checked else Qt.Unchecked) + + def _update_summary(self): + total = sum(1 for r in self._results if not r.header_row) + groups = sum(1 for r in self._results if r.header_row) + if self._active_tab in GROUPED_TABS and groups > 0: + self._summary_label.setText(f"Found {total} files in {groups} groups") + elif total > 0: + self._summary_label.setText(f"Found {total} entries") + else: + self._summary_label.setText("No results") + self._update_selection_count() + + def _update_selection_count(self): + selected = sum(1 for r in self._results if r.checked and not r.header_row) + total = sum(1 for r in self._results if not r.header_row) + if selected > 0: + self._selection_label.setText(f"Selected: {selected}/{total}") + else: + self._selection_label.setText("") + self.selection_changed.emit(selected) + + def apply_selection(self, mode: SelectMode): + self._tree.blockSignals(True) + + if mode == SelectMode.SELECT_ALL: + self._select_all(True) + elif mode == SelectMode.UNSELECT_ALL: + self._select_all(False) + elif mode == SelectMode.INVERT_SELECTION: + self._invert_selection() + elif mode in (SelectMode.SELECT_BIGGEST_SIZE, SelectMode.SELECT_SMALLEST_SIZE, + SelectMode.SELECT_NEWEST, SelectMode.SELECT_OLDEST, + SelectMode.SELECT_BIGGEST_RESOLUTION, SelectMode.SELECT_SMALLEST_RESOLUTION, + SelectMode.SELECT_SHORTEST_PATH, SelectMode.SELECT_LONGEST_PATH): + self._select_by_group_criteria(mode) + + self._tree.blockSignals(False) + self._update_selection_count() + + def _select_all(self, checked: bool): + for i in range(self._tree.topLevelItemCount()): + item = self._tree.topLevelItem(i) + entry = item.data(0, Qt.UserRole) + if entry and not entry.header_row: + entry.checked = checked + item.setCheckState(0, Qt.Checked if checked else Qt.Unchecked) + + def _invert_selection(self): + for i in range(self._tree.topLevelItemCount()): + item = self._tree.topLevelItem(i) + entry = item.data(0, Qt.UserRole) + if entry and not entry.header_row: + entry.checked = not entry.checked + item.setCheckState(0, Qt.Checked if entry.checked else Qt.Unchecked) + + def _select_by_group_criteria(self, mode: SelectMode): + # First unselect all + self._select_all(False) + + if self._active_tab not in GROUPED_TABS: + return + + # Group entries by group_id + groups: dict[int, list[tuple[int, ResultEntry]]] = {} + for i in range(self._tree.topLevelItemCount()): + item = self._tree.topLevelItem(i) + entry = item.data(0, Qt.UserRole) + if entry and not entry.header_row: + groups.setdefault(entry.group_id, []).append((i, entry)) + + for group_id, items in groups.items(): + if len(items) <= 1: + continue + + best_idx = 0 + if mode == SelectMode.SELECT_BIGGEST_SIZE: + best_idx = max(range(len(items)), key=lambda j: items[j][1].values.get("__size_bytes", 0)) + elif mode == SelectMode.SELECT_SMALLEST_SIZE: + best_idx = min(range(len(items)), key=lambda j: items[j][1].values.get("__size_bytes", 0)) + elif mode == SelectMode.SELECT_NEWEST: + best_idx = max(range(len(items)), key=lambda j: items[j][1].values.get("__modified_date_ts", 0)) + elif mode == SelectMode.SELECT_OLDEST: + best_idx = min(range(len(items)), key=lambda j: items[j][1].values.get("__modified_date_ts", 0)) + elif mode == SelectMode.SELECT_SHORTEST_PATH: + best_idx = min(range(len(items)), key=lambda j: len(items[j][1].values.get("__full_path", ""))) + elif mode == SelectMode.SELECT_LONGEST_PATH: + best_idx = max(range(len(items)), key=lambda j: len(items[j][1].values.get("__full_path", ""))) + + # Select all EXCEPT the best (the one to keep) + for j, (tree_idx, entry) in enumerate(items): + if j != best_idx: + entry.checked = True + self._tree.topLevelItem(tree_idx).setCheckState(0, Qt.Checked) + + def sort_by_column(self, column: int, ascending: bool = True): + order = Qt.AscendingOrder if ascending else Qt.DescendingOrder + self._tree.sortItems(column, order) + + def get_checked_entries(self) -> list[ResultEntry]: + return [r for r in self._results if r.checked and not r.header_row] + + def get_all_entries(self) -> list[ResultEntry]: + return [r for r in self._results if not r.header_row] + + def clear(self): + self._results = [] + self._tree.clear() + self._summary_label.setText("No results") + self._selection_label.setText("") diff --git a/czkawka_pyside6/app/settings_panel.py b/czkawka_pyside6/app/settings_panel.py new file mode 100644 index 000000000..1ab5c2159 --- /dev/null +++ b/czkawka_pyside6/app/settings_panel.py @@ -0,0 +1,261 @@ +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QCheckBox, + QLineEdit, QSpinBox, QGroupBox, QFormLayout, QScrollArea, + QPushButton, QListWidget, QFileDialog, QDoubleSpinBox, + QTabWidget, QSizePolicy +) +from PySide6.QtCore import Qt, Signal + +from .models import AppSettings + + +class SettingsPanel(QWidget): + """Global application settings panel.""" + settings_changed = Signal() + close_requested = Signal() + + def __init__(self, settings: AppSettings, parent=None): + super().__init__(parent) + self._settings = settings + self._setup_ui() + + def _setup_ui(self): + main_layout = QVBoxLayout(self) + + # Header + header = QHBoxLayout() + title = QLabel("Settings") + title.setStyleSheet("font-weight: bold; font-size: 16px; padding: 4px;") + header.addWidget(title) + header.addStretch() + close_btn = QPushButton("Close") + close_btn.clicked.connect(self.close_requested.emit) + header.addWidget(close_btn) + main_layout.addLayout(header) + + # Tabs + tabs = QTabWidget() + + # General tab + tabs.addTab(self._create_general_tab(), "General") + # Directories tab + tabs.addTab(self._create_directories_tab(), "Directories") + # Filters tab + tabs.addTab(self._create_filters_tab(), "Filters") + # Preview tab + tabs.addTab(self._create_preview_tab(), "Preview") + + main_layout.addWidget(tabs) + + def _create_general_tab(self) -> QWidget: + scroll = QScrollArea() + scroll.setWidgetResizable(True) + widget = QWidget() + layout = QFormLayout(widget) + + # CLI path + cli_layout = QHBoxLayout() + self._cli_path = QLineEdit(self._settings.czkawka_cli_path) + self._cli_path.textChanged.connect( + lambda t: setattr(self._settings, 'czkawka_cli_path', t) + ) + cli_layout.addWidget(self._cli_path) + browse_btn = QPushButton("Browse") + browse_btn.clicked.connect(self._browse_cli) + cli_layout.addWidget(browse_btn) + layout.addRow("czkawka_cli Path:", cli_layout) + + # Thread number + self._threads = QSpinBox() + self._threads.setRange(0, 64) + self._threads.setValue(self._settings.thread_number) + self._threads.setSpecialValueText("Auto (all cores)") + self._threads.valueChanged.connect( + lambda v: setattr(self._settings, 'thread_number', v) + ) + layout.addRow("Thread Count:", self._threads) + + # Recursive search + recursive = QCheckBox("Recursive search") + recursive.setChecked(self._settings.recursive_search) + recursive.toggled.connect( + lambda v: setattr(self._settings, 'recursive_search', v) + ) + layout.addRow(recursive) + + # Use cache + cache = QCheckBox("Use cache for faster rescans") + cache.setChecked(self._settings.use_cache) + cache.toggled.connect( + lambda v: setattr(self._settings, 'use_cache', v) + ) + layout.addRow(cache) + + # Move to trash + trash = QCheckBox("Move to trash instead of permanent delete") + trash.setChecked(self._settings.move_to_trash) + trash.toggled.connect( + lambda v: setattr(self._settings, 'move_to_trash', v) + ) + layout.addRow(trash) + + # Hide hard links + hardlinks = QCheckBox("Hide hard links") + hardlinks.setChecked(self._settings.hide_hard_links) + hardlinks.toggled.connect( + lambda v: setattr(self._settings, 'hide_hard_links', v) + ) + layout.addRow(hardlinks) + + # Save as JSON + save_json = QCheckBox("Save results as JSON (instead of text)") + save_json.setChecked(self._settings.save_as_json) + save_json.toggled.connect( + lambda v: setattr(self._settings, 'save_as_json', v) + ) + layout.addRow(save_json) + + scroll.setWidget(widget) + return scroll + + def _create_directories_tab(self) -> QWidget: + widget = QWidget() + layout = QVBoxLayout(widget) + + # Included paths + inc_group = QGroupBox("Included Directories") + inc_layout = QVBoxLayout(inc_group) + + self._inc_list = QListWidget() + for path in self._settings.included_paths: + self._inc_list.addItem(path) + inc_layout.addWidget(self._inc_list) + + inc_btns = QHBoxLayout() + add_inc = QPushButton("Add") + add_inc.clicked.connect(self._add_included) + inc_btns.addWidget(add_inc) + rem_inc = QPushButton("Remove") + rem_inc.clicked.connect(self._remove_included) + inc_btns.addWidget(rem_inc) + inc_btns.addStretch() + inc_layout.addLayout(inc_btns) + layout.addWidget(inc_group) + + # Excluded paths + exc_group = QGroupBox("Excluded Directories") + exc_layout = QVBoxLayout(exc_group) + + self._exc_list = QListWidget() + for path in self._settings.excluded_paths: + self._exc_list.addItem(path) + exc_layout.addWidget(self._exc_list) + + exc_btns = QHBoxLayout() + add_exc = QPushButton("Add") + add_exc.clicked.connect(self._add_excluded) + exc_btns.addWidget(add_exc) + rem_exc = QPushButton("Remove") + rem_exc.clicked.connect(self._remove_excluded) + exc_btns.addWidget(rem_exc) + exc_btns.addStretch() + exc_layout.addLayout(exc_btns) + layout.addWidget(exc_group) + + return widget + + def _create_filters_tab(self) -> QWidget: + widget = QWidget() + layout = QFormLayout(widget) + + # Excluded items + self._excluded_items = QLineEdit(self._settings.excluded_items) + self._excluded_items.setPlaceholderText("Wildcard patterns, comma-separated (e.g. *.tmp,cache_*)") + self._excluded_items.textChanged.connect( + lambda t: setattr(self._settings, 'excluded_items', t) + ) + layout.addRow("Excluded Items:", self._excluded_items) + + # Allowed extensions + self._allowed_ext = QLineEdit(self._settings.allowed_extensions) + self._allowed_ext.setPlaceholderText("e.g. jpg,png,gif") + self._allowed_ext.textChanged.connect( + lambda t: setattr(self._settings, 'allowed_extensions', t) + ) + layout.addRow("Allowed Extensions:", self._allowed_ext) + + # Excluded extensions + self._excluded_ext = QLineEdit(self._settings.excluded_extensions) + self._excluded_ext.setPlaceholderText("e.g. log,tmp") + self._excluded_ext.textChanged.connect( + lambda t: setattr(self._settings, 'excluded_extensions', t) + ) + layout.addRow("Excluded Extensions:", self._excluded_ext) + + # Min file size + self._min_size = QLineEdit(self._settings.minimum_file_size) + self._min_size.setPlaceholderText("In bytes (e.g. 1024)") + self._min_size.textChanged.connect( + lambda t: setattr(self._settings, 'minimum_file_size', t) + ) + layout.addRow("Minimum File Size:", self._min_size) + + # Max file size + self._max_size = QLineEdit(self._settings.maximum_file_size) + self._max_size.setPlaceholderText("In bytes (leave empty for no limit)") + self._max_size.textChanged.connect( + lambda t: setattr(self._settings, 'maximum_file_size', t) + ) + layout.addRow("Maximum File Size:", self._max_size) + + return widget + + def _create_preview_tab(self) -> QWidget: + widget = QWidget() + layout = QVBoxLayout(widget) + + preview = QCheckBox("Show image preview") + preview.setChecked(self._settings.show_image_preview) + preview.toggled.connect( + lambda v: setattr(self._settings, 'show_image_preview', v) + ) + layout.addWidget(preview) + + layout.addStretch() + return widget + + def _browse_cli(self): + path, _ = QFileDialog.getOpenFileName( + self, "Select czkawka_cli binary", "", + "Executables (*);;All Files (*)" + ) + if path: + self._cli_path.setText(path) + + def _add_included(self): + path = QFileDialog.getExistingDirectory(self, "Select Directory to Include") + if path and path not in self._settings.included_paths: + self._settings.included_paths.append(path) + self._inc_list.addItem(path) + self.settings_changed.emit() + + def _remove_included(self): + row = self._inc_list.currentRow() + if row >= 0: + self._inc_list.takeItem(row) + self._settings.included_paths.pop(row) + self.settings_changed.emit() + + def _add_excluded(self): + path = QFileDialog.getExistingDirectory(self, "Select Directory to Exclude") + if path and path not in self._settings.excluded_paths: + self._settings.excluded_paths.append(path) + self._exc_list.addItem(path) + self.settings_changed.emit() + + def _remove_excluded(self): + row = self._exc_list.currentRow() + if row >= 0: + self._exc_list.takeItem(row) + self._settings.excluded_paths.pop(row) + self.settings_changed.emit() diff --git a/czkawka_pyside6/app/state.py b/czkawka_pyside6/app/state.py new file mode 100644 index 000000000..2737323d0 --- /dev/null +++ b/czkawka_pyside6/app/state.py @@ -0,0 +1,122 @@ +import json +from pathlib import Path +from PySide6.QtCore import QObject, Signal +from .models import ( + ActiveTab, AppSettings, ToolSettings, ResultEntry, ScanProgress +) + + +class AppState(QObject): + """Central application state manager.""" + + # Signals + tab_changed = Signal(object) # ActiveTab + scan_started = Signal() + scan_finished = Signal() + scan_progress_updated = Signal(object) # ScanProgress + results_updated = Signal() + settings_changed = Signal() + included_paths_changed = Signal() + excluded_paths_changed = Signal() + preview_image_changed = Signal(str) + + def __init__(self): + super().__init__() + self.active_tab = ActiveTab.DUPLICATE_FILES + self.scanning = False + self.processing = False + self.stop_requested = False + self.settings = AppSettings() + self.tool_settings = ToolSettings() + self.results: dict[ActiveTab, list[ResultEntry]] = {} + self.progress = ScanProgress() + self.info_text = "" + self.preview_image_path = "" + self._config_path = Path.home() / ".config" / "czkawka_pyside6" + self._config_path.mkdir(parents=True, exist_ok=True) + self.load_settings() + + def set_active_tab(self, tab: ActiveTab): + if tab != self.active_tab: + self.active_tab = tab + self.tab_changed.emit(tab) + + def set_scanning(self, scanning: bool): + self.scanning = scanning + if scanning: + self.stop_requested = False + self.scan_started.emit() + else: + self.scan_finished.emit() + + def request_stop(self): + self.stop_requested = True + + def update_progress(self, progress: ScanProgress): + self.progress = progress + self.scan_progress_updated.emit(progress) + + def set_results(self, tab: ActiveTab, results: list[ResultEntry]): + self.results[tab] = results + self.results_updated.emit() + + def get_results(self, tab: ActiveTab = None) -> list[ResultEntry]: + if tab is None: + tab = self.active_tab + return self.results.get(tab, []) + + def get_checked_results(self, tab: ActiveTab = None) -> list[ResultEntry]: + return [r for r in self.get_results(tab) if r.checked and not r.header_row] + + def get_selected_count(self, tab: ActiveTab = None) -> int: + return len(self.get_checked_results(tab)) + + def save_settings(self): + config_file = self._config_path / "settings.json" + data = { + "included_paths": self.settings.included_paths, + "excluded_paths": self.settings.excluded_paths, + "excluded_items": self.settings.excluded_items, + "allowed_extensions": self.settings.allowed_extensions, + "excluded_extensions": self.settings.excluded_extensions, + "minimum_file_size": self.settings.minimum_file_size, + "maximum_file_size": self.settings.maximum_file_size, + "recursive_search": self.settings.recursive_search, + "use_cache": self.settings.use_cache, + "save_as_json": self.settings.save_as_json, + "move_to_trash": self.settings.move_to_trash, + "hide_hard_links": self.settings.hide_hard_links, + "thread_number": self.settings.thread_number, + "dark_theme": self.settings.dark_theme, + "show_image_preview": self.settings.show_image_preview, + "czkawka_cli_path": self.settings.czkawka_cli_path, + } + try: + config_file.write_text(json.dumps(data, indent=2)) + except OSError: + pass + + def load_settings(self): + config_file = self._config_path / "settings.json" + if config_file.exists(): + try: + data = json.loads(config_file.read_text()) + s = self.settings + s.included_paths = data.get("included_paths", s.included_paths) + s.excluded_paths = data.get("excluded_paths", s.excluded_paths) + s.excluded_items = data.get("excluded_items", s.excluded_items) + s.allowed_extensions = data.get("allowed_extensions", s.allowed_extensions) + s.excluded_extensions = data.get("excluded_extensions", s.excluded_extensions) + s.minimum_file_size = data.get("minimum_file_size", s.minimum_file_size) + s.maximum_file_size = data.get("maximum_file_size", s.maximum_file_size) + s.recursive_search = data.get("recursive_search", s.recursive_search) + s.use_cache = data.get("use_cache", s.use_cache) + s.save_as_json = data.get("save_as_json", s.save_as_json) + s.move_to_trash = data.get("move_to_trash", s.move_to_trash) + s.hide_hard_links = data.get("hide_hard_links", s.hide_hard_links) + s.thread_number = data.get("thread_number", s.thread_number) + s.dark_theme = data.get("dark_theme", s.dark_theme) + s.show_image_preview = data.get("show_image_preview", s.show_image_preview) + s.czkawka_cli_path = data.get("czkawka_cli_path", s.czkawka_cli_path) + except (json.JSONDecodeError, OSError): + pass diff --git a/czkawka_pyside6/app/tool_settings.py b/czkawka_pyside6/app/tool_settings.py new file mode 100644 index 000000000..a3c5d9e44 --- /dev/null +++ b/czkawka_pyside6/app/tool_settings.py @@ -0,0 +1,504 @@ +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QLabel, QComboBox, QCheckBox, + QSlider, QLineEdit, QGroupBox, QFormLayout, QScrollArea, + QHBoxLayout, QSpinBox, QPushButton, QSizePolicy +) +from PySide6.QtCore import Qt, Signal + +from .models import ( + ActiveTab, ToolSettings, CheckingMethod, HashType, + ImageHashAlg, ImageFilter, MusicSearchMethod, + VideoCropDetect, VideoCropMechanism, VideoCodec, +) + + +class ToolSettingsPanel(QWidget): + """Per-tool settings panel shown on the right side.""" + settings_changed = Signal() + + def __init__(self, tool_settings: ToolSettings, parent=None): + super().__init__(parent) + self._ts = tool_settings + self._active_tab = ActiveTab.DUPLICATE_FILES + self.setMinimumWidth(250) + self.setMaximumWidth(350) + self._panels: dict[ActiveTab, QWidget] = {} + self._setup_ui() + + def _setup_ui(self): + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(4, 4, 4, 4) + + title = QLabel("Tool Settings") + title.setStyleSheet("font-weight: bold; font-size: 13px; padding: 4px;") + main_layout.addWidget(title) + + self._scroll = QScrollArea() + self._scroll.setWidgetResizable(True) + self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + + self._container = QWidget() + self._container_layout = QVBoxLayout(self._container) + self._container_layout.setContentsMargins(0, 0, 0, 0) + + # Create all panel widgets + self._panels[ActiveTab.DUPLICATE_FILES] = self._create_duplicate_panel() + self._panels[ActiveTab.SIMILAR_IMAGES] = self._create_similar_images_panel() + self._panels[ActiveTab.SIMILAR_VIDEOS] = self._create_similar_videos_panel() + self._panels[ActiveTab.SIMILAR_MUSIC] = self._create_similar_music_panel() + self._panels[ActiveTab.BIG_FILES] = self._create_big_files_panel() + self._panels[ActiveTab.BROKEN_FILES] = self._create_broken_files_panel() + self._panels[ActiveTab.BAD_NAMES] = self._create_bad_names_panel() + self._panels[ActiveTab.EXIF_REMOVER] = self._create_exif_panel() + self._panels[ActiveTab.VIDEO_OPTIMIZER] = self._create_video_optimizer_panel() + + for panel in self._panels.values(): + self._container_layout.addWidget(panel) + panel.setVisible(False) + + self._container_layout.addStretch() + self._scroll.setWidget(self._container) + main_layout.addWidget(self._scroll) + + def set_active_tab(self, tab: ActiveTab): + self._active_tab = tab + for t, panel in self._panels.items(): + panel.setVisible(t == tab) + + def _create_duplicate_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + # Check method + self._dup_method = QComboBox() + self._dup_method.addItems(["Hash", "Size", "Name", "Size and Name"]) + method_map = {CheckingMethod.HASH: 0, CheckingMethod.SIZE: 1, + CheckingMethod.NAME: 2, CheckingMethod.SIZE_NAME: 3} + self._dup_method.setCurrentIndex(method_map.get(self._ts.dup_check_method, 0)) + self._dup_method.currentIndexChanged.connect(self._on_dup_method_changed) + layout.addRow("Check Method:", self._dup_method) + + # Hash type + self._dup_hash = QComboBox() + self._dup_hash.addItems(["Blake3", "CRC32", "XXH3"]) + hash_map = {HashType.BLAKE3: 0, HashType.CRC32: 1, HashType.XXH3: 2} + self._dup_hash.setCurrentIndex(hash_map.get(self._ts.dup_hash_type, 0)) + self._dup_hash.currentIndexChanged.connect(self._on_dup_hash_changed) + layout.addRow("Hash Type:", self._dup_hash) + + # Case sensitive + self._dup_case = QCheckBox("Case sensitive name comparison") + self._dup_case.setChecked(self._ts.dup_name_case_sensitive) + self._dup_case.toggled.connect(lambda v: setattr(self._ts, 'dup_name_case_sensitive', v)) + layout.addRow(self._dup_case) + + return panel + + def _on_dup_method_changed(self, idx): + methods = [CheckingMethod.HASH, CheckingMethod.SIZE, + CheckingMethod.NAME, CheckingMethod.SIZE_NAME] + self._ts.dup_check_method = methods[idx] + self.settings_changed.emit() + + def _on_dup_hash_changed(self, idx): + hashes = [HashType.BLAKE3, HashType.CRC32, HashType.XXH3] + self._ts.dup_hash_type = hashes[idx] + self.settings_changed.emit() + + def _create_similar_images_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + # Hash size + self._img_hash_size = QComboBox() + self._img_hash_size.addItems(["8", "16", "32", "64"]) + size_map = {8: 0, 16: 1, 32: 2, 64: 3} + self._img_hash_size.setCurrentIndex(size_map.get(self._ts.img_hash_size, 1)) + self._img_hash_size.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'img_hash_size', [8, 16, 32, 64][idx]) + ) + layout.addRow("Hash Size:", self._img_hash_size) + + # Resize algorithm + self._img_filter = QComboBox() + self._img_filter.addItems(["Lanczos3", "Gaussian", "CatmullRom", "Triangle", "Nearest"]) + filters = [ImageFilter.LANCZOS3, ImageFilter.GAUSSIAN, ImageFilter.CATMULL_ROM, + ImageFilter.TRIANGLE, ImageFilter.NEAREST] + filter_idx = filters.index(self._ts.img_filter) if self._ts.img_filter in filters else 4 + self._img_filter.setCurrentIndex(filter_idx) + self._img_filter.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'img_filter', filters[idx]) + ) + layout.addRow("Resize Algorithm:", self._img_filter) + + # Hash type + self._img_hash_alg = QComboBox() + self._img_hash_alg.addItems(["Mean", "Gradient", "BlockHash", "VertGradient", + "DoubleGradient", "Median"]) + algs = [ImageHashAlg.MEAN, ImageHashAlg.GRADIENT, ImageHashAlg.BLOCKHASH, + ImageHashAlg.VERT_GRADIENT, ImageHashAlg.DOUBLE_GRADIENT, ImageHashAlg.MEDIAN] + alg_idx = algs.index(self._ts.img_hash_alg) if self._ts.img_hash_alg in algs else 1 + self._img_hash_alg.setCurrentIndex(alg_idx) + self._img_hash_alg.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'img_hash_alg', algs[idx]) + ) + layout.addRow("Hash Type:", self._img_hash_alg) + + # Ignore same size + self._img_ignore_size = QCheckBox("Ignore same size") + self._img_ignore_size.setChecked(self._ts.img_ignore_same_size) + self._img_ignore_size.toggled.connect( + lambda v: setattr(self._ts, 'img_ignore_same_size', v) + ) + layout.addRow(self._img_ignore_size) + + # Max difference slider + diff_layout = QHBoxLayout() + self._img_diff_slider = QSlider(Qt.Horizontal) + self._img_diff_slider.setRange(0, 40) + self._img_diff_slider.setValue(self._ts.img_max_difference) + self._img_diff_label = QLabel(str(self._ts.img_max_difference)) + self._img_diff_slider.valueChanged.connect(self._on_img_diff_changed) + diff_layout.addWidget(self._img_diff_slider) + diff_layout.addWidget(self._img_diff_label) + layout.addRow("Max Difference:", diff_layout) + + return panel + + def _on_img_diff_changed(self, value): + self._ts.img_max_difference = value + self._img_diff_label.setText(str(value)) + self.settings_changed.emit() + + def _create_similar_videos_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + # Ignore same size + vid_ignore = QCheckBox("Ignore same size") + vid_ignore.setChecked(self._ts.vid_ignore_same_size) + vid_ignore.toggled.connect(lambda v: setattr(self._ts, 'vid_ignore_same_size', v)) + layout.addRow(vid_ignore) + + # Crop detect + self._vid_crop = QComboBox() + self._vid_crop.addItems(["LetterBox", "Motion", "None"]) + crops = [VideoCropDetect.LETTERBOX, VideoCropDetect.MOTION, VideoCropDetect.NONE] + crop_idx = crops.index(self._ts.vid_crop_detect) if self._ts.vid_crop_detect in crops else 0 + self._vid_crop.setCurrentIndex(crop_idx) + self._vid_crop.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'vid_crop_detect', crops[idx]) + ) + layout.addRow("Crop Detect:", self._vid_crop) + + # Max difference + diff_layout = QHBoxLayout() + self._vid_diff_slider = QSlider(Qt.Horizontal) + self._vid_diff_slider.setRange(0, 20) + self._vid_diff_slider.setValue(self._ts.vid_max_difference) + self._vid_diff_label = QLabel(str(self._ts.vid_max_difference)) + self._vid_diff_slider.valueChanged.connect(lambda v: ( + setattr(self._ts, 'vid_max_difference', v), + self._vid_diff_label.setText(str(v)) + )) + diff_layout.addWidget(self._vid_diff_slider) + diff_layout.addWidget(self._vid_diff_label) + layout.addRow("Max Difference:", diff_layout) + + # Skip forward + skip_layout = QHBoxLayout() + self._vid_skip_slider = QSlider(Qt.Horizontal) + self._vid_skip_slider.setRange(1, 360) + self._vid_skip_slider.setValue(self._ts.vid_skip_forward) + self._vid_skip_label = QLabel(str(self._ts.vid_skip_forward)) + self._vid_skip_slider.valueChanged.connect(lambda v: ( + setattr(self._ts, 'vid_skip_forward', v), + self._vid_skip_label.setText(str(v)) + )) + skip_layout.addWidget(self._vid_skip_slider) + skip_layout.addWidget(self._vid_skip_label) + layout.addRow("Skip Forward (s):", skip_layout) + + # Duration + dur_layout = QHBoxLayout() + self._vid_dur_slider = QSlider(Qt.Horizontal) + self._vid_dur_slider.setRange(1, 60) + self._vid_dur_slider.setValue(self._ts.vid_duration) + self._vid_dur_label = QLabel(str(self._ts.vid_duration)) + self._vid_dur_slider.valueChanged.connect(lambda v: ( + setattr(self._ts, 'vid_duration', v), + self._vid_dur_label.setText(str(v)) + )) + dur_layout.addWidget(self._vid_dur_slider) + dur_layout.addWidget(self._vid_dur_label) + layout.addRow("Hash Duration (s):", dur_layout) + + return panel + + def _create_similar_music_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + # Search method + self._music_method = QComboBox() + self._music_method.addItems(["Tags", "Fingerprint"]) + self._music_method.setCurrentIndex( + 0 if self._ts.music_search_method == MusicSearchMethod.TAGS else 1 + ) + self._music_method.currentIndexChanged.connect(self._on_music_method_changed) + layout.addRow("Audio Check Type:", self._music_method) + + # Tags group + self._tags_group = QGroupBox("Tag Matching") + tags_layout = QVBoxLayout(self._tags_group) + + self._music_approx = QCheckBox("Approximate comparison") + self._music_approx.setChecked(self._ts.music_approximate) + self._music_approx.toggled.connect(lambda v: setattr(self._ts, 'music_approximate', v)) + tags_layout.addWidget(self._music_approx) + + for attr, label in [ + ("music_title", "Title"), ("music_artist", "Artist"), + ("music_bitrate", "Bitrate"), ("music_genre", "Genre"), + ("music_year", "Year"), ("music_length", "Length"), + ]: + cb = QCheckBox(label) + cb.setChecked(getattr(self._ts, attr)) + cb.toggled.connect(lambda v, a=attr: setattr(self._ts, a, v)) + tags_layout.addWidget(cb) + + layout.addRow(self._tags_group) + + # Fingerprint group + self._fp_group = QGroupBox("Fingerprint Matching") + fp_layout = QFormLayout(self._fp_group) + + fp_similar = QCheckBox("Compare with similar titles") + fp_similar.setChecked(self._ts.music_compare_fingerprints_similar_titles) + fp_similar.toggled.connect( + lambda v: setattr(self._ts, 'music_compare_fingerprints_similar_titles', v) + ) + fp_layout.addRow(fp_similar) + + diff_layout = QHBoxLayout() + self._music_diff_slider = QSlider(Qt.Horizontal) + self._music_diff_slider.setRange(0, 100) + self._music_diff_slider.setValue(int(self._ts.music_max_difference * 10)) + self._music_diff_label = QLabel(f"{self._ts.music_max_difference:.1f}") + self._music_diff_slider.valueChanged.connect(lambda v: ( + setattr(self._ts, 'music_max_difference', v / 10.0), + self._music_diff_label.setText(f"{v / 10.0:.1f}") + )) + diff_layout.addWidget(self._music_diff_slider) + diff_layout.addWidget(self._music_diff_label) + fp_layout.addRow("Max Difference:", diff_layout) + + layout.addRow(self._fp_group) + self._on_music_method_changed(self._music_method.currentIndex()) + + return panel + + def _on_music_method_changed(self, idx): + if idx == 0: + self._ts.music_search_method = MusicSearchMethod.TAGS + else: + self._ts.music_search_method = MusicSearchMethod.CONTENT + self._tags_group.setVisible(idx == 0) + self._fp_group.setVisible(idx == 1) + self.settings_changed.emit() + + def _create_big_files_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + self._big_mode = QComboBox() + self._big_mode.addItems(["The Biggest", "The Smallest"]) + self._big_mode.setCurrentIndex(0 if self._ts.big_files_mode == "biggest" else 1) + self._big_mode.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'big_files_mode', "biggest" if idx == 0 else "smallest") + ) + layout.addRow("Method:", self._big_mode) + + self._big_count = QSpinBox() + self._big_count.setRange(1, 100000) + self._big_count.setValue(self._ts.big_files_number) + self._big_count.valueChanged.connect( + lambda v: setattr(self._ts, 'big_files_number', v) + ) + layout.addRow("Number of Files:", self._big_count) + + return panel + + def _create_broken_files_panel(self) -> QWidget: + panel = QWidget() + layout = QVBoxLayout(panel) + layout.addWidget(QLabel("File types to check:")) + + for attr, label in [ + ("broken_audio", "Audio"), ("broken_pdf", "PDF"), + ("broken_archive", "Archive"), ("broken_image", "Image"), + ("broken_video", "Video"), + ]: + cb = QCheckBox(label) + cb.setChecked(getattr(self._ts, attr)) + cb.toggled.connect(lambda v, a=attr: setattr(self._ts, a, v)) + layout.addWidget(cb) + + layout.addStretch() + return panel + + def _create_bad_names_panel(self) -> QWidget: + panel = QWidget() + layout = QVBoxLayout(panel) + layout.addWidget(QLabel("Check for:")) + + checks = [ + ("bad_names_uppercase_ext", "Uppercase extension", + "Files with .JPG, .PNG etc."), + ("bad_names_emoji", "Emoji in name", + "Files containing emoji characters"), + ("bad_names_space", "Space at start/end", + "Leading or trailing whitespace"), + ("bad_names_non_ascii", "Non-ASCII characters", + "Characters outside ASCII range"), + ("bad_names_remove_duplicated", "Remove duplicated non-alphanumeric", + "e.g. file--name..txt"), + ] + + for attr, label, hint in checks: + cb = QCheckBox(label) + cb.setChecked(getattr(self._ts, attr)) + cb.setToolTip(hint) + cb.toggled.connect(lambda v, a=attr: setattr(self._ts, a, v)) + layout.addWidget(cb) + + # Restricted charset + layout.addWidget(QLabel("Restricted charset:")) + self._bad_names_charset = QLineEdit(self._ts.bad_names_restricted_charset) + self._bad_names_charset.setPlaceholderText("Allowed special chars, comma-separated") + self._bad_names_charset.textChanged.connect( + lambda t: setattr(self._ts, 'bad_names_restricted_charset', t) + ) + layout.addWidget(self._bad_names_charset) + + layout.addStretch() + return panel + + def _create_exif_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + self._exif_tags = QLineEdit(self._ts.exif_ignored_tags) + self._exif_tags.setPlaceholderText("Tags to ignore, comma-separated") + self._exif_tags.textChanged.connect( + lambda t: setattr(self._ts, 'exif_ignored_tags', t) + ) + layout.addRow("Ignored EXIF Tags:", self._exif_tags) + + return panel + + def _create_video_optimizer_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + # Mode + self._vo_mode = QComboBox() + self._vo_mode.addItems(["Crop", "Transcode"]) + self._vo_mode.setCurrentIndex(0 if self._ts.video_opt_mode == "crop" else 1) + self._vo_mode.currentIndexChanged.connect(self._on_vo_mode_changed) + layout.addRow("Mode:", self._vo_mode) + + # Crop settings + self._crop_group = QGroupBox("Crop Settings") + crop_layout = QFormLayout(self._crop_group) + + self._vo_crop_type = QComboBox() + self._vo_crop_type.addItems(["Black Bars", "Static Content"]) + mechs = [VideoCropMechanism.BLACKBARS, VideoCropMechanism.STATICCONTENT] + mech_idx = mechs.index(self._ts.video_crop_mechanism) if self._ts.video_crop_mechanism in mechs else 0 + self._vo_crop_type.setCurrentIndex(mech_idx) + self._vo_crop_type.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'video_crop_mechanism', mechs[idx]) + ) + crop_layout.addRow("Crop Type:", self._vo_crop_type) + + self._vo_threshold = QSpinBox() + self._vo_threshold.setRange(0, 128) + self._vo_threshold.setValue(self._ts.video_black_pixel_threshold) + self._vo_threshold.valueChanged.connect( + lambda v: setattr(self._ts, 'video_black_pixel_threshold', v) + ) + crop_layout.addRow("Black Pixel Threshold:", self._vo_threshold) + + self._vo_bar_pct = QSpinBox() + self._vo_bar_pct.setRange(50, 100) + self._vo_bar_pct.setValue(self._ts.video_black_bar_percentage) + self._vo_bar_pct.valueChanged.connect( + lambda v: setattr(self._ts, 'video_black_bar_percentage', v) + ) + crop_layout.addRow("Black Bar Min %:", self._vo_bar_pct) + + self._vo_samples = QSpinBox() + self._vo_samples.setRange(5, 1000) + self._vo_samples.setValue(self._ts.video_max_samples) + self._vo_samples.valueChanged.connect( + lambda v: setattr(self._ts, 'video_max_samples', v) + ) + crop_layout.addRow("Max Samples:", self._vo_samples) + + self._vo_min_crop = QSpinBox() + self._vo_min_crop.setRange(1, 1000) + self._vo_min_crop.setValue(self._ts.video_min_crop_size) + self._vo_min_crop.valueChanged.connect( + lambda v: setattr(self._ts, 'video_min_crop_size', v) + ) + crop_layout.addRow("Min Crop Size:", self._vo_min_crop) + + layout.addRow(self._crop_group) + + # Transcode settings + self._transcode_group = QGroupBox("Transcode Settings") + tc_layout = QFormLayout(self._transcode_group) + + self._vo_codecs = QLineEdit(self._ts.video_excluded_codecs) + self._vo_codecs.textChanged.connect( + lambda t: setattr(self._ts, 'video_excluded_codecs', t) + ) + tc_layout.addRow("Excluded Codecs:", self._vo_codecs) + + self._vo_codec = QComboBox() + self._vo_codec.addItems(["H264", "H265", "AV1", "VP9"]) + codecs = [VideoCodec.H264, VideoCodec.H265, VideoCodec.AV1, VideoCodec.VP9] + codec_idx = codecs.index(self._ts.video_codec) if self._ts.video_codec in codecs else 1 + self._vo_codec.setCurrentIndex(codec_idx) + self._vo_codec.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'video_codec', codecs[idx]) + ) + tc_layout.addRow("Target Codec:", self._vo_codec) + + self._vo_quality = QSpinBox() + self._vo_quality.setRange(0, 51) + self._vo_quality.setValue(self._ts.video_quality) + self._vo_quality.valueChanged.connect( + lambda v: setattr(self._ts, 'video_quality', v) + ) + tc_layout.addRow("Quality:", self._vo_quality) + + self._vo_fail_bigger = QCheckBox("Fail if not smaller") + self._vo_fail_bigger.setChecked(self._ts.video_fail_if_bigger) + self._vo_fail_bigger.toggled.connect( + lambda v: setattr(self._ts, 'video_fail_if_bigger', v) + ) + tc_layout.addRow(self._vo_fail_bigger) + + layout.addRow(self._transcode_group) + + self._on_vo_mode_changed(self._vo_mode.currentIndex()) + return panel + + def _on_vo_mode_changed(self, idx): + mode = "crop" if idx == 0 else "transcode" + self._ts.video_opt_mode = mode + self._crop_group.setVisible(idx == 0) + self._transcode_group.setVisible(idx == 1) + self.settings_changed.emit() diff --git a/czkawka_pyside6/main.py b/czkawka_pyside6/main.py new file mode 100644 index 000000000..6bf9a05f1 --- /dev/null +++ b/czkawka_pyside6/main.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +""" +Czkawka PySide6 - A PySide6/Qt interface for czkawka file cleanup tool. + +This application provides a graphical interface to czkawka, using the +czkawka_cli binary as its backend for all scanning operations. + +Usage: + python main.py + # or + python -m czkawka_pyside6.main + +Requirements: + - PySide6 >= 6.6.0 + - czkawka_cli binary in PATH (or configured in settings) + - Optional: send2trash (for trash support) + - Optional: Pillow (for EXIF cleaning) +""" + +import sys +import os + + +def main(): + # Set environment for better HiDPI support + os.environ.setdefault("QT_AUTO_SCREEN_SCALE_FACTOR", "1") + + from PySide6.QtWidgets import QApplication + from PySide6.QtCore import Qt + from PySide6.QtGui import QFont + + app = QApplication(sys.argv) + app.setApplicationName("Czkawka") + app.setApplicationVersion("11.0.1") + app.setOrganizationName("czkawka") + app.setDesktopFileName("com.github.qarmin.czkawka") + + # Set application icon + from app.icons import app_icon + icon = app_icon() + if not icon.isNull(): + app.setWindowIcon(icon) + + # Set default font + font = QFont() + font.setPointSize(10) + app.setFont(font) + + # Import and create main window + from app.main_window import MainWindow + window = MainWindow() + window.show() + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() From 88a0149b9fad9b174c84b704586ac5452408246e Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 10:09:33 +0100 Subject: [PATCH 32/49] Make PySide6 frontend KDE6/Plasma compliant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove forced dark palette and hardcoded color stylesheets; inherit the system theme (Breeze, Adwaita, etc.) so the app looks native - Use QIcon.fromTheme() with standard XDG/FreeDesktop icon names (system-search, edit-delete, document-save, etc.) with embedded SVG fallbacks for systems without an icon theme - Use QStandardPaths.AppConfigLocation for XDG-compliant config paths - Add .desktop file (com.github.qarmin.czkawka-pyside6.desktop) - Add AppStream metainfo.xml for software center integration - Set desktopFileName and organizationDomain for proper KDE integration - Replace all hardcoded setStyleSheet color values with system palette (setEnabled(False) for muted text, QFont for bold/size, QFrame for separators, style().standardIcon() for dialog icons) - Remove forced QFont size — inherit system font settings Co-Authored-By: Claude Opus 4.6 (1M context) --- czkawka_pyside6/app/action_buttons.py | 14 --- czkawka_pyside6/app/dialogs/about_dialog.py | 24 ++--- czkawka_pyside6/app/dialogs/delete_dialog.py | 16 ++-- czkawka_pyside6/app/dialogs/move_dialog.py | 1 - czkawka_pyside6/app/dialogs/rename_dialog.py | 2 +- czkawka_pyside6/app/dialogs/select_dialog.py | 2 +- czkawka_pyside6/app/icons.py | 80 ++++++++--------- czkawka_pyside6/app/left_panel.py | 18 +--- czkawka_pyside6/app/main_window.py | 88 ++++--------------- czkawka_pyside6/app/preview_panel.py | 10 +-- czkawka_pyside6/app/progress_widget.py | 63 ++++++------- czkawka_pyside6/app/results_view.py | 30 +++++-- czkawka_pyside6/app/settings_panel.py | 5 +- czkawka_pyside6/app/state.py | 6 +- czkawka_pyside6/app/tool_settings.py | 4 +- czkawka_pyside6/main.py | 23 ++--- .../com.github.qarmin.czkawka-pyside6.desktop | 12 +++ ...github.qarmin.czkawka-pyside6.metainfo.xml | 40 +++++++++ 18 files changed, 206 insertions(+), 232 deletions(-) create mode 100644 data/com.github.qarmin.czkawka-pyside6.desktop create mode 100644 data/com.github.qarmin.czkawka-pyside6.metainfo.xml diff --git a/czkawka_pyside6/app/action_buttons.py b/czkawka_pyside6/app/action_buttons.py index 36e928a26..ef39a3947 100644 --- a/czkawka_pyside6/app/action_buttons.py +++ b/czkawka_pyside6/app/action_buttons.py @@ -46,11 +46,6 @@ def _setup_ui(self): self._scan_btn = QPushButton(icon_search(18), " Scan") self._scan_btn.setIconSize(ICON_SIZE) self._scan_btn.setMinimumWidth(90) - self._scan_btn.setStyleSheet( - "QPushButton { background-color: #2d5a27; color: white; font-weight: bold; padding: 6px 16px; }" - "QPushButton:hover { background-color: #3a7a34; }" - "QPushButton:disabled { background-color: #444; color: #888; }" - ) self._scan_btn.clicked.connect(self.scan_clicked.emit) layout.addWidget(self._scan_btn) @@ -58,10 +53,6 @@ def _setup_ui(self): self._stop_btn = QPushButton(icon_stop(18), " Stop") self._stop_btn.setIconSize(ICON_SIZE) self._stop_btn.setMinimumWidth(80) - self._stop_btn.setStyleSheet( - "QPushButton { background-color: #8a2222; color: white; font-weight: bold; padding: 6px 16px; }" - "QPushButton:hover { background-color: #aa3333; }" - ) self._stop_btn.clicked.connect(self.stop_clicked.emit) self._stop_btn.setVisible(False) layout.addWidget(self._stop_btn) @@ -80,11 +71,6 @@ def _setup_ui(self): # Delete button self._delete_btn = QPushButton(icon_delete(18), " Delete") self._delete_btn.setIconSize(ICON_SIZE) - self._delete_btn.setStyleSheet( - "QPushButton { background-color: #8a2222; color: white; padding: 6px 12px; }" - "QPushButton:hover { background-color: #aa3333; }" - "QPushButton:disabled { background-color: #444; color: #888; }" - ) self._delete_btn.clicked.connect(self.delete_clicked.emit) layout.addWidget(self._delete_btn) diff --git a/czkawka_pyside6/app/dialogs/about_dialog.py b/czkawka_pyside6/app/dialogs/about_dialog.py index 54da0fa9f..3284712ea 100644 --- a/czkawka_pyside6/app/dialogs/about_dialog.py +++ b/czkawka_pyside6/app/dialogs/about_dialog.py @@ -1,8 +1,8 @@ from PySide6.QtWidgets import ( - QDialog, QVBoxLayout, QLabel, QDialogButtonBox + QDialog, QVBoxLayout, QLabel, QDialogButtonBox, QFrame ) from PySide6.QtCore import Qt -from PySide6.QtGui import QPixmap +from PySide6.QtGui import QPixmap, QFont from ..icons import app_logo_path @@ -30,24 +30,29 @@ def __init__(self, parent=None): layout.addWidget(logo_label) title = QLabel("Czkawka") - title.setStyleSheet("font-size: 28px; font-weight: bold; padding: 4px;") + title_font = QFont() + title_font.setPointSize(22) + title_font.setBold(True) + title.setFont(title_font) title.setAlignment(Qt.AlignCenter) layout.addWidget(title) - subtitle = QLabel("PySide6 / Qt6 Edition") - subtitle.setStyleSheet("font-size: 14px; color: #6fbf73; padding: 2px;") + subtitle = QLabel("PySide6 / Qt 6 Edition") + sub_font = QFont() + sub_font.setPointSize(11) + subtitle.setFont(sub_font) subtitle.setAlignment(Qt.AlignCenter) layout.addWidget(subtitle) version = QLabel("Version 11.0.1") - version.setStyleSheet("font-size: 11px; color: #888; padding: 2px;") version.setAlignment(Qt.AlignCenter) + version.setEnabled(False) layout.addWidget(version) # Separator - sep = QLabel() - sep.setFixedHeight(1) - sep.setStyleSheet("background-color: #444; margin: 8px 40px;") + sep = QFrame() + sep.setFrameShape(QFrame.HLine) + sep.setFrameShadow(QFrame.Sunken) layout.addWidget(sep) desc = QLabel( @@ -68,7 +73,6 @@ def __init__(self, parent=None): ) desc.setWordWrap(True) desc.setAlignment(Qt.AlignCenter) - desc.setStyleSheet("padding: 6px 20px; line-height: 1.4;") layout.addWidget(desc) layout.addStretch() diff --git a/czkawka_pyside6/app/dialogs/delete_dialog.py b/czkawka_pyside6/app/dialogs/delete_dialog.py index 89ce64bc6..b82ad674d 100644 --- a/czkawka_pyside6/app/dialogs/delete_dialog.py +++ b/czkawka_pyside6/app/dialogs/delete_dialog.py @@ -1,8 +1,9 @@ from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QLabel, QDialogButtonBox, - QCheckBox, QHBoxLayout + QCheckBox, QMessageBox ) from PySide6.QtCore import Qt +from PySide6.QtGui import QFont class DeleteDialog(QDialog): @@ -16,15 +17,17 @@ def __init__(self, count: int, move_to_trash: bool = True, parent=None): layout = QVBoxLayout(self) - # Warning + # Warning icon from system theme icon_label = QLabel() - icon_label.setStyleSheet("font-size: 36px;") - icon_label.setText("Warning") + icon = self.style().standardIcon(self.style().SP_MessageBoxWarning) + icon_label.setPixmap(icon.pixmap(48, 48)) icon_label.setAlignment(Qt.AlignCenter) layout.addWidget(icon_label) msg = QLabel(f"Are you sure you want to delete {count} selected file(s)?") - msg.setStyleSheet("font-size: 14px; padding: 10px;") + msg_font = QFont() + msg_font.setPointSize(11) + msg.setFont(msg_font) msg.setAlignment(Qt.AlignCenter) msg.setWordWrap(True) layout.addWidget(msg) @@ -39,9 +42,6 @@ def __init__(self, count: int, move_to_trash: bool = True, parent=None): QDialogButtonBox.Ok | QDialogButtonBox.Cancel ) buttons.button(QDialogButtonBox.Ok).setText("Delete") - buttons.button(QDialogButtonBox.Ok).setStyleSheet( - "background-color: #8a2222; color: white; padding: 6px 20px;" - ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) diff --git a/czkawka_pyside6/app/dialogs/move_dialog.py b/czkawka_pyside6/app/dialogs/move_dialog.py index 6c754d21a..a26a51405 100644 --- a/czkawka_pyside6/app/dialogs/move_dialog.py +++ b/czkawka_pyside6/app/dialogs/move_dialog.py @@ -17,7 +17,6 @@ def __init__(self, count: int, parent=None): layout = QVBoxLayout(self) msg = QLabel(f"Move or copy {count} selected file(s) to:") - msg.setStyleSheet("font-size: 13px; padding: 6px;") layout.addWidget(msg) # Destination path diff --git a/czkawka_pyside6/app/dialogs/rename_dialog.py b/czkawka_pyside6/app/dialogs/rename_dialog.py index 4c6208ab1..da4cb4fa5 100644 --- a/czkawka_pyside6/app/dialogs/rename_dialog.py +++ b/czkawka_pyside6/app/dialogs/rename_dialog.py @@ -22,7 +22,7 @@ def __init__(self, count: int, rename_type: str = "extensions", parent=None): label = QLabel(msg) label.setWordWrap(True) - label.setStyleSheet("font-size: 13px; padding: 10px;") + label.setContentsMargins(10, 10, 10, 10) layout.addWidget(label) buttons = QDialogButtonBox( diff --git a/czkawka_pyside6/app/dialogs/select_dialog.py b/czkawka_pyside6/app/dialogs/select_dialog.py index 58ea94a11..f4153106c 100644 --- a/czkawka_pyside6/app/dialogs/select_dialog.py +++ b/czkawka_pyside6/app/dialogs/select_dialog.py @@ -30,7 +30,7 @@ def __init__(self, parent=None): layout = QVBoxLayout(self) label = QLabel("Choose selection mode:") - label.setStyleSheet("font-size: 13px; padding: 4px;") + label.setContentsMargins(4, 4, 4, 4) layout.addWidget(label) for mode, name in self.MODES: diff --git a/czkawka_pyside6/app/icons.py b/czkawka_pyside6/app/icons.py index bc094529f..9ec8fdace 100644 --- a/czkawka_pyside6/app/icons.py +++ b/czkawka_pyside6/app/icons.py @@ -1,45 +1,37 @@ -"""SVG icon resources for Czkawka PySide6 interface. +"""Icon resources for Czkawka PySide6 interface. -Uses the same SVG icons as the Krokiet (Slint) interface. -Icons are embedded as strings to avoid file path issues. +KDE-compliant icon strategy: + 1. Try QIcon.fromTheme() with standard XDG/FreeDesktop icon names + 2. Fall back to the embedded SVG icons from the Krokiet icon set + +This ensures the app looks native on KDE/Plasma (Breeze icons) and +other desktops while still working when no icon theme is installed. """ -from PySide6.QtGui import QIcon, QPixmap +from PySide6.QtGui import QIcon, QPixmap, QPainter, QImage from PySide6.QtCore import QSize, Qt from PySide6.QtSvg import QSvgRenderer -from PySide6.QtGui import QPainter, QImage from functools import lru_cache -# Fill color applied to icons for dark theme visibility -_ICON_FILL = "#cccccc" -_ICON_FILL_GREEN = "#6fbf73" -_ICON_FILL_RED = "#e57373" - -def _colorize_svg(svg: str, fill: str = _ICON_FILL) -> str: - """Inject fill color into SVG for dark theme visibility.""" - # Add fill to root svg or g elements - if 'fill="none"' not in svg and f'fill="{fill}"' not in svg: - svg = svg.replace(" QIcon: + """Return XDG theme icon if available, otherwise render the fallback SVG.""" + icon = QIcon.fromTheme(xdg_name) + if not icon.isNull(): + return icon + return _svg_to_icon(fallback_svg, size) @lru_cache(maxsize=64) -def _svg_to_icon(svg_data: str, fill: str = _ICON_FILL, size: int = 24) -> QIcon: - """Convert SVG string to QIcon with specified fill color.""" - colored = _colorize_svg(svg_data, fill) - renderer = QSvgRenderer(colored.encode("utf-8")) +def _svg_to_icon(svg_data: str, size: int = 24) -> QIcon: + """Render SVG string to QIcon. No color injection — respects original SVG.""" + renderer = QSvgRenderer(svg_data.encode("utf-8")) image = QImage(QSize(size, size), QImage.Format_ARGB32_Premultiplied) image.fill(Qt.transparent) painter = QPainter(image) renderer.render(painter) painter.end() - pixmap = QPixmap.fromImage(image) - return QIcon(pixmap) + return QIcon(QPixmap.fromImage(image)) # ─── Raw SVG data ─────────────────────────────────────────── @@ -79,53 +71,57 @@ def _svg_to_icon(svg_data: str, fill: str = _ICON_FILL, size: int = 24) -> QIcon # ─── Icon accessor functions ──────────────────────────────── +# ─── XDG theme name → fallback SVG mapping ───────────────── +# Standard FreeDesktop icon names are tried first (Breeze, Adwaita, etc.) +# If the theme doesn't have them, the embedded Krokiet SVG is used. + def icon_search(size=24): - return _svg_to_icon(SEARCH_SVG, _ICON_FILL_GREEN, size) + return _themed_icon("system-search", SEARCH_SVG, size) def icon_stop(size=24): - return _svg_to_icon(STOP_SVG, _ICON_FILL_RED, size) + return _themed_icon("process-stop", STOP_SVG, size) def icon_delete(size=24): - return _svg_to_icon(DELETE_SVG, _ICON_FILL_RED, size) + return _themed_icon("edit-delete", DELETE_SVG, size) def icon_move(size=24): - return _svg_to_icon(MOVE_SVG, _ICON_FILL, size) + return _themed_icon("folder-move", MOVE_SVG, size) def icon_save(size=24): - return _svg_to_icon(SAVE_SVG, _ICON_FILL, size) + return _themed_icon("document-save", SAVE_SVG, size) def icon_select(size=24): - return _svg_to_icon(SELECT_SVG, _ICON_FILL, size) + return _themed_icon("edit-select-all", SELECT_SVG, size) def icon_sort(size=24): - return _svg_to_icon(SORT_SVG, _ICON_FILL, size) + return _themed_icon("view-sort", SORT_SVG, size) def icon_hardlink(size=24): - return _svg_to_icon(HARDLINK_SVG, _ICON_FILL, size) + return _themed_icon("edit-link", HARDLINK_SVG, size) def icon_symlink(size=24): - return _svg_to_icon(SYMLINK_SVG, _ICON_FILL, size) + return _themed_icon("edit-link", SYMLINK_SVG, size) def icon_rename(size=24): - return _svg_to_icon(RENAME_SVG, _ICON_FILL, size) + return _themed_icon("edit-rename", RENAME_SVG, size) def icon_clean(size=24): - return _svg_to_icon(CLEAN_SVG, _ICON_FILL, size) + return _themed_icon("edit-clear-all", CLEAN_SVG, size) def icon_optimize(size=24): - return _svg_to_icon(OPTIMIZE_SVG, _ICON_FILL, size) + return _themed_icon("configure", OPTIMIZE_SVG, size) def icon_settings(size=24): - return _svg_to_icon(SETTINGS_SVG, _ICON_FILL, size) + return _themed_icon("configure", SETTINGS_SVG, size) def icon_subsettings(size=24): - return _svg_to_icon(SUBSETTINGS_SVG, _ICON_FILL, size) + return _themed_icon("preferences-other", SUBSETTINGS_SVG, size) def icon_dir(size=24): - return _svg_to_icon(DIR_SVG, _ICON_FILL, size) + return _themed_icon("folder", DIR_SVG, size) def icon_info(size=24): - return _svg_to_icon(INFO_SVG, _ICON_FILL, size) + return _themed_icon("dialog-information", INFO_SVG, size) def app_logo_path() -> str: diff --git a/czkawka_pyside6/app/left_panel.py b/czkawka_pyside6/app/left_panel.py index 8c53e77d3..8a8f87185 100644 --- a/czkawka_pyside6/app/left_panel.py +++ b/czkawka_pyside6/app/left_panel.py @@ -96,22 +96,6 @@ def _setup_ui(self): # Tool list self._tool_list = QListWidget() self._tool_list.setSpacing(1) - font = QFont() - font.setPointSize(10) - self._tool_list.setFont(font) - self._tool_list.setStyleSheet(""" - QListWidget::item { - padding: 4px 8px; - border-left: 3px solid transparent; - } - QListWidget::item:selected { - border-left: 3px solid #6fbf73; - background-color: #353535; - } - QListWidget::item:hover { - background-color: #49494926; - } - """) for tab in self.TOOL_TABS: item = QListWidgetItem(TAB_DISPLAY_NAMES[tab]) @@ -126,7 +110,7 @@ def _setup_ui(self): # Version label version_label = QLabel("Czkawka PySide6 v11.0.1") version_label.setAlignment(Qt.AlignCenter) - version_label.setStyleSheet("color: #666; font-size: 9px; padding: 2px;") + version_label.setEnabled(False) layout.addWidget(version_label) def _on_item_changed(self, current, previous): diff --git a/czkawka_pyside6/app/main_window.py b/czkawka_pyside6/app/main_window.py index 2515bd237..e6168badc 100644 --- a/czkawka_pyside6/app/main_window.py +++ b/czkawka_pyside6/app/main_window.py @@ -7,7 +7,7 @@ QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QSplitter, QStatusBar, QMessageBox, QLabel, QApplication ) -from PySide6.QtCore import Qt, QTimer +from PySide6.QtCore import Qt, QTimer, QStandardPaths from PySide6.QtGui import QPalette, QColor from .state import AppState @@ -485,83 +485,29 @@ def _on_settings_changed(self): self._bottom_panel.refresh_lists() def _apply_theme(self): - """Apply dark theme to the application.""" - if not self._state.settings.dark_theme: - return + """Apply minimal styling that works with the system theme. + KDE compliance: we inherit the desktop theme (Breeze, Adwaita, etc.) + and only add small layout tweaks that don't override colors. + """ app = QApplication.instance() - palette = QPalette() - - # Dark theme colors - palette.setColor(QPalette.Window, QColor(43, 43, 43)) - palette.setColor(QPalette.WindowText, QColor(210, 210, 210)) - palette.setColor(QPalette.Base, QColor(30, 30, 30)) - palette.setColor(QPalette.AlternateBase, QColor(38, 38, 38)) - palette.setColor(QPalette.ToolTipBase, QColor(50, 50, 50)) - palette.setColor(QPalette.ToolTipText, QColor(210, 210, 210)) - palette.setColor(QPalette.Text, QColor(210, 210, 210)) - palette.setColor(QPalette.Button, QColor(53, 53, 53)) - palette.setColor(QPalette.ButtonText, QColor(210, 210, 210)) - palette.setColor(QPalette.BrightText, QColor(255, 255, 255)) - palette.setColor(QPalette.Link, QColor(86, 140, 210)) - palette.setColor(QPalette.Highlight, QColor(60, 100, 150)) - palette.setColor(QPalette.HighlightedText, QColor(255, 255, 255)) - palette.setColor(QPalette.Disabled, QPalette.Text, QColor(128, 128, 128)) - palette.setColor(QPalette.Disabled, QPalette.ButtonText, QColor(128, 128, 128)) - - app.setPalette(palette) - - # Additional stylesheet + + # Only apply layout polish — no color overrides so the system + # theme (Breeze dark/light, Adwaita, etc.) is fully respected. app.setStyleSheet(""" - QMainWindow { background-color: #2b2b2b; } - QSplitter::handle { background-color: #404040; width: 2px; } - QTreeWidget { border: 1px solid #404040; } + QSplitter::handle { width: 2px; } QTreeWidget::item { padding: 2px; } - QTreeWidget::item:alternate { background-color: #262626; } - QTreeWidget::item:selected { background-color: #3c6496; } - QListWidget { border: 1px solid #404040; } QListWidget::item { padding: 3px; } - QListWidget::item:selected { background-color: #3c6496; } - QGroupBox { border: 1px solid #505050; border-radius: 4px; - margin-top: 8px; padding-top: 8px; } - QGroupBox::title { subcontrol-origin: margin; left: 10px; - padding: 0 4px; } - QPushButton { padding: 5px 12px; border: 1px solid #555; - border-radius: 3px; background-color: #404040; } - QPushButton:hover { background-color: #505050; } - QPushButton:pressed { background-color: #353535; } - QPushButton:disabled { background-color: #333; color: #666; } - QComboBox { padding: 4px; border: 1px solid #555; - border-radius: 3px; background-color: #404040; } - QComboBox:hover { background-color: #505050; } - QComboBox QAbstractItemView { background-color: #353535; - border: 1px solid #555; - selection-background-color: #3c6496; } - QLineEdit { padding: 4px; border: 1px solid #555; - border-radius: 3px; background-color: #353535; } - QSpinBox { padding: 4px; border: 1px solid #555; - border-radius: 3px; background-color: #353535; } - QSlider::groove:horizontal { height: 6px; background: #404040; - border-radius: 3px; } - QSlider::handle:horizontal { width: 14px; margin: -4px 0; - background: #888; border-radius: 7px; } - QSlider::handle:horizontal:hover { background: #aaa; } - QProgressBar { border: 1px solid #555; border-radius: 3px; - text-align: center; background-color: #353535; } - QProgressBar::chunk { background-color: #3c6496; border-radius: 2px; } + QGroupBox { border-radius: 4px; margin-top: 8px; padding-top: 8px; } + QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 4px; } + QPushButton { padding: 5px 12px; } + QComboBox { padding: 4px; } + QLineEdit { padding: 4px; } + QSpinBox { padding: 4px; } + QProgressBar { text-align: center; } QScrollArea { border: none; } - QTabWidget::pane { border: 1px solid #555; } - QTabBar::tab { padding: 6px 16px; border: 1px solid #555; - border-bottom: none; border-radius: 3px 3px 0 0; - background-color: #353535; } - QTabBar::tab:selected { background-color: #404040; } - QTabBar::tab:hover { background-color: #505050; } - QStatusBar { background-color: #2b2b2b; border-top: 1px solid #404040; } QCheckBox { spacing: 6px; } - QCheckBox::indicator { width: 16px; height: 16px; } - QTextEdit { border: 1px solid #404040; background-color: #1e1e1e; } - QHeaderView::section { background-color: #353535; padding: 4px; - border: 1px solid #404040; } + QHeaderView::section { padding: 4px; } """) def _auto_detect_cli(self): diff --git a/czkawka_pyside6/app/preview_panel.py b/czkawka_pyside6/app/preview_panel.py index 3e3c50586..7b53bc0b2 100644 --- a/czkawka_pyside6/app/preview_panel.py +++ b/czkawka_pyside6/app/preview_panel.py @@ -27,7 +27,9 @@ def _setup_ui(self): layout.setContentsMargins(4, 4, 4, 4) self._title = QLabel("Preview") - self._title.setStyleSheet("font-weight: bold; font-size: 12px; padding: 2px;") + font = self._title.font() + font.setBold(True) + self._title.setFont(font) self._title.setAlignment(Qt.AlignCenter) layout.addWidget(self._title) @@ -35,16 +37,14 @@ def _setup_ui(self): self._image_label.setAlignment(Qt.AlignCenter) self._image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self._image_label.setMinimumSize(QSize(180, 180)) - self._image_label.setStyleSheet( - "background-color: #1a1a1a; border: 1px solid #333; border-radius: 4px;" - ) + self._image_label.setFrameShape(QLabel.StyledPanel) self._image_label.setScaledContents(False) layout.addWidget(self._image_label) self._info_label = QLabel() self._info_label.setAlignment(Qt.AlignCenter) self._info_label.setWordWrap(True) - self._info_label.setStyleSheet("color: #888; font-size: 10px; padding: 2px;") + self._info_label.setEnabled(False) layout.addWidget(self._info_label) def show_preview(self, file_path: str): diff --git a/czkawka_pyside6/app/progress_widget.py b/czkawka_pyside6/app/progress_widget.py index 185e0790d..9c1de28d3 100644 --- a/czkawka_pyside6/app/progress_widget.py +++ b/czkawka_pyside6/app/progress_widget.py @@ -5,7 +5,7 @@ from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QProgressBar, QLabel, QHBoxLayout ) -from PySide6.QtCore import Qt, QTimer +from PySide6.QtCore import Qt, QTimer, QStandardPaths from .models import ActiveTab, ScanProgress @@ -21,23 +21,6 @@ class ProgressWidget(QWidget): - Phase step indicators """ - # File where we persist the last file-collection count per directory set, - # so we can estimate the collection stage percentage on the next scan. - _ESTIMATE_FILE = Path.home() / ".config" / "czkawka_pyside6" / "scan_estimates.json" - - _BAR_STYLE = """ - QProgressBar {{ - border: 1px solid #555; border-radius: 3px; - text-align: center; background-color: #2a2a2a; - font-size: 10px; color: #ccc; - }} - QProgressBar::chunk {{ - background: qlineargradient(x1:0, y1:0, x2:1, y2:0, - stop:0 {c1}, stop:1 {c2}); - border-radius: 2px; - }} - """ - def __init__(self, parent=None): super().__init__(parent) self.setVisible(False) @@ -62,32 +45,31 @@ def _setup_ui(self): # Row 1: stage label + elapsed row1 = QHBoxLayout() self._stage_label = QLabel("Initializing...") - self._stage_label.setStyleSheet("font-weight: bold; font-size: 12px;") + font = self._stage_label.font() + font.setBold(True) + self._stage_label.setFont(font) row1.addWidget(self._stage_label) row1.addStretch() self._elapsed_label = QLabel("") - self._elapsed_label.setStyleSheet("color: #888; font-size: 11px;") + self._elapsed_label.setEnabled(False) # Uses disabled palette color row1.addWidget(self._elapsed_label) layout.addLayout(row1) - # Row 2: current stage bar "Current stage" NN% + # Row 2: current stage bar "Current" NN% row2 = QHBoxLayout() row2.setSpacing(6) lbl2 = QLabel("Current") - lbl2.setStyleSheet("color: #aaa; font-size: 10px;") + lbl2.setEnabled(False) lbl2.setFixedWidth(48) row2.addWidget(lbl2) self._stage_bar = QProgressBar() self._stage_bar.setFixedHeight(14) self._stage_bar.setTextVisible(False) - self._stage_bar.setStyleSheet( - self._BAR_STYLE.format(c1="#1b4332", c2="#2d6a4f") - ) row2.addWidget(self._stage_bar) self._stage_pct = QLabel("") self._stage_pct.setFixedWidth(40) self._stage_pct.setAlignment(Qt.AlignRight | Qt.AlignVCenter) - self._stage_pct.setStyleSheet("color: #aaa; font-size: 10px;") + self._stage_pct.setEnabled(False) row2.addWidget(self._stage_pct) layout.addLayout(row2) @@ -95,38 +77,35 @@ def _setup_ui(self): row3 = QHBoxLayout() row3.setSpacing(6) lbl3 = QLabel("Overall") - lbl3.setStyleSheet("color: #aaa; font-size: 10px;") + lbl3.setEnabled(False) lbl3.setFixedWidth(48) row3.addWidget(lbl3) self._overall_bar = QProgressBar() self._overall_bar.setFixedHeight(14) self._overall_bar.setTextVisible(False) - self._overall_bar.setStyleSheet( - self._BAR_STYLE.format(c1="#2d6a4f", c2="#40916c") - ) row3.addWidget(self._overall_bar) self._overall_pct = QLabel("") self._overall_pct.setFixedWidth(40) self._overall_pct.setAlignment(Qt.AlignRight | Qt.AlignVCenter) - self._overall_pct.setStyleSheet("color: #aaa; font-size: 10px;") + self._overall_pct.setEnabled(False) row3.addWidget(self._overall_pct) layout.addLayout(row3) # Row 4: detail counts row4 = QHBoxLayout() self._detail_label = QLabel("") - self._detail_label.setStyleSheet("color: #888; font-size: 10px;") + self._detail_label.setEnabled(False) row4.addWidget(self._detail_label) row4.addStretch() self._size_label = QLabel("") - self._size_label.setStyleSheet("color: #888; font-size: 10px;") + self._size_label.setEnabled(False) self._size_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter) row4.addWidget(self._size_label) layout.addLayout(row4) # Row 5: step indicators self._steps_label = QLabel("") - self._steps_label.setStyleSheet("color: #666; font-size: 9px;") + self._steps_label.setEnabled(False) self._steps_label.setAlignment(Qt.AlignCenter) self._steps_label.setWordWrap(True) layout.addWidget(self._steps_label) @@ -261,18 +240,26 @@ def _get_estimate_key(self) -> str: def _get_estimate(self) -> int: return self._estimates.get(self._get_estimate_key(), 0) + @staticmethod + def _estimate_file_path() -> Path: + config_dir = QStandardPaths.writableLocation(QStandardPaths.AppConfigLocation) + base = Path(config_dir) if config_dir else Path.home() / ".config" / "czkawka" + return base / "scan_estimates.json" + def _save_estimate(self, count: int): self._estimates[self._get_estimate_key()] = count try: - self._ESTIMATE_FILE.parent.mkdir(parents=True, exist_ok=True) - self._ESTIMATE_FILE.write_text(json.dumps(self._estimates)) + path = self._estimate_file_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(self._estimates)) except OSError: pass def _load_estimates(self): try: - if self._ESTIMATE_FILE.exists(): - self._estimates = json.loads(self._ESTIMATE_FILE.read_text()) + path = self._estimate_file_path() + if path.exists(): + self._estimates = json.loads(path.read_text()) except (json.JSONDecodeError, OSError): self._estimates = {} diff --git a/czkawka_pyside6/app/results_view.py b/czkawka_pyside6/app/results_view.py index fc1aca09c..6c7a37eb9 100644 --- a/czkawka_pyside6/app/results_view.py +++ b/czkawka_pyside6/app/results_view.py @@ -17,10 +17,11 @@ class ResultsView(QWidget): item_activated = Signal(object) # ResultEntry context_menu_requested = Signal(object, object) # QPoint, ResultEntry - # Colors - HEADER_BG = QColor(60, 60, 80) - HEADER_FG = QColor(220, 220, 255) - SELECTED_BG = QColor(40, 80, 40) + # Group header uses the system highlight color (darkened) so it works on + # both light and dark themes. Computed lazily on first result display. + _header_colors_ready = False + HEADER_BG = QColor() + HEADER_FG = QColor() def __init__(self, parent=None): super().__init__(parent) @@ -28,6 +29,23 @@ def __init__(self, parent=None): self._results: list[ResultEntry] = [] self._setup_ui() + def _ensure_header_colors(self): + """Derive group header colors from the system palette.""" + if self._header_colors_ready: + return + from PySide6.QtWidgets import QApplication + palette = QApplication.instance().palette() + # Use a midpoint between window and highlight for header background + win = palette.color(palette.Window) + hi = palette.color(palette.Highlight) + self.HEADER_BG = QColor( + (win.red() + hi.red()) // 2, + (win.green() + hi.green()) // 2, + (win.blue() + hi.blue()) // 2, + ) + self.HEADER_FG = palette.color(palette.HighlightedText) + self._header_colors_ready = True + def _setup_ui(self): layout = QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) @@ -35,11 +53,10 @@ def _setup_ui(self): # Summary bar summary_layout = QHBoxLayout() self._summary_label = QLabel("No results") - self._summary_label.setStyleSheet("padding: 4px;") summary_layout.addWidget(self._summary_label) self._selection_label = QLabel("") self._selection_label.setAlignment(Qt.AlignRight) - self._selection_label.setStyleSheet("padding: 4px; color: #aaa;") + self._selection_label.setEnabled(False) summary_layout.addWidget(self._selection_label) layout.addLayout(summary_layout) @@ -66,6 +83,7 @@ def set_active_tab(self, tab: ActiveTab): header.setSectionResizeMode(i, QHeaderView.ResizeToContents) def set_results(self, results: list[ResultEntry]): + self._ensure_header_colors() self._results = results self._tree.blockSignals(True) self._tree.clear() diff --git a/czkawka_pyside6/app/settings_panel.py b/czkawka_pyside6/app/settings_panel.py index 1ab5c2159..805f6664d 100644 --- a/czkawka_pyside6/app/settings_panel.py +++ b/czkawka_pyside6/app/settings_panel.py @@ -25,7 +25,10 @@ def _setup_ui(self): # Header header = QHBoxLayout() title = QLabel("Settings") - title.setStyleSheet("font-weight: bold; font-size: 16px; padding: 4px;") + font = title.font() + font.setBold(True) + font.setPointSize(font.pointSize() + 2) + title.setFont(font) header.addWidget(title) header.addStretch() close_btn = QPushButton("Close") diff --git a/czkawka_pyside6/app/state.py b/czkawka_pyside6/app/state.py index 2737323d0..9c73524ff 100644 --- a/czkawka_pyside6/app/state.py +++ b/czkawka_pyside6/app/state.py @@ -1,6 +1,6 @@ import json from pathlib import Path -from PySide6.QtCore import QObject, Signal +from PySide6.QtCore import QObject, Signal, QStandardPaths from .models import ( ActiveTab, AppSettings, ToolSettings, ResultEntry, ScanProgress ) @@ -32,7 +32,9 @@ def __init__(self): self.progress = ScanProgress() self.info_text = "" self.preview_image_path = "" - self._config_path = Path.home() / ".config" / "czkawka_pyside6" + # Use QStandardPaths for XDG-compliant config location + config_dir = QStandardPaths.writableLocation(QStandardPaths.AppConfigLocation) + self._config_path = Path(config_dir) if config_dir else Path.home() / ".config" / "czkawka" self._config_path.mkdir(parents=True, exist_ok=True) self.load_settings() diff --git a/czkawka_pyside6/app/tool_settings.py b/czkawka_pyside6/app/tool_settings.py index a3c5d9e44..87d4249f0 100644 --- a/czkawka_pyside6/app/tool_settings.py +++ b/czkawka_pyside6/app/tool_settings.py @@ -30,7 +30,9 @@ def _setup_ui(self): main_layout.setContentsMargins(4, 4, 4, 4) title = QLabel("Tool Settings") - title.setStyleSheet("font-weight: bold; font-size: 13px; padding: 4px;") + font = title.font() + font.setBold(True) + title.setFont(font) main_layout.addWidget(title) self._scroll = QScrollArea() diff --git a/czkawka_pyside6/main.py b/czkawka_pyside6/main.py index 6bf9a05f1..413ab5a3b 100644 --- a/czkawka_pyside6/main.py +++ b/czkawka_pyside6/main.py @@ -22,30 +22,25 @@ def main(): - # Set environment for better HiDPI support - os.environ.setdefault("QT_AUTO_SCREEN_SCALE_FACTOR", "1") - from PySide6.QtWidgets import QApplication from PySide6.QtCore import Qt - from PySide6.QtGui import QFont app = QApplication(sys.argv) app.setApplicationName("Czkawka") app.setApplicationVersion("11.0.1") app.setOrganizationName("czkawka") - app.setDesktopFileName("com.github.qarmin.czkawka") - - # Set application icon - from app.icons import app_icon - icon = app_icon() + app.setOrganizationDomain("github.com/qarmin") + app.setDesktopFileName("com.github.qarmin.czkawka-pyside6") + + # Set application icon — use XDG theme icon with fallback to project logo + from PySide6.QtGui import QIcon + icon = QIcon.fromTheme("com.github.qarmin.czkawka") + if icon.isNull(): + from app.icons import app_icon + icon = app_icon() if not icon.isNull(): app.setWindowIcon(icon) - # Set default font - font = QFont() - font.setPointSize(10) - app.setFont(font) - # Import and create main window from app.main_window import MainWindow window = MainWindow() diff --git a/data/com.github.qarmin.czkawka-pyside6.desktop b/data/com.github.qarmin.czkawka-pyside6.desktop new file mode 100644 index 000000000..6e9c8eac8 --- /dev/null +++ b/data/com.github.qarmin.czkawka-pyside6.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Categories=System;FileTools;Qt; +Exec=czkawka-pyside6 +Icon=com.github.qarmin.czkawka +StartupWMClass=czkawka-pyside6 +Terminal=false +Type=Application + +Name=Czkawka PySide6 +GenericName=File Cleaner +Comment=Multi-functional app to find duplicates, empty folders, similar files and more - Qt/PySide6 edition. +Keywords=Czkawka;duplicate;same;similar;cleaner;copy;copies;compare;files;Qt; diff --git a/data/com.github.qarmin.czkawka-pyside6.metainfo.xml b/data/com.github.qarmin.czkawka-pyside6.metainfo.xml new file mode 100644 index 000000000..e85d44343 --- /dev/null +++ b/data/com.github.qarmin.czkawka-pyside6.metainfo.xml @@ -0,0 +1,40 @@ + + + com.github.qarmin.czkawka-pyside6 + Czkawka PySide6 + Multi-functional app to find duplicates, similar images and more - Qt/PySide6 edition + CC0-1.0 + MIT + +

Czkawka PySide6 is a Qt 6 GUI frontend for the Czkawka file cleanup tool. It uses czkawka_cli as its backend and provides feature parity with the Krokiet (Slint) interface.

+

It can find:

+
    +
  • Duplicates (by hash, name, size)
  • +
  • Similar images
  • +
  • Similar audio files
  • +
  • Similar videos
  • +
  • Empty folders and files
  • +
  • Broken files
  • +
  • Temporary files
  • +
  • Big files
  • +
  • Invalid symlinks
  • +
  • Files with bad names or extensions
  • +
  • Videos that can be optimized/cropped
  • +
  • EXIF metadata to remove
  • +
+ + com.github.qarmin.czkawka-pyside6.desktop + + + + + + Rafał Mikrut + + https://github.com/qarmin/czkawka + https://github.com/qarmin/czkawka/issues + https://github.com/sponsors/qarmin + + com.github.qarmin.czkawka-cli + + From 8bfd1bf57bb6fb197a181274b9d03f9bce7e8eec Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 10:44:18 +0100 Subject: [PATCH 33/49] Fix cargo fmt and show stage index in progress title - Fix writeln! formatting to pass cargo fmt --check - Show stage index in progress bar title (e.g., "[3/7] Calculating prehashes") - All czkawka_core tests pass (5/5 progress_data tests OK) - Krokiet compiles successfully - All CLI subcommands verified working Co-Authored-By: Claude Opus 4.6 (1M context) --- czkawka_cli/src/progress.rs | 6 +----- czkawka_pyside6/app/progress_widget.py | 7 +++++-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/czkawka_cli/src/progress.rs b/czkawka_cli/src/progress.rs index 6156e3daa..f0b4f77a6 100644 --- a/czkawka_cli/src/progress.rs +++ b/czkawka_cli/src/progress.rs @@ -122,11 +122,7 @@ pub(crate) fn connect_progress_json(progress_receiver: &Receiver) }; if let Ok(json) = serde_json::to_string(&progress_data) { - // Wrap in an object that includes the human-readable stage name - let _ = writeln!( - stderr, - "{{\"progress\":{json},\"stage_name\":\"{stage_name}\"}}" - ); + let _ = writeln!(stderr, "{{\"progress\":{json},\"stage_name\":\"{stage_name}\"}}"); } } } diff --git a/czkawka_pyside6/app/progress_widget.py b/czkawka_pyside6/app/progress_widget.py index 9c1de28d3..ecc309745 100644 --- a/czkawka_pyside6/app/progress_widget.py +++ b/czkawka_pyside6/app/progress_widget.py @@ -161,8 +161,11 @@ def update_progress(self, progress: ScanProgress): b_checked = progress.bytes_checked b_to_check = progress.bytes_to_check - # ── Stage label ── - self._stage_label.setText(stage_name) + # ── Stage label with stage index ── + if max_idx > 0: + self._stage_label.setText(f"[{idx + 1}/{max_idx + 1}] {stage_name}") + else: + self._stage_label.setText(stage_name) # ── Update step indicators using stage index ── if max_idx > 0: From 43d06298a7d67ca93d1c49642bb566252116bdba Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 10:45:29 +0100 Subject: [PATCH 34/49] Fix clippy use_self warnings in Commands::get_json_progress Co-Authored-By: Claude Opus 4.6 (1M context) --- czkawka_cli/src/commands.rs | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/czkawka_cli/src/commands.rs b/czkawka_cli/src/commands.rs index 9c73b5913..629368146 100644 --- a/czkawka_cli/src/commands.rs +++ b/czkawka_cli/src/commands.rs @@ -118,20 +118,20 @@ pub enum Commands { impl Commands { pub fn get_json_progress(&self) -> bool { match self { - Commands::Duplicates(a) => a.common_cli_items.json_progress, - Commands::EmptyFolders(a) => a.common_cli_items.json_progress, - Commands::BiggestFiles(a) => a.common_cli_items.json_progress, - Commands::EmptyFiles(a) => a.common_cli_items.json_progress, - Commands::Temporary(a) => a.common_cli_items.json_progress, - Commands::SimilarImages(a) => a.common_cli_items.json_progress, - Commands::SameMusic(a) => a.common_cli_items.json_progress, - Commands::InvalidSymlinks(a) => a.common_cli_items.json_progress, - Commands::BrokenFiles(a) => a.common_cli_items.json_progress, - Commands::SimilarVideos(a) => a.common_cli_items.json_progress, - Commands::BadExtensions(a) => a.common_cli_items.json_progress, - Commands::BadNames(a) => a.common_cli_items.json_progress, - Commands::VideoOptimizer(a) => a.common_cli_items.json_progress, - Commands::ExifRemover(a) => a.common_cli_items.json_progress, + Self::Duplicates(a) => a.common_cli_items.json_progress, + Self::EmptyFolders(a) => a.common_cli_items.json_progress, + Self::BiggestFiles(a) => a.common_cli_items.json_progress, + Self::EmptyFiles(a) => a.common_cli_items.json_progress, + Self::Temporary(a) => a.common_cli_items.json_progress, + Self::SimilarImages(a) => a.common_cli_items.json_progress, + Self::SameMusic(a) => a.common_cli_items.json_progress, + Self::InvalidSymlinks(a) => a.common_cli_items.json_progress, + Self::BrokenFiles(a) => a.common_cli_items.json_progress, + Self::SimilarVideos(a) => a.common_cli_items.json_progress, + Self::BadExtensions(a) => a.common_cli_items.json_progress, + Self::BadNames(a) => a.common_cli_items.json_progress, + Self::VideoOptimizer(a) => a.common_cli_items.json_progress, + Self::ExifRemover(a) => a.common_cli_items.json_progress, } } } From 8e48ff5264318064f1775494477109c2fc35811b Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 11:03:50 +0100 Subject: [PATCH 35/49] Add resizable/sortable columns, load results, fix group header spanning Results table improvements: - Columns are resizable (drag header edges) with sensible defaults - Click column header to sort (ascending/descending toggle) - Sorting works within groups for grouped tools (duplicates, etc.) - Numeric columns (Size, Date) sort by actual values, not strings - Sort indicator arrow shown in header Group header fix: - Group headers now span across all columns (merged cell effect) - setFirstColumnSpanned called after adding item to tree Load results: - New "Load" button in action bar to load previously saved JSON results - Supports both PySide6 save format and raw czkawka_cli JSON output - Save format now preserves group structure, checked state, and group IDs Co-Authored-By: Claude Opus 4.6 (1M context) --- czkawka_pyside6/app/action_buttons.py | 9 ++ czkawka_pyside6/app/dialogs/save_dialog.py | 133 +++++++++++++++-- czkawka_pyside6/app/main_window.py | 14 ++ czkawka_pyside6/app/results_view.py | 166 ++++++++++++++++++--- 4 files changed, 290 insertions(+), 32 deletions(-) diff --git a/czkawka_pyside6/app/action_buttons.py b/czkawka_pyside6/app/action_buttons.py index ef39a3947..241878f7b 100644 --- a/czkawka_pyside6/app/action_buttons.py +++ b/czkawka_pyside6/app/action_buttons.py @@ -22,6 +22,7 @@ class ActionButtons(QWidget): delete_clicked = Signal() move_clicked = Signal() save_clicked = Signal() + load_clicked = Signal() sort_clicked = Signal() hardlink_clicked = Signal() symlink_clicked = Signal() @@ -86,6 +87,14 @@ def _setup_ui(self): self._save_btn.clicked.connect(self.save_clicked.emit) layout.addWidget(self._save_btn) + # Load button + from .icons import icon_dir + self._load_btn = QPushButton(icon_dir(18), " Load") + self._load_btn.setIconSize(ICON_SIZE) + self._load_btn.setToolTip("Load previously saved results") + self._load_btn.clicked.connect(self.load_clicked.emit) + layout.addWidget(self._load_btn) + # Sort button self._sort_btn = QPushButton(icon_sort(18), " Sort") self._sort_btn.setIconSize(ICON_SIZE) diff --git a/czkawka_pyside6/app/dialogs/save_dialog.py b/czkawka_pyside6/app/dialogs/save_dialog.py index d8fa614e6..8df154bb5 100644 --- a/czkawka_pyside6/app/dialogs/save_dialog.py +++ b/czkawka_pyside6/app/dialogs/save_dialog.py @@ -1,11 +1,13 @@ -from PySide6.QtWidgets import ( - QDialog, QVBoxLayout, QLabel, QDialogButtonBox, - QCheckBox, QFileDialog -) +import json +from pathlib import Path + +from PySide6.QtWidgets import QFileDialog + +from ..models import ResultEntry class SaveDialog: - """Save results to file (uses native file dialog).""" + """Save/load results to/from file.""" @staticmethod def save(parent, results: list, save_as_json: bool = False) -> bool: @@ -13,24 +15,30 @@ def save(parent, results: list, save_as_json: bool = False) -> bool: filter_str = "JSON Files (*.json);;All Files (*)" default_ext = ".json" else: - filter_str = "Text Files (*.txt);;All Files (*)" + filter_str = "Text Files (*.txt);;JSON Files (*.json);;All Files (*)" default_ext = ".txt" - path, _ = QFileDialog.getSaveFileName( + path, selected_filter = QFileDialog.getSaveFileName( parent, "Save Results", f"results{default_ext}", filter_str ) if not path: return False + use_json = save_as_json or path.endswith(".json") or "JSON" in selected_filter + try: - import json - if save_as_json: + if use_json: data = [] for entry in results: - if not entry.header_row: - # Filter out internal keys - values = {k: v for k, v in entry.values.items() - if not k.startswith("__")} + if entry.header_row: + data.append({ + "__header": entry.values.get("__header", ""), + "__group_id": entry.group_id, + }) + else: + values = dict(entry.values) + values["__group_id"] = entry.group_id + values["__checked"] = entry.checked data.append(values) with open(path, "w") as f: json.dump(data, f, indent=2) @@ -46,3 +54,102 @@ def save(parent, results: list, save_as_json: bool = False) -> bool: return True except OSError: return False + + @staticmethod + def load(parent) -> list[ResultEntry] | None: + """Load results from a previously saved JSON file. + + Returns list of ResultEntry on success, None on cancel/error. + """ + path, _ = QFileDialog.getOpenFileName( + parent, "Load Results", + "", + "JSON Files (*.json);;All Files (*)" + ) + if not path: + return None + + try: + with open(path) as f: + data = json.load(f) + except (json.JSONDecodeError, OSError): + return None + + if not isinstance(data, list): + # Could be czkawka_cli dict format {size: [[entries]]} + return _parse_cli_json(data) + + results = [] + for item in data: + if not isinstance(item, dict): + continue + + if "__header" in item: + results.append(ResultEntry( + values={"__header": item["__header"]}, + header_row=True, + group_id=item.get("__group_id", 0), + )) + else: + group_id = item.pop("__group_id", 0) + checked = item.pop("__checked", False) + results.append(ResultEntry( + values=item, + checked=checked, + group_id=group_id, + )) + + return results if results else None + + +def _parse_cli_json(data: dict) -> list[ResultEntry] | None: + """Parse raw czkawka_cli JSON output (dict of size buckets or flat list).""" + results = [] + group_id = 0 + + for key, groups in data.items(): + if not isinstance(groups, list): + continue + for group in groups: + if not isinstance(group, list) or len(group) == 0: + continue + + total_size = sum(e.get("size", 0) for e in group if isinstance(e, dict)) + results.append(ResultEntry( + values={"__header": f"Group {group_id + 1} ({len(group)} files)"}, + header_row=True, + group_id=group_id, + )) + + for entry in group: + if not isinstance(entry, dict): + continue + path = entry.get("path", "") + p = Path(path) + values = { + "File Name": p.name, + "Path": str(p.parent), + "Size": _format_size(entry.get("size", 0)), + "Modification Date": str(entry.get("modified_date", "")), + "Hash": entry.get("hash", ""), + "__full_path": path, + "__size_bytes": entry.get("size", 0), + "__modified_date_ts": entry.get("modified_date", 0), + } + results.append(ResultEntry(values=values, group_id=group_id)) + + group_id += 1 + + return results if results else None + + +def _format_size(size_bytes: int) -> str: + if size_bytes == 0: + return "0 B" + units = ["B", "KB", "MB", "GB", "TB"] + i = 0 + size = float(size_bytes) + while size >= 1024 and i < len(units) - 1: + size /= 1024 + i += 1 + return f"{size:.1f} {units[i]}" if i > 0 else f"{int(size)} B" diff --git a/czkawka_pyside6/app/main_window.py b/czkawka_pyside6/app/main_window.py index e6168badc..5f7ebda4f 100644 --- a/czkawka_pyside6/app/main_window.py +++ b/czkawka_pyside6/app/main_window.py @@ -139,6 +139,7 @@ def _connect_signals(self): self._action_buttons.delete_clicked.connect(self._show_delete_dialog) self._action_buttons.move_clicked.connect(self._show_move_dialog) self._action_buttons.save_clicked.connect(self._save_results) + self._action_buttons.load_clicked.connect(self._load_results) self._action_buttons.sort_clicked.connect(self._show_sort_dialog) self._action_buttons.hardlink_clicked.connect(self._create_hardlinks) self._action_buttons.symlink_clicked.connect(self._create_symlinks) @@ -316,6 +317,19 @@ def _save_results(self): if success: self._status_label.setText("Results saved successfully") + def _load_results(self): + results = SaveDialog.load(self) + if results is None: + return + tab = self._state.active_tab + self._state.set_results(tab, results) + self._results_view.set_results(results) + self._action_buttons.set_has_results( + any(not r.header_row for r in results) + ) + count = sum(1 for r in results if not r.header_row) + self._status_label.setText(f"Loaded {count} entries from file") + def _show_sort_dialog(self): columns = TAB_COLUMNS.get(self._state.active_tab, []) if not columns: diff --git a/czkawka_pyside6/app/results_view.py b/czkawka_pyside6/app/results_view.py index 6c7a37eb9..c07be7add 100644 --- a/czkawka_pyside6/app/results_view.py +++ b/czkawka_pyside6/app/results_view.py @@ -27,6 +27,8 @@ def __init__(self, parent=None): super().__init__(parent) self._active_tab = ActiveTab.DUPLICATE_FILES self._results: list[ResultEntry] = [] + self._sort_column = -1 + self._sort_order = Qt.AscendingOrder self._setup_ui() def _ensure_header_colors(self): @@ -34,16 +36,16 @@ def _ensure_header_colors(self): if self._header_colors_ready: return from PySide6.QtWidgets import QApplication + from PySide6.QtGui import QPalette palette = QApplication.instance().palette() - # Use a midpoint between window and highlight for header background - win = palette.color(palette.Window) - hi = palette.color(palette.Highlight) + win = palette.color(QPalette.ColorRole.Window) + hi = palette.color(QPalette.ColorRole.Highlight) self.HEADER_BG = QColor( (win.red() + hi.red()) // 2, (win.green() + hi.green()) // 2, (win.blue() + hi.blue()) // 2, ) - self.HEADER_FG = palette.color(palette.HighlightedText) + self.HEADER_FG = palette.color(QPalette.ColorRole.HighlightedText) self._header_colors_ready = True def _setup_ui(self): @@ -69,28 +71,53 @@ def _setup_ui(self): self._tree.customContextMenuRequested.connect(self._on_context_menu) self._tree.itemChanged.connect(self._on_item_changed) self._tree.itemDoubleClicked.connect(self._on_item_double_clicked) + + # Sortable: click header to sort + self._tree.setSortingEnabled(False) # We handle sorting manually + header = self._tree.header() + header.setSectionsClickable(True) + header.sectionClicked.connect(self._on_header_clicked) + # Resizable columns + header.setSectionResizeMode(QHeaderView.Interactive) + header.setStretchLastSection(True) + layout.addWidget(self._tree) def set_active_tab(self, tab: ActiveTab): self._active_tab = tab + self._sort_column = -1 columns = TAB_COLUMNS.get(tab, ["Selection", "File Name", "Path"]) + self._tree.setColumnCount(len(columns)) self._tree.setHeaderLabels(columns) header = self._tree.header() - for i in range(len(columns)): - if columns[i] == "Path": - header.setSectionResizeMode(i, QHeaderView.Stretch) - else: - header.setSectionResizeMode(i, QHeaderView.ResizeToContents) + # All columns interactive (resizable), last one stretches + header.setSectionResizeMode(QHeaderView.Interactive) + header.setStretchLastSection(True) + # Give Path columns more initial space + for i, col in enumerate(columns): + if col == "Path": + header.resizeSection(i, 300) + elif col == "Selection": + header.resizeSection(i, 30) + elif col in ("Size", "Hash", "Modification Date"): + header.resizeSection(i, 140) + elif col == "File Name": + header.resizeSection(i, 200) def set_results(self, results: list[ResultEntry]): self._ensure_header_colors() self._results = results + self._rebuild_tree() + self._update_summary() + + def _rebuild_tree(self): + """Rebuild tree items from self._results.""" self._tree.blockSignals(True) self._tree.clear() columns = TAB_COLUMNS.get(self._active_tab, ["Selection", "File Name", "Path"]) - for entry in results: + for entry in self._results: if entry.header_row: item = QTreeWidgetItem() header_text = entry.values.get("__header", "Group") @@ -105,14 +132,15 @@ def set_results(self, results: list[ResultEntry]): item.setFlags(item.flags() & ~Qt.ItemIsUserCheckable) item.setData(0, Qt.UserRole, entry) self._tree.addTopLevelItem(item) + # Span header across all columns (must be called after adding to tree) + item.setFirstColumnSpanned(True) else: item = QTreeWidgetItem() - # First column is checkbox (Selection) item.setFlags(item.flags() | Qt.ItemIsUserCheckable) item.setCheckState(0, Qt.Checked if entry.checked else Qt.Unchecked) for col_idx, col_name in enumerate(columns): - if col_idx == 0: # Selection column + if col_idx == 0: continue value = entry.values.get(col_name, "") item.setText(col_idx, str(value)) @@ -121,7 +149,108 @@ def set_results(self, results: list[ResultEntry]): self._tree.addTopLevelItem(item) self._tree.blockSignals(False) - self._update_summary() + + # ── Sorting ────────────────────────────────────────────── + + def _on_header_clicked(self, logical_index: int): + """Sort by column when header is clicked. Toggle ascending/descending.""" + if logical_index == 0: + return # Don't sort by checkbox column + + if self._sort_column == logical_index: + # Toggle order + self._sort_order = ( + Qt.DescendingOrder if self._sort_order == Qt.AscendingOrder + else Qt.AscendingOrder + ) + else: + self._sort_column = logical_index + self._sort_order = Qt.AscendingOrder + + columns = TAB_COLUMNS.get(self._active_tab, []) + col_name = columns[logical_index] if logical_index < len(columns) else "" + + # Update header sort indicator + self._tree.header().setSortIndicator(logical_index, self._sort_order) + self._tree.header().setSortIndicatorShown(True) + + ascending = self._sort_order == Qt.AscendingOrder + + if self._active_tab in GROUPED_TABS: + self._sort_within_groups(col_name, ascending) + else: + self._sort_flat(col_name, ascending) + + def _sort_key(self, entry: ResultEntry, col_name: str): + """Return a sort key for a result entry by column name.""" + # Use numeric values for known numeric columns + if col_name in ("Size",): + return entry.values.get("__size_bytes", 0) + if col_name in ("Modification Date",): + return entry.values.get("__modified_date_ts", 0) + if col_name in ("Similarity", "Bitrate", "Year", "Length"): + raw = entry.values.get(col_name, "") + try: + return float(str(raw).replace(",", "")) + except (ValueError, TypeError): + return 0 + # Default: string comparison (case-insensitive) + return str(entry.values.get(col_name, "")).lower() + + def _sort_flat(self, col_name: str, ascending: bool): + """Sort flat (non-grouped) results.""" + self._results.sort( + key=lambda e: self._sort_key(e, col_name), + reverse=not ascending, + ) + self._rebuild_tree() + + def _sort_within_groups(self, col_name: str, ascending: bool): + """Sort entries within each group, keeping group headers in place.""" + sorted_results = [] + current_group = [] + current_header = None + + for entry in self._results: + if entry.header_row: + if current_header is not None: + current_group.sort( + key=lambda e: self._sort_key(e, col_name), + reverse=not ascending, + ) + sorted_results.append(current_header) + sorted_results.extend(current_group) + current_header = entry + current_group = [] + else: + current_group.append(entry) + + # Last group + if current_header is not None: + current_group.sort( + key=lambda e: self._sort_key(e, col_name), + reverse=not ascending, + ) + sorted_results.append(current_header) + sorted_results.extend(current_group) + + self._results = sorted_results + self._rebuild_tree() + + def sort_by_column(self, column: int, ascending: bool = True): + """Public API for sorting (used by sort dialog).""" + self._sort_column = column + self._sort_order = Qt.AscendingOrder if ascending else Qt.DescendingOrder + columns = TAB_COLUMNS.get(self._active_tab, []) + col_name = columns[column] if column < len(columns) else "" + self._tree.header().setSortIndicator(column, self._sort_order) + self._tree.header().setSortIndicatorShown(True) + if self._active_tab in GROUPED_TABS: + self._sort_within_groups(col_name, ascending) + else: + self._sort_flat(col_name, ascending) + + # ── Item events ────────────────────────────────────────── def _on_item_changed(self, item, column): if column == 0: @@ -188,6 +317,8 @@ def _open_folder(self, entry: ResultEntry): def _set_check(self, item, checked): item.setCheckState(0, Qt.Checked if checked else Qt.Unchecked) + # ── Summary / selection ────────────────────────────────── + def _update_summary(self): total = sum(1 for r in self._results if not r.header_row) groups = sum(1 for r in self._results if r.header_row) @@ -243,13 +374,11 @@ def _invert_selection(self): item.setCheckState(0, Qt.Checked if entry.checked else Qt.Unchecked) def _select_by_group_criteria(self, mode: SelectMode): - # First unselect all self._select_all(False) if self._active_tab not in GROUPED_TABS: return - # Group entries by group_id groups: dict[int, list[tuple[int, ResultEntry]]] = {} for i in range(self._tree.topLevelItemCount()): item = self._tree.topLevelItem(i) @@ -275,15 +404,12 @@ def _select_by_group_criteria(self, mode: SelectMode): elif mode == SelectMode.SELECT_LONGEST_PATH: best_idx = max(range(len(items)), key=lambda j: len(items[j][1].values.get("__full_path", ""))) - # Select all EXCEPT the best (the one to keep) for j, (tree_idx, entry) in enumerate(items): if j != best_idx: entry.checked = True self._tree.topLevelItem(tree_idx).setCheckState(0, Qt.Checked) - def sort_by_column(self, column: int, ascending: bool = True): - order = Qt.AscendingOrder if ascending else Qt.DescendingOrder - self._tree.sortItems(column, order) + # ── Public accessors ───────────────────────────────────── def get_checked_entries(self) -> list[ResultEntry]: return [r for r in self._results if r.checked and not r.header_row] @@ -294,5 +420,7 @@ def get_all_entries(self) -> list[ResultEntry]: def clear(self): self._results = [] self._tree.clear() + self._sort_column = -1 + self._tree.header().setSortIndicatorShown(False) self._summary_label.setText("No results") self._selection_label.setText("") From 93f110b69e38f57a0dd0c297fbcd3db4a9bec7a0 Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Mon, 23 Mar 2026 18:26:33 +0100 Subject: [PATCH 36/49] Fix QA issues, add dry-run mode, improve progress accuracy QA fixes across PySide6 frontend and CLI: - Fix mousePressEvent in left_panel.py (use eventFilter instead of lambda) - Fix deprecated menu.exec_() -> menu.exec() - Fix stdout deadlock: PIPE -> DEVNULL for unused stdout - Fix final progress lines silently discarded after process exit - Fix JSON injection in progress.rs stage_name serialization - Fix temp file leak with try/finally cleanup - Remove .svg from preview (QPixmap can't load SVG) - Remove hardcoded dev path from icons.py - Add settings_changed signal to video/music sliders - Add error handling for subprocess open-file/folder calls - Add JSON parse error logging in backend - Remove unused QMessageBox import New features: - Dry-run checkbox in Delete and Move dialogs - Selection size display (selected/total with human-readable sizes) - Accurate file count: replace stale cached estimate with live background os.walk counter during collection phase Co-Authored-By: Claude Opus 4.6 (1M context) --- czkawka_cli/src/progress.rs | 4 +- czkawka_pyside6/app/backend.py | 152 ++++++++++--------- czkawka_pyside6/app/dialogs/delete_dialog.py | 12 +- czkawka_pyside6/app/dialogs/move_dialog.py | 7 + czkawka_pyside6/app/icons.py | 1 - czkawka_pyside6/app/left_panel.py | 15 +- czkawka_pyside6/app/main_window.py | 26 +++- czkawka_pyside6/app/preview_panel.py | 2 +- czkawka_pyside6/app/progress_widget.py | 105 +++++++------ czkawka_pyside6/app/results_view.py | 57 +++++-- czkawka_pyside6/app/tool_settings.py | 12 +- 11 files changed, 241 insertions(+), 152 deletions(-) diff --git a/czkawka_cli/src/progress.rs b/czkawka_cli/src/progress.rs index f0b4f77a6..76a7e9291 100644 --- a/czkawka_cli/src/progress.rs +++ b/czkawka_cli/src/progress.rs @@ -122,7 +122,9 @@ pub(crate) fn connect_progress_json(progress_receiver: &Receiver) }; if let Ok(json) = serde_json::to_string(&progress_data) { - let _ = writeln!(stderr, "{{\"progress\":{json},\"stage_name\":\"{stage_name}\"}}"); + if let Ok(escaped_name) = serde_json::to_string(&stage_name) { + let _ = writeln!(stderr, "{{\"progress\":{json},\"stage_name\":{escaped_name}}}"); + } } } } diff --git a/czkawka_pyside6/app/backend.py b/czkawka_pyside6/app/backend.py index 3d922c176..e0e508a75 100644 --- a/czkawka_pyside6/app/backend.py +++ b/czkawka_pyside6/app/backend.py @@ -42,40 +42,40 @@ def run(self): with tempfile.NamedTemporaryFile(suffix=".json", delete=False, mode="w") as f: json_output_path = f.name - # Add JSON output flag (use long form to avoid conflict with -C in broken files) - cmd.extend(["--compact-file-to-save", json_output_path]) - # Enable JSON progress on stderr, suppress text output - cmd.extend(["--json-progress", "-N", "-M"]) - - self._process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) + try: + # Add JSON output flag (use long form to avoid conflict with -C in broken files) + cmd.extend(["--compact-file-to-save", json_output_path]) + # Enable JSON progress on stderr, suppress text output + cmd.extend(["--json-progress", "-N", "-M"]) + + self._process = subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) - # Read stderr line-by-line for JSON progress data - self._monitor_process_json(json_output_path) + # Read stderr line-by-line for JSON progress data + self._monitor_process_json(json_output_path) - if self._cancelled: - self._cleanup(json_output_path) - return + if self._cancelled: + return - # Check for CLI errors - returncode = self._process.returncode - if returncode is not None and returncode != 0 and returncode != 11: - error_msg = f"czkawka_cli exited with code {returncode}" - self.error.emit(error_msg) - self._cleanup(json_output_path) - return + # Check for CLI errors (exit code 11 = results found, not an error) + returncode = self._process.returncode + if returncode is not None and returncode != 0 and returncode != 11: + error_msg = f"czkawka_cli exited with code {returncode}" + self.error.emit(error_msg) + return - # Parse results - self.progress.emit(ScanProgress( - step_name="Loading results...", current=0, total=0 - )) - results = self._parse_results(json_output_path) - self.finished.emit(self.tab, results) - self._cleanup(json_output_path) + # Parse results + self.progress.emit(ScanProgress( + step_name="Loading results...", current=0, total=0 + )) + results = self._parse_results(json_output_path) + self.finished.emit(self.tab, results) + finally: + self._cleanup(json_output_path) except FileNotFoundError: self.error.emit( @@ -97,7 +97,7 @@ def cancel(self): def _monitor_process_json(self, json_path: str): """Read JSON progress lines from stderr in real-time.""" - import time + import time, logging while self._process.poll() is None: if self._cancelled: @@ -108,43 +108,40 @@ def _monitor_process_json(self, json_path: str): time.sleep(0.05) continue - line = line.strip() - if not line: - continue - - try: - data = json.loads(line) - progress = data.get("progress", {}) - stage_name = data.get("stage_name", "Processing...") + self._parse_progress_line(line.strip()) - entries_checked = progress.get("entries_checked", 0) - entries_to_check = progress.get("entries_to_check", 0) - bytes_checked = progress.get("bytes_checked", 0) - bytes_to_check = progress.get("bytes_to_check", 0) - current_stage_idx = progress.get("current_stage_idx", 0) - max_stage_idx = progress.get("max_stage_idx", 0) - - self.progress.emit(ScanProgress( - step_name=stage_name, - current=0, - total=0, - current_size=bytes_checked, - stage_name=stage_name, - current_stage_idx=current_stage_idx, - max_stage_idx=max_stage_idx, - entries_checked=entries_checked, - entries_to_check=entries_to_check, - bytes_checked=bytes_checked, - bytes_to_check=bytes_to_check, - )) - except (json.JSONDecodeError, KeyError, TypeError): - continue - - # Drain remaining stderr + # Drain and parse remaining stderr after process exits remaining = self._process.stderr.read() if remaining: for line in remaining.strip().split("\n"): - pass # Final lines already processed + self._parse_progress_line(line.strip()) + + def _parse_progress_line(self, line: str): + """Parse a single JSON progress line from stderr.""" + if not line: + return + try: + data = json.loads(line) + progress = data.get("progress", {}) + stage_name = data.get("stage_name", "Processing...") + + self.progress.emit(ScanProgress( + step_name=stage_name, + current=0, + total=0, + current_size=progress.get("bytes_checked", 0), + stage_name=stage_name, + current_stage_idx=progress.get("current_stage_idx", 0), + max_stage_idx=progress.get("max_stage_idx", 0), + entries_checked=progress.get("entries_checked", 0), + entries_to_check=progress.get("entries_to_check", 0), + bytes_checked=progress.get("bytes_checked", 0), + bytes_to_check=progress.get("bytes_to_check", 0), + )) + except (json.JSONDecodeError, KeyError, TypeError): + import logging + if line.startswith("{"): + logging.warning("Failed to parse progress JSON: %s", line[:200]) def _cleanup(self, path: str): try: @@ -464,7 +461,8 @@ class FileOperations: """File operations: delete, move, hardlink, symlink, rename.""" @staticmethod - def delete_files(entries: list[ResultEntry], move_to_trash: bool = True) -> tuple[int, list[str]]: + def delete_files(entries: list[ResultEntry], move_to_trash: bool = True, + dry_run: bool = False) -> tuple[int, list[str]]: deleted = 0 errors = [] for entry in entries: @@ -476,6 +474,11 @@ def delete_files(entries: list[ResultEntry], move_to_trash: bool = True) -> tupl if not p.exists(): errors.append(f"File not found: {path}") continue + if dry_run: + action = "trash" if move_to_trash else "delete" + errors.append(f"[DRY RUN] Would {action}: {path}") + deleted += 1 + continue if move_to_trash: try: from send2trash import send2trash @@ -495,12 +498,14 @@ def delete_files(entries: list[ResultEntry], move_to_trash: bool = True) -> tupl @staticmethod def move_files(entries: list[ResultEntry], destination: str, - preserve_structure: bool = False, copy_mode: bool = False) -> tuple[int, list[str]]: + preserve_structure: bool = False, copy_mode: bool = False, + dry_run: bool = False) -> tuple[int, list[str]]: import shutil moved = 0 errors = [] dest = Path(destination) - dest.mkdir(parents=True, exist_ok=True) + if not dry_run: + dest.mkdir(parents=True, exist_ok=True) for entry in entries: src_path = entry.values.get("__full_path", "") @@ -509,15 +514,22 @@ def move_files(entries: list[ResultEntry], destination: str, try: src = Path(src_path) if preserve_structure: - # Keep relative directory structure rel = src.parent target_dir = dest / rel.relative_to(rel.anchor) - target_dir.mkdir(parents=True, exist_ok=True) target = target_dir / src.name else: target = dest / src.name - # Handle name conflicts + if dry_run: + action = "copy" if copy_mode else "move" + errors.append(f"[DRY RUN] Would {action}: {src_path} -> {target}") + moved += 1 + continue + + # Create dirs and handle name conflicts for real operations + if preserve_structure: + target.parent.mkdir(parents=True, exist_ok=True) + if target.exists(): stem = target.stem suffix = target.suffix diff --git a/czkawka_pyside6/app/dialogs/delete_dialog.py b/czkawka_pyside6/app/dialogs/delete_dialog.py index b82ad674d..aaae9ef39 100644 --- a/czkawka_pyside6/app/dialogs/delete_dialog.py +++ b/czkawka_pyside6/app/dialogs/delete_dialog.py @@ -1,6 +1,6 @@ from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QLabel, QDialogButtonBox, - QCheckBox, QMessageBox + QCheckBox, QStyle ) from PySide6.QtCore import Qt from PySide6.QtGui import QFont @@ -19,7 +19,7 @@ def __init__(self, count: int, move_to_trash: bool = True, parent=None): # Warning icon from system theme icon_label = QLabel() - icon = self.style().standardIcon(self.style().SP_MessageBoxWarning) + icon = self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxWarning) icon_label.setPixmap(icon.pixmap(48, 48)) icon_label.setAlignment(Qt.AlignCenter) layout.addWidget(icon_label) @@ -37,6 +37,10 @@ def __init__(self, count: int, move_to_trash: bool = True, parent=None): self._trash_cb.setChecked(move_to_trash) layout.addWidget(self._trash_cb) + # Dry run checkbox + self._dry_run_cb = QCheckBox("Dry run (preview only, no files will be deleted)") + layout.addWidget(self._dry_run_cb) + # Buttons buttons = QDialogButtonBox( QDialogButtonBox.Ok | QDialogButtonBox.Cancel @@ -49,3 +53,7 @@ def __init__(self, count: int, move_to_trash: bool = True, parent=None): @property def move_to_trash(self) -> bool: return self._trash_cb.isChecked() + + @property + def dry_run(self) -> bool: + return self._dry_run_cb.isChecked() diff --git a/czkawka_pyside6/app/dialogs/move_dialog.py b/czkawka_pyside6/app/dialogs/move_dialog.py index a26a51405..0c1c62541 100644 --- a/czkawka_pyside6/app/dialogs/move_dialog.py +++ b/czkawka_pyside6/app/dialogs/move_dialog.py @@ -37,6 +37,9 @@ def __init__(self, count: int, parent=None): self._copy_mode = QCheckBox("Copy instead of move") layout.addWidget(self._copy_mode) + self._dry_run = QCheckBox("Dry run (preview only, no files will be moved)") + layout.addWidget(self._dry_run) + # Buttons buttons = QDialogButtonBox( QDialogButtonBox.Ok | QDialogButtonBox.Cancel @@ -62,3 +65,7 @@ def preserve_structure(self) -> bool: @property def copy_mode(self) -> bool: return self._copy_mode.isChecked() + + @property + def dry_run(self) -> bool: + return self._dry_run.isChecked() diff --git a/czkawka_pyside6/app/icons.py b/czkawka_pyside6/app/icons.py index 9ec8fdace..cc274bf92 100644 --- a/czkawka_pyside6/app/icons.py +++ b/czkawka_pyside6/app/icons.py @@ -130,7 +130,6 @@ def app_logo_path() -> str: # Try relative to this file first, then absolute project path for candidate in [ Path(__file__).parent.parent.parent / "krokiet" / "icons" / "krokiet_logo.png", - Path("/mnt/developer/git/aecs4u.it/czkawka/krokiet/icons/krokiet_logo.png"), ]: if candidate.exists(): return str(candidate) diff --git a/czkawka_pyside6/app/left_panel.py b/czkawka_pyside6/app/left_panel.py index 8a8f87185..e47b4df6e 100644 --- a/czkawka_pyside6/app/left_panel.py +++ b/czkawka_pyside6/app/left_panel.py @@ -2,7 +2,7 @@ QWidget, QVBoxLayout, QListWidget, QListWidgetItem, QLabel, QPushButton, QHBoxLayout, QSizePolicy ) -from PySide6.QtCore import Signal, Qt, QSize +from PySide6.QtCore import Signal, Qt, QSize, QEvent from PySide6.QtGui import QFont, QPixmap from .models import ActiveTab, TAB_DISPLAY_NAMES, TABS_WITH_SETTINGS @@ -45,7 +45,7 @@ def _setup_ui(self): layout.setContentsMargins(6, 6, 6, 6) layout.setSpacing(4) - # Logo image (clickable) + # Logo image (clickable via event filter) logo_path = app_logo_path() if logo_path: self._logo_label = QLabel() @@ -55,7 +55,7 @@ def _setup_ui(self): self._logo_label.setAlignment(Qt.AlignCenter) self._logo_label.setCursor(Qt.PointingHandCursor) self._logo_label.setToolTip("About Czkawka") - self._logo_label.mousePressEvent = lambda _: self.about_requested.emit() + self._logo_label.installEventFilter(self) layout.addWidget(self._logo_label) else: title_label = QLabel("Czkawka") @@ -65,7 +65,8 @@ def _setup_ui(self): title_label.setFont(title_font) title_label.setAlignment(Qt.AlignCenter) title_label.setCursor(Qt.PointingHandCursor) - title_label.mousePressEvent = lambda _: self.about_requested.emit() + title_label.installEventFilter(self) + self._logo_label = title_label layout.addWidget(title_label) # Top buttons row with icons @@ -113,6 +114,12 @@ def _setup_ui(self): version_label.setEnabled(False) layout.addWidget(version_label) + def eventFilter(self, obj, event): + if obj is self._logo_label and event.type() == QEvent.Type.MouseButtonPress: + self.about_requested.emit() + return True + return super().eventFilter(obj, event) + def _on_item_changed(self, current, previous): if current: tab = current.data(Qt.UserRole) diff --git a/czkawka_pyside6/app/main_window.py b/czkawka_pyside6/app/main_window.py index 5f7ebda4f..647d53a56 100644 --- a/czkawka_pyside6/app/main_window.py +++ b/czkawka_pyside6/app/main_window.py @@ -202,7 +202,11 @@ def _start_scan(self): self._state.set_scanning(True) self._action_buttons.set_scanning(True) - self._progress.start(tab) + self._progress.start( + tab, + included_paths=self._state.settings.included_paths, + excluded_paths=self._state.settings.excluded_paths, + ) self._results_view.clear() self._status_label.setText(f"Scanning: {tab.name.replace('_', ' ').title()}...") @@ -274,15 +278,18 @@ def _show_delete_dialog(self): dialog = DeleteDialog(len(checked), self._state.settings.move_to_trash, self) if dialog.exec() == DeleteDialog.Accepted: + dry_run = dialog.dry_run deleted, errors = FileOperations.delete_files( - checked, dialog.move_to_trash + checked, dialog.move_to_trash, dry_run=dry_run ) - self._status_label.setText(f"Deleted {deleted} file(s)") + prefix = "[DRY RUN] " if dry_run else "" + self._status_label.setText(f"{prefix}Deleted {deleted} file(s)") if errors: self._bottom_panel.set_text("\n".join(errors)) self._bottom_panel.show_text() - # Refresh results - remove deleted entries - self._refresh_after_action(checked) + # Refresh results - remove deleted entries (skip on dry run) + if not dry_run: + self._refresh_after_action(checked) def _show_move_dialog(self): checked = self._results_view.get_checked_entries() @@ -295,16 +302,19 @@ def _show_move_dialog(self): if not dialog.destination: QMessageBox.warning(self, "No Destination", "Please select a destination folder.") return + dry_run = dialog.dry_run moved, errors = FileOperations.move_files( checked, dialog.destination, - dialog.preserve_structure, dialog.copy_mode + dialog.preserve_structure, dialog.copy_mode, + dry_run=dry_run ) action = "Copied" if dialog.copy_mode else "Moved" - self._status_label.setText(f"{action} {moved} file(s)") + prefix = "[DRY RUN] " if dry_run else "" + self._status_label.setText(f"{prefix}{action} {moved} file(s)") if errors: self._bottom_panel.set_text("\n".join(errors)) self._bottom_panel.show_text() - if not dialog.copy_mode: + if not dialog.copy_mode and not dry_run: self._refresh_after_action(checked) def _save_results(self): diff --git a/czkawka_pyside6/app/preview_panel.py b/czkawka_pyside6/app/preview_panel.py index 7b53bc0b2..3c53d533f 100644 --- a/czkawka_pyside6/app/preview_panel.py +++ b/czkawka_pyside6/app/preview_panel.py @@ -12,7 +12,7 @@ class PreviewPanel(QWidget): SUPPORTED_EXTENSIONS = { ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", - ".tiff", ".tif", ".ico", ".svg" + ".tiff", ".tif", ".ico", } def __init__(self, parent=None): diff --git a/czkawka_pyside6/app/progress_widget.py b/czkawka_pyside6/app/progress_widget.py index ecc309745..bd8537934 100644 --- a/czkawka_pyside6/app/progress_widget.py +++ b/czkawka_pyside6/app/progress_widget.py @@ -1,11 +1,11 @@ -import json +import os import time -from pathlib import Path +import threading from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QProgressBar, QLabel, QHBoxLayout ) -from PySide6.QtCore import Qt, QTimer, QStandardPaths +from PySide6.QtCore import Qt, QTimer from .models import ActiveTab, ScanProgress @@ -27,8 +27,9 @@ def __init__(self, parent=None): self._start_time = 0.0 self._active_tab = ActiveTab.DUPLICATE_FILES self._last_collection_count = 0 # Files found during collection phase - self._estimates: dict[str, int] = {} - self._load_estimates() + self._file_count_estimate = 0 # Live count from background walker + self._file_count_thread: threading.Thread | None = None + self._file_count_stop = threading.Event() self._setup_ui() self._timer = QTimer(self) @@ -112,11 +113,13 @@ def _setup_ui(self): # ── Public API ──────────────────────────────────────────── - def start(self, tab: ActiveTab = None): + def start(self, tab: ActiveTab = None, included_paths: list[str] | None = None, + excluded_paths: list[str] | None = None): if tab is not None: self._active_tab = tab self._start_time = time.monotonic() self._last_collection_count = 0 + self._file_count_estimate = 0 self.setVisible(True) for bar in (self._stage_bar, self._overall_bar): @@ -131,8 +134,12 @@ def start(self, tab: ActiveTab = None): self._steps_label.setText("") self._timer.start() + # Start background file count for collection-phase estimate + self._start_file_count(included_paths or [], excluded_paths or []) + def stop(self): self._timer.stop() + self._stop_file_count() elapsed = time.monotonic() - self._start_time if self._start_time else 0 self._elapsed_label.setText(f"Completed in {self._format_time(elapsed)}") @@ -145,10 +152,6 @@ def stop(self): self._stage_label.setText("Scan complete") self._steps_label.setText("") - # Save collection count for next-scan estimation - if self._last_collection_count > 0: - self._save_estimate(self._last_collection_count) - QTimer.singleShot(3000, self._auto_hide) def update_progress(self, progress: ScanProgress): @@ -175,9 +178,9 @@ def update_progress(self, progress: ScanProgress): is_collecting = (idx == 0 and to_check == 0) if is_collecting: - # Collection phase: use estimate from previous scan + # Collection phase: use live background file count as estimate self._last_collection_count = max(self._last_collection_count, checked) - estimate = self._get_estimate() + estimate = self._file_count_estimate if estimate > 0 and checked > 0: pct = min(99, int(checked * 100 / estimate)) self._stage_bar.setMaximum(100) @@ -221,8 +224,8 @@ def update_progress(self, progress: ScanProgress): if max_idx > 0: if to_check > 0: stage_frac = (b_checked / b_to_check) if b_to_check > 0 else (checked / to_check) - elif is_collecting and self._get_estimate() > 0 and checked > 0: - stage_frac = min(0.99, checked / self._get_estimate()) + elif is_collecting and self._file_count_estimate > 0 and checked > 0: + stage_frac = min(0.99, checked / self._file_count_estimate) else: stage_frac = 0 overall = (idx + min(stage_frac, 0.99)) / (max_idx + 1) @@ -234,37 +237,49 @@ def update_progress(self, progress: ScanProgress): self._overall_bar.setMaximum(0) self._overall_pct.setText("") - # ── Collection estimate persistence ─────────────────────── - - def _get_estimate_key(self) -> str: - """Key for the estimate cache based on active tab.""" - return self._active_tab.name - - def _get_estimate(self) -> int: - return self._estimates.get(self._get_estimate_key(), 0) - - @staticmethod - def _estimate_file_path() -> Path: - config_dir = QStandardPaths.writableLocation(QStandardPaths.AppConfigLocation) - base = Path(config_dir) if config_dir else Path.home() / ".config" / "czkawka" - return base / "scan_estimates.json" - - def _save_estimate(self, count: int): - self._estimates[self._get_estimate_key()] = count - try: - path = self._estimate_file_path() - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(self._estimates)) - except OSError: - pass - - def _load_estimates(self): - try: - path = self._estimate_file_path() - if path.exists(): - self._estimates = json.loads(path.read_text()) - except (json.JSONDecodeError, OSError): - self._estimates = {} + # ── Background file counter ───────────────────────────── + + def _start_file_count(self, included: list[str], excluded: list[str]): + """Start a background thread that walks included dirs to count files.""" + self._stop_file_count() + self._file_count_stop.clear() + self._file_count_estimate = 0 + + if not included: + return + + excluded_set = set(os.path.realpath(p) for p in excluded) + + def _count(): + count = 0 + for root_dir in included: + if self._file_count_stop.is_set(): + return + try: + for dirpath, dirnames, filenames in os.walk(root_dir): + if self._file_count_stop.is_set(): + return + real = os.path.realpath(dirpath) + # Prune excluded subtrees + dirnames[:] = [ + d for d in dirnames + if os.path.realpath(os.path.join(dirpath, d)) not in excluded_set + ] + if real in excluded_set: + continue + count += len(filenames) + self._file_count_estimate = count + except OSError: + continue + + self._file_count_thread = threading.Thread(target=_count, daemon=True) + self._file_count_thread.start() + + def _stop_file_count(self): + self._file_count_stop.set() + if self._file_count_thread is not None: + self._file_count_thread.join(timeout=1) + self._file_count_thread = None # ── Step indicator ──────────────────────────────────────── diff --git a/czkawka_pyside6/app/results_view.py b/czkawka_pyside6/app/results_view.py index c07be7add..5b564ecd2 100644 --- a/czkawka_pyside6/app/results_view.py +++ b/czkawka_pyside6/app/results_view.py @@ -288,53 +288,78 @@ def _on_context_menu(self, pos): deselect_action.triggered.connect(lambda: self._set_check(item, False)) menu.addAction(deselect_action) - menu.exec_(self._tree.viewport().mapToGlobal(pos)) + menu.exec(self._tree.viewport().mapToGlobal(pos)) def _open_file(self, entry: ResultEntry): import subprocess, sys path = entry.values.get("__full_path", "") - if path: + if not path: + return + try: if sys.platform == "linux": - subprocess.Popen(["xdg-open", path]) + subprocess.Popen(["xdg-open", path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) elif sys.platform == "darwin": - subprocess.Popen(["open", path]) + subprocess.Popen(["open", path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) else: - subprocess.Popen(["start", path], shell=True) + subprocess.Popen(["cmd", "/c", "start", "", path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except OSError: + pass def _open_folder(self, entry: ResultEntry): import subprocess, sys from pathlib import Path path = entry.values.get("__full_path", "") - if path: - folder = str(Path(path).parent) + if not path: + return + folder = str(Path(path).parent) + try: if sys.platform == "linux": - subprocess.Popen(["xdg-open", folder]) + subprocess.Popen(["xdg-open", folder], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) elif sys.platform == "darwin": - subprocess.Popen(["open", folder]) + subprocess.Popen(["open", folder], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) else: - subprocess.Popen(["explorer", folder], shell=True) + subprocess.Popen(["explorer", folder], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except OSError: + pass def _set_check(self, item, checked): item.setCheckState(0, Qt.Checked if checked else Qt.Unchecked) # ── Summary / selection ────────────────────────────────── + @staticmethod + def _format_size(size_bytes: int) -> str: + for unit in ("B", "KB", "MB", "GB", "TB"): + if abs(size_bytes) < 1024: + return f"{size_bytes:.1f} {unit}" if unit != "B" else f"{size_bytes} B" + size_bytes /= 1024 + return f"{size_bytes:.1f} PB" + def _update_summary(self): - total = sum(1 for r in self._results if not r.header_row) + entries = [r for r in self._results if not r.header_row] + total = len(entries) + total_size = sum(r.values.get("__size_bytes", 0) for r in entries) groups = sum(1 for r in self._results if r.header_row) + size_str = self._format_size(total_size) if self._active_tab in GROUPED_TABS and groups > 0: - self._summary_label.setText(f"Found {total} files in {groups} groups") + self._summary_label.setText(f"Found {total} files ({size_str}) in {groups} groups") elif total > 0: - self._summary_label.setText(f"Found {total} entries") + self._summary_label.setText(f"Found {total} entries ({size_str})") else: self._summary_label.setText("No results") self._update_selection_count() def _update_selection_count(self): - selected = sum(1 for r in self._results if r.checked and not r.header_row) - total = sum(1 for r in self._results if not r.header_row) + entries = [r for r in self._results if not r.header_row] + total = len(entries) + total_size = sum(r.values.get("__size_bytes", 0) for r in entries) + checked = [r for r in entries if r.checked] + selected = len(checked) + selected_size = sum(r.values.get("__size_bytes", 0) for r in checked) if selected > 0: - self._selection_label.setText(f"Selected: {selected}/{total}") + self._selection_label.setText( + f"Selected: {selected}/{total} ({self._format_size(selected_size)}/{self._format_size(total_size)})" + ) else: self._selection_label.setText("") self.selection_changed.emit(selected) diff --git a/czkawka_pyside6/app/tool_settings.py b/czkawka_pyside6/app/tool_settings.py index 87d4249f0..3426d2b59 100644 --- a/czkawka_pyside6/app/tool_settings.py +++ b/czkawka_pyside6/app/tool_settings.py @@ -201,7 +201,8 @@ def _create_similar_videos_panel(self) -> QWidget: self._vid_diff_label = QLabel(str(self._ts.vid_max_difference)) self._vid_diff_slider.valueChanged.connect(lambda v: ( setattr(self._ts, 'vid_max_difference', v), - self._vid_diff_label.setText(str(v)) + self._vid_diff_label.setText(str(v)), + self.settings_changed.emit(), )) diff_layout.addWidget(self._vid_diff_slider) diff_layout.addWidget(self._vid_diff_label) @@ -215,7 +216,8 @@ def _create_similar_videos_panel(self) -> QWidget: self._vid_skip_label = QLabel(str(self._ts.vid_skip_forward)) self._vid_skip_slider.valueChanged.connect(lambda v: ( setattr(self._ts, 'vid_skip_forward', v), - self._vid_skip_label.setText(str(v)) + self._vid_skip_label.setText(str(v)), + self.settings_changed.emit(), )) skip_layout.addWidget(self._vid_skip_slider) skip_layout.addWidget(self._vid_skip_label) @@ -229,7 +231,8 @@ def _create_similar_videos_panel(self) -> QWidget: self._vid_dur_label = QLabel(str(self._ts.vid_duration)) self._vid_dur_slider.valueChanged.connect(lambda v: ( setattr(self._ts, 'vid_duration', v), - self._vid_dur_label.setText(str(v)) + self._vid_dur_label.setText(str(v)), + self.settings_changed.emit(), )) dur_layout.addWidget(self._vid_dur_slider) dur_layout.addWidget(self._vid_dur_label) @@ -289,7 +292,8 @@ def _create_similar_music_panel(self) -> QWidget: self._music_diff_label = QLabel(f"{self._ts.music_max_difference:.1f}") self._music_diff_slider.valueChanged.connect(lambda v: ( setattr(self._ts, 'music_max_difference', v / 10.0), - self._music_diff_label.setText(f"{v / 10.0:.1f}") + self._music_diff_label.setText(f"{v / 10.0:.1f}"), + self.settings_changed.emit(), )) diff_layout.addWidget(self._music_diff_slider) diff_layout.addWidget(self._music_diff_label) From d7802bd26272d370141a373690ba9d2fc82c6812 Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Tue, 24 Mar 2026 09:18:24 +0100 Subject: [PATCH 37/49] Fix clippy collapsible_if warning in progress.rs Collapse nested if-let into a single condition using let-chains. Co-Authored-By: Claude Opus 4.6 (1M context) --- czkawka_cli/src/progress.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/czkawka_cli/src/progress.rs b/czkawka_cli/src/progress.rs index 76a7e9291..d28dd40d9 100644 --- a/czkawka_cli/src/progress.rs +++ b/czkawka_cli/src/progress.rs @@ -121,10 +121,10 @@ pub(crate) fn connect_progress_json(progress_receiver: &Receiver) get_progress_message(&progress_data) }; - if let Ok(json) = serde_json::to_string(&progress_data) { - if let Ok(escaped_name) = serde_json::to_string(&stage_name) { - let _ = writeln!(stderr, "{{\"progress\":{json},\"stage_name\":{escaped_name}}}"); - } + if let Ok(json) = serde_json::to_string(&progress_data) + && let Ok(escaped_name) = serde_json::to_string(&stage_name) + { + let _ = writeln!(stderr, "{{\"progress\":{json},\"stage_name\":{escaped_name}}}"); } } } From 82a5321f209168911ac8870a987b926a7c4aaef6 Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Tue, 24 Mar 2026 11:17:40 +0100 Subject: [PATCH 38/49] Optimize CLI performance: enable LTO, use HashMap, reduce allocations Phase 1: Enable LTO and codegen-units=1 in release profile for 5-15% runtime improvement on CPU-bound operations. Phase 2: Replace BTreeMap with HashMap across cache internals, duplicate hashing intermediates, and all tool modules. These maps are used only for lookups (O(1) vs O(log n)) and ordering is never needed. Also removes unnecessary String-allocating sort in dir_traversal (sort by PathBuf directly), and optimizes diff_loaded_and_prechecked_files with linear scan for small groups and HashMap for larger ones (resolves TODO). Phase 3: Move sequential filter into parallel iterator in similar_images (resolves TODO). Switch CLI progress channel from unbounded to bounded(256) to prevent theoretical unbounded growth. Adds deterministic sort in same_music fingerprint comparison to ensure consistent grouping regardless of HashMap iteration order. All 303 tests pass. Clippy clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.toml | 4 +- czkawka_cli/src/main.rs | 4 +- czkawka_core/src/common/cache.rs | 72 +++++++++---------- czkawka_core/src/common/dir_traversal.rs | 4 +- czkawka_core/src/tools/broken_files/core.rs | 6 +- czkawka_core/src/tools/broken_files/mod.rs | 4 +- czkawka_core/src/tools/duplicate/core.rs | 53 ++++++++------ czkawka_core/src/tools/exif_remover/core.rs | 8 +-- czkawka_core/src/tools/exif_remover/mod.rs | 4 +- czkawka_core/src/tools/same_music/core.rs | 9 ++- czkawka_core/src/tools/same_music/mod.rs | 4 +- czkawka_core/src/tools/similar_images/core.rs | 26 +++---- czkawka_core/src/tools/similar_images/mod.rs | 4 +- czkawka_core/src/tools/similar_videos/core.rs | 6 +- czkawka_core/src/tools/similar_videos/mod.rs | 6 +- .../src/tools/video_optimizer/core.rs | 14 ++-- czkawka_core/src/tools/video_optimizer/mod.rs | 6 +- .../src/connect_things/connect_settings.rs | 4 +- 18 files changed, 125 insertions(+), 113 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 77c811031..6bc25b5cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,8 +37,8 @@ overflow-checks = true # But it is used to optimize release builds(and probably also in CI, where time is not so important as in local development) # Fat lto, generates a lot smaller executable than thin lto # Also using codegen-units = 1, to generate smaller binaries -#lto = "fat" -#codegen-units = 1 +lto = "fat" +codegen-units = 1 # Optimize all dependencies except application/workspaces, even in debug builds to get reasonable performance e.g. when opening images [profile.dev.package."*"] # OPT PACKAGES diff --git a/czkawka_cli/src/main.rs b/czkawka_cli/src/main.rs index b8b9710e4..bdb519301 100644 --- a/czkawka_cli/src/main.rs +++ b/czkawka_cli/src/main.rs @@ -5,7 +5,7 @@ use std::thread; use clap::Parser; use commands::Commands; -use crossbeam_channel::{Receiver, Sender, unbounded}; +use crossbeam_channel::{Receiver, Sender, bounded}; use czkawka_core::common::config_cache_path::{print_infos_and_warnings, set_config_cache_path}; use czkawka_core::common::consts::DEFAULT_THREAD_SIZE; use czkawka_core::common::image::register_image_decoding_hooks; @@ -66,7 +66,7 @@ fn main() { } let json_progress = command.get_json_progress(); - let (progress_sender, progress_receiver): (Sender, Receiver) = unbounded(); + let (progress_sender, progress_receiver): (Sender, Receiver) = bounded(256); let stop_flag = Arc::new(AtomicBool::new(false)); let store_flag_cloned = stop_flag.clone(); diff --git a/czkawka_core/src/common/cache.rs b/czkawka_core/src/common/cache.rs index 7ec81914e..0ea0a8367 100644 --- a/czkawka_core/src/common/cache.rs +++ b/czkawka_core/src/common/cache.rs @@ -2,7 +2,7 @@ mod cleaning; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::io::{BufReader, BufWriter}; use std::path::Path; use std::{fs, mem}; @@ -45,7 +45,7 @@ fn get_cache_size(file_name: &Path) -> String { } #[fun_time(message = "save_cache_to_file_generalized", level = "debug")] -pub fn save_cache_to_file_generalized(cache_file_name: &str, hashmap: &BTreeMap, save_also_as_json: bool, minimum_file_size: u64) -> Messages +pub fn save_cache_to_file_generalized(cache_file_name: &str, hashmap: &HashMap, save_also_as_json: bool, minimum_file_size: u64) -> Messages where T: Serialize + ResultEntry + Sized + Send + Sync, { @@ -90,10 +90,10 @@ where } pub(crate) fn extract_loaded_cache( - loaded_hash_map: &BTreeMap, - files_to_check: BTreeMap, - records_already_cached: &mut BTreeMap, - non_cached_files_to_check: &mut BTreeMap, + loaded_hash_map: &HashMap, + files_to_check: HashMap, + records_already_cached: &mut HashMap, + non_cached_files_to_check: &mut HashMap, ) where T: Clone, { @@ -107,7 +107,7 @@ pub(crate) fn extract_loaded_cache( } #[fun_time(message = "load_cache_from_file_generalized_by_path", level = "debug")] -pub fn load_cache_from_file_generalized_by_path(cache_file_name: &str, delete_outdated_cache: bool, used_files: &BTreeMap) -> (Messages, Option>) +pub fn load_cache_from_file_generalized_by_path(cache_file_name: &str, delete_outdated_cache: bool, used_files: &HashMap) -> (Messages, Option>) where for<'a> T: Deserialize<'a> + ResultEntry + Sized + Send + Sync + Clone, { @@ -130,14 +130,14 @@ where return (text_messages, None); }; - debug!("Converting cache Vec into BTreeMap"); + debug!("Converting cache Vec into HashMap"); let number_of_entries = vec_loaded_entries.len(); let start_time = std::time::Instant::now(); - let map_loaded_entries: BTreeMap = vec_loaded_entries + let map_loaded_entries: HashMap = vec_loaded_entries .into_iter() .map(|file_entry| (file_entry.get_path().to_string_lossy().into_owned(), file_entry)) .collect(); - debug!("Converted cache Vec({number_of_entries} results) into BTreeMap in {:?}", start_time.elapsed()); + debug!("Converted cache Vec({number_of_entries} results) into HashMap in {:?}", start_time.elapsed()); (text_messages, Some(map_loaded_entries)) } @@ -289,9 +289,9 @@ where pub(crate) fn load_and_split_cache_generalized_by_path( cache_file_name: &str, - mut items_to_check: BTreeMap, + mut items_to_check: HashMap, common_data: &mut C, -) -> (BTreeMap, BTreeMap, BTreeMap) +) -> (HashMap, HashMap, HashMap) where for<'a> K: Deserialize<'a> + ResultEntry + Sized + Send + Sync + Clone, { @@ -301,8 +301,8 @@ where let loaded_hash_map; - let mut records_already_cached: BTreeMap = Default::default(); - let mut non_cached_files_to_check: BTreeMap = Default::default(); + let mut records_already_cached: HashMap = Default::default(); + let mut non_cached_files_to_check: HashMap = Default::default(); let (messages, loaded_items) = load_cache_from_file_generalized_by_path::(cache_file_name, common_data.get_delete_outdated_cache(), &items_to_check); common_data.get_text_messages_mut().extend_with_another_messages(messages); @@ -325,14 +325,14 @@ where (loaded_hash_map, records_already_cached, non_cached_files_to_check) } -pub(crate) fn save_and_connect_cache_generalized_by_path(cache_file_name: &str, vec_file_entry: &[K], loaded_hash_map: BTreeMap, common_data: &mut C) +pub(crate) fn save_and_connect_cache_generalized_by_path(cache_file_name: &str, vec_file_entry: &[K], loaded_hash_map: HashMap, common_data: &mut C) where K: Serialize + ResultEntry + Sized + Send + Sync + Clone, { if !common_data.get_use_cache() { return; } - let mut all_results: BTreeMap = Default::default(); + let mut all_results: HashMap = Default::default(); for file_entry in vec_file_entry.iter().cloned() { all_results.insert(file_entry.get_path().to_string_lossy().to_string(), file_entry); @@ -347,7 +347,7 @@ where #[cfg(test)] mod tests { - use std::collections::BTreeMap; + use std::collections::HashMap; use std::fs; use std::path::PathBuf; use std::sync::Once; @@ -408,17 +408,17 @@ mod tests { #[test] fn test_extract_loaded_cache() { - let mut loaded_cache = BTreeMap::new(); + let mut loaded_cache = HashMap::new(); loaded_cache.insert("file1".to_string(), TestEntry::new("/tmp/file1", 100, 1000, 10)); loaded_cache.insert("file2".to_string(), TestEntry::new("/tmp/file2", 200, 2000, 20)); - let mut files_to_check = BTreeMap::new(); + let mut files_to_check = HashMap::new(); files_to_check.insert("file1".to_string(), TestEntry::new("/tmp/file1", 100, 1000, 10)); files_to_check.insert("file3".to_string(), TestEntry::new("/tmp/file3", 300, 3000, 30)); files_to_check.insert("file2".to_string(), TestEntry::new("/tmp/file2", 200, 2000, 20)); - let mut records_already_cached = BTreeMap::new(); - let mut non_cached_files_to_check = BTreeMap::new(); + let mut records_already_cached = HashMap::new(); + let mut non_cached_files_to_check = HashMap::new(); extract_loaded_cache(&loaded_cache, files_to_check, &mut records_already_cached, &mut non_cached_files_to_check); @@ -433,13 +433,13 @@ mod tests { #[test] fn test_extract_loaded_cache_empty() { - let loaded_cache: BTreeMap = BTreeMap::new(); - let mut files_to_check = BTreeMap::new(); + let loaded_cache: HashMap = HashMap::new(); + let mut files_to_check = HashMap::new(); files_to_check.insert("file1".to_string(), TestEntry::new("/tmp/file1", 100, 1000, 10)); files_to_check.insert("file2".to_string(), TestEntry::new("/tmp/file2", 200, 2000, 20)); - let mut records_already_cached = BTreeMap::new(); - let mut non_cached_files_to_check = BTreeMap::new(); + let mut records_already_cached = HashMap::new(); + let mut non_cached_files_to_check = HashMap::new(); extract_loaded_cache(&loaded_cache, files_to_check, &mut records_already_cached, &mut non_cached_files_to_check); @@ -449,16 +449,16 @@ mod tests { #[test] fn test_extract_loaded_cache_all_cached() { - let mut loaded_cache = BTreeMap::new(); + let mut loaded_cache = HashMap::new(); loaded_cache.insert("file1".to_string(), TestEntry::new("/tmp/file1", 100, 1000, 10)); loaded_cache.insert("file2".to_string(), TestEntry::new("/tmp/file2", 200, 2000, 20)); - let mut files_to_check = BTreeMap::new(); + let mut files_to_check = HashMap::new(); files_to_check.insert("file1".to_string(), TestEntry::new("/tmp/file1", 100, 1000, 10)); files_to_check.insert("file2".to_string(), TestEntry::new("/tmp/file2", 200, 2000, 20)); - let mut records_already_cached = BTreeMap::new(); - let mut non_cached_files_to_check = BTreeMap::new(); + let mut records_already_cached = HashMap::new(); + let mut non_cached_files_to_check = HashMap::new(); extract_loaded_cache(&loaded_cache, files_to_check, &mut records_already_cached, &mut non_cached_files_to_check); @@ -474,7 +474,7 @@ mod tests { fs::write(&temp_file, "test content").unwrap(); let metadata = fs::metadata(&temp_file).unwrap(); - let mut cache_to_save = BTreeMap::new(); + let mut cache_to_save = HashMap::new(); cache_to_save.insert( temp_file.to_string_lossy().to_string(), TestEntry::new(temp_file.to_str().unwrap(), metadata.len(), metadata.modified().unwrap().elapsed().unwrap().as_secs(), 42), @@ -524,7 +524,7 @@ mod tests { )); // Convert to flat map for saving - let mut flat_cache = BTreeMap::new(); + let mut flat_cache = HashMap::new(); for entries in cache_to_save.values() { for entry in entries { flat_cache.insert(entry.path.to_string_lossy().to_string(), entry.clone()); @@ -552,7 +552,7 @@ mod tests { let temp_file = temp_dir.path().join("test_file.txt"); fs::write(&temp_file, "test").unwrap(); - let mut cache_to_save = BTreeMap::new(); + let mut cache_to_save = HashMap::new(); cache_to_save.insert("small_file".to_string(), TestEntry::new("/tmp/small", 10, 1000, 1)); cache_to_save.insert("large_file".to_string(), TestEntry::new("/tmp/large", 1000, 2000, 2)); @@ -581,7 +581,7 @@ mod tests { fs::write(&temp_file, "test content").unwrap(); let metadata = fs::metadata(&temp_file).unwrap(); - let mut cache_to_save = BTreeMap::new(); + let mut cache_to_save = HashMap::new(); cache_to_save.insert( temp_file.to_string_lossy().to_string(), TestEntry::new(temp_file.to_str().unwrap(), metadata.len(), metadata.modified().unwrap().elapsed().unwrap().as_secs(), 42), @@ -597,7 +597,7 @@ mod tests { // Create new files_to_check with updated metadata let new_metadata = fs::metadata(&temp_file).unwrap(); - let mut files_to_check = BTreeMap::new(); + let mut files_to_check = HashMap::new(); files_to_check.insert( temp_file.to_string_lossy().to_string(), TestEntry::new( @@ -621,7 +621,7 @@ mod tests { fn test_load_nonexistent_cache() { setup_cache_path(); let cache_name = format!("nonexistent_cache_{}", std::process::id()); - let files_to_check: BTreeMap = BTreeMap::new(); + let files_to_check: HashMap = HashMap::new(); let (messages, loaded_cache) = load_cache_from_file_generalized_by_path::(&cache_name, false, &files_to_check); @@ -636,7 +636,7 @@ mod tests { let temp_file = temp_dir.path().join("test_file.txt"); fs::write(&temp_file, "test content").unwrap(); - let mut cache_to_save = BTreeMap::new(); + let mut cache_to_save = HashMap::new(); cache_to_save.insert("test_key".to_string(), TestEntry::new("/tmp/test", 100, 1000, 42)); // Save cache with JSON enabled diff --git a/czkawka_core/src/common/dir_traversal.rs b/czkawka_core/src/common/dir_traversal.rs index b682a614a..f6498c887 100644 --- a/czkawka_core/src/common/dir_traversal.rs +++ b/czkawka_core/src/common/dir_traversal.rs @@ -268,7 +268,7 @@ where } } } - file_results.sort_by_cached_key(|fe| fe.path.to_string_lossy().to_string()); + file_results.sort_unstable_by(|a, b| a.path.cmp(&b.path)); for fe in file_results { let key = (self.group_by)(&fe); grouped_file_entries.entry(key).or_default().push(fe); @@ -350,7 +350,7 @@ where for (segment, warnings, mut fe_result) in segments { folders_to_check.extend(segment); all_warnings.extend(warnings); - fe_result.sort_by_cached_key(|fe| fe.path.to_string_lossy().to_string()); + fe_result.sort_unstable_by(|a, b| a.path.cmp(&b.path)); for fe in fe_result { let key = (self.group_by)(&fe); grouped_file_entries.entry(key).or_default().push(fe); diff --git a/czkawka_core/src/tools/broken_files/core.rs b/czkawka_core/src/tools/broken_files/core.rs index 5cfff05b6..0da01a24c 100644 --- a/czkawka_core/src/tools/broken_files/core.rs +++ b/czkawka_core/src/tools/broken_files/core.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::HashMap; use std::fs::File; use std::path::Path; use std::process::Command; @@ -243,12 +243,12 @@ impl BrokenFiles { } #[fun_time(message = "load_cache", level = "debug")] - fn load_cache(&mut self) -> (BTreeMap, BTreeMap, BTreeMap) { + fn load_cache(&mut self) -> (HashMap, HashMap, HashMap) { load_and_split_cache_generalized_by_path(&get_broken_files_cache_file(), mem::take(&mut self.files_to_check), self) } #[fun_time(message = "save_to_cache", level = "debug")] - fn save_to_cache(&mut self, vec_file_entry: &[BrokenEntry], loaded_hash_map: BTreeMap) { + fn save_to_cache(&mut self, vec_file_entry: &[BrokenEntry], loaded_hash_map: HashMap) { save_and_connect_cache_generalized_by_path(&get_broken_files_cache_file(), vec_file_entry, loaded_hash_map, self); } diff --git a/czkawka_core/src/tools/broken_files/mod.rs b/czkawka_core/src/tools/broken_files/mod.rs index 30d861308..3f677d66e 100644 --- a/czkawka_core/src/tools/broken_files/mod.rs +++ b/czkawka_core/src/tools/broken_files/mod.rs @@ -5,7 +5,7 @@ pub mod core; mod tests; pub mod traits; -use std::collections::BTreeMap; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -88,7 +88,7 @@ impl BrokenFilesParameters { pub struct BrokenFiles { common_data: CommonToolData, information: Info, - files_to_check: BTreeMap, + files_to_check: HashMap, broken_files: Vec, params: BrokenFilesParameters, } diff --git a/czkawka_core/src/tools/duplicate/core.rs b/czkawka_core/src/tools/duplicate/core.rs index d78ad9464..197fbc8db 100644 --- a/czkawka_core/src/tools/duplicate/core.rs +++ b/czkawka_core/src/tools/duplicate/core.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::path::Path; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -8,7 +8,6 @@ use std::{mem, thread}; use crossbeam_channel::Sender; use fun_time::fun_time; use humansize::{BINARY, format_size}; -use indexmap::IndexMap; use log::debug; use rayon::prelude::*; @@ -367,11 +366,11 @@ impl DuplicateFinder { fn prehash_save_cache_at_exit( &mut self, loaded_hash_map: BTreeMap>, - pre_hash_results: Vec<(u64, BTreeMap>, Vec)>, + pre_hash_results: Vec<(u64, HashMap>, Vec)>, ) { if self.get_params().use_prehash_cache { // All results = records already cached + computed results - let mut save_cache_to_hashmap: BTreeMap = Default::default(); + let mut save_cache_to_hashmap: HashMap = Default::default(); for (size, vec_file_entry) in loaded_hash_map { if size >= self.get_params().minimal_prehash_cache_file_size { @@ -437,11 +436,11 @@ impl DuplicateFinder { debug!("Starting calculating prehash"); #[expect(clippy::type_complexity)] - let pre_hash_results: Vec<(u64, BTreeMap>, Vec)> = non_cached_files_to_check + let pre_hash_results: Vec<(u64, HashMap>, Vec)> = non_cached_files_to_check .into_par_iter() - .with_max_len(3) // Vectors and BTreeMaps for really big inputs, leave some jobs to 0 thread, to avoid that I minimized max tasks for each thread to 3, which improved performance + .with_max_len(3) // Vectors and HashMaps for really big inputs, leave some jobs to 0 thread, to avoid that I minimized max tasks for each thread to 3, which improved performance .map(|(size, vec_file_entry)| { - let mut hashmap_with_hash: BTreeMap> = Default::default(); + let mut hashmap_with_hash: HashMap> = Default::default(); let mut errors: Vec = Vec::new(); THREAD_BUFFER.with_borrow_mut(|buffer| { @@ -513,16 +512,26 @@ impl DuplicateFinder { for (size, mut vec_file_entry) in used_map { if let Some(cached_vec_file_entry) = loaded_hash_map.get(&size) { - // TODO maybe hashmap is not needed when using < 4 elements - let mut cached_path_entries: IndexMap<&Path, DuplicateEntry> = IndexMap::new(); - for file_entry in cached_vec_file_entry { - cached_path_entries.insert(&file_entry.path, file_entry.clone()); - } - for file_entry in vec_file_entry { - if let Some(cached_file_entry) = cached_path_entries.swap_remove(file_entry.path.as_path()) { - records_already_cached.entry(size).or_default().push(cached_file_entry); - } else { - non_cached_files_to_check.entry(size).or_default().push(file_entry); + if cached_vec_file_entry.len() < 4 { + // For very small groups, linear scan is faster than building a map + for file_entry in vec_file_entry { + if let Some(cached) = cached_vec_file_entry.iter().find(|ce| ce.path == file_entry.path) { + records_already_cached.entry(size).or_default().push(cached.clone()); + } else { + non_cached_files_to_check.entry(size).or_default().push(file_entry); + } + } + } else { + let mut cached_path_entries: HashMap<&Path, DuplicateEntry> = HashMap::with_capacity(cached_vec_file_entry.len()); + for file_entry in cached_vec_file_entry { + cached_path_entries.insert(&file_entry.path, file_entry.clone()); + } + for file_entry in vec_file_entry { + if let Some(cached_file_entry) = cached_path_entries.remove(file_entry.path.as_path()) { + records_already_cached.entry(size).or_default().push(cached_file_entry); + } else { + non_cached_files_to_check.entry(size).or_default().push(file_entry); + } } } } else { @@ -576,7 +585,7 @@ impl DuplicateFinder { fn full_hashing_save_cache_at_exit( &mut self, records_already_cached: BTreeMap>, - full_hash_results: &mut Vec<(u64, BTreeMap>, Vec)>, + full_hash_results: &mut Vec<(u64, HashMap>, Vec)>, loaded_hash_map: BTreeMap>, ) { if !self.common_data.use_cache { @@ -593,7 +602,7 @@ impl DuplicateFinder { } } // Size doesn't exists add results to files - let mut temp_hashmap: BTreeMap> = Default::default(); + let mut temp_hashmap: HashMap> = Default::default(); for file_entry in vec_file_entry { temp_hashmap.entry(file_entry.hash.clone()).or_default().push(file_entry); } @@ -601,7 +610,7 @@ impl DuplicateFinder { } // Must save all results to file, old loaded from file with all currently counted results - let mut all_results: BTreeMap = Default::default(); + let mut all_results: HashMap = Default::default(); for (_size, vec_file_entry) in loaded_hash_map { for file_entry in vec_file_entry { all_results.insert(file_entry.path.to_string_lossy().to_string(), file_entry); @@ -659,11 +668,11 @@ impl DuplicateFinder { "Starting full hashing of {} files", non_cached_files_to_check.iter().map(|(_size, v)| v.len() as u64).sum::() ); - let mut full_hash_results: Vec<(u64, BTreeMap>, Vec)> = non_cached_files_to_check + let mut full_hash_results: Vec<(u64, HashMap>, Vec)> = non_cached_files_to_check .into_par_iter() .with_max_len(3) .map(|(size, vec_file_entry)| { - let mut hashmap_with_hash: BTreeMap> = Default::default(); + let mut hashmap_with_hash: HashMap> = Default::default(); let mut errors: Vec = Vec::new(); THREAD_BUFFER.with_borrow_mut(|buffer| { diff --git a/czkawka_core/src/tools/exif_remover/core.rs b/czkawka_core/src/tools/exif_remover/core.rs index 62e3cac00..bec03aefb 100644 --- a/czkawka_core/src/tools/exif_remover/core.rs +++ b/czkawka_core/src/tools/exif_remover/core.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::HashMap; use std::path::Path; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -23,7 +23,7 @@ use crate::tools::exif_remover::{ExifEntry, ExifRemover, ExifRemoverParameters, impl ExifRemover { pub fn new(params: ExifRemoverParameters) -> Self { - let mut additional_excluded_tags = BTreeMap::new(); + let mut additional_excluded_tags = std::collections::BTreeMap::new(); let tiff_disabled_tags = vec![ "ImageWidth", @@ -92,7 +92,7 @@ impl ExifRemover { &mut self, _stop_flag: &Arc, progress_sender: Option<&Sender>, - ) -> (BTreeMap, BTreeMap, BTreeMap) { + ) -> (HashMap, HashMap, HashMap) { let progress_handler = prepare_thread_handler_common(progress_sender, CurrentStage::ExifRemoverCacheLoading, 0, self.get_test_type(), 0); let res = load_and_split_cache_generalized_by_path(&get_exif_remover_cache_file(), mem::take(&mut self.files_to_check), self); @@ -104,7 +104,7 @@ impl ExifRemover { fn save_to_cache( &mut self, vec_file_entry: &[ExifEntry], - loaded_hash_map: BTreeMap, + loaded_hash_map: HashMap, _stop_flag: &Arc, progress_sender: Option<&Sender>, ) { diff --git a/czkawka_core/src/tools/exif_remover/mod.rs b/czkawka_core/src/tools/exif_remover/mod.rs index bbd5ced41..82b0953ad 100644 --- a/czkawka_core/src/tools/exif_remover/mod.rs +++ b/czkawka_core/src/tools/exif_remover/mod.rs @@ -3,7 +3,7 @@ pub mod core; mod tests; pub mod traits; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::path::PathBuf; use std::time::Duration; @@ -66,7 +66,7 @@ pub struct ExifRemover { common_data: CommonToolData, information: Info, exif_files: Vec, - files_to_check: BTreeMap, + files_to_check: HashMap, params: ExifRemoverParameters, additional_excluded_tags: BTreeMap<&'static str, Vec<&'static str>>, } diff --git a/czkawka_core/src/tools/same_music/core.rs b/czkawka_core/src/tools/same_music/core.rs index a9e63ff1a..3fbaf4881 100644 --- a/czkawka_core/src/tools/same_music/core.rs +++ b/czkawka_core/src/tools/same_music/core.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::{BTreeMap, HashMap}; use std::fs::File; use std::path::Path; use std::sync::Arc; @@ -74,12 +74,12 @@ impl SameMusic { } #[fun_time(message = "load_cache", level = "debug")] - fn load_cache(&mut self, checking_tags: bool) -> (BTreeMap, BTreeMap, BTreeMap) { + fn load_cache(&mut self, checking_tags: bool) -> (HashMap, HashMap, HashMap) { load_and_split_cache_generalized_by_path(&get_similar_music_cache_file(checking_tags), mem::take(&mut self.music_to_check), self) } #[fun_time(message = "save_cache", level = "debug")] - fn save_cache(&mut self, vec_file_entry: &[MusicEntry], loaded_hash_map: BTreeMap, checking_tags: bool) { + fn save_cache(&mut self, vec_file_entry: &[MusicEntry], loaded_hash_map: HashMap, checking_tags: bool) { save_and_connect_cache_generalized_by_path(&get_similar_music_cache_file(checking_tags), vec_file_entry, loaded_hash_map, self); } @@ -457,6 +457,9 @@ impl SameMusic { return WorkContinueStatus::Continue; } + // Sort for deterministic grouping regardless of HashMap/cache iteration order + self.music_entries.sort_unstable_by(|a, b| a.path.cmp(&b.path)); + let grouped_files_to_check = self.split_fingerprints_to_check(); let base_files_number = grouped_files_to_check.iter().map(|g| g.base_files.len()).sum::(); diff --git a/czkawka_core/src/tools/same_music/mod.rs b/czkawka_core/src/tools/same_music/mod.rs index b6f4c9898..6b996eb63 100644 --- a/czkawka_core/src/tools/same_music/mod.rs +++ b/czkawka_core/src/tools/same_music/mod.rs @@ -5,7 +5,7 @@ pub mod traits; #[cfg(test)] mod tests; -use std::collections::BTreeMap; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -123,7 +123,7 @@ impl SameMusicParameters { pub struct SameMusic { common_data: CommonToolData, information: Info, - music_to_check: BTreeMap, + music_to_check: HashMap, music_entries: Vec, duplicated_music_entries: Vec>, duplicated_music_entries_referenced: Vec<(MusicEntry, Vec)>, diff --git a/czkawka_core/src/tools/similar_images/core.rs b/czkawka_core/src/tools/similar_images/core.rs index 071b9c80b..b249d42a4 100644 --- a/czkawka_core/src/tools/similar_images/core.rs +++ b/czkawka_core/src/tools/similar_images/core.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeSet, HashMap}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -73,7 +73,7 @@ impl SimilarImages { } #[fun_time(message = "hash_images_load_cache", level = "debug")] - fn hash_images_load_cache(&mut self) -> (BTreeMap, BTreeMap, BTreeMap) { + fn hash_images_load_cache(&mut self) -> (HashMap, HashMap, HashMap) { load_and_split_cache_generalized_by_path( &get_similar_images_cache_file(self.get_params().hash_size, self.get_params().hash_alg, self.get_params().image_filter), mem::take(&mut self.images_to_check), @@ -82,7 +82,7 @@ impl SimilarImages { } #[fun_time(message = "save_to_cache", level = "debug")] - fn save_to_cache(&mut self, vec_file_entry: &[ImagesEntry], loaded_hash_map: BTreeMap) { + fn save_to_cache(&mut self, vec_file_entry: &[ImagesEntry], loaded_hash_map: HashMap) { save_and_connect_cache_generalized_by_path( &get_similar_images_cache_file(self.get_params().hash_size, self.get_params().hash_alg, self.get_params().image_filter), vec_file_entry, @@ -282,24 +282,24 @@ impl SimilarImages { *similarity != 0 && !hashes_parents.contains_key(*compared_hash) && !hashes_with_multiple_images.contains(*compared_hash) }) .filter(|(similarity, compared_hash)| { - if let Some((_, other_similarity_with_parent)) = hashes_similarity.get(*compared_hash) { - // If current hash is more similar to other hash than to current parent hash, then skip check earlier - // Because there is no way to be more similar to other hash than to current parent hash - if *similarity >= *other_similarity_with_parent { - return false; - } + if let Some((_, other_similarity_with_parent)) = hashes_similarity.get(*compared_hash) + && *similarity >= *other_similarity_with_parent + { + return false; } true }) .collect::>(); - // Sort by tolerance + if found_items.is_empty() && !hashes_with_multiple_images.contains(hash_to_check) { + return Some(None); + } + found_items.sort_unstable_by_key(|f| f.0); - Some((hash_to_check, found_items)) + Some(Some((hash_to_check, found_items))) }) .while_some() - // TODO - this filter move to into_par_iter above - .filter(|(original_hash, vec_similar_hashes)| !vec_similar_hashes.is_empty() || hashes_with_multiple_images.contains(*original_hash)) + .flatten() .collect::>(); if check_if_stop_received(stop_flag) { diff --git a/czkawka_core/src/tools/similar_images/mod.rs b/czkawka_core/src/tools/similar_images/mod.rs index 8f3318cb2..0189f8737 100644 --- a/czkawka_core/src/tools/similar_images/mod.rs +++ b/czkawka_core/src/tools/similar_images/mod.rs @@ -6,7 +6,7 @@ pub use core::return_similarity_from_similarity_preset; #[cfg(test)] mod tests; -use std::collections::BTreeMap; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -121,7 +121,7 @@ pub struct SimilarImages { similar_referenced_vectors: Vec<(ImagesEntry, Vec)>, // Hashmap with image hashes and Vector with names of files image_hashes: IndexMap>, - images_to_check: BTreeMap, + images_to_check: HashMap, params: SimilarImagesParameters, } diff --git a/czkawka_core/src/tools/similar_videos/core.rs b/czkawka_core/src/tools/similar_videos/core.rs index 6249752a3..16c114035 100644 --- a/czkawka_core/src/tools/similar_videos/core.rs +++ b/czkawka_core/src/tools/similar_videos/core.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeSet, HashMap}; use std::mem; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -258,7 +258,7 @@ impl SimilarVideos { } #[fun_time(message = "save_cache", level = "debug")] - fn save_cache(&mut self, vec_file_entry: &[VideosEntry], loaded_hash_map: BTreeMap) { + fn save_cache(&mut self, vec_file_entry: &[VideosEntry], loaded_hash_map: HashMap) { save_and_connect_cache_generalized_by_path( &get_similar_videos_cache_file(self.params.skip_forward_amount, self.params.duration, self.params.crop_detect), vec_file_entry, @@ -268,7 +268,7 @@ impl SimilarVideos { } #[fun_time(message = "load_cache_at_start", level = "debug")] - fn load_cache_at_start(&mut self) -> (BTreeMap, BTreeMap, BTreeMap) { + fn load_cache_at_start(&mut self) -> (HashMap, HashMap, HashMap) { load_and_split_cache_generalized_by_path( &get_similar_videos_cache_file(self.params.skip_forward_amount, self.params.duration, self.params.crop_detect), mem::take(&mut self.videos_to_check), diff --git a/czkawka_core/src/tools/similar_videos/mod.rs b/czkawka_core/src/tools/similar_videos/mod.rs index b47b33bc8..68444043d 100644 --- a/czkawka_core/src/tools/similar_videos/mod.rs +++ b/czkawka_core/src/tools/similar_videos/mod.rs @@ -4,7 +4,7 @@ pub mod traits; #[cfg(test)] mod tests; -use std::collections::BTreeMap; +use std::collections::HashMap; use std::ops::RangeInclusive; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -136,8 +136,8 @@ pub struct SimilarVideos { information: Info, similar_vectors: Vec>, similar_referenced_vectors: Vec<(VideosEntry, Vec)>, - videos_hashes: BTreeMap, Vec>, - videos_to_check: BTreeMap, + videos_hashes: HashMap, Vec>, + videos_to_check: HashMap, params: SimilarVideosParameters, } diff --git a/czkawka_core/src/tools/video_optimizer/core.rs b/czkawka_core/src/tools/video_optimizer/core.rs index b8d1653bc..fe4d24550 100644 --- a/czkawka_core/src/tools/video_optimizer/core.rs +++ b/czkawka_core/src/tools/video_optimizer/core.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::HashMap; use std::mem; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -349,25 +349,25 @@ impl VideoOptimizer { fn load_video_transcode_cache( &mut self, ) -> ( - BTreeMap, - BTreeMap, - BTreeMap, + HashMap, + HashMap, + HashMap, ) { load_and_split_cache_generalized_by_path(&get_video_transcode_cache_file(), mem::take(&mut self.video_transcode_test_entries), self) } #[fun_time(message = "load_video_crop_cache", level = "debug")] - fn load_video_crop_cache(&mut self, params: &VideoCropParams) -> (BTreeMap, BTreeMap, BTreeMap) { + fn load_video_crop_cache(&mut self, params: &VideoCropParams) -> (HashMap, HashMap, HashMap) { load_and_split_cache_generalized_by_path(&get_video_crop_cache_file(params), mem::take(&mut self.video_crop_test_entries), self) } #[fun_time(message = "save_video_transcode_cache", level = "debug")] - fn save_video_transcode_cache(&mut self, vec_file_entry: &[VideoTranscodeEntry], loaded_hash_map: BTreeMap) { + fn save_video_transcode_cache(&mut self, vec_file_entry: &[VideoTranscodeEntry], loaded_hash_map: HashMap) { save_and_connect_cache_generalized_by_path(&get_video_transcode_cache_file(), vec_file_entry, loaded_hash_map, self); } #[fun_time(message = "save_video_crop_cache", level = "debug")] - fn save_video_crop_cache(&mut self, vec_file_entry: &[VideoCropEntry], params: &VideoCropParams, loaded_hash_map: BTreeMap) { + fn save_video_crop_cache(&mut self, vec_file_entry: &[VideoCropEntry], params: &VideoCropParams, loaded_hash_map: HashMap) { save_and_connect_cache_generalized_by_path(&get_video_crop_cache_file(params), vec_file_entry, loaded_hash_map, self); } diff --git a/czkawka_core/src/tools/video_optimizer/mod.rs b/czkawka_core/src/tools/video_optimizer/mod.rs index ace9ddb8e..43e3b92f0 100644 --- a/czkawka_core/src/tools/video_optimizer/mod.rs +++ b/czkawka_core/src/tools/video_optimizer/mod.rs @@ -3,7 +3,7 @@ pub mod core; mod tests; pub mod traits; -use std::collections::BTreeMap; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::time::Duration; @@ -317,8 +317,8 @@ pub enum VideoOptimizerEntry { pub struct VideoOptimizer { common_data: CommonToolData, information: Info, - video_transcode_test_entries: BTreeMap, - video_crop_test_entries: BTreeMap, + video_transcode_test_entries: HashMap, + video_crop_test_entries: HashMap, video_transcode_result_entries: Vec, video_crop_result_entries: Vec, params: VideoOptimizerParameters, diff --git a/czkawka_gui/src/connect_things/connect_settings.rs b/czkawka_gui/src/connect_things/connect_settings.rs index 3a7e09f3c..aaa61cdf5 100644 --- a/czkawka_gui/src/connect_things/connect_settings.rs +++ b/czkawka_gui/src/connect_things/connect_settings.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeMap; +use std::collections::HashMap; use std::default::Default; use czkawka_core::common::cache::{load_cache_from_file_generalized_by_path, load_cache_from_file_generalized_by_size, save_cache_to_file_generalized}; @@ -131,7 +131,7 @@ pub(crate) fn connect_settings(gui_data: &GuiData) { let (mut messages, loaded_items) = load_cache_from_file_generalized_by_size::(&file_name, true, &Default::default()); if let Some(cache_entries) = loaded_items { - let mut hashmap_to_save: BTreeMap = Default::default(); + let mut hashmap_to_save: HashMap = Default::default(); for (_, vec_file_entry) in cache_entries { for file_entry in vec_file_entry { hashmap_to_save.insert(file_entry.path.to_string_lossy().to_string(), file_entry); From d9bf5b41bfe4112f3485475c4f9fdf80bd22de9c Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Tue, 24 Mar 2026 12:26:56 +0100 Subject: [PATCH 39/49] Fix fmt and ensure deterministic music duplicate ordering - Apply rustfmt to prehash_save_cache_at_exit signature - Sort entries within each same_music duplicate group by path to ensure deterministic results regardless of HashMap iteration order, fixing regression test failure Co-Authored-By: Claude Opus 4.6 (1M context) --- czkawka_core/src/tools/duplicate/core.rs | 6 +----- czkawka_core/src/tools/same_music/core.rs | 8 ++++++++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/czkawka_core/src/tools/duplicate/core.rs b/czkawka_core/src/tools/duplicate/core.rs index 197fbc8db..b3146f9c6 100644 --- a/czkawka_core/src/tools/duplicate/core.rs +++ b/czkawka_core/src/tools/duplicate/core.rs @@ -363,11 +363,7 @@ impl DuplicateFinder { } #[fun_time(message = "prehash_save_cache_at_exit", level = "debug")] - fn prehash_save_cache_at_exit( - &mut self, - loaded_hash_map: BTreeMap>, - pre_hash_results: Vec<(u64, HashMap>, Vec)>, - ) { + fn prehash_save_cache_at_exit(&mut self, loaded_hash_map: BTreeMap>, pre_hash_results: Vec<(u64, HashMap>, Vec)>) { if self.get_params().use_prehash_cache { // All results = records already cached + computed results let mut save_cache_to_hashmap: HashMap = Default::default(); diff --git a/czkawka_core/src/tools/same_music/core.rs b/czkawka_core/src/tools/same_music/core.rs index 3fbaf4881..2ac810e61 100644 --- a/czkawka_core/src/tools/same_music/core.rs +++ b/czkawka_core/src/tools/same_music/core.rs @@ -312,6 +312,10 @@ impl SameMusic { progress_handler.join_thread(); + // Sort entries within each group by path for deterministic results + for group in &mut old_duplicates { + group.sort_unstable_by(|a, b| a.path.cmp(&b.path)); + } self.duplicated_music_entries = old_duplicates; if self.common_data.use_reference_folders { @@ -477,6 +481,10 @@ impl SameMusic { progress_handler.join_thread(); + // Sort entries within each group by path for deterministic results + for group in &mut duplicated_music_entries { + group.sort_unstable_by(|a, b| a.path.cmp(&b.path)); + } self.duplicated_music_entries = duplicated_music_entries; if self.common_data.use_reference_folders { From 7b3678dd2affbe96217417b11a2b5a157e86bc0b Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Wed, 25 Mar 2026 00:16:05 +0100 Subject: [PATCH 40/49] Add fuzzy filename matching for duplicate detection (#1854) Adds a new FUZZY_NAME search method that uses Jaro-Winkler distance to find files with similar (but not identical) names, e.g. report_final.pdf vs report_final_v2.pdf. - Add FuzzyName variant to CheckingMethod enum in czkawka_core - Implement check_files_fuzzy_name() with union-find grouping, optimized by pre-grouping files by extension - Add strsim crate dependency for Jaro-Winkler similarity - Add --name-similarity-threshold CLI flag (0.0-1.0, default 0.85) - Update all frontends: krokiet, kalka, czkawka_gui (wildcard-safe) - Add threshold slider to kalka duplicate settings panel Co-Authored-By: Claude Opus 4.6 (1M context) --- czkawka_cli/src/commands.rs | 14 +- czkawka_cli/src/main.rs | 4 +- czkawka_core/Cargo.toml | 1 + czkawka_core/src/common/model.rs | 1 + czkawka_core/src/common/progress_data.rs | 2 +- czkawka_core/src/tools/duplicate/core.rs | 129 +++++ czkawka_core/src/tools/duplicate/mod.rs | 20 + czkawka_core/src/tools/duplicate/traits.rs | 53 ++ kalka/app/backend.py | 635 +++++++++++++++++++++ kalka/app/models.py | 308 ++++++++++ kalka/app/tool_settings.py | 533 +++++++++++++++++ krokiet/src/connect_scan/duplicate.rs | 12 + krokiet/src/settings/combo_box.rs | 1 + 13 files changed, 1708 insertions(+), 5 deletions(-) create mode 100644 kalka/app/backend.py create mode 100644 kalka/app/models.py create mode 100644 kalka/app/tool_settings.py diff --git a/czkawka_cli/src/commands.rs b/czkawka_cli/src/commands.rs index 629368146..f9f9cb38c 100644 --- a/czkawka_cli/src/commands.rs +++ b/czkawka_cli/src/commands.rs @@ -190,10 +190,17 @@ pub struct DuplicatesArgs { long, default_value = "HASH", value_parser = parse_checking_method_duplicate, - help = "Search method (NAME, SIZE, HASH)", - long_help = "Methods to search files.\nNAME - Fast but rarely usable,\nSIZE - Fast but not accurate, checking by the file's size,\nHASH - The slowest method, checking by the hash of the entire file" + help = "Search method (NAME, FUZZY_NAME, SIZE, SIZE_NAME, HASH)", + long_help = "Methods to search files.\nNAME - Fast but rarely usable, finds files with identical names,\nFUZZY_NAME - Finds files with similar names using Jaro-Winkler distance,\nSIZE - Fast but not accurate, checking by the file's size,\nSIZE_NAME - Checks by both file size and name,\nHASH - The slowest method, checking by the hash of the entire file" )] pub search_method: CheckingMethod, + #[clap( + long, + default_value = "0.85", + help = "Name similarity threshold for FUZZY_NAME mode (0.0–1.0)", + long_help = "Minimum Jaro-Winkler similarity score (0.0–1.0) for two filenames to be considered similar. Higher values require closer matches. Only used with FUZZY_NAME search method." + )] + pub name_similarity_threshold: f64, #[clap(flatten)] pub delete_method: DMethod, #[clap( @@ -1110,10 +1117,11 @@ fn parse_tolerance(src: &str) -> Result { fn parse_checking_method_duplicate(src: &str) -> Result { match src.to_ascii_lowercase().as_str() { "name" => Ok(CheckingMethod::Name), + "fuzzy_name" => Ok(CheckingMethod::FuzzyName), "size" => Ok(CheckingMethod::Size), "size_name" => Ok(CheckingMethod::SizeName), "hash" => Ok(CheckingMethod::Hash), - _ => Err("Couldn't parse the search method (allowed: NAME, SIZE, HASH)"), + _ => Err("Couldn't parse the search method (allowed: NAME, FUZZY_NAME, SIZE, SIZE_NAME, HASH)"), } } diff --git a/czkawka_cli/src/main.rs b/czkawka_cli/src/main.rs index bdb519301..e480f4236 100644 --- a/czkawka_cli/src/main.rs +++ b/czkawka_cli/src/main.rs @@ -127,6 +127,7 @@ fn duplicates(duplicates: DuplicatesArgs, stop_flag: &Arc, progress_ maximal_file_size, minimal_cached_file_size, search_method, + name_similarity_threshold, delete_method, hash_type, allow_hard_links, @@ -142,7 +143,8 @@ fn duplicates(duplicates: DuplicatesArgs, stop_flag: &Arc, progress_ minimal_cached_file_size, minimal_prehash_cache_file_size, case_sensitive_name_comparison.case_sensitive_name_comparison, - ); + ) + .with_name_similarity_threshold(name_similarity_threshold); let mut tool = DuplicateFinder::new(params); set_common_settings(&mut tool, &common_cli_items, Some(reference_directories.reference_directories.as_ref())); diff --git a/czkawka_core/Cargo.toml b/czkawka_core/Cargo.toml index d6a20f988..b969f2ebd 100644 --- a/czkawka_core/Cargo.toml +++ b/czkawka_core/Cargo.toml @@ -97,6 +97,7 @@ open = "5.3" log-panics = { version = "2.1.0", features = ["with-backtrace"] } deunicode = "1.6.2" +strsim = "0.11" glibc_musl_version = "0.1.0" rand = "0.10.0" diff --git a/czkawka_core/src/common/model.rs b/czkawka_core/src/common/model.rs index 622a70727..aba1f5cc5 100644 --- a/czkawka_core/src/common/model.rs +++ b/czkawka_core/src/common/model.rs @@ -37,6 +37,7 @@ pub enum CheckingMethod { #[default] None, Name, + FuzzyName, SizeName, Size, Hash, diff --git a/czkawka_core/src/common/progress_data.rs b/czkawka_core/src/common/progress_data.rs index d98372c63..e927534cf 100644 --- a/czkawka_core/src/common/progress_data.rs +++ b/czkawka_core/src/common/progress_data.rs @@ -177,7 +177,7 @@ impl ProgressData { let tool_type_checking_method: Option = match self.checking_method { CheckingMethod::AudioTags | CheckingMethod::AudioContent => Some(ToolType::SameMusic), - CheckingMethod::Name | CheckingMethod::SizeName | CheckingMethod::Size | CheckingMethod::Hash => Some(ToolType::Duplicate), + CheckingMethod::Name | CheckingMethod::FuzzyName | CheckingMethod::SizeName | CheckingMethod::Size | CheckingMethod::Hash => Some(ToolType::Duplicate), CheckingMethod::None => None, }; if let Some(tool_type) = tool_type_checking_method { diff --git a/czkawka_core/src/tools/duplicate/core.rs b/czkawka_core/src/tools/duplicate/core.rs index b3146f9c6..3115eb7bf 100644 --- a/czkawka_core/src/tools/duplicate/core.rs +++ b/czkawka_core/src/tools/duplicate/core.rs @@ -10,6 +10,7 @@ use fun_time::fun_time; use humansize::{BINARY, format_size}; use log::debug; use rayon::prelude::*; +use strsim; use crate::common::cache::{CACHE_DUPLICATE_VERSION, load_cache_from_file_generalized_by_size, save_cache_to_file_generalized}; use crate::common::dir_traversal::{DirTraversalBuilder, DirTraversalResult}; @@ -31,10 +32,12 @@ impl DuplicateFinder { files_with_identical_size: Default::default(), files_with_identical_size_names: Default::default(), files_with_identical_hashes: Default::default(), + files_with_fuzzy_names: Default::default(), files_with_identical_names_referenced: Default::default(), files_with_identical_size_names_referenced: Default::default(), files_with_identical_size_referenced: Default::default(), files_with_identical_hashes_referenced: Default::default(), + files_with_fuzzy_names_referenced: Default::default(), params, } } @@ -112,6 +115,132 @@ impl DuplicateFinder { } } + #[fun_time(message = "check_files_fuzzy_name", level = "debug")] + pub(crate) fn check_files_fuzzy_name(&mut self, stop_flag: &Arc, progress_sender: Option<&Sender>) -> WorkContinueStatus { + // Collect all files grouped by extension for performance (files with different extensions rarely match) + let group_by_func = |fe: &FileEntry| fe.path.extension().map(|e| e.to_string_lossy().to_lowercase()).unwrap_or_default(); + + let result = DirTraversalBuilder::new() + .common_data(&self.common_data) + .group_by(group_by_func) + .stop_flag(stop_flag) + .progress_sender(progress_sender) + .checking_method(CheckingMethod::FuzzyName) + .build() + .run(); + + match result { + DirTraversalResult::SuccessFiles { grouped_file_entries, warnings } => { + self.common_data.text_messages.warnings.extend(warnings); + + let threshold = self.get_params().name_similarity_threshold; + let case_sensitive = self.get_params().case_sensitive_name_comparison; + let mut all_groups: Vec> = Vec::new(); + + for (_ext, files) in grouped_file_entries { + if files.len() < 2 { + continue; + } + if check_if_stop_received(stop_flag) { + return WorkContinueStatus::Stop; + } + + let names: Vec = files + .iter() + .map(|fe| { + let name = fe + .path + .file_stem() + .unwrap_or_else(|| panic!("Found invalid file_stem \"{}\"", fe.path.to_string_lossy())) + .to_string_lossy(); + if case_sensitive { name.to_string() } else { name.to_lowercase() } + }) + .collect(); + + // Union-Find for grouping similar filenames + let n = files.len(); + let mut parent: Vec = (0..n).collect(); + + fn find(parent: &mut Vec, i: usize) -> usize { + if parent[i] != i { + parent[i] = find(parent, parent[i]); + } + parent[i] + } + + fn union(parent: &mut Vec, a: usize, b: usize) { + let ra = find(parent, a); + let rb = find(parent, b); + if ra != rb { + parent[ra] = rb; + } + } + + for i in 0..n { + for j in (i + 1)..n { + let similarity = strsim::jaro_winkler(&names[i], &names[j]); + if similarity >= threshold { + union(&mut parent, i, j); + } + } + } + + // Collect groups + let mut groups: HashMap> = HashMap::new(); + for i in 0..n { + let root = find(&mut parent, i); + groups.entry(root).or_default().push(i); + } + + for (_root, indices) in groups { + if indices.len() > 1 { + let group: Vec = indices.into_iter().map(|idx| files[idx].clone().into_duplicate_entry()).collect(); + all_groups.push(group); + } + } + } + + self.files_with_fuzzy_names = all_groups; + + if self.common_data.use_reference_folders { + let groups = mem::take(&mut self.files_with_fuzzy_names); + self.files_with_fuzzy_names_referenced = groups + .into_iter() + .filter_map(|vec_file_entry| { + let (mut files_from_referenced_folders, normal_files): (Vec<_>, Vec<_>) = vec_file_entry + .into_iter() + .partition(|e| self.common_data.directories.is_in_referenced_directory(e.get_path())); + + if normal_files.is_empty() { + None + } else { + files_from_referenced_folders.pop().map(|file| (file, normal_files)) + } + }) + .collect(); + } + + self.calculate_fuzzy_name_stats(); + WorkContinueStatus::Continue + } + DirTraversalResult::Stopped => WorkContinueStatus::Stop, + } + } + + fn calculate_fuzzy_name_stats(&mut self) { + if self.common_data.use_reference_folders { + for (_fe, vector) in &self.files_with_fuzzy_names_referenced { + self.information.number_of_duplicated_files_by_fuzzy_name += vector.len(); + self.information.number_of_groups_by_fuzzy_name += 1; + } + } else { + for vector in &self.files_with_fuzzy_names { + self.information.number_of_duplicated_files_by_fuzzy_name += vector.len() - 1; + self.information.number_of_groups_by_fuzzy_name += 1; + } + } + } + fn calculate_name_stats(&mut self) { if self.common_data.use_reference_folders { for (_fe, vector) in self.files_with_identical_names_referenced.values() { diff --git a/czkawka_core/src/tools/duplicate/mod.rs b/czkawka_core/src/tools/duplicate/mod.rs index 409e87a19..76597caf0 100644 --- a/czkawka_core/src/tools/duplicate/mod.rs +++ b/czkawka_core/src/tools/duplicate/mod.rs @@ -76,6 +76,8 @@ pub struct Info { pub number_of_duplicated_files_by_name: usize, pub number_of_groups_by_size_name: usize, pub number_of_duplicated_files_by_size_name: usize, + pub number_of_groups_by_fuzzy_name: usize, + pub number_of_duplicated_files_by_fuzzy_name: usize, pub lost_space_by_size: u64, pub lost_space_by_hash: u64, pub scanning_time: Duration, @@ -89,6 +91,7 @@ pub struct DuplicateFinderParameters { pub minimal_cache_file_size: u64, pub minimal_prehash_cache_file_size: u64, pub case_sensitive_name_comparison: bool, + pub name_similarity_threshold: f64, } impl DuplicateFinderParameters { @@ -107,8 +110,13 @@ impl DuplicateFinderParameters { minimal_cache_file_size, minimal_prehash_cache_file_size, case_sensitive_name_comparison, + name_similarity_threshold: 0.85, } } + pub fn with_name_similarity_threshold(mut self, threshold: f64) -> Self { + self.name_similarity_threshold = threshold.clamp(0.0, 1.0); + self + } } pub struct DuplicateFinder { @@ -122,6 +130,8 @@ pub struct DuplicateFinder { files_with_identical_size: BTreeMap>, // File Size, next grouped by file size, next grouped by hash files_with_identical_hashes: BTreeMap>>, + // Fuzzy name groups: group_id -> Vec + files_with_fuzzy_names: Vec>, // File Size, File Entry files_with_identical_names_referenced: BTreeMap)>, // File (Size, Name), File Entry @@ -130,6 +140,8 @@ pub struct DuplicateFinder { files_with_identical_size_referenced: BTreeMap)>, // File Size, next grouped by file size, next grouped by hash files_with_identical_hashes_referenced: BTreeMap)>>, + // Fuzzy name groups with reference: (reference, Vec) + files_with_fuzzy_names_referenced: Vec<(DuplicateEntry, Vec)>, params: DuplicateFinderParameters, } @@ -218,6 +230,14 @@ impl DuplicateFinder { pub fn get_files_with_identical_size_names_referenced(&self) -> &BTreeMap<(u64, String), (DuplicateEntry, Vec)> { &self.files_with_identical_size_names_referenced } + + pub fn get_files_with_fuzzy_names(&self) -> &Vec> { + &self.files_with_fuzzy_names + } + + pub fn get_files_with_fuzzy_names_referenced(&self) -> &Vec<(DuplicateEntry, Vec)> { + &self.files_with_fuzzy_names_referenced + } } pub(crate) fn hash_calculation_limit(buffer: &mut [u8], file_entry: &DuplicateEntry, hash_type: HashType, limit: u64, size_counter: &Arc) -> Result { diff --git a/czkawka_core/src/tools/duplicate/traits.rs b/czkawka_core/src/tools/duplicate/traits.rs index 5f1358b90..9f7ce811b 100644 --- a/czkawka_core/src/tools/duplicate/traits.rs +++ b/czkawka_core/src/tools/duplicate/traits.rs @@ -25,6 +25,7 @@ impl DeletingItems for DuplicateFinder { let files_to_delete = match self.get_params().check_method { CheckingMethod::Name => self.files_with_identical_names.values().cloned().collect::>(), + CheckingMethod::FuzzyName => self.files_with_fuzzy_names.clone(), CheckingMethod::SizeName => self.files_with_identical_size_names.values().cloned().collect::>(), CheckingMethod::Hash => self.files_with_identical_hashes.values().flatten().cloned().collect::>(), CheckingMethod::Size => self.files_with_identical_size.values().cloned().collect::>(), @@ -52,6 +53,12 @@ impl Search for DuplicateFinder { return; } } + CheckingMethod::FuzzyName => { + self.common_data.stopped_search = self.check_files_fuzzy_name(stop_flag, progress_sender) == WorkContinueStatus::Stop; + if self.common_data.stopped_search { + return; + } + } CheckingMethod::SizeName => { self.common_data.stopped_search = self.check_files_size_name(stop_flag, progress_sender) == WorkContinueStatus::Stop; if self.common_data.stopped_search { @@ -123,6 +130,7 @@ impl DebugPrint for DuplicateFinder { println!("Hashed files list size - {}", self.files_with_identical_hashes.len()); println!("Files with identical names - {}", self.files_with_identical_names.len()); println!("Files with identical size names - {}", self.files_with_identical_size_names.len()); + println!("Files with fuzzy names - {}", self.files_with_fuzzy_names.len()); println!("Files with identical names referenced - {}", self.files_with_identical_names_referenced.len()); println!("Files with identical size names referenced - {}", self.files_with_identical_size_names_referenced.len()); println!("Files with identical size referenced - {}", self.files_with_identical_size_referenced.len()); @@ -178,6 +186,48 @@ impl PrintResults for DuplicateFinder { write!(writer, "Not found any files with same names.")?; } } + CheckingMethod::FuzzyName => { + if !self.files_with_fuzzy_names.is_empty() { + writeln!( + writer, + "-------------------------------------------------Files with similar names-------------------------------------------------" + )?; + writeln!( + writer, + "Found {} files in {} groups with similar names (threshold: {:.0}%)", + self.information.number_of_duplicated_files_by_fuzzy_name, + self.information.number_of_groups_by_fuzzy_name, + self.params.name_similarity_threshold * 100.0, + )?; + for vector in &self.files_with_fuzzy_names { + writeln!(writer, "\n---- {} files", vector.len())?; + for j in vector { + writeln!(writer, "\"{}\"", j.path.to_string_lossy())?; + } + } + } else if !self.files_with_fuzzy_names_referenced.is_empty() { + writeln!( + writer, + "-------------------------------------------------Files with similar names in referenced folders-------------------------------------------------" + )?; + writeln!( + writer, + "Found {} files in {} groups with similar names (threshold: {:.0}%)", + self.information.number_of_duplicated_files_by_fuzzy_name, + self.information.number_of_groups_by_fuzzy_name, + self.params.name_similarity_threshold * 100.0, + )?; + for (file_entry, vector) in &self.files_with_fuzzy_names_referenced { + writeln!(writer, "\n---- {} files", vector.len())?; + writeln!(writer, "Reference file - \"{}\"", file_entry.path.to_string_lossy())?; + for j in vector { + writeln!(writer, "\"{}\"", j.path.to_string_lossy())?; + } + } + } else { + write!(writer, "Not found any files with similar names.")?; + } + } CheckingMethod::SizeName => { if !self.files_with_identical_names.is_empty() { writeln!( @@ -317,6 +367,7 @@ impl PrintResults for DuplicateFinder { if self.get_use_reference() { match self.get_params().check_method { CheckingMethod::Name => self.save_results_to_file_as_json_internal(file_name, &self.files_with_identical_names_referenced, pretty_print), + CheckingMethod::FuzzyName => self.save_results_to_file_as_json_internal(file_name, &self.files_with_fuzzy_names_referenced, pretty_print), CheckingMethod::SizeName => { self.save_results_to_file_as_json_internal(file_name, &self.files_with_identical_size_names_referenced.values().collect::>(), pretty_print) } @@ -327,6 +378,7 @@ impl PrintResults for DuplicateFinder { } else { match self.get_params().check_method { CheckingMethod::Name => self.save_results_to_file_as_json_internal(file_name, &self.files_with_identical_names, pretty_print), + CheckingMethod::FuzzyName => self.save_results_to_file_as_json_internal(file_name, &self.files_with_fuzzy_names, pretty_print), CheckingMethod::SizeName => self.save_results_to_file_as_json_internal(file_name, &self.files_with_identical_size_names.values().collect::>(), pretty_print), CheckingMethod::Size => self.save_results_to_file_as_json_internal(file_name, &self.files_with_identical_size, pretty_print), CheckingMethod::Hash => self.save_results_to_file_as_json_internal(file_name, &self.files_with_identical_hashes, pretty_print), @@ -358,6 +410,7 @@ impl CommonData for DuplicateFinder { fn found_any_items(&self) -> bool { self.get_information().number_of_duplicated_files_by_hash > 0 || self.get_information().number_of_duplicated_files_by_name > 0 + || self.get_information().number_of_duplicated_files_by_fuzzy_name > 0 || self.get_information().number_of_duplicated_files_by_size > 0 || self.get_information().number_of_duplicated_files_by_size_name > 0 } diff --git a/kalka/app/backend.py b/kalka/app/backend.py new file mode 100644 index 000000000..f87ca145a --- /dev/null +++ b/kalka/app/backend.py @@ -0,0 +1,635 @@ +import json +import os +import subprocess +import tempfile +from pathlib import Path +from typing import Optional + +from PySide6.QtCore import QObject, QThread, Signal + +from .models import ( + ActiveTab, AppSettings, ToolSettings, ResultEntry, ScanProgress, + TAB_TO_CLI_COMMAND, GROUPED_TABS, CheckingMethod, MusicSearchMethod, +) + + +class ScanWorker(QObject): + """Worker that runs czkawka_cli in a background thread.""" + finished = Signal(object, list) # (ActiveTab, results) + progress = Signal(object) # ScanProgress + error = Signal(str) + + def __init__(self, tab: ActiveTab, app_settings: AppSettings, + tool_settings: ToolSettings): + super().__init__() + self.tab = tab + self.app_settings = app_settings + self.tool_settings = tool_settings + self._process: Optional[subprocess.Popen] = None + self._cancelled = False + + def run(self): + try: + cmd = self._build_command() + if not cmd: + self.error.emit(f"No CLI command for tab {self.tab}") + return + + self.progress.emit(ScanProgress( + step_name="Collecting files...", current=0, total=0 + )) + + with tempfile.NamedTemporaryFile(suffix=".json", delete=False, mode="w") as f: + json_output_path = f.name + + try: + # Add JSON output flag (use long form to avoid conflict with -C in broken files) + cmd.extend(["--compact-file-to-save", json_output_path]) + # Enable JSON progress on stderr, suppress text output + cmd.extend(["--json-progress", "-N", "-M"]) + + self._process = subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + text=True, + ) + + # Read stderr line-by-line for JSON progress data + self._monitor_process_json(json_output_path) + + if self._cancelled: + return + + # Check for CLI errors (exit code 11 = results found, not an error) + returncode = self._process.returncode + if returncode is not None and returncode != 0 and returncode != 11: + error_msg = f"czkawka_cli exited with code {returncode}" + self.error.emit(error_msg) + return + + # Parse results + self.progress.emit(ScanProgress( + step_name="Loading results...", current=0, total=0 + )) + results = self._parse_results(json_output_path) + self.finished.emit(self.tab, results) + finally: + self._cleanup(json_output_path) + + except FileNotFoundError: + self.error.emit( + f"czkawka_cli not found at '{self.app_settings.czkawka_cli_path}'. " + "Please install czkawka_cli or set the correct path in settings." + ) + except Exception as e: + self.error.emit(str(e)) + + def cancel(self): + self._cancelled = True + if self._process and self._process.poll() is None: + self._process.terminate() + try: + self._process.wait(timeout=3) + except subprocess.TimeoutExpired: + self._process.kill() + self._process.wait() + + def _monitor_process_json(self, json_path: str): + """Read JSON progress lines from stderr in real-time.""" + import time, logging + + while self._process.poll() is None: + if self._cancelled: + return + + line = self._process.stderr.readline() + if not line: + time.sleep(0.05) + continue + + self._parse_progress_line(line.strip()) + + # Drain and parse remaining stderr after process exits + remaining = self._process.stderr.read() + if remaining: + for line in remaining.strip().split("\n"): + self._parse_progress_line(line.strip()) + + def _parse_progress_line(self, line: str): + """Parse a single JSON progress line from stderr.""" + if not line: + return + try: + data = json.loads(line) + progress = data.get("progress", {}) + stage_name = data.get("stage_name", "Processing...") + + self.progress.emit(ScanProgress( + step_name=stage_name, + current=0, + total=0, + current_size=progress.get("bytes_checked", 0), + stage_name=stage_name, + current_stage_idx=progress.get("current_stage_idx", 0), + max_stage_idx=progress.get("max_stage_idx", 0), + entries_checked=progress.get("entries_checked", 0), + entries_to_check=progress.get("entries_to_check", 0), + bytes_checked=progress.get("bytes_checked", 0), + bytes_to_check=progress.get("bytes_to_check", 0), + )) + except (json.JSONDecodeError, KeyError, TypeError): + import logging + if line.startswith("{"): + logging.warning("Failed to parse progress JSON: %s", line[:200]) + + def _cleanup(self, path: str): + try: + os.unlink(path) + except OSError: + pass + + def _build_command(self) -> list[str]: + s = self.app_settings + ts = self.tool_settings + cli = s.czkawka_cli_path + subcmd = TAB_TO_CLI_COMMAND.get(self.tab) + if not subcmd: + return [] + + # Handle video-optimizer subcommands + if self.tab == ActiveTab.VIDEO_OPTIMIZER: + if ts.video_opt_mode == "crop": + cmd = [cli, "video-optimizer", "crop"] + else: + cmd = [cli, "video-optimizer", "transcode"] + else: + cmd = [cli, subcmd] + + # Common args + if s.included_paths: + cmd.extend(["-d", ",".join(s.included_paths)]) + if s.excluded_paths: + cmd.extend(["-e", ",".join(s.excluded_paths)]) + if s.excluded_items: + cmd.extend(["-E", s.excluded_items]) + if s.allowed_extensions: + cmd.extend(["-x", s.allowed_extensions]) + if s.excluded_extensions: + cmd.extend(["-P", s.excluded_extensions]) + if not s.recursive_search: + cmd.append("-R") + if not s.use_cache: + cmd.append("-H") + if s.thread_number > 0: + cmd.extend(["-T", str(s.thread_number)]) + + # Tool-specific args + if self.tab == ActiveTab.DUPLICATE_FILES: + cmd.extend(["-s", ts.dup_check_method.value]) + cmd.extend(["-t", ts.dup_hash_type.value]) + if ts.dup_min_size: + cmd.extend(["-m", ts.dup_min_size]) + if ts.dup_max_size: + cmd.extend(["-i", ts.dup_max_size]) + if ts.dup_name_case_sensitive: + cmd.append("-l") + if not s.hide_hard_links: + cmd.append("-L") + if ts.dup_use_prehash: + cmd.append("-u") + if ts.dup_check_method == CheckingMethod.FUZZY_NAME: + cmd.extend(["--name-similarity-threshold", str(ts.dup_name_similarity_threshold)]) + + elif self.tab == ActiveTab.SIMILAR_IMAGES: + cmd.extend(["-g", ts.img_hash_alg.value]) + cmd.extend(["-z", ts.img_filter.value]) + cmd.extend(["-c", str(ts.img_hash_size)]) + cmd.extend(["-s", str(ts.img_max_difference)]) + if ts.img_ignore_same_size: + cmd.append("-J") + + elif self.tab == ActiveTab.SIMILAR_VIDEOS: + cmd.extend(["-t", str(ts.vid_max_difference)]) + cmd.extend(["-U", str(ts.vid_skip_forward)]) + cmd.extend(["-B", ts.vid_crop_detect.value]) + cmd.extend(["-A", str(ts.vid_duration)]) + if ts.vid_ignore_same_size: + cmd.append("-J") + + elif self.tab == ActiveTab.SIMILAR_MUSIC: + cmd.extend(["-s", ts.music_search_method.value]) + if ts.music_search_method == MusicSearchMethod.TAGS: + tags = [] + if ts.music_title: tags.append("track_title") + if ts.music_artist: tags.append("track_artist") + if ts.music_bitrate: tags.append("bitrate") + if ts.music_genre: tags.append("genre") + if ts.music_year: tags.append("year") + if ts.music_length: tags.append("length") + if tags: + cmd.extend(["-z", ",".join(tags)]) + if ts.music_approximate: + cmd.append("-a") + else: + cmd.extend(["-Y", str(ts.music_max_difference)]) + + elif self.tab == ActiveTab.BIG_FILES: + cmd.extend(["-n", str(ts.big_files_number)]) + if ts.big_files_mode == "smallest": + cmd.append("-J") + + elif self.tab == ActiveTab.BROKEN_FILES: + types = [] + if ts.broken_audio: types.append("AUDIO") + if ts.broken_pdf: types.append("PDF") + if ts.broken_archive: types.append("ARCHIVE") + if ts.broken_image: types.append("IMAGE") + if ts.broken_video: types.append("VIDEO") + if types: + cmd.extend(["-C", ",".join(types)]) + + elif self.tab == ActiveTab.BAD_NAMES: + if ts.bad_names_uppercase_ext: + cmd.append("-u") + if ts.bad_names_emoji: + cmd.append("-j") + if ts.bad_names_space: + cmd.append("-w") + if ts.bad_names_non_ascii: + cmd.append("-n") + if ts.bad_names_restricted_charset: + cmd.extend(["-r", ts.bad_names_restricted_charset]) + if ts.bad_names_remove_duplicated: + cmd.append("-a") + + elif self.tab == ActiveTab.VIDEO_OPTIMIZER: + if ts.video_opt_mode == "crop": + cmd.extend(["-m", ts.video_crop_mechanism.value]) + cmd.extend(["-k", str(ts.video_black_pixel_threshold)]) + cmd.extend(["-b", str(ts.video_black_bar_percentage)]) + cmd.extend(["-s", str(ts.video_max_samples)]) + cmd.extend(["-z", str(ts.video_min_crop_size)]) + else: + if ts.video_excluded_codecs: + cmd.extend(["-c", ts.video_excluded_codecs]) + cmd.extend(["--target-codec", ts.video_codec.value]) + cmd.extend(["--quality", str(ts.video_quality)]) + if ts.video_fail_if_bigger: + cmd.append("--fail-if-not-smaller") + + return cmd + + def _parse_results(self, json_path: str) -> list[ResultEntry]: + try: + with open(json_path) as f: + data = json.load(f) + except (json.JSONDecodeError, FileNotFoundError, OSError): + return [] + + if not data: + return [] + + results = [] + is_grouped = self.tab in GROUPED_TABS + + if is_grouped: + # Grouped tools return either: + # dict {size_key: [[entry, ...], [entry, ...]], ...} (duplicates by hash) + # list [[entry, ...], [entry, ...]] (similar images/videos/music) + all_groups = [] + if isinstance(data, dict): + # Dict format: flatten all groups from all size buckets + for size_key, groups_for_size in data.items(): + if isinstance(groups_for_size, list): + for group in groups_for_size: + if isinstance(group, list) and len(group) > 0: + all_groups.append(group) + elif isinstance(data, list): + for item in data: + if isinstance(item, list) and len(item) > 0: + all_groups.append(item) + + for group_id, group in enumerate(all_groups): + # Compute total size for the group header + total_size = sum(e.get("size", 0) for e in group if isinstance(e, dict)) + header_text = ( + f"Group {group_id + 1} ({len(group)} files, " + f"{self._format_size(total_size)})" + ) + header = ResultEntry( + values={"__header": header_text}, + header_row=True, + group_id=group_id, + ) + results.append(header) + for entry in group: + if isinstance(entry, dict): + result = self._parse_entry(entry, group_id) + results.append(result) + else: + # Flat tools return a list of entries + if isinstance(data, list): + for i, entry in enumerate(data): + if isinstance(entry, dict): + result = self._parse_entry(entry, 0) + results.append(result) + elif isinstance(data, dict): + # Some tools might also use dict format + for key, entries in data.items(): + if isinstance(entries, list): + for entry in entries: + if isinstance(entry, dict): + result = self._parse_entry(entry, 0) + results.append(result) + + return results + + def _parse_entry(self, entry: dict, group_id: int) -> ResultEntry: + path = entry.get("path", "") + p = Path(path) + values = { + "File Name": p.name, + "Folder Name": p.name, + "Symlink Name": p.name, + "Path": str(p.parent), + "Symlink Path": str(p.parent), + "Size": self._format_size(entry.get("size", 0)), + "Modification Date": self._format_date(entry.get("modified_date", 0)), + "Hash": entry.get("hash", ""), + "Similarity": str(entry.get("similarity", "")), + "Resolution": entry.get("dimensions", ""), + "Title": entry.get("title", ""), + "Artist": entry.get("artist", ""), + "Year": entry.get("year", ""), + "Bitrate": str(entry.get("bitrate", "")), + "Genre": entry.get("genre", ""), + "Length": entry.get("length", ""), + "Destination Path": entry.get("destination_path", ""), + "Type of Error": entry.get("error_string", entry.get("type_of_error", "")), + "Error Type": entry.get("error_string", entry.get("error_type", "")), + "Current Extension": entry.get("current_extension", ""), + "Proper Extension": entry.get("proper_extension", entry.get("proper_extensions", "")), + "Codec": entry.get("codec", ""), + } + # Store full path for file operations + values["__full_path"] = path + values["__size_bytes"] = entry.get("size", 0) + values["__modified_date_ts"] = entry.get("modified_date", 0) + + return ResultEntry(values=values, group_id=group_id) + + @staticmethod + def _format_size(size_bytes: int) -> str: + if size_bytes == 0: + return "0 B" + units = ["B", "KB", "MB", "GB", "TB"] + i = 0 + size = float(size_bytes) + while size >= 1024 and i < len(units) - 1: + size /= 1024 + i += 1 + return f"{size:.1f} {units[i]}" if i > 0 else f"{int(size)} B" + + @staticmethod + def _format_date(timestamp: int) -> str: + if timestamp == 0: + return "" + from datetime import datetime + try: + return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S") + except (ValueError, OSError): + return str(timestamp) + + +class ScanRunner(QObject): + """Manages scan execution in a background thread.""" + finished = Signal(object, list) + progress = Signal(object) + error = Signal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self._thread: Optional[QThread] = None + self._worker: Optional[ScanWorker] = None + + def start_scan(self, tab: ActiveTab, app_settings: AppSettings, + tool_settings: ToolSettings): + if self._thread and self._thread.isRunning(): + return + + self._thread = QThread() + self._worker = ScanWorker(tab, app_settings, tool_settings) + self._worker.moveToThread(self._thread) + + self._thread.started.connect(self._worker.run) + self._worker.finished.connect(self._on_finished) + self._worker.progress.connect(self.progress.emit) + self._worker.error.connect(self._on_error) + + self._thread.start() + + def stop_scan(self): + if self._worker: + self._worker.cancel() + # Wait briefly for the thread to finish after killing the process + if self._thread and self._thread.isRunning(): + self._thread.quit() + if not self._thread.wait(5000): + self._thread.terminate() + self._thread.wait() + self._thread = None + self._worker = None + # Emit an empty result to signal completion + self.finished.emit(ActiveTab.DUPLICATE_FILES, []) + + def _on_finished(self, tab, results): + self.finished.emit(tab, results) + self._cleanup() + + def _on_error(self, msg): + self.error.emit(msg) + self._cleanup() + + def _cleanup(self): + if self._thread: + self._thread.quit() + self._thread.wait(5000) + self._thread = None + self._worker = None + + +class FileOperations: + """File operations: delete, move, hardlink, symlink, rename.""" + + @staticmethod + def delete_files(entries: list[ResultEntry], move_to_trash: bool = True, + dry_run: bool = False) -> tuple[int, list[str]]: + deleted = 0 + errors = [] + for entry in entries: + path = entry.values.get("__full_path", "") + if not path: + continue + try: + p = Path(path) + if not p.exists(): + errors.append(f"File not found: {path}") + continue + if dry_run: + action = "trash" if move_to_trash else "delete" + errors.append(f"[DRY RUN] Would {action}: {path}") + deleted += 1 + continue + if move_to_trash: + try: + from send2trash import send2trash + send2trash(str(p)) + except ImportError: + p.unlink() + else: + if p.is_dir(): + import shutil + shutil.rmtree(p) + else: + p.unlink() + deleted += 1 + except OSError as e: + errors.append(f"Error deleting {path}: {e}") + return deleted, errors + + @staticmethod + def move_files(entries: list[ResultEntry], destination: str, + preserve_structure: bool = False, copy_mode: bool = False, + dry_run: bool = False) -> tuple[int, list[str]]: + import shutil + moved = 0 + errors = [] + dest = Path(destination) + if not dry_run: + dest.mkdir(parents=True, exist_ok=True) + + for entry in entries: + src_path = entry.values.get("__full_path", "") + if not src_path: + continue + try: + src = Path(src_path) + if preserve_structure: + rel = src.parent + target_dir = dest / rel.relative_to(rel.anchor) + target = target_dir / src.name + else: + target = dest / src.name + + if dry_run: + action = "copy" if copy_mode else "move" + errors.append(f"[DRY RUN] Would {action}: {src_path} -> {target}") + moved += 1 + continue + + # Create dirs and handle name conflicts for real operations + if preserve_structure: + target.parent.mkdir(parents=True, exist_ok=True) + + if target.exists(): + stem = target.stem + suffix = target.suffix + counter = 1 + while target.exists(): + target = target.parent / f"{stem}_{counter}{suffix}" + counter += 1 + + if copy_mode: + shutil.copy2(str(src), str(target)) + else: + shutil.move(str(src), str(target)) + moved += 1 + except OSError as e: + errors.append(f"Error moving {src_path}: {e}") + return moved, errors + + @staticmethod + def create_hardlinks(entries: list[ResultEntry], reference_path: str) -> tuple[int, list[str]]: + created = 0 + errors = [] + for entry in entries: + path = entry.values.get("__full_path", "") + if not path or path == reference_path: + continue + try: + p = Path(path) + p.unlink() + os.link(reference_path, str(p)) + created += 1 + except OSError as e: + errors.append(f"Error creating hardlink for {path}: {e}") + return created, errors + + @staticmethod + def create_symlinks(entries: list[ResultEntry], reference_path: str) -> tuple[int, list[str]]: + created = 0 + errors = [] + for entry in entries: + path = entry.values.get("__full_path", "") + if not path or path == reference_path: + continue + try: + p = Path(path) + p.unlink() + os.symlink(reference_path, str(p)) + created += 1 + except OSError as e: + errors.append(f"Error creating symlink for {path}: {e}") + return created, errors + + @staticmethod + def fix_extensions(cli_path: str, app_settings, tool_settings) -> tuple[bool, str]: + """Run czkawka_cli ext with -F flag to fix extensions.""" + cmd = [cli_path, "ext", "-F"] + if app_settings.included_paths: + cmd.extend(["-d", ",".join(app_settings.included_paths)]) + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + return True, result.stdout + except Exception as e: + return False, str(e) + + @staticmethod + def fix_bad_names(cli_path: str, app_settings, tool_settings) -> tuple[bool, str]: + """Run czkawka_cli bad-names with -F flag to fix names.""" + cmd = [cli_path, "bad-names", "-F"] + if app_settings.included_paths: + cmd.extend(["-d", ",".join(app_settings.included_paths)]) + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + return True, result.stdout + except Exception as e: + return False, str(e) + + @staticmethod + def clean_exif(cli_path: str, entries: list[ResultEntry], + ignored_tags: str = "", overwrite: bool = True) -> tuple[int, list[str]]: + """Remove EXIF data from selected files.""" + cleaned = 0 + errors = [] + for entry in entries: + path = entry.values.get("__full_path", "") + if not path: + continue + try: + from PIL import Image + img = Image.open(path) + data = list(img.getdata()) + clean_img = Image.new(img.mode, img.size) + clean_img.putdata(data) + if overwrite: + clean_img.save(path) + else: + p = Path(path) + new_path = p.parent / f"{p.stem}_clean{p.suffix}" + clean_img.save(str(new_path)) + cleaned += 1 + except Exception as e: + errors.append(f"Error cleaning EXIF for {path}: {e}") + return cleaned, errors diff --git a/kalka/app/models.py b/kalka/app/models.py new file mode 100644 index 000000000..87db08348 --- /dev/null +++ b/kalka/app/models.py @@ -0,0 +1,308 @@ +from enum import Enum, auto +from dataclasses import dataclass, field +from typing import Optional +from pathlib import Path + + +class ActiveTab(Enum): + DUPLICATE_FILES = auto() + EMPTY_FOLDERS = auto() + BIG_FILES = auto() + EMPTY_FILES = auto() + TEMPORARY_FILES = auto() + SIMILAR_IMAGES = auto() + SIMILAR_VIDEOS = auto() + SIMILAR_MUSIC = auto() + INVALID_SYMLINKS = auto() + BROKEN_FILES = auto() + BAD_EXTENSIONS = auto() + BAD_NAMES = auto() + EXIF_REMOVER = auto() + VIDEO_OPTIMIZER = auto() + SETTINGS = auto() + ABOUT = auto() + + +class SelectMode(Enum): + SELECT_ALL = auto() + UNSELECT_ALL = auto() + INVERT_SELECTION = auto() + SELECT_BIGGEST_SIZE = auto() + SELECT_BIGGEST_RESOLUTION = auto() + SELECT_SMALLEST_SIZE = auto() + SELECT_SMALLEST_RESOLUTION = auto() + SELECT_NEWEST = auto() + SELECT_OLDEST = auto() + SELECT_SHORTEST_PATH = auto() + SELECT_LONGEST_PATH = auto() + SELECT_CUSTOM = auto() + + +class DeleteMethod(Enum): + NONE = "NONE" + DELETE = "DELETE" + ALL_EXCEPT_NEWEST = "AEN" + ALL_EXCEPT_OLDEST = "AEO" + ONE_OLDEST = "OO" + ONE_NEWEST = "ON" + HARDLINK = "HARD" + ALL_EXCEPT_BIGGEST = "AEB" + ALL_EXCEPT_SMALLEST = "AES" + ONE_BIGGEST = "OB" + ONE_SMALLEST = "OS" + + +class CheckingMethod(Enum): + HASH = "HASH" + SIZE = "SIZE" + NAME = "NAME" + FUZZY_NAME = "FUZZY_NAME" + SIZE_NAME = "SIZE_NAME" + + +class HashType(Enum): + BLAKE3 = "BLAKE3" + CRC32 = "CRC32" + XXH3 = "XXH3" + + +class ImageHashAlg(Enum): + MEAN = "Mean" + GRADIENT = "Gradient" + BLOCKHASH = "Blockhash" + VERT_GRADIENT = "VertGradient" + DOUBLE_GRADIENT = "DoubleGradient" + MEDIAN = "Median" + + +class ImageFilter(Enum): + LANCZOS3 = "Lanczos3" + NEAREST = "Nearest" + TRIANGLE = "Triangle" + GAUSSIAN = "Gaussian" + CATMULL_ROM = "CatmullRom" + + +class MusicSearchMethod(Enum): + TAGS = "TAGS" + CONTENT = "CONTENT" + + +class VideoCropDetect(Enum): + NONE = "none" + LETTERBOX = "letterbox" + MOTION = "motion" + + +class VideoCropMechanism(Enum): + BLACKBARS = "blackbars" + STATICCONTENT = "staticcontent" + + +class VideoCodec(Enum): + H264 = "h264" + H265 = "h265" + AV1 = "av1" + VP9 = "vp9" + + +# Map ActiveTab to CLI subcommand names +TAB_TO_CLI_COMMAND = { + ActiveTab.DUPLICATE_FILES: "dup", + ActiveTab.EMPTY_FOLDERS: "empty-folders", + ActiveTab.BIG_FILES: "big", + ActiveTab.EMPTY_FILES: "empty-files", + ActiveTab.TEMPORARY_FILES: "temp", + ActiveTab.SIMILAR_IMAGES: "image", + ActiveTab.SIMILAR_VIDEOS: "video", + ActiveTab.SIMILAR_MUSIC: "music", + ActiveTab.INVALID_SYMLINKS: "symlinks", + ActiveTab.BROKEN_FILES: "broken", + ActiveTab.BAD_EXTENSIONS: "ext", + ActiveTab.BAD_NAMES: "bad-names", + ActiveTab.EXIF_REMOVER: "exif-remover", + ActiveTab.VIDEO_OPTIMIZER: "video-optimizer", +} + +# Fluent translation keys for tab display names (resolved via tr() at point of use) +TAB_DISPLAY_KEYS = { + ActiveTab.DUPLICATE_FILES: "tool-duplicate-files", + ActiveTab.EMPTY_FOLDERS: "tool-empty-folders", + ActiveTab.BIG_FILES: "tool-big-files", + ActiveTab.EMPTY_FILES: "tool-empty-files", + ActiveTab.TEMPORARY_FILES: "tool-temporary-files", + ActiveTab.SIMILAR_IMAGES: "tool-similar-images", + ActiveTab.SIMILAR_VIDEOS: "tool-similar-videos", + ActiveTab.SIMILAR_MUSIC: "tool-similar-music", + ActiveTab.INVALID_SYMLINKS: "tool-invalid-symlinks", + ActiveTab.BROKEN_FILES: "tool-broken-files", + ActiveTab.BAD_EXTENSIONS: "tool-bad-extensions", + ActiveTab.BAD_NAMES: "tool-bad-names", + ActiveTab.EXIF_REMOVER: "tool-exif-remover", + ActiveTab.VIDEO_OPTIMIZER: "tool-video-optimizer", +} + +# Which tabs support grouping (results are in groups) +GROUPED_TABS = { + ActiveTab.DUPLICATE_FILES, + ActiveTab.SIMILAR_IMAGES, + ActiveTab.SIMILAR_VIDEOS, + ActiveTab.SIMILAR_MUSIC, +} + +# Which tabs have per-tool settings +TABS_WITH_SETTINGS = { + ActiveTab.DUPLICATE_FILES, + ActiveTab.SIMILAR_IMAGES, + ActiveTab.SIMILAR_VIDEOS, + ActiveTab.SIMILAR_MUSIC, + ActiveTab.BIG_FILES, + ActiveTab.BROKEN_FILES, + ActiveTab.BAD_NAMES, + ActiveTab.EXIF_REMOVER, + ActiveTab.VIDEO_OPTIMIZER, +} + +# Column definitions per tab +TAB_COLUMNS = { + ActiveTab.DUPLICATE_FILES: ["Selection", "Size", "File Name", "Path", "Modification Date", "Hash"], + ActiveTab.EMPTY_FOLDERS: ["Selection", "Folder Name", "Path", "Modification Date"], + ActiveTab.BIG_FILES: ["Selection", "Size", "File Name", "Path", "Modification Date"], + ActiveTab.EMPTY_FILES: ["Selection", "File Name", "Path", "Modification Date"], + ActiveTab.TEMPORARY_FILES: ["Selection", "File Name", "Path", "Modification Date"], + ActiveTab.SIMILAR_IMAGES: ["Selection", "Similarity", "Size", "Resolution", "File Name", "Path", "Modification Date", "Hash"], + ActiveTab.SIMILAR_VIDEOS: ["Selection", "Size", "File Name", "Path", "Modification Date"], + ActiveTab.SIMILAR_MUSIC: ["Selection", "Size", "File Name", "Path", "Title", "Artist", "Year", "Bitrate", "Genre", "Length"], + ActiveTab.INVALID_SYMLINKS: ["Selection", "Symlink Name", "Symlink Path", "Destination Path", "Type of Error"], + ActiveTab.BROKEN_FILES: ["Selection", "File Name", "Path", "Error Type", "Size", "Modification Date"], + ActiveTab.BAD_EXTENSIONS: ["Selection", "File Name", "Path", "Current Extension", "Proper Extension"], + ActiveTab.BAD_NAMES: ["Selection", "File Name", "Path", "Error Type"], + ActiveTab.EXIF_REMOVER: ["Selection", "File Name", "Path"], + ActiveTab.VIDEO_OPTIMIZER: ["Selection", "File Name", "Path", "Size", "Codec"], +} + + +@dataclass +class ResultEntry: + """A single result entry from a scan.""" + values: dict # column_name -> value + checked: bool = False + header_row: bool = False # Group header for grouped results + group_id: int = 0 + + +@dataclass +class ScanProgress: + """Progress information during scanning.""" + step_name: str = "" + current: int = 0 + total: int = 0 + current_size: int = 0 + # Raw fields from czkawka_cli --json-progress + stage_name: str = "" + current_stage_idx: int = 0 + max_stage_idx: int = 0 + entries_checked: int = 0 + entries_to_check: int = 0 + bytes_checked: int = 0 + bytes_to_check: int = 0 + + +@dataclass +class ToolSettings: + """Per-tool settings that map to CLI arguments.""" + # Duplicates + dup_check_method: CheckingMethod = CheckingMethod.HASH + dup_hash_type: HashType = HashType.BLAKE3 + dup_name_case_sensitive: bool = False + dup_name_similarity_threshold: float = 0.85 + dup_min_size: str = "8192" + dup_max_size: str = "" + dup_min_cache_size: str = "257144" + dup_use_prehash: bool = True + dup_min_prehash_cache_size: str = "257144" + + # Similar Images + img_hash_size: int = 16 # 8, 16, 32, 64 + img_filter: ImageFilter = ImageFilter.NEAREST + img_hash_alg: ImageHashAlg = ImageHashAlg.GRADIENT + img_ignore_same_size: bool = False + img_max_difference: int = 5 # 0-40 + + # Similar Videos + vid_ignore_same_size: bool = False + vid_crop_detect: VideoCropDetect = VideoCropDetect.LETTERBOX + vid_max_difference: int = 10 # 0-20 + vid_skip_forward: int = 15 # 0-300 + vid_duration: int = 10 # 1-60 + + # Similar Music + music_search_method: MusicSearchMethod = MusicSearchMethod.TAGS + music_approximate: bool = False + music_title: bool = True + music_artist: bool = True + music_bitrate: bool = False + music_genre: bool = False + music_year: bool = False + music_length: bool = False + music_compare_fingerprints_similar_titles: bool = False + music_max_difference: float = 2.0 + music_min_segment_duration: float = 10.0 + + # Big Files + big_files_mode: str = "biggest" # biggest or smallest + big_files_number: int = 50 + + # Broken Files + broken_audio: bool = True + broken_pdf: bool = True + broken_archive: bool = True + broken_image: bool = True + broken_video: bool = False + + # Bad Names + bad_names_uppercase_ext: bool = True + bad_names_emoji: bool = True + bad_names_space: bool = True + bad_names_non_ascii: bool = True + bad_names_restricted_charset: str = "" + bad_names_remove_duplicated: bool = False + + # EXIF Remover + exif_ignored_tags: str = "" + + # Video Optimizer + video_opt_mode: str = "crop" # crop or transcode + video_crop_mechanism: VideoCropMechanism = VideoCropMechanism.BLACKBARS + video_black_pixel_threshold: int = 32 + video_black_bar_percentage: int = 90 + video_max_samples: int = 20 + video_min_crop_size: int = 10 + video_excluded_codecs: str = "h265,hevc,av1,vp9" + video_codec: VideoCodec = VideoCodec.H265 + video_quality: int = 23 + video_fail_if_bigger: bool = False + video_overwrite: bool = False + video_max_width: int = 1920 + video_max_height: int = 1080 + + +@dataclass +class AppSettings: + """Global application settings.""" + included_paths: list = field(default_factory=lambda: [str(Path.home())]) + excluded_paths: list = field(default_factory=list) + excluded_items: str = "" + allowed_extensions: str = "" + excluded_extensions: str = "" + minimum_file_size: str = "" + maximum_file_size: str = "" + recursive_search: bool = True + use_cache: bool = True + save_as_json: bool = False + move_to_trash: bool = True + hide_hard_links: bool = False + thread_number: int = 0 # 0 = all available + dark_theme: bool = True + show_image_preview: bool = True + czkawka_cli_path: str = "czkawka_cli" # path to CLI binary diff --git a/kalka/app/tool_settings.py b/kalka/app/tool_settings.py new file mode 100644 index 000000000..2c24b2941 --- /dev/null +++ b/kalka/app/tool_settings.py @@ -0,0 +1,533 @@ +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QLabel, QComboBox, QCheckBox, + QSlider, QLineEdit, QGroupBox, QFormLayout, QScrollArea, + QHBoxLayout, QSpinBox, QPushButton, QSizePolicy +) +from PySide6.QtCore import Qt, Signal + +from .localizer import tr +from .models import ( + ActiveTab, ToolSettings, CheckingMethod, HashType, + ImageHashAlg, ImageFilter, MusicSearchMethod, + VideoCropDetect, VideoCropMechanism, VideoCodec, +) + + +class ToolSettingsPanel(QWidget): + """Per-tool settings panel shown on the right side.""" + settings_changed = Signal() + + def __init__(self, tool_settings: ToolSettings, parent=None): + super().__init__(parent) + self._ts = tool_settings + self._active_tab = ActiveTab.DUPLICATE_FILES + self.setMinimumWidth(250) + self.setMaximumWidth(350) + self._panels: dict[ActiveTab, QWidget] = {} + self._setup_ui() + + def _setup_ui(self): + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(4, 4, 4, 4) + + title = QLabel(tr("tool-settings-title")) + font = title.font() + font.setBold(True) + title.setFont(font) + main_layout.addWidget(title) + + self._scroll = QScrollArea() + self._scroll.setWidgetResizable(True) + self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + + self._container = QWidget() + self._container_layout = QVBoxLayout(self._container) + self._container_layout.setContentsMargins(0, 0, 0, 0) + + # Create all panel widgets + self._panels[ActiveTab.DUPLICATE_FILES] = self._create_duplicate_panel() + self._panels[ActiveTab.SIMILAR_IMAGES] = self._create_similar_images_panel() + self._panels[ActiveTab.SIMILAR_VIDEOS] = self._create_similar_videos_panel() + self._panels[ActiveTab.SIMILAR_MUSIC] = self._create_similar_music_panel() + self._panels[ActiveTab.BIG_FILES] = self._create_big_files_panel() + self._panels[ActiveTab.BROKEN_FILES] = self._create_broken_files_panel() + self._panels[ActiveTab.BAD_NAMES] = self._create_bad_names_panel() + self._panels[ActiveTab.EXIF_REMOVER] = self._create_exif_panel() + self._panels[ActiveTab.VIDEO_OPTIMIZER] = self._create_video_optimizer_panel() + + for panel in self._panels.values(): + self._container_layout.addWidget(panel) + panel.setVisible(False) + + self._container_layout.addStretch() + self._scroll.setWidget(self._container) + main_layout.addWidget(self._scroll) + + def set_active_tab(self, tab: ActiveTab): + self._active_tab = tab + for t, panel in self._panels.items(): + panel.setVisible(t == tab) + + def _create_duplicate_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + # Check method + self._dup_method = QComboBox() + self._dup_method.addItems(["Hash", "Size", "Name", "Fuzzy Name", "Size and Name"]) + method_map = {CheckingMethod.HASH: 0, CheckingMethod.SIZE: 1, + CheckingMethod.NAME: 2, CheckingMethod.FUZZY_NAME: 3, + CheckingMethod.SIZE_NAME: 4} + self._dup_method.setCurrentIndex(method_map.get(self._ts.dup_check_method, 0)) + self._dup_method.currentIndexChanged.connect(self._on_dup_method_changed) + layout.addRow(tr("subsettings-check-method"), self._dup_method) + + # Hash type + self._dup_hash = QComboBox() + self._dup_hash.addItems(["Blake3", "CRC32", "XXH3"]) + hash_map = {HashType.BLAKE3: 0, HashType.CRC32: 1, HashType.XXH3: 2} + self._dup_hash.setCurrentIndex(hash_map.get(self._ts.dup_hash_type, 0)) + self._dup_hash.currentIndexChanged.connect(self._on_dup_hash_changed) + layout.addRow(tr("subsettings-hash-type"), self._dup_hash) + + # Name similarity threshold (for fuzzy name mode) + from PySide6.QtWidgets import QSlider, QLabel, QHBoxLayout + from PySide6.QtCore import Qt + threshold_widget = QWidget() + threshold_layout = QHBoxLayout(threshold_widget) + threshold_layout.setContentsMargins(0, 0, 0, 0) + self._dup_threshold_slider = QSlider(Qt.Horizontal) + self._dup_threshold_slider.setRange(50, 100) + self._dup_threshold_slider.setValue(int(self._ts.dup_name_similarity_threshold * 100)) + self._dup_threshold_label = QLabel(f"{self._ts.dup_name_similarity_threshold:.0%}") + self._dup_threshold_slider.valueChanged.connect(self._on_dup_threshold_changed) + threshold_layout.addWidget(self._dup_threshold_slider) + threshold_layout.addWidget(self._dup_threshold_label) + layout.addRow("Name similarity", threshold_widget) + + # Case sensitive + self._dup_case = QCheckBox(tr("subsettings-case-sensitive")) + self._dup_case.setChecked(self._ts.dup_name_case_sensitive) + self._dup_case.toggled.connect(lambda v: setattr(self._ts, 'dup_name_case_sensitive', v)) + layout.addRow(self._dup_case) + + return panel + + def _on_dup_method_changed(self, idx): + methods = [CheckingMethod.HASH, CheckingMethod.SIZE, + CheckingMethod.NAME, CheckingMethod.FUZZY_NAME, + CheckingMethod.SIZE_NAME] + self._ts.dup_check_method = methods[idx] + self.settings_changed.emit() + + def _on_dup_threshold_changed(self, value): + self._ts.dup_name_similarity_threshold = value / 100.0 + self._dup_threshold_label.setText(f"{value}%") + self.settings_changed.emit() + + def _on_dup_hash_changed(self, idx): + hashes = [HashType.BLAKE3, HashType.CRC32, HashType.XXH3] + self._ts.dup_hash_type = hashes[idx] + self.settings_changed.emit() + + def _create_similar_images_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + # Hash size + self._img_hash_size = QComboBox() + self._img_hash_size.addItems(["8", "16", "32", "64"]) + size_map = {8: 0, 16: 1, 32: 2, 64: 3} + self._img_hash_size.setCurrentIndex(size_map.get(self._ts.img_hash_size, 1)) + self._img_hash_size.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'img_hash_size', [8, 16, 32, 64][idx]) + ) + layout.addRow(tr("subsettings-hash-size"), self._img_hash_size) + + # Resize algorithm + self._img_filter = QComboBox() + self._img_filter.addItems(["Lanczos3", "Gaussian", "CatmullRom", "Triangle", "Nearest"]) + filters = [ImageFilter.LANCZOS3, ImageFilter.GAUSSIAN, ImageFilter.CATMULL_ROM, + ImageFilter.TRIANGLE, ImageFilter.NEAREST] + filter_idx = filters.index(self._ts.img_filter) if self._ts.img_filter in filters else 4 + self._img_filter.setCurrentIndex(filter_idx) + self._img_filter.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'img_filter', filters[idx]) + ) + layout.addRow(tr("subsettings-resize-algorithm"), self._img_filter) + + # Hash type + self._img_hash_alg = QComboBox() + self._img_hash_alg.addItems(["Mean", "Gradient", "BlockHash", "VertGradient", + "DoubleGradient", "Median"]) + algs = [ImageHashAlg.MEAN, ImageHashAlg.GRADIENT, ImageHashAlg.BLOCKHASH, + ImageHashAlg.VERT_GRADIENT, ImageHashAlg.DOUBLE_GRADIENT, ImageHashAlg.MEDIAN] + alg_idx = algs.index(self._ts.img_hash_alg) if self._ts.img_hash_alg in algs else 1 + self._img_hash_alg.setCurrentIndex(alg_idx) + self._img_hash_alg.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'img_hash_alg', algs[idx]) + ) + layout.addRow(tr("subsettings-image-hash-type"), self._img_hash_alg) + + # Ignore same size + self._img_ignore_size = QCheckBox(tr("subsettings-ignore-same-size")) + self._img_ignore_size.setChecked(self._ts.img_ignore_same_size) + self._img_ignore_size.toggled.connect( + lambda v: setattr(self._ts, 'img_ignore_same_size', v) + ) + layout.addRow(self._img_ignore_size) + + # Max difference slider + diff_layout = QHBoxLayout() + self._img_diff_slider = QSlider(Qt.Horizontal) + self._img_diff_slider.setRange(0, 40) + self._img_diff_slider.setValue(self._ts.img_max_difference) + self._img_diff_label = QLabel(str(self._ts.img_max_difference)) + self._img_diff_slider.valueChanged.connect(self._on_img_diff_changed) + diff_layout.addWidget(self._img_diff_slider) + diff_layout.addWidget(self._img_diff_label) + layout.addRow(tr("subsettings-max-difference"), diff_layout) + + return panel + + def _on_img_diff_changed(self, value): + self._ts.img_max_difference = value + self._img_diff_label.setText(str(value)) + self.settings_changed.emit() + + def _create_similar_videos_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + # Ignore same size + vid_ignore = QCheckBox(tr("subsettings-ignore-same-size")) + vid_ignore.setChecked(self._ts.vid_ignore_same_size) + vid_ignore.toggled.connect(lambda v: setattr(self._ts, 'vid_ignore_same_size', v)) + layout.addRow(vid_ignore) + + # Crop detect + self._vid_crop = QComboBox() + self._vid_crop.addItems(["LetterBox", "Motion", "None"]) + crops = [VideoCropDetect.LETTERBOX, VideoCropDetect.MOTION, VideoCropDetect.NONE] + crop_idx = crops.index(self._ts.vid_crop_detect) if self._ts.vid_crop_detect in crops else 0 + self._vid_crop.setCurrentIndex(crop_idx) + self._vid_crop.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'vid_crop_detect', crops[idx]) + ) + layout.addRow(tr("subsettings-crop-detect"), self._vid_crop) + + # Max difference + diff_layout = QHBoxLayout() + self._vid_diff_slider = QSlider(Qt.Horizontal) + self._vid_diff_slider.setRange(0, 20) + self._vid_diff_slider.setValue(self._ts.vid_max_difference) + self._vid_diff_label = QLabel(str(self._ts.vid_max_difference)) + self._vid_diff_slider.valueChanged.connect(lambda v: ( + setattr(self._ts, 'vid_max_difference', v), + self._vid_diff_label.setText(str(v)), + self.settings_changed.emit(), + )) + diff_layout.addWidget(self._vid_diff_slider) + diff_layout.addWidget(self._vid_diff_label) + layout.addRow(tr("subsettings-max-difference"), diff_layout) + + # Skip forward + skip_layout = QHBoxLayout() + self._vid_skip_slider = QSlider(Qt.Horizontal) + self._vid_skip_slider.setRange(1, 360) + self._vid_skip_slider.setValue(self._ts.vid_skip_forward) + self._vid_skip_label = QLabel(str(self._ts.vid_skip_forward)) + self._vid_skip_slider.valueChanged.connect(lambda v: ( + setattr(self._ts, 'vid_skip_forward', v), + self._vid_skip_label.setText(str(v)), + self.settings_changed.emit(), + )) + skip_layout.addWidget(self._vid_skip_slider) + skip_layout.addWidget(self._vid_skip_label) + layout.addRow(tr("subsettings-skip-forward"), skip_layout) + + # Duration + dur_layout = QHBoxLayout() + self._vid_dur_slider = QSlider(Qt.Horizontal) + self._vid_dur_slider.setRange(1, 60) + self._vid_dur_slider.setValue(self._ts.vid_duration) + self._vid_dur_label = QLabel(str(self._ts.vid_duration)) + self._vid_dur_slider.valueChanged.connect(lambda v: ( + setattr(self._ts, 'vid_duration', v), + self._vid_dur_label.setText(str(v)), + self.settings_changed.emit(), + )) + dur_layout.addWidget(self._vid_dur_slider) + dur_layout.addWidget(self._vid_dur_label) + layout.addRow(tr("subsettings-hash-duration"), dur_layout) + + return panel + + def _create_similar_music_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + # Search method + self._music_method = QComboBox() + self._music_method.addItems(["Tags", "Fingerprint"]) + self._music_method.setCurrentIndex( + 0 if self._ts.music_search_method == MusicSearchMethod.TAGS else 1 + ) + self._music_method.currentIndexChanged.connect(self._on_music_method_changed) + layout.addRow(tr("subsettings-audio-check-type"), self._music_method) + + # Tags group + self._tags_group = QGroupBox(tr("subsettings-tag-matching")) + tags_layout = QVBoxLayout(self._tags_group) + + self._music_approx = QCheckBox(tr("subsettings-approximate-comparison")) + self._music_approx.setChecked(self._ts.music_approximate) + self._music_approx.toggled.connect(lambda v: setattr(self._ts, 'music_approximate', v)) + tags_layout.addWidget(self._music_approx) + + for attr, label in [ + ("music_title", "Title"), ("music_artist", "Artist"), + ("music_bitrate", "Bitrate"), ("music_genre", "Genre"), + ("music_year", "Year"), ("music_length", "Length"), + ]: + cb = QCheckBox(label) + cb.setChecked(getattr(self._ts, attr)) + cb.toggled.connect(lambda v, a=attr: setattr(self._ts, a, v)) + tags_layout.addWidget(cb) + + layout.addRow(self._tags_group) + + # Fingerprint group + self._fp_group = QGroupBox(tr("subsettings-fingerprint-matching")) + fp_layout = QFormLayout(self._fp_group) + + fp_similar = QCheckBox(tr("subsettings-compare-similar-titles")) + fp_similar.setChecked(self._ts.music_compare_fingerprints_similar_titles) + fp_similar.toggled.connect( + lambda v: setattr(self._ts, 'music_compare_fingerprints_similar_titles', v) + ) + fp_layout.addRow(fp_similar) + + diff_layout = QHBoxLayout() + self._music_diff_slider = QSlider(Qt.Horizontal) + self._music_diff_slider.setRange(0, 100) + self._music_diff_slider.setValue(int(self._ts.music_max_difference * 10)) + self._music_diff_label = QLabel(f"{self._ts.music_max_difference:.1f}") + self._music_diff_slider.valueChanged.connect(lambda v: ( + setattr(self._ts, 'music_max_difference', v / 10.0), + self._music_diff_label.setText(f"{v / 10.0:.1f}"), + self.settings_changed.emit(), + )) + diff_layout.addWidget(self._music_diff_slider) + diff_layout.addWidget(self._music_diff_label) + fp_layout.addRow(tr("subsettings-max-difference"), diff_layout) + + layout.addRow(self._fp_group) + self._on_music_method_changed(self._music_method.currentIndex()) + + return panel + + def _on_music_method_changed(self, idx): + if idx == 0: + self._ts.music_search_method = MusicSearchMethod.TAGS + else: + self._ts.music_search_method = MusicSearchMethod.CONTENT + self._tags_group.setVisible(idx == 0) + self._fp_group.setVisible(idx == 1) + self.settings_changed.emit() + + def _create_big_files_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + self._big_mode = QComboBox() + self._big_mode.addItems([tr("subsettings-the-biggest"), tr("subsettings-the-smallest")]) + self._big_mode.setCurrentIndex(0 if self._ts.big_files_mode == "biggest" else 1) + self._big_mode.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'big_files_mode', "biggest" if idx == 0 else "smallest") + ) + layout.addRow(tr("subsettings-method"), self._big_mode) + + self._big_count = QSpinBox() + self._big_count.setRange(1, 100000) + self._big_count.setValue(self._ts.big_files_number) + self._big_count.valueChanged.connect( + lambda v: setattr(self._ts, 'big_files_number', v) + ) + layout.addRow(tr("subsettings-number-of-files"), self._big_count) + + return panel + + def _create_broken_files_panel(self) -> QWidget: + panel = QWidget() + layout = QVBoxLayout(panel) + layout.addWidget(QLabel(tr("subsettings-file-types"))) + + for attr, label in [ + ("broken_audio", "Audio"), ("broken_pdf", "PDF"), + ("broken_archive", "Archive"), ("broken_image", "Image"), + ("broken_video", "Video"), + ]: + cb = QCheckBox(label) + cb.setChecked(getattr(self._ts, attr)) + cb.toggled.connect(lambda v, a=attr: setattr(self._ts, a, v)) + layout.addWidget(cb) + + layout.addStretch() + return panel + + def _create_bad_names_panel(self) -> QWidget: + panel = QWidget() + layout = QVBoxLayout(panel) + layout.addWidget(QLabel(tr("subsettings-check-for"))) + + checks = [ + ("bad_names_uppercase_ext", tr("subsettings-uppercase-ext"), + tr("subsettings-uppercase-ext-hint")), + ("bad_names_emoji", tr("subsettings-emoji"), + tr("subsettings-emoji-hint")), + ("bad_names_space", tr("subsettings-space"), + tr("subsettings-space-hint")), + ("bad_names_non_ascii", tr("subsettings-non-ascii"), + tr("subsettings-non-ascii-hint")), + ("bad_names_remove_duplicated", tr("subsettings-remove-duplicated"), + tr("subsettings-remove-duplicated-hint")), + ] + + for attr, label, hint in checks: + cb = QCheckBox(label) + cb.setChecked(getattr(self._ts, attr)) + cb.setToolTip(hint) + cb.toggled.connect(lambda v, a=attr: setattr(self._ts, a, v)) + layout.addWidget(cb) + + # Restricted charset + layout.addWidget(QLabel(tr("subsettings-restricted-charset"))) + self._bad_names_charset = QLineEdit(self._ts.bad_names_restricted_charset) + self._bad_names_charset.setPlaceholderText(tr("subsettings-restricted-charset-hint")) + self._bad_names_charset.textChanged.connect( + lambda t: setattr(self._ts, 'bad_names_restricted_charset', t) + ) + layout.addWidget(self._bad_names_charset) + + layout.addStretch() + return panel + + def _create_exif_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + self._exif_tags = QLineEdit(self._ts.exif_ignored_tags) + self._exif_tags.setPlaceholderText(tr("subsettings-ignored-exif-tags-hint")) + self._exif_tags.textChanged.connect( + lambda t: setattr(self._ts, 'exif_ignored_tags', t) + ) + layout.addRow(tr("subsettings-ignored-exif-tags"), self._exif_tags) + + return panel + + def _create_video_optimizer_panel(self) -> QWidget: + panel = QWidget() + layout = QFormLayout(panel) + + # Mode + self._vo_mode = QComboBox() + self._vo_mode.addItems(["Crop", "Transcode"]) + self._vo_mode.setCurrentIndex(0 if self._ts.video_opt_mode == "crop" else 1) + self._vo_mode.currentIndexChanged.connect(self._on_vo_mode_changed) + layout.addRow(tr("subsettings-mode"), self._vo_mode) + + # Crop settings + self._crop_group = QGroupBox(tr("subsettings-crop-settings")) + crop_layout = QFormLayout(self._crop_group) + + self._vo_crop_type = QComboBox() + self._vo_crop_type.addItems(["Black Bars", "Static Content"]) + mechs = [VideoCropMechanism.BLACKBARS, VideoCropMechanism.STATICCONTENT] + mech_idx = mechs.index(self._ts.video_crop_mechanism) if self._ts.video_crop_mechanism in mechs else 0 + self._vo_crop_type.setCurrentIndex(mech_idx) + self._vo_crop_type.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'video_crop_mechanism', mechs[idx]) + ) + crop_layout.addRow(tr("subsettings-crop-type"), self._vo_crop_type) + + self._vo_threshold = QSpinBox() + self._vo_threshold.setRange(0, 128) + self._vo_threshold.setValue(self._ts.video_black_pixel_threshold) + self._vo_threshold.valueChanged.connect( + lambda v: setattr(self._ts, 'video_black_pixel_threshold', v) + ) + crop_layout.addRow(tr("subsettings-black-pixel-threshold"), self._vo_threshold) + + self._vo_bar_pct = QSpinBox() + self._vo_bar_pct.setRange(50, 100) + self._vo_bar_pct.setValue(self._ts.video_black_bar_percentage) + self._vo_bar_pct.valueChanged.connect( + lambda v: setattr(self._ts, 'video_black_bar_percentage', v) + ) + crop_layout.addRow(tr("subsettings-black-bar-min-pct"), self._vo_bar_pct) + + self._vo_samples = QSpinBox() + self._vo_samples.setRange(5, 1000) + self._vo_samples.setValue(self._ts.video_max_samples) + self._vo_samples.valueChanged.connect( + lambda v: setattr(self._ts, 'video_max_samples', v) + ) + crop_layout.addRow(tr("subsettings-max-samples"), self._vo_samples) + + self._vo_min_crop = QSpinBox() + self._vo_min_crop.setRange(1, 1000) + self._vo_min_crop.setValue(self._ts.video_min_crop_size) + self._vo_min_crop.valueChanged.connect( + lambda v: setattr(self._ts, 'video_min_crop_size', v) + ) + crop_layout.addRow(tr("subsettings-min-crop-size"), self._vo_min_crop) + + layout.addRow(self._crop_group) + + # Transcode settings + self._transcode_group = QGroupBox(tr("subsettings-transcode-settings")) + tc_layout = QFormLayout(self._transcode_group) + + self._vo_codecs = QLineEdit(self._ts.video_excluded_codecs) + self._vo_codecs.textChanged.connect( + lambda t: setattr(self._ts, 'video_excluded_codecs', t) + ) + tc_layout.addRow(tr("subsettings-excluded-codecs"), self._vo_codecs) + + self._vo_codec = QComboBox() + self._vo_codec.addItems(["H264", "H265", "AV1", "VP9"]) + codecs = [VideoCodec.H264, VideoCodec.H265, VideoCodec.AV1, VideoCodec.VP9] + codec_idx = codecs.index(self._ts.video_codec) if self._ts.video_codec in codecs else 1 + self._vo_codec.setCurrentIndex(codec_idx) + self._vo_codec.currentIndexChanged.connect( + lambda idx: setattr(self._ts, 'video_codec', codecs[idx]) + ) + tc_layout.addRow(tr("subsettings-target-codec"), self._vo_codec) + + self._vo_quality = QSpinBox() + self._vo_quality.setRange(0, 51) + self._vo_quality.setValue(self._ts.video_quality) + self._vo_quality.valueChanged.connect( + lambda v: setattr(self._ts, 'video_quality', v) + ) + tc_layout.addRow(tr("subsettings-quality"), self._vo_quality) + + self._vo_fail_bigger = QCheckBox(tr("subsettings-fail-if-bigger")) + self._vo_fail_bigger.setChecked(self._ts.video_fail_if_bigger) + self._vo_fail_bigger.toggled.connect( + lambda v: setattr(self._ts, 'video_fail_if_bigger', v) + ) + tc_layout.addRow(self._vo_fail_bigger) + + layout.addRow(self._transcode_group) + + self._on_vo_mode_changed(self._vo_mode.currentIndex()) + return panel + + def _on_vo_mode_changed(self, idx): + mode = "crop" if idx == 0 else "transcode" + self._ts.video_opt_mode = mode + self._crop_group.setVisible(idx == 0) + self._transcode_group.setVisible(idx == 1) + self.settings_changed.emit() diff --git a/krokiet/src/connect_scan/duplicate.rs b/krokiet/src/connect_scan/duplicate.rs index 54c8b2812..494739952 100644 --- a/krokiet/src/connect_scan/duplicate.rs +++ b/krokiet/src/connect_scan/duplicate.rs @@ -58,6 +58,14 @@ pub(crate) fn scan_duplicates(a: Weak, sd: ScanData) { }; vector = values.into_iter().map(|(original, other)| (Some(original), other)).collect::>(); } + CheckingMethod::FuzzyName => { + vector = tool + .get_files_with_fuzzy_names_referenced() + .iter() + .cloned() + .map(|(original, other)| (Some(original), other)) + .collect::>(); + } _ => unreachable!("Invalid check method."), } } else { @@ -74,6 +82,9 @@ pub(crate) fn scan_duplicates(a: Weak, sd: ScanData) { }; vector = values.into_iter().map(|items| (None, items)).collect::>(); } + CheckingMethod::FuzzyName => { + vector = tool.get_files_with_fuzzy_names().iter().cloned().map(|items| (None, items)).collect::>(); + } _ => unreachable!("Invalid check method."), } } @@ -87,6 +98,7 @@ pub(crate) fn scan_duplicates(a: Weak, sd: ScanData) { let (duplicates_number, groups_number, lost_space) = match tool.get_check_method() { CheckingMethod::Hash => (info.number_of_duplicated_files_by_hash, info.number_of_groups_by_hash, info.lost_space_by_hash), CheckingMethod::Name => (info.number_of_duplicated_files_by_name, info.number_of_groups_by_name, 0), + CheckingMethod::FuzzyName => (info.number_of_duplicated_files_by_fuzzy_name, info.number_of_groups_by_fuzzy_name, 0), CheckingMethod::Size => (info.number_of_duplicated_files_by_size, info.number_of_groups_by_size, info.lost_space_by_size), CheckingMethod::SizeName => (info.number_of_duplicated_files_by_size_name, info.number_of_groups_by_size_name, info.lost_space_by_size), _ => unreachable!("invalid check method {:?}", tool.get_check_method()), diff --git a/krokiet/src/settings/combo_box.rs b/krokiet/src/settings/combo_box.rs index 470e8e9e9..755670b23 100644 --- a/krokiet/src/settings/combo_box.rs +++ b/krokiet/src/settings/combo_box.rs @@ -99,6 +99,7 @@ impl StringComboBoxItems { ("hash", "Hash", CheckingMethod::Hash), ("size", "Size", CheckingMethod::Size), ("name", "Name", CheckingMethod::Name), + ("fuzzy_name", "Fuzzy Name", CheckingMethod::FuzzyName), ("size_and_name", "Size and Name", CheckingMethod::SizeName), ]); From 43f9544f139f7d109778bbf051d0d45f8134e4a1 Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Wed, 25 Mar 2026 00:18:48 +0100 Subject: [PATCH 41/49] Add side-by-side comparison view to kalka preview panel (#1857) When two items are selected in the results tree (Ctrl+click), the preview panel switches to comparison mode showing both files side by side in a resizable splitter. Single selection reverts to the normal single-image preview. - Refactor PreviewPanel into stacked single/_ImageSlot and comparison modes - Add current_items_changed signal to ResultsView (fires on tree selection) - Wire up in main_window to auto-switch between single/comparison preview Co-Authored-By: Claude Opus 4.6 (1M context) --- kalka/app/main_window.py | 605 +++++++++++++++++++++++++++++++++++++ kalka/app/preview_panel.py | 192 ++++++++++++ kalka/app/results_view.py | 465 ++++++++++++++++++++++++++++ 3 files changed, 1262 insertions(+) create mode 100644 kalka/app/main_window.py create mode 100644 kalka/app/preview_panel.py create mode 100644 kalka/app/results_view.py diff --git a/kalka/app/main_window.py b/kalka/app/main_window.py new file mode 100644 index 000000000..49a1ad948 --- /dev/null +++ b/kalka/app/main_window.py @@ -0,0 +1,605 @@ +"""Main application window for Kalka interface.""" + +import shutil +from pathlib import Path + +from PySide6.QtWidgets import ( + QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QSplitter, + QStatusBar, QMessageBox, QLabel, QApplication +) +from PySide6.QtCore import Qt, QTimer, QStandardPaths +from PySide6.QtGui import QPalette, QColor + +from .state import AppState +from .models import ( + ActiveTab, TAB_COLUMNS, GROUPED_TABS, TABS_WITH_SETTINGS, SelectMode +) +from .left_panel import LeftPanel +from .results_view import ResultsView +from .action_buttons import ActionButtons +from .tool_settings import ToolSettingsPanel +from .settings_panel import SettingsPanel +from .progress_widget import ProgressWidget +from .preview_panel import PreviewPanel +from .bottom_panel import BottomPanel +from .backend import ScanRunner, FileOperations +from .icons import app_icon +from .localizer import tr +from .dialogs import ( + DeleteDialog, MoveDialog, SelectDialog, + SortDialog, SaveDialog, RenameDialog, AboutDialog +) + + +class MainWindow(QMainWindow): + """Main application window with all panels and functionality.""" + + def __init__(self): + super().__init__() + self._state = AppState() + self._scan_runner = ScanRunner(self) + self._setup_window() + self._setup_ui() + self._connect_signals() + self._apply_theme() + + def _setup_window(self): + self.setWindowTitle(tr("main-window-title")) + self.setMinimumSize(900, 600) + self.resize(1200, 800) + + # Set window icon from project logo + icon = app_icon() + if not icon.isNull(): + self.setWindowIcon(icon) + + # Auto-detect czkawka_cli binary + self._auto_detect_cli() + + def _setup_ui(self): + central = QWidget() + self.setCentralWidget(central) + main_layout = QVBoxLayout(central) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + + # Top area: left panel + content + content_splitter = QSplitter(Qt.Horizontal) + + # Left panel (tool selection) + self._left_panel = LeftPanel() + content_splitter.addWidget(self._left_panel) + + # Center area + center_widget = QWidget() + center_layout = QVBoxLayout(center_widget) + center_layout.setContentsMargins(0, 0, 0, 0) + center_layout.setSpacing(0) + + # Results view + self._results_view = ResultsView() + center_layout.addWidget(self._results_view, 1) + + # Progress widget (hidden by default) + self._progress = ProgressWidget() + center_layout.addWidget(self._progress) + + content_splitter.addWidget(center_widget) + + # Tool settings panel (hidden by default) + self._tool_settings = ToolSettingsPanel(self._state.tool_settings) + self._tool_settings.setVisible(False) + content_splitter.addWidget(self._tool_settings) + + # Preview panel (hidden by default) + self._preview = PreviewPanel() + self._preview.setVisible(False) + content_splitter.addWidget(self._preview) + + # Set splitter sizes + content_splitter.setStretchFactor(0, 0) # Left panel: fixed + content_splitter.setStretchFactor(1, 1) # Center: stretch + content_splitter.setStretchFactor(2, 0) # Tool settings: fixed + content_splitter.setStretchFactor(3, 0) # Preview: fixed + + main_layout.addWidget(content_splitter, 1) + + # Action buttons bar + self._action_buttons = ActionButtons() + main_layout.addWidget(self._action_buttons) + + # Bottom panel (directories / errors) + self._bottom_panel = BottomPanel(self._state.settings) + self._bottom_panel.show_directories() + main_layout.addWidget(self._bottom_panel) + + # Settings panel (overlay, hidden by default) + self._settings_panel = SettingsPanel(self._state.settings) + self._settings_panel.setVisible(False) + + # Status bar + self._statusbar = QStatusBar() + self.setStatusBar(self._statusbar) + self._status_label = QLabel(tr("status-ready")) + self._statusbar.addWidget(self._status_label, 1) + + # Initialize results view columns for default tab + self._results_view.set_active_tab(self._state.active_tab) + + def _connect_signals(self): + # Left panel + self._left_panel.tab_changed.connect(self._on_tab_changed) + self._left_panel.settings_requested.connect(self._show_settings) + self._left_panel.about_requested.connect(self._show_about) + self._left_panel.tool_settings_toggled.connect(self._toggle_tool_settings) + + # Action buttons + self._action_buttons.scan_clicked.connect(self._start_scan) + self._action_buttons.stop_clicked.connect(self._stop_scan) + self._action_buttons.select_clicked.connect(self._show_select_dialog) + self._action_buttons.delete_clicked.connect(self._show_delete_dialog) + self._action_buttons.move_clicked.connect(self._show_move_dialog) + self._action_buttons.save_clicked.connect(self._save_results) + self._action_buttons.load_clicked.connect(self._load_results) + self._action_buttons.sort_clicked.connect(self._show_sort_dialog) + self._action_buttons.hardlink_clicked.connect(self._create_hardlinks) + self._action_buttons.symlink_clicked.connect(self._create_symlinks) + self._action_buttons.rename_clicked.connect(self._rename_files) + self._action_buttons.clean_exif_clicked.connect(self._clean_exif) + self._action_buttons.optimize_video_clicked.connect(self._optimize_video) + + # Results view + self._results_view.selection_changed.connect( + lambda count: self._action_buttons.set_has_selection(count > 0) + ) + self._results_view.item_activated.connect(self._on_item_activated) + self._results_view.current_items_changed.connect(self._on_current_items_changed) + + # Scan runner + self._scan_runner.finished.connect(self._on_scan_finished) + self._scan_runner.progress.connect(self._on_scan_progress) + self._scan_runner.error.connect(self._on_scan_error) + + # Settings + self._settings_panel.close_requested.connect( + lambda: self._settings_panel.setVisible(False) + ) + self._settings_panel.settings_changed.connect(self._on_settings_changed) + + # Bottom panel + self._bottom_panel.directories_changed.connect(self._on_settings_changed) + + def _on_tab_changed(self, tab: ActiveTab): + self._state.set_active_tab(tab) + self._results_view.set_active_tab(tab) + self._action_buttons.set_active_tab(tab) + self._tool_settings.set_active_tab(tab) + + # Show/hide preview for image-related tabs + show_preview = ( + tab in (ActiveTab.SIMILAR_IMAGES, ActiveTab.DUPLICATE_FILES) + and self._state.settings.show_image_preview + ) + self._preview.setVisible(show_preview) + + # Load existing results for this tab + results = self._state.get_results(tab) + if results: + self._results_view.set_results(results) + self._action_buttons.set_has_results(True) + else: + self._results_view.clear() + self._action_buttons.set_has_results(False) + + self._status_label.setText(tr("status-tab", tab_name=tab.name.replace('_', ' ').title())) + + def _start_scan(self): + tab = self._state.active_tab + if not self._state.settings.included_paths: + QMessageBox.warning( + self, tr("no-directories-title"), + tr("no-directories-message") + ) + return + + self._state.set_scanning(True) + self._action_buttons.set_scanning(True) + self._progress.start( + tab, + included_paths=self._state.settings.included_paths, + excluded_paths=self._state.settings.excluded_paths, + ) + self._results_view.clear() + self._status_label.setText(tr("status-scanning", tab_name=tab.name.replace('_', ' ').title())) + + self._scan_runner.start_scan( + tab, self._state.settings, self._state.tool_settings + ) + + def _stop_scan(self): + self._state.request_stop() + self._scan_runner.stop_scan() + self._status_label.setText(tr("status-scan-stopped")) + + def _on_scan_finished(self, tab: ActiveTab, results: list): + self._state.set_scanning(False) + self._state.set_results(tab, results) + self._action_buttons.set_scanning(False) + self._progress.stop() + + if tab == self._state.active_tab: + self._results_view.set_results(results) + self._action_buttons.set_has_results(len(results) > 0) + + count = sum(1 for r in results if not r.header_row) + self._status_label.setText(tr("status-scan-complete", count=count)) + + def _on_scan_progress(self, progress): + self._progress.update_progress(progress) + + def _on_scan_error(self, error_msg: str): + self._state.set_scanning(False) + self._action_buttons.set_scanning(False) + self._progress.stop() + self._status_label.setText(tr("status-error", message=error_msg)) + self._bottom_panel.set_text(tr("status-error", message=error_msg)) + self._bottom_panel.show_text() + QMessageBox.critical(self, tr("scan-error-title"), error_msg) + + def _on_item_activated(self, entry): + path = entry.values.get("__full_path", "") + if path and self._preview.isVisible(): + self._preview.show_preview(path) + + def _on_current_items_changed(self, paths: list): + """Handle tree selection changes for preview/comparison.""" + if not self._preview.isVisible(): + return + if len(paths) == 2: + self._preview.show_comparison(paths[0], paths[1]) + elif len(paths) == 1: + self._preview.show_preview(paths[0]) + elif len(paths) == 0: + self._preview.clear_preview() + + def _show_settings(self): + self._settings_panel.setVisible(True) + # Show as a floating window + self._settings_panel.setParent(None) + self._settings_panel.setWindowTitle(tr("settings-window-title")) + self._settings_panel.setMinimumSize(600, 500) + self._settings_panel.show() + self._settings_panel.raise_() + + def _show_about(self): + dialog = AboutDialog(self) + dialog.exec() + + def _toggle_tool_settings(self, visible: bool): + self._tool_settings.setVisible(visible) + + def _show_select_dialog(self): + dialog = SelectDialog(self) + dialog.mode_selected.connect(self._results_view.apply_selection) + dialog.exec() + + def _show_delete_dialog(self): + checked = self._results_view.get_checked_entries() + if not checked: + QMessageBox.information(self, tr("no-selection-title"), tr("no-selection-delete")) + return + + dialog = DeleteDialog(len(checked), self._state.settings.move_to_trash, self) + if dialog.exec() == DeleteDialog.Accepted: + dry_run = dialog.dry_run + deleted, errors = FileOperations.delete_files( + checked, dialog.move_to_trash, dry_run=dry_run + ) + self._status_label.setText(tr("status-deleted-dry-run", count=deleted) if dry_run else tr("status-deleted", count=deleted)) + if errors: + self._bottom_panel.set_text("\n".join(errors)) + self._bottom_panel.show_text() + # Refresh results - remove deleted entries (skip on dry run) + if not dry_run: + self._refresh_after_action(checked) + + def _show_move_dialog(self): + checked = self._results_view.get_checked_entries() + if not checked: + QMessageBox.information(self, tr("no-selection-title"), tr("no-selection-move")) + return + + dialog = MoveDialog(len(checked), self) + if dialog.exec() == MoveDialog.Accepted: + if not dialog.destination: + QMessageBox.warning(self, tr("no-destination-title"), tr("no-destination-message")) + return + dry_run = dialog.dry_run + moved, errors = FileOperations.move_files( + checked, dialog.destination, + dialog.preserve_structure, dialog.copy_mode, + dry_run=dry_run + ) + action_key = "status-copied" if dialog.copy_mode else "status-moved" + dry_key = action_key + "-dry-run" if dry_run else action_key + self._status_label.setText(tr(dry_key, count=moved)) + if errors: + self._bottom_panel.set_text("\n".join(errors)) + self._bottom_panel.show_text() + if not dialog.copy_mode and not dry_run: + self._refresh_after_action(checked) + + def _save_results(self): + results = self._results_view.get_all_entries() + if not results: + QMessageBox.information(self, tr("no-results-title"), tr("no-results-save")) + return + all_results = self._state.get_results() + success = SaveDialog.save(self, all_results, self._state.settings.save_as_json) + if success: + self._status_label.setText(tr("status-results-saved")) + + def _load_results(self): + results = SaveDialog.load(self) + if results is None: + return + tab = self._state.active_tab + self._state.set_results(tab, results) + self._results_view.set_results(results) + self._action_buttons.set_has_results( + any(not r.header_row for r in results) + ) + count = sum(1 for r in results if not r.header_row) + self._status_label.setText(tr("status-results-loaded", count=count)) + + def _show_sort_dialog(self): + columns = TAB_COLUMNS.get(self._state.active_tab, []) + if not columns: + return + dialog = SortDialog(columns, self) + dialog.sort_requested.connect(self._results_view.sort_by_column) + dialog.exec() + + def _create_hardlinks(self): + checked = self._results_view.get_checked_entries() + if not checked: + return + + # Find the reference file (first unchecked in the same group) + all_results = self._state.get_results() + group_id = checked[0].group_id + reference = None + for r in all_results: + if r.group_id == group_id and not r.header_row and not r.checked: + reference = r.values.get("__full_path", "") + break + + if not reference: + QMessageBox.warning( + self, tr("no-reference-title"), + tr("no-reference-message") + ) + return + + reply = QMessageBox.question( + self, tr("hardlink-dialog-title"), + tr("hardlink-dialog-message", count=len(checked), reference=reference), + QMessageBox.Yes | QMessageBox.No + ) + if reply == QMessageBox.Yes: + created, errors = FileOperations.create_hardlinks(checked, reference) + self._status_label.setText(tr("status-hardlinks-created", count=created)) + if errors: + self._bottom_panel.set_text("\n".join(errors)) + self._bottom_panel.show_text() + + def _create_symlinks(self): + checked = self._results_view.get_checked_entries() + if not checked: + return + + all_results = self._state.get_results() + group_id = checked[0].group_id + reference = None + for r in all_results: + if r.group_id == group_id and not r.header_row and not r.checked: + reference = r.values.get("__full_path", "") + break + + if not reference: + QMessageBox.warning( + self, tr("no-reference-title"), + tr("no-reference-message") + ) + return + + reply = QMessageBox.question( + self, tr("symlink-dialog-title"), + tr("symlink-dialog-message", count=len(checked), reference=reference), + QMessageBox.Yes | QMessageBox.No + ) + if reply == QMessageBox.Yes: + created, errors = FileOperations.create_symlinks(checked, reference) + self._status_label.setText(tr("status-symlinks-created", count=created)) + if errors: + self._bottom_panel.set_text("\n".join(errors)) + self._bottom_panel.show_text() + + def _rename_files(self): + checked = self._results_view.get_checked_entries() + if not checked: + return + + tab = self._state.active_tab + if tab == ActiveTab.BAD_EXTENSIONS: + dialog = RenameDialog(len(checked), "extensions", self) + if dialog.exec() == RenameDialog.Accepted: + success, msg = FileOperations.fix_extensions( + self._state.settings.czkawka_cli_path, + self._state.settings, self._state.tool_settings + ) + self._status_label.setText(tr("status-extensions-fixed") if success else tr("status-error", message=msg)) + elif tab == ActiveTab.BAD_NAMES: + dialog = RenameDialog(len(checked), "names", self) + if dialog.exec() == RenameDialog.Accepted: + success, msg = FileOperations.fix_bad_names( + self._state.settings.czkawka_cli_path, + self._state.settings, self._state.tool_settings + ) + self._status_label.setText(tr("status-names-fixed") if success else tr("status-error", message=msg)) + + def _clean_exif(self): + checked = self._results_view.get_checked_entries() + if not checked: + return + + reply = QMessageBox.question( + self, tr("exif-dialog-title"), + tr("exif-dialog-message", count=len(checked)), + QMessageBox.Yes | QMessageBox.No + ) + if reply == QMessageBox.Yes: + cleaned, errors = FileOperations.clean_exif( + self._state.settings.czkawka_cli_path, + checked, + self._state.tool_settings.exif_ignored_tags + ) + self._status_label.setText(tr("status-exif-cleaned", count=cleaned)) + if errors: + self._bottom_panel.set_text("\n".join(errors)) + self._bottom_panel.show_text() + + def _optimize_video(self): + checked = self._results_view.get_checked_entries() + if not checked: + return + + QMessageBox.information( + self, tr("video-optimize-title"), + tr("video-optimize-message", count=len(checked)) + ) + # Video optimization is done via CLI + self._status_label.setText(tr("status-video-optimize")) + + def _refresh_after_action(self, removed_entries: list): + """Remove processed entries from results and refresh the view.""" + removed_paths = {e.values.get("__full_path") for e in removed_entries} + current_results = self._state.get_results() + new_results = [] + for r in current_results: + if r.header_row: + new_results.append(r) + elif r.values.get("__full_path") not in removed_paths: + new_results.append(r) + + # Remove empty group headers + cleaned = [] + i = 0 + while i < len(new_results): + if new_results[i].header_row: + # Check if next entries belong to this group + has_children = False + for j in range(i + 1, len(new_results)): + if new_results[j].header_row: + break + has_children = True + if has_children: + cleaned.append(new_results[i]) + else: + cleaned.append(new_results[i]) + i += 1 + + self._state.set_results(self._state.active_tab, cleaned) + self._results_view.set_results(cleaned) + self._action_buttons.set_has_results( + any(not r.header_row for r in cleaned) + ) + + def _on_settings_changed(self): + self._state.save_settings() + self._bottom_panel.refresh_lists() + + def _apply_theme(self): + """Apply minimal styling that works with the system theme. + + KDE compliance: we inherit the desktop theme (Breeze, Adwaita, etc.) + and only add small layout tweaks that don't override colors. + """ + app = QApplication.instance() + + # Only apply layout polish — no color overrides so the system + # theme (Breeze dark/light, Adwaita, etc.) is fully respected. + app.setStyleSheet(""" + QSplitter::handle { width: 2px; } + QTreeWidget::item { padding: 2px; } + QListWidget::item { padding: 3px; } + QGroupBox { border-radius: 4px; margin-top: 8px; padding-top: 8px; } + QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 4px; } + QPushButton { padding: 5px 12px; } + QComboBox { padding: 4px; } + QLineEdit { padding: 4px; } + QSpinBox { padding: 4px; } + QProgressBar { text-align: center; } + QScrollArea { border: none; } + QCheckBox { spacing: 6px; } + QHeaderView::section { padding: 4px; } + """) + + def _auto_detect_cli(self): + """Auto-detect czkawka_cli binary location.""" + s = self._state.settings + + # If already valid and exists, keep it + if s.czkawka_cli_path != "czkawka_cli" and Path(s.czkawka_cli_path).is_file(): + return + if shutil.which(s.czkawka_cli_path): + return + + # Search common locations + candidates = [] + project_root = Path(__file__).parent.parent.parent + + # Look for compiled binary in standard target dirs + for build_dir in ["target/release", "target/debug"]: + candidate = project_root / build_dir / "czkawka_cli" + if candidate.exists(): + candidates.append(str(candidate)) + + # Check cargo metadata for custom target directory + if not candidates: + try: + import subprocess, json + result = subprocess.run( + ["cargo", "metadata", "--manifest-path", + str(project_root / "Cargo.toml"), + "--format-version", "1", "--no-deps"], + capture_output=True, text=True, timeout=10 + ) + if result.returncode == 0: + meta = json.loads(result.stdout) + target_dir = meta.get("target_directory", "") + if target_dir: + for sub in ["release", "debug"]: + candidate = Path(target_dir) / sub / "czkawka_cli" + if candidate.exists(): + candidates.append(str(candidate)) + except Exception: + pass + + # Also check PATH + which_result = shutil.which("czkawka_cli") + if which_result: + candidates.append(which_result) + + for candidate in candidates: + if Path(candidate).is_file(): + s.czkawka_cli_path = str(candidate) + self._state.save_settings() + return + + def closeEvent(self, event): + """Save settings on close.""" + self._state.save_settings() + if self._state.scanning: + self._scan_runner.stop_scan() + super().closeEvent(event) diff --git a/kalka/app/preview_panel.py b/kalka/app/preview_panel.py new file mode 100644 index 000000000..a45a453d8 --- /dev/null +++ b/kalka/app/preview_panel.py @@ -0,0 +1,192 @@ +from pathlib import Path + +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QSizePolicy, QSplitter, + QStackedWidget +) +from PySide6.QtCore import Qt, QSize +from PySide6.QtGui import QPixmap + +from .localizer import tr + + +class _ImageSlot(QWidget): + """Single image preview slot with title and info.""" + + SUPPORTED_EXTENSIONS = { + ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", + ".tiff", ".tif", ".ico", + } + + def __init__(self, parent=None): + super().__init__(parent) + self._current_path = "" + self._pixmap = None + + layout = QVBoxLayout(self) + layout.setContentsMargins(2, 2, 2, 2) + layout.setSpacing(2) + + self._image_label = QLabel() + self._image_label.setAlignment(Qt.AlignCenter) + self._image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self._image_label.setMinimumSize(QSize(100, 100)) + self._image_label.setFrameShape(QLabel.StyledPanel) + self._image_label.setScaledContents(False) + layout.addWidget(self._image_label) + + self._info_label = QLabel() + self._info_label.setAlignment(Qt.AlignCenter) + self._info_label.setWordWrap(True) + self._info_label.setEnabled(False) + layout.addWidget(self._info_label) + + def show_file(self, file_path: str): + if not file_path: + self.clear() + return + + self._current_path = file_path + p = Path(file_path) + + if not p.exists(): + self._pixmap = None + self._image_label.setText(tr("preview-file-not-found")) + self._info_label.setText("") + return + + if p.suffix.lower() not in self.SUPPORTED_EXTENSIONS: + self._pixmap = None + self._image_label.setText(tr("preview-not-available")) + self._info_label.setText(p.name) + return + + self._pixmap = QPixmap(file_path) + if self._pixmap.isNull(): + self._pixmap = None + self._image_label.setText(tr("preview-cannot-load")) + self._info_label.setText(p.name) + return + + self._rescale() + size = p.stat().st_size + self._info_label.setText( + f"{p.name}\n{self._pixmap.width()}x{self._pixmap.height()} | {_format_size(size)}" + ) + + def clear(self): + self._current_path = "" + self._pixmap = None + self._image_label.clear() + self._image_label.setText(tr("preview-no-preview")) + self._info_label.setText("") + + def _rescale(self): + if self._pixmap and not self._pixmap.isNull(): + label_size = self._image_label.size() + scaled = self._pixmap.scaled( + label_size, Qt.KeepAspectRatio, Qt.SmoothTransformation + ) + self._image_label.setPixmap(scaled) + + def resizeEvent(self, event): + super().resizeEvent(event) + self._rescale() + + +class PreviewPanel(QWidget): + """Image preview panel supporting single and side-by-side comparison modes.""" + + SUPPORTED_EXTENSIONS = _ImageSlot.SUPPORTED_EXTENSIONS + + def __init__(self, parent=None): + super().__init__(parent) + self.setMinimumWidth(200) + self._current_path = "" + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(4, 4, 4, 4) + layout.setSpacing(4) + + self._title = QLabel(tr("preview-title")) + font = self._title.font() + font.setBold(True) + self._title.setFont(font) + self._title.setAlignment(Qt.AlignCenter) + layout.addWidget(self._title) + + # Stacked widget: page 0 = single, page 1 = comparison + self._stack = QStackedWidget() + layout.addWidget(self._stack) + + # Single preview mode + self._single_slot = _ImageSlot() + self._stack.addWidget(self._single_slot) + + # Side-by-side comparison mode + comparison_widget = QWidget() + comparison_layout = QVBoxLayout(comparison_widget) + comparison_layout.setContentsMargins(0, 0, 0, 0) + + splitter = QSplitter(Qt.Horizontal) + self._left_slot = _ImageSlot() + self._right_slot = _ImageSlot() + splitter.addWidget(self._left_slot) + splitter.addWidget(self._right_slot) + splitter.setStretchFactor(0, 1) + splitter.setStretchFactor(1, 1) + comparison_layout.addWidget(splitter) + + self._stack.addWidget(comparison_widget) + + def show_preview(self, file_path: str): + """Show a single file preview.""" + if not file_path or file_path == self._current_path: + return + self._current_path = file_path + self._stack.setCurrentIndex(0) + self._title.setText(tr("preview-title")) + self._single_slot.show_file(file_path) + + def show_comparison(self, left_path: str, right_path: str): + """Show two files side by side for comparison.""" + self._current_path = "" + self._stack.setCurrentIndex(1) + self._title.setText("Comparison") + self.setMinimumWidth(400) + self._left_slot.show_file(left_path) + self._right_slot.show_file(right_path) + + def clear_preview(self): + self._current_path = "" + self._single_slot.clear() + self._left_slot.clear() + self._right_slot.clear() + self._stack.setCurrentIndex(0) + self._title.setText(tr("preview-title")) + + def resizeEvent(self, event): + super().resizeEvent(event) + # Trigger rescale on the visible slot(s) + if self._current_path: + path = self._current_path + self._current_path = "" + self.show_preview(path) + + @staticmethod + def _format_size(size_bytes: int) -> str: + return _format_size(size_bytes) + + +def _format_size(size_bytes: int) -> str: + if size_bytes == 0: + return "0 B" + units = ["B", "KB", "MB", "GB"] + i = 0 + size = float(size_bytes) + while size >= 1024 and i < len(units) - 1: + size /= 1024 + i += 1 + return f"{size:.1f} {units[i]}" if i > 0 else f"{int(size)} B" diff --git a/kalka/app/results_view.py b/kalka/app/results_view.py new file mode 100644 index 000000000..c72fd48ce --- /dev/null +++ b/kalka/app/results_view.py @@ -0,0 +1,465 @@ +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QTreeWidget, QTreeWidgetItem, QHeaderView, + QAbstractItemView, QMenu, QLabel, QHBoxLayout +) +from PySide6.QtCore import Signal, Qt +from PySide6.QtGui import QColor, QBrush, QFont, QAction + +from .models import ( + ActiveTab, ResultEntry, TAB_COLUMNS, GROUPED_TABS, SelectMode +) +from .localizer import tr + + +class ResultsView(QWidget): + """Results display with tree view for grouped results and table for flat results.""" + + selection_changed = Signal(int) # number of selected items + item_activated = Signal(object) # ResultEntry + current_items_changed = Signal(list) # list of file paths for currently highlighted items + context_menu_requested = Signal(object, object) # QPoint, ResultEntry + + # Group header uses the system highlight color (darkened) so it works on + # both light and dark themes. Computed lazily on first result display. + _header_colors_ready = False + HEADER_BG = QColor() + HEADER_FG = QColor() + + def __init__(self, parent=None): + super().__init__(parent) + self._active_tab = ActiveTab.DUPLICATE_FILES + self._results: list[ResultEntry] = [] + self._sort_column = -1 + self._sort_order = Qt.AscendingOrder + self._setup_ui() + + def _ensure_header_colors(self): + """Derive group header colors from the system palette.""" + if self._header_colors_ready: + return + from PySide6.QtWidgets import QApplication + from PySide6.QtGui import QPalette + palette = QApplication.instance().palette() + win = palette.color(QPalette.ColorRole.Window) + hi = palette.color(QPalette.ColorRole.Highlight) + self.HEADER_BG = QColor( + (win.red() + hi.red()) // 2, + (win.green() + hi.green()) // 2, + (win.blue() + hi.blue()) // 2, + ) + self.HEADER_FG = palette.color(QPalette.ColorRole.HighlightedText) + self._header_colors_ready = True + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + # Summary bar + summary_layout = QHBoxLayout() + self._summary_label = QLabel(tr("results-no-results")) + summary_layout.addWidget(self._summary_label) + self._selection_label = QLabel("") + self._selection_label.setAlignment(Qt.AlignRight) + self._selection_label.setEnabled(False) + summary_layout.addWidget(self._selection_label) + layout.addLayout(summary_layout) + + # Tree widget for results + self._tree = QTreeWidget() + self._tree.setSelectionMode(QAbstractItemView.ExtendedSelection) + self._tree.setRootIsDecorated(False) + self._tree.setAlternatingRowColors(True) + self._tree.setContextMenuPolicy(Qt.CustomContextMenu) + self._tree.customContextMenuRequested.connect(self._on_context_menu) + self._tree.itemChanged.connect(self._on_item_changed) + self._tree.itemDoubleClicked.connect(self._on_item_double_clicked) + self._tree.itemSelectionChanged.connect(self._on_tree_selection_changed) + + # Sortable: click header to sort + self._tree.setSortingEnabled(False) # We handle sorting manually + header = self._tree.header() + header.setSectionsClickable(True) + header.sectionClicked.connect(self._on_header_clicked) + # Resizable columns + header.setSectionResizeMode(QHeaderView.Interactive) + header.setStretchLastSection(True) + + layout.addWidget(self._tree) + + def set_active_tab(self, tab: ActiveTab): + self._active_tab = tab + self._sort_column = -1 + columns = TAB_COLUMNS.get(tab, ["Selection", "File Name", "Path"]) + self._tree.setColumnCount(len(columns)) + self._tree.setHeaderLabels(columns) + header = self._tree.header() + # All columns interactive (resizable), last one stretches + header.setSectionResizeMode(QHeaderView.Interactive) + header.setStretchLastSection(True) + # Give Path columns more initial space + for i, col in enumerate(columns): + if col == "Path": + header.resizeSection(i, 300) + elif col == "Selection": + header.resizeSection(i, 30) + elif col in ("Size", "Hash", "Modification Date"): + header.resizeSection(i, 140) + elif col == "File Name": + header.resizeSection(i, 200) + + def set_results(self, results: list[ResultEntry]): + self._ensure_header_colors() + self._results = results + self._rebuild_tree() + self._update_summary() + + def _rebuild_tree(self): + """Rebuild tree items from self._results.""" + self._tree.blockSignals(True) + self._tree.clear() + + columns = TAB_COLUMNS.get(self._active_tab, ["Selection", "File Name", "Path"]) + + for entry in self._results: + if entry.header_row: + item = QTreeWidgetItem() + header_text = entry.values.get("__header", "Group") + item.setText(0, header_text) + item.setFirstColumnSpanned(True) + font = QFont() + font.setBold(True) + item.setFont(0, font) + for col in range(len(columns)): + item.setBackground(col, QBrush(self.HEADER_BG)) + item.setForeground(col, QBrush(self.HEADER_FG)) + item.setFlags(item.flags() & ~Qt.ItemIsUserCheckable) + item.setData(0, Qt.UserRole, entry) + self._tree.addTopLevelItem(item) + # Span header across all columns (must be called after adding to tree) + item.setFirstColumnSpanned(True) + else: + item = QTreeWidgetItem() + item.setFlags(item.flags() | Qt.ItemIsUserCheckable) + item.setCheckState(0, Qt.Checked if entry.checked else Qt.Unchecked) + + for col_idx, col_name in enumerate(columns): + if col_idx == 0: + continue + value = entry.values.get(col_name, "") + item.setText(col_idx, str(value)) + + item.setData(0, Qt.UserRole, entry) + self._tree.addTopLevelItem(item) + + self._tree.blockSignals(False) + + # ── Sorting ────────────────────────────────────────────── + + def _on_header_clicked(self, logical_index: int): + """Sort by column when header is clicked. Toggle ascending/descending.""" + if logical_index == 0: + return # Don't sort by checkbox column + + if self._sort_column == logical_index: + # Toggle order + self._sort_order = ( + Qt.DescendingOrder if self._sort_order == Qt.AscendingOrder + else Qt.AscendingOrder + ) + else: + self._sort_column = logical_index + self._sort_order = Qt.AscendingOrder + + columns = TAB_COLUMNS.get(self._active_tab, []) + col_name = columns[logical_index] if logical_index < len(columns) else "" + + # Update header sort indicator + self._tree.header().setSortIndicator(logical_index, self._sort_order) + self._tree.header().setSortIndicatorShown(True) + + ascending = self._sort_order == Qt.AscendingOrder + + if self._active_tab in GROUPED_TABS: + self._sort_within_groups(col_name, ascending) + else: + self._sort_flat(col_name, ascending) + + def _sort_key(self, entry: ResultEntry, col_name: str): + """Return a sort key for a result entry by column name.""" + # Use numeric values for known numeric columns + if col_name in ("Size",): + return entry.values.get("__size_bytes", 0) + if col_name in ("Modification Date",): + return entry.values.get("__modified_date_ts", 0) + if col_name in ("Similarity", "Bitrate", "Year", "Length"): + raw = entry.values.get(col_name, "") + try: + return float(str(raw).replace(",", "")) + except (ValueError, TypeError): + return 0 + # Default: string comparison (case-insensitive) + return str(entry.values.get(col_name, "")).lower() + + def _sort_flat(self, col_name: str, ascending: bool): + """Sort flat (non-grouped) results.""" + self._results.sort( + key=lambda e: self._sort_key(e, col_name), + reverse=not ascending, + ) + self._rebuild_tree() + + def _sort_within_groups(self, col_name: str, ascending: bool): + """Sort entries within each group, keeping group headers in place.""" + sorted_results = [] + current_group = [] + current_header = None + + for entry in self._results: + if entry.header_row: + if current_header is not None: + current_group.sort( + key=lambda e: self._sort_key(e, col_name), + reverse=not ascending, + ) + sorted_results.append(current_header) + sorted_results.extend(current_group) + current_header = entry + current_group = [] + else: + current_group.append(entry) + + # Last group + if current_header is not None: + current_group.sort( + key=lambda e: self._sort_key(e, col_name), + reverse=not ascending, + ) + sorted_results.append(current_header) + sorted_results.extend(current_group) + + self._results = sorted_results + self._rebuild_tree() + + def sort_by_column(self, column: int, ascending: bool = True): + """Public API for sorting (used by sort dialog).""" + self._sort_column = column + self._sort_order = Qt.AscendingOrder if ascending else Qt.DescendingOrder + columns = TAB_COLUMNS.get(self._active_tab, []) + col_name = columns[column] if column < len(columns) else "" + self._tree.header().setSortIndicator(column, self._sort_order) + self._tree.header().setSortIndicatorShown(True) + if self._active_tab in GROUPED_TABS: + self._sort_within_groups(col_name, ascending) + else: + self._sort_flat(col_name, ascending) + + # ── Item events ────────────────────────────────────────── + + def _on_item_changed(self, item, column): + if column == 0: + entry = item.data(0, Qt.UserRole) + if entry and not entry.header_row: + entry.checked = item.checkState(0) == Qt.Checked + self._update_selection_count() + + def _on_item_double_clicked(self, item, column): + entry = item.data(0, Qt.UserRole) + if entry and not entry.header_row: + self.item_activated.emit(entry) + + def _on_tree_selection_changed(self): + """Emit file paths of all currently highlighted (blue-selected) items.""" + paths = [] + for item in self._tree.selectedItems(): + entry = item.data(0, Qt.UserRole) + if entry and not entry.header_row: + path = entry.values.get("__full_path", "") + if path: + paths.append(path) + self.current_items_changed.emit(paths) + + def _on_context_menu(self, pos): + item = self._tree.itemAt(pos) + if item: + entry = item.data(0, Qt.UserRole) + if entry and not entry.header_row: + menu = QMenu(self) + open_action = QAction(tr("context-open-file"), self) + open_action.triggered.connect(lambda: self._open_file(entry)) + menu.addAction(open_action) + + open_dir_action = QAction(tr("context-open-folder"), self) + open_dir_action.triggered.connect(lambda: self._open_folder(entry)) + menu.addAction(open_dir_action) + + menu.addSeparator() + + select_action = QAction(tr("context-select"), self) + select_action.triggered.connect(lambda: self._set_check(item, True)) + menu.addAction(select_action) + + deselect_action = QAction(tr("context-deselect"), self) + deselect_action.triggered.connect(lambda: self._set_check(item, False)) + menu.addAction(deselect_action) + + menu.exec(self._tree.viewport().mapToGlobal(pos)) + + def _open_file(self, entry: ResultEntry): + import subprocess, sys + path = entry.values.get("__full_path", "") + if not path: + return + try: + if sys.platform == "linux": + subprocess.Popen(["xdg-open", path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + elif sys.platform == "darwin": + subprocess.Popen(["open", path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + else: + subprocess.Popen(["cmd", "/c", "start", "", path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except OSError: + pass + + def _open_folder(self, entry: ResultEntry): + import subprocess, sys + from pathlib import Path + path = entry.values.get("__full_path", "") + if not path: + return + folder = str(Path(path).parent) + try: + if sys.platform == "linux": + subprocess.Popen(["xdg-open", folder], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + elif sys.platform == "darwin": + subprocess.Popen(["open", folder], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + else: + subprocess.Popen(["explorer", folder], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except OSError: + pass + + def _set_check(self, item, checked): + item.setCheckState(0, Qt.Checked if checked else Qt.Unchecked) + + # ── Summary / selection ────────────────────────────────── + + @staticmethod + def _format_size(size_bytes: int) -> str: + for unit in ("B", "KB", "MB", "GB", "TB"): + if abs(size_bytes) < 1024: + return f"{size_bytes:.1f} {unit}" if unit != "B" else f"{size_bytes} B" + size_bytes /= 1024 + return f"{size_bytes:.1f} PB" + + def _update_summary(self): + entries = [r for r in self._results if not r.header_row] + total = len(entries) + total_size = sum(r.values.get("__size_bytes", 0) for r in entries) + groups = sum(1 for r in self._results if r.header_row) + size_str = self._format_size(total_size) + if self._active_tab in GROUPED_TABS and groups > 0: + self._summary_label.setText(tr("results-found-grouped", total=total, size=size_str, groups=groups)) + elif total > 0: + self._summary_label.setText(tr("results-found-flat", total=total, size=size_str)) + else: + self._summary_label.setText(tr("results-no-results")) + self._update_selection_count() + + def _update_selection_count(self): + entries = [r for r in self._results if not r.header_row] + total = len(entries) + total_size = sum(r.values.get("__size_bytes", 0) for r in entries) + checked = [r for r in entries if r.checked] + selected = len(checked) + selected_size = sum(r.values.get("__size_bytes", 0) for r in checked) + if selected > 0: + self._selection_label.setText( + tr("results-selected", selected=selected, total=total, selected_size=self._format_size(selected_size), total_size=self._format_size(total_size)) + ) + else: + self._selection_label.setText("") + self.selection_changed.emit(selected) + + def apply_selection(self, mode: SelectMode): + self._tree.blockSignals(True) + + if mode == SelectMode.SELECT_ALL: + self._select_all(True) + elif mode == SelectMode.UNSELECT_ALL: + self._select_all(False) + elif mode == SelectMode.INVERT_SELECTION: + self._invert_selection() + elif mode in (SelectMode.SELECT_BIGGEST_SIZE, SelectMode.SELECT_SMALLEST_SIZE, + SelectMode.SELECT_NEWEST, SelectMode.SELECT_OLDEST, + SelectMode.SELECT_BIGGEST_RESOLUTION, SelectMode.SELECT_SMALLEST_RESOLUTION, + SelectMode.SELECT_SHORTEST_PATH, SelectMode.SELECT_LONGEST_PATH): + self._select_by_group_criteria(mode) + + self._tree.blockSignals(False) + self._update_selection_count() + + def _select_all(self, checked: bool): + for i in range(self._tree.topLevelItemCount()): + item = self._tree.topLevelItem(i) + entry = item.data(0, Qt.UserRole) + if entry and not entry.header_row: + entry.checked = checked + item.setCheckState(0, Qt.Checked if checked else Qt.Unchecked) + + def _invert_selection(self): + for i in range(self._tree.topLevelItemCount()): + item = self._tree.topLevelItem(i) + entry = item.data(0, Qt.UserRole) + if entry and not entry.header_row: + entry.checked = not entry.checked + item.setCheckState(0, Qt.Checked if entry.checked else Qt.Unchecked) + + def _select_by_group_criteria(self, mode: SelectMode): + self._select_all(False) + + if self._active_tab not in GROUPED_TABS: + return + + groups: dict[int, list[tuple[int, ResultEntry]]] = {} + for i in range(self._tree.topLevelItemCount()): + item = self._tree.topLevelItem(i) + entry = item.data(0, Qt.UserRole) + if entry and not entry.header_row: + groups.setdefault(entry.group_id, []).append((i, entry)) + + for group_id, items in groups.items(): + if len(items) <= 1: + continue + + best_idx = 0 + if mode == SelectMode.SELECT_BIGGEST_SIZE: + best_idx = max(range(len(items)), key=lambda j: items[j][1].values.get("__size_bytes", 0)) + elif mode == SelectMode.SELECT_SMALLEST_SIZE: + best_idx = min(range(len(items)), key=lambda j: items[j][1].values.get("__size_bytes", 0)) + elif mode == SelectMode.SELECT_NEWEST: + best_idx = max(range(len(items)), key=lambda j: items[j][1].values.get("__modified_date_ts", 0)) + elif mode == SelectMode.SELECT_OLDEST: + best_idx = min(range(len(items)), key=lambda j: items[j][1].values.get("__modified_date_ts", 0)) + elif mode == SelectMode.SELECT_SHORTEST_PATH: + best_idx = min(range(len(items)), key=lambda j: len(items[j][1].values.get("__full_path", ""))) + elif mode == SelectMode.SELECT_LONGEST_PATH: + best_idx = max(range(len(items)), key=lambda j: len(items[j][1].values.get("__full_path", ""))) + + for j, (tree_idx, entry) in enumerate(items): + if j != best_idx: + entry.checked = True + self._tree.topLevelItem(tree_idx).setCheckState(0, Qt.Checked) + + # ── Public accessors ───────────────────────────────────── + + def get_checked_entries(self) -> list[ResultEntry]: + return [r for r in self._results if r.checked and not r.header_row] + + def get_all_entries(self) -> list[ResultEntry]: + return [r for r in self._results if not r.header_row] + + def clear(self): + self._results = [] + self._tree.clear() + self._sort_column = -1 + self._tree.header().setSortIndicatorShown(False) + self._summary_label.setText(tr("results-no-results")) + self._selection_label.setText("") From c765d6934cd857bec9404ac89f77e7f8a8cfbf76 Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Wed, 25 Mar 2026 00:20:55 +0100 Subject: [PATCH 42/49] Add CSV/JSON/text export for scan results (#1858) Extend kalka's save dialog with proper multi-format export: - CSV export with column headers from TAB_COLUMNS, group labels, and a convenience "Full Path" column - JSON export (existing, refactored to static method) - Text export (existing, refactored to static method) The file type is auto-detected from the chosen extension/filter. The active_tab parameter (optional) provides column ordering for CSV. Co-Authored-By: Claude Opus 4.6 (1M context) --- kalka/app/dialogs/save_dialog.py | 205 +++++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 kalka/app/dialogs/save_dialog.py diff --git a/kalka/app/dialogs/save_dialog.py b/kalka/app/dialogs/save_dialog.py new file mode 100644 index 000000000..976bc219d --- /dev/null +++ b/kalka/app/dialogs/save_dialog.py @@ -0,0 +1,205 @@ +import csv +import io +import json +from pathlib import Path + +from PySide6.QtWidgets import QFileDialog + +from ..localizer import tr +from ..models import ResultEntry, TAB_COLUMNS, ActiveTab + + +class SaveDialog: + """Save/load results to/from file.""" + + # Columns to exclude from export (internal metadata) + _INTERNAL_KEYS = {"__full_path", "__size_bytes", "__modified_date_ts", "__header", "__group_id", "__checked"} + + @staticmethod + def save(parent, results: list, save_as_json: bool = False, active_tab: ActiveTab = None) -> bool: + filter_str = ( + "CSV Files (*.csv);;" + "JSON Files (*.json);;" + "Text Files (*.txt);;" + "All Files (*)" + ) + default_ext = ".csv" + + path, selected_filter = QFileDialog.getSaveFileName( + parent, tr("save-dialog-title"), f"results{default_ext}", filter_str + ) + if not path: + return False + + try: + if path.endswith(".json") or "JSON" in selected_filter: + return SaveDialog._save_json(path, results) + elif path.endswith(".csv") or "CSV" in selected_filter: + return SaveDialog._save_csv(path, results, active_tab) + else: + return SaveDialog._save_text(path, results) + except OSError: + return False + + @staticmethod + def _save_json(path: str, results: list) -> bool: + data = [] + for entry in results: + if entry.header_row: + data.append({ + "__header": entry.values.get("__header", ""), + "__group_id": entry.group_id, + }) + else: + values = dict(entry.values) + values["__group_id"] = entry.group_id + values["__checked"] = entry.checked + data.append(values) + with open(path, "w") as f: + json.dump(data, f, indent=2) + return True + + @staticmethod + def _save_csv(path: str, results: list, active_tab: ActiveTab = None) -> bool: + # Determine columns from tab or from first non-header entry + columns = [] + if active_tab and active_tab in TAB_COLUMNS: + columns = [c for c in TAB_COLUMNS[active_tab] if c != "Selection"] + if not columns: + for entry in results: + if not entry.header_row: + columns = [k for k in entry.values if k not in SaveDialog._INTERNAL_KEYS] + break + + columns_with_path = list(columns) + if "Path" in columns_with_path: + # Add full path column for convenience + columns_with_path.append("Full Path") + + with open(path, "w", newline="", encoding="utf-8") as f: + writer = csv.writer(f) + writer.writerow(["Group"] + columns_with_path) + + current_group = "" + for entry in results: + if entry.header_row: + current_group = entry.values.get("__header", f"Group {entry.group_id}") + continue + row = [current_group] + for col in columns: + row.append(entry.values.get(col, "")) + if "Path" in columns: + row.append(entry.values.get("__full_path", "")) + writer.writerow(row) + return True + + @staticmethod + def _save_text(path: str, results: list) -> bool: + with open(path, "w") as f: + for entry in results: + if entry.header_row: + f.write(f"\n--- {entry.values.get('__header', 'Group')} ---\n") + else: + path_val = entry.values.get("__full_path", "") + size = entry.values.get("Size", "") + f.write(f"{path_val}\t{size}\n") + return True + + @staticmethod + def load(parent) -> list[ResultEntry] | None: + """Load results from a previously saved JSON file. + + Returns list of ResultEntry on success, None on cancel/error. + """ + path, _ = QFileDialog.getOpenFileName( + parent, tr("load-dialog-title"), + "", + "JSON Files (*.json);;All Files (*)" + ) + if not path: + return None + + try: + with open(path) as f: + data = json.load(f) + except (json.JSONDecodeError, OSError): + return None + + if not isinstance(data, list): + # Could be czkawka_cli dict format {size: [[entries]]} + return _parse_cli_json(data) + + results = [] + for item in data: + if not isinstance(item, dict): + continue + + if "__header" in item: + results.append(ResultEntry( + values={"__header": item["__header"]}, + header_row=True, + group_id=item.get("__group_id", 0), + )) + else: + group_id = item.pop("__group_id", 0) + checked = item.pop("__checked", False) + results.append(ResultEntry( + values=item, + checked=checked, + group_id=group_id, + )) + + return results if results else None + + +def _parse_cli_json(data: dict) -> list[ResultEntry] | None: + """Parse raw czkawka_cli JSON output (dict of size buckets or flat list).""" + results = [] + group_id = 0 + + for key, groups in data.items(): + if not isinstance(groups, list): + continue + for group in groups: + if not isinstance(group, list) or len(group) == 0: + continue + + total_size = sum(e.get("size", 0) for e in group if isinstance(e, dict)) + results.append(ResultEntry( + values={"__header": f"Group {group_id + 1} ({len(group)} files)"}, + header_row=True, + group_id=group_id, + )) + + for entry in group: + if not isinstance(entry, dict): + continue + path = entry.get("path", "") + p = Path(path) + values = { + "File Name": p.name, + "Path": str(p.parent), + "Size": _format_size(entry.get("size", 0)), + "Modification Date": str(entry.get("modified_date", "")), + "Hash": entry.get("hash", ""), + "__full_path": path, + "__size_bytes": entry.get("size", 0), + "__modified_date_ts": entry.get("modified_date", 0), + } + results.append(ResultEntry(values=values, group_id=group_id)) + + group_id += 1 + + return results if results else None + + +def _format_size(size_bytes: int) -> str: + if size_bytes == 0: + return "0 B" + units = ["B", "KB", "MB", "GB", "TB"] + i = 0 + size = float(size_bytes) + while size >= 1024 and i < len(units) - 1: + size /= 1024 + i += 1 + return f"{size:.1f} {units[i]}" if i > 0 else f"{int(size)} B" From f90a340fa52a9d8e3fceac6202f8fb5210164579 Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Wed, 25 Mar 2026 00:22:02 +0100 Subject: [PATCH 43/49] Add extended file preview: PDF, text, and video thumbnails (#1855) Extend kalka's preview panel beyond images to support: - Text files: plain text preview with syntax-aware extensions (.py, .rs, .json, .xml, .yaml, etc.), truncated at 64KB - Video files: thumbnail extraction via ffmpeg (first frame at 3s), with graceful fallback if ffmpeg is not installed - PDF files: first-page render via PySide6.QtPdf (QPdfDocument), with graceful fallback if QtPdf module is unavailable The panel auto-detects file type by extension and switches between image mode (QPixmap) and text mode (QPlainTextEdit). Co-Authored-By: Claude Opus 4.6 (1M context) --- kalka/app/preview_panel.py | 243 +++++++++++++++++++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 kalka/app/preview_panel.py diff --git a/kalka/app/preview_panel.py b/kalka/app/preview_panel.py new file mode 100644 index 000000000..11615391b --- /dev/null +++ b/kalka/app/preview_panel.py @@ -0,0 +1,243 @@ +import subprocess +from pathlib import Path + +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QLabel, QSizePolicy, QPlainTextEdit, QScrollArea +) +from PySide6.QtCore import Qt, QSize +from PySide6.QtGui import QPixmap + +from .localizer import tr + + +# File extension sets for different preview types +IMAGE_EXTENSIONS = { + ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", + ".tiff", ".tif", ".ico", +} + +TEXT_EXTENSIONS = { + ".txt", ".md", ".csv", ".json", ".xml", ".yaml", ".yml", + ".toml", ".ini", ".cfg", ".conf", ".log", ".sh", ".bash", + ".py", ".rs", ".js", ".ts", ".html", ".css", ".c", ".cpp", + ".h", ".hpp", ".java", ".go", ".rb", ".php", ".sql", +} + +VIDEO_EXTENSIONS = { + ".mp4", ".mkv", ".avi", ".mov", ".wmv", ".flv", ".webm", + ".m4v", ".mpg", ".mpeg", ".3gp", ".ogv", ".ts", +} + +PDF_EXTENSIONS = {".pdf"} + +MAX_TEXT_PREVIEW_BYTES = 64 * 1024 # 64 KB + + +class PreviewPanel(QWidget): + """File preview panel supporting images, text, PDF, and video thumbnails.""" + + SUPPORTED_EXTENSIONS = IMAGE_EXTENSIONS | TEXT_EXTENSIONS | VIDEO_EXTENSIONS | PDF_EXTENSIONS + + def __init__(self, parent=None): + super().__init__(parent) + self.setMinimumWidth(200) + self.setMaximumWidth(400) + self._current_path = "" + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(4, 4, 4, 4) + + self._title = QLabel(tr("preview-title")) + font = self._title.font() + font.setBold(True) + self._title.setFont(font) + self._title.setAlignment(Qt.AlignCenter) + layout.addWidget(self._title) + + # Image preview label + self._image_label = QLabel() + self._image_label.setAlignment(Qt.AlignCenter) + self._image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self._image_label.setMinimumSize(QSize(180, 180)) + self._image_label.setFrameShape(QLabel.StyledPanel) + self._image_label.setScaledContents(False) + layout.addWidget(self._image_label) + + # Text preview area (hidden by default) + self._text_edit = QPlainTextEdit() + self._text_edit.setReadOnly(True) + self._text_edit.setLineWrapMode(QPlainTextEdit.WidgetWidth) + self._text_edit.setVisible(False) + layout.addWidget(self._text_edit) + + # Info label + self._info_label = QLabel() + self._info_label.setAlignment(Qt.AlignCenter) + self._info_label.setWordWrap(True) + self._info_label.setEnabled(False) + layout.addWidget(self._info_label) + + def show_preview(self, file_path: str): + if not file_path or file_path == self._current_path: + return + + self._current_path = file_path + p = Path(file_path) + + if not p.exists(): + self._show_image_mode() + self._image_label.setText(tr("preview-file-not-found")) + self._info_label.setText("") + return + + ext = p.suffix.lower() + + if ext in IMAGE_EXTENSIONS: + self._preview_image(p, file_path) + elif ext in TEXT_EXTENSIONS: + self._preview_text(p) + elif ext in VIDEO_EXTENSIONS: + self._preview_video(p, file_path) + elif ext in PDF_EXTENSIONS: + self._preview_pdf(p, file_path) + else: + self._show_image_mode() + self._image_label.setText(tr("preview-not-available")) + self._info_label.setText(p.name) + + def _preview_image(self, p: Path, file_path: str): + self._show_image_mode() + pixmap = QPixmap(file_path) + if pixmap.isNull(): + self._image_label.setText(tr("preview-cannot-load")) + self._info_label.setText(p.name) + return + + label_size = self._image_label.size() + scaled = pixmap.scaled( + label_size, Qt.KeepAspectRatio, Qt.SmoothTransformation + ) + self._image_label.setPixmap(scaled) + + size = p.stat().st_size + self._info_label.setText( + f"{p.name}\n{pixmap.width()}x{pixmap.height()} | {_format_size(size)}" + ) + + def _preview_text(self, p: Path): + self._show_text_mode() + try: + size = p.stat().st_size + with open(p, "r", errors="replace") as f: + content = f.read(MAX_TEXT_PREVIEW_BYTES) + if size > MAX_TEXT_PREVIEW_BYTES: + content += f"\n\n... (truncated, {_format_size(size)} total)" + self._text_edit.setPlainText(content) + self._info_label.setText(f"{p.name} | {_format_size(size)}") + except OSError: + self._text_edit.setPlainText("Error reading file.") + self._info_label.setText(p.name) + + def _preview_video(self, p: Path, file_path: str): + """Extract a thumbnail from the video using ffmpeg.""" + self._show_image_mode() + try: + result = subprocess.run( + [ + "ffmpeg", "-y", "-i", file_path, + "-ss", "00:00:03", "-frames:v", "1", + "-f", "image2pipe", "-vcodec", "png", "-" + ], + capture_output=True, timeout=10 + ) + if result.returncode == 0 and result.stdout: + pixmap = QPixmap() + pixmap.loadFromData(result.stdout) + if not pixmap.isNull(): + label_size = self._image_label.size() + scaled = pixmap.scaled( + label_size, Qt.KeepAspectRatio, Qt.SmoothTransformation + ) + self._image_label.setPixmap(scaled) + size = p.stat().st_size + self._info_label.setText( + f"{p.name}\n{pixmap.width()}x{pixmap.height()} | {_format_size(size)}" + ) + return + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + + self._image_label.setText("Video preview\n(ffmpeg not available)") + self._info_label.setText(f"{p.name} | {_format_size(p.stat().st_size)}") + + def _preview_pdf(self, p: Path, file_path: str): + """Try to preview first page of PDF using QPdfDocument or fallback.""" + self._show_image_mode() + try: + from PySide6.QtPdf import QPdfDocument + doc = QPdfDocument(self) + doc.load(file_path) + if doc.pageCount() > 0: + image = doc.render(0, QSize(380, 500)) + pixmap = QPixmap.fromImage(image) + if not pixmap.isNull(): + label_size = self._image_label.size() + scaled = pixmap.scaled( + label_size, Qt.KeepAspectRatio, Qt.SmoothTransformation + ) + self._image_label.setPixmap(scaled) + size = p.stat().st_size + self._info_label.setText( + f"{p.name}\n{doc.pageCount()} pages | {_format_size(size)}" + ) + doc.close() + return + doc.close() + except (ImportError, Exception): + pass + + # Fallback: show file info + self._image_label.setText("PDF preview\n(PySide6.QtPdf not available)") + self._info_label.setText(f"{p.name} | {_format_size(p.stat().st_size)}") + + def _show_image_mode(self): + self._image_label.setVisible(True) + self._text_edit.setVisible(False) + + def _show_text_mode(self): + self._image_label.setVisible(False) + self._text_edit.setVisible(True) + + def clear_preview(self): + self._current_path = "" + self._image_label.clear() + self._image_label.setText(tr("preview-no-preview")) + self._text_edit.clear() + self._text_edit.setVisible(False) + self._image_label.setVisible(True) + self._info_label.setText("") + + def resizeEvent(self, event): + super().resizeEvent(event) + if self._current_path: + path = self._current_path + self._current_path = "" + self.show_preview(path) + + @staticmethod + def _format_size(size_bytes: int) -> str: + return _format_size(size_bytes) + + +def _format_size(size_bytes: int) -> str: + if size_bytes == 0: + return "0 B" + units = ["B", "KB", "MB", "GB"] + i = 0 + size = float(size_bytes) + while size >= 1024 and i < len(units) - 1: + size /= 1024 + i += 1 + return f"{size:.1f} {units[i]}" if i > 0 else f"{int(size)} B" From 692907b33fa23f1f4d618ddc322bb4dade8f8a7f Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Wed, 25 Mar 2026 00:23:16 +0100 Subject: [PATCH 44/49] Add combined selection criteria to kalka select dialog (#1856) Redesign the select dialog with two sections: - Quick selection: Select All / Unselect All / Invert (direct apply) - Smart selection: checkboxes for Biggest/Smallest/Newest/Oldest/ Shortest/Longest path criteria that can be combined with AND/OR logic for more powerful file selection within duplicate groups Emits custom_criteria_selected(modes, combinator) signal when multiple criteria are combined. Co-Authored-By: Claude Opus 4.6 (1M context) --- kalka/app/dialogs/select_dialog.py | 104 +++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 kalka/app/dialogs/select_dialog.py diff --git a/kalka/app/dialogs/select_dialog.py b/kalka/app/dialogs/select_dialog.py new file mode 100644 index 000000000..f9bdadd61 --- /dev/null +++ b/kalka/app/dialogs/select_dialog.py @@ -0,0 +1,104 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QCheckBox, QGroupBox, QComboBox, QFrame +) +from PySide6.QtCore import Signal + +from ..models import SelectMode +from ..localizer import tr + + +class SelectDialog(QDialog): + """Dialog for selecting/deselecting results with combinable criteria.""" + mode_selected = Signal(object) # SelectMode + custom_criteria_selected = Signal(list, str) # (list of SelectMode, combinator "AND"/"OR") + + # Simple modes (apply directly) + SIMPLE_MODES = [ + (SelectMode.SELECT_ALL, "select-all"), + (SelectMode.UNSELECT_ALL, "unselect-all"), + (SelectMode.INVERT_SELECTION, "invert-selection"), + ] + + # Combinable criteria (can be combined with AND/OR) + CRITERIA = [ + (SelectMode.SELECT_BIGGEST_SIZE, "select-biggest-size"), + (SelectMode.SELECT_SMALLEST_SIZE, "select-smallest-size"), + (SelectMode.SELECT_NEWEST, "select-newest"), + (SelectMode.SELECT_OLDEST, "select-oldest"), + (SelectMode.SELECT_SHORTEST_PATH, "select-shortest-path"), + (SelectMode.SELECT_LONGEST_PATH, "select-longest-path"), + ] + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle(tr("select-dialog-title")) + self.setMinimumWidth(350) + + layout = QVBoxLayout(self) + + # Simple selection buttons + simple_group = QGroupBox("Quick selection") + simple_layout = QHBoxLayout(simple_group) + for mode, ftl_key in self.SIMPLE_MODES: + btn = QPushButton(tr(ftl_key)) + btn.clicked.connect(lambda checked, m=mode: self._select_simple(m)) + simple_layout.addWidget(btn) + layout.addWidget(simple_group) + + # Separator + line = QFrame() + line.setFrameShape(QFrame.HLine) + line.setFrameShadow(QFrame.Sunken) + layout.addWidget(line) + + # Combined criteria + criteria_group = QGroupBox("Smart selection (combinable)") + criteria_layout = QVBoxLayout(criteria_group) + + criteria_layout.addWidget(QLabel( + "Check multiple criteria to combine them.\n" + "AND = file must match ALL checked criteria.\n" + "OR = file must match ANY checked criterion." + )) + + self._checkboxes: list[tuple[QCheckBox, SelectMode]] = [] + for mode, ftl_key in self.CRITERIA: + cb = QCheckBox(tr(ftl_key)) + criteria_layout.addWidget(cb) + self._checkboxes.append((cb, mode)) + + # Combinator selector + combinator_layout = QHBoxLayout() + combinator_layout.addWidget(QLabel("Combine with:")) + self._combinator = QComboBox() + self._combinator.addItems(["AND (all must match)", "OR (any must match)"]) + combinator_layout.addWidget(self._combinator) + criteria_layout.addLayout(combinator_layout) + + # Apply combined button + apply_btn = QPushButton("Apply combined selection") + apply_btn.clicked.connect(self._apply_combined) + criteria_layout.addWidget(apply_btn) + + layout.addWidget(criteria_group) + + # Cancel + cancel = QPushButton(tr("cancel")) + cancel.clicked.connect(self.reject) + layout.addWidget(cancel) + + def _select_simple(self, mode: SelectMode): + self.mode_selected.emit(mode) + self.accept() + + def _apply_combined(self): + selected = [mode for cb, mode in self._checkboxes if cb.isChecked()] + if not selected: + return + if len(selected) == 1: + self.mode_selected.emit(selected[0]) + else: + combinator = "AND" if self._combinator.currentIndex() == 0 else "OR" + self.custom_criteria_selected.emit(selected, combinator) + self.accept() From 51fe00078b1ddfbc2d7c4d9975d1fc407d7af6e6 Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Wed, 25 Mar 2026 00:25:44 +0100 Subject: [PATCH 45/49] Add drag & drop directory addition to kalka bottom panel (#1862) Enable dragging folders from the system file manager onto the included/excluded directory lists in the bottom panel. - Add _DroppableListWidget subclass with drag/drop support - Filter drops to only accept local directories - Deduplicate against existing entries - Visual drag feedback via Qt's built-in drop indicators Co-Authored-By: Claude Opus 4.6 (1M context) --- kalka/app/bottom_panel.py | 199 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 kalka/app/bottom_panel.py diff --git a/kalka/app/bottom_panel.py b/kalka/app/bottom_panel.py new file mode 100644 index 000000000..9f2301079 --- /dev/null +++ b/kalka/app/bottom_panel.py @@ -0,0 +1,199 @@ +from pathlib import Path + +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QListWidget, + QPushButton, QFileDialog, QTextEdit, QStackedWidget, + QSizePolicy +) +from PySide6.QtCore import Signal, Qt, QUrl + +from .models import AppSettings +from .localizer import tr + + +class _DroppableListWidget(QListWidget): + """QListWidget that accepts folder drag & drop.""" + items_dropped = Signal(list) # list of dropped directory paths + + def __init__(self, parent=None): + super().__init__(parent) + self.setAcceptDrops(True) + + def dragEnterEvent(self, event): + if event.mimeData().hasUrls(): + # Accept if any URL is a directory + for url in event.mimeData().urls(): + if url.isLocalFile() and Path(url.toLocalFile()).is_dir(): + event.acceptProposedAction() + return + event.ignore() + + def dragMoveEvent(self, event): + if event.mimeData().hasUrls(): + event.acceptProposedAction() + else: + event.ignore() + + def dropEvent(self, event): + paths = [] + for url in event.mimeData().urls(): + if url.isLocalFile(): + p = url.toLocalFile() + if Path(p).is_dir(): + paths.append(p) + if paths: + self.items_dropped.emit(paths) + event.acceptProposedAction() + else: + event.ignore() + + +class BottomPanel(QWidget): + """Bottom panel showing directories or error messages.""" + directories_changed = Signal() + + def __init__(self, settings: AppSettings, parent=None): + super().__init__(parent) + self._settings = settings + self.setMaximumHeight(200) + self._setup_ui() + + def _setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(4, 2, 4, 2) + + self._stack = QStackedWidget() + + # Page 0: Directories view + dir_widget = QWidget() + dir_layout = QHBoxLayout(dir_widget) + dir_layout.setContentsMargins(0, 0, 0, 0) + + # Included directories + inc_widget = QWidget() + inc_layout = QVBoxLayout(inc_widget) + inc_layout.setContentsMargins(0, 0, 0, 0) + inc_layout.addWidget(QLabel(tr("bottom-included-dirs"))) + + self._inc_list = _DroppableListWidget() + self._inc_list.setMaximumHeight(120) + self._inc_list.items_dropped.connect(self._on_included_dropped) + for path in self._settings.included_paths: + self._inc_list.addItem(path) + inc_layout.addWidget(self._inc_list) + + inc_btns = QHBoxLayout() + add_btn = QPushButton("+") + add_btn.setFixedWidth(30) + add_btn.clicked.connect(self._add_included) + inc_btns.addWidget(add_btn) + rem_btn = QPushButton("-") + rem_btn.setFixedWidth(30) + rem_btn.clicked.connect(self._remove_included) + inc_btns.addWidget(rem_btn) + inc_btns.addStretch() + inc_layout.addLayout(inc_btns) + dir_layout.addWidget(inc_widget) + + # Excluded directories + exc_widget = QWidget() + exc_layout = QVBoxLayout(exc_widget) + exc_layout.setContentsMargins(0, 0, 0, 0) + exc_layout.addWidget(QLabel(tr("bottom-excluded-dirs"))) + + self._exc_list = _DroppableListWidget() + self._exc_list.setMaximumHeight(120) + self._exc_list.items_dropped.connect(self._on_excluded_dropped) + for path in self._settings.excluded_paths: + self._exc_list.addItem(path) + exc_layout.addWidget(self._exc_list) + + exc_btns = QHBoxLayout() + add_exc = QPushButton("+") + add_exc.setFixedWidth(30) + add_exc.clicked.connect(self._add_excluded) + exc_btns.addWidget(add_exc) + rem_exc = QPushButton("-") + rem_exc.setFixedWidth(30) + rem_exc.clicked.connect(self._remove_excluded) + exc_btns.addWidget(rem_exc) + exc_btns.addStretch() + exc_layout.addLayout(exc_btns) + dir_layout.addWidget(exc_widget) + + self._stack.addWidget(dir_widget) + + # Page 1: Text errors/info + self._text_area = QTextEdit() + self._text_area.setReadOnly(True) + self._text_area.setMaximumHeight(150) + self._stack.addWidget(self._text_area) + + layout.addWidget(self._stack) + + def show_directories(self): + self._stack.setCurrentIndex(0) + self.setVisible(True) + + def show_text(self): + self._stack.setCurrentIndex(1) + self.setVisible(True) + + def hide_panel(self): + self.setVisible(False) + + def set_text(self, text: str): + self._text_area.setPlainText(text) + + def append_text(self, text: str): + self._text_area.append(text) + + def _add_included(self): + path = QFileDialog.getExistingDirectory(self, tr("settings-select-dir-include")) + if path and path not in self._settings.included_paths: + self._settings.included_paths.append(path) + self._inc_list.addItem(path) + self.directories_changed.emit() + + def _remove_included(self): + row = self._inc_list.currentRow() + if row >= 0: + self._inc_list.takeItem(row) + self._settings.included_paths.pop(row) + self.directories_changed.emit() + + def _add_excluded(self): + path = QFileDialog.getExistingDirectory(self, tr("settings-select-dir-exclude")) + if path and path not in self._settings.excluded_paths: + self._settings.excluded_paths.append(path) + self._exc_list.addItem(path) + self.directories_changed.emit() + + def _remove_excluded(self): + row = self._exc_list.currentRow() + if row >= 0: + self._exc_list.takeItem(row) + self._settings.excluded_paths.pop(row) + self.directories_changed.emit() + + def _on_included_dropped(self, paths: list): + for path in paths: + if path not in self._settings.included_paths: + self._settings.included_paths.append(path) + self._inc_list.addItem(path) + self.directories_changed.emit() + + def _on_excluded_dropped(self, paths: list): + for path in paths: + if path not in self._settings.excluded_paths: + self._settings.excluded_paths.append(path) + self._exc_list.addItem(path) + self.directories_changed.emit() + + def refresh_lists(self): + self._inc_list.clear() + for path in self._settings.included_paths: + self._inc_list.addItem(path) + self._exc_list.clear() + for path in self._settings.excluded_paths: + self._exc_list.addItem(path) From e5db92b313d0bad524fa0a635ff495e249c95d6a Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Wed, 25 Mar 2026 00:27:11 +0100 Subject: [PATCH 46/49] Add idle-priority scanning option to kalka (#1863) Add a "Low priority scanning" checkbox in settings that prepends nice -n 19 and ionice -c 3 to the czkawka_cli process on Linux, preventing scans from slowing down other applications. - Add low_priority_scan setting to AppSettings - Prepend nice/ionice in backend.py when spawning CLI process - Add checkbox with tooltip in settings panel - Only activates on Linux (checks sys.platform and shutil.which) Co-Authored-By: Claude Opus 4.6 (1M context) --- kalka/app/backend.py | 11 ++ kalka/app/models.py | 1 + kalka/app/settings_panel.py | 274 ++++++++++++++++++++++++++++++++++++ 3 files changed, 286 insertions(+) create mode 100644 kalka/app/settings_panel.py diff --git a/kalka/app/backend.py b/kalka/app/backend.py index f87ca145a..ee02a3e7a 100644 --- a/kalka/app/backend.py +++ b/kalka/app/backend.py @@ -1,6 +1,8 @@ import json import os +import shutil import subprocess +import sys import tempfile from pathlib import Path from typing import Optional @@ -48,6 +50,15 @@ def run(self): # Enable JSON progress on stderr, suppress text output cmd.extend(["--json-progress", "-N", "-M"]) + # Prepend nice/ionice for low-priority scanning on Linux + if self.app_settings.low_priority_scan and sys.platform.startswith("linux"): + prefix = [] + if shutil.which("ionice"): + prefix.extend(["ionice", "-c", "3"]) + if shutil.which("nice"): + prefix.extend(["nice", "-n", "19"]) + cmd = prefix + cmd + self._process = subprocess.Popen( cmd, stdout=subprocess.DEVNULL, diff --git a/kalka/app/models.py b/kalka/app/models.py index 87db08348..5d9b6cf1f 100644 --- a/kalka/app/models.py +++ b/kalka/app/models.py @@ -306,3 +306,4 @@ class AppSettings: dark_theme: bool = True show_image_preview: bool = True czkawka_cli_path: str = "czkawka_cli" # path to CLI binary + low_priority_scan: bool = False # run scans with idle CPU/IO priority diff --git a/kalka/app/settings_panel.py b/kalka/app/settings_panel.py new file mode 100644 index 000000000..dcd7c30ca --- /dev/null +++ b/kalka/app/settings_panel.py @@ -0,0 +1,274 @@ +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QCheckBox, + QLineEdit, QSpinBox, QGroupBox, QFormLayout, QScrollArea, + QPushButton, QListWidget, QFileDialog, QDoubleSpinBox, + QTabWidget, QSizePolicy +) +from PySide6.QtCore import Qt, Signal + +from .models import AppSettings +from .localizer import tr + + +class SettingsPanel(QWidget): + """Global application settings panel.""" + settings_changed = Signal() + close_requested = Signal() + + def __init__(self, settings: AppSettings, parent=None): + super().__init__(parent) + self._settings = settings + self._setup_ui() + + def _setup_ui(self): + main_layout = QVBoxLayout(self) + + # Header + header = QHBoxLayout() + title = QLabel(tr("settings-title")) + font = title.font() + font.setBold(True) + font.setPointSize(font.pointSize() + 2) + title.setFont(font) + header.addWidget(title) + header.addStretch() + close_btn = QPushButton(tr("settings-close")) + close_btn.clicked.connect(self.close_requested.emit) + header.addWidget(close_btn) + main_layout.addLayout(header) + + # Tabs + tabs = QTabWidget() + + # General tab + tabs.addTab(self._create_general_tab(), tr("settings-tab-general")) + # Directories tab + tabs.addTab(self._create_directories_tab(), tr("settings-tab-directories")) + # Filters tab + tabs.addTab(self._create_filters_tab(), tr("settings-tab-filters")) + # Preview tab + tabs.addTab(self._create_preview_tab(), tr("settings-tab-preview")) + + main_layout.addWidget(tabs) + + def _create_general_tab(self) -> QWidget: + scroll = QScrollArea() + scroll.setWidgetResizable(True) + widget = QWidget() + layout = QFormLayout(widget) + + # CLI path + cli_layout = QHBoxLayout() + self._cli_path = QLineEdit(self._settings.czkawka_cli_path) + self._cli_path.textChanged.connect( + lambda t: setattr(self._settings, 'czkawka_cli_path', t) + ) + cli_layout.addWidget(self._cli_path) + browse_btn = QPushButton(tr("settings-browse")) + browse_btn.clicked.connect(self._browse_cli) + cli_layout.addWidget(browse_btn) + layout.addRow(tr("settings-cli-path"), cli_layout) + + # Thread number + self._threads = QSpinBox() + self._threads.setRange(0, 64) + self._threads.setValue(self._settings.thread_number) + self._threads.setSpecialValueText(tr("settings-thread-auto")) + self._threads.valueChanged.connect( + lambda v: setattr(self._settings, 'thread_number', v) + ) + layout.addRow(tr("settings-thread-count"), self._threads) + + # Recursive search + recursive = QCheckBox(tr("settings-recursive")) + recursive.setChecked(self._settings.recursive_search) + recursive.toggled.connect( + lambda v: setattr(self._settings, 'recursive_search', v) + ) + layout.addRow(recursive) + + # Use cache + cache = QCheckBox(tr("settings-use-cache")) + cache.setChecked(self._settings.use_cache) + cache.toggled.connect( + lambda v: setattr(self._settings, 'use_cache', v) + ) + layout.addRow(cache) + + # Move to trash + trash = QCheckBox(tr("settings-move-to-trash")) + trash.setChecked(self._settings.move_to_trash) + trash.toggled.connect( + lambda v: setattr(self._settings, 'move_to_trash', v) + ) + layout.addRow(trash) + + # Hide hard links + hardlinks = QCheckBox(tr("settings-hide-hard-links")) + hardlinks.setChecked(self._settings.hide_hard_links) + hardlinks.toggled.connect( + lambda v: setattr(self._settings, 'hide_hard_links', v) + ) + layout.addRow(hardlinks) + + # Low priority scanning + low_priority = QCheckBox("Low priority scanning (nice/ionice)") + low_priority.setChecked(self._settings.low_priority_scan) + low_priority.setToolTip("Run scans with idle CPU and I/O priority so they don't slow down other applications") + low_priority.toggled.connect( + lambda v: setattr(self._settings, 'low_priority_scan', v) + ) + layout.addRow(low_priority) + + # Save as JSON + save_json = QCheckBox(tr("settings-save-as-json")) + save_json.setChecked(self._settings.save_as_json) + save_json.toggled.connect( + lambda v: setattr(self._settings, 'save_as_json', v) + ) + layout.addRow(save_json) + + scroll.setWidget(widget) + return scroll + + def _create_directories_tab(self) -> QWidget: + widget = QWidget() + layout = QVBoxLayout(widget) + + # Included paths + inc_group = QGroupBox(tr("settings-included-dirs")) + inc_layout = QVBoxLayout(inc_group) + + self._inc_list = QListWidget() + for path in self._settings.included_paths: + self._inc_list.addItem(path) + inc_layout.addWidget(self._inc_list) + + inc_btns = QHBoxLayout() + add_inc = QPushButton(tr("settings-add")) + add_inc.clicked.connect(self._add_included) + inc_btns.addWidget(add_inc) + rem_inc = QPushButton(tr("settings-remove")) + rem_inc.clicked.connect(self._remove_included) + inc_btns.addWidget(rem_inc) + inc_btns.addStretch() + inc_layout.addLayout(inc_btns) + layout.addWidget(inc_group) + + # Excluded paths + exc_group = QGroupBox(tr("settings-excluded-dirs")) + exc_layout = QVBoxLayout(exc_group) + + self._exc_list = QListWidget() + for path in self._settings.excluded_paths: + self._exc_list.addItem(path) + exc_layout.addWidget(self._exc_list) + + exc_btns = QHBoxLayout() + add_exc = QPushButton(tr("settings-add")) + add_exc.clicked.connect(self._add_excluded) + exc_btns.addWidget(add_exc) + rem_exc = QPushButton(tr("settings-remove")) + rem_exc.clicked.connect(self._remove_excluded) + exc_btns.addWidget(rem_exc) + exc_btns.addStretch() + exc_layout.addLayout(exc_btns) + layout.addWidget(exc_group) + + return widget + + def _create_filters_tab(self) -> QWidget: + widget = QWidget() + layout = QFormLayout(widget) + + # Excluded items + self._excluded_items = QLineEdit(self._settings.excluded_items) + self._excluded_items.setPlaceholderText(tr("settings-excluded-items-hint")) + self._excluded_items.textChanged.connect( + lambda t: setattr(self._settings, 'excluded_items', t) + ) + layout.addRow(tr("settings-excluded-items"), self._excluded_items) + + # Allowed extensions + self._allowed_ext = QLineEdit(self._settings.allowed_extensions) + self._allowed_ext.setPlaceholderText(tr("settings-allowed-extensions-hint")) + self._allowed_ext.textChanged.connect( + lambda t: setattr(self._settings, 'allowed_extensions', t) + ) + layout.addRow(tr("settings-allowed-extensions"), self._allowed_ext) + + # Excluded extensions + self._excluded_ext = QLineEdit(self._settings.excluded_extensions) + self._excluded_ext.setPlaceholderText(tr("settings-excluded-extensions-hint")) + self._excluded_ext.textChanged.connect( + lambda t: setattr(self._settings, 'excluded_extensions', t) + ) + layout.addRow(tr("settings-excluded-extensions"), self._excluded_ext) + + # Min file size + self._min_size = QLineEdit(self._settings.minimum_file_size) + self._min_size.setPlaceholderText(tr("settings-min-file-size-hint")) + self._min_size.textChanged.connect( + lambda t: setattr(self._settings, 'minimum_file_size', t) + ) + layout.addRow(tr("settings-min-file-size"), self._min_size) + + # Max file size + self._max_size = QLineEdit(self._settings.maximum_file_size) + self._max_size.setPlaceholderText(tr("settings-max-file-size-hint")) + self._max_size.textChanged.connect( + lambda t: setattr(self._settings, 'maximum_file_size', t) + ) + layout.addRow(tr("settings-max-file-size"), self._max_size) + + return widget + + def _create_preview_tab(self) -> QWidget: + widget = QWidget() + layout = QVBoxLayout(widget) + + preview = QCheckBox(tr("settings-show-image-preview")) + preview.setChecked(self._settings.show_image_preview) + preview.toggled.connect( + lambda v: setattr(self._settings, 'show_image_preview', v) + ) + layout.addWidget(preview) + + layout.addStretch() + return widget + + def _browse_cli(self): + path, _ = QFileDialog.getOpenFileName( + self, tr("settings-select-cli-binary"), "", + "Executables (*);;All Files (*)" + ) + if path: + self._cli_path.setText(path) + + def _add_included(self): + path = QFileDialog.getExistingDirectory(self, tr("settings-select-dir-include")) + if path and path not in self._settings.included_paths: + self._settings.included_paths.append(path) + self._inc_list.addItem(path) + self.settings_changed.emit() + + def _remove_included(self): + row = self._inc_list.currentRow() + if row >= 0: + self._inc_list.takeItem(row) + self._settings.included_paths.pop(row) + self.settings_changed.emit() + + def _add_excluded(self): + path = QFileDialog.getExistingDirectory(self, tr("settings-select-dir-exclude")) + if path and path not in self._settings.excluded_paths: + self._settings.excluded_paths.append(path) + self._exc_list.addItem(path) + self.settings_changed.emit() + + def _remove_excluded(self): + row = self._exc_list.currentRow() + if row >= 0: + self._exc_list.takeItem(row) + self._settings.excluded_paths.pop(row) + self.settings_changed.emit() From 58793ef22d436a5aa8a1b6b6578286129c7c3c50 Mon Sep 17 00:00:00 2001 From: Emanuele Cannizzaro Date: Wed, 25 Mar 2026 00:56:48 +0100 Subject: [PATCH 47/49] Rename czkawka_pyside6 to kalka, add i18n, and optimize core performance - Rename PySide6 frontend from czkawka_pyside6 to kalka with i18n support (20 languages) - Optimize duplicate finder: use HashMap over BTreeMap, dynamic rayon parallelism - Optimize similar images: reduce cloning with swap_remove, dynamic chunk sizes - Optimize dir traversal: scale max_len with available CPU cores Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 1 + README.md | 68 +- czkawka_core/src/common/dir_traversal.rs | 3 +- czkawka_core/src/tools/duplicate/core.rs | 26 +- czkawka_core/src/tools/similar_images/core.rs | 73 +- czkawka_pyside6/README.md | 176 ----- czkawka_pyside6/app/backend.py | 630 ----------------- czkawka_pyside6/app/bottom_panel.py | 214 ------ czkawka_pyside6/app/dialogs/save_dialog.py | 178 ----- czkawka_pyside6/app/dialogs/select_dialog.py | 48 -- czkawka_pyside6/app/main_window.py | 636 ------------------ czkawka_pyside6/app/models.py | 306 --------- czkawka_pyside6/app/preview_panel.py | 112 --- czkawka_pyside6/app/results_view.py | 507 -------------- czkawka_pyside6/app/settings_panel.py | 264 -------- czkawka_pyside6/app/tool_settings.py | 506 -------------- ...esktop => com.github.qarmin.kalka.desktop} | 6 +- ...l => com.github.qarmin.kalka.metainfo.xml} | 8 +- kalka/CONTRIBUTING.md | 264 ++++++++ kalka/DEVELOPMENT_PLAN.md | 287 ++++++++ kalka/README.md | 115 ++++ {czkawka_pyside6 => kalka}/app/__init__.py | 0 .../app/action_buttons.py | 29 +- .../app/dialogs/__init__.py | 1 - .../app/dialogs/about_dialog.py | 26 +- .../app/dialogs/delete_dialog.py | 22 +- .../app/dialogs/move_dialog.py | 25 +- .../app/dialogs/rename_dialog.py | 10 +- .../app/dialogs/sort_dialog.py | 8 +- {czkawka_pyside6 => kalka}/app/icons.py | 3 +- {czkawka_pyside6 => kalka}/app/left_panel.py | 30 +- kalka/app/localizer.py | 110 +++ .../app/progress_widget.py | 118 ++-- {czkawka_pyside6 => kalka}/app/state.py | 8 - kalka/i18n.toml | 4 + kalka/i18n/ar/kalka.ftl | 338 ++++++++++ kalka/i18n/bg/kalka.ftl | 338 ++++++++++ kalka/i18n/cs/kalka.ftl | 338 ++++++++++ kalka/i18n/de/kalka.ftl | 338 ++++++++++ kalka/i18n/el/kalka.ftl | 338 ++++++++++ kalka/i18n/en/kalka.ftl | 338 ++++++++++ kalka/i18n/es-ES/kalka.ftl | 338 ++++++++++ kalka/i18n/fa/kalka.ftl | 338 ++++++++++ kalka/i18n/fr/kalka.ftl | 338 ++++++++++ kalka/i18n/it/kalka.ftl | 338 ++++++++++ kalka/i18n/ja/kalka.ftl | 338 ++++++++++ kalka/i18n/ko/kalka.ftl | 338 ++++++++++ kalka/i18n/nl/kalka.ftl | 338 ++++++++++ kalka/i18n/no/kalka.ftl | 338 ++++++++++ kalka/i18n/pl/kalka.ftl | 338 ++++++++++ kalka/i18n/pt-BR/kalka.ftl | 338 ++++++++++ kalka/i18n/pt-PT/kalka.ftl | 338 ++++++++++ kalka/i18n/ro/kalka.ftl | 338 ++++++++++ kalka/i18n/ru/kalka.ftl | 338 ++++++++++ kalka/i18n/sv-SE/kalka.ftl | 338 ++++++++++ kalka/i18n/tr/kalka.ftl | 338 ++++++++++ kalka/i18n/uk/kalka.ftl | 338 ++++++++++ kalka/i18n/zh-CN/kalka.ftl | 338 ++++++++++ kalka/i18n/zh-TW/kalka.ftl | 338 ++++++++++ kalka/icons/kalka.png | Bin 0 -> 4284637 bytes kalka/icons/kalka2.png | Bin 0 -> 2753857 bytes {czkawka_pyside6 => kalka}/main.py | 12 +- 62 files changed, 9156 insertions(+), 3790 deletions(-) delete mode 100644 czkawka_pyside6/README.md delete mode 100644 czkawka_pyside6/app/backend.py delete mode 100644 czkawka_pyside6/app/bottom_panel.py delete mode 100644 czkawka_pyside6/app/dialogs/save_dialog.py delete mode 100644 czkawka_pyside6/app/dialogs/select_dialog.py delete mode 100644 czkawka_pyside6/app/main_window.py delete mode 100644 czkawka_pyside6/app/models.py delete mode 100644 czkawka_pyside6/app/preview_panel.py delete mode 100644 czkawka_pyside6/app/results_view.py delete mode 100644 czkawka_pyside6/app/settings_panel.py delete mode 100644 czkawka_pyside6/app/tool_settings.py rename data/{com.github.qarmin.czkawka-pyside6.desktop => com.github.qarmin.kalka.desktop} (81%) rename data/{com.github.qarmin.czkawka-pyside6.metainfo.xml => com.github.qarmin.kalka.metainfo.xml} (78%) create mode 100644 kalka/CONTRIBUTING.md create mode 100644 kalka/DEVELOPMENT_PLAN.md create mode 100644 kalka/README.md rename {czkawka_pyside6 => kalka}/app/__init__.py (100%) rename {czkawka_pyside6 => kalka}/app/action_buttons.py (84%) rename {czkawka_pyside6 => kalka}/app/dialogs/__init__.py (88%) rename {czkawka_pyside6 => kalka}/app/dialogs/about_dialog.py (64%) rename {czkawka_pyside6 => kalka}/app/dialogs/delete_dialog.py (67%) rename {czkawka_pyside6 => kalka}/app/dialogs/move_dialog.py (65%) rename {czkawka_pyside6 => kalka}/app/dialogs/rename_dialog.py (71%) rename {czkawka_pyside6 => kalka}/app/dialogs/sort_dialog.py (84%) rename {czkawka_pyside6 => kalka}/app/icons.py (99%) rename {czkawka_pyside6 => kalka}/app/left_panel.py (81%) create mode 100644 kalka/app/localizer.py rename {czkawka_pyside6 => kalka}/app/progress_widget.py (76%) rename {czkawka_pyside6 => kalka}/app/state.py (91%) create mode 100644 kalka/i18n.toml create mode 100644 kalka/i18n/ar/kalka.ftl create mode 100644 kalka/i18n/bg/kalka.ftl create mode 100644 kalka/i18n/cs/kalka.ftl create mode 100644 kalka/i18n/de/kalka.ftl create mode 100644 kalka/i18n/el/kalka.ftl create mode 100644 kalka/i18n/en/kalka.ftl create mode 100644 kalka/i18n/es-ES/kalka.ftl create mode 100644 kalka/i18n/fa/kalka.ftl create mode 100644 kalka/i18n/fr/kalka.ftl create mode 100644 kalka/i18n/it/kalka.ftl create mode 100644 kalka/i18n/ja/kalka.ftl create mode 100644 kalka/i18n/ko/kalka.ftl create mode 100644 kalka/i18n/nl/kalka.ftl create mode 100644 kalka/i18n/no/kalka.ftl create mode 100644 kalka/i18n/pl/kalka.ftl create mode 100644 kalka/i18n/pt-BR/kalka.ftl create mode 100644 kalka/i18n/pt-PT/kalka.ftl create mode 100644 kalka/i18n/ro/kalka.ftl create mode 100644 kalka/i18n/ru/kalka.ftl create mode 100644 kalka/i18n/sv-SE/kalka.ftl create mode 100644 kalka/i18n/tr/kalka.ftl create mode 100644 kalka/i18n/uk/kalka.ftl create mode 100644 kalka/i18n/zh-CN/kalka.ftl create mode 100644 kalka/i18n/zh-TW/kalka.ftl create mode 100644 kalka/icons/kalka.png create mode 100644 kalka/icons/kalka2.png rename {czkawka_pyside6 => kalka}/main.py (80%) diff --git a/Cargo.lock b/Cargo.lock index 7e36a7f45..c5e9414e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1701,6 +1701,7 @@ dependencies = [ "serde", "serde_json", "static_assertions", + "strsim 0.11.1", "symphonia", "tempfile", "tokio", diff --git a/README.md b/README.md index 5a685bbf7..59af67209 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ - **Cache support** - second and further scans should be much faster than the first one - **Easy to run, easy to compile** - minimal runtime and build dependencies, portable version available - **CLI frontend** - for easy automation -- **GUI frontends** - Slint (Krokiet), GTK 4 (Czkawka GUI), and PySide6/Qt (Czkawka PySide6) +- **GUI frontends** - Slint (Krokiet), GTK 4 (Czkawka GUI), and PySide6/Qt (Kalka) - **Core library** - allows to reuse functionality in other apps - **Android app** - experimental touch-friendly frontend for Android devices - **No spying** - Czkawka does not have access to the Internet, nor does it collect any user information or statistics @@ -59,7 +59,7 @@ Each tool uses different technologies, so you can find instructions for each of - [Krokiet GUI (Slint frontend)](krokiet/README.md)
- [Czkawka GUI (GTK frontend)](czkawka_gui/README.md)
-- [Czkawka PySide6 (Qt/PySide6 frontend, KDE6 compliant)](czkawka_pyside6/README.md)
+- [Kalka (Qt/PySide6 frontend)](kalka/README.md)
- [Czkawka CLI](czkawka_cli/README.md)
- [Czkawka Core](czkawka_core/README.md)
- [Cedinia](cedinia/README.md)
@@ -69,37 +69,37 @@ Each tool uses different technologies, so you can find instructions for each of In this comparison remember, that even if app have same features they may work different(e.g. one app may have more options to choose than other). -| | Krokiet | Czkawka PySide6 | Czkawka | FSlint | DupeGuru | Bleachbit | -|:-------------------------:|:-----------:|:----------------:|:----------------:|:------:|:-----------------:|:-----------:| -| Language | Rust | Python | Rust | Python | Python/Obj-C | Python | -| Framework base language | Rust | C++ | C | C | C/C++/Obj-C/Swift | C | -| Framework | Slint | PySide6 (Qt 6) | GTK 4 | PyGTK2 | Qt 5 (PyQt)/Cocoa | PyGTK3 | -| OS | Lin,Mac,Win | Lin,Mac,Win | Lin,Mac,Win | Lin | Lin,Mac,Win | Lin,Mac,Win | -| Duplicate finder | ✔ | ✔ | ✔ | ✔ | ✔ | | -| Empty files | ✔ | ✔ | ✔ | ✔ | | | -| Empty folders | ✔ | ✔ | ✔ | ✔ | | | -| Temporary files | ✔ | ✔ | ✔ | ✔ | | ✔ | -| Big files | ✔ | ✔ | ✔ | | | | -| Similar images | ✔ | ✔ | ✔ | | ✔ | | -| Similar videos | ✔ | ✔ | ✔ | | | | -| Music duplicates(tags) | ✔ | ✔ | ✔ | | ✔ | | -| Music duplicates(content) | ✔ | ✔ | ✔ | | | | -| Invalid symlinks | ✔ | ✔ | ✔ | ✔ | | | -| Broken files | ✔ | ✔ | ✔ | | | | -| Invalid names/extensions | ✔ | ✔ | ✔ | ✔ | | | -| Exif cleaner | ✔ | ✔ | | | | | -| Video optimizer | ✔ | ✔ | | | | | -| Bad Names | ✔ | ✔ | | | | | -| Names conflict | | | | ✔ | | | -| Installed packages | | | | ✔ | | | -| Bad ID | | | | ✔ | | | -| Non stripped binaries | | | | ✔ | | | -| Redundant whitespace | | | | ✔ | | | -| Overwriting files | | | | ✔ | | ✔ | -| Portable version | ✔ | ✔ | ✔ | | | ✔ | -| Multiple languages | ✔ | | ✔ | ✔ | ✔ | ✔ | -| Cache support | ✔ | ✔ | ✔ | | ✔ | | -| In active development | Yes | Yes | Yes** | No | No* | Yes | +| | Krokiet | Kalka | Czkawka | Cedinia | FSlint | DupeGuru | Bleachbit | +|:-------------------------:|:-----------:|:----------------:|:----------------:|:-------:|:------:|:-----------------:|:-----------:| +| Language | Rust | Python | Rust | Rust | Python | Python/Obj-C | Python | +| Framework base language | Rust | C++ | C | Rust | C | C/C++/Obj-C/Swift | C | +| Framework | Slint | PySide6 (Qt 6) | GTK 4 | Slint | PyGTK2 | Qt 5 (PyQt)/Cocoa | PyGTK3 | +| OS | Lin,Mac,Win | Lin,Mac,Win | Lin,Mac,Win | Android | Lin | Lin,Mac,Win | Lin,Mac,Win | +| Duplicate finder | ✔ | ✔ | ✔ | ✔ | ✔ | ✔ | | +| Empty files | ✔ | ✔ | ✔ | ✔ | ✔ | | | +| Empty folders | ✔ | ✔ | ✔ | ✔ | ✔ | | | +| Temporary files | ✔ | ✔ | ✔ | ✔ | ✔ | | ✔ | +| Big files | ✔ | ✔ | ✔ | ✔ | | | | +| Similar images | ✔ | ✔ | ✔ | ✔ | | ✔ | | +| Similar videos | ✔ | ✔ | ✔ | | | | | +| Music duplicates(tags) | ✔ | ✔ | ✔ | ✔ | | ✔ | | +| Music duplicates(content) | ✔ | ✔ | ✔ | ✔ | | | | +| Invalid symlinks | ✔ | ✔ | ✔ | ✔ | ✔ | | | +| Broken files | ✔ | ✔ | ✔ | ✔ | | | | +| Invalid names/extensions | ✔ | ✔ | ✔ | ✔ | ✔ | | | +| Exif cleaner | ✔ | ✔ | | ✔ | | | | +| Video optimizer | ✔ | ✔ | | | | | | +| Bad Names | ✔ | ✔ | | ✔ | | | | +| Names conflict | | | | | ✔ | | | +| Installed packages | | | | | ✔ | | | +| Bad ID | | | | | ✔ | | | +| Non stripped binaries | | | | | ✔ | | | +| Redundant whitespace | | | | | ✔ | | | +| Overwriting files | | | | | ✔ | | ✔ | +| Portable version | ✔ | ✔ | ✔ | | | | ✔ | +| Multiple languages | ✔ | | ✔ | ✔ | ✔ | ✔ | ✔ | +| Cache support | ✔ | ✔ | ✔ | ✔ | | ✔ | | +| In active development | Yes | Yes | Yes** | Yes*** | No | No* | Yes |

* Few small commits added recently and last version released in 2023

** Czkawka GTK is in maintenance mode receiving only bugfixes

@@ -132,7 +132,7 @@ console apps, then take a look at these: Czkawka exposes its common functionality through a crate called **`czkawka_core`**, which can be reused by other projects. It is written in Rust and is used by all Czkawka frontends (`czkawka_gui`, `czkawka_cli`, `krokiet`, `cedinia`). -The `czkawka_pyside6` frontend uses `czkawka_cli` as its backend, communicating via JSON output and `--json-progress` for real-time progress data. +The `kalka` frontend uses `czkawka_cli` as its backend, communicating via JSON output and `--json-progress` for real-time progress data. It is also used by external projects, such as: diff --git a/czkawka_core/src/common/dir_traversal.rs b/czkawka_core/src/common/dir_traversal.rs index f6498c887..2c2ffb072 100644 --- a/czkawka_core/src/common/dir_traversal.rs +++ b/czkawka_core/src/common/dir_traversal.rs @@ -281,9 +281,10 @@ where return DirTraversalResult::Stopped; } + let dir_max_len = std::thread::available_parallelism().map_or(2, |p| (p.get() / 2).max(2)); let segments: Vec<_> = folders_to_check .into_par_iter() - .with_max_len(2) // Avoiding checking too many folders in batch + .with_max_len(dir_max_len) .map(|current_folder| { let mut dir_result = Vec::new(); let mut warnings = Vec::new(); diff --git a/czkawka_core/src/tools/duplicate/core.rs b/czkawka_core/src/tools/duplicate/core.rs index 3115eb7bf..14a4e5cee 100644 --- a/czkawka_core/src/tools/duplicate/core.rs +++ b/czkawka_core/src/tools/duplicate/core.rs @@ -104,7 +104,7 @@ impl DuplicateFinder { }) .collect::)>>(); for (fe, vec_fe) in vec { - self.files_with_identical_names_referenced.insert(fe.path.to_string_lossy().to_string(), (fe, vec_fe)); + self.files_with_identical_names_referenced.insert(fe.path.to_string_lossy().into_owned(), (fe, vec_fe)); } } self.calculate_name_stats(); @@ -323,7 +323,7 @@ impl DuplicateFinder { .collect::)>>(); for (fe, vec_fe) in vec { self.files_with_identical_size_names_referenced - .insert((fe.size, fe.path.to_string_lossy().to_string()), (fe, vec_fe)); + .insert((fe.size, fe.path.to_string_lossy().into_owned()), (fe, vec_fe)); } } self.calculate_size_name_stats(); @@ -492,7 +492,11 @@ impl DuplicateFinder { } #[fun_time(message = "prehash_save_cache_at_exit", level = "debug")] - fn prehash_save_cache_at_exit(&mut self, loaded_hash_map: BTreeMap>, pre_hash_results: Vec<(u64, HashMap>, Vec)>) { + fn prehash_save_cache_at_exit( + &mut self, + loaded_hash_map: BTreeMap>, + pre_hash_results: Vec<(u64, HashMap>, Vec)>, + ) { if self.get_params().use_prehash_cache { // All results = records already cached + computed results let mut save_cache_to_hashmap: HashMap = Default::default(); @@ -500,7 +504,7 @@ impl DuplicateFinder { for (size, vec_file_entry) in loaded_hash_map { if size >= self.get_params().minimal_prehash_cache_file_size { for file_entry in vec_file_entry { - save_cache_to_hashmap.insert(file_entry.path.to_string_lossy().to_string(), file_entry); + save_cache_to_hashmap.insert(file_entry.path.to_string_lossy().into_owned(), file_entry); } } } @@ -509,7 +513,7 @@ impl DuplicateFinder { if size >= self.get_params().minimal_prehash_cache_file_size { for vec_file_entry in hash_map.into_values() { for file_entry in vec_file_entry { - save_cache_to_hashmap.insert(file_entry.path.to_string_lossy().to_string(), file_entry); + save_cache_to_hashmap.insert(file_entry.path.to_string_lossy().into_owned(), file_entry); } } } @@ -560,10 +564,11 @@ impl DuplicateFinder { let non_cached_files_to_check: Vec<(u64, Vec)> = non_cached_files_to_check.into_iter().collect(); debug!("Starting calculating prehash"); + let rayon_max_len = std::thread::available_parallelism().map_or(3, |p| p.get().max(3)); #[expect(clippy::type_complexity)] let pre_hash_results: Vec<(u64, HashMap>, Vec)> = non_cached_files_to_check .into_par_iter() - .with_max_len(3) // Vectors and HashMaps for really big inputs, leave some jobs to 0 thread, to avoid that I minimized max tasks for each thread to 3, which improved performance + .with_max_len(rayon_max_len) .map(|(size, vec_file_entry)| { let mut hashmap_with_hash: HashMap> = Default::default(); let mut errors: Vec = Vec::new(); @@ -738,13 +743,13 @@ impl DuplicateFinder { let mut all_results: HashMap = Default::default(); for (_size, vec_file_entry) in loaded_hash_map { for file_entry in vec_file_entry { - all_results.insert(file_entry.path.to_string_lossy().to_string(), file_entry); + all_results.insert(file_entry.path.to_string_lossy().into_owned(), file_entry); } } - for (_size, hashmap, _errors) in full_hash_results { + for (_size, hashmap, _errors) in full_hash_results.iter() { for vec_file_entry in hashmap.values() { for file_entry in vec_file_entry { - all_results.insert(file_entry.path.to_string_lossy().to_string(), file_entry.clone()); + all_results.insert(file_entry.path.to_string_lossy().into_owned(), file_entry.clone()); } } } @@ -793,9 +798,10 @@ impl DuplicateFinder { "Starting full hashing of {} files", non_cached_files_to_check.iter().map(|(_size, v)| v.len() as u64).sum::() ); + let rayon_max_len = std::thread::available_parallelism().map_or(3, |p| p.get().max(3)); let mut full_hash_results: Vec<(u64, HashMap>, Vec)> = non_cached_files_to_check .into_par_iter() - .with_max_len(3) + .with_max_len(rayon_max_len) .map(|(size, vec_file_entry)| { let mut hashmap_with_hash: HashMap> = Default::default(); let mut errors: Vec = Vec::new(); diff --git a/czkawka_core/src/tools/similar_images/core.rs b/czkawka_core/src/tools/similar_images/core.rs index b249d42a4..5a0b9b768 100644 --- a/czkawka_core/src/tools/similar_images/core.rs +++ b/czkawka_core/src/tools/similar_images/core.rs @@ -54,7 +54,7 @@ impl SimilarImages { .into_par_iter() .flat_map(if self.get_hide_hard_links() { |(_, fes)| fes } else { take_1_per_inode }) .map(|fe| { - let fe_str = fe.path.to_string_lossy().to_string(); + let fe_str = fe.path.to_string_lossy().into_owned(); let image_entry = fe.into_images_entry(); (fe_str, image_entry) @@ -185,24 +185,35 @@ impl SimilarImages { .collect(); let mut base_hashes = Vec::new(); // Initial hashes if self.common_data.use_reference_folders { - let mut files_from_referenced_folders: IndexMap> = IndexMap::new(); - let mut normal_files: IndexMap> = IndexMap::new(); + let mut hashes_referenced: IndexSet = IndexSet::new(); + let mut hashes_normal: IndexSet = IndexSet::new(); - all_hashed_images.clone().into_iter().for_each(|(hash, vec_file_entry)| { + for (hash, vec_file_entry) in all_hashed_images { + let mut has_referenced = false; + let mut has_normal = false; for file_entry in vec_file_entry { if is_in_reference_folder(&self.common_data.directories.reference_directories, &file_entry.path) { - files_from_referenced_folders.entry(hash.clone()).or_default().push(file_entry); + has_referenced = true; } else { - normal_files.entry(hash.clone()).or_default().push(file_entry); + has_normal = true; + } + if has_referenced && has_normal { + break; } } - }); + if has_referenced { + hashes_referenced.insert(hash.clone()); + } + if has_normal { + hashes_normal.insert(hash.clone()); + } + } - for hash in normal_files.into_keys() { + for hash in hashes_normal { self.bktree.add(hash); } - for hash in files_from_referenced_folders.into_keys() { + for hash in hashes_referenced { base_hashes.push(hash); } } else { @@ -219,35 +230,37 @@ impl SimilarImages { &self, hashes_parents: IndexMap, hashes_with_multiple_images: &IndexSet, - all_hashed_images: &IndexMap>, + all_hashed_images: &mut IndexMap>, collected_similar_images: &mut IndexMap>, hashes_similarity: IndexMap, ) { - // Collecting results to vector + // Collecting results to vector - use swap_remove to move data instead of cloning for (parent_hash, child_number) in hashes_parents { // If hash contains other hasher OR multiple images are available for checked hash if child_number > 0 || hashes_with_multiple_images.contains(&parent_hash) { - let vec_fe = all_hashed_images[&parent_hash].clone(); - collected_similar_images.insert(parent_hash.clone(), vec_fe); + if let Some(vec_fe) = all_hashed_images.swap_remove(&parent_hash) { + collected_similar_images.insert(parent_hash, vec_fe); + } } } for (child_hash, (parent_hash, similarity)) in hashes_similarity { - let mut vec_fe = all_hashed_images[&child_hash].clone(); - for fe in &mut vec_fe { - fe.difference = similarity; + if let Some(mut vec_fe) = all_hashed_images.swap_remove(&child_hash) { + for fe in &mut vec_fe { + fe.difference = similarity; + } + collected_similar_images + .get_mut(&parent_hash) + .expect("Cannot find parent hash - this should be added in previous step") + .append(&mut vec_fe); } - collected_similar_images - .get_mut(&parent_hash) - .expect("Cannot find parent hash - this should be added in previous step") - .append(&mut vec_fe); } } #[fun_time(message = "compare_hashes_with_non_zero_tolerance", level = "debug")] fn compare_hashes_with_non_zero_tolerance( &mut self, - all_hashed_images: &IndexMap>, + all_hashed_images: &mut IndexMap>, collected_similar_images: &mut IndexMap>, progress_sender: Option<&Sender>, stop_flag: &Arc, @@ -265,7 +278,11 @@ impl SimilarImages { // Without chunks, every single hash would be compared to every other hash and generate really big amount of results // With chunks we can save results to variables and later use such variables, to skip ones with too big difference // Not really helpful, when not finding almost any duplicates, but with bigger amount of them, this should help a lot - let base_hashes_chunks = base_hashes.chunks(1000); + let chunk_size = { + let num_cores = std::thread::available_parallelism().map_or(4, |p| p.get()); + (base_hashes.len() / (num_cores * 4)).clamp(1000, 10000) + }; + let base_hashes_chunks = base_hashes.chunks(chunk_size); for chunk in base_hashes_chunks { let partial_results = chunk .into_par_iter() @@ -417,7 +434,7 @@ impl SimilarImages { // Results let mut collected_similar_images: IndexMap> = Default::default(); - let all_hashed_images = mem::take(&mut self.image_hashes); + let mut all_hashed_images = mem::take(&mut self.image_hashes); // Checking entries with tolerance 0 is really easy and fast, because only entries with same hashes needs to be checked if tolerance == 0 { @@ -426,7 +443,7 @@ impl SimilarImages { collected_similar_images.insert(hash, vec_file_entry); } } - } else if self.compare_hashes_with_non_zero_tolerance(&all_hashed_images, &mut collected_similar_images, progress_sender, stop_flag, tolerance) == WorkContinueStatus::Stop + } else if self.compare_hashes_with_non_zero_tolerance(&mut all_hashed_images, &mut collected_similar_images, progress_sender, stop_flag, tolerance) == WorkContinueStatus::Stop { return WorkContinueStatus::Stop; } @@ -519,7 +536,7 @@ impl SimilarImages { continue; } for file_entry in vec_file_entry { - let st = file_entry.path.to_string_lossy().to_string(); + let st = file_entry.path.to_string_lossy().into_owned(); if result_hashset.contains(&st) { found = true; error!("Duplicated Element {st}"); @@ -644,7 +661,7 @@ fn debug_check_for_duplicated_things( hashmap_hashes.insert((*hash).clone()); for i in &all_hashed_images[hash] { - let name = i.path.to_string_lossy().to_string(); + let name = i.path.to_string_lossy().into_owned(); if hashmap_names.contains(&name) { debug!("------1--NAME--{numm} {name:?}"); found_broken_thing = true; @@ -661,7 +678,7 @@ fn debug_check_for_duplicated_things( hashmap_hashes.insert((*hash).clone()); for i in &all_hashed_images[hash] { - let name = i.path.to_string_lossy().to_string(); + let name = i.path.to_string_lossy().into_owned(); if hashmap_names.contains(&name) { debug!("------2--NAME--{numm} {name:?}"); found_broken_thing = true; @@ -951,7 +968,7 @@ mod tests { similar_images.find_similar_hashes(&Arc::default(), None); let res = similar_images.get_similar_images(); assert_eq!(res.len(), 1); - let mut path = res[0].iter().map(|e| e.path.to_string_lossy().to_string()).collect::>(); + let mut path = res[0].iter().map(|e| e.path.to_string_lossy().into_owned()).collect::>(); path.sort(); if res[0].len() == 3 { assert_eq!(path, vec!["abc.txt".to_string(), "bcd.txt".to_string(), "rrd.txt".to_string()]); diff --git a/czkawka_pyside6/README.md b/czkawka_pyside6/README.md deleted file mode 100644 index 0366753e1..000000000 --- a/czkawka_pyside6/README.md +++ /dev/null @@ -1,176 +0,0 @@ -# Czkawka PySide6 - -A Qt 6 / PySide6 GUI frontend for Czkawka, with feature parity with the Krokiet (Slint) interface. KDE6/Plasma compliant (14/14 checks passed). - -This frontend uses `czkawka_cli` as its backend, communicating via JSON output for results and `--json-progress` for real-time progress data. - -## Features - -All 14 scanning tools are supported: - -- Duplicate Files (by hash, size, name, or size+name) -- Empty Folders / Empty Files -- Big Files (biggest or smallest) -- Temporary Files -- Similar Images (with configurable hash algorithm, size, similarity) -- Similar Videos (with crop detection, skip forward, duration) -- Similar Music (by tags or audio fingerprint) -- Invalid Symlinks -- Broken Files (audio, PDF, archive, image, video) -- Bad Extensions -- Bad Names (uppercase, emoji, spaces, non-ASCII, restricted charset) -- EXIF Remover -- Video Optimizer (crop black bars / transcode) - -### GUI features - -- **KDE6/Plasma compliant** - inherits system theme (Breeze, Adwaita, etc.), XDG icons via `QIcon.fromTheme()`, `QStandardPaths`, `.desktop` file, AppStream metadata -- **Two-bar progress display** - current stage + overall progress with stage index (e.g., "[3/7] Calculating prehashes") - - Real-time entry and byte counts (e.g., "50,000/94,500 (308 MB/371 MB)") - - File collection estimation using cached counts from previous scans - - Elapsed time display -- **Resizable and sortable columns** - drag header edges to resize, click to sort (ascending/descending toggle), numeric columns sort by value -- **Filter/search bar** - filter results by filename or path in real-time -- **Column visibility toggle** - right-click column header to show/hide columns -- **Keyboard shortcuts**: - - `Ctrl+S` / `F5` — Start scan - - `Escape` — Stop scan - - `Ctrl+A` — Select all - - `Ctrl+I` — Invert selection - - `Ctrl+D` — Delete selected - - `Ctrl+M` — Move selected - - `Ctrl+Shift+S` — Save results - - `Ctrl+L` — Load results -- **Drag-and-drop** - drop directories onto the bottom panel to add include paths -- **System tray** - minimize to tray, scan completion notifications -- **Scan history** - persistent log of past scans with timestamp, tool, entries found, duration -- **Scan queue** - queue multiple scan types and run them sequentially -- **Diff view** - side-by-side file comparison for duplicates (right-click two selected files) -- **Image preview panel** for duplicate/similar image results -- **Grouped results view** with spanning group headers for duplicate/similar file groups -- **Selection modes** - Select All/None, Invert, Biggest/Smallest, Newest/Oldest, Shortest/Longest Path -- **File actions** - Delete (with trash support), Move/Copy, Hardlink, Symlink, Rename, Clean EXIF -- **Save/Load results** - save as JSON, text, or CSV with task-specific filenames; load previously saved results (supports both app and raw CLI JSON formats) -- **Per-tool settings** - all tool-specific options (hash type, similarity thresholds, etc.) -- **Global settings** - directories, filters, cache, thread count -- **Directory management** - included/excluded paths with add/remove buttons and drag-and-drop -- **Context menus** - right-click to open file, open folder, select/deselect, compare -- **Settings persistence** - window geometry, splitter positions, and settings saved via JSON -- **Auto-detection** of `czkawka_cli` binary (checks PATH, cargo target directory, cargo metadata) - -## Requirements - -- Python 3.10+ -- PySide6 >= 6.6.0 -- `czkawka_cli` binary (installed or in PATH) -- Optional: `send2trash` (for trash support on Linux) -- Optional: `Pillow` (for EXIF cleaning fallback) - -## Installation - -### 1. Install czkawka_cli - -```shell -# From the project root -cargo install --path czkawka_cli - -# Or build it -cargo build --release -p czkawka_cli -``` - -### 2. Install Python dependencies - -```shell -cd czkawka_pyside6 -pip install -r requirements.txt -``` - -### 3. Run - -```shell -python main.py -``` - -The application will auto-detect the `czkawka_cli` binary. If it can't find it, configure the path in Settings. - -## Testing - -The project includes a comprehensive test suite (98 tests): - -```shell -cd czkawka_pyside6 -pip install pytest -QT_QPA_PLATFORM=offscreen python -m pytest tests/ -v -``` - -### Test coverage - -| Test file | Tests | Coverage | -|---|---|---| -| `test_models.py` | 16 | Enums, data models, settings defaults, column definitions | -| `test_backend.py` | 25 | CLI command building (all 14 tools), JSON parsing (flat/grouped/empty), formatters | -| `test_widgets.py` | 24 | ResultsView (selection, sorting, filtering), LeftPanel, ActionButtons, ProgressWidget | -| `test_new_features.py` | 20 | ScanHistory, ScanQueue, SaveLoad roundtrip, DiffDialog | -| `test_main_window.py` | 13 | Integration: window creation, all tabs, state, features presence | -| **Total** | **98** | **All pass** | - -## Architecture - -``` -czkawka_pyside6/ -├── main.py # Entry point -├── requirements.txt # Python dependencies -├── tests/ # Test suite (98 tests) -│ ├── conftest.py # Qt app fixture -│ ├── test_models.py # Data model tests -│ ├── test_backend.py # CLI interface tests -│ ├── test_widgets.py # Widget component tests -│ ├── test_new_features.py # Feature-specific tests -│ └── test_main_window.py # Integration tests -├── app/ -│ ├── main_window.py # Main window with all panels -│ ├── left_panel.py # Tool selection sidebar (14 tools) -│ ├── results_view.py # Results tree with grouping, selection, sorting, filtering -│ ├── action_buttons.py # Scan/Stop/Delete/Move/Save/Load/Sort buttons with icons -│ ├── tool_settings.py # Per-tool settings (9 tool panels) -│ ├── settings_panel.py # Global settings (General/Directories/Filters/Preview) -│ ├── progress_widget.py # Two-bar progress: current stage + overall -│ ├── preview_panel.py # Image preview panel -│ ├── bottom_panel.py # Directory management + error display (with drag-and-drop) -│ ├── backend.py # CLI subprocess interface with JSON progress parsing -│ ├── models.py # Data models, enums, column definitions -│ ├── state.py # Application state with Qt signals, geometry persistence -│ ├── icons.py # XDG theme icons with SVG fallbacks from Krokiet -│ ├── system_tray.py # System tray integration with notifications -│ ├── scan_history.py # Persistent scan history log -│ ├── scan_queue.py # Multi-tab sequential scan queue -│ └── dialogs/ # Delete, Move, Select, Sort, Save/Load, Rename, About, Diff -``` - -### How it works - -1. **Scanning**: The app spawns `czkawka_cli` as a subprocess with `--compact-file-to-save` for JSON results and `--json-progress` for real-time progress data on stderr. - -2. **Progress**: JSON lines on stderr provide `ProgressData` with stage index, entry counts, byte counts — the same data the Slint frontend gets via crossbeam channels. The progress widget displays two bars (current stage and overall) with percentage, counts, and elapsed time. - -3. **Results**: JSON results are parsed and displayed in a tree view with spanning group headers for duplicate/similar file tools. Columns are resizable and sortable (click header), with a filter bar for real-time search. - -4. **File operations**: Delete, move, hardlink, symlink, and rename operations are performed directly in Python. EXIF cleaning and extension/name fixing use `czkawka_cli` subcommands. - -5. **Persistence**: Window geometry, scan estimates, scan history, and settings are saved via `QStandardPaths` for XDG compliance. - -### KDE6/Plasma Compliance - -The app passes all 14 KDE compliance checks: - -- System theme inherited (no color overrides) -- `QIcon.fromTheme()` with standard XDG icon names + SVG fallbacks -- `.desktop` file and AppStream `metainfo.xml` -- `QStandardPaths` for config/cache paths -- `desktopFileName` and `organizationDomain` set -- System font inherited, HiDPI via Qt6 native support -- System dialog icons via `style().standardIcon()` - -## LICENSE - -MIT diff --git a/czkawka_pyside6/app/backend.py b/czkawka_pyside6/app/backend.py deleted file mode 100644 index 865f6097e..000000000 --- a/czkawka_pyside6/app/backend.py +++ /dev/null @@ -1,630 +0,0 @@ -import json -import os -import subprocess -import tempfile -from pathlib import Path -from typing import Optional - -from PySide6.QtCore import QObject, QThread, Signal - -from .models import ( - ActiveTab, AppSettings, ToolSettings, ResultEntry, ScanProgress, - TAB_TO_CLI_COMMAND, GROUPED_TABS, CheckingMethod, MusicSearchMethod, -) - - -class ScanWorker(QObject): - """Worker that runs czkawka_cli in a background thread.""" - finished = Signal(object, list) # (ActiveTab, results) - progress = Signal(object) # ScanProgress - error = Signal(str) - - def __init__(self, tab: ActiveTab, app_settings: AppSettings, - tool_settings: ToolSettings): - super().__init__() - self.tab = tab - self.app_settings = app_settings - self.tool_settings = tool_settings - self._process: Optional[subprocess.Popen] = None - self._cancelled = False - - def run(self): - try: - cmd = self._build_command() - if not cmd: - self.error.emit(f"No CLI command for tab {self.tab}") - return - - self.progress.emit(ScanProgress( - step_name="Collecting files...", current=0, total=0 - )) - - with tempfile.NamedTemporaryFile(suffix=".json", delete=False, mode="w") as f: - json_output_path = f.name - - # Add JSON output flag (use long form to avoid conflict with -C in broken files) - cmd.extend(["--compact-file-to-save", json_output_path]) - # Enable JSON progress on stderr, suppress text output - cmd.extend(["--json-progress", "-N", "-M"]) - - self._process = subprocess.Popen( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - - # Read stderr line-by-line for JSON progress data - self._monitor_process_json(json_output_path) - - if self._cancelled: - self._cleanup(json_output_path) - return - - # Check for CLI errors - returncode = self._process.returncode - if returncode is not None and returncode != 0 and returncode != 11: - error_msg = f"czkawka_cli exited with code {returncode}" - self.error.emit(error_msg) - self._cleanup(json_output_path) - return - - # Parse results - self.progress.emit(ScanProgress( - step_name="Loading results...", current=0, total=0 - )) - results = self._parse_results(json_output_path) - self.finished.emit(self.tab, results) - self._cleanup(json_output_path) - - except FileNotFoundError: - self.error.emit( - f"czkawka_cli not found at '{self.app_settings.czkawka_cli_path}'. " - "Please install czkawka_cli or set the correct path in settings." - ) - except Exception as e: - self.error.emit(str(e)) - - def cancel(self): - self._cancelled = True - if self._process and self._process.poll() is None: - self._process.terminate() - try: - self._process.wait(timeout=3) - except subprocess.TimeoutExpired: - self._process.kill() - self._process.wait() - - def _monitor_process_json(self, json_path: str): - """Read JSON progress lines from stderr in real-time.""" - import time - - while self._process.poll() is None: - if self._cancelled: - return - - line = self._process.stderr.readline() - if not line: - time.sleep(0.05) - continue - - line = line.strip() - if not line: - continue - - try: - data = json.loads(line) - progress = data.get("progress", {}) - stage_name = data.get("stage_name", "Processing...") - - entries_checked = progress.get("entries_checked", 0) - entries_to_check = progress.get("entries_to_check", 0) - bytes_checked = progress.get("bytes_checked", 0) - bytes_to_check = progress.get("bytes_to_check", 0) - current_stage_idx = progress.get("current_stage_idx", 0) - max_stage_idx = progress.get("max_stage_idx", 0) - - self.progress.emit(ScanProgress( - step_name=stage_name, - current=0, - total=0, - current_size=bytes_checked, - stage_name=stage_name, - current_stage_idx=current_stage_idx, - max_stage_idx=max_stage_idx, - entries_checked=entries_checked, - entries_to_check=entries_to_check, - bytes_checked=bytes_checked, - bytes_to_check=bytes_to_check, - )) - except (json.JSONDecodeError, KeyError, TypeError): - continue - - # Drain remaining stderr - remaining = self._process.stderr.read() - if remaining: - for line in remaining.strip().split("\n"): - pass # Final lines already processed - - def _cleanup(self, path: str): - try: - os.unlink(path) - except OSError: - pass - - def _build_command(self) -> list[str]: - s = self.app_settings - ts = self.tool_settings - cli = s.czkawka_cli_path - subcmd = TAB_TO_CLI_COMMAND.get(self.tab) - if not subcmd: - return [] - - # Handle video-optimizer subcommands - if self.tab == ActiveTab.VIDEO_OPTIMIZER: - if ts.video_opt_mode == "crop": - cmd = [cli, "video-optimizer", "crop"] - else: - cmd = [cli, "video-optimizer", "transcode"] - else: - cmd = [cli, subcmd] - - # Common args - if s.included_paths: - cmd.extend(["-d", ",".join(s.included_paths)]) - if s.excluded_paths: - cmd.extend(["-e", ",".join(s.excluded_paths)]) - if s.excluded_items: - cmd.extend(["-E", s.excluded_items]) - if s.allowed_extensions: - cmd.extend(["-x", s.allowed_extensions]) - if s.excluded_extensions: - cmd.extend(["-P", s.excluded_extensions]) - if not s.recursive_search: - cmd.append("-R") - if not s.use_cache: - cmd.append("-H") - if s.thread_number > 0: - cmd.extend(["-T", str(s.thread_number)]) - - # Reference directories (only for grouped tools that support -r) - if s.reference_paths and self.tab in ( - ActiveTab.DUPLICATE_FILES, ActiveTab.SIMILAR_IMAGES, - ActiveTab.SIMILAR_VIDEOS, ActiveTab.SIMILAR_MUSIC, - ): - ref_dirs = [p for p in s.reference_paths if p in s.included_paths] - if ref_dirs: - cmd.extend(["-r", ",".join(ref_dirs)]) - - # Tool-specific args - if self.tab == ActiveTab.DUPLICATE_FILES: - cmd.extend(["-s", ts.dup_check_method.value]) - cmd.extend(["-t", ts.dup_hash_type.value]) - if ts.dup_min_size: - cmd.extend(["-m", ts.dup_min_size]) - if ts.dup_max_size: - cmd.extend(["-i", ts.dup_max_size]) - if ts.dup_name_case_sensitive: - cmd.append("-l") - if not s.hide_hard_links: - cmd.append("-L") - if ts.dup_use_prehash: - cmd.append("-u") - - elif self.tab == ActiveTab.SIMILAR_IMAGES: - cmd.extend(["-g", ts.img_hash_alg.value]) - cmd.extend(["-z", ts.img_filter.value]) - cmd.extend(["-c", str(ts.img_hash_size)]) - cmd.extend(["-s", str(ts.img_max_difference)]) - if ts.img_ignore_same_size: - cmd.append("-J") - - elif self.tab == ActiveTab.SIMILAR_VIDEOS: - cmd.extend(["-t", str(ts.vid_max_difference)]) - cmd.extend(["-U", str(ts.vid_skip_forward)]) - cmd.extend(["-B", ts.vid_crop_detect.value]) - cmd.extend(["-A", str(ts.vid_duration)]) - if ts.vid_ignore_same_size: - cmd.append("-J") - - elif self.tab == ActiveTab.SIMILAR_MUSIC: - cmd.extend(["-s", ts.music_search_method.value]) - if ts.music_search_method == MusicSearchMethod.TAGS: - tags = [] - if ts.music_title: tags.append("track_title") - if ts.music_artist: tags.append("track_artist") - if ts.music_bitrate: tags.append("bitrate") - if ts.music_genre: tags.append("genre") - if ts.music_year: tags.append("year") - if ts.music_length: tags.append("length") - if tags: - cmd.extend(["-z", ",".join(tags)]) - if ts.music_approximate: - cmd.append("-a") - else: - cmd.extend(["-Y", str(ts.music_max_difference)]) - - elif self.tab == ActiveTab.BIG_FILES: - cmd.extend(["-n", str(ts.big_files_number)]) - if ts.big_files_mode == "smallest": - cmd.append("-J") - - elif self.tab == ActiveTab.BROKEN_FILES: - types = [] - if ts.broken_audio: types.append("AUDIO") - if ts.broken_pdf: types.append("PDF") - if ts.broken_archive: types.append("ARCHIVE") - if ts.broken_image: types.append("IMAGE") - if ts.broken_video: types.append("VIDEO") - if types: - cmd.extend(["-C", ",".join(types)]) - - elif self.tab == ActiveTab.BAD_NAMES: - if ts.bad_names_uppercase_ext: - cmd.append("-u") - if ts.bad_names_emoji: - cmd.append("-j") - if ts.bad_names_space: - cmd.append("-w") - if ts.bad_names_non_ascii: - cmd.append("-n") - if ts.bad_names_restricted_charset: - cmd.extend(["-r", ts.bad_names_restricted_charset]) - if ts.bad_names_remove_duplicated: - cmd.append("-a") - - elif self.tab == ActiveTab.VIDEO_OPTIMIZER: - if ts.video_opt_mode == "crop": - cmd.extend(["-m", ts.video_crop_mechanism.value]) - cmd.extend(["-k", str(ts.video_black_pixel_threshold)]) - cmd.extend(["-b", str(ts.video_black_bar_percentage)]) - cmd.extend(["-s", str(ts.video_max_samples)]) - cmd.extend(["-z", str(ts.video_min_crop_size)]) - else: - if ts.video_excluded_codecs: - cmd.extend(["-c", ts.video_excluded_codecs]) - cmd.extend(["--target-codec", ts.video_codec.value]) - cmd.extend(["--quality", str(ts.video_quality)]) - if ts.video_fail_if_bigger: - cmd.append("--fail-if-not-smaller") - - return cmd - - def _parse_results(self, json_path: str) -> list[ResultEntry]: - try: - with open(json_path) as f: - data = json.load(f) - except (json.JSONDecodeError, FileNotFoundError, OSError): - return [] - - if not data: - return [] - - results = [] - is_grouped = self.tab in GROUPED_TABS - - if is_grouped: - # Grouped tools return either: - # dict {size_key: [[entry, ...], [entry, ...]], ...} (duplicates by hash) - # list [[entry, ...], [entry, ...]] (similar images/videos/music) - all_groups = [] - if isinstance(data, dict): - # Dict format: flatten all groups from all size buckets - for size_key, groups_for_size in data.items(): - if isinstance(groups_for_size, list): - for group in groups_for_size: - if isinstance(group, list) and len(group) > 0: - all_groups.append(group) - elif isinstance(data, list): - for item in data: - if isinstance(item, list) and len(item) > 0: - all_groups.append(item) - - for group_id, group in enumerate(all_groups): - # Compute total size for the group header - total_size = sum(e.get("size", 0) for e in group if isinstance(e, dict)) - header_text = ( - f"Group {group_id + 1} ({len(group)} files, " - f"{self._format_size(total_size)})" - ) - header = ResultEntry( - values={"__header": header_text}, - header_row=True, - group_id=group_id, - ) - results.append(header) - for entry in group: - if isinstance(entry, dict): - result = self._parse_entry(entry, group_id) - results.append(result) - else: - # Flat tools return a list of entries - if isinstance(data, list): - for i, entry in enumerate(data): - if isinstance(entry, dict): - result = self._parse_entry(entry, 0) - results.append(result) - elif isinstance(data, dict): - # Some tools might also use dict format - for key, entries in data.items(): - if isinstance(entries, list): - for entry in entries: - if isinstance(entry, dict): - result = self._parse_entry(entry, 0) - results.append(result) - - return results - - def _parse_entry(self, entry: dict, group_id: int) -> ResultEntry: - path = entry.get("path", "") - p = Path(path) - values = { - "File Name": p.name, - "Folder Name": p.name, - "Symlink Name": p.name, - "Path": str(p.parent), - "Symlink Path": str(p.parent), - "Size": self._format_size(entry.get("size", 0)), - "Modification Date": self._format_date(entry.get("modified_date", 0)), - "Hash": entry.get("hash", ""), - "Similarity": str(entry.get("similarity", "")), - "Resolution": entry.get("dimensions", ""), - "Title": entry.get("title", ""), - "Artist": entry.get("artist", ""), - "Year": entry.get("year", ""), - "Bitrate": str(entry.get("bitrate", "")), - "Genre": entry.get("genre", ""), - "Length": entry.get("length", ""), - "Destination Path": entry.get("destination_path", ""), - "Type of Error": entry.get("error_string", entry.get("type_of_error", "")), - "Error Type": entry.get("error_string", entry.get("error_type", "")), - "Current Extension": entry.get("current_extension", ""), - "Proper Extension": entry.get("proper_extension", entry.get("proper_extensions", "")), - "Codec": entry.get("codec", ""), - } - # Store full path for file operations - values["__full_path"] = path - values["__size_bytes"] = entry.get("size", 0) - values["__modified_date_ts"] = entry.get("modified_date", 0) - - return ResultEntry(values=values, group_id=group_id) - - @staticmethod - def _format_size(size_bytes: int) -> str: - if size_bytes == 0: - return "0 B" - units = ["B", "KB", "MB", "GB", "TB"] - i = 0 - size = float(size_bytes) - while size >= 1024 and i < len(units) - 1: - size /= 1024 - i += 1 - return f"{size:.1f} {units[i]}" if i > 0 else f"{int(size)} B" - - @staticmethod - def _format_date(timestamp: int) -> str: - if timestamp == 0: - return "" - from datetime import datetime - try: - return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S") - except (ValueError, OSError): - return str(timestamp) - - -class ScanRunner(QObject): - """Manages scan execution in a background thread.""" - finished = Signal(object, list) - progress = Signal(object) - error = Signal(str) - - def __init__(self, parent=None): - super().__init__(parent) - self._thread: Optional[QThread] = None - self._worker: Optional[ScanWorker] = None - - def start_scan(self, tab: ActiveTab, app_settings: AppSettings, - tool_settings: ToolSettings): - if self._thread and self._thread.isRunning(): - return - - self._thread = QThread() - self._worker = ScanWorker(tab, app_settings, tool_settings) - self._worker.moveToThread(self._thread) - - self._thread.started.connect(self._worker.run) - self._worker.finished.connect(self._on_finished) - self._worker.progress.connect(self.progress.emit) - self._worker.error.connect(self._on_error) - - self._thread.start() - - def stop_scan(self): - if self._worker: - self._worker.cancel() - # Wait briefly for the thread to finish after killing the process - if self._thread and self._thread.isRunning(): - self._thread.quit() - if not self._thread.wait(5000): - self._thread.terminate() - self._thread.wait() - self._thread = None - self._worker = None - # Emit an empty result to signal completion - self.finished.emit(ActiveTab.DUPLICATE_FILES, []) - - def _on_finished(self, tab, results): - self.finished.emit(tab, results) - self._cleanup() - - def _on_error(self, msg): - self.error.emit(msg) - self._cleanup() - - def _cleanup(self): - if self._thread: - self._thread.quit() - self._thread.wait(5000) - self._thread = None - self._worker = None - - -class FileOperations: - """File operations: delete, move, hardlink, symlink, rename.""" - - @staticmethod - def delete_files(entries: list[ResultEntry], move_to_trash: bool = True) -> tuple[int, list[str]]: - deleted = 0 - errors = [] - for entry in entries: - path = entry.values.get("__full_path", "") - if not path: - continue - try: - p = Path(path) - if not p.exists(): - errors.append(f"File not found: {path}") - continue - if move_to_trash: - try: - from send2trash import send2trash - send2trash(str(p)) - except ImportError: - p.unlink() - else: - if p.is_dir(): - import shutil - shutil.rmtree(p) - else: - p.unlink() - deleted += 1 - except OSError as e: - errors.append(f"Error deleting {path}: {e}") - return deleted, errors - - @staticmethod - def move_files(entries: list[ResultEntry], destination: str, - preserve_structure: bool = False, copy_mode: bool = False) -> tuple[int, list[str]]: - import shutil - moved = 0 - errors = [] - dest = Path(destination) - dest.mkdir(parents=True, exist_ok=True) - - for entry in entries: - src_path = entry.values.get("__full_path", "") - if not src_path: - continue - try: - src = Path(src_path) - if preserve_structure: - # Keep relative directory structure - rel = src.parent - target_dir = dest / rel.relative_to(rel.anchor) - target_dir.mkdir(parents=True, exist_ok=True) - target = target_dir / src.name - else: - target = dest / src.name - - # Handle name conflicts - if target.exists(): - stem = target.stem - suffix = target.suffix - counter = 1 - while target.exists(): - target = target.parent / f"{stem}_{counter}{suffix}" - counter += 1 - - if copy_mode: - shutil.copy2(str(src), str(target)) - else: - shutil.move(str(src), str(target)) - moved += 1 - except OSError as e: - errors.append(f"Error moving {src_path}: {e}") - return moved, errors - - @staticmethod - def create_hardlinks(entries: list[ResultEntry], reference_path: str) -> tuple[int, list[str]]: - created = 0 - errors = [] - for entry in entries: - path = entry.values.get("__full_path", "") - if not path or path == reference_path: - continue - try: - p = Path(path) - p.unlink() - os.link(reference_path, str(p)) - created += 1 - except OSError as e: - errors.append(f"Error creating hardlink for {path}: {e}") - return created, errors - - @staticmethod - def create_symlinks(entries: list[ResultEntry], reference_path: str) -> tuple[int, list[str]]: - created = 0 - errors = [] - for entry in entries: - path = entry.values.get("__full_path", "") - if not path or path == reference_path: - continue - try: - p = Path(path) - p.unlink() - os.symlink(reference_path, str(p)) - created += 1 - except OSError as e: - errors.append(f"Error creating symlink for {path}: {e}") - return created, errors - - @staticmethod - def fix_extensions(cli_path: str, app_settings, tool_settings) -> tuple[bool, str]: - """Run czkawka_cli ext with -F flag to fix extensions.""" - cmd = [cli_path, "ext", "-F"] - if app_settings.included_paths: - cmd.extend(["-d", ",".join(app_settings.included_paths)]) - try: - result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) - return True, result.stdout - except Exception as e: - return False, str(e) - - @staticmethod - def fix_bad_names(cli_path: str, app_settings, tool_settings) -> tuple[bool, str]: - """Run czkawka_cli bad-names with -F flag to fix names.""" - cmd = [cli_path, "bad-names", "-F"] - if app_settings.included_paths: - cmd.extend(["-d", ",".join(app_settings.included_paths)]) - try: - result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) - return True, result.stdout - except Exception as e: - return False, str(e) - - @staticmethod - def clean_exif(cli_path: str, entries: list[ResultEntry], - ignored_tags: str = "", overwrite: bool = True) -> tuple[int, list[str]]: - """Remove EXIF data from selected files.""" - cleaned = 0 - errors = [] - for entry in entries: - path = entry.values.get("__full_path", "") - if not path: - continue - try: - from PIL import Image - img = Image.open(path) - data = list(img.getdata()) - clean_img = Image.new(img.mode, img.size) - clean_img.putdata(data) - if overwrite: - clean_img.save(path) - else: - p = Path(path) - new_path = p.parent / f"{p.stem}_clean{p.suffix}" - clean_img.save(str(new_path)) - cleaned += 1 - except Exception as e: - errors.append(f"Error cleaning EXIF for {path}: {e}") - return cleaned, errors diff --git a/czkawka_pyside6/app/bottom_panel.py b/czkawka_pyside6/app/bottom_panel.py deleted file mode 100644 index 460c9e638..000000000 --- a/czkawka_pyside6/app/bottom_panel.py +++ /dev/null @@ -1,214 +0,0 @@ -import os - -from PySide6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QLabel, QListWidget, - QPushButton, QFileDialog, QTextEdit, QStackedWidget, - QTreeWidget, QTreeWidgetItem, QHeaderView -) -from PySide6.QtCore import Signal, Qt - -from .models import AppSettings - - -class BottomPanel(QWidget): - """Bottom panel showing directories or error messages. - - Included directories have a "Ref" checkbox — when checked, that - directory is a reference directory: its files are kept as references - and never selected for deletion in grouped tools (duplicates, similar - images/videos/music). - """ - directories_changed = Signal() - - def __init__(self, settings: AppSettings, parent=None): - super().__init__(parent) - self._settings = settings - self.setMaximumHeight(200) - self.setAcceptDrops(True) - self._setup_ui() - - def _setup_ui(self): - layout = QVBoxLayout(self) - layout.setContentsMargins(4, 2, 4, 2) - - self._stack = QStackedWidget() - - # Page 0: Directories view - dir_widget = QWidget() - dir_layout = QHBoxLayout(dir_widget) - dir_layout.setContentsMargins(0, 0, 0, 0) - - # ── Included directories (with Ref checkbox) ── - inc_widget = QWidget() - inc_layout = QVBoxLayout(inc_widget) - inc_layout.setContentsMargins(0, 0, 0, 0) - inc_layout.addWidget(QLabel("Included Directories:")) - - self._inc_tree = QTreeWidget() - self._inc_tree.setMaximumHeight(120) - self._inc_tree.setHeaderLabels(["Ref", "Path"]) - self._inc_tree.setColumnCount(2) - header = self._inc_tree.header() - header.setSectionResizeMode(0, QHeaderView.ResizeToContents) - header.setSectionResizeMode(1, QHeaderView.Stretch) - self._inc_tree.setRootIsDecorated(False) - self._inc_tree.itemChanged.connect(self._on_ref_toggled) - - self._populate_included() - inc_layout.addWidget(self._inc_tree) - - inc_btns = QHBoxLayout() - add_btn = QPushButton("+") - add_btn.setFixedWidth(30) - add_btn.setToolTip("Add included directory") - add_btn.clicked.connect(self._add_included) - inc_btns.addWidget(add_btn) - rem_btn = QPushButton("-") - rem_btn.setFixedWidth(30) - rem_btn.setToolTip("Remove selected directory") - rem_btn.clicked.connect(self._remove_included) - inc_btns.addWidget(rem_btn) - inc_btns.addStretch() - inc_layout.addLayout(inc_btns) - dir_layout.addWidget(inc_widget) - - # ── Excluded directories ── - exc_widget = QWidget() - exc_layout = QVBoxLayout(exc_widget) - exc_layout.setContentsMargins(0, 0, 0, 0) - exc_layout.addWidget(QLabel("Excluded Directories:")) - - self._exc_list = QListWidget() - self._exc_list.setMaximumHeight(120) - for path in self._settings.excluded_paths: - self._exc_list.addItem(path) - exc_layout.addWidget(self._exc_list) - - exc_btns = QHBoxLayout() - add_exc = QPushButton("+") - add_exc.setFixedWidth(30) - add_exc.setToolTip("Add excluded directory") - add_exc.clicked.connect(self._add_excluded) - exc_btns.addWidget(add_exc) - rem_exc = QPushButton("-") - rem_exc.setFixedWidth(30) - rem_exc.setToolTip("Remove selected directory") - rem_exc.clicked.connect(self._remove_excluded) - exc_btns.addWidget(rem_exc) - exc_btns.addStretch() - exc_layout.addLayout(exc_btns) - dir_layout.addWidget(exc_widget) - - self._stack.addWidget(dir_widget) - - # Page 1: Text errors/info - self._text_area = QTextEdit() - self._text_area.setReadOnly(True) - self._text_area.setMaximumHeight(150) - self._stack.addWidget(self._text_area) - - layout.addWidget(self._stack) - - # ── Included directory helpers ──────────────────────────── - - def _populate_included(self): - """Rebuild the included paths tree from settings.""" - self._inc_tree.blockSignals(True) - self._inc_tree.clear() - for path in self._settings.included_paths: - item = QTreeWidgetItem() - item.setFlags(item.flags() | Qt.ItemIsUserCheckable) - is_ref = path in self._settings.reference_paths - item.setCheckState(0, Qt.Checked if is_ref else Qt.Unchecked) - item.setText(1, path) - item.setToolTip(0, "Check to mark as reference directory.\n" - "Files in reference directories are never selected for deletion.") - self._inc_tree.addTopLevelItem(item) - self._inc_tree.blockSignals(False) - - def _on_ref_toggled(self, item, column): - """Handle Ref checkbox toggle.""" - if column != 0: - return - path = item.text(1) - if item.checkState(0) == Qt.Checked: - self._settings.reference_paths.add(path) - else: - self._settings.reference_paths.discard(path) - self.directories_changed.emit() - - def _add_included(self): - path = QFileDialog.getExistingDirectory(self, "Select Directory to Include") - if path and path not in self._settings.included_paths: - self._settings.included_paths.append(path) - self._populate_included() - self.directories_changed.emit() - - def _remove_included(self): - items = self._inc_tree.selectedItems() - if not items: - # Try current item - item = self._inc_tree.currentItem() - if item: - items = [item] - for item in items: - path = item.text(1) - if path in self._settings.included_paths: - self._settings.included_paths.remove(path) - self._settings.reference_paths.discard(path) - self._populate_included() - self.directories_changed.emit() - - # ── Excluded directory helpers ──────────────────────────── - - def _add_excluded(self): - path = QFileDialog.getExistingDirectory(self, "Select Directory to Exclude") - if path and path not in self._settings.excluded_paths: - self._settings.excluded_paths.append(path) - self._exc_list.addItem(path) - self.directories_changed.emit() - - def _remove_excluded(self): - row = self._exc_list.currentRow() - if row >= 0: - self._exc_list.takeItem(row) - self._settings.excluded_paths.pop(row) - self.directories_changed.emit() - - # ── Public API ──────────────────────────────────────────── - - def show_directories(self): - self._stack.setCurrentIndex(0) - self.setVisible(True) - - def show_text(self): - self._stack.setCurrentIndex(1) - self.setVisible(True) - - def hide_panel(self): - self.setVisible(False) - - def set_text(self, text: str): - self._text_area.setPlainText(text) - - def append_text(self, text: str): - self._text_area.append(text) - - def refresh_lists(self): - self._populate_included() - self._exc_list.clear() - for path in self._settings.excluded_paths: - self._exc_list.addItem(path) - - def dragEnterEvent(self, event): - if event.mimeData().hasUrls(): - event.acceptProposedAction() - - def dropEvent(self, event): - for url in event.mimeData().urls(): - path = url.toLocalFile() - if path and os.path.isdir(path): - if path not in self._settings.included_paths: - self._settings.included_paths.append(path) - self._populate_included() - self.directories_changed.emit() diff --git a/czkawka_pyside6/app/dialogs/save_dialog.py b/czkawka_pyside6/app/dialogs/save_dialog.py deleted file mode 100644 index dc93023d1..000000000 --- a/czkawka_pyside6/app/dialogs/save_dialog.py +++ /dev/null @@ -1,178 +0,0 @@ -import json -from pathlib import Path - -from PySide6.QtWidgets import QFileDialog - -from ..models import ResultEntry - - -class SaveDialog: - """Save/load results to/from file.""" - - @staticmethod - def save(parent, results: list, save_as_json: bool = False, - tool_name: str = "") -> bool: - # Build a task-specific default filename - slug = tool_name.lower().replace(" ", "_") if tool_name else "results" - from datetime import datetime - stamp = datetime.now().strftime("%Y%m%d_%H%M%S") - default_name = f"czkawka_{slug}_{stamp}" - - if save_as_json: - filter_str = "JSON Files (*.json);;All Files (*)" - default_ext = ".json" - else: - filter_str = "Text Files (*.txt);;JSON Files (*.json);;CSV Files (*.csv);;All Files (*)" - default_ext = ".txt" - - path, selected_filter = QFileDialog.getSaveFileName( - parent, f"Save {tool_name or 'Results'}", - f"{default_name}{default_ext}", filter_str - ) - if not path: - return False - - use_json = save_as_json or path.endswith(".json") or "JSON" in selected_filter - use_csv = path.endswith(".csv") or "CSV" in selected_filter - - try: - if use_csv: - import csv - with open(path, "w", newline="") as f: - writer = csv.writer(f) - # Write header from first non-header entry - cols = [] - for entry in results: - if not entry.header_row: - cols = [k for k in entry.values.keys() if not k.startswith("__")] - writer.writerow(cols) - break - for entry in results: - if not entry.header_row: - writer.writerow([entry.values.get(k, "") for k in cols]) - elif use_json: - data = [] - for entry in results: - if entry.header_row: - data.append({ - "__header": entry.values.get("__header", ""), - "__group_id": entry.group_id, - }) - else: - values = dict(entry.values) - values["__group_id"] = entry.group_id - values["__checked"] = entry.checked - data.append(values) - with open(path, "w") as f: - json.dump(data, f, indent=2) - else: - with open(path, "w") as f: - for entry in results: - if entry.header_row: - f.write(f"\n--- {entry.values.get('__header', 'Group')} ---\n") - else: - path_val = entry.values.get("__full_path", "") - size = entry.values.get("Size", "") - f.write(f"{path_val}\t{size}\n") - return True - except OSError: - return False - - @staticmethod - def load(parent) -> list[ResultEntry] | None: - """Load results from a previously saved JSON file. - - Returns list of ResultEntry on success, None on cancel/error. - """ - path, _ = QFileDialog.getOpenFileName( - parent, "Load Results", - "", - "JSON Files (*.json);;All Files (*)" - ) - if not path: - return None - - try: - with open(path) as f: - data = json.load(f) - except (json.JSONDecodeError, OSError): - return None - - if not isinstance(data, list): - # Could be czkawka_cli dict format {size: [[entries]]} - return _parse_cli_json(data) - - results = [] - for item in data: - if not isinstance(item, dict): - continue - - if "__header" in item: - results.append(ResultEntry( - values={"__header": item["__header"]}, - header_row=True, - group_id=item.get("__group_id", 0), - )) - else: - group_id = item.pop("__group_id", 0) - checked = item.pop("__checked", False) - results.append(ResultEntry( - values=item, - checked=checked, - group_id=group_id, - )) - - return results if results else None - - -def _parse_cli_json(data: dict) -> list[ResultEntry] | None: - """Parse raw czkawka_cli JSON output (dict of size buckets or flat list).""" - results = [] - group_id = 0 - - for key, groups in data.items(): - if not isinstance(groups, list): - continue - for group in groups: - if not isinstance(group, list) or len(group) == 0: - continue - - total_size = sum(e.get("size", 0) for e in group if isinstance(e, dict)) - results.append(ResultEntry( - values={"__header": f"Group {group_id + 1} ({len(group)} files)"}, - header_row=True, - group_id=group_id, - )) - - for entry in group: - if not isinstance(entry, dict): - continue - path = entry.get("path", "") - p = Path(path) - values = { - "File Name": p.name, - "Path": str(p.parent), - "Size": _format_size(entry.get("size", 0)), - "Modification Date": str(entry.get("modified_date", "")), - "Hash": entry.get("hash", ""), - "__full_path": path, - "__size_bytes": entry.get("size", 0), - "__modified_date_ts": entry.get("modified_date", 0), - } - results.append(ResultEntry(values=values, group_id=group_id)) - - group_id += 1 - - return results if results else None - - -def _format_size(size_bytes: int) -> str: - if size_bytes == 0: - return "0 B" - units = ["B", "KB", "MB", "GB", "TB"] - i = 0 - size = float(size_bytes) - while size >= 1024 and i < len(units) - 1: - size /= 1024 - i += 1 - return f"{size:.1f} {units[i]}" if i > 0 else f"{int(size)} B" diff --git a/czkawka_pyside6/app/dialogs/select_dialog.py b/czkawka_pyside6/app/dialogs/select_dialog.py deleted file mode 100644 index f4153106c..000000000 --- a/czkawka_pyside6/app/dialogs/select_dialog.py +++ /dev/null @@ -1,48 +0,0 @@ -from PySide6.QtWidgets import ( - QDialog, QVBoxLayout, QLabel, QPushButton, QDialogButtonBox -) -from PySide6.QtCore import Signal - -from ..models import SelectMode - - -class SelectDialog(QDialog): - """Dialog for selecting/deselecting results.""" - mode_selected = Signal(object) # SelectMode - - MODES = [ - (SelectMode.SELECT_ALL, "Select All"), - (SelectMode.UNSELECT_ALL, "Unselect All"), - (SelectMode.INVERT_SELECTION, "Invert Selection"), - (SelectMode.SELECT_BIGGEST_SIZE, "Select Biggest (by Size)"), - (SelectMode.SELECT_SMALLEST_SIZE, "Select Smallest (by Size)"), - (SelectMode.SELECT_NEWEST, "Select Newest"), - (SelectMode.SELECT_OLDEST, "Select Oldest"), - (SelectMode.SELECT_SHORTEST_PATH, "Select Shortest Path"), - (SelectMode.SELECT_LONGEST_PATH, "Select Longest Path"), - ] - - def __init__(self, parent=None): - super().__init__(parent) - self.setWindowTitle("Select Results") - self.setMinimumWidth(300) - - layout = QVBoxLayout(self) - - label = QLabel("Choose selection mode:") - label.setContentsMargins(4, 4, 4, 4) - layout.addWidget(label) - - for mode, name in self.MODES: - btn = QPushButton(name) - btn.clicked.connect(lambda checked, m=mode: self._select(m)) - layout.addWidget(btn) - - # Cancel - cancel = QPushButton("Cancel") - cancel.clicked.connect(self.reject) - layout.addWidget(cancel) - - def _select(self, mode: SelectMode): - self.mode_selected.emit(mode) - self.accept() diff --git a/czkawka_pyside6/app/main_window.py b/czkawka_pyside6/app/main_window.py deleted file mode 100644 index 83580bd30..000000000 --- a/czkawka_pyside6/app/main_window.py +++ /dev/null @@ -1,636 +0,0 @@ -"""Main application window for Czkawka PySide6 interface.""" - -import shutil -from pathlib import Path - -from PySide6.QtWidgets import ( - QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QSplitter, - QStatusBar, QMessageBox, QLabel, QApplication -) -from PySide6.QtCore import Qt, QTimer, QStandardPaths -from PySide6.QtGui import QPalette, QColor, QShortcut, QKeySequence - -from .state import AppState -from .models import ( - ActiveTab, TAB_COLUMNS, GROUPED_TABS, TABS_WITH_SETTINGS, SelectMode -) -from .left_panel import LeftPanel -from .results_view import ResultsView -from .action_buttons import ActionButtons -from .tool_settings import ToolSettingsPanel -from .settings_panel import SettingsPanel -from .progress_widget import ProgressWidget -from .preview_panel import PreviewPanel -from .bottom_panel import BottomPanel -from .backend import ScanRunner, FileOperations -from .icons import app_icon -from .dialogs import ( - DeleteDialog, MoveDialog, SelectDialog, - SortDialog, SaveDialog, RenameDialog, AboutDialog -) - - -class MainWindow(QMainWindow): - """Main application window with all panels and functionality.""" - - def __init__(self): - super().__init__() - self._state = AppState() - self._scan_runner = ScanRunner(self) - self._setup_window() - self._setup_ui() - self._connect_signals() - self._apply_theme() - self._setup_shortcuts() - - from .system_tray import SystemTray - self._tray = SystemTray(self) - - from .scan_history import ScanHistory - self._scan_history = ScanHistory() - - from .scan_queue import ScanQueue - self._scan_queue = ScanQueue(self) - self._scan_queue.next_scan.connect(self._run_queued_scan) - - def _setup_window(self): - self.setWindowTitle("Czkawka - PySide6 Edition") - self.setMinimumSize(900, 600) - self.resize(1200, 800) - - # Set window icon from project logo - icon = app_icon() - if not icon.isNull(): - self.setWindowIcon(icon) - - # Restore saved window geometry - if self._state.window_geometry: - from PySide6.QtCore import QByteArray - self.restoreGeometry(QByteArray.fromHex(self._state.window_geometry.encode())) - - # Auto-detect czkawka_cli binary - self._auto_detect_cli() - - def _setup_ui(self): - central = QWidget() - self.setCentralWidget(central) - main_layout = QVBoxLayout(central) - main_layout.setContentsMargins(0, 0, 0, 0) - main_layout.setSpacing(0) - - # Top area: left panel + content - content_splitter = QSplitter(Qt.Horizontal) - - # Left panel (tool selection) - self._left_panel = LeftPanel() - content_splitter.addWidget(self._left_panel) - - # Center area - center_widget = QWidget() - center_layout = QVBoxLayout(center_widget) - center_layout.setContentsMargins(0, 0, 0, 0) - center_layout.setSpacing(0) - - # Results view - self._results_view = ResultsView() - center_layout.addWidget(self._results_view, 1) - - # Progress widget (hidden by default) - self._progress = ProgressWidget() - center_layout.addWidget(self._progress) - - content_splitter.addWidget(center_widget) - - # Tool settings panel (hidden by default) - self._tool_settings = ToolSettingsPanel(self._state.tool_settings) - self._tool_settings.setVisible(False) - content_splitter.addWidget(self._tool_settings) - - # Preview panel (hidden by default) - self._preview = PreviewPanel() - self._preview.setVisible(False) - content_splitter.addWidget(self._preview) - - # Set splitter sizes - content_splitter.setStretchFactor(0, 0) # Left panel: fixed - content_splitter.setStretchFactor(1, 1) # Center: stretch - content_splitter.setStretchFactor(2, 0) # Tool settings: fixed - content_splitter.setStretchFactor(3, 0) # Preview: fixed - - main_layout.addWidget(content_splitter, 1) - - # Action buttons bar - self._action_buttons = ActionButtons() - main_layout.addWidget(self._action_buttons) - - # Bottom panel (directories / errors) - self._bottom_panel = BottomPanel(self._state.settings) - self._bottom_panel.show_directories() - main_layout.addWidget(self._bottom_panel) - - # Settings panel (overlay, hidden by default) - self._settings_panel = SettingsPanel(self._state.settings) - self._settings_panel.setVisible(False) - - # Status bar - self._statusbar = QStatusBar() - self.setStatusBar(self._statusbar) - self._status_label = QLabel("Ready") - self._statusbar.addWidget(self._status_label, 1) - - # Initialize results view columns for default tab - self._results_view.set_active_tab(self._state.active_tab) - - def _connect_signals(self): - # Left panel - self._left_panel.tab_changed.connect(self._on_tab_changed) - self._left_panel.settings_requested.connect(self._show_settings) - self._left_panel.about_requested.connect(self._show_about) - self._left_panel.tool_settings_toggled.connect(self._toggle_tool_settings) - - # Action buttons - self._action_buttons.scan_clicked.connect(self._start_scan) - self._action_buttons.stop_clicked.connect(self._stop_scan) - self._action_buttons.select_clicked.connect(self._show_select_dialog) - self._action_buttons.delete_clicked.connect(self._show_delete_dialog) - self._action_buttons.move_clicked.connect(self._show_move_dialog) - self._action_buttons.save_clicked.connect(self._save_results) - self._action_buttons.load_clicked.connect(self._load_results) - self._action_buttons.sort_clicked.connect(self._show_sort_dialog) - self._action_buttons.hardlink_clicked.connect(self._create_hardlinks) - self._action_buttons.symlink_clicked.connect(self._create_symlinks) - self._action_buttons.rename_clicked.connect(self._rename_files) - self._action_buttons.clean_exif_clicked.connect(self._clean_exif) - self._action_buttons.optimize_video_clicked.connect(self._optimize_video) - - # Results view - self._results_view.selection_changed.connect( - lambda count: self._action_buttons.set_has_selection(count > 0) - ) - self._results_view.item_activated.connect(self._on_item_activated) - - # Scan runner - self._scan_runner.finished.connect(self._on_scan_finished) - self._scan_runner.progress.connect(self._on_scan_progress) - self._scan_runner.error.connect(self._on_scan_error) - - # Settings - self._settings_panel.close_requested.connect( - lambda: self._settings_panel.setVisible(False) - ) - self._settings_panel.settings_changed.connect(self._on_settings_changed) - - # Bottom panel - self._bottom_panel.directories_changed.connect(self._on_settings_changed) - - def _setup_shortcuts(self): - QShortcut(QKeySequence("Ctrl+S"), self, self._start_scan) - QShortcut(QKeySequence("Escape"), self, self._stop_scan) - QShortcut(QKeySequence("Ctrl+A"), self, lambda: self._results_view.apply_selection(SelectMode.SELECT_ALL)) - QShortcut(QKeySequence("Ctrl+D"), self, self._show_delete_dialog) - QShortcut(QKeySequence("Ctrl+M"), self, self._show_move_dialog) - QShortcut(QKeySequence("Ctrl+Shift+S"), self, self._save_results) - QShortcut(QKeySequence("Ctrl+L"), self, self._load_results) - QShortcut(QKeySequence("Ctrl+I"), self, lambda: self._results_view.apply_selection(SelectMode.INVERT_SELECTION)) - QShortcut(QKeySequence("F5"), self, self._start_scan) - - def _on_tab_changed(self, tab: ActiveTab): - self._state.set_active_tab(tab) - self._results_view.set_active_tab(tab) - self._action_buttons.set_active_tab(tab) - self._tool_settings.set_active_tab(tab) - - # Show/hide preview for image-related tabs - show_preview = ( - tab in (ActiveTab.SIMILAR_IMAGES, ActiveTab.DUPLICATE_FILES) - and self._state.settings.show_image_preview - ) - self._preview.setVisible(show_preview) - - # Load existing results for this tab - results = self._state.get_results(tab) - if results: - self._results_view.set_results(results) - self._action_buttons.set_has_results(True) - else: - self._results_view.clear() - self._action_buttons.set_has_results(False) - - self._status_label.setText(f"Tab: {tab.name.replace('_', ' ').title()}") - - def _start_scan(self): - tab = self._state.active_tab - if not self._state.settings.included_paths: - QMessageBox.warning( - self, "No Directories", - "Please add at least one directory to scan in the bottom panel." - ) - return - - self._state.set_scanning(True) - self._action_buttons.set_scanning(True) - self._progress.start(tab) - self._results_view.clear() - self._status_label.setText(f"Scanning: {tab.name.replace('_', ' ').title()}...") - - self._scan_runner.start_scan( - tab, self._state.settings, self._state.tool_settings - ) - - def _stop_scan(self): - self._state.request_stop() - self._scan_runner.stop_scan() - self._status_label.setText("Scan stopped by user") - - def _on_scan_finished(self, tab: ActiveTab, results: list): - self._state.set_scanning(False) - self._state.set_results(tab, results) - self._action_buttons.set_scanning(False) - self._progress.stop() - - if tab == self._state.active_tab: - self._results_view.set_results(results) - self._action_buttons.set_has_results(len(results) > 0) - - count = sum(1 for r in results if not r.header_row) - self._status_label.setText(f"Scan complete: found {count} entries") - - if hasattr(self, '_tray') and not self.isVisible(): - self._tray.notify("Scan Complete", f"Found {count} entries") - - import time - duration = time.monotonic() - self._progress._start_time if self._progress._start_time else 0 - groups = sum(1 for r in results if r.header_row) - self._scan_history.add( - tool=tab.name, - directories=self._state.settings.included_paths, - entries=count, - groups=groups, - duration=duration, - ) - - self._scan_queue.on_scan_completed() - - def _on_scan_progress(self, progress): - self._progress.update_progress(progress) - - def _on_scan_error(self, error_msg: str): - self._state.set_scanning(False) - self._action_buttons.set_scanning(False) - self._progress.stop() - self._status_label.setText(f"Error: {error_msg}") - self._bottom_panel.set_text(f"Error: {error_msg}") - self._bottom_panel.show_text() - QMessageBox.critical(self, "Scan Error", error_msg) - - def _on_item_activated(self, entry): - path = entry.values.get("__full_path", "") - if path and self._preview.isVisible(): - self._preview.show_preview(path) - - def _show_settings(self): - self._settings_panel.setVisible(True) - # Show as a floating window - self._settings_panel.setParent(None) - self._settings_panel.setWindowTitle("Czkawka Settings") - self._settings_panel.setMinimumSize(600, 500) - self._settings_panel.show() - self._settings_panel.raise_() - - def _show_about(self): - dialog = AboutDialog(self) - dialog.exec() - - def _toggle_tool_settings(self, visible: bool): - self._tool_settings.setVisible(visible) - - def _show_select_dialog(self): - dialog = SelectDialog(self) - dialog.mode_selected.connect(self._results_view.apply_selection) - dialog.exec() - - def _show_delete_dialog(self): - checked = self._results_view.get_checked_entries() - if not checked: - QMessageBox.information(self, "No Selection", "No files selected for deletion.") - return - - dialog = DeleteDialog(len(checked), self._state.settings.move_to_trash, self) - if dialog.exec() == DeleteDialog.Accepted: - deleted, errors = FileOperations.delete_files( - checked, dialog.move_to_trash - ) - self._status_label.setText(f"Deleted {deleted} file(s)") - if errors: - self._bottom_panel.set_text("\n".join(errors)) - self._bottom_panel.show_text() - # Refresh results - remove deleted entries - self._refresh_after_action(checked) - - def _show_move_dialog(self): - checked = self._results_view.get_checked_entries() - if not checked: - QMessageBox.information(self, "No Selection", "No files selected.") - return - - dialog = MoveDialog(len(checked), self) - if dialog.exec() == MoveDialog.Accepted: - if not dialog.destination: - QMessageBox.warning(self, "No Destination", "Please select a destination folder.") - return - moved, errors = FileOperations.move_files( - checked, dialog.destination, - dialog.preserve_structure, dialog.copy_mode - ) - action = "Copied" if dialog.copy_mode else "Moved" - self._status_label.setText(f"{action} {moved} file(s)") - if errors: - self._bottom_panel.set_text("\n".join(errors)) - self._bottom_panel.show_text() - if not dialog.copy_mode: - self._refresh_after_action(checked) - - def _save_results(self): - results = self._results_view.get_all_entries() - if not results: - QMessageBox.information(self, "No Results", "No results to save.") - return - all_results = self._state.get_results() - from .models import TAB_DISPLAY_NAMES - tool_name = TAB_DISPLAY_NAMES.get(self._state.active_tab, "Results") - success = SaveDialog.save(self, all_results, self._state.settings.save_as_json, tool_name) - if success: - self._status_label.setText("Results saved successfully") - - def _load_results(self): - results = SaveDialog.load(self) - if results is None: - return - tab = self._state.active_tab - self._state.set_results(tab, results) - self._results_view.set_results(results) - self._action_buttons.set_has_results( - any(not r.header_row for r in results) - ) - count = sum(1 for r in results if not r.header_row) - self._status_label.setText(f"Loaded {count} entries from file") - - def _show_sort_dialog(self): - columns = TAB_COLUMNS.get(self._state.active_tab, []) - if not columns: - return - dialog = SortDialog(columns, self) - dialog.sort_requested.connect(self._results_view.sort_by_column) - dialog.exec() - - def _create_hardlinks(self): - checked = self._results_view.get_checked_entries() - if not checked: - return - - # Find the reference file (first unchecked in the same group) - all_results = self._state.get_results() - group_id = checked[0].group_id - reference = None - for r in all_results: - if r.group_id == group_id and not r.header_row and not r.checked: - reference = r.values.get("__full_path", "") - break - - if not reference: - QMessageBox.warning( - self, "No Reference", - "Cannot determine reference file. Leave at least one file unchecked in the group." - ) - return - - reply = QMessageBox.question( - self, "Create Hardlinks", - f"Replace {len(checked)} selected file(s) with hardlinks to:\n{reference}?", - QMessageBox.Yes | QMessageBox.No - ) - if reply == QMessageBox.Yes: - created, errors = FileOperations.create_hardlinks(checked, reference) - self._status_label.setText(f"Created {created} hardlink(s)") - if errors: - self._bottom_panel.set_text("\n".join(errors)) - self._bottom_panel.show_text() - - def _create_symlinks(self): - checked = self._results_view.get_checked_entries() - if not checked: - return - - all_results = self._state.get_results() - group_id = checked[0].group_id - reference = None - for r in all_results: - if r.group_id == group_id and not r.header_row and not r.checked: - reference = r.values.get("__full_path", "") - break - - if not reference: - QMessageBox.warning( - self, "No Reference", - "Cannot determine reference file. Leave at least one file unchecked in the group." - ) - return - - reply = QMessageBox.question( - self, "Create Symlinks", - f"Replace {len(checked)} selected file(s) with symlinks to:\n{reference}?", - QMessageBox.Yes | QMessageBox.No - ) - if reply == QMessageBox.Yes: - created, errors = FileOperations.create_symlinks(checked, reference) - self._status_label.setText(f"Created {created} symlink(s)") - if errors: - self._bottom_panel.set_text("\n".join(errors)) - self._bottom_panel.show_text() - - def _rename_files(self): - checked = self._results_view.get_checked_entries() - if not checked: - return - - tab = self._state.active_tab - if tab == ActiveTab.BAD_EXTENSIONS: - dialog = RenameDialog(len(checked), "extensions", self) - if dialog.exec() == RenameDialog.Accepted: - success, msg = FileOperations.fix_extensions( - self._state.settings.czkawka_cli_path, - self._state.settings, self._state.tool_settings - ) - self._status_label.setText("Extensions fixed" if success else f"Error: {msg}") - elif tab == ActiveTab.BAD_NAMES: - dialog = RenameDialog(len(checked), "names", self) - if dialog.exec() == RenameDialog.Accepted: - success, msg = FileOperations.fix_bad_names( - self._state.settings.czkawka_cli_path, - self._state.settings, self._state.tool_settings - ) - self._status_label.setText("Names fixed" if success else f"Error: {msg}") - - def _clean_exif(self): - checked = self._results_view.get_checked_entries() - if not checked: - return - - reply = QMessageBox.question( - self, "Clean EXIF", - f"Remove EXIF metadata from {len(checked)} selected file(s)?", - QMessageBox.Yes | QMessageBox.No - ) - if reply == QMessageBox.Yes: - cleaned, errors = FileOperations.clean_exif( - self._state.settings.czkawka_cli_path, - checked, - self._state.tool_settings.exif_ignored_tags - ) - self._status_label.setText(f"Cleaned EXIF from {cleaned} file(s)") - if errors: - self._bottom_panel.set_text("\n".join(errors)) - self._bottom_panel.show_text() - - def _optimize_video(self): - checked = self._results_view.get_checked_entries() - if not checked: - return - - QMessageBox.information( - self, "Video Optimization", - f"Video optimization for {len(checked)} file(s) will be performed " - "using czkawka_cli. Check the status bar for progress." - ) - # Video optimization is done via CLI - self._status_label.setText("Video optimization: use CLI directly for this feature") - - def _refresh_after_action(self, removed_entries: list): - """Remove processed entries from results and refresh the view.""" - removed_paths = {e.values.get("__full_path") for e in removed_entries} - current_results = self._state.get_results() - new_results = [] - for r in current_results: - if r.header_row: - new_results.append(r) - elif r.values.get("__full_path") not in removed_paths: - new_results.append(r) - - # Remove empty group headers - cleaned = [] - i = 0 - while i < len(new_results): - if new_results[i].header_row: - # Check if next entries belong to this group - has_children = False - for j in range(i + 1, len(new_results)): - if new_results[j].header_row: - break - has_children = True - if has_children: - cleaned.append(new_results[i]) - else: - cleaned.append(new_results[i]) - i += 1 - - self._state.set_results(self._state.active_tab, cleaned) - self._results_view.set_results(cleaned) - self._action_buttons.set_has_results( - any(not r.header_row for r in cleaned) - ) - - def _on_settings_changed(self): - self._state.save_settings() - self._bottom_panel.refresh_lists() - - def _apply_theme(self): - """Apply minimal styling that works with the system theme. - - KDE compliance: we inherit the desktop theme (Breeze, Adwaita, etc.) - and only add small layout tweaks that don't override colors. - """ - app = QApplication.instance() - - # Only apply layout polish — no color overrides so the system - # theme (Breeze dark/light, Adwaita, etc.) is fully respected. - app.setStyleSheet(""" - QSplitter::handle { width: 2px; } - QTreeWidget::item { padding: 2px; } - QListWidget::item { padding: 3px; } - QGroupBox { border-radius: 4px; margin-top: 8px; padding-top: 8px; } - QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 4px; } - QPushButton { padding: 5px 12px; } - QComboBox { padding: 4px; } - QLineEdit { padding: 4px; } - QSpinBox { padding: 4px; } - QProgressBar { text-align: center; } - QScrollArea { border: none; } - QCheckBox { spacing: 6px; } - QHeaderView::section { padding: 4px; } - """) - - def _auto_detect_cli(self): - """Auto-detect czkawka_cli binary location.""" - s = self._state.settings - - # If already valid and exists, keep it - if s.czkawka_cli_path != "czkawka_cli" and Path(s.czkawka_cli_path).is_file(): - return - if shutil.which(s.czkawka_cli_path): - return - - # Search common locations - candidates = [] - project_root = Path(__file__).parent.parent.parent - - # Look for compiled binary in standard target dirs - for build_dir in ["target/release", "target/debug"]: - candidate = project_root / build_dir / "czkawka_cli" - if candidate.exists(): - candidates.append(str(candidate)) - - # Check cargo metadata for custom target directory - if not candidates: - try: - import subprocess, json - result = subprocess.run( - ["cargo", "metadata", "--manifest-path", - str(project_root / "Cargo.toml"), - "--format-version", "1", "--no-deps"], - capture_output=True, text=True, timeout=10 - ) - if result.returncode == 0: - meta = json.loads(result.stdout) - target_dir = meta.get("target_directory", "") - if target_dir: - for sub in ["release", "debug"]: - candidate = Path(target_dir) / sub / "czkawka_cli" - if candidate.exists(): - candidates.append(str(candidate)) - except Exception: - pass - - # Also check PATH - which_result = shutil.which("czkawka_cli") - if which_result: - candidates.append(which_result) - - for candidate in candidates: - if Path(candidate).is_file(): - s.czkawka_cli_path = str(candidate) - self._state.save_settings() - return - - def _run_queued_scan(self, tab: ActiveTab): - self._state.set_active_tab(tab) - self._left_panel.set_active_tab(tab) - self._on_tab_changed(tab) - self._start_scan() - - def closeEvent(self, event): - """Save settings on close.""" - self._state.window_geometry = self.saveGeometry().toHex().data().decode() - self._state.save_settings() - if self._state.scanning: - self._scan_runner.stop_scan() - super().closeEvent(event) diff --git a/czkawka_pyside6/app/models.py b/czkawka_pyside6/app/models.py deleted file mode 100644 index 90330b50b..000000000 --- a/czkawka_pyside6/app/models.py +++ /dev/null @@ -1,306 +0,0 @@ -from enum import Enum, auto -from dataclasses import dataclass, field -from typing import Optional -from pathlib import Path - - -class ActiveTab(Enum): - DUPLICATE_FILES = auto() - EMPTY_FOLDERS = auto() - BIG_FILES = auto() - EMPTY_FILES = auto() - TEMPORARY_FILES = auto() - SIMILAR_IMAGES = auto() - SIMILAR_VIDEOS = auto() - SIMILAR_MUSIC = auto() - INVALID_SYMLINKS = auto() - BROKEN_FILES = auto() - BAD_EXTENSIONS = auto() - BAD_NAMES = auto() - EXIF_REMOVER = auto() - VIDEO_OPTIMIZER = auto() - SETTINGS = auto() - ABOUT = auto() - - -class SelectMode(Enum): - SELECT_ALL = auto() - UNSELECT_ALL = auto() - INVERT_SELECTION = auto() - SELECT_BIGGEST_SIZE = auto() - SELECT_BIGGEST_RESOLUTION = auto() - SELECT_SMALLEST_SIZE = auto() - SELECT_SMALLEST_RESOLUTION = auto() - SELECT_NEWEST = auto() - SELECT_OLDEST = auto() - SELECT_SHORTEST_PATH = auto() - SELECT_LONGEST_PATH = auto() - SELECT_CUSTOM = auto() - - -class DeleteMethod(Enum): - NONE = "NONE" - DELETE = "DELETE" - ALL_EXCEPT_NEWEST = "AEN" - ALL_EXCEPT_OLDEST = "AEO" - ONE_OLDEST = "OO" - ONE_NEWEST = "ON" - HARDLINK = "HARD" - ALL_EXCEPT_BIGGEST = "AEB" - ALL_EXCEPT_SMALLEST = "AES" - ONE_BIGGEST = "OB" - ONE_SMALLEST = "OS" - - -class CheckingMethod(Enum): - HASH = "HASH" - SIZE = "SIZE" - NAME = "NAME" - SIZE_NAME = "SIZE_NAME" - - -class HashType(Enum): - BLAKE3 = "BLAKE3" - CRC32 = "CRC32" - XXH3 = "XXH3" - - -class ImageHashAlg(Enum): - MEAN = "Mean" - GRADIENT = "Gradient" - BLOCKHASH = "Blockhash" - VERT_GRADIENT = "VertGradient" - DOUBLE_GRADIENT = "DoubleGradient" - MEDIAN = "Median" - - -class ImageFilter(Enum): - LANCZOS3 = "Lanczos3" - NEAREST = "Nearest" - TRIANGLE = "Triangle" - GAUSSIAN = "Gaussian" - CATMULL_ROM = "CatmullRom" - - -class MusicSearchMethod(Enum): - TAGS = "TAGS" - CONTENT = "CONTENT" - - -class VideoCropDetect(Enum): - NONE = "none" - LETTERBOX = "letterbox" - MOTION = "motion" - - -class VideoCropMechanism(Enum): - BLACKBARS = "blackbars" - STATICCONTENT = "staticcontent" - - -class VideoCodec(Enum): - H264 = "h264" - H265 = "h265" - AV1 = "av1" - VP9 = "vp9" - - -# Map ActiveTab to CLI subcommand names -TAB_TO_CLI_COMMAND = { - ActiveTab.DUPLICATE_FILES: "dup", - ActiveTab.EMPTY_FOLDERS: "empty-folders", - ActiveTab.BIG_FILES: "big", - ActiveTab.EMPTY_FILES: "empty-files", - ActiveTab.TEMPORARY_FILES: "temp", - ActiveTab.SIMILAR_IMAGES: "image", - ActiveTab.SIMILAR_VIDEOS: "video", - ActiveTab.SIMILAR_MUSIC: "music", - ActiveTab.INVALID_SYMLINKS: "symlinks", - ActiveTab.BROKEN_FILES: "broken", - ActiveTab.BAD_EXTENSIONS: "ext", - ActiveTab.BAD_NAMES: "bad-names", - ActiveTab.EXIF_REMOVER: "exif-remover", - ActiveTab.VIDEO_OPTIMIZER: "video-optimizer", -} - -TAB_DISPLAY_NAMES = { - ActiveTab.DUPLICATE_FILES: "Duplicate Files", - ActiveTab.EMPTY_FOLDERS: "Empty Folders", - ActiveTab.BIG_FILES: "Big Files", - ActiveTab.EMPTY_FILES: "Empty Files", - ActiveTab.TEMPORARY_FILES: "Temporary Files", - ActiveTab.SIMILAR_IMAGES: "Similar Images", - ActiveTab.SIMILAR_VIDEOS: "Similar Videos", - ActiveTab.SIMILAR_MUSIC: "Similar Music", - ActiveTab.INVALID_SYMLINKS: "Invalid Symlinks", - ActiveTab.BROKEN_FILES: "Broken Files", - ActiveTab.BAD_EXTENSIONS: "Bad Extensions", - ActiveTab.BAD_NAMES: "Bad Names", - ActiveTab.EXIF_REMOVER: "EXIF Remover", - ActiveTab.VIDEO_OPTIMIZER: "Video Optimizer", -} - -# Which tabs support grouping (results are in groups) -GROUPED_TABS = { - ActiveTab.DUPLICATE_FILES, - ActiveTab.SIMILAR_IMAGES, - ActiveTab.SIMILAR_VIDEOS, - ActiveTab.SIMILAR_MUSIC, -} - -# Which tabs have per-tool settings -TABS_WITH_SETTINGS = { - ActiveTab.DUPLICATE_FILES, - ActiveTab.SIMILAR_IMAGES, - ActiveTab.SIMILAR_VIDEOS, - ActiveTab.SIMILAR_MUSIC, - ActiveTab.BIG_FILES, - ActiveTab.BROKEN_FILES, - ActiveTab.BAD_NAMES, - ActiveTab.EXIF_REMOVER, - ActiveTab.VIDEO_OPTIMIZER, -} - -# Column definitions per tab -TAB_COLUMNS = { - ActiveTab.DUPLICATE_FILES: ["Selection", "Size", "File Name", "Path", "Modification Date", "Hash"], - ActiveTab.EMPTY_FOLDERS: ["Selection", "Folder Name", "Path", "Modification Date"], - ActiveTab.BIG_FILES: ["Selection", "Size", "File Name", "Path", "Modification Date"], - ActiveTab.EMPTY_FILES: ["Selection", "File Name", "Path", "Modification Date"], - ActiveTab.TEMPORARY_FILES: ["Selection", "File Name", "Path", "Modification Date"], - ActiveTab.SIMILAR_IMAGES: ["Selection", "Similarity", "Size", "Resolution", "File Name", "Path", "Modification Date", "Hash"], - ActiveTab.SIMILAR_VIDEOS: ["Selection", "Size", "File Name", "Path", "Modification Date"], - ActiveTab.SIMILAR_MUSIC: ["Selection", "Size", "File Name", "Path", "Title", "Artist", "Year", "Bitrate", "Genre", "Length"], - ActiveTab.INVALID_SYMLINKS: ["Selection", "Symlink Name", "Symlink Path", "Destination Path", "Type of Error"], - ActiveTab.BROKEN_FILES: ["Selection", "File Name", "Path", "Error Type", "Size", "Modification Date"], - ActiveTab.BAD_EXTENSIONS: ["Selection", "File Name", "Path", "Current Extension", "Proper Extension"], - ActiveTab.BAD_NAMES: ["Selection", "File Name", "Path", "Error Type"], - ActiveTab.EXIF_REMOVER: ["Selection", "File Name", "Path"], - ActiveTab.VIDEO_OPTIMIZER: ["Selection", "File Name", "Path", "Size", "Codec"], -} - - -@dataclass -class ResultEntry: - """A single result entry from a scan.""" - values: dict # column_name -> value - checked: bool = False - header_row: bool = False # Group header for grouped results - group_id: int = 0 - - -@dataclass -class ScanProgress: - """Progress information during scanning.""" - step_name: str = "" - current: int = 0 - total: int = 0 - current_size: int = 0 - # Raw fields from czkawka_cli --json-progress - stage_name: str = "" - current_stage_idx: int = 0 - max_stage_idx: int = 0 - entries_checked: int = 0 - entries_to_check: int = 0 - bytes_checked: int = 0 - bytes_to_check: int = 0 - - -@dataclass -class ToolSettings: - """Per-tool settings that map to CLI arguments.""" - # Duplicates - dup_check_method: CheckingMethod = CheckingMethod.HASH - dup_hash_type: HashType = HashType.BLAKE3 - dup_name_case_sensitive: bool = False - dup_min_size: str = "8192" - dup_max_size: str = "" - dup_min_cache_size: str = "257144" - dup_use_prehash: bool = True - dup_min_prehash_cache_size: str = "257144" - - # Similar Images - img_hash_size: int = 16 # 8, 16, 32, 64 - img_filter: ImageFilter = ImageFilter.NEAREST - img_hash_alg: ImageHashAlg = ImageHashAlg.GRADIENT - img_ignore_same_size: bool = False - img_max_difference: int = 5 # 0-40 - - # Similar Videos - vid_ignore_same_size: bool = False - vid_crop_detect: VideoCropDetect = VideoCropDetect.LETTERBOX - vid_max_difference: int = 10 # 0-20 - vid_skip_forward: int = 15 # 0-300 - vid_duration: int = 10 # 1-60 - - # Similar Music - music_search_method: MusicSearchMethod = MusicSearchMethod.TAGS - music_approximate: bool = False - music_title: bool = True - music_artist: bool = True - music_bitrate: bool = False - music_genre: bool = False - music_year: bool = False - music_length: bool = False - music_compare_fingerprints_similar_titles: bool = False - music_max_difference: float = 2.0 - music_min_segment_duration: float = 10.0 - - # Big Files - big_files_mode: str = "biggest" # biggest or smallest - big_files_number: int = 50 - - # Broken Files - broken_audio: bool = True - broken_pdf: bool = True - broken_archive: bool = True - broken_image: bool = True - broken_video: bool = False - - # Bad Names - bad_names_uppercase_ext: bool = True - bad_names_emoji: bool = True - bad_names_space: bool = True - bad_names_non_ascii: bool = True - bad_names_restricted_charset: str = "" - bad_names_remove_duplicated: bool = False - - # EXIF Remover - exif_ignored_tags: str = "" - - # Video Optimizer - video_opt_mode: str = "crop" # crop or transcode - video_crop_mechanism: VideoCropMechanism = VideoCropMechanism.BLACKBARS - video_black_pixel_threshold: int = 32 - video_black_bar_percentage: int = 90 - video_max_samples: int = 20 - video_min_crop_size: int = 10 - video_excluded_codecs: str = "h265,hevc,av1,vp9" - video_codec: VideoCodec = VideoCodec.H265 - video_quality: int = 23 - video_fail_if_bigger: bool = False - video_overwrite: bool = False - video_max_width: int = 1920 - video_max_height: int = 1080 - - -@dataclass -class AppSettings: - """Global application settings.""" - included_paths: list = field(default_factory=lambda: [str(Path.home())]) - reference_paths: set = field(default_factory=set) # subset of included_paths marked as reference - excluded_paths: list = field(default_factory=list) - excluded_items: str = "" - allowed_extensions: str = "" - excluded_extensions: str = "" - minimum_file_size: str = "" - maximum_file_size: str = "" - recursive_search: bool = True - use_cache: bool = True - save_as_json: bool = False - move_to_trash: bool = True - hide_hard_links: bool = False - thread_number: int = 0 # 0 = all available - dark_theme: bool = True - show_image_preview: bool = True - czkawka_cli_path: str = "czkawka_cli" # path to CLI binary diff --git a/czkawka_pyside6/app/preview_panel.py b/czkawka_pyside6/app/preview_panel.py deleted file mode 100644 index 7b53bc0b2..000000000 --- a/czkawka_pyside6/app/preview_panel.py +++ /dev/null @@ -1,112 +0,0 @@ -from pathlib import Path - -from PySide6.QtWidgets import ( - QWidget, QVBoxLayout, QLabel, QSizePolicy -) -from PySide6.QtCore import Qt, QSize -from PySide6.QtGui import QPixmap, QImage - - -class PreviewPanel(QWidget): - """Image preview panel for similar images / duplicate files.""" - - SUPPORTED_EXTENSIONS = { - ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", - ".tiff", ".tif", ".ico", ".svg" - } - - def __init__(self, parent=None): - super().__init__(parent) - self.setMinimumWidth(200) - self.setMaximumWidth(400) - self._current_path = "" - self._setup_ui() - - def _setup_ui(self): - layout = QVBoxLayout(self) - layout.setContentsMargins(4, 4, 4, 4) - - self._title = QLabel("Preview") - font = self._title.font() - font.setBold(True) - self._title.setFont(font) - self._title.setAlignment(Qt.AlignCenter) - layout.addWidget(self._title) - - self._image_label = QLabel() - self._image_label.setAlignment(Qt.AlignCenter) - self._image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) - self._image_label.setMinimumSize(QSize(180, 180)) - self._image_label.setFrameShape(QLabel.StyledPanel) - self._image_label.setScaledContents(False) - layout.addWidget(self._image_label) - - self._info_label = QLabel() - self._info_label.setAlignment(Qt.AlignCenter) - self._info_label.setWordWrap(True) - self._info_label.setEnabled(False) - layout.addWidget(self._info_label) - - def show_preview(self, file_path: str): - if not file_path or file_path == self._current_path: - return - - self._current_path = file_path - p = Path(file_path) - - if not p.exists(): - self._image_label.setText("File not found") - self._info_label.setText("") - return - - if p.suffix.lower() not in self.SUPPORTED_EXTENSIONS: - self._image_label.setText("Preview not available\nfor this file type") - self._info_label.setText(p.name) - return - - pixmap = QPixmap(file_path) - if pixmap.isNull(): - self._image_label.setText("Cannot load image") - self._info_label.setText(p.name) - return - - # Scale to fit while keeping aspect ratio - label_size = self._image_label.size() - scaled = pixmap.scaled( - label_size, Qt.KeepAspectRatio, Qt.SmoothTransformation - ) - self._image_label.setPixmap(scaled) - - # Show info - size = p.stat().st_size - size_str = self._format_size(size) - self._info_label.setText( - f"{p.name}\n{pixmap.width()}x{pixmap.height()} | {size_str}" - ) - self._title.setText("Preview") - - def clear_preview(self): - self._current_path = "" - self._image_label.clear() - self._image_label.setText("No preview") - self._info_label.setText("") - - def resizeEvent(self, event): - super().resizeEvent(event) - # Re-render if we have a current image - if self._current_path: - path = self._current_path - self._current_path = "" - self.show_preview(path) - - @staticmethod - def _format_size(size_bytes: int) -> str: - if size_bytes == 0: - return "0 B" - units = ["B", "KB", "MB", "GB"] - i = 0 - size = float(size_bytes) - while size >= 1024 and i < len(units) - 1: - size /= 1024 - i += 1 - return f"{size:.1f} {units[i]}" if i > 0 else f"{int(size)} B" diff --git a/czkawka_pyside6/app/results_view.py b/czkawka_pyside6/app/results_view.py deleted file mode 100644 index 46ea53a85..000000000 --- a/czkawka_pyside6/app/results_view.py +++ /dev/null @@ -1,507 +0,0 @@ -from PySide6.QtWidgets import ( - QWidget, QVBoxLayout, QTreeWidget, QTreeWidgetItem, QHeaderView, - QAbstractItemView, QMenu, QLabel, QHBoxLayout, QLineEdit -) -from PySide6.QtCore import Signal, Qt -from PySide6.QtGui import QColor, QBrush, QFont, QAction - -from .models import ( - ActiveTab, ResultEntry, TAB_COLUMNS, GROUPED_TABS, SelectMode -) - - -class ResultsView(QWidget): - """Results display with tree view for grouped results and table for flat results.""" - - selection_changed = Signal(int) # number of selected items - item_activated = Signal(object) # ResultEntry - context_menu_requested = Signal(object, object) # QPoint, ResultEntry - - # Group header uses the system highlight color (darkened) so it works on - # both light and dark themes. Computed lazily on first result display. - _header_colors_ready = False - HEADER_BG = QColor() - HEADER_FG = QColor() - - def __init__(self, parent=None): - super().__init__(parent) - self._active_tab = ActiveTab.DUPLICATE_FILES - self._results: list[ResultEntry] = [] - self._sort_column = -1 - self._sort_order = Qt.AscendingOrder - self._setup_ui() - - def _ensure_header_colors(self): - """Derive group header colors from the system palette.""" - if self._header_colors_ready: - return - from PySide6.QtWidgets import QApplication - from PySide6.QtGui import QPalette - palette = QApplication.instance().palette() - win = palette.color(QPalette.ColorRole.Window) - hi = palette.color(QPalette.ColorRole.Highlight) - self.HEADER_BG = QColor( - (win.red() + hi.red()) // 2, - (win.green() + hi.green()) // 2, - (win.blue() + hi.blue()) // 2, - ) - self.HEADER_FG = palette.color(QPalette.ColorRole.HighlightedText) - self._header_colors_ready = True - - def _setup_ui(self): - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - - # Summary bar - summary_layout = QHBoxLayout() - self._summary_label = QLabel("No results") - summary_layout.addWidget(self._summary_label) - self._selection_label = QLabel("") - self._selection_label.setAlignment(Qt.AlignRight) - self._selection_label.setEnabled(False) - summary_layout.addWidget(self._selection_label) - layout.addLayout(summary_layout) - - # Tree widget for results - self._tree = QTreeWidget() - self._tree.setSelectionMode(QAbstractItemView.ExtendedSelection) - self._tree.setRootIsDecorated(False) - self._tree.setAlternatingRowColors(True) - self._tree.setContextMenuPolicy(Qt.CustomContextMenu) - self._tree.customContextMenuRequested.connect(self._on_context_menu) - self._tree.itemChanged.connect(self._on_item_changed) - self._tree.itemDoubleClicked.connect(self._on_item_double_clicked) - - # Sortable: click header to sort - self._tree.setSortingEnabled(False) # We handle sorting manually - header = self._tree.header() - header.setSectionsClickable(True) - header.sectionClicked.connect(self._on_header_clicked) - # Resizable columns - header.setSectionResizeMode(QHeaderView.Interactive) - header.setStretchLastSection(True) - header.setContextMenuPolicy(Qt.CustomContextMenu) - header.customContextMenuRequested.connect(self._on_header_context_menu) - - # Filter bar - self._filter_edit = QLineEdit() - self._filter_edit.setPlaceholderText("Filter results by filename or path...") - self._filter_edit.setClearButtonEnabled(True) - self._filter_edit.textChanged.connect(self._apply_filter) - layout.addWidget(self._filter_edit) - - layout.addWidget(self._tree) - - def _on_header_context_menu(self, pos): - """Right-click header to show/hide columns.""" - columns = TAB_COLUMNS.get(self._active_tab, []) - menu = QMenu(self) - header = self._tree.header() - for i, col_name in enumerate(columns): - if i == 0: # Don't hide selection column - continue - action = QAction(col_name, self) - action.setCheckable(True) - action.setChecked(not header.isSectionHidden(i)) - action.toggled.connect(lambda checked, idx=i: header.setSectionHidden(idx, not checked)) - menu.addAction(action) - menu.exec_(header.mapToGlobal(pos)) - - def _apply_filter(self, text: str): - """Show/hide tree items based on filter text.""" - text = text.lower() - for i in range(self._tree.topLevelItemCount()): - item = self._tree.topLevelItem(i) - entry = item.data(0, Qt.UserRole) - if not entry: - continue - if entry.header_row: - # Show header if any child in its group matches - item.setHidden(False) # Will be hidden later if no children visible - continue - if not text: - item.setHidden(False) - else: - name = str(entry.values.get("File Name", "")).lower() - path = str(entry.values.get("Path", "")).lower() - full = str(entry.values.get("__full_path", "")).lower() - item.setHidden(text not in name and text not in path and text not in full) - - # Hide group headers with no visible children - if text and self._active_tab in GROUPED_TABS: - i = 0 - while i < self._tree.topLevelItemCount(): - item = self._tree.topLevelItem(i) - entry = item.data(0, Qt.UserRole) - if entry and entry.header_row: - # Check if any following non-header items are visible - has_visible = False - for j in range(i + 1, self._tree.topLevelItemCount()): - next_item = self._tree.topLevelItem(j) - next_entry = next_item.data(0, Qt.UserRole) - if next_entry and next_entry.header_row: - break - if not next_item.isHidden(): - has_visible = True - break - item.setHidden(not has_visible) - i += 1 - - def set_active_tab(self, tab: ActiveTab): - self._active_tab = tab - self._sort_column = -1 - columns = TAB_COLUMNS.get(tab, ["Selection", "File Name", "Path"]) - self._tree.setColumnCount(len(columns)) - self._tree.setHeaderLabels(columns) - header = self._tree.header() - # All columns interactive (resizable), last one stretches - header.setSectionResizeMode(QHeaderView.Interactive) - header.setStretchLastSection(True) - # Give Path columns more initial space - for i, col in enumerate(columns): - if col == "Path": - header.resizeSection(i, 300) - elif col == "Selection": - header.resizeSection(i, 30) - elif col in ("Size", "Hash", "Modification Date"): - header.resizeSection(i, 140) - elif col == "File Name": - header.resizeSection(i, 200) - - def set_results(self, results: list[ResultEntry]): - self._ensure_header_colors() - self._results = results - self._rebuild_tree() - self._update_summary() - - def _rebuild_tree(self): - """Rebuild tree items from self._results.""" - self._tree.blockSignals(True) - self._tree.clear() - - columns = TAB_COLUMNS.get(self._active_tab, ["Selection", "File Name", "Path"]) - - for entry in self._results: - if entry.header_row: - item = QTreeWidgetItem() - header_text = entry.values.get("__header", "Group") - item.setText(0, header_text) - item.setFirstColumnSpanned(True) - font = QFont() - font.setBold(True) - item.setFont(0, font) - for col in range(len(columns)): - item.setBackground(col, QBrush(self.HEADER_BG)) - item.setForeground(col, QBrush(self.HEADER_FG)) - item.setFlags(item.flags() & ~Qt.ItemIsUserCheckable) - item.setData(0, Qt.UserRole, entry) - self._tree.addTopLevelItem(item) - # Span header across all columns (must be called after adding to tree) - item.setFirstColumnSpanned(True) - else: - item = QTreeWidgetItem() - item.setFlags(item.flags() | Qt.ItemIsUserCheckable) - item.setCheckState(0, Qt.Checked if entry.checked else Qt.Unchecked) - - for col_idx, col_name in enumerate(columns): - if col_idx == 0: - continue - value = entry.values.get(col_name, "") - item.setText(col_idx, str(value)) - - item.setData(0, Qt.UserRole, entry) - self._tree.addTopLevelItem(item) - - self._tree.blockSignals(False) - - # ── Sorting ────────────────────────────────────────────── - - def _on_header_clicked(self, logical_index: int): - """Sort by column when header is clicked. Toggle ascending/descending.""" - if logical_index == 0: - return # Don't sort by checkbox column - - if self._sort_column == logical_index: - # Toggle order - self._sort_order = ( - Qt.DescendingOrder if self._sort_order == Qt.AscendingOrder - else Qt.AscendingOrder - ) - else: - self._sort_column = logical_index - self._sort_order = Qt.AscendingOrder - - columns = TAB_COLUMNS.get(self._active_tab, []) - col_name = columns[logical_index] if logical_index < len(columns) else "" - - # Update header sort indicator - self._tree.header().setSortIndicator(logical_index, self._sort_order) - self._tree.header().setSortIndicatorShown(True) - - ascending = self._sort_order == Qt.AscendingOrder - - if self._active_tab in GROUPED_TABS: - self._sort_within_groups(col_name, ascending) - else: - self._sort_flat(col_name, ascending) - - def _sort_key(self, entry: ResultEntry, col_name: str): - """Return a sort key for a result entry by column name.""" - # Use numeric values for known numeric columns - if col_name in ("Size",): - return entry.values.get("__size_bytes", 0) - if col_name in ("Modification Date",): - return entry.values.get("__modified_date_ts", 0) - if col_name in ("Similarity", "Bitrate", "Year", "Length"): - raw = entry.values.get(col_name, "") - try: - return float(str(raw).replace(",", "")) - except (ValueError, TypeError): - return 0 - # Default: string comparison (case-insensitive) - return str(entry.values.get(col_name, "")).lower() - - def _sort_flat(self, col_name: str, ascending: bool): - """Sort flat (non-grouped) results.""" - self._results.sort( - key=lambda e: self._sort_key(e, col_name), - reverse=not ascending, - ) - self._rebuild_tree() - - def _sort_within_groups(self, col_name: str, ascending: bool): - """Sort entries within each group, keeping group headers in place.""" - sorted_results = [] - current_group = [] - current_header = None - - for entry in self._results: - if entry.header_row: - if current_header is not None: - current_group.sort( - key=lambda e: self._sort_key(e, col_name), - reverse=not ascending, - ) - sorted_results.append(current_header) - sorted_results.extend(current_group) - current_header = entry - current_group = [] - else: - current_group.append(entry) - - # Last group - if current_header is not None: - current_group.sort( - key=lambda e: self._sort_key(e, col_name), - reverse=not ascending, - ) - sorted_results.append(current_header) - sorted_results.extend(current_group) - - self._results = sorted_results - self._rebuild_tree() - - def sort_by_column(self, column: int, ascending: bool = True): - """Public API for sorting (used by sort dialog).""" - self._sort_column = column - self._sort_order = Qt.AscendingOrder if ascending else Qt.DescendingOrder - columns = TAB_COLUMNS.get(self._active_tab, []) - col_name = columns[column] if column < len(columns) else "" - self._tree.header().setSortIndicator(column, self._sort_order) - self._tree.header().setSortIndicatorShown(True) - if self._active_tab in GROUPED_TABS: - self._sort_within_groups(col_name, ascending) - else: - self._sort_flat(col_name, ascending) - - # ── Item events ────────────────────────────────────────── - - def _on_item_changed(self, item, column): - if column == 0: - entry = item.data(0, Qt.UserRole) - if entry and not entry.header_row: - entry.checked = item.checkState(0) == Qt.Checked - self._update_selection_count() - - def _on_item_double_clicked(self, item, column): - entry = item.data(0, Qt.UserRole) - if entry and not entry.header_row: - self.item_activated.emit(entry) - - def _on_context_menu(self, pos): - item = self._tree.itemAt(pos) - if item: - entry = item.data(0, Qt.UserRole) - if entry and not entry.header_row: - menu = QMenu(self) - open_action = QAction("Open File", self) - open_action.triggered.connect(lambda: self._open_file(entry)) - menu.addAction(open_action) - - open_dir_action = QAction("Open Containing Folder", self) - open_dir_action.triggered.connect(lambda: self._open_folder(entry)) - menu.addAction(open_dir_action) - - menu.addSeparator() - - select_action = QAction("Select", self) - select_action.triggered.connect(lambda: self._set_check(item, True)) - menu.addAction(select_action) - - deselect_action = QAction("Deselect", self) - deselect_action.triggered.connect(lambda: self._set_check(item, False)) - menu.addAction(deselect_action) - - # Compare action (when 2 items are selected) - selected_items = self._tree.selectedItems() - data_items = [it for it in selected_items if it.data(0, Qt.UserRole) and not it.data(0, Qt.UserRole).header_row] - if len(data_items) == 2: - menu.addSeparator() - compare_action = QAction("Compare Selected", self) - compare_action.triggered.connect(lambda: self._compare_items(data_items[0], data_items[1])) - menu.addAction(compare_action) - - menu.exec_(self._tree.viewport().mapToGlobal(pos)) - - def _open_file(self, entry: ResultEntry): - import subprocess, sys - path = entry.values.get("__full_path", "") - if path: - if sys.platform == "linux": - subprocess.Popen(["xdg-open", path]) - elif sys.platform == "darwin": - subprocess.Popen(["open", path]) - else: - subprocess.Popen(["start", path], shell=True) - - def _open_folder(self, entry: ResultEntry): - import subprocess, sys - from pathlib import Path - path = entry.values.get("__full_path", "") - if path: - folder = str(Path(path).parent) - if sys.platform == "linux": - subprocess.Popen(["xdg-open", folder]) - elif sys.platform == "darwin": - subprocess.Popen(["open", folder]) - else: - subprocess.Popen(["explorer", folder], shell=True) - - def _set_check(self, item, checked): - item.setCheckState(0, Qt.Checked if checked else Qt.Unchecked) - - def _compare_items(self, item1, item2): - from .dialogs.diff_dialog import DiffDialog - entry1 = item1.data(0, Qt.UserRole) - entry2 = item2.data(0, Qt.UserRole) - if entry1 and entry2: - dialog = DiffDialog(entry1, entry2, self) - dialog.exec() - - # ── Summary / selection ────────────────────────────────── - - def _update_summary(self): - total = sum(1 for r in self._results if not r.header_row) - groups = sum(1 for r in self._results if r.header_row) - if self._active_tab in GROUPED_TABS and groups > 0: - self._summary_label.setText(f"Found {total} files in {groups} groups") - elif total > 0: - self._summary_label.setText(f"Found {total} entries") - else: - self._summary_label.setText("No results") - self._update_selection_count() - - def _update_selection_count(self): - selected = sum(1 for r in self._results if r.checked and not r.header_row) - total = sum(1 for r in self._results if not r.header_row) - if selected > 0: - self._selection_label.setText(f"Selected: {selected}/{total}") - else: - self._selection_label.setText("") - self.selection_changed.emit(selected) - - def apply_selection(self, mode: SelectMode): - self._tree.blockSignals(True) - - if mode == SelectMode.SELECT_ALL: - self._select_all(True) - elif mode == SelectMode.UNSELECT_ALL: - self._select_all(False) - elif mode == SelectMode.INVERT_SELECTION: - self._invert_selection() - elif mode in (SelectMode.SELECT_BIGGEST_SIZE, SelectMode.SELECT_SMALLEST_SIZE, - SelectMode.SELECT_NEWEST, SelectMode.SELECT_OLDEST, - SelectMode.SELECT_BIGGEST_RESOLUTION, SelectMode.SELECT_SMALLEST_RESOLUTION, - SelectMode.SELECT_SHORTEST_PATH, SelectMode.SELECT_LONGEST_PATH): - self._select_by_group_criteria(mode) - - self._tree.blockSignals(False) - self._update_selection_count() - - def _select_all(self, checked: bool): - for i in range(self._tree.topLevelItemCount()): - item = self._tree.topLevelItem(i) - entry = item.data(0, Qt.UserRole) - if entry and not entry.header_row: - entry.checked = checked - item.setCheckState(0, Qt.Checked if checked else Qt.Unchecked) - - def _invert_selection(self): - for i in range(self._tree.topLevelItemCount()): - item = self._tree.topLevelItem(i) - entry = item.data(0, Qt.UserRole) - if entry and not entry.header_row: - entry.checked = not entry.checked - item.setCheckState(0, Qt.Checked if entry.checked else Qt.Unchecked) - - def _select_by_group_criteria(self, mode: SelectMode): - self._select_all(False) - - if self._active_tab not in GROUPED_TABS: - return - - groups: dict[int, list[tuple[int, ResultEntry]]] = {} - for i in range(self._tree.topLevelItemCount()): - item = self._tree.topLevelItem(i) - entry = item.data(0, Qt.UserRole) - if entry and not entry.header_row: - groups.setdefault(entry.group_id, []).append((i, entry)) - - for group_id, items in groups.items(): - if len(items) <= 1: - continue - - best_idx = 0 - if mode == SelectMode.SELECT_BIGGEST_SIZE: - best_idx = max(range(len(items)), key=lambda j: items[j][1].values.get("__size_bytes", 0)) - elif mode == SelectMode.SELECT_SMALLEST_SIZE: - best_idx = min(range(len(items)), key=lambda j: items[j][1].values.get("__size_bytes", 0)) - elif mode == SelectMode.SELECT_NEWEST: - best_idx = max(range(len(items)), key=lambda j: items[j][1].values.get("__modified_date_ts", 0)) - elif mode == SelectMode.SELECT_OLDEST: - best_idx = min(range(len(items)), key=lambda j: items[j][1].values.get("__modified_date_ts", 0)) - elif mode == SelectMode.SELECT_SHORTEST_PATH: - best_idx = min(range(len(items)), key=lambda j: len(items[j][1].values.get("__full_path", ""))) - elif mode == SelectMode.SELECT_LONGEST_PATH: - best_idx = max(range(len(items)), key=lambda j: len(items[j][1].values.get("__full_path", ""))) - - for j, (tree_idx, entry) in enumerate(items): - if j != best_idx: - entry.checked = True - self._tree.topLevelItem(tree_idx).setCheckState(0, Qt.Checked) - - # ── Public accessors ───────────────────────────────────── - - def get_checked_entries(self) -> list[ResultEntry]: - return [r for r in self._results if r.checked and not r.header_row] - - def get_all_entries(self) -> list[ResultEntry]: - return [r for r in self._results if not r.header_row] - - def clear(self): - self._results = [] - self._tree.clear() - self._sort_column = -1 - self._tree.header().setSortIndicatorShown(False) - self._summary_label.setText("No results") - self._selection_label.setText("") diff --git a/czkawka_pyside6/app/settings_panel.py b/czkawka_pyside6/app/settings_panel.py deleted file mode 100644 index 805f6664d..000000000 --- a/czkawka_pyside6/app/settings_panel.py +++ /dev/null @@ -1,264 +0,0 @@ -from PySide6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QLabel, QCheckBox, - QLineEdit, QSpinBox, QGroupBox, QFormLayout, QScrollArea, - QPushButton, QListWidget, QFileDialog, QDoubleSpinBox, - QTabWidget, QSizePolicy -) -from PySide6.QtCore import Qt, Signal - -from .models import AppSettings - - -class SettingsPanel(QWidget): - """Global application settings panel.""" - settings_changed = Signal() - close_requested = Signal() - - def __init__(self, settings: AppSettings, parent=None): - super().__init__(parent) - self._settings = settings - self._setup_ui() - - def _setup_ui(self): - main_layout = QVBoxLayout(self) - - # Header - header = QHBoxLayout() - title = QLabel("Settings") - font = title.font() - font.setBold(True) - font.setPointSize(font.pointSize() + 2) - title.setFont(font) - header.addWidget(title) - header.addStretch() - close_btn = QPushButton("Close") - close_btn.clicked.connect(self.close_requested.emit) - header.addWidget(close_btn) - main_layout.addLayout(header) - - # Tabs - tabs = QTabWidget() - - # General tab - tabs.addTab(self._create_general_tab(), "General") - # Directories tab - tabs.addTab(self._create_directories_tab(), "Directories") - # Filters tab - tabs.addTab(self._create_filters_tab(), "Filters") - # Preview tab - tabs.addTab(self._create_preview_tab(), "Preview") - - main_layout.addWidget(tabs) - - def _create_general_tab(self) -> QWidget: - scroll = QScrollArea() - scroll.setWidgetResizable(True) - widget = QWidget() - layout = QFormLayout(widget) - - # CLI path - cli_layout = QHBoxLayout() - self._cli_path = QLineEdit(self._settings.czkawka_cli_path) - self._cli_path.textChanged.connect( - lambda t: setattr(self._settings, 'czkawka_cli_path', t) - ) - cli_layout.addWidget(self._cli_path) - browse_btn = QPushButton("Browse") - browse_btn.clicked.connect(self._browse_cli) - cli_layout.addWidget(browse_btn) - layout.addRow("czkawka_cli Path:", cli_layout) - - # Thread number - self._threads = QSpinBox() - self._threads.setRange(0, 64) - self._threads.setValue(self._settings.thread_number) - self._threads.setSpecialValueText("Auto (all cores)") - self._threads.valueChanged.connect( - lambda v: setattr(self._settings, 'thread_number', v) - ) - layout.addRow("Thread Count:", self._threads) - - # Recursive search - recursive = QCheckBox("Recursive search") - recursive.setChecked(self._settings.recursive_search) - recursive.toggled.connect( - lambda v: setattr(self._settings, 'recursive_search', v) - ) - layout.addRow(recursive) - - # Use cache - cache = QCheckBox("Use cache for faster rescans") - cache.setChecked(self._settings.use_cache) - cache.toggled.connect( - lambda v: setattr(self._settings, 'use_cache', v) - ) - layout.addRow(cache) - - # Move to trash - trash = QCheckBox("Move to trash instead of permanent delete") - trash.setChecked(self._settings.move_to_trash) - trash.toggled.connect( - lambda v: setattr(self._settings, 'move_to_trash', v) - ) - layout.addRow(trash) - - # Hide hard links - hardlinks = QCheckBox("Hide hard links") - hardlinks.setChecked(self._settings.hide_hard_links) - hardlinks.toggled.connect( - lambda v: setattr(self._settings, 'hide_hard_links', v) - ) - layout.addRow(hardlinks) - - # Save as JSON - save_json = QCheckBox("Save results as JSON (instead of text)") - save_json.setChecked(self._settings.save_as_json) - save_json.toggled.connect( - lambda v: setattr(self._settings, 'save_as_json', v) - ) - layout.addRow(save_json) - - scroll.setWidget(widget) - return scroll - - def _create_directories_tab(self) -> QWidget: - widget = QWidget() - layout = QVBoxLayout(widget) - - # Included paths - inc_group = QGroupBox("Included Directories") - inc_layout = QVBoxLayout(inc_group) - - self._inc_list = QListWidget() - for path in self._settings.included_paths: - self._inc_list.addItem(path) - inc_layout.addWidget(self._inc_list) - - inc_btns = QHBoxLayout() - add_inc = QPushButton("Add") - add_inc.clicked.connect(self._add_included) - inc_btns.addWidget(add_inc) - rem_inc = QPushButton("Remove") - rem_inc.clicked.connect(self._remove_included) - inc_btns.addWidget(rem_inc) - inc_btns.addStretch() - inc_layout.addLayout(inc_btns) - layout.addWidget(inc_group) - - # Excluded paths - exc_group = QGroupBox("Excluded Directories") - exc_layout = QVBoxLayout(exc_group) - - self._exc_list = QListWidget() - for path in self._settings.excluded_paths: - self._exc_list.addItem(path) - exc_layout.addWidget(self._exc_list) - - exc_btns = QHBoxLayout() - add_exc = QPushButton("Add") - add_exc.clicked.connect(self._add_excluded) - exc_btns.addWidget(add_exc) - rem_exc = QPushButton("Remove") - rem_exc.clicked.connect(self._remove_excluded) - exc_btns.addWidget(rem_exc) - exc_btns.addStretch() - exc_layout.addLayout(exc_btns) - layout.addWidget(exc_group) - - return widget - - def _create_filters_tab(self) -> QWidget: - widget = QWidget() - layout = QFormLayout(widget) - - # Excluded items - self._excluded_items = QLineEdit(self._settings.excluded_items) - self._excluded_items.setPlaceholderText("Wildcard patterns, comma-separated (e.g. *.tmp,cache_*)") - self._excluded_items.textChanged.connect( - lambda t: setattr(self._settings, 'excluded_items', t) - ) - layout.addRow("Excluded Items:", self._excluded_items) - - # Allowed extensions - self._allowed_ext = QLineEdit(self._settings.allowed_extensions) - self._allowed_ext.setPlaceholderText("e.g. jpg,png,gif") - self._allowed_ext.textChanged.connect( - lambda t: setattr(self._settings, 'allowed_extensions', t) - ) - layout.addRow("Allowed Extensions:", self._allowed_ext) - - # Excluded extensions - self._excluded_ext = QLineEdit(self._settings.excluded_extensions) - self._excluded_ext.setPlaceholderText("e.g. log,tmp") - self._excluded_ext.textChanged.connect( - lambda t: setattr(self._settings, 'excluded_extensions', t) - ) - layout.addRow("Excluded Extensions:", self._excluded_ext) - - # Min file size - self._min_size = QLineEdit(self._settings.minimum_file_size) - self._min_size.setPlaceholderText("In bytes (e.g. 1024)") - self._min_size.textChanged.connect( - lambda t: setattr(self._settings, 'minimum_file_size', t) - ) - layout.addRow("Minimum File Size:", self._min_size) - - # Max file size - self._max_size = QLineEdit(self._settings.maximum_file_size) - self._max_size.setPlaceholderText("In bytes (leave empty for no limit)") - self._max_size.textChanged.connect( - lambda t: setattr(self._settings, 'maximum_file_size', t) - ) - layout.addRow("Maximum File Size:", self._max_size) - - return widget - - def _create_preview_tab(self) -> QWidget: - widget = QWidget() - layout = QVBoxLayout(widget) - - preview = QCheckBox("Show image preview") - preview.setChecked(self._settings.show_image_preview) - preview.toggled.connect( - lambda v: setattr(self._settings, 'show_image_preview', v) - ) - layout.addWidget(preview) - - layout.addStretch() - return widget - - def _browse_cli(self): - path, _ = QFileDialog.getOpenFileName( - self, "Select czkawka_cli binary", "", - "Executables (*);;All Files (*)" - ) - if path: - self._cli_path.setText(path) - - def _add_included(self): - path = QFileDialog.getExistingDirectory(self, "Select Directory to Include") - if path and path not in self._settings.included_paths: - self._settings.included_paths.append(path) - self._inc_list.addItem(path) - self.settings_changed.emit() - - def _remove_included(self): - row = self._inc_list.currentRow() - if row >= 0: - self._inc_list.takeItem(row) - self._settings.included_paths.pop(row) - self.settings_changed.emit() - - def _add_excluded(self): - path = QFileDialog.getExistingDirectory(self, "Select Directory to Exclude") - if path and path not in self._settings.excluded_paths: - self._settings.excluded_paths.append(path) - self._exc_list.addItem(path) - self.settings_changed.emit() - - def _remove_excluded(self): - row = self._exc_list.currentRow() - if row >= 0: - self._exc_list.takeItem(row) - self._settings.excluded_paths.pop(row) - self.settings_changed.emit() diff --git a/czkawka_pyside6/app/tool_settings.py b/czkawka_pyside6/app/tool_settings.py deleted file mode 100644 index 87d4249f0..000000000 --- a/czkawka_pyside6/app/tool_settings.py +++ /dev/null @@ -1,506 +0,0 @@ -from PySide6.QtWidgets import ( - QWidget, QVBoxLayout, QLabel, QComboBox, QCheckBox, - QSlider, QLineEdit, QGroupBox, QFormLayout, QScrollArea, - QHBoxLayout, QSpinBox, QPushButton, QSizePolicy -) -from PySide6.QtCore import Qt, Signal - -from .models import ( - ActiveTab, ToolSettings, CheckingMethod, HashType, - ImageHashAlg, ImageFilter, MusicSearchMethod, - VideoCropDetect, VideoCropMechanism, VideoCodec, -) - - -class ToolSettingsPanel(QWidget): - """Per-tool settings panel shown on the right side.""" - settings_changed = Signal() - - def __init__(self, tool_settings: ToolSettings, parent=None): - super().__init__(parent) - self._ts = tool_settings - self._active_tab = ActiveTab.DUPLICATE_FILES - self.setMinimumWidth(250) - self.setMaximumWidth(350) - self._panels: dict[ActiveTab, QWidget] = {} - self._setup_ui() - - def _setup_ui(self): - main_layout = QVBoxLayout(self) - main_layout.setContentsMargins(4, 4, 4, 4) - - title = QLabel("Tool Settings") - font = title.font() - font.setBold(True) - title.setFont(font) - main_layout.addWidget(title) - - self._scroll = QScrollArea() - self._scroll.setWidgetResizable(True) - self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - - self._container = QWidget() - self._container_layout = QVBoxLayout(self._container) - self._container_layout.setContentsMargins(0, 0, 0, 0) - - # Create all panel widgets - self._panels[ActiveTab.DUPLICATE_FILES] = self._create_duplicate_panel() - self._panels[ActiveTab.SIMILAR_IMAGES] = self._create_similar_images_panel() - self._panels[ActiveTab.SIMILAR_VIDEOS] = self._create_similar_videos_panel() - self._panels[ActiveTab.SIMILAR_MUSIC] = self._create_similar_music_panel() - self._panels[ActiveTab.BIG_FILES] = self._create_big_files_panel() - self._panels[ActiveTab.BROKEN_FILES] = self._create_broken_files_panel() - self._panels[ActiveTab.BAD_NAMES] = self._create_bad_names_panel() - self._panels[ActiveTab.EXIF_REMOVER] = self._create_exif_panel() - self._panels[ActiveTab.VIDEO_OPTIMIZER] = self._create_video_optimizer_panel() - - for panel in self._panels.values(): - self._container_layout.addWidget(panel) - panel.setVisible(False) - - self._container_layout.addStretch() - self._scroll.setWidget(self._container) - main_layout.addWidget(self._scroll) - - def set_active_tab(self, tab: ActiveTab): - self._active_tab = tab - for t, panel in self._panels.items(): - panel.setVisible(t == tab) - - def _create_duplicate_panel(self) -> QWidget: - panel = QWidget() - layout = QFormLayout(panel) - - # Check method - self._dup_method = QComboBox() - self._dup_method.addItems(["Hash", "Size", "Name", "Size and Name"]) - method_map = {CheckingMethod.HASH: 0, CheckingMethod.SIZE: 1, - CheckingMethod.NAME: 2, CheckingMethod.SIZE_NAME: 3} - self._dup_method.setCurrentIndex(method_map.get(self._ts.dup_check_method, 0)) - self._dup_method.currentIndexChanged.connect(self._on_dup_method_changed) - layout.addRow("Check Method:", self._dup_method) - - # Hash type - self._dup_hash = QComboBox() - self._dup_hash.addItems(["Blake3", "CRC32", "XXH3"]) - hash_map = {HashType.BLAKE3: 0, HashType.CRC32: 1, HashType.XXH3: 2} - self._dup_hash.setCurrentIndex(hash_map.get(self._ts.dup_hash_type, 0)) - self._dup_hash.currentIndexChanged.connect(self._on_dup_hash_changed) - layout.addRow("Hash Type:", self._dup_hash) - - # Case sensitive - self._dup_case = QCheckBox("Case sensitive name comparison") - self._dup_case.setChecked(self._ts.dup_name_case_sensitive) - self._dup_case.toggled.connect(lambda v: setattr(self._ts, 'dup_name_case_sensitive', v)) - layout.addRow(self._dup_case) - - return panel - - def _on_dup_method_changed(self, idx): - methods = [CheckingMethod.HASH, CheckingMethod.SIZE, - CheckingMethod.NAME, CheckingMethod.SIZE_NAME] - self._ts.dup_check_method = methods[idx] - self.settings_changed.emit() - - def _on_dup_hash_changed(self, idx): - hashes = [HashType.BLAKE3, HashType.CRC32, HashType.XXH3] - self._ts.dup_hash_type = hashes[idx] - self.settings_changed.emit() - - def _create_similar_images_panel(self) -> QWidget: - panel = QWidget() - layout = QFormLayout(panel) - - # Hash size - self._img_hash_size = QComboBox() - self._img_hash_size.addItems(["8", "16", "32", "64"]) - size_map = {8: 0, 16: 1, 32: 2, 64: 3} - self._img_hash_size.setCurrentIndex(size_map.get(self._ts.img_hash_size, 1)) - self._img_hash_size.currentIndexChanged.connect( - lambda idx: setattr(self._ts, 'img_hash_size', [8, 16, 32, 64][idx]) - ) - layout.addRow("Hash Size:", self._img_hash_size) - - # Resize algorithm - self._img_filter = QComboBox() - self._img_filter.addItems(["Lanczos3", "Gaussian", "CatmullRom", "Triangle", "Nearest"]) - filters = [ImageFilter.LANCZOS3, ImageFilter.GAUSSIAN, ImageFilter.CATMULL_ROM, - ImageFilter.TRIANGLE, ImageFilter.NEAREST] - filter_idx = filters.index(self._ts.img_filter) if self._ts.img_filter in filters else 4 - self._img_filter.setCurrentIndex(filter_idx) - self._img_filter.currentIndexChanged.connect( - lambda idx: setattr(self._ts, 'img_filter', filters[idx]) - ) - layout.addRow("Resize Algorithm:", self._img_filter) - - # Hash type - self._img_hash_alg = QComboBox() - self._img_hash_alg.addItems(["Mean", "Gradient", "BlockHash", "VertGradient", - "DoubleGradient", "Median"]) - algs = [ImageHashAlg.MEAN, ImageHashAlg.GRADIENT, ImageHashAlg.BLOCKHASH, - ImageHashAlg.VERT_GRADIENT, ImageHashAlg.DOUBLE_GRADIENT, ImageHashAlg.MEDIAN] - alg_idx = algs.index(self._ts.img_hash_alg) if self._ts.img_hash_alg in algs else 1 - self._img_hash_alg.setCurrentIndex(alg_idx) - self._img_hash_alg.currentIndexChanged.connect( - lambda idx: setattr(self._ts, 'img_hash_alg', algs[idx]) - ) - layout.addRow("Hash Type:", self._img_hash_alg) - - # Ignore same size - self._img_ignore_size = QCheckBox("Ignore same size") - self._img_ignore_size.setChecked(self._ts.img_ignore_same_size) - self._img_ignore_size.toggled.connect( - lambda v: setattr(self._ts, 'img_ignore_same_size', v) - ) - layout.addRow(self._img_ignore_size) - - # Max difference slider - diff_layout = QHBoxLayout() - self._img_diff_slider = QSlider(Qt.Horizontal) - self._img_diff_slider.setRange(0, 40) - self._img_diff_slider.setValue(self._ts.img_max_difference) - self._img_diff_label = QLabel(str(self._ts.img_max_difference)) - self._img_diff_slider.valueChanged.connect(self._on_img_diff_changed) - diff_layout.addWidget(self._img_diff_slider) - diff_layout.addWidget(self._img_diff_label) - layout.addRow("Max Difference:", diff_layout) - - return panel - - def _on_img_diff_changed(self, value): - self._ts.img_max_difference = value - self._img_diff_label.setText(str(value)) - self.settings_changed.emit() - - def _create_similar_videos_panel(self) -> QWidget: - panel = QWidget() - layout = QFormLayout(panel) - - # Ignore same size - vid_ignore = QCheckBox("Ignore same size") - vid_ignore.setChecked(self._ts.vid_ignore_same_size) - vid_ignore.toggled.connect(lambda v: setattr(self._ts, 'vid_ignore_same_size', v)) - layout.addRow(vid_ignore) - - # Crop detect - self._vid_crop = QComboBox() - self._vid_crop.addItems(["LetterBox", "Motion", "None"]) - crops = [VideoCropDetect.LETTERBOX, VideoCropDetect.MOTION, VideoCropDetect.NONE] - crop_idx = crops.index(self._ts.vid_crop_detect) if self._ts.vid_crop_detect in crops else 0 - self._vid_crop.setCurrentIndex(crop_idx) - self._vid_crop.currentIndexChanged.connect( - lambda idx: setattr(self._ts, 'vid_crop_detect', crops[idx]) - ) - layout.addRow("Crop Detect:", self._vid_crop) - - # Max difference - diff_layout = QHBoxLayout() - self._vid_diff_slider = QSlider(Qt.Horizontal) - self._vid_diff_slider.setRange(0, 20) - self._vid_diff_slider.setValue(self._ts.vid_max_difference) - self._vid_diff_label = QLabel(str(self._ts.vid_max_difference)) - self._vid_diff_slider.valueChanged.connect(lambda v: ( - setattr(self._ts, 'vid_max_difference', v), - self._vid_diff_label.setText(str(v)) - )) - diff_layout.addWidget(self._vid_diff_slider) - diff_layout.addWidget(self._vid_diff_label) - layout.addRow("Max Difference:", diff_layout) - - # Skip forward - skip_layout = QHBoxLayout() - self._vid_skip_slider = QSlider(Qt.Horizontal) - self._vid_skip_slider.setRange(1, 360) - self._vid_skip_slider.setValue(self._ts.vid_skip_forward) - self._vid_skip_label = QLabel(str(self._ts.vid_skip_forward)) - self._vid_skip_slider.valueChanged.connect(lambda v: ( - setattr(self._ts, 'vid_skip_forward', v), - self._vid_skip_label.setText(str(v)) - )) - skip_layout.addWidget(self._vid_skip_slider) - skip_layout.addWidget(self._vid_skip_label) - layout.addRow("Skip Forward (s):", skip_layout) - - # Duration - dur_layout = QHBoxLayout() - self._vid_dur_slider = QSlider(Qt.Horizontal) - self._vid_dur_slider.setRange(1, 60) - self._vid_dur_slider.setValue(self._ts.vid_duration) - self._vid_dur_label = QLabel(str(self._ts.vid_duration)) - self._vid_dur_slider.valueChanged.connect(lambda v: ( - setattr(self._ts, 'vid_duration', v), - self._vid_dur_label.setText(str(v)) - )) - dur_layout.addWidget(self._vid_dur_slider) - dur_layout.addWidget(self._vid_dur_label) - layout.addRow("Hash Duration (s):", dur_layout) - - return panel - - def _create_similar_music_panel(self) -> QWidget: - panel = QWidget() - layout = QFormLayout(panel) - - # Search method - self._music_method = QComboBox() - self._music_method.addItems(["Tags", "Fingerprint"]) - self._music_method.setCurrentIndex( - 0 if self._ts.music_search_method == MusicSearchMethod.TAGS else 1 - ) - self._music_method.currentIndexChanged.connect(self._on_music_method_changed) - layout.addRow("Audio Check Type:", self._music_method) - - # Tags group - self._tags_group = QGroupBox("Tag Matching") - tags_layout = QVBoxLayout(self._tags_group) - - self._music_approx = QCheckBox("Approximate comparison") - self._music_approx.setChecked(self._ts.music_approximate) - self._music_approx.toggled.connect(lambda v: setattr(self._ts, 'music_approximate', v)) - tags_layout.addWidget(self._music_approx) - - for attr, label in [ - ("music_title", "Title"), ("music_artist", "Artist"), - ("music_bitrate", "Bitrate"), ("music_genre", "Genre"), - ("music_year", "Year"), ("music_length", "Length"), - ]: - cb = QCheckBox(label) - cb.setChecked(getattr(self._ts, attr)) - cb.toggled.connect(lambda v, a=attr: setattr(self._ts, a, v)) - tags_layout.addWidget(cb) - - layout.addRow(self._tags_group) - - # Fingerprint group - self._fp_group = QGroupBox("Fingerprint Matching") - fp_layout = QFormLayout(self._fp_group) - - fp_similar = QCheckBox("Compare with similar titles") - fp_similar.setChecked(self._ts.music_compare_fingerprints_similar_titles) - fp_similar.toggled.connect( - lambda v: setattr(self._ts, 'music_compare_fingerprints_similar_titles', v) - ) - fp_layout.addRow(fp_similar) - - diff_layout = QHBoxLayout() - self._music_diff_slider = QSlider(Qt.Horizontal) - self._music_diff_slider.setRange(0, 100) - self._music_diff_slider.setValue(int(self._ts.music_max_difference * 10)) - self._music_diff_label = QLabel(f"{self._ts.music_max_difference:.1f}") - self._music_diff_slider.valueChanged.connect(lambda v: ( - setattr(self._ts, 'music_max_difference', v / 10.0), - self._music_diff_label.setText(f"{v / 10.0:.1f}") - )) - diff_layout.addWidget(self._music_diff_slider) - diff_layout.addWidget(self._music_diff_label) - fp_layout.addRow("Max Difference:", diff_layout) - - layout.addRow(self._fp_group) - self._on_music_method_changed(self._music_method.currentIndex()) - - return panel - - def _on_music_method_changed(self, idx): - if idx == 0: - self._ts.music_search_method = MusicSearchMethod.TAGS - else: - self._ts.music_search_method = MusicSearchMethod.CONTENT - self._tags_group.setVisible(idx == 0) - self._fp_group.setVisible(idx == 1) - self.settings_changed.emit() - - def _create_big_files_panel(self) -> QWidget: - panel = QWidget() - layout = QFormLayout(panel) - - self._big_mode = QComboBox() - self._big_mode.addItems(["The Biggest", "The Smallest"]) - self._big_mode.setCurrentIndex(0 if self._ts.big_files_mode == "biggest" else 1) - self._big_mode.currentIndexChanged.connect( - lambda idx: setattr(self._ts, 'big_files_mode', "biggest" if idx == 0 else "smallest") - ) - layout.addRow("Method:", self._big_mode) - - self._big_count = QSpinBox() - self._big_count.setRange(1, 100000) - self._big_count.setValue(self._ts.big_files_number) - self._big_count.valueChanged.connect( - lambda v: setattr(self._ts, 'big_files_number', v) - ) - layout.addRow("Number of Files:", self._big_count) - - return panel - - def _create_broken_files_panel(self) -> QWidget: - panel = QWidget() - layout = QVBoxLayout(panel) - layout.addWidget(QLabel("File types to check:")) - - for attr, label in [ - ("broken_audio", "Audio"), ("broken_pdf", "PDF"), - ("broken_archive", "Archive"), ("broken_image", "Image"), - ("broken_video", "Video"), - ]: - cb = QCheckBox(label) - cb.setChecked(getattr(self._ts, attr)) - cb.toggled.connect(lambda v, a=attr: setattr(self._ts, a, v)) - layout.addWidget(cb) - - layout.addStretch() - return panel - - def _create_bad_names_panel(self) -> QWidget: - panel = QWidget() - layout = QVBoxLayout(panel) - layout.addWidget(QLabel("Check for:")) - - checks = [ - ("bad_names_uppercase_ext", "Uppercase extension", - "Files with .JPG, .PNG etc."), - ("bad_names_emoji", "Emoji in name", - "Files containing emoji characters"), - ("bad_names_space", "Space at start/end", - "Leading or trailing whitespace"), - ("bad_names_non_ascii", "Non-ASCII characters", - "Characters outside ASCII range"), - ("bad_names_remove_duplicated", "Remove duplicated non-alphanumeric", - "e.g. file--name..txt"), - ] - - for attr, label, hint in checks: - cb = QCheckBox(label) - cb.setChecked(getattr(self._ts, attr)) - cb.setToolTip(hint) - cb.toggled.connect(lambda v, a=attr: setattr(self._ts, a, v)) - layout.addWidget(cb) - - # Restricted charset - layout.addWidget(QLabel("Restricted charset:")) - self._bad_names_charset = QLineEdit(self._ts.bad_names_restricted_charset) - self._bad_names_charset.setPlaceholderText("Allowed special chars, comma-separated") - self._bad_names_charset.textChanged.connect( - lambda t: setattr(self._ts, 'bad_names_restricted_charset', t) - ) - layout.addWidget(self._bad_names_charset) - - layout.addStretch() - return panel - - def _create_exif_panel(self) -> QWidget: - panel = QWidget() - layout = QFormLayout(panel) - - self._exif_tags = QLineEdit(self._ts.exif_ignored_tags) - self._exif_tags.setPlaceholderText("Tags to ignore, comma-separated") - self._exif_tags.textChanged.connect( - lambda t: setattr(self._ts, 'exif_ignored_tags', t) - ) - layout.addRow("Ignored EXIF Tags:", self._exif_tags) - - return panel - - def _create_video_optimizer_panel(self) -> QWidget: - panel = QWidget() - layout = QFormLayout(panel) - - # Mode - self._vo_mode = QComboBox() - self._vo_mode.addItems(["Crop", "Transcode"]) - self._vo_mode.setCurrentIndex(0 if self._ts.video_opt_mode == "crop" else 1) - self._vo_mode.currentIndexChanged.connect(self._on_vo_mode_changed) - layout.addRow("Mode:", self._vo_mode) - - # Crop settings - self._crop_group = QGroupBox("Crop Settings") - crop_layout = QFormLayout(self._crop_group) - - self._vo_crop_type = QComboBox() - self._vo_crop_type.addItems(["Black Bars", "Static Content"]) - mechs = [VideoCropMechanism.BLACKBARS, VideoCropMechanism.STATICCONTENT] - mech_idx = mechs.index(self._ts.video_crop_mechanism) if self._ts.video_crop_mechanism in mechs else 0 - self._vo_crop_type.setCurrentIndex(mech_idx) - self._vo_crop_type.currentIndexChanged.connect( - lambda idx: setattr(self._ts, 'video_crop_mechanism', mechs[idx]) - ) - crop_layout.addRow("Crop Type:", self._vo_crop_type) - - self._vo_threshold = QSpinBox() - self._vo_threshold.setRange(0, 128) - self._vo_threshold.setValue(self._ts.video_black_pixel_threshold) - self._vo_threshold.valueChanged.connect( - lambda v: setattr(self._ts, 'video_black_pixel_threshold', v) - ) - crop_layout.addRow("Black Pixel Threshold:", self._vo_threshold) - - self._vo_bar_pct = QSpinBox() - self._vo_bar_pct.setRange(50, 100) - self._vo_bar_pct.setValue(self._ts.video_black_bar_percentage) - self._vo_bar_pct.valueChanged.connect( - lambda v: setattr(self._ts, 'video_black_bar_percentage', v) - ) - crop_layout.addRow("Black Bar Min %:", self._vo_bar_pct) - - self._vo_samples = QSpinBox() - self._vo_samples.setRange(5, 1000) - self._vo_samples.setValue(self._ts.video_max_samples) - self._vo_samples.valueChanged.connect( - lambda v: setattr(self._ts, 'video_max_samples', v) - ) - crop_layout.addRow("Max Samples:", self._vo_samples) - - self._vo_min_crop = QSpinBox() - self._vo_min_crop.setRange(1, 1000) - self._vo_min_crop.setValue(self._ts.video_min_crop_size) - self._vo_min_crop.valueChanged.connect( - lambda v: setattr(self._ts, 'video_min_crop_size', v) - ) - crop_layout.addRow("Min Crop Size:", self._vo_min_crop) - - layout.addRow(self._crop_group) - - # Transcode settings - self._transcode_group = QGroupBox("Transcode Settings") - tc_layout = QFormLayout(self._transcode_group) - - self._vo_codecs = QLineEdit(self._ts.video_excluded_codecs) - self._vo_codecs.textChanged.connect( - lambda t: setattr(self._ts, 'video_excluded_codecs', t) - ) - tc_layout.addRow("Excluded Codecs:", self._vo_codecs) - - self._vo_codec = QComboBox() - self._vo_codec.addItems(["H264", "H265", "AV1", "VP9"]) - codecs = [VideoCodec.H264, VideoCodec.H265, VideoCodec.AV1, VideoCodec.VP9] - codec_idx = codecs.index(self._ts.video_codec) if self._ts.video_codec in codecs else 1 - self._vo_codec.setCurrentIndex(codec_idx) - self._vo_codec.currentIndexChanged.connect( - lambda idx: setattr(self._ts, 'video_codec', codecs[idx]) - ) - tc_layout.addRow("Target Codec:", self._vo_codec) - - self._vo_quality = QSpinBox() - self._vo_quality.setRange(0, 51) - self._vo_quality.setValue(self._ts.video_quality) - self._vo_quality.valueChanged.connect( - lambda v: setattr(self._ts, 'video_quality', v) - ) - tc_layout.addRow("Quality:", self._vo_quality) - - self._vo_fail_bigger = QCheckBox("Fail if not smaller") - self._vo_fail_bigger.setChecked(self._ts.video_fail_if_bigger) - self._vo_fail_bigger.toggled.connect( - lambda v: setattr(self._ts, 'video_fail_if_bigger', v) - ) - tc_layout.addRow(self._vo_fail_bigger) - - layout.addRow(self._transcode_group) - - self._on_vo_mode_changed(self._vo_mode.currentIndex()) - return panel - - def _on_vo_mode_changed(self, idx): - mode = "crop" if idx == 0 else "transcode" - self._ts.video_opt_mode = mode - self._crop_group.setVisible(idx == 0) - self._transcode_group.setVisible(idx == 1) - self.settings_changed.emit() diff --git a/data/com.github.qarmin.czkawka-pyside6.desktop b/data/com.github.qarmin.kalka.desktop similarity index 81% rename from data/com.github.qarmin.czkawka-pyside6.desktop rename to data/com.github.qarmin.kalka.desktop index 6e9c8eac8..671cabad6 100644 --- a/data/com.github.qarmin.czkawka-pyside6.desktop +++ b/data/com.github.qarmin.kalka.desktop @@ -1,12 +1,12 @@ [Desktop Entry] Categories=System;FileTools;Qt; -Exec=czkawka-pyside6 +Exec=kalka Icon=com.github.qarmin.czkawka -StartupWMClass=czkawka-pyside6 +StartupWMClass=kalka Terminal=false Type=Application -Name=Czkawka PySide6 +Name=Kalka GenericName=File Cleaner Comment=Multi-functional app to find duplicates, empty folders, similar files and more - Qt/PySide6 edition. Keywords=Czkawka;duplicate;same;similar;cleaner;copy;copies;compare;files;Qt; diff --git a/data/com.github.qarmin.czkawka-pyside6.metainfo.xml b/data/com.github.qarmin.kalka.metainfo.xml similarity index 78% rename from data/com.github.qarmin.czkawka-pyside6.metainfo.xml rename to data/com.github.qarmin.kalka.metainfo.xml index e85d44343..b86a8118c 100644 --- a/data/com.github.qarmin.czkawka-pyside6.metainfo.xml +++ b/data/com.github.qarmin.kalka.metainfo.xml @@ -1,12 +1,12 @@ - com.github.qarmin.czkawka-pyside6 - Czkawka PySide6 + com.github.qarmin.kalka + Kalka Multi-functional app to find duplicates, similar images and more - Qt/PySide6 edition CC0-1.0 MIT -

Czkawka PySide6 is a Qt 6 GUI frontend for the Czkawka file cleanup tool. It uses czkawka_cli as its backend and provides feature parity with the Krokiet (Slint) interface.

+

Kalka is a Qt 6 GUI frontend for the Czkawka file cleanup tool. It uses czkawka_cli as its backend and provides feature parity with the Krokiet (Slint) interface.

It can find:

  • Duplicates (by hash, name, size)
  • @@ -23,7 +23,7 @@
  • EXIF metadata to remove
- com.github.qarmin.czkawka-pyside6.desktop + com.github.qarmin.kalka.desktop diff --git a/kalka/CONTRIBUTING.md b/kalka/CONTRIBUTING.md new file mode 100644 index 000000000..4e0f29418 --- /dev/null +++ b/kalka/CONTRIBUTING.md @@ -0,0 +1,264 @@ +# Contributing to Kalka + +Thanks for your interest in contributing to Kalka! This guide will help you get started. + +--- + +## Branching Model + +> **`master`** is the single source-of-truth branch. +> +> **How contributors should work:** +> 1. Fork the repository +> 2. Create a `feat/*` or `fix/*` branch from `master` +> 3. Open a PR targeting `master` + +## First-Time Contributors + +Welcome — contributions of all sizes are valued. If this is your first contribution, here is how to get started: + +1. **Find an issue.** Look for issues labeled `good first issue` — these are scoped for newcomers and include context to get moving quickly. + +2. **Pick a scope.** Good first contributions include: + - Typo and documentation fixes + - Translation improvements (see [Adding Translations](#adding-translations)) + - Test additions or improvements + - Small bug fixes with clear reproduction steps + +3. **Follow the fork → branch → change → test → PR workflow:** + - Fork the repository and clone your fork + - Create a feature branch (`git checkout -b feat/my-change` or `git checkout -b fix/my-change`) + - Make your changes and run the quality checks (see [Development Setup](#development-setup)) + - Open a PR against `master` + +4. **Start with Track A.** Kalka uses three [collaboration tracks](#collaboration-tracks-risk-based) (A/B/C) based on risk. First-time contributors should target **Track A** (docs, tests, translations) — these require lighter review and are the fastest path to a merged PR. + +If you get stuck, open a draft PR early and ask questions in the description. + +## Development Setup + +### Prerequisites + +- Python 3.10+ +- PySide6 >= 6.6.0 +- `czkawka_cli` binary (built from the project root or installed via `cargo install`) + +### Getting Started + +```bash +# Clone the repo +git clone https://github.com/qarmin/czkawka.git +cd czkawka/kalka + +# Create a virtual environment +python3 -m venv .venv +source .venv/bin/activate + +# Install dependencies +pip install -r requirements.txt + +# Build czkawka_cli (from the project root) +cd .. +cargo build --release -p czkawka_cli +cd kalka + +# Run the application +python main.py + +# Run tests +python -m pytest tests/ -v + +# Format & lint (required before PR) +python -m ruff check app/ --fix +python -m ruff format app/ +``` + +### Pre-commit Checks + +Before submitting a PR, ensure: + +```bash +# Syntax check all Python files +python -c "import ast, glob; [ast.parse(open(f).read()) for f in glob.glob('app/**/*.py', recursive=True)]; print('OK')" + +# Verify translation keys match across all locales +diff <(grep -oP '^[a-z][-a-z0-9]+' i18n/en/kalka.ftl | sort) <(grep -oP '^[a-z][-a-z0-9]+' i18n/pl/kalka.ftl | sort) + +# Run the app to verify no import errors +python -c "from app.localizer import tr, init; init(); print('i18n OK')" +``` + +## Project Architecture + +``` +kalka/ +├── main.py # Entry point +├── requirements.txt # Python dependencies +├── i18n.toml # Fluent i18n configuration +├── i18n/ # Translation files (Fluent .ftl format) +│ ├── en/kalka.ftl # English (fallback) +│ ├── pl/kalka.ftl # Polish +│ └── .../kalka.ftl # Other locales +├── icons/ # Application icons +├── app/ +│ ├── localizer.py # i18n infrastructure (Fluent) +│ ├── main_window.py # Main window with all panels +│ ├── left_panel.py # Tool selection sidebar (14 tools) +│ ├── results_view.py # Results tree with grouping, selection, sorting +│ ├── action_buttons.py # Scan/Stop/Delete/Move/Save/Sort buttons +│ ├── tool_settings.py # Per-tool settings (9 tool panels) +│ ├── settings_panel.py # Global settings (General/Directories/Filters/Preview) +│ ├── progress_widget.py # Two-bar progress: current stage + overall +│ ├── preview_panel.py # Image preview panel +│ ├── bottom_panel.py # Directory management + error display +│ ├── backend.py # CLI subprocess interface with JSON progress parsing +│ ├── models.py # Data models, enums, column definitions +│ ├── state.py # Application state with Qt signals +│ ├── icons.py # SVG icon resources from Krokiet icon set +│ └── dialogs/ # Delete, Move, Select, Sort, Save, Rename, About +└── tests/ # Test files +``` + +### How It Works + +Kalka is a **PySide6/Qt 6 GUI frontend** that uses `czkawka_cli` as its backend: + +1. **Scanning**: Spawns `czkawka_cli` as a subprocess with `--compact-file-to-save` for JSON results and `--json-progress` for real-time progress data on stderr. +2. **Progress**: JSON lines on stderr provide stage index, entry counts, byte counts — displayed as two progress bars (current stage and overall). +3. **Results**: JSON results are parsed and displayed in a tree view with group headers for duplicate/similar file tools. +4. **File operations**: Delete, move, hardlink, symlink, and rename are performed directly in Python. EXIF cleaning and extension/name fixing use `czkawka_cli` subcommands. + +## Adding Translations + +Kalka uses [Project Fluent](https://projectfluent.org/) (.ftl files) for internationalization, matching the same format as krokiet. + +### Adding a New Language + +1. Create a new directory under `i18n/` with the locale code (e.g., `i18n/de/`) +2. Copy `i18n/en/kalka.ftl` as a starting point +3. Translate all message values (keep the message IDs unchanged) +4. Test by running: `LANG=de_DE.UTF-8 python main.py` + +### Translation Guidelines + +- **Keep message IDs unchanged** — only translate the values after `=` +- **Preserve placeholders** — `{ $count }`, `{ $name }`, etc. must remain in the translation +- **Match the English file's key set** — every key in `en/kalka.ftl` must exist in your translation +- **Use natural phrasing** — don't translate word-for-word; adapt to how the target language naturally expresses the concept +- **Test your translation** — run the app with your locale to verify layout doesn't break with longer strings + +### Translation File Format + +```fluent +# Simple string +scan-button = Skanuj + +# String with placeholder +status-scan-complete = Skanowanie zakończone: znaleziono { $count } pozycji + +# Multiline string +about-description = + Kalka to prosty, szybki i darmowy program do usuwania + zbędnych plikÃŗw z komputera. +``` + +### Using Translations in Code + +All user-visible strings must use the `tr()` function: + +```python +from .localizer import tr + +# Simple string +label.setText(tr("scan-button")) + +# String with parameters +status.setText(tr("status-scan-complete", count=42)) +``` + +## Collaboration Tracks (Risk-Based) + +To keep review throughput high without lowering quality, every PR should map to one track: + +| Track | Typical scope | Required review depth | +|---|---|---| +| **Track A (Low risk)** | docs, translations, tests, isolated refactors | 1 maintainer review + CI green | +| **Track B (Medium risk)** | UI behavior changes, backend parsing, settings, new tool support | 1 subsystem-aware review + validation evidence | +| **Track C (High risk)** | CLI interface changes, file operations (delete/move/hardlink), i18n infrastructure, `czkawka_core` changes | 2-pass review, rollback plan required | + +When in doubt, choose the higher track. + +## PR Definition of Ready (DoR) + +Before requesting review, ensure all of the following are true: + +- Scope is focused to a single concern. +- Relevant local validation has been run (syntax check, lint, manual testing). +- No personal/sensitive data is introduced in code/docs/tests. +- If translations were changed, all locale files have matching key sets. +- Linked issue (or rationale for no issue) is included. + +## PR Definition of Done (DoD) + +A PR is merge-ready when: + +- CI is green. +- Required reviewers approved. +- User-visible behavior changes are documented. +- Follow-up TODOs are explicit and tracked in issues. +- Translation files are consistent across all locales. + +## Code Style + +- **Python 3.10+ features** — use type hints, dataclasses, match statements where appropriate +- **PySide6 patterns** — signals/slots, Qt naming conventions for UI code +- **Minimal dependencies** — every package adds installation complexity +- **Translatable strings** — all user-visible text must go through `tr()` from `localizer.py` +- **No hardcoded paths** — use `Path` objects and relative references +- **Security by default** — never execute user-provided strings as code; validate file paths before operations + +## Code Naming Conventions + +- **Python casing**: modules/files `snake_case`, classes `PascalCase`, functions/variables `snake_case`, constants `SCREAMING_SNAKE_CASE` +- **Qt widgets**: prefix private widgets with `_` (e.g., `self._scan_btn`) +- **Translation keys**: `kebab-case` with section prefixes (e.g., `settings-cli-path`, `delete-dialog-title`) +- **Fluent message IDs**: match krokiet conventions where possible for cross-project consistency + +## Commit Convention + +We use [Conventional Commits](https://www.conventionalcommits.org/): + +``` +feat: add PDF preview support +feat(i18n): add German translation +fix: handle exit code 2 from czkawka_cli gracefully +docs: update contributing guide +test: add backend JSON parsing tests +refactor: extract progress formatting to utility +chore: bump PySide6 to 6.8.0 +``` + +Recommended scope keys: + +- `i18n`, `ui`, `backend`, `settings`, `dialogs`, `preview`, `results`, `docs`, `tests` + +## Reporting Issues + +- **Bugs**: Include OS, Python version, PySide6 version, `czkawka_cli` version, steps to reproduce, expected vs actual behavior +- **Features**: Describe the use case and which component would be affected +- **Translations**: Note the language, the incorrect string, and the suggested correction + +## Agent Collaboration Guidance + +Agent-assisted contributions are welcome and treated as first-class contributions. + +For smoother review: + +- Keep PR summaries concrete (problem, change, non-goals). +- Include reproducible validation evidence (syntax check, manual testing screenshots). +- Agent-assisted PRs are welcome, but contributors remain accountable for understanding what the code does. +- Call out uncertainty and risky edges explicitly. + +## License + +By contributing, you agree that your contributions will be licensed under the MIT License. diff --git a/kalka/DEVELOPMENT_PLAN.md b/kalka/DEVELOPMENT_PLAN.md new file mode 100644 index 000000000..ac0d96238 --- /dev/null +++ b/kalka/DEVELOPMENT_PLAN.md @@ -0,0 +1,287 @@ +# Kalka Development Plan + +Issues and improvements identified during code review, prioritized by impact. + +## 1. Critical Bugs + +### 1.1 Broken Files and Video Optimizer always return "exit with code 2" +- **Problem**: `czkawka_cli` exits with code 2 for these tools, and kalka treats it as an error +- **Root cause**: Likely a mismatch in CLI subcommand arguments or missing required flags +- **Fix**: Debug the exact CLI invocations in `backend.py`, compare against what krokiet sends. Exit code 2 in czkawka_cli typically means "no results found" or argument error — need to handle it gracefully +- **Files**: `app/backend.py` + +### 1.2 Empty folders detection doesn't work +- **Problem**: The tool doesn't detect empty folders +- **Root cause**: Likely a parsing issue — `czkawka_cli empty-folders` may return results in a format that `backend.py` doesn't handle +- **Fix**: Run `czkawka_cli empty-folders` manually, inspect JSON output, fix the parser in `backend.py` +- **Files**: `app/backend.py` + +### 1.3 Invalid symlinks incorrectly set Destination Path / Type of Error +- **Problem**: Column values are misplaced or missing for invalid symlinks results +- **Root cause**: JSON field mapping in `backend.py` doesn't match the CLI output for symlinks +- **Fix**: Parse the symlink-specific fields (`destination`, `error_type`) from CLI JSON and map to the correct columns +- **Files**: `app/backend.py`, `app/models.py` (TAB_COLUMNS) + +### 1.4 Random "Error: czkawka_cli exited with code 2" errors +- **Problem**: Opaque error messages with no useful context +- **Fix**: + - Capture and display stderr from czkawka_cli + - Map known exit codes to human-readable messages + - Show the actual CLI command that failed (for debugging) + - Don't treat exit code 2 as a fatal error if results were produced +- **Files**: `app/backend.py`, `app/main_window.py` + +## 2. UX / Behavior Issues + +### 2.1 Scan/Stop button can be spammed +- **Problem**: No state management — button works in 2 modes with no "disabled while processing" state +- **Fix**: + - Add a `STOPPING` state to the scan lifecycle + - Disable both Scan and Stop buttons during the transition period + - Show "Stopping scan, please wait..." in status bar + - Re-enable Scan only after the process is fully terminated +- **Files**: `app/action_buttons.py`, `app/main_window.py`, `app/state.py` + +### 2.2 Double-click opens image preview instead of expected behavior +- **Problem**: Double-clicking a result shows image preview — users expect it to open the file or select it +- **Fix**: Change double-click to open the file in the system file manager (like krokiet). Move preview to single-click selection or a dedicated preview button +- **Files**: `app/results_view.py`, `app/main_window.py` + +### 2.3 Cross-tab result loading allows loading incompatible results +- **Problem**: Loading results saved from one tool (e.g., big files) into another (e.g., duplicate files) — data is lost or columns don't match +- **Fix**: + - Save the tool type (ActiveTab) in the JSON results file + - On load, validate that results match the current tab or auto-switch to the correct tab + - Show a warning if there's a mismatch +- **Files**: `app/dialogs/save_dialog.py`, `app/main_window.py` + +### 2.4 Error/warning panel is never cleared +- **Problem**: Bottom panel always shows "Error: czkawka_cli exited with code 2", never shows useful krokiet-style messages +- **Fix**: + - Clear the error panel when starting a new scan + - Show per-scan warnings/info (like krokiet does) + - Append new errors instead of replacing + - Add a "Clear" button +- **Files**: `app/bottom_panel.py`, `app/main_window.py` + +### 2.5 Duplicated tab info at the bottom +- **Problem**: Bottom panel shows the same info as the selected tab +- **Fix**: Remove the redundant display or repurpose the bottom area for directories only (its original purpose) +- **Files**: `app/bottom_panel.py`, `app/main_window.py` + +### 2.6 Bad Names missing "new file name" column +- **Problem**: Has "Error Type" column but doesn't show what the corrected name would be +- **Fix**: Parse the `new_name` field from CLI output and add a "New Name" column to the Bad Names tab +- **Files**: `app/models.py` (TAB_COLUMNS), `app/backend.py` + +## 3. Performance Issues + +### 3.1 GUI is slow with large result sets (20,000+ entries) +- **Problem**: Tab switching takes 0.5-several seconds, sorting is slow +- **Root cause**: `QTreeWidget` rebuilds all items on every operation — O(n) item creation +- **Fix** (incremental): + 1. **Short term**: Use `blockSignals()` during bulk operations (already partially done), add batch item creation + 2. **Medium term**: Switch from `QTreeWidget` to `QTreeView` + `QAbstractItemModel` — this enables virtual rendering (only visible items are rendered) + 3. **Long term**: Implement lazy loading / pagination for very large result sets +- **Files**: `app/results_view.py` + +### 3.2 JSON results are huge +- **Problem**: Each file entry stores all data from all possible modes — much larger than necessary +- **Fix**: + - Only serialize fields relevant to the current tool mode + - Use a compact format: skip null/empty fields + - Consider storing `__size_bytes` and `__modified_date_ts` as primary fields and deriving display strings +- **Files**: `app/dialogs/save_dialog.py`, `app/backend.py` + +## 4. Visual / Polish + +### 4.1 Modernize the look +- **Problem**: Current look is a mix of GTK/Slint versions, doesn't leverage PySide6/Qt styling +- **Fix**: + - Use the Kalka logo (already done, `icons/kalka.png`) + - Keep the system theme approach (KDE Breeze/Adwaita) but polish spacing, margins, and layout + - Consider adding subtle styling touches (rounded group boxes, consistent icon sizes, toolbar styling) + - Ensure dark/light theme consistency +- **Files**: `app/main_window.py` (`_apply_theme`), all panel files + +### 4.2 Non-ASCII character handling causes display issues +- **Problem**: Resizing columns causes strange jumps in text with emoji/special characters +- **Root cause**: Qt font metrics miscalculate width for certain Unicode characters +- **Fix**: + - Set a font that has good Unicode coverage + - Use `QHeaderView.ResizeToContents` for affected columns + - Consider eliding long paths instead of letting them cause layout thrash +- **Files**: `app/results_view.py` + +### 4.3 File size filter defaults are unclear +- **Problem**: Not clear what the default min/max file size values are +- **Fix**: + - Show default values in placeholder text (e.g., "Default: 8192 bytes (8 KB)") + - Or set explicit default values and show them +- **Files**: `app/settings_panel.py`, `app/models.py` + +## 5. Missing Features + +### 5.1 No logging to terminal +- **Problem**: No terminal output makes debugging very difficult +- **Fix**: + - Add Python `logging` module throughout the codebase + - Log CLI commands, exit codes, stderr output, timing + - Add `--verbose` / `--debug` CLI flags to main.py + - Log to both terminal and an in-app log panel +- **Files**: all files, `main.py` + +## 6. New Features (inspired by Fast Duplicate File Finder & dupeGuru) + +### 6.1 Reference/Source directory protection +- **Problem**: No way to mark directories as "originals" that should never be selected for deletion — only used for comparison +- **Inspiration**: Both FDFF and dupeGuru have this concept ("source folders" / "reference directories") +- **Fix**: + - Add a third directory category: "Reference" (alongside Included/Excluded) + - Files in reference directories participate in duplicate detection but are never auto-selected for deletion + - Add `--reference-dir` flag to `czkawka_cli` and propagate through `czkawka_core` + - In kalka, add a "Reference" section to the bottom panel directory management +- **Files**: `czkawka_core`, `czkawka_cli`, `app/bottom_panel.py`, `app/backend.py`, `app/models.py` + +### 6.2 Fuzzy filename matching for duplicates +- **Problem**: Duplicate detection only supports exact name or size+name — misses renamed duplicates like `report_final.pdf` vs `report_final_v2.pdf` +- **Inspiration**: FDFF has configurable similarity threshold (0–100%) for filenames +- **Fix**: + - Add a fuzzy name matching mode to `czkawka_core` duplicate finder using Levenshtein or Jaro-Winkler distance + - Expose as a new `--search-method fuzzy-name` option in `czkawka_cli` with `--name-similarity-threshold` parameter + - Add UI controls in kalka's duplicate tool settings +- **Files**: `czkawka_core` (duplicate finder), `czkawka_cli`, `app/tool_settings.py`, `app/backend.py` + +### 6.3 Extended file preview (PDF, text, video thumbnails) +- **Problem**: Preview panel only supports images (JPG, PNG, GIF, BMP, WebP, TIFF, ICO) +- **Inspiration**: FDFF previews PDFs, Excel, TXT, video, audio, and 300+ RAW camera formats +- **Fix**: + - Add PDF preview using `QPdfDocument` (available in PySide6) + - Add plain text preview for TXT, CSV, JSON, XML, etc. + - Add video thumbnail preview using first-frame extraction (via `QMediaPlayer` or ffmpeg subprocess) + - Consider RAW image support via `rawpy` or similar library +- **Files**: `app/preview_panel.py`, `requirements.txt` + +### 6.4 Combined selection criteria +- **Problem**: Select dialog offers individual modes (biggest, newest, shortest path) but no way to combine them +- **Inspiration**: FDFF's "Quick Check/Uncheck" dialog combines multiple criteria (date + size + path length) +- **Fix**: + - Redesign the select dialog to allow combining criteria with AND/OR logic + - E.g., "keep newest AND shortest path" or "select biggest OR oldest" + - Add priority ordering when criteria conflict +- **Files**: `app/dialogs/select_dialog.py` + +### 6.5 Side-by-side comparison view +- **Problem**: No way to visually compare two files from a duplicate/similar group +- **Inspiration**: dupeGuru's picture mode and FDFF's file preview +- **Fix**: + - Add a split-view mode to the preview panel showing two selected files side by side + - For images: show both images with zoom sync + - For text files: show a diff view + - Activate when two items are selected in a results group +- **Files**: `app/preview_panel.py`, `app/results_view.py` + +### 6.6 CSV/JSON export of results +- **Problem**: Results can only be saved/loaded in kalka's internal format +- **Inspiration**: FDFF exports to XML, CSV, and proprietary formats +- **Fix**: + - Add export options: CSV, JSON, and plain text + - Include column headers and group separators + - Add "Export" button to action bar or extend existing Save dialog +- **Files**: `app/dialogs/save_dialog.py`, `app/action_buttons.py` + +### 6.7 Exclude from self-scan per folder +- **Problem**: No way to prevent files within a folder from being compared against each other +- **Inspiration**: FDFF's "Exclude from self-scan" prevents internal comparison within a folder — only compares against files in other folders +- **Fix**: + - Add `--no-self-compare` flag per directory in `czkawka_core` and `czkawka_cli` + - Useful for comparing a "known good" archive against a messy downloads folder + - Add a per-directory toggle in kalka's bottom panel +- **Files**: `czkawka_core`, `czkawka_cli`, `app/bottom_panel.py`, `app/backend.py` + +### 6.8 Similarity confidence scores in results +- **Problem**: Similar image/video/music results don't show how similar files are to each other +- **Inspiration**: Both FDFF and dupeGuru show similarity percentages +- **Fix**: + - `czkawka_core` already computes similarity internally — expose the score in JSON output + - Add a "Similarity" column to similar images/videos/music result tabs + - Color-code or sort by confidence to help users prioritize +- **Files**: `czkawka_core`, `czkawka_cli`, `app/models.py` (TAB_COLUMNS), `app/backend.py`, `app/results_view.py` + +### 6.9 Scan profiles/presets +- **Problem**: Users who run recurring cleanup tasks must reconfigure settings each time +- **Fix**: + - Save/load entire scan configurations (tool + settings + directories) as named profiles + - Store profiles as JSON files in the config directory + - Add a profile selector dropdown in the UI + - Useful for automation: `czkawka_cli --profile weekly-cleanup` +- **Files**: `czkawka_cli`, `app/settings_panel.py`, `app/main_window.py` + +### 6.10 Idle-priority scanning +- **Problem**: Scanning large drives can slow down other applications +- **Inspiration**: FDFF offers IDLE process priority settings +- **Fix**: + - Add a "Low priority" toggle in kalka settings + - When enabled, spawn `czkawka_cli` with `nice -n 19` and `ionice -c 3` on Linux + - On Windows, use `IDLE_PRIORITY_CLASS` +- **Files**: `app/backend.py`, `app/settings_panel.py` + +### 6.11 Fuzzy music tag matching +- **Problem**: Similar music tool matches by exact tags or fingerprint — misses variations like "Beatles" vs "The Beatles" +- **Inspiration**: dupeGuru's music mode does fuzzy matching on tags +- **Fix**: + - Add fuzzy string comparison for music tags in `czkawka_core` + - Normalize common patterns: strip "The ", case-insensitive, trim whitespace + - Expose as `--tag-match-mode exact|fuzzy` in `czkawka_cli` +- **Files**: `czkawka_core` (music duplicate finder), `czkawka_cli`, `app/tool_settings.py` + +### 6.12 Similar document/archive content detection +- **Problem**: No way to find documents with similar but not identical content +- **Inspiration**: FDFF analyzes file content to find similar documents even with rearranged paragraphs +- **Fix**: + - Add a new tool or mode in `czkawka_core` that extracts text from documents (PDF, DOCX, TXT) and computes similarity using shingling/MinHash or similar algorithm + - Extend to archives: compare file listings within ZIP/TAR files + - This is a large feature — consider as a separate tool alongside existing ones +- **Files**: `czkawka_core` (new module), `czkawka_cli`, `app/models.py`, `app/backend.py` + +### 6.13 Drag & drop directory addition +- **Problem**: Adding directories requires using a file dialog or typing paths +- **Fix**: + - Enable drag & drop on the bottom panel's directory lists + - Accept folder drops from the system file manager + - Visual feedback (highlight) when dragging over the directory area +- **Files**: `app/bottom_panel.py` + +## Priority Order + +| Priority | Item | Effort | Where | +|----------|------|--------|-------| +| P0 | 1.1 Broken Files / Video Optimizer exit code | Small | kalka | +| P0 | 1.4 Opaque error messages | Small | kalka | +| P0 | 2.1 Scan/Stop button state management | Small | kalka | +| P1 | 1.2 Empty folders detection | Small | kalka | +| P1 | 1.3 Invalid symlinks columns | Small | kalka | +| P1 | 5.1 Terminal logging | Medium | kalka | +| P1 | 2.3 Cross-tab result loading | Medium | kalka | +| P1 | 2.4 Error panel improvements | Small | kalka | +| P1 | 6.1 Reference/source directory protection | Medium | czkawka_core + czkawka_cli + kalka | +| P1 | 6.2 Fuzzy filename matching | Medium | czkawka_core + czkawka_cli + kalka | +| P1 | 6.5 Side-by-side comparison view | Medium | kalka | +| P1 | 6.6 CSV/JSON export | Small | kalka | +| P2 | 3.1 GUI performance with large results | Large | kalka | +| P2 | 2.2 Double-click behavior | Small | kalka | +| P2 | 2.6 Bad Names new name column | Small | kalka | +| P2 | 4.3 File size filter defaults | Small | kalka | +| P2 | 6.3 Extended file preview (PDF, text, video) | Medium | kalka | +| P2 | 6.4 Combined selection criteria | Small | kalka | +| P2 | 6.7 Exclude from self-scan per folder | Medium | czkawka_core + czkawka_cli + kalka | +| P2 | 6.8 Similarity confidence scores | Small | czkawka_core + czkawka_cli + kalka | +| P2 | 6.9 Scan profiles/presets | Small | czkawka_cli + kalka | +| P2 | 6.13 Drag & drop directory addition | Small | kalka | +| P3 | 3.2 Compact JSON results | Medium | kalka | +| P3 | 4.1 Modern look polish | Medium | kalka | +| P3 | 4.2 Non-ASCII display issues | Medium | kalka | +| P3 | 2.5 Duplicated tab info | Small | kalka | +| P3 | 6.10 Idle-priority scanning | Small | kalka | +| P3 | 6.11 Fuzzy music tag matching | Medium | czkawka_core + czkawka_cli | +| P3 | 6.12 Similar document/archive content | Large | czkawka_core + czkawka_cli + kalka | diff --git a/kalka/README.md b/kalka/README.md new file mode 100644 index 000000000..100aeb500 --- /dev/null +++ b/kalka/README.md @@ -0,0 +1,115 @@ +# Kalka + +A Qt 6 / PySide6 GUI frontend for Czkawka, with feature parity with the Krokiet (Slint) interface. + +This frontend uses `czkawka_cli` as its backend, communicating via JSON output for results and `--json-progress` for real-time progress data. + +## Features + +All 14 scanning tools are supported: + +- Duplicate Files (by hash, size, name, or size+name) +- Empty Folders / Empty Files +- Big Files (biggest or smallest) +- Temporary Files +- Similar Images (with configurable hash algorithm, size, similarity) +- Similar Videos (with crop detection, skip forward, duration) +- Similar Music (by tags or audio fingerprint) +- Invalid Symlinks +- Broken Files (audio, PDF, archive, image, video) +- Bad Extensions +- Bad Names (uppercase, emoji, spaces, non-ASCII, restricted charset) +- EXIF Remover +- Video Optimizer (crop black bars / transcode) + +### GUI features + +- **Dark theme** with full Qt stylesheet +- **Two-bar progress display** - current stage + overall progress, matching the Slint frontend + - Real-time entry and byte counts (e.g., "Calculating hashes: 15,000/25,000 (500 MB/1 GB)") + - File collection estimation using cached counts from previous scans + - Elapsed time display + - Stage step indicators +- **Project icons** - uses the same SVG icons as the Krokiet interface +- **Image preview panel** for duplicate/similar image results +- **Grouped results view** with tree display for duplicate/similar file groups +- **Selection modes** - Select All/None, Invert, Biggest/Smallest, Newest/Oldest, Shortest/Longest Path +- **File actions** - Delete (with trash support), Move/Copy, Hardlink, Symlink, Rename, Clean EXIF +- **Per-tool settings** - all tool-specific options (hash type, similarity thresholds, etc.) +- **Global settings** - directories, filters, cache, thread count +- **Directory management** - included/excluded paths with add/remove buttons +- **Context menus** - right-click to open file or containing folder +- **Settings persistence** via JSON config files +- **Auto-detection** of `czkawka_cli` binary (checks PATH, cargo target directory, cargo metadata) + +## Requirements + +- Python 3.10+ +- PySide6 >= 6.6.0 +- `czkawka_cli` binary (installed or in PATH) +- Optional: `send2trash` (for trash support on Linux) +- Optional: `Pillow` (for EXIF cleaning fallback) + +## Installation + +### 1. Install czkawka_cli + +```shell +# From the project root +cargo install --path czkawka_cli + +# Or build it +cargo build --release -p czkawka_cli +``` + +### 2. Install Python dependencies + +```shell +cd kalka +pip install -r requirements.txt +``` + +### 3. Run + +```shell +python main.py +``` + +The application will auto-detect the `czkawka_cli` binary. If it can't find it, configure the path in Settings. + +## Architecture + +``` +kalka/ +├── main.py # Entry point +├── requirements.txt # Python dependencies +├── app/ +│ ├── main_window.py # Main window with all panels +│ ├── left_panel.py # Tool selection sidebar (14 tools) +│ ├── results_view.py # Results tree with grouping, selection, sorting +│ ├── action_buttons.py # Scan/Stop/Delete/Move/Save/Sort buttons with icons +│ ├── tool_settings.py # Per-tool settings (9 tool panels) +│ ├── settings_panel.py # Global settings (General/Directories/Filters/Preview) +│ ├── progress_widget.py # Two-bar progress: current stage + overall +│ ├── preview_panel.py # Image preview panel +│ ├── bottom_panel.py # Directory management + error display +│ ├── backend.py # CLI subprocess interface with JSON progress parsing +│ ├── models.py # Data models, enums, column definitions +│ ├── state.py # Application state with Qt signals +│ ├── icons.py # SVG icon resources from Krokiet icon set +│ └── dialogs/ # Delete, Move, Select, Sort, Save, Rename, About +``` + +### How it works + +1. **Scanning**: The app spawns `czkawka_cli` as a subprocess with `--compact-file-to-save` for JSON results and `--json-progress` for real-time progress data on stderr. + +2. **Progress**: JSON lines on stderr provide `ProgressData` with stage index, entry counts, byte counts — the same data the Slint frontend gets via crossbeam channels. The progress widget displays two bars (current stage and overall) with percentage, counts, and elapsed time. + +3. **Results**: JSON results are parsed and displayed in a tree view with group headers for duplicate/similar file tools. + +4. **File operations**: Delete, move, hardlink, symlink, and rename operations are performed directly in Python. EXIF cleaning and extension/name fixing use `czkawka_cli` subcommands. + +## LICENSE + +MIT diff --git a/czkawka_pyside6/app/__init__.py b/kalka/app/__init__.py similarity index 100% rename from czkawka_pyside6/app/__init__.py rename to kalka/app/__init__.py diff --git a/czkawka_pyside6/app/action_buttons.py b/kalka/app/action_buttons.py similarity index 84% rename from czkawka_pyside6/app/action_buttons.py rename to kalka/app/action_buttons.py index 241878f7b..a310024cb 100644 --- a/czkawka_pyside6/app/action_buttons.py +++ b/kalka/app/action_buttons.py @@ -9,6 +9,7 @@ icon_save, icon_sort, icon_hardlink, icon_symlink, icon_rename, icon_clean, icon_optimize, ) +from .localizer import tr ICON_SIZE = QSize(18, 18) @@ -44,14 +45,14 @@ def _setup_ui(self): layout.setSpacing(4) # Scan button - self._scan_btn = QPushButton(icon_search(18), " Scan") + self._scan_btn = QPushButton(icon_search(18), " " + tr("scan-button")) self._scan_btn.setIconSize(ICON_SIZE) self._scan_btn.setMinimumWidth(90) self._scan_btn.clicked.connect(self.scan_clicked.emit) layout.addWidget(self._scan_btn) # Stop button - self._stop_btn = QPushButton(icon_stop(18), " Stop") + self._stop_btn = QPushButton(icon_stop(18), " " + tr("stop-button")) self._stop_btn.setIconSize(ICON_SIZE) self._stop_btn.setMinimumWidth(80) self._stop_btn.clicked.connect(self.stop_clicked.emit) @@ -64,69 +65,69 @@ def _setup_ui(self): layout.addWidget(spacer) # Select button - self._select_btn = QPushButton(icon_select(18), " Select") + self._select_btn = QPushButton(icon_select(18), " " + tr("select-button")) self._select_btn.setIconSize(ICON_SIZE) self._select_btn.clicked.connect(self.select_clicked.emit) layout.addWidget(self._select_btn) # Delete button - self._delete_btn = QPushButton(icon_delete(18), " Delete") + self._delete_btn = QPushButton(icon_delete(18), " " + tr("delete-button")) self._delete_btn.setIconSize(ICON_SIZE) self._delete_btn.clicked.connect(self.delete_clicked.emit) layout.addWidget(self._delete_btn) # Move button - self._move_btn = QPushButton(icon_move(18), " Move") + self._move_btn = QPushButton(icon_move(18), " " + tr("move-button")) self._move_btn.setIconSize(ICON_SIZE) self._move_btn.clicked.connect(self.move_clicked.emit) layout.addWidget(self._move_btn) # Save button - self._save_btn = QPushButton(icon_save(18), " Save") + self._save_btn = QPushButton(icon_save(18), " " + tr("save-button")) self._save_btn.setIconSize(ICON_SIZE) self._save_btn.clicked.connect(self.save_clicked.emit) layout.addWidget(self._save_btn) # Load button from .icons import icon_dir - self._load_btn = QPushButton(icon_dir(18), " Load") + self._load_btn = QPushButton(icon_dir(18), " " + tr("load-button")) self._load_btn.setIconSize(ICON_SIZE) - self._load_btn.setToolTip("Load previously saved results") + self._load_btn.setToolTip(tr("load-button-tooltip")) self._load_btn.clicked.connect(self.load_clicked.emit) layout.addWidget(self._load_btn) # Sort button - self._sort_btn = QPushButton(icon_sort(18), " Sort") + self._sort_btn = QPushButton(icon_sort(18), " " + tr("sort-button")) self._sort_btn.setIconSize(ICON_SIZE) self._sort_btn.clicked.connect(self.sort_clicked.emit) layout.addWidget(self._sort_btn) # Hardlink button (grouped tools only) - self._hardlink_btn = QPushButton(icon_hardlink(18), " Hardlink") + self._hardlink_btn = QPushButton(icon_hardlink(18), " " + tr("hardlink-button")) self._hardlink_btn.setIconSize(ICON_SIZE) self._hardlink_btn.clicked.connect(self.hardlink_clicked.emit) layout.addWidget(self._hardlink_btn) # Symlink button (grouped tools only) - self._symlink_btn = QPushButton(icon_symlink(18), " Symlink") + self._symlink_btn = QPushButton(icon_symlink(18), " " + tr("symlink-button")) self._symlink_btn.setIconSize(ICON_SIZE) self._symlink_btn.clicked.connect(self.symlink_clicked.emit) layout.addWidget(self._symlink_btn) # Rename button (bad extensions / bad names) - self._rename_btn = QPushButton(icon_rename(18), " Rename") + self._rename_btn = QPushButton(icon_rename(18), " " + tr("rename-button")) self._rename_btn.setIconSize(ICON_SIZE) self._rename_btn.clicked.connect(self.rename_clicked.emit) layout.addWidget(self._rename_btn) # Clean EXIF button - self._clean_exif_btn = QPushButton(icon_clean(18), " Clean EXIF") + self._clean_exif_btn = QPushButton(icon_clean(18), " " + tr("clean-exif-button")) self._clean_exif_btn.setIconSize(ICON_SIZE) self._clean_exif_btn.clicked.connect(self.clean_exif_clicked.emit) layout.addWidget(self._clean_exif_btn) # Optimize Video button - self._optimize_btn = QPushButton(icon_optimize(18), " Optimize") + self._optimize_btn = QPushButton(icon_optimize(18), " " + tr("optimize-button")) self._optimize_btn.setIconSize(ICON_SIZE) self._optimize_btn.clicked.connect(self.optimize_video_clicked.emit) layout.addWidget(self._optimize_btn) diff --git a/czkawka_pyside6/app/dialogs/__init__.py b/kalka/app/dialogs/__init__.py similarity index 88% rename from czkawka_pyside6/app/dialogs/__init__.py rename to kalka/app/dialogs/__init__.py index e716a321c..f1c6d549e 100644 --- a/czkawka_pyside6/app/dialogs/__init__.py +++ b/kalka/app/dialogs/__init__.py @@ -5,4 +5,3 @@ from .save_dialog import SaveDialog from .rename_dialog import RenameDialog from .about_dialog import AboutDialog -from .diff_dialog import DiffDialog diff --git a/czkawka_pyside6/app/dialogs/about_dialog.py b/kalka/app/dialogs/about_dialog.py similarity index 64% rename from czkawka_pyside6/app/dialogs/about_dialog.py rename to kalka/app/dialogs/about_dialog.py index 3284712ea..99c0bbbb4 100644 --- a/czkawka_pyside6/app/dialogs/about_dialog.py +++ b/kalka/app/dialogs/about_dialog.py @@ -5,6 +5,7 @@ from PySide6.QtGui import QPixmap, QFont from ..icons import app_logo_path +from ..localizer import tr class AboutDialog(QDialog): @@ -12,7 +13,7 @@ class AboutDialog(QDialog): def __init__(self, parent=None): super().__init__(parent) - self.setWindowTitle("About Czkawka PySide6") + self.setWindowTitle(tr("about-title")) self.setMinimumWidth(480) self.setMinimumHeight(420) @@ -29,7 +30,7 @@ def __init__(self, parent=None): logo_label.setAlignment(Qt.AlignCenter) layout.addWidget(logo_label) - title = QLabel("Czkawka") + title = QLabel(tr("about-app-name")) title_font = QFont() title_font.setPointSize(22) title_font.setBold(True) @@ -37,14 +38,14 @@ def __init__(self, parent=None): title.setAlignment(Qt.AlignCenter) layout.addWidget(title) - subtitle = QLabel("PySide6 / Qt 6 Edition") + subtitle = QLabel(tr("about-subtitle")) sub_font = QFont() sub_font.setPointSize(11) subtitle.setFont(sub_font) subtitle.setAlignment(Qt.AlignCenter) layout.addWidget(subtitle) - version = QLabel("Version 11.0.1") + version = QLabel(tr("about-version")) version.setAlignment(Qt.AlignCenter) version.setEnabled(False) layout.addWidget(version) @@ -55,22 +56,7 @@ def __init__(self, parent=None): sep.setFrameShadow(QFrame.Sunken) layout.addWidget(sep) - desc = QLabel( - "Czkawka (tch-kav-ka) is a simple, fast and free app to remove\n" - "unnecessary files from your computer.\n\n" - "This PySide6/Qt interface uses the czkawka_cli backend\n" - "for all scanning and file operations.\n\n" - "Features:\n" - " - Find duplicate files (by hash, name, or size)\n" - " - Find empty files and folders\n" - " - Find similar images, videos, and music\n" - " - Find broken files and invalid symlinks\n" - " - Find files with bad extensions or names\n" - " - Remove EXIF metadata from images\n" - " - Optimize and crop videos\n\n" - "Licensed under MIT License\n" - "https://github.com/qarmin/czkawka" - ) + desc = QLabel(tr("about-description")) desc.setWordWrap(True) desc.setAlignment(Qt.AlignCenter) layout.addWidget(desc) diff --git a/czkawka_pyside6/app/dialogs/delete_dialog.py b/kalka/app/dialogs/delete_dialog.py similarity index 67% rename from czkawka_pyside6/app/dialogs/delete_dialog.py rename to kalka/app/dialogs/delete_dialog.py index b82ad674d..e481e669f 100644 --- a/czkawka_pyside6/app/dialogs/delete_dialog.py +++ b/kalka/app/dialogs/delete_dialog.py @@ -1,17 +1,19 @@ from PySide6.QtWidgets import ( QDialog, QVBoxLayout, QLabel, QDialogButtonBox, - QCheckBox, QMessageBox + QCheckBox, QStyle ) from PySide6.QtCore import Qt from PySide6.QtGui import QFont +from ..localizer import tr + class DeleteDialog(QDialog): """Confirmation dialog for deleting files.""" def __init__(self, count: int, move_to_trash: bool = True, parent=None): super().__init__(parent) - self.setWindowTitle("Delete Files") + self.setWindowTitle(tr("delete-dialog-title")) self.setMinimumWidth(400) self._move_to_trash = move_to_trash @@ -19,12 +21,12 @@ def __init__(self, count: int, move_to_trash: bool = True, parent=None): # Warning icon from system theme icon_label = QLabel() - icon = self.style().standardIcon(self.style().SP_MessageBoxWarning) + icon = self.style().standardIcon(QStyle.StandardPixmap.SP_MessageBoxWarning) icon_label.setPixmap(icon.pixmap(48, 48)) icon_label.setAlignment(Qt.AlignCenter) layout.addWidget(icon_label) - msg = QLabel(f"Are you sure you want to delete {count} selected file(s)?") + msg = QLabel(tr("delete-dialog-message", count=count)) msg_font = QFont() msg_font.setPointSize(11) msg.setFont(msg_font) @@ -33,15 +35,19 @@ def __init__(self, count: int, move_to_trash: bool = True, parent=None): layout.addWidget(msg) # Move to trash checkbox - self._trash_cb = QCheckBox("Move to trash instead of permanent delete") + self._trash_cb = QCheckBox(tr("delete-dialog-trash")) self._trash_cb.setChecked(move_to_trash) layout.addWidget(self._trash_cb) + # Dry run checkbox + self._dry_run_cb = QCheckBox(tr("delete-dialog-dry-run")) + layout.addWidget(self._dry_run_cb) + # Buttons buttons = QDialogButtonBox( QDialogButtonBox.Ok | QDialogButtonBox.Cancel ) - buttons.button(QDialogButtonBox.Ok).setText("Delete") + buttons.button(QDialogButtonBox.Ok).setText(tr("delete-dialog-confirm")) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) @@ -49,3 +55,7 @@ def __init__(self, count: int, move_to_trash: bool = True, parent=None): @property def move_to_trash(self) -> bool: return self._trash_cb.isChecked() + + @property + def dry_run(self) -> bool: + return self._dry_run_cb.isChecked() diff --git a/czkawka_pyside6/app/dialogs/move_dialog.py b/kalka/app/dialogs/move_dialog.py similarity index 65% rename from czkawka_pyside6/app/dialogs/move_dialog.py rename to kalka/app/dialogs/move_dialog.py index a26a51405..f5c9e461e 100644 --- a/czkawka_pyside6/app/dialogs/move_dialog.py +++ b/kalka/app/dialogs/move_dialog.py @@ -5,49 +5,54 @@ ) from PySide6.QtCore import Qt +from ..localizer import tr + class MoveDialog(QDialog): """Dialog for moving/copying files to a destination.""" def __init__(self, count: int, parent=None): super().__init__(parent) - self.setWindowTitle("Move/Copy Files") + self.setWindowTitle(tr("move-dialog-title")) self.setMinimumWidth(500) layout = QVBoxLayout(self) - msg = QLabel(f"Move or copy {count} selected file(s) to:") + msg = QLabel(tr("move-dialog-message", count=count)) layout.addWidget(msg) # Destination path dest_layout = QHBoxLayout() self._dest_edit = QLineEdit() - self._dest_edit.setPlaceholderText("Select destination folder...") + self._dest_edit.setPlaceholderText(tr("move-dialog-placeholder")) dest_layout.addWidget(self._dest_edit) - browse_btn = QPushButton("Browse") + browse_btn = QPushButton(tr("settings-browse")) browse_btn.clicked.connect(self._browse) dest_layout.addWidget(browse_btn) layout.addLayout(dest_layout) # Options - self._preserve_structure = QCheckBox("Preserve folder structure") + self._preserve_structure = QCheckBox(tr("move-dialog-preserve")) layout.addWidget(self._preserve_structure) - self._copy_mode = QCheckBox("Copy instead of move") + self._copy_mode = QCheckBox(tr("move-dialog-copy-mode")) layout.addWidget(self._copy_mode) + self._dry_run = QCheckBox(tr("move-dialog-dry-run")) + layout.addWidget(self._dry_run) + # Buttons buttons = QDialogButtonBox( QDialogButtonBox.Ok | QDialogButtonBox.Cancel ) - buttons.button(QDialogButtonBox.Ok).setText("Move") + buttons.button(QDialogButtonBox.Ok).setText(tr("move-dialog-confirm")) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) def _browse(self): - path = QFileDialog.getExistingDirectory(self, "Select Destination") + path = QFileDialog.getExistingDirectory(self, tr("move-dialog-select-dest")) if path: self._dest_edit.setText(path) @@ -62,3 +67,7 @@ def preserve_structure(self) -> bool: @property def copy_mode(self) -> bool: return self._copy_mode.isChecked() + + @property + def dry_run(self) -> bool: + return self._dry_run.isChecked() diff --git a/czkawka_pyside6/app/dialogs/rename_dialog.py b/kalka/app/dialogs/rename_dialog.py similarity index 71% rename from czkawka_pyside6/app/dialogs/rename_dialog.py rename to kalka/app/dialogs/rename_dialog.py index da4cb4fa5..d31a4f097 100644 --- a/czkawka_pyside6/app/dialogs/rename_dialog.py +++ b/kalka/app/dialogs/rename_dialog.py @@ -2,6 +2,8 @@ QDialog, QVBoxLayout, QLabel, QDialogButtonBox ) +from ..localizer import tr + class RenameDialog(QDialog): """Confirmation dialog for renaming files (fix extensions or bad names).""" @@ -14,11 +16,9 @@ def __init__(self, count: int, rename_type: str = "extensions", parent=None): layout = QVBoxLayout(self) if rename_type == "extensions": - msg = f"Fix extensions for {count} selected file(s)?\n\n" \ - "Files will be renamed to use their proper extensions." + msg = tr("rename-dialog-ext-message", count=count) else: - msg = f"Fix names for {count} selected file(s)?\n\n" \ - "Files with problematic names will be renamed." + msg = tr("rename-dialog-names-message", count=count) label = QLabel(msg) label.setWordWrap(True) @@ -28,7 +28,7 @@ def __init__(self, count: int, rename_type: str = "extensions", parent=None): buttons = QDialogButtonBox( QDialogButtonBox.Ok | QDialogButtonBox.Cancel ) - buttons.button(QDialogButtonBox.Ok).setText("Rename") + buttons.button(QDialogButtonBox.Ok).setText(tr("rename-dialog-confirm")) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) diff --git a/czkawka_pyside6/app/dialogs/sort_dialog.py b/kalka/app/dialogs/sort_dialog.py similarity index 84% rename from czkawka_pyside6/app/dialogs/sort_dialog.py rename to kalka/app/dialogs/sort_dialog.py index 3d6f627e4..5000ad194 100644 --- a/czkawka_pyside6/app/dialogs/sort_dialog.py +++ b/kalka/app/dialogs/sort_dialog.py @@ -4,6 +4,8 @@ ) from PySide6.QtCore import Signal +from ..localizer import tr + class SortDialog(QDialog): """Dialog for sorting results.""" @@ -11,16 +13,16 @@ class SortDialog(QDialog): def __init__(self, columns: list[str], parent=None): super().__init__(parent) - self.setWindowTitle("Sort Results") + self.setWindowTitle(tr("sort-dialog-title")) self.setMinimumWidth(300) layout = QFormLayout(self) self._column = QComboBox() self._column.addItems(columns) - layout.addRow("Sort by:", self._column) + layout.addRow(tr("sort-by"), self._column) - self._ascending = QCheckBox("Ascending") + self._ascending = QCheckBox(tr("sort-ascending")) self._ascending.setChecked(True) layout.addRow(self._ascending) diff --git a/czkawka_pyside6/app/icons.py b/kalka/app/icons.py similarity index 99% rename from czkawka_pyside6/app/icons.py rename to kalka/app/icons.py index 9ec8fdace..a1b445364 100644 --- a/czkawka_pyside6/app/icons.py +++ b/kalka/app/icons.py @@ -1,4 +1,4 @@ -"""Icon resources for Czkawka PySide6 interface. +"""Icon resources for Kalka interface. KDE-compliant icon strategy: 1. Try QIcon.fromTheme() with standard XDG/FreeDesktop icon names @@ -130,7 +130,6 @@ def app_logo_path() -> str: # Try relative to this file first, then absolute project path for candidate in [ Path(__file__).parent.parent.parent / "krokiet" / "icons" / "krokiet_logo.png", - Path("/mnt/developer/git/aecs4u.it/czkawka/krokiet/icons/krokiet_logo.png"), ]: if candidate.exists(): return str(candidate) diff --git a/czkawka_pyside6/app/left_panel.py b/kalka/app/left_panel.py similarity index 81% rename from czkawka_pyside6/app/left_panel.py rename to kalka/app/left_panel.py index 8a8f87185..ed00ac05a 100644 --- a/czkawka_pyside6/app/left_panel.py +++ b/kalka/app/left_panel.py @@ -2,11 +2,12 @@ QWidget, QVBoxLayout, QListWidget, QListWidgetItem, QLabel, QPushButton, QHBoxLayout, QSizePolicy ) -from PySide6.QtCore import Signal, Qt, QSize +from PySide6.QtCore import Signal, Qt, QSize, QEvent from PySide6.QtGui import QFont, QPixmap -from .models import ActiveTab, TAB_DISPLAY_NAMES, TABS_WITH_SETTINGS +from .models import ActiveTab, TAB_DISPLAY_KEYS, TABS_WITH_SETTINGS from .icons import app_logo_path, icon_settings, icon_subsettings +from .localizer import tr class LeftPanel(QWidget): @@ -45,7 +46,7 @@ def _setup_ui(self): layout.setContentsMargins(6, 6, 6, 6) layout.setSpacing(4) - # Logo image (clickable) + # Logo image (clickable via event filter) logo_path = app_logo_path() if logo_path: self._logo_label = QLabel() @@ -54,18 +55,19 @@ def _setup_ui(self): self._logo_label.setPixmap(scaled) self._logo_label.setAlignment(Qt.AlignCenter) self._logo_label.setCursor(Qt.PointingHandCursor) - self._logo_label.setToolTip("About Czkawka") - self._logo_label.mousePressEvent = lambda _: self.about_requested.emit() + self._logo_label.setToolTip(tr("about-logo-tooltip")) + self._logo_label.installEventFilter(self) layout.addWidget(self._logo_label) else: - title_label = QLabel("Czkawka") + title_label = QLabel(tr("fallback-app-name")) title_font = QFont() title_font.setPointSize(16) title_font.setBold(True) title_label.setFont(title_font) title_label.setAlignment(Qt.AlignCenter) title_label.setCursor(Qt.PointingHandCursor) - title_label.mousePressEvent = lambda _: self.about_requested.emit() + title_label.installEventFilter(self) + self._logo_label = title_label layout.addWidget(title_label) # Top buttons row with icons @@ -75,14 +77,14 @@ def _setup_ui(self): self._settings_btn = QPushButton(icon_settings(20), "") self._settings_btn.setFixedSize(32, 32) self._settings_btn.setIconSize(QSize(20, 20)) - self._settings_btn.setToolTip("Application Settings") + self._settings_btn.setToolTip(tr("settings-tooltip")) self._settings_btn.clicked.connect(self.settings_requested.emit) btn_row.addWidget(self._settings_btn) self._tool_settings_btn = QPushButton(icon_subsettings(20), "") self._tool_settings_btn.setFixedSize(32, 32) self._tool_settings_btn.setIconSize(QSize(20, 20)) - self._tool_settings_btn.setToolTip("Tool-specific Settings") + self._tool_settings_btn.setToolTip(tr("tool-settings-tooltip")) self._tool_settings_btn.setCheckable(True) self._tool_settings_btn.clicked.connect( lambda checked: self.tool_settings_toggled.emit(checked) @@ -98,7 +100,7 @@ def _setup_ui(self): self._tool_list.setSpacing(1) for tab in self.TOOL_TABS: - item = QListWidgetItem(TAB_DISPLAY_NAMES[tab]) + item = QListWidgetItem(tr(TAB_DISPLAY_KEYS[tab])) item.setData(Qt.UserRole, tab) item.setSizeHint(item.sizeHint().__class__(item.sizeHint().width(), 30)) self._tool_list.addItem(item) @@ -108,11 +110,17 @@ def _setup_ui(self): layout.addWidget(self._tool_list) # Version label - version_label = QLabel("Czkawka PySide6 v11.0.1") + version_label = QLabel(tr("version-label")) version_label.setAlignment(Qt.AlignCenter) version_label.setEnabled(False) layout.addWidget(version_label) + def eventFilter(self, obj, event): + if obj is self._logo_label and event.type() == QEvent.Type.MouseButtonPress: + self.about_requested.emit() + return True + return super().eventFilter(obj, event) + def _on_item_changed(self, current, previous): if current: tab = current.data(Qt.UserRole) diff --git a/kalka/app/localizer.py b/kalka/app/localizer.py new file mode 100644 index 000000000..54507580b --- /dev/null +++ b/kalka/app/localizer.py @@ -0,0 +1,110 @@ +"""Internationalization support for Kalka using Fluent (.ftl) files. + +Follows the same Fluent format as krokiet for consistency. +Translation files are stored in kalka/i18n/{locale}/kalka.ftl. +""" + +import locale +import os +from pathlib import Path + +from fluent.runtime import FluentLocalization, FluentResourceLoader + + +_I18N_DIR = Path(__file__).parent.parent / "i18n" +_RESOURCE_FILE = "kalka.ftl" +_FALLBACK_LOCALE = "en" + +# Available locales (directories under i18n/ that contain kalka.ftl) +AVAILABLE_LOCALES: list[str] = [] + +# Active localization instance +_l10n: FluentLocalization | None = None + + +def _detect_system_locale() -> str: + """Detect system locale, returning a BCP47-style tag like 'en', 'pl', 'zh-CN'.""" + for env_var in ("LC_MESSAGES", "LC_ALL", "LANG", "LANGUAGE"): + val = os.environ.get(env_var, "") + if val: + # Strip encoding (e.g. "pl_PL.UTF-8" -> "pl_PL") + val = val.split(".")[0].split("@")[0] + break + else: + val, _ = locale.getdefaultlocale() + if not val: + return _FALLBACK_LOCALE + # Convert underscore to hyphen: pl_PL -> pl-PL + val = val.replace("_", "-") + return val + + +def _discover_locales() -> list[str]: + """Find all locale directories that contain a kalka.ftl file.""" + locales = [] + if _I18N_DIR.is_dir(): + for d in sorted(_I18N_DIR.iterdir()): + if d.is_dir() and (d / _RESOURCE_FILE).is_file(): + locales.append(d.name) + return locales + + +def _match_locale(requested: str, available: list[str]) -> list[str]: + """Build a locale negotiation chain: exact -> language-only -> fallback.""" + chain = [] + if requested in available: + chain.append(requested) + # Try language-only (e.g. "zh-CN" -> "zh") + lang = requested.split("-")[0] + if lang != requested and lang in available: + chain.append(lang) + # Always end with fallback + if _FALLBACK_LOCALE not in chain: + chain.append(_FALLBACK_LOCALE) + return chain + + +def init(locale_override: str | None = None): + """Initialize the localization system. + + Call this once at startup. If locale_override is None, + the system locale is auto-detected. + """ + global _l10n, AVAILABLE_LOCALES + + AVAILABLE_LOCALES = _discover_locales() + if not AVAILABLE_LOCALES: + AVAILABLE_LOCALES = [_FALLBACK_LOCALE] + + requested = locale_override or _detect_system_locale() + chain = _match_locale(requested, AVAILABLE_LOCALES) + + loader = FluentResourceLoader(str(_I18N_DIR / "{locale}")) + _l10n = FluentLocalization(chain, [_RESOURCE_FILE], loader) + + +def set_locale(locale_code: str): + """Switch to a different locale at runtime.""" + init(locale_override=locale_code) + + +def tr(msg_id: str, **kwargs) -> str: + """Translate a message ID, with optional keyword arguments for placeholders. + + Usage: + tr("scan-button") # simple + tr("scan-complete", count=42) # with variable + tr("deleted-files", deleted=5, total=10) # multiple variables + """ + if _l10n is None: + init() + value = _l10n.format_value(msg_id, kwargs if kwargs else None) + # fluent returns the msg_id itself if not found + return value + + +def get_current_locale() -> str: + """Return the first (best-match) locale in the current chain.""" + if _l10n is None: + init() + return _l10n.locales[0] if _l10n.locales else _FALLBACK_LOCALE diff --git a/czkawka_pyside6/app/progress_widget.py b/kalka/app/progress_widget.py similarity index 76% rename from czkawka_pyside6/app/progress_widget.py rename to kalka/app/progress_widget.py index ecc309745..4ce05a482 100644 --- a/czkawka_pyside6/app/progress_widget.py +++ b/kalka/app/progress_widget.py @@ -1,13 +1,14 @@ -import json +import os import time -from pathlib import Path +import threading from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QProgressBar, QLabel, QHBoxLayout ) -from PySide6.QtCore import Qt, QTimer, QStandardPaths +from PySide6.QtCore import Qt, QTimer from .models import ActiveTab, ScanProgress +from .localizer import tr class ProgressWidget(QWidget): @@ -27,8 +28,9 @@ def __init__(self, parent=None): self._start_time = 0.0 self._active_tab = ActiveTab.DUPLICATE_FILES self._last_collection_count = 0 # Files found during collection phase - self._estimates: dict[str, int] = {} - self._load_estimates() + self._file_count_estimate = 0 # Live count from background walker + self._file_count_thread: threading.Thread | None = None + self._file_count_stop = threading.Event() self._setup_ui() self._timer = QTimer(self) @@ -44,7 +46,7 @@ def _setup_ui(self): # Row 1: stage label + elapsed row1 = QHBoxLayout() - self._stage_label = QLabel("Initializing...") + self._stage_label = QLabel(tr("progress-initializing")) font = self._stage_label.font() font.setBold(True) self._stage_label.setFont(font) @@ -58,7 +60,7 @@ def _setup_ui(self): # Row 2: current stage bar "Current" NN% row2 = QHBoxLayout() row2.setSpacing(6) - lbl2 = QLabel("Current") + lbl2 = QLabel(tr("progress-current")) lbl2.setEnabled(False) lbl2.setFixedWidth(48) row2.addWidget(lbl2) @@ -76,7 +78,7 @@ def _setup_ui(self): # Row 3: overall bar "Overall" NN% row3 = QHBoxLayout() row3.setSpacing(6) - lbl3 = QLabel("Overall") + lbl3 = QLabel(tr("progress-overall")) lbl3.setEnabled(False) lbl3.setFixedWidth(48) row3.addWidget(lbl3) @@ -112,11 +114,13 @@ def _setup_ui(self): # ── Public API ──────────────────────────────────────────── - def start(self, tab: ActiveTab = None): + def start(self, tab: ActiveTab = None, included_paths: list[str] | None = None, + excluded_paths: list[str] | None = None): if tab is not None: self._active_tab = tab self._start_time = time.monotonic() self._last_collection_count = 0 + self._file_count_estimate = 0 self.setVisible(True) for bar in (self._stage_bar, self._overall_bar): @@ -124,17 +128,21 @@ def start(self, tab: ActiveTab = None): bar.setValue(0) self._stage_pct.setText("") self._overall_pct.setText("") - self._stage_label.setText("Starting scan...") + self._stage_label.setText(tr("progress-starting")) self._detail_label.setText("") self._size_label.setText("") self._elapsed_label.setText("0s") self._steps_label.setText("") self._timer.start() + # Start background file count for collection-phase estimate + self._start_file_count(included_paths or [], excluded_paths or []) + def stop(self): self._timer.stop() + self._stop_file_count() elapsed = time.monotonic() - self._start_time if self._start_time else 0 - self._elapsed_label.setText(f"Completed in {self._format_time(elapsed)}") + self._elapsed_label.setText(tr("progress-completed-in", time=self._format_time(elapsed))) for bar, lbl in ((self._stage_bar, self._stage_pct), (self._overall_bar, self._overall_pct)): @@ -142,13 +150,9 @@ def stop(self): bar.setValue(100) lbl.setText("100%") - self._stage_label.setText("Scan complete") + self._stage_label.setText(tr("progress-scan-complete")) self._steps_label.setText("") - # Save collection count for next-scan estimation - if self._last_collection_count > 0: - self._save_estimate(self._last_collection_count) - QTimer.singleShot(3000, self._auto_hide) def update_progress(self, progress: ScanProgress): @@ -175,9 +179,9 @@ def update_progress(self, progress: ScanProgress): is_collecting = (idx == 0 and to_check == 0) if is_collecting: - # Collection phase: use estimate from previous scan + # Collection phase: use live background file count as estimate self._last_collection_count = max(self._last_collection_count, checked) - estimate = self._get_estimate() + estimate = self._file_count_estimate if estimate > 0 and checked > 0: pct = min(99, int(checked * 100 / estimate)) self._stage_bar.setMaximum(100) @@ -221,8 +225,8 @@ def update_progress(self, progress: ScanProgress): if max_idx > 0: if to_check > 0: stage_frac = (b_checked / b_to_check) if b_to_check > 0 else (checked / to_check) - elif is_collecting and self._get_estimate() > 0 and checked > 0: - stage_frac = min(0.99, checked / self._get_estimate()) + elif is_collecting and self._file_count_estimate > 0 and checked > 0: + stage_frac = min(0.99, checked / self._file_count_estimate) else: stage_frac = 0 overall = (idx + min(stage_frac, 0.99)) / (max_idx + 1) @@ -234,37 +238,49 @@ def update_progress(self, progress: ScanProgress): self._overall_bar.setMaximum(0) self._overall_pct.setText("") - # ── Collection estimate persistence ─────────────────────── - - def _get_estimate_key(self) -> str: - """Key for the estimate cache based on active tab.""" - return self._active_tab.name - - def _get_estimate(self) -> int: - return self._estimates.get(self._get_estimate_key(), 0) - - @staticmethod - def _estimate_file_path() -> Path: - config_dir = QStandardPaths.writableLocation(QStandardPaths.AppConfigLocation) - base = Path(config_dir) if config_dir else Path.home() / ".config" / "czkawka" - return base / "scan_estimates.json" - - def _save_estimate(self, count: int): - self._estimates[self._get_estimate_key()] = count - try: - path = self._estimate_file_path() - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(self._estimates)) - except OSError: - pass - - def _load_estimates(self): - try: - path = self._estimate_file_path() - if path.exists(): - self._estimates = json.loads(path.read_text()) - except (json.JSONDecodeError, OSError): - self._estimates = {} + # ── Background file counter ───────────────────────────── + + def _start_file_count(self, included: list[str], excluded: list[str]): + """Start a background thread that walks included dirs to count files.""" + self._stop_file_count() + self._file_count_stop.clear() + self._file_count_estimate = 0 + + if not included: + return + + excluded_set = set(os.path.realpath(p) for p in excluded) + + def _count(): + count = 0 + for root_dir in included: + if self._file_count_stop.is_set(): + return + try: + for dirpath, dirnames, filenames in os.walk(root_dir): + if self._file_count_stop.is_set(): + return + real = os.path.realpath(dirpath) + # Prune excluded subtrees + dirnames[:] = [ + d for d in dirnames + if os.path.realpath(os.path.join(dirpath, d)) not in excluded_set + ] + if real in excluded_set: + continue + count += len(filenames) + self._file_count_estimate = count + except OSError: + continue + + self._file_count_thread = threading.Thread(target=_count, daemon=True) + self._file_count_thread.start() + + def _stop_file_count(self): + self._file_count_stop.set() + if self._file_count_thread is not None: + self._file_count_thread.join(timeout=1) + self._file_count_thread = None # ── Step indicator ──────────────────────────────────────── diff --git a/czkawka_pyside6/app/state.py b/kalka/app/state.py similarity index 91% rename from czkawka_pyside6/app/state.py rename to kalka/app/state.py index 5dd993ffa..9c73524ff 100644 --- a/czkawka_pyside6/app/state.py +++ b/kalka/app/state.py @@ -32,8 +32,6 @@ def __init__(self): self.progress = ScanProgress() self.info_text = "" self.preview_image_path = "" - self.window_geometry = None # bytes as hex string - self.window_state = None # Use QStandardPaths for XDG-compliant config location config_dir = QStandardPaths.writableLocation(QStandardPaths.AppConfigLocation) self._config_path = Path(config_dir) if config_dir else Path.home() / ".config" / "czkawka" @@ -79,7 +77,6 @@ def save_settings(self): config_file = self._config_path / "settings.json" data = { "included_paths": self.settings.included_paths, - "reference_paths": list(self.settings.reference_paths), "excluded_paths": self.settings.excluded_paths, "excluded_items": self.settings.excluded_items, "allowed_extensions": self.settings.allowed_extensions, @@ -95,8 +92,6 @@ def save_settings(self): "dark_theme": self.settings.dark_theme, "show_image_preview": self.settings.show_image_preview, "czkawka_cli_path": self.settings.czkawka_cli_path, - "window_geometry": self.window_geometry, - "window_state": self.window_state, } try: config_file.write_text(json.dumps(data, indent=2)) @@ -110,7 +105,6 @@ def load_settings(self): data = json.loads(config_file.read_text()) s = self.settings s.included_paths = data.get("included_paths", s.included_paths) - s.reference_paths = set(data.get("reference_paths", [])) s.excluded_paths = data.get("excluded_paths", s.excluded_paths) s.excluded_items = data.get("excluded_items", s.excluded_items) s.allowed_extensions = data.get("allowed_extensions", s.allowed_extensions) @@ -126,7 +120,5 @@ def load_settings(self): s.dark_theme = data.get("dark_theme", s.dark_theme) s.show_image_preview = data.get("show_image_preview", s.show_image_preview) s.czkawka_cli_path = data.get("czkawka_cli_path", s.czkawka_cli_path) - self.window_geometry = data.get("window_geometry", self.window_geometry) - self.window_state = data.get("window_state", self.window_state) except (json.JSONDecodeError, OSError): pass diff --git a/kalka/i18n.toml b/kalka/i18n.toml new file mode 100644 index 000000000..76f7c3103 --- /dev/null +++ b/kalka/i18n.toml @@ -0,0 +1,4 @@ +fallback_language = "en" + +[fluent] +assets_dir = "i18n" diff --git a/kalka/i18n/ar/kalka.ftl b/kalka/i18n/ar/kalka.ftl new file mode 100644 index 000000000..fdbcd226c --- /dev/null +++ b/kalka/i18n/ar/kalka.ftl @@ -0,0 +1,338 @@ +# Kalka – English translations (Fluent format) +# This file follows the same Fluent convention as krokiet/i18n/ + +# ── Window & app ────────────────────────────────────────── +main-window-title = Kalka - Data Cleaner +about-title = About Kalka +about-app-name = Kalka +about-subtitle = PySide6 / Qt 6 Edition +about-version = Version 11.0.1 +about-description = + Kalka is a simple, fast and free app to remove + unnecessary files from your computer. + + This PySide6/Qt interface uses the czkawka_cli backend + for all scanning and file operations. + + Features: + - Find duplicate files (by hash, name, or size) + - Find empty files and folders + - Find similar images, videos, and music + - Find broken files and invalid symlinks + - Find files with bad extensions or names + - Remove EXIF metadata from images + - Optimize and crop videos + + Licensed under MIT License + https://github.com/qarmin/czkawka +about-logo-tooltip = About Kalka +version-label = Kalka v11.0.1 + +# ── Status bar ──────────────────────────────────────────── +status-ready = Ready +status-tab = Tab: { $tab_name } +status-scanning = Scanning: { $tab_name }... +status-scan-complete = Scan complete: found { $count } entries +status-scan-stopped = Scan stopped by user +status-error = Error: { $message } +status-deleted = Deleted { $count } file(s) +status-deleted-dry-run = [DRY RUN] Deleted { $count } file(s) +status-moved = Moved { $count } file(s) +status-moved-dry-run = [DRY RUN] Moved { $count } file(s) +status-copied = Copied { $count } file(s) +status-copied-dry-run = [DRY RUN] Copied { $count } file(s) +status-hardlinks-created = Created { $count } hardlink(s) +status-symlinks-created = Created { $count } symlink(s) +status-exif-cleaned = Cleaned EXIF from { $count } file(s) +status-extensions-fixed = Extensions fixed +status-names-fixed = Names fixed +status-results-saved = Results saved successfully +status-results-loaded = Loaded { $count } entries from file +status-video-optimize = Video optimization: use CLI directly for this feature + +# ── Buttons ─────────────────────────────────────────────── +scan-button = Scan +stop-button = Stop +select-button = Select +delete-button = Delete +move-button = Move +save-button = Save +load-button = Load +load-button-tooltip = Load previously saved results +sort-button = Sort +hardlink-button = Hardlink +symlink-button = Symlink +rename-button = Rename +clean-exif-button = Clean EXIF +optimize-button = Optimize + +# ── Tool names ──────────────────────────────────────────── +tool-duplicate-files = Duplicate Files +tool-empty-folders = Empty Folders +tool-big-files = Big Files +tool-empty-files = Empty Files +tool-temporary-files = Temporary Files +tool-similar-images = Similar Images +tool-similar-videos = Similar Videos +tool-similar-music = Similar Music +tool-invalid-symlinks = Invalid Symlinks +tool-broken-files = Broken Files +tool-bad-extensions = Bad Extensions +tool-bad-names = Bad Names +tool-exif-remover = EXIF Remover +tool-video-optimizer = Video Optimizer + +# ── Column headers ──────────────────────────────────────── +column-selection = Selection +column-size = Size +column-file-name = File Name +column-path = Path +column-modification-date = Modification Date +column-hash = Hash +column-similarity = Similarity +column-resolution = Resolution +column-title = Title +column-artist = Artist +column-year = Year +column-bitrate = Bitrate +column-genre = Genre +column-length = Length +column-folder-name = Folder Name +column-symlink-name = Symlink Name +column-symlink-path = Symlink Path +column-destination-path = Destination Path +column-type-of-error = Type of Error +column-error-type = Error Type +column-current-extension = Current Extension +column-proper-extension = Proper Extension +column-codec = Codec + +# ── Left panel ──────────────────────────────────────────── +settings-tooltip = Application Settings +tool-settings-tooltip = Tool-specific Settings +fallback-app-name = Kalka + +# ── Settings panel ──────────────────────────────────────── +settings-title = Settings +settings-close = Close +settings-tab-general = General +settings-tab-directories = Directories +settings-tab-filters = Filters +settings-tab-preview = Preview +settings-window-title = Kalka Settings + +# General settings +settings-cli-path = czkawka_cli Path: +settings-browse = Browse +settings-thread-count = Thread Count: +settings-thread-auto = Auto (all cores) +settings-recursive = Recursive search +settings-use-cache = Use cache for faster rescans +settings-move-to-trash = Move to trash instead of permanent delete +settings-hide-hard-links = Hide hard links +settings-save-as-json = Save results as JSON (instead of text) +settings-select-cli-binary = Select czkawka_cli binary + +# Directories settings +settings-included-dirs = Included Directories +settings-excluded-dirs = Excluded Directories +settings-add = Add +settings-remove = Remove +settings-select-dir-include = Select Directory to Include +settings-select-dir-exclude = Select Directory to Exclude + +# Filters settings +settings-excluded-items = Excluded Items: +settings-excluded-items-hint = Wildcard patterns, comma-separated (e.g. *.tmp,cache_*) +settings-allowed-extensions = Allowed Extensions: +settings-allowed-extensions-hint = e.g. jpg,png,gif +settings-excluded-extensions = Excluded Extensions: +settings-excluded-extensions-hint = e.g. log,tmp +settings-min-file-size = Minimum File Size: +settings-min-file-size-hint = In bytes (e.g. 1024) +settings-max-file-size = Maximum File Size: +settings-max-file-size-hint = In bytes (leave empty for no limit) + +# Preview settings +settings-show-image-preview = Show image preview + +# ── Tool settings panel ────────────────────────────────── +tool-settings-title = Tool Settings + +# Duplicate files +subsettings-check-method = Check Method: +subsettings-hash-type = Hash Type: +subsettings-case-sensitive = Case sensitive name comparison + +# Similar images +subsettings-hash-size = Hash Size: +subsettings-resize-algorithm = Resize Algorithm: +subsettings-image-hash-type = Hash Type: +subsettings-ignore-same-size = Ignore same size +subsettings-max-difference = Max Difference: + +# Similar videos +subsettings-crop-detect = Crop Detect: +subsettings-skip-forward = Skip Forward (s): +subsettings-hash-duration = Hash Duration (s): + +# Similar music +subsettings-audio-check-type = Audio Check Type: +subsettings-tag-matching = Tag Matching +subsettings-approximate-comparison = Approximate comparison +subsettings-fingerprint-matching = Fingerprint Matching +subsettings-compare-similar-titles = Compare with similar titles + +# Big files +subsettings-method = Method: +subsettings-the-biggest = The Biggest +subsettings-the-smallest = The Smallest +subsettings-number-of-files = Number of Files: + +# Broken files +subsettings-file-types = File types to check: + +# Bad names +subsettings-check-for = Check for: +subsettings-uppercase-ext = Uppercase extension +subsettings-uppercase-ext-hint = Files with .JPG, .PNG etc. +subsettings-emoji = Emoji in name +subsettings-emoji-hint = Files containing emoji characters +subsettings-space = Space at start/end +subsettings-space-hint = Leading or trailing whitespace +subsettings-non-ascii = Non-ASCII characters +subsettings-non-ascii-hint = Characters outside ASCII range +subsettings-remove-duplicated = Remove duplicated non-alphanumeric +subsettings-remove-duplicated-hint = e.g. file--name..txt +subsettings-restricted-charset = Restricted charset: +subsettings-restricted-charset-hint = Allowed special chars, comma-separated + +# EXIF +subsettings-ignored-exif-tags = Ignored EXIF Tags: +subsettings-ignored-exif-tags-hint = Tags to ignore, comma-separated + +# Video optimizer +subsettings-mode = Mode: +subsettings-crop-settings = Crop Settings +subsettings-crop-type = Crop Type: +subsettings-black-pixel-threshold = Black Pixel Threshold: +subsettings-black-bar-min-pct = Black Bar Min %: +subsettings-max-samples = Max Samples: +subsettings-min-crop-size = Min Crop Size: +subsettings-transcode-settings = Transcode Settings +subsettings-excluded-codecs = Excluded Codecs: +subsettings-target-codec = Target Codec: +subsettings-quality = Quality: +subsettings-fail-if-bigger = Fail if not smaller + +# ── Progress widget ────────────────────────────────────── +progress-initializing = Initializing... +progress-starting = Starting scan... +progress-current = Current +progress-overall = Overall +progress-scan-complete = Scan complete +progress-completed-in = Completed in { $time } +progress-done = done + +# ── Results view ───────────────────────────────────────── +results-no-results = No results +results-found-grouped = Found { $total } files ({ $size }) in { $groups } groups +results-found-flat = Found { $total } entries ({ $size }) +results-selected = Selected: { $selected }/{ $total } ({ $selected_size }/{ $total_size }) + +# ── Preview panel ──────────────────────────────────────── +preview-title = Preview +preview-no-preview = No preview +preview-file-not-found = File not found +preview-not-available = Preview not available + for this file type +preview-cannot-load = Cannot load image + +# ── Context menu ───────────────────────────────────────── +context-open-file = Open File +context-open-folder = Open Containing Folder +context-select = Select +context-deselect = Deselect + +# ── Bottom panel ───────────────────────────────────────── +bottom-included-dirs = Included Directories: +bottom-excluded-dirs = Excluded Directories: + +# ── Delete dialog ──────────────────────────────────────── +delete-dialog-title = Delete Files +delete-dialog-message = Are you sure you want to delete { $count } selected file(s)? +delete-dialog-trash = Move to trash instead of permanent delete +delete-dialog-dry-run = Dry run (preview only, no files will be deleted) +delete-dialog-confirm = Delete + +# ── Move dialog ────────────────────────────────────────── +move-dialog-title = Move/Copy Files +move-dialog-message = Move or copy { $count } selected file(s) to: +move-dialog-placeholder = Select destination folder... +move-dialog-preserve = Preserve folder structure +move-dialog-copy-mode = Copy instead of move +move-dialog-dry-run = Dry run (preview only, no files will be moved) +move-dialog-confirm = Move +move-dialog-select-dest = Select Destination + +# ── Rename dialog ──────────────────────────────────────── +rename-dialog-confirm = Rename +rename-dialog-ext-message = Fix extensions for { $count } selected file(s)? + + Files will be renamed to use their proper extensions. +rename-dialog-names-message = Fix names for { $count } selected file(s)? + + Files with problematic names will be renamed. + +# ── Select dialog ──────────────────────────────────────── +select-dialog-title = Select Results +select-dialog-prompt = Choose selection mode: +select-all = Select All +unselect-all = Unselect All +invert-selection = Invert Selection +select-biggest-size = Select Biggest (by Size) +select-smallest-size = Select Smallest (by Size) +select-newest = Select Newest +select-oldest = Select Oldest +select-shortest-path = Select Shortest Path +select-longest-path = Select Longest Path +cancel = Cancel + +# ── Sort dialog ────────────────────────────────────────── +sort-dialog-title = Sort Results +sort-by = Sort by: +sort-ascending = Ascending + +# ── Hardlink / symlink dialogs ─────────────────────────── +hardlink-dialog-title = Create Hardlinks +hardlink-dialog-message = Replace { $count } selected file(s) with hardlinks to: + { $reference }? +symlink-dialog-title = Create Symlinks +symlink-dialog-message = Replace { $count } selected file(s) with symlinks to: + { $reference }? +no-reference-title = No Reference +no-reference-message = Cannot determine reference file. Leave at least one file unchecked in the group. + +# ── EXIF dialog ────────────────────────────────────────── +exif-dialog-title = Clean EXIF +exif-dialog-message = Remove EXIF metadata from { $count } selected file(s)? + +# ── Video optimize dialog ──────────────────────────────── +video-optimize-title = Video Optimization +video-optimize-message = Video optimization for { $count } file(s) will be performed using czkawka_cli. Check the status bar for progress. + +# ── Warnings / errors ──────────────────────────────────── +no-directories-title = No Directories +no-directories-message = Please add at least one directory to scan in the bottom panel. +no-selection-title = No Selection +no-selection-delete = No files selected for deletion. +no-selection-move = No files selected. +no-results-title = No Results +no-results-save = No results to save. +no-destination-title = No Destination +no-destination-message = Please select a destination folder. +scan-error-title = Scan Error + +# ── Save/load dialogs ──────────────────────────────────── +save-dialog-title = Save Results +load-dialog-title = Load Results diff --git a/kalka/i18n/bg/kalka.ftl b/kalka/i18n/bg/kalka.ftl new file mode 100644 index 000000000..fdbcd226c --- /dev/null +++ b/kalka/i18n/bg/kalka.ftl @@ -0,0 +1,338 @@ +# Kalka – English translations (Fluent format) +# This file follows the same Fluent convention as krokiet/i18n/ + +# ── Window & app ────────────────────────────────────────── +main-window-title = Kalka - Data Cleaner +about-title = About Kalka +about-app-name = Kalka +about-subtitle = PySide6 / Qt 6 Edition +about-version = Version 11.0.1 +about-description = + Kalka is a simple, fast and free app to remove + unnecessary files from your computer. + + This PySide6/Qt interface uses the czkawka_cli backend + for all scanning and file operations. + + Features: + - Find duplicate files (by hash, name, or size) + - Find empty files and folders + - Find similar images, videos, and music + - Find broken files and invalid symlinks + - Find files with bad extensions or names + - Remove EXIF metadata from images + - Optimize and crop videos + + Licensed under MIT License + https://github.com/qarmin/czkawka +about-logo-tooltip = About Kalka +version-label = Kalka v11.0.1 + +# ── Status bar ──────────────────────────────────────────── +status-ready = Ready +status-tab = Tab: { $tab_name } +status-scanning = Scanning: { $tab_name }... +status-scan-complete = Scan complete: found { $count } entries +status-scan-stopped = Scan stopped by user +status-error = Error: { $message } +status-deleted = Deleted { $count } file(s) +status-deleted-dry-run = [DRY RUN] Deleted { $count } file(s) +status-moved = Moved { $count } file(s) +status-moved-dry-run = [DRY RUN] Moved { $count } file(s) +status-copied = Copied { $count } file(s) +status-copied-dry-run = [DRY RUN] Copied { $count } file(s) +status-hardlinks-created = Created { $count } hardlink(s) +status-symlinks-created = Created { $count } symlink(s) +status-exif-cleaned = Cleaned EXIF from { $count } file(s) +status-extensions-fixed = Extensions fixed +status-names-fixed = Names fixed +status-results-saved = Results saved successfully +status-results-loaded = Loaded { $count } entries from file +status-video-optimize = Video optimization: use CLI directly for this feature + +# ── Buttons ─────────────────────────────────────────────── +scan-button = Scan +stop-button = Stop +select-button = Select +delete-button = Delete +move-button = Move +save-button = Save +load-button = Load +load-button-tooltip = Load previously saved results +sort-button = Sort +hardlink-button = Hardlink +symlink-button = Symlink +rename-button = Rename +clean-exif-button = Clean EXIF +optimize-button = Optimize + +# ── Tool names ──────────────────────────────────────────── +tool-duplicate-files = Duplicate Files +tool-empty-folders = Empty Folders +tool-big-files = Big Files +tool-empty-files = Empty Files +tool-temporary-files = Temporary Files +tool-similar-images = Similar Images +tool-similar-videos = Similar Videos +tool-similar-music = Similar Music +tool-invalid-symlinks = Invalid Symlinks +tool-broken-files = Broken Files +tool-bad-extensions = Bad Extensions +tool-bad-names = Bad Names +tool-exif-remover = EXIF Remover +tool-video-optimizer = Video Optimizer + +# ── Column headers ──────────────────────────────────────── +column-selection = Selection +column-size = Size +column-file-name = File Name +column-path = Path +column-modification-date = Modification Date +column-hash = Hash +column-similarity = Similarity +column-resolution = Resolution +column-title = Title +column-artist = Artist +column-year = Year +column-bitrate = Bitrate +column-genre = Genre +column-length = Length +column-folder-name = Folder Name +column-symlink-name = Symlink Name +column-symlink-path = Symlink Path +column-destination-path = Destination Path +column-type-of-error = Type of Error +column-error-type = Error Type +column-current-extension = Current Extension +column-proper-extension = Proper Extension +column-codec = Codec + +# ── Left panel ──────────────────────────────────────────── +settings-tooltip = Application Settings +tool-settings-tooltip = Tool-specific Settings +fallback-app-name = Kalka + +# ── Settings panel ──────────────────────────────────────── +settings-title = Settings +settings-close = Close +settings-tab-general = General +settings-tab-directories = Directories +settings-tab-filters = Filters +settings-tab-preview = Preview +settings-window-title = Kalka Settings + +# General settings +settings-cli-path = czkawka_cli Path: +settings-browse = Browse +settings-thread-count = Thread Count: +settings-thread-auto = Auto (all cores) +settings-recursive = Recursive search +settings-use-cache = Use cache for faster rescans +settings-move-to-trash = Move to trash instead of permanent delete +settings-hide-hard-links = Hide hard links +settings-save-as-json = Save results as JSON (instead of text) +settings-select-cli-binary = Select czkawka_cli binary + +# Directories settings +settings-included-dirs = Included Directories +settings-excluded-dirs = Excluded Directories +settings-add = Add +settings-remove = Remove +settings-select-dir-include = Select Directory to Include +settings-select-dir-exclude = Select Directory to Exclude + +# Filters settings +settings-excluded-items = Excluded Items: +settings-excluded-items-hint = Wildcard patterns, comma-separated (e.g. *.tmp,cache_*) +settings-allowed-extensions = Allowed Extensions: +settings-allowed-extensions-hint = e.g. jpg,png,gif +settings-excluded-extensions = Excluded Extensions: +settings-excluded-extensions-hint = e.g. log,tmp +settings-min-file-size = Minimum File Size: +settings-min-file-size-hint = In bytes (e.g. 1024) +settings-max-file-size = Maximum File Size: +settings-max-file-size-hint = In bytes (leave empty for no limit) + +# Preview settings +settings-show-image-preview = Show image preview + +# ── Tool settings panel ────────────────────────────────── +tool-settings-title = Tool Settings + +# Duplicate files +subsettings-check-method = Check Method: +subsettings-hash-type = Hash Type: +subsettings-case-sensitive = Case sensitive name comparison + +# Similar images +subsettings-hash-size = Hash Size: +subsettings-resize-algorithm = Resize Algorithm: +subsettings-image-hash-type = Hash Type: +subsettings-ignore-same-size = Ignore same size +subsettings-max-difference = Max Difference: + +# Similar videos +subsettings-crop-detect = Crop Detect: +subsettings-skip-forward = Skip Forward (s): +subsettings-hash-duration = Hash Duration (s): + +# Similar music +subsettings-audio-check-type = Audio Check Type: +subsettings-tag-matching = Tag Matching +subsettings-approximate-comparison = Approximate comparison +subsettings-fingerprint-matching = Fingerprint Matching +subsettings-compare-similar-titles = Compare with similar titles + +# Big files +subsettings-method = Method: +subsettings-the-biggest = The Biggest +subsettings-the-smallest = The Smallest +subsettings-number-of-files = Number of Files: + +# Broken files +subsettings-file-types = File types to check: + +# Bad names +subsettings-check-for = Check for: +subsettings-uppercase-ext = Uppercase extension +subsettings-uppercase-ext-hint = Files with .JPG, .PNG etc. +subsettings-emoji = Emoji in name +subsettings-emoji-hint = Files containing emoji characters +subsettings-space = Space at start/end +subsettings-space-hint = Leading or trailing whitespace +subsettings-non-ascii = Non-ASCII characters +subsettings-non-ascii-hint = Characters outside ASCII range +subsettings-remove-duplicated = Remove duplicated non-alphanumeric +subsettings-remove-duplicated-hint = e.g. file--name..txt +subsettings-restricted-charset = Restricted charset: +subsettings-restricted-charset-hint = Allowed special chars, comma-separated + +# EXIF +subsettings-ignored-exif-tags = Ignored EXIF Tags: +subsettings-ignored-exif-tags-hint = Tags to ignore, comma-separated + +# Video optimizer +subsettings-mode = Mode: +subsettings-crop-settings = Crop Settings +subsettings-crop-type = Crop Type: +subsettings-black-pixel-threshold = Black Pixel Threshold: +subsettings-black-bar-min-pct = Black Bar Min %: +subsettings-max-samples = Max Samples: +subsettings-min-crop-size = Min Crop Size: +subsettings-transcode-settings = Transcode Settings +subsettings-excluded-codecs = Excluded Codecs: +subsettings-target-codec = Target Codec: +subsettings-quality = Quality: +subsettings-fail-if-bigger = Fail if not smaller + +# ── Progress widget ────────────────────────────────────── +progress-initializing = Initializing... +progress-starting = Starting scan... +progress-current = Current +progress-overall = Overall +progress-scan-complete = Scan complete +progress-completed-in = Completed in { $time } +progress-done = done + +# ── Results view ───────────────────────────────────────── +results-no-results = No results +results-found-grouped = Found { $total } files ({ $size }) in { $groups } groups +results-found-flat = Found { $total } entries ({ $size }) +results-selected = Selected: { $selected }/{ $total } ({ $selected_size }/{ $total_size }) + +# ── Preview panel ──────────────────────────────────────── +preview-title = Preview +preview-no-preview = No preview +preview-file-not-found = File not found +preview-not-available = Preview not available + for this file type +preview-cannot-load = Cannot load image + +# ── Context menu ───────────────────────────────────────── +context-open-file = Open File +context-open-folder = Open Containing Folder +context-select = Select +context-deselect = Deselect + +# ── Bottom panel ───────────────────────────────────────── +bottom-included-dirs = Included Directories: +bottom-excluded-dirs = Excluded Directories: + +# ── Delete dialog ──────────────────────────────────────── +delete-dialog-title = Delete Files +delete-dialog-message = Are you sure you want to delete { $count } selected file(s)? +delete-dialog-trash = Move to trash instead of permanent delete +delete-dialog-dry-run = Dry run (preview only, no files will be deleted) +delete-dialog-confirm = Delete + +# ── Move dialog ────────────────────────────────────────── +move-dialog-title = Move/Copy Files +move-dialog-message = Move or copy { $count } selected file(s) to: +move-dialog-placeholder = Select destination folder... +move-dialog-preserve = Preserve folder structure +move-dialog-copy-mode = Copy instead of move +move-dialog-dry-run = Dry run (preview only, no files will be moved) +move-dialog-confirm = Move +move-dialog-select-dest = Select Destination + +# ── Rename dialog ──────────────────────────────────────── +rename-dialog-confirm = Rename +rename-dialog-ext-message = Fix extensions for { $count } selected file(s)? + + Files will be renamed to use their proper extensions. +rename-dialog-names-message = Fix names for { $count } selected file(s)? + + Files with problematic names will be renamed. + +# ── Select dialog ──────────────────────────────────────── +select-dialog-title = Select Results +select-dialog-prompt = Choose selection mode: +select-all = Select All +unselect-all = Unselect All +invert-selection = Invert Selection +select-biggest-size = Select Biggest (by Size) +select-smallest-size = Select Smallest (by Size) +select-newest = Select Newest +select-oldest = Select Oldest +select-shortest-path = Select Shortest Path +select-longest-path = Select Longest Path +cancel = Cancel + +# ── Sort dialog ────────────────────────────────────────── +sort-dialog-title = Sort Results +sort-by = Sort by: +sort-ascending = Ascending + +# ── Hardlink / symlink dialogs ─────────────────────────── +hardlink-dialog-title = Create Hardlinks +hardlink-dialog-message = Replace { $count } selected file(s) with hardlinks to: + { $reference }? +symlink-dialog-title = Create Symlinks +symlink-dialog-message = Replace { $count } selected file(s) with symlinks to: + { $reference }? +no-reference-title = No Reference +no-reference-message = Cannot determine reference file. Leave at least one file unchecked in the group. + +# ── EXIF dialog ────────────────────────────────────────── +exif-dialog-title = Clean EXIF +exif-dialog-message = Remove EXIF metadata from { $count } selected file(s)? + +# ── Video optimize dialog ──────────────────────────────── +video-optimize-title = Video Optimization +video-optimize-message = Video optimization for { $count } file(s) will be performed using czkawka_cli. Check the status bar for progress. + +# ── Warnings / errors ──────────────────────────────────── +no-directories-title = No Directories +no-directories-message = Please add at least one directory to scan in the bottom panel. +no-selection-title = No Selection +no-selection-delete = No files selected for deletion. +no-selection-move = No files selected. +no-results-title = No Results +no-results-save = No results to save. +no-destination-title = No Destination +no-destination-message = Please select a destination folder. +scan-error-title = Scan Error + +# ── Save/load dialogs ──────────────────────────────────── +save-dialog-title = Save Results +load-dialog-title = Load Results diff --git a/kalka/i18n/cs/kalka.ftl b/kalka/i18n/cs/kalka.ftl new file mode 100644 index 000000000..fdbcd226c --- /dev/null +++ b/kalka/i18n/cs/kalka.ftl @@ -0,0 +1,338 @@ +# Kalka – English translations (Fluent format) +# This file follows the same Fluent convention as krokiet/i18n/ + +# ── Window & app ────────────────────────────────────────── +main-window-title = Kalka - Data Cleaner +about-title = About Kalka +about-app-name = Kalka +about-subtitle = PySide6 / Qt 6 Edition +about-version = Version 11.0.1 +about-description = + Kalka is a simple, fast and free app to remove + unnecessary files from your computer. + + This PySide6/Qt interface uses the czkawka_cli backend + for all scanning and file operations. + + Features: + - Find duplicate files (by hash, name, or size) + - Find empty files and folders + - Find similar images, videos, and music + - Find broken files and invalid symlinks + - Find files with bad extensions or names + - Remove EXIF metadata from images + - Optimize and crop videos + + Licensed under MIT License + https://github.com/qarmin/czkawka +about-logo-tooltip = About Kalka +version-label = Kalka v11.0.1 + +# ── Status bar ──────────────────────────────────────────── +status-ready = Ready +status-tab = Tab: { $tab_name } +status-scanning = Scanning: { $tab_name }... +status-scan-complete = Scan complete: found { $count } entries +status-scan-stopped = Scan stopped by user +status-error = Error: { $message } +status-deleted = Deleted { $count } file(s) +status-deleted-dry-run = [DRY RUN] Deleted { $count } file(s) +status-moved = Moved { $count } file(s) +status-moved-dry-run = [DRY RUN] Moved { $count } file(s) +status-copied = Copied { $count } file(s) +status-copied-dry-run = [DRY RUN] Copied { $count } file(s) +status-hardlinks-created = Created { $count } hardlink(s) +status-symlinks-created = Created { $count } symlink(s) +status-exif-cleaned = Cleaned EXIF from { $count } file(s) +status-extensions-fixed = Extensions fixed +status-names-fixed = Names fixed +status-results-saved = Results saved successfully +status-results-loaded = Loaded { $count } entries from file +status-video-optimize = Video optimization: use CLI directly for this feature + +# ── Buttons ─────────────────────────────────────────────── +scan-button = Scan +stop-button = Stop +select-button = Select +delete-button = Delete +move-button = Move +save-button = Save +load-button = Load +load-button-tooltip = Load previously saved results +sort-button = Sort +hardlink-button = Hardlink +symlink-button = Symlink +rename-button = Rename +clean-exif-button = Clean EXIF +optimize-button = Optimize + +# ── Tool names ──────────────────────────────────────────── +tool-duplicate-files = Duplicate Files +tool-empty-folders = Empty Folders +tool-big-files = Big Files +tool-empty-files = Empty Files +tool-temporary-files = Temporary Files +tool-similar-images = Similar Images +tool-similar-videos = Similar Videos +tool-similar-music = Similar Music +tool-invalid-symlinks = Invalid Symlinks +tool-broken-files = Broken Files +tool-bad-extensions = Bad Extensions +tool-bad-names = Bad Names +tool-exif-remover = EXIF Remover +tool-video-optimizer = Video Optimizer + +# ── Column headers ──────────────────────────────────────── +column-selection = Selection +column-size = Size +column-file-name = File Name +column-path = Path +column-modification-date = Modification Date +column-hash = Hash +column-similarity = Similarity +column-resolution = Resolution +column-title = Title +column-artist = Artist +column-year = Year +column-bitrate = Bitrate +column-genre = Genre +column-length = Length +column-folder-name = Folder Name +column-symlink-name = Symlink Name +column-symlink-path = Symlink Path +column-destination-path = Destination Path +column-type-of-error = Type of Error +column-error-type = Error Type +column-current-extension = Current Extension +column-proper-extension = Proper Extension +column-codec = Codec + +# ── Left panel ──────────────────────────────────────────── +settings-tooltip = Application Settings +tool-settings-tooltip = Tool-specific Settings +fallback-app-name = Kalka + +# ── Settings panel ──────────────────────────────────────── +settings-title = Settings +settings-close = Close +settings-tab-general = General +settings-tab-directories = Directories +settings-tab-filters = Filters +settings-tab-preview = Preview +settings-window-title = Kalka Settings + +# General settings +settings-cli-path = czkawka_cli Path: +settings-browse = Browse +settings-thread-count = Thread Count: +settings-thread-auto = Auto (all cores) +settings-recursive = Recursive search +settings-use-cache = Use cache for faster rescans +settings-move-to-trash = Move to trash instead of permanent delete +settings-hide-hard-links = Hide hard links +settings-save-as-json = Save results as JSON (instead of text) +settings-select-cli-binary = Select czkawka_cli binary + +# Directories settings +settings-included-dirs = Included Directories +settings-excluded-dirs = Excluded Directories +settings-add = Add +settings-remove = Remove +settings-select-dir-include = Select Directory to Include +settings-select-dir-exclude = Select Directory to Exclude + +# Filters settings +settings-excluded-items = Excluded Items: +settings-excluded-items-hint = Wildcard patterns, comma-separated (e.g. *.tmp,cache_*) +settings-allowed-extensions = Allowed Extensions: +settings-allowed-extensions-hint = e.g. jpg,png,gif +settings-excluded-extensions = Excluded Extensions: +settings-excluded-extensions-hint = e.g. log,tmp +settings-min-file-size = Minimum File Size: +settings-min-file-size-hint = In bytes (e.g. 1024) +settings-max-file-size = Maximum File Size: +settings-max-file-size-hint = In bytes (leave empty for no limit) + +# Preview settings +settings-show-image-preview = Show image preview + +# ── Tool settings panel ────────────────────────────────── +tool-settings-title = Tool Settings + +# Duplicate files +subsettings-check-method = Check Method: +subsettings-hash-type = Hash Type: +subsettings-case-sensitive = Case sensitive name comparison + +# Similar images +subsettings-hash-size = Hash Size: +subsettings-resize-algorithm = Resize Algorithm: +subsettings-image-hash-type = Hash Type: +subsettings-ignore-same-size = Ignore same size +subsettings-max-difference = Max Difference: + +# Similar videos +subsettings-crop-detect = Crop Detect: +subsettings-skip-forward = Skip Forward (s): +subsettings-hash-duration = Hash Duration (s): + +# Similar music +subsettings-audio-check-type = Audio Check Type: +subsettings-tag-matching = Tag Matching +subsettings-approximate-comparison = Approximate comparison +subsettings-fingerprint-matching = Fingerprint Matching +subsettings-compare-similar-titles = Compare with similar titles + +# Big files +subsettings-method = Method: +subsettings-the-biggest = The Biggest +subsettings-the-smallest = The Smallest +subsettings-number-of-files = Number of Files: + +# Broken files +subsettings-file-types = File types to check: + +# Bad names +subsettings-check-for = Check for: +subsettings-uppercase-ext = Uppercase extension +subsettings-uppercase-ext-hint = Files with .JPG, .PNG etc. +subsettings-emoji = Emoji in name +subsettings-emoji-hint = Files containing emoji characters +subsettings-space = Space at start/end +subsettings-space-hint = Leading or trailing whitespace +subsettings-non-ascii = Non-ASCII characters +subsettings-non-ascii-hint = Characters outside ASCII range +subsettings-remove-duplicated = Remove duplicated non-alphanumeric +subsettings-remove-duplicated-hint = e.g. file--name..txt +subsettings-restricted-charset = Restricted charset: +subsettings-restricted-charset-hint = Allowed special chars, comma-separated + +# EXIF +subsettings-ignored-exif-tags = Ignored EXIF Tags: +subsettings-ignored-exif-tags-hint = Tags to ignore, comma-separated + +# Video optimizer +subsettings-mode = Mode: +subsettings-crop-settings = Crop Settings +subsettings-crop-type = Crop Type: +subsettings-black-pixel-threshold = Black Pixel Threshold: +subsettings-black-bar-min-pct = Black Bar Min %: +subsettings-max-samples = Max Samples: +subsettings-min-crop-size = Min Crop Size: +subsettings-transcode-settings = Transcode Settings +subsettings-excluded-codecs = Excluded Codecs: +subsettings-target-codec = Target Codec: +subsettings-quality = Quality: +subsettings-fail-if-bigger = Fail if not smaller + +# ── Progress widget ────────────────────────────────────── +progress-initializing = Initializing... +progress-starting = Starting scan... +progress-current = Current +progress-overall = Overall +progress-scan-complete = Scan complete +progress-completed-in = Completed in { $time } +progress-done = done + +# ── Results view ───────────────────────────────────────── +results-no-results = No results +results-found-grouped = Found { $total } files ({ $size }) in { $groups } groups +results-found-flat = Found { $total } entries ({ $size }) +results-selected = Selected: { $selected }/{ $total } ({ $selected_size }/{ $total_size }) + +# ── Preview panel ──────────────────────────────────────── +preview-title = Preview +preview-no-preview = No preview +preview-file-not-found = File not found +preview-not-available = Preview not available + for this file type +preview-cannot-load = Cannot load image + +# ── Context menu ───────────────────────────────────────── +context-open-file = Open File +context-open-folder = Open Containing Folder +context-select = Select +context-deselect = Deselect + +# ── Bottom panel ───────────────────────────────────────── +bottom-included-dirs = Included Directories: +bottom-excluded-dirs = Excluded Directories: + +# ── Delete dialog ──────────────────────────────────────── +delete-dialog-title = Delete Files +delete-dialog-message = Are you sure you want to delete { $count } selected file(s)? +delete-dialog-trash = Move to trash instead of permanent delete +delete-dialog-dry-run = Dry run (preview only, no files will be deleted) +delete-dialog-confirm = Delete + +# ── Move dialog ────────────────────────────────────────── +move-dialog-title = Move/Copy Files +move-dialog-message = Move or copy { $count } selected file(s) to: +move-dialog-placeholder = Select destination folder... +move-dialog-preserve = Preserve folder structure +move-dialog-copy-mode = Copy instead of move +move-dialog-dry-run = Dry run (preview only, no files will be moved) +move-dialog-confirm = Move +move-dialog-select-dest = Select Destination + +# ── Rename dialog ──────────────────────────────────────── +rename-dialog-confirm = Rename +rename-dialog-ext-message = Fix extensions for { $count } selected file(s)? + + Files will be renamed to use their proper extensions. +rename-dialog-names-message = Fix names for { $count } selected file(s)? + + Files with problematic names will be renamed. + +# ── Select dialog ──────────────────────────────────────── +select-dialog-title = Select Results +select-dialog-prompt = Choose selection mode: +select-all = Select All +unselect-all = Unselect All +invert-selection = Invert Selection +select-biggest-size = Select Biggest (by Size) +select-smallest-size = Select Smallest (by Size) +select-newest = Select Newest +select-oldest = Select Oldest +select-shortest-path = Select Shortest Path +select-longest-path = Select Longest Path +cancel = Cancel + +# ── Sort dialog ────────────────────────────────────────── +sort-dialog-title = Sort Results +sort-by = Sort by: +sort-ascending = Ascending + +# ── Hardlink / symlink dialogs ─────────────────────────── +hardlink-dialog-title = Create Hardlinks +hardlink-dialog-message = Replace { $count } selected file(s) with hardlinks to: + { $reference }? +symlink-dialog-title = Create Symlinks +symlink-dialog-message = Replace { $count } selected file(s) with symlinks to: + { $reference }? +no-reference-title = No Reference +no-reference-message = Cannot determine reference file. Leave at least one file unchecked in the group. + +# ── EXIF dialog ────────────────────────────────────────── +exif-dialog-title = Clean EXIF +exif-dialog-message = Remove EXIF metadata from { $count } selected file(s)? + +# ── Video optimize dialog ──────────────────────────────── +video-optimize-title = Video Optimization +video-optimize-message = Video optimization for { $count } file(s) will be performed using czkawka_cli. Check the status bar for progress. + +# ── Warnings / errors ──────────────────────────────────── +no-directories-title = No Directories +no-directories-message = Please add at least one directory to scan in the bottom panel. +no-selection-title = No Selection +no-selection-delete = No files selected for deletion. +no-selection-move = No files selected. +no-results-title = No Results +no-results-save = No results to save. +no-destination-title = No Destination +no-destination-message = Please select a destination folder. +scan-error-title = Scan Error + +# ── Save/load dialogs ──────────────────────────────────── +save-dialog-title = Save Results +load-dialog-title = Load Results diff --git a/kalka/i18n/de/kalka.ftl b/kalka/i18n/de/kalka.ftl new file mode 100644 index 000000000..fdbcd226c --- /dev/null +++ b/kalka/i18n/de/kalka.ftl @@ -0,0 +1,338 @@ +# Kalka – English translations (Fluent format) +# This file follows the same Fluent convention as krokiet/i18n/ + +# ── Window & app ────────────────────────────────────────── +main-window-title = Kalka - Data Cleaner +about-title = About Kalka +about-app-name = Kalka +about-subtitle = PySide6 / Qt 6 Edition +about-version = Version 11.0.1 +about-description = + Kalka is a simple, fast and free app to remove + unnecessary files from your computer. + + This PySide6/Qt interface uses the czkawka_cli backend + for all scanning and file operations. + + Features: + - Find duplicate files (by hash, name, or size) + - Find empty files and folders + - Find similar images, videos, and music + - Find broken files and invalid symlinks + - Find files with bad extensions or names + - Remove EXIF metadata from images + - Optimize and crop videos + + Licensed under MIT License + https://github.com/qarmin/czkawka +about-logo-tooltip = About Kalka +version-label = Kalka v11.0.1 + +# ── Status bar ──────────────────────────────────────────── +status-ready = Ready +status-tab = Tab: { $tab_name } +status-scanning = Scanning: { $tab_name }... +status-scan-complete = Scan complete: found { $count } entries +status-scan-stopped = Scan stopped by user +status-error = Error: { $message } +status-deleted = Deleted { $count } file(s) +status-deleted-dry-run = [DRY RUN] Deleted { $count } file(s) +status-moved = Moved { $count } file(s) +status-moved-dry-run = [DRY RUN] Moved { $count } file(s) +status-copied = Copied { $count } file(s) +status-copied-dry-run = [DRY RUN] Copied { $count } file(s) +status-hardlinks-created = Created { $count } hardlink(s) +status-symlinks-created = Created { $count } symlink(s) +status-exif-cleaned = Cleaned EXIF from { $count } file(s) +status-extensions-fixed = Extensions fixed +status-names-fixed = Names fixed +status-results-saved = Results saved successfully +status-results-loaded = Loaded { $count } entries from file +status-video-optimize = Video optimization: use CLI directly for this feature + +# ── Buttons ─────────────────────────────────────────────── +scan-button = Scan +stop-button = Stop +select-button = Select +delete-button = Delete +move-button = Move +save-button = Save +load-button = Load +load-button-tooltip = Load previously saved results +sort-button = Sort +hardlink-button = Hardlink +symlink-button = Symlink +rename-button = Rename +clean-exif-button = Clean EXIF +optimize-button = Optimize + +# ── Tool names ──────────────────────────────────────────── +tool-duplicate-files = Duplicate Files +tool-empty-folders = Empty Folders +tool-big-files = Big Files +tool-empty-files = Empty Files +tool-temporary-files = Temporary Files +tool-similar-images = Similar Images +tool-similar-videos = Similar Videos +tool-similar-music = Similar Music +tool-invalid-symlinks = Invalid Symlinks +tool-broken-files = Broken Files +tool-bad-extensions = Bad Extensions +tool-bad-names = Bad Names +tool-exif-remover = EXIF Remover +tool-video-optimizer = Video Optimizer + +# ── Column headers ──────────────────────────────────────── +column-selection = Selection +column-size = Size +column-file-name = File Name +column-path = Path +column-modification-date = Modification Date +column-hash = Hash +column-similarity = Similarity +column-resolution = Resolution +column-title = Title +column-artist = Artist +column-year = Year +column-bitrate = Bitrate +column-genre = Genre +column-length = Length +column-folder-name = Folder Name +column-symlink-name = Symlink Name +column-symlink-path = Symlink Path +column-destination-path = Destination Path +column-type-of-error = Type of Error +column-error-type = Error Type +column-current-extension = Current Extension +column-proper-extension = Proper Extension +column-codec = Codec + +# ── Left panel ──────────────────────────────────────────── +settings-tooltip = Application Settings +tool-settings-tooltip = Tool-specific Settings +fallback-app-name = Kalka + +# ── Settings panel ──────────────────────────────────────── +settings-title = Settings +settings-close = Close +settings-tab-general = General +settings-tab-directories = Directories +settings-tab-filters = Filters +settings-tab-preview = Preview +settings-window-title = Kalka Settings + +# General settings +settings-cli-path = czkawka_cli Path: +settings-browse = Browse +settings-thread-count = Thread Count: +settings-thread-auto = Auto (all cores) +settings-recursive = Recursive search +settings-use-cache = Use cache for faster rescans +settings-move-to-trash = Move to trash instead of permanent delete +settings-hide-hard-links = Hide hard links +settings-save-as-json = Save results as JSON (instead of text) +settings-select-cli-binary = Select czkawka_cli binary + +# Directories settings +settings-included-dirs = Included Directories +settings-excluded-dirs = Excluded Directories +settings-add = Add +settings-remove = Remove +settings-select-dir-include = Select Directory to Include +settings-select-dir-exclude = Select Directory to Exclude + +# Filters settings +settings-excluded-items = Excluded Items: +settings-excluded-items-hint = Wildcard patterns, comma-separated (e.g. *.tmp,cache_*) +settings-allowed-extensions = Allowed Extensions: +settings-allowed-extensions-hint = e.g. jpg,png,gif +settings-excluded-extensions = Excluded Extensions: +settings-excluded-extensions-hint = e.g. log,tmp +settings-min-file-size = Minimum File Size: +settings-min-file-size-hint = In bytes (e.g. 1024) +settings-max-file-size = Maximum File Size: +settings-max-file-size-hint = In bytes (leave empty for no limit) + +# Preview settings +settings-show-image-preview = Show image preview + +# ── Tool settings panel ────────────────────────────────── +tool-settings-title = Tool Settings + +# Duplicate files +subsettings-check-method = Check Method: +subsettings-hash-type = Hash Type: +subsettings-case-sensitive = Case sensitive name comparison + +# Similar images +subsettings-hash-size = Hash Size: +subsettings-resize-algorithm = Resize Algorithm: +subsettings-image-hash-type = Hash Type: +subsettings-ignore-same-size = Ignore same size +subsettings-max-difference = Max Difference: + +# Similar videos +subsettings-crop-detect = Crop Detect: +subsettings-skip-forward = Skip Forward (s): +subsettings-hash-duration = Hash Duration (s): + +# Similar music +subsettings-audio-check-type = Audio Check Type: +subsettings-tag-matching = Tag Matching +subsettings-approximate-comparison = Approximate comparison +subsettings-fingerprint-matching = Fingerprint Matching +subsettings-compare-similar-titles = Compare with similar titles + +# Big files +subsettings-method = Method: +subsettings-the-biggest = The Biggest +subsettings-the-smallest = The Smallest +subsettings-number-of-files = Number of Files: + +# Broken files +subsettings-file-types = File types to check: + +# Bad names +subsettings-check-for = Check for: +subsettings-uppercase-ext = Uppercase extension +subsettings-uppercase-ext-hint = Files with .JPG, .PNG etc. +subsettings-emoji = Emoji in name +subsettings-emoji-hint = Files containing emoji characters +subsettings-space = Space at start/end +subsettings-space-hint = Leading or trailing whitespace +subsettings-non-ascii = Non-ASCII characters +subsettings-non-ascii-hint = Characters outside ASCII range +subsettings-remove-duplicated = Remove duplicated non-alphanumeric +subsettings-remove-duplicated-hint = e.g. file--name..txt +subsettings-restricted-charset = Restricted charset: +subsettings-restricted-charset-hint = Allowed special chars, comma-separated + +# EXIF +subsettings-ignored-exif-tags = Ignored EXIF Tags: +subsettings-ignored-exif-tags-hint = Tags to ignore, comma-separated + +# Video optimizer +subsettings-mode = Mode: +subsettings-crop-settings = Crop Settings +subsettings-crop-type = Crop Type: +subsettings-black-pixel-threshold = Black Pixel Threshold: +subsettings-black-bar-min-pct = Black Bar Min %: +subsettings-max-samples = Max Samples: +subsettings-min-crop-size = Min Crop Size: +subsettings-transcode-settings = Transcode Settings +subsettings-excluded-codecs = Excluded Codecs: +subsettings-target-codec = Target Codec: +subsettings-quality = Quality: +subsettings-fail-if-bigger = Fail if not smaller + +# ── Progress widget ────────────────────────────────────── +progress-initializing = Initializing... +progress-starting = Starting scan... +progress-current = Current +progress-overall = Overall +progress-scan-complete = Scan complete +progress-completed-in = Completed in { $time } +progress-done = done + +# ── Results view ───────────────────────────────────────── +results-no-results = No results +results-found-grouped = Found { $total } files ({ $size }) in { $groups } groups +results-found-flat = Found { $total } entries ({ $size }) +results-selected = Selected: { $selected }/{ $total } ({ $selected_size }/{ $total_size }) + +# ── Preview panel ──────────────────────────────────────── +preview-title = Preview +preview-no-preview = No preview +preview-file-not-found = File not found +preview-not-available = Preview not available + for this file type +preview-cannot-load = Cannot load image + +# ── Context menu ───────────────────────────────────────── +context-open-file = Open File +context-open-folder = Open Containing Folder +context-select = Select +context-deselect = Deselect + +# ── Bottom panel ───────────────────────────────────────── +bottom-included-dirs = Included Directories: +bottom-excluded-dirs = Excluded Directories: + +# ── Delete dialog ──────────────────────────────────────── +delete-dialog-title = Delete Files +delete-dialog-message = Are you sure you want to delete { $count } selected file(s)? +delete-dialog-trash = Move to trash instead of permanent delete +delete-dialog-dry-run = Dry run (preview only, no files will be deleted) +delete-dialog-confirm = Delete + +# ── Move dialog ────────────────────────────────────────── +move-dialog-title = Move/Copy Files +move-dialog-message = Move or copy { $count } selected file(s) to: +move-dialog-placeholder = Select destination folder... +move-dialog-preserve = Preserve folder structure +move-dialog-copy-mode = Copy instead of move +move-dialog-dry-run = Dry run (preview only, no files will be moved) +move-dialog-confirm = Move +move-dialog-select-dest = Select Destination + +# ── Rename dialog ──────────────────────────────────────── +rename-dialog-confirm = Rename +rename-dialog-ext-message = Fix extensions for { $count } selected file(s)? + + Files will be renamed to use their proper extensions. +rename-dialog-names-message = Fix names for { $count } selected file(s)? + + Files with problematic names will be renamed. + +# ── Select dialog ──────────────────────────────────────── +select-dialog-title = Select Results +select-dialog-prompt = Choose selection mode: +select-all = Select All +unselect-all = Unselect All +invert-selection = Invert Selection +select-biggest-size = Select Biggest (by Size) +select-smallest-size = Select Smallest (by Size) +select-newest = Select Newest +select-oldest = Select Oldest +select-shortest-path = Select Shortest Path +select-longest-path = Select Longest Path +cancel = Cancel + +# ── Sort dialog ────────────────────────────────────────── +sort-dialog-title = Sort Results +sort-by = Sort by: +sort-ascending = Ascending + +# ── Hardlink / symlink dialogs ─────────────────────────── +hardlink-dialog-title = Create Hardlinks +hardlink-dialog-message = Replace { $count } selected file(s) with hardlinks to: + { $reference }? +symlink-dialog-title = Create Symlinks +symlink-dialog-message = Replace { $count } selected file(s) with symlinks to: + { $reference }? +no-reference-title = No Reference +no-reference-message = Cannot determine reference file. Leave at least one file unchecked in the group. + +# ── EXIF dialog ────────────────────────────────────────── +exif-dialog-title = Clean EXIF +exif-dialog-message = Remove EXIF metadata from { $count } selected file(s)? + +# ── Video optimize dialog ──────────────────────────────── +video-optimize-title = Video Optimization +video-optimize-message = Video optimization for { $count } file(s) will be performed using czkawka_cli. Check the status bar for progress. + +# ── Warnings / errors ──────────────────────────────────── +no-directories-title = No Directories +no-directories-message = Please add at least one directory to scan in the bottom panel. +no-selection-title = No Selection +no-selection-delete = No files selected for deletion. +no-selection-move = No files selected. +no-results-title = No Results +no-results-save = No results to save. +no-destination-title = No Destination +no-destination-message = Please select a destination folder. +scan-error-title = Scan Error + +# ── Save/load dialogs ──────────────────────────────────── +save-dialog-title = Save Results +load-dialog-title = Load Results diff --git a/kalka/i18n/el/kalka.ftl b/kalka/i18n/el/kalka.ftl new file mode 100644 index 000000000..fdbcd226c --- /dev/null +++ b/kalka/i18n/el/kalka.ftl @@ -0,0 +1,338 @@ +# Kalka – English translations (Fluent format) +# This file follows the same Fluent convention as krokiet/i18n/ + +# ── Window & app ────────────────────────────────────────── +main-window-title = Kalka - Data Cleaner +about-title = About Kalka +about-app-name = Kalka +about-subtitle = PySide6 / Qt 6 Edition +about-version = Version 11.0.1 +about-description = + Kalka is a simple, fast and free app to remove + unnecessary files from your computer. + + This PySide6/Qt interface uses the czkawka_cli backend + for all scanning and file operations. + + Features: + - Find duplicate files (by hash, name, or size) + - Find empty files and folders + - Find similar images, videos, and music + - Find broken files and invalid symlinks + - Find files with bad extensions or names + - Remove EXIF metadata from images + - Optimize and crop videos + + Licensed under MIT License + https://github.com/qarmin/czkawka +about-logo-tooltip = About Kalka +version-label = Kalka v11.0.1 + +# ── Status bar ──────────────────────────────────────────── +status-ready = Ready +status-tab = Tab: { $tab_name } +status-scanning = Scanning: { $tab_name }... +status-scan-complete = Scan complete: found { $count } entries +status-scan-stopped = Scan stopped by user +status-error = Error: { $message } +status-deleted = Deleted { $count } file(s) +status-deleted-dry-run = [DRY RUN] Deleted { $count } file(s) +status-moved = Moved { $count } file(s) +status-moved-dry-run = [DRY RUN] Moved { $count } file(s) +status-copied = Copied { $count } file(s) +status-copied-dry-run = [DRY RUN] Copied { $count } file(s) +status-hardlinks-created = Created { $count } hardlink(s) +status-symlinks-created = Created { $count } symlink(s) +status-exif-cleaned = Cleaned EXIF from { $count } file(s) +status-extensions-fixed = Extensions fixed +status-names-fixed = Names fixed +status-results-saved = Results saved successfully +status-results-loaded = Loaded { $count } entries from file +status-video-optimize = Video optimization: use CLI directly for this feature + +# ── Buttons ─────────────────────────────────────────────── +scan-button = Scan +stop-button = Stop +select-button = Select +delete-button = Delete +move-button = Move +save-button = Save +load-button = Load +load-button-tooltip = Load previously saved results +sort-button = Sort +hardlink-button = Hardlink +symlink-button = Symlink +rename-button = Rename +clean-exif-button = Clean EXIF +optimize-button = Optimize + +# ── Tool names ──────────────────────────────────────────── +tool-duplicate-files = Duplicate Files +tool-empty-folders = Empty Folders +tool-big-files = Big Files +tool-empty-files = Empty Files +tool-temporary-files = Temporary Files +tool-similar-images = Similar Images +tool-similar-videos = Similar Videos +tool-similar-music = Similar Music +tool-invalid-symlinks = Invalid Symlinks +tool-broken-files = Broken Files +tool-bad-extensions = Bad Extensions +tool-bad-names = Bad Names +tool-exif-remover = EXIF Remover +tool-video-optimizer = Video Optimizer + +# ── Column headers ──────────────────────────────────────── +column-selection = Selection +column-size = Size +column-file-name = File Name +column-path = Path +column-modification-date = Modification Date +column-hash = Hash +column-similarity = Similarity +column-resolution = Resolution +column-title = Title +column-artist = Artist +column-year = Year +column-bitrate = Bitrate +column-genre = Genre +column-length = Length +column-folder-name = Folder Name +column-symlink-name = Symlink Name +column-symlink-path = Symlink Path +column-destination-path = Destination Path +column-type-of-error = Type of Error +column-error-type = Error Type +column-current-extension = Current Extension +column-proper-extension = Proper Extension +column-codec = Codec + +# ── Left panel ──────────────────────────────────────────── +settings-tooltip = Application Settings +tool-settings-tooltip = Tool-specific Settings +fallback-app-name = Kalka + +# ── Settings panel ──────────────────────────────────────── +settings-title = Settings +settings-close = Close +settings-tab-general = General +settings-tab-directories = Directories +settings-tab-filters = Filters +settings-tab-preview = Preview +settings-window-title = Kalka Settings + +# General settings +settings-cli-path = czkawka_cli Path: +settings-browse = Browse +settings-thread-count = Thread Count: +settings-thread-auto = Auto (all cores) +settings-recursive = Recursive search +settings-use-cache = Use cache for faster rescans +settings-move-to-trash = Move to trash instead of permanent delete +settings-hide-hard-links = Hide hard links +settings-save-as-json = Save results as JSON (instead of text) +settings-select-cli-binary = Select czkawka_cli binary + +# Directories settings +settings-included-dirs = Included Directories +settings-excluded-dirs = Excluded Directories +settings-add = Add +settings-remove = Remove +settings-select-dir-include = Select Directory to Include +settings-select-dir-exclude = Select Directory to Exclude + +# Filters settings +settings-excluded-items = Excluded Items: +settings-excluded-items-hint = Wildcard patterns, comma-separated (e.g. *.tmp,cache_*) +settings-allowed-extensions = Allowed Extensions: +settings-allowed-extensions-hint = e.g. jpg,png,gif +settings-excluded-extensions = Excluded Extensions: +settings-excluded-extensions-hint = e.g. log,tmp +settings-min-file-size = Minimum File Size: +settings-min-file-size-hint = In bytes (e.g. 1024) +settings-max-file-size = Maximum File Size: +settings-max-file-size-hint = In bytes (leave empty for no limit) + +# Preview settings +settings-show-image-preview = Show image preview + +# ── Tool settings panel ────────────────────────────────── +tool-settings-title = Tool Settings + +# Duplicate files +subsettings-check-method = Check Method: +subsettings-hash-type = Hash Type: +subsettings-case-sensitive = Case sensitive name comparison + +# Similar images +subsettings-hash-size = Hash Size: +subsettings-resize-algorithm = Resize Algorithm: +subsettings-image-hash-type = Hash Type: +subsettings-ignore-same-size = Ignore same size +subsettings-max-difference = Max Difference: + +# Similar videos +subsettings-crop-detect = Crop Detect: +subsettings-skip-forward = Skip Forward (s): +subsettings-hash-duration = Hash Duration (s): + +# Similar music +subsettings-audio-check-type = Audio Check Type: +subsettings-tag-matching = Tag Matching +subsettings-approximate-comparison = Approximate comparison +subsettings-fingerprint-matching = Fingerprint Matching +subsettings-compare-similar-titles = Compare with similar titles + +# Big files +subsettings-method = Method: +subsettings-the-biggest = The Biggest +subsettings-the-smallest = The Smallest +subsettings-number-of-files = Number of Files: + +# Broken files +subsettings-file-types = File types to check: + +# Bad names +subsettings-check-for = Check for: +subsettings-uppercase-ext = Uppercase extension +subsettings-uppercase-ext-hint = Files with .JPG, .PNG etc. +subsettings-emoji = Emoji in name +subsettings-emoji-hint = Files containing emoji characters +subsettings-space = Space at start/end +subsettings-space-hint = Leading or trailing whitespace +subsettings-non-ascii = Non-ASCII characters +subsettings-non-ascii-hint = Characters outside ASCII range +subsettings-remove-duplicated = Remove duplicated non-alphanumeric +subsettings-remove-duplicated-hint = e.g. file--name..txt +subsettings-restricted-charset = Restricted charset: +subsettings-restricted-charset-hint = Allowed special chars, comma-separated + +# EXIF +subsettings-ignored-exif-tags = Ignored EXIF Tags: +subsettings-ignored-exif-tags-hint = Tags to ignore, comma-separated + +# Video optimizer +subsettings-mode = Mode: +subsettings-crop-settings = Crop Settings +subsettings-crop-type = Crop Type: +subsettings-black-pixel-threshold = Black Pixel Threshold: +subsettings-black-bar-min-pct = Black Bar Min %: +subsettings-max-samples = Max Samples: +subsettings-min-crop-size = Min Crop Size: +subsettings-transcode-settings = Transcode Settings +subsettings-excluded-codecs = Excluded Codecs: +subsettings-target-codec = Target Codec: +subsettings-quality = Quality: +subsettings-fail-if-bigger = Fail if not smaller + +# ── Progress widget ────────────────────────────────────── +progress-initializing = Initializing... +progress-starting = Starting scan... +progress-current = Current +progress-overall = Overall +progress-scan-complete = Scan complete +progress-completed-in = Completed in { $time } +progress-done = done + +# ── Results view ───────────────────────────────────────── +results-no-results = No results +results-found-grouped = Found { $total } files ({ $size }) in { $groups } groups +results-found-flat = Found { $total } entries ({ $size }) +results-selected = Selected: { $selected }/{ $total } ({ $selected_size }/{ $total_size }) + +# ── Preview panel ──────────────────────────────────────── +preview-title = Preview +preview-no-preview = No preview +preview-file-not-found = File not found +preview-not-available = Preview not available + for this file type +preview-cannot-load = Cannot load image + +# ── Context menu ───────────────────────────────────────── +context-open-file = Open File +context-open-folder = Open Containing Folder +context-select = Select +context-deselect = Deselect + +# ── Bottom panel ───────────────────────────────────────── +bottom-included-dirs = Included Directories: +bottom-excluded-dirs = Excluded Directories: + +# ── Delete dialog ──────────────────────────────────────── +delete-dialog-title = Delete Files +delete-dialog-message = Are you sure you want to delete { $count } selected file(s)? +delete-dialog-trash = Move to trash instead of permanent delete +delete-dialog-dry-run = Dry run (preview only, no files will be deleted) +delete-dialog-confirm = Delete + +# ── Move dialog ────────────────────────────────────────── +move-dialog-title = Move/Copy Files +move-dialog-message = Move or copy { $count } selected file(s) to: +move-dialog-placeholder = Select destination folder... +move-dialog-preserve = Preserve folder structure +move-dialog-copy-mode = Copy instead of move +move-dialog-dry-run = Dry run (preview only, no files will be moved) +move-dialog-confirm = Move +move-dialog-select-dest = Select Destination + +# ── Rename dialog ──────────────────────────────────────── +rename-dialog-confirm = Rename +rename-dialog-ext-message = Fix extensions for { $count } selected file(s)? + + Files will be renamed to use their proper extensions. +rename-dialog-names-message = Fix names for { $count } selected file(s)? + + Files with problematic names will be renamed. + +# ── Select dialog ──────────────────────────────────────── +select-dialog-title = Select Results +select-dialog-prompt = Choose selection mode: +select-all = Select All +unselect-all = Unselect All +invert-selection = Invert Selection +select-biggest-size = Select Biggest (by Size) +select-smallest-size = Select Smallest (by Size) +select-newest = Select Newest +select-oldest = Select Oldest +select-shortest-path = Select Shortest Path +select-longest-path = Select Longest Path +cancel = Cancel + +# ── Sort dialog ────────────────────────────────────────── +sort-dialog-title = Sort Results +sort-by = Sort by: +sort-ascending = Ascending + +# ── Hardlink / symlink dialogs ─────────────────────────── +hardlink-dialog-title = Create Hardlinks +hardlink-dialog-message = Replace { $count } selected file(s) with hardlinks to: + { $reference }? +symlink-dialog-title = Create Symlinks +symlink-dialog-message = Replace { $count } selected file(s) with symlinks to: + { $reference }? +no-reference-title = No Reference +no-reference-message = Cannot determine reference file. Leave at least one file unchecked in the group. + +# ── EXIF dialog ────────────────────────────────────────── +exif-dialog-title = Clean EXIF +exif-dialog-message = Remove EXIF metadata from { $count } selected file(s)? + +# ── Video optimize dialog ──────────────────────────────── +video-optimize-title = Video Optimization +video-optimize-message = Video optimization for { $count } file(s) will be performed using czkawka_cli. Check the status bar for progress. + +# ── Warnings / errors ──────────────────────────────────── +no-directories-title = No Directories +no-directories-message = Please add at least one directory to scan in the bottom panel. +no-selection-title = No Selection +no-selection-delete = No files selected for deletion. +no-selection-move = No files selected. +no-results-title = No Results +no-results-save = No results to save. +no-destination-title = No Destination +no-destination-message = Please select a destination folder. +scan-error-title = Scan Error + +# ── Save/load dialogs ──────────────────────────────────── +save-dialog-title = Save Results +load-dialog-title = Load Results diff --git a/kalka/i18n/en/kalka.ftl b/kalka/i18n/en/kalka.ftl new file mode 100644 index 000000000..fdbcd226c --- /dev/null +++ b/kalka/i18n/en/kalka.ftl @@ -0,0 +1,338 @@ +# Kalka – English translations (Fluent format) +# This file follows the same Fluent convention as krokiet/i18n/ + +# ── Window & app ────────────────────────────────────────── +main-window-title = Kalka - Data Cleaner +about-title = About Kalka +about-app-name = Kalka +about-subtitle = PySide6 / Qt 6 Edition +about-version = Version 11.0.1 +about-description = + Kalka is a simple, fast and free app to remove + unnecessary files from your computer. + + This PySide6/Qt interface uses the czkawka_cli backend + for all scanning and file operations. + + Features: + - Find duplicate files (by hash, name, or size) + - Find empty files and folders + - Find similar images, videos, and music + - Find broken files and invalid symlinks + - Find files with bad extensions or names + - Remove EXIF metadata from images + - Optimize and crop videos + + Licensed under MIT License + https://github.com/qarmin/czkawka +about-logo-tooltip = About Kalka +version-label = Kalka v11.0.1 + +# ── Status bar ──────────────────────────────────────────── +status-ready = Ready +status-tab = Tab: { $tab_name } +status-scanning = Scanning: { $tab_name }... +status-scan-complete = Scan complete: found { $count } entries +status-scan-stopped = Scan stopped by user +status-error = Error: { $message } +status-deleted = Deleted { $count } file(s) +status-deleted-dry-run = [DRY RUN] Deleted { $count } file(s) +status-moved = Moved { $count } file(s) +status-moved-dry-run = [DRY RUN] Moved { $count } file(s) +status-copied = Copied { $count } file(s) +status-copied-dry-run = [DRY RUN] Copied { $count } file(s) +status-hardlinks-created = Created { $count } hardlink(s) +status-symlinks-created = Created { $count } symlink(s) +status-exif-cleaned = Cleaned EXIF from { $count } file(s) +status-extensions-fixed = Extensions fixed +status-names-fixed = Names fixed +status-results-saved = Results saved successfully +status-results-loaded = Loaded { $count } entries from file +status-video-optimize = Video optimization: use CLI directly for this feature + +# ── Buttons ─────────────────────────────────────────────── +scan-button = Scan +stop-button = Stop +select-button = Select +delete-button = Delete +move-button = Move +save-button = Save +load-button = Load +load-button-tooltip = Load previously saved results +sort-button = Sort +hardlink-button = Hardlink +symlink-button = Symlink +rename-button = Rename +clean-exif-button = Clean EXIF +optimize-button = Optimize + +# ── Tool names ──────────────────────────────────────────── +tool-duplicate-files = Duplicate Files +tool-empty-folders = Empty Folders +tool-big-files = Big Files +tool-empty-files = Empty Files +tool-temporary-files = Temporary Files +tool-similar-images = Similar Images +tool-similar-videos = Similar Videos +tool-similar-music = Similar Music +tool-invalid-symlinks = Invalid Symlinks +tool-broken-files = Broken Files +tool-bad-extensions = Bad Extensions +tool-bad-names = Bad Names +tool-exif-remover = EXIF Remover +tool-video-optimizer = Video Optimizer + +# ── Column headers ──────────────────────────────────────── +column-selection = Selection +column-size = Size +column-file-name = File Name +column-path = Path +column-modification-date = Modification Date +column-hash = Hash +column-similarity = Similarity +column-resolution = Resolution +column-title = Title +column-artist = Artist +column-year = Year +column-bitrate = Bitrate +column-genre = Genre +column-length = Length +column-folder-name = Folder Name +column-symlink-name = Symlink Name +column-symlink-path = Symlink Path +column-destination-path = Destination Path +column-type-of-error = Type of Error +column-error-type = Error Type +column-current-extension = Current Extension +column-proper-extension = Proper Extension +column-codec = Codec + +# ── Left panel ──────────────────────────────────────────── +settings-tooltip = Application Settings +tool-settings-tooltip = Tool-specific Settings +fallback-app-name = Kalka + +# ── Settings panel ──────────────────────────────────────── +settings-title = Settings +settings-close = Close +settings-tab-general = General +settings-tab-directories = Directories +settings-tab-filters = Filters +settings-tab-preview = Preview +settings-window-title = Kalka Settings + +# General settings +settings-cli-path = czkawka_cli Path: +settings-browse = Browse +settings-thread-count = Thread Count: +settings-thread-auto = Auto (all cores) +settings-recursive = Recursive search +settings-use-cache = Use cache for faster rescans +settings-move-to-trash = Move to trash instead of permanent delete +settings-hide-hard-links = Hide hard links +settings-save-as-json = Save results as JSON (instead of text) +settings-select-cli-binary = Select czkawka_cli binary + +# Directories settings +settings-included-dirs = Included Directories +settings-excluded-dirs = Excluded Directories +settings-add = Add +settings-remove = Remove +settings-select-dir-include = Select Directory to Include +settings-select-dir-exclude = Select Directory to Exclude + +# Filters settings +settings-excluded-items = Excluded Items: +settings-excluded-items-hint = Wildcard patterns, comma-separated (e.g. *.tmp,cache_*) +settings-allowed-extensions = Allowed Extensions: +settings-allowed-extensions-hint = e.g. jpg,png,gif +settings-excluded-extensions = Excluded Extensions: +settings-excluded-extensions-hint = e.g. log,tmp +settings-min-file-size = Minimum File Size: +settings-min-file-size-hint = In bytes (e.g. 1024) +settings-max-file-size = Maximum File Size: +settings-max-file-size-hint = In bytes (leave empty for no limit) + +# Preview settings +settings-show-image-preview = Show image preview + +# ── Tool settings panel ────────────────────────────────── +tool-settings-title = Tool Settings + +# Duplicate files +subsettings-check-method = Check Method: +subsettings-hash-type = Hash Type: +subsettings-case-sensitive = Case sensitive name comparison + +# Similar images +subsettings-hash-size = Hash Size: +subsettings-resize-algorithm = Resize Algorithm: +subsettings-image-hash-type = Hash Type: +subsettings-ignore-same-size = Ignore same size +subsettings-max-difference = Max Difference: + +# Similar videos +subsettings-crop-detect = Crop Detect: +subsettings-skip-forward = Skip Forward (s): +subsettings-hash-duration = Hash Duration (s): + +# Similar music +subsettings-audio-check-type = Audio Check Type: +subsettings-tag-matching = Tag Matching +subsettings-approximate-comparison = Approximate comparison +subsettings-fingerprint-matching = Fingerprint Matching +subsettings-compare-similar-titles = Compare with similar titles + +# Big files +subsettings-method = Method: +subsettings-the-biggest = The Biggest +subsettings-the-smallest = The Smallest +subsettings-number-of-files = Number of Files: + +# Broken files +subsettings-file-types = File types to check: + +# Bad names +subsettings-check-for = Check for: +subsettings-uppercase-ext = Uppercase extension +subsettings-uppercase-ext-hint = Files with .JPG, .PNG etc. +subsettings-emoji = Emoji in name +subsettings-emoji-hint = Files containing emoji characters +subsettings-space = Space at start/end +subsettings-space-hint = Leading or trailing whitespace +subsettings-non-ascii = Non-ASCII characters +subsettings-non-ascii-hint = Characters outside ASCII range +subsettings-remove-duplicated = Remove duplicated non-alphanumeric +subsettings-remove-duplicated-hint = e.g. file--name..txt +subsettings-restricted-charset = Restricted charset: +subsettings-restricted-charset-hint = Allowed special chars, comma-separated + +# EXIF +subsettings-ignored-exif-tags = Ignored EXIF Tags: +subsettings-ignored-exif-tags-hint = Tags to ignore, comma-separated + +# Video optimizer +subsettings-mode = Mode: +subsettings-crop-settings = Crop Settings +subsettings-crop-type = Crop Type: +subsettings-black-pixel-threshold = Black Pixel Threshold: +subsettings-black-bar-min-pct = Black Bar Min %: +subsettings-max-samples = Max Samples: +subsettings-min-crop-size = Min Crop Size: +subsettings-transcode-settings = Transcode Settings +subsettings-excluded-codecs = Excluded Codecs: +subsettings-target-codec = Target Codec: +subsettings-quality = Quality: +subsettings-fail-if-bigger = Fail if not smaller + +# ── Progress widget ────────────────────────────────────── +progress-initializing = Initializing... +progress-starting = Starting scan... +progress-current = Current +progress-overall = Overall +progress-scan-complete = Scan complete +progress-completed-in = Completed in { $time } +progress-done = done + +# ── Results view ───────────────────────────────────────── +results-no-results = No results +results-found-grouped = Found { $total } files ({ $size }) in { $groups } groups +results-found-flat = Found { $total } entries ({ $size }) +results-selected = Selected: { $selected }/{ $total } ({ $selected_size }/{ $total_size }) + +# ── Preview panel ──────────────────────────────────────── +preview-title = Preview +preview-no-preview = No preview +preview-file-not-found = File not found +preview-not-available = Preview not available + for this file type +preview-cannot-load = Cannot load image + +# ── Context menu ───────────────────────────────────────── +context-open-file = Open File +context-open-folder = Open Containing Folder +context-select = Select +context-deselect = Deselect + +# ── Bottom panel ───────────────────────────────────────── +bottom-included-dirs = Included Directories: +bottom-excluded-dirs = Excluded Directories: + +# ── Delete dialog ──────────────────────────────────────── +delete-dialog-title = Delete Files +delete-dialog-message = Are you sure you want to delete { $count } selected file(s)? +delete-dialog-trash = Move to trash instead of permanent delete +delete-dialog-dry-run = Dry run (preview only, no files will be deleted) +delete-dialog-confirm = Delete + +# ── Move dialog ────────────────────────────────────────── +move-dialog-title = Move/Copy Files +move-dialog-message = Move or copy { $count } selected file(s) to: +move-dialog-placeholder = Select destination folder... +move-dialog-preserve = Preserve folder structure +move-dialog-copy-mode = Copy instead of move +move-dialog-dry-run = Dry run (preview only, no files will be moved) +move-dialog-confirm = Move +move-dialog-select-dest = Select Destination + +# ── Rename dialog ──────────────────────────────────────── +rename-dialog-confirm = Rename +rename-dialog-ext-message = Fix extensions for { $count } selected file(s)? + + Files will be renamed to use their proper extensions. +rename-dialog-names-message = Fix names for { $count } selected file(s)? + + Files with problematic names will be renamed. + +# ── Select dialog ──────────────────────────────────────── +select-dialog-title = Select Results +select-dialog-prompt = Choose selection mode: +select-all = Select All +unselect-all = Unselect All +invert-selection = Invert Selection +select-biggest-size = Select Biggest (by Size) +select-smallest-size = Select Smallest (by Size) +select-newest = Select Newest +select-oldest = Select Oldest +select-shortest-path = Select Shortest Path +select-longest-path = Select Longest Path +cancel = Cancel + +# ── Sort dialog ────────────────────────────────────────── +sort-dialog-title = Sort Results +sort-by = Sort by: +sort-ascending = Ascending + +# ── Hardlink / symlink dialogs ─────────────────────────── +hardlink-dialog-title = Create Hardlinks +hardlink-dialog-message = Replace { $count } selected file(s) with hardlinks to: + { $reference }? +symlink-dialog-title = Create Symlinks +symlink-dialog-message = Replace { $count } selected file(s) with symlinks to: + { $reference }? +no-reference-title = No Reference +no-reference-message = Cannot determine reference file. Leave at least one file unchecked in the group. + +# ── EXIF dialog ────────────────────────────────────────── +exif-dialog-title = Clean EXIF +exif-dialog-message = Remove EXIF metadata from { $count } selected file(s)? + +# ── Video optimize dialog ──────────────────────────────── +video-optimize-title = Video Optimization +video-optimize-message = Video optimization for { $count } file(s) will be performed using czkawka_cli. Check the status bar for progress. + +# ── Warnings / errors ──────────────────────────────────── +no-directories-title = No Directories +no-directories-message = Please add at least one directory to scan in the bottom panel. +no-selection-title = No Selection +no-selection-delete = No files selected for deletion. +no-selection-move = No files selected. +no-results-title = No Results +no-results-save = No results to save. +no-destination-title = No Destination +no-destination-message = Please select a destination folder. +scan-error-title = Scan Error + +# ── Save/load dialogs ──────────────────────────────────── +save-dialog-title = Save Results +load-dialog-title = Load Results diff --git a/kalka/i18n/es-ES/kalka.ftl b/kalka/i18n/es-ES/kalka.ftl new file mode 100644 index 000000000..fdbcd226c --- /dev/null +++ b/kalka/i18n/es-ES/kalka.ftl @@ -0,0 +1,338 @@ +# Kalka – English translations (Fluent format) +# This file follows the same Fluent convention as krokiet/i18n/ + +# ── Window & app ────────────────────────────────────────── +main-window-title = Kalka - Data Cleaner +about-title = About Kalka +about-app-name = Kalka +about-subtitle = PySide6 / Qt 6 Edition +about-version = Version 11.0.1 +about-description = + Kalka is a simple, fast and free app to remove + unnecessary files from your computer. + + This PySide6/Qt interface uses the czkawka_cli backend + for all scanning and file operations. + + Features: + - Find duplicate files (by hash, name, or size) + - Find empty files and folders + - Find similar images, videos, and music + - Find broken files and invalid symlinks + - Find files with bad extensions or names + - Remove EXIF metadata from images + - Optimize and crop videos + + Licensed under MIT License + https://github.com/qarmin/czkawka +about-logo-tooltip = About Kalka +version-label = Kalka v11.0.1 + +# ── Status bar ──────────────────────────────────────────── +status-ready = Ready +status-tab = Tab: { $tab_name } +status-scanning = Scanning: { $tab_name }... +status-scan-complete = Scan complete: found { $count } entries +status-scan-stopped = Scan stopped by user +status-error = Error: { $message } +status-deleted = Deleted { $count } file(s) +status-deleted-dry-run = [DRY RUN] Deleted { $count } file(s) +status-moved = Moved { $count } file(s) +status-moved-dry-run = [DRY RUN] Moved { $count } file(s) +status-copied = Copied { $count } file(s) +status-copied-dry-run = [DRY RUN] Copied { $count } file(s) +status-hardlinks-created = Created { $count } hardlink(s) +status-symlinks-created = Created { $count } symlink(s) +status-exif-cleaned = Cleaned EXIF from { $count } file(s) +status-extensions-fixed = Extensions fixed +status-names-fixed = Names fixed +status-results-saved = Results saved successfully +status-results-loaded = Loaded { $count } entries from file +status-video-optimize = Video optimization: use CLI directly for this feature + +# ── Buttons ─────────────────────────────────────────────── +scan-button = Scan +stop-button = Stop +select-button = Select +delete-button = Delete +move-button = Move +save-button = Save +load-button = Load +load-button-tooltip = Load previously saved results +sort-button = Sort +hardlink-button = Hardlink +symlink-button = Symlink +rename-button = Rename +clean-exif-button = Clean EXIF +optimize-button = Optimize + +# ── Tool names ──────────────────────────────────────────── +tool-duplicate-files = Duplicate Files +tool-empty-folders = Empty Folders +tool-big-files = Big Files +tool-empty-files = Empty Files +tool-temporary-files = Temporary Files +tool-similar-images = Similar Images +tool-similar-videos = Similar Videos +tool-similar-music = Similar Music +tool-invalid-symlinks = Invalid Symlinks +tool-broken-files = Broken Files +tool-bad-extensions = Bad Extensions +tool-bad-names = Bad Names +tool-exif-remover = EXIF Remover +tool-video-optimizer = Video Optimizer + +# ── Column headers ──────────────────────────────────────── +column-selection = Selection +column-size = Size +column-file-name = File Name +column-path = Path +column-modification-date = Modification Date +column-hash = Hash +column-similarity = Similarity +column-resolution = Resolution +column-title = Title +column-artist = Artist +column-year = Year +column-bitrate = Bitrate +column-genre = Genre +column-length = Length +column-folder-name = Folder Name +column-symlink-name = Symlink Name +column-symlink-path = Symlink Path +column-destination-path = Destination Path +column-type-of-error = Type of Error +column-error-type = Error Type +column-current-extension = Current Extension +column-proper-extension = Proper Extension +column-codec = Codec + +# ── Left panel ──────────────────────────────────────────── +settings-tooltip = Application Settings +tool-settings-tooltip = Tool-specific Settings +fallback-app-name = Kalka + +# ── Settings panel ──────────────────────────────────────── +settings-title = Settings +settings-close = Close +settings-tab-general = General +settings-tab-directories = Directories +settings-tab-filters = Filters +settings-tab-preview = Preview +settings-window-title = Kalka Settings + +# General settings +settings-cli-path = czkawka_cli Path: +settings-browse = Browse +settings-thread-count = Thread Count: +settings-thread-auto = Auto (all cores) +settings-recursive = Recursive search +settings-use-cache = Use cache for faster rescans +settings-move-to-trash = Move to trash instead of permanent delete +settings-hide-hard-links = Hide hard links +settings-save-as-json = Save results as JSON (instead of text) +settings-select-cli-binary = Select czkawka_cli binary + +# Directories settings +settings-included-dirs = Included Directories +settings-excluded-dirs = Excluded Directories +settings-add = Add +settings-remove = Remove +settings-select-dir-include = Select Directory to Include +settings-select-dir-exclude = Select Directory to Exclude + +# Filters settings +settings-excluded-items = Excluded Items: +settings-excluded-items-hint = Wildcard patterns, comma-separated (e.g. *.tmp,cache_*) +settings-allowed-extensions = Allowed Extensions: +settings-allowed-extensions-hint = e.g. jpg,png,gif +settings-excluded-extensions = Excluded Extensions: +settings-excluded-extensions-hint = e.g. log,tmp +settings-min-file-size = Minimum File Size: +settings-min-file-size-hint = In bytes (e.g. 1024) +settings-max-file-size = Maximum File Size: +settings-max-file-size-hint = In bytes (leave empty for no limit) + +# Preview settings +settings-show-image-preview = Show image preview + +# ── Tool settings panel ────────────────────────────────── +tool-settings-title = Tool Settings + +# Duplicate files +subsettings-check-method = Check Method: +subsettings-hash-type = Hash Type: +subsettings-case-sensitive = Case sensitive name comparison + +# Similar images +subsettings-hash-size = Hash Size: +subsettings-resize-algorithm = Resize Algorithm: +subsettings-image-hash-type = Hash Type: +subsettings-ignore-same-size = Ignore same size +subsettings-max-difference = Max Difference: + +# Similar videos +subsettings-crop-detect = Crop Detect: +subsettings-skip-forward = Skip Forward (s): +subsettings-hash-duration = Hash Duration (s): + +# Similar music +subsettings-audio-check-type = Audio Check Type: +subsettings-tag-matching = Tag Matching +subsettings-approximate-comparison = Approximate comparison +subsettings-fingerprint-matching = Fingerprint Matching +subsettings-compare-similar-titles = Compare with similar titles + +# Big files +subsettings-method = Method: +subsettings-the-biggest = The Biggest +subsettings-the-smallest = The Smallest +subsettings-number-of-files = Number of Files: + +# Broken files +subsettings-file-types = File types to check: + +# Bad names +subsettings-check-for = Check for: +subsettings-uppercase-ext = Uppercase extension +subsettings-uppercase-ext-hint = Files with .JPG, .PNG etc. +subsettings-emoji = Emoji in name +subsettings-emoji-hint = Files containing emoji characters +subsettings-space = Space at start/end +subsettings-space-hint = Leading or trailing whitespace +subsettings-non-ascii = Non-ASCII characters +subsettings-non-ascii-hint = Characters outside ASCII range +subsettings-remove-duplicated = Remove duplicated non-alphanumeric +subsettings-remove-duplicated-hint = e.g. file--name..txt +subsettings-restricted-charset = Restricted charset: +subsettings-restricted-charset-hint = Allowed special chars, comma-separated + +# EXIF +subsettings-ignored-exif-tags = Ignored EXIF Tags: +subsettings-ignored-exif-tags-hint = Tags to ignore, comma-separated + +# Video optimizer +subsettings-mode = Mode: +subsettings-crop-settings = Crop Settings +subsettings-crop-type = Crop Type: +subsettings-black-pixel-threshold = Black Pixel Threshold: +subsettings-black-bar-min-pct = Black Bar Min %: +subsettings-max-samples = Max Samples: +subsettings-min-crop-size = Min Crop Size: +subsettings-transcode-settings = Transcode Settings +subsettings-excluded-codecs = Excluded Codecs: +subsettings-target-codec = Target Codec: +subsettings-quality = Quality: +subsettings-fail-if-bigger = Fail if not smaller + +# ── Progress widget ────────────────────────────────────── +progress-initializing = Initializing... +progress-starting = Starting scan... +progress-current = Current +progress-overall = Overall +progress-scan-complete = Scan complete +progress-completed-in = Completed in { $time } +progress-done = done + +# ── Results view ───────────────────────────────────────── +results-no-results = No results +results-found-grouped = Found { $total } files ({ $size }) in { $groups } groups +results-found-flat = Found { $total } entries ({ $size }) +results-selected = Selected: { $selected }/{ $total } ({ $selected_size }/{ $total_size }) + +# ── Preview panel ──────────────────────────────────────── +preview-title = Preview +preview-no-preview = No preview +preview-file-not-found = File not found +preview-not-available = Preview not available + for this file type +preview-cannot-load = Cannot load image + +# ── Context menu ───────────────────────────────────────── +context-open-file = Open File +context-open-folder = Open Containing Folder +context-select = Select +context-deselect = Deselect + +# ── Bottom panel ───────────────────────────────────────── +bottom-included-dirs = Included Directories: +bottom-excluded-dirs = Excluded Directories: + +# ── Delete dialog ──────────────────────────────────────── +delete-dialog-title = Delete Files +delete-dialog-message = Are you sure you want to delete { $count } selected file(s)? +delete-dialog-trash = Move to trash instead of permanent delete +delete-dialog-dry-run = Dry run (preview only, no files will be deleted) +delete-dialog-confirm = Delete + +# ── Move dialog ────────────────────────────────────────── +move-dialog-title = Move/Copy Files +move-dialog-message = Move or copy { $count } selected file(s) to: +move-dialog-placeholder = Select destination folder... +move-dialog-preserve = Preserve folder structure +move-dialog-copy-mode = Copy instead of move +move-dialog-dry-run = Dry run (preview only, no files will be moved) +move-dialog-confirm = Move +move-dialog-select-dest = Select Destination + +# ── Rename dialog ──────────────────────────────────────── +rename-dialog-confirm = Rename +rename-dialog-ext-message = Fix extensions for { $count } selected file(s)? + + Files will be renamed to use their proper extensions. +rename-dialog-names-message = Fix names for { $count } selected file(s)? + + Files with problematic names will be renamed. + +# ── Select dialog ──────────────────────────────────────── +select-dialog-title = Select Results +select-dialog-prompt = Choose selection mode: +select-all = Select All +unselect-all = Unselect All +invert-selection = Invert Selection +select-biggest-size = Select Biggest (by Size) +select-smallest-size = Select Smallest (by Size) +select-newest = Select Newest +select-oldest = Select Oldest +select-shortest-path = Select Shortest Path +select-longest-path = Select Longest Path +cancel = Cancel + +# ── Sort dialog ────────────────────────────────────────── +sort-dialog-title = Sort Results +sort-by = Sort by: +sort-ascending = Ascending + +# ── Hardlink / symlink dialogs ─────────────────────────── +hardlink-dialog-title = Create Hardlinks +hardlink-dialog-message = Replace { $count } selected file(s) with hardlinks to: + { $reference }? +symlink-dialog-title = Create Symlinks +symlink-dialog-message = Replace { $count } selected file(s) with symlinks to: + { $reference }? +no-reference-title = No Reference +no-reference-message = Cannot determine reference file. Leave at least one file unchecked in the group. + +# ── EXIF dialog ────────────────────────────────────────── +exif-dialog-title = Clean EXIF +exif-dialog-message = Remove EXIF metadata from { $count } selected file(s)? + +# ── Video optimize dialog ──────────────────────────────── +video-optimize-title = Video Optimization +video-optimize-message = Video optimization for { $count } file(s) will be performed using czkawka_cli. Check the status bar for progress. + +# ── Warnings / errors ──────────────────────────────────── +no-directories-title = No Directories +no-directories-message = Please add at least one directory to scan in the bottom panel. +no-selection-title = No Selection +no-selection-delete = No files selected for deletion. +no-selection-move = No files selected. +no-results-title = No Results +no-results-save = No results to save. +no-destination-title = No Destination +no-destination-message = Please select a destination folder. +scan-error-title = Scan Error + +# ── Save/load dialogs ──────────────────────────────────── +save-dialog-title = Save Results +load-dialog-title = Load Results diff --git a/kalka/i18n/fa/kalka.ftl b/kalka/i18n/fa/kalka.ftl new file mode 100644 index 000000000..fdbcd226c --- /dev/null +++ b/kalka/i18n/fa/kalka.ftl @@ -0,0 +1,338 @@ +# Kalka – English translations (Fluent format) +# This file follows the same Fluent convention as krokiet/i18n/ + +# ── Window & app ────────────────────────────────────────── +main-window-title = Kalka - Data Cleaner +about-title = About Kalka +about-app-name = Kalka +about-subtitle = PySide6 / Qt 6 Edition +about-version = Version 11.0.1 +about-description = + Kalka is a simple, fast and free app to remove + unnecessary files from your computer. + + This PySide6/Qt interface uses the czkawka_cli backend + for all scanning and file operations. + + Features: + - Find duplicate files (by hash, name, or size) + - Find empty files and folders + - Find similar images, videos, and music + - Find broken files and invalid symlinks + - Find files with bad extensions or names + - Remove EXIF metadata from images + - Optimize and crop videos + + Licensed under MIT License + https://github.com/qarmin/czkawka +about-logo-tooltip = About Kalka +version-label = Kalka v11.0.1 + +# ── Status bar ──────────────────────────────────────────── +status-ready = Ready +status-tab = Tab: { $tab_name } +status-scanning = Scanning: { $tab_name }... +status-scan-complete = Scan complete: found { $count } entries +status-scan-stopped = Scan stopped by user +status-error = Error: { $message } +status-deleted = Deleted { $count } file(s) +status-deleted-dry-run = [DRY RUN] Deleted { $count } file(s) +status-moved = Moved { $count } file(s) +status-moved-dry-run = [DRY RUN] Moved { $count } file(s) +status-copied = Copied { $count } file(s) +status-copied-dry-run = [DRY RUN] Copied { $count } file(s) +status-hardlinks-created = Created { $count } hardlink(s) +status-symlinks-created = Created { $count } symlink(s) +status-exif-cleaned = Cleaned EXIF from { $count } file(s) +status-extensions-fixed = Extensions fixed +status-names-fixed = Names fixed +status-results-saved = Results saved successfully +status-results-loaded = Loaded { $count } entries from file +status-video-optimize = Video optimization: use CLI directly for this feature + +# ── Buttons ─────────────────────────────────────────────── +scan-button = Scan +stop-button = Stop +select-button = Select +delete-button = Delete +move-button = Move +save-button = Save +load-button = Load +load-button-tooltip = Load previously saved results +sort-button = Sort +hardlink-button = Hardlink +symlink-button = Symlink +rename-button = Rename +clean-exif-button = Clean EXIF +optimize-button = Optimize + +# ── Tool names ──────────────────────────────────────────── +tool-duplicate-files = Duplicate Files +tool-empty-folders = Empty Folders +tool-big-files = Big Files +tool-empty-files = Empty Files +tool-temporary-files = Temporary Files +tool-similar-images = Similar Images +tool-similar-videos = Similar Videos +tool-similar-music = Similar Music +tool-invalid-symlinks = Invalid Symlinks +tool-broken-files = Broken Files +tool-bad-extensions = Bad Extensions +tool-bad-names = Bad Names +tool-exif-remover = EXIF Remover +tool-video-optimizer = Video Optimizer + +# ── Column headers ──────────────────────────────────────── +column-selection = Selection +column-size = Size +column-file-name = File Name +column-path = Path +column-modification-date = Modification Date +column-hash = Hash +column-similarity = Similarity +column-resolution = Resolution +column-title = Title +column-artist = Artist +column-year = Year +column-bitrate = Bitrate +column-genre = Genre +column-length = Length +column-folder-name = Folder Name +column-symlink-name = Symlink Name +column-symlink-path = Symlink Path +column-destination-path = Destination Path +column-type-of-error = Type of Error +column-error-type = Error Type +column-current-extension = Current Extension +column-proper-extension = Proper Extension +column-codec = Codec + +# ── Left panel ──────────────────────────────────────────── +settings-tooltip = Application Settings +tool-settings-tooltip = Tool-specific Settings +fallback-app-name = Kalka + +# ── Settings panel ──────────────────────────────────────── +settings-title = Settings +settings-close = Close +settings-tab-general = General +settings-tab-directories = Directories +settings-tab-filters = Filters +settings-tab-preview = Preview +settings-window-title = Kalka Settings + +# General settings +settings-cli-path = czkawka_cli Path: +settings-browse = Browse +settings-thread-count = Thread Count: +settings-thread-auto = Auto (all cores) +settings-recursive = Recursive search +settings-use-cache = Use cache for faster rescans +settings-move-to-trash = Move to trash instead of permanent delete +settings-hide-hard-links = Hide hard links +settings-save-as-json = Save results as JSON (instead of text) +settings-select-cli-binary = Select czkawka_cli binary + +# Directories settings +settings-included-dirs = Included Directories +settings-excluded-dirs = Excluded Directories +settings-add = Add +settings-remove = Remove +settings-select-dir-include = Select Directory to Include +settings-select-dir-exclude = Select Directory to Exclude + +# Filters settings +settings-excluded-items = Excluded Items: +settings-excluded-items-hint = Wildcard patterns, comma-separated (e.g. *.tmp,cache_*) +settings-allowed-extensions = Allowed Extensions: +settings-allowed-extensions-hint = e.g. jpg,png,gif +settings-excluded-extensions = Excluded Extensions: +settings-excluded-extensions-hint = e.g. log,tmp +settings-min-file-size = Minimum File Size: +settings-min-file-size-hint = In bytes (e.g. 1024) +settings-max-file-size = Maximum File Size: +settings-max-file-size-hint = In bytes (leave empty for no limit) + +# Preview settings +settings-show-image-preview = Show image preview + +# ── Tool settings panel ────────────────────────────────── +tool-settings-title = Tool Settings + +# Duplicate files +subsettings-check-method = Check Method: +subsettings-hash-type = Hash Type: +subsettings-case-sensitive = Case sensitive name comparison + +# Similar images +subsettings-hash-size = Hash Size: +subsettings-resize-algorithm = Resize Algorithm: +subsettings-image-hash-type = Hash Type: +subsettings-ignore-same-size = Ignore same size +subsettings-max-difference = Max Difference: + +# Similar videos +subsettings-crop-detect = Crop Detect: +subsettings-skip-forward = Skip Forward (s): +subsettings-hash-duration = Hash Duration (s): + +# Similar music +subsettings-audio-check-type = Audio Check Type: +subsettings-tag-matching = Tag Matching +subsettings-approximate-comparison = Approximate comparison +subsettings-fingerprint-matching = Fingerprint Matching +subsettings-compare-similar-titles = Compare with similar titles + +# Big files +subsettings-method = Method: +subsettings-the-biggest = The Biggest +subsettings-the-smallest = The Smallest +subsettings-number-of-files = Number of Files: + +# Broken files +subsettings-file-types = File types to check: + +# Bad names +subsettings-check-for = Check for: +subsettings-uppercase-ext = Uppercase extension +subsettings-uppercase-ext-hint = Files with .JPG, .PNG etc. +subsettings-emoji = Emoji in name +subsettings-emoji-hint = Files containing emoji characters +subsettings-space = Space at start/end +subsettings-space-hint = Leading or trailing whitespace +subsettings-non-ascii = Non-ASCII characters +subsettings-non-ascii-hint = Characters outside ASCII range +subsettings-remove-duplicated = Remove duplicated non-alphanumeric +subsettings-remove-duplicated-hint = e.g. file--name..txt +subsettings-restricted-charset = Restricted charset: +subsettings-restricted-charset-hint = Allowed special chars, comma-separated + +# EXIF +subsettings-ignored-exif-tags = Ignored EXIF Tags: +subsettings-ignored-exif-tags-hint = Tags to ignore, comma-separated + +# Video optimizer +subsettings-mode = Mode: +subsettings-crop-settings = Crop Settings +subsettings-crop-type = Crop Type: +subsettings-black-pixel-threshold = Black Pixel Threshold: +subsettings-black-bar-min-pct = Black Bar Min %: +subsettings-max-samples = Max Samples: +subsettings-min-crop-size = Min Crop Size: +subsettings-transcode-settings = Transcode Settings +subsettings-excluded-codecs = Excluded Codecs: +subsettings-target-codec = Target Codec: +subsettings-quality = Quality: +subsettings-fail-if-bigger = Fail if not smaller + +# ── Progress widget ────────────────────────────────────── +progress-initializing = Initializing... +progress-starting = Starting scan... +progress-current = Current +progress-overall = Overall +progress-scan-complete = Scan complete +progress-completed-in = Completed in { $time } +progress-done = done + +# ── Results view ───────────────────────────────────────── +results-no-results = No results +results-found-grouped = Found { $total } files ({ $size }) in { $groups } groups +results-found-flat = Found { $total } entries ({ $size }) +results-selected = Selected: { $selected }/{ $total } ({ $selected_size }/{ $total_size }) + +# ── Preview panel ──────────────────────────────────────── +preview-title = Preview +preview-no-preview = No preview +preview-file-not-found = File not found +preview-not-available = Preview not available + for this file type +preview-cannot-load = Cannot load image + +# ── Context menu ───────────────────────────────────────── +context-open-file = Open File +context-open-folder = Open Containing Folder +context-select = Select +context-deselect = Deselect + +# ── Bottom panel ───────────────────────────────────────── +bottom-included-dirs = Included Directories: +bottom-excluded-dirs = Excluded Directories: + +# ── Delete dialog ──────────────────────────────────────── +delete-dialog-title = Delete Files +delete-dialog-message = Are you sure you want to delete { $count } selected file(s)? +delete-dialog-trash = Move to trash instead of permanent delete +delete-dialog-dry-run = Dry run (preview only, no files will be deleted) +delete-dialog-confirm = Delete + +# ── Move dialog ────────────────────────────────────────── +move-dialog-title = Move/Copy Files +move-dialog-message = Move or copy { $count } selected file(s) to: +move-dialog-placeholder = Select destination folder... +move-dialog-preserve = Preserve folder structure +move-dialog-copy-mode = Copy instead of move +move-dialog-dry-run = Dry run (preview only, no files will be moved) +move-dialog-confirm = Move +move-dialog-select-dest = Select Destination + +# ── Rename dialog ──────────────────────────────────────── +rename-dialog-confirm = Rename +rename-dialog-ext-message = Fix extensions for { $count } selected file(s)? + + Files will be renamed to use their proper extensions. +rename-dialog-names-message = Fix names for { $count } selected file(s)? + + Files with problematic names will be renamed. + +# ── Select dialog ──────────────────────────────────────── +select-dialog-title = Select Results +select-dialog-prompt = Choose selection mode: +select-all = Select All +unselect-all = Unselect All +invert-selection = Invert Selection +select-biggest-size = Select Biggest (by Size) +select-smallest-size = Select Smallest (by Size) +select-newest = Select Newest +select-oldest = Select Oldest +select-shortest-path = Select Shortest Path +select-longest-path = Select Longest Path +cancel = Cancel + +# ── Sort dialog ────────────────────────────────────────── +sort-dialog-title = Sort Results +sort-by = Sort by: +sort-ascending = Ascending + +# ── Hardlink / symlink dialogs ─────────────────────────── +hardlink-dialog-title = Create Hardlinks +hardlink-dialog-message = Replace { $count } selected file(s) with hardlinks to: + { $reference }? +symlink-dialog-title = Create Symlinks +symlink-dialog-message = Replace { $count } selected file(s) with symlinks to: + { $reference }? +no-reference-title = No Reference +no-reference-message = Cannot determine reference file. Leave at least one file unchecked in the group. + +# ── EXIF dialog ────────────────────────────────────────── +exif-dialog-title = Clean EXIF +exif-dialog-message = Remove EXIF metadata from { $count } selected file(s)? + +# ── Video optimize dialog ──────────────────────────────── +video-optimize-title = Video Optimization +video-optimize-message = Video optimization for { $count } file(s) will be performed using czkawka_cli. Check the status bar for progress. + +# ── Warnings / errors ──────────────────────────────────── +no-directories-title = No Directories +no-directories-message = Please add at least one directory to scan in the bottom panel. +no-selection-title = No Selection +no-selection-delete = No files selected for deletion. +no-selection-move = No files selected. +no-results-title = No Results +no-results-save = No results to save. +no-destination-title = No Destination +no-destination-message = Please select a destination folder. +scan-error-title = Scan Error + +# ── Save/load dialogs ──────────────────────────────────── +save-dialog-title = Save Results +load-dialog-title = Load Results diff --git a/kalka/i18n/fr/kalka.ftl b/kalka/i18n/fr/kalka.ftl new file mode 100644 index 000000000..fdbcd226c --- /dev/null +++ b/kalka/i18n/fr/kalka.ftl @@ -0,0 +1,338 @@ +# Kalka – English translations (Fluent format) +# This file follows the same Fluent convention as krokiet/i18n/ + +# ── Window & app ────────────────────────────────────────── +main-window-title = Kalka - Data Cleaner +about-title = About Kalka +about-app-name = Kalka +about-subtitle = PySide6 / Qt 6 Edition +about-version = Version 11.0.1 +about-description = + Kalka is a simple, fast and free app to remove + unnecessary files from your computer. + + This PySide6/Qt interface uses the czkawka_cli backend + for all scanning and file operations. + + Features: + - Find duplicate files (by hash, name, or size) + - Find empty files and folders + - Find similar images, videos, and music + - Find broken files and invalid symlinks + - Find files with bad extensions or names + - Remove EXIF metadata from images + - Optimize and crop videos + + Licensed under MIT License + https://github.com/qarmin/czkawka +about-logo-tooltip = About Kalka +version-label = Kalka v11.0.1 + +# ── Status bar ──────────────────────────────────────────── +status-ready = Ready +status-tab = Tab: { $tab_name } +status-scanning = Scanning: { $tab_name }... +status-scan-complete = Scan complete: found { $count } entries +status-scan-stopped = Scan stopped by user +status-error = Error: { $message } +status-deleted = Deleted { $count } file(s) +status-deleted-dry-run = [DRY RUN] Deleted { $count } file(s) +status-moved = Moved { $count } file(s) +status-moved-dry-run = [DRY RUN] Moved { $count } file(s) +status-copied = Copied { $count } file(s) +status-copied-dry-run = [DRY RUN] Copied { $count } file(s) +status-hardlinks-created = Created { $count } hardlink(s) +status-symlinks-created = Created { $count } symlink(s) +status-exif-cleaned = Cleaned EXIF from { $count } file(s) +status-extensions-fixed = Extensions fixed +status-names-fixed = Names fixed +status-results-saved = Results saved successfully +status-results-loaded = Loaded { $count } entries from file +status-video-optimize = Video optimization: use CLI directly for this feature + +# ── Buttons ─────────────────────────────────────────────── +scan-button = Scan +stop-button = Stop +select-button = Select +delete-button = Delete +move-button = Move +save-button = Save +load-button = Load +load-button-tooltip = Load previously saved results +sort-button = Sort +hardlink-button = Hardlink +symlink-button = Symlink +rename-button = Rename +clean-exif-button = Clean EXIF +optimize-button = Optimize + +# ── Tool names ──────────────────────────────────────────── +tool-duplicate-files = Duplicate Files +tool-empty-folders = Empty Folders +tool-big-files = Big Files +tool-empty-files = Empty Files +tool-temporary-files = Temporary Files +tool-similar-images = Similar Images +tool-similar-videos = Similar Videos +tool-similar-music = Similar Music +tool-invalid-symlinks = Invalid Symlinks +tool-broken-files = Broken Files +tool-bad-extensions = Bad Extensions +tool-bad-names = Bad Names +tool-exif-remover = EXIF Remover +tool-video-optimizer = Video Optimizer + +# ── Column headers ──────────────────────────────────────── +column-selection = Selection +column-size = Size +column-file-name = File Name +column-path = Path +column-modification-date = Modification Date +column-hash = Hash +column-similarity = Similarity +column-resolution = Resolution +column-title = Title +column-artist = Artist +column-year = Year +column-bitrate = Bitrate +column-genre = Genre +column-length = Length +column-folder-name = Folder Name +column-symlink-name = Symlink Name +column-symlink-path = Symlink Path +column-destination-path = Destination Path +column-type-of-error = Type of Error +column-error-type = Error Type +column-current-extension = Current Extension +column-proper-extension = Proper Extension +column-codec = Codec + +# ── Left panel ──────────────────────────────────────────── +settings-tooltip = Application Settings +tool-settings-tooltip = Tool-specific Settings +fallback-app-name = Kalka + +# ── Settings panel ──────────────────────────────────────── +settings-title = Settings +settings-close = Close +settings-tab-general = General +settings-tab-directories = Directories +settings-tab-filters = Filters +settings-tab-preview = Preview +settings-window-title = Kalka Settings + +# General settings +settings-cli-path = czkawka_cli Path: +settings-browse = Browse +settings-thread-count = Thread Count: +settings-thread-auto = Auto (all cores) +settings-recursive = Recursive search +settings-use-cache = Use cache for faster rescans +settings-move-to-trash = Move to trash instead of permanent delete +settings-hide-hard-links = Hide hard links +settings-save-as-json = Save results as JSON (instead of text) +settings-select-cli-binary = Select czkawka_cli binary + +# Directories settings +settings-included-dirs = Included Directories +settings-excluded-dirs = Excluded Directories +settings-add = Add +settings-remove = Remove +settings-select-dir-include = Select Directory to Include +settings-select-dir-exclude = Select Directory to Exclude + +# Filters settings +settings-excluded-items = Excluded Items: +settings-excluded-items-hint = Wildcard patterns, comma-separated (e.g. *.tmp,cache_*) +settings-allowed-extensions = Allowed Extensions: +settings-allowed-extensions-hint = e.g. jpg,png,gif +settings-excluded-extensions = Excluded Extensions: +settings-excluded-extensions-hint = e.g. log,tmp +settings-min-file-size = Minimum File Size: +settings-min-file-size-hint = In bytes (e.g. 1024) +settings-max-file-size = Maximum File Size: +settings-max-file-size-hint = In bytes (leave empty for no limit) + +# Preview settings +settings-show-image-preview = Show image preview + +# ── Tool settings panel ────────────────────────────────── +tool-settings-title = Tool Settings + +# Duplicate files +subsettings-check-method = Check Method: +subsettings-hash-type = Hash Type: +subsettings-case-sensitive = Case sensitive name comparison + +# Similar images +subsettings-hash-size = Hash Size: +subsettings-resize-algorithm = Resize Algorithm: +subsettings-image-hash-type = Hash Type: +subsettings-ignore-same-size = Ignore same size +subsettings-max-difference = Max Difference: + +# Similar videos +subsettings-crop-detect = Crop Detect: +subsettings-skip-forward = Skip Forward (s): +subsettings-hash-duration = Hash Duration (s): + +# Similar music +subsettings-audio-check-type = Audio Check Type: +subsettings-tag-matching = Tag Matching +subsettings-approximate-comparison = Approximate comparison +subsettings-fingerprint-matching = Fingerprint Matching +subsettings-compare-similar-titles = Compare with similar titles + +# Big files +subsettings-method = Method: +subsettings-the-biggest = The Biggest +subsettings-the-smallest = The Smallest +subsettings-number-of-files = Number of Files: + +# Broken files +subsettings-file-types = File types to check: + +# Bad names +subsettings-check-for = Check for: +subsettings-uppercase-ext = Uppercase extension +subsettings-uppercase-ext-hint = Files with .JPG, .PNG etc. +subsettings-emoji = Emoji in name +subsettings-emoji-hint = Files containing emoji characters +subsettings-space = Space at start/end +subsettings-space-hint = Leading or trailing whitespace +subsettings-non-ascii = Non-ASCII characters +subsettings-non-ascii-hint = Characters outside ASCII range +subsettings-remove-duplicated = Remove duplicated non-alphanumeric +subsettings-remove-duplicated-hint = e.g. file--name..txt +subsettings-restricted-charset = Restricted charset: +subsettings-restricted-charset-hint = Allowed special chars, comma-separated + +# EXIF +subsettings-ignored-exif-tags = Ignored EXIF Tags: +subsettings-ignored-exif-tags-hint = Tags to ignore, comma-separated + +# Video optimizer +subsettings-mode = Mode: +subsettings-crop-settings = Crop Settings +subsettings-crop-type = Crop Type: +subsettings-black-pixel-threshold = Black Pixel Threshold: +subsettings-black-bar-min-pct = Black Bar Min %: +subsettings-max-samples = Max Samples: +subsettings-min-crop-size = Min Crop Size: +subsettings-transcode-settings = Transcode Settings +subsettings-excluded-codecs = Excluded Codecs: +subsettings-target-codec = Target Codec: +subsettings-quality = Quality: +subsettings-fail-if-bigger = Fail if not smaller + +# ── Progress widget ────────────────────────────────────── +progress-initializing = Initializing... +progress-starting = Starting scan... +progress-current = Current +progress-overall = Overall +progress-scan-complete = Scan complete +progress-completed-in = Completed in { $time } +progress-done = done + +# ── Results view ───────────────────────────────────────── +results-no-results = No results +results-found-grouped = Found { $total } files ({ $size }) in { $groups } groups +results-found-flat = Found { $total } entries ({ $size }) +results-selected = Selected: { $selected }/{ $total } ({ $selected_size }/{ $total_size }) + +# ── Preview panel ──────────────────────────────────────── +preview-title = Preview +preview-no-preview = No preview +preview-file-not-found = File not found +preview-not-available = Preview not available + for this file type +preview-cannot-load = Cannot load image + +# ── Context menu ───────────────────────────────────────── +context-open-file = Open File +context-open-folder = Open Containing Folder +context-select = Select +context-deselect = Deselect + +# ── Bottom panel ───────────────────────────────────────── +bottom-included-dirs = Included Directories: +bottom-excluded-dirs = Excluded Directories: + +# ── Delete dialog ──────────────────────────────────────── +delete-dialog-title = Delete Files +delete-dialog-message = Are you sure you want to delete { $count } selected file(s)? +delete-dialog-trash = Move to trash instead of permanent delete +delete-dialog-dry-run = Dry run (preview only, no files will be deleted) +delete-dialog-confirm = Delete + +# ── Move dialog ────────────────────────────────────────── +move-dialog-title = Move/Copy Files +move-dialog-message = Move or copy { $count } selected file(s) to: +move-dialog-placeholder = Select destination folder... +move-dialog-preserve = Preserve folder structure +move-dialog-copy-mode = Copy instead of move +move-dialog-dry-run = Dry run (preview only, no files will be moved) +move-dialog-confirm = Move +move-dialog-select-dest = Select Destination + +# ── Rename dialog ──────────────────────────────────────── +rename-dialog-confirm = Rename +rename-dialog-ext-message = Fix extensions for { $count } selected file(s)? + + Files will be renamed to use their proper extensions. +rename-dialog-names-message = Fix names for { $count } selected file(s)? + + Files with problematic names will be renamed. + +# ── Select dialog ──────────────────────────────────────── +select-dialog-title = Select Results +select-dialog-prompt = Choose selection mode: +select-all = Select All +unselect-all = Unselect All +invert-selection = Invert Selection +select-biggest-size = Select Biggest (by Size) +select-smallest-size = Select Smallest (by Size) +select-newest = Select Newest +select-oldest = Select Oldest +select-shortest-path = Select Shortest Path +select-longest-path = Select Longest Path +cancel = Cancel + +# ── Sort dialog ────────────────────────────────────────── +sort-dialog-title = Sort Results +sort-by = Sort by: +sort-ascending = Ascending + +# ── Hardlink / symlink dialogs ─────────────────────────── +hardlink-dialog-title = Create Hardlinks +hardlink-dialog-message = Replace { $count } selected file(s) with hardlinks to: + { $reference }? +symlink-dialog-title = Create Symlinks +symlink-dialog-message = Replace { $count } selected file(s) with symlinks to: + { $reference }? +no-reference-title = No Reference +no-reference-message = Cannot determine reference file. Leave at least one file unchecked in the group. + +# ── EXIF dialog ────────────────────────────────────────── +exif-dialog-title = Clean EXIF +exif-dialog-message = Remove EXIF metadata from { $count } selected file(s)? + +# ── Video optimize dialog ──────────────────────────────── +video-optimize-title = Video Optimization +video-optimize-message = Video optimization for { $count } file(s) will be performed using czkawka_cli. Check the status bar for progress. + +# ── Warnings / errors ──────────────────────────────────── +no-directories-title = No Directories +no-directories-message = Please add at least one directory to scan in the bottom panel. +no-selection-title = No Selection +no-selection-delete = No files selected for deletion. +no-selection-move = No files selected. +no-results-title = No Results +no-results-save = No results to save. +no-destination-title = No Destination +no-destination-message = Please select a destination folder. +scan-error-title = Scan Error + +# ── Save/load dialogs ──────────────────────────────────── +save-dialog-title = Save Results +load-dialog-title = Load Results diff --git a/kalka/i18n/it/kalka.ftl b/kalka/i18n/it/kalka.ftl new file mode 100644 index 000000000..fdbcd226c --- /dev/null +++ b/kalka/i18n/it/kalka.ftl @@ -0,0 +1,338 @@ +# Kalka – English translations (Fluent format) +# This file follows the same Fluent convention as krokiet/i18n/ + +# ── Window & app ────────────────────────────────────────── +main-window-title = Kalka - Data Cleaner +about-title = About Kalka +about-app-name = Kalka +about-subtitle = PySide6 / Qt 6 Edition +about-version = Version 11.0.1 +about-description = + Kalka is a simple, fast and free app to remove + unnecessary files from your computer. + + This PySide6/Qt interface uses the czkawka_cli backend + for all scanning and file operations. + + Features: + - Find duplicate files (by hash, name, or size) + - Find empty files and folders + - Find similar images, videos, and music + - Find broken files and invalid symlinks + - Find files with bad extensions or names + - Remove EXIF metadata from images + - Optimize and crop videos + + Licensed under MIT License + https://github.com/qarmin/czkawka +about-logo-tooltip = About Kalka +version-label = Kalka v11.0.1 + +# ── Status bar ──────────────────────────────────────────── +status-ready = Ready +status-tab = Tab: { $tab_name } +status-scanning = Scanning: { $tab_name }... +status-scan-complete = Scan complete: found { $count } entries +status-scan-stopped = Scan stopped by user +status-error = Error: { $message } +status-deleted = Deleted { $count } file(s) +status-deleted-dry-run = [DRY RUN] Deleted { $count } file(s) +status-moved = Moved { $count } file(s) +status-moved-dry-run = [DRY RUN] Moved { $count } file(s) +status-copied = Copied { $count } file(s) +status-copied-dry-run = [DRY RUN] Copied { $count } file(s) +status-hardlinks-created = Created { $count } hardlink(s) +status-symlinks-created = Created { $count } symlink(s) +status-exif-cleaned = Cleaned EXIF from { $count } file(s) +status-extensions-fixed = Extensions fixed +status-names-fixed = Names fixed +status-results-saved = Results saved successfully +status-results-loaded = Loaded { $count } entries from file +status-video-optimize = Video optimization: use CLI directly for this feature + +# ── Buttons ─────────────────────────────────────────────── +scan-button = Scan +stop-button = Stop +select-button = Select +delete-button = Delete +move-button = Move +save-button = Save +load-button = Load +load-button-tooltip = Load previously saved results +sort-button = Sort +hardlink-button = Hardlink +symlink-button = Symlink +rename-button = Rename +clean-exif-button = Clean EXIF +optimize-button = Optimize + +# ── Tool names ──────────────────────────────────────────── +tool-duplicate-files = Duplicate Files +tool-empty-folders = Empty Folders +tool-big-files = Big Files +tool-empty-files = Empty Files +tool-temporary-files = Temporary Files +tool-similar-images = Similar Images +tool-similar-videos = Similar Videos +tool-similar-music = Similar Music +tool-invalid-symlinks = Invalid Symlinks +tool-broken-files = Broken Files +tool-bad-extensions = Bad Extensions +tool-bad-names = Bad Names +tool-exif-remover = EXIF Remover +tool-video-optimizer = Video Optimizer + +# ── Column headers ──────────────────────────────────────── +column-selection = Selection +column-size = Size +column-file-name = File Name +column-path = Path +column-modification-date = Modification Date +column-hash = Hash +column-similarity = Similarity +column-resolution = Resolution +column-title = Title +column-artist = Artist +column-year = Year +column-bitrate = Bitrate +column-genre = Genre +column-length = Length +column-folder-name = Folder Name +column-symlink-name = Symlink Name +column-symlink-path = Symlink Path +column-destination-path = Destination Path +column-type-of-error = Type of Error +column-error-type = Error Type +column-current-extension = Current Extension +column-proper-extension = Proper Extension +column-codec = Codec + +# ── Left panel ──────────────────────────────────────────── +settings-tooltip = Application Settings +tool-settings-tooltip = Tool-specific Settings +fallback-app-name = Kalka + +# ── Settings panel ──────────────────────────────────────── +settings-title = Settings +settings-close = Close +settings-tab-general = General +settings-tab-directories = Directories +settings-tab-filters = Filters +settings-tab-preview = Preview +settings-window-title = Kalka Settings + +# General settings +settings-cli-path = czkawka_cli Path: +settings-browse = Browse +settings-thread-count = Thread Count: +settings-thread-auto = Auto (all cores) +settings-recursive = Recursive search +settings-use-cache = Use cache for faster rescans +settings-move-to-trash = Move to trash instead of permanent delete +settings-hide-hard-links = Hide hard links +settings-save-as-json = Save results as JSON (instead of text) +settings-select-cli-binary = Select czkawka_cli binary + +# Directories settings +settings-included-dirs = Included Directories +settings-excluded-dirs = Excluded Directories +settings-add = Add +settings-remove = Remove +settings-select-dir-include = Select Directory to Include +settings-select-dir-exclude = Select Directory to Exclude + +# Filters settings +settings-excluded-items = Excluded Items: +settings-excluded-items-hint = Wildcard patterns, comma-separated (e.g. *.tmp,cache_*) +settings-allowed-extensions = Allowed Extensions: +settings-allowed-extensions-hint = e.g. jpg,png,gif +settings-excluded-extensions = Excluded Extensions: +settings-excluded-extensions-hint = e.g. log,tmp +settings-min-file-size = Minimum File Size: +settings-min-file-size-hint = In bytes (e.g. 1024) +settings-max-file-size = Maximum File Size: +settings-max-file-size-hint = In bytes (leave empty for no limit) + +# Preview settings +settings-show-image-preview = Show image preview + +# ── Tool settings panel ────────────────────────────────── +tool-settings-title = Tool Settings + +# Duplicate files +subsettings-check-method = Check Method: +subsettings-hash-type = Hash Type: +subsettings-case-sensitive = Case sensitive name comparison + +# Similar images +subsettings-hash-size = Hash Size: +subsettings-resize-algorithm = Resize Algorithm: +subsettings-image-hash-type = Hash Type: +subsettings-ignore-same-size = Ignore same size +subsettings-max-difference = Max Difference: + +# Similar videos +subsettings-crop-detect = Crop Detect: +subsettings-skip-forward = Skip Forward (s): +subsettings-hash-duration = Hash Duration (s): + +# Similar music +subsettings-audio-check-type = Audio Check Type: +subsettings-tag-matching = Tag Matching +subsettings-approximate-comparison = Approximate comparison +subsettings-fingerprint-matching = Fingerprint Matching +subsettings-compare-similar-titles = Compare with similar titles + +# Big files +subsettings-method = Method: +subsettings-the-biggest = The Biggest +subsettings-the-smallest = The Smallest +subsettings-number-of-files = Number of Files: + +# Broken files +subsettings-file-types = File types to check: + +# Bad names +subsettings-check-for = Check for: +subsettings-uppercase-ext = Uppercase extension +subsettings-uppercase-ext-hint = Files with .JPG, .PNG etc. +subsettings-emoji = Emoji in name +subsettings-emoji-hint = Files containing emoji characters +subsettings-space = Space at start/end +subsettings-space-hint = Leading or trailing whitespace +subsettings-non-ascii = Non-ASCII characters +subsettings-non-ascii-hint = Characters outside ASCII range +subsettings-remove-duplicated = Remove duplicated non-alphanumeric +subsettings-remove-duplicated-hint = e.g. file--name..txt +subsettings-restricted-charset = Restricted charset: +subsettings-restricted-charset-hint = Allowed special chars, comma-separated + +# EXIF +subsettings-ignored-exif-tags = Ignored EXIF Tags: +subsettings-ignored-exif-tags-hint = Tags to ignore, comma-separated + +# Video optimizer +subsettings-mode = Mode: +subsettings-crop-settings = Crop Settings +subsettings-crop-type = Crop Type: +subsettings-black-pixel-threshold = Black Pixel Threshold: +subsettings-black-bar-min-pct = Black Bar Min %: +subsettings-max-samples = Max Samples: +subsettings-min-crop-size = Min Crop Size: +subsettings-transcode-settings = Transcode Settings +subsettings-excluded-codecs = Excluded Codecs: +subsettings-target-codec = Target Codec: +subsettings-quality = Quality: +subsettings-fail-if-bigger = Fail if not smaller + +# ── Progress widget ────────────────────────────────────── +progress-initializing = Initializing... +progress-starting = Starting scan... +progress-current = Current +progress-overall = Overall +progress-scan-complete = Scan complete +progress-completed-in = Completed in { $time } +progress-done = done + +# ── Results view ───────────────────────────────────────── +results-no-results = No results +results-found-grouped = Found { $total } files ({ $size }) in { $groups } groups +results-found-flat = Found { $total } entries ({ $size }) +results-selected = Selected: { $selected }/{ $total } ({ $selected_size }/{ $total_size }) + +# ── Preview panel ──────────────────────────────────────── +preview-title = Preview +preview-no-preview = No preview +preview-file-not-found = File not found +preview-not-available = Preview not available + for this file type +preview-cannot-load = Cannot load image + +# ── Context menu ───────────────────────────────────────── +context-open-file = Open File +context-open-folder = Open Containing Folder +context-select = Select +context-deselect = Deselect + +# ── Bottom panel ───────────────────────────────────────── +bottom-included-dirs = Included Directories: +bottom-excluded-dirs = Excluded Directories: + +# ── Delete dialog ──────────────────────────────────────── +delete-dialog-title = Delete Files +delete-dialog-message = Are you sure you want to delete { $count } selected file(s)? +delete-dialog-trash = Move to trash instead of permanent delete +delete-dialog-dry-run = Dry run (preview only, no files will be deleted) +delete-dialog-confirm = Delete + +# ── Move dialog ────────────────────────────────────────── +move-dialog-title = Move/Copy Files +move-dialog-message = Move or copy { $count } selected file(s) to: +move-dialog-placeholder = Select destination folder... +move-dialog-preserve = Preserve folder structure +move-dialog-copy-mode = Copy instead of move +move-dialog-dry-run = Dry run (preview only, no files will be moved) +move-dialog-confirm = Move +move-dialog-select-dest = Select Destination + +# ── Rename dialog ──────────────────────────────────────── +rename-dialog-confirm = Rename +rename-dialog-ext-message = Fix extensions for { $count } selected file(s)? + + Files will be renamed to use their proper extensions. +rename-dialog-names-message = Fix names for { $count } selected file(s)? + + Files with problematic names will be renamed. + +# ── Select dialog ──────────────────────────────────────── +select-dialog-title = Select Results +select-dialog-prompt = Choose selection mode: +select-all = Select All +unselect-all = Unselect All +invert-selection = Invert Selection +select-biggest-size = Select Biggest (by Size) +select-smallest-size = Select Smallest (by Size) +select-newest = Select Newest +select-oldest = Select Oldest +select-shortest-path = Select Shortest Path +select-longest-path = Select Longest Path +cancel = Cancel + +# ── Sort dialog ────────────────────────────────────────── +sort-dialog-title = Sort Results +sort-by = Sort by: +sort-ascending = Ascending + +# ── Hardlink / symlink dialogs ─────────────────────────── +hardlink-dialog-title = Create Hardlinks +hardlink-dialog-message = Replace { $count } selected file(s) with hardlinks to: + { $reference }? +symlink-dialog-title = Create Symlinks +symlink-dialog-message = Replace { $count } selected file(s) with symlinks to: + { $reference }? +no-reference-title = No Reference +no-reference-message = Cannot determine reference file. Leave at least one file unchecked in the group. + +# ── EXIF dialog ────────────────────────────────────────── +exif-dialog-title = Clean EXIF +exif-dialog-message = Remove EXIF metadata from { $count } selected file(s)? + +# ── Video optimize dialog ──────────────────────────────── +video-optimize-title = Video Optimization +video-optimize-message = Video optimization for { $count } file(s) will be performed using czkawka_cli. Check the status bar for progress. + +# ── Warnings / errors ──────────────────────────────────── +no-directories-title = No Directories +no-directories-message = Please add at least one directory to scan in the bottom panel. +no-selection-title = No Selection +no-selection-delete = No files selected for deletion. +no-selection-move = No files selected. +no-results-title = No Results +no-results-save = No results to save. +no-destination-title = No Destination +no-destination-message = Please select a destination folder. +scan-error-title = Scan Error + +# ── Save/load dialogs ──────────────────────────────────── +save-dialog-title = Save Results +load-dialog-title = Load Results diff --git a/kalka/i18n/ja/kalka.ftl b/kalka/i18n/ja/kalka.ftl new file mode 100644 index 000000000..fdbcd226c --- /dev/null +++ b/kalka/i18n/ja/kalka.ftl @@ -0,0 +1,338 @@ +# Kalka – English translations (Fluent format) +# This file follows the same Fluent convention as krokiet/i18n/ + +# ── Window & app ────────────────────────────────────────── +main-window-title = Kalka - Data Cleaner +about-title = About Kalka +about-app-name = Kalka +about-subtitle = PySide6 / Qt 6 Edition +about-version = Version 11.0.1 +about-description = + Kalka is a simple, fast and free app to remove + unnecessary files from your computer. + + This PySide6/Qt interface uses the czkawka_cli backend + for all scanning and file operations. + + Features: + - Find duplicate files (by hash, name, or size) + - Find empty files and folders + - Find similar images, videos, and music + - Find broken files and invalid symlinks + - Find files with bad extensions or names + - Remove EXIF metadata from images + - Optimize and crop videos + + Licensed under MIT License + https://github.com/qarmin/czkawka +about-logo-tooltip = About Kalka +version-label = Kalka v11.0.1 + +# ── Status bar ──────────────────────────────────────────── +status-ready = Ready +status-tab = Tab: { $tab_name } +status-scanning = Scanning: { $tab_name }... +status-scan-complete = Scan complete: found { $count } entries +status-scan-stopped = Scan stopped by user +status-error = Error: { $message } +status-deleted = Deleted { $count } file(s) +status-deleted-dry-run = [DRY RUN] Deleted { $count } file(s) +status-moved = Moved { $count } file(s) +status-moved-dry-run = [DRY RUN] Moved { $count } file(s) +status-copied = Copied { $count } file(s) +status-copied-dry-run = [DRY RUN] Copied { $count } file(s) +status-hardlinks-created = Created { $count } hardlink(s) +status-symlinks-created = Created { $count } symlink(s) +status-exif-cleaned = Cleaned EXIF from { $count } file(s) +status-extensions-fixed = Extensions fixed +status-names-fixed = Names fixed +status-results-saved = Results saved successfully +status-results-loaded = Loaded { $count } entries from file +status-video-optimize = Video optimization: use CLI directly for this feature + +# ── Buttons ─────────────────────────────────────────────── +scan-button = Scan +stop-button = Stop +select-button = Select +delete-button = Delete +move-button = Move +save-button = Save +load-button = Load +load-button-tooltip = Load previously saved results +sort-button = Sort +hardlink-button = Hardlink +symlink-button = Symlink +rename-button = Rename +clean-exif-button = Clean EXIF +optimize-button = Optimize + +# ── Tool names ──────────────────────────────────────────── +tool-duplicate-files = Duplicate Files +tool-empty-folders = Empty Folders +tool-big-files = Big Files +tool-empty-files = Empty Files +tool-temporary-files = Temporary Files +tool-similar-images = Similar Images +tool-similar-videos = Similar Videos +tool-similar-music = Similar Music +tool-invalid-symlinks = Invalid Symlinks +tool-broken-files = Broken Files +tool-bad-extensions = Bad Extensions +tool-bad-names = Bad Names +tool-exif-remover = EXIF Remover +tool-video-optimizer = Video Optimizer + +# ── Column headers ──────────────────────────────────────── +column-selection = Selection +column-size = Size +column-file-name = File Name +column-path = Path +column-modification-date = Modification Date +column-hash = Hash +column-similarity = Similarity +column-resolution = Resolution +column-title = Title +column-artist = Artist +column-year = Year +column-bitrate = Bitrate +column-genre = Genre +column-length = Length +column-folder-name = Folder Name +column-symlink-name = Symlink Name +column-symlink-path = Symlink Path +column-destination-path = Destination Path +column-type-of-error = Type of Error +column-error-type = Error Type +column-current-extension = Current Extension +column-proper-extension = Proper Extension +column-codec = Codec + +# ── Left panel ──────────────────────────────────────────── +settings-tooltip = Application Settings +tool-settings-tooltip = Tool-specific Settings +fallback-app-name = Kalka + +# ── Settings panel ──────────────────────────────────────── +settings-title = Settings +settings-close = Close +settings-tab-general = General +settings-tab-directories = Directories +settings-tab-filters = Filters +settings-tab-preview = Preview +settings-window-title = Kalka Settings + +# General settings +settings-cli-path = czkawka_cli Path: +settings-browse = Browse +settings-thread-count = Thread Count: +settings-thread-auto = Auto (all cores) +settings-recursive = Recursive search +settings-use-cache = Use cache for faster rescans +settings-move-to-trash = Move to trash instead of permanent delete +settings-hide-hard-links = Hide hard links +settings-save-as-json = Save results as JSON (instead of text) +settings-select-cli-binary = Select czkawka_cli binary + +# Directories settings +settings-included-dirs = Included Directories +settings-excluded-dirs = Excluded Directories +settings-add = Add +settings-remove = Remove +settings-select-dir-include = Select Directory to Include +settings-select-dir-exclude = Select Directory to Exclude + +# Filters settings +settings-excluded-items = Excluded Items: +settings-excluded-items-hint = Wildcard patterns, comma-separated (e.g. *.tmp,cache_*) +settings-allowed-extensions = Allowed Extensions: +settings-allowed-extensions-hint = e.g. jpg,png,gif +settings-excluded-extensions = Excluded Extensions: +settings-excluded-extensions-hint = e.g. log,tmp +settings-min-file-size = Minimum File Size: +settings-min-file-size-hint = In bytes (e.g. 1024) +settings-max-file-size = Maximum File Size: +settings-max-file-size-hint = In bytes (leave empty for no limit) + +# Preview settings +settings-show-image-preview = Show image preview + +# ── Tool settings panel ────────────────────────────────── +tool-settings-title = Tool Settings + +# Duplicate files +subsettings-check-method = Check Method: +subsettings-hash-type = Hash Type: +subsettings-case-sensitive = Case sensitive name comparison + +# Similar images +subsettings-hash-size = Hash Size: +subsettings-resize-algorithm = Resize Algorithm: +subsettings-image-hash-type = Hash Type: +subsettings-ignore-same-size = Ignore same size +subsettings-max-difference = Max Difference: + +# Similar videos +subsettings-crop-detect = Crop Detect: +subsettings-skip-forward = Skip Forward (s): +subsettings-hash-duration = Hash Duration (s): + +# Similar music +subsettings-audio-check-type = Audio Check Type: +subsettings-tag-matching = Tag Matching +subsettings-approximate-comparison = Approximate comparison +subsettings-fingerprint-matching = Fingerprint Matching +subsettings-compare-similar-titles = Compare with similar titles + +# Big files +subsettings-method = Method: +subsettings-the-biggest = The Biggest +subsettings-the-smallest = The Smallest +subsettings-number-of-files = Number of Files: + +# Broken files +subsettings-file-types = File types to check: + +# Bad names +subsettings-check-for = Check for: +subsettings-uppercase-ext = Uppercase extension +subsettings-uppercase-ext-hint = Files with .JPG, .PNG etc. +subsettings-emoji = Emoji in name +subsettings-emoji-hint = Files containing emoji characters +subsettings-space = Space at start/end +subsettings-space-hint = Leading or trailing whitespace +subsettings-non-ascii = Non-ASCII characters +subsettings-non-ascii-hint = Characters outside ASCII range +subsettings-remove-duplicated = Remove duplicated non-alphanumeric +subsettings-remove-duplicated-hint = e.g. file--name..txt +subsettings-restricted-charset = Restricted charset: +subsettings-restricted-charset-hint = Allowed special chars, comma-separated + +# EXIF +subsettings-ignored-exif-tags = Ignored EXIF Tags: +subsettings-ignored-exif-tags-hint = Tags to ignore, comma-separated + +# Video optimizer +subsettings-mode = Mode: +subsettings-crop-settings = Crop Settings +subsettings-crop-type = Crop Type: +subsettings-black-pixel-threshold = Black Pixel Threshold: +subsettings-black-bar-min-pct = Black Bar Min %: +subsettings-max-samples = Max Samples: +subsettings-min-crop-size = Min Crop Size: +subsettings-transcode-settings = Transcode Settings +subsettings-excluded-codecs = Excluded Codecs: +subsettings-target-codec = Target Codec: +subsettings-quality = Quality: +subsettings-fail-if-bigger = Fail if not smaller + +# ── Progress widget ────────────────────────────────────── +progress-initializing = Initializing... +progress-starting = Starting scan... +progress-current = Current +progress-overall = Overall +progress-scan-complete = Scan complete +progress-completed-in = Completed in { $time } +progress-done = done + +# ── Results view ───────────────────────────────────────── +results-no-results = No results +results-found-grouped = Found { $total } files ({ $size }) in { $groups } groups +results-found-flat = Found { $total } entries ({ $size }) +results-selected = Selected: { $selected }/{ $total } ({ $selected_size }/{ $total_size }) + +# ── Preview panel ──────────────────────────────────────── +preview-title = Preview +preview-no-preview = No preview +preview-file-not-found = File not found +preview-not-available = Preview not available + for this file type +preview-cannot-load = Cannot load image + +# ── Context menu ───────────────────────────────────────── +context-open-file = Open File +context-open-folder = Open Containing Folder +context-select = Select +context-deselect = Deselect + +# ── Bottom panel ───────────────────────────────────────── +bottom-included-dirs = Included Directories: +bottom-excluded-dirs = Excluded Directories: + +# ── Delete dialog ──────────────────────────────────────── +delete-dialog-title = Delete Files +delete-dialog-message = Are you sure you want to delete { $count } selected file(s)? +delete-dialog-trash = Move to trash instead of permanent delete +delete-dialog-dry-run = Dry run (preview only, no files will be deleted) +delete-dialog-confirm = Delete + +# ── Move dialog ────────────────────────────────────────── +move-dialog-title = Move/Copy Files +move-dialog-message = Move or copy { $count } selected file(s) to: +move-dialog-placeholder = Select destination folder... +move-dialog-preserve = Preserve folder structure +move-dialog-copy-mode = Copy instead of move +move-dialog-dry-run = Dry run (preview only, no files will be moved) +move-dialog-confirm = Move +move-dialog-select-dest = Select Destination + +# ── Rename dialog ──────────────────────────────────────── +rename-dialog-confirm = Rename +rename-dialog-ext-message = Fix extensions for { $count } selected file(s)? + + Files will be renamed to use their proper extensions. +rename-dialog-names-message = Fix names for { $count } selected file(s)? + + Files with problematic names will be renamed. + +# ── Select dialog ──────────────────────────────────────── +select-dialog-title = Select Results +select-dialog-prompt = Choose selection mode: +select-all = Select All +unselect-all = Unselect All +invert-selection = Invert Selection +select-biggest-size = Select Biggest (by Size) +select-smallest-size = Select Smallest (by Size) +select-newest = Select Newest +select-oldest = Select Oldest +select-shortest-path = Select Shortest Path +select-longest-path = Select Longest Path +cancel = Cancel + +# ── Sort dialog ────────────────────────────────────────── +sort-dialog-title = Sort Results +sort-by = Sort by: +sort-ascending = Ascending + +# ── Hardlink / symlink dialogs ─────────────────────────── +hardlink-dialog-title = Create Hardlinks +hardlink-dialog-message = Replace { $count } selected file(s) with hardlinks to: + { $reference }? +symlink-dialog-title = Create Symlinks +symlink-dialog-message = Replace { $count } selected file(s) with symlinks to: + { $reference }? +no-reference-title = No Reference +no-reference-message = Cannot determine reference file. Leave at least one file unchecked in the group. + +# ── EXIF dialog ────────────────────────────────────────── +exif-dialog-title = Clean EXIF +exif-dialog-message = Remove EXIF metadata from { $count } selected file(s)? + +# ── Video optimize dialog ──────────────────────────────── +video-optimize-title = Video Optimization +video-optimize-message = Video optimization for { $count } file(s) will be performed using czkawka_cli. Check the status bar for progress. + +# ── Warnings / errors ──────────────────────────────────── +no-directories-title = No Directories +no-directories-message = Please add at least one directory to scan in the bottom panel. +no-selection-title = No Selection +no-selection-delete = No files selected for deletion. +no-selection-move = No files selected. +no-results-title = No Results +no-results-save = No results to save. +no-destination-title = No Destination +no-destination-message = Please select a destination folder. +scan-error-title = Scan Error + +# ── Save/load dialogs ──────────────────────────────────── +save-dialog-title = Save Results +load-dialog-title = Load Results diff --git a/kalka/i18n/ko/kalka.ftl b/kalka/i18n/ko/kalka.ftl new file mode 100644 index 000000000..fdbcd226c --- /dev/null +++ b/kalka/i18n/ko/kalka.ftl @@ -0,0 +1,338 @@ +# Kalka – English translations (Fluent format) +# This file follows the same Fluent convention as krokiet/i18n/ + +# ── Window & app ────────────────────────────────────────── +main-window-title = Kalka - Data Cleaner +about-title = About Kalka +about-app-name = Kalka +about-subtitle = PySide6 / Qt 6 Edition +about-version = Version 11.0.1 +about-description = + Kalka is a simple, fast and free app to remove + unnecessary files from your computer. + + This PySide6/Qt interface uses the czkawka_cli backend + for all scanning and file operations. + + Features: + - Find duplicate files (by hash, name, or size) + - Find empty files and folders + - Find similar images, videos, and music + - Find broken files and invalid symlinks + - Find files with bad extensions or names + - Remove EXIF metadata from images + - Optimize and crop videos + + Licensed under MIT License + https://github.com/qarmin/czkawka +about-logo-tooltip = About Kalka +version-label = Kalka v11.0.1 + +# ── Status bar ──────────────────────────────────────────── +status-ready = Ready +status-tab = Tab: { $tab_name } +status-scanning = Scanning: { $tab_name }... +status-scan-complete = Scan complete: found { $count } entries +status-scan-stopped = Scan stopped by user +status-error = Error: { $message } +status-deleted = Deleted { $count } file(s) +status-deleted-dry-run = [DRY RUN] Deleted { $count } file(s) +status-moved = Moved { $count } file(s) +status-moved-dry-run = [DRY RUN] Moved { $count } file(s) +status-copied = Copied { $count } file(s) +status-copied-dry-run = [DRY RUN] Copied { $count } file(s) +status-hardlinks-created = Created { $count } hardlink(s) +status-symlinks-created = Created { $count } symlink(s) +status-exif-cleaned = Cleaned EXIF from { $count } file(s) +status-extensions-fixed = Extensions fixed +status-names-fixed = Names fixed +status-results-saved = Results saved successfully +status-results-loaded = Loaded { $count } entries from file +status-video-optimize = Video optimization: use CLI directly for this feature + +# ── Buttons ─────────────────────────────────────────────── +scan-button = Scan +stop-button = Stop +select-button = Select +delete-button = Delete +move-button = Move +save-button = Save +load-button = Load +load-button-tooltip = Load previously saved results +sort-button = Sort +hardlink-button = Hardlink +symlink-button = Symlink +rename-button = Rename +clean-exif-button = Clean EXIF +optimize-button = Optimize + +# ── Tool names ──────────────────────────────────────────── +tool-duplicate-files = Duplicate Files +tool-empty-folders = Empty Folders +tool-big-files = Big Files +tool-empty-files = Empty Files +tool-temporary-files = Temporary Files +tool-similar-images = Similar Images +tool-similar-videos = Similar Videos +tool-similar-music = Similar Music +tool-invalid-symlinks = Invalid Symlinks +tool-broken-files = Broken Files +tool-bad-extensions = Bad Extensions +tool-bad-names = Bad Names +tool-exif-remover = EXIF Remover +tool-video-optimizer = Video Optimizer + +# ── Column headers ──────────────────────────────────────── +column-selection = Selection +column-size = Size +column-file-name = File Name +column-path = Path +column-modification-date = Modification Date +column-hash = Hash +column-similarity = Similarity +column-resolution = Resolution +column-title = Title +column-artist = Artist +column-year = Year +column-bitrate = Bitrate +column-genre = Genre +column-length = Length +column-folder-name = Folder Name +column-symlink-name = Symlink Name +column-symlink-path = Symlink Path +column-destination-path = Destination Path +column-type-of-error = Type of Error +column-error-type = Error Type +column-current-extension = Current Extension +column-proper-extension = Proper Extension +column-codec = Codec + +# ── Left panel ──────────────────────────────────────────── +settings-tooltip = Application Settings +tool-settings-tooltip = Tool-specific Settings +fallback-app-name = Kalka + +# ── Settings panel ──────────────────────────────────────── +settings-title = Settings +settings-close = Close +settings-tab-general = General +settings-tab-directories = Directories +settings-tab-filters = Filters +settings-tab-preview = Preview +settings-window-title = Kalka Settings + +# General settings +settings-cli-path = czkawka_cli Path: +settings-browse = Browse +settings-thread-count = Thread Count: +settings-thread-auto = Auto (all cores) +settings-recursive = Recursive search +settings-use-cache = Use cache for faster rescans +settings-move-to-trash = Move to trash instead of permanent delete +settings-hide-hard-links = Hide hard links +settings-save-as-json = Save results as JSON (instead of text) +settings-select-cli-binary = Select czkawka_cli binary + +# Directories settings +settings-included-dirs = Included Directories +settings-excluded-dirs = Excluded Directories +settings-add = Add +settings-remove = Remove +settings-select-dir-include = Select Directory to Include +settings-select-dir-exclude = Select Directory to Exclude + +# Filters settings +settings-excluded-items = Excluded Items: +settings-excluded-items-hint = Wildcard patterns, comma-separated (e.g. *.tmp,cache_*) +settings-allowed-extensions = Allowed Extensions: +settings-allowed-extensions-hint = e.g. jpg,png,gif +settings-excluded-extensions = Excluded Extensions: +settings-excluded-extensions-hint = e.g. log,tmp +settings-min-file-size = Minimum File Size: +settings-min-file-size-hint = In bytes (e.g. 1024) +settings-max-file-size = Maximum File Size: +settings-max-file-size-hint = In bytes (leave empty for no limit) + +# Preview settings +settings-show-image-preview = Show image preview + +# ── Tool settings panel ────────────────────────────────── +tool-settings-title = Tool Settings + +# Duplicate files +subsettings-check-method = Check Method: +subsettings-hash-type = Hash Type: +subsettings-case-sensitive = Case sensitive name comparison + +# Similar images +subsettings-hash-size = Hash Size: +subsettings-resize-algorithm = Resize Algorithm: +subsettings-image-hash-type = Hash Type: +subsettings-ignore-same-size = Ignore same size +subsettings-max-difference = Max Difference: + +# Similar videos +subsettings-crop-detect = Crop Detect: +subsettings-skip-forward = Skip Forward (s): +subsettings-hash-duration = Hash Duration (s): + +# Similar music +subsettings-audio-check-type = Audio Check Type: +subsettings-tag-matching = Tag Matching +subsettings-approximate-comparison = Approximate comparison +subsettings-fingerprint-matching = Fingerprint Matching +subsettings-compare-similar-titles = Compare with similar titles + +# Big files +subsettings-method = Method: +subsettings-the-biggest = The Biggest +subsettings-the-smallest = The Smallest +subsettings-number-of-files = Number of Files: + +# Broken files +subsettings-file-types = File types to check: + +# Bad names +subsettings-check-for = Check for: +subsettings-uppercase-ext = Uppercase extension +subsettings-uppercase-ext-hint = Files with .JPG, .PNG etc. +subsettings-emoji = Emoji in name +subsettings-emoji-hint = Files containing emoji characters +subsettings-space = Space at start/end +subsettings-space-hint = Leading or trailing whitespace +subsettings-non-ascii = Non-ASCII characters +subsettings-non-ascii-hint = Characters outside ASCII range +subsettings-remove-duplicated = Remove duplicated non-alphanumeric +subsettings-remove-duplicated-hint = e.g. file--name..txt +subsettings-restricted-charset = Restricted charset: +subsettings-restricted-charset-hint = Allowed special chars, comma-separated + +# EXIF +subsettings-ignored-exif-tags = Ignored EXIF Tags: +subsettings-ignored-exif-tags-hint = Tags to ignore, comma-separated + +# Video optimizer +subsettings-mode = Mode: +subsettings-crop-settings = Crop Settings +subsettings-crop-type = Crop Type: +subsettings-black-pixel-threshold = Black Pixel Threshold: +subsettings-black-bar-min-pct = Black Bar Min %: +subsettings-max-samples = Max Samples: +subsettings-min-crop-size = Min Crop Size: +subsettings-transcode-settings = Transcode Settings +subsettings-excluded-codecs = Excluded Codecs: +subsettings-target-codec = Target Codec: +subsettings-quality = Quality: +subsettings-fail-if-bigger = Fail if not smaller + +# ── Progress widget ────────────────────────────────────── +progress-initializing = Initializing... +progress-starting = Starting scan... +progress-current = Current +progress-overall = Overall +progress-scan-complete = Scan complete +progress-completed-in = Completed in { $time } +progress-done = done + +# ── Results view ───────────────────────────────────────── +results-no-results = No results +results-found-grouped = Found { $total } files ({ $size }) in { $groups } groups +results-found-flat = Found { $total } entries ({ $size }) +results-selected = Selected: { $selected }/{ $total } ({ $selected_size }/{ $total_size }) + +# ── Preview panel ──────────────────────────────────────── +preview-title = Preview +preview-no-preview = No preview +preview-file-not-found = File not found +preview-not-available = Preview not available + for this file type +preview-cannot-load = Cannot load image + +# ── Context menu ───────────────────────────────────────── +context-open-file = Open File +context-open-folder = Open Containing Folder +context-select = Select +context-deselect = Deselect + +# ── Bottom panel ───────────────────────────────────────── +bottom-included-dirs = Included Directories: +bottom-excluded-dirs = Excluded Directories: + +# ── Delete dialog ──────────────────────────────────────── +delete-dialog-title = Delete Files +delete-dialog-message = Are you sure you want to delete { $count } selected file(s)? +delete-dialog-trash = Move to trash instead of permanent delete +delete-dialog-dry-run = Dry run (preview only, no files will be deleted) +delete-dialog-confirm = Delete + +# ── Move dialog ────────────────────────────────────────── +move-dialog-title = Move/Copy Files +move-dialog-message = Move or copy { $count } selected file(s) to: +move-dialog-placeholder = Select destination folder... +move-dialog-preserve = Preserve folder structure +move-dialog-copy-mode = Copy instead of move +move-dialog-dry-run = Dry run (preview only, no files will be moved) +move-dialog-confirm = Move +move-dialog-select-dest = Select Destination + +# ── Rename dialog ──────────────────────────────────────── +rename-dialog-confirm = Rename +rename-dialog-ext-message = Fix extensions for { $count } selected file(s)? + + Files will be renamed to use their proper extensions. +rename-dialog-names-message = Fix names for { $count } selected file(s)? + + Files with problematic names will be renamed. + +# ── Select dialog ──────────────────────────────────────── +select-dialog-title = Select Results +select-dialog-prompt = Choose selection mode: +select-all = Select All +unselect-all = Unselect All +invert-selection = Invert Selection +select-biggest-size = Select Biggest (by Size) +select-smallest-size = Select Smallest (by Size) +select-newest = Select Newest +select-oldest = Select Oldest +select-shortest-path = Select Shortest Path +select-longest-path = Select Longest Path +cancel = Cancel + +# ── Sort dialog ────────────────────────────────────────── +sort-dialog-title = Sort Results +sort-by = Sort by: +sort-ascending = Ascending + +# ── Hardlink / symlink dialogs ─────────────────────────── +hardlink-dialog-title = Create Hardlinks +hardlink-dialog-message = Replace { $count } selected file(s) with hardlinks to: + { $reference }? +symlink-dialog-title = Create Symlinks +symlink-dialog-message = Replace { $count } selected file(s) with symlinks to: + { $reference }? +no-reference-title = No Reference +no-reference-message = Cannot determine reference file. Leave at least one file unchecked in the group. + +# ── EXIF dialog ────────────────────────────────────────── +exif-dialog-title = Clean EXIF +exif-dialog-message = Remove EXIF metadata from { $count } selected file(s)? + +# ── Video optimize dialog ──────────────────────────────── +video-optimize-title = Video Optimization +video-optimize-message = Video optimization for { $count } file(s) will be performed using czkawka_cli. Check the status bar for progress. + +# ── Warnings / errors ──────────────────────────────────── +no-directories-title = No Directories +no-directories-message = Please add at least one directory to scan in the bottom panel. +no-selection-title = No Selection +no-selection-delete = No files selected for deletion. +no-selection-move = No files selected. +no-results-title = No Results +no-results-save = No results to save. +no-destination-title = No Destination +no-destination-message = Please select a destination folder. +scan-error-title = Scan Error + +# ── Save/load dialogs ──────────────────────────────────── +save-dialog-title = Save Results +load-dialog-title = Load Results diff --git a/kalka/i18n/nl/kalka.ftl b/kalka/i18n/nl/kalka.ftl new file mode 100644 index 000000000..fdbcd226c --- /dev/null +++ b/kalka/i18n/nl/kalka.ftl @@ -0,0 +1,338 @@ +# Kalka – English translations (Fluent format) +# This file follows the same Fluent convention as krokiet/i18n/ + +# ── Window & app ────────────────────────────────────────── +main-window-title = Kalka - Data Cleaner +about-title = About Kalka +about-app-name = Kalka +about-subtitle = PySide6 / Qt 6 Edition +about-version = Version 11.0.1 +about-description = + Kalka is a simple, fast and free app to remove + unnecessary files from your computer. + + This PySide6/Qt interface uses the czkawka_cli backend + for all scanning and file operations. + + Features: + - Find duplicate files (by hash, name, or size) + - Find empty files and folders + - Find similar images, videos, and music + - Find broken files and invalid symlinks + - Find files with bad extensions or names + - Remove EXIF metadata from images + - Optimize and crop videos + + Licensed under MIT License + https://github.com/qarmin/czkawka +about-logo-tooltip = About Kalka +version-label = Kalka v11.0.1 + +# ── Status bar ──────────────────────────────────────────── +status-ready = Ready +status-tab = Tab: { $tab_name } +status-scanning = Scanning: { $tab_name }... +status-scan-complete = Scan complete: found { $count } entries +status-scan-stopped = Scan stopped by user +status-error = Error: { $message } +status-deleted = Deleted { $count } file(s) +status-deleted-dry-run = [DRY RUN] Deleted { $count } file(s) +status-moved = Moved { $count } file(s) +status-moved-dry-run = [DRY RUN] Moved { $count } file(s) +status-copied = Copied { $count } file(s) +status-copied-dry-run = [DRY RUN] Copied { $count } file(s) +status-hardlinks-created = Created { $count } hardlink(s) +status-symlinks-created = Created { $count } symlink(s) +status-exif-cleaned = Cleaned EXIF from { $count } file(s) +status-extensions-fixed = Extensions fixed +status-names-fixed = Names fixed +status-results-saved = Results saved successfully +status-results-loaded = Loaded { $count } entries from file +status-video-optimize = Video optimization: use CLI directly for this feature + +# ── Buttons ─────────────────────────────────────────────── +scan-button = Scan +stop-button = Stop +select-button = Select +delete-button = Delete +move-button = Move +save-button = Save +load-button = Load +load-button-tooltip = Load previously saved results +sort-button = Sort +hardlink-button = Hardlink +symlink-button = Symlink +rename-button = Rename +clean-exif-button = Clean EXIF +optimize-button = Optimize + +# ── Tool names ──────────────────────────────────────────── +tool-duplicate-files = Duplicate Files +tool-empty-folders = Empty Folders +tool-big-files = Big Files +tool-empty-files = Empty Files +tool-temporary-files = Temporary Files +tool-similar-images = Similar Images +tool-similar-videos = Similar Videos +tool-similar-music = Similar Music +tool-invalid-symlinks = Invalid Symlinks +tool-broken-files = Broken Files +tool-bad-extensions = Bad Extensions +tool-bad-names = Bad Names +tool-exif-remover = EXIF Remover +tool-video-optimizer = Video Optimizer + +# ── Column headers ──────────────────────────────────────── +column-selection = Selection +column-size = Size +column-file-name = File Name +column-path = Path +column-modification-date = Modification Date +column-hash = Hash +column-similarity = Similarity +column-resolution = Resolution +column-title = Title +column-artist = Artist +column-year = Year +column-bitrate = Bitrate +column-genre = Genre +column-length = Length +column-folder-name = Folder Name +column-symlink-name = Symlink Name +column-symlink-path = Symlink Path +column-destination-path = Destination Path +column-type-of-error = Type of Error +column-error-type = Error Type +column-current-extension = Current Extension +column-proper-extension = Proper Extension +column-codec = Codec + +# ── Left panel ──────────────────────────────────────────── +settings-tooltip = Application Settings +tool-settings-tooltip = Tool-specific Settings +fallback-app-name = Kalka + +# ── Settings panel ──────────────────────────────────────── +settings-title = Settings +settings-close = Close +settings-tab-general = General +settings-tab-directories = Directories +settings-tab-filters = Filters +settings-tab-preview = Preview +settings-window-title = Kalka Settings + +# General settings +settings-cli-path = czkawka_cli Path: +settings-browse = Browse +settings-thread-count = Thread Count: +settings-thread-auto = Auto (all cores) +settings-recursive = Recursive search +settings-use-cache = Use cache for faster rescans +settings-move-to-trash = Move to trash instead of permanent delete +settings-hide-hard-links = Hide hard links +settings-save-as-json = Save results as JSON (instead of text) +settings-select-cli-binary = Select czkawka_cli binary + +# Directories settings +settings-included-dirs = Included Directories +settings-excluded-dirs = Excluded Directories +settings-add = Add +settings-remove = Remove +settings-select-dir-include = Select Directory to Include +settings-select-dir-exclude = Select Directory to Exclude + +# Filters settings +settings-excluded-items = Excluded Items: +settings-excluded-items-hint = Wildcard patterns, comma-separated (e.g. *.tmp,cache_*) +settings-allowed-extensions = Allowed Extensions: +settings-allowed-extensions-hint = e.g. jpg,png,gif +settings-excluded-extensions = Excluded Extensions: +settings-excluded-extensions-hint = e.g. log,tmp +settings-min-file-size = Minimum File Size: +settings-min-file-size-hint = In bytes (e.g. 1024) +settings-max-file-size = Maximum File Size: +settings-max-file-size-hint = In bytes (leave empty for no limit) + +# Preview settings +settings-show-image-preview = Show image preview + +# ── Tool settings panel ────────────────────────────────── +tool-settings-title = Tool Settings + +# Duplicate files +subsettings-check-method = Check Method: +subsettings-hash-type = Hash Type: +subsettings-case-sensitive = Case sensitive name comparison + +# Similar images +subsettings-hash-size = Hash Size: +subsettings-resize-algorithm = Resize Algorithm: +subsettings-image-hash-type = Hash Type: +subsettings-ignore-same-size = Ignore same size +subsettings-max-difference = Max Difference: + +# Similar videos +subsettings-crop-detect = Crop Detect: +subsettings-skip-forward = Skip Forward (s): +subsettings-hash-duration = Hash Duration (s): + +# Similar music +subsettings-audio-check-type = Audio Check Type: +subsettings-tag-matching = Tag Matching +subsettings-approximate-comparison = Approximate comparison +subsettings-fingerprint-matching = Fingerprint Matching +subsettings-compare-similar-titles = Compare with similar titles + +# Big files +subsettings-method = Method: +subsettings-the-biggest = The Biggest +subsettings-the-smallest = The Smallest +subsettings-number-of-files = Number of Files: + +# Broken files +subsettings-file-types = File types to check: + +# Bad names +subsettings-check-for = Check for: +subsettings-uppercase-ext = Uppercase extension +subsettings-uppercase-ext-hint = Files with .JPG, .PNG etc. +subsettings-emoji = Emoji in name +subsettings-emoji-hint = Files containing emoji characters +subsettings-space = Space at start/end +subsettings-space-hint = Leading or trailing whitespace +subsettings-non-ascii = Non-ASCII characters +subsettings-non-ascii-hint = Characters outside ASCII range +subsettings-remove-duplicated = Remove duplicated non-alphanumeric +subsettings-remove-duplicated-hint = e.g. file--name..txt +subsettings-restricted-charset = Restricted charset: +subsettings-restricted-charset-hint = Allowed special chars, comma-separated + +# EXIF +subsettings-ignored-exif-tags = Ignored EXIF Tags: +subsettings-ignored-exif-tags-hint = Tags to ignore, comma-separated + +# Video optimizer +subsettings-mode = Mode: +subsettings-crop-settings = Crop Settings +subsettings-crop-type = Crop Type: +subsettings-black-pixel-threshold = Black Pixel Threshold: +subsettings-black-bar-min-pct = Black Bar Min %: +subsettings-max-samples = Max Samples: +subsettings-min-crop-size = Min Crop Size: +subsettings-transcode-settings = Transcode Settings +subsettings-excluded-codecs = Excluded Codecs: +subsettings-target-codec = Target Codec: +subsettings-quality = Quality: +subsettings-fail-if-bigger = Fail if not smaller + +# ── Progress widget ────────────────────────────────────── +progress-initializing = Initializing... +progress-starting = Starting scan... +progress-current = Current +progress-overall = Overall +progress-scan-complete = Scan complete +progress-completed-in = Completed in { $time } +progress-done = done + +# ── Results view ───────────────────────────────────────── +results-no-results = No results +results-found-grouped = Found { $total } files ({ $size }) in { $groups } groups +results-found-flat = Found { $total } entries ({ $size }) +results-selected = Selected: { $selected }/{ $total } ({ $selected_size }/{ $total_size }) + +# ── Preview panel ──────────────────────────────────────── +preview-title = Preview +preview-no-preview = No preview +preview-file-not-found = File not found +preview-not-available = Preview not available + for this file type +preview-cannot-load = Cannot load image + +# ── Context menu ───────────────────────────────────────── +context-open-file = Open File +context-open-folder = Open Containing Folder +context-select = Select +context-deselect = Deselect + +# ── Bottom panel ───────────────────────────────────────── +bottom-included-dirs = Included Directories: +bottom-excluded-dirs = Excluded Directories: + +# ── Delete dialog ──────────────────────────────────────── +delete-dialog-title = Delete Files +delete-dialog-message = Are you sure you want to delete { $count } selected file(s)? +delete-dialog-trash = Move to trash instead of permanent delete +delete-dialog-dry-run = Dry run (preview only, no files will be deleted) +delete-dialog-confirm = Delete + +# ── Move dialog ────────────────────────────────────────── +move-dialog-title = Move/Copy Files +move-dialog-message = Move or copy { $count } selected file(s) to: +move-dialog-placeholder = Select destination folder... +move-dialog-preserve = Preserve folder structure +move-dialog-copy-mode = Copy instead of move +move-dialog-dry-run = Dry run (preview only, no files will be moved) +move-dialog-confirm = Move +move-dialog-select-dest = Select Destination + +# ── Rename dialog ──────────────────────────────────────── +rename-dialog-confirm = Rename +rename-dialog-ext-message = Fix extensions for { $count } selected file(s)? + + Files will be renamed to use their proper extensions. +rename-dialog-names-message = Fix names for { $count } selected file(s)? + + Files with problematic names will be renamed. + +# ── Select dialog ──────────────────────────────────────── +select-dialog-title = Select Results +select-dialog-prompt = Choose selection mode: +select-all = Select All +unselect-all = Unselect All +invert-selection = Invert Selection +select-biggest-size = Select Biggest (by Size) +select-smallest-size = Select Smallest (by Size) +select-newest = Select Newest +select-oldest = Select Oldest +select-shortest-path = Select Shortest Path +select-longest-path = Select Longest Path +cancel = Cancel + +# ── Sort dialog ────────────────────────────────────────── +sort-dialog-title = Sort Results +sort-by = Sort by: +sort-ascending = Ascending + +# ── Hardlink / symlink dialogs ─────────────────────────── +hardlink-dialog-title = Create Hardlinks +hardlink-dialog-message = Replace { $count } selected file(s) with hardlinks to: + { $reference }? +symlink-dialog-title = Create Symlinks +symlink-dialog-message = Replace { $count } selected file(s) with symlinks to: + { $reference }? +no-reference-title = No Reference +no-reference-message = Cannot determine reference file. Leave at least one file unchecked in the group. + +# ── EXIF dialog ────────────────────────────────────────── +exif-dialog-title = Clean EXIF +exif-dialog-message = Remove EXIF metadata from { $count } selected file(s)? + +# ── Video optimize dialog ──────────────────────────────── +video-optimize-title = Video Optimization +video-optimize-message = Video optimization for { $count } file(s) will be performed using czkawka_cli. Check the status bar for progress. + +# ── Warnings / errors ──────────────────────────────────── +no-directories-title = No Directories +no-directories-message = Please add at least one directory to scan in the bottom panel. +no-selection-title = No Selection +no-selection-delete = No files selected for deletion. +no-selection-move = No files selected. +no-results-title = No Results +no-results-save = No results to save. +no-destination-title = No Destination +no-destination-message = Please select a destination folder. +scan-error-title = Scan Error + +# ── Save/load dialogs ──────────────────────────────────── +save-dialog-title = Save Results +load-dialog-title = Load Results diff --git a/kalka/i18n/no/kalka.ftl b/kalka/i18n/no/kalka.ftl new file mode 100644 index 000000000..fdbcd226c --- /dev/null +++ b/kalka/i18n/no/kalka.ftl @@ -0,0 +1,338 @@ +# Kalka – English translations (Fluent format) +# This file follows the same Fluent convention as krokiet/i18n/ + +# ── Window & app ────────────────────────────────────────── +main-window-title = Kalka - Data Cleaner +about-title = About Kalka +about-app-name = Kalka +about-subtitle = PySide6 / Qt 6 Edition +about-version = Version 11.0.1 +about-description = + Kalka is a simple, fast and free app to remove + unnecessary files from your computer. + + This PySide6/Qt interface uses the czkawka_cli backend + for all scanning and file operations. + + Features: + - Find duplicate files (by hash, name, or size) + - Find empty files and folders + - Find similar images, videos, and music + - Find broken files and invalid symlinks + - Find files with bad extensions or names + - Remove EXIF metadata from images + - Optimize and crop videos + + Licensed under MIT License + https://github.com/qarmin/czkawka +about-logo-tooltip = About Kalka +version-label = Kalka v11.0.1 + +# ── Status bar ──────────────────────────────────────────── +status-ready = Ready +status-tab = Tab: { $tab_name } +status-scanning = Scanning: { $tab_name }... +status-scan-complete = Scan complete: found { $count } entries +status-scan-stopped = Scan stopped by user +status-error = Error: { $message } +status-deleted = Deleted { $count } file(s) +status-deleted-dry-run = [DRY RUN] Deleted { $count } file(s) +status-moved = Moved { $count } file(s) +status-moved-dry-run = [DRY RUN] Moved { $count } file(s) +status-copied = Copied { $count } file(s) +status-copied-dry-run = [DRY RUN] Copied { $count } file(s) +status-hardlinks-created = Created { $count } hardlink(s) +status-symlinks-created = Created { $count } symlink(s) +status-exif-cleaned = Cleaned EXIF from { $count } file(s) +status-extensions-fixed = Extensions fixed +status-names-fixed = Names fixed +status-results-saved = Results saved successfully +status-results-loaded = Loaded { $count } entries from file +status-video-optimize = Video optimization: use CLI directly for this feature + +# ── Buttons ─────────────────────────────────────────────── +scan-button = Scan +stop-button = Stop +select-button = Select +delete-button = Delete +move-button = Move +save-button = Save +load-button = Load +load-button-tooltip = Load previously saved results +sort-button = Sort +hardlink-button = Hardlink +symlink-button = Symlink +rename-button = Rename +clean-exif-button = Clean EXIF +optimize-button = Optimize + +# ── Tool names ──────────────────────────────────────────── +tool-duplicate-files = Duplicate Files +tool-empty-folders = Empty Folders +tool-big-files = Big Files +tool-empty-files = Empty Files +tool-temporary-files = Temporary Files +tool-similar-images = Similar Images +tool-similar-videos = Similar Videos +tool-similar-music = Similar Music +tool-invalid-symlinks = Invalid Symlinks +tool-broken-files = Broken Files +tool-bad-extensions = Bad Extensions +tool-bad-names = Bad Names +tool-exif-remover = EXIF Remover +tool-video-optimizer = Video Optimizer + +# ── Column headers ──────────────────────────────────────── +column-selection = Selection +column-size = Size +column-file-name = File Name +column-path = Path +column-modification-date = Modification Date +column-hash = Hash +column-similarity = Similarity +column-resolution = Resolution +column-title = Title +column-artist = Artist +column-year = Year +column-bitrate = Bitrate +column-genre = Genre +column-length = Length +column-folder-name = Folder Name +column-symlink-name = Symlink Name +column-symlink-path = Symlink Path +column-destination-path = Destination Path +column-type-of-error = Type of Error +column-error-type = Error Type +column-current-extension = Current Extension +column-proper-extension = Proper Extension +column-codec = Codec + +# ── Left panel ──────────────────────────────────────────── +settings-tooltip = Application Settings +tool-settings-tooltip = Tool-specific Settings +fallback-app-name = Kalka + +# ── Settings panel ──────────────────────────────────────── +settings-title = Settings +settings-close = Close +settings-tab-general = General +settings-tab-directories = Directories +settings-tab-filters = Filters +settings-tab-preview = Preview +settings-window-title = Kalka Settings + +# General settings +settings-cli-path = czkawka_cli Path: +settings-browse = Browse +settings-thread-count = Thread Count: +settings-thread-auto = Auto (all cores) +settings-recursive = Recursive search +settings-use-cache = Use cache for faster rescans +settings-move-to-trash = Move to trash instead of permanent delete +settings-hide-hard-links = Hide hard links +settings-save-as-json = Save results as JSON (instead of text) +settings-select-cli-binary = Select czkawka_cli binary + +# Directories settings +settings-included-dirs = Included Directories +settings-excluded-dirs = Excluded Directories +settings-add = Add +settings-remove = Remove +settings-select-dir-include = Select Directory to Include +settings-select-dir-exclude = Select Directory to Exclude + +# Filters settings +settings-excluded-items = Excluded Items: +settings-excluded-items-hint = Wildcard patterns, comma-separated (e.g. *.tmp,cache_*) +settings-allowed-extensions = Allowed Extensions: +settings-allowed-extensions-hint = e.g. jpg,png,gif +settings-excluded-extensions = Excluded Extensions: +settings-excluded-extensions-hint = e.g. log,tmp +settings-min-file-size = Minimum File Size: +settings-min-file-size-hint = In bytes (e.g. 1024) +settings-max-file-size = Maximum File Size: +settings-max-file-size-hint = In bytes (leave empty for no limit) + +# Preview settings +settings-show-image-preview = Show image preview + +# ── Tool settings panel ────────────────────────────────── +tool-settings-title = Tool Settings + +# Duplicate files +subsettings-check-method = Check Method: +subsettings-hash-type = Hash Type: +subsettings-case-sensitive = Case sensitive name comparison + +# Similar images +subsettings-hash-size = Hash Size: +subsettings-resize-algorithm = Resize Algorithm: +subsettings-image-hash-type = Hash Type: +subsettings-ignore-same-size = Ignore same size +subsettings-max-difference = Max Difference: + +# Similar videos +subsettings-crop-detect = Crop Detect: +subsettings-skip-forward = Skip Forward (s): +subsettings-hash-duration = Hash Duration (s): + +# Similar music +subsettings-audio-check-type = Audio Check Type: +subsettings-tag-matching = Tag Matching +subsettings-approximate-comparison = Approximate comparison +subsettings-fingerprint-matching = Fingerprint Matching +subsettings-compare-similar-titles = Compare with similar titles + +# Big files +subsettings-method = Method: +subsettings-the-biggest = The Biggest +subsettings-the-smallest = The Smallest +subsettings-number-of-files = Number of Files: + +# Broken files +subsettings-file-types = File types to check: + +# Bad names +subsettings-check-for = Check for: +subsettings-uppercase-ext = Uppercase extension +subsettings-uppercase-ext-hint = Files with .JPG, .PNG etc. +subsettings-emoji = Emoji in name +subsettings-emoji-hint = Files containing emoji characters +subsettings-space = Space at start/end +subsettings-space-hint = Leading or trailing whitespace +subsettings-non-ascii = Non-ASCII characters +subsettings-non-ascii-hint = Characters outside ASCII range +subsettings-remove-duplicated = Remove duplicated non-alphanumeric +subsettings-remove-duplicated-hint = e.g. file--name..txt +subsettings-restricted-charset = Restricted charset: +subsettings-restricted-charset-hint = Allowed special chars, comma-separated + +# EXIF +subsettings-ignored-exif-tags = Ignored EXIF Tags: +subsettings-ignored-exif-tags-hint = Tags to ignore, comma-separated + +# Video optimizer +subsettings-mode = Mode: +subsettings-crop-settings = Crop Settings +subsettings-crop-type = Crop Type: +subsettings-black-pixel-threshold = Black Pixel Threshold: +subsettings-black-bar-min-pct = Black Bar Min %: +subsettings-max-samples = Max Samples: +subsettings-min-crop-size = Min Crop Size: +subsettings-transcode-settings = Transcode Settings +subsettings-excluded-codecs = Excluded Codecs: +subsettings-target-codec = Target Codec: +subsettings-quality = Quality: +subsettings-fail-if-bigger = Fail if not smaller + +# ── Progress widget ────────────────────────────────────── +progress-initializing = Initializing... +progress-starting = Starting scan... +progress-current = Current +progress-overall = Overall +progress-scan-complete = Scan complete +progress-completed-in = Completed in { $time } +progress-done = done + +# ── Results view ───────────────────────────────────────── +results-no-results = No results +results-found-grouped = Found { $total } files ({ $size }) in { $groups } groups +results-found-flat = Found { $total } entries ({ $size }) +results-selected = Selected: { $selected }/{ $total } ({ $selected_size }/{ $total_size }) + +# ── Preview panel ──────────────────────────────────────── +preview-title = Preview +preview-no-preview = No preview +preview-file-not-found = File not found +preview-not-available = Preview not available + for this file type +preview-cannot-load = Cannot load image + +# ── Context menu ───────────────────────────────────────── +context-open-file = Open File +context-open-folder = Open Containing Folder +context-select = Select +context-deselect = Deselect + +# ── Bottom panel ───────────────────────────────────────── +bottom-included-dirs = Included Directories: +bottom-excluded-dirs = Excluded Directories: + +# ── Delete dialog ──────────────────────────────────────── +delete-dialog-title = Delete Files +delete-dialog-message = Are you sure you want to delete { $count } selected file(s)? +delete-dialog-trash = Move to trash instead of permanent delete +delete-dialog-dry-run = Dry run (preview only, no files will be deleted) +delete-dialog-confirm = Delete + +# ── Move dialog ────────────────────────────────────────── +move-dialog-title = Move/Copy Files +move-dialog-message = Move or copy { $count } selected file(s) to: +move-dialog-placeholder = Select destination folder... +move-dialog-preserve = Preserve folder structure +move-dialog-copy-mode = Copy instead of move +move-dialog-dry-run = Dry run (preview only, no files will be moved) +move-dialog-confirm = Move +move-dialog-select-dest = Select Destination + +# ── Rename dialog ──────────────────────────────────────── +rename-dialog-confirm = Rename +rename-dialog-ext-message = Fix extensions for { $count } selected file(s)? + + Files will be renamed to use their proper extensions. +rename-dialog-names-message = Fix names for { $count } selected file(s)? + + Files with problematic names will be renamed. + +# ── Select dialog ──────────────────────────────────────── +select-dialog-title = Select Results +select-dialog-prompt = Choose selection mode: +select-all = Select All +unselect-all = Unselect All +invert-selection = Invert Selection +select-biggest-size = Select Biggest (by Size) +select-smallest-size = Select Smallest (by Size) +select-newest = Select Newest +select-oldest = Select Oldest +select-shortest-path = Select Shortest Path +select-longest-path = Select Longest Path +cancel = Cancel + +# ── Sort dialog ────────────────────────────────────────── +sort-dialog-title = Sort Results +sort-by = Sort by: +sort-ascending = Ascending + +# ── Hardlink / symlink dialogs ─────────────────────────── +hardlink-dialog-title = Create Hardlinks +hardlink-dialog-message = Replace { $count } selected file(s) with hardlinks to: + { $reference }? +symlink-dialog-title = Create Symlinks +symlink-dialog-message = Replace { $count } selected file(s) with symlinks to: + { $reference }? +no-reference-title = No Reference +no-reference-message = Cannot determine reference file. Leave at least one file unchecked in the group. + +# ── EXIF dialog ────────────────────────────────────────── +exif-dialog-title = Clean EXIF +exif-dialog-message = Remove EXIF metadata from { $count } selected file(s)? + +# ── Video optimize dialog ──────────────────────────────── +video-optimize-title = Video Optimization +video-optimize-message = Video optimization for { $count } file(s) will be performed using czkawka_cli. Check the status bar for progress. + +# ── Warnings / errors ──────────────────────────────────── +no-directories-title = No Directories +no-directories-message = Please add at least one directory to scan in the bottom panel. +no-selection-title = No Selection +no-selection-delete = No files selected for deletion. +no-selection-move = No files selected. +no-results-title = No Results +no-results-save = No results to save. +no-destination-title = No Destination +no-destination-message = Please select a destination folder. +scan-error-title = Scan Error + +# ── Save/load dialogs ──────────────────────────────────── +save-dialog-title = Save Results +load-dialog-title = Load Results diff --git a/kalka/i18n/pl/kalka.ftl b/kalka/i18n/pl/kalka.ftl new file mode 100644 index 000000000..e46d12ca5 --- /dev/null +++ b/kalka/i18n/pl/kalka.ftl @@ -0,0 +1,338 @@ +# Kalka – Polish translations (Fluent format) +# Tłumaczenie polskie + +# ── Window & app ────────────────────────────────────────── +main-window-title = Kalka - Czyścioch Danych +about-title = O programie Kalka +about-app-name = Kalka +about-subtitle = Edycja PySide6 / Qt 6 +about-version = Wersja 11.0.1 +about-description = + Kalka to prosty, szybki i darmowy program do usuwania + zbędnych plikÃŗw z komputera. + + Ten interfejs PySide6/Qt korzysta z backendu czkawka_cli + do wszystkich operacji skanowania i na plikach. + + Funkcje: + - Znajdowanie duplikatÃŗw plikÃŗw (po hashu, nazwie lub rozmiarze) + - Znajdowanie pustych plikÃŗw i folderÃŗw + - Znajdowanie podobnych obrazÃŗw, filmÃŗw i muzyki + - Znajdowanie uszkodzonych plikÃŗw i nieprawidłowych dowiązań + - Znajdowanie plikÃŗw z błędnymi rozszerzeniami lub nazwami + - Usuwanie metadanych EXIF z obrazÃŗw + - Optymalizacja i przycinanie filmÃŗw + + Na licencji MIT + https://github.com/qarmin/czkawka +about-logo-tooltip = O programie Kalka +version-label = Kalka v11.0.1 + +# ── Status bar ──────────────────────────────────────────── +status-ready = Gotowy +status-tab = Karta: { $tab_name } +status-scanning = Skanowanie: { $tab_name }... +status-scan-complete = Skanowanie zakończone: znaleziono { $count } pozycji +status-scan-stopped = Skanowanie zatrzymane przez uÅŧytkownika +status-error = Błąd: { $message } +status-deleted = Usunięto { $count } plik(Ãŗw) +status-deleted-dry-run = [PRÓBA] Usunięto { $count } plik(Ãŗw) +status-moved = Przeniesiono { $count } plik(Ãŗw) +status-moved-dry-run = [PRÓBA] Przeniesiono { $count } plik(Ãŗw) +status-copied = Skopiowano { $count } plik(Ãŗw) +status-copied-dry-run = [PRÓBA] Skopiowano { $count } plik(Ãŗw) +status-hardlinks-created = Utworzono { $count } twardych dowiązań +status-symlinks-created = Utworzono { $count } dowiązań symbolicznych +status-exif-cleaned = Usunięto EXIF z { $count } plik(Ãŗw) +status-extensions-fixed = Rozszerzenia naprawione +status-names-fixed = Nazwy naprawione +status-results-saved = Wyniki zapisane pomyślnie +status-results-loaded = Wczytano { $count } pozycji z pliku +status-video-optimize = Optymalizacja wideo: uÅŧyj CLI bezpośrednio + +# ── Buttons ─────────────────────────────────────────────── +scan-button = Skanuj +stop-button = Zatrzymaj +select-button = Wybierz +delete-button = Usuń +move-button = Przenieś +save-button = Zapisz +load-button = Wczytaj +load-button-tooltip = Wczytaj wcześniej zapisane wyniki +sort-button = Sortuj +hardlink-button = Twarde dowiązanie +symlink-button = Dowiązanie sym. +rename-button = Zmień nazwę +clean-exif-button = Wyczyść EXIF +optimize-button = Optymalizuj + +# ── Tool names ──────────────────────────────────────────── +tool-duplicate-files = Zduplikowane pliki +tool-empty-folders = Puste foldery +tool-big-files = DuÅŧe pliki +tool-empty-files = Puste pliki +tool-temporary-files = Pliki tymczasowe +tool-similar-images = Podobne obrazy +tool-similar-videos = Podobne wideo +tool-similar-music = Podobna muzyka +tool-invalid-symlinks = Błędne dowiązania +tool-broken-files = Uszkodzone pliki +tool-bad-extensions = Nieprawidłowe rozszerzenia +tool-bad-names = Błędne nazwy +tool-exif-remover = Usuwanie EXIF +tool-video-optimizer = Optymalizator wideo + +# ── Column headers ──────────────────────────────────────── +column-selection = WybÃŗr +column-size = Rozmiar +column-file-name = Nazwa pliku +column-path = ŚcieÅŧka +column-modification-date = Data modyfikacji +column-hash = Hash +column-similarity = Podobieństwo +column-resolution = Rozdzielczość +column-title = Tytuł +column-artist = Artysta +column-year = Rok +column-bitrate = Przepływność +column-genre = Gatunek +column-length = Długość +column-folder-name = Nazwa folderu +column-symlink-name = Nazwa dowiązania +column-symlink-path = ŚcieÅŧka dowiązania +column-destination-path = ŚcieÅŧka docelowa +column-type-of-error = Typ błędu +column-error-type = Typ błędu +column-current-extension = Obecne rozszerzenie +column-proper-extension = Właściwe rozszerzenie +column-codec = Kodek + +# ── Left panel ──────────────────────────────────────────── +settings-tooltip = Ustawienia aplikacji +tool-settings-tooltip = Ustawienia narzędzia +fallback-app-name = Kalka + +# ── Settings panel ──────────────────────────────────────── +settings-title = Ustawienia +settings-close = Zamknij +settings-tab-general = OgÃŗlne +settings-tab-directories = Katalogi +settings-tab-filters = Filtry +settings-tab-preview = Podgląd +settings-window-title = Ustawienia Kalka + +# General settings +settings-cli-path = ŚcieÅŧka czkawka_cli: +settings-browse = Przeglądaj +settings-thread-count = Liczba wątkÃŗw: +settings-thread-auto = Automatycznie (wszystkie rdzenie) +settings-recursive = Wyszukiwanie rekurencyjne +settings-use-cache = UÅŧyj pamięci podręcznej +settings-move-to-trash = Przenieś do kosza zamiast trwałego usunięcia +settings-hide-hard-links = Ukryj twarde dowiązania +settings-save-as-json = Zapisz wyniki jako JSON (zamiast tekstu) +settings-select-cli-binary = Wybierz plik binarny czkawka_cli + +# Directories settings +settings-included-dirs = Wybrane katalogi +settings-excluded-dirs = Wykluczone katalogi +settings-add = Dodaj +settings-remove = Usuń +settings-select-dir-include = Wybierz katalog do uwzględnienia +settings-select-dir-exclude = Wybierz katalog do wykluczenia + +# Filters settings +settings-excluded-items = Wykluczone elementy: +settings-excluded-items-hint = Wzorce wildcard, rozdzielone przecinkami (np. *.tmp,cache_*) +settings-allowed-extensions = Dozwolone rozszerzenia: +settings-allowed-extensions-hint = np. jpg,png,gif +settings-excluded-extensions = Wykluczone rozszerzenia: +settings-excluded-extensions-hint = np. log,tmp +settings-min-file-size = Minimalny rozmiar pliku: +settings-min-file-size-hint = W bajtach (np. 1024) +settings-max-file-size = Maksymalny rozmiar pliku: +settings-max-file-size-hint = W bajtach (puste = bez limitu) + +# Preview settings +settings-show-image-preview = PokaÅŧ podgląd obrazu + +# ── Tool settings panel ────────────────────────────────── +tool-settings-title = Ustawienia narzędzia + +# Duplicate files +subsettings-check-method = Metoda sprawdzania: +subsettings-hash-type = Typ hasha: +subsettings-case-sensitive = Uwzględniaj wielkość liter + +# Similar images +subsettings-hash-size = Rozmiar hasha: +subsettings-resize-algorithm = Algorytm zmiany rozmiaru: +subsettings-image-hash-type = Typ hasha: +subsettings-ignore-same-size = Ignoruj ten sam rozmiar +subsettings-max-difference = Maksymalna rÃŗÅŧnica: + +# Similar videos +subsettings-crop-detect = Wykrywanie przycinania: +subsettings-skip-forward = Pomiń początek (s): +subsettings-hash-duration = Czas haszowania (s): + +# Similar music +subsettings-audio-check-type = Typ sprawdzania audio: +subsettings-tag-matching = Dopasowanie tagÃŗw +subsettings-approximate-comparison = PrzybliÅŧone porÃŗwnywanie +subsettings-fingerprint-matching = Dopasowanie odciskÃŗw +subsettings-compare-similar-titles = PorÃŗwnuj z podobnymi tytułami + +# Big files +subsettings-method = Metoda: +subsettings-the-biggest = Największe +subsettings-the-smallest = Najmniejsze +subsettings-number-of-files = Liczba plikÃŗw: + +# Broken files +subsettings-file-types = Typy plikÃŗw do sprawdzenia: + +# Bad names +subsettings-check-for = SprawdÅē: +subsettings-uppercase-ext = Wielkie litery w rozszerzeniu +subsettings-uppercase-ext-hint = Pliki z .JPG, .PNG itp. +subsettings-emoji = Emoji w nazwie +subsettings-emoji-hint = Pliki zawierające znaki emoji +subsettings-space = Spacja na początku/końcu +subsettings-space-hint = Spacje na początku lub końcu nazwy +subsettings-non-ascii = Znaki spoza ASCII +subsettings-non-ascii-hint = Znaki spoza zakresu ASCII +subsettings-remove-duplicated = PowtÃŗrzone znaki niealfanumeryczne +subsettings-remove-duplicated-hint = np. plik--nazwa..txt +subsettings-restricted-charset = Ograniczony zestaw znakÃŗw: +subsettings-restricted-charset-hint = Dozwolone znaki specjalne, oddzielone przecinkami + +# EXIF +subsettings-ignored-exif-tags = Ignorowane tagi EXIF: +subsettings-ignored-exif-tags-hint = Tagi do pominięcia, oddzielone przecinkami + +# Video optimizer +subsettings-mode = Tryb: +subsettings-crop-settings = Ustawienia przycinania +subsettings-crop-type = Typ przycinania: +subsettings-black-pixel-threshold = PrÃŗg czarnego piksela: +subsettings-black-bar-min-pct = Minimalny % czarnego paska: +subsettings-max-samples = Maksymalna liczba prÃŗbek: +subsettings-min-crop-size = Minimalny rozmiar przycięcia: +subsettings-transcode-settings = Ustawienia transkodowania +subsettings-excluded-codecs = Wykluczone kodeki: +subsettings-target-codec = Docelowy kodek: +subsettings-quality = Jakość: +subsettings-fail-if-bigger = Niepowodzenie jeśli większy + +# ── Progress widget ────────────────────────────────────── +progress-initializing = Inicjalizacja... +progress-starting = Rozpoczynanie skanowania... +progress-current = BieÅŧący +progress-overall = OgÃŗlny +progress-scan-complete = Skanowanie zakończone +progress-completed-in = Ukończono w { $time } +progress-done = gotowe + +# ── Results view ───────────────────────────────────────── +results-no-results = Brak wynikÃŗw +results-found-grouped = Znaleziono { $total } plikÃŗw ({ $size }) w { $groups } grupach +results-found-flat = Znaleziono { $total } pozycji ({ $size }) +results-selected = Wybrano: { $selected }/{ $total } ({ $selected_size }/{ $total_size }) + +# ── Preview panel ──────────────────────────────────────── +preview-title = Podgląd +preview-no-preview = Brak podglądu +preview-file-not-found = Nie znaleziono pliku +preview-not-available = Podgląd niedostępny + dla tego typu pliku +preview-cannot-load = Nie moÅŧna załadować obrazu + +# ── Context menu ───────────────────────────────────────── +context-open-file = OtwÃŗrz plik +context-open-folder = OtwÃŗrz folder +context-select = Zaznacz +context-deselect = Odznacz + +# ── Bottom panel ───────────────────────────────────────── +bottom-included-dirs = Wybrane katalogi: +bottom-excluded-dirs = Wykluczone katalogi: + +# ── Delete dialog ──────────────────────────────────────── +delete-dialog-title = Usuń pliki +delete-dialog-message = Czy na pewno chcesz usunąć { $count } wybranych plikÃŗw? +delete-dialog-trash = Przenieś do kosza zamiast trwałego usunięcia +delete-dialog-dry-run = PrÃŗba (tylko podgląd, pliki nie zostaną usunięte) +delete-dialog-confirm = Usuń + +# ── Move dialog ────────────────────────────────────────── +move-dialog-title = Przenieś/Kopiuj pliki +move-dialog-message = Przenieś lub skopiuj { $count } wybranych plikÃŗw do: +move-dialog-placeholder = Wybierz folder docelowy... +move-dialog-preserve = Zachowaj strukturę folderÃŗw +move-dialog-copy-mode = Kopiuj zamiast przenosić +move-dialog-dry-run = PrÃŗba (tylko podgląd, pliki nie zostaną przeniesione) +move-dialog-confirm = Przenieś +move-dialog-select-dest = Wybierz katalog docelowy + +# ── Rename dialog ──────────────────────────────────────── +rename-dialog-confirm = Zmień nazwę +rename-dialog-ext-message = Naprawić rozszerzenia { $count } wybranych plikÃŗw? + + Pliki zostaną przemianowane na właściwe rozszerzenia. +rename-dialog-names-message = Naprawić nazwy { $count } wybranych plikÃŗw? + + Pliki z problematycznymi nazwami zostaną przemianowane. + +# ── Select dialog ──────────────────────────────────────── +select-dialog-title = WybÃŗr wynikÃŗw +select-dialog-prompt = Wybierz tryb zaznaczania: +select-all = Zaznacz wszystko +unselect-all = Odznacz wszystko +invert-selection = OdwrÃŗÄ‡ zaznaczenie +select-biggest-size = Wybierz największe (wg rozmiaru) +select-smallest-size = Wybierz najmniejsze (wg rozmiaru) +select-newest = Wybierz najnowsze +select-oldest = Wybierz najstarsze +select-shortest-path = Wybierz najkrÃŗtszą ścieÅŧkę +select-longest-path = Wybierz najdłuÅŧszą ścieÅŧkę +cancel = Anuluj + +# ── Sort dialog ────────────────────────────────────────── +sort-dialog-title = Sortuj wyniki +sort-by = Sortuj wg: +sort-ascending = Rosnąco + +# ── Hardlink / symlink dialogs ─────────────────────────── +hardlink-dialog-title = UtwÃŗrz twarde dowiązania +hardlink-dialog-message = Zastąpić { $count } wybranych plikÃŗw twardymi dowiązaniami do: + { $reference }? +symlink-dialog-title = UtwÃŗrz dowiązania symboliczne +symlink-dialog-message = Zastąpić { $count } wybranych plikÃŗw dowiązaniami symbolicznymi do: + { $reference }? +no-reference-title = Brak referencji +no-reference-message = Nie moÅŧna określić pliku referencyjnego. Pozostaw co najmniej jeden plik odznaczony w grupie. + +# ── EXIF dialog ────────────────────────────────────────── +exif-dialog-title = Wyczyść EXIF +exif-dialog-message = Usunąć metadane EXIF z { $count } wybranych plikÃŗw? + +# ── Video optimize dialog ──────────────────────────────── +video-optimize-title = Optymalizacja wideo +video-optimize-message = Optymalizacja wideo dla { $count } plikÃŗw zostanie wykonana za pomocą czkawka_cli. SprawdÅē pasek stanu. + +# ── Warnings / errors ──────────────────────────────────── +no-directories-title = Brak katalogÃŗw +no-directories-message = Dodaj co najmniej jeden katalog do skanowania w dolnym panelu. +no-selection-title = Brak wyboru +no-selection-delete = Nie wybrano plikÃŗw do usunięcia. +no-selection-move = Nie wybrano plikÃŗw. +no-results-title = Brak wynikÃŗw +no-results-save = Brak wynikÃŗw do zapisania. +no-destination-title = Brak celu +no-destination-message = Wybierz folder docelowy. +scan-error-title = Błąd skanowania + +# ── Save/load dialogs ──────────────────────────────────── +save-dialog-title = Zapisz wyniki +load-dialog-title = Wczytaj wyniki diff --git a/kalka/i18n/pt-BR/kalka.ftl b/kalka/i18n/pt-BR/kalka.ftl new file mode 100644 index 000000000..fdbcd226c --- /dev/null +++ b/kalka/i18n/pt-BR/kalka.ftl @@ -0,0 +1,338 @@ +# Kalka – English translations (Fluent format) +# This file follows the same Fluent convention as krokiet/i18n/ + +# ── Window & app ────────────────────────────────────────── +main-window-title = Kalka - Data Cleaner +about-title = About Kalka +about-app-name = Kalka +about-subtitle = PySide6 / Qt 6 Edition +about-version = Version 11.0.1 +about-description = + Kalka is a simple, fast and free app to remove + unnecessary files from your computer. + + This PySide6/Qt interface uses the czkawka_cli backend + for all scanning and file operations. + + Features: + - Find duplicate files (by hash, name, or size) + - Find empty files and folders + - Find similar images, videos, and music + - Find broken files and invalid symlinks + - Find files with bad extensions or names + - Remove EXIF metadata from images + - Optimize and crop videos + + Licensed under MIT License + https://github.com/qarmin/czkawka +about-logo-tooltip = About Kalka +version-label = Kalka v11.0.1 + +# ── Status bar ──────────────────────────────────────────── +status-ready = Ready +status-tab = Tab: { $tab_name } +status-scanning = Scanning: { $tab_name }... +status-scan-complete = Scan complete: found { $count } entries +status-scan-stopped = Scan stopped by user +status-error = Error: { $message } +status-deleted = Deleted { $count } file(s) +status-deleted-dry-run = [DRY RUN] Deleted { $count } file(s) +status-moved = Moved { $count } file(s) +status-moved-dry-run = [DRY RUN] Moved { $count } file(s) +status-copied = Copied { $count } file(s) +status-copied-dry-run = [DRY RUN] Copied { $count } file(s) +status-hardlinks-created = Created { $count } hardlink(s) +status-symlinks-created = Created { $count } symlink(s) +status-exif-cleaned = Cleaned EXIF from { $count } file(s) +status-extensions-fixed = Extensions fixed +status-names-fixed = Names fixed +status-results-saved = Results saved successfully +status-results-loaded = Loaded { $count } entries from file +status-video-optimize = Video optimization: use CLI directly for this feature + +# ── Buttons ─────────────────────────────────────────────── +scan-button = Scan +stop-button = Stop +select-button = Select +delete-button = Delete +move-button = Move +save-button = Save +load-button = Load +load-button-tooltip = Load previously saved results +sort-button = Sort +hardlink-button = Hardlink +symlink-button = Symlink +rename-button = Rename +clean-exif-button = Clean EXIF +optimize-button = Optimize + +# ── Tool names ──────────────────────────────────────────── +tool-duplicate-files = Duplicate Files +tool-empty-folders = Empty Folders +tool-big-files = Big Files +tool-empty-files = Empty Files +tool-temporary-files = Temporary Files +tool-similar-images = Similar Images +tool-similar-videos = Similar Videos +tool-similar-music = Similar Music +tool-invalid-symlinks = Invalid Symlinks +tool-broken-files = Broken Files +tool-bad-extensions = Bad Extensions +tool-bad-names = Bad Names +tool-exif-remover = EXIF Remover +tool-video-optimizer = Video Optimizer + +# ── Column headers ──────────────────────────────────────── +column-selection = Selection +column-size = Size +column-file-name = File Name +column-path = Path +column-modification-date = Modification Date +column-hash = Hash +column-similarity = Similarity +column-resolution = Resolution +column-title = Title +column-artist = Artist +column-year = Year +column-bitrate = Bitrate +column-genre = Genre +column-length = Length +column-folder-name = Folder Name +column-symlink-name = Symlink Name +column-symlink-path = Symlink Path +column-destination-path = Destination Path +column-type-of-error = Type of Error +column-error-type = Error Type +column-current-extension = Current Extension +column-proper-extension = Proper Extension +column-codec = Codec + +# ── Left panel ──────────────────────────────────────────── +settings-tooltip = Application Settings +tool-settings-tooltip = Tool-specific Settings +fallback-app-name = Kalka + +# ── Settings panel ──────────────────────────────────────── +settings-title = Settings +settings-close = Close +settings-tab-general = General +settings-tab-directories = Directories +settings-tab-filters = Filters +settings-tab-preview = Preview +settings-window-title = Kalka Settings + +# General settings +settings-cli-path = czkawka_cli Path: +settings-browse = Browse +settings-thread-count = Thread Count: +settings-thread-auto = Auto (all cores) +settings-recursive = Recursive search +settings-use-cache = Use cache for faster rescans +settings-move-to-trash = Move to trash instead of permanent delete +settings-hide-hard-links = Hide hard links +settings-save-as-json = Save results as JSON (instead of text) +settings-select-cli-binary = Select czkawka_cli binary + +# Directories settings +settings-included-dirs = Included Directories +settings-excluded-dirs = Excluded Directories +settings-add = Add +settings-remove = Remove +settings-select-dir-include = Select Directory to Include +settings-select-dir-exclude = Select Directory to Exclude + +# Filters settings +settings-excluded-items = Excluded Items: +settings-excluded-items-hint = Wildcard patterns, comma-separated (e.g. *.tmp,cache_*) +settings-allowed-extensions = Allowed Extensions: +settings-allowed-extensions-hint = e.g. jpg,png,gif +settings-excluded-extensions = Excluded Extensions: +settings-excluded-extensions-hint = e.g. log,tmp +settings-min-file-size = Minimum File Size: +settings-min-file-size-hint = In bytes (e.g. 1024) +settings-max-file-size = Maximum File Size: +settings-max-file-size-hint = In bytes (leave empty for no limit) + +# Preview settings +settings-show-image-preview = Show image preview + +# ── Tool settings panel ────────────────────────────────── +tool-settings-title = Tool Settings + +# Duplicate files +subsettings-check-method = Check Method: +subsettings-hash-type = Hash Type: +subsettings-case-sensitive = Case sensitive name comparison + +# Similar images +subsettings-hash-size = Hash Size: +subsettings-resize-algorithm = Resize Algorithm: +subsettings-image-hash-type = Hash Type: +subsettings-ignore-same-size = Ignore same size +subsettings-max-difference = Max Difference: + +# Similar videos +subsettings-crop-detect = Crop Detect: +subsettings-skip-forward = Skip Forward (s): +subsettings-hash-duration = Hash Duration (s): + +# Similar music +subsettings-audio-check-type = Audio Check Type: +subsettings-tag-matching = Tag Matching +subsettings-approximate-comparison = Approximate comparison +subsettings-fingerprint-matching = Fingerprint Matching +subsettings-compare-similar-titles = Compare with similar titles + +# Big files +subsettings-method = Method: +subsettings-the-biggest = The Biggest +subsettings-the-smallest = The Smallest +subsettings-number-of-files = Number of Files: + +# Broken files +subsettings-file-types = File types to check: + +# Bad names +subsettings-check-for = Check for: +subsettings-uppercase-ext = Uppercase extension +subsettings-uppercase-ext-hint = Files with .JPG, .PNG etc. +subsettings-emoji = Emoji in name +subsettings-emoji-hint = Files containing emoji characters +subsettings-space = Space at start/end +subsettings-space-hint = Leading or trailing whitespace +subsettings-non-ascii = Non-ASCII characters +subsettings-non-ascii-hint = Characters outside ASCII range +subsettings-remove-duplicated = Remove duplicated non-alphanumeric +subsettings-remove-duplicated-hint = e.g. file--name..txt +subsettings-restricted-charset = Restricted charset: +subsettings-restricted-charset-hint = Allowed special chars, comma-separated + +# EXIF +subsettings-ignored-exif-tags = Ignored EXIF Tags: +subsettings-ignored-exif-tags-hint = Tags to ignore, comma-separated + +# Video optimizer +subsettings-mode = Mode: +subsettings-crop-settings = Crop Settings +subsettings-crop-type = Crop Type: +subsettings-black-pixel-threshold = Black Pixel Threshold: +subsettings-black-bar-min-pct = Black Bar Min %: +subsettings-max-samples = Max Samples: +subsettings-min-crop-size = Min Crop Size: +subsettings-transcode-settings = Transcode Settings +subsettings-excluded-codecs = Excluded Codecs: +subsettings-target-codec = Target Codec: +subsettings-quality = Quality: +subsettings-fail-if-bigger = Fail if not smaller + +# ── Progress widget ────────────────────────────────────── +progress-initializing = Initializing... +progress-starting = Starting scan... +progress-current = Current +progress-overall = Overall +progress-scan-complete = Scan complete +progress-completed-in = Completed in { $time } +progress-done = done + +# ── Results view ───────────────────────────────────────── +results-no-results = No results +results-found-grouped = Found { $total } files ({ $size }) in { $groups } groups +results-found-flat = Found { $total } entries ({ $size }) +results-selected = Selected: { $selected }/{ $total } ({ $selected_size }/{ $total_size }) + +# ── Preview panel ──────────────────────────────────────── +preview-title = Preview +preview-no-preview = No preview +preview-file-not-found = File not found +preview-not-available = Preview not available + for this file type +preview-cannot-load = Cannot load image + +# ── Context menu ───────────────────────────────────────── +context-open-file = Open File +context-open-folder = Open Containing Folder +context-select = Select +context-deselect = Deselect + +# ── Bottom panel ───────────────────────────────────────── +bottom-included-dirs = Included Directories: +bottom-excluded-dirs = Excluded Directories: + +# ── Delete dialog ──────────────────────────────────────── +delete-dialog-title = Delete Files +delete-dialog-message = Are you sure you want to delete { $count } selected file(s)? +delete-dialog-trash = Move to trash instead of permanent delete +delete-dialog-dry-run = Dry run (preview only, no files will be deleted) +delete-dialog-confirm = Delete + +# ── Move dialog ────────────────────────────────────────── +move-dialog-title = Move/Copy Files +move-dialog-message = Move or copy { $count } selected file(s) to: +move-dialog-placeholder = Select destination folder... +move-dialog-preserve = Preserve folder structure +move-dialog-copy-mode = Copy instead of move +move-dialog-dry-run = Dry run (preview only, no files will be moved) +move-dialog-confirm = Move +move-dialog-select-dest = Select Destination + +# ── Rename dialog ──────────────────────────────────────── +rename-dialog-confirm = Rename +rename-dialog-ext-message = Fix extensions for { $count } selected file(s)? + + Files will be renamed to use their proper extensions. +rename-dialog-names-message = Fix names for { $count } selected file(s)? + + Files with problematic names will be renamed. + +# ── Select dialog ──────────────────────────────────────── +select-dialog-title = Select Results +select-dialog-prompt = Choose selection mode: +select-all = Select All +unselect-all = Unselect All +invert-selection = Invert Selection +select-biggest-size = Select Biggest (by Size) +select-smallest-size = Select Smallest (by Size) +select-newest = Select Newest +select-oldest = Select Oldest +select-shortest-path = Select Shortest Path +select-longest-path = Select Longest Path +cancel = Cancel + +# ── Sort dialog ────────────────────────────────────────── +sort-dialog-title = Sort Results +sort-by = Sort by: +sort-ascending = Ascending + +# ── Hardlink / symlink dialogs ─────────────────────────── +hardlink-dialog-title = Create Hardlinks +hardlink-dialog-message = Replace { $count } selected file(s) with hardlinks to: + { $reference }? +symlink-dialog-title = Create Symlinks +symlink-dialog-message = Replace { $count } selected file(s) with symlinks to: + { $reference }? +no-reference-title = No Reference +no-reference-message = Cannot determine reference file. Leave at least one file unchecked in the group. + +# ── EXIF dialog ────────────────────────────────────────── +exif-dialog-title = Clean EXIF +exif-dialog-message = Remove EXIF metadata from { $count } selected file(s)? + +# ── Video optimize dialog ──────────────────────────────── +video-optimize-title = Video Optimization +video-optimize-message = Video optimization for { $count } file(s) will be performed using czkawka_cli. Check the status bar for progress. + +# ── Warnings / errors ──────────────────────────────────── +no-directories-title = No Directories +no-directories-message = Please add at least one directory to scan in the bottom panel. +no-selection-title = No Selection +no-selection-delete = No files selected for deletion. +no-selection-move = No files selected. +no-results-title = No Results +no-results-save = No results to save. +no-destination-title = No Destination +no-destination-message = Please select a destination folder. +scan-error-title = Scan Error + +# ── Save/load dialogs ──────────────────────────────────── +save-dialog-title = Save Results +load-dialog-title = Load Results diff --git a/kalka/i18n/pt-PT/kalka.ftl b/kalka/i18n/pt-PT/kalka.ftl new file mode 100644 index 000000000..fdbcd226c --- /dev/null +++ b/kalka/i18n/pt-PT/kalka.ftl @@ -0,0 +1,338 @@ +# Kalka – English translations (Fluent format) +# This file follows the same Fluent convention as krokiet/i18n/ + +# ── Window & app ────────────────────────────────────────── +main-window-title = Kalka - Data Cleaner +about-title = About Kalka +about-app-name = Kalka +about-subtitle = PySide6 / Qt 6 Edition +about-version = Version 11.0.1 +about-description = + Kalka is a simple, fast and free app to remove + unnecessary files from your computer. + + This PySide6/Qt interface uses the czkawka_cli backend + for all scanning and file operations. + + Features: + - Find duplicate files (by hash, name, or size) + - Find empty files and folders + - Find similar images, videos, and music + - Find broken files and invalid symlinks + - Find files with bad extensions or names + - Remove EXIF metadata from images + - Optimize and crop videos + + Licensed under MIT License + https://github.com/qarmin/czkawka +about-logo-tooltip = About Kalka +version-label = Kalka v11.0.1 + +# ── Status bar ──────────────────────────────────────────── +status-ready = Ready +status-tab = Tab: { $tab_name } +status-scanning = Scanning: { $tab_name }... +status-scan-complete = Scan complete: found { $count } entries +status-scan-stopped = Scan stopped by user +status-error = Error: { $message } +status-deleted = Deleted { $count } file(s) +status-deleted-dry-run = [DRY RUN] Deleted { $count } file(s) +status-moved = Moved { $count } file(s) +status-moved-dry-run = [DRY RUN] Moved { $count } file(s) +status-copied = Copied { $count } file(s) +status-copied-dry-run = [DRY RUN] Copied { $count } file(s) +status-hardlinks-created = Created { $count } hardlink(s) +status-symlinks-created = Created { $count } symlink(s) +status-exif-cleaned = Cleaned EXIF from { $count } file(s) +status-extensions-fixed = Extensions fixed +status-names-fixed = Names fixed +status-results-saved = Results saved successfully +status-results-loaded = Loaded { $count } entries from file +status-video-optimize = Video optimization: use CLI directly for this feature + +# ── Buttons ─────────────────────────────────────────────── +scan-button = Scan +stop-button = Stop +select-button = Select +delete-button = Delete +move-button = Move +save-button = Save +load-button = Load +load-button-tooltip = Load previously saved results +sort-button = Sort +hardlink-button = Hardlink +symlink-button = Symlink +rename-button = Rename +clean-exif-button = Clean EXIF +optimize-button = Optimize + +# ── Tool names ──────────────────────────────────────────── +tool-duplicate-files = Duplicate Files +tool-empty-folders = Empty Folders +tool-big-files = Big Files +tool-empty-files = Empty Files +tool-temporary-files = Temporary Files +tool-similar-images = Similar Images +tool-similar-videos = Similar Videos +tool-similar-music = Similar Music +tool-invalid-symlinks = Invalid Symlinks +tool-broken-files = Broken Files +tool-bad-extensions = Bad Extensions +tool-bad-names = Bad Names +tool-exif-remover = EXIF Remover +tool-video-optimizer = Video Optimizer + +# ── Column headers ──────────────────────────────────────── +column-selection = Selection +column-size = Size +column-file-name = File Name +column-path = Path +column-modification-date = Modification Date +column-hash = Hash +column-similarity = Similarity +column-resolution = Resolution +column-title = Title +column-artist = Artist +column-year = Year +column-bitrate = Bitrate +column-genre = Genre +column-length = Length +column-folder-name = Folder Name +column-symlink-name = Symlink Name +column-symlink-path = Symlink Path +column-destination-path = Destination Path +column-type-of-error = Type of Error +column-error-type = Error Type +column-current-extension = Current Extension +column-proper-extension = Proper Extension +column-codec = Codec + +# ── Left panel ──────────────────────────────────────────── +settings-tooltip = Application Settings +tool-settings-tooltip = Tool-specific Settings +fallback-app-name = Kalka + +# ── Settings panel ──────────────────────────────────────── +settings-title = Settings +settings-close = Close +settings-tab-general = General +settings-tab-directories = Directories +settings-tab-filters = Filters +settings-tab-preview = Preview +settings-window-title = Kalka Settings + +# General settings +settings-cli-path = czkawka_cli Path: +settings-browse = Browse +settings-thread-count = Thread Count: +settings-thread-auto = Auto (all cores) +settings-recursive = Recursive search +settings-use-cache = Use cache for faster rescans +settings-move-to-trash = Move to trash instead of permanent delete +settings-hide-hard-links = Hide hard links +settings-save-as-json = Save results as JSON (instead of text) +settings-select-cli-binary = Select czkawka_cli binary + +# Directories settings +settings-included-dirs = Included Directories +settings-excluded-dirs = Excluded Directories +settings-add = Add +settings-remove = Remove +settings-select-dir-include = Select Directory to Include +settings-select-dir-exclude = Select Directory to Exclude + +# Filters settings +settings-excluded-items = Excluded Items: +settings-excluded-items-hint = Wildcard patterns, comma-separated (e.g. *.tmp,cache_*) +settings-allowed-extensions = Allowed Extensions: +settings-allowed-extensions-hint = e.g. jpg,png,gif +settings-excluded-extensions = Excluded Extensions: +settings-excluded-extensions-hint = e.g. log,tmp +settings-min-file-size = Minimum File Size: +settings-min-file-size-hint = In bytes (e.g. 1024) +settings-max-file-size = Maximum File Size: +settings-max-file-size-hint = In bytes (leave empty for no limit) + +# Preview settings +settings-show-image-preview = Show image preview + +# ── Tool settings panel ────────────────────────────────── +tool-settings-title = Tool Settings + +# Duplicate files +subsettings-check-method = Check Method: +subsettings-hash-type = Hash Type: +subsettings-case-sensitive = Case sensitive name comparison + +# Similar images +subsettings-hash-size = Hash Size: +subsettings-resize-algorithm = Resize Algorithm: +subsettings-image-hash-type = Hash Type: +subsettings-ignore-same-size = Ignore same size +subsettings-max-difference = Max Difference: + +# Similar videos +subsettings-crop-detect = Crop Detect: +subsettings-skip-forward = Skip Forward (s): +subsettings-hash-duration = Hash Duration (s): + +# Similar music +subsettings-audio-check-type = Audio Check Type: +subsettings-tag-matching = Tag Matching +subsettings-approximate-comparison = Approximate comparison +subsettings-fingerprint-matching = Fingerprint Matching +subsettings-compare-similar-titles = Compare with similar titles + +# Big files +subsettings-method = Method: +subsettings-the-biggest = The Biggest +subsettings-the-smallest = The Smallest +subsettings-number-of-files = Number of Files: + +# Broken files +subsettings-file-types = File types to check: + +# Bad names +subsettings-check-for = Check for: +subsettings-uppercase-ext = Uppercase extension +subsettings-uppercase-ext-hint = Files with .JPG, .PNG etc. +subsettings-emoji = Emoji in name +subsettings-emoji-hint = Files containing emoji characters +subsettings-space = Space at start/end +subsettings-space-hint = Leading or trailing whitespace +subsettings-non-ascii = Non-ASCII characters +subsettings-non-ascii-hint = Characters outside ASCII range +subsettings-remove-duplicated = Remove duplicated non-alphanumeric +subsettings-remove-duplicated-hint = e.g. file--name..txt +subsettings-restricted-charset = Restricted charset: +subsettings-restricted-charset-hint = Allowed special chars, comma-separated + +# EXIF +subsettings-ignored-exif-tags = Ignored EXIF Tags: +subsettings-ignored-exif-tags-hint = Tags to ignore, comma-separated + +# Video optimizer +subsettings-mode = Mode: +subsettings-crop-settings = Crop Settings +subsettings-crop-type = Crop Type: +subsettings-black-pixel-threshold = Black Pixel Threshold: +subsettings-black-bar-min-pct = Black Bar Min %: +subsettings-max-samples = Max Samples: +subsettings-min-crop-size = Min Crop Size: +subsettings-transcode-settings = Transcode Settings +subsettings-excluded-codecs = Excluded Codecs: +subsettings-target-codec = Target Codec: +subsettings-quality = Quality: +subsettings-fail-if-bigger = Fail if not smaller + +# ── Progress widget ────────────────────────────────────── +progress-initializing = Initializing... +progress-starting = Starting scan... +progress-current = Current +progress-overall = Overall +progress-scan-complete = Scan complete +progress-completed-in = Completed in { $time } +progress-done = done + +# ── Results view ───────────────────────────────────────── +results-no-results = No results +results-found-grouped = Found { $total } files ({ $size }) in { $groups } groups +results-found-flat = Found { $total } entries ({ $size }) +results-selected = Selected: { $selected }/{ $total } ({ $selected_size }/{ $total_size }) + +# ── Preview panel ──────────────────────────────────────── +preview-title = Preview +preview-no-preview = No preview +preview-file-not-found = File not found +preview-not-available = Preview not available + for this file type +preview-cannot-load = Cannot load image + +# ── Context menu ───────────────────────────────────────── +context-open-file = Open File +context-open-folder = Open Containing Folder +context-select = Select +context-deselect = Deselect + +# ── Bottom panel ───────────────────────────────────────── +bottom-included-dirs = Included Directories: +bottom-excluded-dirs = Excluded Directories: + +# ── Delete dialog ──────────────────────────────────────── +delete-dialog-title = Delete Files +delete-dialog-message = Are you sure you want to delete { $count } selected file(s)? +delete-dialog-trash = Move to trash instead of permanent delete +delete-dialog-dry-run = Dry run (preview only, no files will be deleted) +delete-dialog-confirm = Delete + +# ── Move dialog ────────────────────────────────────────── +move-dialog-title = Move/Copy Files +move-dialog-message = Move or copy { $count } selected file(s) to: +move-dialog-placeholder = Select destination folder... +move-dialog-preserve = Preserve folder structure +move-dialog-copy-mode = Copy instead of move +move-dialog-dry-run = Dry run (preview only, no files will be moved) +move-dialog-confirm = Move +move-dialog-select-dest = Select Destination + +# ── Rename dialog ──────────────────────────────────────── +rename-dialog-confirm = Rename +rename-dialog-ext-message = Fix extensions for { $count } selected file(s)? + + Files will be renamed to use their proper extensions. +rename-dialog-names-message = Fix names for { $count } selected file(s)? + + Files with problematic names will be renamed. + +# ── Select dialog ──────────────────────────────────────── +select-dialog-title = Select Results +select-dialog-prompt = Choose selection mode: +select-all = Select All +unselect-all = Unselect All +invert-selection = Invert Selection +select-biggest-size = Select Biggest (by Size) +select-smallest-size = Select Smallest (by Size) +select-newest = Select Newest +select-oldest = Select Oldest +select-shortest-path = Select Shortest Path +select-longest-path = Select Longest Path +cancel = Cancel + +# ── Sort dialog ────────────────────────────────────────── +sort-dialog-title = Sort Results +sort-by = Sort by: +sort-ascending = Ascending + +# ── Hardlink / symlink dialogs ─────────────────────────── +hardlink-dialog-title = Create Hardlinks +hardlink-dialog-message = Replace { $count } selected file(s) with hardlinks to: + { $reference }? +symlink-dialog-title = Create Symlinks +symlink-dialog-message = Replace { $count } selected file(s) with symlinks to: + { $reference }? +no-reference-title = No Reference +no-reference-message = Cannot determine reference file. Leave at least one file unchecked in the group. + +# ── EXIF dialog ────────────────────────────────────────── +exif-dialog-title = Clean EXIF +exif-dialog-message = Remove EXIF metadata from { $count } selected file(s)? + +# ── Video optimize dialog ──────────────────────────────── +video-optimize-title = Video Optimization +video-optimize-message = Video optimization for { $count } file(s) will be performed using czkawka_cli. Check the status bar for progress. + +# ── Warnings / errors ──────────────────────────────────── +no-directories-title = No Directories +no-directories-message = Please add at least one directory to scan in the bottom panel. +no-selection-title = No Selection +no-selection-delete = No files selected for deletion. +no-selection-move = No files selected. +no-results-title = No Results +no-results-save = No results to save. +no-destination-title = No Destination +no-destination-message = Please select a destination folder. +scan-error-title = Scan Error + +# ── Save/load dialogs ──────────────────────────────────── +save-dialog-title = Save Results +load-dialog-title = Load Results diff --git a/kalka/i18n/ro/kalka.ftl b/kalka/i18n/ro/kalka.ftl new file mode 100644 index 000000000..fdbcd226c --- /dev/null +++ b/kalka/i18n/ro/kalka.ftl @@ -0,0 +1,338 @@ +# Kalka – English translations (Fluent format) +# This file follows the same Fluent convention as krokiet/i18n/ + +# ── Window & app ────────────────────────────────────────── +main-window-title = Kalka - Data Cleaner +about-title = About Kalka +about-app-name = Kalka +about-subtitle = PySide6 / Qt 6 Edition +about-version = Version 11.0.1 +about-description = + Kalka is a simple, fast and free app to remove + unnecessary files from your computer. + + This PySide6/Qt interface uses the czkawka_cli backend + for all scanning and file operations. + + Features: + - Find duplicate files (by hash, name, or size) + - Find empty files and folders + - Find similar images, videos, and music + - Find broken files and invalid symlinks + - Find files with bad extensions or names + - Remove EXIF metadata from images + - Optimize and crop videos + + Licensed under MIT License + https://github.com/qarmin/czkawka +about-logo-tooltip = About Kalka +version-label = Kalka v11.0.1 + +# ── Status bar ──────────────────────────────────────────── +status-ready = Ready +status-tab = Tab: { $tab_name } +status-scanning = Scanning: { $tab_name }... +status-scan-complete = Scan complete: found { $count } entries +status-scan-stopped = Scan stopped by user +status-error = Error: { $message } +status-deleted = Deleted { $count } file(s) +status-deleted-dry-run = [DRY RUN] Deleted { $count } file(s) +status-moved = Moved { $count } file(s) +status-moved-dry-run = [DRY RUN] Moved { $count } file(s) +status-copied = Copied { $count } file(s) +status-copied-dry-run = [DRY RUN] Copied { $count } file(s) +status-hardlinks-created = Created { $count } hardlink(s) +status-symlinks-created = Created { $count } symlink(s) +status-exif-cleaned = Cleaned EXIF from { $count } file(s) +status-extensions-fixed = Extensions fixed +status-names-fixed = Names fixed +status-results-saved = Results saved successfully +status-results-loaded = Loaded { $count } entries from file +status-video-optimize = Video optimization: use CLI directly for this feature + +# ── Buttons ─────────────────────────────────────────────── +scan-button = Scan +stop-button = Stop +select-button = Select +delete-button = Delete +move-button = Move +save-button = Save +load-button = Load +load-button-tooltip = Load previously saved results +sort-button = Sort +hardlink-button = Hardlink +symlink-button = Symlink +rename-button = Rename +clean-exif-button = Clean EXIF +optimize-button = Optimize + +# ── Tool names ──────────────────────────────────────────── +tool-duplicate-files = Duplicate Files +tool-empty-folders = Empty Folders +tool-big-files = Big Files +tool-empty-files = Empty Files +tool-temporary-files = Temporary Files +tool-similar-images = Similar Images +tool-similar-videos = Similar Videos +tool-similar-music = Similar Music +tool-invalid-symlinks = Invalid Symlinks +tool-broken-files = Broken Files +tool-bad-extensions = Bad Extensions +tool-bad-names = Bad Names +tool-exif-remover = EXIF Remover +tool-video-optimizer = Video Optimizer + +# ── Column headers ──────────────────────────────────────── +column-selection = Selection +column-size = Size +column-file-name = File Name +column-path = Path +column-modification-date = Modification Date +column-hash = Hash +column-similarity = Similarity +column-resolution = Resolution +column-title = Title +column-artist = Artist +column-year = Year +column-bitrate = Bitrate +column-genre = Genre +column-length = Length +column-folder-name = Folder Name +column-symlink-name = Symlink Name +column-symlink-path = Symlink Path +column-destination-path = Destination Path +column-type-of-error = Type of Error +column-error-type = Error Type +column-current-extension = Current Extension +column-proper-extension = Proper Extension +column-codec = Codec + +# ── Left panel ──────────────────────────────────────────── +settings-tooltip = Application Settings +tool-settings-tooltip = Tool-specific Settings +fallback-app-name = Kalka + +# ── Settings panel ──────────────────────────────────────── +settings-title = Settings +settings-close = Close +settings-tab-general = General +settings-tab-directories = Directories +settings-tab-filters = Filters +settings-tab-preview = Preview +settings-window-title = Kalka Settings + +# General settings +settings-cli-path = czkawka_cli Path: +settings-browse = Browse +settings-thread-count = Thread Count: +settings-thread-auto = Auto (all cores) +settings-recursive = Recursive search +settings-use-cache = Use cache for faster rescans +settings-move-to-trash = Move to trash instead of permanent delete +settings-hide-hard-links = Hide hard links +settings-save-as-json = Save results as JSON (instead of text) +settings-select-cli-binary = Select czkawka_cli binary + +# Directories settings +settings-included-dirs = Included Directories +settings-excluded-dirs = Excluded Directories +settings-add = Add +settings-remove = Remove +settings-select-dir-include = Select Directory to Include +settings-select-dir-exclude = Select Directory to Exclude + +# Filters settings +settings-excluded-items = Excluded Items: +settings-excluded-items-hint = Wildcard patterns, comma-separated (e.g. *.tmp,cache_*) +settings-allowed-extensions = Allowed Extensions: +settings-allowed-extensions-hint = e.g. jpg,png,gif +settings-excluded-extensions = Excluded Extensions: +settings-excluded-extensions-hint = e.g. log,tmp +settings-min-file-size = Minimum File Size: +settings-min-file-size-hint = In bytes (e.g. 1024) +settings-max-file-size = Maximum File Size: +settings-max-file-size-hint = In bytes (leave empty for no limit) + +# Preview settings +settings-show-image-preview = Show image preview + +# ── Tool settings panel ────────────────────────────────── +tool-settings-title = Tool Settings + +# Duplicate files +subsettings-check-method = Check Method: +subsettings-hash-type = Hash Type: +subsettings-case-sensitive = Case sensitive name comparison + +# Similar images +subsettings-hash-size = Hash Size: +subsettings-resize-algorithm = Resize Algorithm: +subsettings-image-hash-type = Hash Type: +subsettings-ignore-same-size = Ignore same size +subsettings-max-difference = Max Difference: + +# Similar videos +subsettings-crop-detect = Crop Detect: +subsettings-skip-forward = Skip Forward (s): +subsettings-hash-duration = Hash Duration (s): + +# Similar music +subsettings-audio-check-type = Audio Check Type: +subsettings-tag-matching = Tag Matching +subsettings-approximate-comparison = Approximate comparison +subsettings-fingerprint-matching = Fingerprint Matching +subsettings-compare-similar-titles = Compare with similar titles + +# Big files +subsettings-method = Method: +subsettings-the-biggest = The Biggest +subsettings-the-smallest = The Smallest +subsettings-number-of-files = Number of Files: + +# Broken files +subsettings-file-types = File types to check: + +# Bad names +subsettings-check-for = Check for: +subsettings-uppercase-ext = Uppercase extension +subsettings-uppercase-ext-hint = Files with .JPG, .PNG etc. +subsettings-emoji = Emoji in name +subsettings-emoji-hint = Files containing emoji characters +subsettings-space = Space at start/end +subsettings-space-hint = Leading or trailing whitespace +subsettings-non-ascii = Non-ASCII characters +subsettings-non-ascii-hint = Characters outside ASCII range +subsettings-remove-duplicated = Remove duplicated non-alphanumeric +subsettings-remove-duplicated-hint = e.g. file--name..txt +subsettings-restricted-charset = Restricted charset: +subsettings-restricted-charset-hint = Allowed special chars, comma-separated + +# EXIF +subsettings-ignored-exif-tags = Ignored EXIF Tags: +subsettings-ignored-exif-tags-hint = Tags to ignore, comma-separated + +# Video optimizer +subsettings-mode = Mode: +subsettings-crop-settings = Crop Settings +subsettings-crop-type = Crop Type: +subsettings-black-pixel-threshold = Black Pixel Threshold: +subsettings-black-bar-min-pct = Black Bar Min %: +subsettings-max-samples = Max Samples: +subsettings-min-crop-size = Min Crop Size: +subsettings-transcode-settings = Transcode Settings +subsettings-excluded-codecs = Excluded Codecs: +subsettings-target-codec = Target Codec: +subsettings-quality = Quality: +subsettings-fail-if-bigger = Fail if not smaller + +# ── Progress widget ────────────────────────────────────── +progress-initializing = Initializing... +progress-starting = Starting scan... +progress-current = Current +progress-overall = Overall +progress-scan-complete = Scan complete +progress-completed-in = Completed in { $time } +progress-done = done + +# ── Results view ───────────────────────────────────────── +results-no-results = No results +results-found-grouped = Found { $total } files ({ $size }) in { $groups } groups +results-found-flat = Found { $total } entries ({ $size }) +results-selected = Selected: { $selected }/{ $total } ({ $selected_size }/{ $total_size }) + +# ── Preview panel ──────────────────────────────────────── +preview-title = Preview +preview-no-preview = No preview +preview-file-not-found = File not found +preview-not-available = Preview not available + for this file type +preview-cannot-load = Cannot load image + +# ── Context menu ───────────────────────────────────────── +context-open-file = Open File +context-open-folder = Open Containing Folder +context-select = Select +context-deselect = Deselect + +# ── Bottom panel ───────────────────────────────────────── +bottom-included-dirs = Included Directories: +bottom-excluded-dirs = Excluded Directories: + +# ── Delete dialog ──────────────────────────────────────── +delete-dialog-title = Delete Files +delete-dialog-message = Are you sure you want to delete { $count } selected file(s)? +delete-dialog-trash = Move to trash instead of permanent delete +delete-dialog-dry-run = Dry run (preview only, no files will be deleted) +delete-dialog-confirm = Delete + +# ── Move dialog ────────────────────────────────────────── +move-dialog-title = Move/Copy Files +move-dialog-message = Move or copy { $count } selected file(s) to: +move-dialog-placeholder = Select destination folder... +move-dialog-preserve = Preserve folder structure +move-dialog-copy-mode = Copy instead of move +move-dialog-dry-run = Dry run (preview only, no files will be moved) +move-dialog-confirm = Move +move-dialog-select-dest = Select Destination + +# ── Rename dialog ──────────────────────────────────────── +rename-dialog-confirm = Rename +rename-dialog-ext-message = Fix extensions for { $count } selected file(s)? + + Files will be renamed to use their proper extensions. +rename-dialog-names-message = Fix names for { $count } selected file(s)? + + Files with problematic names will be renamed. + +# ── Select dialog ──────────────────────────────────────── +select-dialog-title = Select Results +select-dialog-prompt = Choose selection mode: +select-all = Select All +unselect-all = Unselect All +invert-selection = Invert Selection +select-biggest-size = Select Biggest (by Size) +select-smallest-size = Select Smallest (by Size) +select-newest = Select Newest +select-oldest = Select Oldest +select-shortest-path = Select Shortest Path +select-longest-path = Select Longest Path +cancel = Cancel + +# ── Sort dialog ────────────────────────────────────────── +sort-dialog-title = Sort Results +sort-by = Sort by: +sort-ascending = Ascending + +# ── Hardlink / symlink dialogs ─────────────────────────── +hardlink-dialog-title = Create Hardlinks +hardlink-dialog-message = Replace { $count } selected file(s) with hardlinks to: + { $reference }? +symlink-dialog-title = Create Symlinks +symlink-dialog-message = Replace { $count } selected file(s) with symlinks to: + { $reference }? +no-reference-title = No Reference +no-reference-message = Cannot determine reference file. Leave at least one file unchecked in the group. + +# ── EXIF dialog ────────────────────────────────────────── +exif-dialog-title = Clean EXIF +exif-dialog-message = Remove EXIF metadata from { $count } selected file(s)? + +# ── Video optimize dialog ──────────────────────────────── +video-optimize-title = Video Optimization +video-optimize-message = Video optimization for { $count } file(s) will be performed using czkawka_cli. Check the status bar for progress. + +# ── Warnings / errors ──────────────────────────────────── +no-directories-title = No Directories +no-directories-message = Please add at least one directory to scan in the bottom panel. +no-selection-title = No Selection +no-selection-delete = No files selected for deletion. +no-selection-move = No files selected. +no-results-title = No Results +no-results-save = No results to save. +no-destination-title = No Destination +no-destination-message = Please select a destination folder. +scan-error-title = Scan Error + +# ── Save/load dialogs ──────────────────────────────────── +save-dialog-title = Save Results +load-dialog-title = Load Results diff --git a/kalka/i18n/ru/kalka.ftl b/kalka/i18n/ru/kalka.ftl new file mode 100644 index 000000000..fdbcd226c --- /dev/null +++ b/kalka/i18n/ru/kalka.ftl @@ -0,0 +1,338 @@ +# Kalka – English translations (Fluent format) +# This file follows the same Fluent convention as krokiet/i18n/ + +# ── Window & app ────────────────────────────────────────── +main-window-title = Kalka - Data Cleaner +about-title = About Kalka +about-app-name = Kalka +about-subtitle = PySide6 / Qt 6 Edition +about-version = Version 11.0.1 +about-description = + Kalka is a simple, fast and free app to remove + unnecessary files from your computer. + + This PySide6/Qt interface uses the czkawka_cli backend + for all scanning and file operations. + + Features: + - Find duplicate files (by hash, name, or size) + - Find empty files and folders + - Find similar images, videos, and music + - Find broken files and invalid symlinks + - Find files with bad extensions or names + - Remove EXIF metadata from images + - Optimize and crop videos + + Licensed under MIT License + https://github.com/qarmin/czkawka +about-logo-tooltip = About Kalka +version-label = Kalka v11.0.1 + +# ── Status bar ──────────────────────────────────────────── +status-ready = Ready +status-tab = Tab: { $tab_name } +status-scanning = Scanning: { $tab_name }... +status-scan-complete = Scan complete: found { $count } entries +status-scan-stopped = Scan stopped by user +status-error = Error: { $message } +status-deleted = Deleted { $count } file(s) +status-deleted-dry-run = [DRY RUN] Deleted { $count } file(s) +status-moved = Moved { $count } file(s) +status-moved-dry-run = [DRY RUN] Moved { $count } file(s) +status-copied = Copied { $count } file(s) +status-copied-dry-run = [DRY RUN] Copied { $count } file(s) +status-hardlinks-created = Created { $count } hardlink(s) +status-symlinks-created = Created { $count } symlink(s) +status-exif-cleaned = Cleaned EXIF from { $count } file(s) +status-extensions-fixed = Extensions fixed +status-names-fixed = Names fixed +status-results-saved = Results saved successfully +status-results-loaded = Loaded { $count } entries from file +status-video-optimize = Video optimization: use CLI directly for this feature + +# ── Buttons ─────────────────────────────────────────────── +scan-button = Scan +stop-button = Stop +select-button = Select +delete-button = Delete +move-button = Move +save-button = Save +load-button = Load +load-button-tooltip = Load previously saved results +sort-button = Sort +hardlink-button = Hardlink +symlink-button = Symlink +rename-button = Rename +clean-exif-button = Clean EXIF +optimize-button = Optimize + +# ── Tool names ──────────────────────────────────────────── +tool-duplicate-files = Duplicate Files +tool-empty-folders = Empty Folders +tool-big-files = Big Files +tool-empty-files = Empty Files +tool-temporary-files = Temporary Files +tool-similar-images = Similar Images +tool-similar-videos = Similar Videos +tool-similar-music = Similar Music +tool-invalid-symlinks = Invalid Symlinks +tool-broken-files = Broken Files +tool-bad-extensions = Bad Extensions +tool-bad-names = Bad Names +tool-exif-remover = EXIF Remover +tool-video-optimizer = Video Optimizer + +# ── Column headers ──────────────────────────────────────── +column-selection = Selection +column-size = Size +column-file-name = File Name +column-path = Path +column-modification-date = Modification Date +column-hash = Hash +column-similarity = Similarity +column-resolution = Resolution +column-title = Title +column-artist = Artist +column-year = Year +column-bitrate = Bitrate +column-genre = Genre +column-length = Length +column-folder-name = Folder Name +column-symlink-name = Symlink Name +column-symlink-path = Symlink Path +column-destination-path = Destination Path +column-type-of-error = Type of Error +column-error-type = Error Type +column-current-extension = Current Extension +column-proper-extension = Proper Extension +column-codec = Codec + +# ── Left panel ──────────────────────────────────────────── +settings-tooltip = Application Settings +tool-settings-tooltip = Tool-specific Settings +fallback-app-name = Kalka + +# ── Settings panel ──────────────────────────────────────── +settings-title = Settings +settings-close = Close +settings-tab-general = General +settings-tab-directories = Directories +settings-tab-filters = Filters +settings-tab-preview = Preview +settings-window-title = Kalka Settings + +# General settings +settings-cli-path = czkawka_cli Path: +settings-browse = Browse +settings-thread-count = Thread Count: +settings-thread-auto = Auto (all cores) +settings-recursive = Recursive search +settings-use-cache = Use cache for faster rescans +settings-move-to-trash = Move to trash instead of permanent delete +settings-hide-hard-links = Hide hard links +settings-save-as-json = Save results as JSON (instead of text) +settings-select-cli-binary = Select czkawka_cli binary + +# Directories settings +settings-included-dirs = Included Directories +settings-excluded-dirs = Excluded Directories +settings-add = Add +settings-remove = Remove +settings-select-dir-include = Select Directory to Include +settings-select-dir-exclude = Select Directory to Exclude + +# Filters settings +settings-excluded-items = Excluded Items: +settings-excluded-items-hint = Wildcard patterns, comma-separated (e.g. *.tmp,cache_*) +settings-allowed-extensions = Allowed Extensions: +settings-allowed-extensions-hint = e.g. jpg,png,gif +settings-excluded-extensions = Excluded Extensions: +settings-excluded-extensions-hint = e.g. log,tmp +settings-min-file-size = Minimum File Size: +settings-min-file-size-hint = In bytes (e.g. 1024) +settings-max-file-size = Maximum File Size: +settings-max-file-size-hint = In bytes (leave empty for no limit) + +# Preview settings +settings-show-image-preview = Show image preview + +# ── Tool settings panel ────────────────────────────────── +tool-settings-title = Tool Settings + +# Duplicate files +subsettings-check-method = Check Method: +subsettings-hash-type = Hash Type: +subsettings-case-sensitive = Case sensitive name comparison + +# Similar images +subsettings-hash-size = Hash Size: +subsettings-resize-algorithm = Resize Algorithm: +subsettings-image-hash-type = Hash Type: +subsettings-ignore-same-size = Ignore same size +subsettings-max-difference = Max Difference: + +# Similar videos +subsettings-crop-detect = Crop Detect: +subsettings-skip-forward = Skip Forward (s): +subsettings-hash-duration = Hash Duration (s): + +# Similar music +subsettings-audio-check-type = Audio Check Type: +subsettings-tag-matching = Tag Matching +subsettings-approximate-comparison = Approximate comparison +subsettings-fingerprint-matching = Fingerprint Matching +subsettings-compare-similar-titles = Compare with similar titles + +# Big files +subsettings-method = Method: +subsettings-the-biggest = The Biggest +subsettings-the-smallest = The Smallest +subsettings-number-of-files = Number of Files: + +# Broken files +subsettings-file-types = File types to check: + +# Bad names +subsettings-check-for = Check for: +subsettings-uppercase-ext = Uppercase extension +subsettings-uppercase-ext-hint = Files with .JPG, .PNG etc. +subsettings-emoji = Emoji in name +subsettings-emoji-hint = Files containing emoji characters +subsettings-space = Space at start/end +subsettings-space-hint = Leading or trailing whitespace +subsettings-non-ascii = Non-ASCII characters +subsettings-non-ascii-hint = Characters outside ASCII range +subsettings-remove-duplicated = Remove duplicated non-alphanumeric +subsettings-remove-duplicated-hint = e.g. file--name..txt +subsettings-restricted-charset = Restricted charset: +subsettings-restricted-charset-hint = Allowed special chars, comma-separated + +# EXIF +subsettings-ignored-exif-tags = Ignored EXIF Tags: +subsettings-ignored-exif-tags-hint = Tags to ignore, comma-separated + +# Video optimizer +subsettings-mode = Mode: +subsettings-crop-settings = Crop Settings +subsettings-crop-type = Crop Type: +subsettings-black-pixel-threshold = Black Pixel Threshold: +subsettings-black-bar-min-pct = Black Bar Min %: +subsettings-max-samples = Max Samples: +subsettings-min-crop-size = Min Crop Size: +subsettings-transcode-settings = Transcode Settings +subsettings-excluded-codecs = Excluded Codecs: +subsettings-target-codec = Target Codec: +subsettings-quality = Quality: +subsettings-fail-if-bigger = Fail if not smaller + +# ── Progress widget ────────────────────────────────────── +progress-initializing = Initializing... +progress-starting = Starting scan... +progress-current = Current +progress-overall = Overall +progress-scan-complete = Scan complete +progress-completed-in = Completed in { $time } +progress-done = done + +# ── Results view ───────────────────────────────────────── +results-no-results = No results +results-found-grouped = Found { $total } files ({ $size }) in { $groups } groups +results-found-flat = Found { $total } entries ({ $size }) +results-selected = Selected: { $selected }/{ $total } ({ $selected_size }/{ $total_size }) + +# ── Preview panel ──────────────────────────────────────── +preview-title = Preview +preview-no-preview = No preview +preview-file-not-found = File not found +preview-not-available = Preview not available + for this file type +preview-cannot-load = Cannot load image + +# ── Context menu ───────────────────────────────────────── +context-open-file = Open File +context-open-folder = Open Containing Folder +context-select = Select +context-deselect = Deselect + +# ── Bottom panel ───────────────────────────────────────── +bottom-included-dirs = Included Directories: +bottom-excluded-dirs = Excluded Directories: + +# ── Delete dialog ──────────────────────────────────────── +delete-dialog-title = Delete Files +delete-dialog-message = Are you sure you want to delete { $count } selected file(s)? +delete-dialog-trash = Move to trash instead of permanent delete +delete-dialog-dry-run = Dry run (preview only, no files will be deleted) +delete-dialog-confirm = Delete + +# ── Move dialog ────────────────────────────────────────── +move-dialog-title = Move/Copy Files +move-dialog-message = Move or copy { $count } selected file(s) to: +move-dialog-placeholder = Select destination folder... +move-dialog-preserve = Preserve folder structure +move-dialog-copy-mode = Copy instead of move +move-dialog-dry-run = Dry run (preview only, no files will be moved) +move-dialog-confirm = Move +move-dialog-select-dest = Select Destination + +# ── Rename dialog ──────────────────────────────────────── +rename-dialog-confirm = Rename +rename-dialog-ext-message = Fix extensions for { $count } selected file(s)? + + Files will be renamed to use their proper extensions. +rename-dialog-names-message = Fix names for { $count } selected file(s)? + + Files with problematic names will be renamed. + +# ── Select dialog ──────────────────────────────────────── +select-dialog-title = Select Results +select-dialog-prompt = Choose selection mode: +select-all = Select All +unselect-all = Unselect All +invert-selection = Invert Selection +select-biggest-size = Select Biggest (by Size) +select-smallest-size = Select Smallest (by Size) +select-newest = Select Newest +select-oldest = Select Oldest +select-shortest-path = Select Shortest Path +select-longest-path = Select Longest Path +cancel = Cancel + +# ── Sort dialog ────────────────────────────────────────── +sort-dialog-title = Sort Results +sort-by = Sort by: +sort-ascending = Ascending + +# ── Hardlink / symlink dialogs ─────────────────────────── +hardlink-dialog-title = Create Hardlinks +hardlink-dialog-message = Replace { $count } selected file(s) with hardlinks to: + { $reference }? +symlink-dialog-title = Create Symlinks +symlink-dialog-message = Replace { $count } selected file(s) with symlinks to: + { $reference }? +no-reference-title = No Reference +no-reference-message = Cannot determine reference file. Leave at least one file unchecked in the group. + +# ── EXIF dialog ────────────────────────────────────────── +exif-dialog-title = Clean EXIF +exif-dialog-message = Remove EXIF metadata from { $count } selected file(s)? + +# ── Video optimize dialog ──────────────────────────────── +video-optimize-title = Video Optimization +video-optimize-message = Video optimization for { $count } file(s) will be performed using czkawka_cli. Check the status bar for progress. + +# ── Warnings / errors ──────────────────────────────────── +no-directories-title = No Directories +no-directories-message = Please add at least one directory to scan in the bottom panel. +no-selection-title = No Selection +no-selection-delete = No files selected for deletion. +no-selection-move = No files selected. +no-results-title = No Results +no-results-save = No results to save. +no-destination-title = No Destination +no-destination-message = Please select a destination folder. +scan-error-title = Scan Error + +# ── Save/load dialogs ──────────────────────────────────── +save-dialog-title = Save Results +load-dialog-title = Load Results diff --git a/kalka/i18n/sv-SE/kalka.ftl b/kalka/i18n/sv-SE/kalka.ftl new file mode 100644 index 000000000..fdbcd226c --- /dev/null +++ b/kalka/i18n/sv-SE/kalka.ftl @@ -0,0 +1,338 @@ +# Kalka – English translations (Fluent format) +# This file follows the same Fluent convention as krokiet/i18n/ + +# ── Window & app ────────────────────────────────────────── +main-window-title = Kalka - Data Cleaner +about-title = About Kalka +about-app-name = Kalka +about-subtitle = PySide6 / Qt 6 Edition +about-version = Version 11.0.1 +about-description = + Kalka is a simple, fast and free app to remove + unnecessary files from your computer. + + This PySide6/Qt interface uses the czkawka_cli backend + for all scanning and file operations. + + Features: + - Find duplicate files (by hash, name, or size) + - Find empty files and folders + - Find similar images, videos, and music + - Find broken files and invalid symlinks + - Find files with bad extensions or names + - Remove EXIF metadata from images + - Optimize and crop videos + + Licensed under MIT License + https://github.com/qarmin/czkawka +about-logo-tooltip = About Kalka +version-label = Kalka v11.0.1 + +# ── Status bar ──────────────────────────────────────────── +status-ready = Ready +status-tab = Tab: { $tab_name } +status-scanning = Scanning: { $tab_name }... +status-scan-complete = Scan complete: found { $count } entries +status-scan-stopped = Scan stopped by user +status-error = Error: { $message } +status-deleted = Deleted { $count } file(s) +status-deleted-dry-run = [DRY RUN] Deleted { $count } file(s) +status-moved = Moved { $count } file(s) +status-moved-dry-run = [DRY RUN] Moved { $count } file(s) +status-copied = Copied { $count } file(s) +status-copied-dry-run = [DRY RUN] Copied { $count } file(s) +status-hardlinks-created = Created { $count } hardlink(s) +status-symlinks-created = Created { $count } symlink(s) +status-exif-cleaned = Cleaned EXIF from { $count } file(s) +status-extensions-fixed = Extensions fixed +status-names-fixed = Names fixed +status-results-saved = Results saved successfully +status-results-loaded = Loaded { $count } entries from file +status-video-optimize = Video optimization: use CLI directly for this feature + +# ── Buttons ─────────────────────────────────────────────── +scan-button = Scan +stop-button = Stop +select-button = Select +delete-button = Delete +move-button = Move +save-button = Save +load-button = Load +load-button-tooltip = Load previously saved results +sort-button = Sort +hardlink-button = Hardlink +symlink-button = Symlink +rename-button = Rename +clean-exif-button = Clean EXIF +optimize-button = Optimize + +# ── Tool names ──────────────────────────────────────────── +tool-duplicate-files = Duplicate Files +tool-empty-folders = Empty Folders +tool-big-files = Big Files +tool-empty-files = Empty Files +tool-temporary-files = Temporary Files +tool-similar-images = Similar Images +tool-similar-videos = Similar Videos +tool-similar-music = Similar Music +tool-invalid-symlinks = Invalid Symlinks +tool-broken-files = Broken Files +tool-bad-extensions = Bad Extensions +tool-bad-names = Bad Names +tool-exif-remover = EXIF Remover +tool-video-optimizer = Video Optimizer + +# ── Column headers ──────────────────────────────────────── +column-selection = Selection +column-size = Size +column-file-name = File Name +column-path = Path +column-modification-date = Modification Date +column-hash = Hash +column-similarity = Similarity +column-resolution = Resolution +column-title = Title +column-artist = Artist +column-year = Year +column-bitrate = Bitrate +column-genre = Genre +column-length = Length +column-folder-name = Folder Name +column-symlink-name = Symlink Name +column-symlink-path = Symlink Path +column-destination-path = Destination Path +column-type-of-error = Type of Error +column-error-type = Error Type +column-current-extension = Current Extension +column-proper-extension = Proper Extension +column-codec = Codec + +# ── Left panel ──────────────────────────────────────────── +settings-tooltip = Application Settings +tool-settings-tooltip = Tool-specific Settings +fallback-app-name = Kalka + +# ── Settings panel ──────────────────────────────────────── +settings-title = Settings +settings-close = Close +settings-tab-general = General +settings-tab-directories = Directories +settings-tab-filters = Filters +settings-tab-preview = Preview +settings-window-title = Kalka Settings + +# General settings +settings-cli-path = czkawka_cli Path: +settings-browse = Browse +settings-thread-count = Thread Count: +settings-thread-auto = Auto (all cores) +settings-recursive = Recursive search +settings-use-cache = Use cache for faster rescans +settings-move-to-trash = Move to trash instead of permanent delete +settings-hide-hard-links = Hide hard links +settings-save-as-json = Save results as JSON (instead of text) +settings-select-cli-binary = Select czkawka_cli binary + +# Directories settings +settings-included-dirs = Included Directories +settings-excluded-dirs = Excluded Directories +settings-add = Add +settings-remove = Remove +settings-select-dir-include = Select Directory to Include +settings-select-dir-exclude = Select Directory to Exclude + +# Filters settings +settings-excluded-items = Excluded Items: +settings-excluded-items-hint = Wildcard patterns, comma-separated (e.g. *.tmp,cache_*) +settings-allowed-extensions = Allowed Extensions: +settings-allowed-extensions-hint = e.g. jpg,png,gif +settings-excluded-extensions = Excluded Extensions: +settings-excluded-extensions-hint = e.g. log,tmp +settings-min-file-size = Minimum File Size: +settings-min-file-size-hint = In bytes (e.g. 1024) +settings-max-file-size = Maximum File Size: +settings-max-file-size-hint = In bytes (leave empty for no limit) + +# Preview settings +settings-show-image-preview = Show image preview + +# ── Tool settings panel ────────────────────────────────── +tool-settings-title = Tool Settings + +# Duplicate files +subsettings-check-method = Check Method: +subsettings-hash-type = Hash Type: +subsettings-case-sensitive = Case sensitive name comparison + +# Similar images +subsettings-hash-size = Hash Size: +subsettings-resize-algorithm = Resize Algorithm: +subsettings-image-hash-type = Hash Type: +subsettings-ignore-same-size = Ignore same size +subsettings-max-difference = Max Difference: + +# Similar videos +subsettings-crop-detect = Crop Detect: +subsettings-skip-forward = Skip Forward (s): +subsettings-hash-duration = Hash Duration (s): + +# Similar music +subsettings-audio-check-type = Audio Check Type: +subsettings-tag-matching = Tag Matching +subsettings-approximate-comparison = Approximate comparison +subsettings-fingerprint-matching = Fingerprint Matching +subsettings-compare-similar-titles = Compare with similar titles + +# Big files +subsettings-method = Method: +subsettings-the-biggest = The Biggest +subsettings-the-smallest = The Smallest +subsettings-number-of-files = Number of Files: + +# Broken files +subsettings-file-types = File types to check: + +# Bad names +subsettings-check-for = Check for: +subsettings-uppercase-ext = Uppercase extension +subsettings-uppercase-ext-hint = Files with .JPG, .PNG etc. +subsettings-emoji = Emoji in name +subsettings-emoji-hint = Files containing emoji characters +subsettings-space = Space at start/end +subsettings-space-hint = Leading or trailing whitespace +subsettings-non-ascii = Non-ASCII characters +subsettings-non-ascii-hint = Characters outside ASCII range +subsettings-remove-duplicated = Remove duplicated non-alphanumeric +subsettings-remove-duplicated-hint = e.g. file--name..txt +subsettings-restricted-charset = Restricted charset: +subsettings-restricted-charset-hint = Allowed special chars, comma-separated + +# EXIF +subsettings-ignored-exif-tags = Ignored EXIF Tags: +subsettings-ignored-exif-tags-hint = Tags to ignore, comma-separated + +# Video optimizer +subsettings-mode = Mode: +subsettings-crop-settings = Crop Settings +subsettings-crop-type = Crop Type: +subsettings-black-pixel-threshold = Black Pixel Threshold: +subsettings-black-bar-min-pct = Black Bar Min %: +subsettings-max-samples = Max Samples: +subsettings-min-crop-size = Min Crop Size: +subsettings-transcode-settings = Transcode Settings +subsettings-excluded-codecs = Excluded Codecs: +subsettings-target-codec = Target Codec: +subsettings-quality = Quality: +subsettings-fail-if-bigger = Fail if not smaller + +# ── Progress widget ────────────────────────────────────── +progress-initializing = Initializing... +progress-starting = Starting scan... +progress-current = Current +progress-overall = Overall +progress-scan-complete = Scan complete +progress-completed-in = Completed in { $time } +progress-done = done + +# ── Results view ───────────────────────────────────────── +results-no-results = No results +results-found-grouped = Found { $total } files ({ $size }) in { $groups } groups +results-found-flat = Found { $total } entries ({ $size }) +results-selected = Selected: { $selected }/{ $total } ({ $selected_size }/{ $total_size }) + +# ── Preview panel ──────────────────────────────────────── +preview-title = Preview +preview-no-preview = No preview +preview-file-not-found = File not found +preview-not-available = Preview not available + for this file type +preview-cannot-load = Cannot load image + +# ── Context menu ───────────────────────────────────────── +context-open-file = Open File +context-open-folder = Open Containing Folder +context-select = Select +context-deselect = Deselect + +# ── Bottom panel ───────────────────────────────────────── +bottom-included-dirs = Included Directories: +bottom-excluded-dirs = Excluded Directories: + +# ── Delete dialog ──────────────────────────────────────── +delete-dialog-title = Delete Files +delete-dialog-message = Are you sure you want to delete { $count } selected file(s)? +delete-dialog-trash = Move to trash instead of permanent delete +delete-dialog-dry-run = Dry run (preview only, no files will be deleted) +delete-dialog-confirm = Delete + +# ── Move dialog ────────────────────────────────────────── +move-dialog-title = Move/Copy Files +move-dialog-message = Move or copy { $count } selected file(s) to: +move-dialog-placeholder = Select destination folder... +move-dialog-preserve = Preserve folder structure +move-dialog-copy-mode = Copy instead of move +move-dialog-dry-run = Dry run (preview only, no files will be moved) +move-dialog-confirm = Move +move-dialog-select-dest = Select Destination + +# ── Rename dialog ──────────────────────────────────────── +rename-dialog-confirm = Rename +rename-dialog-ext-message = Fix extensions for { $count } selected file(s)? + + Files will be renamed to use their proper extensions. +rename-dialog-names-message = Fix names for { $count } selected file(s)? + + Files with problematic names will be renamed. + +# ── Select dialog ──────────────────────────────────────── +select-dialog-title = Select Results +select-dialog-prompt = Choose selection mode: +select-all = Select All +unselect-all = Unselect All +invert-selection = Invert Selection +select-biggest-size = Select Biggest (by Size) +select-smallest-size = Select Smallest (by Size) +select-newest = Select Newest +select-oldest = Select Oldest +select-shortest-path = Select Shortest Path +select-longest-path = Select Longest Path +cancel = Cancel + +# ── Sort dialog ────────────────────────────────────────── +sort-dialog-title = Sort Results +sort-by = Sort by: +sort-ascending = Ascending + +# ── Hardlink / symlink dialogs ─────────────────────────── +hardlink-dialog-title = Create Hardlinks +hardlink-dialog-message = Replace { $count } selected file(s) with hardlinks to: + { $reference }? +symlink-dialog-title = Create Symlinks +symlink-dialog-message = Replace { $count } selected file(s) with symlinks to: + { $reference }? +no-reference-title = No Reference +no-reference-message = Cannot determine reference file. Leave at least one file unchecked in the group. + +# ── EXIF dialog ────────────────────────────────────────── +exif-dialog-title = Clean EXIF +exif-dialog-message = Remove EXIF metadata from { $count } selected file(s)? + +# ── Video optimize dialog ──────────────────────────────── +video-optimize-title = Video Optimization +video-optimize-message = Video optimization for { $count } file(s) will be performed using czkawka_cli. Check the status bar for progress. + +# ── Warnings / errors ──────────────────────────────────── +no-directories-title = No Directories +no-directories-message = Please add at least one directory to scan in the bottom panel. +no-selection-title = No Selection +no-selection-delete = No files selected for deletion. +no-selection-move = No files selected. +no-results-title = No Results +no-results-save = No results to save. +no-destination-title = No Destination +no-destination-message = Please select a destination folder. +scan-error-title = Scan Error + +# ── Save/load dialogs ──────────────────────────────────── +save-dialog-title = Save Results +load-dialog-title = Load Results diff --git a/kalka/i18n/tr/kalka.ftl b/kalka/i18n/tr/kalka.ftl new file mode 100644 index 000000000..fdbcd226c --- /dev/null +++ b/kalka/i18n/tr/kalka.ftl @@ -0,0 +1,338 @@ +# Kalka – English translations (Fluent format) +# This file follows the same Fluent convention as krokiet/i18n/ + +# ── Window & app ────────────────────────────────────────── +main-window-title = Kalka - Data Cleaner +about-title = About Kalka +about-app-name = Kalka +about-subtitle = PySide6 / Qt 6 Edition +about-version = Version 11.0.1 +about-description = + Kalka is a simple, fast and free app to remove + unnecessary files from your computer. + + This PySide6/Qt interface uses the czkawka_cli backend + for all scanning and file operations. + + Features: + - Find duplicate files (by hash, name, or size) + - Find empty files and folders + - Find similar images, videos, and music + - Find broken files and invalid symlinks + - Find files with bad extensions or names + - Remove EXIF metadata from images + - Optimize and crop videos + + Licensed under MIT License + https://github.com/qarmin/czkawka +about-logo-tooltip = About Kalka +version-label = Kalka v11.0.1 + +# ── Status bar ──────────────────────────────────────────── +status-ready = Ready +status-tab = Tab: { $tab_name } +status-scanning = Scanning: { $tab_name }... +status-scan-complete = Scan complete: found { $count } entries +status-scan-stopped = Scan stopped by user +status-error = Error: { $message } +status-deleted = Deleted { $count } file(s) +status-deleted-dry-run = [DRY RUN] Deleted { $count } file(s) +status-moved = Moved { $count } file(s) +status-moved-dry-run = [DRY RUN] Moved { $count } file(s) +status-copied = Copied { $count } file(s) +status-copied-dry-run = [DRY RUN] Copied { $count } file(s) +status-hardlinks-created = Created { $count } hardlink(s) +status-symlinks-created = Created { $count } symlink(s) +status-exif-cleaned = Cleaned EXIF from { $count } file(s) +status-extensions-fixed = Extensions fixed +status-names-fixed = Names fixed +status-results-saved = Results saved successfully +status-results-loaded = Loaded { $count } entries from file +status-video-optimize = Video optimization: use CLI directly for this feature + +# ── Buttons ─────────────────────────────────────────────── +scan-button = Scan +stop-button = Stop +select-button = Select +delete-button = Delete +move-button = Move +save-button = Save +load-button = Load +load-button-tooltip = Load previously saved results +sort-button = Sort +hardlink-button = Hardlink +symlink-button = Symlink +rename-button = Rename +clean-exif-button = Clean EXIF +optimize-button = Optimize + +# ── Tool names ──────────────────────────────────────────── +tool-duplicate-files = Duplicate Files +tool-empty-folders = Empty Folders +tool-big-files = Big Files +tool-empty-files = Empty Files +tool-temporary-files = Temporary Files +tool-similar-images = Similar Images +tool-similar-videos = Similar Videos +tool-similar-music = Similar Music +tool-invalid-symlinks = Invalid Symlinks +tool-broken-files = Broken Files +tool-bad-extensions = Bad Extensions +tool-bad-names = Bad Names +tool-exif-remover = EXIF Remover +tool-video-optimizer = Video Optimizer + +# ── Column headers ──────────────────────────────────────── +column-selection = Selection +column-size = Size +column-file-name = File Name +column-path = Path +column-modification-date = Modification Date +column-hash = Hash +column-similarity = Similarity +column-resolution = Resolution +column-title = Title +column-artist = Artist +column-year = Year +column-bitrate = Bitrate +column-genre = Genre +column-length = Length +column-folder-name = Folder Name +column-symlink-name = Symlink Name +column-symlink-path = Symlink Path +column-destination-path = Destination Path +column-type-of-error = Type of Error +column-error-type = Error Type +column-current-extension = Current Extension +column-proper-extension = Proper Extension +column-codec = Codec + +# ── Left panel ──────────────────────────────────────────── +settings-tooltip = Application Settings +tool-settings-tooltip = Tool-specific Settings +fallback-app-name = Kalka + +# ── Settings panel ──────────────────────────────────────── +settings-title = Settings +settings-close = Close +settings-tab-general = General +settings-tab-directories = Directories +settings-tab-filters = Filters +settings-tab-preview = Preview +settings-window-title = Kalka Settings + +# General settings +settings-cli-path = czkawka_cli Path: +settings-browse = Browse +settings-thread-count = Thread Count: +settings-thread-auto = Auto (all cores) +settings-recursive = Recursive search +settings-use-cache = Use cache for faster rescans +settings-move-to-trash = Move to trash instead of permanent delete +settings-hide-hard-links = Hide hard links +settings-save-as-json = Save results as JSON (instead of text) +settings-select-cli-binary = Select czkawka_cli binary + +# Directories settings +settings-included-dirs = Included Directories +settings-excluded-dirs = Excluded Directories +settings-add = Add +settings-remove = Remove +settings-select-dir-include = Select Directory to Include +settings-select-dir-exclude = Select Directory to Exclude + +# Filters settings +settings-excluded-items = Excluded Items: +settings-excluded-items-hint = Wildcard patterns, comma-separated (e.g. *.tmp,cache_*) +settings-allowed-extensions = Allowed Extensions: +settings-allowed-extensions-hint = e.g. jpg,png,gif +settings-excluded-extensions = Excluded Extensions: +settings-excluded-extensions-hint = e.g. log,tmp +settings-min-file-size = Minimum File Size: +settings-min-file-size-hint = In bytes (e.g. 1024) +settings-max-file-size = Maximum File Size: +settings-max-file-size-hint = In bytes (leave empty for no limit) + +# Preview settings +settings-show-image-preview = Show image preview + +# ── Tool settings panel ────────────────────────────────── +tool-settings-title = Tool Settings + +# Duplicate files +subsettings-check-method = Check Method: +subsettings-hash-type = Hash Type: +subsettings-case-sensitive = Case sensitive name comparison + +# Similar images +subsettings-hash-size = Hash Size: +subsettings-resize-algorithm = Resize Algorithm: +subsettings-image-hash-type = Hash Type: +subsettings-ignore-same-size = Ignore same size +subsettings-max-difference = Max Difference: + +# Similar videos +subsettings-crop-detect = Crop Detect: +subsettings-skip-forward = Skip Forward (s): +subsettings-hash-duration = Hash Duration (s): + +# Similar music +subsettings-audio-check-type = Audio Check Type: +subsettings-tag-matching = Tag Matching +subsettings-approximate-comparison = Approximate comparison +subsettings-fingerprint-matching = Fingerprint Matching +subsettings-compare-similar-titles = Compare with similar titles + +# Big files +subsettings-method = Method: +subsettings-the-biggest = The Biggest +subsettings-the-smallest = The Smallest +subsettings-number-of-files = Number of Files: + +# Broken files +subsettings-file-types = File types to check: + +# Bad names +subsettings-check-for = Check for: +subsettings-uppercase-ext = Uppercase extension +subsettings-uppercase-ext-hint = Files with .JPG, .PNG etc. +subsettings-emoji = Emoji in name +subsettings-emoji-hint = Files containing emoji characters +subsettings-space = Space at start/end +subsettings-space-hint = Leading or trailing whitespace +subsettings-non-ascii = Non-ASCII characters +subsettings-non-ascii-hint = Characters outside ASCII range +subsettings-remove-duplicated = Remove duplicated non-alphanumeric +subsettings-remove-duplicated-hint = e.g. file--name..txt +subsettings-restricted-charset = Restricted charset: +subsettings-restricted-charset-hint = Allowed special chars, comma-separated + +# EXIF +subsettings-ignored-exif-tags = Ignored EXIF Tags: +subsettings-ignored-exif-tags-hint = Tags to ignore, comma-separated + +# Video optimizer +subsettings-mode = Mode: +subsettings-crop-settings = Crop Settings +subsettings-crop-type = Crop Type: +subsettings-black-pixel-threshold = Black Pixel Threshold: +subsettings-black-bar-min-pct = Black Bar Min %: +subsettings-max-samples = Max Samples: +subsettings-min-crop-size = Min Crop Size: +subsettings-transcode-settings = Transcode Settings +subsettings-excluded-codecs = Excluded Codecs: +subsettings-target-codec = Target Codec: +subsettings-quality = Quality: +subsettings-fail-if-bigger = Fail if not smaller + +# ── Progress widget ────────────────────────────────────── +progress-initializing = Initializing... +progress-starting = Starting scan... +progress-current = Current +progress-overall = Overall +progress-scan-complete = Scan complete +progress-completed-in = Completed in { $time } +progress-done = done + +# ── Results view ───────────────────────────────────────── +results-no-results = No results +results-found-grouped = Found { $total } files ({ $size }) in { $groups } groups +results-found-flat = Found { $total } entries ({ $size }) +results-selected = Selected: { $selected }/{ $total } ({ $selected_size }/{ $total_size }) + +# ── Preview panel ──────────────────────────────────────── +preview-title = Preview +preview-no-preview = No preview +preview-file-not-found = File not found +preview-not-available = Preview not available + for this file type +preview-cannot-load = Cannot load image + +# ── Context menu ───────────────────────────────────────── +context-open-file = Open File +context-open-folder = Open Containing Folder +context-select = Select +context-deselect = Deselect + +# ── Bottom panel ───────────────────────────────────────── +bottom-included-dirs = Included Directories: +bottom-excluded-dirs = Excluded Directories: + +# ── Delete dialog ──────────────────────────────────────── +delete-dialog-title = Delete Files +delete-dialog-message = Are you sure you want to delete { $count } selected file(s)? +delete-dialog-trash = Move to trash instead of permanent delete +delete-dialog-dry-run = Dry run (preview only, no files will be deleted) +delete-dialog-confirm = Delete + +# ── Move dialog ────────────────────────────────────────── +move-dialog-title = Move/Copy Files +move-dialog-message = Move or copy { $count } selected file(s) to: +move-dialog-placeholder = Select destination folder... +move-dialog-preserve = Preserve folder structure +move-dialog-copy-mode = Copy instead of move +move-dialog-dry-run = Dry run (preview only, no files will be moved) +move-dialog-confirm = Move +move-dialog-select-dest = Select Destination + +# ── Rename dialog ──────────────────────────────────────── +rename-dialog-confirm = Rename +rename-dialog-ext-message = Fix extensions for { $count } selected file(s)? + + Files will be renamed to use their proper extensions. +rename-dialog-names-message = Fix names for { $count } selected file(s)? + + Files with problematic names will be renamed. + +# ── Select dialog ──────────────────────────────────────── +select-dialog-title = Select Results +select-dialog-prompt = Choose selection mode: +select-all = Select All +unselect-all = Unselect All +invert-selection = Invert Selection +select-biggest-size = Select Biggest (by Size) +select-smallest-size = Select Smallest (by Size) +select-newest = Select Newest +select-oldest = Select Oldest +select-shortest-path = Select Shortest Path +select-longest-path = Select Longest Path +cancel = Cancel + +# ── Sort dialog ────────────────────────────────────────── +sort-dialog-title = Sort Results +sort-by = Sort by: +sort-ascending = Ascending + +# ── Hardlink / symlink dialogs ─────────────────────────── +hardlink-dialog-title = Create Hardlinks +hardlink-dialog-message = Replace { $count } selected file(s) with hardlinks to: + { $reference }? +symlink-dialog-title = Create Symlinks +symlink-dialog-message = Replace { $count } selected file(s) with symlinks to: + { $reference }? +no-reference-title = No Reference +no-reference-message = Cannot determine reference file. Leave at least one file unchecked in the group. + +# ── EXIF dialog ────────────────────────────────────────── +exif-dialog-title = Clean EXIF +exif-dialog-message = Remove EXIF metadata from { $count } selected file(s)? + +# ── Video optimize dialog ──────────────────────────────── +video-optimize-title = Video Optimization +video-optimize-message = Video optimization for { $count } file(s) will be performed using czkawka_cli. Check the status bar for progress. + +# ── Warnings / errors ──────────────────────────────────── +no-directories-title = No Directories +no-directories-message = Please add at least one directory to scan in the bottom panel. +no-selection-title = No Selection +no-selection-delete = No files selected for deletion. +no-selection-move = No files selected. +no-results-title = No Results +no-results-save = No results to save. +no-destination-title = No Destination +no-destination-message = Please select a destination folder. +scan-error-title = Scan Error + +# ── Save/load dialogs ──────────────────────────────────── +save-dialog-title = Save Results +load-dialog-title = Load Results diff --git a/kalka/i18n/uk/kalka.ftl b/kalka/i18n/uk/kalka.ftl new file mode 100644 index 000000000..fdbcd226c --- /dev/null +++ b/kalka/i18n/uk/kalka.ftl @@ -0,0 +1,338 @@ +# Kalka – English translations (Fluent format) +# This file follows the same Fluent convention as krokiet/i18n/ + +# ── Window & app ────────────────────────────────────────── +main-window-title = Kalka - Data Cleaner +about-title = About Kalka +about-app-name = Kalka +about-subtitle = PySide6 / Qt 6 Edition +about-version = Version 11.0.1 +about-description = + Kalka is a simple, fast and free app to remove + unnecessary files from your computer. + + This PySide6/Qt interface uses the czkawka_cli backend + for all scanning and file operations. + + Features: + - Find duplicate files (by hash, name, or size) + - Find empty files and folders + - Find similar images, videos, and music + - Find broken files and invalid symlinks + - Find files with bad extensions or names + - Remove EXIF metadata from images + - Optimize and crop videos + + Licensed under MIT License + https://github.com/qarmin/czkawka +about-logo-tooltip = About Kalka +version-label = Kalka v11.0.1 + +# ── Status bar ──────────────────────────────────────────── +status-ready = Ready +status-tab = Tab: { $tab_name } +status-scanning = Scanning: { $tab_name }... +status-scan-complete = Scan complete: found { $count } entries +status-scan-stopped = Scan stopped by user +status-error = Error: { $message } +status-deleted = Deleted { $count } file(s) +status-deleted-dry-run = [DRY RUN] Deleted { $count } file(s) +status-moved = Moved { $count } file(s) +status-moved-dry-run = [DRY RUN] Moved { $count } file(s) +status-copied = Copied { $count } file(s) +status-copied-dry-run = [DRY RUN] Copied { $count } file(s) +status-hardlinks-created = Created { $count } hardlink(s) +status-symlinks-created = Created { $count } symlink(s) +status-exif-cleaned = Cleaned EXIF from { $count } file(s) +status-extensions-fixed = Extensions fixed +status-names-fixed = Names fixed +status-results-saved = Results saved successfully +status-results-loaded = Loaded { $count } entries from file +status-video-optimize = Video optimization: use CLI directly for this feature + +# ── Buttons ─────────────────────────────────────────────── +scan-button = Scan +stop-button = Stop +select-button = Select +delete-button = Delete +move-button = Move +save-button = Save +load-button = Load +load-button-tooltip = Load previously saved results +sort-button = Sort +hardlink-button = Hardlink +symlink-button = Symlink +rename-button = Rename +clean-exif-button = Clean EXIF +optimize-button = Optimize + +# ── Tool names ──────────────────────────────────────────── +tool-duplicate-files = Duplicate Files +tool-empty-folders = Empty Folders +tool-big-files = Big Files +tool-empty-files = Empty Files +tool-temporary-files = Temporary Files +tool-similar-images = Similar Images +tool-similar-videos = Similar Videos +tool-similar-music = Similar Music +tool-invalid-symlinks = Invalid Symlinks +tool-broken-files = Broken Files +tool-bad-extensions = Bad Extensions +tool-bad-names = Bad Names +tool-exif-remover = EXIF Remover +tool-video-optimizer = Video Optimizer + +# ── Column headers ──────────────────────────────────────── +column-selection = Selection +column-size = Size +column-file-name = File Name +column-path = Path +column-modification-date = Modification Date +column-hash = Hash +column-similarity = Similarity +column-resolution = Resolution +column-title = Title +column-artist = Artist +column-year = Year +column-bitrate = Bitrate +column-genre = Genre +column-length = Length +column-folder-name = Folder Name +column-symlink-name = Symlink Name +column-symlink-path = Symlink Path +column-destination-path = Destination Path +column-type-of-error = Type of Error +column-error-type = Error Type +column-current-extension = Current Extension +column-proper-extension = Proper Extension +column-codec = Codec + +# ── Left panel ──────────────────────────────────────────── +settings-tooltip = Application Settings +tool-settings-tooltip = Tool-specific Settings +fallback-app-name = Kalka + +# ── Settings panel ──────────────────────────────────────── +settings-title = Settings +settings-close = Close +settings-tab-general = General +settings-tab-directories = Directories +settings-tab-filters = Filters +settings-tab-preview = Preview +settings-window-title = Kalka Settings + +# General settings +settings-cli-path = czkawka_cli Path: +settings-browse = Browse +settings-thread-count = Thread Count: +settings-thread-auto = Auto (all cores) +settings-recursive = Recursive search +settings-use-cache = Use cache for faster rescans +settings-move-to-trash = Move to trash instead of permanent delete +settings-hide-hard-links = Hide hard links +settings-save-as-json = Save results as JSON (instead of text) +settings-select-cli-binary = Select czkawka_cli binary + +# Directories settings +settings-included-dirs = Included Directories +settings-excluded-dirs = Excluded Directories +settings-add = Add +settings-remove = Remove +settings-select-dir-include = Select Directory to Include +settings-select-dir-exclude = Select Directory to Exclude + +# Filters settings +settings-excluded-items = Excluded Items: +settings-excluded-items-hint = Wildcard patterns, comma-separated (e.g. *.tmp,cache_*) +settings-allowed-extensions = Allowed Extensions: +settings-allowed-extensions-hint = e.g. jpg,png,gif +settings-excluded-extensions = Excluded Extensions: +settings-excluded-extensions-hint = e.g. log,tmp +settings-min-file-size = Minimum File Size: +settings-min-file-size-hint = In bytes (e.g. 1024) +settings-max-file-size = Maximum File Size: +settings-max-file-size-hint = In bytes (leave empty for no limit) + +# Preview settings +settings-show-image-preview = Show image preview + +# ── Tool settings panel ────────────────────────────────── +tool-settings-title = Tool Settings + +# Duplicate files +subsettings-check-method = Check Method: +subsettings-hash-type = Hash Type: +subsettings-case-sensitive = Case sensitive name comparison + +# Similar images +subsettings-hash-size = Hash Size: +subsettings-resize-algorithm = Resize Algorithm: +subsettings-image-hash-type = Hash Type: +subsettings-ignore-same-size = Ignore same size +subsettings-max-difference = Max Difference: + +# Similar videos +subsettings-crop-detect = Crop Detect: +subsettings-skip-forward = Skip Forward (s): +subsettings-hash-duration = Hash Duration (s): + +# Similar music +subsettings-audio-check-type = Audio Check Type: +subsettings-tag-matching = Tag Matching +subsettings-approximate-comparison = Approximate comparison +subsettings-fingerprint-matching = Fingerprint Matching +subsettings-compare-similar-titles = Compare with similar titles + +# Big files +subsettings-method = Method: +subsettings-the-biggest = The Biggest +subsettings-the-smallest = The Smallest +subsettings-number-of-files = Number of Files: + +# Broken files +subsettings-file-types = File types to check: + +# Bad names +subsettings-check-for = Check for: +subsettings-uppercase-ext = Uppercase extension +subsettings-uppercase-ext-hint = Files with .JPG, .PNG etc. +subsettings-emoji = Emoji in name +subsettings-emoji-hint = Files containing emoji characters +subsettings-space = Space at start/end +subsettings-space-hint = Leading or trailing whitespace +subsettings-non-ascii = Non-ASCII characters +subsettings-non-ascii-hint = Characters outside ASCII range +subsettings-remove-duplicated = Remove duplicated non-alphanumeric +subsettings-remove-duplicated-hint = e.g. file--name..txt +subsettings-restricted-charset = Restricted charset: +subsettings-restricted-charset-hint = Allowed special chars, comma-separated + +# EXIF +subsettings-ignored-exif-tags = Ignored EXIF Tags: +subsettings-ignored-exif-tags-hint = Tags to ignore, comma-separated + +# Video optimizer +subsettings-mode = Mode: +subsettings-crop-settings = Crop Settings +subsettings-crop-type = Crop Type: +subsettings-black-pixel-threshold = Black Pixel Threshold: +subsettings-black-bar-min-pct = Black Bar Min %: +subsettings-max-samples = Max Samples: +subsettings-min-crop-size = Min Crop Size: +subsettings-transcode-settings = Transcode Settings +subsettings-excluded-codecs = Excluded Codecs: +subsettings-target-codec = Target Codec: +subsettings-quality = Quality: +subsettings-fail-if-bigger = Fail if not smaller + +# ── Progress widget ────────────────────────────────────── +progress-initializing = Initializing... +progress-starting = Starting scan... +progress-current = Current +progress-overall = Overall +progress-scan-complete = Scan complete +progress-completed-in = Completed in { $time } +progress-done = done + +# ── Results view ───────────────────────────────────────── +results-no-results = No results +results-found-grouped = Found { $total } files ({ $size }) in { $groups } groups +results-found-flat = Found { $total } entries ({ $size }) +results-selected = Selected: { $selected }/{ $total } ({ $selected_size }/{ $total_size }) + +# ── Preview panel ──────────────────────────────────────── +preview-title = Preview +preview-no-preview = No preview +preview-file-not-found = File not found +preview-not-available = Preview not available + for this file type +preview-cannot-load = Cannot load image + +# ── Context menu ───────────────────────────────────────── +context-open-file = Open File +context-open-folder = Open Containing Folder +context-select = Select +context-deselect = Deselect + +# ── Bottom panel ───────────────────────────────────────── +bottom-included-dirs = Included Directories: +bottom-excluded-dirs = Excluded Directories: + +# ── Delete dialog ──────────────────────────────────────── +delete-dialog-title = Delete Files +delete-dialog-message = Are you sure you want to delete { $count } selected file(s)? +delete-dialog-trash = Move to trash instead of permanent delete +delete-dialog-dry-run = Dry run (preview only, no files will be deleted) +delete-dialog-confirm = Delete + +# ── Move dialog ────────────────────────────────────────── +move-dialog-title = Move/Copy Files +move-dialog-message = Move or copy { $count } selected file(s) to: +move-dialog-placeholder = Select destination folder... +move-dialog-preserve = Preserve folder structure +move-dialog-copy-mode = Copy instead of move +move-dialog-dry-run = Dry run (preview only, no files will be moved) +move-dialog-confirm = Move +move-dialog-select-dest = Select Destination + +# ── Rename dialog ──────────────────────────────────────── +rename-dialog-confirm = Rename +rename-dialog-ext-message = Fix extensions for { $count } selected file(s)? + + Files will be renamed to use their proper extensions. +rename-dialog-names-message = Fix names for { $count } selected file(s)? + + Files with problematic names will be renamed. + +# ── Select dialog ──────────────────────────────────────── +select-dialog-title = Select Results +select-dialog-prompt = Choose selection mode: +select-all = Select All +unselect-all = Unselect All +invert-selection = Invert Selection +select-biggest-size = Select Biggest (by Size) +select-smallest-size = Select Smallest (by Size) +select-newest = Select Newest +select-oldest = Select Oldest +select-shortest-path = Select Shortest Path +select-longest-path = Select Longest Path +cancel = Cancel + +# ── Sort dialog ────────────────────────────────────────── +sort-dialog-title = Sort Results +sort-by = Sort by: +sort-ascending = Ascending + +# ── Hardlink / symlink dialogs ─────────────────────────── +hardlink-dialog-title = Create Hardlinks +hardlink-dialog-message = Replace { $count } selected file(s) with hardlinks to: + { $reference }? +symlink-dialog-title = Create Symlinks +symlink-dialog-message = Replace { $count } selected file(s) with symlinks to: + { $reference }? +no-reference-title = No Reference +no-reference-message = Cannot determine reference file. Leave at least one file unchecked in the group. + +# ── EXIF dialog ────────────────────────────────────────── +exif-dialog-title = Clean EXIF +exif-dialog-message = Remove EXIF metadata from { $count } selected file(s)? + +# ── Video optimize dialog ──────────────────────────────── +video-optimize-title = Video Optimization +video-optimize-message = Video optimization for { $count } file(s) will be performed using czkawka_cli. Check the status bar for progress. + +# ── Warnings / errors ──────────────────────────────────── +no-directories-title = No Directories +no-directories-message = Please add at least one directory to scan in the bottom panel. +no-selection-title = No Selection +no-selection-delete = No files selected for deletion. +no-selection-move = No files selected. +no-results-title = No Results +no-results-save = No results to save. +no-destination-title = No Destination +no-destination-message = Please select a destination folder. +scan-error-title = Scan Error + +# ── Save/load dialogs ──────────────────────────────────── +save-dialog-title = Save Results +load-dialog-title = Load Results diff --git a/kalka/i18n/zh-CN/kalka.ftl b/kalka/i18n/zh-CN/kalka.ftl new file mode 100644 index 000000000..fdbcd226c --- /dev/null +++ b/kalka/i18n/zh-CN/kalka.ftl @@ -0,0 +1,338 @@ +# Kalka – English translations (Fluent format) +# This file follows the same Fluent convention as krokiet/i18n/ + +# ── Window & app ────────────────────────────────────────── +main-window-title = Kalka - Data Cleaner +about-title = About Kalka +about-app-name = Kalka +about-subtitle = PySide6 / Qt 6 Edition +about-version = Version 11.0.1 +about-description = + Kalka is a simple, fast and free app to remove + unnecessary files from your computer. + + This PySide6/Qt interface uses the czkawka_cli backend + for all scanning and file operations. + + Features: + - Find duplicate files (by hash, name, or size) + - Find empty files and folders + - Find similar images, videos, and music + - Find broken files and invalid symlinks + - Find files with bad extensions or names + - Remove EXIF metadata from images + - Optimize and crop videos + + Licensed under MIT License + https://github.com/qarmin/czkawka +about-logo-tooltip = About Kalka +version-label = Kalka v11.0.1 + +# ── Status bar ──────────────────────────────────────────── +status-ready = Ready +status-tab = Tab: { $tab_name } +status-scanning = Scanning: { $tab_name }... +status-scan-complete = Scan complete: found { $count } entries +status-scan-stopped = Scan stopped by user +status-error = Error: { $message } +status-deleted = Deleted { $count } file(s) +status-deleted-dry-run = [DRY RUN] Deleted { $count } file(s) +status-moved = Moved { $count } file(s) +status-moved-dry-run = [DRY RUN] Moved { $count } file(s) +status-copied = Copied { $count } file(s) +status-copied-dry-run = [DRY RUN] Copied { $count } file(s) +status-hardlinks-created = Created { $count } hardlink(s) +status-symlinks-created = Created { $count } symlink(s) +status-exif-cleaned = Cleaned EXIF from { $count } file(s) +status-extensions-fixed = Extensions fixed +status-names-fixed = Names fixed +status-results-saved = Results saved successfully +status-results-loaded = Loaded { $count } entries from file +status-video-optimize = Video optimization: use CLI directly for this feature + +# ── Buttons ─────────────────────────────────────────────── +scan-button = Scan +stop-button = Stop +select-button = Select +delete-button = Delete +move-button = Move +save-button = Save +load-button = Load +load-button-tooltip = Load previously saved results +sort-button = Sort +hardlink-button = Hardlink +symlink-button = Symlink +rename-button = Rename +clean-exif-button = Clean EXIF +optimize-button = Optimize + +# ── Tool names ──────────────────────────────────────────── +tool-duplicate-files = Duplicate Files +tool-empty-folders = Empty Folders +tool-big-files = Big Files +tool-empty-files = Empty Files +tool-temporary-files = Temporary Files +tool-similar-images = Similar Images +tool-similar-videos = Similar Videos +tool-similar-music = Similar Music +tool-invalid-symlinks = Invalid Symlinks +tool-broken-files = Broken Files +tool-bad-extensions = Bad Extensions +tool-bad-names = Bad Names +tool-exif-remover = EXIF Remover +tool-video-optimizer = Video Optimizer + +# ── Column headers ──────────────────────────────────────── +column-selection = Selection +column-size = Size +column-file-name = File Name +column-path = Path +column-modification-date = Modification Date +column-hash = Hash +column-similarity = Similarity +column-resolution = Resolution +column-title = Title +column-artist = Artist +column-year = Year +column-bitrate = Bitrate +column-genre = Genre +column-length = Length +column-folder-name = Folder Name +column-symlink-name = Symlink Name +column-symlink-path = Symlink Path +column-destination-path = Destination Path +column-type-of-error = Type of Error +column-error-type = Error Type +column-current-extension = Current Extension +column-proper-extension = Proper Extension +column-codec = Codec + +# ── Left panel ──────────────────────────────────────────── +settings-tooltip = Application Settings +tool-settings-tooltip = Tool-specific Settings +fallback-app-name = Kalka + +# ── Settings panel ──────────────────────────────────────── +settings-title = Settings +settings-close = Close +settings-tab-general = General +settings-tab-directories = Directories +settings-tab-filters = Filters +settings-tab-preview = Preview +settings-window-title = Kalka Settings + +# General settings +settings-cli-path = czkawka_cli Path: +settings-browse = Browse +settings-thread-count = Thread Count: +settings-thread-auto = Auto (all cores) +settings-recursive = Recursive search +settings-use-cache = Use cache for faster rescans +settings-move-to-trash = Move to trash instead of permanent delete +settings-hide-hard-links = Hide hard links +settings-save-as-json = Save results as JSON (instead of text) +settings-select-cli-binary = Select czkawka_cli binary + +# Directories settings +settings-included-dirs = Included Directories +settings-excluded-dirs = Excluded Directories +settings-add = Add +settings-remove = Remove +settings-select-dir-include = Select Directory to Include +settings-select-dir-exclude = Select Directory to Exclude + +# Filters settings +settings-excluded-items = Excluded Items: +settings-excluded-items-hint = Wildcard patterns, comma-separated (e.g. *.tmp,cache_*) +settings-allowed-extensions = Allowed Extensions: +settings-allowed-extensions-hint = e.g. jpg,png,gif +settings-excluded-extensions = Excluded Extensions: +settings-excluded-extensions-hint = e.g. log,tmp +settings-min-file-size = Minimum File Size: +settings-min-file-size-hint = In bytes (e.g. 1024) +settings-max-file-size = Maximum File Size: +settings-max-file-size-hint = In bytes (leave empty for no limit) + +# Preview settings +settings-show-image-preview = Show image preview + +# ── Tool settings panel ────────────────────────────────── +tool-settings-title = Tool Settings + +# Duplicate files +subsettings-check-method = Check Method: +subsettings-hash-type = Hash Type: +subsettings-case-sensitive = Case sensitive name comparison + +# Similar images +subsettings-hash-size = Hash Size: +subsettings-resize-algorithm = Resize Algorithm: +subsettings-image-hash-type = Hash Type: +subsettings-ignore-same-size = Ignore same size +subsettings-max-difference = Max Difference: + +# Similar videos +subsettings-crop-detect = Crop Detect: +subsettings-skip-forward = Skip Forward (s): +subsettings-hash-duration = Hash Duration (s): + +# Similar music +subsettings-audio-check-type = Audio Check Type: +subsettings-tag-matching = Tag Matching +subsettings-approximate-comparison = Approximate comparison +subsettings-fingerprint-matching = Fingerprint Matching +subsettings-compare-similar-titles = Compare with similar titles + +# Big files +subsettings-method = Method: +subsettings-the-biggest = The Biggest +subsettings-the-smallest = The Smallest +subsettings-number-of-files = Number of Files: + +# Broken files +subsettings-file-types = File types to check: + +# Bad names +subsettings-check-for = Check for: +subsettings-uppercase-ext = Uppercase extension +subsettings-uppercase-ext-hint = Files with .JPG, .PNG etc. +subsettings-emoji = Emoji in name +subsettings-emoji-hint = Files containing emoji characters +subsettings-space = Space at start/end +subsettings-space-hint = Leading or trailing whitespace +subsettings-non-ascii = Non-ASCII characters +subsettings-non-ascii-hint = Characters outside ASCII range +subsettings-remove-duplicated = Remove duplicated non-alphanumeric +subsettings-remove-duplicated-hint = e.g. file--name..txt +subsettings-restricted-charset = Restricted charset: +subsettings-restricted-charset-hint = Allowed special chars, comma-separated + +# EXIF +subsettings-ignored-exif-tags = Ignored EXIF Tags: +subsettings-ignored-exif-tags-hint = Tags to ignore, comma-separated + +# Video optimizer +subsettings-mode = Mode: +subsettings-crop-settings = Crop Settings +subsettings-crop-type = Crop Type: +subsettings-black-pixel-threshold = Black Pixel Threshold: +subsettings-black-bar-min-pct = Black Bar Min %: +subsettings-max-samples = Max Samples: +subsettings-min-crop-size = Min Crop Size: +subsettings-transcode-settings = Transcode Settings +subsettings-excluded-codecs = Excluded Codecs: +subsettings-target-codec = Target Codec: +subsettings-quality = Quality: +subsettings-fail-if-bigger = Fail if not smaller + +# ── Progress widget ────────────────────────────────────── +progress-initializing = Initializing... +progress-starting = Starting scan... +progress-current = Current +progress-overall = Overall +progress-scan-complete = Scan complete +progress-completed-in = Completed in { $time } +progress-done = done + +# ── Results view ───────────────────────────────────────── +results-no-results = No results +results-found-grouped = Found { $total } files ({ $size }) in { $groups } groups +results-found-flat = Found { $total } entries ({ $size }) +results-selected = Selected: { $selected }/{ $total } ({ $selected_size }/{ $total_size }) + +# ── Preview panel ──────────────────────────────────────── +preview-title = Preview +preview-no-preview = No preview +preview-file-not-found = File not found +preview-not-available = Preview not available + for this file type +preview-cannot-load = Cannot load image + +# ── Context menu ───────────────────────────────────────── +context-open-file = Open File +context-open-folder = Open Containing Folder +context-select = Select +context-deselect = Deselect + +# ── Bottom panel ───────────────────────────────────────── +bottom-included-dirs = Included Directories: +bottom-excluded-dirs = Excluded Directories: + +# ── Delete dialog ──────────────────────────────────────── +delete-dialog-title = Delete Files +delete-dialog-message = Are you sure you want to delete { $count } selected file(s)? +delete-dialog-trash = Move to trash instead of permanent delete +delete-dialog-dry-run = Dry run (preview only, no files will be deleted) +delete-dialog-confirm = Delete + +# ── Move dialog ────────────────────────────────────────── +move-dialog-title = Move/Copy Files +move-dialog-message = Move or copy { $count } selected file(s) to: +move-dialog-placeholder = Select destination folder... +move-dialog-preserve = Preserve folder structure +move-dialog-copy-mode = Copy instead of move +move-dialog-dry-run = Dry run (preview only, no files will be moved) +move-dialog-confirm = Move +move-dialog-select-dest = Select Destination + +# ── Rename dialog ──────────────────────────────────────── +rename-dialog-confirm = Rename +rename-dialog-ext-message = Fix extensions for { $count } selected file(s)? + + Files will be renamed to use their proper extensions. +rename-dialog-names-message = Fix names for { $count } selected file(s)? + + Files with problematic names will be renamed. + +# ── Select dialog ──────────────────────────────────────── +select-dialog-title = Select Results +select-dialog-prompt = Choose selection mode: +select-all = Select All +unselect-all = Unselect All +invert-selection = Invert Selection +select-biggest-size = Select Biggest (by Size) +select-smallest-size = Select Smallest (by Size) +select-newest = Select Newest +select-oldest = Select Oldest +select-shortest-path = Select Shortest Path +select-longest-path = Select Longest Path +cancel = Cancel + +# ── Sort dialog ────────────────────────────────────────── +sort-dialog-title = Sort Results +sort-by = Sort by: +sort-ascending = Ascending + +# ── Hardlink / symlink dialogs ─────────────────────────── +hardlink-dialog-title = Create Hardlinks +hardlink-dialog-message = Replace { $count } selected file(s) with hardlinks to: + { $reference }? +symlink-dialog-title = Create Symlinks +symlink-dialog-message = Replace { $count } selected file(s) with symlinks to: + { $reference }? +no-reference-title = No Reference +no-reference-message = Cannot determine reference file. Leave at least one file unchecked in the group. + +# ── EXIF dialog ────────────────────────────────────────── +exif-dialog-title = Clean EXIF +exif-dialog-message = Remove EXIF metadata from { $count } selected file(s)? + +# ── Video optimize dialog ──────────────────────────────── +video-optimize-title = Video Optimization +video-optimize-message = Video optimization for { $count } file(s) will be performed using czkawka_cli. Check the status bar for progress. + +# ── Warnings / errors ──────────────────────────────────── +no-directories-title = No Directories +no-directories-message = Please add at least one directory to scan in the bottom panel. +no-selection-title = No Selection +no-selection-delete = No files selected for deletion. +no-selection-move = No files selected. +no-results-title = No Results +no-results-save = No results to save. +no-destination-title = No Destination +no-destination-message = Please select a destination folder. +scan-error-title = Scan Error + +# ── Save/load dialogs ──────────────────────────────────── +save-dialog-title = Save Results +load-dialog-title = Load Results diff --git a/kalka/i18n/zh-TW/kalka.ftl b/kalka/i18n/zh-TW/kalka.ftl new file mode 100644 index 000000000..fdbcd226c --- /dev/null +++ b/kalka/i18n/zh-TW/kalka.ftl @@ -0,0 +1,338 @@ +# Kalka – English translations (Fluent format) +# This file follows the same Fluent convention as krokiet/i18n/ + +# ── Window & app ────────────────────────────────────────── +main-window-title = Kalka - Data Cleaner +about-title = About Kalka +about-app-name = Kalka +about-subtitle = PySide6 / Qt 6 Edition +about-version = Version 11.0.1 +about-description = + Kalka is a simple, fast and free app to remove + unnecessary files from your computer. + + This PySide6/Qt interface uses the czkawka_cli backend + for all scanning and file operations. + + Features: + - Find duplicate files (by hash, name, or size) + - Find empty files and folders + - Find similar images, videos, and music + - Find broken files and invalid symlinks + - Find files with bad extensions or names + - Remove EXIF metadata from images + - Optimize and crop videos + + Licensed under MIT License + https://github.com/qarmin/czkawka +about-logo-tooltip = About Kalka +version-label = Kalka v11.0.1 + +# ── Status bar ──────────────────────────────────────────── +status-ready = Ready +status-tab = Tab: { $tab_name } +status-scanning = Scanning: { $tab_name }... +status-scan-complete = Scan complete: found { $count } entries +status-scan-stopped = Scan stopped by user +status-error = Error: { $message } +status-deleted = Deleted { $count } file(s) +status-deleted-dry-run = [DRY RUN] Deleted { $count } file(s) +status-moved = Moved { $count } file(s) +status-moved-dry-run = [DRY RUN] Moved { $count } file(s) +status-copied = Copied { $count } file(s) +status-copied-dry-run = [DRY RUN] Copied { $count } file(s) +status-hardlinks-created = Created { $count } hardlink(s) +status-symlinks-created = Created { $count } symlink(s) +status-exif-cleaned = Cleaned EXIF from { $count } file(s) +status-extensions-fixed = Extensions fixed +status-names-fixed = Names fixed +status-results-saved = Results saved successfully +status-results-loaded = Loaded { $count } entries from file +status-video-optimize = Video optimization: use CLI directly for this feature + +# ── Buttons ─────────────────────────────────────────────── +scan-button = Scan +stop-button = Stop +select-button = Select +delete-button = Delete +move-button = Move +save-button = Save +load-button = Load +load-button-tooltip = Load previously saved results +sort-button = Sort +hardlink-button = Hardlink +symlink-button = Symlink +rename-button = Rename +clean-exif-button = Clean EXIF +optimize-button = Optimize + +# ── Tool names ──────────────────────────────────────────── +tool-duplicate-files = Duplicate Files +tool-empty-folders = Empty Folders +tool-big-files = Big Files +tool-empty-files = Empty Files +tool-temporary-files = Temporary Files +tool-similar-images = Similar Images +tool-similar-videos = Similar Videos +tool-similar-music = Similar Music +tool-invalid-symlinks = Invalid Symlinks +tool-broken-files = Broken Files +tool-bad-extensions = Bad Extensions +tool-bad-names = Bad Names +tool-exif-remover = EXIF Remover +tool-video-optimizer = Video Optimizer + +# ── Column headers ──────────────────────────────────────── +column-selection = Selection +column-size = Size +column-file-name = File Name +column-path = Path +column-modification-date = Modification Date +column-hash = Hash +column-similarity = Similarity +column-resolution = Resolution +column-title = Title +column-artist = Artist +column-year = Year +column-bitrate = Bitrate +column-genre = Genre +column-length = Length +column-folder-name = Folder Name +column-symlink-name = Symlink Name +column-symlink-path = Symlink Path +column-destination-path = Destination Path +column-type-of-error = Type of Error +column-error-type = Error Type +column-current-extension = Current Extension +column-proper-extension = Proper Extension +column-codec = Codec + +# ── Left panel ──────────────────────────────────────────── +settings-tooltip = Application Settings +tool-settings-tooltip = Tool-specific Settings +fallback-app-name = Kalka + +# ── Settings panel ──────────────────────────────────────── +settings-title = Settings +settings-close = Close +settings-tab-general = General +settings-tab-directories = Directories +settings-tab-filters = Filters +settings-tab-preview = Preview +settings-window-title = Kalka Settings + +# General settings +settings-cli-path = czkawka_cli Path: +settings-browse = Browse +settings-thread-count = Thread Count: +settings-thread-auto = Auto (all cores) +settings-recursive = Recursive search +settings-use-cache = Use cache for faster rescans +settings-move-to-trash = Move to trash instead of permanent delete +settings-hide-hard-links = Hide hard links +settings-save-as-json = Save results as JSON (instead of text) +settings-select-cli-binary = Select czkawka_cli binary + +# Directories settings +settings-included-dirs = Included Directories +settings-excluded-dirs = Excluded Directories +settings-add = Add +settings-remove = Remove +settings-select-dir-include = Select Directory to Include +settings-select-dir-exclude = Select Directory to Exclude + +# Filters settings +settings-excluded-items = Excluded Items: +settings-excluded-items-hint = Wildcard patterns, comma-separated (e.g. *.tmp,cache_*) +settings-allowed-extensions = Allowed Extensions: +settings-allowed-extensions-hint = e.g. jpg,png,gif +settings-excluded-extensions = Excluded Extensions: +settings-excluded-extensions-hint = e.g. log,tmp +settings-min-file-size = Minimum File Size: +settings-min-file-size-hint = In bytes (e.g. 1024) +settings-max-file-size = Maximum File Size: +settings-max-file-size-hint = In bytes (leave empty for no limit) + +# Preview settings +settings-show-image-preview = Show image preview + +# ── Tool settings panel ────────────────────────────────── +tool-settings-title = Tool Settings + +# Duplicate files +subsettings-check-method = Check Method: +subsettings-hash-type = Hash Type: +subsettings-case-sensitive = Case sensitive name comparison + +# Similar images +subsettings-hash-size = Hash Size: +subsettings-resize-algorithm = Resize Algorithm: +subsettings-image-hash-type = Hash Type: +subsettings-ignore-same-size = Ignore same size +subsettings-max-difference = Max Difference: + +# Similar videos +subsettings-crop-detect = Crop Detect: +subsettings-skip-forward = Skip Forward (s): +subsettings-hash-duration = Hash Duration (s): + +# Similar music +subsettings-audio-check-type = Audio Check Type: +subsettings-tag-matching = Tag Matching +subsettings-approximate-comparison = Approximate comparison +subsettings-fingerprint-matching = Fingerprint Matching +subsettings-compare-similar-titles = Compare with similar titles + +# Big files +subsettings-method = Method: +subsettings-the-biggest = The Biggest +subsettings-the-smallest = The Smallest +subsettings-number-of-files = Number of Files: + +# Broken files +subsettings-file-types = File types to check: + +# Bad names +subsettings-check-for = Check for: +subsettings-uppercase-ext = Uppercase extension +subsettings-uppercase-ext-hint = Files with .JPG, .PNG etc. +subsettings-emoji = Emoji in name +subsettings-emoji-hint = Files containing emoji characters +subsettings-space = Space at start/end +subsettings-space-hint = Leading or trailing whitespace +subsettings-non-ascii = Non-ASCII characters +subsettings-non-ascii-hint = Characters outside ASCII range +subsettings-remove-duplicated = Remove duplicated non-alphanumeric +subsettings-remove-duplicated-hint = e.g. file--name..txt +subsettings-restricted-charset = Restricted charset: +subsettings-restricted-charset-hint = Allowed special chars, comma-separated + +# EXIF +subsettings-ignored-exif-tags = Ignored EXIF Tags: +subsettings-ignored-exif-tags-hint = Tags to ignore, comma-separated + +# Video optimizer +subsettings-mode = Mode: +subsettings-crop-settings = Crop Settings +subsettings-crop-type = Crop Type: +subsettings-black-pixel-threshold = Black Pixel Threshold: +subsettings-black-bar-min-pct = Black Bar Min %: +subsettings-max-samples = Max Samples: +subsettings-min-crop-size = Min Crop Size: +subsettings-transcode-settings = Transcode Settings +subsettings-excluded-codecs = Excluded Codecs: +subsettings-target-codec = Target Codec: +subsettings-quality = Quality: +subsettings-fail-if-bigger = Fail if not smaller + +# ── Progress widget ────────────────────────────────────── +progress-initializing = Initializing... +progress-starting = Starting scan... +progress-current = Current +progress-overall = Overall +progress-scan-complete = Scan complete +progress-completed-in = Completed in { $time } +progress-done = done + +# ── Results view ───────────────────────────────────────── +results-no-results = No results +results-found-grouped = Found { $total } files ({ $size }) in { $groups } groups +results-found-flat = Found { $total } entries ({ $size }) +results-selected = Selected: { $selected }/{ $total } ({ $selected_size }/{ $total_size }) + +# ── Preview panel ──────────────────────────────────────── +preview-title = Preview +preview-no-preview = No preview +preview-file-not-found = File not found +preview-not-available = Preview not available + for this file type +preview-cannot-load = Cannot load image + +# ── Context menu ───────────────────────────────────────── +context-open-file = Open File +context-open-folder = Open Containing Folder +context-select = Select +context-deselect = Deselect + +# ── Bottom panel ───────────────────────────────────────── +bottom-included-dirs = Included Directories: +bottom-excluded-dirs = Excluded Directories: + +# ── Delete dialog ──────────────────────────────────────── +delete-dialog-title = Delete Files +delete-dialog-message = Are you sure you want to delete { $count } selected file(s)? +delete-dialog-trash = Move to trash instead of permanent delete +delete-dialog-dry-run = Dry run (preview only, no files will be deleted) +delete-dialog-confirm = Delete + +# ── Move dialog ────────────────────────────────────────── +move-dialog-title = Move/Copy Files +move-dialog-message = Move or copy { $count } selected file(s) to: +move-dialog-placeholder = Select destination folder... +move-dialog-preserve = Preserve folder structure +move-dialog-copy-mode = Copy instead of move +move-dialog-dry-run = Dry run (preview only, no files will be moved) +move-dialog-confirm = Move +move-dialog-select-dest = Select Destination + +# ── Rename dialog ──────────────────────────────────────── +rename-dialog-confirm = Rename +rename-dialog-ext-message = Fix extensions for { $count } selected file(s)? + + Files will be renamed to use their proper extensions. +rename-dialog-names-message = Fix names for { $count } selected file(s)? + + Files with problematic names will be renamed. + +# ── Select dialog ──────────────────────────────────────── +select-dialog-title = Select Results +select-dialog-prompt = Choose selection mode: +select-all = Select All +unselect-all = Unselect All +invert-selection = Invert Selection +select-biggest-size = Select Biggest (by Size) +select-smallest-size = Select Smallest (by Size) +select-newest = Select Newest +select-oldest = Select Oldest +select-shortest-path = Select Shortest Path +select-longest-path = Select Longest Path +cancel = Cancel + +# ── Sort dialog ────────────────────────────────────────── +sort-dialog-title = Sort Results +sort-by = Sort by: +sort-ascending = Ascending + +# ── Hardlink / symlink dialogs ─────────────────────────── +hardlink-dialog-title = Create Hardlinks +hardlink-dialog-message = Replace { $count } selected file(s) with hardlinks to: + { $reference }? +symlink-dialog-title = Create Symlinks +symlink-dialog-message = Replace { $count } selected file(s) with symlinks to: + { $reference }? +no-reference-title = No Reference +no-reference-message = Cannot determine reference file. Leave at least one file unchecked in the group. + +# ── EXIF dialog ────────────────────────────────────────── +exif-dialog-title = Clean EXIF +exif-dialog-message = Remove EXIF metadata from { $count } selected file(s)? + +# ── Video optimize dialog ──────────────────────────────── +video-optimize-title = Video Optimization +video-optimize-message = Video optimization for { $count } file(s) will be performed using czkawka_cli. Check the status bar for progress. + +# ── Warnings / errors ──────────────────────────────────── +no-directories-title = No Directories +no-directories-message = Please add at least one directory to scan in the bottom panel. +no-selection-title = No Selection +no-selection-delete = No files selected for deletion. +no-selection-move = No files selected. +no-results-title = No Results +no-results-save = No results to save. +no-destination-title = No Destination +no-destination-message = Please select a destination folder. +scan-error-title = Scan Error + +# ── Save/load dialogs ──────────────────────────────────── +save-dialog-title = Save Results +load-dialog-title = Load Results diff --git a/kalka/icons/kalka.png b/kalka/icons/kalka.png new file mode 100644 index 0000000000000000000000000000000000000000..fb3c4abecb0ef79ce73eac7951a4ffffc3535571 GIT binary patch literal 4284637 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ⓈbC?NK{Xt)s@3CrT*VLGUa@^faGW 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 ?^gHJxFwFH0i17<^5MCF)kH=pKE)gN9pP24 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⁡^h~JA-*VNClubzx4<3TMxNkrm!yl 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

BH#wNOdyIyrq3h&Uo7(*Q~d>jb^$ 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

nO@Ah&&I!K0>D7nk@ zwh${szFL^(N|6j^tbSW3|E?4JBv-^5582SP-`xiESZ+{l?lm4AD)}&1VFx6rBvpvkeM?YW$TqmNYDiqd`F*9D zTp;&AUK3SkwCs$4|m#hc*@(T;m?F4(F*9 z6tC`cZjdX8WP#|!gt_aoomdCFr^R(CAVxI8JiG)i@_PjJC;kw_MU>-=FGaG%W}lOm zxb%ui-0fp>ajBG^?KN)xL)?+x8Zl7%u64V94Tborx{ins>{XVwC|Ca0k*Bl3(Jz!>-F0iq0-v3Nz+YiyxzkiSJ zs;X}rZY{kq7ffXI6s=Bg{kf(07^JelpI~Qznztk_&Tfeh@Z$QwKWGGys&XfU~QDTevmBe*UzAl5f_OdWK;S*BB{^a zPMFJ`fG}U8fO4F*pjnZPFbyJ*pd|Ty6NC*+D7BACFp;tBcy|u|JM7f+uh5a#*l#C2 z=zrzZ{z+(2{xieujJzf1ix_(vviGAL&Ay35uqIOy(?;;v+8r!an+g;-o`99~$=g$# zGJE>-khh+7s6R+rI%-v$#MtWwc?^sV#8m?Cjb8juBQD{4XpE2M9f|^+k>MfP&fO_n z_RHuWGT134d;i2$GT4Nx6Xr!^#|1ry`@$Dit6Adp&4Pij(3eLvjP zX`1RkoHX}#bZ+OufYsbq|Bk-N%X5EbM<@NP=*Mfz!3TQQ`6J=FOlaXUej#Fba6QTJ ziXH#6B}-yqMhn@G@7)&{CVeTLcv{bdaBB4JwkApk8wy_w%eCf*=_~xdsaHgvj|yw7 z1HjlkUoiY(y!y*suTmc$-4qDJ<&A#xf>frl3k!t?d92^f@|6vDAFj)3I6E1XA~SSL zbZn5`t<|7KzpGWbOMo-4tXS08(13@3^`Z(EJ9FrKQoUN-{CL9(J2d_zw^3aYS z|JxB;yBhY4s?1AMB?ki=4>qw@jay&BQ(WTGC*s;+FCN+)!CB?{o8XX0~wn_34J$n;W0qlHUFqQ^mfr-xvM%qH0@i^0-r1BF>hv^W8&lMVtf+e^fPV*oeHZ|HMHBvse9#;{eF~|2L>XVJ_A~;|N4BD zfyb6(o<2{q+7o>8t_|t~rRRi{j~=*KLI&fC^uEFFFH)drJVz&|1)?!5%W)CC#%nH zd!<}>HT`pSqx;KN-}!6wX|3t0Xp^GkbyKd3K6s3^loZHD^hn#U-5GWhWhR zJrXFU;C0|B1x;(=_*ZIYB*G*4b1C|+;{0mLX1>(INRw#Z8{mv{_GovU6N}k@<^hDG zZFH70pEH-Wyu#+TpZC6S`lUd3-!aSDJx0?{+;@D@no(Tfqt{u{E1q2#o&DD5bLFbc zs8Vyohrv38NCxYfhu(c)X-)OJC0>2T^V0{fzN}u~`m|{-y5+*VfUjQT|9#}xIJNB7 zix`o@eKJfCL2WKlxZ(CnQ2aej*2#4f?EoATHF5*?EX|c#%r4Z`xtkqWK$x6?VIYyA zV(j|E#8cS?F;hCpv6ErbZ~2ImN8q@=(1QZvFNzzE5i7J?nTvw~L{yc<0$-`c`n*Ul zY~FhHsE(=x9^M<@Y7>{B#|)3pxUDpnoeU(oELGr;R0)k`&J z!6$v6G#TL2RH5;YI7^CEYO((pWGKR>ExLKekANa9UvDj@SY5~2g-zi?psBM|EqKcj zh%trj?#9FWn#1PE1*X%kao4h1?~|NNpcdk}d#NhU67bs9Z`9zOyYwYgV)Sr+RY|-> zLabqHJhBe43F8jo2rd9G>X~7YNTgH305fJR!#Q!r1w8T$yfaHZ;IfOt14IVIq6d=% zQ^qhT1Si1A!iNG-)DGMv_Xn2_&rg5^uH@1n2c*LQ0so(qRZF1hk+*{z8@T#rAGHt| z6R#OIQS6+i!P;p_K`nE}}REH>05h6=ZeyP$Q)cPVusZxD^GB3%g3QZ)Rr}E^^uKSxCr>wRm!vDh?p~zpDV@Yj)vd z!Bn;7*J5^(P2v)Ms#@-p1QV;!vTu4rH&HzhrcpB+_A$NFAK<0b){BJ$xhd7E1v*g+ z4RAI>s095#Ei9F~Ol`6xN0Yo##MRS}??I#K`*{Ju>DrQV!^!ECsp%oFR5YFR2Sn1* z7QOOMRK=B|fGcbN__lJ*tqQG+YG5s&{Pp4(+G#E! zzOM1w!2tnG4C`X0*-7iN-E^*``1LJ&4I6#$hK{9lnaOhqYC7Jl1n=M$rkBXAQ_Sar zQC2mSZN9rFYK!3tCamT#wt8m33_!Y}zRQ#{~!nxVjnQoLE{~`ik17Doy~H z2Al~G_mjgnEy5-KmOCa&pM3PaZe4=I@zt(vDWD^%$i<*JCk?s;o3sSZK+OFLVS7->bux2(iQY$d8V_3%9`1A2QDB;w-5l~ zIrEH$NI2_FdyLNm-qSRMMgB&5lJ4^<$5D1PTW`bkhOgUVp)7b_;Ntf;8m)TY8usZR z{lS?n|EsFLxj80t^dhFScuYoYY@YaU>&%4ieo;;Pj&S>DfyvV^{6D#%rEPwV|1j75 zsxuGZ^VWx0_7yg%_;p+^wZ~pdyG8adpoHgsvKf+0WbULZ?Pj&L)4bWuuV0ymJ)oxF znL)coWrF!Ap{DvzGd&mib8(^7YFaw4MlOx~}bIlA5MfG6U8OPPo}B34TONS?mXB06)Kt^l6V!o|}6*pJ*5 z46NK!BoPjLPu(OYVu$_iAmyqVOMnl20?lrt9y1`lvum*d0>MO_KpZ~tMF>Q4OhcM@ z4wx7ms@UnWlaKV6BdH=bN2q}-DZ)pIb%`_(3-;cif;oD( z%FfMPj7_1%R2x%3o|;P$q{(rdi?PFu|S8ug|4_u@1BJ2~s z&Wf;-|1W@v1q2)re_CjGFq~CcTw*$yMcHl_<*>*8p_``^xLDM}5`gU~NevW-vp9AJYtUgsN z9MXENxbR+a@{zmN@JQe6e0JaXghzUp)=(+6=k4Tl!$s=AheqMQJ=X;kt;oPqs4+RB z$h&sWe;UOK7#J6(l8`C+6k4%i*PRoc&OGRhpWmXqfy1%`GEPXgBn<}MQ4B`Ir*d4+ zPo}ki3;LZucJH4IPG~sh zTn?$LCREcix4k>dg9rq*_wzfrF}mdHx7wu*AZruN;%`?05HUPpeaQRKt!r0x4xK5< z8fqH%zI-ZYSI4;<<#&k!aNY(O{ycJ zIMws^w!%v@PK?Je3)4QO7rs(kUBYyloTuPZ(VOD6J1dzKqMhMC^j&$CFZ<_iq1vbUJ{Eu2bQ6p8=R`Y%IZ2=1vvwP4jQvUfkMox5;-h;$h>C(c_i} zxXtBx#Nb%qx^k;^V>_6-y5UE(C%7M+Kz4xoju8&K78lC%EM-PE2tH1m@~)7!Ptvn7 z52uZoc7T9d#5~N@*n$u$IbW{Gm`ba}us_s8gnj-#p3-eSPY6CO9v*3TIFKYf_-SRU z?WB=$5ig|41V-bWp3cMZAc|q+xVcWgA{&sx{L%n0BF-1 z_{%Iw_4qyg2#-brPq=>|o8x;))OEO4RY!GTb60X4$@CKIz_3iiWZ3|^1UK!*2}L9Y z{6UOra^pVgJ2Nq)BFx!HRwuVQr#*to`mWxW5!C99aRctcmM}72j28j?3s3?)3)_Os z90EKxKnNIKl6;#q$GRhiOh(RH4c;C8IT>rM{r+}@tST@omE7Vh!8PR4uxok_66CJD zdAszO0X3dBFcT;3M19A&O{x$e6_R>@!fB^ii`j#@y*_3Z8tN?}S5U>}fGNluAXzj9 zhKU5aD^*m%-HF%;cgY6r2Da5d90;`h%!neq3@hVW?Sia$xRGGNN3-r&Jz}%*^@U2J z#f2-y6SX^mDPY;^Mf)Z99yZ{smNsWG#4XYQ>UowT1`ulKbl}&1ub=VY1LoBNa+TjE zk!S5{EJYg@nm8#&RRQUGf`zE@yZO{=OQrAgeK(NgFXt?#kfVtWWBKH;1d~4A(FWJM ztk>AcmaEJq$d$ZZMwqX>J1i1go1kL~0;#36I5wWZncIf8fe01E50k_$E{vGM4zcje zx(|2d%g28V&(&-FzB~8G$}g>df^F8TyrprlvS$0}xL?2&*Z8AR z|0{>!Nq?&<8Ofj_YwoKOs#EeTCiVM(WuEJ#$S+@8P*L{jag&M6{H7C0$ufTr>Wis_ ze=RT^($Nz;g0#agJLxAvUB9GmyBs`q{sqU$M|RSFe3>))eN+Ezlf00~*SQ>)J29LJ zuw}kF8UpkmI;EKt!ZA&E$VZ35a3?%F*N0fdWsY1#liza@+P=1!^3hkdYPubsi4GJ* zl?l)N${P&2zqYDc0Q0s1mU+0Pvh z%i~@;sbQOxskjacz0H-^68z4hO1F|KYh=w1?O}(T=CeNiaP%UahTj^&y3|3{yR6#3 z{Gv<%=2m4TJiNNDZp208cL-O-xaMTza4_$LUUlVJ-`5?ze$`fg8|ga&=zUXD6Y|J; zluh+sJobHgXr2tWT;SrK*6y~4<$`e~kr6p+y;LQgZW%QMgmTVA>EC-p!AgoDtOl&g z^3}TR{y&b2PDI|~#ptZbLv#Pm8m&Jvs_b=9dfPu!#=A3X_O;%vJ- z68CdfUE=;0QswMW($z*CoBP6n)M!%~)lXpVue<-Nw1oz(Myr{J6|V>ei<9&HG3y_l zrRa8h}8_tp}S=TbMD=Z!$(7>Iq@}zvU1L(IYq3s?pkslCnUEkEYU}9Zy<- zlVFW7Od9h_x$a+Gzu6soj)^RPLuc>0IZ= zW^-m!`@H67{`!{pjaaz+@N;NMpd~)ub(A?E{gbE|i}0yjRTg>H!>@zrmr5>_&;xb& z-JK1b(%VJfUD9$hd2770Y`v_Bp;EO;U4osoV@uS_j^Bf`ep=0rep8!gEk<(vS~TNm zo1*scK)~tWf6Q;PxFQpX1|7q}e`V7A-aPIq?CKl)>zz63s5w8!|Kf!}w`hMyz~uHh z$m{lv9qpUhSTR4}_hp6F^inc?=h5ZHgcn`H3k{f`Fem`E_US$J7_FKTNb*eoMf2wC zz5n?-Y+W}~d-WDn*Od$-?aki;=?fIGfg1ysc!9IVePt8=N1G`=2D^yBHEHAn6A#l= zZQ8&ZuS}Px?U7}%0ws@?%vC`IbmrJA<$i=ReyVWGbgG)^G1>a=c z4^*aRFs)o>;uNVEyTbmR#h@i=yNBQrn5VtljT(dvD4$nC2#%I+2R?)LG(`HPwYxBPDT4`IvR+FhzFFj}1Agy7f*6$-no36^l zGuGWFR2ejO;O~!Y&cPy>hTd4Vr7^op zrHxnf5Ki({@O=nj2Y*1O(vDXRd~^W@VTo{e8IM2~5j-TYiNQ}I-O(ixB|>EI+r&n~ zxo3uK@z_S8STT25@cW^1XEC^S@hdeBV#fzLd z55qDZdnjk&^3{OSd-53i9ovOy-q#7$+r+|!h$UdE@zkAIv<$9<;0nLoTL!N`H3uK5 z-!4m=Hkb&je?s+L^O4zDd?yu;xzXgHsr1fXW9YSJ-T_5)nCy*E9Sd(U$~K9WA63zF z(=KFXROswV7@|TO@q3d(Ndtwonclfd^^+$pogXcQhSIp^=blQ@brWW#vTBj}84zp5 zV+(1e;uq2QnR-Oq);Me;J>1~^8Z6mFXl$?x*%$e`AWI3*I{A`nL$E)3jZ+Pn@b^sb z%s_CFXiDRjm_AZ&N8WZX& zbUBZ@RH%b}jS#N3Wby5m;vQVgS6>)~g4Srt)Dc9dNXD45Jo2oZM zgXiSds(%vP+`g)B&rCYH`l|I-kgjFkw&eFi4p0sT1wF4$FDL)|An#_?4b2#IeYdLYh@8a0@>EnOZb0Q` zYf6-=j!Oy(aHA>Csch)PN#D7i{iPS=Xp(&P>;-uTdVCfx$t{kk8pt70brhLV;GcG( zN1I#f(1YD3M0%dpOL9$k8Nu|_B1(}UB~5~X5{Fj(DD5|FP2KB@f(KZ zVlIO*3nFt(qqVJ$InYWMs=++dOU{YLH1{UIfe$0CIO^*o<+6Y8`*m`qL&lVMRYu@g{BVW7?M$ee_ccpGE0w z+@{pma^>@&c&RPPCcsoPTIl5b&xOpAP)PGMO^Wcd>Q)L6WhYAPn7y4O-uuHGr$;y? zFjTdfrJ3Sv4|naAbhYEvu6gCWzP_jz{uu-5dtOs?{pEEbn8>%Q^- zeg`^xp}%R!Hy}Nyv#F2n1Y;c+?x_6|a!u4gZ-jN~t1Rwbj11yivP&%u9hl!2ru@fJT=*swv zcNVrzoFNA94>&uAaYim-VgIHPy+VS<&Fvh#Q-s56zuHpFDe2vgZTDD2FCDbY$7fG& z(AUSfYw$C}C^H6V@36Rs1>dQ|pUwGbGwJHZSu@7b6~tYoixWE~m-S`e`>m<1&{iF< zGSce5%vIh1Apz-|_V)j!OtJ?|1x$<12v_`SoKp-K?U?yoEa?}Z3#IJe|FO1lCU}p( zRxdnvGbpZxp)`Lzi+46@9oNY(pxeFpc*xW#TW+W~`%AEk(x{ihUM283`^SWietoIJ zf>aemL6VNCKo<= zAOHGdjr=do$-lo{hMsx8=tFJMDZz{{9d*%)r?hQm${f6;{ zS2L4G3UVL&WZq=4fb{EM4Nr}ofzFRQxD z8jfqSqW$iX7hb54X^jt2W8fTl{(csKh1pU(rDkA-C_Ng35yboGuj{(~28EMb%_U0LU7>Bk?XUCsZTvH2O< zF7(!zMz#gExx8H%q5a-d;Zv@**%ZlbjJ^ZtlcTW$b{tL>12POKX8{7(Rmrh|_16^_ zzrnF#fMam;7l#W6bkMvcLer@J?*QKN$1hx4$Kio~HDs;dNDv#C(Y+eq>h#zaBy9!se{2yRwtheo#(fxwcstfRw z+Tm>=6=OI^A2o>`+Y^a)E3X4SqBwl=v`e*OtsGYOy>rLS#?1x;U71XGSxA4 zJnTlM{LYa(slivr28nC}AW82neBK2{Alh(x8~_NsZ!JI^K3Fj=IbEO!TOw^v5CXcQ7~?%Ej{u()ICD;z$*QdcG%?m_7i89xb$%qC zG^Ro5(G0s|b<6iz0g+|LWVf=Th0bMM1hG6;6$u^&bb{+kCEx&}Om1R3sX7wwn@JmQ zwnjWOxWy#ivMxOmyCkwN&8DrBkL$)xAzg~v{|^^1Wm7X^&-Px?kfv1bnr=8qt29c~ zxP!J?!@+qzJYb5b(P=~S4#;yEYY*=E9GDBEaG9V)YlxrpZ-t?f!{&5ZDJ$Z_S;Qvn zqYJDQ6MR)qEz)qjtd^iBZv)sK((6p+?9YIY*Z1Dkf>qp=peU&%eb)XB zOJlfkYj?bl4_Xie!DRog4FUlmkh|dO)*$u&>OH{FJoF9-d2>cncbi}8^up*uG*|h! z_|$N`)}$kSExn7rAdFL!^|8}5^cr_yh{md3$uRTwI0uGJT8#L-+InG%^4RLE)RQ6(TxGBpi zxU}x=d-)&3qTPY&pbBYoZ>u&|Wq``tnF*6K!GTl-7eskt!;=ZA`zb-V1p0zT?mVfT z@rLfv`)ze?fe#G7yHDYx?}dO8>d1670086jFVJ`eQ}z81(Mc*w#o)M`=)KY-a&odT zeW|r>>PYM(78P=i>=lypOhcD)P51WM`KJEg@)y#+`3*fze)h5l`jGDm`dZiCg7jlS z+MlMEg$j_3G*n0PEP!wN6mC-A2>E&i)7%k*q-~}lPd0u3i{_Ygo>1yPK0CL+^kO&L z5Sm)s>^AUgX87~j0bjZh25&vXWQ)5pD-N$qruS62HT@AWGkO>KH<(in>OvX#12hlzop3Cj0frK+3V{*rd(=dBXQZ9)hI++vkYxnyH7Pl>i6NAs(&*Dul*Ja%PY`;EilPWySUp`6qZ>pFaU6`Fqq5tfd{{DN%-p$9$=A^ZzKYRXB z_bSs&_8D*HGC!OE)Y$3>(0tlL;OD5-JBzzdY7b&A53sARM*jr|^=9fB&3s6(HWqPT z{k1zu2Kl`Q@tC4`<{0uRkR)n-j&utCCRC7yrmAIr?03*>*yaX$-iebcsBTsDZ+_|Z z9t!W!^@b$29*+5N?3ZJ}Kxmq|)Gm~DUodm|p%I0;Wo-+gA2KfGV6d|i1d7@$?w-TZ zn(PN$ZxF)ix23<;1c$764 zxGo#Ke|EV3ZM5Oil}?m!8e`{2rwz5m`(ws-5tkY}R(+yu-T!d` zZr}ty>Qq%Ds1-w_D;FcxX^D7hnIb%`)h*IVjyP3914j&A9WD&WgK?Eh1MB&nsQp*r z0i#GL&G>ROOg`F7QV^}^(1aJC+j(e4nE-sdHe)63%0t#cp^+x(!!$NiRSjN#?*K++pazYSWHZe zB90h+6zxq6)Uuw06jmD%zbrPnQ_7DiIq6q++@S~+3RuVndv`f2zPS9p6 z-pUbh_5mldS9lQB@>H9)>4QaVQTj^3wWC+CAprY043BYZV`?^Hnpc()cZc`0&hpF|_m+(^SrCslFherZC?F2r-N~Ly? zl+hCsL6T$-w@9$)0jN-pW-;&wbWI3+wZfalCb)bLqi^m2>W+WF+t)#xqa6OetLF0A z(6QTj*>AUr2iIW{$X;0PbG%d*u&+`~AQEpC%EQRaAozMF9TlPjTaHI#3sbFp0lzq! zg5wd~YV|VX#~rO0xMazItI+REWrVMa}fS?`G}s54_9-_Gg7) zE=J3fA_b0@UhtCZp;u^c|HW6+)6=sK@@wX+(ZK;*MReR+RSdU?wBGZIZNVh@5onbm z9q1Jc|47p3!`SahD@p7 znD9^KTU8Ym_4NT^jXV!Z(KP%eLPX$JTa4~|EY=k^nq4S^$_6*_wMfr%bw&tZb!S`H%{1*J$#gM;Bh0I zCp5QGVrselixx8nch1}q@1LrVt~X}EIf11@9*&~3gfZC?COyYs#v%*Y^xL8@V7uKk&z4&n3trX|(3ne=9{+&8!1z18zfW zLfW2hYUQmNUH|dP+u(RK>N-C<(1^f=bh<-F1TtK!-&eCg+MEux2kb1&_gfi%=LuPg z!Hj6`%A@cEIjey&2MdWHUw3!UL6(>2R@E&#)OUJ|;^T73b(&hqGc{I!?ft*GFPzex z`NiMn+xXzb6J6<+k;;*_gf0P$NSj$o^*!%x=n^^KLxfb%Sr%4yZy%Y#-9BDkHy8tL zMI6zS?=Cw2i{57-`Pm)0X2?5+Pd=JJQaq&_p4# zk|8ozIwDaha+I+XO8oA%`+Tqe^}nv~_jAzR@4MdhuJt_kb3e~>uOoAX)7{4ct}fA< ziRzZ@>K%5zG^9&8n&!P@B@Rug{%g@82G`*JojI?q*ECv)xh{%ct*U7cp}wlCO zI##PJ{9Xnb1V85OiGa)b7Yx#e7QTcy^>gpm9CuECwdLW$y~&%Cmi3RT5vQSp?{!v8tkD@n;A~--0Jpm7{tzTqr(+VwPhTnts4HnjY2bOcFlm=WFOvaJ8 zvQ*FwWHSQt>#t48;Xh)+$%RY>u@)$j>9C-5z!f?~)P@giT!j!>3n2%{7>>3ui6BVg zgdik>iom!hnNJgGV6x(8RqoQf5#N(k5PUAa1*t4f{0`u=Og`j$n=pTmbQwk}iR%+J z`H*0tReOm7UfspxRB$Ok6hKpi=H%xO_T9@#$pFxx0oxsj0opR3b)DcuR{(Xz6UQhI zMTg+g8zj0X*CEDWKAi1MgiHU!GDR8^PBRA(N_`DVhM%5 zT!%VzMuQ)-PB6shLwI;`KB}gv41fIp582qiB(Rk5xAKIkitp9sK`zI@ie(l|4W`ko~-k=L21Xsj;IT&&SjQJnYZ9TrVk2#uAhk$7?lWA!{ zqX0_;o^$YO6sC*-DQb@}I?EBF@{go(7$5m8EHj+x1}=${i@Hqbb=4$XAT3RIt6rAE z6JJ8@AQYdLj^1U)__Rg-VxUk86>`=)`7kQ&R2WWQUa|R9LFpWQU9tG3p}Vj=IZV;t zhpZ4FDIYHTIAJyEe}41$t3s_$AAa6P(ecM+bFzi2X9WvC%q;ydGkSGwOfK1b?%!wD zIQ8Prv)pU~%dUTJd|aA(J$CELzlzz;h`Am!Z#xKx8R)B)7p3oqz1RM)*>jU>I?^wU zqgh_om)qb**WHzue|gsBPOlE08=QBAlMMf3Ch{`hzAe1B^j`Rm5d6Hs*l2>-L2x8V zebd@*kK%v8KL3wUHx@bf&P%h4oDWWtF-V3UY5_q~Ep}$)`)Qz{m+Bx+LUE2-(y}un zptZ-2OF@Z=cDvg8%ahi#Nd6dY2T$YaTlMFJg2=n@S@3~~qZ3`Ta^RBQX?pEQmS}TI z{SOw#Q+?=6#$+>S7MC<*^~W_3%o$FkyX!ciC^GP#eG-vx#J!SR%qp$F`feLuB6lVk zzddf-|5hDmzm;Tv?>-PGB7!`$*stkOAD}A#saFfWW7$Ug8@^fmIO3yl1eo;j+&<-Nr1MpRoNE-aW5`CcLTQWuv-6 z-lx^y((V9^-R)Izrlmms5g5V}Ltr=yQq!TV@k^K7{)LZg01PdfkAc%^1x=On<+9p&S z(dwSwy)9jox3f$(v|BP=l!jgx3neF6l)uotoS%umM*W0KL7*)@{a2mrqlo9S+felB zQh>4vd{^?dRF4r&2hFp%kGS-dl16JMGzZp@0J zJ3EM=W{1gN&7KyHui&Cq%aZ0xQsRkfnPyTfVhKL zTq=SYKj~4jJr8eN%apK^F>gXWR|+ zD52^;Ze#x)$i2W#f=CeeRs`a+BJF*#q0D6aW4Q9jaP&S6hRwW2cXlsAkP@!cOjjc3J_X z4}1RoH#{udgChz>N{3LO)nKA1HdfSaCC#-(--wPbMfTqpP`B3Sts6=@Y#)0X2ye;^ z$&KHVUoA27&)n>>(UG|hBvfX43yVr-r47}s-oAaGu=ILT%;~gwtz|BCCU11t@pR(A zL{bWquNBpCGz-M-6JND!cZh6PMmR0>ly&=RXOy5_XE)w%W4A5GTIy2-?0T9_Lt#!r z8_z$wmOxj5Yxg1%wWptH4n3b3^Dj?G#~P1ad}I{;#zu2 z0d`RLHlfYc2aqzpDSl>Tf8D#~d)X2O>=)egpKSblkz*8egWg#05$`mjp4n9DnvuBl z@cEgb6sgNq|Ka43Fj{}HIgy2kQG3;?yDkKaR2*kI_B?Vx0I9`JH1Qu$df#34$?eE*`OH4U>?I4Ni>`I76&CbXX;0LVca&AM`kT$%{&UzF! z^f6&D+i4dSX=9>f=*Rwd0h*l#DOx5|DAT!LJ6xyBl}h@C{a$EXWfeK#k;S)kaI>Bz zR$Q>aBe|<{W|lmRaW2362?mASU87F6ofhX#8+m*5s?y6`(a}>dH zAkLt@--uGKR%g4AQMRsFdFmgAM#VYw`{nR=D@hoQijQ3=Hl6wR#jnGsR?v4wjUri;G2*^bs-qs;tWit2_3b<*Tve(Qrh{$6z5DUgE9-8im}O*KoiSf2X6Y{Jg~wAaE&>WW8vEtZw%HASuX znva3-&^tEgTN?%3j?uXn?-6{;@Z`4j$U!`+%p#-XQ>#OS`tULD} zU;pm;!^*1N*+Wji{y=1mIi};3HQZ7h+eA*V}5hu@XJBePQ?|NbMk+jyqy807fATR|H8?T25NY|ahpo$2y2{9JHZZRFAO zrUuD(O}ftGGt(#d)W2op_+T|2Fd)nAwM?rmiqHMWzw=S%-k*vOpH3{oISt=iaPUaP zhbpV2BRO)Xk7>0YbdgV63+No>+&vNnt4Z{Jevvgy`Y|{wuU8cd&NhA<<=C2L)>{}9 zfEV=_@3Cnh&@st;%dtw&ZfFb2N^neosi6kPxWwTfU?tFwhMme%k;G2QL9+>(EwQQt zrLals+&PW?`hxZjunV|^qipKO$Rhl5O`Q5jV*N0>F{1PxEe-txcta;#;8tW37??5z|JDdny@+A6eArj^_OH@zVKcd5m2pG z3-wS6PUU{$n_-Srj#Ibg(U+ZE+^rTb@_}bE9OB9P=1fbS5luLZ%CQaH1$cL-lcMP( zi4d+onzhG`M-K&N@J~g0F}hm;23$Mts@h4`Im&vcP*A4=kt?@jw2I8aheca8Gf|e+ zJ-%tKZLT_bRy`mx;Cs_C^u;e541H{-xS=X`ld7^SOorIa8%#EDPkh> zHyt-ai!)9}yvG-uh)KP9B>1c>DYzrpCHjMA} zqCL-KrH^`0rE%qKh2c62x>lEWJ5NKC+|0+$51-0^D+I6uJ{Qhi_MaKc^dDO|vWF)v zFUhmx+o=~q!tZl(7TJ-TDbl=L4_W=47+tNmbn-==tahb%=l(P)?)F6Y42xH1<)k?v z&viHbxU)vXU^RLk00e08r@SKWEb5}vXX18J`f@ufOe@_Jm^5Cv7IdYcO_;Y7`Zr1T z@z^%KMwF}A0P!q94S6_<_(`FYo&zd?g_bbvuW54upb@{UuHV}TmxBKi&(chON52}! zeeH13=&jY)zy}do=nzldPGgDV6I?;HGVyRVcp~FbaSY@T`g6330e7z1#~TtyMXpn+ zp_TL(6guz|_eFGZVPv=v%oTi#O)kPO+y4E)b_!dn01Ap$4KZ_4b;j!WDbnFIE$4M_egdJ;-I?w*d+`-z0eH66ZNwSW~gN zO#_Zs_ytBPY4IN$Z)mskG7ZshCZ$RyYBfZ5{K6Med@-kZSbQI5OU0#5irnz8P6#@L zlg3eVdtNH<*ycZunM^7 z_kLMfL|1e$|0*n~N#-{EQz-#RXo_EoN4}b)9riUb>?4^7EGQD)s1867j(W*oRVhkur#W(%DL zLEW&o?h{-19k^6!yTS}fx1GyUJ}RR8&FYd4pXd}>m6|88I3X3h%-vnx-4tQE;!NeH z0H$)l)W-8a=4MZyZ#Xu0sIjHIGEe#3i~knPi9R%mSSNdas%o|Oufc`=U6I%OkDq() z-yVIV;FQ`m*G(tRp4HR0`L^Ui=0Ozgcx-H9awAF#ONps`ZSuISy-BQ8L>Qv99qq>I zOxG;od;0bsax_(aT_Ie?b9YB_1;*cX)s3G`rrlhpIfSUL*kkLlh)+7GZMoi_sIV}pJk=K$SYOSCjGS4XbYVSbX|YgkA35Pb zy`qx_>*yA3CwGQMF}4O`j{sFTxmtkEvskFOO-7c^#W-SR06SeEAsv(&1A$de*d?+@ zqQ5E$=?C$}EsIf}o#9%!)Wi5+8@TUadX`uxQbl4oeY4?pl)&br9UaE%S;`o81_?(U zwZ6f-pwMAR;mpW`A!b$f=-1;ZU1hczCpK1mnCnRH*ZOTbyJq&=hq+^8-;Vv3etT*D zYX7um%eeom1sLUt+*Aldoi4>hJuvy`<9$%YE3!AsY5I!W-K)|fyEKMRj z^gD)X!=ydCpn6dd`zt>4g{`EnpqlUcsA_fxA4I(h)(P-VNJuhlHF_h-A7~Vju-eU% z;eT<>&-`4yVdRUOTt?6$!gxTH{OqkQb&GLsY17z?^X0okJ6nYrcewNSc^->g{{(6| z``b+D=|&+ZHzHYFbTj&+pN*NtzbpL5tCqd7o^C6A+BD|(3CFJB)aBo;@bx!Qkp9`+E#xw_AeY&Tdp6MAHBh{Owf*ZKcWo?TaOF?~1r=eh?U z6^=}+wLJ9wzHyzU5fy3^U};W%)2|$+b9Zc;(CkankMgj~Ca2ZhzrVfUs7Hkc3h&P% z5@m;fd7^jI^(LYQLmi5%D#!$~4JJ*pK<3stpDN`C<+XOFJ{L?o+H?ehxr6}%|3aI2 zmcmJ;@7;u~LRTkEI*?vg_QWorDDC8CrBSV!{PMbh*=4havvG6(1>9=h)_r#F@`|`! zjp}r}rzOse6Pk)Xawj)&4rtak{W~k+odM`5?3py%t$|@e1$Xmvvm;IplK3U}qv(X@ zE&1zCROuJN)sSz@-*wkM=?C!O3Tbu5703RqNXKw7=*XA}PCywTBFwgXWzm!3RH+{m zk=GUUjMUl8SX6oK!M;a5Aw7kHUvPPaonnj!L;RH>k+2TMMb$w|$yvK$^P2@0Jb1I! z!Po5d*~~lUZcf{~NgDzh&$_KpD%56haBF&)wSv%tq3|vw{xA?EEpZ}B_n=!}$A5uO zS9R>nEt|Q~cX{==@9dAc7jMruS^51pzBl{fLq3*t(T)}SO<_&!*WSCan3A#PB2Stv zSdB_B0XT}0jD43LBXpde~lf+&k(e9UX=`lex_xXVy(o;sZAXA7p`g5y4TL})g5i~3y zh=VV<3k3N@RsO%OhmY;9&HiTE#KJ4W@Jgc&l0xkyL0 zjynmiyz2h!{ybq9CUDbs=QX$@XavrUCIIoO@$b!QI%uwh-e~k69tCn<3=WKW!3l(z zz;j_O%uVgQFm<8~ZMAm$YXET-eS|*ZczKNq;u`0US5z zEru?=UicJ2nH(gzq@%x40~8=`&mlj&*1@)dxB=+~rjLv$3Br9qknkcP_DRgtpt7}{8ho0Ygj&$NC;?i z=7~csans@n;xtxk2Np-;th9J_dNpTqfOye0s!<^e@({sikYNGd6evI_cs94D~tPxa`Wa^YT$AkU*2n}p zje@8tVbEwVj5rGlZ=X%rCC)0q3veOx9{T?=y~MR)(Wy=HTqMM)CK0&^rXA)W_c5~T zPQL)fI%ci%y&IITBttU>Z-FVS>rQcq*M1)@j&ujMpoBEn#7$K-U!v^Pj`hHLB*h6I zploqmLz+uOe2rZ1ZW=GNQBP!MuG=ZNX_N96o9(yeuMQYzdHdCrk9MAy`!Ln4e5>ok zj;VhirpGduDPLW2YQg!@SCe&4J;yg|i}LOH0nEkf0=_wg1cCnE%%Frfrz<6Ziq2>l ztkXxS3eIFu^Vt5XvvNbrg9c&quf8HnVHeHpL_sr^aM{26_N6Jt{Dsk(kvFG-{|;up2^WC(agvsP6youB79bbh z*7uBCV=4dLm)5sp_+h^dvUKR=^rwuK1*qJ0dp_JQ8-F*Sg5qugG_X4!Ve-A-TChiP zeFN7@R8M5o5BhPlP8ZH+IUTrRpVr29+G5TL$DtS~o2F(Akt9%LyV1B4+Pz#aQTFR% z>Zgy9=w|!c3{hblFVmQews6J%(@Pdd4(aM3iYAJB8FvvSTUoX2H;L$XER28DEtbl2_1<#i`7KLhV^+m_t(k#T}jZF7Ix81kWlBgR~h}+FDc}pKQ)YuFPP2$P%-jrWqIrtt8t&IOC#l`d@oG)5KiF%8(f#~ z?MkVOg8N` z7Pe}hsLj~4_ir8@a2#Ag`B^br?Vg2N6>j>v*h&y=mRsX5NNjmTY$UnzHZ&$!yCD$i zVE-Grcc`&+Wo@5smPlq_JzyQ3KB}%Yk+*;Jgx2H<|6d0`q&v)3wERA2?dCtQ?L)|& z_wS#gzY>zt>Z)c2sdrd6!crz9{|)x&?+R>NPKC`&X{#^sAuC*3$a^=X>a`?Fsy_eF zcXrSkAxlTbLpLZ%FrPcwf1H10``N3lgD)l~Kag_6ftKz*!y~g79-f=B{8to6aPs7A z$A_sFm6q6t0l$avB*^YR{h9v|1xR^bH+cCC4Px1TzrtNEDG-UUEnl<0JaIro9Vl)x6b)rbSvo*B>M}7I#<~K!952Z-S$jbW zihz7$)&Sr*rceeMMZv-$rn$@E?)8m0Hd>}*WMROsfY}ezCjx$GeVm&q)vC&^^gr2j zH?V;&zgaWj>c(xm=!)wNF}%$yHfJvs*14+=nOA`~*8H{yOd3z7wHaj8Ek2Qz;Ca;% zf@-w}CPPq8!fr%3iC1#!@2tmdUqW5AdXn0m%ac(q`g|!yh<|rS8N&tJ16Ig`o(xIC zG(hX}4oWN0t_F1O7+EEzcY``0h67IBAW->&8-L5e!- z7cMssT(MDHM0AlN#hsyNpT?}whbd3h3l`YPZf})cc}w} zkmiT*xqgt4n$^CSbv6eWatjil$9im);EEP!?1yLZ34CPiq9b))4cVSUO3T32doc>L zp(C2u5#}i34^jlqDG_^Ezk6x>E;Zx;<8SAM5Ui@f#9uqLj~h}7Iyz*fzmTvGz$}q= zikE0xt{2jE|GLYCL(xbN8ZCRzs+MFRGyHU&B1n?%MK1h4T=-o%W zF)swm9_2+mk4=E(VSRyNApa~KWNc56aVeGmE{Ezdp=_I96Gd+5exgBR8%KH#?l*@) zo5#VhLXxcvXeN^Kh2C?t9B_c{s7l}_j+Wq$XBt+NM89~ClK$ItotYc+WRR|vW8z{C z@duIZRYW#6ZTvLBm55|}&$+iU5Ym37joqmBLDs-yD?`&Uz8R245s5^=4t#3nHsG-d zh0Ty-H!nqvLL8A5DWpt9331^cnl&&ph+JLb6M->|L_#2+HT?pF-)D=nwNXe(pC zRJDmaZq=_^fczXlCz*`*xr;DrBRMgWk?W}8q7tQSHWQgUM?!&VzwSu8R;@U(F1{Ic z5H+5Ft9Xh$C^c=xz*B)f4$+T8wEIz$38m0I?~hL9SA3wlSz{X_JAAj(ry%U3Iwy6@ z)hU&u&P(ak780ng(UuKvjHSzUJH>M*`zzO2RM!P5b;N-0>y2(#noyjtT~Ot*6Lggh zO2jDzsBVovSkM6#I%>ClE7n92k@?ObHz>Q>82dbyN7uHMk%l)RC@#X+>BAWkhER#O zhuI|aGsqzibSYS5;B5I;5qj((W({V?gtA1Yf1i{F!)2k+bs~9*$E(z+H!>uo8Hm9k zg%eIjawnX;WSGso{3=#8`n@Y%vvqw6u(E*fkNpp>+Dn2M#(NeN@8cCl1c9{#!6Xp6 z3}^ex$lRL9BNNarE$x?#nM^YnU<#5~ccfy+C^`hqFELDp3E3LB;Z=90|yy`kOj(l z!W_4(JqD?H#&U4~kf`D7j`cyhxlCaY4;J!gY{C`4@O@BE%Cdua5+9b|T0*)Ljuo^r zruTY`xxUe|FeOzS$rC1}x{dk>@6ldo?U(8rT1}Ccm@pQ=B8nBDFC7|d(r9xuS7@e5 z?~e;XLGX&jDV3Ht?+bNtbdG;dSN~2Q`~H0IY*)aXR*&-RKSz2y-p)xZZh6X8MX$MF z@nYk2?T6nNT*XmR{Ko6!=%>gAwN^EW&}h3GH#)C)2vjOEHqoMi$n|iXk-<9HVU6^# z&uZ!>vFS}O%plsrh~q90H+=M9Swh;C`2`qD4tT>5^TB&TCljHX{@uV?EpkmmZj?6t z5x;cMM#(f-==@!qBG*j3yKuYa*=uKMu1jmf8XZ?Gu~Aw^Pi+f#ZJc1^KnTe!wmC0$ zx(Ov~qjbBZ_8=;S>kuB;>Mn~61!NhVpH(LQu z56Nj-|Mjo;xH{K&MA7Ll|FLGrg_uy99TOr^5E4yygDVw9Ba5T~R2~xS#d$$0DYUAn zw7Ar7zHrgWRj^9?gfxGtYUG+ruC_Z8qp!L6isUq3DLy_swcx!akm>8W`aHP>qBS0$ zr&}|N;AZdWsR+)`+E`-tTtg_v{1KM?CdA^W$*utatg{TiuM(f4UMKtI3X#!2-e4}4_WbSCl)d=y{`H|_&z1y?&iD6x z!$+C=`L?`S?%@&DRZDI+NZ{*%cu48)d)bZVczDL}ZHHP*4}lH!tHF>V>{I&XZNeMqk8ePrKGA z+s=?)MEMv>>(;l{Xe(A-xwt*!?C*cYOp~|YYG^xppXl;`ZuHT-@O-DnCmzz}+S2Z!mTp2OuX3tQ_ zxqR2VGL^{KFxx~)oGIw<{SiFqimRqY2_vo^)VJ!FTxo9+S54KJo{Pw5TPu_g#Fxh} zQ$9SZK3A#L(lYzC#XCXv{LjKUjkeE~R^{J=+#`n315{S(a9HV$^_CCGexN%aqRB*G zbNebhxVeDC%HwW6!a92pc4aA|-b@Y040U1#r2T{$++{ikab%>uUyZdlHg zsEuA8((pOR15&z8yfRS&{k8AIAg-G#Ixmp6l^mgP`ssy>ZOd68_EYjh$;S>aEg8f< zRTtKAiva#!>x4`P&Si?JL~}*k+H~z3DQITeladv>_KkHkkS(iuI(ND# z7un|fP(g_lg~%stZ>Ad8|+(1OR&{9JBRx<$XBlh8Q}W`jN};$&LUAq;-g^ zLJ=Q1RHvlQyn~NaOV&XP{EM2%-M=_e*$3go6KN%Ia>TI#QU1X!`aURD=s?*aNy@^Z zX1~|&jR5rEBcF_N=K|~@HE>EIfQ{1dEvNp-tCMh$K)pZy>T9EtFwigHL<;^!kf5^& zd}7{CQiWO+3pD7wrrSB5P*7KhoNuAbA%s7Lki~^&xEFx!AZO#Z|ADu}{JkQcI}kqG z=YLgSHEix6mH1}_N$COzGtwGt;4Q^`P^R<}o*dRD?4ul9Gf85Qq$BeXq#8MQT#YBa zjw|N3MFmTLAtgqKNS&Ofta=BEK?zXAld}8{=JIp!%QjLTs6w)Pd|uFeg|r>s45=X` zUKk_uA{dz5))u~I1ZE7tuG#&{AeUDIAR*GXRxdju0B%@`7(MjMz{@pz`k41z?+{-T ztPcyiiDq}%1pMQ5k8Ijj6p^^R-g(^VG7Kn5JpB)$0M!B!z;BbBD2G`D^1{S%W+npw zg65@wibO>M#sOD=mK$&H;Sz6VTu`UGQ4mec*J#_bd zK#IlRhHxpBNqAi!=3ubCRC`ziXFNGg1nlfs^D140!S{+I4gY^Y8p{bWOuUkizEpN& zru$ZM^ZrmU2?XhLoEB$A)N+;?O}ZWHW*&_*V%{K373T6QdB(3mc(f86yUJFmLrq>{ zha)liU))>kJp1>?HG~j>U|jBCffreKC~;6A<(Om;xB@|$AhdNcf2sw=PMe^I%83F1 z;Yt2Kn>z@joYEwp;urQ;>XdrLbj166pBq9upNDla;?NCp_+YOGa}YQ!q8+6J*GY_l z*oy|7OQwHop72OCsu?*YlCL_P;2;_uiSX3qyKy@xcv&sg31Vo+L1%wV6UF@Pnp+vCAdAL_L%~PTkl^w=% zI5Ny9a4I4f!2{W!o`0esk|9VVuN&o>2;%;%b^!hVt;Mk@27Q^WwhcRCDh=!HwysOJ z>{F@vT%G%c2pl981+_963+XOTH$yFTB-c1jobjUUxrVR~HV2mG4JDNfMRjC?D8fEN z-gd4EwN)ZgFd10%h0c9Gke(aUn(0j*xH_Ao)jZWJsX0BqNqjiTyL^4jR%x-e){v`* z&+E%E)hz1whgs`iHWJa>Nx>UbfjQd~#iluXLZ0%a^_sX{!Dct$?=D&^m^Cv9y?11*|>zd zR%#60j4|i4bkA#6Ke?jL$Xz`W`tYB<1EC&IP8iDHH$2iJ*cayU4&_VGDE;hhe+3S~ zrj4f)uITsg?X4T^;{Q{FeyP)n8_Eh!;=(!&vE~A71sHE_`pf=>mt0dG)7locWYjl| z1Y8Oj%M4JM>r*TlFH(#WYHibDm#I@^-5FI#nsAXOc_Jn6`^lq9_k4F#J~m zHJ#KI%L7 zs&I?{wB+#_iX5-W{dKL;zgj~IXgRX0XTR(Qy^|C$s32so8TlX!8a3j)&6#geD)!wo z=<}5}^@JTX6RlpYnW}&&t?vuYudd~)y&$|6wJD`k@D3y@CxDC?zxTO2UA$!SqQ4}- zV3OVHqJeIU?myP7xvU9wanZqDoT?OV?!vF3G=h<=m9{)2`IO6tNDS~Cmg!s5`nI0v0>kHa{S z`26%7jvpS~tvucK@Lb$URVp|=kBucHVs zmT!Nn=DDAujIxq)7jaqQ8`BqKILP5D%Si{hc!%)bJVQfnm0HV$s;z}_CnIYE1BMHK=go8nd_K}YbmX_dhnfCOcl)Z}KK&sUSNI7o=E+-APbhmcWqUlE8TiK1tElW@u&CzM*8n+$uzf67X77w$lOe z444z)K&Um_NXnr6WsiV)>MkI|dZC17xxeqe@k(*a(g_bUcpb*48{6OyPStHu=Nm7Y z`d}z2sI^-g-JxVC0jCVzc()U zFl#ycK48*idedA)K%&({t$xFnBP##Y{~j{+`}sZcFQv@0C8(0gR8ecM2|~08pK2$0 zm%yGSjEuadSb?j;kUNY<<<}0nyzlf9!1E?X5 z#^fV@ltbyj@gW8ViXik2=h_TZGqn^^`476YOhmJ*W06FLH?8rnml|`Zb8xql)a9c} zsqf*oB8Y%c7l4+?BgC7HE+y;|ARAxKLa4StiYsUZzrqc+<0&@LB{d{#lA%hdSh*!QwI<}&SD+ZJyfQbq6l*n( zXC<*auCrqeW^l*V(|Z2(7yM8FL8ifzy$NFDdIdUZA_QEyuQ{u#<$SqiZ3UtvOjf~Q z0^@G1Q<6lDFyARs$qNfJG0=j`?I8GM6$^*y-~pYl`Ix8XNhVsfb7(&4#}FQjAR^AP zB4ii9J|MP#J;+)nA4wXaz2Xlt<`?|m)M4XF2O;pmuVhZE`2sm%$P1eT$jhS>hwzm; zQYbI%RYZ1|tO*}dQNcj?v=!U~U$;I{e=cb=3XVeY9=SefJ~)cN(^VK973ci`&ygAd zhv0U85+%JBN>i8WMs)*-Me>*qoHa|nNY-#6*gMW1^CYnoiAD%^29_rX7G#`S+CoHa zeyGOvvl%Z)Q9mY z@^81frK=NrMrYivm?r0l?`ZmL zVJE&e3^jmv8;5jdW9)1_)P?QW&u>r@A^MAJ*9^2Ww~9)6$+Et*osqDlkm>H7>M;H}IBSZHirE4B^Y zFlf#lXxN?_j+eo*R$$k?UK*Oy+x7)vJX=x+aBzb2SlBObT$RRL=?%c0cA zBcjo9vr*%OLZ9Gcgrh{lO4w}kGkSB$PkZQ6fBrhL_#BO4pob}K5J@9`)r)W>FC3e-GT?K@ZT zp-1bN!`wQpxf20FBj-l1o}TSpH{_36$UM2-{ipl=#D!6r#X=)SAD7L>Yc+hBYx89{tQ`7&W#JmH zqP_*nTK&_PACC82T0QQgIoh*Ci>5AdbwQ}>r;f|ZXc7IixL7{%nhPhX18+4YgNn*} z$HB_9kfRVs=Bxsi4gF@`R5)_>=M|}Sy?ZNUJGc>dJ0ryM<-z{B;6V`)K*2cp`4V6& z#I;z;iR5ep6bdE^Aq)*Wl#3nAY}s5yNruWFEgo^%Qchl`+HyIf%Wiv;(+TVdQ$`*o zZ6h+Jhrn|h*3V<2JRxF?$^W_+-O6^f+ygR(!W=Og*}}YtuGhI z$dJk$o^jgi89~+ySt$*k}_j6;z?~t`? zoD%k5-?e>}L?}OBny9%=m+^vqa;+Q2ASU{E7OFL@-O6@!?QMXv*!v@(4#h*GttKCC zUNyY=P;+}f6n}@k4}4KF>>D0lj4mOvoGudT{R)X7a?t?M1(Drtks@q{)zFJvn;V_= zd8Kj*EiM^3EH+xfyLzy+Yc`qqQR%khB>~S%n%`Kj6FB_j%uZpe{C&*> zXVad2_POf%=}d*H!)dZ`VR@3rZm z)Uw#*Y*OOt-m4w5H_FaiWj|fV!7kO6C3q?x61S$w6amDK5vTFfMWgg)C}hb9I~{ci)gJ#WWWfDxnB( zrWfqG8{4*2AjF)9=1C3%LA63wAS)~-6c8^vrt)USE^6BA{*Y8|2r;fTbNlQa&|7y+ zF4%UZ1AjeWRdplbD#Pj%O}3D|EHjEeXxBb9Z7B83WS9lEUm zwiYnZg^yGeX&glL$<$nn#=yjO)GV0D--x)-2=I8`o1Z%w{jz^NpCiXZ>huP3OOoWI zM#V3%UVoT1f=mAR8bR?^{y=~d__Jc~<1#zqrejb$TujI~As=j@LnqABr2b?aCp5qg z0L07%Zz*EtpKF;YnLQscH}~J8$sLnzdREIlWD=uNTdy8lmC4y8Tk6-ZPnb|@BQU@ z!zC_>hkot*;3+Y6#_K*mj*AGBQhTugsu%ls6UA9#Vvr8hz9^!{CruL+rt1n(bv|E| z<=+Z|=}y^gj&khsP}-Mjh|NM(0zxLb6pN=E$qx&$LP()nmPm@lQf*%BoYD=dH>@K< zV=86KrdbBQx6;OM9;dD*-wCR>grEaa{fgtL(Q;HnvdMKUDnqm(q5P1e{$3`k^xD{R z%$$67ob$h&`xjcAw@{~f6Nxn0F#mNu^8E7hUB;G>0(lFy5c~#Zj%Zmg?Uj6Db)JMxbfo*~O98)#|XvB8&j@q}OQPZQsoq8(&V6ty~* zNI=V5G0ch$D)F%7!pe7N{)R#bt>=3ETaNIEyj71hw{Y>7!*}E<#bP?aP`KpM=ZJiS z6Ricuoe(O+6Jns`f&Tmy*S5h)M|)aVB%I0}Ekyt1fSHGm?GP@w`X522;PS&(f<3xB zmhO;^3RZ|6&Inf)g$fL$&HgtMvDkxu9`-m@>+qEJQz7m1hbn77N%Lcvqp81(rUJ!DkI3W)0EdK-RDD>@oV$D6{WIeqcG7w)*zNuS&4%Le(3J*V$VT38&u zhM~r>Mp*_F*70<_XIhF)h}ny%(GcjV^ho1^$}*hSREV0~gVr`s?IEhko-#f6?m;j{ z#8bkoI2Lj^2Xsy=>&Rtej(s*+?!>FpW`@~puzT^&Ld?gESr*3~`{FFG#SRQ68w+Iv zL!6~;9QLA?mBQ6zDGlKqNW0u0uDL5z%(^@pf(0w6!wcb*bnkTXOkGW8mSr)))Sxi` zDDJIy=$vJktM;YZkb!3sh|-xhr{K!0K%HNV%=eAz|X_&mONrgXT=$v^knqGoz9DJfYhw`*k2mLwCt_uodg zBt#OK@9?Ug3nuTozWGhg&?zWQYH2aG_|(%Ac#rLCYO1NJiE2hj8)%jl-zq(Q{ybzm zy)=_-J@7#VW6KnOK zHCL}*buT=)ICK7i_}{-*4)n3}t%g^x!j-lCCPtreUyOIDaJXwD4zc7C_znEzI|{>)aUz?(8BUtd#-V8YcF?YvG;%vkH-vki4P z&?u(KS#MI;QnlqEs`UBePuDId`Y^54P*ZhsDvf7nD&qSzcVG4V?5`Rk0}$2QZdU6% zGk)Y(!^|tKpUD%Yvw)xxhgHsccxO5K85_n zA0vnzKCfw~YOwQ{3*qx(?_)7f#^^}#Yw7Ys`Wow6+HNO3{C?p`((&%&jiDn?w~rp1 zI~x!@_us(Wv8U68zc(a5l*~Q%F@_z|`+lc%`MBYS0e?+M#7upUPyXxq>n7(-Cp1?- zs?uEc>sx4F-@uzQpE{2fBm|6}Tl`_}{>%i?tYeZCU#6 zwX~7JjyPhTLhVWOi!h*U5R4EEv5}eQq`%3MXTvhGXH&do#*W{r4R#|YKZjt<_bQ+9`Szy0B1UD;~LIR9RqC##KD9dj~q?m%j>kYdi zEQ4*Qax7REC_(dJyy8aTwxr~%{^Nt2Af;2Ud|WemdK!H17%Cl?O^uxY zVLI{sJB|fZD1Xt~x9#t|TatI!U8h<~vfWRey`G-q+~0LQb6|ewx9d&L3oNnrPW@U0 znDFgX)xbzMD-?~rxS$!%ln{%(S-5_PpKUhiX%Xsr^G3#^2*J!&e)f$5NmeX$6g_jZ z(Qun)K5+#)k3|}|AYbhS`*e3&qh|ImSEV~PIalw~FH zrKQ^kCMrk2Peoq3wBT#(K-C&x-|ycezim)0Hn&`noZRkyN>fXV{MbE~bu8d=^3;#Q zX~lrrWBo4YCyw<_&L(|mGM)Y8TNH4j*7xazV)v7?pFGP4rl(gfnVi%tKkJy}LiUPF z^10-1WqsF7SD|{J`m#%V&-2|S@$ygKblthbEd;-Ub|TVU%>R7Q-@cT6;H}6L*cm6| z*2>_7v!BPtqE9E0-8<2u<4H-5A(8t~&1a9C5(K16w|c#Ze{ymOYFHF*@05vlbb9BxIr-{?m1K`6m}2%F zdqLwK+XU4+qM*Ie^t%98m$5PF0D7P>xnxO#=|bNnuxNeJ;7(?HlECohbhv~6yMtpA za3&e zBMqED`*;7~Xrm?!Lv<)#i6hU=d+dP$mww_A`i+sWZt%9>l2lY$E*I_Oy5T{ldTG;j z9Dwf3x>t#_%-_&(#K#%UNB8?DMt*b6z0jIjGM+puHz!;^)1f>TaqQeezU+lpab)PV zOACgFPra-V2uU#64S#@qhI3l!UHMQG{mN;7oO*?`V=O#fi`IGq?O2KIkX7C|l25C+ zD_)W+z*2#9EJ_!kiA>XxQ@VLoY+2LJF*hI^Dt{r_jT}{La*BpQRc&}`sf5Mhf8ow7 zlx|W*-5we(9a;B~U+0rS_D!0$7qvb@mftEzh&pdMzHKFMhTTj2vhmu>3W2ou=EVZ5 zgH^(!QgknH8*nh4j5c2(J^MDJ(omjBSCZ~ZmF-ra2^-GHUgU)vz@{fp2{_$>XOBLSItS#w}TYHSJ8*y+qrPdJnpKW=~)GToee? zy&xn2fR^GN`#`WOB~6DX{NWmYNRpacVha@+5H0S?g`9_lm4Qrsj867VYV>5xLGM?@ zSeTu@Te?SrgD7CP(!Kxbx}$xI>p8jk{o~OvS;y1W?r=;Vh89$Bh5yf@0i4L=R{&p} zDv}a>-13DRJ_itm*AV*x2Z{8`^A}opZnRS=%k);jfd|CaEmsOh7w@BRApwcXoevc( zL-2%nWjg^vIXA&IA;rl$Zb(i@u(0ryfsq4<%(ftK;;N4}znxjJSW2tWQs`mU6Yx!B znJxIFwBFECfxrlq^Vwugi{KSod)WGI>`htTVqjIADAm7_SWyfcP=-JsU@yy^MMqOE z2nilB2kN>HXc@ea%3o(-NtP1cJr07{>GcZgg#gP?C(OPnrFJ_WCb{@%s3#z1Wh`VD z3DVvue`GfXcs_x4$DbJaCAup93_MSXq=5x*)!9>Rqk85LDfQjE@f!F1wNzlKki{dG z`avc>n;58nk`)Y9Ci127URP?-;swCA^OXEPOsxPv)LdI)EFkPL7oYT z!Y`h%rA4tos+;6Ddl}xunpyVT5bkoUj|1or<46iD2JYB{9AvIPrHe&iDL9@(^Ir)t zKc!81`M)XNkru3dY0`k64d_f}K1D6qnXh70FQncO5A7MEOe@P291mH%HgVTy!+I$k zRb_ElM2fo=i1`_F5)0(&Mxf+GKV!;{wD$%VxyF-hXm)%oZ3!$Lj!VN*hV(9(d!eUM z1WA6{ep;C*&~LC2C0+_Ba5M>v2*^8@b5D-QHAhe$!{=x*U}8K^5+qE5rQlR-dwf@~ z=*xyG$FZBk8TY1AruVA`U#;8839g2n@5p=d@C7c$`;EGq(HKdLLf8cFzHR%jX#yHi`|p@^@eo^VxeCx;z^3i!7h_ z46*@Y-{Ir~m6n~B;4U&-Q5*!`g}9>J^)!Xn$FS46>zmG(yLk6@^{v@h_xpEJYfo7H zP3OY$OQE#`asYZ}IGzDU*r&HF+dYHnniuHVJXHUvwDWb>9izZjSE>nIuK5JLjYmuY zEiIrJs~}Ym!P>2f?Vg1X48M5xe}fnxL)q~Fqmor~{q&|2Ta)j~bKq+WpU)-{k=6}+ z;?qh|Gb!M%A&Csz#$#JFwdO1Q>Pi~!_unzU=bGt`&&M}=SuZWc~HE z7i%nvb&?;xf&unoxZlUR=r8TJVkzS(yw~o+`IH)bjq@4^6G7eLd$*|C5NDKQZ3eKNC4P ztv>rZVBpArWashn#CGvj8BARg_96CCh&^bmALJT(k9`nl)lu#VEYe%c8%VR+DHMI# z5%QsC5+(6yT^qtNPJ3^EVD?#Hs|cFY>S&Jjvhqw9VS9QD4b(2-kT44-X;gh1+O;O zf5|*J@Abm2CeDiNZ;BLpt+sxMsf(3+?8DQ~vppjBK3a9}$m#mF|20CT^3I7LgA%b_ zT}ictrU-`4k39@nmN<26?o-S7vf192iOjjinfs+U2d8 z)G<6vRAqo#+PFnqovV5PWjk;bNpXN5n0MgU_F?Dio>*Q(B}iPOe9go83xX+}Gn8;$ zy{pMNkILT_v5siyLX_D#4}EDShPTW&Vn+@9hk#BTxY9z3zgeWqI1dp|et5mXQQa-C zhUzsPEWJ*!tNZ8+@fnAeF7W1?Er{_Nad@j{z4f!v|_B~tlbj1FmP zuz8_d-0KM)1SWv zTi9**=H#b*i^YGIX8C3xFHTf9Y0j+()$Ciy8Nc!Unpc=$aZ&n3N=Dp@l8RKFcc&cw zX*ruhpyr1I02f-5>sInTf4FxhrSc6%Wr($|^~ICNG(7j-+1v_pZUne-)f(fEegym=BcW0=sB19bq7gytC`=aA~cn93s*2-mmXy`6NeD zI1bM5U;doyBG;GX8}|Y>u~P5oawTx40r%jTfGMQl?`3TLPgY4Y?~j)|rK-RGhVCZ| z7XAI3YEB>(>E2W75s~O0K!KI3%f75h1}wyBfSuix-1N2_dbI0oJ61wz8CRAD2Eo3E z0&f|>-<|h_>{~;8ZE08?IQPj-yOsRTUF?6t0!BwGX}X;6o~!2rw1oSAHP(^#TAwZaA#Z$dAO{XSK$Sw|B@lk6h}U13c(*EP@4uQ-J>G`*b`*a2>#v_1(%h_!yjLGtApe3S_50H+%&XM}T~? z_HL$&AQwTqRo;%=|Kb5@gQWiojO+rs256A?b%- zJ(-hb;8L7xIz%4mAzrTe8sZ0hPy0(G8rBK7K9-;i5VZe1*BAK|p@BWTy1;CBW29SX z{F)@s{a>Tka8P8n-$08YH0Ys2dO`q$)+B2U4BjFASAPN31L*IR+WF3UwHYljTm`k;V_e%9_ol{}F~Jd+EK|>=JspHN~Ml8Gb4UbV`;gaow&o?dw1F z{l42y3yyxL?>EUZnAkph242s(Cax`HqJSc86B*ZR=P*LQt@9LJE&(0klj0&LoS2eD zRG`uIQmZhxyG0OGyN~+jjH2(qCZCQi@`iSfFYcXfuAT`|;ndXrHf|ZUwmi_w=Fo@t zy~XNTfx&aJeoC!MQ>Po;th&NZHcVC)Y-#m;>||8Tllu=&Bk&m%oP0RWfMkJ{&Tlmm z!NG=pJ7=uAzNuuq1FiO=_jH4Lo4STZG5U1E-1w<-a!#x7-09>0_^3@f)b?w8HMW`{ zDIem|r;DFS6J<2deY=fEbPnDq%4di8$KYh$Y5xY#;?FC*`m`@xF!=yWcqc+SAT-o< z$N1Uodnqb7RuBF-=~!{i6c#MQgv6znyL?lD$Qa|6Hg?l~@%~ z2z1muH%Q^)F<%BZ=-vQ1ZSK`ZX@8QAl;JKrW)V8^E1`FBW%uf^!4+-ucGVL$i&2_C zyuMFd%W^E6OZwXSIKcnP?E~H)CjHckGAwfSF)1ig1=jq-PtDWBdGB&j>>?21IW`8K0&?Pv;rKhayGW(lsLP?%u#i2vg0&=IeNidm1aHn08wbx>+~!%;5VZhrro9t44) z$!|M0ZXClFbG8vs>1Y8)sa0OWy*_Bkjpm{#dZ_HIE9qV(ovRlCy@-p(m509D zu2{3u&SWxSmDGzkV70w9QLW$-(k`R;xAa!=9k~9Nui8Iz=TUP3jfYkn)i!7_p zY5DJ>vVh&9qYVx)Gu}BT?^l?X#b1)Y6P#C;`~s0k@?%8gf;Mr{L!SUII9x_fY`NG8 z#sufhe#78fyLipbLm$-e@tB8h?RPrKz2y^ozm8tS`AY*9yt-(+LFWN2p9>#q1ss$5 zFA#T*SZSfR2qN^xTm*lX4}OkVchFFYNw+LyTN+8rJ67Un|)BX&Yf+)3uKAEj{mQ`6e?;*#a&Ql9e>~z-0ch!36b^X67Vt9=t)H zL{G>xf{q2S-lxy?6&UBQS?Si@36XM}uDKS?z_-QRv@gk>^5%0olGf)>D)~oT#{W5G zTmEg*PX~1Ym2o9e39^t%sFM`4NlPxBC9+v;^6nnkN4$e81N&fM(M7uT*!sr|48#t zp&fJY&~tRM7*KK*A4_ehb8;0AbwUkq^uZ#wbv+OdOpl>_T0=Y5VxW$MymdXwD31O* zcBN{3Q?Hg&Bx9HJ7QLwGkG^hWQ6yilgJfpR+;g8=PjWepP#ry;P$zB06elitgj{P6 zPg^6hRfbcH+M|u=!&YrpL0V*P0u+Z56mS;2B$4M=`gy=EkE~3z0f)pQdAiZf2g$IyJcXZk5gv?H8tWk)R&}%Xd$4DaQe}L+S zly?1Kf#Q=;Y;%l!iVW4u+}TKB*Mo53xlh^?Ngk9AWc-pp$ygyCM3LPo8HrLdj6_^t z8C6UL_}K?ZWKM4B&Mj<(FuD=aOEJ^B_yeE%qY|B6NPddH(*GD(fj_DHT*Wtbxr$Ni zA)td%SSyY@h*78aZDe;p7pV|~VUv;d2v4q`d@CTI3q^K8B7Nlq#ikdSv3(`nHK|-A zl^w9ZFDA#4^d4C$(yB&XE zZD$AZVz0g;!9Dq_cI*tCI`6>WwKC|#he|Qg(yFTLE$qWn8y;EY=W||P#yAxyDhk2| zessCikC3*&@|z<;rz>PUV;?JrXga2wv2_IIju3oHE||gh>(Gu^uMd+??B=#K`#FAL zd(kpwsQYS~3HoTdK8hS0hI2$&*S|ST&EG#9^%oK&tt86xYk};!7()6EwDIoZ-rnak z|K-rvlLIpg;}hfajhZioW|mhA|6Fk`-xH|xMK8&a&0AXo!)@B+=7Yk{Yt9WX z`PweccpVL1*c!X9;akS&s)uT=>>;Y&=nD4p=T-o2Ap6O&wfee@PKQ!n`dfcv9_o3_ zacaPT*QQ=sC(88e z$dT~pWP+MHIV-XiWG2nqQPVaQ=$NQX<#H55@>LfEUiSo-)v-9xv)|7K#HUD*vb=1C zIBEm3wQi4s9^l_~G1}fKonGU28$YaPrUsD?2qobkX%w73xM4GLn(t~sI^lX@jPttY zuJe&n8q@1Z+A=;rqt#RFBdomI~fg_SYCWOgR|$K}B_2iEcasLMv>=O`IjLE?lfEPr82bH*CI~eN$+~?<#LwSA!qU61nZu8b+?|UAg0XuDCoq zm3x<8Uzx^In?cH*sFd=2v6kBrW$W0_=O4{Hs+j0AdiN2wU{XyHrT0~0>L;_WbD6hj z`*M=PtHx7@S!Gz+PK#%wSWJX!hG|GRbH#8!uKxy!B=O-==7vhAQCanR`pp@` z%UFOo>O+ZZRI&Z9#^B5s0ZtOfQpq{nGGjei{uPG|m1L(74X86`gLc(*`5zl<+?V0kgZM>oW-|` zY2we-p?uyq@3Q!+`um01`}n_lKB0-2tQmdf*jK0S^i7Tpok!s|cKEI{4yv{wPHj}Z zcecGbT(%vkw+*??U;QF@tZjsZ`-gsAap)2eKsN+A_S(<}wb=LX*MBXKHDe3;krDg& z@|?`jVWjOtY3St z?G9dz%lf^!E)q)HMjxtOlYmF?{1RW1S!r3|K8ee78=94INaEOohFGR#eU+9))MesYjMB_N1G1aW0=a>8g5Jwz=;9p@(;-Qmv&c7M>Q#Laz1SW9_*K zY$f`J2UbHSCx%jGJrT_?`8G9~wn7`FP+E#cHfz(`Pm!(Jc$jK-_USSQi)R{w`gD;$ zy0JmgK?AW_>3@WU3kzG9zjn3|jX*UGts#P(G%HzHk3T@RCpK0CEojvkRjvSb+ir^$ z(|u?h_isyu6hvYwS2in~5p;CyQRk*VUqG}+VENGtAvIYc1_qpL_XuUQpkURrcqTq2 zKn(Do0Z`Tz7RjLa;1wBVU@TAwv9!+Xju5m#@7VwW4<2sh^yZL{{Lf00Q77Q=AeFS_ zJ}3defiVf=KDiU0nb_SZZ7EJb5X2*#wEujFUZ;#I6n?I0l}7y~ zJ`SkRLS(k?YBBysPOxb}2g+**>Be{nAEVd8sG40vI(J+pwHuiXlPN$8hlm=Fa!H|OPV~nEL|X7axuHNq3hv49 z%on%6?cVX?EWY8f4jO$DXi9OgOWjeDu4_B2H*kqoPoyT-~=e@OKsC1&?W~r zzF%mt?uhaqEoq>e7%OU9N^3IEpX0e% zi+NdxIlzo^(`D{)fqFGZo#o2;~s5;SKJZe>7Y{*0Z@6q=VkznAv#FYKQPw~Jz z`A|)4+5Nv639A1e7+glHKdW%^l_ix+l)iG^D3g{EMouUoB5xf^$`F~d$5z7)MAnHg z^2?tG;?`oBe5zui@0g6kl6DNQ48JeLOG@eLBHBBV9=9UMOpw=3-YuS;EX1pH@cU=O zSOyzMw{;WMCS_Uxm$FKh0xdvVK~SX5sf3K6y(_!0R5DT_+=>aa<6yDO-Dz^02<_ls z^`&?**blexYejR=L$+qKp&+zd=`c&6T`JHeVY{o_Zf$X+6jFd*SUqvLL`|Fmx|ZW$?m9 zp}G1_k!9?FeyNRt=$v%EU4-^o9qKZd61N}B9=Ws4DZOw6okegm_QuvBR1HOocLtrO zx^nfy+hvV^R~=20NaNAS83(<9`kKEAWtBqE%AOax7_Y9z;t*CFQ)@d)d3|aF4OU{i ze|Hl1LIjC8rUTlTe2FCPxfl|w@)C5Wj& zIh(1|nSVzvQs}=lCaq_xrdxh@gWb}jxY?tNQDLm(w%$K)_B|bx`7-_9a80(;dmA5^Y~2QB zqa?HFzYcV-6srjR_-knM%T&%^8rz=?-t<}$n$Pc>JTlw4^ii`-YNE10E@~(7#BvLF zi=`ecFo13&ee+|_IO5!~0nS9jY6~jX=CTM0EbYtO12o8~>Qg4ML{XhczOk<2q)Aka zLOLUxjyh^reN3{sIdOv59Uy1PI(_$1gG5$t?LVh@>pAG@Dl@lJDeF>$Xvd(b<7=W= zA`(SGl{MU8H}}_i;VWsx#nbPS*i3mUEMQH}rN*v$Mz%z&gowQT262>3MR_CklE9## z+6zLVXT!%sey~2=8PG~3P;y4LWtv#q3C(;N6b$_tRQ)n*&UZ zMZync#O3OmxMsI2v>mPYsoP5O4-13@Dl)el+3zwkZrE{Uwxzjt&Fu=oGtXxID3)Ig zCI&SZRVU{{`^G=t4jb-!X0mjxIx*|_U7=yWOrMuzRcAoPYI|wOTr8*Ulia_y@|CxX zSUf^mQ9%@?WjO=!=hOa$n9OeNLu_uv+f4lZcI^dl&>I*F&K(cZ!j$nd$NC=~Y8>U1 zOO>9l2>Z8hIBq_@`sv$|hm*Y3gI+iHH1zMSs_z+0?R}e>KFF;ILeO$xu5anoc}4G^ z1M7NZw;?=&W9G0-ttZ7gWkl!4@_0WsSTg>HzrZ9T=3)h%SG<-R9N+`Z3`{g*;TD@m zIPhaet)-p%5>w67J^9s)cx^fp^3|X`3Vq&uWL0x>D!&EQBpgQ{Owu1K6%3q z!_HGlW{^g@#=vc}H{s4B#cRfuvolViKbIE6ERFqJI;eb|L04RK97I0`l*1#=XTP}p zBEFOn>#w})t8ml_vtcM8-H1hVPK@p@lAFENMibZ`z0C0R3|Mg7D=S&G^lj-<*uPmb z4YRYFzb*t${SrfP2gfKq&VOC}AkY7D0fvXZ39gz;xG|zHO=Dq;*-XqYS$7*L1U;zB z5v~bFRi3l13IEM11h(30$`~Eg9E@Xde}4$1$Dqav!4PX$w}$_KjtQ(N>DU37MaY~~ zeXcI86wpEy5R!&%1TJfmE->O{D2dl~N?!pqBm&zPpBE7Wa?4Vj6!Z|30(&ip%5G9a z4MQ)0|Fsm3w1jFQ&<1dWIB5w&;Dw9`3bepJnLS8m*~zOn02ZnyA4S=JEH02M$g)Fu z_F|EsfP@LJV;B!UYB_Z?g<;ucqL53<_vxqsV-?g#nJ)gM*RoC}(d$_WY+=#~4hQ5w zhY)=pPM#xOuDs7~N8H9igeRawlogP(MI|H6L)a}(i=|Lsk(Y(kUK7wmSTMpjkx!!k zh&H6c)5w=PXeRi+U>@NRr6h630K2Jb&8kM2YNIp^d} zL6sYwU}SN&&16`?p9U~M?Q)%TSyFTik&2|ZO`*3~q2GCvzWRWHlei9GU`aPbDF*?h zfneMLhA}O}%697|@hY<_2(tj`Ir@KvfK7Is@A5Ce0`H zEdD&Ob~b8dk5MMg=_*7nz(z3?piand?jvR2PJ%4tiEx6~`4TYhOpf`UJisXQI3dUr z(3VafJOmFJ(|n8py=Hf;T|UydiT#bbS3YR#&cjH;X;D?c?2;Rpp>25YHIxs z%hZFSSe9lt3XRAD35ZvA6^~ewlaCYU3T`4iB$P>s$N;H$#tI_~hgc!}gix+ss8>4% z{Q>AgB}02g*x_=E2K^HaGzqN3jab2vkV*q3B8bNHMD~)7;;dxI1YtYmp@Iig2OF=yK&JyBmqW=#%!G!84v+^I{Lhl}IZS-k8DoWZ7j@D3?ape0O++>kN{WnNRbgIf>}FbNW@mC;>Yoxt zH&DGg*-*WMB~Q{p4ZTY$ECp7{%-NJ+5hIX>^3p#b@|Sy{1Is8rGP4GltAOm z&G8uCJAt0nz_SX;Pi3vBEXQm1Z%-@Rf}6YX+!VdyMs$0l+zz8}hNBg&YFxy~krJ+p z;|q2te*}X*EEE63=E8~gw+51vnoFEZ8meBJH%6AVqq7V<7^kxMQ_vY_&}YP*k16tY zHWe8{RVo}`Y>Bjv7g5~SS9#O_^Vm1ZQL(a+X^qAHzB$fGp{2b`41IHt#tVB^j!ug? z*{Nm4ezdRdTaGeLi-G$F7TSp^dxorNZlp;l6bKSQ)?r*Cmy!zuC|q>9zVUtUH4)vf zdll;{8sA$5k9Y-N4MXz%@JwOW{E_aknUxYTmoFR&UK)JtSF7ikkuA95eEVc$dK?DU z`^tCf*9JMx9alOwA9C=lo>rat=U_t?nlh(;o{7Ec)aLMozasW8D4Z$Y$MPgZWd%7+ z$G@h-kuvHpfZH!G}%$n=M z0h!n|nTj$;LEa!AKrcI~#+NJvTo=7j>5ds#n9XDQ);#EYp?vW=1TB1+^nspX-a5J* zalqifNLXvc`od6{TSgxq8?Q23NAxlXE*Wf6X7)5W>hAlfZ?dt%zcP3-qNfA(x|IQe zt<##nBd-cox^9+R1rgx8XSHa=^Tr}W zOT-{RcgvKd-fk-B*_C!2d&0%)n5Zz2khfRzRPtT8K^$X3FWqsZo_DOu) z^CgE1xVO2(CgzH^)G-(omUgBBEZsjC_L&0qP)DccAmzCNM z5uBD=Lx!q;FObqwzynL`ycPK9qLu5H^9h50U*|8?IJg*-we)xyVYd=rQcd~+F6*xi= z%t}t0xCiRtxSnAhP*lBtSfvi3IysC%p#dcPGy{~gs8)|!uL-;|ElO4GFeL1 zgp}ob&6>SJOY1Jwvgek^Pt~k=XuHe!#r0*h_~lR`rLrg{66-=?0DO_-Q!WYA9iUl) ztQ%57EHQ&&vaH-tm+@k(kZfTT;b@9^U^<9ZfWJmJlGs(S@WA#2q7Zyp5F_g@e3_!C ztw=ljX%hmEiLA7<&~c2f;}a*nhdGJH50IcX`TR1P1TRr{Bo`1SfQWjqppfvCWY4bq zFI31C*9{_Yj&w_dLYD3X!A0k8(Z>VFGR<&oHzVLuVIyNB2pnGJdZMQyT;|NKk3bKL zaQJnYXS(?&93x=Z>n8SWocjOG{t2I^N}YukuVsV97l=tJady#gFakJ&65 zP)8mN<{Zv%qhVU~NA32M>yZ3opu}w60)Zf$pPBa_nHiv^904w=T+fCvA9`tq(JUOd zc+o%!(O7glb}w#3_hGdi+;lB`YBDa$FKxi5dVoFO;iGTqn7!kFWC)z+)`hnTs{7KdO9rg(Yp@^PZ?Jmnz%9_l~~Td zS6hE|bE2>~m#wI7^qNw}B=7K#Y>X_P`zev@S)I9f`jZm|EDNF*d#P;G^j?fs2}F-fpijIe6;Yn3BfE$=M0$9biB4C8rz|wxC)y zz89hLf?IL<)0#DpzPUsdWY8XCql5|oYu&DvJ?brxZ=%R>2e0?NiohK!F#Kg%ioCs{ zup;kn?^<_8gs|z?8`=WN1VP`crE=Yj{`~Tmu43--Egtl34$)MsrNpiw75(?FK+YIL zvWEptO+hHx7*Pi;?9dBLa%5Y#8t`OYAx?(AdK~mz97z11#mCW5*rLQr9|Ii_SWcjG zMoJ7iR_;Lq5S$?blMKb&DMS{g&i!)WMv~)s&M%DQ?>7- z+RMLX=Xt}v#7(^XsgqINv^aVH@=Bf#cY{rgjehH~h|dRFrwpU3X^E^Zch3D=G>7VQ zcMSJAb$xT=U$4v9*|2-RM{un?|K`Ycb9R_+zt^TGqF9h* z)_Dlu)r(jNjiXQ!Z22E@{#b~u8i())t>8Iy%;z*7Y-ItZ0D>V_EaY2)uCBNG#=9cs zOslfjZfClB^(xV231>mJymCu-w}5R+zxVT-d*k>U65b)+Z|LEeCUHJA4h{&4f}rQ~ zz~}VodiAzXj=$!R1u$N{w3GJb#0?hPz~K6zzQA)2mulL4LINARJ?&LFtZjE{i=Vyq z2olPm5;sB3CJ}6k>3$A!fJ!fSI2XUHZ+dc`Gy2!akk0A_GXZPw zb75gT*qX+BKR%_FX8kh8VnyiCiLgtWKW{Gy)~gLBw}nw9J9PYirL=RdZ-Bg=ceFeb z@F*{TE&rS8F?9p`V31e#egB!Z#hXiKh2{sEKdBWy)P5vz{6c`jgRQ{s<3U)r$%s?X z>Kf2K+;nI;PB3T!O?NX>lS}1h1D;7(rP_<5X}1nVh8MXuMvfNG49H%ZmM)ViZB==U z^So3Jnx`;Od6YtZxU?#bO>3cFiT#+)d&wv*nPRavUHLI&0;7%ofkKIvD7A$#0&4a$ zB0K@Z-_v@#GuI6~@c)oI?k~l5^=fD3?|)<}7sj?$FMjKsrDn~r&%f_nT&p>y8g`{n z{PpMQxu{M8Hgx|r!K!cM&SF5$Z|3Pe(O8sk_F& z@1Xnzo;!{=XLYU%=o_fQ9N+tm&6KS}nb*c8?dX;*TYwh)c5&a*Vx_6Q#CzSf?~04I zgE@P%`c1R=$jzr0{xYE8qH58H-5$`pVHrE1sYr)1w`qbdnkzm4EycYCP4xc2=zeee zHI!!M0|t8^kc#Z`2nxk1{oT4L<<~zoN(y2)xRVdU#p=ps){{`WyURq4A85@||Agxo z8QT6d>va%qg_>Y$!pCNYvE)~^>YY{#^LwU7)C#c63hLBM{@xZ&Iga*HBWI33Kxn(XTr|5~ z(R4HN7!Rjg<+6%O1f=Z0+m-w7FP^tG zG$q;>v11;4y7})-_J?fz#)dTKB}z_>qU6J>-_j4ZVmonk5u{olCai zxA+8FS2?6#z=03=h*StbkV!X~Zjc9*V%Ck0supGriR!%Q51h`K(nC47k_hBicf;&l zAVF_VMf-D%Dv^J_naRy#BF>f9Vlx2c{-Jz$i3P2=aeUn+VvPD$!f@58$k1)pocojr zl`*1%$QdHU7ajnOMcmOR%fpy5naxwoM*fl>xJs2$SVUJ2#|`_e8wn9=VnyJV8qm#H zF)qtVQsm;C{+u8!U;-_PDxL>zCKS_AC4}z{laDGj8Zf3iyF~QVp`l@w44ImeG9xrE zRw3vO&zJ`$6rjUMyF*11!Q;kcW*&kAPq~a#;m`Y9bMaPTq_$ zi@Kky^BCwTY~8?vG@W&n%U1g2EBM^pe?VJKher~(4Z**7j2X|=qjl4GdL{f_qD&^h zvWRu(%3|&;V|uC`8gj%XrX(wN5Fm(`N}7`(&WMg#dCrj1I;$(4hXXUx!vfPr*=dFE zUSSZ_QBQP-h-A2jmYzM{8?Y_DJbK>w>Dewuh?~<&WxCNeok(KN-1+42>=M+Yy_lCb zDm~ldPOF!e!h`k+;ov0SGH$Vfa{Itd+_C3BEOTEgk7OY{hITZLV)vFkXMt8R$8gy5 z=qLy6yIoZV9MGd$!ixG4^Xll5S*X2?pd56xH$k%n1({UAwdt#Q^xl)Fg3Ow{pi`ut z*zi!1uEX zI$-@AU0N;_ar30Imu$lE7gOu-X?{Rq)67f3>Su!uai*Jfzs?9P4KF1_z;;emEogR1 zeeu1Q%gKKE)}(wMZn*xV^{;;V?N$$5x>#Jy4ozE^G-TQ}8a8t<@f;}EIK^+7@!ng0 z)IX1Af9mvPw9w3V&8Ii7+KH5BHjgYXQFvB)`OfO&-w-6pJ6(}EL{Vae`~L#Q0!DI= zgVkC~~{qe4A z2+6Ltyuyg^3;%hBBj~MsCnEZ(viWvk4kqNQrC#OM|2q&3=3%@fQ2H@Cdf8_^R9|7C zQX8K?-!?^hU-@kquMX4iJcT?!SS18?MUOo&Jz;+-Vw1ypo>8{S#a6G0OVtx$9Z}$) z-nm|}){}c9)fJOJ0`9<_v*mYQn2)G|hTzRE2T)^_)Z zgN~42rX=w9WH36qgma||hs%RMLR+T%*cJbJwsBPYYnV=JgpH?rO*&~V-kzx+@tSNO zu6lYuYmVVV;56;3-JGmRrg}I}fLrDK_vXs!4?!Fu;_N0_AMC_wahI-W5}Woe#tU4#Jq=9V(Ff42A3_>@#IOVPSf@E?R$W9RBS@MhSE`JJI?Nv@ju#b?u}dS=_1#$j}d`B;_XQoQQe14pG8X zXLOfzyo5_anXg{iqI=y%`_G2QuCrw5Cq*0?a1kjF&)9SQhFQGs_waYuL8@gOjsAGv z=o^&vw?h}q4z z^Ud50`>}UcO!If!_ga*Z6!kE5cJB2yTRqywJcKmZ?b0>pxh@YTtkbsJk^5Ee!5TtC zxfb<&gYiZGyCgIo373bVcansjshk`(d6F4WqSTsg$Bc(uk88?rJi4VptYWX6!t$lJ zS+hbja!Vtc%_&iG3$Mn7o1dP@T4JdBIr6YrQ}ed&-dan0`!A=JB~QcO8FK&I6)dB} zmuaLgOf3^TF~{<9 zaNgfll4swoRZJwISb?59=7>qFEWCn$hD~up7*`uqFk9Az-01AlT=nPbJ0oh76ob{_<5l$YLg8V1tg_sM?D0b4D>6@7gmV&DW-2d`GT| zX}LjUY3TRJOH=IsD2m8steF~m92ONe#JRw**uM0G*IH;5;@N^;mD(?Ye<{gvD`szcb4Uu1z#OnT3r-o?Y^POIQy@%G-O4jElb0WQvB?PWki4D|>~iAMBrMBnCN8IuQ9 zZKV(Ax6U7Wh(I;l=%@s-)8NB|$?7d?g=a!{#7h#;4~UXZgsNqHu6kIAmffh4hQ&9= zRyH9V(8gl!HiyhYk-A;@$((yJH;)iHsYom6WT+8~$UH1$*d$KBZNKq>=?;*V2f zSZtF#JQL{%ZwfZ^KdMgp-9?#PuqWV2j5>ZEd1Nfy05q(gyn;P> zk)Vs1G)aYrE>1)aq3AZWxEXpo>W!a>D# zM_<5$X5k%8QT&-W_o+O)4gG!~P6A;AqPwGwh7b*6$EVW;d1Sg zH3vTzmpX4ARM6=ITl@GCTKm|miY{YOX{?O^+~#S6EcSE!8`z>mQWbSi9`be+q*ypQ zJ>?g0-^C(Qw- zDm^Xre?Sa@>jreBqfUpbbu1!dWc_TUk?uH>u18}BIj;K zdbl<|19ABt_u?Wg7C{P^efm)zlKB|_-n&4NzHCUA9*MXAv>N4%<=`%5SO8QvM^@!`y{5gg)x>BhOyUwrfX z!shKh-w*qF`^(0VD_5&0#A8?H=Tb={7qqHyJz3H-sB-R{+u@WRofz;mRz@8Ot93VX z9F2?qM`1I2N%ysDN{{Pfq>}!b+!5Bs*zu@4p-jFYFdIu8av^JqK z=l5?K2^9`p@v~X68C7mb2O6JNON8n*`3y-)V~7GiOZvh>&Djb(ri)rXJzYMj=WisC zRQNk|?}?aOpoTmf)nXC*xLSV?J)#WkqVM1=)ulHBJ+V^nA9XQm{;FGA;@faO)n)WL5@yO#&}x+S+V0mhfnf}BWIO%Hl{5~IF z@WOCdETR@iIBXiayOEsobl{d;a_!{Br?^rMywgW}RPJyicYBKxib^u#bIo7p3Ov5G znSH~vlj|G7OuXFkOFi`O!Eat+iG2|0OR1jW4a;nn1WMW`r{I??Tvl0m>)~<~&W3PR^lr)jkQ;=df3j~6tQ2IS?|$Fe&q zH85-bEUtcMxQ56yRm=Vb*z{ zOm`x2>Grd5c?#kDB9&ZM*w-m7F7Nl)QU@mhl7MA>2v7GzXLXzb-ZojK}7&2`1 z>*!HECjI%GYV=M?4Ey|WGEsB(U_PgqL}|dhv@}z4Z0O_RC)08QM+PTlj8crlMI&b@3T?d5OlH1vh4f27Pv5>`L#4+)+9u@q)yU)gmAi15}G4CbZ>m-a4Sck&Xs3S{O+ze#s^!A#7)_7IKv4M z6^`)9d=Y8JhQ-Y-+rc72_XirNRTfX*h@M%qMXvAm{ejnmzPYtqI|8ncvt1p3(lEXd zqFE!fvWL~z|yq*;`;Rs3AF*vVT;kxAD@hz`0dcP z<7(%q(36%PlU4I`Vc#2OPb_t|P0c*&GXNJ_e8{%{U$v>Gqqc*`Gc3~*bgO%mjP?qD zo!)Vn88n-i=e{x zQ%u?NZDO_Z-e9;70ooq_7I^YhW4=(zaDpg~9M<)>xsRaH2HY)OMlephAy9o-rmW?S z=<$_TzP><%5})(#qsJRgs(*i!wJ0c+C`G-RS|tP4tf&R4u8O>e6}Kqy%_$X>ojKVU ztYIdW5~n1+VU^G6(3hc|Q!j(OZtA%w`nLbnu!$bIFPZUHXEI!}Z!v6ka_N)ir@qC< zi$+zmt#tiUhc$M~`YvOwuYMdKCE(HAJi=Dh{@> z9qzlJ^m}jMvr4I+v+)62K;ag%5`zy)#W#yGW}x!y5?F?vhwlRUO*2p~(AEXgI z*NTCvZw@IbGa&$k-rNGfvzWB~t5J#M zLNAT3p*od|hypZO%d)aqNa#zNiEs^VqR%wANlZ$b1j&bR729dACyC&Yse!g4C;Lvk z4+d670uqU@jkYjg_Z(G4pYofQZstXF_=0l;Wh}9UkY0j7wQD8mI6EW{M3Tp z*6@}UdMeYAJ}7|yf_V|i-RSOSk8hJp5di9tS-VnS9XPQ}7(|zFJTMu#l;O!E$fton zqU#v>B9ev6M4&*VQ9K4f#E0nXtc9 z^bD*Ep%0C|wDBJ*6FMGnv0#qS3!7{tyd!KrvNhndxclxxcLrx+tTMd0DO}1wMX>&T ze5sLo^3Zw4fnC(VXs)lijH(h;L6&RVK2~5;MzNlJdvlE;S#^g50 zGe0cjVf&>#+yxG3INJ;DW~7a!T#br0(A7$NN5U%u%#lqf9l=( zYLH{36jRR`JbUTWNlL@=>fe{_1{*?0O(w<{DweiiXkO0dTh*r!C$_5-Kp@CvjE9v` zCY_U?6jp^(5b4I^c*=ACqeJ&ok&6Iu;9d#@rL~LIF7qyUaz^-ns1W@f?rAss+3QKI zT*%-N(rznk;%D`Ozd(b)Zf|vCYtmtM$bbHq&?lMdIsd5@OHaoKFJieM zt>T80KwQSRUoRw9M_)ijSUe7Kxa8fW;hM9j-hc|dq#Z{W4xY;ty=uzS!t6g%G=SI2`LJ}QY(2_K-?P;{%wv3^R13bQd__0-&ablLs!E-3(l8mP7lxaElyXrJPeuY z;TY!2$a+%uUif$TKb${mpMLJwqc(~1-m|u~Qy6`TLK~?9@UBN{5&D5Wwb|4ab6uFV zaee;7ulrmot0c-}v+XluQ3eDQ|8uLa^{cK+s1#-O)V_EJKR=0z3t%J-mMxM zrzI4kQ(v3DmWqfDi)SdDbhTga+n*z~%28OAP2>(=1XqGyCCf!!GVUmX;$jg{OtK6vfPbn(023spjMkF(DFJ6e1q>)$l}#}E0sgr#`p*^6|KSQ&Haw({WHPzkRokt*n&A z>1$?e($CzylC~pa&#m2a`X1Y^$by)&e+#U4ysPwhgzAk%z$7!SsF1!*IRy{=u|thq z#Sqf-E-w>lGSrRtTP=J^{>O&|MmJ_OQb)qvstZvMRENy;Vc@x?-Fx=X19s(JZ(Lk6 zkG^{#LPf=PiASX2gXDKR9&F83OiW#Wm0D4;12pg#{SnDbn+;Z8o`@P2`S{P#KKR>) z{)o6glA1NwO$7hmSJ!F&s#tP>IH>OUH5V)Sm1hQL`=6fiKgR)>iJQIA*(?fN9c@1z zdT$R~6bHU{A-6m3JknX$2o%|D?0T|vJ0wBxZ0+idaXb8ivF8OB!(0_F zwBmyN<_zb;Ld#ar>(0$hPp;Nc|70jSlle&&!(fK>Bn;LLF8U~_7Ze4# z85m6pu?R4>OC&3d7X!q_Hx{)DQxU8yEx@0*yIjG~$_?r3&hp` z+!dI+nEt4LdeuEsIY-HKOlQ|ucg5u5MuzxLLY>$7ZM;Xx(``(wi}GOBS5KmM^wX+I z(Na%J(T)yM;lwFRvXMv&xhZ**f`mHoL|sD%D9n?3Y0n0DX)~1=Sp%6-l!g#;Q+OMD zCO}msjo@B9I&+V@1l|)td?+g;5Lio*AQZKIDBC=6W*oWyEoT)943LaN9#X;MbCnRkAW8u@FEx_RF#mg#7hhY^MGl` zXiguEue0)|lfzk?1I6jDorAoBbUcr*1U(W%&jehC)gY>Hj)N9oePJArz#~={Mb9-^ z&dT_(`2&?jmJ6ULUO-pA>;1?R(-Taz;*YX{7g6DYDi@!&3~Jl+4CHWXV{=09tU*;m505DTEK zmMnx_w3!`OyO<*`&^!N7;e?PjzC9QjUgWj(YNv4Lt8`IZ5?097H z?j7qcT_`zJjgQW~?Du}EQ21zrk&;Vh*JI;US7oF5F$hlBAI>lB5tQp(v-|m4Q5+{7 zXI88|C7n?c^I?bYgVDJr#n!Gp;svcG9&e-!bId4;f=~QQu0UO2`Xx#ZdQ5Z~58Pc{ zSF*JU-E>3x0N$RcUMXw;Md>+zuVA+cva9bwTkgY-RMzLR9WCYg5QBhh*(oq^z4|X0 zvmfmlzcw;*;_!(dAMU!iwyo4RoN7U5DzJw<1s|?iUJ)}^xMN;Y;;Yv3L!;6CO`xb! z_w-A|LmiLx7(Ynf@8Bi)$Rf|VM+NQf`ur6W1M^YhX7=pu+cl3>CG%G)y{xYqf8$o8 za`M%+H|s{bSyyl;-{@dH=Yd4Ek(d%mD62V-8b4D{mpQccn7``$KxcJqC7Mw6ltk<} z4K0k-xxL@et+#Pi$OzDDnoFv;$$m#c`A5c5&(HYo?c4su-~4&p!ptG6enD30+{cPh zxkZ&Qw9w+${F$iv%wfwLsW)funv<1=MJHPrBgIiqmVh`GxyZfSI%Njd6F@xzG_6*j36lKvE zYu8dS#h!Gf3f_n%epJ5&4O($5)-f{m@QZ}2?c2`9h|@d1i&=cv_%%@7lp=qFO={2W znbfrOADiCg7!R9afZR(?%liytth3t5b=ed9!oD7wSqS^sH+Xfa{b78*VRJ%YS?RN|yK*F3LkgATmdvt~y&5P=wvusETFSnZ znRRn7mDQ0^H#tUytl#T>`u_g--fxcM^Lfwbc|EUZz5Ri6^6cx$s9PlV{9{7$siOz) zS;p9AvG$Za`X{E}hw_Ml<8FHfY%pW9vw=(GSzsaQ1klvG5QNOBe0@7Efa=Dx{DtT) z7Nc_bzgJZc^SM^DJesM0DuS}z1p@4-1EyT*W67JV8(Z`hL2kB4c$<iimm+8o)W!sIi3)){34cEdY(^xz*wNciF| zI2junfDYO{=>EvL-k)K(F67g$gmBh8*W5kuoYNTTeCuHn0P(;;rLnH-$To#*?c#9- zgv}QMsNq%|Js4{OfOOkJx5RYdAoZTBT$Ix({QlMF@yL=s&w%Mf<58Fa1NDr>%H<6@ zi=8?s52zJz(V;S+=|b7Zbmw*tx4WD0hHs~S1daYwS9+TKkL~AxJJ#9x-ynjO`DgB0 zD;csXcU*t=6%E<8L33>VloB{!&Y0}#ar-DcR;ko_FmQH?{l8Efjo&8+mVB011`l0Z zQrmuXdLhm}TcKT};(OZVm|Gjqjh{Pwb#K}AbH$;;Y#ja!6N?v>bH72>Y<%$Gp$lKY zzU1L(SJOO{Mf%WI#gMn!&3C*uG-v=$ffq7gF6>R0d2{dC9+MhgxD#Amu^vsQ5HWG@ zddk@|h!o^|;<_nUz!T%6UQA>)tO1vqH(xKkfubc_cnih`Hv!giIIB26bgxWsnw3-u%+v9a7 zc6ub}veY;*;~}8EMl$Ml(H63x#xid*nY0D5TYJv}EfPKD0d2)pZbaL)y-Gn*w~_TH z!hIWWiSq(d;yZyGh^`wsKX{&?3=RIwQ@Uv>2Vad6)c1BoAFClY`*i3$;Rqd5S3}!T zCZ$JAD%#Rwx>HnE05C<@2+{(N;Q^gzPk5M(WKgvCy;;W+7bVY&=Blqcf1yt(Y!&B0 zmyu3d>lxO7ojX=}HhZ6*bHNq&B-j2*;;3XtxMf{ap z;);+`;99*jvgUYi%Z`l%y2gmIGXaGjR$1FoH?US0y@$ipv9y2(1dT%c0qW`TW!80ZGL*W(1VurbDi#x)TL430&DK0d-Tirs)<+WfnV5yNK^*xLb zj@z$E?On|kwubNN*bUj}A$j$E$8PHWR&$u8Mg}TF5&)X~c?)?TT#8{v@x`s+!B!5Q zV85Yzd^Ld{yxe52Ks+xKbSFq(V&mhR3MO7cIxiGseqdh*Gc-vHLo$b7u!p&TwUiko zCm1_y;^hNsZaIuU&^p;hyh~(SWMl2{bOX??6^Y~kY$FHYe+cK*S}Zu05{?^86RU={ z=MLH3SDRj?9r8uDnIM=+%{D*kcW1P&uOr7Bg5f@$<=lel`xeTP z!QL1=X}_eTUh#5T@gke(gTGa;V|UgeI91~GIgxuQEkoyyKu~=$C?sX)*Ms4Q;8dLO~AgPiV0_mYfmvMJ$(2JVZiA!CeCi)4N6?* zo2S1#e^Gw4;Cy{(%Sml{U?Ucb(U@e~DN7ENje6Q|VJt54t^ac+&{!exTnTBt#yww! zDhkior8sQvW<5(61ET8^PH9rz1}KaSV7kV~ zo>v*j;ZMKg13ut!)}}D!(|Mk_&Ho<@@OCp2f8$r22p7C8baEUQfQhnZ4Ea#DF-Cvq zqgP_7dZv+C4tmGgX}$YYpkxwWbp*&5JAR=Zx8~}*^x<63;pcaKup-qz|Ez8{TMPoJJ`u#10EWJR-%2GU<2g z+BY|*~{<|-lBeXVNu!of;@gS^7i6=0k>%GaXy}SR+nj=KN z8{|rG6S>gj4j>|7*)oH=zeLI5?4vAoa+9wAnT?bf$FVSXEzgDhJ@>os!a%L8ozfV z)XDZt{)WihHuL~HifWwjyhe*j$byeixnXI}2`k7N{@ThS{7qA(?dn!e4%)YVae&5q%Kq5>6u%q{S zE;<+X@tJ1W+k5#;CxZO)NI`)d`gXIEJ?cOiGgZ@@M@l^x#~Qn2on}ZgELlpUcO&>Cv%WrAO5AS05NI7cE-YyWAv7H(O{APIR`PwHO(- zAM6vIryfm`AH36}8C@HAzP0RuuK5|K0iWcZ9`l22Tu;8HKd=6_TUna##Cqjc$Bg3E zs*FQC{PR`ts-|2!Dc$+B*mcw0!^tt{2coGvC7vK86@6xS?zyle_V2flQfWGi^dP`S z@yo9F^%?01@%X{dX2C+a{x08QJE!aD_EwYJH$V5B0aDA6YQMJk;^+2djsE2>wXOTy z(`5X68po{Jihf76ODtqa82WjivOOa*f?Cuu9Oj(&Y*F!!G-bm9YdqWA*Q>mjp6~;oXpt|-N8wj%X7h3biRo$>s|bqT3!EGzG5*#(Y>s47$rSt?sjoeas`+S544#P zC4rNfLni=uBB266gy@8FNlp%SXwPvmrC-WTfITh%@S#r#7-3MKkLJw5RT46^|86b8 za zZUccZxfTSeRzQ7ep(+7hTT&{c6diZ6xNcXBUZ75sL5Z&k%zh%USUi%oPQ|T0Vib4{ zJQ9JDnS`pSB59DsRwH`we`M7Eh#^>$fwjLhrv^W?EYl)$NR1+rK+s5RR{JbF^E0AX zZ~3Lqo*gksdv^*|zE&XKDC5@?K_L;cO_1<(AjJrtlJYxB7J$eh$4x0ucr#%z0ILjD>Mm_}v`PKxx>56YLM~6RmJE`lS00s&3+VXe}Uzn0U2NvzD7uEu1Ea7073=a879FiIT`Ne2) zgsc(AOps{L1U5>*L$3~dZXnxL0UxN+Td_$3TMU37fnb@RIi}W-KxB#%$>RuvKP5hV zVHcK*eKoUPGk46oLH#snr{s}i(E%6s@@#5ba)dxTfa2Tz3l@(k&IX4Sb@MiVzb4VcVFfN{BL9cCvW) zDHJ~2TIqvd=@H;dhKodBtP!y;;HQ`u)uhQlXwgWGA zeGU9-KP$8}Wxu%TpFf}b&HP!N-lK4?tN!07XNsE{-c`D0CrF9iF?#Y}D z{wfx$30+2l0p9=dN4hy8ijb^+)^*fZYw!}3ku?_rsg13F1m!)er@QOkI)AiaI-DXA z>e?fSk*m0*yq%aD-uHlKH3WDIy9wm;e zsrztNr0_)s)vYzjerffx+Q8CxH}~^eh|n1t{<A)sSy06{a68@4H zXz;DMVln1U_G%?X7QDG8W0dxOztk_I?XsaW)r*?~ClAea4J-uDE><%Jh6d(>blNYx z=S=zH1XiIa>HiH0@n3lYG3P6RT~P7}Ra#F<69zm678cxPuEqk&dMuj+P}vELxDbw= zgHcO-kriSh@GVD=WE5ajk68Jm@^Aj++&3Zp;!5jk``$J=Qpbvvx|>s6dg_%Gqapn2 zcjUO6#e->w3{Mn;N`P5IG7@ktnT{A@b57r?tJF=CO6k2GO5tRCYn0=(mk&V%Y}vn| zrg)@k821LIX!ezY$7Umu#!Pv~^fEBXdn=}2gowg1Q$yX@8gkl4#D5v=@H})TjvMmb zMM6Gic2A|T_lf%T)9`Mw!C+H^i!es$Zf%!v&*xW@KQ%T4PW-*bDFrc)J*k?J{hIBW zI-@(rogy$?R<_fK`vxD@JA3Zmq>dg#+=1UyP@yXWEvHLDp1>}=ufY6Fonu!~2+M9#nmb5FMQFqcmO?MeClJRKQn;#TzkPJyA)pfREfw~h5PfS1#o!tN4|7q4;{Jt{N$&1 zD2KC7o$jvg?(-af)w4r_3Kj=wllOklo~=nJ`z;X~&E=XR0l6Aq6fxAa6LTb2K_&$4*nfjcZ5;3PcGu_2g}1p8n*r?(31PuO~zF8 zqLHC)fk{-vp$D>7PKQ(mxs9CIph^h36wB-6yCW*5Y`O?FYv1wgM^Q*E4`K(2* zPm87(=DfPTUPU($8>gNf@4?rLd@?Oz8VCIA&Q`92Q*}?t73cN`pDLd`PZDMm8f$aR zkmbtxtFLbj>Qk%#!}(BV>7mZd`B=@U+>RN|5kDl2qPvcA(eH1L-##T<1GH{|r1cwq zIn+;;oih+Xzoo3zUWgywt|$CPAgo*S=|ej+)G?g>vQVqI&<$F0e)QcF{%0K}=}0z3 zQOYYW)VKAad~EBqK;G1nVgbdPVb+4mn-b#GwxVOQ~l2!?EDCVPq=hpRXKeb(qQv+HDUTFs-C>MR)G;5L-q5O0CbXcl= zPuDl?*E5S1Ct6Co`bTfPw(;Pu&skxK@FTt$>2G3d-WbS=>8(D^yVWx1kXXW&;1z3h zL&HruLIV>RJbWxGRtQMsUjL1gPdPS*Klqi+`5z6py)Mtp(I1^^_3G;HZ+PI@a$ZS2 z+})hUV#HEuCh|cxz6jc;_2r9L^Uz-Gt34>3gr-^995t$xL#usAw@-95HRPGf zrjYMVJwhL@VYycEb?Z;_#E2aC+8)okIX^I$#v-8BxYD%Th?= z)z9Z7+PUo?$MW(>tSY5RD@y6DQcF}7wX&Ey#5ZougZ3NDei)?hPQyr=_4-vwl->ew z(y5e(swhR5!UF?o=-4BMN_Oacx*JV8XqQH^!mdftO-ABTUi9GzjqVL+>?7~IxwBVl1y@AlDrJN9 z=4AY68N?>MUY6%qM>q7rpT`Un-SZN|!Jn4q-&)Cn%t3@=9XJnl)4-<9?J4Wi^}mtchWHTC?C3jyc7&WyCRP4cnbOc zwSvI=GZ>_Nxz8;pv}llynXHlmIj-@-{($P7-mer zXAy#SBwnV4vCJRQ<4AK2?b09)38msL{-~THA`#tqFwvcK%R9=SXx>;e&ZB6Uu|5%9 zNkomzU9bzPdWPo`W15djN=#*+^kjaKj}P<4OtAm-J-10OV)Oj`!2HkWvV02))hYDt zqjw9{aa^-BS~P@CY+valt_D907A>r~fpl;@LBBV8@^TGU3TxKkZiOX6%avo68q5QM z)_PWRH%K=*@q{qrTE*2kqRh9UCyTiT&Rty^e-ddeCqh1;)#?q$lRxL7a>}+RX3^u( z_sKeO!emYt101)mdLvSXH90{upL;sefwp?pmLBMWL$;ak+>ilIVIcY2C`vv`%($C# zs-X!L#vzrGubaSn!>GRoP@>xf(L)<&1tYIA03bkfYd3dVDmt6xS0=a35{{ zc+j61ooBGXxQh3%jeYTNnbb&%|A2W z96LlBPgCtJcV1bMgsC38?NZ3KlDc0#`fjSB^(S}t33+`!lk5)FRWF}>Gqv$}VW+@~ zDy>>a)f9S$7CVz@zTe-SS?X$EkY66tnNge=XW6yokj`>i=ctZxw*z}2SEu#*{pwF4 z9H84tzoFsU64(4!$B&xYm89=1Qmq@+nc9Exi_W6v3w7V}uOE!9E{W6&Rs_u_E3u=E z9Bbh%uN==~rYJOj^^pMe1UD3Ovt>lc>tX$Cy0N)jTlKwU_4$e|-ep{npn#0|QK^w8 zD(dU+fBc*#{+#U@_m9dL<$B$S{-(3gskwqzs)9_De@wv( zxGOGdT@N~Oa@4VLbhO6)Cu8Zz14jR2`ISj+ZBMYX--kYbj>%>3+pm1MUFN=f}-6klP=QO-HKa*ygxvgIl zxxIio(kpl(w_Iog^W3zk+OTa_&G)`=(mlGaMi+gCF4d*&_wm}f&F-P*YZu20`FtUz zZYV$!&XZ5M{x}|?)A>|18c%SZB6kQ+$9a$7X*W(pTcj|EW{Ac7z;yg-Jn@pJks7th za1}Mmz0*s%-r=6bRto1jLv#;ZSEX2^i(@Wgt^JP(AB3CUu-Y34O?@d&VV)RB^DO#d zE zwcv$@cNYpe(bIOY@4V!i*Pl(S&U!hgu_KJD{)2xX=k|Js9!2!jt72HM_cFD<9|1Xy z6DyP`7G|)`^K@!q&T}|s?}Lo=I0q>)x@YOpad`5`ju5ka(*f6m`7i? zk+Y|6M|F}jUAUnzw*TeJmn(ZJay)GhDBMKbZCyr->!{<=nvtL0jzXTt4m>{kxpexE zqkc1$4A07n;ehH1pE;S}9=G9{<)wj!>IKC`g&#pv-@d(a?0HymF|e-p!m-MW4sB~r zKR${Za0sh;1~Zj^(UJF(Y=oX<+!!CNVpmGW2g z?N`bdD)DhUeCz&L#bR6gKQ9yjG=8 z(${U(PG&uwp`qS*)XS<@Rg`)T`cwpOQBo(ez~@9n7{Gr6Ty$}H-B|)#SqXq_grkMG-n?~r7A>` z70@M1>wb^UUIA!J@>N#S$&|o2DbDsAVS%zla`YC6+;->nOLp(f_nyAbefQJ3LUtvn zvUSR-O6>M~rI^yCSBf4V4q;sn@=e8OSz_&^>soJP)T!H#XK(fXKQLuB`?l8Zg31@&7fSHl z;1{MaI<%X^+gy(m*sS-w+6nGY6XF^z;YySc*4R`*6nRl%_o11GVA@&e+Y60NfZi}9 zqX9pUL_0dzZKKaKTe&@wj&7WZ*ffW}vsI?#6M`DMnO*#BDE#Y5hfDFP7FDV_0p;g0 zM}SOGD+v3n#CQ3I+5|FU$phs%&1LN^jb@$bc!wt5hULI-xQJv;Yy#?I6Yya|J}Kak zaceXpc5c?wCjEi22^d*NpGaZsHGp9J`L$M6Dm$chjIo3dpe}j`$Lpf+U=Ce@bLTHb z(vihdmt9_JWr1;$rvY)BLv~|Km>^&dv<;a}VJskhL74H-9xM~u_%RVE;BEnxOts7h zM-0angRM{n)MFxUwBRIzAV_RnwO7Dih7*{Is1U46<9?!2$PWNmIIp8=7BN~Xfi$T= zdwtdfUM`LBhu*^LIaz2OdVHTc!iD+tmBcOuiUr!&>b(>fSmnYKqh-b8$uGvb5@0-( zSR>eR7GXZ~#NQ&g=zFS|@Npbl07NI<%kgVE1bMUG2l`{_Eu9 zAHU{=5&v(|NsnsgObPk}>Ss2RXPyK#w#NO$5D{lCR`@lXaVX3Qc{K3F{;&AGx@_?9 ziv(w;om6!`L+zlf zh<$j6VEJEnl!vMljV~rnBsQ-l=bQA-HV|J9=1`l$at1<4MEp7|v5hJ(SK+s4n9Xd$ zh=(S+NB4RIOzOos_lZcfAHTCwVihUX@Wf=KH{VuW-C>+wZeKy)G_WB3Xm-wiK`MXH z46VXmip~Zt^Q37<4JU}sty&TvnKSABOW;e{o608)%|3WJU(dbT;p4wybXq8h(jo-d zNCT$Jsjv0)za;FvW+`D{>d0*UwMBKe2d$kYRUB-iqI&b;p?{0j4XZ2k^MIDGZR z*4`c9QpEqw6+Ao)p9mO+S1*lFC!JAIGeH zC&We;9TrjPR@l%7Ji!n^CGhj~lz7;haX=@6Yf7AuYdRQ8zenM$-jL+HIU@;D3@4L$ zZ_uyYpcS@Ra~`p94PKylNqV#6Jqz)iK&A-5g@Wx@x>Z{WgP4t9_({2*I%t}kf{yRF z;7ofKmMSD4HNp8Fjb#T_B7G#D^`%PE-4y5p!vgA?srx4fd&Q&1mw(XV9lmMv8^hwPQM*n9bZ z7nZN`ok_MuXl8xcc2noV*dWs%LC!zw1y?3r1sF`b?%H#xy9VOz@E^mUKXXz=Skav8 z$)+SFb~7_G?+f*1iMtSiXiP1>%FpPpcHE^14>-5o$;)~pcgvNE<-=we15XrIQ|B-4 zewlVXmut;kn!)L45!{XLulhEB_j#+mw!Htlua95DPWBVKI{Tm#a1f7X2%h+DHyW&- z$i*eOU7S}ywYFwY3_pMLlg2w|t7pZnS&tvcy_13Kn-d08h=zfU(x{;m+}depVfL7Z z1Z8Tandaw@%@3JB17Lk^30wQlp_>t3N>+Fp)e44H%6MC?Og=yCU&(?RjoxasL&-^$ zv3WFf+m5EU{CS-zO6=WV5O(WbiyyzJdzhzqArIoy7_g3InA71G+Lb`RJ@r|KZtAV; zzc_hRJ2=o`;6a+%?gs}SADzpso|#5?;*TS92bYruS{V!J%UU{f2$0?AR+Yu&-!r$c zDRZ)JbW(M6C(iw^d|>15r%7GB>g%b3x(tcZ?!rx&PNtXyI;V`p~SAD`Q`D1 zcj?5^VnFir%FTZ7FC>3&QP=TIuWMhbotZ5NW(n!pq zIJ~!zl2VFk10_CV-(Z)ikhbLjP}JF;yU7u+hilEd9&I0Gy?y@Z=P|nynL>x`x1)QM z{j?sAG1RCNFBAfQxDNs#FAM9;tLU^A)c)Do+&S+fBGM7=iFHp*EDQxnaB@&KQh9W! z;{Mn?Pov=OgHP-q$UpP+EUpf2NVQ+seQ_u?)9XQ#;=-8O^uy&d)v3#~IzQLW_Rmx` zpcmVrs5Fs^fO)pepdYbNt={fHUpklFB}I)1Y#Xw=4FKfeWwlV8_}hM5tt|7>eS~PC z5FKd3sEtN-Ey2%Q7ZtWU#ylCvn%hT z|LHdHGQZ#(2uY$UPv%U0j}_hXB4I80nXrao=$LFnUJSD-9G9)8`A?RmoSZ+Z1K)?gDC?pwW%5S za0FJZ@u6MP2i-&M=oP3wM#~E0l7i?2twTRN1VBOIs0fr?+Ccsa{ zUHZKjF6UxzCQkfmF?R!d#zn{_kg7b?{k)~uMNMBMT>D*(P>j`EXwh17L-FBeAP^t! z8SX{`LOv*>iLHd6G9m00KpOt*M`Qk*P(wheb4j;T2M>1ZP6C5oJcO_6dRbBQUhba^ z17s4BP}^+HWcS2E1I0MoG^}h?@u~<4J%ulHMUp$ezBu6>!+E(t4M%bg-m6CaMm5*^aw(LoZQ$l4^2^`E1*`5(g>i`S zR!LMRkK6;r38dshtzJiUew)^H>|{0$qWY0Fozb zmnE`u|Ka#wbRQf2Fb9@dyth#XTP@eR4-+>|UP*O}Y3K$6>nu$(9vRkjuBmfK#Aw+Y!tOjgLSiPODS5t z%Z0GVI+lmduO}~8x_&U1>njXAy_6xZTPk-MJc^Cg%JC(OIX8&q9!BsK2 zy}C&)cu;1xE%mwlYtEIgEkTWCsqacFZA%X_O&&IsfKv?;`OZJ0 zzcQP+wrEuy#;6K7?&bbfEbRQQM_*gfH2z0@cKPAtn&HI=0ELpHrRU7jZmLANns$9R zWJ@gmdJx66c-H;*^s(!vf9ZkR)8n6GD9|x@X<`$4Ep5VEf)4os&Lp2dn&OoM4|MmJ zBoARUOji=r($;?LJnCOL<-60$hKSL2=Mir3Yd}9N5T6#Mb_$mpA@~D?Dh9u*Yfs0 z=$3o^xdjUA!tBCyyWjkeZPbrXJxlG(3#c0$+L}tt>RooTv0xir2&5ZDeR?Q{nX6O_ zrR+j?d0-n<4YWQb{pAoePD^@2gGx*KG>4AovAOc#dAG^#<^Sx5pTXt` zrmx{XG|H!1E0{6zl{zMhwTW`7KO}TwcxovIJv}xm%qO*1f4>vB)GA5`c2MysFRl)H zc`a>-vG8qRXzkKJjNyUBmDQ_4f_iAu*YZ?Y(Aj?Y`qyF{55$aBpzo6wk+9!K?3V-s z4zSe3@+Jm4ABUh;3gQ9D;q#np4>iqlF?L_ z@vi*u8pWW=$nMPGdY`KKU1jvi$pkgVfQ8NdXK((>ob3!wULIPzn77bc{X@;+{{4sA zquv)=zqD7^b#}eT$lg#=>a{bo$+E(4nf*+5xracMKH$@Q#hbnlVw}uF{`UFm6+r&Q z_A)3k-F3WY2WyGzM{n&;peviYLxxV{h$*yVr{xQrXslwSi5?jI;8~g_&Y9=r*zt$|HFZ?~ctSUjciMx9xE}x(b(U41EtLy_q;==3e33(IKpE|I6?G!*qJc_=tZc znAu%xa6Li90mN%RZ29u)Qc7A^gpXU*rGCHfzNaYnb=?@B`)5n57gYl18>(6vi@TP0 zERR%wcgu1VuzP4`m)Sr4^Sk61OYXSo$5SJ%o3sPI-!65v4p9ZSUzU3QX!futdCldJ zzp5CoP|w*&Y4oJL@@U)n9*xZQv5>si^&dY<+E)a$cRGFCr1NuI|1tX7#f0VC%M&{D zcLF~)1Sfv+{m`@g!EDMq<)6n>|2^rh2w2W&8=Cxn=Eu21>?*q3M1E8rzNvEaHJgfu zkhj|1LuWwnXsdySP4P;MEb^%r%tw@@odP6{vV2+Uc8#L7`C7YG(<<2e69WN?{pt6xtq@i9N)a!tNd|V++x`yvAjneJxT3b z+Wx7Uyt$mWoH6$;x#8KTAimq3FPn!w_1JCyDFd*Mt9xRhbySfX&>NN`Vi~Qm(su%; z-hGrCshZw?plUXt+UV2?v#QCTA@qpq>CWJ=<^QfN-duPPJn7?Esx?{X6O4k{k3>fo zJCDFR+`rEHTx_){eb2>QsJWpps_m9qPeKM)C9tkhL+Z>4-E;bre=;!zwIiNsb7l2P ztz5~I0o0?*0rlNBai5d;Ue}>fCw!Ge6N9SZlZ_FZlGlsS&9nH$ZGndqlMwx*wq}Ab zN7g)`t$3)JUA+RLe?Y6NXb?gV>NY1uB(_Ez4`$MEEX{i7Zo4<0EsSlv$))jaxna{n zxLrA8=4f#6#qRS(&&EgFm#(Kjb-AMVbWr!i-s5b$y|Z}Iwi<6gKd3AD-`kic>attC z#d5Rq`8OZxlsFVBE1(x~JakahR(8i50ozV5(>9F&op!H*FJ%t~X1*@Z^p=g1VQSEMzeT3M#RKE>NX7v2DOZY%K z;0A3r-Q17K;7#2y=DR3HPq4H>DGXzYut+_FGrE(F6OxU<4RT2ocm&trg|u40A2;SF zjq|nqEGtSm?D;sZadFbA6=qVLW(7!Aod~#>PSG`xN|^Z_0C?(|1ZRiuRnV>l`4mSZ z7O88tfQ?z6a#M{HvYZEjZsr^vqqUT^Km;ZRUZ?b0k^wzMHFX2#pJ3Xw7rCBEtfd?b zIZC*Dc4By8&a+4$kVG*tzFJ8#tyzm92`w8tRcmY8GuG=t^=wP}lfq4<39=eW5E6iD zCPhzXdh#*)BY>@;%{F}O_zp&7E>sws{p)#HV=NWsiwuS{SN;T|r}EDz$f;a)g2lz59g~%y7f&~|1@)>_pwDt}Ir;*RM|8JEr#L#9e03}~*uM>iN z_BrKKZO?$21{AbmJ_2Puo?92jb_pNENK1{eECAL9d{~Ms%sQETi#lY{6yL?m>BzAW zzsb16{2XQnb`7&3+rh5S!LTAnGl>`@SUH>x6mO+rp`f1a3h;02N|!LsGqL9!%}aqj zZ9q03psWxMYt9m<@AH)748upPhWfk~H2on9jDR_f;jM^~peK{#;l8gbhnwbm6&s&8 zVT~ImPR=LZkN?ucjIB@8H3%lRJ);F`9%iA%>abW0knFonhwaMPz1#Bbb}QJLm&%Uq zP8FMy@(WnLT3)`<=wm=;HQm=I=gr%aiEGP`f)f#>vn={_rAEcj6P*Q>v8a`%D#D}< z`eT!C{X!!jV=NP6Tx{Q#tO;?_=-`;IdU6mEh{vT%j;XgU1R?Km;@#dIttnlu*S1h;F=3GY1Oc2i2b;--+@Ri1zB30Y`T= zZzTxL01vG#O{`zaYIzjx`yaV(BC(r}J#B)7EZq653U3e~R$wis| zu`hf0?v)fD9R2p+`ly>e*5}7IwGPtf2ZsOk2$|OWnaY@OUXEM7tn=gAqQ#c}%8%}8 zrJ5qA1rGfEzGZQ7*UP@X&3!p+`gdm9R3r4rF=vO_D<}bh4E3~<%0|hy;1-T-TqOl5 zu*Nzof%R*?t|X_9IetkK#1YKZ4>gj6+%U%_bBCP<`+l2UV-e0cIn_x-vf{UpZ*rWX z2ecmR>BjDDc9=E(XbeGQgRc|JNkk7hwmSEH?LYb$NO7{z?6Nldv)Hi{MyW%b3e@r#g^Mh7tnww0U*!x_4 zX#eOyL%*Km?JS>>a{kKLn(4Mct-*eazfm|~aKU}VH&Bo9#x`s}UNU3K_oLFw$3JGX zKZC;GbBu!Pv3Ju;{Z)iV24pHpxj|-654%*u z$8|0!Z1V7mUxpGOb8-Q>ML2(_%~Nhl;?6TPeSCbry^l7xIc=n$D*a|CP`mS%(Mh<@ zDgTr_`RbhWPsxd|EmrOf5!*xUYb*TEA0KEtsR-_I*F_XIR$cz4^BsklmNcsy2bR|^ z$Y%yK%3G#UM<;M{v3+j#_{-_spl_jOlIAehkgLjXvS^sq;Q4nN@P(s*mFA;MZLNQ8zO$zFPhnyu`k|q`1gg_5IZ7 zoy-NHKXy+2sF?omj#;Cs$FYOjKfm!`2o1^DBkl;XaL)xoq$^5AG-yf*8z{f}0z|_h z?Ejf7Fe|ux=cc6au`4ofM^n0lVRe{rM!gIv9ISA~(v`Tr)l|fd)vWX>{NQ~EyXkZo z=ixiurROn~Zeugf$12s=#V*n_J_>ZHWlrB*er`WH?oA1D zI(%wev_I#uTis{xTF{*DcfNk?u-YcQ5Opof-q@Z%%GUW4>L&c#LW-VL1-)EMeO^%! zQ1^0iW8gy5>T#Xr9ijvK7v9_dv{&CO?eDtqN+vUL;?vUCO^Fjzb%;6yy4a@)@Dv3| zHFJGRigU$IL&I8DRqIF&3h$Nz++gU(tT(4G0l;ySf8)l6Upnq<#z1BkAOzJ6DoxN@ z8yX*yfLo`7eyKgIKJ@`2b!`WbjXi+;bW6dpHj$|p?E zfi6a5BqtLu07r$|v6?4rmjw!7+r-K1chXm2YnG6}8^b!DqRAwQ9V{G_noAcFV+P_t z$rvNCsY|$>>G)(vGUh=53TVgOPH>De)$`UQy*k}}k`)gN1%>HQg8~wdJS?DU4?=%y z8#)2tgxH1erIK*KZ3O)hq6$e*L;GTlB?Y%g={XTm24o?~9nM>IPZ^ zcOe@i7>0ah5x|ZnHQU%gLE_ui7#~GXfhAw?qFDHZHozg^-wR71>_kirMh1j=BI}9- z4t&UjjP<}W!&KnF+O=JG%J$rcUyvCS#^B7ABUC&en0+`Ep`MP! z8;y67oiDW%g7nSHiO*)_`eh9iLt;1?^W=?Y8(?zgG%rbT+MnU3 zZmf$pu@j(nykJ73!q`6{=({G3RC4NYoX} zmKWx?sZ-rg5Geq?1DS3qht6#Rewc_D%Z|5yrDXdc2-E0=%?4{3&L%^?2jpqt;(`0Y zl+#{FnQyocAPKs`fO*vgeurts#mF8PWo|{XiLroz2oeJTN0XRH_=XMUEeA~*)=Ulw&Lbv_q2k&R!JsJLH>Rh9ANX|FtD*nt8XLC%W6f62SFX zZ=7gubY@MG+cbJ&~Wo}{z zmG$sg5CnaTYfk+6qnua70I3pyL|l==qWFj{$+bsaWJ7-XN&Sef?G85!a|*4XN)rl? zXE+z`e0CnHxf^b}eCzep&c_`ANDJvXz0HKPlH^JgKl~0wSgRp#p);oEG767tq@Cth z%_{uVQ{OkKJ1{W$^Tot*uI1_F*^M3nABtP7zqS-vJVVuuD@Zs>5w@vQZXN4E&Sic} z%DXl>8@9?0clGqQEQ%V^MKb$OikQ&Cd4TnB=9gf)RiF`TQXDwwnnkaYXU0s0hef2&4yz?td0CjW8wz1Z1ScxUmz^aR{5p z&57RwV)RXXxOn(u!Nl$Ps*oz@q?k7muiZDC1GDe%Sxr4D)>=liF@S3%%sna2h;ZI4 zJTp5dkyXO!Z-4?@eZKoR1Kp_J@ODOm^J#~V;~()c<U#qNh7s27VSXa2R!(05s;0 z3?MVN$$sQu@b~JE&rz-?YjUu&{gxTJ>3-*QYv1(RbEsnd$?Tmc|ESQ^*}hFBV0}Yp zt>00CLb@*PVQj*|LDv>Ib=y^}P#!7dxa>|^heO1%O`WPrsG?M+i9eWYtV;h}>9uon zN^-<=w}Ce!D`|(`zk>O&P33(yEW)Sk2}mE|@md*s9w8Me|7|L&sdBvqhT-!wk3*pw zjtrzEr#zt0eQj?Y@$m6E?&EBG%EWi>r{q5douhahm3BqXFYrWk%TFf1a=JqZJuDSzH#r; zcl%ktrLXo2YIE;07Xv&LtG*Yxd8oITYzv(FyCFWOB&pLQyYRHWBzIA(^?D52PdcG+ z&=_q!-rOxT!i67sSzDX|4TQ#Tj4cNuFTD6Tefk(qHYx zJ16TFS2mPuCYSTKtj;^A_$l)I7YaWpYsiYn@>*J2h!zHPexA~=$!QL`T#4;0`rV&F z41WYRomo@(sS>qe3j|QvMgjTT$$Jna$CJhwNRXy_ZTRt@(capjm}eef%8%{LAKs%%EmfAL$)c-&2>@R!>YX^DQ?8FNjXg!5Iy$>Fcrc z@A)>z+d>2lE<6AbOxCX*SVv{ZArQ;x)}{I^ecC<12|_R>C`Qx}+H90UU&UIxmZ%+Q zu&Fz^+o?q9x)dojcGq?@As}Lg0nBO{AbOy2R|vOnr9d5j)oOmlt6L{vAZe#sMH3g# zf_^NF=QU3>pQ>tfh+qX@#=TzXHr>KdY6lo8+2bdS5N$Ia3a_t;J+GO4*EW+r&-FNYygD_1qRq=z&BJ2Dg~I$(uMV9K;rg)6pGj+H`4Wg zF;avv^IWF@Da3?8wMk$b1;FTCj|0@wgWjJ62x#2>g8IO^G2BQ}D_5D2PKIp41jePu z0c#N!3~~-1WBojUi@+!Cu~DMX0k3tgLbp?CAA^`!q_&8nl?wsKu~C_#Y^?_v)+G%5 zM#w?33gXl&@vMFbd?pVAF-EW$3~9m>0VKuq)l^ zHK_*g`Nk^)iUgJ$*Ln>}1n$RV2lf-hi>!l%zPZ4OKDNdOJW>fGmvV z%4#Oae`zSpPJsa4ki<#@(}ygxdwLN5+yc6Co-s#;5O_LDU&>u#bW;-@Qd> z%kGQObPEm%U`DFZchgke+(M(Qn+TpqKX#o{58DReq?>HD2Ob8799 zg~4qHSD!YnsF0UF|C$n@OhX1cqW z>V>%MXZ5|c80YIivFTTH3xC1MmhA_so-Tsb(;rQ zROw{hjLxY0E%?a`P%>wf->v?>;_cQ+|bw z|01mN$v|+;>Yet1^KMga3zNYq_DgG*S86X0Zc}7jm7l)+@8S2X0@)tV7@E^sUgTI7 za7W^VK-9ia?nug>OiHBK2}K7MaEJl_6SfWabjv#naef0ng;;bzzlbbB z2YfMNO5`V(%8cVzpB|EJ|F&-d;eMLFi*woC;qM?uZn;g|Ner+ z?-eyqi{em9(1I2Y@@MDazeoniHGkUmX#;YA3O6Th zRq4ywiDY2!F4q&cUuq$l2-6wZzNRIhcBN6EyDxqi3cn>Q8hr{S3GnopYdS$lRgy?XI^b*snixqcs=(dqVq z;$7ZVvxD0ZUm&&qr=N3>&qy7eElBR3%c+65WT7=p;arh~fN#T-KbPPCvL{vMN70M& za@-UDn{b{4fgdXQPj$}n&N^#pr+$Fv3@R$xYTl0?s%^% zo*pCzb7$D}%o^ji9^=$ah!%MiU!Vk%|FL|>sf+Winx+qRoLnac7XDj)rMNhiIr6V_ z@LEOfrLAqIfir!Y7yru(zU`%G`p~|!^X(4_7fJ|#(3(OJa$B$9t|zAyg2YFS1#pJI z$A~_KAK#V)vG}1Vwm-vMKbE2jjQ;35_$eb~xBD}CH(#XyCvS7SEe%0~OfGX92ZP-G zy5}+diHW~Z-UlAEJGcJyT^ww@yC7Qq-4J=DE^n`x9}YdrO5`Ktb)<<-R=z8*vFx97&5bpJEwwN!7vD7SEMc{sSOdRB44 zD{!f9X7=sDTfqz_)mfuk>`Ur4V=cCBpsa;MHaYy~d$-(cI$E4!&lf=B`<4Jx=&7 zVJ~}xIqba%yb0hB5C|Y*rLY;(BU*cH-*5pF6I4q|QFt?CGrC_$G02;>@6MhAT*%jw z;S7EbaKy{&!pfxmX|uN3i6;4ma84nAYa=};p0`X+B78C*gtMRDcB0CjsD@ zxCX5NN-l~JR|DZ=fCFf?*9?Fq?7|)w?*tdX2)G`0e*Xbd%!?^p!XJb2JTFHnR6*>E~b0<{En{C#Pao zUurn^Y$WYOprXc)oqry0Xp8nzPVn6_eT&kMPJSrBGO3;DyRoMtj#SA&kCDNnq=X=; zzkN~xrCLx3=hx??bn-?2rrqw4?*~yaCy7*`QDYTsLIoIJJ5u0B>lCw6HnTSxDO*t$ zi)t*W(id*j!W*sE=W@{y)gZ;wm`&4u)u};&s|ecuMClbP8p9|Z(=f}Z2C2+J<$!96a3=H zum_t-g+r`_mQ+gI2PF)|m$Zi2$Xp<1nREQ{gO-hFu?ljug}YcJ9~4ch`ahnoJP@k& zf0ra%#ZVL#8e7U*2xV!KkrZ`Pr0i}tL=j51kSWTNG6`)|-?C>2ktIuLQY8nHqv^f?ISXuNk2yCkAWi=RK{b@ zd8}3lwN~mMWmbMPk)sOv^xBmD92@?q6&SHTp_Bc1sNuZCuSOvlEkP|ZmPQOlnRShg zi!(N@zKc6S$(LeGjxv)f=ePenD=(8>t|<)Rj-yQAt(y=Mv+jJSTp`rvgKoF*fl3qJK-9mX_G^F7(MA7Mfmw4WeL z(W0%rdc+TFxe580Gi&|tWav7lMDki6<7Ok+|1z&>t-BevMYpD1!unY4o@I~R?=_oZ zx-_pmk26Fm;v>8EtM~0{RJR_pAVrMgGxOV1ly~m7jxFQ6z1#prq>N>dU1>H|3}?BU z+%}e4@!Qy`Z|PaTn%PmUz@)N&bc5n+Ke3?|Deu;ltMLw^KZ{*YBtjky5{0~CwV!6* zA4~VYwfW5<#r)8Q@13hyZSAJ4my5I(9XPW017poh*U*nOvrmFjgMO}=Su`;-rq3QY zGRvDg+9t0u(KB)K>QUgAzcK{VLa})0-E6b1dI1UqwNR2HcGm(gVaV8!_om`ZO%KV72I_T#GJ~CN$*DJ zG~`{-OY9oqDu2~bQQ@ihW+xwyd|2cn@{Wv4i zh`E5jp}ykl)OAaK_=~Yt@w#a=Y5)huVE(kz4C*gI<_L`G?H3z&CE6WRiZh^RK$@As zD-#NZP(yCoJ{i=ED?h3>ow@f<^i$ufl~rG7HaRQb)sl?pP&m){=&ARkF?E7xP2l*A zrDeakz61=|pFAd0r4i0K}W_^2d@}xF$nz+_8{eEXYdFx1niTm+8&njTJTp-uy@YB=J zzklDrc;M6fewsM#X1tcx@E_^jcKB)0J>%^WigMtYwdystPJG&1Q9eD*ER|mqG`7es zYj?oZ^kx5v5qT6;*`Jr{gLUU$Xz8RC&V@Q-_}OEvMoZWe&sbW#nBseFvl~C0;&*^X zJLj3@M?2yDa7&0*H%&w>GjUi4&6Y5L>qjuL_;j=RMc00}I~zy!K;`UF}XI`bAhIqtdjO^e3g5@T8V=UDJ16q(hY{ayA; zWpq@3Ht*Y+{-)hW)OVL}T$a+=d2{1rky;C$hL5TmypXm}7U0L7&|1qls{Cyr0zMzK z4N&H>NNax4e9BaKdxF(i)V$kM1X3de0dXfs`RH&gYDusR^-r##*zHRKCQ`~2I{ zrOKt2TW~U*27pt7&282=X7?Xeje$c^7X0o zIm%U?k-DLvRMR~J4Xh!_)b8Q@%PEHXqd(4c+t($OO(g{}^nc`afBL-i2xM5nfXv&= zjPa9!+#DamB`30x2%c(2o>-nJJ_uV!N(-@s8IaxE0JW+TVL`?Um}bM5!jv5XZN9KM zz0z~khH$?m`sxFM14YDYMlS~1*Z;v4N@faT1X5 zD+>x+bn@scSN##kr0qjAg#@AMk?b6UNN#@p&FzQU*oM5)QT`X7CoER*@3H&x1a4i6 zC{h~Q&!ytJbVFjo77CX`(OG8Ph$~@?EK{jhu%NWEIpJ~_Z8$zR+TBDXE!a=SzrZeg zF{PTCi!jrCl*)_el<7vUKuizUt41$@ed`X+@|pyFN2n7Dm;Zq%bB_dYqQUH* ziS?8m#RDaJqBclw3`e7xTsIjH&dRLkA|Vj=jHo=0M39?7qyWi6OyhtuBJ4N^b_Va# z=96U`UVj*T0|(p|e;XM-8;(~C-dDv7dlbMIh$0d2M)doXpvA}mXE@)pWVjVMkPG6P zxC)|9<`4rw0cRI6V#0x!Mw&P>xbT>fDl{Qrk%LppF+N7>EJlw&IT;jR1P!9;2rX$` zkvu_5dUAX)OQ;Ag+~Z;m=P3$oLhv_Z3R{3PO+vV3mr9x}EQUVeqN6+)*8rA|YT|0i zNa-aoO$^f+JvF#0F~+_^Q+T$k9gwP$dN$Y^p&+PEq81rV;5wos=#2Lul9|qp`#oH2 zNvt4Hpc$>0EBDlwxnp}dK~srn z`4$3Q;WptT6P1mS6hOjV9Jm@+_yYQfL|$8s*uemt%Mg9L(kO!R$b&mgvEkel z&UC@h90E~&nHcwkU^bC8B`&66`*5Ov()bC^tN^|=q720`1vwW9=2O%fBF((s=akVHq z&TV}!`K}q>J)1YHH#?CvyB)us(D}Y+-TADuRX?Y;nRl9in&|<+h(zcSEw1b>0{n2#=-vkTysWCASl&Kg300w=9s zy+bor=o!aE)3p5RE(xH!_1K^bAvRr8-_EZt^_4>Ps_AQr|6%kq z5v^PBg&FSX6I72k+z$J#(3N=goO?voJ8MPkN?e4#rN?Hl##2Wed4>y=v9q;uL+z*5 ztmL?;)}@ml(-UVWv}PA|ylhCzE=V}KPV^6O5-3(46J0oJ3F^gK>GtcfWN0DMjzQ0X8zu5dgl3~)+5@-mE1P`ajd5Rdm?al#(IYCY9-Sj$c7~ zF_pOo3ZL)+u~x#uL6+`q841LH>?W^NOaqZe-0f_HXJ`8`2t|@Nis8S+JG6qMc^{@~ zq%AFDq7$Q+O-H6CCld^#ln{3qA?&@UF@vOIz^yS= zaJ+8k9{WyREiK=v>LcSmGv89DPfj&<&**Os7#6<5{OVWV*;2|g8k;D$wJa#wbk7~I zi0+*V&XDQ50p0YwPs+;r2PZTvv&W*43JAC4RAsXPmxHAY?`P#amH@6IzZuy?09AO z%;p!AsW(TaGz$s}O2&sbcTYVzo|~W0mz(|Fb%~Y$GrDCg3-u2%m6VaM4_`g-;p735 z$Cn!94(T$Jt2$Dx4hM|SN7Kwm%^Hms29Ca;|KrV$H6OvH8?AK0<{9)m79RAQXuD@- zk=VcD9?a%o!xmZ9(a|ydRX#P|qob?K(xEqCtBCN8S4#EXQZhHU(X!1JZJqq4vE=;P zIPHcVFSA}rURyBfFRSqFOkdOPt^JeTvx!Xu(Pg7Sv+kvn$wOX&XW64`0?iv4k)e4p zyRKM%9*K7bsR))mV{Qy}3&zz|lg z*$3!1nhi*d>WM62eK(__e$Iqid7IAPbTzmBWeGiPi(Yw({_4OZq5cO9XM4`x`}y+s zZw*oU^YxU}sh%@>#k#|nrx^M#vu6G-d!{l~QSc<_AHLcS$xDJStMe*D=H-3|=l4fs z#l~GXsqR(VrHG|Csux1Hzt5pUlx_vHc8}c{i%x2H4LDWy_K(Elmjk8__Un&dp3$3q zA2i)bjgfhpqX|5_o>SUQA?xlHKO424Gf!>wm3VRmyN)SFbx+f8YE z92`2$MHj{~ccy*UeLA$h-i&2z^QxmE++p7bj~YIJnf#4K&j!&nw}P{ z>0y`4#^QtkLSQhkL0T6upJ@vUhQo znaa{3)K<`IL%Oq}{=182m(;Z@!_Y-$1#d)ESm?LpNCSW_IDQaJ66WsI7HRNbFTh-6 z#CiegM;X2qF$Q)9AWgggLk#(i5-)y(b&wTs29xfhzDtDYQZE&ZT=djehKo=|PDV6x zu3d{wW&ETTZF4XRnb-cf8P%HdLsTKcE*)ZMxt*(u0bV~)0t;u}(;E3ajdenxp5j1I zK%_o>KJJTP;Jk4`=T~g!9!Hb|~fakD|A-WSqYP+Nw z>WDI-r+^npkj7$0&>``hamijR9VSlURldQX!u}3opnzY}@^QgE0Q*Ee8M!lHgw-5^ z5Cmb@^<2}6LswyS87@RvX&0(3DOBJla*Me&q{BgPI+T{4XFV70rk+>~>L zrZCCbOU411nGkCgnwRMyILP1uazQyKLbN4%qS8ayG(e1 z?%}{UYTy9->sUcKKPZ2zIFh+R1}%jERyxfy0f7-OfQJ*tJN1?Q#cVi3K`dQB3D3I*l3F8#h~?t)pl6y^Au(|2QqcP|Bew19(>$${Cr?&RlzdE_ z5SeY&e?B{IHKVgdV6<_=mUB16Jj!NvwoqHgbtcXz`ZpnT&Wr@_04n%urp zQItvB;3NmCcD|#;PC(-&ZMr*?zdYSW9M)J2p*Bq;mssun!xJf}VEwLlIua`(Z0ORP z;v~n4y_IjA94f7AmiF3QcYC?stCFpqCku-mw<>>oYAU2Zi}pWw zfOG9SO=oHbaY2ZDBv^0UWe~%3YX>1dZ_ah*-W4O*?*?N7qhf_7!N~;jq7+CYB)Br#YFRF{ge+@?ap&MI0k_Z< zpX{=qaZCzGddN{0kD>We_z@z=T8T(}(^Xywv^uUYbW!gHxfL`#F&tVJQyM{FlGF^Y zso6y}EWkRR^L$`ZUo#sG0g0CLyhZ{}Q&c*p9$1EmJGhe#!-1cFF4^6~%wCxYC=^Z1=Zb z&UaT+MFTGSfReAu;P@AbBmKv^r&b=B-L_^#pzNo6=}cpmS6LbRS6i&Q*lD7{bo4<- z>fyZ@>5W>KYQmcaT2Pb@L!X;jlXa!$)z)_0q>|Dy%zT;9E%2Oi(||qGOTAs#3MPsT zJI`E6*W0cRNijUTYUuTy?$5ek9~I(^4Z(f8CweOTO?tg`EZB?&rxb_|&4=zeN!2uJ z8a{e{`dohvDPAs`bbA4)gM!kpzzwO*7l6O_7kdSh9nj_HH5NMBFQ0zk&;I1@>F?c; zR2&?e7(cUSGSu$2^!$i@*mp+KD^1vB0-mNB zh+@+4nkq)Z7}JzlYxWl@n;ca}W1*-Bb1|TU`X+uSqn*n`(I-F=zT$5rTX<&XM&>10 z^NFA<#~?f9rC)cUZo^%PdseAl8XI0){raA=yKK_-(wdGRSu;xL_3O==;U75d0%e8W zPd2YEEZ$O4DniXN&+?m@+8l1wEwL_!+0aCb4n^t3S}8-;f*;dX7A7oCNRzKT$o$dw$QxbNoyHIP>_d%r1@845bSd<}wW4H)m>4{yCPc z{)bGc8+HsG%|K_l5skYOyX3IWn2$*=LF)W~SQ8L)P&~XD;nJN#;dp3sb96)gY{NG1 zf4tM1Uz8>z@kZ;%ai!aKNzR#Aq4+BXd1D0Io@`nD=j4k#|Ma3!o1pJm!+)RG?THZ1fu}w~$sMN1- z_Pu)j-Kk#R*@Cj_vgzZ~E6aW>%?gg)OIWGOcL6?N5FKaIGc+#4DSgT$hDprRVmO9= zv0-%XpB@*uDg>Vj`jeXk<@) zD?10;R#z8}g$c!}>N^l@@doZp{BbK-qc0yfD8MX`czSd)s3;c^FtH0E1{mcTPLX_L zY@0&byz&@vY4viLX_$2Q-=u(PoAcI{Qqzu)lu|nNQA)LmYp5piK~;n{ zhhGVUJ4f~s&pa&CQ)Lb1oeu|(rgZ-^)_-Pt^`K8y{;$mc4gSx=wR(SFL(EUXKWBLB z=us>Fc^1j7C+WFl8;#f0fG^%)-!N?{_u|{Up-UI7R+N!bTOoUiMwdLJvt`S#ZCk$m z?`g~7vFXX_%z??AOrMs{GvD7Ivl{YZ^t*WPjcLP-??w2ID_x{_r}5=sqOCyRl!IuZpK%q>|NYP;7OfVr72|6oA4>2 zC*cz{yOC)!2uB8u*ba+wO`{HrAEtAdR|f+Z8vlukv3ataGKPCwxSMiZWiy}KNz6Bs z-cpR4HeIJl2>t@N$KG)m;;&S%xDw^USLiFKCB{sf$@RxS(@dr|Fw~5QpVAEihA~>f zG>TBblTTjdD4PQPGO0T4H|QMKqf{yeH*h&F`dUsS^Z6>KZrqo-t^{)m2KAu#)y)_t zorA3giRL8mGu>BnOyK$2y>SoZ$qzr<6O{k#t74Mk&5iqyo&A+_6*hKuTFy)W^@YMOQ|I{tRl%(^7B=6-Z!eY^G==H zm=QwB zx*`ix`wY?m*3Yn3anKA!%JchzUoJR&fTwpsK zi4EShdY4<%!Axn>Tth$XulBOrEUBnI>3_TM#HPzrJ!65_h5`qCpCzcl-x&DBSM7~; z<%XO_RY9UvII5l7(b0RA_fMvu^squar>CLk)aMr?d+3u$y!F{gG$J1QiBFEE? zdy_<^>aaLTpavRG`+c}ZTOcLskkVinK5mW_*QQeqMFHP2;ufzr$=@<4Iq33sJ2x{Sa$cx% z9!1#THBMGv0M7w&gT2QFBr{wwbg?y4NYguha z23wnLO!?fJKJfg{tT^^+g7F=X9VvnT*s}e5mQMfBf6?Td`eSGJlygeyWLW3tOZ{4@ zUS2tO-r!SKHn29Bu-WX0hxc~-`4?gJSs15V&K64p9 z*BJ>LBzC86{oHnM%}Ia%y8E%Nuke%a^@e|HvWxWoi zJ&U?0rB(E8#(x!*HWmc+>jq4n+pS$vn6@GPPuh}U+=rOumiBGynjSmu0h@df6aR{3 z;gcuQ5k`vP(){(P6WNRNc=mZ7cvh}`NpOCKd#i)wj!W&PSNdmp@`cUWod&GM1uyFRUyZNro~oI! z3Y>14$Pb!n(Uv@N+wGgmsVl(`FRoI#tkO0l7iwgqm3|a`5_|YVNTT^5W;wG?Rj?IR z(yxEn<{+Pb9p@0Thy(K0U{OTeJH({0(Z7&|lt3zL3U!jc+XZQ1S z+P_OhCv#`J3#5t40Hu;4kpZpgCSj9dfHAocf)*EX6vy^P_3@gr+~+=XI4q_106)6}8kDQqX6120!DHhKVxrXS$gJ7&bTRo3?5$>1=#7 zSCX>Yt$9hhl>kQI)^ zTL=wU5{HY*5&(E|q@F~hWrr)uabU)oD)KYs1g{lvj?F{oh4|Rh1Nh;b>1r&rH{cu?=NGkE zSRO|obkJe85DvG_OonGBFc;8}tjIcZK!t&^ngc%GfPx%QG~PzN{Ga0@t`gROAaV#a zRKbAYJE|NJd-Px(>3}hbyMgaVBL{#bUhR;_=ZT6V6RPcqrCmb^GnW`yOS)vPC7J^a ziV(j@X6K(77N&;qmf~uUNx#BHP3FUJYf{!sM)0*cg>HbT46q50aSq@F8YQ;{2gZN$DPKcffHQ;lxwhJ6c$89gM*2tu1-1l8 z#gljx<%N?qct{8erN)xMorC#RCbvay+ZXi+w3%30Z>kiRyu66*&$F0>S*w`-LIH7I8-L!GlPZh`45A(sx{>^gO%YPe>A>9?*g)JM zrmG$g_00YiE8v&$q0vMpsV!Iil?HTvtaH1Ej%K{Iu8i;(KUffsEg8)|3om*}enms8 znAJ7t0a4Q0hQ{35zU&Ki%l6oDs(4lI(F|b=z%Ck0^NgJ2DmOyG$ZIDufACY)3ILwD z_DSky-s!>t%JD}P-hF@e{D5D-L(lQ)=viieYC!ECF4@QXa~7MX+dJ7>nJV7!f9RL- z&e>UfM|x+&+qePW6jYqs`$FocH!be_5XohpQR1H()ppoOV&x7Lir&VT7 z7M72E7U+6fTH)@vT3$U~Sq6|-eTkEtEdOFGE8wOyZI4A;@{?T8N$x6qSm%)}Q>Q(Q z$H1F%SBb?}l`LfbM$s&fZ&BPAvpWi3t}6)}?kQaRhLI|@Sh@>IAjy+3N11r2{E*6x zIAa=WVZ8`dCXYoE{dV&OQz3q9O}vl?Bl^f-GfFmByQm@Hw6L%@a#QRG6X*HBqNoW8 zw?;Qm9iC|l+l@RiptvG1iyF$8-uU-JG0U+OlSw<5@En)_>SjM*QPAzEsKd_DGi7is zjtcmEZmg}jI%wj^tk%@SpkJ>>+Lr2vOm7!&kSk4tZ4h|e|o zo;{kzt$1LdBCxDd^J}!bjY*c%{!?OWYgnpgcF&Y z-@b96uEa61M>}Pa=Tv(+9Ro;?Q&xD~?AQO)m+TK$;4hsix0?7;xx4lIsf}hC%M`^U zDn>qUu(GmRU;n~VOI_lcl~_aZmNh|BYBK&qPsjdzuXpBG)WzwB;-S*|nl(YS@863w zr$L4$L*UxAYfji=_~QL%5sZ~;^uWNet&=~IYZ>ljxo@KqAUgiTgywl*|F6j{j(ljX z%=vgCsIh2$WQE?~Jg+CkE>AlBPIxumTeIa(mu6u9P~gSm)5)_*OM`~h)|?$z+{-+u zbm&`V-NtQgIjgiXe_eZg6;k#2b#l+N)O zU#z?+aUw%dxR&AW<|x}3Exzc)!=7M@U=z(<$x$&TUGR1WGCxO6r@Zd(_uE7M`L$VV z&DqQPzvO4RgGN-Q<+{h8WR08~n%%m+JpP`Q;9lv0>U}(LEvk!`fA_g|#Ixu359}B0 z({==;COIyzDWCO23;u#TxkImw0LgE~EGa@S*(l1KZL zLRt({uo58O|AzMJrGmnT8blZX>PUahBJ8nLcsh1{|FaPj;(o<*y^|*krDb>*zhhZm zKoERkqP1(m`D-e?RnPTM2=Q^;_v5{MOTbWZH+y!fdsge+{!C>RY~VLheW_8?Hy=-T znkeAdJlaDSaUQcf67}IY^Hkkr?v+5 zWlj68FW8Z;aA?r@QcZ$_>eUdmoh4IWVwFcAM1gaCNDs09H8BHbskKY0qH(J`?I(;8 zxX&W=1$a~V-|ig{ru^3nfUdSV*qBVcjiM2rU7b6%V-}% zs7Iw(-4}rmf!Vc@uJ9#_&+Hl>7IbLP;RJSx38N3mQp)gHv^hoC@TAO&&0nbhk|XAk z`0l}0lwWP7ig1dm^1WECY9G-A#cRWO3BQB@%=4B(}idf^|XBtYSzPaHYf{m_%<#bZn(uaLD!DVxx z28&;BeWF#=*DxV@_A*CMn(Rvm_#zDR8;K$#ry2&T+SRhaf4&R`egj6*!pchsJS=qM z>g@RhK9H}|7Ph0#XKVl&$CkdR8$!4#oWV=t!VC~jmS6uv{~=kAprb|@qdQ#O?DrQuR1&TH~XZQq+?NseuIO1!B%0o z<7pgXX(><$a2&_B`imO@rzXAB&3xu@pvlJQ0KLW>q)AA>_Uy)km>2{|VbaW9+Ls1~ z-;IgT#>+dY^Z5h`n?Nur!FD*EIp~T5!6{-80gXWS5}1jpW)93q;>Eo3vji$9X#f;+ z#v1P>92|*0m?&E@PG%JSgC{8D&oC5!iDM_7q-ZB)7zP-^iIV}!$VnC&@q$KwL+=H_ z2=L^j({3LBqzijfC;7z2y_7jeNi^9Zde%MzBCo1-E`FdKE6=1GUPEWEtaJe0(w zDEIZ|Z#(#;!;s^MhN!v5Y43~@>LncDVfx$=x^;A}5cj*GWO5L4_P@&tUE?TWL&g;+ zO5nd&Do7Tb?|fV#j?uvdCdJb*nqM%Lh^IIPp8jKHQl_(|>3q<_cmT+(L5eUEPZ~=( zL+Bz=SE5X2#TapAF#2LC<7*R1L(% z_o(5NMhMeC6NvE-JjBJvjf@0ScWGD%dA3BdG~|2h$%yNrl|ohiB~mBM2d^DGbbKSt z^_e(me=T^ezwAB9r^x%pjVc@qY zzev9+yDzNXq+Tkiu&}6F^IWKYlH2@@8>%tsTmK7-nY3U{TBcsvw7X~?Dp^X+W?(B zp^viDHjKrDr7ALzi+HcA`ghnwVLj-h^$cf2}0b z8TS+=BDW1_Lg2MHCL5e1_^2N5xzAmn`+8SXnEU_Y?G*rg=k=*tB;)XQ)-4K+yS!f= zeIb?(;Lc>+@+7SsB*oLAmFT$>D3gVq5-p#4dqdMQX`*IeSGEbqCDw$v+j#E35XboB zAT(QlWO~gCRY&g5kDfQ}mD8ElD3gL%l?;6p{oRu)=$=ufZOLX_e7G~t_@oTKjV9Zk zMky#*^h->aO?gzHl6WVsUf^-paOzCWtaSI(+On$e7yC<2o^9O7J?ZbVd37z%wQFMH z@xekof{7ySwH-OTT*;!elrE#@s?G@z>S)Q`7;`fg7+AIELkz9u`kOtqkM47ykV0;= z@QwAhb6hUCt!E=9V8)Z8>vhhUSi>&CUw9^C+ge<0nNjK5v zOTVQnXp6|RX*a0GkuMV)?$T}@YZTmGqWr}1xkU*+0OO(Gt zxImmo)UdL)g5TqkgoSbG=+o|g&*I^k$s>Jjd9Gyh6Ax;bv%B6d7@b)qF2}yOGbQNv z4g2fjcRUYhnw<1MyQ|R{6e8M1V`OA%ADKDdbauAUloGFwwYD-{aWundMPb0K(v_pA z9$V6Bo`*abyzGmsXnUHj*khZKLsI=i0hv&1E$eo1UkzcP#!Xt)NA2ntHkqiH6^Fhb z|9pH#W~gL#Ng25Nb&~^ItadB6eRtVfJQ3x7&@<6qsK&=Wkw3N6&)fxEqp2dHw~u@) zwDn+~kFA$)L5V#5C|AQpF|BV|%77ky-!}jRav*4kXz-J#Pe1?uecins?3AH8ldib7 zegBiwC!SvvQ;}+XKNIJC5V@GDV8?0?g-Yx_DjEHx_$sxm2X6s6SM3FQ?fK^v{Fh6b z;U)Q?t(KM3J%nb^>A+XH`>l^T5hfCQdzwuJ!*P(Q;-SOe4Cz{`Lc$OT1AKiQXRU>>`k{tzZv(NrTaT|9U1O@?rQg1)vo;g`(=1ut({jIH-9$B zj6SNr*Yu;OUtN5?(%~*W*PzXD|I~g2yE|Q#I@>b4X{IfReK{{vsd7)^DUE^xEwsff z_3TvG9u{WpyjtGP;+V6f3kOWCFcr4V;9y{Iw5n!;eYGFQ5X*H zzVBvh%`~L;b*4_%jQ_1QBjh|A_B{KsQF1hZg@UN(;fDUSY_2cz`|QARuhF{m-_m2fB#%B( zOJMe$*qH%{dw$_SkpsgLsXKX_?|=;Vf}%{MqrA zA#p{fh`=!V%P66W2vJ^>bKIJu&v+QRnkKrNE z2_>r<-C&3;sP;DU<^t;L$XQHr1@bd$EivW-U+C)=^NhWWsVrnmpi6!j@1McJJ+xqV zZ0Z*2ZAhF98&~hG3RXMdP4SHqzO8``%HmCRMxj+$n}k}u(H1~S-fLoajypqE5glUq z2&#e?KfTtNQpa$=5Jn1dyi3!CMS9yqAaP1c3tJ5NI)0D zOnJwVtxp6kI2R4_6EKB&Hkre7p<#0l<`agL^haH$pOhys z=Z$Q-$ZZi6eZhm>=I~fd0I4c`1m;SK!~cUrbylpP;0^RP0LjsYP2-p*y=D|4axzFE z$W_b%tvQ&B6Rerr&x7-uB#$Mjd{tfE3G~YqiU>jHAg(!LlYmlit{_rx0ajtBUK0o9 z4(9E`)uJHoCxRQ@7#Dvt4{_=_U4{=NI?7?#!-UA2{{RR3=bbSxxM(V z;Bh^CmhUx|+H96y;+k`$r<1oRN@PI@gIT$7TiCMgHlXX9GYGJuahY~n!90;A!8 zOGN>bmkgdR-f<9jwF|EW#Lnd?v2k)EL0 zHZK}iK!|VbkgO7LBzr!;HE5u!fJwTY%nQy}mAoREVWT-}xU`r=Td7k#kV0@6p$cK= z?zk=Kr3rB|F^sh3%5AG-R5-~MH$*IKG_0rDzUuz{d^TDCpR8HklCy~n@%xQJt5z=U z2pAg6dl18i4!>mg-=+6h3APXA_`OjTFU*vf_LLG~eGrdWpp_c1RDb-AK7VDcx86v! z@7Vn{7J>&>30<*-)IhWG&+^GuKeqoyEGr(LiLn(@gaN+d^>Tf$MAhs5tch+BJ zYCps@#4EJQg*4n1+ij}&%`KLBpF0l4qgbXMqvEAz@ogy$3qF*OA^3jfndY{ZH>?Zs z^4O6S|F@HCk|h`2Tln$yjb<&2o%Wk5f*aT_Bus7?4cWOc>*yv%=~_Bf8Zt+g{Q2Yn zJNdKv*^iddf7${)pB4Rm%dveqE!-{O^qI=`tQoo4p5wD?qGT1M|2lSG>2+KKRpaaL z;ElGTA97D@PL+P{scjHxd`IH+zR-NRv)%DFvNB&L=kpzssM)r|=4H0+{iVmpL-dEQ zmrdwTFQ$F|#Ws)O(BQrtMAvUYj`jRq!b!Y3!sRD0bWIJ;<+$)zuq_k#~$*CV(E2VSDXV`aQi+uxJ=gjX(tX2`DdTH!>)Pf!4m9KLi3G}I+ zNY47%8A&}gP;?@Z=hUd4#wt1GYs{~nQd|&t)$Z(yYs3bS#zu&xwx+M)S-obpc8bUS z{I_FUh}D#&qJ{R6mpGAAXc~GLGJk(W;5>PpZPQmGM6zf2*T-0NoWAzeOHnbaH2Jwu0)|!vH4JBDdl8wg z*W4jD8)B7pWI;KY6u$Ov&}hLwvF?=yid#~zeZ!{1rbY(0y2$ohD+8wsN+Wij{{xOR zQ=d~OcTP>cnk{&QYaQ7AWodzTA7kLdm+A#gs<7#jdGb=JQWY*);t?qgck`XJR(bf9 z1R2p?J1t`ArJQ()BTdiGNcyEV}Pd5UO&_y2l=~yb^iX z6Gkr{`AGkkLYu=)iK@=Ajvrs|WsR>5`nGhmPk+L3-~OVb&(d!3DvvVlzu~?dQWi56 zKIJ%8c*uYH>zJp1AvXBye{M|DGU@d^`RVl2T(r}8Nyb*`9eGlG=6N%Ob*ck<0s=qz z>3BUojHEnKjy-_6lfSO&_L$aKTT$O!OM%7Dh-z#b@MdI>UlIJuPHLN> zEG>Apd#|Zy>0uPPeQa0M#12eUjCeym^h$8^%HrmgX%aD2um1stg}zr;mji@C(6C5< zh(3joL-EZkYmP!J9iYW7qyfzvHERcoaz*OoQv)h0`@$==e++)#a(?ES>MCs4GJ++O zS770A-NW5);!2AAcijugn);D7@^JRm@!_$V!R~&opzj53?1|L+XxZu_uG8$Z{h8+%T<3tX_bRAdnUkha z#?k9B@dQA}!ZlNJNo(}&C0Dqk-Jf`hpHW_VyfE;ef^m`Aw`H|QCXWZE3{B3U;euGa z$!8r7i5s2P^|-W1XJMhRXoUSU#Zw5!>mzy9Me2Ya5IE3&!k==eKYT*^}GKf@Fws zu;n|mJ)!8CFst5n`R%*wBohbQZrfrpVA?%~B{F}1b&kBvwiSsH3-dDtJ0smqyK~lC zg&HADSaNSb^WAG7QAUxnu#*0|M`IV=i7DK+BctVl?;&mzQ8BYy)Ru zaY4Lj$%=jP1H~HcpG~VGZ{M-s^L7=MNP9{De|}$O9d2FS{ex{aE37~5I%6C7OJ(L@ zW0aHW28Ds>Crjjbz#I$s-Rl$75X_}rjXC4 z4UyM=n)#S=^twXY@ZiUg7GK|QSV*4~7lWO`SPTwc^0$?aA5pbvd2uy5sj{}RZ$Z;W zVPSlemyYEztV4yxx?agyPIBk_@O(y@e)QA!WV(J}d7V77Qw}1z7~tC`0l(O*;JQ*F zO+I={*J~eyrA3#t7McFkJG_R$i^10i$lQ-=+O@IVQcu2e^RtI)NlAe3qwXpK4sT z1XD=(8z|*us(O4fmD3EX!r|iC3<6Y@vD=FiAM)zMBIi-CF^kXSR0b2RcK)eOK)WO( z4A_~S2ULfnU+V3Yzts~yUINY;3?EViPH6f6)8KVmpMq15^K(dH$dltWyOsTyn^Xj# z!INYzTdAbyV;cuIR%oh_l%;|Y_c$%|xre%^n0$*8=K7B%2Z$!46vIg`TiM$EiJLGJVG1Z>b) z;aWaPYD{rrPo*%#Z z3V?qws;x<~*i>e+j0mHkXn!ER`2R6mxS0qN;?aa8!wVdyKHg2v~#p>|V zOlwD6MN8?7&+p1Al_`~=i>bet21fV`Ea{7V)z#UwcrEzH&8rog7N`9rZi2hkE{;b_ z`u%(gEcG5!TfzYx?n%5`N_BV`ZF%}|(9qb(TKy4`^~tY-h#|K`4O@RzL^qnel8tx$)?$P(i&gu4O)EG5168W;ca`1waobrca*H3 zkNAY{Y`e;j;rCf@#H8%RQOUm5Y}&HWR&4!f!>u)>wc@c0O85uhrRr3|)IO5M`87rY zMTNKVHjrkr|E8Gv+Qoc+_jpFu@bRgKv;09{U1zz5ww^i9<&loIgwfxFR|kCTk0rD> zE$%(2VIqaG^mcxx`Q#6U`5TZ&3BIR`?Me3bzAJ^T<~KKz6qQR>n|lL_O4*Id3*$VY zsIa>ulD9cS_-qX)h27)ySH@4<-EytzLsb#Nw=4BTG)<&#rZI%LxKNQbc_uDYT%jSc zo)-*WcYLtm^1Ft;?Yye5xZU!BhQ+u9c~xIqrzx$fE_oIC7%h)Kw{TtGWkR)k7Ll>Z zRFT_mfvH=cXmP%W_qL^c%;-{Y^jA}hG93?#$%K{;9b^mGih0}auwD0_t}j_@<|JNZ zSAZs1F9-MNt^5?H>u<@zYoBDTw5@wB8p4&z_oHu!?~R|_P3O1UJ#Ck#dsRgUtsg*m z@-Z~zMJcFss2mCXHy&jmy`V9+FSW)=Td84fyUG zcwT?Zb=Gl)4c|@m8;4^p5(ft(Ju3eGdVay;FGV0&MX}C$@>)Ckl8&D%mbGT5(Teb8 zG9}{=Db!6C0&McpA7%8SpPAuYdLq#nC?dq)rrhwPv({?)tM2Kdw}S85AMfbs9L~PM zG{=)vyJot7hFAY*rGD+2-yhf9Y-WD-u8KX>qV$F_a6(q&;8ms6nUJBr#-wgTN@~Cl zzV7krmIrNLszdy3?+;Xe^fWm7jk>V0ZsRI=djfMH+Sh#mc&~!(@RG{f-1Sj?RYmTJ z#Qe0S%+Sqx#ry~jSYTYp}b|F0K- z9g#WUH#WFRxO~Kqn&-7O6(~Bg2iXK9$p(qlfkvG6>fHgq+FXC0eWfZ`iwB8~@KfB9 zihScq${PRaznA$O#AY+(cDd`F&H*2xSW~cG00|@V8!#O2mkDInlkL0Vf>{24Hh zn5K=EK!|P1IcH1~uMqE8IQx?eV{+*CDk`Oe$taRkPmbaS4#u$lr zZdg(*3+1-8#ACy%3R>5$HLW8s~-8%zva7Hc=;X2v*( z>vH-@v$08`#Z*Z0xE!*y*t()OSCs&?W~*B!LWX8n&-4cU>Ys=Y`f-H)y%-91A&->e zCO*Ym@g7jNst&Z1)>CHVPUc4F{Nlj6vw?*qg)S!}2 zzg`L{7(qlw@bri>-fwST zd#&Dxrg~}1fOXEU79U!q{}c5~Yh^>7ZQB-4KPy6O%ZL3Dl_4E*e><(z$%J}(G!AKx z<*H&NS3o)NMoyVbA#$HH%y+4O-s&)n6>`p zEUC+~_PRiH5H)r!I_2}w+qNbX3%L&=t=Rr-@9X#Tr5QdMPI6jWXvUPj?I_NFtsY?v z>uQmj_m*O4u}c~gt(~a8XVywSor#x3e0IKd`67TCl@BQpNB4Qji2T(lem=#=CgkRT zZnN`tS@9yI0d+-ct1jRD$l*i|64mBiBE|JSA5|~4x*T4f-WSQ6IT~I%6kEkY*Ccdk znCBU~+&Z8m?JisJqVXs;6&Gb*`J$M7-^O93t(b|-ZO12s1Ogdi+X8M15j8uh zAAHc-`rO?0R)@Pgr5j2faihQA+7=7=>PrM!iOpdR6_g7I!~_|`bS;0$CC%yTZbJtR z0Evp*f?g&Eawe;7fy)p#9V)g9eX%{#JnWIJ%@5F`i2&D&hCdkPqqu(JgDk^N9f zuh3{hxKfaw(6E>zYD*9NSkIw^flC`|pgacf(>vx!4#^?VZA6VBY*b>Ba@UX2d+>*U z`OAP5lP=ebKuyIUxZ_$OODooU5nspXzKo*@6@)MSk91aP&=~m)pwNSZ2`J=nzJSEn zaH;shsyrvxP!*Y(5yD9m0=_$r;4`#HKgdS0fM8n;Oi0_p-fh5tMB;P3|8fq3Be?_V zvD{FXXXmPQw_;M=P<<#Kh%YZb%3Ug^s$gfzLIQ)U$e@x>CO90Ybv58HI9~$k26X@MmeAF{N;X)_ZUm z7oxW_MfO$bWdV{01VC>aP_%-FaokZt8ogKq+6J^>`25)nwYYJ4bk=ij6DMhy%i@sf zkut*NoW@D&NLBD+>BFtK4V(;uG@o*ix4jm|2=^Snqn;lS#ToVU4S8-j@&BXg$^)s~ z+V2fzOr}ba@t9J{kU4XOt4Q6el#roh$`r173Pr}0NTpC;*H~24k+>qH=%S=kghWm{ zG7rDCPxtr7cRz8?JM4GAdq3-W)>;pM$1`;a$}ddv|8VUw{`udhF*2~XQ%V<>JeHv3 znzM#5`G_4EHnP;B=`x(GH3W?bnK6Qj<1wb>aIpM=ZJ;V}6E|HFQ zRPu4_5g?1lBkF${ngQgBkYmDx$wX5UxCavyL{SD;w4kN)V(l@ zY`s;`lY4t~mcBG!`!Zq%d%MTmjMWFVI}ExfM%FBHxL>JMhwFa#Xf`tHV2BvxE3Pa^ z%lM{!uSH(;f?&7HP4P7e4v)1aESDR^T^~Pjxsz}9w}Nf7dHbjB`|t68P2fO!qzabj zC;!Ek5b-6uJI(-otWWmt?hfEV!zm9$Pw2j4nlJR}6pMSe$viwqv!~m=ruXVSSKeN7DO5t@k@T|D)xqPO4w!s_~bW;$Y8C ztT$mZf1P_76$Ml2#CHPmJ#P!iI=+p~txTT(zQvZ4m%Y9;gFE7Wv1Bxr1>(c{?XS94 z_eu!zH*GKFuHR0aBN|Hx{pRSUl|~*%b_v*ZOD0yAOb$e%CRqU`v3&4%WdxDvbBM(@zL<*&61+zTTS49f|T+Swza?V~pD#Zq*S zxn6kAdI_xURVSOPJd*~Ra7lK16zZz}08Yn3UtMCFk282m8xe@(m_w*X9aS$hFL)GJ z2r`qa9T#sTUEbZ+2w@Uz#891bE=F!TAl;xC&k5Ypx43X?uF=P@rdg}r1{l|!t@cWC zvXr%?z;*O`NitO_)}b%GWKG^>x!x|Yg1ruJM%s_-%_$21ieJC5%W&j4lhPem?`RTj zk!sLyCYu>qENgzi{D#ow;bfIb6Kr`Nexe~8Q#q>?c8z5fNF_STAH9T$47UPpo};CT zCCQy``Tt@IHDR;UFRR+^nOv_~wnfo+Bq1_@*ya?n@^8PU1lEbwgNvOfHE=E{cbqf33kn8cjKS8eAG`;2oSj8vXO!)UbJh}}z#%fHds z_X*EMZB6E{o#?G?>l^KUudlnkk6I|T^tByKjTiQ_rPTi>kL*I-hUroo+mt$z{YnD{?8&Z}UQocLsFWp>tSyQUI ziT!wF%b-!m$!z)S`jhAobG9Z&P-teX?+At@Tj>PMh=Zn2w?8h%Xr9h4MV9=>_wSJ& zfO!bFtT(2YeQlF8{6?5%*&4GHXr=T*OQ6(qET4vvp!p(au-b=L7?o5ly< zz`*CTf1ynR!uDcJZ_m4E$p_DkMzwv&MFM1KUwwG2G-<%p6{QNu)L_MCbza3WG+-@wwm3 zeD=5)&sL=kelmXrkX{oS@rkJIO#LfIdYavs6ipGN1N3omTKuhvcnF6q_IwbVAxw&_?Ie*M0gx4t3t zQOC5j%wI#nj@<#9(wmA^F0cmlrka>o32o_p;jdY-MI>D-iBuEZi*W}MS(P0rc%;=| z5IH4Wr*qez{uuh?W0pmyX&)2}wi*}WqsqlK^&dNJ)b@SyeJov`wo@%eB>UQ0Z zYfpJXr*F~KI36T?C`*w)3XUXe?^>A~BCl1o(k!#1Gnb?+US0ZF-D(n9Z6tiEm_zvN ziU+sQNFIyjtMU>>h+tW^f<73J`~+Wi;xiLAujGK74d$oEbiB+z*@VxEPh4DPh+EAW zs0{REWZ!=;|2iWZYgWY;w*1LW^2CB?%v?be-GNXnGP+XlgFYLWh33VS8}c z^X~9L@d;J^MLJ`6IYYm`;viBSLS=^0E_afg_ON6ZR(^i9{tV;Kk>BH4SPpgVrmfem zV}3@_;bX($y(-Tc zj=SMMX;)|9o+z5h!l>B3SK13^gkiMK>9`h`LV8H`4XjDG5Go5C%32GJkSPl?F9D!I zpin6q;(VyeT;G37%URJRW5ej+V?CRArPraXu%SASN-xhSQS>+x$7g4Aa;w`Bd|CxQ zMd&Q)c)jaZ*9)&JtE!M+1U^@9WjuTJb298BY{XT>*gYN|buAn*V0&|lyf)=H#PJ-e zQctSe*<(k$UX;*vs){RgCN~j>#d@`hyOqF>!N*3b^GXD}M_+u}e)9N-zB$RH)MKj|6OLs$0)fx4(mCa$tE!lsdXY7H@mTN{Uvos;SKBB zx@a+uuW3Std>$Lk?wIj;%ABmIRv8v`x^Tje);&JG=x;Z0IB-byTGEEj=u(>-quNw-}KNzW`|KdbKu+i#`?CJUSZ(Za$k0SIUM);E38oFIdbU9(FEo^p_VRQDenRK&~!5m_k(^@NqZp+_sy z8oF1K6u0;#e2nP$$Uho&`PVl$t_A~Kq&d{C3_ec$D-u3y9eGSi)ZfNTyqC4H3$!Pu z5N&<$W0JT(j^LDBwTFkFWFp8!<-ed%GPqk4&v9YoZj|1-B2Zq~Pnp=u41&pbJjNQ? zH!tjOu=y{b{}mnQ;M4YIdH34L^}IEWE2W5cBE@K8$T4A!;QTZW=%Iojf`g1FzDa0Y zBx)}Mkv7u7iNQLgMUuZFy*;kd*~wd_qDz0baFXWY>lsQRY70_1Ao*uR-~G?lwWB7I z9@MN(qH9kB52XC!oT+g4-Yr1>BEm4nr$MLZfO`kVe0|ldFzz(GX~@09)Tqr3EMB9~ zf7G)T3xo<8NuFooS9o;vn^h#<_8=7ua*G5RE2VLxFEwZ%MZ72Ux)I@R z8T<%{g3uDuk5A9k38fx4OmUTtp&0Z?OhjGvpp@fzkm3X-s$ncp%{CBB@pTr#aZ5WbaGLqz3CsWj`Fw@$JM<5d#5V0(IP>fuNH**p`;GO@Pz=pl*N8B8(qpw+A1Vze=F zfH#EU!%nEnRWsFaHZfsij$w@?Cx`k_yvH`m1aj_PrUZ|%Uy)oJZW+ctW*%Gg z%$Z|e1rvF4niee3uSHV)GQB-Rm=CEY$H@tI z3wS9OB=}3Kf-7&$)%jjNY)|He<^I~a(%F{UAL2g-7n{`7FLZ0Py5GF`>{F+=^0q|E zTpyhCe6sIUo)Fz5fi%HB)tc}~i>$6M)lnUPbnQ@hohG2+tv9HnKPxzCqhWRCaZL#2 z?23bzvl_v_9jzAFA1R^g=6R#*MTM-$A29QHL;+XS`X8wE^64R6GBCHnmIyBI=TlPA zLl;{cvQZqbc+GbX4sD#AE>0PUL0I-5x@wATi}HF`o#!rrb+;h6I{Pjv=}7wJWDdR5 zW(yN5bIFy(#cXB@E02UiZTzePA%H=TtTJ>Vl`AvG&gi^*-$RjFNz~CSy$4s`V%G1t zvple}BIzJ#ZckXl~htZ;CIIhpp zQZ{KIv1HWL!ks&ZN`u3QI@GgW8)dX_J>A#k!lH3TWy^`PrY{4pF%)YT=-*E)x7NO( zM=XboO^1w+i{mLY6T7wEcD+3U-r=8*VDD>kDUd(e0*{u@;JsLDBSuGJHcwWH|$d3c#NN?Czp&itpcfu@%73?sH^r> zt+6RSbPiLw;lHUx+XrrqXyC$~mOVDT=Vgj?7|rjBoVfzJ8lP*|U(U7WLgd#!`5mQ? zzZuU!>^Y1O72~~cfW)4#R$ zyA1)$;(&A|HwlrKr*?+Xz{K9KIC3#e|Fm{8NwK5w+)|^1@H(ySl09|s=QqX1;Qr9O zpr6fh?`itJnq!(fb<1dUt-W$*=A}`uf2jy>B;PRIAB$)MQF8FZ8l@* zCI#!O`*4}>HwM~=H_MgMBoYyAPsM{RBCvwtKLmPB=)|lJLZ=k=MxTa9z99i^i}N~j z;do!-!JFzmVq3C>@uF-LRbpfvAC%hr{T1}_>sKxkxpe~t<_;%<)Ac+0@jj}q?*CS! z^ze~(h4`wj;QpzptZ6z8#&x0Woik~c`XuhI$>T~Wy@&OHx-D1$s~YD66PdDLDLvDV z;}dC&``gE+?^`sw41Jx;qAh$|&WIRQS`et6dJu87$9`hy2<^t?<#`knUFh`U_dj zUtyPE01>qu;*Q(8xnd*L7u;-_@Us}!lAd0ZAVNKh?pO`RhQ-Y;b9-bor5(g99eTuh z-)z%Nk#1AOj4Zr$X47y$L*9*ZOVu}Ne?S}?e&O1}$hH1G;gbh0l=5zQRH4K3c<~3f zIe$tKJHX~j?Cxn5Sd?m8g%8*yB9z2x&sgOShTcyJ#Zo<4G<1m zDx?x`&={b1ymKw__G;=%H7QB<5q%r5Grg(OFk$iEv_Sd?kl`NI))t+qD;dRl)pt5l z0C$rH9#zlK(l`hj5qJqnx|KPj(Ziz99?;*GJvtR4^Z*>x5aQN(KmVV&dX%EiVXPY2?!d$ZES}P`A)J zQBqo(jpd20VPvwYM7*rci+S}b8YLwqSSmEf0px;h-_qCQj*I{1rz?7SIL)y~^-bP< z&7L!hVp=`)HTNI+=)UjBYW%@bJKQovV)DBEG;F^U}LbPpUdHm#G0;lu(y!O~;cEFuR*z$yjF^aoSt6xIWfIg$e z**8WXo%D?2ve-*r;iGHh<(hFS*8z^J>>(EQ6-t=2^i6U9-rJ~i9Cd1aC zTa`+?G>A}t3$Az*seWyQqW9#{C;EcYvg$PYdQV?mq&j80sxMlhB-P+7yAQgR>`VNd z2(XDP*P}ClwK)5K!285-1qxPM#Zk3;ccXJ3jz`nxuP&dD7_?uyG@}?Xduetu!zOfj zo)*u6++0e^2?=xuh70FaR8@5Kd;`MvmJE%5O0zun#FY^gXsLg&E7E2!m2G54MEKc0B)Z=yoo-!Bh}9|F91@%>`XGh zk@*ZQ$rapu9;F~{k131&63&Xt1tlOnJQOhy8;I1rbz0*S(S%%%`jOpwe)k-xY`P7tH%O& zdh5^UV*h{_Zsi&yt7qXFiX|0;xdL7=R=^>D;Rwk z5qez_WvC|qaA(2FqAAwe>5$szLs;CkW(8@p{7YznzTsW<>3|+aP((^@Q=$BqK-To9 zhBQbNZ_ss&AUwp@BfzCTaxvirY64E^>h%L*Hv-gVi_d5fa2Fx7p)4Gj(-D6@Q*Srx zFCbEinalX^e=ws}5D9Z-d$U-+D9>#OW39PFU=`xn>SuJ6h+%ys6EESn|Anmx_6Zsw z0EGxdQ*}Aj0lda`Lw`$RUQe*6hqQnPjDQ#b4!|*54X62Nz!<#a1TDPn!QTL1NVxn6 z7g6<=IQgLIkyIkn%LZLo_6?a`PWF^2Gf23EGK0ihz$;=UD6$?->NqwIg5X;n4PF|f ztsEe#P4wJG$UvkMA8wNQ36Lg9yza@2#gU*FPkjiX&>9a^Jy7|;T}hIf2h$wK1K(wu zU@{N8jX(`@q+g#gYmMVu&*H(F=xKCuO@lari-NsJP5aG=62S-&_7L%wAvfA=aQr-h zN8A88mI7vgH>so^wMu{t6#&l+T$3BRWwH|w_LvThl6*C4w^Gr=1WbBRCer)xYaHti zv@IS|(PYGdKnU7nDhDzE8436yAns9W!|w!Hs*4H_|N^5z(4?n!H0q7E7cCMRF}cGQY8^ zLqDnh>olaR2Y*ywlSRJ2Fzk)+{L_0bmkJxJQzXMpWObT%K05~ zfYUJ%S;%FEtdOxBldC+}3 zWDpBIJb9M%97u@M1lk^GqG?!DKoW zsIDEzJu!tip?E`fbR5X?C@5yk=~_x*9s<)wmnch=vgJ_7%vFx+ft}|s_(?JXpz9sa z;N)MB95QR!ofti%&1k@fnI$EXws;K27;~s*4W0>i;6+@8zA8l4L{aY_#lt6=1z1k> z08i)SBZAI1h%IYFz1CW}ZrHo_*3_ouLE3`%OhN4=J?*R8hQHm9*PjmgJ#hY91?`hi zr;0IW9$R`sB$mQ}ot}pw4Slx;;a_)vfkTd)Tj<<3Z}7?)_nf~Swln*qvFl`Zrk5pH zQJL1wE}%Pgs!4Kko7^~ouCy`{*mOusI()#hx8oN|>bhN4;aR8Qll2^^)%)^q^R|?@}+`kznMilKE`6 z42*ZpJXipe60EsKcDq)LoN9mnCtTCC4tKAGz~}aU>0_M<9GGYU0_e!=G2bG(~Zp zxubi0%8HHcjw>nM!Z=5fIGw=kzTT6}sE0&W1JU-W)#!wbm;pl8i^N`t(8%JFDfxGE z+kvPuJ5<|M>!~NQUAd0KukxttnZ64`tqfqKOo97dO#ub;RlYkI)o48(ED8d0B!peW zB!A#jq;)H!3A=C=pItliI$#9_^+>J0ul7zWcgyazg%gnM@YImj@hCx*K8%RiV^VOJ zwe!A2cH5_~O|uIL<`VTP?aWwZ8a(7PIsjV}pQ3R8YTMxY&do1@0Lp&zaPV;r(X#0I z`Dri*s;xH^3AR-GKGas(%e|&Z5Y-C>`|w#A4IB0$pJ#-s-f5ySImXLs|6%9pyxr8F zLkGHoKZDkc2+HyBI~zc_%O`ck5KPVO!QL-Le(38$cf) zR2?f=t1-U42RBl zdiw+}%RrY9@zrukIO3;GMBKHxI}v9m{0kpEqH2GC?xI0)JkM=LdPCS#YvoiQ1&6gf zE}_)9iNXd`T>g)R4*R*g3zfA?8U{PBEqzJ3cgW@6LJhY+;xvtc32YO9lb#<9B}A*P6S6 zetum$O{bT&S?Lrqn()kZ+gm;1`l@{41f^VD8{>|j_X=*tYEZ5}jy~=FAnifFgm?s9 zX4`e!BU53c>b4Q4UXy4R&a;@OUW#FNy*at5Xu<*uE68~E!J(nI-iVnYwEaL0Ygr0< zx|Op*_LL($6-c8mW1F&MdCZ&f_USja!Me$zKSo5x>0)0_xpYs1u3pv)dXG8gPO$6~ zvIc*dCLMkX&p%b87^;hjx4(b?Sn8nW)!qMNLD7VN|DAWQ?+&;9>rS24p$$BUSbQ*3 zTsz;pP`^B^sw-y~v3D!0=L%6F6NQWLo0r3uln^P9XVHZHxYj+pC+hO)^@4HeA z6IO-JemCF=`LcPrk2a^XkVA?{lXBr_>mN{!?}oUxYAW6Km)~54l+i{s%G!0@?gTbr zFfl{A9uaXq)Zl8R*iBX+a7W?99N>1eS}GBX!JS>E&$jO89K!N+`;08oo}SZ=v7mdfjTuH5L(58UV_xwrFc}$DgNfo&g20gTBhv9Tf)02WZ1IMA9ChiwROs z!--ZnfAFzH z4&Zf`}W#N&(9NuHDV8z1ip=zo4ygth1Z~})pfA&YA^dQ|nzp$`2RM2J9d%jH* zWeM^^N3hCCE4RFtFW9~pFzTspd!}`2!#e^Y#nyx5te(@g4u}UYqUmu@xZfhLX6t$i z?xV=h)zVb20S@uz7N+5_C@uTuLA;NvMdyb+Ss<9oRYjeg7ad5mIfiChHp$dM#}vF# zYwQBXFub;?5(<_~>+8j87Miz>+tu1ZvE~UQqXdx3(Cnm7g--pm-n9Scx2@j#%RIfI z^Lz9LJ8d);-s&?-C(hT-6wR)u&fAI6paJ^1HGSs8^s;2BmshjJ$>wk0R!cTnVD#xB zLq7SL(HGymyH}pvm%t$rl&&d=DEqjRhI)Qv^2#dC=wz2XBJ@^fDk6|JFGt`4SAC!$ zM2XG1KC_Up{Rj>dHFBLNsHE*Y1tAU`3I zHvC9A^cvMwBpf%A$_w>)5}$}qnumuE(Gmd6Y-N^OU|A2q0LN7290O$Mhba@*t`*EL z0`nj)ZbD`Ww?<-ZWaj}=5|U9{017}ri5d*~A0l2Hj~kT=Qtm-2AEli{bgAJq^C04m zM&Vu|beT=^G;wrFVnP%IsR-YhqZRp?pe$y9iBsURV^QD@16u)HBP6%@frDOG6}vS4m8i%-CVN-)kuVTT|i+%w8K zaa)(}VTd5W4^Xnpp)344YAcAmg3vO!^D;rJBg1#DV}gM_9Et4L%Lx^}P)mX2;-;lm{3>pem|iz3@gc1y-Q0SsrR&X|0Xx2!0|JLxQ7Mqv+H?3RMF4 zVA9LH5lp8bVuC;rE#?A2>tt>+(#b?w)@MF$CgSWh`5v_ue`KZ^X5`X@B^$bd_xS7d zscb~d^5|E$TEU3`T$Tn3NlTF{M2wi=g*jPBnd}#?C3uxorj+%a?~!5=amYZ=`Jr>q zK3D%Rxiso%hlOKlkfKZ#LzSgx481gn#Ol z^rlMXtzZOT%iO`0p3s4KxknzNkaP}Yst;8ECUXm(VEIIo0h^n+^O&D&opO$f@Yi+6 zo+j#Z>2VeL52_R&JIqAzj+990Bfb6j;8$WAT1}7L3)N599XXq~Bcq^LXsr~BPMS#(JPz~MWp&*KcH&rzp!7@} z(uvg-cM4$Ty;s^@9okTJ(pu5Do1Z_`^%!{Va)VPMV0{HOKMg+dDTy_^XMqFu-o)_tfkr@TuyTu#>SJZ`)lU!L;=Lzw#Cm zf8WYPg)0YDvvZWJN9AwDSK;;wL_nJ!#Mh002Cy4S9TEhq@l2hp%E?FDMW?WvpM59E zzMoit1+cD`&Ym=1k7!6CwZZjpM#<>q=ZPgs^4R|X$Bvk4?>m7dJVs*=e{Y1KRX z(ShCK8^k1!YHJWdU?}&VKoRstml$Fa5X8Tou^2TfUds)mH-r{9_7hUI04f>0rY%rRG2zCD2J$*6Ft1m-;46Ux68jZLVF|V|+Yv}=P zW^c)dP?7FU^*A>Aea+oz*^ z!Rg(I=+pAG=ynzPj3TgKD3McTe{=$0j47K?YIJNYn}C37j8=jcyJR6ptcpZz45xYw z$EO5gLH580eSgf~@0xxRKm15(&hBpQn|C#V7i-ooho_@{u~E+U?)@&|>ZtjaZ0o;3 zhYEp<=v6D(w@B6B0D-+zWn@mQ<4xF2B!d1k844 zshDPLRjZm?O*39$gdkV5Bf^JK_K4p7wE9v0l7+w)*x>w#X(-*rgq>ekdj}2 zmS|(i@c4M1m#8Mf+r`CAT&g%E57hR?vAcRfuNay=i>_0?I<)>(*TNSRJn#6AKN{J$ z@UC>Jb|EF=TSWhn2)gPv?>`Tk1pYPsLuO(8VZ`~5(mSs-Qz?x7Y`SL zRukg7F{EYaiFHj{4 z&x1kQ!rHlGnt@qAEs8!)ht6@%Q&p#S8ol|mTqLYpI$9As%BGDRDMjfU}G9NzB zUhh?PSMAfC=w5!XoKw4Cv%EEQen;NohiljMcgod7)woDjCx%}>)SX-|4%V4JS2fik z=gE#n>WNTHwpq2&-TUtq1Plcds4`uP<-W z%k+ZrerRvPXig%g9ERr_n#^2}b8{oJq0MRR+5vVW`kj_=vy?v|qE&R|GI_Pj@vaWQ zcZuSzDAE<(Sr*R4JY&WDkup`)5FTb;FTsU_B&34f0nX~m{?hBl9>Exjt1F<09&)PK zge~4txTnh8pOYdhFo{#;ou0P1mw$yh3|ZFX8Vnek?CFJ-LE2&XZQFfH zMaz+o9)$Y%o0MmCG^#ufrHc8dIj+C;=cia{zgCHgRIBfSTP~WBRQ-v%S%QHZZ_8j_ zxzILm+UXFTi8lS2&V|n@%X)BGTPm8(Sbn3A8c%H9KHK6q0iV!F8Lfcl%ALm8E54bq z-6oE&siBfkndDWpU4*QHqDPE}AUc_lpDp{i|A#zjMa4T*592}fCYXRHCvmU;WbEO` zGzLQ)MCuGj5vU`ETGN77oMku;F8~Kh4Wg(;P$V=j-~B8>dUyVc12ubAr3bD|*JO_E zszi38I^ZRpxl%T(k~Xs7mh$z8QYLPiiLGR>kxUd95^a@E3r*Nsg5Tj{N)Y8C#{7WB zS3N^$bwbXKlNt)xo8k*3)U47n?N`9bsn10zKE!#ioLi5`B}rdI(19vjFGh9pk{jV5-@-fvfs6; zm(NH5XY0ohm9@o_0GHgjN9v3~^LnfWN1?gU#ORQ_3qM$;MIgzk=9=gjQaHeMCyHl* zxrdM#=Q4JcA)nn$eoZ5xaf*N+M}wp?`T_^u(b{TKg&J+IX|x*berS@3>}OlAhS4@O zX2<-)+8fLA{$V;BFK?r+OyVBcN)vN4ZYW&T_na!^AyG(9ux8;dUed%Q-`j% zB&{xkbN=!Uqu*<*L#I4)(pDQ)(tTngCcX6d`4~JC^mD}3DP>$XmLNBd0mKO4Hfv&; z8GQT*f7uzM)3^)f13WO4HUqsr8p7g!Vbe?DZRC&?+W?kUJc+bDrDmufQ!lf-NwE-K z7&$bcgeU=#ra-$fn6U=L zFw>l{B>@K%FbnYyc04#zUweVOLYXcG{bq?#6ClPM>ki-qRl#Pkh5HaV5}C>8hkONdJOH#A=w#A9M>s)*nCOY5S!%#N%K#7^;ga zoSey@+0QH80vpjv_`zAqxYCKaaEYy1#reWB|15vjAEYf#FB!jdw|4JHL1KH$k-@i? z3v64t*7X$f?3||_aEE}cc*j&&!GU;BYh7eY6h5LVI;lH9DX2Faw#?VbSnt2s+VWsJ zN#lXX)-?dTW>W;7yNbA$MDrJT$chq`Bx7<&eMW(36=i&7-$+@ z8|ObDy9tB>=Siq}+U{5RY4`c+(fFStQhTdTB$BPvQbHdAhXe{5;`Yu`N2TZP6PzA? z;2bmj-~kk^XmMV2q^kN}Dbp?Mv+pAm-m6BWdb-e7Czu7#fQ3@ji7b(){pCi%=}W41 zLQw2)#V(6B27TM!Tc}=YiEI4~<#QNKvy^*4bASGD#d|;Ps~q)%OBSF!=1LN?m@BeU z*S&PoN#uE?$ra?fif#8@_2#}vlN{p<&34goCD_9-dsoobLv$sDz76N(;VFC%;u-l} z{&wuyW24Vtvp+BxSexuS;5&UzeEwbcjMDP?KiGudgG`jIxHvFsWCiME`Q=#{VkCj@CTX4J(WI++R& zb!d_s;OF&A6VM?sn~LPA#+URH$hC`zDKXx#TnZC_8}xXy!iZ^AZGF|oPqoj1|f zu76Vz2@Sn!VqnE%41How?}^Z**`F6B&3B(?}0c7HK?F7e#DH8bZ1}+BATe^E!uMm`O7g_WandI z$NPw`D5r~PJWxdgSm%L|hPJlE7GFyn_(%`To?lP0t5OsF|8VOqf1_KU6*C%}e@VDw zE$w@`-*st{co<5@!k3zALioh*hcpiY~`RJz44!`x0MKir(P8 z2Z3Wkg`Iw_biy~r0-U-0Zrfw{t&syK1`Bki>{x~)UNha zLG}TiotMs$J`^=8$FWf9wwvIy(IyIU`RMgU+!-BmkYg}(J)Q#%XL0%8>xV|*$(%`r zit*hsNH2Gy>)~G3%5QIMv8ul50$A`7g|mZ2y~**ZFd!v192BO6eSONws>sJoPpw|m z-0r_~c+g0n$D=si4T#TECm!o}O~~)GHU4&=LaE!^1^>l5S^|snm%~gYrsEL|sxIu! z!8}9LX4MVPqgp>d-Ub3E1fT)Zb9o6EmdwIYZI(0`yC6xH0P_-+42G}kMpLd?Ih$Gzb8tM}MQP|9WM)Gk0xE`gpT6?yB))>nev4Es(zeEiV1ZmWsV z*&A}IclMp_@Oz7DDwTdi=28IMyKLR>hv5aRmfzCLqCTKo_OCf8wyG_QarJ6RD9!B# z<+?YTf);_j`$!33he+5+Q)~jJl1hf7a4K96d0ZTd70s3NU@ZU4Q52|Um&&5%$5rS= zxK}kqJ!@W-p$is$7~=|+&DljXy6N*h?}+myN((*RwTD$cLCAJBjhWpD+|Joa{bK05Ac$&eWhBd|>;ccd94!8G8-rKZlzd&C+% zMh^&P5J`Z=ac)yUkg~P|B;>W{V@Lll67`bsnw-`L)rtv86^W>)>glPc;l@dpNs zcur~cNU?Y@Oicl141CM}F%*bL;eZ*YJ2j|&b}`FX>trF07LRWF;Bs;BM*fr4$ML&wQ$y9BI1*)`X# zV>b&m{oP7fQUZ}Eh4FNSy1ZdOcb%lNjS(d!de6vb3C>@>3K&2YK%IZl!!zwqr&RLtKhaD*j3a zI~8M$*+5#>kSQSQd~zRYxs7vIrR`!#2SRngv?rYeJ^Wm96wO3qHHGoHqh|dMB9dif zY|ifyU|#_!%d`qm>~%JZ{S6-=H;C8Z!wJH^dWh!Hya42qoMr^A1`83?6e!c9n;K+u z+O*Yh75J3^Pt1#JyNPDMx-KHXtqDc&;Kk&IdvjvU5nn4#@}97}iGf>nktaLRoR9>i zl#Mi*M#MJEmyx#$>K;NqLp_f}`uaE$7LGYXY@Bz#|M+=isMBwW0Rj1t{qo&q-jH>t z1V^i+fBF}RoL#TvApGTQ9=z}%kk5D62>Pcr7#J_Cda71KKEB7~x$m(N^frB~N}W)R z;0a_s7vm8%p` z$RFY**_s=X=?2%)t=#>3gMt)GW{>QZL=Fqz(>wSLt5MU0a(=aBuCA5AJq0$bNw)4B z(wu1R5yfF`<|?o`bvH(u$*Tl9SCdPBD6qn za}G!TJf}TJedSvuzRFg0{(}`;@s3lTNye7zk-YU_Ci>v$v}s4+*Q!q6;WyKhyTD8t z^mP^9{c`kKPk<>)4k<+J>0bHC;n90=pYd(rhy>57>VYhtPE+^fD@gW@q30$l)1Ern zW*WRm^9qBjK?{_x>-l*U7Mzn#@gi?*N1jK)34seKURoeLY;YMPjtzOU5*>)C zTnoh6bC0`Ubj&?@81m!bJgxgrfPAtW)eA<;_7-2n=oz42D7hs4x1T1qGU9-y z9#F$vYT20x6!bT8vHOZ(uzF_)cKQ2Nov_+AI{qFcYOTNLVO}-8)2o@_pGePJM((R? zFelH=H0RK!S13NDaw9rO*mB4^zdN$6}9e^$MEM=%ygphXbb*`TOaHuMIxc!>3?0mIV z^>r^Db3dJF6T8Fh-ZN9)3;G}dEEWOfe_Y>GCnXk;~yToitQ;xQ<9YQ zn613b+?vnBqkj$sH$1)jAaJ1(J!dtj6RX1+8W9=mmGlb5Wy2S{m$xnduwSy7wO<-i zIxr_#-^Tr7GWYxq$+MlShXZbmhG@qk!kecWqolQh>^)r@B@cC7%!ER^2*g;MIY$X2 ze=c*t#5W#EMH}`L;*#B-maE8Kx=Bs1j(hvgC@Cub^NHp1YoFFE-K2e9vpgp5dSdwB z#oy=32OjqsD;=v12Pi_ZNks%Q+mRr@>8#=QW8bH~9T6IWP^T60;m(n>jJ}b`y3 zS};kl2}jRVoEuS{=bUH{4xu9bO6JF^W3^$%3IN}zdCXJqJ#ZG=&DLaP-cF?_F`mN6 z>Sx7|*K*>1dNj05*iflX^hozkv}<02Q`f_=Gg6+m?&$T#uVha(IqUT$cxp_mb`e;m zIvd5Q!O(UBDd4vIUe}2R^HhmM^v~u(HlU9P#PODXUzpuPP!wA~`kWvxB!Glya|vde ze7(n67Ere%(6rxG&nIwtT><1dZdZb5fB043kyd>aHpO3RU;AnHn=0xbW(qL*(!2o7 z^{$G6qhy7l_47yw#tDsfvHU22(vx=r$Y}+#(nu6<;m&RbXt%R*zC4!(>}Sr2NDtnl z&l$t*3jB|QXRq9;briF-ShTSFG`qkbvpan7QaIc^Mr#|q7aIe=voKB@KRiKGtjwOB z+Hm7Si^~cK3XnOW`ZZY`&F~T3Z__iqsp!kR7__d!vfDva9KU*Ivg!up8%#9+Z8#>YCSRr3&brL*j7Nc#?Tg9#X%hY11z1wttY=nPv?8ul{H zzYV>WAJPWz+qd+>(iiU^g<$k9(nft%<)6F(y`1SjW94nfY?^Ly3v*f6JulKwjrdl( z=zWO(!8gP&U*xZ;usDIg=g)c)MoE=h2!tD(t=wfqT00x4bSxJ6ID>ogp1SS+#Fxs} zJvNErRhag7a@Z5BoyeuT48HxgwQ>ivj;csQ4Sh!8!OkWZs0cb8k5&TTADGr@^1e#{ ziYA?vz6p`mT)h<-#yN(u5fm5-fS8}-M z9k3mcFz5zE`N^7X)^e5n%9=o`CUaLvX)bvd9N>G6K2C%@=60pY0>zV!uBXQD@7z!* z)_cE7b*~)q1FG5HUVz#kHiqd}{Cm|+7$X&&cUXzrP)hFezN)KK?ks3ssNd)AwV#}C z)Cm7E&5&D8jL5D1-oB_yTZoT17n~Qw7+Tx%*U-&jalc1LXC~dn{WuUj!>7t2M==eR z1%%Ep;u=a55Y-jP(n2r@_Iw42xowUkECWP=bOTvkGuS~4nJ2_NH1}XdxJarcU~hR$ zxByp>FpnJ(7-Az)rXs`G14(y6lj#rpuMk3_b>8%c>4e)(6bkqeR*6o)3%~@aqdCP&4gEb;I6Iz1(vpU*y9D4Q3vBYR0{MQN{U2#R(^fPnWL9BFi334j&yWHE z5;mlM;_$(VSu{xva_~!Sz4=k0*Fb75>_d$O(j!d-EYXl6hHniJlnHE|6eSyK5J4(A zVZdO!g#bRmlRzqxAP49mg$HKbe1K?fq@rWC@MdZC4B>K6zA?o|031^PsZ&|g0UZV| z4G}b|NI;1AkVGPC7EBcB!Ej*?6)8i+eRkjmQ2UHW2m}Efgpr5)E3N@3fShrn1b>U! zDut?(9}$fc9Dt@Ok<9W1D<|A<4WZ!wT6H=?kMa7vrK#LX@?P4}jL)^2tfV+{lY*8? zF%|=oqLpCC$9hVXOUU=E%8o(VB0Yw)gwWMGOjofGum$1}Zjr?N%yH69)#NA>v91Hx zi48K0k1y2mNRdnr-V6Z%g(0z0K%Pvq6>RK`5`QU9$qy%Z+>jUyL`zm6kf0aq*l|#l zCouU)iHiURz-E9pRH6WdnU9Ldc?jw4)-wPQat-7oVy3!f!6zAYM@lXPymEMyY!N%8%h6DiZY;Y- zDtZmZhe|vtNSP2bKge9b1|#xYGnrHb-SnfoS$ylla&SzoeQ%d;=_tK@Iyg_cG1tBL zN&B5`U$VSp(0(7`Mr-Z{mLpk3H$W;&(wgZ+O1E@mS?;G?3X`t=-toZDtx56D*tOat3Y^ zdjAk^AcZQey6z8=13c%p;fKdyQ25_mNkSZ?3VqXlXWJrDJt%9u8#IY~%9MXKouGYgZgk$blphtpUy4#i0;ytpq1=IUrxrQ|@4vLtA z>6h>PS=F1Yy%#h8N#Hv)l$!sjwl-5ye`YRfc~EI#<XyFYMjnc)A$*r%yPd15Rib$uG8E@5!0oOl|0(%@}nOvl#!M2^2Mev9Oq&)2&g#G8d<7{2{yYWCYg z!1EC{m+h+fu&~M1RGdtTf5oo4pMMp!aXaxKwcx~aYPE{s5umi4APd#lr8-{Oj8+Cm zwqJbl^RHc%4mo!Yf=9xnD)3oH@uh!xBFLXR6jxLvNAx4Amffg*9k}@&1fYF^(~}nG zVr5{0?^|>Hyvt!>CxG3S3I`GM?TwLXrui1hp1oFATu!}-)UXe)Lzjemt(Wu}8Aruy zKR#F(ibxV)z7)=$)?8&D9Jc&aWo^=g@O|sq|9w9EowV82bY^ixZ-U{@Y_tz3npgGy z@I;z(Tzq`yN1G0HYAz~*zGi_x{5gHWXDTgpkv$@|cia1Q>gO-FzuIVGWjp8_OnL55 zvN{Cwvx#y~kUvRT^P1&n;UtTL-1EY0QtT#zNYKALxLGnIn%m<+^UGc#;^E=xc zS3f>LmldN{3^=s8Js0Z)7*B2wTu%b~n`_|tgawWaLk_;$VJ z+|i%s4wmgrepc;m(b?Jrq}sG^ztuU?8D^NV(}_o=4IX1eNOZt6Q(#imgblQPSe3!x zY{Jym9k>LjUK!yZT1Hft-0ynW=S4~|qko}y0kp|@G3Gmom4g(Q!_g$qeJz{R;ZE+W zcg?MMV(^kZj#AbZd~D`Ni_)WzfOf!ZKuWwQ?Wzp5iSa%tH(1clpr|Mik#Os+eaWLg zUo6gE=>4{e^N-_KWIyQbthk>za{v3#xAph65Aav2<>i&+?hE|vdT#TzzFOC#+Sjh> z4~s{9ZhZIhpmY<~FqE0z#wOo_ZKjFvr$$sJXmERPNj)?7RH zvb~YN9Ce_l(3Hln$&} zzOkHYKc%rG@WIZ8Jk2$W`K2)qVSjyI8W;26bs8hud+LsGg=BA%GO__ERaXAV zQ9iC?Qde-AmHMzwf3Y`QV|ieC?$XSz+DV(!OLJ>{HpKPjQR;M+KwJ19lxn9qJxFmo zt$|vaJHD=~HyQS5{B=#vr;zRI3Wvz4W?KUKsy>MOkkJwSL8lmR;y*l6ETX2U ziBA~{3HQEK`@`ej^u?Oa#IUgA0`Dtdj51myrk*de{XmyJ=npRa+^zB@1nhupED2p3 zN2@y!t{gy{^K+DrXNbl=RuwWFAA561JPdTZRjAuXROx#sd{|+mv+)ZYgTwxX8 zQ|fA|XaR|Aq-=H{_nL3)kZly!Y=F_9S2tGSFb~aLIb7oiB2AAcMR{hqq29g)Huhw{ zHAE{H;TUVK7BX{1z^G;K#q+&Q>P=wNF*2wMFrPYZ%wh|iAg00t*3TX~fekGVo@jPjXtn+p<(>6#4( zadRyEyT+bKL-U0PWNs|587PXa-{E}g%Zl7bM?rgC$z8FA_$fM?D}be-K2MbftYpVC zdD+5)Vt8`RylU^a7s1&u73)1z!(0g+54|Se3M9HV-tS?z3(+exPD+)r64vU?KVj6p2Y6VI1XDg zT3Mh;1=?SDFi4$))Od;r!)ATDu#=TJX=(y$6N$yCq#z+)0i)Po(!nFT@2Sb5b z=@`oXjal{q#;WC0Hw{fkc;ELc=zWgFIpdH1Y|mpjFq0?*V3R(Zx|Q5ozr;jd+XG>< z9##%LwC@f*$8N&09SLms&o(2r>qkI=2p8FN+}>mIYjKoc1pN5=C8#8UzS1aRJ!%c< zGZ#J|K282iJ}Sn2%X1q zhF(z*!TqT-T2PK>S|i^cN6~$%z8Uu-#S%hjG`CPrV5i4vH9bBtk%v*FH>$HYhrSSo zA5+mU==lFn<$E7656q+Eaqp&jt~u+iKN;g;eJ6nLGKPkXImW`^#~;kn10qi){F1e? zX+bo7JI&R2b%PGh0+o`nk>!C^i;W}xEbJH;8yRE^&#A9X4Ibdx zfdqu9#IAs}Tm(i*4_>zrN=kO5;bEu-nFqWCP#P1g2q+Q?D8S@VRRv3uaz>n|o-8K= z%8npMbZvzBsT$n5h7REVImlEYEnfcXgV+v_Y$bA)7z|Rx(YI$F0#?z_Js5b{vzPw~ zQK7B>^|*(!gIs}KFgQ#)ADzr%Jiq?odUbIErNlGXC_q6h(U!mK{lj)mDi4=TBO-gu z0$^Ltq%?qjGT7nUaM$1zrZvR>y6(%=Xo_2z+8=I{IXRLWA>Ls6DamXNJ1ku4PxGEIfb zo)ID{JK2gNTjXSG6U~h5r$vY)vQ-*O#}*<(4pGSVyI!aF=lA_%mg1cAoacExujjh& z`&z<7ZiLMD&aKkYzHqiZdFt<$x}A?R1An~KslKr8M-aA_#%8;9XnB8lLvx>Y$A5%0 zOOI9)_l5EMDUnkzS5*!kTkv`?waQ6*a<;^ap7eQq%aM+mDlhGK0aw`W3IOC04CB_S zav1v)Hn~3xCFXD6sP>eIwvu#H5i-j? zslie!Ai&s1Dq9kY0*O1RbHnuv`V#8mQ!O~|YoLR%J(O}8owo5I+sRBto+?j$mLgKt zVit%(A|<}&*gEhWC9kS~ke(J{PQeD=f!u(AU*iw#^v1k*0a9n2z>&fWER63YSJTFG z1^gK$(&h#a3L!~jXhwjp8`VP^TmjCa*qP7<-c!E!#ry5!vNj*SEOzP3$ONl|fGTE2HgL2!bBcG8;670SXD{-mvWWbzQ9vo%Dv0Fk!@kI$&lHOwt&k%Lc8je z@N<$7UBSR3x^9qmDis%Vx@V*5RaoLUX=J{^1^a=#rw5zoXH#0z;{G1cP7IeEE5T!8lEb5>x~YenqB$s?_jm5 zBgXwRtM>9H`q%3B9CW(ENV`-|;6VO2&5(JW7RKz?%H-FN1=||CfyrSmh( z^I1sK+nV#wUMqQz8l5x^{3^IC=8?I@$i&_)Dd_DPTQ1sY?9uI|M}JgEL<>}X{{bnj zgvyAi!JU%YuNUER5%Jn~(DV<_-AyNU#2JS1O52JGyUUC{z58n6g~C!yqxJ(j|MC;$6b7G`e&{s&xRov1=DMToO8332qxT!A8W-Vu(9!QC34-l9Dp zQ|NQ{vG7H>Dj#S8KWx0kncP@gnrGmWjNtX|?m}nh!RD{epkgA^KPeTLJi9b3`fe`? z6^-uR$tI6rJ_%o6Uod3g%bEYPsHT}P*&vTwbMBRVZM3u{9X z9_!3s_6uI!Dg9`jiK|Mv4+yOe>nJ=tBE}DIR}-pQorxC0S_NX1a^;1(9Yqb~ODvu~ zQu}y>pc5=>f5tzf9DDJ74zJ4WsmPu-b@0smG` ze8GyPPS+GCiPyTk%ngn_CE>oJ7x?e@pEV?^@#)D<|DrCPiO*$HLsvAn9Gi95Sqhj_ zSV~(CtsFVF_%%J`UGEF&*wNGeXmJGL@URnn;J&g6LILXVWy<@E9?e8)2<)eO*r^vz zM(2L8(zEYnC5@E3$yclIA3`bmXP#ZCyWu^>Z2q4@)9$v{oX&Jesu=kg^8l}|@cujg zv`v=pg&VC0-pT`gZp5OGCz8*%z%7~-{ zT6>F9qTI)y@!?@%T-6EZH%CxE+r@+)AUUHkdt|abQBxv_)T#i-b7m(vU3t4GJTEHp zZ%A;Kns$T}ei4n1@z-35Vr?_2158ePeW4@d#!B|(=@`9?O+msP;eQ!F3|omktMxp3 z3OuFUJS>6gbS<~W9X|+>#E{8wEmonudu40+5Muc~NpqjtU!W5g79v%9K z3i^W;Y3uZ8PIbj|SesD}TVsZE@NaArU1w1%g6MjPFPq;B3vb0^_kEmT{hmAqtzN!x z0B4Gm9*P@0VI`jQWFkEWJ25y9+03UB>{QV!!Y~@}I`gcoPeWrzlG4SGT6of}!|3OC z`R4(Fdkw&^zJbwZ6b@X~%z&jmXDv^aR#pd1n7h&2AqEn|5NjyI2$ ztN7;_+$(ug!KjN5B4MwE9x+|f-0~dvJbW3xl?n5vBf_V$CV~eTaPGL=yI{7MwD6#J zn(N#2w87si6We`ev<{*}26s;~7huxof%ik#Nf5GOnv*3A6y+OFSBIIZ?5tX1Nytu$Z*|35J|pB(aIi3d9@Om6;+4F*Z<|T^i%& zYN(TySV8b0Z_~7vi2*CO;K4GfA9w`-6mNG84XH&G1*CMbF(c5F=sp;RnX4*Qv_tw~6odH-CnpF1l2 za_gWjFY80widXx2eLWTN%5onq+gp;CHDQ!aI(G)Yp?Yk6zz$lFOk^CeD^gIR%?e39 zB92K7QCFafK)P4f=?b;UMsji?-NC0S+id6<%1=;_avq|)p{5DOl|_LOpBm0B%1hKh zHpwCw5^ymQJUI!t+`mpBIm`Itn&|xhE2l^qiONJ2Ud-fsilC;$34?_}4OIHID{=NS zqw{fw8&#a8B*^LsJTM`SS_!U32YRgtI^QovjxgDX@qzA3tpdHnN2HFTEWNJw!=tVVC)1X&JP zUUBu8nEG*)E-5%;%tU#S#>btg0(sU+-GPxooo$-*Y6rKeQ=AfEBG@9II0(P0p*Qed z(3rfkG@w(<_<1vgtKOs}+Q%cyT&AmhXim~~}#;?Su(p?z`&=HVB62C9KkB0-~ z<)jOuA9hd9wO2MP%)JwhbgBC5JZk{%g4~ITiCgY;F@u$d!8dsJh<|nVAIeSc^grK8 z|1piVCc{A*#*I0V?q6YSq1&sv^02YEiqZ-hirqGr45kYRPb ziWI^<0qTiVMoc9^9K1o)u~Hq9$t+l!H3lsA{djne+orwriKF^W|EhZH71;1PLR;vU{z|62N&TFG74z z)WXX@G{ql{v6*A+ZTb}%b zo=%h)I-mf(KhShXd+jKIDlA@s0o+s+oPlQ=0ufXqJ)2UaiWG;vEuqrqrOG`T^FqTb zfXAz8-MIiIqyE`#R{;;WJ@wl7HeEE?E3JWOH2o-4$`JU8 z4|(?hlG5*9D7YjrO?Wf169^nEC$yj)3WocZ&YwQ%6 z_@E3p04ezi4>i(4dfI(9wF=fSjQR?OIau2-Ky%Vk=%_7+SmVhl5A32+^e=J^28_*d zdTGwed}|t~^ckAvK33}~z>`y5bxJ}GIi`qL93xbf@)OZ){*1AgAVk#)$^HI$(jQO8 zl*sQiJd;k_3eV{IVE%wsp!9Z}#U3?I8Xk}(7KhnsbYmB2Vl3NbX&UltX&dRr^40UE z=e#mGdT0M$7JQWvvfR5EwD2>ekMY_eJ0kwmzkYXqYi}SK(q%p#sl$A&J16Bx9ZLEE z;2n+y<=m()%RyM5=xnW8Z$Vdv-_*Fg&GJt!&)I>HezR}ZOVp4Qh2B%MRiaDTC!XEj zIc~H3n+Au?^PmF{`M#ET3Tw(puRs|Hh+yc(cR4{58uo2iAx!#^9JOoN$)qK8)DaDSPvM2`KtrJXSNozS>t^v6&u=JfApIi@MZNLJE{rS_UEp)9tdnP-b zvrvcv4md%6!G-XTj&ei`Yauexg}qA7gMC_FWIK3_W_DfBHV0 z1;1%W%n2+N)Nl}VRT2A`s-i}e1LG-tj2&iJEXoIlSgUqA5dRkxX0)FXhC*)D5OSoZXhI4bSb&=R{nCG!az}) z{Htfc7StTRE5FqWu|Rl-J+gH5VU8>AblmhxYwr0BsU`@~@2_VmJqnO4PG(W=C!^V} zqBT(%*SzzPp(Ls=RF-id_t5BX&>E*zIIJmMqdZ+vXu`MRN$-<1VV&tY*Co2nyz}hC z%ITX>CJ9k^;!}H)-~zcFO_nW+W@{zuO;Vity`FNfpc>`zVU`$%*zlRy++?LjoT;<% z(Uf292Bb9|h8(31GL+Gt&Yy5Qo38k@0>!|)fCHEPTw>f`Z#lo$K=DxW=y&65zuNfF zrmQYKdW1|a|AlD=w%F4BE_|py=%^BUFVeo&1_Hcda=qH=46eu@=)?xdSjomHnTKBJ z)+lkRRdo<<^d9iER4~c*0@U@(l*vZp$O;1r^9?s17P@CNQjTDY`g-vb1W9OjMD+z~ zd(+j<{no!UVrHQHCb{5!52jM-RSdOIVAK>6oRL zuD&O1q)z5FPIpUd@r$Lk0WP&dXeoU3!M@#4U9rAah*auvoZ=5YJ~?p=H(iY0^(ssc zA2;wNrrt!~12e&Kv$fu7zB8}XH2!^4=CQEO-K}xMBSuAEcbdn9@baDBI>z#1Wt`~G z&|?c9%ocZY`AGewO~EumLJYD1E+<%p)~sFYSh0<#R}xVp09u0Z?rAXtZU;L5)hzFK z_yw3UqXp}+LCQo5!ug1Jz>veL1w@Fs=%rs2uw6_PQz_)6k52XHJ!OF zk;oWmegs9Tp*c_b1V->Nv zCBGI)4(}`zPFAb-G7A8L^1;hOqZ?QjHOHsro61i{N_RCe%9a)v^GAb^yE1}IGq(FY z+gwVos$&b6xH};xzH${sj?%MYuiWICRBmy$E!=CTZbYr)y<#AKwMI-#SzKRSQ5Unz zbNfx=ec7t?#vlFV-P{w9ccFLY!eYp5PubCS?VP`NPYydUdHrb=kV?$JQquaBZIZ!M ziX#{BCXug`Brp{lWP-eIJm!Y1@?w~DjUiBl@Xe}Kj{&)w@-nLnqAi&Iq=M)hMA?v3>d?uz~u9swptsV|8Jw*9j>HUBN2cHqHbbCTU~s}B@&Rhj zI5NA4iVg>i#K5$Z_WCQ}^MxjJr==DF+P2}3?kRvHh!h30oU#vaBZr5l!6m59X z1*zsJK@zl#3X7YPxQ{V2!hfNEc<-`{N6g*=TWtdm-eJgcHxRylRkM&rXJ@}9z9iyC zDq`^!K${}x;^|c)TgR0F^9iDSHbf>Gkwa;|1TO(g-AzWv#Ug&07~ql;-GwIX%A~?y z#Tg-QL<=FFAhTs|)2!E>Akm!;%pqBEanlZvYvAA=9nJGNPY@lFn4q$2Odo@%3&r;$G5*u3{>v1P1#*wJg4>Y^y$pWY0p^ zjODsJ@Dc^i7JvF82Y~pNt}1u>vY+|QuCiZ z(aMU4SRvwzluqB_d?pWxi0fYNv`;Jb;D-m7B`sFk{`T8|*QWSMImu@SuS;S(3X((t z3_%MI86wFVBvJbL#FNJj^@Zr_dc1XG@3o>OXgw=-!+x8=Bka73=tZ9I1_N+#6&@%|heb~n?*lmc_zm7sXt*Ts4cmFM-9+lxnLI;0PiQ^hZP4kOn2eCKrC8GD8HFgmCUB$8S6aCe z(l44uLTfJHfu_2k>67Q}@6a+{x#Q*M`?}k%zxddJ;91ROH8^T59s18)y;04rGa9Y} zTG~A->sO8X+x@fZ0vnB_I~s3%0?skC#=& zH82uCT4Ksmle(whx|_ghk$Y9?8amC+2cnZ1Qwa4wALK+6FQW@8pSx1mw>d3vIwtrjW-SL>0dpE%)ISyCAL8?KBm zd{?D~hS?ESy+dSJnC8AJ(N4$^iKX_rC2bw+U%nVw3XSShY?%i6c)(K~AiUhC_N5^s zqw)TJPi=X&RM6o;u~6A){I&?z3hnbiH4wo3^+CL#=@AHL>Co~J=OyS#k;ZW&^yN;^ zr|GE=*#8C?$-dgAiSzklheTb4UD`v=z`3Q(zi$>Af`SH@wBjtTpyK+i9|44_sThGtipNMk*{y$?13 zSvgs%H^caLXlW^vji>ZCd!OAIv(3&rdryA@CF%NO82C(QMuC@NCq&}62LhQk%7vyx z@Nq+e;8-k3(V$5Us5nlS!xWXMK7MrO81SRCMnR7-1?PWUu?!Yoo!N8}hx+ z$%irzIuyvDRIYCwP)DlHIWy+HEmYDMuT2U;1qSHHF;N0Xuu{u8=v6te(wZzSfi}1lK7n^f}L|g#4#7 z6u0QTyw0X#G0|@era%@PyeD=zKd`fvu6i`X$&zT!qRNKEY;sq(?gWea{*7w-3`4!A zM?dN@!)zUVPF61sW-T3Dcv|_beRj^~=hm*E;Q-(C5zZ|)BXd>u*h%A-6o($B5T}P{ z#ell~wTfLdT0s(qJn#h!%Zv}h=^B~7gv3OA3S5G}_l$I-{E$J(V|xv0qoeTAI7#MI zFgiYuY;tP-Bk4L?zqgyJ`LKgG)nmur#NMO1;<%OVL^+T!6MRHMSy;`xh zeXdsIx3V7T#^x#CA7~}L*GUMfd%M*AFwXj*?BySL@9PoI|KkFH@`)5*3Di;88$Ns4 zNkdXZK^hl68%f1Hm;ydj@=RTWAp-y5oP<2JkI%5b%WcIosUw!m2*pjH>|S#wudW_u z(=Th%n0cKFb6P{uVF}2XpihxUK>w{^a1j}s+ zTT20ET;y&v13rnZOrfD(m3iPIk?cm_e@F2~!Z=H!i;!R7bJj4sP0?($FeSCIG&8v@?*fZwiPi0H8ove)TN1fP|XjkI?TPie6116*^n#|O zomYRG3>`P%9ft)d$(zVn$7Bv6LlUiTs0yHa$)adG0tlKaYd-X3>P(E46*}z+aA&3t zGvnu#m?paBKq3^o)%tAYQ$Vo6zn&K9vQ6$H$D(NzDBnpemx!tv2P>Be&mdWPpb7r4 zK$&a|luqDK=o}&@w?xbW%{+kRMUqnQ0K2aU6dg^hlo%cYAqkp?cIEV4UN-TH(@X?; zEE+}S22h|ffa;-V&}aT6gYqoNTr88C-=THzV&apc^(@CoO@jB_##9%8Jk3iFlqaDh zov2Rm8w6NGv7*qqpYx)CnPVur^MUfHA0chHNix8R$!ya>@{6Vf$+ssq8f=HA#({aT z1W#*%lmMJBfNG0*k({G(Kqw`eG0ehCPynF%B5L!grZALKC`a*fI4CTwrwWF;ZqwBAc8VAP@0qov#E1NaPUn8S~t8X39*fzWTF)3Ddp3M%=lkL2>nfX z4d4G{7acgB6}-K*snFlU{1yZ)!ka2a`F83JNy0@*L*5;0u8>)OQZwXdxQ*PX z>c@EZ-U9z$KZ-dTnj?gFQ+Q!jr^*ts3~&6HGMN-0w=roQg$4sHa!G%cTFg_6`d}y< zw$oyM0?$8WM1;w%zz>OeGP8OlIi4q+&^5_lO)Jz)B&0N=5kclUnadeg56PY;%afdF zW_INZEtUOIe`jVy=|*K>U{}^iB7F`L6FIedlZ$9g{O|IUA_-w1SV9;QVCI7ZtVnt8 zcn!5LY8}MQAY}0=x$@E6{crFAxRFrqk>cmYxFM`!j)P|IC{}|ya&@9%;|}g#nB(E= zy#95L!fTqzJwqo0z7AaTRU7+X|}!YRfN-FWjuegvdjIare?dz*m2isrva!ot=IJ2#l32(c+A32mo; zlzCZ~#t6J(RyohA5&VtaDRr*Ddlk~%8g73`XiLmpEqh7K_zu_vwY$$f(*lla$|$Ha z5#hevle7tjn=o!tUN?r2$7%V8rA5Vua^DyI?%HQ!kqLv>q=*=GjZ{*fwrH48YHD? z%EiPlrV?VWR9JU~_3%2%O`14L+Ig~N8--FX?!wcTVAz$Dl$uA<_%&Y%IFvc6UbN8T zW$%!@iw8~_T)Q^YKe$ko?!NWj%1um+8!6DQti)Iy#+Uk(z5Hi*I#sVG`CVcJ|Ge7! z(`HEkPCZLD>#{9!$Owh!uE*Me<0IDhhWE#Y5oR<{ zyaEFpPfUzx`1_Lza2D(3HLNX6q(1SH2+K>eLOY*n%=0YCsT0LO8DA8>aa+f+7|!+nnfK1hA(izEh-*Rz_fcjTJySo&FMs~D=iSks#Ryl(?LZFhDL^6O)6ee++LSh`l&0@#`hbDapb85aG-AF zeE-mo>)C2s%miz9duhciw0SIhEVx#F%b%)T77e*srq*lc((fGngSA9@XN}@@NX|3w zhQ6NX>)FJgJ5}SWQhZi$H~R4xF`o?$d9;h;Wi7}l~Z%Q4&Dw9_dZ{~CbLf~(@adsjBj86 zU+Xr|QI{$o0@>&%O=Ru0o?j#}w+>9}6*g+$;^6=-zM>pQ=jG68s^gveA87>Sv<@2WM zrLsIjmO1G#n)BL?elN+7A@svG{9daSF*X>Ph9Oy7Vtc+#ar&0RruV96&t7*cPoN@; z{%g}_su3gOv)2%o^G9R*w$gJLTF_1u9AbcHx$_VZ7YiEHJ2xY5WjjixmrV)Zi~3Sf z3wvrS@UK2Qq}c80ukNkc?Wxi=`QOB?-le9>CImb!Npk7zR!Z48^FrYFjSnw49`vsG z=Z3{^cPzeiK21hbD$n z^6kgT?i%M*R&Q(MTCS`NZ|7Sc3Hc^EA&WKk?ML@2Y0L-JJbDV`1s~i&bzX+%={j_M z*e8JoYSa#4r$(6!T}~s$HMb^>D73>CDku`r^Hmvpnvh%g=dbBln&pi*P68>q5@qbbsbL zzu{Xt2c`8q@=i*zm+-kc_)rS}F15b~wZteMZRo?G;s8rkucYF=vdmuW5J-{&%lL)` z>DySw-{IFwZ9p@KgWiTkNjp2;Qy)QUr=>kmz&Vk0Q1qDAQ`E41PF;n_EpHdJFK^qM zcXG*DzgD#nKXV71Rd`U|5p!FGRd5|!rIRHz85Q#2#qV3`rHVoN`Uy`&rh$7mSd z^3<4Hwvg%QNsKA_y)5yX&w-zO(=BNmj4AJ===RcHxe1tU)rIuudf!Uxa3jb&>jHG zzllawG>R^`I7*@e=DszQcm$h%(5GFe$YF1$o$ZkP3$m^XMbl(3TUT)6p6bHicY#v~ zbm7;DGO6KnjaJLFyMxf0i&P_K{b;?FiEetV(9h&^qcwP$v#I`qHyS>x7_@%g@!ZZ9fv9x(g%YxO{+vnx5&O(Ua zOj*cHok@@7b4z(TOCA$1dl%Es{Zp4gr(0jP=!6-YtOfKB|D)UJeluX_6a&~L9b6!- zi9D#>0h|FVfoKnr0R(h2mj*2hiZwN@nR?^)#FOa6ruL-?f&DdK zRg~e~$e2L^=ryz(RRjA)!piyu-A)y)=QUf$+Y8R6%H=VLvHgNJE_9K!jVQk|VESJGYGq62W-?pKI6u8@S+X zJSlo*%3CQqBuoAmw5H-QJZ*-kPmC{?`xwid=(LxLCg%iD8t9$WM`PsOaRyh^Um@Rp zlFSX_07*fvtBT7;1#nP2)ag_r3xzn_I^bxSC>1jULX^Ynh_wJJ0^I^2@}*$B*yCoP zG`ULTA5z~S*dApS`A)#N%-()Q3W4nTtCz)?M`4fTOI)tGOEspKAk(TtB*4ZFB~)MdWA_ilUi%>vE*AJN|GqNac~(K8{mm0 z#aSRu1n4BT&~3`VUaH~{=0~q7lSzp;nWnm9qWXNnv|=kv#7U z;M5za0u>DW1jws@tz?`ZQDfYj2ESn-G9~waxh6OltZtcxV>K$|lH3{GX04T=xPJ|) zQm;vclEpSeRnnwNkpcz+$*8JO@KFgwF98HD5 z!cJznqlqEdprvhE2zTP8!8fY#0bovfrey-EI;6RB zPAWLo?#aD%d~uAPdoK5zS>JTazie9n$JUz{;At`xeZd_M%P&-x)va)^l3V)+;K222 zuTJxaUFCi+b*AGMc+(6J8QZ=-IidBVUL>7T@NGQc8>W|PY-K>@4} zry?~BkbtVk*1i1A#h(EpnH#!tFowX_%za_Ly>nd@WyJKa?KJjKce&t3RRJ^o)kdj~ z?aB!TaC&eo5aSj&A49o;L8%)UjkL>*vO)?s_g#14z5GQUj&$|wt6s~@j*X`*PgX7q zpV%@EGpZiVGB!-^TX*WJ7F|H~RFV=ig-XV4^F~Y_ELE$u@Qiw!> zwYx`IrrU&Sja}D6!)IrG?6S+sG!QvWyn*Fv6(hMwq-?v84Z%7oTo} z@$gyU!yFcG16ZNZH7RZwLy-qPzK5_A%9k$90j+_a&bAGxGvhNF*MLi6CE3h^SvLY1 zdR6U3eoH9l+g1=;mJm^_(x^f&f@`v~vM6T>x@52Oem}Eq1wG#ALs)=BbeC$aiv$V) z;xG=R;=XqJ{s+wEPP9dyl!;^mw;9@bQZOYpw+R~>$UCZa^r1gmA>So({r2GGqNZm~ zoBnB36v>ADibsAvdryDU{WanJCY^%B->yBzb9IZXkJv7-V$|S+enYdmNv>eU?7O&$&tVU$&wCq`zWrC)8>-F+ev*Ho zBa1H)YbTT|?$6*4b+odvyje>uQ4Q7J9FM;}6RR9ndT#!7KiAU2%&cI@oK4?%ADz!% z=Qgh!YM6g~;-h|sYy_49884KysVfL_wwm;AB5tL8)*fS*?R0r>wo-RV$kpeVANxDB4NC4p2oqjw8c)~EzQIcLH9z77)=k?V5%_Za( zk8Nq;B4O^1qo_Xo)mnERz6>>T7vxftp8klr=WJ~Rdu%|Rho@u=jRa(IVBTTU8bcYY zF)l(Ao=ie!%6dNqo~kpcEi7`Ay&qq5UdZoxi0%$jdBkX?n;i$>dTH+G>w7yFJ4G6t zr0i1tW?tPSs`4R9F;dhxXvCT{my1BNjl3iI{% zuNJp*HNYV2Tu4YuPFAq75DIPIk2|{dbWLGiMC^n%Iuyh;qa}_ijOVbpfh(~;2&*oV zGkh@9Jhyb+htVS*eRMmIUa!x7Gh-R3Com{_h&Jv_j1p8@a8h=zJS*oEy3=uDm1t0` zr{{a>@>hjL#=;wgMW5&MwZrKxqVFExcd0roovp-YW&OlW7`6;~21kGn@{!vf1sxvr z+u->P6B8-k%Y|n46tk@ISLIt*17uI{sN_M93y3NicRA{_G-}9juqinYe;v?FnPR{B zhMu$bjI7nM;3dIeYRG`uf)SjDmtlm!x@6jR#q5dWx7m_&ySo$ET`&lLkF{8G^h-G! z_}-=k4-a=_?m#2`r0I#7^~qNqhtffyyX-zRW_ULlJtcjYDj}oOz|{c2>$Rxfynjvi zh#TZ>2uP&fAZZvUkNv#3&D+z*s{ZNZWOsCaPw$j#$ehB|Xyp&ru1dd`&mKJwnz{bH zaQu7NXF6S2KRNHNL6K(gU=}FGK9k(O+vyZMDOuy}DdF+R~7<&u0*CVOLd`OG3y~mbr1cAno5O*j= ztI^-*blIuHEZ}6|llN{Po8Q3F@JxB$cIf^G+uaFJne-PLr2Mu5+}XO z8d3sc2`>%YfjIfWy5cJcWtU-F75QRNi2K0}n81s%10hEU1-+a%<^S`-Hjd|h>a7YM zmd3-v%Psj+EudW73Yjs3S4Tr+h0WZTPuI<~=UCu&immi>iC4UzB#qf!Mg`nVhTbA7@U&6hZfEfq4#s{ z-V8tS3X^XVv-b7Z`T|e|oh7pvqDtU0TA3(V%f{RNXG3QN}L?dnC4lW(DyJt5FxkSpaNcCQ-N*O;*a+_kNg|O1=`faH*>_D3A0TS%i&$$^YOsxnxocWv2*_M77_Xq}t z;0Or^3rQ;BWIk{T^}(+O_YO*o}-?Ie**RH+u_RIYyLSs;~I$6`bR z2Z^8YS%T1ORqxnd4>cBJ-}ocw3ODGSgki^n4=0*_VlVpyjzL8CPn0bP8e|!=1B)WLli4|ELe=Vcr`Rs8BE^BVjTTfkHb`68~&Z=g05)J%AIhC}-%_JIl% z$n$?+pAh-f0Zo4y^Fq+!1O_0Jf)q}v5dC!_AhZ_5X%tjZs5#_?kFMBCe!DkouvQ?E ze5r`2zP%8%oW&JymkYka-JzA_dm21KS29H~Bi{++zcv5AsJo(RXxG{Z1lqVUbwfo5cKv`fOt;6(_#kBJ?T3M~slCOp+siE{O|9{16;ZvE-Aw^p`&w z2#Gy{SPSzv4MAADG+3+39jL(k>V(^fQzQZdWl$J3>TE+P>!JjQA|a8QF%;s9njvEp zE-W?N9j?svuH<~cj?>{hN!#Y^_<7rTB5QFWYsh)IJZoU&%zpj6*r4+t7x&mKCWsD9 zjjk5K6W-qxL~@?wqsM*}BIQW$wDKNl9>dGjF_ni-;&*v-lCyq}REp*~|4@p)*#FGT zUL#3h`vx9$16Ns)Soh?X>TjZ(NL@g;#6E>=|UEy*h43X&io{CVzLjb`5?cV&JZQ2M@-<<8! z_I@BnD~%e_Y&WV^`SJ4`kN7#l8=V;eL%MwgSUY zD<_6C(|joO)=oCD9WQDQi*QW&dZdB&m9lw?xT_eq zUd%SQ1UtN|QDS?fv$-K={91yRJr$FB3LkwiB1+dznNRawc<=vl0X9mA5&7)39!>X? z*e`W4B|qS7*)o{gsH85JtxO(%rZAK`ZFUBzrrbvihWInG*+)~J;a#C<7#2H#HlGoN zXS<`M3M=fH(!TmfvY`XqOd-5*4`Fr7kH#%VdeXL##l-e#3czy2fdn5C$>%+AuF&!C zRlN!k1IYQt;;S`wk3LI`3hLD;;l<_^{ZXj=_cKn-D~L~L4ejeC#LBlg#;g#(x-!yE za*q@^`$-=+sc6z3;$3{w@g>I`2F`0I5kmiAb$M9_N@n;4OJDza4T*D{TgDkrPHmCN zjyL%k{5dHxcZ3WTT_-ljNt)4}+)|Frn`FwwgYEMP?obxhO2Utw4vf6(} zDM!sFn-*ZNF*#mzujt$P^7qYB>p;z!n4U{M*Ucuy(faznc6u(PQP91}9d6UltNM}w zj;zDFlx*dn*BvEp8yZT4PIbQ-_$*(p6UzEMi4Tvu#T83;LdN1I?uAT@E)1zvhPG8Z zn`ITot-tkT`OEss#St~j6e6P_`sV>?voWL)*x1w6J@sRR$LOD7J^)XQj=W9xoBm?e z132U!{N!J5aU`1UXIFwjY%LU{ZpVm5f=5pb{+vg`z=?U2%D0+-wYQ|JHF4Z&J3jgb zo!m!jF&FA8T<`KDXt*gY&JXJI3o;$2A0~t8A!W13QTg4v@>TZ;Sp3i^aPfKr6@Mpu z^1N6Gy>HB``G|O>_GpfNtcxIZM&tcb z)Q99WUy0iyOd^=S73nRj^j4m<7R#Sr29`1QsJ>U0m#$9kepdS!9!QF}&y#oTu1J>2Er zbQaLt|GRSG@4?pFrJ6r46g`?TfI_7ivZ1LNkyv{C#Z{whs zN!7NlKExOGlu`5S?SF65lNxIhd@uF{E?idQ@_D|vkQVs4Y4hUGl-}9OrA<2D_M&n{ z-rA6J@DNvmH{`U}&9?N!6N`lTsm&I+LC}DXr;32i@e+xM631~b8Su_`{CUtx0tO5J zJ+s|aF*ly@`BP}+MDKF`!olT_mGvXCI*oD5VL`4cmHTohCcng~F6bXB%O z8Xrsn>|XHQoiO3m(6i&m)*dfisyuXEBKIAd6>g*w_%km-)nVOTT?2D|x1a;Od5=t_ zs4ql_eCtO~0tc~-XibT{i!m*R%VjRe1= zuGtJt6Y0@v5zW6MOEWrtgsR9K)WZe_UZl3&c~qPF&18mdpiHZGxy!G$8;$qG35@)^ zG6b~e$I?E0uW7IN@kf|rnfAALC$n{eYHgOkFDHdei!Nk^z2_#1nI(z>LEtcn0_0Rx#59eM zP0T+*Kmbdte+<~c(295>5XTvILlw%eh~LL<7($ZjpTx z6`&0fmtzPzh?watnSvqt1)2_VdY~cJ1+m78ZYLKQ>(lShuS)ik(tUOoaq^Cc7R1Xq zm0RS31<5*{G$HYmYz{-|ERq}@YZ^N=uV|aNtUY`zcwA>8ZY~M5+fPN;T6!u5KHZC> zvfu2P8$1wv<8@ErAc{|H+o}`#Z5k%FUJ219cD|EtzlD( zQqRlAPW-5!`?TIgDdWa_^M|Q@Z)apnk6yJET_t1APEi_m98HR{b%)IHgcpCYjt_;>=L7XOYG4s-I2>bgc=ruTnUNzCc90fjDQtl zlL2oXwpdUv?wMJbTLFZD*bPI@O;r^(;5Be$hL=Xgwe?M=RW>bu(^+f@oc4^PHfor& z|D+Y);4t>@#bexhFrtUZX@klNPChF6GyZ{%59Q7!P|dDrpeogV=sKY2e`RO(QDwg2 z+h`^c%atqIYerbu-I%CTh2gP9);Q$Nxioh~st7ePjhc;a(av{suZ+eEmfa)E_L5+s!awQ&> zdGd`Kdg#|F25}Chmt2T+@i&DQ-z-(qSkGlhNW^qlMTOPd)BwYVELhjteJQ`UX;8=( zw4NBfXgoVa!$rbBpRZm4c2Hi7wk<=>$LGEjo|md_1Me)`l7o6(^7L*gtue(>Yrqb1 z;&#b?`L*=#S1-NablNM`q2M3cHU5k~R0bx@YT+fY^{!3xa8rF5WFXMeHdqYpUX;&WJ-}vuj z-qrfLwr1vD+>z;g`;aT8XD^&&we|`+qZRdw2Ru3s3{SQ6e)}%Eptsa-vv3ds+;5d{ zmM7bnJjS(`R=kfa>Y6(LHJza?!BhYE!~T<-q7z1H$I*3 z4$sSiYwKbk-)y07kzV=79f>3GJbD)}JP@Y??etHPu5JkP^9R!d&o93yWpoS$J{unS zy49N#o$7)n)!m65KTSzLwR8=D@{RZESuN8V=wXT=pCsb$TReVu>#_f6*pOagVcGWP z!l+??pic{Y0fNSGOk#BrL6xwUHTweq$RFiJa^oQE1c)aw$<%=TmqtPj7CSK)%Zml1 z)=Ii?60{<)nk1szHUT<6_nW#r;z(CLLdFcnFNs+K3}gKnfnQy1?#;m3RG@Tc&&kZ$ zHwQ#jUfag#F-RkRCxp8Xt3Ex?BsOM5moDWvJ9v+EhZ&q7#(T6Tpgm8ZcJ=lSB;Xfi zB1Mv>2hpw{#WJ`jM~R4&{WSs;fS>)# z6CL17#VU@Tym-?{2|Tl)@#Q!pqib=q)}?#^uTk=`I{8$yduk*ni}!MzcCapQd&Tc} zCt<;5CAjikenve-h3ai#>|kknLc4O}yIF92e%JDEm5ahz--eePvxeK3-{kY{Q7>gx zUggZ4UHHiP!6#6__rr8cIuF!x?#K%#>ow4C|NdMtpgLmLc7}G)0qv8vw{nw3!dqYN zQGt+#^X6h0i7e6TDzwR}Q+i?}`$v6QO9#myl@R!%iJ4{7`8V8&PrhGhL0^~Mt>n>F*w1PC-m5oiu!mBP*p{6TJ9JV2TihU;5*{e|z>>gKoe$C4#r3)&bW^$Q0z& zre}|)#~PJhTxKh9X$W&4N{1=-{B@BJ7=tGm{812lolL6l6RQe`aDG$`jgF0*CAq8a z>{xF!-D4+Nx~os9cnakMZ)re^uxTTYlsG-^~y7*}Q9uppUIQi+C` zcW7*M=!>9XpF{lxRidr1S3$aIO!{D|;-~Z4DjCxnwvzamy5{txy~xEiVA)rd&W9M9 zyKn4v%Vg_rYKc&!)kj##O5knS8=fVa)Cwu0y59=vu4A2s-pXyANz$uVl^&hI@@0qL z6hw={*VtnE|u&yinyrGgQUxFYvjAzkzK)dBi!^ufVSBp^NT5WezEF2>#b z%s#tXTKJ(odAiE}S6t3mdkPURs*@KOfN!#7Do`iL&^wrBkf(-Md|a} zP|(ZMQ&tEQWT(*74OauykuEILWmDI1VaKl;z_*ETunBS-iX;|Ll>KbZz?7s-SP@dM zw_Rim|LQ`Mn&3ZeM|8AFMR2WeMv|EuN8ln5Dx9LD`qpkN5O17kCIhNRF@U51o#8r+ zW~6$mV1E!$9Cu(L>Vhp(`U{*)n8}2$V2cTYUxbV|_>XWaG?GNO|8;mt=>U{Sss_li z;Uk5lY|JNs(Bzp^ci{iVwHP+|#`&F&AvFW35LlV;R5eip?z&T&Af6F zGwp-?+*X*v*>C-xyB`pdKyoG#9!QUrMa-ZOv_T132txwYDQrCsC_;c0b-f6&elECk z2x;QW#D2AWn?e{l3A+INiLdUiRkhD0YpR5(Gt7%o4a`c$6i=85eyEZFeB=EdOh2}b zP>&jnXXgoBA?%56E@8f7svIb-!cKMGPLMQV(-|Deqn8kZ5 zs<9*?1m1D0)!Ui=j#~-h7F0LH768=`;9Nce*O`5LQbCY4#r&fuvbLF=CPDxMP{;Wo z(C`0jok*y+4f>XB%5W3wBZ!=`f}QCzK&qRylk(B>6uU%c0h|jxcnTA5*okxoMV29xFlAGdDRRI1 z+TcD<;CpduyW6<}nE2nsD3S$>&f-%kfDb7zNy($k&8#B|q>vY*OyWKgo}a$fPQDfa zzvA!~BeNWwe`dKyYG8GINzAs?$O&QIKgzKTuW7=SDfyYXzrN5pHpWc z{Z73*qn;(Xx(CR%8D6e?J8C&#W^txUHRx8`rabhMF|jAUegKXFoL-8FU1lCg+)1)=l*+uJZLF_E!M{Zdk+{ z7`*-2Oqw@vjDoGKOvet{)*~B1w>e>x)!7!XJ>2+PY<}W?=`<9JYag|X-_&d!D+YQ5Z z!WOvHp39i3D{)Q8{hl=OBeZ~j6f8SySkMxC;8Lwi)B{0dqy<-Cg2#?HV)bbGCR2DC zQq5iWt%mqK^$T=VYzaX*rhFuWl7r*-GudB>VegNODE19exg}rqsRC|Ec`8kCpKZH{ z2>DzQ+~pLml?AMao%g#M?E z!UlspIJ-mdN%wi9WtYYaWMK+p8+{2Cz5ZDKm1Cjw+J{F!B4R38tbXjo$*clRpU0wJ zyUZM|{k}K!ex6yL746Fj`PuSz-gL&)_EG*L>C}o-#~*Ao`hd%HT7n}Yp|MU+%v zF-Q-!S)SP1+1>W(0zf69&?*?mg1Je7fz!0yzq1J|6}US1EJpA_wkd+R@tM-*4WEh zB(gOLNs*JHqL74$W6PGUD9WD6R!XP`ZAQwTlx&rbl(JQ$q9n2}zw0%g=ll8n@jT}o z#>{(p&Fg*L_kArTuYA6Q25oOL9@c3?(n63#h)GfOm+w%2E?NigfNyD=@pG#6NC8QAG*7(gf_D`9dzA# z*Ws21Etch`Rp8t!x7JabHH+cT#+C~MAG+~@U`m(n17bz5@IeRu4uXP-tVqfbMSB1D z`g0PY&yjprJ|t>(|B{8_W^m|S+Ol6Yw0`mKdwStAu^3p7QVHHk>dAwHZphIq^gT-P zjH@D9dq01kl&bf87cf4wnvaMwQ)AK4t{Qd%V`j)hhKP~R-fel_F!fLTOwHL`hisRV z!-t9Alz&=#)Zcen{(NhO;=vhozi*X}8Rh$I-5_MG8m2@dj`{G%Zg8|Pp_PVO<54k2 z6g1O#82-P3ACe(Rc*P~;{F7FZ%Zj=M{z29Dfqo&|dO+vtYTDYCz16X2OQnCE(b>_d zD~}()%`9&Hho{5$6N7P(U z`S|w#{G)XELuTV#_V-lJg+JBJ67$=_67_gVfJO3aaIt0?*V~zQ&p0yiK(pGvQb^f47Fv< z&yte%l5M(oCQ@zLzM~mpFfmvsB^|>t{)%=Koo^;GTdla)WaX`@^7eURKgH0R zVl0e=SurARC@K&yXiI#YzRpgcpn3mTrCr*!6D7Gnm<~IXxdSpFn9(%>dlH_XJS+-(14Ed9Q zZ_bG=HRI*`EJ9UqlHDHe!Eq`Ej(b#A@OJP!P~8cDh(-5kWn!gILEg~|ul!FGj&)md zx@)AuT)ZIqAO2gg-CgSQyj!6iTjP6Jg7Ry4OGXKNhfO;AOdLi?BI2x<3fht9b;p4J z#wl56WgK#(Jll5F({gxwL7^Z6Sxc^h1V>&jbKG|4wL`ObXl-i5a^jd~#l?dR zCqePf6kF}MZqC1RSHa-Rg?7x=&4HOAqUey3clcCT`Iq1jY#}|Odm&Aq$ohf@z9MLX zU4DIx>GOp#eiK&)%X35fY^rBQ1$jal)id{JL#ihxesT9q2Oh2pXuaUl#=%N~6`>m} z6*x|T7{MF78a06nN!v@Bq>|KXbsm!6MmnFch-J5i`%T zy39;$JnDx*VmDYri3_B}_I=*Nj6()aV8TVFi&Ro`WG|XU9NkENCit2}myw!e9MR1G zONAHMU_MxBq|yR!a|3lK}mCJp_?1~3^C zsC(|OkWss4P?6)IcM0{XKQoS8I?#>~Z&Z2e?FeREq?IgQfJngkq=aUC$5nX{N6t(! zV>l9g%*+<#t0fVASYjq|yqzZf_i@tfUHHG@q# zo-C%vjsh+-yYCek?*vG6E#3*{9gC-kQ*bp%eWtq! zAT5A`bUrYKLK~P@Y}AC@LKi|V@~2&Tw>T`p?_Y~K@r)Xi!xzVo7ZE<$?_jJ)n*8d? zSAQ9cbWvmlV|qQ1;NiIwE?|gqu!C|T$3%}W*!H(1647%wP&=fT#ePnNL?QCC;V;QR zI|83Un4*ZYB8r{ONS8mS4ZgxW^>XA=lKi`Ug!sc`Juz=Y)qZdAivCKqi3Iz zgcNcR$e-6o@nW!rlg$%1BQX#Il3^z1_rVc_CI?EA)D?0G@QJ(ezXV+}3UbEW;khOP z$-9qb>|owlyre2-aoKb~(1&TywLL@H!3|`?G zaE2AvgAkwK>EyW|3JNlra34*1DyrUd`D(p|`mQL+6)_JUK8V^fk~7z+|Fn9%WPz_^ z71jEu@1e zXV?@@$rW@*04^o*Cw9%NkiA4m#&4}@I0O2udeU*nJ?kV16Rv=58Shc2XW&y!RZ*m4 z3gXn{_sVD-P=S8-Q*V6vKDtMweCJI?j(P_#c`n@~jy;_GM>5j!6xnM&W)9DTD0qBW zAA@FZ0`7t2JBfQKw_GmiDyuiKi9TDa84fZF%>)}DO}5#9U%UR=+2`h&XrzNTRP?6s zr4KI$x4OW3rx8yLJ$)# z>}b^?^1kI@H%$1^$sIbrv~MAD&w7Iqv4q)-bc7XGX){ToWiti0r_mg+Sk%NRnYQ1E ze-m0^9amw+k``kNR>ulI0; zqivNC&jlg%fmVttym$088qgB9Ojv%ysEr~mNvesE{4}E;Ee7n`jkTIw>rhyjng7QF zHJDR_YEmFYG`|35g~P0fNw~Y~*wG9)PA;Pt-g;1V4FjB)?VzlVRju1rB%}_K{BBe^ zH*^vLD6JHrA6l+M1{eu+kLAtaZsQspb>F*9%t?T+0Jfqo${Kkw-I|$MKGr`yhhumv zJ>IOzyd3+sME~=&wYJUV7tgs(p<@#Z^7?=FhF!ti?2rh#q_@T?dIPLK^{2<-itx`4ZrIVjoxHq|7nZ6&FBi(6b@E~8 zlyBN?;gm1tDfq167fCA*g| z3lf!Yeqi14Cjv6Xqdh<6g%61El?S}DY!)sv}@(RiAl^-?Q z1oEuMn_dJThL!4;EO=fK22iomjPsZ2={1~pzIC+%;ZObgqa+yZeC1t`W6!?qtw9a+ zLj|S3r^mAgRP?X@LF!h4GgL?+d(6DT!rE-Ar~d|f3H{ zhBntrHHeIvGXiDLAgqv+!WWYrnT?f}nB43XXIP{`Gv>Pi3x8d#S@nWaX;9G*X0>%5 zqofpqUDs}%5E)`$NK{h9mms#hs!F!(q6hqhG$*h*rTW?{t6HSTz>HyF`0U>AqXsm%DVl zB@Q04kK}5as@L-yLNp+DO0YndC18q4o4c1pi=k5XhTmSJx{u_7XXi}bmG~mJP#;#u zu2Nhc^yj>ATgm?KQ5ubivJ3CMQ#1i~5V2wLG)?|ViQJZQbz(`LciE2*hxaWLxZ*ik zzVKs#kuxW}AXHpEy&`m=XJp&@w!>bJhlBr_xMHisZi)J@Jp0}FGT(m2Qy++3yQT02 zrh-UhVZ%Mq#kCaS7t#ieRle5T=Tx1bR$}BG%^;S~sSmVSqq8HO4{ zcD%Ox6zseG*fVgspg#^7blO>5Tnr)moLib}gTmS5ZSh8fqNU4X+( zHZPHD<|{~~tG8OR=EZ<}6aJdY>bP7GigS4E6}+~+-i$Azp_6P%jponDm>0T2>dA?w zhmL(<2L*a1(PuM<3wE7l1J61c7n((k|9$J-=K*0FG^0yRj%v@$>Dk zuoQHH%^A@i$Fb2&Es6N|yvGOjc2_C2N`I&B7~ALOCQAYOP+Gw}o6`h#uF@=#}zjBI6fJfh-Z-D9{@^Wse%G zZxBstEV`fB#u39|RzyX4YOFa_tKMRc>040C=UTrRu7<4>5_4;`M(e{yqSZ&o2ThI&I@gn*xeMKi0T7yjRj^6c!3OR z0O!ej|Lc7+@eWwx{{^{t0*|U?us25bI*=qo@&`x@LYQ^`3gisbh|t*i;fC{EL~5mU zAQQ?5O9#FnSh#+M)+Q$o_t#+Q0Q7vFWY718)L;KR7txj$Z2Kz(NX=Peg^VC)6@u2c<31;>6a9q9c#7=MH|*>p07#2`il-gY<+A8KRt#6-nh^# z?A|HPnv+(s;SM_w3lZ#w2cBam_|ttvG7+@*CCu{;|C|VAEef7f(8JoFC7>0zL6_GP z9cuzqQAB~MwT^VzQ|Zv2MD=eAEAOx-Pk+kRf&GQ|3pwk_Czr1W77F@J!%~{uQ+0ov*X_!vnp_ zKkOZ(N}Nq!%{4!#KmBaVAKcCc=e0w2-lMatCkD;d9G)HTsXcjZ%c~%EehzP#Y!pPJ zPs;^6KMzD~-HJ#9N#n9_{u|nWiv90+iP<0Z->5W;p{2TQ|MB3>Mt||_F6v)(njZhG zOh(UFli#!e+aT4DUj_+1H$X9jnumrCqct~J`rDxO);*81@02W?;Y!LWY!#(mXgND8 zr?>)7#a}f>-~3_q<2?hh>0H-0S&YJBFbMW*G}Q)4HNGXQ=n72Xq*V-u5r*OlT(Bj$ z2MYt5!C0l`O_&F*dl%$#7_KYVV8-sD#8+!22ew)J1Jb(EUL?pJ$=Gvkpf{dhHOVTn z`Lk-X=ynnauj($ooz;<&B*~s6Z`r8#waC)!S}Pih?5SvV;4QBEqQO|SiP0Xl0lmM< z`yM)P^EHx=Ywu5En=l;INuu2}qGy?U-KxDXs5JkfZFVB=hd5?eo7)h{YX~l+5s*ET zoqeJRKp4Z<^I-LZA$isrk}i0vL+@F;5e7&PH20qf=f{r@Dyo}rf6#Hp!FCm$9*F`c z#M4At8o}Gl=QRmGx>5ISWBdE7E}#C{f{hijv)9Hu_13W3rppAI#y=Y->bR-*1B#hZ)3{YMu2+(4Yh(R)5 zSB~COJquK&ZI7(_>uPT~y8=PR)s@4w<4(%4LFyJ8C7 z*zzGEqPd9%)1p{><)2Qlkw87fbtCjzKpkQNJM{(!?OlMCN0gzj$gp*Bzh5Hio4uKd zrNH#jMgceGA)`UdM{hJrk$}i9O$#6A%E!@~^88(2>NX0qtn+Wpe-Rq^bkKfYbK#}_ zZ-Lo2IrAkC9|!F^W|eQmdeA=W>t*h@O|b?J$(l*WJ?=E8MpBGr*`piHl?Pwk%QRJ# zsG5#x|08t%#ksPwipk!kwxUcA{>HYs5B9xNHlb&3U$%D%98XfWANgoM1sGtqm`#PJ zb;H%P)m^|1H!+{c54={|+R5&?yj4r?q)JI*-#=Ge9p%_YU9Vj-d~`}r<%Z6$+&8dhO3~H4Khe;R^?ubH3lt^KdKurZ_}Zgv4EY$t!GGPh^QTJ1 zo5#W&WW3d_mz%1USLT_{a|4(~%rrUy?eb}W8F{N%L6gN~TOmq9Li7=Aoi*a3(OP}Fuoyn7>-BQsX|4qo7!4K*}8PJ9U^QNRx1 zpH5%L`3RgOW^(nQn|gB5kIZ5VM743+*x1CW9k+9Fk8~LjwXog4c_h+)&TzhNVODtR z+ro79m*15-X65}AAH2fKPWg_;iPb*R;=h|&oc^Nui}$msjrZ;MomUL$N{lRszL&X2 zXz&p~8@2!>{Oa0}TDk){!$nZlcpbKIzpTTM-spESt*&fg+P+WJKCCfkx((S4Q++)x z{-Gl}!E%PhQCgycrMEuXM$8 z%_#X&j`i4l7fg{pZCAKTdg}Xq;bzN8pM@uFFMl4!ZLnfN0J)h05+NZI-=3Qiu|qvG z^D?sM8cE3no%9}zfl0_c?Jv@xCTbrLga~}}DT;fQAdreHhIHq2^SR04#rQXoH zQH8k?C$6ycqF7kwJM?TR-6szZY~pSK`F)$l4dfV0;bd^=QHTsLd}+JxzRzI`0Cq?0 z#g5BQ4`T+0B)IjyQuiH~wQo#p854W+1>+$T>08+TB)6kyLLML(PN|8YF*0*i2VWMC zt~2Wc(UF zv}jyV4GiPl@xd$awCJ|s49JWYMGtwNoG6NM8jimeTi;vzp!tEv4!mhYwmI5O6bIzL z?eAia?Vb2TN4T0Gv{>JB-BQ0xzRAMz{*2l9Rdwrh)=Z(iQ;p0H+8gpIFmG zQnJy2B)xM2012yRk>0>0J5$KNlXfzJxwX2mnlwR!7X5W_z0=vz_=EAMxCjDQ3JP5A1K>DhnO^}c<0_?D6Q9G>-Cb!H6_JS&lQ~JUi6#O110Cr5+5CaGQxMk(L8Qmiua|6 zr<2zCh#nZ^N27Ro{vR?$7#As%hLg6=hFq;P)Wz6=WmFC4Ntjg4*Ub~6oWxmq_%#yx z?iH=iS6XS<7veq|NFnWKNz_k7k!!*@grb-wBa%~`g7A$<@TVY_FuoGW##f8#$c+CI zJ_Dx%L@%DcgjJnfuW4r&dA(2!*=gWr&*2bqpq?L&mJF(zvspe zyM^eF>HYhq*iNsEFEOCxU^?2e7pPQkxGd|T-mRqC45hLjI-cZ|I>UC zn%MeN080Y~Pg1N@8#4uz(veRcW3V!%L38EEHSIdD$mDf5z-X zrwzs#@dnJ)#WP4AIV{@sC2dW=HMa`(<&vr=DhvNs8$C^tE^d{Z7Wr`SB>o@|6AQVITA8d(M3GLdoKS=B zvJ)O^BsxhnsoG%vElBP0^Co=eW9kD`ehnR3W8hYd^6%bXPl(RoHY6hIdP9ms*U#gY zFnHzbylH%GqP4@{0@+$($k&G_jHb0VZ`({G zG9JSsCGO6e>d`*`NxAvLuq)N$N(=07heIx^%znMJH_h#&vW};+sFoHCz&9VVzw3!Y z7chh`H3o8V!b*m5x~fUP_d9u+n0>VS>oRWN7TBtDZuF0k=SlnUWxfzTvOTN$KHF(i zjrg#j(4?Hnbzx^#C-a=t>t+1hq>%)MPe#|<2Y4TZKi;!eKOzAlIdqS>J;`Nh^w-ZU zBv<-aQ0`{#Lu??w?}3C}MMA4j567uNU1*xZ?_wVPWL91ddvPQFYKSWPV}%*V{V2p=!vY$D)#q0vY-d>3EJtJIylf4@aMZw_`@o__KxU`3@x$lnIV_SuYZ)@ zpFZjlZ{ykRwOUj~e@^_!!?7%153q<%1x)>xK~wjTL2ZVXXF|YjYw(Kfa!?v-fNrxo zWF4T3J(u#)%{T@ zytT@Nj~@EA`h$Dc_>gwkh{}Rc==AOdHoXPMurm_VFKnJ;MN!gHUn};^kC&A{F89(q zlx$#UBPn0RUy8He4%iL#0>*}|v55Z3f7qpr8j+D1mw3?LJ{dX|Zu)ccVha}}rE_o% z3@S~3cxU`f0W{-qh>y~J)OC+<2hlPrS%zwuNtqugnd2|-Ib|yhAq$z{NJmutKyN;4 z6)so#<2=@(88#9#bF5Bpi`4xG0yFeedWU>Od@%H-5r5AEYeJTT2C+w#sM$kS4w8!Y$OZqK`OiPIG#0M?{6;J;Yzha*oC?F^J1`{1f4n3}y-_CcP(m2M1TVKTIr zsKuaUGL71ErH{&Gc3TX?SYjKY4gt+-M|63hR7X4;w`79|h6r~dKX8&ocpQg&yP1s7 zy-W*KJk7K{)C&j*yc``O(fsbf2^pBQf_FXCg`I#!jd9V8sRZ64U&ssrC;5?Lwr&)> zwQz&T&J>Z>MgDw ztdWnkN&@IHk8LD=^?A-Uc2O0F4Q8qnuUy6^GVjclt9*(Lp_#ob(68Kt|kaYsqs~I`0i)Cj7xdYA^ zfdA2;33!~dQA zBA7%~g44lSf=B`M%kI;L^k?>wQ%tfFa49Jx09jicS507)Mh`USA3jPbEMNgGUA#m@ zatvrs6cI_ApI11KqygFz;EW4`B7qz<9DexiI*s|n>L=bY!YSP{8UWR}@c##I3Gs@3 zG^VANrAv^_Gf#UzCOf^!Ag)W*52w<2jbhkasr)=C+UtoG(UeH+Kl~h~!S>!{EOAOD zkqe+*UA|Ebj8Yq$HZ@uK*)Z^fh&I;?E1reL)WBQi;bqX_VmWTqRn}|vXLEu?LS^Os z0LD;&BX1;UhY>+dI@{DFJrVi`ff6(z4FD-m~t)DKv2Mw03H)Wx;8 zcpG@gbsJ97PKcs(KNa9O63*K{0fSriQ(!5jhoC)=>q`bYHR%SZ?hG%Q5;6z)a7^PW z%7`_|QYMuInPQMS8#)qH99W1Ts^G#uBd;T64OTI!Xm^vlBCavvZ2`MTqWAzIP5^@W zQ{fOyrC5=rOSo~wiB^t~w4UK5Pcku#-%Sf@=#&-I#SjSfZQTcf3H)ZUfHLO4gsOL# zDChaRn1xhgJD z=n+|8#gJv?rl{o*(7;uIn^;Al5_*4L23ef9oMZ)Sa^=zBP05pt&6QLW>{b+1XXmSR zvXXa_Rd{ZGOEF2rRXWO@BEW$pcQ{q8+$R~^%*wr5I>MT{(9xQtM)RMcS)AuWIxCEj z;%hXYi=7ah7BiP)-)WM&48w>sy+6l|9sqkGgp@poZSpbi9?lw_ij#p7Lpp()5Vc%e z``_L`r4ONFQq{AiC3mg&pSXT0>;0N1rl3@!QXJ}9#!tV~@(RML$T-lrd`mr$a0k3t_qrV!uYCXuql_Z|4*VddHv#nGS|nL#_V2ls z?$hgvcXm7{FUZ9gPV2nch9}K)$Wyc-lVjCRdbE*FLgcXA8JTb+t^4P-8O*R@@L`}% z1;xjQKW|Am#f)#JYTK0psK;)=O*90XjA4w=d2jM;e5ol4M$r| z83r=xoW$kAM8+)H5f=1CD7GRn?2xbcn^Zm6o@J>9lFSB8J11o)2@Q{Dp;YchGNYXa zklxN}oPZ-#S8u@9$W^^YoL_Fpzcg>?*8aEd$Kc!bgLRLr8@?=SiSL?NSQR!lJmWa~ z!G8L}LFF=-mek~DS6{`|;=IRmCBnC`^BA)gA26QDBn=GjhguJMK;xu{ONT4_-Zkw$ z_4r4OgJ?3i9Fz77W%dISbIA)L62X&K+Gbx0YlnQlpP5tnEN^$!eEdYLtyc`9<0jT3 zk?X}*U>Yi6MZdH?GJZpl&+=R4UbipzdMw?ZLYJ8SE-{&|d~lrGgYTkKfMYYaP0p54 z|AjZZe?Oa_*MITtz?Vp^9$824F3*BZodXU>DQd?CUYe;kIIIDWLHq75C6e%*neLO+ zj`V?@D49w49F{jl&BMv(PK%W&bdxVpPz*dO^md7h9+ae0l2RJJ5S|zk!J|EX+^+@Le*4}C*qXavT>A-TUOID)1oJO z!g6+p{cP{NIT2qpT(IHb*2gdcJ*{_tpw(NiPwhYnM4SbyQW&rOlN#@31M|D699H+i zyrW0uf$z0{Q-KCo5(2fUm>DM;BOS)?-|Xu=?NTU2tWWHmWNDGj^@^m-ThXXnZ>dDkGZNyc~IBWp@$1jQIbOpntq0jd`A3VY*9T0_i(wpeH zd#6ed8(s;KsHiyI_~OOD;oxkZ`40BkyqqcR(9y7k6&>x|ww1@U58WR&;yUT6^2YYS ziBF(ac_FQ<)B)z>Gp*hyIvX?!H*^LKNXFUGM!~Q9i>Z9eEesSR_`Dct`>er&QsxC zQ2xI!nm@Pr?XYVzr~3bzKPNFVRN3~MyL#;HvZ@y~RVomC4sweZu!&Pm3&L|=CZFFX&7r(3C+$`64>)ZKr_GiQd*(~cHX<}NJ z80F-?j=kNKGaatADB;WwrCXSt+1q)O3OO1;ko*0P2+BB3L>@a_5yWawA|XVJrEt7@ z5#i#}rP<;8RC66TrG;L{Nm2Zly9T_d6H&9n3rOKav$kpT16Q)WJM?H-bK#K~QP7!O z^Ev%LML@P5QWO(jGqg64+!5;^`DlVG=uLs;*n5TYQ$3mfs$y z#!;mdQSXF}GDeaGc;$Grwq>~5GOJpRySo%n0~85yoVv69m=%P5?v`#cYABO2oHN1n z4bi8usU}8n@geLFTx8OUjG~-a!L-xRhE|(;A)+qsRU%7Q94OHdR#d90iyU13h}{cv z>jmKAg#MZv9QR5$rZ6pHGnb?0;7}X5Q6No=smaE-U}9~q7jw7+E`m0yMdC|zmnGkX zr-QCaN8`e>_ueoZRm9vsZPm`Pkv}%1lgOA3-hOdyLxb(oah=1J3t^+vcJ>1Uq4U*@ zOAE1KXLzdS^t4L!`A6tJ0GcQ~}IJD6>FlUYuC~32jZR&m<%pCV3h-8em8q?QKHw!8SNzink_>ZABnI zaROoq)**PCfTmL_+T=+N(se4_q9oDB1rh_7{;8djuB;w$%O?mPIGx>R_lNr}5IGR+ zMY6Vn4|>C#6K~}Rm_D2ueDK$)Mky%s^O;@!{_t`+z19x`5(qjVVDg!I@-}qSTS#m` zXadfLloF=8|K}(Q6)O4UCACWl+{2jy3aSzKo&rJ+^D|J^EJAMPqyp`GGFxDLtY09h z3jnHwV%;fT0`Z;^l2P|l#)jB3QVA?}#hI;c1kr7{&?Cd7OEIA*c^{xaa*}Wk zXD2w=;X)au`yT!U0Fie4_-d_AyEp4%hA=)h6hYvgs*7cCq$LY{REoDy;zzt zte%8kc=JRj0qo3HJ3D&iP6TXAYMVqyVgQ7fl9A6uQc!J=GHIeSZyQeGWTT@jurhNt zgJTS*@UI)SfIb&KX@*BvhHr-C-BCPQfOPk>0l_V`(}(XSk`I?L<^cA;WGbx|9w1B> z3usp4cf<-EWh#yfKh&70-->_iY?k81fuM5tc+>F~uR^zd%wseDv z3rtjx(g+2K0)6q z-&6egQ-lOejn1(0bLh#kN#RrL#hmn71-AE^|HrA-Fy)1wz;@?7lb06m=k&GBmxrC} z+VJkDW~)sbpu<4#)a9z>9#1Vv^EXd0qK@us#zzt~uuRE6z*cMea*=-URk20zAD#u) zNF&!3FJ8zbpX9TJA?7+Tc+jCnjySyk8DjErR{N#fQ>>n|!Y*!e^O4*dvbSQ`VtA}y z%$iLAvL8x%r2J7}_LY`qwNH@3yjc0-3n!LBN&%V=f&yfD^ZqvXj)9AilPsr+5f?_| z+Oe*bYy4xG>p}IEOs1ItqUH^E9z&NOAGDcz?DYfy`5Fh&Txrg>Sc*Cbo``Ehy`&va z(S_aC26=~zNVvqN_;iUAKdiR|eB?R0$lqa4LcOHUuC$pV8M_a(4m9+kX?m^5@A)Gr z-?;Dl1Mi@q3ubvZ4Q6Fyp=6{U(xVVD;Py$Qtz=5Hdfsi8Q8KTXZ=2Ap=htytL)#C` zs3IYB`60q~T#gtCH9u<@_?W~Yvg!K7#n*%vF3pLq3@=n@aUlc2j~X;+2xw4J#v-F;24dgTS#&`q(tKs z5G1X3Cdqc-qKqVQ7KJZ|b%2AYY>IU5-)Ivn5~*9MGL;oEm=8*iiV~Iws*>>bdtO07 zP4}$P_6@;6qRp@Zb-tmBmFPQ zmTSsP=u#wPLVv{qgl#Wx%d|wB) zJa|;4Y#>UhyY76-d4HIS2O#TFcby{-gM5}Mq{074S6@Z-ZcOZkLM*9ezYxU zp?~S)GsAaJyCi<@XWf9_oVYZ4g+8~5lLuz`*3uDbe(xW7iEO>g-$k{Kd68yXnCdw{ zK0}G#T{*FO{+a#knuTWlnYL5+eLDt)gBd>dHy;0bm7!zqEQ*4CbS^p#YNZU;35N}xn`B?; z({B~7{>>IT6X~f|I>H!V@#A2C&&-v9wj3X%0h#P|i_UC*2d)jx1hGGbaQI2b(q-Cm zW|tI}{(iMX0^EuKB^(RHJ62qX^4{J4tVT0$ndg@q9c*nkin!I?%ltFnYS7B5a^btZMd)MBm%c zA2w5c5_5c^pR__F^Ri+&igWBoM60LX#&R642yR^X6JO#}<{3PoBjGhSySn70C*}=L zTwPPz>z-SzUy*voRzzDqKl-?kX`FUqlvx7pKQ=DPT@CafnK_4u@&*V7XNfT+#!f>lg*U??x|L@^sL0_Df4cPZQ+g0q0%t6*CM!Qq&;=NljTG&P^Q&uA=KZVd z7sh(R;zJ#6wKaCUOjEKL+=Mu}Yv6=O)V2H|{eEb;7qwU$d@PW=Za}nsCO0+hT4r})pJVV_DyFU@WXi&%gOsehnfrFVJI6I8SY7U$!5p+;=q zk>9~4wG!pKA)^;$O<6VnnYBwtrWt82ZT7zz_wLGN+E;w8o-7Fs*B|wqV-KFXt>4(x z7MAE;b+hfWzux4To#2a^WDE`tym8KJGknv4A;!HuZcjBkAMj%aVRaek_iq4w^y0-R zcmHHz{W@kebwKjFXAp+ZrZ8)}w;oEWZ5+Hb82U?yW;ed)Qsty2d6jPLuzfh!>OWa( zKP9AGruF7byK9~`37@-XjlD7ZD!(AO7uZD6{2_Wyz~#J`6IqzUDMGuAU-i{%=F>sC zbF)=y9-^|sr1KAU?+7^Qx*mw(z*{tZt4C=IY9tx3Nh^YqHDgTOLs7o0164?NzGtE?-hcnix)pk7?T#g{xYnxjC$L`Y73Cdfq1^Z^^Z6`yRPLF0DXQedzbNATpC{|i z3-T?g<={Liy~Ok+zi5&}>Bk=@AK$wDJ@fvAt7p-GB zbd#W30Q1~^X(Qdke5Z`YyMJxNC1-k7TRv2tZmF=wmm@H@1B&oH+Z8h{{NFgs-gkr^ zB@ve49T3=@fL%64Z3B1FCNl*=%p0vPlf(rDHtq%y*4z}9b$54hS+i`%A@ZfVDw4aM z$Btkn_WTXEf%$a{OE(7DUE?;l%JXmhM_*DY%_|IRIIILIx&BHl?kVsz)> zut}ZuiL)xzzk5c%`1c&?`Z@L9ruw{x5yn-Jg-g2f~ zIJ*gJjJM*zZ~1^$O&wERKzhO|l4dskNrtd|px_XQhqmtlLE|eBJi`q0oOrT)31QVMJi7mqaR)i z)wN))g|2#AC(@pADTYxsA~ud0{C!@VvVwomFV{lAs_*9mb?M2S)niSw!7TsD*Slv~oBnWr z*ByOzTOeEZ>{5>6V$apfQUPaUKf(rpMFcjwov>SGY4V0ovovuGrS=)7H#^yoc5kc! zvV>KI*%B-Hwj;YbDTYIRs_KT$-8iBg!|lD3T;B=4XhI^jYYQKq0v&4v`S=3RWPYI5 zxm^(aZ$87nRo}vFB+l$*mbs+~53*Jo#Hp+s*!KzoxOTW+2ec{xY;Hv6Jn8ijzqO`& zXyIS9CZ0MmHJnM3;D7(LIL43P5rcC>=~yF&jPRJhjPLoxMAV@Fa747R(>RZxj{<6l zlhpuCt#E_6{{OfD{9SPWz*NJ=&Dmcsr;rcn!M1}$-gW}ykcN=}1AdRKmgm#+V-MO~ z^y$mWP@2zK7_^xxt)45R-DF`Kx&hf*Glk_m9&?N*z-Cq05viMCLVwoz0NlxKm3WN# z`7lPmxu$>>7i7=T+|p&PkSV)}rd};=5Mg+wA1D(j-N%K|zGCf9Rh7&RjU@R|)^)BS zd3zeo*Mp#Q+wyt$YR#k=_zB*>fRT5QdYi_8pDa9IsKaYtH}8~Tcan`ts4w>gJ-IY> z%Wgro!S|4t-FXx1(FLu=t|4!OEICjSjvZL#e*cd#;V89-C&trPF!CVXOEFqW_R=pE z-P_O>U5BmQO@{9}LY9j*c`wI1PQ<&Sw4%jY7qk;EMP#M9t#C(@Xhl{*sTDaHGn~x+ z2d@s?glnv$8&HP#;bDb5$!%^cj5^`Si5yjoI9t(bLo%`-$%aE1Gdyq}-50y`A?GLf z3Oqa9+dJa(EZhg;|NP8$zCv^RGWLCw%E{u*Jt6gT>^+Q2v#&}<-!9}=8xJbE?yaks z>pwg5*ydmx`^_~Us!z9E5LT&)>{!#Z(UIG9Os!xqzy~`XUSE2@+_w2}xVra~O$n{! zVuAJHD!q|$;lsbU7iz=ma^`&WpLqvgD65)OZtpvve(6J&s07~ZNcjAcm zWR##7!^y0|1FUOh4H}+p#2-0wg{#Icfr-J`PwI)Z=eSEhf7XNkrPY)uwp0#j6HhYB z1e~)UR#(fZK|HZu1JC^!qhtMQF&sBtZky4^@BIic3yTwGIbg7FIzu_=E=%UiF{3w;xtbB3oT=Qs6Cca)_M#CnDfYP}MhW%!3(aLv~K6Mws`Si4k1v9`rdCE(BW zrlt{q@Jrk(_SKB(B0Il)Uzrm}90=(NVrpOC?MGXg>in@+56jE@f~&w_X{tTx9%;pu z25^Ku;PjTgW_=G)D#RdXkM3(F3vwL3AI1(DeFpq-t-5XUh)Q54ERR$BQB2E2bV6gm zG)9tSD^S4GxRYhWP#^7QY-lZM{jmOR#YF$s;E3um`&sGgajmd;^+S7`gdXl`3h&rb zv?6UP#ti25m?}9R+MOZ|bnz9tGSS(9uVOq15ZZbAlh@{#cHVk9;F3jaiyfN|bk4M? z8tYggkmWf)Xa8j|Y?9}9Q}tNS?|=P!j&1dH>WLA<*Doo=A>2ti;wn73QtFhYzuH}Q zaXhf9L4;!bH-!<&qt6&GDpPx@P_gi8XL0xGbM~`J)pa>jW%GwZze_AEv!D8=-(0xj zXIVpqSYXH_IS=IJb#-VTJmkpsBvzj6A+~OxkBc#)HKEjc7w2KDiI9^rK+Gd3z5JT` zYCZ-&)r5I~G$8b2vaK7+{H2C^PLjnaw-i_qQZJ9Z_bD7JOMi`f_l7NqhDGvJje2~y zRxkr&4Y$$pL{TpuG<%Tps;+|oVF((Qm+o8N*{XC7;_y7!56Hkom_S~rb2lzQUnu!w zO^OxvMx_||EC8{_NPfgXEE&F3&_ke4Z|nxL4ZR4;g7x%n2uQkh>~=G(60cbpwxJJS zklI}D2q}Y3*Bg1rV5BH~rC`1TmBme{ROU51| zkI@(s!ggTQ>6J|5iDO3KF|lkGzeKzUVCr*@LtP*qs)4h~9!OJOoiCF9@t`7DdV+Y! zG>FLWqQM<$th@ZtBNVq}Uql?Fv({+{35b;+i&{es`12I?KGn6*{;Xs9ZuL$3&MwrM zQW*Fh&FDr_@?){cCr54$)QG33gRP3e2M*(qjcyW2*>Jev3b(d~sw_4WJn*69OWw4qGcV+e|@`UyS1c3HgZ6~q?l zg1?w3%5vS5zd*?tl;k zpaRSi>Qtsj!b5~~jT~bn2Bf4PlJvtB)irCjSVkrqj6^jfp!coK6QvMdVamf`HyZlv zAV=FllB@5uwDuc1hzH!!Si+k05DQEX7my`sNA2~`?GXb*wHCziDI*I!MN+(^cmVxV zSv5^`@30da1!f5YkSye=9Q7_1#f37ige(u`loYa%f2RPnA#eGtfayWj{=#M_x!3^| zI0uo9>rCiIgg2vW<_9e_%&aWbFGb|58}`pMV?h9&O0$^wk>i1=I+o&hOnCX#PF*wH z2fZUB26ZB|9sD{j(y>`=>cHy|(5MS=Q}{T#B#*>3FID10fZ#iV(s3IzKz+mEKV8Zc zzf1Ww9OSqwkYlnfCd(sK5CvrhH+K}TKBU$ZBpYKbV0Z-!C7^tU;hOCZ%`h2~Y-wd7 z??GOeqlu0PNzZsrhA(6ggmp>oIrC5vbd29Ep&Ryx<8P!`aec^u2-lR{DrIt<-SuQR z)(y*F{5$+LNZ~}32qX%dc`eBIflQ-F1!mH(y?D);SCN#F(D7UF^E9tR;;%Sszu}dv z)S=J4hBy~xTvuI=5y@Rt(r&@i=Zb7|jb^S__nJYI`PwjL9)8DQLEC2wzC@dI{p_$u6rh%s|h@>K+V zgRXhFBEax8FW+ViGE8r}Mr@>;Ht$VSjWjxfyW0t)hV};zqI^TkVkzgNJl;b$C5fN~ zmyA*Hd(_`XP>gr`eFJh%IEFYbcOSLp$cWB71Aeo}IIQN^tP!d@*yygtTNDZH#Hmw} ze19~fNQ=4mHbB8(jsGwj(XR8g)0&tUz|Jo&bcqU%=jucH-AtT2wND& zGk0!yZY#s|>gUaE^37B8_4n3atqZJ_gX4v}&t|dXj!$5yfnseJD@g^Nk;KT@<3v#c zv(6zdfXOs`{u`rVP6hPH#HnY5NUj*-$1(OnK*6i`W^dp-za7tz|I9H-sPEz?h2n?S+DmkQj#)p@hvs3;M{hq&E< zKhvXPYqoPw;8zIZNpVU~}M*c85gpxss5n3$rim z7u+gFe|CNNSQ%x`unjmB)N&k7_{C{U>4kZc>@?H&*x-l{YyZC=-RMQ5<2u7$-$nVt zbAQjz^n98Rmi}HRG5I=ZHmLfS@FeQ%37UPyu7UY<|YZw$5@**MvKn`iSSjg(qz;UrY&fwbdAuNq*oN$-C7RqPTs^75dv#Lu2J z^m!SaRa+)2zt?%WMgz(S(okiT$bVrimq>paAFx5zo6n( z^;k{=7L`)%nRE!`6Vox%dG;w0Ay&qsK#i~83YmgmH2VG1Wos^J5dn<V+->04|P#K#>R66ZiZ0LZLR$ z!9DqB%gA__3K1GS8x+->pL9Ig{Rl4OSr$l%>YZk=^-PU>o(c(iLeHYD7ClXrmRdDv`5FHJ^T4s0}!QTZh?T#)3sO{FN z^k=yyxINX&%je;j(~pky)s!rJ*P3ct_@iPuddB-^8e73w+70GDl@O5!efEp5qWC+w zNj6OSuGLYn@xBz|P7?w@_BDK0&04HjaPxw?)o7f3>5YMaYe*h#+f zzH{)hxL2FSOB$CrE`J2)V9W_UFb{|e_qeF_*5LJfhwPRA@z{3u$*Hq= z!D(4kZ#TjJvgHm>z}&%dyPehv1HNWo>9Y9Sf&F{9!v;L(HiQn?^+=zmxk(~Jp~;)1 zTzI&zZ)z5de(SGt?TklV?Gc1+yYGF$>Uz58PD`IxZ?N8VmM*&C`6mYnh(Gqg;^-r| zr~Zwt{f2{k{HKwa)3Mr9%OrbSY0WYk?LVP9RUI&|O$1Ac{XaS&w;})NLwc|Vplz!+L@jd8BsPFTtf$+h0W#_RU*bR>$ zz6Sw+d>DhKSRF626oRJNpO|W=6`%)iqS3+@BpDm1hbJMO>CI3n!;DBccu3+aX2p(i zwP?G*AT|M&B`k;O82+GYY7~itYbqTh-qL-!P%>=B@=d~E3|*h16m__?3iA=uHw7~2 zQiv|MZa5CNBMM5dTx3N^FJ3Q&Q5-uY>(*!f;)jn@wBRZyzth78*s+%i@DV|W?e2@u z*pL_6Y2MDdl3w+?9bXNqmtK#??&zUNTNi1>%oU{~!}8F(#y#@u_c}Xa6#g3P+V8rr z->B|hCy2{R_xa%&dNnPg%lL7aY0w|3GM~OL{-MeClcW02a_09gEJMeGo;_J7+>K!c zm5ArFI!*h-qttjgMwSwk#l*`Jz=}i_n@NBcTIc1@&2Q8xgQ~V9g1hS=$V-IH-H9$Q z<6N8+dg(_pxEpo309cuwSv*q{>jAFrv_$`ZJbiZ{)%*W{$=<6{WS;EGC?gV54#~<* zX&~zs5-LPw#VN9jtoFKzl%ujTGD0d{qmwP_Dziew@9{c)zQ2FE-8k=k-mk~=`Fspj z4L)ZW(BDCjVQlX%l5&I&wH9DHVSCqVl@QVrL2W?Vr%3exkWC;QD@6}ZpvnZV9l#&K z<5_6mr+$S!y&jC6j!f>@Z0Bkq8kx<3CQ~4a`ZxexPX}zo%-s z1vtg{cW8_y2d-DwTZL16k$xviFac6vCPgS4Wb$(xlMVZoI;(qPfO4`ar%+zc76=Nl zvM~$`Q@GJ-uUW5dJovW>tE>?l5r8iSBxe)9C4V~d!i3;qB?>H$&(S+^|Hv4;#waET{1C2DnJd$rOwqRzkuoaILRr z#hpUy|1q=f%;YpcHv??B$GDByMgd=}Def}C?o@*C$<XFGItS9u93@b=M zXLC5J9^Je@r2-AlW?~1;R6fWFBPTHM$nECAdCm{f_6oH#qY6sw$XN1PpsUu*y`o>W92-pRa%>?Apw%Z zYAI}kc}UuMZ_dXm_u)F{{ZgxOV)ehYu&?4_ss6_AX(z%rKPrk!&$zUVmLb4VD+UWs z*yS-zdGfOFfy`rJgZ7}I6C3f#XrhRi=i%&O!}W-R?QO8_E0X{Zd&T+?M^h4D=Xy$b zxA`?aEE-r@+`I;yA*#kp4uQab%fVq2J*Hb?H`JTc4)V*q=qf%UL73f;bI~Mj7eNnjGr6f!0<2m1PQ@(CfYr?5%dxR9ZiomCs@a%; zY8gnmB`WNanZ4Ev0+!@)SoPt(!wcKDhpA|Gd7psIs)YzBCrSnJFHuc=GoiS9Im z`B4)3HkzxtVqI5mD&Szb$fYr1EiurNji}y8Vj{q@02K;z0xTCG;aXs&aCN$HpLX(~O}c|h zH8?TddSvQX&}e|BX`Z%h1o;__(^Zc4MePxt&rB4KR`09o&q2I#>pbR9*{&EUN6!{u zd4sd8Xc-)xL~-5CWf1*Af*jVKQc`b*#|TAcd97Fn9y*tFw|$V&V)=@H{jlxT?;xzS z(r_iwI5KjM4aR(V+5h~b;^3)h5s%UamcnA_XhLOT?1m5~k)=g@#GJ%6A@J@-KaQkf z??tE4laX#No%uE1c9~gR4qTvO5$1avldLb8XB~_;c303$;-i!}EQ9pL?FzS|Oni1? z_G-3Yy`K=`b$L~SE5DKBv$<}ciIlL{%!C&OK{$&25CzQ~?>=%?{ zHk38~dtrmxVR!G_?`czQFDmeMQ zL~n_zzV%P+6lxF!_gQmdNh+K^&WK}L(Z=p}-xYKnU-OZ!zHzI)c9t|Mf;#(xS>7N(-awyAT?@UoYJu*!O`7x#<28P-$jndF z9PI@e@8c`)k38=Q_&qo0*nYC7Ma<>(v$hXS6rHIpe1Wl+#TxLh9BCYbl3c(&@MeU$eOz2OaIoQ59 zTt3`k)t2txQgo1OVrsT~L+DDWkL7hzi{VnezgJ2fe-_qjwa_czUtTf%d%E1PC!l<0 zo+so>V?{IU)q{MerVs&#YvV$yjBdz)Bh|Bo0$5rH0MzzUWEh}B(NJiUyN#7eMu&wvE29h zvrpF?13!DH7ARDFpAKc?DWCk2Kk-k+!t@vC=j{6|%JwUc7%R|jIl~Kpk}^8@E?_ig z>j1`HRO2GG9S|m=zlBII_~o=Z$jOp&>J~QxG)s1Vei&7b>A4CEc_BlZNKe{6r2D^v z7y_z!33E=W`9EF^c2>*1Z96PcXFmCOyi*e23Uq-Tpvn3=*Bd!3^`^M8*-v&ugTR?F z0g2iT~p%|?N1lSr{Svl--f9T^HYnej9aa9wOwD> zHIw3aWt=lJm>~;PcYOKqy46)U-+LU{7@L0TUbWL$?Kn5H_Rh+&soaIHjsq1l|12&G zn{1ia`83MLh_V`W^nG6P39hzHCN`^%#73+;>S+=8E~*9@IJCPu+mEE3IRV}`VY$G# zO~`_XByL9YxtusEw03o&4XBo9ED`<-+hpX7Jt->m5l{U1P{BlF&Qj>(lUMhv6r@74 zE2ei=)Q1L13*HjF&tmRm)kK*yEK1Z(inGNx^PlD0tqC3H1;%M+tCNa8S`@zvBjHJe-4W zqKOicY6%^fgTZ>e1+TMhx$Xz;-$h0wB1~u~i!{>6bW*MMwaj%5k2Es{VoX*p;z7iN*ACu>zE!9^Opj zOoU*QyVV$R4@fuKqL6H6fu(*jGIQzwaRI(wwiI){yUAKir1TQe?(%a_eXy_}$MNndJul6l2tFt3U%@IZ zL8@`=3E@}}e4rhLIKsA1ne=ao57V9GKY~sq+isI5>d`xZlBu>cs;y=$e^nX*_&8^h z-_WJ95m4!G$$v`}-Zr59`@^e6WuJtqKgqyCvcTN~s5~(G>6YhWl8Y4p@pz zIUv3H|2b-DfM)CI&tMoz4vUC&i7H$Lk^&i;3L5CI5exS;xh4&Sdm%!KpV&t<4ZHu0 z9rgjZVn58xW^%mDpOxUK67{(y*Fru3$WIOuY0L7yg1Ln_MmX}5#4e@;+MFo_AaNc{ zg$SVzk!y;LBWeK(h=W9Uz8`=S6CVIo#9uLI1~P|4@`;&i0Q`UcH*0Z{L{=KkEk~p0 zFxmFdelPyypq2ZfnP6TrSfo;kw?B@z$AkI1G?ghT5dm;fpq7ZzxtU@}tvah7{uFhM z>Y)@9Y5ZmlG!Ma76tVz8TM=ZkO*kA3e|}iO;#0VS{?g=DJA%!DRk}3qV8#SUTnpNGousu|sBw#dP>Z z=_0M0-s==6_$LR#KarOrNV3qRC0F6Cz`;CzB1Bfy6_ex6<$mZ@-heFy@Z@Ot2=~0! zvWLSfw91quXx8oF@ygUgH_1&X5*t|%ssc2fEMVKD%Q=xc0|+1G{m@l18t0FDKx8PA zTm{VxJiWyYrI*Brz$Y`Z$#caR1(|s`&Z$&NdHhmw9!e5Cc;%+3rJ3FjDHEm*n8Mw_ zFy#P>urM*Q(S-_fcA!F4YWbw#F(SrJFFnHx@F;lsLkSbIVxRPjd4mf+8pH!Eo5I>L zq^zDSSS7ZKk_1_hL7J21?MEzWuUZy9Ra8|>OD%ZtuFgLMcgES^ri=rphf6}g*9D3- z7;xlmA{t&n;A=};aFyQz zFH#b`oUCg?Shps9r?w>JxXNEMZlT2-$?Jm^2b(NQFhb!e>VD`4w;*9$$Y=@O>?(Vp zJkdc%uzD>c-)mP+qbvWcHgfkP613PR*PDp{Y4)T+bk||3AltFy&V6gowa~P|()MO5 z9P2(l62&`KOKfgH7L%;*4(MLXghhUg#0r|ZQvFx0kI5O$-3`PR%%&d zBgCZ-0}A50foMK%Sy?elVa+?bT_4r%B$FDdDJ*`?`sDm7&rCM!@%6AkALlqGRwZLU7$ zywCn-ZC(DM!!Rpa6}H$On(Z-G*3$eVp>6Ti7oMJGzv+k{FM~s7;x8+5 zx8#)d`gLctsy1?^8TWv_0WOT`P6WlghEfeSImH zOLe*mYpbTuOa#yNj_NLcPFPUfuysCG>UW=1-^*{oA>XFUUrmQiPfryIRMf6KRC-l0 zR`FxlWb$-(umHnu(~wx!e-URp^>*%8GfuB=GNYYSH;#MO`l>1`y9VkfbsH|}&f%+V zurd~Q0lAn2Nt?X+?>rhC23kk=iTU5T#HN|FjLmC5m__T-$^@>nA(z@AQZTwVWiI#< zBMlK?mq_dxOk{>nJn#FcT|Qq@QGH=UdCS)Mt_@QGVY4gixhrM?CG@o~F~m2g_}!07 zRxMVMJ1clkI^%sCgyamZ56`XTK=-;shXeGJd+VSCQyOT#AOlJj+~646xGhw5{nb;e$_@cRM9RRP5;(cmW z*T%oP!+tL1Q3?ARyui9x*Rv3`Q0ut3KaS(?M;aUt8rLw=b)4^^;IkAGdT)!pL@CEO zm;k!5nPpAK!$}7ch~vli`uIHc62~A@ge|A5`}pFd$T81Pc5$Fe&;Gk%LTX-bYqQnw z!HSyp^2xu-$18(7QZh5s9*wbHH-Qg_ggo5rk#Lq6N4YJsef+=q7XVfN{?3r`Km9W+ z!Ju5BP0!ALAuo4f)xvPa@2UAWxeIkl0WBT!78d(fLn||M@r=bUi?$!)17g~r)UIv&g`#8Y} zy_Msi&&Ubx^z@pu1nadPPw+bKaAee^6mA^KItzGtOLVB*9~RQkyP+BXe7)GCgX>9? z-xrnc=QZF0$Ohqauf%NPr55DQG2I^hMxOtaQis7CLi3JwTH!bMszuFXkxP#1|o z)!-oKHI#))p?$fymxzq~4sHykbz!Nhb{FB{m=qp{L4+-wk5uSi&UhR|%$_#`(F01z z@VSMt_Ikc|-_>d0zzO3-B4u9;y{Uub#`(fF^6GTTBtdXk>SQ!Ke=!Yui4=h(N<;Q# zRtRHkh&fK@LIYr&3*q&7Wd_~Zit_fAvAf0=LX*2WebL<7GkPQGc(X;p8#^Tjvu8o- z;ZeOJtp|8rgsT-TcXiIx#e#a&8M;AuId19pR9gTXu&xf;!+FhX z_hoi28&oUid?s+ly;~$)WPRgpumT)Q&Pb8v0wFpLtQJOac&aYkgDeVohBQWmXQf1Z zE^9Sc8?M1<<*@)M#i2W=i{2J_CwM*8slp3VHe2PmczdZ(>R0hQEE~4|;@&!&zi?r` zsb#)ye$>9aCZKFFt0XgR4>PX9D-sR`6lXTN5;s3u?5c-&YD&_zX~k;^Hrd66ejGQ! zH2lc~CK+s)lAlmyn)NUdJCo@{JSgo8b@rlfOa3az#7zYBjzKj{{3e0HeuIQ9~?;l9ILAm`HL;%M1qpBpTNoY@WbWF_{8v3(Fgk z$uhSIMFK&OOk*3SvH*Jl-Xb=$3yX*U?7c=Rez1#a;{OuUz=g32N2 z0x&XxWhfi)Gj>|>Dj2TRm%2}&60v$nS&F^+7rX?NMNJ@eE{O6F@K)-a8Gk?VU)|NO zjQw?sk}`$OxOA5xswK0KlEJPB0AUguQrW*Lqep>2?~qqoR57_Ib$}tJ#D{A9b7Rk8 zaYR~o)Sk7Y@*bfGRTkrV*X;gTgQ^L%XB0%#7n?JmOPv#*&di%T575&}_G^`{R zMgGJr6#{+<{glX;o-3kQAfPCJiE@L9@=2jV;v&d1{E%625ZQBxBrdiTid15Jiu(@* zGbGvBY;COd{mW3*eRzvLheNL?x(`)BW0fkF6Ro`>_vQ0aZCCwBQD|)hp{n| z!BB1FuRLmB$ZfMkGdU30fCzuBMJB{G6J< zB@j3uNj+YZEBdc4*%_yfH{lJu8YRF|xcYC^XAs_mqSEE?<|V6*pT*HU3;D8u^AT?Q z>R~Q~r&2^C#ZWpD!?oxp_$chgZ3YW;&MB_)v_Fo=9N+gxyOtmf=M;Y@$4>9P*bytm z84>5snG_l)IW|#vrs2i?Xt(HHc!S1mjQKGHU+qCL5IPDIE_%yygM#Df7H8?n=p6K%J-?cJZeEJm0uQh= z71n}6X`LK*GT|&wZ;*K44WchxY3zt=xlkoAIUhy~R7-7Gb$YcD#WvbqP6zF!;psGo z_jbonBC;c*%N%1lcUT=g2)vQ2ZMQuSU&-_a=Umh?PCN|Uk(d~cf_*;r=1Tgdr?(%8 zN5Roa*A#*}R6P~~JkNM*^sKzF{AHl`l}H?u^uUBbdP8&)myqK2C$9?aASNlqrPftw z-2q1!+S$f(xQh_en19WcB8Un-y|Emmy4M$`i>E84cC+-R9eDW^3!D)Zc|n2ZU{?m>5}1 z44ZYFRSHFBL)wPWMFGF*%9YP7GzITYjvqIIt|oppn+oBvORO>eo9n0UT^4ggPDZN3 zqxg)EAG11&H5+Yd4uL^oEromjvkf}A$o0ccOnt^bY(e4AAFhjudz%a-19d9;hdS3# zLuTG;BVz1p=Q_q`1jI@om8rYgQrX=63Kp`Unl}ylJv&~~&4O}Hu~*fi>C4bL+)k=* z@aDIV(Y3vY1g2aw&v;#h3^Q;1>3;>2NI1AF{QTB*@2`S=A82!PngPWMwe)T0r*kWM z#T^$DryWZZVlo)7JM^@F)R;QXvbVJBm5=vtxn8~X$3WPn>Cv1=wN(OPJpz-*7n6%N z9<$Ugou6?TZ4<%x_gkrdu>Xa#p`Cb4=+QNtoDt_&@u0$Or?C0Bm9*C&|!vEq6vf>0~X;k+C!2wgFZ5y%&T z&lbL`Z{cv?slb0t7?zUc9rF3ioivPHx>2y`I!Y==!0yPchX~sdR%hv6xL&~v!G|}vg-fKO)loyL7!(wiuF}C1h#b#2%U-CG9kEX0RhUi>yT&%O2gokd=4RJe7+nnjx= zqVHY(H~L6@6d)&o&sng`{9Z0(qja(_r2A>`LL8n!b0ItrG2g=5R+|giAS7|Mu|!)N zmI;Zr-EwF#+ZH5P9$cXz(#eX)156S{>+^ttZu{#VQ3^P+%!g%lSMYq8sbqGP->g->P?rI=HhK1sPrET>fEJEeq-$K+TPZ5@Etij@f%;ev{ zZ^51}8Lri#Pv2Z(Yxs&8Hxy?WC5ymR)rRZ5@{z|6(CTU6%G*TbdF&7tLmxv7x1HlE zo%bozpLq$q7c62$VRNIXHXT0Jp31CMkL73%k!O0YvC{XMzr-6BZXoO+YlEv9SNZKr zO~aLVa2|d_bjxNTbPBL8{$V-6yFh*i*6xUngmVs4kBs+eZ~tBngZeECG(t&1dHczo!Oa7Pw68anF*C zgmW4y;cMSoZ46=-2~opoUucig;lw5NT4v_%_v|Z>lI$u*ePyLNY0xjIks(D13IH^o zjevW@iD#@^CJxgO|3}*}HS+gKM+K&!YflOh6150zh)cMj z74+cS5L}UqE6hy#U^3K5&pnltqsXR4S09%RkJh;+leLLa=+)ya?Fmpn*Aykl0eM8M z3WN0JiM#F;CQH6<;>HKKWDL?Wa3joxbIoD>MwRYX%cdH$o0YCZVT;NNvYJ3TCX#MGoq4yIj~|L&Q%lYcOhM&yR!i^;>`55nI1E z&DM%H!W*_asm~K*+g1xze)7AP)@!3SJm`ek#SP^6N^_H|cS4pYZ3zblTICk7A7S&i zTyX5}mEZH2#B5AYJ9>`&F3q~XfBIDMH+rP^R|+S^*pT(6v`l!-1;YrVDCt+?w!#X+ zajRUH*qX4Lu*$F)Q|oO7oiv?ao#m1Vv~*%5RCNywz8v$b|1Cpgf}QFgcqnEV91@twaqMkb#t1YfhKR(vDm?Zs^H3 zT{#;lD|>gTcbC|ifsHGbk7p^53@q!J`ISEj9}+h);D!=!G<0~2>v&*>F}5Z;!}Aun z*&v~oZKBxRq1Dxy%5o=*NutSgafc_L1n4I_w7}H(JMS7NkxN6+*#T%O5g7!5*dFRBYyKr zw5sE01LYae(j_V(4EqruKmRZ^@ZxK9G0WJ14Gs1Zp-7SUd&JnO2qi`+$zQhw2jn)b z9)>~Iwnx{BC_+~X$Mdqs68yaI*KqLDk?81ZTQ1B=!sy~~1;d3QN}3HL>*VOLl{t_;lwr&5UFNGV+1a&z<%`k*FwE*JPX|$MI~`Ik zZrtL(y!+Tk$+2g8k~39x`GFz7*Gcw-To@T~{IvB~V;F7g&$|7B zK7TVoyrksv29jQd`>4wEv$sv_D<(mlkVPgx>pG@`;@2b(DiY{1IG=vpdc0ikon{A} z530ySFuz*s&QZ8;PxJBB+5I0E;NrH_d%{TSzPYQ>$Q>#bI#+Vg<~MXOo;O%}L`0a{ zE|2SOD4OUGtSzqn)@L64cd1U+v&Er1^V=$hr3Ura%uYxxsE4*BEQqzon3jK^PzV%v zl?pj=y5V%0aBS;@i8S}&!K)_HQG(w4Glo~mu*Nx)uV1ntm#`H|zoI<`n1MkaF5Jr9e?;$FudbJ^oL`<;Zb zzen|tY1{Ek`O3%!hJ@T~dljh(rU;Rt^6yaFtAyvaC3MeLMf5 z_PSAUVDm`+0!vtb!o2fBb;Wn7H*}uu4ZayDY~HTELEa-2%o7cT{nQF=j^3Ka;u4Fg z+wtof69tEYn3s#4GO#;uVswf7U-1U?q>S}n%b zBn*y*4ACEcmx#-|21nR;AI_p!PtDbTr$V%LJ}Y5X6;T zTdgCav5%hv1{T}S9m(k2p-^}AZej*g6j0LD(M;q7O$blC*tQzO32%lp23Kb`;*I7# zl4^Tb=2)>fdQS3#(B|C4rPD|`vJZrFYsC{V%Non>UOil*ce*4oqohA;iB(0O&Z9jIn<7-0tS6zL_ofxa#uG|$m(4IyX*Yuzq8pPwvk{vv7E)bGy zKqM}$hj_eBu}+;eD=3eSfga2FR%M5F!>M8hUjnSTQe_?ZWieAxzx?X?!$c@gj2ezz z5kN6_?7opv)U_@8?|j7TgdbcE9#3Or`b^d>)>Kr6E#&^L3z-tHsQO|Z^y97R6S{MC zp=xjM8Cz$M%mCxk6mZ%vrDr!4uT4JNn}Om6?CfrjOofI3R*=9oQaq9a<>5@ztpJux zJg8sN5Ljd^6+b>{K?W0)9O7A|p8}O?kT6wx>LS}I{2;fEg;5V!3*VehBB%vO&j5Sd zgtdh**}Zd+FDwKNu^vMoHb*)CUvi65211M6x<6tJ`0G4=jC4B)aUT;|Y>mfScLfC; z0yQPYJ48ZaqMxkCJLSm5Xm3KyOPL?4tG%V_m)B<&{Uk^qY2c5e?Ym@0-WnX z50b~>;J~09W~z`;TjIUjVRo44e#x+54le<)+U048RXCH3)ID(Mab1{dT^!(;D5KO$ z7`q-abHb&<(R*Zikh4h+gvn3o2GU>8L0~cvw5C~zqGpgnjV1EQ9yiay38=??>SB$i z3k%9Gr~@F72)%*%PKO!U)>t$)hCMQcC^T$_ve0AJ?Qvlm8yhl1?;r8o<)D+{hPY== z6S#l4ycwk?Y71P06hq+_uc`7Hfbc)utz2x46iuY@UaK{>`*eqxF3u8{hFLRsCqgQ~ zs)cKkp};ipeRm+=-VXiE(^iBLSaA@i8gN;k%fNARO=S#;9PDs|F5Tyk7)FX&u+{iD z{ZTMP1<4wSBCrofCLP{OQ#VG1MTFnfe!XOc$`n@*+!Vz3VKN4S4zeh)SIBTtOa%$! zPd!|VsV0(ZhX;<_qalV@4kh_4N|jDGW0NHfDKLA&O~T%Bx-*H`StB@6&A8-v7PnQK zDbAck{-6~ISEMS(7E1_;jd>maTsN7VbOnq^W)f$$u&Oqq$i*-G@99MpyZd#{Q(RF* zZB3fG9vWjPCnag-FfW>83OwK?%K{~YKaL;<&h}7?kfIzd<;VWB1}e9d5K zE{U&kNi~mAhJu(w9t)}DngH<7=EG1D;Zp{#nF3}kFxMLIf)WIerUJfPr;@30f-K;a z{7*kb4@&+Dmn?i>R!fdcKze2qeht-_aje1+E%0zotk2F!*8Xe&0VkdYSNW2*)z#Jl zrVAHx=ksTI!X~zUdVa-wL<0KH5ip$wdVW+N(0q1iH9B0~daky&qnj*&lq-!u%O!Xh z@3KS=PalgP^^^Tg3G>}yOD?|((V6osUz7jc%6FbwD0XqQIfY-laOZf@UQLb>CNMSlKmw>Py1fo9|cwzARRz8z+ZsVe>gNL#0BVHzw|O z|9hVLjvLm7X5x{y@-Y4Hw&9|GtJP=*t+g|wMr&tw!_vs5G9VXam`4k0Jb4SQE900- zOf@YD)yTa)FzNCHhX#6lX)s&8e)J%!T^f)NK|Q+s5cd=cHB-_%9V_JB!+jnLuB909et-5IysP(n*U|(i5{4P zJ1z<4{_wUur5tUrdbiyvG{+K`Ayo{(I9Q?1X`lQt28`2>Tmc?*-RExmhHh!1mIh0e z_YOhs0ue0c6rgOPk9oT*o8_IWyt840*s_NAis%XoVEu0YasKe}&99$~ENh=@f9fM~ znKpIcS=sLuK4G6$&Q2{ZRMh3pJ`PK6+jG7bP_td-%tsw50oL$r$aP#2&m7lT^T?U3 zz+%;`JJxpW;Gnfn3zW`(cIp1ORgHJU=%I&vKvO|lE%Vw301YrzU%8ZWAvl>D!! z>enYCE9PQmRxbV!QR?mr(we^KZ4riIvIMfQZFX;i z3sn$|K6#>Ud)`+b@k!(--(;YND(?HAqH^rt`K9U@U$^dIK06UpD;G zEPp|G>s;}IeD0y1N&kwODK(DEkNiSU#wfg{-n}lll3t#ao{{HD1=7 z+7AiyV;R{30KD@xC|Pf}aj!sCduJJg-uJe_;sX`HzPK@MH5)Q2l|jnF+u9+rdv)2` zqCG@y?2CYF--slycg2E{DFI8!BR@2+_!DVmfs6vd)@AZ?pn~LL0t4f9sHp zCcL$bW)j#w?;VbKdMKyGo-a}2KAi4-aMdVLwvadGsH?8z(*wt{T1-EjcDZ-`@Za{Y zPWN42{Mpl=JGT<_qmtYOA05?x46Rlp{JTc3q1;!0eOwzPM`nn`8-Y>%+OmM77O-f0 zv`bc1o#mI>s>B)kss`G>J1yAeG*)*WQfel+KhbqwYKE*nM9E*&>z^GM80ci6O1YlV z7l6aXI?c-?%clHHG`25K*x}RQmtJpi%9~d<;)ui3LsGi0O@DRNFNOT-C*HDL4bYugqpR!t`)vET}%RVBjiu?9iyp_az}Yo<7L(47eZhr64iGs|*^{ zj-e2J-?BrrCB|QmS^zW?m7S8?^`hmB_p4nawkWz)K@o|@1LkDUtZ$pU%PuGH;dr+z z1Qn>Srp5NQv)=HgLTT(78EcLiG3kiuP3gCOcy)kOYKN46N{c|ri?t+M0IQq^kuZ3R zw~|nALDiGj6ZjacY}?-iE8La_gIx9GVcscKJG1aP>Dz|Nki{UFj*wa~YZK3aAr%=n zwyo|nF=YMz$W|?*q12B4Z3k!V-_ZWYp^drvO*Zi{vF$6!sxMEosv8r$CUiyfYKX7Jpd z`oboa%&mt7bBK1fsRlkYS-?c-UDsicPqJpnWv3^>`>UkpNmi;Xr-NX7y|$!Mz#a z*aLtQ?JV9?;w~_|(n=pJCyE6L8kqhAlGD0!)*$n5Ely-3_`vs_#gZ}iIOKP_&p`gFkqJBkDgmM|VLXDjAeV_mmHsjZ=bzL&^uI*b+lb znSb9ixeqXSY7HW%XC4fi73_raIKT_ECl9t<=NIP$a~0H$V_Q%l;1Mccb^ddI#} zxf(sKo`bTqG5Ak)GV2gRLW9719c7|PUr0*Ls)i$ZI>SjJhnzG;T~8)1$w;_G_%Tln z_J)T_(<-i$4O$Cq+#@G;j9jTdHxO5yND@x5QG?WulR=h}W6mqEhJD9WC04;W~b z*L;oY$Ut7)z5-O6X7h{CE0FSz*`UJ{f{Qx?_gm*o0dg5}WXuPed7hWWn0`(z#D(ds z_X88r8WF}`K{!r9@J=cT=BM$8rLZ2ecxYO(_NFzfRkFzw30}6vPat_LWN)2b*|9^d zS>d!4-cMa?S{oCb_gOcA%3~wuvcw2ng^^Qh?PO}i;vKk!j2yLLpz=IVhbO;$?81C+ z!u;>|tz71}PCkxzI&@J`bH7hVKNj4_-~|PMFlj?rnaoUax(qAcKSr?s6ge$#>m-hE zPhyk|Jcp>PHfHR4OX*x-u%ji0K4gR5+5%_;;P=VvKzvfb=U{;&g0!rd99JYB=6BS? zS1_H9>$oMY30ZuZHcX!fW`NzvpAq+$r zQsmtzHWaEzkyEDnY9S+Wd0Ea2sMds*TAqSHL6%b}w#g`dNph^y2u<{>tcklVDH)qw zuc*KR=Qb=)#Vmn2GvtxV=3YoAGmc=n*6&eAu5fM}QOPh{A{l90qtSf8CqZI}ZMyc$ zQ)z`~C;96#Ko>FMOJn$ayE7SP*l~{c2D}8&=?$r&k{7r3q z>)?{%PVs+sKu3Zf&XEHHa&$QKxs0HhmxecK-X>#Xp$F(!?Lp`5>7o25MVNoMApFnmD7iediI`sD>m5b4`T zko%XAAaVJR&!kgzoA{m9BxoFHaB4bG3>&7#DzRm0FP2f91yIZ=A83c;l*n$klJ2Xl zSe}ASw_rr>>82AD5;6$uQGLnT@8969KQ&7w4j$-rc{4ibui*v*#(MDZ%Pv!W{qmP* z#*td^=x5r<4|64v>TI_R*o!oNGUwNkp&Y@_?qrE+;b-eOL1iI)gjc7pr8To=SsX z=LtU@$yHhpD`!7tcvh&3pN=el#m z`9EXN$)EdkCmZ_KUT&YB@2(iDn>pAsy=;C|YStHm!!ZxrTlwN}l({Ve$wW!(s?Mz2 z%g!*4%hy#W89RGm7IZX^wO=n{>@Q;NC>xmTEsZ!;y=CYjQ7U%;Za|(LK^z#O>8m3$7e(?Yn??PUdDr4L9I7~j zL?wMm`?UO>R<3ts5@s$Xej^j3TQb%~_FnKED$>e!;yF$?cLP>k zZ;zQ>Y8Ae3F+rsFTpSON9E!HC~#$tyyW6JN`RA z^la{e%GPGb#jOkMn>;?U>=)F$W!JV}?z&$|SM1&Q4v%)xjilw31$?AzIDWmzaMG0G zKcS$v9hUWKkzhb&WsP05b9$n3$KmhG!3Mt(jKQ(+Vr-xS8%_>F9g;2^I58P(+OIBl z!=SzVm~P zA+x4oSvqCCee^;JOMIcHU9ZTap`Vd(cZEhDJumBGz>||VhMjaJiKVH7$}Nnfy<>xN zI`c-(^Cjy@tk~f>s5Yg)O(?_c_Ls7${+B#Jqbs3nDrol8e-~_ePJMH-wHV{6wy`^= z32K@SHxvVX4b;<60)Ougn)|s9)&V7Bu^Kk5%`*_!4-t=$yBx5d$KGknjI3_w@1FuW(;SvgLr} zXcBj;-QAP0rT*df>9~&C8p1h6@yv)H2r5B%X12>@{zHEi5PqPTC_4Sji0s2VcW z>Z86(1TT~$%FqD3*?+^5r?GH6umm|asa2R8HD_8WtwKq5iijDy`a=3ACXya)I#eFQ z#X54@ZQn(GgKmtjiN#&VP@n@k^;-VZZ_x`FyEhfZJ97Hdr$&3cjZcvQDed0mm&|t2 z%B~5MJWVuob_j3S8v63b>FvUGL4B=OTR+WDvJpl^>qg&a2c(hfjFAu{Cb%#YGc6qS znYl|2@|MGL)6>)MS7W#s6I1+tv%gLgHvjw}hVIns-e}V+wKjn&i3*y33S~cjb|GOj zFr+lOQ@6=fy7QqnR;+cXLeX2xL$*OZQsU7_aPx}f)ahBXFJBuEI*Qs4;DvITNvqc` zK@~n#m>7W(Y;XuTOr>_ew5;)>_xHBHwAr_7&!x>aFy@Be_Qzx5Qxh*bR(FxmcO1SL z35`yy`ElB9zj$uQB$o>>RysP4Qs2O!Uwxb^DK*;paOHoZthaygeYWs7aw&Ldf9ct~ z;5u+jTro?t`}JH%2=B}NJ|^M9B5)0mWidvn>$z6AJM{NRftIV{at&?En5ydH(CDzK zgvF8hPm6;aCjW680ryxS=D=L_aIz~shV;Zm>_rOUz+)Yy zE&m%mkh0~^9tjw)So3pnmqcmwe;-Dt4=ye&w4e0J@rVm}+)Yv8Q<1;2@tf=snZs*~ zuA4^Ft#7_uX0=76ec8yOnWeuue`}8x`j;(;o zI#dtRZM@@s)rM$-P#k#HQo-&|0;7p&sr8Angu^UE0f32olqjr`J3UNsK2|fv=PzfF z8KmvPZ>%rmqpsb*30Kw|h#JETG+<{`hjx5S7x#5JR0J_c;616}SOqJ`);qKUR~8WA?dBxL%z6>~TW<(gw&{>ep>3 zRSJYZY7GPG5{ysM#V1zy%XKH~dqhM&#YxnnT9By86!zz2o;gyTQ5)(`ak;SgqT$}( z)f7MTN53utWy8l<@u^+R9zC|>Yxw%Z!T;j|FwYu6a_Pk#)P6IZzcz$8GI2q>j6Zg; zbNRX;FuDVF!}wGRiVa5cHd*}2P-1MR3Fm~?#6BXe)`T3JqZLXHE+P;j9$b_OJ&1Px z2yrH~aJ8K>6_Nwt>ZDJK6g|{%Fb1|FcPfYYxk^Bfy-k|TRBp1xALq@a91z(BIU=}ETjC`OT7f&-U zHsliS)N{ToI#KQ)b+;Fk?6@cd#*s-yby=klN_C>U>7m&ytP6`KA=b#o=~_C zbL+E|(__$z>4A6Wncc1$E9+ViZ|;)AM)~61(CCi|6pfoko@u@3OLhS}YvT!huv&MP`3i1KZF^;|! zd;h8nabaCGECP6MVwktwjL8S8t3x{-Vu$LDecku&V`t}roFq)an<@fh%3FLmNEWUq z*#XL4|P&uO;EB($d~_|(1zoF@aeI7_Z*KyrKexchRC;m_x6zR zI1f^dD2Dk70xKW%LO)vfNr;hkCj*AzLtJWQn5CPH2bQVvYgVgS?{wa`{%Y06T~EG} zgA$o5-+dl~8~PfLWGzgoGr%%`Zww*|?v9bCxS%y@b~_*PT&EjXbiC}44HrlP-g?z) z1>QU>ng&2Ig8{nPfkRu=c%z+ja3Mr%Wx5869;mIj2i_;tMiSbeO?w@g4l zfVX-HB|PxQc*InY`!oG_bd~m)m03ft#BLAPw*3COSd%+*CoCfO*Yx6=(~Qq^RY$HU zE%go>{9U4{a|HPkfPgM*zLag_WT2-A)K~*pahEmK$tT`!JpOXN*sxyhmy3?SUOEn0 zEq>Wr4G}8eX!l~Qu*j`AeSHJlb4k7iQ~ynbJFs5?;!r~}G2g1%pzG6Xcw%35YrhJT z-u7PRGYh)@ZV$fBou`bBDqQlu_Je1Ow!yD*fmxTZ`P^R9#kX7Q_XLD~^Q<_>@}Ty_ zR$yM~l)0&llRkhvYOrz}?T~-|XqTT{IGhzfA#VKxsAJAzpY!@eg^UuxK$FLJ6PUq) z#oS9|X|yr<;oo_Eg2^2}*O+mFk|F4-c_VUqbaeKYeT7kH->;&};1}f{SiRVhu;9GV zAvJqLKlC?SST9FtdVbiae|T?=AIzPLUFo>UE)_B^bH)k=i{Zo+XxO*^7+$BTieL95 znB$$2ji6Je6?%Gj)nqgn{pn}8%E!clZQIbn&SFNX)rzrp+H8z}2q3l$_@W_Iwgdhy zY|C!@b7E;Ze^xX<`tLlWD;joWA}gUGE>!8sZ=IZ>+F1J~*+avG|ygXj_#8M%C=X#h6b?3HkXmQVYYmegABzc)ela*M?U1KzRBLywiFu^>N~< zqs=-KzP2-woYz^Gpfsd;&ub!&~@VmVhME|efZ&w#ve;h`)%%W zh9jCQ=;DkOn93cKjkpFK6T%k6hEdxZ+mIR@X0Kq_BRUKl8`2we3Mv|}oISH}tz~h^ z*6GJv>l_ynLa)5zD700NzA{nSU26VPr6|Q}D>bBT3g9EfCBGHf%&U{wrnrwb~vBxC1&I!A*1)tGD=yisUEV*ym$UHfVG22D{Vm zgnQ`?=-6HdW50E_=Jd0OT_$SH9>Cycc}AFm*26iBIQZy+lZ1e6Bt#q;9H0EO^L1;J z&g%p1#M!jtXPO3Y(?ETpswmrl{sg9{(s(4>PgV*K=p%GKnKOz4=L@iRRC$Q~DTq1l z?;J?xHuy&6P@iF*pvr^N(Ts$RAUxw-`0C1me>h%QBT&%op#6~V)uQ+#o6+&Nxd$Em zExeWP4#Z}=j=blDEnlt9MI!VW%l4HB`tjd#sgD&2X6qB|M@J!wpPBizv(_PK@%QxX z%s{>-?=rV^JPp+T69MjJhp;aD>BMV#G9EScZEVJ_U#RgCzC8ws-@fPB+!12zb(c_( zl$1=w8_s?I9(d|XpdpfDg-XWyTzKq@Ei_Km&MU2^p{{i(fxF5E`l*n_TUli!0fp;2s*x@prPI}C{l{} z7;z_YW^JcX=`^%~);1dggQt(qo14FPe<3)VMeH12Be;Ag41`G2NWUdY8B?pl(G#+E zS1~LB4sES}j=71;n$Bs=b}Tg8Xn>^XM#Y5td@+l!!k1%jenQBPM#mv>$Jt%~ZR{<# zx!~K=QOstRdKOoe-T8b(`AN_#Zi?Elz*of%SjNGVx0)#K53aI%^V~0J;=J(EbPZOi zGskZq+&c7cT?MitdBQGS-{Cv?HYRsK&<5v6MnGWx01JWkU_u1RKo>Jzc-0srePkUw z96*_%^*WU|3>}2jlnA2H#NMC6cj!l_09>BRhaK-|%PM!(AT)KZ88NTrL@y5j`=0(`14iIu|Kp{jM zJ4!JyAhC4J_+Wziw6UEYkmAWp6IeF5`eSTQPEd+EhfKG-g><*mF`{fPAvhf8Goy;I zhL0x{t{D0rw%@BB9 zX~}B4w|2?gDZ^^fM1y;HVmDr;{%3!moiHPwj6q66Pp@i~~MnxQ12~ z=KykohT~b!P--}Bi&glevmkWGqD*`fCf7Y8a><{|kFB?KBH}MZcIMmK{7(2#6}&VOUX*73JCSP>H3svd zalnfR?!Dr;$e^6)BUxHVo4qCzbM~6bbVGd)<^whH#Vavz9Ztr=55Rp?g5pnANLH&X z^s(kx=(qY^H!mL6*W=O^xvqJ3VGBL)(UV#Z#~GH(ZS|6I1605(i&h%Gw)&P|=gb=| zP0qqU{C)%)KyA_XLtKs<=&ME%ImM?^AzcHD*%IqxXG7YA80=~7;k@z(b z@gBkM3HG#wC*df$7coCUyM#GZ>Mf4019K%wEQU6OVfC@rpEW0HeLwY7YKa|1Bwj9=Vbop~2jG+*DSf&(QOz*w$Rc zedD95_1@0ltnS~fgCpWYKLwJ23qVa-br|^?x;Jr&Vtd~A5 z*uGIRqRnFsV%~d+Gj5K-9T{^iW zZj@iXfOPLBS9gqet$dkSgA!;U5p|jASs?Tovt{a5GT5ncB@eDF-7A?;qqCD6Tdi-4 zAd;_bpy-WtS;c<9V$V)D?TR5@-9m$PA;aTR`)%g)F!(*)YcN}FrBoL9_XFo$*443l zSA?yJl@>)@tcNbZSOcO64kW5zD~40+a^?1uGWImi zcgxt0Sq(0uN-L#*v)rna)vUK}*MB-A@hhCYGB`elx`!=XSm;}r4-T7OQCU+EGQIF^ zSJ+r<@WLsBYb!=98W}mk!J!lxdXxOYm1rz^FtU){z=tuPhPvM;U#tg`D`%KTisXts zfw>kq{npC)4YhwWZD*KLxjF}U5u^L~cYKQ775S33)_BrDjE)%meXf&!Q@C6no{;{S z5U>NYF!M>w{pg6YP9L#-c#HkU?sOHzz)3120bx1sy9W`k_&p$?WkF}fn47*pXaSA)DI$y{C zt0nwf!N`|^;o#}1$Wp*#A)gv37J}o2;McAMZVvgH?TWH!qmik9y=F`e`%>!9xGEfLEWu)FF1Fodc6klMDEA#->txn`g~)(74vE*(c@Z7%mFrVXOR5# z>zsTbX8hv&J+i%?A1T#S>&_x@J#cI_X+W6Yj1hI z!vXk9B%ayKoLSM$X-206cew;dv}+I9QH(s1x-wJI;s_Dd!F*u*(=}JqT<$KV;nZlL zs2rMs59qbrtMY9rVzG`;PQK7<2A74>UoQvS_6O*iBcPDnp7Ae|zR?lbt`rlTz5Cw9 zkU3@NZ+9w3eacRbZm2zdH8#}QJ9NK7s`p)-+;=k=SbKQfU5p)|+Wo46Lz*(Fqf36A zy8iLi$x@AN@<)Bf2hYxZlX18_+`RqFwE*qdg4ti`Q)A)t&7%dgOuMaFAL1fm(s2S} zI!NA5uLt-cC>3i>7`DVw>F;$Yajr%}6I z!Xy&3F%#s%seOx$WzeN7&tItUAO|g2d5!Ff@nXphO6T3K5WGWwCBolU+Aor-LV}x+`14<@E2UI`rWO{-b9nR6 z4yzZe#MpEZ2CpH`34o}*LKsLD6$2n2gev6u#4;7Wtnvf>@F7m*CSa5!K!)<5=&*PT zVx5RRD5e=OY|R^c7`S-0iCPk2D3nu+7a9IS7FYy&x;yX(4vWvM98Uv;VJegf@wg67 z=@KgJrnKv&gry^EI9(g11QxNk4T*~DN?WvmqB4OsQ}#qUBa47s0^VglLbVcS1YE=g zv`mNxt$01LLE6KtP{^Om@juS&HcAALyTzDeEGT&l`^u>s>v_cq#0dy1R<*yXB9Bi%%r|Oj?U5|va zt&pwZDIJSnmUKgI;lgG=SjNdk+5ykVWk9_`?oXo5BA4?rZf54RHK`^rq3@UNKw)JL zC!h?eGWDb?x)q-J-@h%IVJ8zcGw^WX| zPSuD1RvkI=!({ajMU9OEj6dsY`3-KoJndHkUKkp}6JTYImI7|^R)O)B)<~8@bO96_ z`*zv0>o0^l_dKm!m{AO{WeEAS}fknK7T+CsT{8ncB4%S0`}| zA_}3}iCgQtSp@dl3HUiDTfs#qelxBhUCqNdK9c;vJ82#g(!7cZWUkQU%_&%cveEI{ za=5gZY!`W~eNaRQOn9z6ih20#zH@Y!*ivWh`Oc5e)%GAd^C>K_6K#z4qXfN<6ev-* zpiq|@xjlJ(Rk}i!Rh0VxU600jAM<}6$Wk7TzfGbyR$6#OQhhha0%2LhpFeQSF(a1f zC4sPP^V;Mrq*;E~aPftmFQnM310^H&T5Og=*P!j7$ zp8|gb$SU!xR_rf&SCSPA75T>#NiG>?s3%rNN7%hcCiZ;SMAlur$h$+<-a8a(&@0+I zSfcH`eXh%b73~nM^{>(La9?G1ua&1-O-O2uY5}MGpU-a>!N=1kJ&XzK_QRr2B9=MDg@_RSD z3M`*olD?wgagJBN_cev@9ZhO+2{#GIQ7Xyn-4LjN?8gp=2r7$z;YN-;vSDK1p;D#^ ziG`3EHy#Vaqr!UWK=BlvwyY=#%G&@q`+B;GV}M40jTO5|NfjI~)*@Xf>PK0cv7li> zI|lIy$r01FO~jR#wVbh?E3GT50>v3-J`XI<@hv+v*LZ60}jCCSi!O92XWkej;K10=e z@P!uS%F>vr$+m$ZJ$|Fh>a6TxmsR3NDA$HN7&uQ{i{2f~Sx*g6*dh+JBZsjI23|+P z!<(NRimQ$*>A5~FqrcEKFs(ZOAL3kR8R2OyBl>isi7O_#fjb84DzyIP?%HAd_Q-hrytDskZglnRh@>bJG{n3-tSk*K6>Pd~=?Y4V#Jy zOMp&nT0CUKH5?3-M%>P4s?P3TPPB=pn{K zw{SFJ-Ra68QoYcZtNTeUf(-dyHC{xu7w^p8vyLBC^1*!E`6k;B<_7jzlrYHBgB1Ds!LnZF+0HN~@NgvBIo_SCS(VpF(Hz=TzRkRz6YCxj51 zHPZ#t(0y9gV~xL0<|5DvH;+V0*0pn~$E*T1-gEiFF_f6`U=hF4 z8Jn96^T{8qnCuGhl%sol{wrJZI;u+R=3B=*k>8_sa?fv8G`_lB#OS-s+b!pAD}4A@ z?Cx4o-$BXuj)H<$^KFKfdPkZl7|UL|B&U`9w()DDt`}qG$-Ux_jLEr-;)2qF&!@a{ zzBYOYIIE&#o?@49hL=syHV+(o-=N5ObyPej4ufdKYI6c!dt z_4__>Cg5<f!0a&_WCSq3Kw z5yg<5B^ zL8Fi7R;onwJas(;1&<;wG3q{i8FGk5Bsd`o|DFV+SYcv=dWh+Nb>6Sbbg^87;VND) zNe%!l5`0B+0hvd**oql5M>)D}#HVPPP(eEsQB3g@7EosP1vy_OM!l3nc)=s`T^?{? zkbEEXKOUqYfDk9dky+Lehh4nl5k72j3nw0?zIbnDGDP8P8RkfaIzAtv52TgDPF_jo zEueeS)X~BHUy^Ut5~mO{@^`u^42ic0em zb|C=1otQwiGDXs{`*xA>(A?L{Eqr|j=G(d8(Pp_;m=QWZGri4Vd~@7^8&OxE<&OUM zMCek64G+3jOlLr4T_UkDM*1e)7DQNFWs@i-atAc!Dx-yI?nC<0JZkSkx^PZ56yS_Gz7pkZjQ%B;q%d}RRg?XiZ(e=YoN z%g%-DGR-s8-{uw1x;GCr$Lf)}$?45F6M_h4iWjQ*7-VVUG( z@(PW`Og(s@hg`Z}?nNZG5nu0Rnsru@7z50#_Ad336enX{c}?RI9w#P3Ibh?6xSUhB z6i}YMINp))`q-sHq;D^-2V^a@bIhHzrX0*CK$sq2>F?O@$n!t2;e5(AIynwD5EH)%C5|!9^)QHqi&nhAkN4 zV-WC+ZvxGgA{sLOc8d$tvOCu9UB@5i>Ztr)pviiN#+G06yTYUSD<&Sy(ieKVzdf3J z!+$@=;97mi@bqd~Df4qIXVGuNfU^Bc_tWrrvL_eNv2hG{ozmIkjQ7F4nT+tdEimg} zc%eGVvG4}+>sg)!naP}mWpTGtw2BJEXDY_po5Rx7*+a!FQtVLnm`nc$MzY+gbdP9> zpA6T3??=A#%HMnQAmHSQ=HDly)mrQ=gt#9cf5mI?XL42{yqht7>aF7Rry1vO)ni*N zdF)?2aSje!K#QS}gM>HXrrpsOqMy9m!)b4p)l%qxM#rc7mK6^S;62J zCuaK?yApPhn(M^yslOD>Sl%;*sQaw}dWMYLD8au+lC*$#{Ib!oIwnMHLyLFb^=PQd zjv6~D+g}^JspaLB&eyZOG2K0?3tw$Uc)Djz=l?9sX)82)W`_>5T~gYAZ^+eBc|5dm z;L1P`z<*cZNd^pi;FbzscapM-i4?bvoq8Ca4(%WVM2m9uPT;m6UcMRg;dJ39qlpMh z0YR!K-DapG8pzOnwR-T=h-DH=V&cEWn_pv*# zo^L4G;Rp?#0M-{;Z56kr4=)V+;Yq)==JP5Ji__nADUj)V9|UCuEc(PfQwTx37$shz z>ICK(i)cwvmDZT|mmYOamo`?~(ZA}w55c#pGsXKR;uE?bKRtzICg>RC6W8o4jWkHB z7tF=cg}APbaX{@Ke;bPwzSd{QK*@430b6)7LKp}H#vc9lN5_?IY)k)T+vAHFb!N?5 zk~ap8OJvY`wH&3=_NvFIjHbQz1jIPc;Q-Q&O7)N<#SCce45rOl)JemGD^KaCg#8Xdj z3EhOJR_pt))qX7PVo@z~n>=y9m7qNFC@*^ zhAHe(#$UetdG^4o`FnZwX}eWXMxO7G_jQ#ucl~FM;+;d7UvpH(cCo({>Q(C7w3X&- z);J+$jg!wE`IOj#1q4&xJuW-hbL`2>HIR+nq=j}{M7&GMbB$17D#FaqIxbzwEOX!_ zbnV%D(R+h*wabpVY;ivuo9Eu!jQuVMUH+oy;rgMf>DhC6%WKuH*XZFRyaAkks!cT+e zCxYv}ofKfKnk!Nh&&3qn>C>p@QD@*oE&=Qe{+fU-^1piERk>0wf>Q~AT?9y52&Q48 z=GU1u@zd+6?wDvyAy5j6lMN-o+j`-++0$BGmKn5%KsZ8pQb#-Qzuu8F1uD6wU~TX# zQ82Ge+MY;NnDqeZ12_n9cgb~m@E?lYLO!cv1%Q%3& z!eOG9A)qrXmzBi9GCfM8{G5`fKEN9^NE^~^w?j31NT}6l7H}66S5rrQP-?KCB7yX# z!;lHo34UHf7auJrII%IFNWF!jm&t4EPXtmvR1?1h3U3_I8bD;?xDWIT$O@UZL?a~J z#uFpRr(H$%SL6%;wDTjGjj~jzP2jl<^ReaM%XbXc1j_#p9S84#2F6TDVyMc9{HI`@ zPTgrC>Y+%IBZe;ls~5mZKvGEUgER5@nOl&sbf_kfnAKdx zd-0ki*2pYHN*C$M5Zpj;D@p*5$Qo5mAa5Ba{zbeqDd|XUMQA9%T@q>r^!tJEuG5Bw#q~>G4U~cD;rqsOi{53jaV#F;P_DHO*QrKe+AS z5+KD}mL_VbGvF^FEj#R}>|5ONV%X2-woC<-^8yRT!ty6ogw3{VlNHEVt+)-2H$n zvD~Tglw51GaTv)k^3LIHoKon9c!xeyXjEd4;TS z2cfJ?z>MC-vRvC$MA4+}thahTd;j!$jLI(MIfzt4^XIbWal8b0>uVh~%;d&KA+}uv z9gI3F&e8XU#HrOg_eg@2rY#q~j>&ePT#NxLqJT}P5nF2EV9a#=gZpNc#%l?%IM(rR zUw7?F+Yb|Z52y2v;H@iuUbf~5@IjlBXmdahqpi20X8k|REMr>|GXIFDc;}yN^J@&{ z1`YM-`XM+kmiQ^WSif!Wq;eAC>hCEJN3~$@x!{L1sQW*^c6m2g{WJ2mTDkvkO(Zh? zepR;K91tH$a%d%d2p^^kgq@2?q7&tCMx3cgqC@I}kIh}*UpjiE{EefO#CV0e4J-_Z zD}tyU7y{(@-bv=y(@|r*PY* z+wk0Yd`S6L3kb0nqDU=Tf_>DL2z4qL9t`u|`C~6j_7Riz!4eRelUQo8w-#64H zGdMdqDCo3ASwoxe=Z&4tTL zhR%uW?eTQ_yw;w*^ry~7L_-kN?yG4d&ooTNwC=Seu1(39G|mq1-{iEf$#>=oqrd$W zW3+kxOL(@yjPtDSaK+UAVds#vHC162udx3wfBfOP=?1S^4N{7;s!c9j4ACU+pmk}g% zeLN0Za2<$`{E&0{P6+~FFsAr_ny8_DsF+mPTpcG5gAAKLHx29=f`M|cG zw{7po)C`^yZ-Q)nA3Qua+0=Qo_o#k=c4y-1hna`Nev0cImVdQj`e)^D<=K4vC*(_c z^Vr$ukPBt^F87}uyTjr1xwGY3PokAACmK9R-=O$H*P=_2R*Q3a7$l7imDkNq5M0VY%P z)_w-{V#QqGVUdiWOk4DI$BS$&jbg15YK-`jmbhXPhsD>Ot;Eoc0s%glKmPPxDuMFR zrp-ptrFVDr=Z;owk+e-SGt)RYA{_oL{L8Jd;eyc}1~V-l7Wxahi~*@#{WBXs4mlKF z;9D|=t}RZHUb6{I$?Yt^Z1+Ju38=KC`-De}V;_+WRH4`;^lc>kbsIhC^1#DeWS>0; z?=Fj|f_uuCAq1^k5Ism&SMsN$KLKrjct<;b^ObH zP3-?&)e(H%wzT5OGxJXmHs0Ld|HL`shUpgord*M8O09$R7Co7jEB{nfV-=FJzO_=5 zQ~uVf+2=$t-WlF{@mKePb~xYX>D+|@pT->{L+z`PJ!rE3kZ{LGbOLDVC#di=#<~>! zqJr@-*v)3_%N>rir>D9WzRTX4nVkBhx^F6Up+@qG-U3U`!nR7fpzwg7y!zM=zCFN7 zIDf(HxNKKp%yK(EKk$u{UqOSb*A%Kgy8=U~=TrihJo$F=<*XyBXYnvfTLGOp7MQ;1MNG&d2cZ3RJZ-SVuLGPq-s5 z&NX_Q&$13)`37$UB`@jFJ2esa`-wYyOzf338fI_RKS0OUHVOVi<-m?FU%r5hA*bW` zKJR?6>kRa2n>@M$u@yYMC&AnhK0Qp}T2rs54ehxOnxTx<#&t$G7uYi@ep)redEnr8 z_`Fwez^%4}ZcCz3N(@CNLbqVH6_Ht*?K2n{cw8+O73>X_idYo363FkjUn}~Z^$kz( za@Ot-mXm&bch^k~a3{#wnOtu6E)ACoe0>)5@hOT4jrQXF>6&N>IkZA9R0wIKS`Fde z$&%iNQ6%y+Yu2O{Jp-PEA?al@ZRNMRRz}wx@G=Dc;YU}vgQ>PjDUxBKS)EvxYq^16}hT}r7h(` zWGNF=5d;jEc`%HiaQE{QM@fJtfL|rZYFT6ND zxEKgb2yqfCMQ|0ER`N=?z7vubf9Rq<)4B*^05TE+;AFT0+?lNNB4nRD2dq{OavKL8 zBD4q!Q6O|tMpKvyo2{#;0tLX&Y5!xFDOf{IMmh@t`EhiB%9#M}hyEu$e4z!^dqEB) z8cb9VybTRvV>~gNH^TdnFB*-HO92Q38_J|anJNtj3he&zP1mdhIOJFg51yM}9x|En zQ^fI}f*b*&8jAQzF@$5e5@PH)l3k=|gl2x4A|WKhDq2GO%lC4m0IVH@jtH>T_|T*X z;3goKLNrInH~^ey%6Eb@nE?a@6<-AW#T8v7ubB=*9GK_51lpK9w1rNS)```IGbYha z5>lTWIe3?YB}<>I9c z%Sa9=xC9}oGwRGFE%ZBHX%(Cmz;vjYkUK9`YN?b{@)0>3c>RzY3lGA~nRHbE=V_d% z7dE>YM?6-q$3PpgV{#r45E9maUE`mP_HI##| z(P3FbBkU@v`E()XhM47*6{kNENRShNJ3Ny(HWAbhrMw`MS%P;W)$$8wQrnoSFZ3X6 zncDd^?FcQ1C9w*NhxflPfe zD+;G;GAjH`L?*J|d&})Z{!5ENL*+DMmaAg=En+bpGfVGo{5^bOzyUZ!p4m<>8 z7X$Q0n7-X-pyNhIvdLi(1K!flWOl2raJw`;FFCcOV&q#T{bZnxw`RdQ@lD(OVuDy^y5TkHxq)R=4M{8Y>-Ee{oRIHVJ}X%-PnyzDF^)T*P=((6HI zyAGY&GsI~TvI{UBLU6*=D&EPL^c|&yyA8OlK}5Xx6Jo1IX7oFlukT#4g$Rw!6P*Zs zAwy52+pU@rGmr3lX2KfUN$+Q;YU3T+!H_W1)btu;&EJ+zCcVcBCYz4GBl2SoMQHPl>%ttBzrMPk@#zZ=G$*HHH0Iv_!J87B|5 z&dLxYYM9=^Obps7s101@&$_r=BP8kRzlh)@yO1O=BG5C=Z$Oh#e#Ps4fwBff?9fjN z!EDWXqN;4Y37CiC*Ky@7k8oTiG)F)VKk*yhJ6!keqP}&!&X32ZA5yCI|6a5Gma(T$ zu|Ow>=@ye4O+z!@1 zFurbpl?};Pazc87Sd`z-6g$wWg7DTS9%})FY5~pUv+NYHp5(UuUVdE(8`AdM#nSX* z#@EyPb^S3UYVLM#85`-3pTrhIK~xjP8hfIy8c3-0yB$3*PIbSmoIgBk8#dIvAgs6WJiT6{di_;Srz}azYv?y2uhnek zLF)(gjWy4;H}F>|sox}Jl_@t-5&0T&Of!v{68|STV#n;1Ee6IaM<2kHb$s$yZuc)) zr=P0R{dXqY+ZESlq}Lwb7eR59Metn#Cw@kD7&tStwaq{$paLCo@8h07H+G!Z@92rr zY+Kd+#vxry24L{nO9iZ_8AaTu34X=Ef zc^tKAkp51Ie{gwfkH(4ry!*Yv<_pI945rhk+nr~XBquZ*yt0KSc>;4nYa0fYPsjsl zL%VSeQAYMk0}V@GAplTXVQ+zQ?(&jB?soJL;;axOz`#gG|1A_XE-jbjG^8GA-iM8@&d{Ehmn+=lG#@}3tz#>RKm=ltng zP#-w#M6;#8T0Iz?`$4#rKKJB-bD(9u$=b$1O+;lRcWIL}KQv>8Jcl+C1KprwfXX|~ zGbN&0I-z)OO5dednOQ$b=MSGBnBoX;Hh7jZpBx@H_-%RM*4*Of8bmqn=O}{U4j!V> zz6P~VGYAh>GUTe>J=L=rtuHV0kiNN#LGyG*U-Lq)cGl-qALr8p3LIP`rut z=#OQJ)FipSG9VpTinJh-Kb!P}}G|cep z7W{E4?eFm%5P4vD`O>^K6iemXQ0+FDt;AlCPUp*!HHywOL#vt50=jQ5Mla?^-;&C((_qlbQ<*89m;*}Er| zw>ma!LVQsxwve+r;NGc%=|;WguBW1`203GN+%(%C!HLTTiG&|@%;cqSza{x*My#!K zEK);Utbu{@{g=TtTuq5wiy0?31~};pedl;F!@38_mFg!BE}ICA5y9PggM(i0{nWiu z!eP6QfZ*PVEhRr2{c$=U8+0#;@K6CBqQhQ6@OT);2krvwAB^q& z+Y1`KW1qvN1sWk;RKRwtWaP=Iamg!AqL0tL+IQz`(Ss$(ec}I=K0llDMRj&ICs#=^ zQvZabvOU|&6Tb16xrcqj1>4iAzv;SUUaHOkt8A=)!hR$+5h_$Jhj~bs_ zJNAox-QmZrRn2p%3o;wz4p;nMTQMDfIKiECTbd(VsiT)(JXn98Dr%upQVPD6I-2Bn zh&p-?VNC>ugO$}xwN_GOAy)8y1lzyHx~wwF`sOnD2>mg`X3Iwre$aOGi!?y-ou1R|U>w zeh%~>FSAu(kPgL25mDc4O#(EPu7O9Vo@RfpVVMV}4~?1P9VaGFn}kDX^GvLXC;#6s z3p`AiTpSpAS^?fxGASd|P*0tbO`i4O>+Zs~WK*Rw&&(G9h%;^da6xKkuaO>gB78H> z$EMvkp5q&8CR#yPjW@*`s;&h7-AHwHD#Ew9A^Qoa^Ss@Y^cKYgXdZVnoO=8 zUz5~9izOH?fH2LJIBs$lkx*W$w1e+h64mKGu1joBZlecOIVmO6cZf?69M|zm0h}lc z2QKf+G(XEu2j3-JQWPmulabZ^^r>*g-Hn>^Hpcz5rN%0>Qm#f}j#&8?F60T57z1r` z9(*5|urmLExUaGXTc(=y@O4 zUz;Y#k}0ooo>O$Jk#5ey;BBuKv08KS^3>6IPzZNkU-qwG=v zi_O-df@>q@o|&rAM^HUa9L#UjSIC#<&Z!JO|B9CL=lTL$<;>*l%d%3drohRN(HDE}8mB#%@1B*@ z&k+lF2f4bj97^0H2Z^1b91vLOfQ3j&ev-QeLTv(VbhD;tmER7>{4~k&v@@?sWZ6|R z#%1w7Kf+X!;|A@tN!!g>c_mmJPEe&s&e(C&q*w&jBnug{(rK3a-S7)DW5a9{&TJ9| zu?I?;$+DWLOa`a}H;QXY za&u`|-?~vFqIBgwU^*ONO*LZOnt-)pc?XpOJ&&gwR|ge!H}3qrM!4^2BujtMm5Q|5 zvHXTSPWr!y50jzojC}c0n*jB@#wJplTw~yuUhSj@-2XuQ7Dkv?^nnPQ5;K5)0~UE0 zJnF8L!Jwj-SDx*5F?dKo4qYz+Umv%>i{wk{T|sv~?9t&n2%3+)tu**%v>#Ve-+JK~ zOLQ+p`Q2&MErRm?g_dbm9gTV+>>D7xt~@(zptaX@m*r+@q3-R|xsCQ`09v#%^7$J) zZR{iWH(A&U2#)KL%0vHq`2)Yynl|Z!vbg0AvcB}cqGX&J``lmRHml7$(awb;zG8#(9BOGDf z;Wb;jr_}YQ$77~uk8U4sx&iaJFUS5KgF(g_hyINBAMrcaICURmv!nM9G~OkKU|&klWm(qI7f+=BT0tm^#!6Wc20-Meq3*DOEKJ9<=V|L~gOQm4R!Yuk{> znqBB8KLk!8QEHj(_nE5BPA*>&7}LOh-%K zI96-D^e5&s-|Hd5M&F=oIA|$_UBnuUn?+IrF>c#}~%Ebkk4OZt&k(a`NVZ z&ED(Mvkp|fg$PD{z~cf^egmvEI0lZoO(2!H5#H%{Bwj`wl$!a9PqM%e_v7JCTUa4+V~FnSWpq`?BX}pO^XBn3#=SA@gjv0%W9h9xcnGaIHMaxQ$zjq{qLZ3V@;6-QyYe22a(a~R>pZ`` z1serPI1rWxSYpBbvsmM((2^!cJ zHf@!Ixoe)$iJyBgx6jTZJ#tya6rbIw3qzPUuWJ1zz$yi7KX)WH-xuvt%!uIAL5g?m z#EKkiY4T>^>#@IWWk~9o2*L_Q0dNIm zk!u--b^@kGB4XEt$(DF>tnYys{>F<37qiun`A^piV;itp) zminCSd*IwR<~%Jse07JjbLupN-2{-^w zqeS|@3a6EMgyuJdR}xl0$Xtbm`G5^olqk~))HTONSj4*?*}BQHpf+L4?~)01cVdmt zA}TcRMTK#Onua%cWwn(ktR1Y}thN)g$p}CKCH6*>*twI1*oPO-2N9&bV5hA}@~R3< zFH^=rlj@)--kuZ)Q(Z{~tTv6B5rEhks!)VKMa4wmW{@QFX^+*LNrW=7znQGo6J}^M z*X&}xEve=-rA8r9B4@N8Mwn;GC8$U$Q;zrxk0s%iH8U&CDM9H|YRtEq3M2PnVoeOu zzBJC7)z;}HJJe{7S)Imb2MIL`^p3qbBNN@<@uySg(i-aBy|#m>8)bNgZFp1x*i#&bb=#Lo3;iw(*~~Jln$8 z$FRskndl;K3Z2Bu@1O-SA^lI)H8sHMn)@H(`*aG0Eqsen4qj=tP)x-hY_ffGg50uP z5i21o}kbCkqW4)1b>X!>t6Di@l zHCh4IuRh)`hQW*XTVN!8jLRm1#>7$J2zwCIVj`juPraF(d)rN1dG|RMwGEN#Sjn$J zjp?zOe9HHR$}^QDT(QrxSbI+!(4+$M(Wy zu875vLDkO4+NqRO=o0t!y;iMGLQNe(&kkxN{g@c!5i;+;k0?{fG}gUVto4GJLw@+= zZNSm4RWlVb7_XvZ*MUw7(VJB`1LvSjhs@td+EUTsjib!zzLT;n$a<0DQdZ1WyI_&H zeR+WOze*tF3rKfHZNTFOxS2*&6Oh-u6l2}~Xqmvv?<(biO&!?0AHwq!rZ_^4bgRsr z+@@9j5)N`K|NS7g`tFs#iZ8C28tUloAC&AK+x}X0ZZ@Z1_~-19mkAZ>Zo)U~NTBl%|tJJbdTdHOGB zfKC-@n^cZ?{M2AHXpUv0ZkG6d;fr$}v+?1B-8BZYHuIZ9W;6AB7iK>?e>p#&_Ab(c zg-e!`W9B>fwv!CX5~HTuN-oT>2B zkGkhCU#gN1|JW5 z7Yog;Z})vH1Y{#Chb~NDYjw;RR(nRw^56j$29-mvQQzsD0r{Veu6PdH!-ShvH1YFmo zzX+KbUAEAfGb=fjyfB#4J6?RM_o}U=mbGNJe-_85$elJJ^Zh&|MqwgqN9SXGrvv%M zasekw{5BVp9GxGi{Jac(iDpZ4fXbG;{$PGvGQ@IUL-aLkiNX)JA5>&WmGTqkuCqFN z>EkLu@Ub5zC;ZO|=Y-8xhn!qEK3~&3%QZi+FmF?odhx)Mfs-rsK8H3+AOOV;a0x*1 zpyK;g;MJ-B^1Sqi_Pp_eUsFD+a)hQ@_KlwWGJO3MCSZDo-|N&jHmnF4RGkl=KN~(D z_)Bs=I~*q1yTa%f^`}<>S@Z_b3u@i#V zMdyDIH^&uW(>!5cUxq6O>yN!C4tVl(K!@+%%cd=op~flE_J!OS);$W;C~~1F;2;Dg zB(RI55n0|YPSW_Xgikv18_I5;KkBxM=8;xuJGsxxN=NP_uD;^H;@JvJenQ#>RH8Z7 z+Qc>X{~$@CV3(th2%2>!W%iDJI(*P<)FKJ!GoZF*0v*dwK1hsw2ZQ|iRqZjQH^KtZl|fBlJN7Ej+C~=qG2KGni(ko_SjNQ!`n|L0M@)j zGY+r$%kch%Xt5;^$$*|Zif<3(zL|{D-N@bYA1Ve`I}9~=$AkATL2lnk(pYfKRA(n~ zue$QYD45Vp2+x2>P8WLn6VT8mlxDgBmD_G&RL9Y0??o8MB%o^~B@N?wOBn1>&TK~ZI)&vZ zZdn#oq(rX>h-2p(7xAyd(Q{~bOV$bJ!9f{X&-1VRC2j->iM>k^?Wupf_i+dJe;W;E zi*2S}^_2<$J~v;wbDh*=I4ZfR?y0jSztS2;uykQxdCDPRsWP= zrT<|vm<@s)ULg1?8y&tt+2+Ve3kX)^f_IIy%J23qBESv8=R>{;QxpPl3O(x2z?%bD zjETIQN9LHU?lWLcj6*`~K_Hnv&@7=Of!@^+vT>da&I5r$wCQ1pFlF2GCRhLYp1KodEIv?vay%-TO2Ijaj>a@(Eo_AX-Ks zEs*ZjtIYhOSR+A`6E2;xpD^iN&A_XLuieCr$pC1Zil~i8#b#Vl6PIk-9S0XUMWyr7OH zt)&`Wdci_24k2<)g;XKpCZc=yOz6Jl0OFa}nWTCNmV2f}4p#bq@8+hi+D6W_S~KCq z-HKjs#lp_Sx-1>!{gZ<0tw8+IZj--gnU^J$U0g(o=ckk6sZN>+r`3#MNeP+BV^XY; z;{!a#8%{YjYL2R)CaL8dtzq6h+uTH@3h~iS52pCYAC{o@;FKa&8NQb|j18FO+6)t! zpTe6Ntun@C#61B;iuzVcT8)ZEGgWl--Vdq>Ggb`pZHi_osskq|*L@$D_s%4Pk2V=e z?b-cHh4f(XJ*o320k&}$F_A5UAmOd3I?#A9#jZx$Ip{bL80>}v6SW92z^y?ro@_Ek%2d)YTVK=YUL0o=%!v9Ks>Io(1yKG{Pd$S^QH3pTAR5j4yTWdlLBfNR+{Zdq@ddT`P^>D}eKpBlLT@<598^ z{N9eRp_k#2M+2K5KAt$^*go_1*gU7j-U5z}k_gj0BaPjjHqwzlj7$=$QLM*N``4F9 z)m#%fbA+>_?Iwk^8EU1{o~dlY419{Q5G_d5L(cZBpC>fJ6i^jy{m1@J)ZOg?YUnFG z8I|Zq4uO7>;f52hOaJ9YeFDpn=l%stnmq=RrZ42%Ulz6yOQfSK_rRSir3Xu*GCF|n zyufTpMW&uduiGWmTu#PnW1@jOw?#k-d7^OW3b@C%QVz8Cb$L6ffAK@Zm&-;H$eE=U zw?#zT7bnP70uVD<=OX@G$HdxIoQLJANKyv5e=LIdECuacn=F(B**Z?cbxc2pK1b1{ zYeAO&7T$ZP^lHW0JUuiBGvhz5yeDrQdY>QVGBi7GsF#*_qy7FBAeM>+sO{!Qw2O}( z?Xn)%C4E@f2>7;G%LCrV^#QlVgAc}?dh0~5&IwB%Y9cm+3w^KPiypJm{R#^$ksG|V zFB~KLFWyQB6=&=%=Tqyy;s$-adC0k40$W%yhU&H@t0j6T9 z;Be#Ai?%IOAD{-Hsg3OrI?wzKca1%9{KLoG$g4m`+2(<++=HRRH*i9uLa1T^{?Dpb zjBLR}GiOieUlrMU0m>MQaoqzyaV1sIlvQW1-(}B@qQs6Hy7HlPM44-B^VFz?F6RPR zs>?R~a0iP_+R;I|GMVLylV?2MAS2nC_2Y0@*64J>gYd!h>Ar;@jK0jAuH@E7XZXW< zhA+%K6%O93^MPGankA);oufg_B7b>@19+dj4&;`%7w}e34@Wc<2TUGmwjr}k1@rg9 zS9gX#ua5K!*M}3=u|KjrN`X;2c22B^Zj*C{0kd+L5Y_}R}EPMhEjH1Pr-h6 zy0p@u&q~^bvEo17$7>Et$#u`JHF#z4r+HT9vcaY?pTl9P)f}Nt?M=8?AD(;VghE>b z@#%#=L!n|Mw#O0kUjNX8kWnq7#|dCXIKyp!>Cer~Q{u!5nU?RBqs*TlK@qQrgLpS0|K?#SoouLA*LGc~T8G$xj;)}Qg|uCZAW);GDp9yZ1w zilM|ub3a2CUgijF9xop~668XAe(hN(ZWhY9$Rcg&$a<~8`xHPq5w!#oFuT;k9EB}h zcVtG{)MaS`##Y9LN0zrm-7(Z?vy#i8VsXjEknz+9EcR&2QUk!{@75aLk5(QUJh3jX ztf=Z1z1elO$de=CqsVZtATQUkc_c4Vs;&8Vnk8 zz$lx}9r(skIjUWlUYor-KeguaslFgR4Kq+}X7C+p*>6BFp)2*`OOA>1mOsz52A|x& zNc8+L;oP0ZG}QS)+)D1NNTj9Sx+M>3a~vSw%U%4J*R8m}OgggK-`xOiJ}-Ypv<&h% z>xMjUT~k@>JUbRnH<*{49k@Gc9u(ztV6E<&HKpUVZ5&(kFi;z-zH0wIu~XGc`C?Kl za{M_rqH;*X5aCe$mm@vLbp1nqo165!b?}K8qv@1?Ne>?mAIORTZcj8_h*T7Ahg?eeRB7muvEPL%**Dmj(%88OIWl+@To(*qSmD&*DD#4 z*Vh&V&3@v4+hA#ClzV?QKW~)6`=5r6ZE`b65vnswF{Z&Oajh~qK@UPla%C8;g?LWm z^_N1Na-i-n%QWrgMk=XQc@>igtYq0X-x6TpN>7vdDBiQy+xQ9l;ut~6(%BR z_Ap7-KFNU5BxaU8h4siWseBNhY+oa~pn0Sf8$!Plo6Dax9d6bN7DKz5X4r$OK zvWsAaJKkQnO$9gmRz(s7yT5Ha0eQN!XnvhDFwS$bG$%!h#?y-Mcui_q9J(oB6~?C>E*F ztAyA%^^(TI;LHn`ubC!$#2)%$Z^RNi6c0V>M3+MvrgmE(Z2Cy!6v7V;(HO+(B&psi z`o)ubteDy-XI6w>uk|lm?#c|#$9q@Fio(`5-cHnGiJ4Y|UELWi^iFEU9-~aL$CT6# zGy-(8x{m5d965Z~_47JCm8xBm7iQaY<{8r;wZkKFIdv8P8JB>L1hoY&DVcBr^}a?j zXzUNI`sg#QTg}eb?QS>Jo@{G#A4#>*zglc|fjxBKr#6}}AG@bo$Gqm>=eQV@yFL`o z37K6U3CR_8?5s;tO^TQW>vOGS>Tr91HvR*AM#oeofD)yYTP{;I!$N2hNC*viP@`4= z`*3UdY~Cx2{}6r(G64OgQm^v_Tnbig0_MYl;(D)U+tKBpAG~Hhw_hn}mqxp>kgBX_ zk`X-jlnIecfLfO-9Syp4yt`E_8385yE>*jIs8eu7RD+E$le1U#lM{b*$&ScW1Nbp8tNe;nh&*7 zTPaf${}>OJRNL4ftbvH~F&#-{wK0`FtL<2&;ADOE302;@)!Cy&R zAY#dA<7u1$a@06@ay5ixYCPf^HP7E!pG?JTtq7Q}N9!xzPmb>OYN&rB4$NY&tS-A? zYCTHMI4UJ$sLF#4KH0u9LR4S%ZwJh!Uq$-KLFB)Duk5a>f5;U`*vnSAW}-(agBpMb zlh?xC{2v!_b85tE&E?>a{p3v5Xo2lS-?Lwicer_p#}FxpB1u{H>!=aAH1aQ+RDD!xXDjla6j;%RVJ8X zhraKq5~Qrsh$q7rHHahOPLHhF%0>M4YfNwqT=IkY3JYQ1B&O^|(sMlu9FgqzucB2fsZa&Gaow zMpp)B{m=uqkdy?_P2qf^fBiu&J^oH)TFEr!WW}?I$#_mtW&L1J4&?5plo2(~GG6lV zkY^$*PnW{vMf-{TxV-YRVEJM`-3W;lj7S7Mmy6xmXKCu|uwi;7tCX^<7 zgYe0`cR%&~y>A4}^X75HeiA0k?h%$6(5u@E_ZfH}w`Xm#&eT;Yk^bGqxzls{`~ zTy1e=WwWKxYK!t1@14U57qNizV1`pqYHRW1t=S?*4&wTbvU++H_o24FymiT!;~AX$ z-#E(dCK7V#V7f_iNW>-k7$~{y_=V1eNbgI+q6P^?0_?{0hJOdWK^T@BfqWP^?PtNXRD*@(E-`AbPc6BV5u1WI{N2l$RIrhA^aq8IfO;c5`UM>B=mexip+r1q1?k&h*@?=_q za?ckrIO$D`jyA-ku*K@19XO1KiqfzrxaGM+V#G*AxB`J2D?tZRasCLY_&vG-Y4e!y zJdq=KT#T5bJ$pzdolYphK95en!2vJ2cqr{L+!k#*{5wAm_5=pEc2h(kS_wsT_fiNt zcm%K=!c!+3j%#F+b{CSjgmC<8ZXxF>TM6|~gg#mpkFf<0ii*|V4>!E5;gFL_2Vg{h7Oo z8|z;^8|i!7|8!;PZ0()4%YyLxiE-HS?A~CoJ5Rpt&hv-!yFOhgoyj>JFH$--dS_mZ z^*y6ca%L%OL-?2KA<_L$4F9UM4jkcbGwd(11)}lSX>RZ89*zAqjL`yHWbxm3x`i(t zxOxR@;3xgUkqzBvZg5VNdY&?Sf9u~9^F?KYUL(%~hll%I{Tc@ATvBJ5;nw0>-2SI& zw@)cv#|$y*1X?vgl^)s|wI)}qVV$kKOh8B|Asfnp@X4CN*4#D8ntl^kI=nWy_uIqg zt;R7YvETu&_2u~LYQut+DE$tUS4Zdmd#B1q;l=jHDdR43k9VamD_eLnZxt}%wXp8o zfQ02;&hO_8TuW#FF)L1O(0m|%YdgFEv#Cp6Bf8!gRgjfNL*D#fJi1GQYm?*!8{nk{c3*@bjorj7pH;;xd{w>*iJzz$3ditNGMLwGA z@X2#S#wLkbI|#noz_}lxq#rJC*~o3qe;Re#KzqjMKx;sZ?R1}urr+270ezbnB+6JT z7lr*gTLXn97hbOHcKR|oP(RuoYyYxb_d$D=*wPia1m!mp^z|mvcj!@Suv2n^9sXS~ zXFCU04wz_yqwR=`MO5k~!-8Sa+s`=fsL3Jm6QNf(Y}cNb2jIgdN?cXt`C;B1H}ac4 zd>S40$-oat&Kx#v#or@OVUV5#vJb&utiTOx-U=ZRT*!54=a0tH8-q3~m2VN!lO!pj zRD!QzZ1c|d&Qyc_=OnkSsnB}m!Ci?HwGLGneMRMit*btynx4LQem$tjSEI4oYvYmh zfJvm2PVyL)2?jkM8e*|jF*&QkRR;@^@5#q|`bWJ(C9uA}Cx+|JhKM^190(T=H!d))9}alF%F zEDtw&hgEQN!1G8=KB!=-jJ0q#F+y|Oe=<8kAN3gul02vrxwhE!dwk%rD~3%D?walU(bks^XhTpv;d%4k0=Mg;FaE|c#S7Sx;53XU;`ffRIRn5{M>6eb(T$%E zNs0Ik_?WuJ2zG@pu%n04N)t?ysX$j#_%*zow{;w809cpr!Fawck1U%^Rs%^q#Qv&Q zI~&e1fa>eDF!+Y24giuML!d<aDa^_wFZqT?)jB?zo7MaBs)H3 z-=(*|iULwIB)NH-{Z|$M=GW2y*nX+tQGVd`fbe1~p|mz(OZa6Oe)7qXqT!hGp_Bli zREzVL-|DkkKSKO0asM+SP67B?Tuh^$W5<7R^(Yj(@Z&3bJyl7%Y;8A8WllMgl^S&b zN!B3KN~8r~RPqXcGLtz7>__k<;q#MRoX@Nxg%a>BgR(hFIf6%)VW+FPClZ=0+?;l* z{c=}Xe*K_n3ug~AikeEJlKnWa*N6omQF>c4(JWsJ=K_W76^+MU66}PvNOCN*OY0g}s0j9ss}2aeTO{SrrFJ znZT|Ho^z0g0Q*b5s2fcgHxR1!w<1EHFbzu@b7W=(bB3V!A(X-i5Z66Ae0#5$Tq}a9VFQ); zjp+>h^sC<)oIOX&6Ov8hbbHpjsK^I1N=`kuWwT+p$R>D6XNy7cp<7QLL<}+~{`RZ&!Ira6$Kav=b%)3^M#@+eHelFdx@jU2UI72`aM(=oWsgO$ z`yW-L!Gf}ov@=5AdWkV~<8Vk%g}5AK`Jt=F%b^rxchYL@C}F-;Jl#Xsg5KRGYQ`jF9E&8jJ@YJcQ9*EQfL+pe2?Y_u>pFeLu%*CwM#Llq||-o165RW2WD zU9}39EeWf9sVVgh7rpI#y?Y1CW}d{HclvN_wC~(yn-9Jg-{5Ypg`c~@sa~|0V#Hsmt~>Dy!`e%gdjaYwhQR{Ed(DXH;zc)rsC$L*V3Nnx2@>p$*vQ{Xuo^G1C zn%I$}nq=ED({M-#5=le*W;O8Bw+hRXkO8f$EdYN8qbO3Ey;NmJ@HF@$0kGHc%KD`D z=qs4>@29_34H9#JRC*)3_mLLyavh@DP*#ay#}|X7d#n5&r$|Se!C+3uCP<76tu9Al zoC{+iP~#}a#R_uSoP02dSzn=v(&qNCXo19^vxny;14kRj=K^|%`)n6qv37iUf46u% zvD)xT{8{z!r#S>R6c%jcR*8qB?RLI~O8MLAA>F-A%fiEDwCs1x41GVWJv%KNFnedw zWpPn*j+9buNl+N_*~p|Pn;>gTdCbxKzWNo`Qu8`idk)L z{P&zG-<3!$u@{fr!m8F7e|fUl(Af|@-yzwZlRn)taQMN=GpEc*=pw{s-hNP4d#Ze5 zav&4{yg%6}s#J}^WZG(t zaYi!If8>NlTz^_q*ZhpQs#R4=)$nw8XBE697mro#|H7?36RJ7f+cDkyk0Mt1zqULm z)W?I|DZX1TXVo5)4}_+*r%0!T22Y^p5X0fQipN1#DspBM_pfC{7aHKXVVsA(1uYAy&$0t{>UvBfQ(_hLMa=&5I<{KgiDD-HZ zz=QJegDCvv3X2Z^i?R1?`%X9_5uY z^UYTeIM8776YaFiD?aRXa{A|uVV&GGV_qY0qVwz9%^BtoHz=yx9TJ#$@y=KJ6(#J) z)oQzcD*n(YVoKXcLc{kHYs!1POb`+!(=xpMCWT&jt!s(8b2>1^xL~&aNdRVFpDebS zjjd$uV*Q5lr*2>)#Ns05MM@+~nl^5t;;@|9U%l}m=(Xq%(`WRpkJE>E>t4lX%ulgs zfxVs64t`%w&uIKT>d^6H=|+BL#Iu4^hWjuLwD0PXO>z<(ac6)a1!S0lg2&s)1U(k6 zov;`;sK#9WU4AC^1!;~Z2(y=N6$6)V+z2R zoDS8Hp%XFv)OOGl3r#RvM2Bj(^EWN1csjgJpLtVkbCbBj+&7v(5 zouvf7`D_ug^lpbdk4P;tAM$Hq3D_;oQMHAHKO;uZ|BeXBd(s?yvENv#cr77cM0gn> zwx2!R;ZyV=v1OB^{`)Iyvfe&Z+VFa2zVfk>_$veZ6NiX%jU3XX6HT`MV*}Zi0sZgI z8s^qnsAsU`N~eeM36U6ciZPcX~-rU)80ci;G4W9!MhQN+!ned2#2pM*1cXOJuero>BRStjUpJ z;!~%7*MGluNBnv|pp~!R2k3AjF2Ah2>_TuHhEy{583)1rElZcO9lTYGK4SVdmF!%1 z=EA2?;V(TVX6Phz1Wx8$CV%vCJ(Soz@@j_xMnk4AlQFcGN#6)XH=D$m#k#&Lf0#JrQ z#wd*X6q4X6Crz?AAENOBkWA1dunM83oeO^HZU|BR5DfP5wNMUtmsPHgCx{n=s|ra# zLhKPe{Bfz*AKW}x|0I}~fN5y|gbD*haN?&&VMJO(_y+`h;P*i4$}m^rnTK6uERNkd zj&Sr)0f3{Y61o*iW0RtpO-%5?TVpvXo7e363(PCwe*Xhegd!z92Ed_V_7UKy{-gbW z9a|C+H>v0! zKV9@qdaMgp06gxgePM#bx{c4+6fHK1!Qw%Bib^|@TXrsqm zm15|}InFk!LSaD*apzI4^}CMbABPlQ%v8$4lSB5u-p8k!&T>&VSZ@a9uTaQ}hr$bn zViYnKta;#5%m1_hMmP;x+!=6##z#q!26DL`7*Ka)F#ZTvC;zY32Wg_LL_wtmzdxSk zz?N`NH1<2qK5L_%J%qMrYkp8-G1!`aanEYd_Q{H|CPhi?-iE4?pCQyiXByW*uA{*2 zr{G0L6-h4uy%_@Wiz&k{4~fjZlOLUq`o!t&Ns6QOC3}wG-G22Zr26(iflkJp*%hPY zFVzQKCbPP_vAc&t7625(}B6&$sh9?NhApD2V1C9ij1PulSpITKxQYp}6 z<24_#M=+F=z0mM!1O8kd`jqdZk)^`hkz7^^OO;g-&ANY3ynoM^Tj+zyrC#iBmYm(0 zF|1MeFd$VXy?4KldSa&0 zFYzFpJ2kA^QA?s0<9R55Zov%;TsbYCclRoOw_6rGXwv2+a(;@BxI_VuPJLyxi&vIG zlNCGwJ!b-whp(JvDE=wz+p`~2M_G%Dt>-^ajYlf?+02E#FS)lZf4$u;L5GlKQB}sf zd-QU6xK@=vTIYSxjLx|-a)PcD{8)tcO;76Ipv~L{TlU%f*<@_~{-elRlcuD!g@Tq; z2hB!*+tx`|@v!>Xka}OejfTea(ec>IGafNm=a7=-VS-PK1coj`1yR*QO5?0@5zJ?K zyg!n5h@G0y61ZzyVVyU2YN-7@KwKsL#%8y#^zwRdQ)}YpDJ+B;pUW0}yJM2ug#Xbu z6;nTS0^$@4V<|*0#qyY78*pKJ0m#UKhoRd?HhkoBSUy|S{LvV4kp!y`)v$7e>tdK) z-wlJCn;;qb$r%j7AOhx9L^nm-+V5!kDAlw+8IPe*P?Mu<*1eo9X(XZd{aTEV@>(DX z(N7zK+Fu@w750Y|xnh$eIT@C#9=1d9QfCp{?cq-sS}xb$tk{=2bj#Dmss>>!O)ZHb zhRDn+sz9jF1|w-ti<7%~WnFW?fU56pWCCBW0Nd!^e(>=6>fRi|xg<-j{|npVcI(zQ z_j_&enJ=MZrwA2#?BlHhsId`39FqvNMgA0_NN9}i6^>zdItp+81d_0wDyVlALM(Mq zed=;^+Tf;LA+=~%Rk~YfAp}Wr3k-qY^L#6aforh*y9gdfEOHqBZ2=Wu1KB&ikxc*V zy!*j3AJq7`pyWSvjf-IdF>73nwt8n;jf4bztx;=X7FD?YAj!y_14X*Fh*y^~mju=b zy>Q+W}7oczTM)kYQq@udlYMeJv(<^|89IT?1e2_ zU35-$AH}WmLY6S|xS8I-wm*D6zQ&hKirw7A?=KtPpglCX}ijFa4rX-!V zI4*7;gGqVa9bpw+zXcD_RpN-7SaNMD1P_9m{I~bUAamKs1XFkbR|B3PL5}>4I>_JZ zerr?I;E-k%f7!&zz!cVO;5?W1n$tV{XJoz^^1lyOJh#$7wonzQBn<)2c3=wDR_Yh}QZLD8$GAdq_%A$BUZQhl>NRB|w;dvI&lCLUkzE5wbM3j z@dMwx5dYVJ^>A>(Wbh8|!Gf3CZ5hWr%T#|3-kGR2n+{_wmVK>Wj17!9?YE#dGbUPU zBp*&BR8Q^6os#~I*Jt7b zzq!opVGXisrxt=Foie`k>;KVJ<}dCJA9Jd;-c5u(qtp5%mVWMa4y-|->@gVHh+x~R+v%pI1lusIo1dl;$#Pyui> zfE1GiWGC}R@JKP84z(3Q=YByNQb*EBoVcNY40f)Zhq#Xzj)$9QYJ7?fG8E#~)s--2 zfpaJk$Yc+lb2lbqT5e2TJ+J}a z#J(VgnV;>z_nhc6{4{lr;~}HjIm*i{Ff54i9e0FU3za{6SP!!%*d5=F*N$2-_>3YH zgIxv{OkVIVp-5$(Rni76xofINn)sT@A5q~ z6a9uANB~Zflc<;+W#=Z}6hM1vu@>S`5B=c~^fsWrbUj1{avxVo!BfE_Z?9t5U^7>R zx9sln`UVTB{sx8d6Yg%gI^DUfLRMHdr{@I$p)GR=O?q666-0cV8&2!5%NFGp#adf!?<978iPn+*&6g6vj!!1g zmhE@Wb9RdXrA!ov*rnVhUyd90vmpc^1Cs;eg42arc_mm=5_W^X^^?!KBuG(`B-!(b z@Wh?GzQmWrzx>JdlFie8yqukBoWYIPHTm(HQ(T?LICwc5VG{n!z{Ll4ghVv|*)!vHiOkDO$|@NQb|y|wJAfE)7Rle4iSwese`8fCY!*nu z5YSv+7%(jX)kZzzV(?@v69?Gxk?lZY4)^F){Eigy5G3zhIh@mL$IphJG1#>m5xm6~ zh)9rPgj6m9Wa^fK;Rhx7v?)8lsD#GsT*0neer23`#=ys9dNrGICA#=I3L{DP(h82q zd^w@SkiOKbJZ|IHD$A2gR_XIBNnQ%)5nAjr-pajWkhhs)7_5+nDS218yTBN}5hc|8DI71%CZTsiG-{HIv^pl5ez|Av zV2?21P7N_>>0G!ieTPW2wze%UjAI8c$*CRnfs9?7TpzqjpMMpZch$Cr$>nJbV>)yo zQ|CHc%>%7It)qaQ85;u(``@4?42#LF$NeW0mJt0w2rL~F=}vu04jjUB6cKTGly**- zdJ76+?7ABwWD1P$oiQQ6cB;?!-V+hxok84&ZelwkEsd1Q&|q;gLrd;CZZ8g=)M_XY zHH9;X4-TF)lEb9ogy)gmI+81k15xX17bfZ&@phu1!i_@Q(<`XtVO7b*b33l|G`3Qs z_E8reO}kLe9{=Q!5cf=I&K{s)>^**Nw|jU0;-6(N%cdT&gy!ys^5t%}cH3{OB{(J7 zT@;KFo0k0!WZAIb%YrV%f)7Z`ziiCviUr0IKPA7KFCCr>2CCl3ErT`>DtJ{IjJps=u#F+Rx+|_Z6RY-M_=Iu*$#EhAzset{yONeD5 zgDP;^JmJ)S0p2Y)Tj0qH9z9!RUBcLyd!klfNn8TSr za_m!H6<7kwCqs%m;0eJlk+478hF|M@tmFpOJ<50W3*Vk~!KwKQTC(UOWkY}ht%VAl z0B|JA_&gqf5kbx-)D%e;h~SGX_c;pXJaoMYM1SNK9}%GoDt^nH-J!M4@a5V4<%UHv z4m-kbQ2dV5ZptICq7Gu;6^YUce$L7~$=3bC#ad%2tj5h}7ou$Y%I1r8>Z0ctY;Wjj zkG+2Gn^b$j=WTVacIFP?6bVx=7iUSpYg24kbA_IPfzi0R6Y6*Cn4hc6r)OKWzxuJR zoc0^L95|aXb8G%%u76^*_ToB&Wphcik=L68llOGy>teJJz4L*`6q3vBVe`S$BS}>a z3>=4wc~TRH(WX|lPl4y02SuIN%y(zZo?RT(8H-lFR(ra1MxnLDBQOwiU{~?TjTb=n z@A|x{K{0o;+55FOaH(NAAy0!)7rSE&&a(e)v&^8!vXotYpxZJ+n%0_=sZjfkV6!w| z_yA+bhk+T3827k*%4+T0prN*cTnpL^ zVm87g^eICwPVjgi4JzTkjWO)Im(Im|*a$P`Soe1UWRkyXl5k~bi@^#IDWj#mdEZ4_ zzAlG6k!?jQ7Y?=hm3?3Bz;RTmN_iUbGsy0Ei8JMiO|^G8ms6f#ct**H1s^BmSZwkctC&_vmBBv+Iur1YFUo@tEFPM&NSWVT?Mj!=xn8fiZB6Wgb}yxMA8yc+IkREbTv z!rQ^5zsX zpQq2|IIdx48d3MC;-xUx`1P?$mFd0>*;35G(){T1nZ>K7V^L*Rl(uu5OU%OHHsCNtJkjLcQwWNM$1v`j$EN zh%9yM2gCkzJKxPUd|aJ{%5Vr@ZH65wfNSxQn92gB8fOgI026j%TKynC7LN@SaY;!8 zY&cJi<56p@cd`gtc2Un6YK@YH?#N*|mAC`a;OdG>a&;H`1_pR3pwFYDPe3ojWpC3?RrFdBT|g+O3K5%{)2QFAhC7Y@%5irI2k zM6E%FF$t7AK&R9-!CrP7*|w?&g$u370PTyF#GDBitr?y``JnO{kdzl@ZsUug%O7v3 zElBl#b}Tj+1I?)EOd)PpSLZ~fAWL9rk|$cMs$km-w$X%MiiN1dz0kQ64;oY^#$nKW z;Pa76l8Wra6EUQe4V$W*p*dXbhl7t4%1JbnUsVgG%JH3U1^fQ zMCm5cwYXBtSN4IJs5SXkfbT7VTn(c!`TGZPu<9BLQQ6ywEWSa}(^OLq>oG|;fo%*1 zmnuCe5k+txcxu+N|BHvuTUU8Yx%*xAIrD|AncR_q)>0%~JGF0e6tIC!J*pSt3~7Qk z*(UyQpS`B;_RohR{RQ2FC=XV;e6;!SlUSwBnI4lY4p?ZNv0j{R9C2BkD0}sFbbrI1 zTLw}_*l_K{?-7GT~>?fTK?&ceCs;FxDQ(J*e6#NDms78ox?#nG5^i zMgg7BwnpG7;5n*)EP_QboSYf2RVCv9^&zD9S>6{s+j3ssmaBg6__uJ%Gbk11U>t2(YBEBu~Td0Ok?J0)Q$x z9rm>>6$i+^1PosQ6GW-XC}n*+Ph4IB*_Q?sF~QCRfZxB?^`-Rc0+Zs!s6%k0W2c4c zNv9z=5~6t^{?cB564N>TJ?=WjQSahvR;|Wqq)TK4gDlT``>5A$^xE<`Z#je!0az}B z+`f)zq1m{%kD7elZB&XNRha7F9NawdN9pb!_UYoQA?~N7IgE}KyoP6Gt%*_x1_Zbn zNiGd^LO_!nL$)Trb?wAI^BT@Frme@0OFzPkGz3nWj9nvg9bR@7wX2!<#q)CYEbZIq zKybJK?g{FWlnA?@dhaU^Q;6qGh`RujmkYj#?gGHNa?-!+Ti6IMlU20pL z|9Z-AcxueQj1``o-Sq8Evvw^{Q8L}KrTO=w;^)40y?}0nslsNT2}RJ2(3Z9Fm8xn5 z`B8TzB>MIVs$@|Z5|kM9p!82-Y$tBtTE;%$y#-sw;T4^cb9y$uO_4dtFqPtu;xVSjgnn-*RI5J@qMz~v#F5=sGS zcSv*K0=v$Bv-tDe2oXT z^lgJPl}LJ`;>f}qkEr^jb*lr7S*?;cFEP=HCkc80l-$^#Tt z))q0zTflP3$Kxz93!>;Kn~r2sl7p2QhbxDG@(7+GJRNAV-{9*@T|RE*Job8XK>b&o zwpGNP(<5rEkG6fCi)ySD$m$r#_&PfvoO|ufE7$T{yJYWVp9LBO>$M8CExP-C*iCCp zW?|;i$F!CVtUMj?MflCd#kTo)OY&7G3`VH5EGPcLq&Q5Q5G%93JHt|{3f};;2W8kjkWIAD5^_r{`mB;DG#m&n0@Vty9v@S?SvJw&;w%c zlVpPsC@K$f7vLMO$C66Lg1nEjO#+tm3nL*kmsY#z?ru2CJL=)_mJixexPv zht~QE7;hsFEz`=!-$(jzp3hj4xDKmx-g;iLY(8*2TXJ3{m2cACiSy~Qt?_k}vFDGj z@l;7|bN&%;J+;zp!8j-JS-SW5w2=rB`)(oYtPKI0NAH1a{DRK4*wUs?pS3PwtmG#B z%rUE~Hp~&fj|qm&1Cs*2p}tNFj5>-kYK zHXDC>0bQp}E|Hg4ID$}R1h425`|xxukjFDLS_fANq0=J^V{@wKXEpNS zZL99uXOriimWi1a=Z5kc22~ov$A?7)JWcx~Knor2r^4T5&pjy4C9$?yplhODU!X)M zGpYND-KU4mYGX^;zQ6nJh9D=W(yubEHhdn4bT=?XJVisP*T-iV-$9?k@=wGeq{icq zGb*q`K+SUI@9L`5rR}NLNIo4Jqw&dDF_nK}ng_%|qZ}DqWqy2m%&VmkRFN?(3D&@j zE3_Lfnsou|x5{TX`5Gd(nHuj8eFiQ=_hTg^jP{curwF@4W_}CJX7w@lz5mk!{E;IC zPG%!t?hnbQKNaKhV4YL?9d9Uf8I427WF+$+XaQ^iE2>Y7-f-!^r% z8i#9vfqZxxT2Qb}u2~1S++cM%F39Dpd%OS9I^5}+bJGH-J*F00j6bLkJ?F^s7H%Y8 z#bEO;Ds~eugzto=iG0|3cTFv=o2g2B)^ES7`u6w_Gr#U1^_y8awmsI1eVukkMJ?Y> z&Q>nWM7kFdH=BA_1jBu(RhK`N`my5cs4+deC=3N?>9Y_)|K&)S8u|AKOpRvx_Ai#} zj4YjhZ9DDrDfhA9cD{mp=H8GzL-az($wzedq#?jEz|bCG$o!~{zl=kwRMnVJ5&)sG z!4`n{e@F|klFVU(?u8o-@EoXMg3_^=P48pd(}2)jT2(>=T>%aj42If&47cAOJHfv4 zoMne~0;8=YbB@EbUqA|=RDg|v2@s==D|ug&VC%u50tXCbV`|W?3CR*5YKeQmrCvKs zyaLt{3}3NB08!|2j#0)`ZGY1h7FXNo8F_uqo<<^7++x1r7Wj5Hvr? z-^MtAO!&>gFT8^U*I`ryWea9geWiI1Ljl2#-SLd6e?$U!2-cQ_7(X^?A2l5!3AQ|X z-S{=-(~J^?KapNz1>uA-Jb{WK9Ov6eSh%t z{#9zROA7GzxK5}9dVYrv8Y2L2A62SaVA76E+@eZRgXtqGhaw*a`)Gn7ec1uFCWwlS zhG6gO3F&<)l~U}aB-KtpyFGYUfZlkz%1FUiUb{w2nvqL2$nA zZ?$to+Mm`79v(SX;XED2=xN|~WmW89mtFShWP)^2r=@FSZ_CEU;n}GvNeCiRho83| zdw$`(wd9o_+vK-h6!Ns!7ciy_{rNacNKY@9%Y5IcafzU?s|csVmL^#{n%v4H*|G$=t%99}he~9;p4RyHO&q zj@H}~&f(*r){*iR=beU%Iq->bq{5_Lu%9w<#^I1>3C zMgt<3eX~fRx{FK*7;!A^Sv7vrjl@6cQ4k9yRr#DqdS;Z!O&9K=j$UD3aWs8ix6?+6 zb2kYLj`LO_o_}z;8Fbrv2P_kgnKtT3j7i3o#TiK{Y0ArOmhBg$kD?<0jH^a8v6bvL>GSyGZY9(Bc%+f2emhkE29Vm*%uDG zRt0toX}_i(0V;^r-`b0%4ljJGqW!)qrgj9HcFL`6 zzxwfwrJ%Vo=;RKKD+cr}&ef{O{D`vyP4C-jq94rl)EQ5vutAfg$$oRLz@;o~N&ECXJ>Ir;(JpqeJo=uskqT*1ZWlJ;b8NcBPsL7fVXvQ`c ztCGw(P_Fua=utqlLl?p-bsLX>99j_OPJZ5YSQ$O0C|~dB~S>7eB=vZ^BrI=C2Ib*xz!kCyxwz+4iS3;c?Zp+>IM}VCy)*8Zt-(dcQoaxoy|(sIqFa>SdYateRjIN&OwU$TT& z5$UsAQE7Npz})c6+)Pf{+!GIG)M@R2cn<|UaYgQ_4gCJ%e%%ZGLvx9{6i|3-9@(sU z%DScST|Zn!w3lT+AiCsQOFqvFr07Kp$A-;zUrofVTphP#v*H}g^E{8Xty||4Fs0og zTlnhF+<=gkAGK^}nxaSN7Rx*9#xERS|5WF*a&?!bXqCicN~FIW+*$?sB7?f9adKoo zvCNOEz?y_sSKLc<*V2uS*QZ$o%cW!DhL5{_L-09iUa36x)~*53P0CBvyJzCjM1~H@ zSPI4qy|+E$$xV*Y_*cWB z@B{|Bc@C(=$0Pv@A>oFPduuHo8p}wZ*Fm)UO4S`?Nkcw(Vq4$7O#4v50jzzOy6Gx1 zatcxY5Q}_#cBaiY{G)gA(YtpNMdy~5jTExN%cgbaTjwis7b&)jZ%%i;)0ndLZg>^& z)jp8snU`vH0-5%viu~8VL!a9kk97dEb5bKvJa7Px zlckeybN4T8v4XVv!)B!%i<4iUAs1Q>3|??BddBX?u0yx>+c;1k&KVrFDjgkLUx(ab zxy7Nty0XP^mYB|bN&x?ql#82*11lNX&1i6c*8*I&xZ&L2uP&C{(?1+_sW zN1%#!V<;aQ8}+z95}jOeM-gjB7$lVTbazcn&3(JTasq%NZV1~Fq@qMV(sn!X&CMco z!)op07$Q>4@qw$?H`_`xb>CZ&38l(N?6xRGqM-C~H2v1JK|ul^$>Y$`!4f|{QtxJB;o?(|cE!FuuT-;_B{NL7LY#!XfTkA&c_hhtMK}J0kVEG_+JP9~{twS8 zKFO=H#%&je%`aGnIR-<)2<1LBrdcAf4tXuY*Xs;^hT3OtRO%Q0`hMR=&`RSyula3O zj9pw`WEk-3diuESLYPj-)b-R8-5(Zyf8=FacZDW^97;m>nt?lyt-Qmnm2*9p5Dw}n6tH=4Z} zTuu}f*ssMVSh0>A7toiej3?-uz?IVHy+=W>6J5CcqTZ z_tQo@fk|>w2@X2uV#bEU*d;FkMqYZUYR3LL$+@POL5UyY-T}{Tgyj(qJIVu!W48%<#$mWg7$LUFIHGBKnvrZu z8$ATTwzP`J-h|4IBnK=bEe*66*j9?F1Ot-mgosAeKh#ynNE(qDUS+t-uO86Ti}VnP znC6mlI&}@hh_ngfk~L93sR(C3kK*Ozl-<#bTV)jK&LM?F+HvCu>*Wwv8ql~M=-ti1 zYU{ZV!vkpMFie)T9}yruELbcpz^ z!x_&cp>Dc{B-UKgwZI)@rxfAv)Yhw&KVPpF(im!#xN=5q|RO6I|rKp!) zeu_X%Ngfro9>!Q2S0{6cL#TI}_n$tm#n@*ofIGE{t@PsTX{Eu)rJR%dtb?|*4D(5{ z_}TcRka|V7`8~xm1~u0X@=`w6kt2Qw(YSX$RP`j1Bm(Yu2|6m7L9}@^mDPIo;_bQr zw8qG=@VM+*hf|eN#X@S!DpTs^BGq|DcaDA_M_DX1jQlIj{I`S9$rH2;6%vs@k%|eY zh-#D^60+pekLx2$4%D`#Z#`BpyZzA5h?UG*)8Doj)Lbf z>dBi_yfewk*?DnP=zQ@PvxRV$Jgcf~vbm%LnHg742Mltp>6xEb_p+VU+tgei>V6LU z=jyOsa4pQbhGBeljTfhDAW2B7EnN{&AzI3so5?R59PU1A(s0LDg>U8I@Q)j^^S_5Y zL+|qhol}cD-0BykgHzG0#CsdmJ6X!{5mc8Olt`lMl4wFlz*q_*%lB}5Ll6>82#%Hp zlu)c!O4w2`Ule9?ikOxnjJFxXY;#wa6Y=4t53^TSAR}4??S;epZh;oT406izNlulb za_OyPSlJ>Z#|v-p-Ub|151m5xr=PI^sgI)5bm<}q&Twu%A@Z$Bj35Vyw2Qt6@1heb zUEtbB{Hu;DF_>lvv89{mi(^a-y8%P`-bu7;;6)x*HNc^z!d&T6S>PZH5N^d*wiXV- zVH*%IlzryI!x(H3l1O#lm$y;4@C_0URWOf8RTGkB#U?Q8`YC z>?@dpNMtRng@rH>ge{XW^{`M9a|QSXKqk^lQW-idzh;w+;0NR^k`3UR6PocSHUg0R zL{^M`A$0T1OWBs05uo(2=x1~K3uIxpP{!od-*O~HZ_}JX#_tTYi`PkRt^uo*UzsFGfCEJ5ze za0F48jbn5{DPkkdR(;rNNJaC*-_-3}Zp^~ClJ zVGZewH;!g3)Z6Md>h#VJEu9s-E0|M&slHX=J32p)j*PtYdEqdmF<12N+7+5ag-So7 z&(+(C4B_bMHW#)FaXO~=G7BrTb0aT)ttQ#9UW?PiUDi6YYdfF%#a9P(|5(si_fV9` zTsdxmF_h1y^4>A3H;i6cZ*q-f1)xVG(>(F{{dxO_t6EIV8fRkE7pq9lb`r5}EcLAd z#@>~Wp1aiNKhinkJ$&x=+Z*T0U)?wo@3eX`KV#Z(+BdLw*xA?b#(KAC(SYeawiTv- z@ezF@+H~c53T?ve8zKd({8E-eaLJo&joDwl6^yoJ6tFGenA_eHNeRK;OSo6pl++3{ z4)#$w4x`nXVI;5>dbl%~L81or-X6PMiIBq{_h0)9lWd$4NP$x+3hJ^mgQ+Chh_Ugh z)b|1iQRcwwTy0GeZ38Gq!AzI(OQ*!-l^A;sj{FLnI3^F5-q z+N4yLK9REnpRM)Fh4h&0T+FpmfAQNcmP)SIrs#~{VM&zDkK1O?qEoWoD!Ae+JiBA) zVn3mDJAbrTTeBd+7~MoU;*%SjjOQH!1*}j4LQ6bN|I+mwDR=#PdU3vS)NE0hwSl!L zxgg{B{eD@+_0kEcobBs2Z$5Yesa@URQK6|!p>E|3b$A_@W4U8Dx}mpsJUORA~T?~hxIOhEB3zXG6$ zZ^@m&q>-s7*nWnA>=L$3JYSglvTV*)|6A`39# zee8uBi)V+IG^SalD~sGUWB1Zg;1gni{dRi9XaL)SDV8&+ahz^O!@z0dTJ(jD-I~AQ zCf$4w4wVi7oi){?McEd z=U=B|K!-Q`09cP5wKju*R`24)6bffG_& zrGl+hV(AKyyv0iTI zPm~Y_8dfO3xw>1uMj|G@CoAlreo3HLwe3R2qT#W{@2BHBn3tY6R}{Isy=ZRXvT$ov z^y)`FYJUY0+Wt@DniHB1Hpo2|d1JE!0$OEes?x3TrM6GMlubWkEwS}0_4vOqiL~*L zF`*K`)h@A-?)2_e>rX!d&=V{nifF(y!V&@}v!O|zUQauK8UQG&d;)_AX2C3{AP`v^ z8+B7hE5)P)dOqW)v^RRQsZ-~Uy}-hF`8^#Qy`S{TvIQ_3efE!dy#c}#1woMJ zx#W`f{tt75X&}(G)hU>i4`_4&1wUEeN39&JjPi*`xiF0XO_#)_bjcR;x-A9GG)iP8 zmARs9mm9KDfolDQ%Ymv(36>hiPpBWHhE69Hvz^z0?xDd}207UWVKiQ9oNht!bkPS&)0GLEkvg%K9e%{Kcnb2g;Dw?#B+|4&q;6kb7q7b~#YeWcC zU61`iy+F#KaYrGYA}V(8u`3XFs|C>zYjsJN@)J`=pjlV*NS$K)nXu;(;?%eT<$l4% zvs+H*|0-wjQ)aYLX0I%+z1^Q)VCV_Mk4rxKVv8M00rE^-jgDXEKk3g18#g7;DB6Xe0ZgN6(sXpv7Eja}1l zx9g6mGH3=7A-|aXXTFslg$|%5b2OjW0Pa!=&>_VXz|1>#P7Rh?8a(8C11pzLxc`%d{Hn zZ^^HNn^o5x(=JfL<5y$jv$)IFxu~jz+o~Hr-43M3f&?*+{+H=(x9?(e^@66vd1NtM za_p<*9N*-Rb@$H{3*1dqetWLlFVN7rsReDu)W1EE!3nmA=pG?9g>4ps;ZW7T6lP@Y zmXE3w#0(klR`m_LT|rZu^P?*6L%shi)Rqdm76UUIVAxJWcZ9v6?mfJ^>eCvBGw8pv zd(r*^pq6Fgk^gbF^f25#kQzr)?_l;OkBlX-p%Cd8SfY-kbCTueW(|nTno!qc^6Q`e z5T`vDPh%fMBP1;!d6&o0%QuoB19!n*c|V~q6`qajl`&% zq`)!{Qgf(eL5aWwxHBKI-tT#^?85DZE0c}Ve`BY7tj$xB!&sDKoWzMs8x1lZHyf9o z!X7uCOIfI3Qr@%Fvd@zU@%(Bm_eV1%%>{=gNk8Ut%U3Vm!!Uk9fTLT?B4RJI$ zKYdQCg-QjS4n{MnXtJ6s7Frd#yTG>^J0>UU3VRSh?L{|#V?@}A|IYiZDFPYq*6`pT z9v*02!MKa5LK}W`P_sQ``cfm`p$Gd43>WbuChqze6ytdMTqFPJ!}r-sKv_ka00t-5 zo`a(qGNuoap0NRB>hfUC2cM0Mecf}Va^M{7gaxv|^++xSSs_VeR$@YixHfHGbf33T&TaapYWJ+;UPz5&F*-vD^ ztlPuqhH8FCj^D({#uk^T&asvA&H=5_EP;ndW?H_c)T{ja;X^;>2|R3O{ykmRwRlbz z6&o%nR8f4!Ik3Kg>jMfB6alkskglVe`C}06&D<<=UPxuziJzlB-&Fb0qdTrwEk+sk z+#H{@omOCt3@@Gy7@Jz;ypEWtQWwbr+jZWjwf67gq|!mBgS;VOe`Ju$5fy$dFw0#J zv}pR;4NT#00#|9Mxqv;Eu7BYLr7LkoYRs}cyM?-n_yq_^tDDFPEx}BPIVPxN$+JdA zaRGswAjU$=0%Lh_!Gj|2AUGqeYutpaDv@P(2?t&*Mav1pQ%HQH6)VEC?75 zArHIDaUSye6d8?ss4?#=hA02NdyA06UzK-PZ(4ZsXSJwOU6BG*V9y8`__k_0SlUaNYF zF_cIi8Jyo;MMB+e6cLH50;|!ZAV#&*plq{q_OP)|;@Vka+u2{&SXM8|@?z1-rajB( zwZa=$Z(0dmP$ZzKT==0AGV2RgCfruQbb>p=hhyntE$6@9s^&Hw{_H8Hj&uMRZ$nM= zXqC}T;o?hM@N*JRz4lxmr3>vKHXAo0^*mzOAwu<%l)cOa>U z;fl|#0^F#`)JhfPu@DU*-xGZqA{CjuD8|%^W*HO8(U%%yKyg2cStSQ(fV^j> zN#=dg8iy@a5SUv*nP_vk*v*=Z?TBlH|6ec-7}``3Wc9r|5^hv*W}iHG?K7tk+JI#e zQ>Cs^R_M~Ld~5-=m^OEuAR?F2Ltuw^6(c@-Qm=)5KYQz%enuL!VzWuh`>k$M1=nB7(|xmg0-kNH!L$zj9&t;+t~@N=oCJX z2kePtK5Z?g@X;i_O((9ReBXqIqwSCYl2bV0vHU#$H?D-?N(4|bsMSA86%GyQfE=EJ7^jb+oRbw!xw*;}qw`t!iI zp+8s=tjW!+>+@Bc10`GQmfo>CbmCS`O*tCyu1wmQkt+aKNN&VrB#2Krw9#TJr#1lv z?`$vGOF0c=6MPHPf-#l-WR{OYbkw1Oz)3)*Ui|3**B~RkgfoaO^`S+bk6p4YElOoX z6k&T(tFfd1)8V71n$JdlcK^tk=grF_$m{cn!nv9fVN@>|vRsTVWK@s4CgLL~A`>^# zv%#Rt$nH2zmMW#pA+?X5RjI73>TXzd^t);6i~gY(8HFt@LmjWy2Cb?0Ip0pmXiNHn zh~?Mb=%eTj%s&0NQdI+fCg78qcibMuS2o~lnrza*a}^+(2E1onQu=B6#NSGi;5OLX zHWGmX*k#&if{)JG#H2LA;k?*NQKyderFVPT#)bEROYH%EhoGwf%+WyeM~*oR;YAyM zbr{ZuX+VT2X|(i$xBVDSB22xr9B(@{U)WAuszZ<{X7J7x|FbuN2_gx_Ai@*HV!l!y z&-EQn#FvE3xXQqS5EmBz$7|+4dTUBy*&^P_WqHJ~@dZq0YQhu}C-VO^U3nmtY5R`s zmAw*%gOg)vv1N&DoyrzvOiG(wLW;7KC0m?g>>*p5q8Xv0oh-=~BaMjcQIkRlS3xdQ$G~4~uY}IsyMn{Q5i%xXnBR;<} zKy}>a8?IFIF@lwN2dspj&=}ZOmbpz$TB$ZtbC|> z7xJmEGMgqtdj}POfFiIC1@->hA9mA`S=CMM`4c;(NsGc3Hh*sdDd(Otj63~_{9zsJJf z>s@ImKK$fWTxlygbSEwcrl}ft-%K{d42Rg@B)t&nqTZtS_fLdXPvjW&-)2TIV?t+o znd=V4XSHN+X7<@4duYwGg=UC{+EdntMKQ%Ib@_jxgVWm0}?y06;(-#ykh zG+Yue82Za#N#Mmj;|Is!z%b!wwf0hEmjv3HxGg2qZ(8{n7@I!5kNd(d#{LnQp zrYEEymL0vxqk8g53Roa(EHamYw>h3Ql>YX~_&%7BxRW@FY9Pn$ z2Rk;={1}RPhG>j9`yLXARaIDzjes4y%wDX#R%6)`U81;M-Zx9~b7T1Mz#4`R66T`e zIl#AQj|jdROw{4_@E8KgelM?V_~cw-PV(B#M*v1ER<@tnA&ho~qPA3gY^(d1@Kwkk zp%rfNgd7jTJ36rz$G1{M`#LkC`a>e=O0e%g7^ab7ZqGYX(gtipt2AW8s5DrAHH)`64J*}N8O_sRXBj~`R+nhOVmf4%xTr-U5IZ-3gKdcTP{ zgAr>BS%SuJY@UxQDrk6t%6x7Aw?AXhxhql5D{h3(jb4xHZH$!-(a@9K{|-}fNm#wN zy2*xlNwv^q!(n4rJy&B*+DuMp$Xkkk};qL2zFVaHcZYN`=t zCASpserx6`dfVwKmPmWUH%%Lk`0hp-nZh9Uhbzh zG86}`-baW%MuuK~*cO>kvk8(mpZ#kLr+U!$gW>a^ua+aYF|UnZ*}6YSKN)=TM;^PH zr7Lc&IyKS&Ll>{5YZWtJ7KRKKx#zYSeBbhI@J012>B)ZXqt}Io#5O=bj;Zy#)jT=( zXh~Ro+e{TyYI8USd>$L(ZVkW~loKxe#O89ZJJBpcD+rm2Xr!Ak+m5OnLt$a;E5q^% zhHBX+vI@jGISjs45}NKZSZz-XkD^v)Wjw$@)A<0uo=c_<_{V;%y$xo9Ah}mI=RiG_ zbRJEwZsI(!pTg&uhE7w*Gnug6cIbpNV8!lhd3=@U{rTC!ch7?6l7u-~A0>>r%;xX0zc`RMq66Ja^YgA*~YsM{EJ>mYa` zm`{mQkvz@NI4VKBF?i4Cn?!Uqn`zL(#H*mW#-sA{Q0B-nge#GTfJA`3hP=YCDv^{| zI2Kd1)MtBOGADO2qoPhL^y|S5Pvp+Y{&Cr_Z(_otxfvk1%Vm$?;JsF9D~0f<(-1!P z=yr0$##<R2=IW6CwM*tpoxMz}iT`wahq=qA z*j~*qmkyp1I{GBEa&d7RIF#pUPla(utr!1{_0%is@Jx-3)Wc@)(1j}k72jQ!(kgz0 zP8KoiLo<69r$bXVFOJ)u+IBAvYo*Ta_jDUS!q|vaJsHz3e)|jY;+vza8EGa(@0vAN2mcmdx4J!qg90h;{&k2MgnO)`RT?r-28k7*@R=`u6QlXY?ydVa@+khi0KI$-h#- zhL3V~j00;G(WydGC0l)OgJ&=b21m~uvVhh)N!EO&Ys$O^Pi(V(7d7d3jSf0kl?mDj z$n%WfHA+2h#8)delnygR>M$aLw$UmE<4_!6e%0jC?jg?ecnfAs23;PSi~Q$A3Ha$vM$ zP9FYwoCkj0bxy@8wQzQ|yY`^rL^H2DPG=hm7sN6wXBA$Usv2VXt16aSV;qJ0Kpqe6 z6>Lo^KZ&m|G2{_NWJVtNlf=wy6qYcZuDgUwQQgFX=wmYZ9S3ZIb(IrKF=80dCEJ4X zf`>R*!|wn_l+6sA{qfwjVzDKya3SWp;d{uojxFcW(MR5K-ix(bv;S61H*Q#McHRs9 zv%L`($sa&wX&bSB*HHEG&F9qd^4bsryE{z>&H+mSS3d*nlwoW({)N%4 z{IG(OPioc#(HZvaabAEeFL9J5jhNM&>H%y5m%Ww{wqk6qWr0ir2k~S4!v+69NDjeZ zI5dXDIoZ_&h$6FS444>bt(ic0eEQsVR_Bo+qmlvWgkoYqh7IY_7$M3@p7Jfbyh6MSm73>p- z2sIiXh_gx1FEBjduMA`4mWV0&v)gDgXN*D%n~=^>=#Uq>W@zYoJ?n$J$lgvy?Pw6oqh4oWH+lTGq!z7CYx~wr< z&LK|S#6DPM6U@o=27PB@Vt4|G3`n1a*G+m}tx0-uh}Uy6iv{e7_PNFl7e;?$CuSV1 zGfC;}bfv8(1g8S9z>&z+O*-ppwx6kjYLu{m>YyrO6#1-?HG0Xxjd7xh=IMuh!*eo) z>W7;JWfA$j!#D#Ahw(~M|HR|6vOX*>(EL2&dc|aNd`UquQ0VM-Df@pIn{csYSal8| zUBx{Xwk1pPV{4z)qh#)@Vmr{F`4q>5KdU{YgWc*71K}k|nN54tfC&h$!ms?<#G2pn zwSFh}ZRY>zo@4RE(A8f_X<0{^~C(2-WN30pXx1V0QCh6-Tv5N4v5Y~HIa_2+2$CO*_g6#ozrrN(iEfW>B7$J zrhB12jlu#VDAbg(@U18~PWor7^!6`d`Y%-kqZ%(im^P6YypCZZc5o+&j4?AckyMH= zWwoc%B_aS&OSXWQIuW>}$f_m#_U4z$!Yt9w1K5B!kKB}nYT1AsIP-86u(rk5Z>p3~ zf--eGb`r4&6+$f4!7y+&(vr+7K?79r0iZ(oL^Yy)Qb|Hn;p%ms1TuXJ-VSGZKxeNw zEm^|QRvW2dOG2k*YZ;MYX&YY2rk0&@a0Cf;cUfiG%-rH+-BOW}(_i+1x(h0|^Lo2lL5qD-cifN*v zP9Tm9MrUPVozayi$8hl?^F_tNBqua~f+cW-m|zf3Gdo2*4o}U|TpKq5d9+FA8vOX4 zV!zXD9ue6R9%FyodfBPZ5+209#>llwqj1YlziaJgf*tb5ReZvC@ZtG6w8;&cG;m)} z?$7X5Q^QOnLzG?+=Jk`%5pr8ntl>$YF-mpVE-%TC#uKr<^pWK?H!&&%u@r7UB0|W? zIHYMJ2bS?+S7JQ31Cp&V2*(nd;4_Vn1Z{|JQ!7S=oj=}P2a{Y-sKRt;J7DHQ&nqP6 zZG(%y`C67uLL`c_g;ZJ9V}+`-g2Tl$AfI zZf=UHqZ0mG(no9;+L_Z@vp;RWAuDsfZ3C~1r)1jB(4e8EM~+q|dp%yRVLcP~(T-3v z(aYK#tMO&xPpAlchsWj`1SZWZ7L*sl3}%xBQ>Q=L{&Ti(d|(BZRy|tn%rt_j_aUXirNzUK;1elYd=X+;b7WI$Jt?oTLyCyBf zRY&7&g?;A705T;}OWvnMd?Uis4q;B2q)f!NYmn9Z zVErG(>R8r9`|39dK{At&E@9rJ!^n@#ijBnjy>I2#cOkA%SQp*yWnl%-$n+k>f zPUo<#_ie*)pqQ4_fu9(ir7*50WGEm#*VT=-Gd7X_mr3e3Q#B(_nIqXPDb-cm4Nkd7 zRLrJ@{#h~MQBfnnoP8HyUmabwVOUR>ztwK}IuhJ+)3iyG#HuyAK%06`16*XqJaFXa zuWpD_-3kmkIahU8DF*1rtBVkL9Ddx~6xU9SOHi@Gi{sdXzn^W89+x}1Bp&+NU_mEj zjyZcUQo<)nk41U}Q`tq=%{?b~1XZ~-OSEJ{vu!BeN`V44pIwlsdsD@2? zdv(~W#+?TQ!~)K&E8e#>)v_QN+FvoNypR(*np+ut&a&&~zsvbmZ=nN>t(BFYAd5Aj z5V1%dJMT&Eld^QPJA4wY10h-2bWJqWPijjhI5Zn6M}7D>0VDL-PQ{H0V1hu(V0l>_ z&WIG=HyyVgT*V`aStzDu0hsQyV)unHB}R61QFM!Vj1bC31;qj@ zBvn>{Z`fow>!z^mG0b}^$@yTp3ZVFHyk`ha&_U^Z^qB#C&Hf6C=3sXSw6X5s#^=UoRtO{ zak|Gr{%bKl9b45X2c5obr|{)Ei{r3od5g{o(pX=SsgD_D^;t{)XqLSW%7?-~Aihtn zs;jX$e&Br7iP$*!q7$Mv-&1I%{(J>WHqi(cQ z7z`*cQls(G%1#>V1 z3-(~plRX|U*^KTzsOs1xx-Q)_ZN=vJ6<3l4_*dZeimCWnu~_uL@$$Y;&tb1xioU+x zKI}dSxe9XwugM^1T^DYr{nwvsJFagVesou_Jf!c0`$?Tswp`4ATNZVeDy8RTz6o&q zcy=UL-xEH+lH?#I6~AC#M`dGxi~-y_kwV(z1U}t&Hs&Cff8#dz3LTUQ{|x0!<#jTY zPKGi;ZP{KqP*(~O4hTA(EQm+oBH^5m!j)z60(`7d^#lFVbb;XoN1IX~-9y)O3`pce z&C_2fMu&{!lLFBGepG^H1YisW#lIk{=?FMLuy|HB%#c|CnRKrSb|%pI7oOEBn|lEA z#i%Eie5|vqJ~S~8B$*K;T>Q=&XGXgQTHI>#S_OEl%MdlCrBJW)*5bL!DWIY zd(b3r!H`^S5>r1}vT%k0S`Ihh=ek{}Bw|>Ok~jr0O(X+_%$g3U9?6xs+vGgL z3(f3r@+ucJlJ>Y>p;0F;u!WiS6bEcK4yv+`g<(#;rCKddB5zXN5GbP+zV##Kci9nH z3*vUtU(1q6gV+LQ2heG*C!KzW%-iG+$w{gi!+mlp8P@E$93}(gIv!$dW?jcP>Rn7L zaA@l@{^DSVGd~FCa855z_a|ypzv08OsMEM=Xe(o8)V;+L0$>eP{5q~lVhwI8@^_?z z4hP9}WfClv08h)cjy>>RcT?Xvyvj5vhfq~I0P)ed_5sXp)-=(h!M+E~@4z%f5t%rm z_|d<#jQHU^Qne*@;yu{$$K#kesAGKOn_wog@i8o|7`+5YUoyWNP9nVKaVwCHKDn7u z07bH$N+y+F4eG4BB$HKl!oAGMk#@IpyUUuT(m2+q7}^tgY?k|UwP1=&Fr6tal*>41{<=H zC+C_PHwT;S9G_v<7*tJsoVJy@@uU1Wnjkk<}EIxD}+0;jFf`Lpg z1hmi-0Zkk^3+Kufrvk;USOF_l1qp<@Xri8-lli?iYO2Ku1vJ^7r>Y^fLqY8Lfn@s8YDk$N0}m zAMFJ9JM4&IIP4LJ2{mzi2j0sP!Q_qDq96KsVWmO=AGPFdE&5D13relG!(Ect`=F^+ zx^Jgt$^hD7{m`fqs|d+&-%4JWd0|Sy-g+)tMNxa(5$*2Z6#4L4FGYa(J1B&Rji;V2 z63UMkro8#|e6r9xI9dNST8&>OOd0?}_JY`+4qJrudNGLr>#Sn0pp%|M)!z4m=e1_% zD#qPr8!mDF%rN`Dd}ddjQ5=Y`8c0yQXX}R&zexOIqx#)>eX{J^Bab`RHnb zw;G^CLXA3VbLbq5j(P^Qhwm<$+<81s=)@gZL;A8Bo~l09W|a^=Ulzw4>rOt0aZ{Ev zzZ$oPE_NMRSrt49P8Jv>;Kv=eE?-o2q{RkSSH7OIUn}fw!hyPfze+7duyl)snwwg* z`Xi*@n<;_cH!o| zkDfjqoc{7o4CM_x|NP6K(!Q?o++QIKPNCfuL6vh8iLesf2n5B;UXlV&?}3w{2%RSb zraR$i5@XwVWbl1_96k19Zx772ZVpWyd^Tt>cb~bgV*0P%AwmDT)f?`x^PJ5E(*+W0 zno9CMz_#aBpq+L4sNMVB&P6*B;y@KV20j*+MFztk`}xa6ib5tO7D^1hLpEG7J{dH5 zqjzX&scv!N&pfWKIb)~~E_j&{zR`K$Tg9l)Lq2Dnb^gwJ$X2MTt`lER8F%<-wNMZm zT`~7XdZIsmN&DkCTYgELsUxoQC@NhT=YaBbyikD#PZZCtz5r%Y*(Pc@j~}cP*<}$h z(0eS85I3wnUTQui_|P(KMVWhHBBU~kqI$_?n^5(2prcK%L@te;M?PPCeTO_O!2g)a zio^<%WGK7_@jU7!udS7to4ro1z4PTsum9rw{Ly!3dRx?Mvpl{!k7cjjeP<%7JmwgK z8ndtDf(?UvqDI7uvxQFICRphxy4pSJjh%JA_&>)=@+lnGDBIek_Fv>E*fU_Bnc!UE zT(o6Gti`wY-I>MEVW}3Wb8$K|ht7@FOidDo1_?yP=Lb_5FUZ_SF#u2c6OG|4dsqjk zVtzve=M?A{Q!Y|RZ|>Y93NDjO`$la>UVz|MtN*E4Euw_iV zHPbAHdO`;=UtVUsXUDR}_En*>h@sf6_k|`0J~2fcfb$9TWje1(Xy$PW12*XzLVokK)aRfe~I7Rff6Ha83$dA@UI0^6c3y=8p zF0L)mrP-7nW|!(C)axgSsPqjgzh($l#YK>tVTX`o@NekFV-ltNHh^IM0~Dg;LP8eu z1z6U!NGBpOOin_k=tKcd5=o?OG|Ud8TS$J^iB`m9A_+(7d5L z6x@9LLXNRUf1;(d6FP!2{rpm4nc=KDq@Cs_zYjNdXy)gL&eMd{pdA|YI`F4r8crv* z?FkpZKC?=1Lh^yESy)zbDev&j=K7^!k&wBfC2x9IIHM}LT?zg_hhp;SFFyJh9p6w| z@;q>vgqh3pr*h4yU<<*BzM(^FlehkkdH$~33s%g<(0Z$d?G?EtUZNS3Pbj>dbzm18 zj#Hb|O@l6hq72~U20*C}?BkCv=q4m>F0V;0^}iBj1^>A2)bS80XKyZy#vxvP3Ssg zwvf*xLQU5DlXsF~2$-J`=HVaS*-ab+18sdoOa^8weMBUi;+A!R1P?ZMH%A3pd<3LD zt+ycJB!BS9p#scpA=WYLyPjF`cy8z+dQU=hW|b7MV~Ge%n{CFIL|;^!^a^nxP@%sx z0+qd&fA}!F=cENOW&t%Df5B*>{-WZugSl>hq9!^+Kx7PPlDH@GlZGxT4jgtbVNv6a zJdtz}HYbk?L|tn@9yE4|-M6CzqdC~SV7a%?N08Ia7&ek1dG|P2c{<6j=Yy#7NASu0 zh$~t6M(sV|Q_RW1It96#$=Qf*qARf!vjnR_7zsN@mN)r!92gNW%CKq;xZ!}SO<4Vb z@~lf|&JxGsw1D9~Ap`n(E^kgJ=uJ&V^kwzCmyvfO%=={48NknRxg+OdRawOzOcrZ- zWQdy8!7zw`ZSd1s%79=T7t4%N=9g6WSqt7J_puOR3856(P`(3a?U{LZx;HEsc}@Wf zrc{An^6R|tCuxfbj;U$Y@=RC+J#88S0Z82*FGJ&*95_R0z_$|$ha^zQ#XJ$(L~f#a za%m;0lXv@wJLvf#`B_0$H8}e`xtmlxyOGT_-h_jI!1I?m8_U6ey{}x(dR#LoPLZF4 zn{NZO2a-)%84?rpZm{Lu&=G@LwL=8m0v|l8(T%BZ#1ANkLL=n6l$|?oWs?2q+_aE) zufDZMluQ4#0CQQc-U&nCVc-Nh2SOq7WCzn$-aeBz6K7W=Nxc6ps?$O~MI+X5#_9C- zgUzS8y=US+p5E0=QEmKe;R<(XX@{mzRRg<<{;v z^p$INi((l@<7ycek)8dg{RjH{!^BeoRAEsfiI$`4RwzfG``?N)+au~M&)vnL<$?zC zgbJy7>m*aqWf>`Mhr75hk=>)0gSbO>Q*5%7=To~&e;%$By7Uw{!J&2dVyGFq8`~`E~j3C?sdfp zI`ZLNyZq_ki_d@RxTOG{)y@$?5yC_~8e^y9e1*^*o!ibqz($CrXqxA3K`%s&95AAr z&~&vwpnPtO11LqPa`59u4=?;?(?K_4ej@330L0bdEE7~cM)!&hpU&=(tKhI1>gRn>$Luqgi8P!2ee-pv~P_mYjhJEC8Apa>KWo z8rh-Ga~-ySNyG)FUHn}zjd{h7jnfH&u4) znk#3$tL^d*ig;(PqQAJc;)ilYQ|?azrnbRiaz*aa$&WTGir&?R1P*69b#~R-8Z0X5+P?o#RdzVxyt_ge2W&22+39Zr2ab(Vjnig@E6yD5*la7y zF0lb)kfU10*azvDpcAiuy+cy&g?n#+T5<;&Lok1~kqY^;s>tbM+tJ0X26F-n+6MDh z%$19;50*0*gkBGHTZ#f$neU)tBKC3nN-;Uu4%A!R3`egB3$N!vxM$ZD&(wo{oL4fP zeJu68c>KFKErIx(FSUAtE^?Yo^&}#Mv%y-)U^Z{_@9eeH4Fzj1+wmmn_@PzZkHbhs zkdGKfoKBW<3g zog*(a*I1EC^*ZQF#4y_|4%P3lPvFf54ISJ`#3*&-+22!HCf)Snqgu_={9jKN*)C*D zGkFlNyu=wY&0NS7-FKw02-s;T=!sL;R-shJMEc)fYepk%g?WRSS97n67PjsfkBUD6-d4aF|CY1Ip9Rlf zo}WKwoSJDL%O+zcN>|Ii+CUBL_Iv-tr&IUHn(`k!28%7sOHGq$vjg!P=2Ea~> zH@XHe8;UQVi@bw8ME&(^OKYOEs#J=kz(oa9yWu4+e*A_k8@xZ18tco(4jnl zy7SZX`#IuWW&IA{EZsJBrMeU}=_?c_@nVRD*1_FW+|lqheeBC@^|&h#pJE;J1V1KO z0}%crm6#Z$+kr7|gY|)3NQh(jpY~L^*$QK__ot<+8lhG<8DU|Y;GRrnWm)L$ftI&iZDPB0nSIu?bii2P`#Xbj(PKU~-E ztE<7U#!CW3OToQhzTkuSoq91hjMe}ATs;d)9#$rZEQ}R}t)RR53oY(!anud|Mc-iQ zOGje}>~TdtvjRwt{p|ToQF%rArOj??U2`M1|^jm+GiHSeP^Fvx5jmQ zV^aLK4_o8Dh_*K!IB7~*rrHyq;MQ2vRTLamvDC=aZ8j=2T7`RM#d_q=9r}!TFIeNI zei^*5Q?~Eh(iZQYa)-H3e@=c;ccKbac5B`B?kT?TDK{jQ`E4yz(qJx{NikQfOKy7; z!^56H?5GD!)8TLj3_1g?AsnrHXuw>7@&vgC1Ia-B8Y$SJmyFa;7F{mKl!e?fhLE8S zf4dr8a8rn8(m4m5T+)8-NQU7P3lS3V69mg}h@V+-5YQXFa#J;t6~@pb5yZqvI-M%^ zfOU0n67h02WS{VY!ZM5id6q{E$l5rND$TKE$R81DLyn3<@+9#u!}7Du!X^~$bO|?M zAwz<;NXicYIdpQYLkCD>_`@Eg5)L2)cxkbq8=PKHF+iINU<$fD5gvBOc%u{CF`PlP zt)xi6q2%m;wq3ZzGor?bwBK3MyRrL*F>V80B*Zn+t6T(zFz_((5n1?Z-?Ki%jX-~s zfoeeU!;sCY^g6=^+u_!Y8UFElBsbK zJ~W4i%876RRV`F98Mq1w;3~GTjAlG1HwwelgnUU@x!g3W%8;}PcPlFi5RM@|=@;E# zkXV3b&tC?)6bs`*K6tsP(2mBDQIlTouYup;<LDA?qQ-zTZMGU`!*A^!@iIFAfSpZG#>uzwAxugx<-hXIN?t4JeP zJP{Tz+_;_~o#TxwH8*$eq+VP`4i)Z$tm?z+4J8rauSvuS=aGi=s!jMapz~AmR;XW2 zG--9}CBlcYo=AU#Vm1<&M0Hb1^v+FK211P}Q&tlPoMrWuYq(k9+=L&)MRI*$hC`+Y zxV>13x48JxVosUYi6f^WiaSU&ns$#<zuocM>c{%*X;EA{ZVhqap*296GmEZs1u_6~j`By>o+DB&OdrSzxBp3&;! z+I!%g`cNNi+k^L@O<=fYF9)@h%LiF=ftmg&ng?K-C~jho$dY@KmMDFwmxigZ|(qO7QKb>G!fX?Bb%-+ zl9z!X&uR363C@ZHTz2#M;BRRT3LmoO)P7_mW3yaDXg~fa z0}HPOW{1k=qP1e4&qYf2*Z@xU=tdF{@Jh4n0KnhQUhdX46v(o0_kMk^(_2W$l$UyEH5$*eWLVjLu7cctO3x8gP|nExt}`Fpl3sJA3UeG!$kyz4AffU zu-}1kratN6iWn+m`U-EI(fX`gn^vtxO9ukzPJd@|09mnFDP_=s@glNzEc=akJ#td- zU5>)`;+o{-6Jz(u*D-S+O!8YW^A&K)EDHU82C+EMF-%`PhRVtegB?5A<$wKIw-~sd z{YSLb{@UhuOJfzy2Gb&;F$QyEi<$K3Q-RgH{Qa{$jC2(Fs1qMLE@&uKw)ySTTsKHI8eDo%sB3mFCtRtr-G4MmIXMH3y;kA~$w+UVrk zs?lH_S^fERRrsr~@FG7MIDZ}lU#JdXdRB@!m|A_sLgBrS^iKSOk)<&}DO5D&&f{+R zjPgHl+r;ef#{0CQ_H)B|zCQim|FxR?{&{}ZZK+QB z%1TAq@Ucxg&c+Ic_L-8&*2)Sx>3#Oq#r67oypTFo;%?-harl;P$0uEJ_I04*!gyK*y+qOS3Pu83`1ZouY_GOLle!Gn?pPttUojs! z84QJPsygku;9f{>5cg)zFFRWd5q~aeSb0`bCq9q5hHQ0x!~)9LIh< zeP!fcvLLDjZ)xe6QX|En!8Wawr>O@}KIMioDyAxyT*}999F;9UxvP8i1$o6!a_e-i zaio1oeL63KYa%<*`j@TVp}RQm{J^$#U_}T&9RY%SYw4;w$Q|%~QRgciyQ@scN>ienaz0}Tpxp^syc`@{dTL|L6 zzWx=&Tw5ML`Jv%Q0HCeA7mowG9Q&zsOXDz*L+I|KzT?wc-T{lPyA;BUf`4+gzgu{7 zbV+$Zg841CZ)|D1LG$<4nN#ar4_iG844_G>-ZZ`KN4{r#^Gw!fbpO145#+LIl) z7#08chf%)Txm|gJLR8YT0GAy8Ejx)~9mCLCF;wELap;CP(0k_5^3Qnzjr#j1#1Qh# z^*@NDMt&^VT$TU`Q!!>R{>gA!eDd43fH zXfSJgrXlt73EBaAN3`BQ!Q7)`M8fh*Gz(@T6f{7W!Z^p=GgjU zg_V)|+<9E@f^VC2+$uzb2+1)c%A-!2P-VLosMb)TQ!;|3>l_c;-l9eC6Ym;~YTxIX z91gX+A2q)bZYE9o+PPK<4D>S|fW$5q!2_c!MGkBPzV?h5?~IS40_ZjBC3${^RRG7~M3C))zo#J-Noya^#$_h)4gz&(*>F zkO{G#1?m!$p4Bic0X*FfJhqZ5C~0w0 z5CE;4H1oq}K!9Ks4l;zb0z*FHS%?~AHUTiVg?O{GY(>ye4#Q{Q7i2{Re>w+{Sd1|8 zgeuiZ1E2g$G`gQiLrozhcbGcjBU#*hePZPecxp1d{g0+gh~}Ym%z`-u*=aQFFkS(*gq4$K{5->7k^pdvz#GM5XbL8u&9>#Iyp(K zVDT9C!Hxa3dJEMINkr5m;v^a*2?Yi&4azYAU~|@cxMsFcNi$rV{FKI3^AOKo9)6#J zzEF)3p{)nFo}l~Gap3;3e*vc;gs~;?$b_e{khQ%8EG$W_MeYJj6NH4GIhWVtoX(IF zOKt=*Vu*OjGPmUIVwIyPBX&FTCkYzk&EX=7r~veY;gN~0uGsWaOy71j7+}hRoh@ip zBhnHnh&c#s6(Uu00EHO>7|gId?Wx12S7L}yr0*bv4<MYoOi&t^vV7-p;;!z!; zN73%1TeG43We$h2Qf$Va@CXivQ|Z*CV=xD%kcjFL$otn9$BA~xuS^ny;R?o_SqTWd zcE~Kfi%eEKmVbm=av~ab7SPWN52gR@;Z$*?pp?QuE(x#Dp_$*{s-+wyZse+_2L9*P z%}owO_liwT9LYiEQ#uq@QwJ3XFLZsuUG6(1f50?Qh?AEcbq<)|uwH~>?-euz2D#Aa z)>74F09&H%l(F!ry~ergV!A9{4 z79}d@Xf~Loi^H?Bc?#O5+8&(^w@F9MO_q1H3}+4|$vo&a0dXBf80%ZWq8h|lA*cqd z8Vb>f!VWMRyBIe-cwmbNza27)5*#?G-%GF&pt-Ox;-F8XIVDVMRg&+T23R+k+Xey<}zq35DweiEcjn}hKGJ;ilD>)0b z(4T!jADuda0sbyC#r&A}SKIrZ9M*Q`Z?HD;jtxR64sA&&#s3a|e_fmQZu9u&1z&^4Ugq{tfkB^-Hzv2m+2^V3ybUvGw#s?( z1Rt&!;kK%YlgWnmJ{TO$TQARiahCt`q2ac-^y5F9Z+rKSms;uPm8E`2&R@Zl_8MR7 z(^U?B#Nymih*PXiu3O>r866taGfCXy?H$2AXIER>-BDEHxwwPF``LuG_svgnva9g; zJjnmoOzGy1ynL;2`^};UscTCwCI|~LDZAfiWep9DK7Xe^A~rZPA8e3QGMv%bVmH$H z^xUrC=hxRHpqvd4PpJ8nEuLEbHASUjBswHPz>;?P{G{IAESMXZ-qgK*a*tS-q;c5B z9%~=LDzsn(4IP_|M8Cn~gCj|$@*pwKb)|5uVKrdDtlv${N&ShkO{?Awk^v65sx)it z?vBHglVcwHNAd1N$j<_lUpw^L4hIgS8TvwFw^*IUbwlZhn2TP=ExJ1+PT!`pu^*&N zT#oY^H4-h3Q^2BWT`48G0TV}IAsiEhSKZ{&9+fvK3fcl_%@r1Vm~^vA85`U{ob;}d@U_u%>XY{TDOQ4yYXFjqx?)^}?*MqP6c(EN_8 zwBhUIhWIcs!=lZ2Ocz7bpr4;_n>ZeD-SlPkzX*%9!&u4nkN58bwmcthjbG@Si=K;T zzRexXWWLjKu(51go_1z%YCMMeBgcktv^gHMYwuR~>MGbFdDeWmjr9g!p1=gO08+$N z@k0@|0)M}w-I4e@*n2BhxniL)G;Y#;0J&w-3$^dSUC}Gwnp}9_LVYjaPL6>DF&Vm} zgpG>Q0b7@;^O93r_WnoA66TSks|BwP>R|VmCcaE6cc!QE@3(R~cKSUhXGW zFa(;&&ivL!!l>LLoPo|s%C(UT$!kUlNPuu22j26RdpQ0Z+Vx4j`$fvp=;T3i4+abP zoLJ_eEY{rWeZa!^r4*4>UjXHDxGe)7IFrEGhOy841@lC^=wQCMWlDmX&2cl3hgK-(0hgk9!MOgL-85O2_ zbx}BYC-vIy!&B1rio9prIT{b{)K#8xE4rFoAlmW2w?U-vE9{6xkhrbp?EMz*MhUnB zNf%H7Sl%Wgh61tG7Cv~qP?8_-J3BU{vJnRc1y}klhNo_HV)nbDqiuF_LeFVJS2ZTp zTFX`&(``KE!b`t4P0|4!&KyR9$;GQF?C$Waw(HVvnuJ59HJ`Bev_9S;!s`yI898!Z z&UgbEBo&L8Hcdj*@F+8o2n1yBA#N7FS1XbrWjB3mr_*r(wXhOeJKUyJt=p4Cm(-KL zrLIIhjq=J~*kXNpRZZEXZRqFF7fw!B_

g>co0oq=Ty+dt%6bllqr7PSf_$=b!G4zaRfyHj`L5Gw#$Po&gwrf2+!JN zpjp!&Xj^3K59@!u|Ng2&rL=)HVZ$ zE0LdSp%Dx>Y%4PO(r}z4o|8~ENNboe z(lpcYZ2d$Q*!`@Ui9l$4o3(j}wIzW-fRj8j97MT~gW~P#20Ow(=@F_elq#%mK)TS+ zSm8gce<-7t0hZlJJw@!*hg}A|!BqM6dX}9%)CBNo?5JfY8dw~7@hV|%H_*6PUm2fA zURu7A6i4OAyC8W_I9fs!dUS~=2d!;%+p|XeV-frxE{j7M2eEJI@EePm^o>;`LfA>2yhl7Z!BYp?#buwwM89LWS@WVH8J+n5^2IcN)(C)o?qmWV zMjJr5>D%GaD1`PDtIC9FB)k)ZjJrE1VP4+|aS4rdoK^ner=G|vH`a42e>LoD+P%(~ zr$&ZwW-v)dbn3ydSy|i$s3q2sp$g<(Fdh{l*OOc?cGI9l(1=ueU3`nVqhlJ7 z>=!}EOv(XOiW@K=;JgLb$Z-7L&?QbS_>lgAKRC>U5EgoDh>HuNfEp?GDs2tPsoy*!LUiFIqb+E7T7p!A0F}Yps^VIMKRA>Zkiq@ z=wEHWnronCp(=Nz_h&LwD)&gz7Z>a&=e^1_XFt(G!uKg{yJnF(3MzwH>-^PrNbK3j z+Ulp5RL}%iIrj`+xo0Mq8+`X49}JzbD)~{PRC9XZb-BnHhaAgThYQKj?sVho^64jI z*zqQ%K&|$?U>K=_VKk?jMA=POd81&+Wz5o&$c2BS%JMW_gMbmw_)a)zi>2dHtgaV} zxZWqoWtOl;$jD!<7Sf}(HWA+fNm2M5-BAcvTg&1BKDmogDy}yH%R41uV~yQ6 zbuCp})EODK80}lp>_^Nvwg}m9aS#)GC6ctk@Vv!nJe${!7H=yTc5dRwcgtn?>~ji3 z%}NE$RQS_*kXmmyJxt>ujKwm7PhZ~JY9|M|F7nq4L7{>FH8VkR|xjKdAIR#ggS%diyCC2_vxd#N`4g-f~@qjXsC(8X`}(! zNxzGN%vs~0l-l^QD(K4R=OQ4R9u=Z4FAfc>L5FOK=6kEz1$LPns&4e3Vi&}F?mpsnLp3|E5tZ*C0v@RLTuHH zjh9`*1iw6y&Dc(nMZXT#Hhnm%vc(1{h4&QGrFGjh_6l}#6AllyIJicB(_QHsqJ8R^ z*A3cRySdrH_{WEO$1CAmI{ROf3`;s^DC(op&Fxh2y_jSERYm2rHz?GP4W?bW68!T1+A67|6$5QQE=1c3zzXIeNUpB-JGI(0*shn>?#D@<)3`rkspxox zFIeNp*M`Ar#+gIwj?8^F7#H~N%bb$#&$Q|t8k`onyI3{I9q;Mo5?iUUJ8Kw+v&+Dxi~_n23n`>t&u z_*Ccd_~+i)jZ1xF%yl8(+?FVduWJi7#D1yO(qD-2%_tCKBX%-magYy43{)m*Zp&+@ zBA>5vEEqlbU8`bxw|?;C1)+bU83m4E=I8mk%G6ub-v!IT)H$6Bl5sm@boof1%?g9> z)A6&{ibAt_Z}en%L`h=X#NM%PbiUSIUxwGF&O#K%#xmKFN(s+g?t3XWeqPCuyIGq& zTSPgJR@i756)wRDMCc;sll%Dnj8FhlbZ|VZkuJJh(fxE`>q^ZGuot$wI)+i5(bC)N z!6}enhzD9&gu8$hD4@}F_3}EhSCf56)9=<+87L|XR$js6XR1Kz>&-V;ZeA1i8@KRuYb@di^{8or1G3-y)eGvv9@lSs5eTo8EzL{KS8Fj!u7Jvhz#RmPh)BPx_fDMk*Dq@>wZ$ z6#90mhOzy~@^tsOQXp7YG@xr6?6?2abrqmZ+GDp|W{didZ@{52J3(aF#YrsVWojxr;9AQk}tc%c-UJ@sq7%bI(;a2KjWR|7Srr6n+1R z&hh1=S)sbx-(R9~TmyrmcyGC1$7-~y>#YKaCYIE_H|)8D*yI%Xj%T|&!5!8kPR%MZ zf=4V}RlH3@=tA%q7N9AhXjyatR5`s@LAMUq99h+t)Zo+nmPAheyCT7ntKfN&vj)|L z%WadGK9$#ai1*jne15Kqh>Ef=X4y2B?=7;Ui6RD(ttsHYU^b|}TSUmndwKM+8<t?fZ{P!m>%IQWA?3apTsU~f9gYaVfa%Mdko((n~Grt`$L8F4? z!#3S?yWM{A^+7+B+bab|5Df>!NTPeKpBnvxNf17IV8d)r@)Kj14|MX%{h%{R- zn_v3hqSaRrGt&*p z85S-WBGi6@5Nw3A>wL-Xw=hdB$7IC>7sgfX$4>frtZtREx3Fgf=9Iv;rxd$nN(2me zFtWI1ZG^ACZ3Eq=tet`hm4;QU>L=K<;+LD&@(US_)nD6$k|gujcpgJEmT$CMSb(o zK#%x*Tk7u|hbpQ==W>^#7pBJMd3{b>Hr52@B*-j7qg{r8iG&*4UxaNCya}*`CJs=f z#T20W7f+!pXdS`C)FY0Wx`f#MKc^G7OoEz!8RBiej1Hywk;{h5Dw#BRbX$A+n#59_ zPf%ldYix<|v~yU;v^!&_Ly|x>AwsRe2M};f$TFKr6P3Up;*)r9h|nI$$~WsH++_$8 z#7z_B_z!sTCD*KF;o&0KL89S&#IZab+!q3UXFq^{-h(4TKYsIyay}L*8ZROERhPh4 zylZ#_TKI5fWckeNxxoTb9AksrU(IU*<_Yk|S7H(R|5rLNL+=CWV>kdQ68{<=ho)>y zMbE3MdDlxVZKC?BgsGwuMJuD5!`n3GDXb(b3HgQN^G$_-#*>GOnr?HNatyct?iOu= zMwgoianZ{iGdmGZ0Dng2vZNM5+g_{M>qN}YPrwRNRcsOov?S4$)l^5Ho$x^P+yn?Z zB@7(?GBQw^aOvPmLQi}RIqH5EeH&#^H*q(2fFn9f3VTAB(2}Z66;8@0k#9geSc-MP zM_JK@rV-xev5=Og2{Ca8hR0<`fy7{urt~MbX^!1rCBt9F%gOEJoInS%)TRy##xz9= z5?e=d;h|e)Ik}UOhv0jl zi@1yp$L}r^CiIoG^+z`l@(`)hSeG9b>7Mxm$I!o z0}M~1iR=mi(#^m3O=42d32QFED2JWLTf2G-%SpP&syEmtT(P_h0R^q3MHg7WC;iOu zle=^_UKB5O z7RCNQkqaD}Zze`MZxE{%tkM)R@*pFG(TL|-4SEg~g`2w3F%(He3EssQrXawD1F}Dk zqy$)gt0mCiW~g#Whc2i_1CU+;yvHoRz)|A1cCKq}negM^Yio;^8kkK6lkrPzAxrU# zE8eo*W@mfwwV~Qae%GrTI0xDu!g&SG&q4{?~01W&l0f#0gH78$cDMU@R7Nc zHv2w)>4;-UTt?lA^2tnT5k7PZvsQSG!YXqj>~aK#|F>AgUdPPIXZEyn-UxmBY@ z*)39rw?YcMe$XOY*EFeLXqVG3XWX7PW3bw58TA^wfyCT%c<5u;9YlKqo^%<8d@@5+ko%{Y*#nSFQ{aO{1T`OrLw((c1-uqpiY-)4CCf{h> z>*sjqCg=)+8_rzsQrI0?SN40>!eN6SPC+NHIP?aluQgeY>7#CnNw|i0!O4VJRr|Y<((QJrFUBDFr(dhf@w4A_x`QTeM8IolGjJ*f!)%Ao;>)?fbSGi5& z{st5>WbirMR)Z%8MWiLz24kiaDJucbciHj70&_p7gc`LcZZltpu6<8GCvMS}p`YZg z)3#IX$${aF@~@A+)~Q%UO+UFITDngGp=sL5lhw<=oOQ@?$ z1wLl2H1pk@?NUcYT~X+yR>1VhDye7sx#?f9qY3S;w-|-{HV?t+Izc1J@qyEo+(8SU zX<0{~-%{8gru8Cp-aIrr^z+fh!G%v&i_71>J38`4c$X2!?P&GFRWMNmq(lKMwvk~M zFx~pD<43a?u{a0C2xg4x86z3`exB+rn2j~;*M%f?N)js+B;*7#2j(ye?%iDrh0TgT zQoo#WvLkV5Tf%LNj-cz_KjoWPoGGgF2iE21>VDs9l$}a*(Ch9Y-pj-eB~aBz8aaBpI`HJ=l*o%3bYdHIqQIKT%%FStp6Yw124dY7NG zyyE=o|K|eWOMd~)(WFu=yWjbW{6&J1h0tLs-kpsgBemD?X*t)YU#Aa^zVinyPq|Ch ze11L&tMH1BbKChqWkEZ+YeO5|bwCxZ2hF5*aX7HrC}Cs4+4qR^B;O;a9V3o6RV(#~ zE4z3lVz0u$-UOS7q>@>{)dX=s=+@6^ z4~u)OfB;2p3|*duEMC#_?lI)oUl`wB*8X{0!InL6)M?X@+5ScM2j#et(v-zLe;i?5 z51yZm>NtxTI173$dfT>vvm>l%Pi?m3c*{ON&8{ghS#wNRV2fCq*23n{uSLS0>BChx zKFixtrC%S>CQlnX;G(LpSRWqzLG1YrHPzf^&l7qfBoSB;D@Q0ad$VeulYnI`d+2RyqkCRY}(u_T)WM5KpVoD=AWviH?5QT`}b)TN^>-Wbz z&(v_v=X^f*x$oMuCU1mc*$&Gg_XdLUBbQP(lz|fL=mAFyg-aV4Okss!hw-BKPNc9fr34Ng z8vKi`B4;Da%cR!8i|Ao!6)Fd|!lZ8#F#8^CzllSIq70M93}b1^1U2t)3soMVZ}T&^SQ zg4G@!knh#fg(kf+g^Qqb-1i9z`Do`z3DPiB#q|I6q}Z(oZ5+akq&P@fh(RW)BiM2t z+-!$mR*gi!!3UaHpCvkIgSdL4kz%v{faI_kWN|g>T6^pk4xh#Tv3WTsfVI zjpwgE2>X7qA0HVA2u&yJqRkp@0OMGF4wB`8^#I3A5ci4ff(o|lC)i9ing{x4{lszY z1m(j$5WNy~9~8)N#h^OT3~dhDiSh!M%uCuR7c_}>GgkkkYbMY;drk}2O9m|}OF348 zyl#~ji>a?x14q4{7WrT&`H{guMd*Y~hz^A;*Ju7@ngd)qo2gEWA{_v5hyfZ^i-6JELKjoY_Nvz9=y3f6raq`JrQ> z@ksxXx!JIowky|m7Y9vF@p$LM7`;h$k-rQ41_Tu-dG(c)k7srNfIdOVO_&&%iK3yO zjG3F=M`euWV`b+Ga&BeING?w=)TV~;DBkFzYjJONF3{#p=^nCKE1Yb>thZ1&aNC@N z9w|=MEMs?klEI=X(1t1ge7v+9T+WGLE_XiCp}|9im%*XAOT0#xDz@20Mg?FAvKiP&W`}gj%w&DSb>9I1N{jZvBLy0++!Svy=2ctj-20)c}TD~ ziLF|@Kzckp0lT?eYZ?O6hf@_!=S52Vg5a4d= z$8)#uzBqfEDPFiq5*x2AY?Wn{bM1!FJxl~%OzM@&lgRyp;j;w&%1`hzXs;bJxYq9G z+wCEFypXI@YAoNniK(e(&%;3H$0r(iW&Pe33uRiv2eAO^@FbW5H$t}3%S&3RIy`O= zypJOf$Ni5RshrXG^F3YOLhA^&1S3gsoQn%zhotzHigRb91X7pUn0;^r`|z z3tLmB(&1HAb>GMY2KXtTw%BRm1p2>bZG551pc6i^g;nCs>kIzh-HTQ~Fpa@2c50VY3 z2ttA(4eKf6%!wm)EjLToiPND3(C^6_`%+m`6HPJWVtur>^*ejE?IFZOPYfk(Q01&~ zQ})sa#DG-QSn>e00v=y+AF+Tdc*GhE9C#qVJ>*c9Ieqx87DHU~i)Y`DrL{rU3vY5} z_CI}S+wyeyNZm!lV7MvrO8$%IZu_C;;Ep*8Wm>VD|Q zpBui)F47j`!shyB_YdhF>95%kN|zC2ZP{E?qjI3Ja<8xNn)_*m#dqolzF*YjNtVM> zd=C6@xaQ%9g(>0xm~BzHeu2|}j!L;Nzq#@Je~-g5!aj=pJpX0>if>k3>)J8fWs}Yx z_uBomWJMr$N%hE9V*no@#(rhbW|;&ORswkbhBBhjg}Pn6K|g+^x5L)!Dq-HYduzBJGEZlU?}Y3lu!;S;|6W+tP}Fm_yl z0}74ee%A&#e>JyX-01CT|A>{){-#i+x3_O%;O*AQ3F962q~E-GQ}WGxY&DfwQ+MZ? z6{f*yh_6)eGGI7*|EZC(2=feHtkISBy78JzPDMamf;47BV{kH-Yq#2PK|jdJj22yy zcDY%Hjvy-VQ5rPOn`iPpIU^q90Q##}S0N(yY5T$0_u?>rOzKH88#!vV){MLiHcU;l z3RRH_nO6e;F%54R$P2-JCcM?!ie9qIBbUPE!fJnM7mM#X^`d@qJrA$LAd5;VFH+k} zM`U|Ti@gdoV`P05Y3XCdS*^`5@x_+XG*ItH5r8%%7CG^dyV#7-KLp*Xnuzy3SO4s6 zapl<2g^uE%N9OYuKW><`{Vd%VwnSG@_S;$eM>?zQkeT_QvJ%Vdcx+~-6w=j(f?E*u zE@nzOBk*Oc9UF@zZO3iUn-q?=vhjhc`>1NivOqD1*6X-te6H5&(EdHz)OoIp-uLZM z-%{U5qe+!IfoYQnfu*_kQ4VhTmE`gjK57<%7RzdYe@?i zRXA|PVU^niPQQ^+KF*pMTAaH-BfI!D4C5Rk^XH~MH@{^i%sfr+8!nL+#cc&2Hp+~C zfNjC^YKc>~MFUUh=fyeYiBJCXqhT|X^9ajn3!T7Hx!GGHefVTAL>yj-0SYnzsd5^b zU^fG;C&P97rhi_$zf6=KiTd8>(GnDSLRYppU+&elYqePo*YScEbLMNYXo23V_bj`{ zSk?-Uat53l#gL!Z6uOU{PYl`0&^u7*NZPPfJ0rm8q&E#1e@8Ksi~AZ2`lJkehdER% z?~=S(Jo5J3!U22N;yYpCPeLe;xh9g?er?vQj2#?$UjHX8tnCUT#llS|e0^;TFuSDK zV9;TKeXtvaG_wNMS`>(9Ti~+(yaDuHv$pM4i|1cqdGXPth5-vWr|BViW z$!i5F{wQlD=sGrX(Of0ucHg;rhp+{s{v)$17Cf&lJv!{6CmJi)15_W*rfUzB!pCs~o4!sb z!U+;l0UaBobUU0EsW3_O(1q*~f1SpSIV&x3Q{*gDiDfPE2P^}q!e$Vl)8~QRMGZ!1 zpo2LYY~YqnqpCwWQKFq_!+BDP7;tkCl<&YOg_j0UE6`+e5K{+;*p$+N3DRy-W@tDe zw>rGph9v(eL_B70-@?y5hzOo_CGki-2hJq)>F#c9RA>pM!>>>U6$viIm0;H*#CpQO z`!V&Td#0LT6p7^dMK0s(m^fq-n^h^g0@o>r8AS*3^8v|0>$H0;iMgYE@>@Rze)wiK z;Fa*%(dW}=9kd)&sN^GwJA>FpAArX%!h4b5tK?IsuuvB06D12Vyi7oKfsK79T-y4(5_5|vwZ?CDae|V2#-s0(Lpwdoc3OV9RnFbX+A~kL;p5&qsnBmDyb31{Y6a-w$MJDcQjmwK221;c7NaCD zHUq_te6vkxqmBbU{g4#}3u+y#&XI?el7$tN;e$8*_5U#4+{^al%&62b=R1kVeUMm6 z66U^RCCoBmvH8Dzrsc%A8-M{oAFGu-Va+8U&4$7;x3O9{;A1mHe54dP*fV6pCm$!~ zax~5-z{JlvAejvA%F~CvOH-mapvp&1tua+!eAKLrJv}RMJaZ%tfI8pP)YvQDQ*|o)p5e?>kp+z-KMvJp#yRb~ zqy9jm{Xp)Wms?VVT<+Tc4!&KiL0!8i8oEHusxM{~=R^|@#>ua8m(WXri?EE_S3LBn zl6X6d#z7M~8Hti-8mSbpF7B%=&9DUvZR3Y8W5035KXWZ7R879)IzHV)H)=D!JuHT1 z$1?I1F?iH^5W%P(u0S5pV+eea-1L(Bq$~Nt-%J|BTJW8XK%0JYqD1@-1N!Lv(PycEn*xcTM>kVBElmM-%BOPXsqPf zyA$2g<#-|U4D2yrf|GqTpA=aESFnO*%!pZZ!?E~E8!%$=)OG9zcf}usLY1Qp{OJQR zP^h7}ExzPzg+b}Fjk`imPygAp5vf>Rm_y-TiQ?vvgMpKyM@S!d^4g{O1z=t$?N-7` zdX0EZpm%9iRTT!3PxvNO2Txw?)s@cedWuhP+if##t=Vlj&kCLNTI3BI*u5yd*gZBU zaIbx&pzN}A>YCX1>`thC(7HLf}&FzALW4$N;=KWf$>D$0Mvbbwu zL3Z}s{6Nm^w!eMH=&Ro_1#|SPO*5W^@4z#Iv0f2SqFnb%Yze>NL8fwI8@O%R}zHGHK*}CrU8hDVP`+{KJ6hla~7=_OvqCp{#-qEs)deXvX zNb2F-xW#B5eUjQt#Tduyy6@et3ZBAF4Yzk~*}X1O`ZPW>KX;0lq+;{-ar<#fisZJd z@KsJH(?!3vZYR1Hnmp!MGapl3GY$U$feogw#o#FCfqtjBh9@ls9d62yLA6?6ilV-U z5%(R7!D>{j#qxBHIyVNPo{{YY4xMaQsy?phkeZ-+X|JB^2bT83EKpS2kUmhG9 zq~$5(hrox~inYEQc$0p2On0 zF^ac4t!!amdoU6;L&L{~><@mJMe!S-?Fh@BoE75!^ReRR(;d(3XIc<%u_0{W%Hq!z z3l1lQ#aSVbowI}IzHjSYY)mKTFHW$7u?Z_r3FQi~s;dsrkG%D>nk=I4n7U>1)3dYQdVFRbCKwv~5BblDD247=G|HqZ zSUg7~N6_NEhGMg{$$@>DMHjb5j)C8i0e7Zwz31H^0wOSqXtWwJwHiCXaLM zohcu=rVvB_>T|m~8m2y7)PiUTQ$@$JzI|D(TQNAfWBygx!tRNL#cf96<=S1^SfY6n z5pJ-+7D@(MU?V8~L-wCK>~XtDj8ES#Kn0z;+;GcHR|C0|$=DR2 zG5;SS%tk}_cap4`Ku_qg7%XL)iSd!!o5;UGSvy z^?VN63hSU&M zfOD|-|0`)A!UyW*Xx$0GDu4?mFTe)w1OZY^5B`d7-RbSGL>p$)=j7$sb%jhy^X;AL zgg)W7$QGzw!G?`+=bkifunDy$UOfl7UDAj2xt{d3-0xXpX^frP*hSs!LIWM>DE!e9 zBE?oC!j>xOC6;SAky>SXP-#W$_VKcQoRaMFfiwAXf4QZ41b&P}y@m#~?rk`la*!*5 zE;vE9fFN3>s6*@Ca8l&xv+e2Gd`-c7g>G`ktKmLa=I(!n5{4Qx_F=rb=8=upAua1K70>t?$xo)KUsL zoKA_2Hb(<|)6;3g_=F(%2gh{C{VOLW$VnxB3-yQ&<>38JQf}%ISy&Q+{_^U~y)6bq z&y%>UNQq8Qh;zeg||*#ziPHZt3637^W8%_xC@LE{IVynm-$MZFcCQ*80$QkqH^lNG`nD!&BT_ zosX$a+LrPWmU&?~^FB8Ke4-@o$iN9i*wu=0KsN=pI`N#Sn2<`9f(+H1f`j-nZ}} z@FoLA+MVc5#^8Hb91+s#G@#^F(Y^Z7athtwdTrN_emV&AKd5!DKo6uy8rg3{dSgAY zS0PQPJERE>BXw9O*m^aPU`Yjms!o7g-D+=3k;VDrm37qySZcQ;=H9 z!rN9mW!^RQcyS7pt4vm|+{*5Y6gOo(Ih4fIq?&TI!Eh^RD;O2z>{P>6h7?w3DL$&$ z@1q7dQM};My$*f^y#<=i;ppiEAcaw&Oyq?NRG*TgWw3*HR6CPh0`K)Z_F5^23e)a` z%GKPgE)E~^m>vcQuC)T))s_3lXV-^KriUekeao5MF{8NHRmblY^Nl&&SDjl75;A|y zjG0!*4qKGYU~s{MF*0uYLXIejafNo(#5GU(m_adOQAI zO7_(Gvk6v@v?kB;@W)`^=d%*J?x$|=COG(Ek!XI`rS)1V#%LO!VCkT;qqI6XaFQ|v7K2b(r7PN@ zSK*W2Wa``e4Ouqzv+`$~G>n#fiM&RyM> zwbob;BV69(aDM9l9j03csT;uAw2xd@kKQ=n!s|PmR=l|7jN?Am^>*=?OvT|sXOpKy zznsJmX?T{#3Yw=L#OLN%5T>KjCJZvSOPzDN8!fvqI2XE@c|bVhxlY8buIqQc)~G0$kAbb+ z=ae~`-&Kz@8^xY`gHCt}@6EtZ8|CSTw($QRpxjLZ>7FNHoooYlV`e*?w0&Hz>J?5? zl~pEmbQfTzo<0Nrxt;U%V?X74ADOqC`+qo1Y84{3Wr`EOMlhx@b3q3P0goh;CvgZ2 z?^y_;$Fe-tQ8gHb&aDT_B1}?;N$=_N8@Uu+-LZkQldeYNuJiGAKXhk&7v3CsuW|0r zf2<3UK|6gtVuym%i?_M-yCMbH=LsdXY7rPnMEQuDwDJ7K`KiT;H&7*yI80$4eL=&y@hD1^SLv))|#c`>yR|UWt5HmT2{c*FF1* z_3PlgHL-jbo$(DjW}W8(w;?~L%p|bik~h)hgZNKss+XS6&3ZzdIcYvtbPR(wg81`! zn=!l+BZ){imSUwAlx33Ki2*+Z6=UX-vNfK1#dGLwRUqb3(2InvIaU8as@1*vMTwjqAW#TQwh_zPjPN2BknU9Tz4N2w2k1N7&M5|Gl<obrdpohn?;G?@E@Na+@=;G9(i!wvcPbHH7ZN1dT?BOJ?w05U-j% zSN2_5GCe*0(h=v&Dm=?r=)DvI^0=^(-dSeZjVW&o1t#67;ECn4&HpV^T22ap+!#0n zbO#&}#l)PF1gyj?T{217=M_(+g+p6Nx5zd33)})F6(k}euOB*}6kGP|#$?7C>mXSJ zYR3zADh8jIK2tx=ZRT<*0;GsQZq%CXU<&29xXg@|S&XE5gAN35acx2a#O$Tx$C}au z7#{j#6l-f#S-~Q(2=-s7g2Jg7fp)!2Mtjl94BE4e%hF?;qMnTEiTKvY*HU#;m+Oq3 za*aK}=F>$80iN4v&=CcL97S5L)pwHv4zZ0B|Ee8~6w=%!2wmmREs90gtlI2|&b&jRGK z4D|)bqD=P03TE8efnkr*)kd_V?kPYOLWKsxe*M3k*WF6tEQP+-__6HIGM&4#C z)X~6WgJa9^`q-u>?E!)a00xbX{|HFbYJe0qN)G-T2R|FCY5f8?>>M3zn}Ub07%ZC) zz|Rm73$1jQtsf-k89ShN&d3tgch6S>Kp)Srl9yp#4-^=6+P9&xj7BeKB)w>+3ZNxkQc2YG~ z41z6Iz!ltPMh88`bDP;nUK1=U2}KG3i%>M$pe_zWCd8|P`6Mv%ffd;67QS%agdXS- zpVr z1nPp8^EFS{Ofsw(unY+7wIK99ZlMyRdw;8qgpBwKS`W9&QPebzgnS-&PHPl5b!&Y4 zk$F#gwxjOHikZO0C$jIp2(=A7*&%!G$*H-k4{8Sdjm9IZF>@jL($VuKK-cU=A1@0i z_tL<|olvn_%Z28lp|!Wy?Jk;b&K#%Fl&jHGa}7$a&6|pgR_=6RZ=aIPEA(E}boAAh zi5?D5npu~r<_WN7&0{0X-tG~e{JrX9lcdMx%d)o^hwajOzF zBs_{?_rGlsx3qB6aWWfTV{0WJ-mAZyd8Sc^Zi(ROdpg)W42><#{yF=Y!l2#3Hg_`L zcPWcm@j{zly-Z{s>|jAWF>J4D@=R(W8U_4owf>-FKw>D_XmNFx= z-XnNYb%qbzLZ-3k#ioJ9qr&I6op?JrS5#-z*EjaCsG_T8+R#5=JB2Cv%s^w?_B(mU zUv@c0F5iAHHtf^xwX4wX2jhYI)J zzCL|X2%1M9Dfhb;#POo1TpuRpyK(8Rr@I)MyR==oJjc0%+RW?mrm!#Nm8>E(url=`4wq**o6u1shcdg0Ld&L7IX&Sf*DYw zKWRPD4cRRb7+|wqX^9_rc=(BRql+FB!AEdV8N>Pau&8_JfizmW_v81-6QIp(1{0_8 zCaJl;pXkw-SXvX+?4*G>+Osr>877;eyL9nU={TRRD!+axPqS0jO3~3l_k0PbGaA5< z=ZD_{h73V2s|$xGbpQ;>C4|sz*?kXYFldQhdnL$djRM=&1zEifW%x`Uh${;Zr+kI{ zhkkl}d#}BD)(}`X8}_wgMVisPcwNG&!?TT^H#pDL`0W$sBca;Dydh&zSV_yp!nY=x zd9f!+n}Xe)7`;Ic>c-P#=d~6G>*kBUrWk#T3I3iYJNPI)EHUAJ`^4S%JCAb-#c9nH?D!Hh7;3S8}G)Q`{4`qedYc~hO+}$(Wkqht+NbOZBRzvXeH_9X_#z*$@p!$PJdIv;fd#M36)a~ zJ4A|uzR&BqUkyHUUe3M*&q+q+3w?N{c6S%(x4&yRa$n*(TTwL~p>L2VJs~9riyF-* zhWq|hdHsQTHU^OTwTQ-2|Eq!~E+Usys}d`bi*BKCiA>cn)jOk9*N8PP*KfU*A`R0z zE($t$nCmBdG<&E91HK&cgg!xI4qLY|nGvVLHp1A$1v=XA*Cmo{yi>5qe&gg#?p9Ke zaEoZ{Mk%o!sk>^V#g6UerK4E>H}gVm#>zq{|6BVjHddj&i&%!au^R1;WqRF*7!dcK z0TyyTUiju-7uMxyymVyn{_fY@meyyS)A^%HcEOMc`eA%SqhJ6cEfVPMvm`nAs^(C2 zA#*axrp#x>Cxgk8`Y93O{Bj zm~Pof$2E)fVLhnQFU!Wf@RT$`LZqBAcBYp4UqZo$w^N9CH(?Qw;-m2@+EsnNeqO2e zn}6TH?ZxuNi!^c8RWu+FH40Bun3KL6Z#aTax(Cz1_Sz;ctzz>5}Gg^sDj`l z1as~Z6l=&#{tHD+E7x|73x$F9YJCg6JO4Zq)L!?sc#(I0^vJyO*tu`Tk0)tujAYx* zG6-O}=yNH;XXNh#M=Q_b=s47Yt}NSW7INAFAcfsK&VG;7xnc zVz6`8O`{{r(-*Akwd+(~v-P8pCafU?i10pi@Nz%oaOE8bD^4@90Tj0}Aw-*!di60s zf;sUr5zKVPFSRtY$4xc%uzT7}Qs$teg9nv0S&VB0&_nWp1kY+M=3y?u;@cCgt8_G3 zWzah;M-mlr$MwRb4)B)o=#V2=g*$1n*O9Izfu@tNxYhHsD#(Gr@Co6(!8N0(zl*Fk ztup*>$*!^0D=`#aeYiVb6JLGMzYYe;o^sth0ZtAaIw!ky^qMTYN9wF6UlN>4kjaljAJp?uPiRpmqEE$lfI zj8V@N;^zV0K25jM!8am}*=}=TO$G^G9?R<0 zN1!blgFF}ldj47tI7c$RNh_%)sm9;H+HE9?XI12ITl0srvR{W6M?16_Y3@brkt2^0 z1TT0W8!<0TTMI$F+`c2d+Uo`XD3KP{Scc~1nGsA$Z+u@reAdcnGG}4OqK?sb*1{h{ zcH_sShw!Z%)TU1SgqQ(f^7jA!*^R_RfNOY|NPtJdUj`w(}vo<_7| z1WK_1FaeGjmPGP9kuk*k98L*(1|a_!v?&?9Jwb@lqKLoVL!{BT966xRT$6*?gd%V? z!Y~u)YDi;brp^3Kf(;2<3T82jdcFpnrOiPEX9fVv4kwY=O?KUo1ZFu0QJ(iT9a`S- zm1?)d{??rK%uB0yzXuQ__l?2GmGCr)$Az@J5}I3Ok!rj zVKZj{`$p`9V=kl`A%YI_u}BE1@z(&{hUA&^1X3q87pJ3U`MFc3JQ~rZK-f7kNNo2< ze4MWUL?Jz}N>l~m4wVAGg*V#Hqr}nB5Wlzh3d;znfXHVI)q#`w;J)e; zEld&@rML(83u=xxWJT=mQPxf}W52R7d0v>EMOFNm9bXH<_pgPAVNOzO#JlxMK#8*H zvM!3i#O{`eJ#rCJtT#AekvmEy%sr`Y-rxblJ>?*sn#C0jN1R6p7IlwfdXa_rhV(~% z`PbB~!iTR?B01P8bPMu>Eu{FvVs18A9(x2F^+Z9lj_5%p)Hg>n0lQD4qTq0KJ6!J6 zEx|7zR~j!wR0K{!8CiVZrHnuG={fTsjT-A_eSgkfb9in}aUcB9v{f->8jbJ6hgU5p zt>PZWpBZr8&OC%L3;DO`wMwHI|9jkt5ZTb#zl@su#^ZfUgvH$zJ~jV*n5$)*zmm22 zQQ11<62{WA{i2xga2+3=y4H(eHB9py&1@z*s30o(gXMxz+)b+wJOQIaEVb;g zR3RWt-V5!dh^#q)3m#Y*o_?&udR+?1Gs1E-(l7&8;qx-soE>W|KiV;VjIb|SDY@7ziFE@0UJ?`#~4TL>w}s5A6dBv1)5d(o*|9>;F!VuLm$Bbll2|ZSGCzN#=VhXotJqi!ZPT z!n^V^xbY$m0xpr_F9j8+HUslIWop4&tV!O{OCrKikOA=93(vAqcvjmJ4!v_D=4Wn# zZd!#AuM1Gy?E?_T)=G4T8+VO59`W3Y^TJL5agytJG1y-dc(^(!{IM}*FFw#k@uvow z7fbPa03cOetEz&tTdgsxN(8f-JW)w#T~0ncb&!k#V3On&ezOUTBR}5vP$X zlo7TvO+?oUSlJW zLH82a2}*R-dM)*wmeg>c-1-lvZls#7*X{v$L0a%i*F9F@5{v>sHEuf1&6I~1q1PWS z3MH@6EG>5$fyq`)pasrKi@>hJcO1a&kS|xbh;%Vd_Wog2IMszK(qgo{Emy>29XoRJ zn!wKwfwPYm-yNL|3>&L!F`Aj4S61(v%P0;V;2!x&4=!L<{1)yL<)bjY z`}Bv~qIY_|$;w8uXO&TV9rEmDdKaI*I(E`2WFcbH!1$;hE3dSf+2(uhNl2}zZP51x z!K*w)mRS*P0f<%gg7iH&JN!>htHNs=rY(m2{&o{q5ANvuKE1el{=bBuS)mWk*9RUx z7gIH?Qae2nv+lNHo0g{e4$7qSAmN=z1PvfM9i}(w(di=7sF6M_gi{)@j;jqJL*M+w zT0K3U{8Y%;K?aFmRo50NZeO=H`(MS8`-UF-Bf|#A=3-_t>lT)N`IJ=c{bs#(j8>9s zNh<%zG=2mRU$6k>%MVHkOZv_z^WXon?9|rpijw4iJAOIr!-4r1_v@NUHrE8%Y-M`i z+?)Htv5DkvwKyRQGXiaM`=e-y`8-TngY1DRj7#5}6`gG2ql9P!#635LQ(H8|IQaN4>f+n7*cnXzQ9hEE>=gjQ-gDh_*6${xy9usd6qujbaZPL-=tf!&jD)huH+k>k+ z@-3Odm<)9yypcJ0pfT9}6r=`9ppN?#>v8;D*F$BZ5p#;6!wJP96SNuCqd0sQFVwuC3xsYsf zQ|`1sR#?i^&XYJ?8`@g74$l9ZerRA3!W(GEi&ri08wJ12B-MS9nx`#dm`!`}U5@jg ziWS#j?1D0R=a1I~6%?_lE>usRdd8+Fq;3YjOnJ^z)p_}7s(XG6_Kk0+Znzu|kg1*X z@lc=dSnOP^cP^-&o_{j&poA&-(QSo5mrvduREN5RrcHIURN3C(_w}=gVgFR`S#O_z z2J1V*=CZ>+PBoPg! zK+5qLd{l@?-*Ck4rUnttQGpeYV> z*Xz-8SD|Zr;LHTDzXtIcRJ>CKi~)tfRizzpgh1SyxWrFxb2SAwB+@%l7&In zHj+4@jt(%K6vri*{(0(Y0s9N`O1x?iFKP#3h6wLW@gGZ)9o6i>@9-#JTj!tWt0oiK zWamt+$!cgVFYsG@JN9)2Mqaj`=m)^J6YyfK>5Q`fZ{#BNM;9NC-#1X|AJ3wT2>!*p zp*O6--DmVo@^hVh%_o!7GsvEJJp0+`N9Gy|>q9u1gi?hZ_Ax+mC{W^kBGJ~dsKyu_ zTR{Q5Hm1P8qy=OCBtOPIb2k7AYDv;mhmttn2WRpdHhd=Y;N5Vr!xvbzdk8LiC%|L_ z0j7fcZg&0NubK$yEWxrwWdUr~82S{>alqb_qoupr~2Pn4LLz0BV zX8UmOYCQqw2e*M<^!4P3H)m367LK)5$q-z1< zzhz_>befrc_?-Ng)6rfV*a70U5`l(|7J;b~`R`R{Du}4%K@#(KNT&k`au2{qB&s~T z%4{l0U23Urm6UI0;qm~wweVm8iuqs$=p&biPlTJzuB_N~kD)W_y;kt`qzYtDiYfus zFIvMi{zG_Tll*v|P*EeS;IOvbVaSdYB1pwF$uDSGeHjx< zqzoj51G(basCcS%85vw6CCq-g=DmZvaJ%t#Sn~nNGmsf12ikHk93^&@s(O=Jy4yCxHA3<=P!yvs1%G9_DBNAT6vr@xVG=rMXT*+21Vv) zc8||3WXTSnU&c?MPs!nooUQMs@-~#dzO{WU>kx)7%9uM8$b^fTN3I5)Ul_AA&aZVg zZw+fd9n>PS_#{DN_1K-x#z4av8F#&s?=2dr0FKjFHf@$a?(C(e$?Xj1KYMcXQR8S{ zLo{I|Q*qTCI%MYrn1>k|o3OEyMh*X6~cMZwo#tuoRX1s{Kj1vY&nT2s3OnhC82;; zf$Je&EpUy{NP8-|T8fWDS$qGhcx2L)1!{={2(=BEjjcupRF23sNUGkPnNj=yX#pD4 zFJ#l6c~!J`N8UB2yoHYC8hmZBuGyMYQZ3ws&mA{Opy0#@&7un7ZoKG1=8Tf?^<~zV-{{VzM9QXM3=*Io6j-|sk9`yI%JGkH)@#iX`w5CNILUv zl;rO1>!*6>8+>HSw zh%%qRxx>&JY#aGd@J~c1yWm9@CaESzd$ik!&kFmytD5w!3T%sDshuC#Ft4{zRQGXY zzPWDNw?y)ahLVX)VkES_b^;k{6IaRigN(T5N_^Q6Z>JV{M0w(w?}9?z2j9iQJG~Rp z8^Yq3i`Uq%idf^F-cs5gIxpCFG3omCH>+c}DYexnS?|rO;E{oifsD-Iq@K#N0RYp# z(YBUsIrd>k*l^#ozPYONyhe)+x*w0bS-yU-Cmr4}Fq(uL8dod$Dwxsxu)0?ZgFN3( zWRyLiVIFK(E4 zn^60BVwM&&xl#m+zx(ymBDqgmoo9zDPORbJVExyfxV*~FjE0~oims~=pF9@2f5Ek( zry^nBcsECn2Mpblu3utXvQ#IT_@&tPSaMR@+?3@ANx_Li&}x~!W)j^PSj9QO)(m10 zfWj^(-HU5Gn~Qwm6AQ2GJQor6mQ2|l@2mwg6);v%e2tDu;G}Dpl*U4IU>gQnZljz+ zI%O_J41-G$yd8D`ooY^6q->!M@D~@>ENsA+dzT+t71PAgN^}ymE*{lHx=>~;7aRm4e*&4g)DL+(p+h2{Ey={r8TzQ>LTl6mK=~pM5Lyqjs12`+1@&rTKx%s z+0c~5@!N}{=WT-)DmF~id{17U|LxChZp!X=p@cvmg{s{OgCS+@k3M-mkt;SF_Umcy zZn1^czayGA-Xl*ze`AfJlBH24pnE9Xgo|H(Qa9&`L;SQzU3hWr^tSm1qw$!T?~6a> zcj{?AY~?!YB!$Q|EOBfj;y2GoDhs_kHLy|kNd;y^JIfWv!e)1do~|AK@%to48V77|eY?^oK{JsMJ8&^1m9i^!GT~^;O zHcCSK(T~l}OWUec50xY|0aXjM97|OV8H+VFd&G$@Dz<@`CXmuA6VOw0qb6;G z2nZ=YTEV+00NTLgP@f&H33&v^k_vOP`U`7JG0-F?#-W7(j#M z4mp{aVK@>Es!P;M^*RUO7B%QhtQSuyMEbmj5uxNw2-RT-6Bd?We+M(d5a{JQQ#%h5 z$Ol}KTajiLh5EIr>>o(D^?0fvcCANuvJpEZMA$=>NGf{rd|$rRck?G}cCF8kx^q*L zHBUGCd3$`Ho{UETgg9pF|_xW1djFxT$A&>fnoQ%0`YHc!Nxt=@FKls70 z#lZaY4HG$w4SgL(^ZH@>?H6~v`x1o9F^TQaav1zf;3Fa)2{2hJMFpq=uW5saMD0Zs z##(M_T#^Z8NcAzAt*F9z0W1k`Ipjd($K}Z=uGTwunvE0nGpl8(L9x&wkmh+1=K$@R~Hw9|J9RM2F@)h{S$5NepZK_*& z`0)!Vl^}B+*kJTchT6$Z*P=MRsgtSLSX>3!lqY~tF|gPJAPPiYQfCqgcYSOe8OAYrtrxS4M&Gn00GO`-hAQ@^~tTtA!%JVAO->evYv*bLkjR zE?jm%u2j6$OUJ^P?1Q7>D`O+5awTlxIMNe%ns+%FSMNLpn!WB}|E|{Goru{8;j2}H z?sEavO{P-idU{MLi3hm>37gw5ENvWJcUUkgxEYq*I5LhhZDO-D_2p&69sY+H4b!UE z5-2+3_2?j(FytCk4Q6t=;Pd3f5JdT+S~-r2lX*1iftbV4UZmcqYKC{CN#VXAJJhf2 z_G*3Ir|pZ@lMz|;(;`f2fo4OMDqxd-tfw2Q;5?F@f#P9-R2^! zWkF>7n=_ zSv1rZ*q)Xo&bqU_v7vV9YgE~ZT=3~1bGQ_>cU_^0r1Y)ks@KlCO z>Whr6{L2`%H&?BzK(fsUh%7g>=aFqsvN#2xyH*JqyI{-jEf=AOjwAlVnYD46a*xWc z#oFO0%xL|1T0w(N34u*P#ts*drH!D!jX@k$n546q4JgO^c)O}pa9e@Jr6hPqCff;E z6H{*vN=z(Y?ncJv2r>}pAWA-F9+ext)H#oQMx}$~Y zo;(5Ea|VX@%@D1+j1^?+wX_vZ6ij%3UB89kZyJ8rjKlIe#yvS(Bt;bz#;qyhpx>{z zSeY4T^y->|x$4zN;!Du zO zN;mI@FWI)DGAVgF0^BDpPDCJo8r!GuLvIIrfj^Ab&bRBi>VWP40Lt2^Ip z0Mjz(PFt4Zd5VeMv)jz2plyk-yPPmji*$Ex*CkPH6(mf7 zFP&>Y4dim;dO~}bO5m_K$JDvU+3VV9%~6~j-=ich)n{xQl2b@*z9z1Qm`X-;WVqp$ zG-D+s0D}V(d8Oua2qY^j?fWRR?Q?eDk8!E_uVIscJssCKWJ&tieX*&2*sG01b>}8H zaVnv2CY|yT))CjQfbhOoFk<@qhmaah&?NFScRiRLdTxR>^mg(cq|+L?xW`&8kM{1b zZBBnJGVyJ3H2vd-l+w(-HBG`YUy98H*##7LLA~`4hW`n{cLSG-S0~&W)Bil{-#%vW zxUeKXKDNL5Z-X~=gIS#sMbq8JKE1;@o2gcEomQZz;m>rwZr3u*EQQ|jYJXmz-&#@8GAGumSSQO#FDDw5398= zANqv_U$1D)gj632&O@uPeRBBQSb3t`^u(>5W&i;Jq9MfQ&fLVUL4YaD%6x<|*rLU| zY1JlyS3eukce3&CDMIY-SQgW*Af7mzftzOPeXL=p;>Eh}&%n6H&601(YbK^IV6vUt zvFFGBcA3egVk^ksy=NOtE-^9@4p8m7g-nq<*PTdxA*lzko)t#y*HE^Dq48nG{>JJa z!W>Wpv*8ADSxm(+u4!I{dCn3`bb6pqZ7xH6&o$8sIeM?Yd^y9aujuccNn|MM?(=uIXH=54N%sL)#Z*M zzH`iYi(Yl)nNKQ%NItB>=F6$sT7Axw7VM-If?>#So+a*hpm;~{8AL4W2scSFGey9-8mY6A`z)apgHyzufRdiQRUj&mKQfC?D{4P9&#B*c5T? z+tz3dC5i5)y@K8v^zoSnMdCZY;}MB>RFqNLP4Ab3@Bf^0JuC}fgeq_m2n_r-=%H*j ze)Tj(K!yed3DVn{EZThlM;W{f+yQ@~P$!_zAUJ!=vy<#kP!zck=}CBAi@_uk_zAjq z=5V(-l#qaku2Dg5c-glP{=f{^s%(bP3Y?*AU`#L?7u<&Vv#xZ78`n1T)hxMKxe#$PMGz>ZuF45-s z$ob2WLwD}t>%=(n#M;c+tt=au$e!C@C=u;O<*}dtB#fsR&6Ph`AEkQM)JIKrtHD2? zo}P02cn$}S-YyR_VY$k*^V%S;NKRA&k2ZzvM%tCkWbFtALc01%C%qYEXJ`nPjW_PE8 z!rda72-zAL*SizlpI#ID! z?pEMz1@pb0sb^~+(zg%7HK#soyQbLMIV?;G!n)W0n)jS6Bq|ZiTk4>Ku1Q~ugrOln zKq3kUzGgeXQo>14{B2-BkxZ9XaGJ8QfdU~D@x?a(%Mpx0iyXA8qkQwv4~>&m`rv1$ zvB{k|gwqh0!a&~^NR#9i?0Y3nc+rShV=&;~LI*=?24NL!I^+f~TtT48mTw_S>%L@X z^{6`t=lxzZ_a_x^j-Nt9o)Z!%_$ls%rJdk8Z)vNILnO_yb_|D3SwD8*PDgx&JPQdO zn1>1il|8)I_umo2ehVp%bKjOco){8nQ;X<%ToH%|3&;(z@$-Z8K?nr0d~T;PZ;hqI zf;0&6i{<-qW=V4A!{p`2)O?hEhYR(9a{(gl1r!Bh^i;&c8>wikEhTO+&42rVgK-hg z#r?D|{j5KNI!8aSBb&J7{?6=a)msy>cm3#wu$;byx`o{fPwOUqC&m_LjRarCe`?hq z*47JfT-ve`Q{oY%^O23wYmIz5WrM8xwVbTk17Ra|gMEu?i}FW)d`)=wO)twy?r`_$gK6u!0{Hs*mveSZ2Z%} zwqrwTMn8XFsHht+*1FQZV~+RDSn<9N!8z&b&{@rq*(h{-)(PT}rR+HXg(H!vGyHYHh!i8S)gl@U1x**zfZ>(&dalEV zispNf(Zi~n-1YQD(8B0ya~rF_@HV+RMESdac}AFKk?H#%1H2Nip$oky9{k zHMtOu{eo(_g?}5)`Y5}T0+xh%i@ngRQt`X+P)mJRpb&}GFyTXab7sHjKXca^Pa8a~ z_*`2gA^DHMa3&c>=6YgI(Xvp^ifu&v8eFw@adjh!`$lR6o$QTEgx47+aWUvMd%cuI z#Z0v5{x9xlp$S5PUisfNO#pDOrARD|gMBe~8Z?E3afglKXo_9{G)IYUjTxW?rS&U# z3&h@&lPnxAJg*Q zX!5!>K55n|+!T_7jzf#ZM#{6Ob0u{TOU&|$RAd-x->OfA4C{@|GJk$s2Fb_h^osVj z#!s#H&(G=7CNA~O99dj({_Epf<__7g%Xj-9U0zW=|DS8`x#>f4M3nW;*5gzna*jJ& zV`RgaRb#c6#pN; zn#9Tc4(SHqfWYXX#ET{PjG};9`;NPgIXG^Fx_J)2aZ*6gBLI+*K;E*?`a8W~hFzAu z(zV|Z_50Kt2)koScO8OzkOAOk@_#ibNY8m&mvTiuvOd@l(oPjummTowv+9FKG4so*iDZ3@W zmeB8Y{Wk|&p?LU`A~0Z15;3`(wD7*!DS9dpHkjKusxkEtf_?xGiTFtpO9;%Q1Rj@Q zr&`L>{#bF57rg{Cjg|}7;8n-eD}~U|T4d;&#b806j(Yl1L4fFN;<*kBTeyE4=Cn#D zn&)W^H~Wj?7Oc&K=Sw@kJhZ@Zr8sc>ZCVWViUm_3JGp(#C$9tPVG=Epc-A3fLZ{?F zCVF)+Vbw+OO?cAxTc!2il8Cp5AXdr~hdOo);%fZ&uc~H}pJOFTEsUbP$I_l$Ha09y76)?ui(FA((zI6p65vB6_jLll*xirdD1LvvFJC-c*ZaYh9FvBJvc9GObk? zF4P2%4v$36H6IZ@tTta?#9TcW;&!Lzn|Z9U?p}!X1*6W}mo!bDa=sJ0k2f`SO0qS| zuRAbxonKppD2SUC!oIco$7rLfp>r#zcQbz)G}AskeAN5lJ3XYTxXe^Fg#mnHAPGS8 zFfpIVge!rF0o9PENRRa=#B9b6791urgGyp{4BzLq0TPE0@u==ojAU81nxSw;%5tGl zrGX!H9t1(RsOPAou}_cv13(U(U}Ym96uyCg=z#=K$b`tAAOvEIi-V#*Ng02o?IQ;O zNZ35!D<6PW8j1GXfxmpL)o4`NES9L=<4lrX5+C!?{`X`b`WPgB?oX!Fe^?URg_o`cU=wZG>Bvv$j}^FF(EV;rOLT4Y4u9G8008o8EVmn7Pao&JM2fVt4deoB0XgakW zIu>4inKc~b9F7uy!un_IND9YnCsx~RoFQT#fm>PrSCno9u>Y=McslhGSpk+Tj-Qdr zVsk5Fy=gH-v|MQp`~)>1esEIAyi!?^5Uo8dGjo(OdWi_cVIIR#EtE8Ma*y#q0YTPw zT)lkwy^z|Yn?i2-9Xf>#!}|c3MU$o`ZUW#x``*8w45@|ZyD>Wk{Z|%(xU#v<5OO z{l~&f{NSX}PtXVybMlbt&PuM$hiw=bTXg4?!7i`e={EAG-Mt|m-GB{)w;t8g82$=_ z6Ug|%CU^yt>4-rhtSY6VoW{2m*B%M0yCF_Y;i@c!2`=Y0j#D6E2p{x5uq0g zyXmz%Vch15oMHqGx-&*Y5n_<&sBqdm7B8!8Z(6ty|)h_PQ);GSUVJSpU`e%ZG;^%M{C2bz;hUGcQ`0nW^<(&wLIFxkMQlPPUWY zUea(k;C9r0=IGQ+e#mT2?+MIFhCBYyQ{&tCdBM-&4L94{9UjV}o$rrzv2#sqIj!Z9 zpUlS2<@>NDQotWQZr!}K!et0Jfl&TlyRX)Ne(EhLi*YWHW{~;|H#P3>W(paMFIH3@SXt?~`LV^{ zVH~B}uC#Uj8wSww2DNv&SKSnQuF&^>B1JPZ94-DqN@vEE1icE~hd;6kqJ(O`UXa%c zIPdq8?ka`ybB^}T{<*K+&Em4l%Tv8wbf91uwQL?@(~)MAxvf}b-MsUQlSH7MtWpdX z;%O6Jb1(@V#+ukLILd0!;R%XP0z$hAk!&1>61nTmPz%ZmV6SNVc5~Zhw>xCUjg2@o zV1a=p!Zu|NJYeaU^43XC(sqjg@_|^W${GS}H(PdOVAC8!t3vBzi z`DZ5Vz~NBS-dvvTSw5QbOLg9W+1WE#&HR@cHjv_!_dexy+35rATBwwPR`0B*dwa}% z%l^md@cEB3tG06e`0%G+ON(pFcR1{r6dN2q!13Qy(6nx7PUuVyQ)|AedhT2ZJQ5Wg z7spe}b+cB@H9x%vM=sg$9oR1VY&8XIW1@p^O57JQJuzQiwBR=TJ=%Zpt^xJqs|9^b z#Kk#$nm82-7l?Pw9+_+sSibR~ytCi7>b*UcjXX0JpVgy*5}~a)goFT1h|7;3#6pDb z*`s)#V17~#UA{im>}(~>L-75=s|S0$N`7T2p=6$;P0WAL(gbit!;TGIEWN|pWa#9e z7<+%Dj^fbMM0@~PVm3r&G+0gsM+=~OZ!3dEs3JcYkrCk28|=Oek-dl~@RjD__Hwa4 zUP!^r3I$3|F~Jla3>Hc^SYG0aew&0&6sEA>KZO%WHW6Fkbrs%yx%h?n(mWlyJ_*~x z%1=n2VO?83K}1pFVwVc>BPI>K!aF zCvc5!A}=mi0u?nI#L;(_cNz*T!Ai_$Lz3RHgo{UAq8M z2A|<^pi_LMSJhuX+Ga1U+a!E{WyqKI(AaR#okk*n!^(R*-0fkbN!M|=1T;YF?!-u>U!mI}offR&WG*}oeU zB!Q3SxsWUR%8s^3gEGYpRi=z$Nam2ZSz?4>)rl3j&%+)=epD7+10s5(~YYiOe3wu!xgov-vtvVMA$B18h#)Hf{!7?C^nABjDmAJskmu z5rFO(Ro{08fKp$T6^%_r0NBNTCn#{y)@CUWWO#g&tc;hSl5nLxCzdBoWJwZ}` za#WWK#XNq+T8>CT0KCS6w=7));5tt6C-W_*pG@cdnc(*7YUXLl!GI%E3-o2Rxx7uHR6Mj^ionuqisk@()>sqhNQ#$Z=Y_jKznq~VmbV(`J z+=8!cL}a5vQhK9n+r854IokaRy}!ROeVYegW&J`D(}|{Q=5sSKK2Fc-*^t3Gu>}ZQ z8U+B(R7q#E@b=LbRqS*dj6d;juKa1qYui}$xwXG?{7ko^!Q;$5Z&JvU>I@~gZ<2J6O zi^H3`HZ4Mc%Y{D-xePFC1DNlkaG_`%t25kEV)BP^Oh2Ag!=rRdX_cj86QN_Fz1H)M z%tW=&uSfnnb95-{(VOpeFZJgFx>i$Rcmau;9zD|>hR$oD>%BdAYx{PgQZXP-x6esA zGgkV{;GP|(@)loO6(iVG&^$%k3d3%hk-YGkv^l|%j;{21FKCd+`U%DMKrpdy?zoNm zI0aTnksO4U9jYZh|?a0QhmV z9p|j}B={c9Q2#&BE|{XPn4fnPh|pyjT9?VSnYWb+WHggZ1advNV39gHVd_6%7LV%nj3vLEq6!Bd0}p1zXu=63FC?-a-PY=BtJC)hNc@-a+SVS3EN_6zt2bI!Fp>P zZ*Bsf1AXCwSRkEmzIJl)>{#1%vQ{G5YbPcOTbdzlPecB^Y81VW=exPdsp4JZ=hAN_ zhNFQdt744}PcNxeC7rtM-Z|7K(nRTvI1{Lqv&?dnQB#=Zt_MK_Xi_*Ah^(AM9mT>u zLfd!-GL^QU;Aq8jnEnChX)M}lBI;`uUYjYb=UI5F!SWxK=ctxHzx6CM!ta{wxxnb{ z72tr%t4A!?)CjEyVEcfrW7E@_a}%|9;FmnVmo`1cv<{iHUf@~$8aj~E=i2Z(MV|i2 z(|H&JOBW$zfkOm(_leJv9wqR;quv~Cp4bu78|2rwf?2`Thh4(Y_HyP=zlZvBl#)z; zknzgP6kprj5sdExyg&9Vr0PMp!OiT|T8V{@cTVIb*V1d8Ma7&~8 zN(V9Ir-ZH@wrvUssTv>kcauo^b@r%E78>cQ5Cp-VLcr({ zZZ2Q?ZBuOgC0iXx#{|k85!`S)=cD$j7zKeq*LIzx2->}`zlKw=G4}`A2LuI0O3u6V zOl%DrU(EimakY&Z42i|OxFgr`TivMTb3b}gp|4j&44cpAx@;3qSMcAhtF_HeeeyF- z`4}F&)2?`J*u|Gay=VWSya{|zcAe39hg1IgirtsZ0M5UD_#QdvdL`@Kg<9?_25 zwn<6s^xm^XSyno04~N< zPL`;~Vbha$Sw}G!88%qRAk!Ic{PonAR_(DRJ=||i&drLb#}hiClWGebi`^@kj`|A= zIbZA*G879pDvR3Jb+=~Ly35{mLf(-(K{+9g0zR&`P zZh3g*yw#S6f7h=F8S$Ig&#ZO)%9wAeeyu$6EcSb30mNgzvedjiW*TK#*ZOAKyKnrP zqM!8jZ1}#k^vn@!fhYoPACENm(`qIgs&uOtLz!C_yIK}DgnnDGxY==Ld}X;o&UBrk zewf!~yz8>l>TktUUQvX2^`SGux|QgpTGco(`FwS_nBC^4Du^GY-zO!gn5L zriQbJ&gGYyVh92#_yUQ$I(Gz}tXy>s6y>s1;#R?(Oor!`qbwcFPL_ zp)ZHTA7wDSZ?R5cRB#_SloAHCXJrYf`TK9Ry=uTr-Zu9$;yRuiaU5uKC`Z*bEOGwt zDRj_<_eIj_x2FDv+}G~J=;$bdSQNAxC5bt?Ei^SQ%k1xvc{}2h=%Wjvldl!`4I#;T zGvreZLUmswqh-spmK#KZrP6k+ukm$Xe$@tSb(SwI#B0k&-14C{21Q^m1Ct%n%tQAw z!4ir%CEWN@h%F9U<00EjD5cI0Ow!5XZM0C1ueWq(!#-pLnxc#te`LT7Khyd@ySY+9HQYDzd+Doj%ogXfp+d9y55sxqgMFQGm zvd+>ScHQ4=oIPU~p30=_C?XB=GfYp>DkILP@+?J+t{}&n^>J%UovkgR%6;zTX->tK za!45z9i~l+hJ0VS==Tp>6dFuKQ7$0b2`)JQ!Go<14K%z7?*#HrT^L#uo#xw`tDav@ zn_9ECX0h96w1-*Fyd1jVIIE$j(9#o}+7k3%68mjwWM_2$<2R1${N235OVhlgLY@U>!l|Q8kvOkXhe3@)ZQONK(QAYQ*ue($r1ByN6s(0<%Ch zu)`xG6$lFXlK7G(L~a@jD?6S=BY;TS-hq0!$R*Tql_)}B?S{`P7u=)}P%|Jc+Kr?Y zQmn$0g2m}l1>qi%)^n8LL(r{9CyWOuBnBmZvp96B#ys zv~rI>@EKuG9n_0JqXWu8Rv@*uoCmK!WVwcEOuwmRqyU~mDz^hhpUHqC*O=v=@OBgw zp9vz*08V>7jLA{B=5;qDtwb*1{#!ESViO8>*YAJb{7ugi2Mwe>3T&dXfHWi#3B0~m z{E6|Oy!p%xPpLD6Hw`EZ;=nB#e@5NOJ9b*u{{+(lF%X9zOi zRI|hRWL*-0=D6mNNf7M=3Z5{5W_*!9L7NV4MDfSPf{o<5kUM)0ivpV6F}+mp>;uw~ z)2IGVMR8Q(&Zs7}}lTl%be7y^lOFcuwoc^@q*@BJxdsX>LT!yx4Ol86y(ruHjG@oWbV;x=FXUekS`e^yw94qzf zaVf7DY+0PCZpfK`XRs|N)_%2Pz}(&IwclN&zI+(eZ_!PSO}A-}5{p9ZZYv{emIx9+ zMKV{+&Sr0Vu=-Xd`38}kdAi^DHiP-+hlO+R9o~`X_W8jNR|T+lWE!&=va;1Rq_(N@ zk(5<|9O#9 ztlv}M6MyQATFSz;;>Wn*3Dx*zibR6};-b~Wknv|Hh*a?ijl`IX5EJA+%M{L!;U3s< z%d?OWDW&^v3u8Rf-t8yW*Fj3yY9nmifNGf#lcKv0Q7@4#Mn8qD0`Ekaw=~FMO&O3n zb0OO=m96i9V$KA|);=UxxCiSe<>(cd6v(6p^ILgCsR@R+`sDjO8xgNykAo`0OKT+E z@1*D?rURVpETn%Z;SdDn_O}=0g>BM`b~mQ@p}F8c)I-L`U7$myKF6Vld_bPo;i5S3 zLRKd1zUNQEg6zcI4h7-c!sf}cWre=b2@X1KA&jyeZ#3d%kO5%Mesl|%e;I;8V727z z^=>L_-+rR3Og43Aqk0c)$lH6>9v&U_*Go;;^*!~Gv?z>+p5#FphnBNKp{N~ZzpXF@ zPV~J9WVzGFL3ALcxdm8JN%@9J3y_PvSn1n2%pqX-{s@1N6Y-DJ=7c)WI}M}FosM50 ziG5p9Irr7^i`G;Pb3QfX(!vkFCwFTey4}$9%(Ih?SNV>D5DO|k%tG1lOz{hmWpzID z1D$LArsh+s$J?jlLi!8_zVyrly!xcr@Zij!Nnc0U1&43$zKIU<%=96j&v0J$x>@jO z_m@wD42Z9JA&GcK?zQf*H}-B+ndjTB#S`m9Mb@T(i>!b%NDs|j$L4ia#Z4Tm481ktkNuSFniy^)u z>uLH-scJQIN63}XiJXP~jZ@nCw9NF>SZlqC!7ed9MVFrP>cIM4r{b5I(?2NZ!Ci!8 za208(U*rk;QN^{G{p-jL~r_w zo|D0YXG33RqE-C*fq)>+)Z!4HM7?jpin#zEL*Jp~_3~P3>REiyVsvZH!djm!u?<`; zRl$crrRVIC;#kAg$EQ$iAaiW){<2X_wg7r>dUw?2n9*<_OGz4C!iZdw01& z;rMj7-1EkjAqzX#4hlO?*RGs-pRfO7B66jY*oUi!p97kCj%h^1I(DhXpE<%_Z_;uQ~yG6fMQvQ#O0}`{FCt zYLs=nbm~;gWQ_RJyo=da(8lwkAJ5EGPo~bK_|fWSD)i0Ei-xnkf?c-e1XSuNDw?dr z{oCoX4tamj;1{@|p!af7m>Sew--*KU2yuE#)G3X^(wV0&2xqc8rL~c1#b*ZI4KjKU z(q6PIu4HbUkrZpTb4S>rDebA+}ZhDDKaTDjr9O4U$b2TovLU?ZZMbc&m+8qtjotn)LuJ&cvgg-wkyRA^Pb9jyn0uN*occRWRqic5PZiWFBqZhzE zTZaBj^bk!@P!bcpk$i<436%@P_p+YYh?F~=|F1P(W~&k`^J4bN)O95lL_OVbzkP?M zjI2^#yv{LlC+ts1)^{~R2?37L*NP0(7{bf7a3h`)UGxnH`=x)=HYV(wH}?~@OI_KK z817fCX;v5^A#BHr;-&{{2}+N@=CAN>DV(;Ic=5kTI*T_%*TuVxB=OsDX+c~%LIj{- zIg1GQy|{KD|B)x_{a(zhRvCI|j|V?i{u3T!Q7D@Fs?ARpQtodO(eEzoU}VvLJOT}Z z7A+FHsNI{C&}&(ds!Fui4eA~{gkC>z%0M9bJ08z6nlBBw+@`>U9Sb-6{ypK(esG8Mo23ZHHE_UlEtAl14>ij%8;!2*w>pTv{(4v8|A=MJ?|A0|AaS^9vpa zd1V{*o%pRvb4yNN?)PDiGixRWTMix<_$S^Jb1I5Fr0>-py`%rS^z4_VCWhAz>>trz z6#Mb0wMZiW-Jf`m;#OpK)&|Y17|gt6YAt4k&N({sJkMOau|Y#eZTV@lY*%9?f1{wC z;Gj(bUWUBn(tpou{ieAcL?tP{|A*s*f$#_ptTV7IGwRz_H9ngpcC<(#@hn|rxkWCl;R^M>E$cX#}Vco?|;-%n#co)%7QgB=Ab|&cpQ>p9EJZ;2QtKE zR0)M~5tJJ%i4Y@#9fQ#W3e#EU4x&N2bS~g?Msm3zLyI9S)qHV!9j6Oe$1cFSN|ovJ z_$W?tcw;~C<@zz?*Q5bSNqJ$x5Mxu}@^!I9z5umO(lwqvyQAL1IBXq1*{IDB;yy-$ zDBFK8$vy{Qri}fA5&+oHZNOB|9MfoZ&C`>H#~9VLHbH=l5@EU^F;yNkslp#FY^RHVmm4@_ zaw_6-?7+F_I0@sL4Jb`e_Ov<+(Xs$FDiMN#KAp+y(|Dug7NXu} zSBXYRA(=Yi|Dj}yi{7|VQ>Z*1zap9zR*Qh#Fw`<*H2}!F2pZpR8u+6P^^r$`{OI?#rM_| zyF&-6KcChLwtUrEy!~-GbKXxY*N!x)W%eiwI%%Lcubg*rySp*Bc|7u`&K!ph?)%x7 zhjQlm_2)kI@D079OWzzXw*GiQR_jjbQHc~xIZ!DL{j>hy7*0eb2;adhT=U=FT|k{S zveb0c4#Lo;g%y*T3i~NVVeKmLOC|L z=U|K9Pw(eY72ZZ;A;yBx&I&M-JF?#uI%s0(8}HH@ICyr;XAP7TviP%7ULC}he62hG zi3c2M-4(b#*s6Qvw}An)BQWj+hkTXa^a6~Ko9odNpne1u^z?FYV zMsd^!QBD_}bM7fBU`N! zx+P#)_US2dnX?Se;24`YDp|lz!q7YV7K1Rb6HnK{xEc|r;#k~|DxiLmO%Lw5^AGt* zS?Y-%okG8kdN`UrY9oC_$QH}q>frx96u$Iy{m&sz`Lw|Nbtyw%milB_NBV_K{8jyG z<>F$`*TK-q{OOe;!#U5|`Q#OIo>b=_#g4cB>NyTf9>S&o6lB|K;C`23$PBxK7Y#jQ z{np&i9AV77+MhG=++h4BeQnT4V8|aE!q? zPGguzN746uuAwpqQU+hvKlJeE4)D+T8O4~(4~+>Oikp|H)av>C>BouT2@BngWj|^k zY&f$FcW&m39GqF@s6&$t#3eE9mb8{FxFfPVL4?E=+v>T~@qn{8Ap1Ix!>6a_`S-1$=8tnQ3H-jzA40u(G;)6oQpk+%5y`E%G@ zT-6I2D9MeS7DnQ{bI9WuE*>pDUPuG68t9{P6wv zZf-w$@)FY=r(ZY@w1@m?U!^tk!g1cMJ8-c9!-FH)W6a<&zDG|JJtyDVLUW=&LyZ&G zDR)@oKQsbouF(F9>c1)~V2J)|#X|3j@wpI2oUAo%$T!D*r11f}3k5@1Vy8nL#xm*M zIz@`WLd|}vVc*+hCp>Fh>@1d`1zbaKykR08X->ov=9V_kWCeZCTPSzjHkHvRR6<=L|@UlD!juaFn> zJWY4!p)?(xWF@*M=K8iV(AU@u+;q+vLt79^#sBUuD0EE5x*h!X!AMq=(%8Aeaodz) zcQc>CR4P+zxXKQ+*LR%v@+Bqvxg!lgjy9JkF1R1waza}_cy>don$P^>(Ak#VUsmZv?6Xd8 z?U;mGTu1SY0G2Myj1K+TB2@(YM60V$TL+y!qS|VZl)M(cTa>X_zw+&`u$M(4RD(&z zf>FpoK2yYTu{9OnxA2Ktfu*v_&f;ciJYi~M!@I%;g`2N-R z3@9!pF*AF9$yKNR`jQj=bhP=o+-1zm+G8qDbnVp%Nbtz~CcVG%o(X$g9F#`OVCDsb zOfpdnx9&sMbe%>^Q%*<&yBncz!+Uq1n-B7r9eMd>tOb|#2Z+;vDPnA!98YIcHl0P%|a6||5w@pw#aB-zv z+<@9ZWKsuVopDfgp^dh&J1__v=!sYzMT_0o)evt36_OTFh(Ix~g=PG^FGMj78V8R< z_|}HjgoW|}^~^_YPxSR#U!jkRJb@j`9DG;vpyo?7!%$$!auhIC)&s-u?ZZs?Eks?G z#ee%m`WF&{Mt|c=p?`)g-_@uD)WEhY@wux~ku)p3 zH&0Q4BARmwCZp)|3J`yh2S;p`MS$HAS|$GyC3%`ImpSBz7h|O7usct}5u}(AVK0WU zjH3M(&+Nj7CJmL1J-eS8Oia(~_^o?G8+#n`-AB`7@X;RLl$Wo4;m9Y0)?%cQ(8kpt zxNv{xL`zfNE&M5`=;rz87%U+b%)TXc^?Vszq+5@>7|dGNcFer@3(8b@qNmq-St)$( zUHZ${a)_JXN;q9WNfW1tI)5g2usp0N_MQUe zFD?gQGxP~WKn{(u`+zC2PwrWdpTIL3ad#`(#*h*VRTZ`pwDVglqAtFwnHK|+T?}pO zRda9OT0Jy7H?gn~(thsK)Wf!qf$m$fJoDLp{P9ucGSluoJO9OF$B(ha8X3Q@Tb)uT zym_>4cLa5{i)_ipj_Zcoj4#;TWdCBcuvF%_yhfkY8&j9OtnX*t8C7i;Mj*-fITfl5MPog|I9O%)cfuvLI6(R|iCNSe z?G39FUTVr6)>MPMY?^SKi4YO`*ve8BsR8lE;Om?oJ3W{Yt>G zGgvHsSe+43+GLZ5AR!_bCy)PG!zROy3qS^3X<6QedYX{rlr#x9>hbb(wwg=xPfE*f z?x}KN`O#|H9il;$RRI&@Y4`9>sbI>ASd&GjRPDyDlT(U zgdM@%=cdW~Cy_>mBI=-w2v@HqeF*_cuG0RgD0lo*;rtxNVJtpjZY&PF5#i1Q^Q&dL zlTobEBL#?aH$HZnVoo8mq!)US``&*hxCy~h3K#hItTve09eOEr%6fiBWo6II=*?6; zv{5fGyx7%fgFAnM%qae$TXrn(=wHE%j@xCI%xA^le}sf}I3ggsL|V%{yd`96<#g*5 z^N(Lax66d3KJ`5OQS&HM-B^g9^cK5Igg?B2SlKgW=-1_Su&TBIO>h7^L_J(l4JQmp<3hN*WaK93^4VdjP%)d$|Apu8BN(6TaBTIu5 z+z$nOjsWCP&=u-Irk(-b9QZ@t`P)`_Ul;<1oj^B|BzqrTUj#b)BmKvjJOP-bYM-y~ zNNCb*>N>d+nj6R>Tu%yjhJ3LR?V7}LOWZfEXmD>UOo{LYK_LqlyUrh+%2xU5=X#1^ zYYoZMu*qE_5d37a=M3YKTfGZxP;Zo8+R^G;90#V>e;Gxq9ZCdymKxsAyuG95=~^BgG7-zYj%}Mvm3JPSGhGg{wj! ze{!ys@DT0vu{>i1mT9?E1VGi6(*XCg#z>oC{U#%Bq;m`z%LRsB zHJDQ<-uWjyKcBB~&S-O|py-*CiEE86=TpSs{9KkJ`jt{-C;PCYA5P9(4=WuAr2!gK5&?6f7RW zJ+{4hs^XIp(vSi%5&@g5KR=ih4i8ymDb&**dT+XFU$EM%$e=%Uv8cM)XSUX3I^AGG zD!%i?HNUGDTdJnOR0Zdk3H$6k^=1dk$|Sm#iBotfqw?v=%$%MvwS@$OdTZv-mQ#x( zj4v++X@>hh|NE={`v?+#HWS_vIdmuj6%iw2aks0dT9!kw7{aEB^woX87NRXnuYVYJ zD4(nSvznPRvp~gjVK)6t7Yi_aK~g0H+syT>!X zHO~2Y)G7$L+T{ufHqa0#ZWNAYKl%Ld<5KRpgr12+%SNcmh7!ANA@cLv<_W( zE{9GJ;*qAZlwj(G;jXsgrXT0Oyg3e(^Io^-*J9xOCC0*<8;enZD=!8MvE(Hv%h>s z`Iv*urZZ1nI-78af8aS=d@59`u7+W>QsiiwWyBw7SKU7FVTS&7F`M2Tkp3j}S7P`y z{jZh&_vR;iCL`vNEvq{n7}B|7e13%P(XD%$`F5*X_NS8W^_eYv+xyO!pW4zAnRH4s zg;%@EuNKLZXj@|b6m3ClNL-L42rNn546zp|S!axvXv5k%*F3#tUMGaXn8`YPUutY|!ha-+{x2njxF`@^^V zaDi=_H6P563XnYuo*CaeaTaDX-Xm&7+`MWIdM-VFm$){nyxTB_VMW>S0R0=>^y^br zQqa!4RVi}#1YkN_%aczh*-=MyobkXPw-xY?_(10<4RgKV9=MN_kS_}6$>ZygFdVF2k6uI5 zy}4#oYxz9p!;e!uui<6V;KV)sbzx~Z!g*hU`5>H02iAKEMqY(5*R8Ea@_4C33mlzY zS!Z|^h9EH>nd!KupI9;T8nFKvzI#e_a0lELwtnxOfK9X94V=e;2- zX^Jzt&kV%ioxnpOC}Q=R-5F|is$Ci;mY2YRm>y;!VB0}9?`U-RV@)%7*DTd4NaI!t z&)YYCzQu92DCFOcrnU^aeGFOI9xU3#4;R>L69LXn6b)-~CcLDw z+u`OlHGensXWZx)=5XBDUB?+{A3ya(J(!rk-+FOwj&Bg9bA|e3GxSfg2t^WI>no>4 z?ag58L?o3Rj9-aN!>q48HK>9x?5y!kG5|`Jhc)uiL^=}B&K@VE)v!b`Oq5%OxTo-t z5k=u8|5HPdc=TAvfGL2YE*bFtka`F}w-mWES8oa1!dGRNf;uN3PcifvK77D!5i{&8 z^e4vE=&B%K#wOHgII2YCJE(tzQQEALyj4p`cBHYIu=fxO9$1$Q7XlEkF@xjVj>a0X zGHUOk@EycTnp8y0l&v^QE(yvSqLj&?%R!SIAfktYiN&Uukm3SgU`>DAp@%P-Xn+Q& z0_#<^0DS`$x8(t64bM9`jR6zGv4s%-CPLPvDym@VU3%*A zmwvy?wFvKQd=3mzM-?D@sVhCf-mfI49ep+C?+p{(#B7Jrcen_MCV z;&6LNn?pUWPHtiyO4kG9x9KK=;->|SG4u<|_8gU14jhk>P&|YW=BR3F@VxZt;xeLK zz>~GfoK;wG|4U?|xOx%WcgROs(e=>e(jjAQ-tewdGwGAtb z$>{VFN?bVciKxkMv?PxT4uZ6}{#VOD00*Lz+?fCrE)=5wa`keSP)4f+O&nKg;??*0 z9eIKkF=Olk-yFHR8u3ncq$klUR-u}6eJ0)t8PD%?$yhXcsa4e zkAEZY9PCoQp|?=5@yQQ~f1?7j0~TiG6A}N=`Lhcq!ove^Zo@AGn+>B8!j;los9c0E z#W*=pN(!^K?6TY~_YSR+JEg$<;;?l42Ta}EQ-pS#9T)N1%~sz*=0V)D2&i>hxIOM zwQk*iIHrueAvEoaETe;D!aTv9UW^!MkkSaTOYlUjB9iG&3uP#`=#+{1tsCfwl@xH=D_ zzcY9JJ-jvwoHF*jd3n9Gp60#Q8*L4f;#eyPicHvpKVhuUoVV7<8kN}odYso4=T{`? zVH@lQENk@=;RA8u%(pd`@91UcER_3c8adWoBR=#&;m4g(*ob{pSqY`sGz)P6Yp^G! z>tT2Hymm_JdL2?2qg>gN(j{NL@3^lDw+x0!4E@$RmcxdP1#DzaXr-H@O7V0iMm>-p zyxZW6Nc$M+Wp*+TzgVJZ!+bDFsw0N)Vg0A{n+`jK)_LUQ959$&?KtAP`>eGXFaASdbE+AL>$a!^ibIqieBi6*DVeEelNh; zQa5zeaXNpd)@R0nHa?ze?)~$yXCdOQf+F4p;KWScARgt^`EOJqW8jM2iXE=eXpTVG(A#@+nHVyN{WM zQE`}i`PF^pUDf@stG~}p=Z6fh7>!#THK_Y^I3VPj|IFu|&3=5_ZH}K}zKzz?U6(mX zRquzXi0?WR_UJvQxFdE5k+k(_6YeA{3X2bPg>9rAvG|gXrNpaq{>6piZgxy0;(c~S zraC{QD0poOQ^92-f@{!rDNO5Czu4_J z+i_rk>TG5t%Q^b_7hC=-)%)|hq2oPYe7ZwpTS8|7pIrFrR;}Ko__6hgw`$@sL@VyL zki$ZTDUT2D+T{MH1=zj3z9YS}w#{#?Mkd`Gl@$p7T`+oj9_%Cm7XVg@W$yKVp7Ao& z7!xnv`Ay_A7YtsjEifG2lv_TOxgOU_$1hR@^WFx&T$Oysb_i2J=ziy2XDj=#1JURo z$|QE*(aZ4ApZ#xtTK%iV*`>7Ua*z2H2F%nu8$aAH3OG%Zd)cg)wYft!9GzcG>_k!2 z#Uo7EJk4sZjyf_QGqnt6xtQB&Q?Cu`Zl^m)csrJQad+Wt1n4N7KiuJrE z4L;_E2%L2XVF3PbeVI47o_ygsZ%`Aoq5|#uvWJFzT?v+Bc%V0mJz#e|z&{ z?&MqrCQXTt0>AH;tS1ip?N#6JLmeUsxnO6ROgO?;t-(=%7vc}(_aeXY$+4i+n5T>; zEMg(}97Rv?&?BX@ie<=OZ9%3d>#Ctt>D&jCi1UZ;U>c`6;NSrt3Af`cKQ0U!;W!Fc zcgC`dL^!ZG_F&yC+u({f^s47=tJu^c zM>rmQ@Bt5`y#-xv1c)Jq)JY0Ow>ru&<)f}UJ|RZS+cEtr5~E3}^Hg(}Ryp>NvLE%8 zmRMKxTDtD5<_F+$IbL|LGZwyO3tugim+y6!@WUyjqnWgg{R$2L>9PzErSnSmqkukh z<>hgB#G95+^%svsY}q&&Y-2AsX9{u*yV+2?8g*aEb_|ojovgSiAiH>%jX0Vu;FWN9 zQ1>T9*hlD}A1J?J$5Z$8ekOt*?+uCWz?}8N5eZ^0EO|M68$%+$JR#WTM6YD#K>foz z<9wlCLcb0SO%O2 zHx`J->u!QZ8!hSGMb&|SwUY#hLU`j3*b?lFXrs!VK*&LB14M`-oLT)F=b;5$tG$A< zrUTlm;ul7mT!+lq&=)|e5m6Emj7k|R>v#Gqg7O=+>Jf32lh@K3BTYbO(gLvitg8`M zSCJlvAbD#Xh@@U(gvI{{(lXud&k&Y0=J^}ia)MCjCbbhW)E-knX%kPPwF%(}o8p8)aBz7OV*h^A&D)9U!m?*%^QqWk> zd<{nqRPPAe`Ggh)`8FIOqv7Im8GW=ET-Q+jw0e%BU4*|3NFuEja)_*Ik6gzf2>cO; zF;>>w?t-f&Qj<|v^cT5*W%$=~73 zOpfx0{k}`6em1SL881Wch19I^mFqV9Q&g6qc4j>x6F=}2$8c#!qAo+LWDQ5?U3?>) zd}TMsR3B-)2?~;RX~`o`)Os))CC?Pk-&g%8#l^NE`rqy}YzaNs8Gg8D4Mw(;>{@#lO{FhP2Ql>HFwPJ5jQWVT%*^+1*IfXo@ZIJ6Msu z>0ULGZ08>@t5w2PxDgS6itGNs{dmCU`}uEp3Ckr z3?5rG@|bcHp*2F}Dw+YmyF!nH-n)3G)g9;recfEoRD61v>2B(n?*6&siI>#)iuvr& zw4NEc>ZJBB8>`1Z?^jxPYS|g|G&EqK1U{0jv0uFR8G=tPU{E}sT?SNPA(G0`--0Oy zT0hv_pPMsuiRWGNv#XgK#%5D#H9qshsnh!xN8={++)qkX^&LDk-8C_#bO}KIv$Oz^ znt+g;aFn@(*YP_YePO)TZTc~G-@;eu2Q_jPIZV{tZ9j0`{(u#%4>uvW))=!jB9FAu zYU|mb-BZ>3YbbT?An|uPS1;c6iD0MTPtafhN1U(HxU?#8@WWraILf6!5Y8YvlVCad zq@u#*Pi*xU&~6a#e5Kh$-#sc({Zsy$k&F6%nP*~H^O`BL9Xs-qYm334KB?}GvA4M! z^y7KzvO2e2+0|3}p$k6qYzA{Vi`y6f%9-<-?)|#_zd8^7xg+mA2cC`y$O_)z>3n|& z6PzCvju+b0wfRv_cWxR8AX*n-L4Zj+3=DNHL{&@xGe|dDUN-qVtHNzya#pnS$C}t! zkG=D>&^|wmkbE|%)8qKC-)iUA4Qj4EceZ=A^R?t`l@s{)Xn*sb@NJtS!~gISj?v(J zj(Q*Skv8zck(=v1`}Da5#3V`}xe>%(xKSu6Jz44J+r}k@dyVzXh%v0DpiFCo5QhuO zSwX-~dsQ403>1}l!2mD!It|(v23{bJWW%51eB6%<_q~1Ufaqj%tXR1~YRmreuCR4g zGLnkW+z3~F+b7-H7tGJ_FXpVE)mqP&8@#w+@oFx%aLF!=B+*AxYA}`a-v^b_#0w9{e#OP6 z9N8k{o3g7PcVaB%We5DisIAx8pPT|Bwd1t|fE<7V$3#ZfM%jN^m!BAQs2x6-`|mq4 z-4;8f7E))vFlVdl+iA0#X68K%UUn%+Yq$@ad&fTJXzUdb;pcpsw4YRNF9L%WzMdc% zt8vV41}%;YC#s)4Yze0BuTU;6`T8vFR0jv2{c zy=rp8Xw+nd$fG^x>UP{BV`jrfM#>|%0ta7q2QH*d1Z2-Wtrzc!D<7yDx#!WewcYB$ zyNgBp6<$AuRhxDx=DTd*{>PTS|2ELH2+Fi~kIuO@TQyTHwh8S#ad^#|f}QeOVMsTq zdUy{T&$mBa1>$8bWCm}o<4cDrY)7(v$@agE4#9rD#J2ibr;~mm#Vw71czk zx}6MBMQHfhA0ghGt6@%r^a~0`b>6gbv|h<%za$V%uk*FD|DyiVP*9RhQ1Itl&)4CK za8q8g>Z(2dhCS0fIotwN|DMcR?PjaO<$*`Z{4%0X<5sNbrCQu*!&4R+!|j?@^)S=w zXZL9Z?}KTuSvBr;w$IyG)0oAaX0oM*2Ser56RFbXjyf2rA8*X#!%8D2FWO zFtt~uoR_w8z~V3xeUc<$64|(S6w|Cw{me6?NO3~2BpMR?8~2_{In0570r<{a;U_)H zGgdVjFi2NDEqoZENIt2UE{;+4m1lh)K7c^%fbF$uhGevu80!##38;yu5@{$z??NPL zK=!qWpO+8uK!lSP{u&uZTdCaAB&G5$8TAnLm8bpHuM*a)GQH!~#b*d9ns8?W!L5o9 zZA#lD%u4NyEf+$dQJrF9r3lZ_^hB&1p5aJwh-5v9E41fQ3v9c*+p3M-Ds$|Lf&IC`g`(slNifFz6tnP zV#2eJ{JRH;-}!!B3eMvll@j>`lhzRs=v5|^3l*iQqUnw6t zP$RGaw2TJVD)JF~5j(UWT@a0l%Nk%R$*t@H3q|Ivqrb!Th`3_1#==3GYx*2# zvYy*t`IOTsT^*ck=1%vMNuh|&i9@QmjKXc{x|ehxbyC43Bjk#Rt;mhdLguNzR%aKi zTjdeGdJ=kn{G1dG_1c?$UlcrA|0fJQh|gd#g@r*A3vmZ>4Dwez%WaE# zAcEb@CY}!WoYnD|Y6w;+y8{+0KQiVb5fa19oS0kY3G9Uf=Y}&FZI5wk24M?cM%x5V z!Y)=`5s1y$!^%BAD0Z**%NIE#{2a*8J3=V=nBL(vo`r8MT)b0$jD;x)#5^9!c9ux! zKs$H34P2!Zjk#p*>ylMvuea_zR8TtZ&xD?y*z&@{2c)i32J|MTJ#cJ2xDSJ!`$+HF z*aTKH78_ZLKn|e3-|-{yb^6*6 zgK1Y;+pA*UIs#J{bIou^+nSVq1&0PtxQKJ!KlR7Z8XzMLEL$OyywX~lV+^-tqBapC zRH4UU2(LmTIbd-g*8DLYr8 z;^h|bOU`ORmd%M5&nY%bHH9(tVyl9jDVbNTL`u~rRxhs9^k4(%oP%PwVQ0~Dz3o+p z8Y_AV5kgjeP)uuJyha|r9w)XBq|}LUP}75<5%^XWVR<6<^H8wC&d^Q; z&d9=oW?*eF{*sy0Xd=X+G1!lh?KxT!Joe%F(}_O)`cBNHlftiX52n8Bw81NTcQ#0N z3=|HwbgW?<+<9hZwDWK`lao2rvbZkfXU@D)*Cts-pRIxXI^uy~6(9URs@^;<=JkIc zKc%$MUZQ9*TBVH^?Fp5X5>d1-QYm%XR8oqfq>Y9aA{8g1MUfJTGzyUz+SG(1724}} z-Q)fFJs#gb&N&=;&CLCJ&3#|b=kr?DVm+=3voJLo&}s!)ez@auoP8u_GW~8>M62_3 ziy?FXrxygmoF@&s=Ev-*L(f~HAujs-xe2rkUZRaez`&|xH2~w!y#xAHdpn+c(&d!_ zqYc*mw=FPcL3t0yLZjrgz&s@m9}8L$M0lp_V&=gg1eSLb7ppXxD?t(^CcQhT;9= zN1F2I?PGEz=g0VdTIm|+=Qyt3q7FE_@o{`XPl;eGOF~VvWsb?3vdw}-{}ffQsC)hk zyYs?Z=g+S6i22t0!w<^aVksR=KdH@uXXCo$u$oVwZap%BK!A!KFJh6;SJ816>P;{d zC=&l|^Mpu0vkz6*iH^x`@M``v~_vEc9`oehI>y=Tx6SM#5 zAD5R)wD1w(K_hnHF}pb7jOEB`D7?ZT)FJRIMnHq()E2Z1(2r=FB=}XI$>D!GP39dLM>5PyoQ=79q}AY z;v2sW4E4`F_q@-a2u~Xcj4y(%?C?Ff2Gc7DBP?mz8BcHTJbz**?byuxv*Te-W#i}P zzd85zuAlrkGAA=pQ?9W?x%ik}+4d1M;2T>~oqqms3@G2fO1{*#Kv0Ki#OnBwhWlqVESoVHUW1aYIB$F;C)?2)p*A}^;OPelcfQpYh@*8zrCC6 zUii^AJ`>YF(eNeb9T76*w`s#Ngmg2aCG{fzEXmN*Gbnol*IAskyq}soQm%}}%*{FX zrOw?pY@Mzd`8->rl4cV=8t$r7n&}WxTff6`hcGM!;BlY;^Cny5QRI=)u0cxF10dwAV#B14g zBZ=RDfm_n<(d?_2s+tDwNIxcwK$SNgQn0Nc5lKY4oM~GJ z+Oe%bNzw%`4+{_gWlT+_*ol|B#pMxkM0>;oP@}m5s%$j8r^--ybyNjr>+E2w+>J`R zaT-e4u78 z-7qcj%*B}Na5iLABtQzLK1AsD*Dwqw^&~38mLM%Dw%0sODZl#oInZ0tUjZXj7LBmsv)32%*FARti&I-R1G+Imp8p_TW7- zveq|&yyqQwo>0Dv6=AmaPUr#rW{I&VdXG?dtc8LGwo=3>+EQ<0>4rZ?F{?;(JgHNh zy}~Ops-fi6(`6&$HDTR-Joz!_C1-9+ewLemQ{Ot+eYAD3EBfNeWhxOP!TEy&TP5*j zkph2DpO<)_)A3|YMG;4B82s#8lYdSkS)dPD?|)%eRZUvFGVrGLAsAk=jH(5%F?bSn zp<3O=Qp(AjWG!X-ujTL4@`Q!`nwyqE{0(w~oc}NCZxCfRb%CKL0MREu5qwE{+#i9? zIhsjWs~j9Ffj58NSBfa_K^;+Tk%wSdS%Q+^p&TH{F9gl+ebiLMw(d)^W}?B_#ayF4 ze;e(x#IA<)fQbwIp}!E68L8?Q%(Mn-+6N}dWUiWh-wRq+HA#Dw0)G5sDoBDhIUH-# zPw=Ntt_Am}h`nB%I>|N2%bK z_Oz@?|MHY4yH=o>k2C7r@_&+-hhkIjMoryVk1wUjhHrkvDb3sWoZ$}K+vY}SVMBz4 zcK7dU;= zeB!4-7K~I?kV;?*6uuwx89mR72o_*X5{KX#s9ieg-EvjNmPGNxNO}Ur3~U|PVHki3 z1a&y);ng<`~nF^DMwHFIZYCki$slsmRZ^=g(RLt7T+8lRAX%QD^ zFvQhbjOSGB`T1^vvZA;4@Pmg0iqVv*6xTSHy9epeU*Y6sCFCgJc%9{Zi)g@; zpb!FkiZYrji0KUr;#6tGWJ#+}5uZ~@5P1mrxvY3r0i=831+0+?0I3Kbz%BcbX+|zV zl2~!ulivN`?=d}MI>s=?+y5iB<$*e;2_jcS3e>blKYfyvaZ7gvs@nJ6ZHihfPUkmC z9B~OQGeV)HZq{TOkEz*1Ha>c**?9N-#KP)@(U?EZW2c=L0AYUi4QxB;yEdSHd-Tly zACX0k7?T3fd7N}mg4OVCI6EBEF$MX+L20$Lz)0-``_UfB1=nw%R#t_7l+)jS?-(oR zH;1pmJM4U<)rE*SSPEe%Fg<4Pltoc>wL?n&m(DTDL1RJi97NQSR|>!g9Nq>Kh(~#V zjX~L6x{3{jBl1YNc2h^^BTl~Xk|OqBYV*`=GL5oibJmIik@j&Zsb6*j}B(!?w7h1eo4(pMVRMG#DtKYC1TN&cqN`UgtOMixn!vXySYB~RLJ zwkaD~WbX;S44#7fM_K)tP@Vf0+3Kd+hFH9r6F;0uZBoVulIdC1Mx0nyJj* zsz{984wgPc*S^BSt54X51YsUK4!3YIcbC&0%%RynWaKx)mb@L8@7!0F;sBTItfme`$P82v!L_EDTQUyF_(s_DLqa)B zLM>@Jv9K5MO)&tEije~|!;i&~(3+$qN%oWdE{?ES<&MbPuZvQTiWhQqBEi{TVq|pS-rBsQOh=<+;13dp6BCs2oiRG7ze~ zgwk$PVj<{R$^PDM=fDC7+z@iVIE>p_3B&l_nTinC{q|mxcz8Q`QS)dPX2iJ9AC^ugw`w!{ky{9NBI1Y$hAcpbc5oS z=Lm_Vn)^Tp5D^~EhQe0R20jVs0xBA}aS0CH8#|RQnDW4R2SVe@Hv1+(ttQYQaa=95 zbtq{?!$5DWj$ByLsUuHk629dIM15aiI8P7Gt1dLuPmKJWiJ9b>`guHH94Eg3%QiL@ z!|;6XqO8F|Ay#z`0TH@zJjH|bwlLlkM~tK(I}1!29M;HinvP@? z%LpTm#-*RafBtEtgKvdrFEEbUVlWa;@~5Vz8U;G+#%2O_Tv~=;!eg3w3brg3><@*D z&wn>LL#SQw=)mnaW%JiRyil#?J&1xa{LmW&{%CIWu{qb|8Pl&WTXf%hcI<9_vuJM2 zJ@Zu0langN>G^v5!3gW&)|{>EQQ)e@?slS$pg^O7y!~q5GlNl2d^*-8y<77#S@N2x%uPX^TW

E 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

Y?gyHLJvJNmkp9u%iAoo6-y04rDes99*HZ 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%0As?G0qc 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