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}}" diff --git a/Cargo.lock b/Cargo.lock index 82536866d..c5e9414e3 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", ] @@ -1638,6 +1645,7 @@ dependencies = [ "humansize", "indicatif", "log", + "serde_json", ] [[package]] @@ -1693,6 +1701,7 @@ dependencies = [ "serde", "serde_json", "static_assertions", + "strsim 0.11.1", "symphonia", "tempfile", "tokio", @@ -1734,6 +1743,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" @@ -2002,6 +2025,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" @@ -2048,7 +2077,7 @@ dependencies = [ "failure", "proc-macro2", "quote", - "serde_derive_internals", + "serde_derive_internals 0.25.0", "syn 1.0.109", ] @@ -3882,7 +3911,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", @@ -6809,6 +6838,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" @@ -7030,6 +7091,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" @@ -7114,6 +7199,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" @@ -8158,10 +8254,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" @@ -8562,7 +8683,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", @@ -8589,7 +8710,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..72c24031a 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", @@ -37,8 +38,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/README.md b/README.md index dbbdfccbc..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 frontend** - uses Slint or GTK 4 frameworks +- **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,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)
+- [Kalka (Qt/PySide6 frontend)](kalka/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 | 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

@@ -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 `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/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. 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..530884d4f 100644 --- a/czkawka_cli/src/commands.rs +++ b/czkawka_cli/src/commands.rs @@ -1,5 +1,7 @@ use std::path::PathBuf; +use log::error; + #[cfg(not(feature = "no_colors"))] use clap::builder::Styles; #[cfg(not(feature = "no_colors"))] @@ -115,6 +117,27 @@ pub enum Commands { ExifRemover(ExifRemoverArgs), } +impl Commands { + pub fn get_json_progress(&self) -> bool { + match self { + 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, + } + } +} + #[derive(Debug, clap::Args)] pub struct DuplicatesArgs { #[clap(flatten)] @@ -169,10 +192,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( @@ -848,6 +878,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)] @@ -998,29 +1036,44 @@ pub struct IgnoreSameSize { impl FileToSave { pub(crate) fn file_name(&self) -> Option<&str> { - if let Some(file_name) = &self.file_to_save { - return file_name.to_str(); + match &self.file_to_save { + Some(file_name) => match file_name.to_str() { + Some(s) => Some(s), + None => { + error!("Output file path contains invalid UTF-8: {:?}", file_name); + None + } + }, + None => None, } - - None } } impl JsonCompactFileToSave { pub(crate) fn file_name(&self) -> Option<&str> { - if let Some(file_name) = &self.compact_file_to_save { - return file_name.to_str(); + match &self.compact_file_to_save { + Some(file_name) => match file_name.to_str() { + Some(s) => Some(s), + None => { + error!("Compact JSON output file path contains invalid UTF-8: {:?}", file_name); + None + } + }, + None => None, } - - None } } impl JsonPrettyFileToSave { pub(crate) fn file_name(&self) -> Option<&str> { - if let Some(file_name) = &self.pretty_file_to_save { - return file_name.to_str(); + match &self.pretty_file_to_save { + Some(file_name) => match file_name.to_str() { + Some(s) => Some(s), + None => { + error!("Pretty JSON output file path contains invalid UTF-8: {:?}", file_name); + None + } + }, + None => None, } - - None } } @@ -1081,10 +1134,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 871183662..777b34ecb 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; @@ -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; @@ -45,6 +45,7 @@ mod progress; pub struct CliOutput { pub found_any_files: bool, pub ignored_error_code_on_found: bool, + pub had_save_errors: bool, pub output: String, } @@ -65,7 +66,8 @@ fn main() { debug!("Running command - {command:?}"); } - let (progress_sender, progress_receiver): (Sender, Receiver) = unbounded(); + let json_progress = command.get_json_progress(); + let (progress_sender, progress_receiver): (Sender, Receiver) = bounded(256); let stop_flag = Arc::new(AtomicBool::new(false)); let store_flag_cloned = stop_flag.clone(); @@ -98,7 +100,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"); @@ -107,7 +113,9 @@ fn main() { println!("{}", cli_output.output); } - if cli_output.found_any_files && !cli_output.ignored_error_code_on_found { + if cli_output.had_save_errors { + std::process::exit(1); + } else if cli_output.found_any_files && !cli_output.ignored_error_code_on_found { std::process::exit(11); } else { std::process::exit(0); @@ -122,6 +130,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, @@ -137,7 +146,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())); @@ -547,38 +557,46 @@ fn exif_remover(exif_remover: ExifRemoverArgs, stop_flag: &Arc, prog } fn save_and_write_results_to_writer(component: &T, common_cli_items: &CommonCliItems) -> CliOutput { + let mut had_save_errors = false; + if let Some(file_name) = common_cli_items.file_to_save.file_name() && let Err(e) = component.print_results_to_file(file_name) { error!("Failed to save results to file {e}"); + had_save_errors = true; } if let Some(file_name) = common_cli_items.json_compact_file_to_save.file_name() && let Err(e) = component.save_results_to_file_as_json(file_name, false) { error!("Failed to save compact json results to file {e}"); + had_save_errors = true; } if let Some(file_name) = common_cli_items.json_pretty_file_to_save.file_name() && let Err(e) = component.save_results_to_file_as_json(file_name, true) { error!("Failed to save pretty json results to file {e}"); + had_save_errors = true; } let mut buf_writer = std::io::BufWriter::new(Vec::new()); if !common_cli_items.do_not_print.do_not_print_results { - let _ = component.print_results_to_writer(&mut buf_writer).map_err(|e| { + if let Err(e) = component.print_results_to_writer(&mut buf_writer) { error!("Failed to print results to output: {e}"); - }); + had_save_errors = true; + } } if !common_cli_items.do_not_print.do_not_print_messages { - let _ = component.get_text_messages().print_messages_to_writer(&mut buf_writer).map_err(|e| { + if let Err(e) = component.get_text_messages().print_messages_to_writer(&mut buf_writer) { error!("Failed to print results to output: {e}"); - }); + had_save_errors = true; + } } let mut cli_output = CliOutput { found_any_files: component.found_any_items(), ignored_error_code_on_found: common_cli_items.ignore_error_code_on_found, + had_save_errors, output: String::new(), }; diff --git a/czkawka_cli/src/progress.rs b/czkawka_cli/src/progress.rs index 72952de01..f0b4f77a6 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,35 @@ 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) { + 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/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/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..2c2ffb072 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); @@ -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(); @@ -350,7 +351,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/common/directories.rs b/czkawka_core/src/common/directories.rs index f7955421c..d3bf2c433 100644 --- a/czkawka_core/src/common/directories.rs +++ b/czkawka_core/src/common/directories.rs @@ -222,6 +222,7 @@ impl Directories { // Get device IDs for included directories, probably ther better solution would be to get one id per directory, but this is faster, but a little less precise #[cfg(target_family = "unix")] if self.exclude_other_filesystems() { + self.included_dev_ids.clear(); for d in &self.included_directories { match fs::metadata(d) { Ok(m) => self.included_dev_ids.push(m.dev()), diff --git a/czkawka_core/src/common/items.rs b/czkawka_core/src/common/items.rs index f49ab916b..09532f300 100644 --- a/czkawka_core/src/common/items.rs +++ b/czkawka_core/src/common/items.rs @@ -72,6 +72,8 @@ impl ExcludedItems { checked_expressions.push(expression); } + self.expressions.clear(); + self.connected_expressions.clear(); for checked_expression in &checked_expressions { let item = new_excluded_item(checked_expression); self.expressions.push(item.expression.clone()); diff --git a/czkawka_core/src/common/model.rs b/czkawka_core/src/common/model.rs index 41919a49d..aba1f5cc5 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, @@ -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 d23abe7e8..e927534cf 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, @@ -176,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/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..14a4e5cee 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,9 +8,9 @@ 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::*; +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}; @@ -32,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, } } @@ -102,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(); @@ -113,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() { @@ -195,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(); @@ -367,16 +495,16 @@ 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 { 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); } } } @@ -385,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); } } } @@ -436,12 +564,13 @@ 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, 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(rayon_max_len) .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 +642,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 +715,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 +732,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,16 +740,16 @@ 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); + 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()); } } } @@ -659,11 +798,12 @@ 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 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: 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/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/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/invalid_symlinks/core.rs b/czkawka_core/src/tools/invalid_symlinks/core.rs index b1b0bea56..41adf48bd 100644 --- a/czkawka_core/src/tools/invalid_symlinks/core.rs +++ b/czkawka_core/src/tools/invalid_symlinks/core.rs @@ -73,8 +73,9 @@ impl InvalidSymlinks { current_path = match current_path.read_link() { Ok(t) => t, Err(_inspected) => { - // Looks that some next symlinks are broken, but we do nothing with it - TODO why they are broken - return None; + // A symlink in the chain is broken (e.g. A -> B -> missing) + type_of_error = ErrorType::NonExistentFile; + break; } }; diff --git a/czkawka_core/src/tools/same_music/core.rs b/czkawka_core/src/tools/same_music/core.rs index a9e63ff1a..8f306f515 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); } @@ -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 { @@ -426,7 +430,16 @@ impl SameMusic { Err(e) => return Some(Err(flc!("core_error_comparing_fingerprints", reason = e.to_string()))), }; segments.retain(|s| s.duration(configuration) > minimum_segment_duration && s.score < maximum_difference); - if segments.is_empty() { None } else { Some(Ok((e_string, e_entry))) } + if segments.is_empty() { + None + } else { + let best_score = segments + .iter() + .map(|s| s.score) + .min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)) + .unwrap_or(0.0); + Some(Ok((e_string, e_entry, best_score))) + } }) .flatten() .partition_map(|res| match res { @@ -436,12 +449,14 @@ impl SameMusic { self.common_data.text_messages.errors.extend(errors); - collected_similar_items.retain(|(path, _entry)| !used_paths.contains(path)); + collected_similar_items.retain(|(path, _entry, _score)| !used_paths.contains(path)); if !collected_similar_items.is_empty() { let mut music_entries = Vec::new(); - for (path, entry) in collected_similar_items { + for (path, entry, score) in collected_similar_items { used_paths.insert(path); - music_entries.push(entry.clone()); + let mut entry = entry.clone(); + entry.similarity_score = score; + music_entries.push(entry); } used_paths.insert(f_string); music_entries.push(f_entry); @@ -457,6 +472,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::(); @@ -474,6 +492,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 { diff --git a/czkawka_core/src/tools/same_music/mod.rs b/czkawka_core/src/tools/same_music/mod.rs index b6f4c9898..04813dc3f 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; @@ -44,6 +44,9 @@ pub struct MusicEntry { pub length: u32, pub genre: String, pub bitrate: u32, + /// Best fingerprint match score (lower = more similar). 0 for tag-based matches. + #[serde(default)] + pub similarity_score: f64, } impl ResultEntry for MusicEntry { @@ -72,6 +75,7 @@ impl FileEntry { length: 0, genre: String::new(), bitrate: 0, + similarity_score: 0.0, } } } @@ -123,7 +127,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..ae30a3bb9 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; @@ -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) @@ -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, @@ -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); + if is_in_reference_folder(&self.common_data.directories.reference_directories, &self.common_data.directories.reference_files, &file_entry.path) { + 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() @@ -282,24 +299,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) { @@ -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}"); @@ -532,8 +549,8 @@ impl SimilarImages { } } -fn is_in_reference_folder(reference_directories: &[PathBuf], path: &Path) -> bool { - reference_directories.iter().any(|e| path.starts_with(e)) +fn is_in_reference_folder(reference_directories: &[PathBuf], reference_files: &[PathBuf], path: &Path) -> bool { + reference_directories.iter().any(|e| path.starts_with(e)) || reference_files.iter().any(|e| e.as_path() == path) } #[expect(clippy::indexing_slicing)] // Because hash size is validated before @@ -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_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); 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/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) 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 = [] 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) 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) 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" diff --git a/data/com.github.qarmin.kalka.desktop b/data/com.github.qarmin.kalka.desktop new file mode 100644 index 000000000..671cabad6 --- /dev/null +++ b/data/com.github.qarmin.kalka.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Categories=System;FileTools;Qt; +Exec=kalka +Icon=com.github.qarmin.czkawka +StartupWMClass=kalka +Terminal=false +Type=Application + +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.kalka.metainfo.xml b/data/com.github.qarmin.kalka.metainfo.xml new file mode 100644 index 000000000..b86a8118c --- /dev/null +++ b/data/com.github.qarmin.kalka.metainfo.xml @@ -0,0 +1,40 @@ + + + com.github.qarmin.kalka + Kalka + Multi-functional app to find duplicates, similar images and more - Qt/PySide6 edition + CC0-1.0 + MIT + +

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)
  • +
  • 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.kalka.desktop + + + + + + Rafał Mikrut + + https://github.com/qarmin/czkawka + https://github.com/qarmin/czkawka/issues + https://github.com/sponsors/qarmin + + com.github.qarmin.czkawka-cli + +
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/kalka/app/__init__.py b/kalka/app/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/kalka/app/action_buttons.py b/kalka/app/action_buttons.py new file mode 100644 index 000000000..a310024cb --- /dev/null +++ b/kalka/app/action_buttons.py @@ -0,0 +1,192 @@ +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, +) +from .localizer import tr + +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() + load_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), " " + 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), " " + tr("stop-button")) + self._stop_btn.setIconSize(ICON_SIZE) + self._stop_btn.setMinimumWidth(80) + 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), " " + 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), " " + 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), " " + 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), " " + 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), " " + tr("load-button")) + self._load_btn.setIconSize(ICON_SIZE) + 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), " " + 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), " " + 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), " " + 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), " " + 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), " " + 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), " " + tr("optimize-button")) + 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/kalka/app/backend.py b/kalka/app/backend.py new file mode 100644 index 000000000..ee02a3e7a --- /dev/null +++ b/kalka/app/backend.py @@ -0,0 +1,646 @@ +import json +import os +import shutil +import subprocess +import sys +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"]) + + # 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, + 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/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) diff --git a/kalka/app/dialogs/__init__.py b/kalka/app/dialogs/__init__.py new file mode 100644 index 000000000..f1c6d549e --- /dev/null +++ b/kalka/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/kalka/app/dialogs/about_dialog.py b/kalka/app/dialogs/about_dialog.py new file mode 100644 index 000000000..99c0bbbb4 --- /dev/null +++ b/kalka/app/dialogs/about_dialog.py @@ -0,0 +1,68 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QDialogButtonBox, QFrame +) +from PySide6.QtCore import Qt +from PySide6.QtGui import QPixmap, QFont + +from ..icons import app_logo_path +from ..localizer import tr + + +class AboutDialog(QDialog): + """About dialog showing application information.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle(tr("about-title")) + 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(tr("about-app-name")) + title_font = QFont() + title_font.setPointSize(22) + title_font.setBold(True) + title.setFont(title_font) + title.setAlignment(Qt.AlignCenter) + layout.addWidget(title) + + 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(tr("about-version")) + version.setAlignment(Qt.AlignCenter) + version.setEnabled(False) + layout.addWidget(version) + + # Separator + sep = QFrame() + sep.setFrameShape(QFrame.HLine) + sep.setFrameShadow(QFrame.Sunken) + layout.addWidget(sep) + + desc = QLabel(tr("about-description")) + desc.setWordWrap(True) + desc.setAlignment(Qt.AlignCenter) + layout.addWidget(desc) + + layout.addStretch() + + buttons = QDialogButtonBox(QDialogButtonBox.Ok) + buttons.accepted.connect(self.accept) + layout.addWidget(buttons) diff --git a/kalka/app/dialogs/delete_dialog.py b/kalka/app/dialogs/delete_dialog.py new file mode 100644 index 000000000..e481e669f --- /dev/null +++ b/kalka/app/dialogs/delete_dialog.py @@ -0,0 +1,61 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QDialogButtonBox, + 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(tr("delete-dialog-title")) + self.setMinimumWidth(400) + self._move_to_trash = move_to_trash + + layout = QVBoxLayout(self) + + # Warning icon from system theme + icon_label = QLabel() + 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(tr("delete-dialog-message", count=count)) + msg_font = QFont() + msg_font.setPointSize(11) + msg.setFont(msg_font) + msg.setAlignment(Qt.AlignCenter) + msg.setWordWrap(True) + layout.addWidget(msg) + + # Move to trash checkbox + 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(tr("delete-dialog-confirm")) + 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() + + @property + def dry_run(self) -> bool: + return self._dry_run_cb.isChecked() diff --git a/kalka/app/dialogs/move_dialog.py b/kalka/app/dialogs/move_dialog.py new file mode 100644 index 000000000..f5c9e461e --- /dev/null +++ b/kalka/app/dialogs/move_dialog.py @@ -0,0 +1,73 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QDialogButtonBox, + QCheckBox, QLineEdit, QHBoxLayout, QPushButton, + QFileDialog, QFormLayout +) +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(tr("move-dialog-title")) + self.setMinimumWidth(500) + + layout = QVBoxLayout(self) + + msg = QLabel(tr("move-dialog-message", count=count)) + layout.addWidget(msg) + + # Destination path + dest_layout = QHBoxLayout() + self._dest_edit = QLineEdit() + self._dest_edit.setPlaceholderText(tr("move-dialog-placeholder")) + dest_layout.addWidget(self._dest_edit) + + 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(tr("move-dialog-preserve")) + layout.addWidget(self._preserve_structure) + + 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(tr("move-dialog-confirm")) + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + layout.addWidget(buttons) + + def _browse(self): + path = QFileDialog.getExistingDirectory(self, tr("move-dialog-select-dest")) + 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() + + @property + def dry_run(self) -> bool: + return self._dry_run.isChecked() diff --git a/kalka/app/dialogs/rename_dialog.py b/kalka/app/dialogs/rename_dialog.py new file mode 100644 index 000000000..d31a4f097 --- /dev/null +++ b/kalka/app/dialogs/rename_dialog.py @@ -0,0 +1,34 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QDialogButtonBox +) + +from ..localizer import tr + + +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 = tr("rename-dialog-ext-message", count=count) + else: + msg = tr("rename-dialog-names-message", count=count) + + label = QLabel(msg) + label.setWordWrap(True) + label.setContentsMargins(10, 10, 10, 10) + layout.addWidget(label) + + buttons = QDialogButtonBox( + QDialogButtonBox.Ok | QDialogButtonBox.Cancel + ) + 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/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" 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() diff --git a/kalka/app/dialogs/sort_dialog.py b/kalka/app/dialogs/sort_dialog.py new file mode 100644 index 000000000..5000ad194 --- /dev/null +++ b/kalka/app/dialogs/sort_dialog.py @@ -0,0 +1,41 @@ +from PySide6.QtWidgets import ( + QDialog, QVBoxLayout, QLabel, QPushButton, QComboBox, + QCheckBox, QDialogButtonBox, QFormLayout +) +from PySide6.QtCore import Signal + +from ..localizer import tr + + +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(tr("sort-dialog-title")) + self.setMinimumWidth(300) + + layout = QFormLayout(self) + + self._column = QComboBox() + self._column.addItems(columns) + layout.addRow(tr("sort-by"), self._column) + + self._ascending = QCheckBox(tr("sort-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/kalka/app/icons.py b/kalka/app/icons.py new file mode 100644 index 000000000..a1b445364 --- /dev/null +++ b/kalka/app/icons.py @@ -0,0 +1,144 @@ +"""Icon resources for Kalka interface. + +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, QPainter, QImage +from PySide6.QtCore import QSize, Qt +from PySide6.QtSvg import QSvgRenderer +from functools import lru_cache + + +def _themed_icon(xdg_name: str, fallback_svg: str, size: int = 24) -> 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, 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() + return QIcon(QPixmap.fromImage(image)) + + +# ─── 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 ──────────────────────────────── + +# ─── 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 _themed_icon("system-search", SEARCH_SVG, size) + +def icon_stop(size=24): + return _themed_icon("process-stop", STOP_SVG, size) + +def icon_delete(size=24): + return _themed_icon("edit-delete", DELETE_SVG, size) + +def icon_move(size=24): + return _themed_icon("folder-move", MOVE_SVG, size) + +def icon_save(size=24): + return _themed_icon("document-save", SAVE_SVG, size) + +def icon_select(size=24): + return _themed_icon("edit-select-all", SELECT_SVG, size) + +def icon_sort(size=24): + return _themed_icon("view-sort", SORT_SVG, size) + +def icon_hardlink(size=24): + return _themed_icon("edit-link", HARDLINK_SVG, size) + +def icon_symlink(size=24): + return _themed_icon("edit-link", SYMLINK_SVG, size) + +def icon_rename(size=24): + return _themed_icon("edit-rename", RENAME_SVG, size) + +def icon_clean(size=24): + return _themed_icon("edit-clear-all", CLEAN_SVG, size) + +def icon_optimize(size=24): + return _themed_icon("configure", OPTIMIZE_SVG, size) + +def icon_settings(size=24): + return _themed_icon("configure", SETTINGS_SVG, size) + +def icon_subsettings(size=24): + return _themed_icon("preferences-other", SUBSETTINGS_SVG, size) + +def icon_dir(size=24): + return _themed_icon("folder", DIR_SVG, size) + +def icon_info(size=24): + return _themed_icon("dialog-information", INFO_SVG, 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", + ]: + 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/kalka/app/left_panel.py b/kalka/app/left_panel.py new file mode 100644 index 000000000..ed00ac05a --- /dev/null +++ b/kalka/app/left_panel.py @@ -0,0 +1,141 @@ +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QListWidget, QListWidgetItem, QLabel, + QPushButton, QHBoxLayout, QSizePolicy +) +from PySide6.QtCore import Signal, Qt, QSize, QEvent +from PySide6.QtGui import QFont, QPixmap + +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): + """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 via event filter) + 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(tr("about-logo-tooltip")) + self._logo_label.installEventFilter(self) + layout.addWidget(self._logo_label) + else: + 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.installEventFilter(self) + self._logo_label = title_label + 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(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(tr("tool-settings-tooltip")) + 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) + + for tab in self.TOOL_TABS: + 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) + + self._tool_list.setCurrentRow(0) + self._tool_list.currentItemChanged.connect(self._on_item_changed) + layout.addWidget(self._tool_list) + + # Version label + 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) + 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/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/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/models.py b/kalka/app/models.py new file mode 100644 index 000000000..5d9b6cf1f --- /dev/null +++ b/kalka/app/models.py @@ -0,0 +1,309 @@ +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 + low_priority_scan: bool = False # run scans with idle CPU/IO priority diff --git a/kalka/app/preview_panel.py b/kalka/app/preview_panel.py new file mode 100644 index 000000000..7464b3237 --- /dev/null +++ b/kalka/app/preview_panel.py @@ -0,0 +1,310 @@ +import subprocess +from pathlib import Path + +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QLabel, QSizePolicy, QSplitter, + QStackedWidget, QPlainTextEdit +) +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 _PreviewSlot(QWidget): + """Single preview slot 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._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._text_edit = QPlainTextEdit() + self._text_edit.setReadOnly(True) + self._text_edit.setLineWrapMode(QPlainTextEdit.WidgetWidth) + self._text_edit.setVisible(False) + layout.addWidget(self._text_edit) + + 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._show_image_mode() + self._pixmap = None + 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._pixmap = None + 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() + 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 _preview_text(self, p: Path): + self._show_text_mode() + self._pixmap = None + 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): + self._show_image_mode() + self._pixmap = None + 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(): + self._pixmap = pixmap + self._rescale() + 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): + self._show_image_mode() + self._pixmap = None + 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(): + self._pixmap = pixmap + self._rescale() + 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 + 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(self): + self._current_path = "" + self._pixmap = None + 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 _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): + """File preview panel supporting single and side-by-side comparison modes, + with extended file type support (images, text, PDF, video).""" + + SUPPORTED_EXTENSIONS = _PreviewSlot.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 = _PreviewSlot() + 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 = _PreviewSlot() + self._right_slot = _PreviewSlot() + 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) + 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/progress_widget.py b/kalka/app/progress_widget.py new file mode 100644 index 000000000..4ce05a482 --- /dev/null +++ b/kalka/app/progress_widget.py @@ -0,0 +1,331 @@ +import os +import time +import threading + +from PySide6.QtWidgets import ( + QWidget, QVBoxLayout, QProgressBar, QLabel, QHBoxLayout +) +from PySide6.QtCore import Qt, QTimer + +from .models import ActiveTab, ScanProgress +from .localizer import tr + + +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 + """ + + 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._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) + 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(tr("progress-initializing")) + 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.setEnabled(False) # Uses disabled palette color + row1.addWidget(self._elapsed_label) + layout.addLayout(row1) + + # Row 2: current stage bar "Current" NN% + row2 = QHBoxLayout() + row2.setSpacing(6) + lbl2 = QLabel(tr("progress-current")) + lbl2.setEnabled(False) + lbl2.setFixedWidth(48) + row2.addWidget(lbl2) + self._stage_bar = QProgressBar() + self._stage_bar.setFixedHeight(14) + self._stage_bar.setTextVisible(False) + 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.setEnabled(False) + row2.addWidget(self._stage_pct) + layout.addLayout(row2) + + # Row 3: overall bar "Overall" NN% + row3 = QHBoxLayout() + row3.setSpacing(6) + lbl3 = QLabel(tr("progress-overall")) + lbl3.setEnabled(False) + lbl3.setFixedWidth(48) + row3.addWidget(lbl3) + self._overall_bar = QProgressBar() + self._overall_bar.setFixedHeight(14) + self._overall_bar.setTextVisible(False) + 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.setEnabled(False) + row3.addWidget(self._overall_pct) + layout.addLayout(row3) + + # Row 4: detail counts + row4 = QHBoxLayout() + self._detail_label = QLabel("") + self._detail_label.setEnabled(False) + row4.addWidget(self._detail_label) + row4.addStretch() + self._size_label = QLabel("") + 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.setEnabled(False) + self._steps_label.setAlignment(Qt.AlignCenter) + self._steps_label.setWordWrap(True) + layout.addWidget(self._steps_label) + + # ── Public API ──────────────────────────────────────────── + + 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): + bar.setMaximum(0) # indeterminate + bar.setValue(0) + self._stage_pct.setText("") + self._overall_pct.setText("") + 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(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)): + bar.setMaximum(100) + bar.setValue(100) + lbl.setText("100%") + + self._stage_label.setText(tr("progress-scan-complete")) + self._steps_label.setText("") + + 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 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: + 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 live background file count as estimate + self._last_collection_count = max(self._last_collection_count, checked) + estimate = self._file_count_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._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) + 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("") + + # ── 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 ──────────────────────────────────────── + + 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/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("") 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() diff --git a/kalka/app/state.py b/kalka/app/state.py new file mode 100644 index 000000000..9c73524ff --- /dev/null +++ b/kalka/app/state.py @@ -0,0 +1,124 @@ +import json +from pathlib import Path +from PySide6.QtCore import QObject, Signal, QStandardPaths +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 = "" + # 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() + + 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/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/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 000000000..fb3c4abec Binary files /dev/null and b/kalka/icons/kalka.png differ diff --git a/kalka/icons/kalka2.png b/kalka/icons/kalka2.png new file mode 100644 index 000000000..c7bd61f28 Binary files /dev/null and b/kalka/icons/kalka2.png differ diff --git a/kalka/main.py b/kalka/main.py new file mode 100644 index 000000000..83c657415 --- /dev/null +++ b/kalka/main.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +""" +Kalka - 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 kalka.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(): + from PySide6.QtWidgets import QApplication + from PySide6.QtCore import Qt + + # Initialize i18n before creating any widgets + from app.localizer import init as init_l10n + init_l10n() + + app = QApplication(sys.argv) + app.setApplicationName("Kalka") + app.setApplicationVersion("11.0.1") + app.setOrganizationName("czkawka") + app.setOrganizationDomain("github.com/qarmin") + app.setDesktopFileName("com.github.qarmin.kalka") + + # 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) + + # Import and create main window + from app.main_window import MainWindow + window = MainWindow() + window.show() + + sys.exit(app.exec()) + + +if __name__ == "__main__": + main() 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), ]);