1
0
mirror of https://github.com/arsenetar/dupeguru.git synced 2026-01-25 16:11:39 +00:00

Compare commits

..

82 Commits

Author SHA1 Message Date
c408873d20 Update changelog 2022-03-25 23:37:46 -05:00
bbcdfbf698 Add vscode extension recommendation 2022-03-21 22:27:16 -05:00
8cee1a9467 Fix internal links in CONTRIBUTING.md 2022-03-21 22:19:58 -05:00
448d33dcb6 Add workflow yml validation settings
- Add yml validation to project for vscode
- Allow .vscode/settings.json
- Apply formatting to workflow files
2022-03-21 22:18:22 -05:00
8d414cadac Add initial partial CONTRIBUTING.md
- Adopt a CONTRIBUTING.md format similar to that used by atom/atom.
- Add label section as replacement to wiki
- Add style guide section
- Setup basic document structure

TODO:
- Migrate some existing wiki information here where applicable.
- Migrate some existing help information here.
- Finish up remaining sections.
2022-03-21 22:04:45 -05:00
f902ee889a Add configuration for isort to pyproject.toml 2022-03-21 00:25:36 -05:00
bc89e71935 Update .gitignore
- Pull from github/gitignore to cover some things better
- Organize remaining items
- Remove a few no longer relevant items
2022-03-20 23:25:01 -05:00
17b83c8001 Move polib to setup_requires instead of install_requires 2022-03-20 22:48:03 -05:00
0f845ee67a Update min python version in Makefile 2022-03-20 01:23:01 -05:00
d40e32a143 Update transifex config & pull latest updates
- Update transifex configuration to new format
- Pull translation updates
2022-03-19 20:21:14 -05:00
1bc206e62d Bump version to 4.2.1 2022-03-19 19:02:41 -05:00
106a0feaba Add sponsor information 2022-03-19 17:46:12 -05:00
984e0c4094 Fix help path for local files and some help doc updates 2022-03-19 17:43:11 -05:00
9321e811d7 Enforce minimum Windows version ref #983 2022-03-19 17:01:54 -05:00
a64fcbfb5c Fix deprecation warning from sqlite 2022-03-19 17:01:53 -05:00
cff07a12d6 Black formatter changes 2022-03-19 17:01:53 -05:00
Alfonso Montero
b9c7832c4a Apply @arsenetar's proposed change to fix for errors on window change event. Solves #937. (#980) 2022-03-15 20:47:48 -05:00
b9dfeac2f3 Drop Python 3.6 Support 2022-03-15 05:10:41 -05:00
efc99eee96 Merge pull request #978 from glubsy/fix_zoom_scrollbar
Fix image viewer scrollbar zoom
2022-03-14 20:43:40 -05:00
glubsy
ff7733bb73 Fix image viewer
When zooming in or out, the value computed might be a float instead
of an int, which is what the QScrollBar expect for its setValue method.
Simply casting to int should be enough here.
2022-03-12 22:36:17 +01:00
4b2fbe87ea Default to English on unsupported system language Fix #976
- Add check for supported language to system locale detection
- Fall-back to English when not a supported locale
2022-03-12 04:36:13 -06:00
9e4b41feb5 Fix BASE_PATH for frozen macOS app 2022-03-09 06:50:41 -06:00
cbfa8720f1 Update imports for objc module 2022-03-09 05:01:12 -06:00
a02c5e5b9b Add built modules as artifacts 2022-03-04 01:14:01 -06:00
35e6ffd6af Fix macOS packaging issue 2022-02-09 22:33:41 -06:00
e957f840da Fix python version check in makefile, close #971 2022-02-09 21:59:35 -06:00
85e22089bd Black formatting changes 2022-02-09 21:49:51 -06:00
b7d68b4458 Update debian control template depends 2022-02-09 21:45:45 -06:00
8f440603ee Add Python 3.10 to tox.ini 2022-01-25 10:39:52 -06:00
5d8e559ca3 Fix issue introduced in fix for #900 2022-01-25 10:39:08 -06:00
2c11eecf97 Update version and changelog to 4.2.0 2022-01-24 22:28:40 -06:00
02803f738b Update translation files including Malay 2022-01-24 21:05:33 -06:00
db27e6a645 Add Malay to language selection 2022-01-24 21:02:57 -06:00
c9c35cc60d Add translation source file for dark style change. 2022-01-24 19:33:42 -06:00
880205dbc8 Fix python 3.10 in default action 2022-01-24 19:30:42 -06:00
6456e64328 Update python versions for CI/CD
- Update python versions for Default action
- Set python versions for sonarcloud
2022-01-24 19:27:29 -06:00
f6a0c0cc6d Add initial dark style for use in Windows
- Other platforms can achieve this with the OS theme so not enabled for them at this time.
- Adds preference in display options to use dark style, default is false.
2022-01-24 19:14:30 -06:00
eb57d269fc Update translation source files 2021-11-23 21:11:30 -06:00
34f41dc522 Merge pull request #942 from Dobatymo/hash-cache
Implement hash cache for md5 hash based on sqlite
2021-11-23 21:08:22 -06:00
Dobatymo
77460045c4 clean up abstraction 2021-10-29 15:24:47 +08:00
Dobatymo
9753afba74 change FilesDB to singleton class
move hash calculation back in to Files class
clear cache now clears hash cache in addition to picture cache
2021-10-29 15:12:40 +08:00
Dobatymo
1ea108fc2b changed cache filename 2021-10-29 15:12:40 +08:00
Dobatymo
2f02a6010d implement hash cache for md5 hash based on sqlite 2021-10-29 15:12:40 +08:00
b80489fd66 Update translation source files 2021-09-15 20:15:09 -05:00
1d60e124ee Update invoke_custom_command to run for all selected items 2021-09-02 20:48:25 -05:00
e22d7d2fc9 Remove filtering of 0 size files in engine
Files size is already able to be filtered at a higher level, some users
may decide to see zero length files. Fix #321.
2021-08-28 18:16:22 -05:00
0a0694e095 Expand fix for #630 to fix #551 2021-08-28 17:29:25 -05:00
3da9d5d869 Update documentation files, add multi-language doc build
- Update links in documentation, and some errors
- Remove non-existent page
- Update build to build all languages with --alldoc flag
- Fix one minor debugging change introduced in package.py
2021-08-28 17:07:18 -05:00
78fb052d77 Add more progress details to getmatches, ref #700 2021-08-28 04:58:22 -05:00
9805cba10d Use different message for direct delete success, close #904 2021-08-28 04:27:34 -05:00
4c3dfe2f1f Provide more feedback during scans
- Add output for number of collected files / folders
- Update to allow indeterminate progress bar
- Remove unused hscommon\jobprogress\qt.py
2021-08-28 04:05:07 -05:00
b0baa5bfd6 Add windows position handling at open, fix #653
- Move offscreen windows back on screen
- Restore maximized state without impacting resored size
- Fullscreen comes back on primary screen, needs further work to support
  restore on other screens
2021-08-27 23:26:19 -05:00
22996ee914 Merge pull request #935 from chchia/master
resize preference dialog file size box
2021-08-27 21:57:03 -05:00
chchia
31ec9c667f resize preference dialog file size box 2021-08-28 10:28:06 +08:00
3045361243 Add preference to ignore large files, close #430 2021-08-27 05:35:54 -05:00
809116c764 Fix CodeQL Alerts
- Cast int to Py_ssize_t for multiplication
2021-08-26 03:43:31 -05:00
83f401595d Minor Updates
- Cleanup extension modules in setup.py to use correct namespaces
- Update build.py to leverage setup.py for modules
- Roll mutagen required version back to 1.44.0 to support more distros
- Change build.py and sphinxgen.py to use pathlib
- Remove hsaudiotag from package list for debian and arch
2021-08-26 03:29:24 -05:00
814d145366 Updates to setup files
- Include additional non-python files in MANIFEST.in (package_data in
  setup.cfg was not including the files)
- Update requirements in setup.cfg
2021-08-25 04:10:38 -05:00
efb76c7686 Add OS and Python Information to error dialog 2021-08-25 02:05:18 -05:00
47dbe805bb More cleanup and fixed a flake8 build issue 2021-08-25 01:11:24 -05:00
f11fccc889 More cleanups
- Cleanup columns.py and tables
- Other misc cleanups
- Remove text_field.py from qtlib as it is not used
- Remove unused variables from image_viewer method
2021-08-25 00:46:33 -05:00
2e13c4ccb5 Update internationalization files 2021-08-24 03:54:54 -05:00
da72ffd1fd Add ability to use non-native dialog for directories
- Add preference for native dialogs
- Add non-native directory selection to allow selecting multiple folders
  fixes #874 when using non-native.
2021-08-24 03:52:43 -05:00
2c9437bef4 Fix #897 2021-08-24 03:13:03 -05:00
f9085386a6 First pass code cleanup in qt/qtlib 2021-08-24 00:12:23 -05:00
d576a7043c Code cleanups in core and other affected files 2021-08-21 18:02:02 -05:00
1ef5f56158 Code cleanups in hscommon & external effects 2021-08-21 16:56:27 -05:00
f9316de244 Code cleanups in hscommon\tests 2021-08-21 16:25:33 -05:00
0189c29f47 Misc cleanups in core/tests 2021-08-21 03:52:09 -05:00
b4fa1d68f0 Add check for python version to build.py, close #589 2021-08-20 23:49:20 -05:00
16df882481 Update requirements.txt for previous change 2021-08-19 00:17:46 -05:00
58c04ff9ad Switch from hsaudiotag to mutagen, close #440
- This opens up the ability to support more tags and audio information
- Also makes progress on #333
2021-08-19 00:14:26 -05:00
6b8f85e39a Reveal in Explorer / Finder, close #895 2021-08-18 20:51:45 -05:00
2fff1a3436 Add ablity to load results at start, closes #902
- Add ablility to load .dupguru file at start by passing as first argument
- Add file association to .dupeguru file in windows at install
2021-08-18 19:24:14 -05:00
a685524dd5 Add files for more standardized build tools
- Prior investigation into linux packaging (not using pyinstaller) suggested
having setuptools files could make packaging easier and automatable
- Add setup.cfg and setup.py as initial starting point
- Add MANIFEST.in (at least temporarily)

Currently with the python build module this almost works for main application.
It does not include all the extra data files right now.
2021-08-18 04:12:38 -05:00
74918e2c56 Attempt to fix apt-get failure 2021-08-18 03:07:47 -05:00
18895d983b Fix syntax error in codeql-analysis.yml 2021-08-18 03:04:44 -05:00
fe720208ea Add minimum custom build for codeql cpp 2021-08-18 02:49:20 -05:00
091d9e9239 Create codeql-analysis.yml
Test out codeql
2021-08-18 02:33:40 -05:00
5a4958cff9 Update translation .pot files 2021-08-17 21:18:47 -05:00
be10b462fc Add portable mode
If settings.ini is present next to the executable, will run in portable mode.
This results in settings, data, and cache all being in same folder as dupeGuru.
2021-08-17 21:12:32 -05:00
d62b13bcdb Removing travis
- All CI is now covered by Github Actions
- Remove .travis.yml
- Remove tox-travis in requirements-extra.txt
2021-08-17 18:16:20 -05:00
192 changed files with 6259 additions and 3311 deletions

13
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
# These are supported funding model platforms
github: arsenetar
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

50
.github/workflows/codeql-analysis.yml vendored Normal file
View File

@@ -0,0 +1,50 @@
name: "CodeQL"
on:
push:
branches: [master]
pull_request:
# The branches below must be a subset of the branches above
branches: [master]
schedule:
- cron: "24 20 * * 2"
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: ["cpp", "python"]
steps:
- name: Checkout repository
uses: actions/checkout@v2
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: ${{ matrix.language }}
# 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.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
- if: matrix.language == 'cpp'
name: Build Cpp
run: |
sudo apt-get update
sudo apt-get install python3-pyqt5
make modules
- if: matrix.language == 'python'
name: Autobuild
uses: github/codeql-action/autobuild@v1
# Analysis
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

View File

@@ -4,71 +4,81 @@ name: Default CI/CD
on:
push:
branches: [ master ]
branches: [master]
pull_request:
branches: [ master ]
branches: [master]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.9
uses: actions/setup-python@v2
with:
python-version: 3.9
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt -r requirements-extra.txt
- name: Lint with flake8
run: |
flake8 .
- uses: actions/checkout@v2
- name: Set up Python 3.10
uses: actions/setup-python@v2
with:
python-version: "3.10"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt -r requirements-extra.txt
- name: Lint with flake8
run: |
flake8 .
format:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.9
uses: actions/setup-python@v2
with:
python-version: 3.9
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt -r requirements-extra.txt
- name: Check format with black
run: |
black .
- uses: actions/checkout@v2
- name: Set up Python 3.10
uses: actions/setup-python@v2
with:
python-version: "3.10"
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt -r requirements-extra.txt
- name: Check format with black
run: |
black .
test:
needs: [lint, format]
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python-version: [3.6, 3.7, 3.8, 3.9]
python-version: [3.7, 3.8, 3.9, "3.10"]
exclude:
- os: macos-latest
python-version: 3.6
python-version: 3.7
- os: macos-latest
python-version: 3.7
- os: windows-latest
python-version: 3.6
python-version: 3.8
- os: macos-latest
python-version: 3.9
- os: windows-latest
python-version: 3.7
- os: windows-latest
python-version: 3.8
- os: windows-latest
python-version: 3.9
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt -r requirements-extra.txt
- name: Build python modules
run: |
python build.py --modules
- name: Run tests
run: |
pytest core hscommon
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt -r requirements-extra.txt
- name: Build python modules
run: |
python build.py --modules
- name: Run tests
run: |
pytest core hscommon
- name: Upload Artifacts
if: matrix.os == 'ubuntu-latest'
uses: actions/upload-artifact@v3
with:
name: modules ${{ matrix.python-version }}
path: ${{ github.workspace }}/**/*.so

123
.gitignore vendored
View File

@@ -1,28 +1,111 @@
.DS_Store
__pycache__
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.waf*
.lock-waf*
.tox
/tags
#*.pot
build
dist
env*
/deps
cocoa/autogen
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
/run.py
/cocoa/*/Info.plist
/cocoa/*/build
# Environments
.env
.venv
env*/
venv/
ENV/
env.bak/
venv.bak/
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# macOS
.DS_Store
# Visual Studio Code
.vscode/*
!.vscode/settings.json
#!.vscode/tasks.json
#!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets
# Local History for Visual Studio Code
.history/
# Built Visual Studio Code Extensions
*.vsix
# dupeGuru Specific
/qt/*_rc.py
/help/*/conf.py
/help/*/changelog.rst
/transifex
cocoa/autogen
/cocoa/*/Info.plist
/cocoa/*/build
*.pyd
*.exe
*.spec
.vscode
*.waf*
.lock-waf*
/tags

1
.sonarcloud.properties Normal file
View File

@@ -0,0 +1 @@
sonar.python.version=3.7, 3.8, 3.9, 3.10

View File

@@ -1,27 +0,0 @@
sudo: false
language: python
install:
- pip3 install -r requirements.txt -r requirements-extra.txt
script: tox
matrix:
include:
- os: "linux"
dist: "xenial"
python: "3.6"
- os: "linux"
dist: "xenial"
python: "3.7"
- os: "linux"
dist: "focal"
python: "3.8"
- os: "linux"
dist: "focal"
python: "3.9"
- os: "windows"
language: shell
python: "3.9"
env: "PATH=/c/python39:/c/python39/Scripts:$PATH"
before_install:
- choco install python --version=3.9.6
- cp /c/python39/python.exe /c/python39/python3.exe
script: tox -e py39

View File

@@ -1,26 +1,27 @@
[main]
host = https://www.transifex.com
[dupeguru-1.core]
file_filter = locale/<lang>/LC_MESSAGES/core.po
source_file = locale/core.pot
source_lang = en
type = PO
[dupeguru-1.columns]
[o:voltaicideas:p:dupeguru-1:r:columns]
file_filter = locale/<lang>/LC_MESSAGES/columns.po
source_file = locale/columns.pot
source_lang = en
type = PO
type = PO
[dupeguru-1.ui]
file_filter = locale/<lang>/LC_MESSAGES/ui.po
source_file = locale/ui.pot
[o:voltaicideas:p:dupeguru-1:r:core]
file_filter = locale/<lang>/LC_MESSAGES/core.po
source_file = locale/core.pot
source_lang = en
type = PO
type = PO
[dupeguru-1.qtlib]
[o:voltaicideas:p:dupeguru-1:r:qtlib]
file_filter = qtlib/locale/<lang>/LC_MESSAGES/qtlib.po
source_file = qtlib/locale/qtlib.pot
source_lang = en
type = PO
type = PO
[o:voltaicideas:p:dupeguru-1:r:ui]
file_filter = locale/<lang>/LC_MESSAGES/ui.po
source_file = locale/ui.pot
source_lang = en
type = PO

10
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,10 @@
{
// List of extensions which should be recommended for users of this workspace.
"recommendations": [
"redhat.vscode-yaml",
"ms-python.vscode-pylance",
"ms-python.python"
],
// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
"unwantedRecommendations": []
}

12
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,12 @@
{
"python.formatting.provider": "black",
"cSpell.words": [
"Dupras",
"hscommon"
],
"python.languageServer": "Pylance",
"yaml.schemaStore.enable": true,
"yaml.schemas": {
"https://json.schemastore.org/github-workflow.json": ".github/workflows/*.yml"
}
}

88
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,88 @@
# Contributing to dupeGuru
The following is a set of guidelines and information for contributing to dupeGuru.
#### Table of Contents
[Things to Know Before Starting](#things-to-know-before-starting)
[Ways to Contribute](#ways-to-contribute)
* [Reporting Bugs](#reporting-bugs)
* [Suggesting Enhancements](#suggesting-enhancements)
* [Localization](#localization)
* [Code Contribution](#code-contribution)
* [Pull Requests](#pull-requests)
[Style Guides](#style-guides)
* [Git Commit Messages](#git-commit-messages)
* [Python Style Guide](#python-style-guide)
* [Documentation Style Guide](#documentation-style-guide)
[Additional Notes](#additional-notes)
* [Issue and Pull Request Labels](#issue-and-pull-request-labels)
## Things to Know Before Starting
**TODO**
## Ways to contribute
### Reporting Bugs
**TODO**
### Suggesting Enhancements
**TODO**
### Localization
**TODO**
### Code Contribution
**TODO**
### Pull Requests
Please follow these steps to have your contribution considered by the maintainers:
1. Keep Pull Request specific to one feature or bug.
2. Follow the [style guides](#style-guides)
3. After you submit your pull request, verify that all [status checks](https://help.github.com/articles/about-status-checks/) are passing <details><summary>What if the status checks are failing?</summary>If a status check is failing, and you believe that the failure is unrelated to your change, please leave a comment on the pull request explaining why you believe the failure is unrelated. A maintainer will re-run the status check for you. If we conclude that the failure was a false positive, then we will open an issue to track that problem with our status check suite.</details>
While the prerequisites above must be satisfied prior to having your pull request reviewed, the reviewer(s) may ask you to complete additional design work, tests, or other changes before your pull request can be ultimately accepted.
## Style Guides
### Git Commit Messages
- Use the present tense ("Add feature" not "Added feature")
- Use the imperative mood ("Move cursor to..." not "Moves cursor to...")
- Limit the first line to 72 characters or less
- Reference issues and pull requests liberally after the first line
### Python Style Guide
- All files are formatted with [Black](https://github.com/psf/black)
- Follow [PEP 8](https://peps.python.org/pep-0008/) as much as practical
- Pass [flake8](https://flake8.pycqa.org/en/latest/) linting
- Include [PEP 484](https://peps.python.org/pep-0484/) type hints (new code)
### Documentation Style Guide
**TODO**
## Additional Notes
### Issue and Pull Request Labels
This section lists and describes the various labels used with issues and pull requests. Each of the labels is listed with a search link as well.
#### Issue Type and Status
| Label name | Search | Description |
|------------|--------|-------------|
| `enhancement` | [search](https://github.com/arsenetar/dupeguru/issues?q=is%3Aopen+is%3Aissue+label%3Aenhancement) | Feature requests and enhancements. |
| `bug` | [search](https://github.com/arsenetar/dupeguru/issues?q=is%3Aopen+is%3Aissue+label%3Abug) | Bug reports. |
| `duplicate` | [search](https://github.com/arsenetar/dupeguru/issues?q=is%3Aopen+is%3Aissue+label%3Aduplicate) | Issue is a duplicate of existing issue. |
| `needs-reproduction` | [search](https://github.com/arsenetar/dupeguru/issues?q=is%3Aopen+is%3Aissue+label%3Aneeds-reproduction) | A bug that has not been able to be reproduced. |
| `needs-information` | [search](https://github.com/arsenetar/dupeguru/issues?q=is%3Aopen+is%3Aissue+label%3Aneeds-information) | More information needs to be collected about these problems or feature requests (e.g. steps to reproduce). |
| `blocked` | [search](https://github.com/arsenetar/dupeguru/issues?q=is%3Aopen+is%3Aissue+label%3Ablocked) | Issue blocked by other issues. |
| `beginner` | [search](https://github.com/arsenetar/dupeguru/issues?q=is%3Aopen+is%3Aissue+label%3Abeginner) | Less complex issues for users who want to start contributing. |
#### Category Labels
| Label name | Search | Description |
|------------|--------|-------------|
| `3rd party` | [search](https://github.com/arsenetar/dupeguru/issues?q=is%3Aopen+is%3Aissue+label%3A%223rd%20party%22) | Related to a 3rd party dependency. |
| `crash` | [search](https://github.com/arsenetar/dupeguru/issues?q=is%3Aopen+is%3Aissue+label%3Acrash) | Related to crashes (complete, or unhandled). |
| `documentation` | [search](https://github.com/arsenetar/dupeguru/issues?q=is%3Aopen+is%3Aissue+label%3Adocumentation) | Related to any documentation. |
| `linux` | [search](https://github.com/arsenetar/dupeguru/issues?q=is%3Aopen+is%3Aissue+label%3linux) | Related to running on Linux. |
| `mac` | [search](https://github.com/arsenetar/dupeguru/issues?q=is%3Aopen+is%3Aissue+label%3Amac) | Related to running on macOS. |
| `performance` | [search](https://github.com/arsenetar/dupeguru/issues?q=is%3Aopen+is%3Aissue+label%3Aperformance) | Related to the performance. |
| `ui` | [search](https://github.com/arsenetar/dupeguru/issues?q=is%3Aopen+is%3Aissue+label%3Aui)| Related to the visual design. |
| `windows` | [search](https://github.com/arsenetar/dupeguru/issues?q=is%3Aopen+is%3Aissue+label%3Awindows) | Related to running on Windows. |
#### Pull Request Labels
None at this time, if the volume of Pull Requests increase labels may be added to manage.

6
MANIFEST.in Normal file
View File

@@ -0,0 +1,6 @@
recursive-include core *.h
recursive-include core *.m
include run.py
graft locale
graft help
graft qtlib/locale

View File

@@ -1,7 +1,7 @@
PYTHON ?= python3
PYTHON_VERSION_MINOR := $(shell ${PYTHON} -c "import sys; print(sys.version_info.minor)")
PYRCC5 ?= pyrcc5
REQ_MINOR_VERSION = 6
REQ_MINOR_VERSION = 7
PREFIX ?= /usr/local
# Window compatability via Msys2
@@ -53,7 +53,7 @@ pyc: | env
${VENV_PYTHON} -m compileall ${packages}
reqs:
ifneq ($(shell test $(PYTHON_VERSION_MINOR) -gt $(REQ_MINOR_VERSION); echo $$?),0)
ifneq ($(shell test $(PYTHON_VERSION_MINOR) -ge $(REQ_MINOR_VERSION); echo $$?),0)
$(error "Python 3.${REQ_MINOR_VERSION}+ required. Aborting.")
endif
ifndef NO_VENV

View File

@@ -36,7 +36,7 @@ For windows instructions see the [Windows Instructions](Windows.md).
For macos instructions (qt version) see the [macOS Instructions](macos.md).
### Prerequisites
* [Python 3.6+][python]
* [Python 3.7+][python]
* PyQt5
### System Setup

View File

@@ -2,7 +2,7 @@
### Prerequisites
- [Python 3.6+][python]
- [Python 3.7+][python]
- [Visual Studio 2019][vs] or [Visual Studio Build Tools 2019][vsBuildTools] with the Windows 10 SDK
- [nsis][nsis] (for installer creation)
- [msys2][msys2] (for using makefile method)
@@ -16,7 +16,7 @@ After installing python it is recommended to update setuptools before compiling
More details on setting up python for compiling packages on windows can be found on the [python wiki][pythonWindowsCompilers] Take note of the required vc++ versions.
### With build.py (preferred)
To build with a different python version 3.6 vs 3.8 or 32 bit vs 64 bit specify that version instead of -3.8 to the `py` command below. If you want to build additional versions while keeping all virtual environments setup use a different location for each virtual environment.
To build with a different python version 3.7 vs 3.8 or 32 bit vs 64 bit specify that version instead of -3.8 to the `py` command below. If you want to build additional versions while keeping all virtual environments setup use a different location for each virtual environment.
$ cd <dupeGuru directory>
$ py -3.8 -m venv .\env

106
build.py
View File

@@ -4,18 +4,17 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
import os
import os.path as op
from pathlib import Path
import sys
from optparse import OptionParser
import shutil
from multiprocessing import Pool
from setuptools import setup, Extension
from setuptools import sandbox
from hscommon import sphinxgen
from hscommon.build import (
add_to_pythonpath,
print_and_do,
move_all,
fix_qt_resource_file,
)
from hscommon import loc
@@ -30,7 +29,8 @@ def parse_args():
dest="clean",
help="Clean build folder before building",
)
parser.add_option("--doc", action="store_true", dest="doc", help="Build only the help file")
parser.add_option("--doc", action="store_true", dest="doc", help="Build only the help file (en)")
parser.add_option("--alldoc", action="store_true", dest="all_doc", help="Build only the help file in all languages")
parser.add_option("--loc", action="store_true", dest="loc", help="Build only localization")
parser.add_option(
"--updatepot",
@@ -60,16 +60,16 @@ def parse_args():
return options
def build_help():
print("Generating Help")
current_path = op.abspath(".")
help_basepath = op.join(current_path, "help", "en")
help_destpath = op.join(current_path, "build", "help")
changelog_path = op.join(current_path, "help", "changelog")
def build_one_help(language):
print("Generating Help in {}".format(language))
current_path = Path(".").absolute()
changelog_path = current_path.joinpath("help", "changelog")
tixurl = "https://github.com/arsenetar/dupeguru/issues/{}"
confrepl = {"language": "en"}
changelogtmpl = op.join(current_path, "help", "changelog.tmpl")
conftmpl = op.join(current_path, "help", "conf.tmpl")
changelogtmpl = current_path.joinpath("help", "changelog.tmpl")
conftmpl = current_path.joinpath("help", "conf.tmpl")
help_basepath = current_path.joinpath("help", language)
help_destpath = current_path.joinpath("build", "help", language)
confrepl = {"language": language}
sphinxgen.gen(
help_basepath,
help_destpath,
@@ -81,16 +81,23 @@ def build_help():
)
def build_help():
languages = ["en", "de", "fr", "hy", "ru", "uk"]
# Running with Pools as for some reason sphinx seems to cross contaminate the output otherwise
with Pool(len(languages)) as p:
p.map(build_one_help, languages)
def build_qt_localizations():
loc.compile_all_po(op.join("qtlib", "locale"))
loc.merge_locale_dir(op.join("qtlib", "locale"), "locale")
loc.compile_all_po(Path("qtlib", "locale"))
loc.merge_locale_dir(Path("qtlib", "locale"), "locale")
def build_localizations():
loc.compile_all_po("locale")
build_qt_localizations()
locale_dest = op.join("build", "locale")
if op.exists(locale_dest):
locale_dest = Path("build", "locale")
if locale_dest.exists():
shutil.rmtree(locale_dest)
shutil.copytree("locale", locale_dest, ignore=shutil.ignore_patterns("*.po", "*.pot"))
@@ -98,57 +105,35 @@ def build_localizations():
def build_updatepot():
print("Building .pot files from source files")
print("Building core.pot")
loc.generate_pot(["core"], op.join("locale", "core.pot"), ["tr"])
loc.generate_pot(["core"], Path("locale", "core.pot"), ["tr"])
print("Building columns.pot")
loc.generate_pot(["core"], op.join("locale", "columns.pot"), ["coltr"])
loc.generate_pot(["core"], Path("locale", "columns.pot"), ["coltr"])
print("Building ui.pot")
# When we're not under OS X, we don't want to overwrite ui.pot because it contains Cocoa locs
# We want to merge the generated pot with the old pot in the most preserving way possible.
ui_packages = ["qt", op.join("cocoa", "inter")]
loc.generate_pot(ui_packages, op.join("locale", "ui.pot"), ["tr"], merge=True)
ui_packages = ["qt", Path("cocoa", "inter")]
loc.generate_pot(ui_packages, Path("locale", "ui.pot"), ["tr"], merge=True)
print("Building qtlib.pot")
loc.generate_pot(["qtlib"], op.join("qtlib", "locale", "qtlib.pot"), ["tr"])
loc.generate_pot(["qtlib"], Path("qtlib", "locale", "qtlib.pot"), ["tr"])
def build_mergepot():
print("Updating .po files using .pot files")
loc.merge_pots_into_pos("locale")
loc.merge_pots_into_pos(op.join("qtlib", "locale"))
# loc.merge_pots_into_pos(op.join("cocoalib", "locale"))
loc.merge_pots_into_pos(Path("qtlib", "locale"))
# loc.merge_pots_into_pos(Path("cocoalib", "locale"))
def build_normpo():
loc.normalize_all_pos("locale")
loc.normalize_all_pos(op.join("qtlib", "locale"))
# loc.normalize_all_pos(op.join("cocoalib", "locale"))
loc.normalize_all_pos(Path("qtlib", "locale"))
# loc.normalize_all_pos(Path("cocoalib", "locale"))
def build_pe_modules():
print("Building PE Modules")
exts = [
Extension(
"_block",
[
op.join("core", "pe", "modules", "block.c"),
op.join("core", "pe", "modules", "common.c"),
],
),
Extension(
"_cache",
[
op.join("core", "pe", "modules", "cache.c"),
op.join("core", "pe", "modules", "common.c"),
],
),
]
exts.append(Extension("_block_qt", [op.join("qt", "pe", "modules", "block.c")]))
setup(
script_args=["build_ext", "--inplace"],
ext_modules=exts,
)
move_all("_block_qt*", op.join("qt", "pe"))
move_all("_block*", op.join("core", "pe"))
move_all("_cache*", op.join("core", "pe"))
# Leverage setup.py to build modules
sandbox.run_setup("setup.py", ["build_ext", "--inplace"])
def build_normal():
@@ -159,19 +144,22 @@ def build_normal():
print("Building localizations")
build_localizations()
print("Building Qt stuff")
print_and_do("pyrcc5 {0} > {1}".format(op.join("qt", "dg.qrc"), op.join("qt", "dg_rc.py")))
fix_qt_resource_file(op.join("qt", "dg_rc.py"))
print_and_do("pyrcc5 {0} > {1}".format(Path("qt", "dg.qrc"), Path("qt", "dg_rc.py")))
fix_qt_resource_file(Path("qt", "dg_rc.py"))
build_help()
def main():
if sys.version_info < (3, 7):
sys.exit("Python < 3.7 is unsupported.")
options = parse_args()
if options.clean:
if op.exists("build"):
shutil.rmtree("build")
if not op.exists("build"):
os.mkdir("build")
if options.clean and Path("build").exists():
shutil.rmtree("build")
if not Path("build").exists():
Path("build").mkdir()
if options.doc:
build_one_help("en")
elif options.all_doc:
build_help()
elif options.loc:
build_localizations()

View File

@@ -1,2 +1,2 @@
__version__ = "4.1.1"
__version__ = "4.2.1"
__appname__ = "dupeGuru"

View File

@@ -48,31 +48,31 @@ MSG_MANY_FILES_TO_OPEN = tr(
class DestType:
Direct = 0
Relative = 1
Absolute = 2
DIRECT = 0
RELATIVE = 1
ABSOLUTE = 2
class JobType:
Scan = "job_scan"
Load = "job_load"
Move = "job_move"
Copy = "job_copy"
Delete = "job_delete"
SCAN = "job_scan"
LOAD = "job_load"
MOVE = "job_move"
COPY = "job_copy"
DELETE = "job_delete"
class AppMode:
Standard = 0
Music = 1
Picture = 2
STANDARD = 0
MUSIC = 1
PICTURE = 2
JOBID2TITLE = {
JobType.Scan: tr("Scanning for duplicates"),
JobType.Load: tr("Loading"),
JobType.Move: tr("Moving"),
JobType.Copy: tr("Copying"),
JobType.Delete: tr("Sending to Trash"),
JobType.SCAN: tr("Scanning for duplicates"),
JobType.LOAD: tr("Loading"),
JobType.MOVE: tr("Moving"),
JobType.COPY: tr("Copying"),
JobType.DELETE: tr("Sending to Trash"),
}
@@ -126,18 +126,20 @@ class DupeGuru(Broadcaster):
PICTURE_CACHE_TYPE = "sqlite" # set to 'shelve' for a ShelveCache
def __init__(self, view):
def __init__(self, view, portable=False):
if view.get_default(DEBUG_MODE_PREFERENCE):
logging.getLogger().setLevel(logging.DEBUG)
logging.debug("Debug mode enabled")
Broadcaster.__init__(self)
self.view = view
self.appdata = desktop.special_folder_path(desktop.SpecialFolder.AppData, appname=self.NAME)
self.appdata = desktop.special_folder_path(desktop.SpecialFolder.APPDATA, appname=self.NAME, portable=portable)
if not op.exists(self.appdata):
os.makedirs(self.appdata)
self.app_mode = AppMode.Standard
self.app_mode = AppMode.STANDARD
self.discarded_file_count = 0
self.exclude_list = ExcludeList()
hash_cache_file = op.join(self.appdata, "hash_cache.db")
fs.filesdb.connect(hash_cache_file)
self.directories = directories.Directories(self.exclude_list)
self.results = results.Results(self)
self.ignore_list = IgnoreList()
@@ -148,7 +150,7 @@ class DupeGuru(Broadcaster):
"escape_filter_regexp": True,
"clean_empty_dirs": False,
"ignore_hardlink_matches": False,
"copymove_dest_type": DestType.Relative,
"copymove_dest_type": DestType.RELATIVE,
"picture_cache_type": self.PICTURE_CACHE_TYPE,
}
self.selected_dupes = []
@@ -169,9 +171,9 @@ class DupeGuru(Broadcaster):
def _recreate_result_table(self):
if self.result_table is not None:
self.result_table.disconnect()
if self.app_mode == AppMode.Picture:
if self.app_mode == AppMode.PICTURE:
self.result_table = pe.result_table.ResultTable(self)
elif self.app_mode == AppMode.Music:
elif self.app_mode == AppMode.MUSIC:
self.result_table = me.result_table.ResultTable(self)
else:
self.result_table = se.result_table.ResultTable(self)
@@ -184,15 +186,13 @@ class DupeGuru(Broadcaster):
return op.join(self.appdata, cache_name)
def _get_dupe_sort_key(self, dupe, get_group, key, delta):
if self.app_mode in (AppMode.Music, AppMode.Picture):
if key == "folder_path":
dupe_folder_path = getattr(dupe, "display_folder_path", dupe.folder_path)
return str(dupe_folder_path).lower()
if self.app_mode == AppMode.Picture:
if delta and key == "dimensions":
r = cmp_value(dupe, key)
ref_value = cmp_value(get_group().ref, key)
return get_delta_dimensions(r, ref_value)
if self.app_mode in (AppMode.MUSIC, AppMode.PICTURE) and key == "folder_path":
dupe_folder_path = getattr(dupe, "display_folder_path", dupe.folder_path)
return str(dupe_folder_path).lower()
if self.app_mode == AppMode.PICTURE and delta and key == "dimensions":
r = cmp_value(dupe, key)
ref_value = cmp_value(get_group().ref, key)
return get_delta_dimensions(r, ref_value)
if key == "marked":
return self.results.is_marked(dupe)
if key == "percentage":
@@ -212,10 +212,9 @@ class DupeGuru(Broadcaster):
return result
def _get_group_sort_key(self, group, key):
if self.app_mode in (AppMode.Music, AppMode.Picture):
if key == "folder_path":
dupe_folder_path = getattr(group.ref, "display_folder_path", group.ref.folder_path)
return str(dupe_folder_path).lower()
if self.app_mode in (AppMode.MUSIC, AppMode.PICTURE) and key == "folder_path":
dupe_folder_path = getattr(group.ref, "display_folder_path", group.ref.folder_path)
return str(dupe_folder_path).lower()
if key == "percentage":
return group.percentage
if key == "dupe_count":
@@ -267,7 +266,7 @@ class DupeGuru(Broadcaster):
return None
def _get_export_data(self):
columns = [col for col in self.result_table.columns.ordered_columns if col.visible and col.name != "marked"]
columns = [col for col in self.result_table._columns.ordered_columns if col.visible and col.name != "marked"]
colnames = [col.display for col in columns]
rows = []
for group_id, group in enumerate(self.results.groups):
@@ -294,32 +293,36 @@ class DupeGuru(Broadcaster):
self.view.show_message(msg)
def _job_completed(self, jobid):
if jobid == JobType.Scan:
if jobid == JobType.SCAN:
self._results_changed()
fs.filesdb.commit()
if not self.results.groups:
self.view.show_message(tr("No duplicates found."))
else:
self.view.show_results_window()
if jobid in {JobType.Move, JobType.Delete}:
if jobid in {JobType.MOVE, JobType.DELETE}:
self._results_changed()
if jobid == JobType.Load:
if jobid == JobType.LOAD:
self._recreate_result_table()
self._results_changed()
self.view.show_results_window()
if jobid in {JobType.Copy, JobType.Move, JobType.Delete}:
if jobid in {JobType.COPY, JobType.MOVE, JobType.DELETE}:
if self.results.problems:
self.problem_dialog.refresh()
self.view.show_problem_dialog()
else:
msg = {
JobType.Copy: tr("All marked files were copied successfully."),
JobType.Move: tr("All marked files were moved successfully."),
JobType.Delete: tr("All marked files were successfully sent to Trash."),
}[jobid]
if jobid == JobType.COPY:
msg = tr("All marked files were copied successfully.")
elif jobid == JobType.MOVE:
msg = tr("All marked files were moved successfully.")
elif jobid == JobType.DELETE and self.deletion_options.direct:
msg = tr("All marked files were deleted successfully.")
else:
msg = tr("All marked files were successfully sent to Trash.")
self.view.show_message(msg)
def _job_error(self, jobid, err):
if jobid == JobType.Load:
if jobid == JobType.LOAD:
msg = tr("Could not load file: {}").format(err)
self.view.show_message(msg)
return False
@@ -349,17 +352,17 @@ class DupeGuru(Broadcaster):
# --- Protected
def _get_fileclasses(self):
if self.app_mode == AppMode.Picture:
if self.app_mode == AppMode.PICTURE:
return [pe.photo.PLAT_SPECIFIC_PHOTO_CLASS]
elif self.app_mode == AppMode.Music:
elif self.app_mode == AppMode.MUSIC:
return [me.fs.MusicFile]
else:
return [se.fs.File]
def _prioritization_categories(self):
if self.app_mode == AppMode.Picture:
if self.app_mode == AppMode.PICTURE:
return pe.prioritize.all_categories()
elif self.app_mode == AppMode.Music:
elif self.app_mode == AppMode.MUSIC:
return me.prioritize.all_categories()
else:
return prioritize.all_categories()
@@ -393,20 +396,20 @@ class DupeGuru(Broadcaster):
g = self.results.get_group_of_duplicate(dupe)
for other in g:
if other is not dupe:
self.ignore_list.Ignore(str(other.path), str(dupe.path))
self.ignore_list.ignore(str(other.path), str(dupe.path))
self.remove_duplicates(dupes)
self.ignore_list_dialog.refresh()
def apply_filter(self, filter):
def apply_filter(self, result_filter):
"""Apply a filter ``filter`` to the results so that it shows only dupe groups that match it.
:param str filter: filter to apply
"""
self.results.apply_filter(None)
if self.options["escape_filter_regexp"]:
filter = escape(filter, set("()[]\\.|+?^"))
filter = escape(filter, "*", ".")
self.results.apply_filter(filter)
result_filter = escape(result_filter, set("()[]\\.|+?^"))
result_filter = escape(result_filter, "*", ".")
self.results.apply_filter(result_filter)
self._results_changed()
def clean_empty_dirs(self, path):
@@ -420,14 +423,17 @@ class DupeGuru(Broadcaster):
except FileNotFoundError:
pass # we don't care
def clear_hash_cache(self):
fs.filesdb.clear()
def copy_or_move(self, dupe, copy: bool, destination: str, dest_type: DestType):
source_path = dupe.path
location_path = first(p for p in self.directories if dupe.path in p)
dest_path = Path(destination)
if dest_type in {DestType.Relative, DestType.Absolute}:
if dest_type in {DestType.RELATIVE, DestType.ABSOLUTE}:
# no filename, no windows drive letter
source_base = source_path.remove_drive_letter().parent()
if dest_type == DestType.Relative:
if dest_type == DestType.RELATIVE:
source_base = source_base[location_path:]
dest_path = dest_path[source_base]
if not dest_path.exists():
@@ -466,7 +472,7 @@ class DupeGuru(Broadcaster):
)
if destination:
desttype = self.options["copymove_dest_type"]
jobid = JobType.Copy if copy else JobType.Move
jobid = JobType.COPY if copy else JobType.MOVE
self._start_job(jobid, do)
def delete_marked(self):
@@ -482,7 +488,7 @@ class DupeGuru(Broadcaster):
self.deletion_options.direct,
]
logging.debug("Starting deletion job with args %r", args)
self._start_job(JobType.Delete, self._do_delete, args=args)
self._start_job(JobType.DELETE, self._do_delete, args=args)
def export_to_xhtml(self):
"""Export current results to XHTML.
@@ -535,21 +541,21 @@ class DupeGuru(Broadcaster):
return
if not self.selected_dupes:
return
dupe = self.selected_dupes[0]
group = self.results.get_group_of_duplicate(dupe)
ref = group.ref
cmd = cmd.replace("%d", str(dupe.path))
cmd = cmd.replace("%r", str(ref.path))
match = re.match(r'"([^"]+)"(.*)', cmd)
if match is not None:
# This code here is because subprocess. Popen doesn't seem to accept, under Windows,
# executable paths with spaces in it, *even* when they're enclosed in "". So this is
# a workaround to make the damn thing work.
exepath, args = match.groups()
path, exename = op.split(exepath)
subprocess.Popen(exename + args, shell=True, cwd=path)
else:
subprocess.Popen(cmd, shell=True)
dupes = self.selected_dupes
refs = [self.results.get_group_of_duplicate(dupe).ref for dupe in dupes]
for dupe, ref in zip(dupes, refs):
dupe_cmd = cmd.replace("%d", str(dupe.path))
dupe_cmd = dupe_cmd.replace("%r", str(ref.path))
match = re.match(r'"([^"]+)"(.*)', dupe_cmd)
if match is not None:
# This code here is because subprocess. Popen doesn't seem to accept, under Windows,
# executable paths with spaces in it, *even* when they're enclosed in "". So this is
# a workaround to make the damn thing work.
exepath, args = match.groups()
path, exename = op.split(exepath)
subprocess.Popen(exename + args, shell=True, cwd=path)
else:
subprocess.Popen(dupe_cmd, shell=True)
def load(self):
"""Load directory selection and ignore list from files in appdata.
@@ -582,7 +588,7 @@ class DupeGuru(Broadcaster):
def do(j):
self.results.load_from_xml(filename, self._get_file, j)
self._start_job(JobType.Load, do)
self._start_job(JobType.LOAD, do)
def make_selected_reference(self):
"""Promote :attr:`selected_dupes` to reference position within their respective groups.
@@ -595,9 +601,8 @@ class DupeGuru(Broadcaster):
changed_groups = set()
for dupe in dupes:
g = self.results.get_group_of_duplicate(dupe)
if g not in changed_groups:
if self.results.make_ref(dupe):
changed_groups.add(g)
if g not in changed_groups and self.results.make_ref(dupe):
changed_groups.add(g)
# It's not always obvious to users what this action does, so to make it a bit clearer,
# we change our selection to the ref of all changed groups. However, we also want to keep
# the files that were ref before and weren't changed by the action. In effect, what this
@@ -647,15 +652,14 @@ class DupeGuru(Broadcaster):
def open_selected(self):
"""Open :attr:`selected_dupes` with their associated application."""
if len(self.selected_dupes) > 10:
if not self.view.ask_yes_no(MSG_MANY_FILES_TO_OPEN):
return
if len(self.selected_dupes) > 10 and not self.view.ask_yes_no(MSG_MANY_FILES_TO_OPEN):
return
for dupe in self.selected_dupes:
desktop.open_path(dupe.path)
def purge_ignore_list(self):
"""Remove files that don't exist from :attr:`ignore_list`."""
self.ignore_list.Filter(lambda f, s: op.exists(f) and op.exists(s))
self.ignore_list.filter(lambda f, s: op.exists(f) and op.exists(s))
self.ignore_list_dialog.refresh()
def remove_directories(self, indexes):
@@ -753,6 +757,9 @@ class DupeGuru(Broadcaster):
self.exclude_list.save_to_xml(p)
self.notify("save_session")
def close(self):
fs.filesdb.close()
def save_as(self, filename):
"""Save results in ``filename``.
@@ -786,7 +793,7 @@ class DupeGuru(Broadcaster):
for k, v in self.options.items():
if hasattr(scanner, k):
setattr(scanner, k, v)
if self.app_mode == AppMode.Picture:
if self.app_mode == AppMode.PICTURE:
scanner.cache_path = self._get_picture_cache_path()
self.results.groups = []
self._recreate_result_table()
@@ -794,7 +801,7 @@ class DupeGuru(Broadcaster):
def do(j):
j.set_progress(0, tr("Collecting files to scan"))
if scanner.scan_type == ScanType.Folders:
if scanner.scan_type == ScanType.FOLDERS:
files = list(self.directories.get_folders(folderclass=se.fs.Folder, j=j))
else:
files = list(self.directories.get_files(fileclasses=self.fileclasses, j=j))
@@ -804,7 +811,7 @@ class DupeGuru(Broadcaster):
self.results.groups = scanner.get_dupe_groups(files, self.ignore_list, j)
self.discarded_file_count = scanner.discarded_file_count
self._start_job(JobType.Scan, do)
self._start_job(JobType.SCAN, do)
def toggle_selected_mark_state(self):
selected = self.without_ref(self.selected_dupes)
@@ -849,18 +856,18 @@ class DupeGuru(Broadcaster):
@property
def SCANNER_CLASS(self):
if self.app_mode == AppMode.Picture:
if self.app_mode == AppMode.PICTURE:
return pe.scanner.ScannerPE
elif self.app_mode == AppMode.Music:
elif self.app_mode == AppMode.MUSIC:
return me.scanner.ScannerME
else:
return se.scanner.ScannerSE
@property
def METADATA_TO_READ(self):
if self.app_mode == AppMode.Picture:
if self.app_mode == AppMode.PICTURE:
return ["size", "mtime", "dimensions", "exif_timestamp"]
elif self.app_mode == AppMode.Music:
elif self.app_mode == AppMode.MUSIC:
return [
"size",
"mtime",

View File

@@ -11,6 +11,7 @@ import logging
from hscommon.jobprogress import job
from hscommon.path import Path
from hscommon.util import FileOrPath
from hscommon.trans import tr
from . import fs
@@ -30,9 +31,9 @@ class DirectoryState:
* DirectoryState.Excluded: Don't scan this folder
"""
Normal = 0
Reference = 1
Excluded = 2
NORMAL = 0
REFERENCE = 1
EXCLUDED = 2
class AlreadyThereError(Exception):
@@ -82,50 +83,49 @@ class Directories:
# We iterate even if we only have one item here
for denied_path_re in self._exclude_list.compiled:
if denied_path_re.match(str(path.name)):
return DirectoryState.Excluded
return DirectoryState.EXCLUDED
# return # We still use the old logic to force state on hidden dirs
# Override this in subclasses to specify the state of some special folders.
if path.name.startswith("."):
return DirectoryState.Excluded
return DirectoryState.EXCLUDED
def _get_files(self, from_path, fileclasses, j):
for root, dirs, files in os.walk(str(from_path)):
j.check_if_cancelled()
rootPath = Path(root)
state = self.get_state(rootPath)
if state == DirectoryState.Excluded:
root_path = Path(root)
state = self.get_state(root_path)
if state == DirectoryState.EXCLUDED and not any(p[: len(root_path)] == root_path for p in self.states):
# Recursively get files from folders with lots of subfolder is expensive. However, there
# might be a subfolder in this path that is not excluded. What we want to do is to skim
# through self.states and see if we must continue, or we can stop right here to save time
if not any(p[: len(rootPath)] == rootPath for p in self.states):
del dirs[:]
del dirs[:]
try:
if state != DirectoryState.Excluded:
if state != DirectoryState.EXCLUDED:
# Old logic
if self._exclude_list is None or not self._exclude_list.mark_count:
found_files = [fs.get_file(rootPath + f, fileclasses=fileclasses) for f in files]
found_files = [fs.get_file(root_path + f, fileclasses=fileclasses) for f in files]
else:
found_files = []
# print(f"len of files: {len(files)} {files}")
for f in files:
if not self._exclude_list.is_excluded(root, f):
found_files.append(fs.get_file(rootPath + f, fileclasses=fileclasses))
found_files.append(fs.get_file(root_path + f, fileclasses=fileclasses))
found_files = [f for f in found_files if f is not None]
# In some cases, directories can be considered as files by dupeGuru, which is
# why we have this line below. In fact, there only one case: Bundle files under
# OS X... In other situations, this forloop will do nothing.
for d in dirs[:]:
f = fs.get_file(rootPath + d, fileclasses=fileclasses)
f = fs.get_file(root_path + d, fileclasses=fileclasses)
if f is not None:
found_files.append(f)
dirs.remove(d)
logging.debug(
"Collected %d files in folder %s",
len(found_files),
str(rootPath),
str(root_path),
)
for file in found_files:
file.is_ref = state == DirectoryState.Reference
file.is_ref = state == DirectoryState.REFERENCE
yield file
except (EnvironmentError, fs.InvalidPath):
pass
@@ -137,8 +137,8 @@ class Directories:
for folder in self._get_folders(subfolder, j):
yield folder
state = self.get_state(from_folder.path)
if state != DirectoryState.Excluded:
from_folder.is_ref = state == DirectoryState.Reference
if state != DirectoryState.EXCLUDED:
from_folder.is_ref = state == DirectoryState.REFERENCE
logging.debug("Yielding Folder %r state: %d", from_folder, state)
yield from_folder
except (EnvironmentError, fs.InvalidPath):
@@ -183,8 +183,12 @@ class Directories:
"""
if fileclasses is None:
fileclasses = [fs.File]
file_count = 0
for path in self._dirs:
for file in self._get_files(path, fileclasses=fileclasses, j=j):
file_count += 1
if type(j) != job.NullJob:
j.set_progress(-1, tr("Collected {} files to scan").format(file_count))
yield file
def get_folders(self, folderclass=None, j=job.nulljob):
@@ -194,9 +198,13 @@ class Directories:
"""
if folderclass is None:
folderclass = fs.Folder
folder_count = 0
for path in self._dirs:
from_folder = folderclass(path)
for folder in self._get_folders(from_folder, j):
folder_count += 1
if type(j) != job.NullJob:
j.set_progress(-1, tr("Collected {} folders to scan").format(folder_count))
yield folder
def get_state(self, path):
@@ -207,9 +215,9 @@ class Directories:
# direct match? easy result.
if path in self.states:
return self.states[path]
state = self._default_state_for_path(path) or DirectoryState.Normal
state = self._default_state_for_path(path) or DirectoryState.NORMAL
# Save non-default states in cache, necessary for _get_files()
if state != DirectoryState.Normal:
if state != DirectoryState.NORMAL:
self.states[path] = state
return state

View File

@@ -24,6 +24,7 @@ from hscommon.jobprogress import job
) = range(3)
JOB_REFRESH_RATE = 100
PROGRESS_MESSAGE = tr("%d matches found from %d groups")
def getwords(s):
@@ -106,14 +107,14 @@ def compare_fields(first, second, flags=()):
# We don't want to remove field directly in the list. We must work on a copy.
second = second[:]
for field1 in first:
max = 0
max_score = 0
matched_field = None
for field2 in second:
r = compare(field1, field2, flags)
if r > max:
max = r
if r > max_score:
max_score = r
matched_field = field2
results.append(max)
results.append(max_score)
if matched_field:
second.remove(matched_field)
else:
@@ -248,10 +249,11 @@ def getmatches(
match_flags.append(MATCH_SIMILAR_WORDS)
if no_field_order:
match_flags.append(NO_FIELD_ORDER)
j.start_job(len(word_dict), tr("0 matches found"))
j.start_job(len(word_dict), PROGRESS_MESSAGE % (0, 0))
compared = defaultdict(set)
result = []
try:
word_count = 0
# This whole 'popping' thing is there to avoid taking too much memory at the same time.
while word_dict:
items = word_dict.popitem()[1]
@@ -266,7 +268,8 @@ def getmatches(
result.append(m)
if len(result) >= LIMIT:
return result
j.add_progress(desc=tr("%d matches found") % len(result))
word_count += 1
j.add_progress(desc=PROGRESS_MESSAGE % (len(result), word_count))
except MemoryError:
# This is the place where the memory usage is at its peak during the scan.
# Just continue the process with an incomplete list of matches.
@@ -285,17 +288,21 @@ def getmatches_by_contents(files, bigsize=0, j=job.nulljob):
"""
size2files = defaultdict(set)
for f in files:
if f.size:
size2files[f.size].add(f)
size2files[f.size].add(f)
del files
possible_matches = [files for files in size2files.values() if len(files) > 1]
del size2files
result = []
j.start_job(len(possible_matches), tr("0 matches found"))
j.start_job(len(possible_matches), PROGRESS_MESSAGE % (0, 0))
group_count = 0
for group in possible_matches:
for first, second in itertools.combinations(group, 2):
if first.is_ref and second.is_ref:
continue # Don't spend time comparing two ref pics together.
if first.size == 0 and second.size == 0:
# skip md5 for zero length files
result.append(Match(first, second, 100))
continue
if first.md5partial == second.md5partial:
if bigsize > 0 and first.size > bigsize:
if first.md5samples == second.md5samples:
@@ -303,7 +310,8 @@ def getmatches_by_contents(files, bigsize=0, j=job.nulljob):
else:
if first.md5 == second.md5:
result.append(Match(first, second, 100))
j.add_progress(desc=tr("%d matches found") % len(result))
group_count += 1
j.add_progress(desc=PROGRESS_MESSAGE % (len(result), group_count))
return result

View File

@@ -150,10 +150,7 @@ class ExcludeList(Markable):
# @timer
@memoize
def _do_compile(self, expr):
try:
return re.compile(expr)
except Exception as e:
raise (e)
return re.compile(expr)
# @timer
# @memoize # probably not worth memoizing this one if we memoize the above
@@ -235,7 +232,7 @@ class ExcludeList(Markable):
# This exception should never be ignored
raise AlreadyThereException()
if regex in forbidden_regexes:
raise Exception("Forbidden (dangerous) expression.")
raise ValueError("Forbidden (dangerous) expression.")
iscompilable, exception, compiled = self.compile_re(regex)
if not iscompilable and not forced:
@@ -510,7 +507,6 @@ if ISWINDOWS:
def has_sep(regexp):
return "\\" + sep in regexp
else:
def has_sep(regexp):

View File

@@ -14,7 +14,11 @@
import hashlib
from math import floor
import logging
import sqlite3
from threading import Lock
from typing import Any
from hscommon.path import Path
from hscommon.util import nonone, get_file_ext
__all__ = [
@@ -78,6 +82,82 @@ class OperationError(FSError):
cls_message = "Operation on '{name}' failed."
class FilesDB:
create_table_query = "CREATE TABLE IF NOT EXISTS files (path TEXT PRIMARY KEY, size INTEGER, mtime_ns INTEGER, entry_dt DATETIME, md5 BLOB, md5partial BLOB)"
drop_table_query = "DROP TABLE files;"
select_query = "SELECT {key} FROM files WHERE path=:path AND size=:size and mtime_ns=:mtime_ns"
insert_query = """
INSERT INTO files (path, size, mtime_ns, entry_dt, {key}) VALUES (:path, :size, :mtime_ns, datetime('now'), :value)
ON CONFLICT(path) DO UPDATE SET size=:size, mtime_ns=:mtime_ns, entry_dt=datetime('now'), {key}=:value;
"""
def __init__(self):
self.conn = None
self.cur = None
self.lock = None
def connect(self, path):
# type: (str, ) -> None
self.conn = sqlite3.connect(path, check_same_thread=False)
self.cur = self.conn.cursor()
self.cur.execute(self.create_table_query)
self.lock = Lock()
def clear(self):
# type: () -> None
with self.lock:
self.cur.execute(self.drop_table_query)
self.cur.execute(self.create_table_query)
def get(self, path, key):
# type: (Path, str) -> bytes
stat = path.stat()
size = stat.st_size
mtime_ns = stat.st_mtime_ns
with self.lock:
self.cur.execute(self.select_query.format(key=key), {"path": str(path), "size": size, "mtime_ns": mtime_ns})
result = self.cur.fetchone()
if result:
return result[0]
return None
def put(self, path, key, value):
# type: (Path, str, Any) -> None
stat = path.stat()
size = stat.st_size
mtime_ns = stat.st_mtime_ns
with self.lock:
self.cur.execute(
self.insert_query.format(key=key),
{"path": str(path), "size": size, "mtime_ns": mtime_ns, "value": value},
)
def commit(self):
# type: () -> None
with self.lock:
self.conn.commit()
def close(self):
# type: () -> None
with self.lock:
self.cur.close()
self.conn.close()
filesdb = FilesDB() # Singleton
class File:
"""Represents a file and holds metadata to be used for scanning."""
@@ -107,10 +187,32 @@ class File:
result = self.INITIAL_INFO[attrname]
return result
# This offset is where we should start reading the file to get a partial md5
# For audio file, it should be where audio data starts
def _get_md5partial_offset_and_size(self):
return (0x4000, 0x4000) # 16Kb
def _calc_md5(self):
# type: () -> bytes
with self.path.open("rb") as fp:
md5 = hashlib.md5()
# The goal here is to not run out of memory on really big files. However, the chunk
# size has to be large enough so that the python loop isn't too costly in terms of
# CPU.
CHUNK_SIZE = 1024 * 1024 # 1 mb
filedata = fp.read(CHUNK_SIZE)
while filedata:
md5.update(filedata)
filedata = fp.read(CHUNK_SIZE)
return md5.digest()
def _calc_md5partial(self):
# type: () -> bytes
# This offset is where we should start reading the file to get a partial md5
# For audio file, it should be where audio data starts
offset, size = (0x4000, 0x4000)
with self.path.open("rb") as fp:
fp.seek(offset)
partialdata = fp.read(size)
return hashlib.md5(partialdata).digest()
def _read_info(self, field):
# print(f"_read_info({field}) for {self}")
@@ -120,28 +222,20 @@ class File:
self.mtime = nonone(stats.st_mtime, 0)
elif field == "md5partial":
try:
with self.path.open("rb") as fp:
offset, size = self._get_md5partial_offset_and_size()
fp.seek(offset)
partialdata = fp.read(size)
md5 = hashlib.md5(partialdata)
self.md5partial = md5.digest()
except Exception:
pass
self.md5partial = filesdb.get(self.path, "md5partial")
if self.md5partial is None:
self.md5partial = self._calc_md5partial()
filesdb.put(self.path, "md5partial", self.md5partial)
except Exception as e:
logging.warning("Couldn't get md5partial for %s: %s", self.path, e)
elif field == "md5":
try:
with self.path.open("rb") as fp:
md5 = hashlib.md5()
filedata = fp.read(CHUNK_SIZE)
while filedata:
md5.update(filedata)
filedata = fp.read(CHUNK_SIZE)
# FIXME For python 3.8 and later
# while filedata := fp.read(CHUNK_SIZE):
# md5.update(filedata)
self.md5 = md5.digest()
except Exception:
pass
self.md5 = filesdb.get(self.path, "md5")
if self.md5 is None:
self.md5 = self._calc_md5()
filesdb.put(self.path, "md5", self.md5)
except Exception as e:
logging.warning("Couldn't get md5 for %s: %s", self.path, e)
elif field == "md5samples":
try:
with self.path.open("rb") as fp:
@@ -168,7 +262,6 @@ class File:
setattr(self, field, md5.digest())
except Exception as e:
logging.error(f"Error computing md5samples: {e}")
pass
def _read_all_info(self, attrnames=None):
"""Cache all possible info.

View File

@@ -15,16 +15,21 @@ class DupeGuruGUIObject(Listener):
self.app = app
def directories_changed(self):
# Implemented in child classes
pass
def dupes_selected(self):
# Implemented in child classes
pass
def marking_changed(self):
# Implemented in child classes
pass
def results_changed(self):
# Implemented in child classes
pass
def results_changed_but_keep_selection(self):
# Implemented in child classes
pass

View File

@@ -44,5 +44,4 @@ class DetailsPanel(GUIObject, DupeGuruGUIObject):
# --- Event Handlers
def dupes_selected(self):
self._refresh()
self.view.refresh()
self._view_updated()

View File

@@ -11,7 +11,7 @@ from hscommon.gui.tree import Tree, Node
from ..directories import DirectoryState
from .base import DupeGuruGUIObject
STATE_ORDER = [DirectoryState.Normal, DirectoryState.Reference, DirectoryState.Excluded]
STATE_ORDER = [DirectoryState.NORMAL, DirectoryState.REFERENCE, DirectoryState.EXCLUDED]
# Lazily loads children
@@ -86,9 +86,9 @@ class DirectoryTree(Tree, DupeGuruGUIObject):
else:
# All selected nodes or on second-or-more level, exclude them.
nodes = self.selected_nodes
newstate = DirectoryState.Excluded
if all(node.state == DirectoryState.Excluded for node in nodes):
newstate = DirectoryState.Normal
newstate = DirectoryState.EXCLUDED
if all(node.state == DirectoryState.EXCLUDED for node in nodes):
newstate = DirectoryState.NORMAL
for node in nodes:
node.state = newstate
@@ -103,5 +103,4 @@ class DirectoryTree(Tree, DupeGuruGUIObject):
# --- Event Handlers
def directories_changed(self):
self._refresh()
self.view.refresh()
self._view_updated()

View File

@@ -5,7 +5,6 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
# from hscommon.trans import tr
from .exclude_list_table import ExcludeListTable
from core.exclude import has_sep
from os import sep
@@ -47,10 +46,7 @@ class ExcludeListDialogCore:
return False
def add(self, regex):
try:
self.exclude_list.add(regex)
except Exception as e:
raise (e)
self.exclude_list.add(regex)
self.exclude_list.mark(regex)
self.exclude_list_table.add(regex)

View File

@@ -16,7 +16,7 @@ class ExcludeListTable(GUITable, DupeGuruGUIObject):
def __init__(self, exclude_list_dialog, app):
GUITable.__init__(self)
DupeGuruGUIObject.__init__(self, app)
self.columns = Columns(self)
self._columns = Columns(self)
self.dialog = exclude_list_dialog
def rename_selected(self, newname):

View File

@@ -24,7 +24,7 @@ class IgnoreListDialog:
return
msg = tr("Do you really want to remove all %d items from the ignore list?") % len(self.ignore_list)
if self.app.view.ask_yes_no(msg):
self.ignore_list.Clear()
self.ignore_list.clear()
self.refresh()
def refresh(self):

View File

@@ -22,7 +22,7 @@ class IgnoreListTable(GUITable):
def __init__(self, ignore_list_dialog):
GUITable.__init__(self)
self.columns = Columns(self)
self._columns = Columns(self)
self.view = None
self.dialog = ignore_list_dialog

View File

@@ -21,7 +21,7 @@ class ProblemTable(GUITable):
def __init__(self, problem_dialog):
GUITable.__init__(self)
self.columns = Columns(self)
self._columns = Columns(self)
self.dialog = problem_dialog
# --- Override

View File

@@ -82,7 +82,7 @@ class ResultTable(GUITable, DupeGuruGUIObject):
def __init__(self, app):
GUITable.__init__(self)
DupeGuruGUIObject.__init__(self, app)
self.columns = Columns(self, prefaccess=app, savename="ResultTable")
self._columns = Columns(self, prefaccess=app, savename="ResultTable")
self._power_marker = False
self._delta_values = False
self._sort_descriptors = ("name", True)
@@ -190,4 +190,4 @@ class ResultTable(GUITable, DupeGuruGUIObject):
self.view.refresh()
def save_session(self):
self.columns.save_columns()
self._columns.save_columns()

View File

@@ -20,8 +20,7 @@ class IgnoreList:
# ---Override
def __init__(self):
self._ignored = {}
self._count = 0
self.clear()
def __iter__(self):
for first, seconds in self._ignored.items():
@@ -32,7 +31,7 @@ class IgnoreList:
return self._count
# ---Public
def AreIgnored(self, first, second):
def are_ignored(self, first, second):
def do_check(first, second):
try:
matches = self._ignored[first]
@@ -42,23 +41,23 @@ class IgnoreList:
return do_check(first, second) or do_check(second, first)
def Clear(self):
def clear(self):
self._ignored = {}
self._count = 0
def Filter(self, func):
def filter(self, func):
"""Applies a filter on all ignored items, and remove all matches where func(first,second)
doesn't return True.
"""
filtered = IgnoreList()
for first, second in self:
if func(first, second):
filtered.Ignore(first, second)
filtered.ignore(first, second)
self._ignored = filtered._ignored
self._count = filtered._count
def Ignore(self, first, second):
if self.AreIgnored(first, second):
def ignore(self, first, second):
if self.are_ignored(first, second):
return
try:
matches = self._ignored[first]
@@ -88,9 +87,8 @@ class IgnoreList:
except KeyError:
return False
if not inner(first, second):
if not inner(second, first):
raise ValueError()
if not inner(first, second) and not inner(second, first):
raise ValueError()
def load_from_xml(self, infile):
"""Loads the ignore list from a XML created with save_to_xml.
@@ -110,7 +108,7 @@ class IgnoreList:
for sfn in subfile_elems:
subfile_path = sfn.get("path")
if subfile_path:
self.Ignore(file_path, subfile_path)
self.ignore(file_path, subfile_path)
def save_to_xml(self, outfile):
"""Create a XML file that can be used by load_from_xml.

View File

@@ -17,9 +17,11 @@ class Markable:
# in self.__marked, and is not affected by __inverted. Thus, self.mark while __inverted
# is True will launch _DidUnmark.
def _did_mark(self, o):
# Implemented in child classes
pass
def _did_unmark(self, o):
# Implemented in child classes
pass
def _get_markable_count(self):

View File

@@ -6,7 +6,7 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from hsaudiotag import auto
import mutagen
from hscommon.util import get_file_ext, format_size, format_time
from core.util import format_timestamp, format_perc, format_words, format_dupe_count
@@ -26,6 +26,9 @@ TAG_FIELDS = {
"comment",
}
# This is a temporary workaround for migration from hsaudiotag for the can_handle method
SUPPORTED_EXTS = {"mp3", "wma", "m4a", "m4p", "ogg", "flac", "aif", "aiff", "aifc"}
class MusicFile(fs.File):
INITIAL_INFO = fs.File.INITIAL_INFO.copy()
@@ -50,7 +53,7 @@ class MusicFile(fs.File):
def can_handle(cls, path):
if not fs.File.can_handle(path):
return False
return get_file_ext(path.name) in auto.EXT2CLASS
return get_file_ext(path.name) in SUPPORTED_EXTS
def get_display_info(self, group, delta):
size = self.size
@@ -95,21 +98,23 @@ class MusicFile(fs.File):
}
def _get_md5partial_offset_and_size(self):
f = auto.File(str(self.path))
return (f.audio_offset, f.audio_size)
# No longer calculating the offset and audio size, just whole file
size = self.path.stat().st_size
return (0, size)
def _read_info(self, field):
fs.File._read_info(self, field)
if field in TAG_FIELDS:
f = auto.File(str(self.path))
self.audiosize = f.audio_size
self.bitrate = f.bitrate
self.duration = f.duration
self.samplerate = f.sample_rate
self.artist = f.artist
self.album = f.album
self.title = f.title
self.genre = f.genre
self.comment = f.comment
self.year = f.year
self.track = f.track
# The various conversions here are to make this look like the previous implementation
file = mutagen.File(str(self.path), easy=True)
self.audiosize = self.path.stat().st_size
self.bitrate = file.info.bitrate / 1000
self.duration = file.info.length
self.samplerate = file.info.sample_rate
self.artist = ", ".join(file.tags.get("artist") or [])
self.album = ", ".join(file.tags.get("album") or [])
self.title = ", ".join(file.tags.get("title") or [])
self.genre = ", ".join(file.tags.get("genre") or [])
self.comment = ", ".join(file.tags.get("comment") or [""])
self.year = ", ".join(file.tags.get("date") or [])
self.track = (file.tags.get("tracknumber") or [""])[0]

View File

@@ -17,9 +17,9 @@ class ScannerME(ScannerBase):
@staticmethod
def get_scan_options():
return [
ScanOption(ScanType.Filename, tr("Filename")),
ScanOption(ScanType.Fields, tr("Filename - Fields")),
ScanOption(ScanType.FieldsNoOrder, tr("Filename - Fields (No Order)")),
ScanOption(ScanType.Tag, tr("Tags")),
ScanOption(ScanType.Contents, tr("Contents")),
ScanOption(ScanType.FILENAME, tr("Filename")),
ScanOption(ScanType.FIELDS, tr("Filename - Fields")),
ScanOption(ScanType.FIELDSNOORDER, tr("Filename - Fields (No Order)")),
ScanOption(ScanType.TAG, tr("Tags")),
ScanOption(ScanType.CONTENTS, tr("Contents")),
]

View File

@@ -193,8 +193,8 @@ class TIFF_file:
self.s2nfunc = s2n_intel if self.endian == INTEL_ENDIAN else s2n_motorola
def s2n(self, offset, length, signed=0, debug=False):
slice = self.data[offset : offset + length]
val = self.s2nfunc(slice)
data_slice = self.data[offset : offset + length]
val = self.s2nfunc(data_slice)
# Sign extension ?
if signed:
msb = 1 << (8 * length - 1)
@@ -206,7 +206,7 @@ class TIFF_file:
"Slice for offset %d length %d: %r and value: %d",
offset,
length,
slice,
data_slice,
val,
)
return val
@@ -236,10 +236,10 @@ class TIFF_file:
for i in range(entries):
entry = ifd + 2 + 12 * i
tag = self.s2n(entry, 2)
type = self.s2n(entry + 2, 2)
if not 1 <= type <= 10:
entry_type = self.s2n(entry + 2, 2)
if not 1 <= entry_type <= 10:
continue # not handled
typelen = [1, 1, 2, 4, 8, 1, 1, 2, 4, 8][type - 1]
typelen = [1, 1, 2, 4, 8, 1, 1, 2, 4, 8][entry_type - 1]
count = self.s2n(entry + 4, 4)
if count > MAX_COUNT:
logging.debug("Probably corrupt. Aborting.")
@@ -247,14 +247,14 @@ class TIFF_file:
offset = entry + 8
if count * typelen > 4:
offset = self.s2n(offset, 4)
if type == 2:
if entry_type == 2:
# Special case: nul-terminated ASCII string
values = str(self.data[offset : offset + count - 1], encoding="latin-1")
else:
values = []
signed = type == 6 or type >= 8
for j in range(count):
if type in {5, 10}:
signed = entry_type == 6 or entry_type >= 8
for _ in range(count):
if entry_type in {5, 10}:
# The type is either 5 or 10
value_j = Fraction(self.s2n(offset, 4, signed), self.s2n(offset + 4, 4, signed))
else:
@@ -263,7 +263,7 @@ class TIFF_file:
values.append(value_j)
offset = offset + typelen
# Now "values" is either a string or an array
a.append((tag, type, values))
a.append((tag, entry_type, values))
return a
@@ -298,7 +298,7 @@ def get_fields(fp):
T = TIFF_file(data)
# There may be more than one IFD per file, but we only read the first one because others are
# most likely thumbnails.
main_IFD_offset = T.first_IFD()
main_ifd_offset = T.first_IFD()
result = {}
def add_tag_to_result(tag, values):
@@ -310,8 +310,8 @@ def get_fields(fp):
return # don't overwrite data
result[stag] = values
logging.debug("IFD at offset %d", main_IFD_offset)
IFD = T.dump_IFD(main_IFD_offset)
logging.debug("IFD at offset %d", main_ifd_offset)
IFD = T.dump_IFD(main_ifd_offset)
exif_off = gps_off = 0
for tag, type, values in IFD:
if tag == 0x8769:

View File

@@ -2,9 +2,9 @@
* Created On: 2010-01-30
* Copyright 2014 Hardcoded Software (http://www.hardcoded.net)
*
* This software is licensed under the "BSD" License as described in the "LICENSE" file,
* which should be included with this package. The terms are also available at
* http://www.hardcoded.net/licenses/bsd_license
* This software is licensed under the "BSD" License as described in the
* "LICENSE" file, which should be included with this package. The terms are
* also available at http://www.hardcoded.net/licenses/bsd_license
*/
#include "common.h"
@@ -17,83 +17,81 @@ static PyObject *DifferentBlockCountError;
/* Returns a 3 sized tuple containing the mean color of 'image'.
* image: a PIL image or crop.
*/
static PyObject* getblock(PyObject *image)
{
int i, totr, totg, totb;
Py_ssize_t pixel_count;
PyObject *ppixels;
static PyObject *getblock(PyObject *image) {
int i, totr, totg, totb;
Py_ssize_t pixel_count;
PyObject *ppixels;
totr = totg = totb = 0;
ppixels = PyObject_CallMethod(image, "getdata", NULL);
if (ppixels == NULL) {
return NULL;
}
totr = totg = totb = 0;
ppixels = PyObject_CallMethod(image, "getdata", NULL);
if (ppixels == NULL) {
return NULL;
}
pixel_count = PySequence_Length(ppixels);
for (i=0; i<pixel_count; i++) {
PyObject *ppixel, *pr, *pg, *pb;
int r, g, b;
pixel_count = PySequence_Length(ppixels);
for (i = 0; i < pixel_count; i++) {
PyObject *ppixel, *pr, *pg, *pb;
int r, g, b;
ppixel = PySequence_ITEM(ppixels, i);
pr = PySequence_ITEM(ppixel, 0);
pg = PySequence_ITEM(ppixel, 1);
pb = PySequence_ITEM(ppixel, 2);
Py_DECREF(ppixel);
r = PyLong_AsLong(pr);
g = PyLong_AsLong(pg);
b = PyLong_AsLong(pb);
Py_DECREF(pr);
Py_DECREF(pg);
Py_DECREF(pb);
ppixel = PySequence_ITEM(ppixels, i);
pr = PySequence_ITEM(ppixel, 0);
pg = PySequence_ITEM(ppixel, 1);
pb = PySequence_ITEM(ppixel, 2);
Py_DECREF(ppixel);
r = PyLong_AsLong(pr);
g = PyLong_AsLong(pg);
b = PyLong_AsLong(pb);
Py_DECREF(pr);
Py_DECREF(pg);
Py_DECREF(pb);
totr += r;
totg += g;
totb += b;
}
totr += r;
totg += g;
totb += b;
}
Py_DECREF(ppixels);
Py_DECREF(ppixels);
if (pixel_count) {
totr /= pixel_count;
totg /= pixel_count;
totb /= pixel_count;
}
if (pixel_count) {
totr /= pixel_count;
totg /= pixel_count;
totb /= pixel_count;
}
return inttuple(3, totr, totg, totb);
return inttuple(3, totr, totg, totb);
}
/* Returns the difference between the first block and the second.
* It returns an absolute sum of the 3 differences (RGB).
*/
static int diff(PyObject *first, PyObject *second)
{
int r1, g1, b1, r2, b2, g2;
PyObject *pr, *pg, *pb;
pr = PySequence_ITEM(first, 0);
pg = PySequence_ITEM(first, 1);
pb = PySequence_ITEM(first, 2);
r1 = PyLong_AsLong(pr);
g1 = PyLong_AsLong(pg);
b1 = PyLong_AsLong(pb);
Py_DECREF(pr);
Py_DECREF(pg);
Py_DECREF(pb);
static int diff(PyObject *first, PyObject *second) {
int r1, g1, b1, r2, b2, g2;
PyObject *pr, *pg, *pb;
pr = PySequence_ITEM(first, 0);
pg = PySequence_ITEM(first, 1);
pb = PySequence_ITEM(first, 2);
r1 = PyLong_AsLong(pr);
g1 = PyLong_AsLong(pg);
b1 = PyLong_AsLong(pb);
Py_DECREF(pr);
Py_DECREF(pg);
Py_DECREF(pb);
pr = PySequence_ITEM(second, 0);
pg = PySequence_ITEM(second, 1);
pb = PySequence_ITEM(second, 2);
r2 = PyLong_AsLong(pr);
g2 = PyLong_AsLong(pg);
b2 = PyLong_AsLong(pb);
Py_DECREF(pr);
Py_DECREF(pg);
Py_DECREF(pb);
pr = PySequence_ITEM(second, 0);
pg = PySequence_ITEM(second, 1);
pb = PySequence_ITEM(second, 2);
r2 = PyLong_AsLong(pr);
g2 = PyLong_AsLong(pg);
b2 = PyLong_AsLong(pb);
Py_DECREF(pr);
Py_DECREF(pg);
Py_DECREF(pb);
return abs(r1 - r2) + abs(g1 - g2) + abs(b1 - b2);
return abs(r1 - r2) + abs(g1 - g2) + abs(b1 - b2);
}
PyDoc_STRVAR(block_getblocks2_doc,
"Returns a list of blocks (3 sized tuples).\n\
"Returns a list of blocks (3 sized tuples).\n\
\n\
image: A PIL image to base the blocks on.\n\
block_count_per_side: This integer determine the number of blocks the function will return.\n\
@@ -101,153 +99,150 @@ If it is 10, for example, 100 blocks will be returns (10 width, 10 height). The
necessarely cover square areas. The area covered by each block will be proportional to the image\n\
itself.\n");
static PyObject* block_getblocks2(PyObject *self, PyObject *args)
{
int block_count_per_side, width, height, block_width, block_height, ih;
PyObject *image;
PyObject *pimage_size, *pwidth, *pheight;
PyObject *result;
static PyObject *block_getblocks2(PyObject *self, PyObject *args) {
int block_count_per_side, width, height, block_width, block_height, ih;
PyObject *image;
PyObject *pimage_size, *pwidth, *pheight;
PyObject *result;
if (!PyArg_ParseTuple(args, "Oi", &image, &block_count_per_side)) {
if (!PyArg_ParseTuple(args, "Oi", &image, &block_count_per_side)) {
return NULL;
}
pimage_size = PyObject_GetAttrString(image, "size");
pwidth = PySequence_ITEM(pimage_size, 0);
pheight = PySequence_ITEM(pimage_size, 1);
width = PyLong_AsLong(pwidth);
height = PyLong_AsLong(pheight);
Py_DECREF(pimage_size);
Py_DECREF(pwidth);
Py_DECREF(pheight);
if (!(width && height)) {
return PyList_New(0);
}
block_width = max(width / block_count_per_side, 1);
block_height = max(height / block_count_per_side, 1);
result = PyList_New((Py_ssize_t)block_count_per_side * block_count_per_side);
if (result == NULL) {
return NULL;
}
for (ih = 0; ih < block_count_per_side; ih++) {
int top, bottom, iw;
top = min(ih * block_height, height - block_height);
bottom = top + block_height;
for (iw = 0; iw < block_count_per_side; iw++) {
int left, right;
PyObject *pbox;
PyObject *pmethodname;
PyObject *pcrop;
PyObject *pblock;
left = min(iw * block_width, width - block_width);
right = left + block_width;
pbox = inttuple(4, left, top, right, bottom);
pmethodname = PyUnicode_FromString("crop");
pcrop = PyObject_CallMethodObjArgs(image, pmethodname, pbox, NULL);
Py_DECREF(pmethodname);
Py_DECREF(pbox);
if (pcrop == NULL) {
Py_DECREF(result);
return NULL;
}
pimage_size = PyObject_GetAttrString(image, "size");
pwidth = PySequence_ITEM(pimage_size, 0);
pheight = PySequence_ITEM(pimage_size, 1);
width = PyLong_AsLong(pwidth);
height = PyLong_AsLong(pheight);
Py_DECREF(pimage_size);
Py_DECREF(pwidth);
Py_DECREF(pheight);
if (!(width && height)) {
return PyList_New(0);
}
block_width = max(width / block_count_per_side, 1);
block_height = max(height / block_count_per_side, 1);
result = PyList_New(block_count_per_side * block_count_per_side);
if (result == NULL) {
}
pblock = getblock(pcrop);
Py_DECREF(pcrop);
if (pblock == NULL) {
Py_DECREF(result);
return NULL;
}
PyList_SET_ITEM(result, ih * block_count_per_side + iw, pblock);
}
}
for (ih=0; ih<block_count_per_side; ih++) {
int top, bottom, iw;
top = min(ih*block_height, height-block_height);
bottom = top + block_height;
for (iw=0; iw<block_count_per_side; iw++) {
int left, right;
PyObject *pbox;
PyObject *pmethodname;
PyObject *pcrop;
PyObject *pblock;
left = min(iw*block_width, width-block_width);
right = left + block_width;
pbox = inttuple(4, left, top, right, bottom);
pmethodname = PyUnicode_FromString("crop");
pcrop = PyObject_CallMethodObjArgs(image, pmethodname, pbox, NULL);
Py_DECREF(pmethodname);
Py_DECREF(pbox);
if (pcrop == NULL) {
Py_DECREF(result);
return NULL;
}
pblock = getblock(pcrop);
Py_DECREF(pcrop);
if (pblock == NULL) {
Py_DECREF(result);
return NULL;
}
PyList_SET_ITEM(result, ih*block_count_per_side+iw, pblock);
}
}
return result;
return result;
}
PyDoc_STRVAR(block_avgdiff_doc,
"Returns the average diff between first blocks and seconds.\n\
"Returns the average diff between first blocks and seconds.\n\
\n\
If the result surpasses limit, limit + 1 is returned, except if less than min_iterations\n\
iterations have been made in the blocks.\n");
static PyObject* block_avgdiff(PyObject *self, PyObject *args)
{
PyObject *first, *second;
int limit, min_iterations;
Py_ssize_t count;
int sum, i, result;
static PyObject *block_avgdiff(PyObject *self, PyObject *args) {
PyObject *first, *second;
int limit, min_iterations;
Py_ssize_t count;
int sum, i, result;
if (!PyArg_ParseTuple(args, "OOii", &first, &second, &limit, &min_iterations)) {
return NULL;
}
if (!PyArg_ParseTuple(args, "OOii", &first, &second, &limit,
&min_iterations)) {
return NULL;
}
count = PySequence_Length(first);
if (count != PySequence_Length(second)) {
PyErr_SetString(DifferentBlockCountError, "");
return NULL;
}
if (!count) {
PyErr_SetString(NoBlocksError, "");
return NULL;
}
count = PySequence_Length(first);
if (count != PySequence_Length(second)) {
PyErr_SetString(DifferentBlockCountError, "");
return NULL;
}
if (!count) {
PyErr_SetString(NoBlocksError, "");
return NULL;
}
sum = 0;
for (i=0; i<count; i++) {
int iteration_count;
PyObject *item1, *item2;
sum = 0;
for (i = 0; i < count; i++) {
int iteration_count;
PyObject *item1, *item2;
iteration_count = i + 1;
item1 = PySequence_ITEM(first, i);
item2 = PySequence_ITEM(second, i);
sum += diff(item1, item2);
Py_DECREF(item1);
Py_DECREF(item2);
if ((sum > limit*iteration_count) && (iteration_count >= min_iterations)) {
return PyLong_FromLong(limit + 1);
}
iteration_count = i + 1;
item1 = PySequence_ITEM(first, i);
item2 = PySequence_ITEM(second, i);
sum += diff(item1, item2);
Py_DECREF(item1);
Py_DECREF(item2);
if ((sum > limit * iteration_count) &&
(iteration_count >= min_iterations)) {
return PyLong_FromLong(limit + 1);
}
}
result = sum / count;
if (!result && sum) {
result = 1;
}
return PyLong_FromLong(result);
result = sum / count;
if (!result && sum) {
result = 1;
}
return PyLong_FromLong(result);
}
static PyMethodDef BlockMethods[] = {
{"getblocks2", block_getblocks2, METH_VARARGS, block_getblocks2_doc},
{"avgdiff", block_avgdiff, METH_VARARGS, block_avgdiff_doc},
{"getblocks2", block_getblocks2, METH_VARARGS, block_getblocks2_doc},
{"avgdiff", block_avgdiff, METH_VARARGS, block_avgdiff_doc},
{NULL, NULL, 0, NULL} /* Sentinel */
};
static struct PyModuleDef BlockDef = {
PyModuleDef_HEAD_INIT,
"_block",
NULL,
-1,
BlockMethods,
NULL,
NULL,
NULL,
NULL
};
static struct PyModuleDef BlockDef = {PyModuleDef_HEAD_INIT,
"_block",
NULL,
-1,
BlockMethods,
NULL,
NULL,
NULL,
NULL};
PyObject *
PyInit__block(void)
{
PyObject *m = PyModule_Create(&BlockDef);
if (m == NULL) {
return NULL;
}
PyObject *PyInit__block(void) {
PyObject *m = PyModule_Create(&BlockDef);
if (m == NULL) {
return NULL;
}
NoBlocksError = PyErr_NewException("_block.NoBlocksError", NULL, NULL);
PyModule_AddObject(m, "NoBlocksError", NoBlocksError);
DifferentBlockCountError = PyErr_NewException("_block.DifferentBlockCountError", NULL, NULL);
PyModule_AddObject(m, "DifferentBlockCountError", DifferentBlockCountError);
NoBlocksError = PyErr_NewException("_block.NoBlocksError", NULL, NULL);
PyModule_AddObject(m, "NoBlocksError", NoBlocksError);
DifferentBlockCountError =
PyErr_NewException("_block.DifferentBlockCountError", NULL, NULL);
PyModule_AddObject(m, "DifferentBlockCountError", DifferentBlockCountError);
return m;
return m;
}

View File

@@ -10,6 +10,8 @@
#include "common.h"
#import <Foundation/Foundation.h>
#import <CoreGraphics/CoreGraphics.h>
#import <ImageIO/ImageIO.h>
#define RADIANS( degrees ) ( degrees * M_PI / 180 )

View File

@@ -18,12 +18,12 @@ class ScannerPE(Scanner):
@staticmethod
def get_scan_options():
return [
ScanOption(ScanType.FuzzyBlock, tr("Contents")),
ScanOption(ScanType.ExifTimestamp, tr("EXIF Timestamp")),
ScanOption(ScanType.FUZZYBLOCK, tr("Contents")),
ScanOption(ScanType.EXIFTIMESTAMP, tr("EXIF Timestamp")),
]
def _getmatches(self, files, j):
if self.scan_type == ScanType.FuzzyBlock:
if self.scan_type == ScanType.FUZZYBLOCK:
return matchblock.getmatches(
files,
cache_path=self.cache_path,
@@ -31,7 +31,7 @@ class ScannerPE(Scanner):
match_scaled=self.match_scaled,
j=j,
)
elif self.scan_type == ScanType.ExifTimestamp:
elif self.scan_type == ScanType.EXIFTIMESTAMP:
return matchexif.getmatches(files, self.match_scaled, j)
else:
raise Exception("Invalid scan type")
raise ValueError("Invalid scan type")

View File

@@ -21,16 +21,16 @@ from . import engine
class ScanType:
Filename = 0
Fields = 1
FieldsNoOrder = 2
Tag = 3
Folders = 4
Contents = 5
FILENAME = 0
FIELDS = 1
FIELDSNOORDER = 2
TAG = 3
FOLDERS = 4
CONTENTS = 5
# PE
FuzzyBlock = 10
ExifTimestamp = 11
FUZZYBLOCK = 10
EXIFTIMESTAMP = 11
ScanOption = namedtuple("ScanOption", "scan_type label")
@@ -77,16 +77,23 @@ class Scanner:
self.discarded_file_count = 0
def _getmatches(self, files, j):
if self.size_threshold or self.scan_type in {
ScanType.Contents,
ScanType.Folders,
}:
if (
self.size_threshold
or self.large_size_threshold
or self.scan_type
in {
ScanType.CONTENTS,
ScanType.FOLDERS,
}
):
j = j.start_subjob([2, 8])
for f in j.iter_with_progress(files, tr("Read size of %d/%d files")):
f.size # pre-read, makes a smoother progress if read here (especially for bundles)
if self.size_threshold:
files = [f for f in files if f.size >= self.size_threshold]
if self.scan_type in {ScanType.Contents, ScanType.Folders}:
if self.large_size_threshold:
files = [f for f in files if f.size <= self.large_size_threshold]
if self.scan_type in {ScanType.CONTENTS, ScanType.FOLDERS}:
return engine.getmatches_by_contents(files, bigsize=self.big_file_size_threshold, j=j)
else:
j = j.start_subjob([2, 8])
@@ -94,13 +101,13 @@ class Scanner:
kw["match_similar_words"] = self.match_similar_words
kw["weight_words"] = self.word_weighting
kw["min_match_percentage"] = self.min_match_percentage
if self.scan_type == ScanType.FieldsNoOrder:
self.scan_type = ScanType.Fields
if self.scan_type == ScanType.FIELDSNOORDER:
self.scan_type = ScanType.FIELDS
kw["no_field_order"] = True
func = {
ScanType.Filename: lambda f: engine.getwords(rem_file_ext(f.name)),
ScanType.Fields: lambda f: engine.getfields(rem_file_ext(f.name)),
ScanType.Tag: lambda f: [
ScanType.FILENAME: lambda f: engine.getwords(rem_file_ext(f.name)),
ScanType.FIELDS: lambda f: engine.getfields(rem_file_ext(f.name)),
ScanType.TAG: lambda f: [
engine.getwords(str(getattr(f, attrname)))
for attrname in SCANNABLE_TAGS
if attrname in self.scanned_tags
@@ -150,7 +157,7 @@ class Scanner:
# "duplicated duplicates if you will). Then, we also don't want mixed file kinds if the
# option isn't enabled, we want matches for which both files exist and, lastly, we don't
# want matches with both files as ref.
if self.scan_type == ScanType.Folders and matches:
if self.scan_type == ScanType.FOLDERS and matches:
allpath = {m.first.path for m in matches}
allpath |= {m.second.path for m in matches}
sortedpaths = sorted(allpath)
@@ -167,14 +174,14 @@ class Scanner:
matches = [m for m in matches if m.first.path.exists() and m.second.path.exists()]
matches = [m for m in matches if not (m.first.is_ref and m.second.is_ref)]
if ignore_list:
matches = [m for m in matches if not ignore_list.AreIgnored(str(m.first.path), str(m.second.path))]
matches = [m for m in matches if not ignore_list.are_ignored(str(m.first.path), str(m.second.path))]
logging.info("Grouping matches")
groups = engine.get_groups(matches)
if self.scan_type in {
ScanType.Filename,
ScanType.Fields,
ScanType.FieldsNoOrder,
ScanType.Tag,
ScanType.FILENAME,
ScanType.FIELDS,
ScanType.FIELDSNOORDER,
ScanType.TAG,
}:
matched_files = dedupe([m.first for m in matches] + [m.second for m in matches])
self.discarded_file_count = len(matched_files) - sum(len(g) for g in groups)
@@ -199,8 +206,9 @@ class Scanner:
match_similar_words = False
min_match_percentage = 80
mix_file_kind = True
scan_type = ScanType.Filename
scan_type = ScanType.FILENAME
scanned_tags = {"artist", "title"}
size_threshold = 0
large_size_threshold = 0
big_file_size_threshold = 0
word_weighting = False

View File

@@ -13,7 +13,7 @@ class ScannerSE(ScannerBase):
@staticmethod
def get_scan_options():
return [
ScanOption(ScanType.Filename, tr("Filename")),
ScanOption(ScanType.Contents, tr("Contents")),
ScanOption(ScanType.Folders, tr("Folders")),
ScanOption(ScanType.FILENAME, tr("Filename")),
ScanOption(ScanType.CONTENTS, tr("Contents")),
ScanOption(ScanType.FOLDERS, tr("Folders")),
]

View File

@@ -23,7 +23,7 @@ from ..scanner import ScanType
def add_fake_files_to_directories(directories, files):
directories.get_files = lambda j=None: iter(files)
directories._dirs.append("this is just so Scan() doesnt return 3")
directories._dirs.append("this is just so Scan() doesn't return 3")
class TestCaseDupeGuru:
@@ -43,7 +43,7 @@ class TestCaseDupeGuru:
dgapp.apply_filter("()[]\\.|+?^abc")
call = dgapp.results.apply_filter.calls[1]
eq_("\\(\\)\\[\\]\\\\\\.\\|\\+\\?\\^abc", call["filter_str"])
dgapp.apply_filter("(*)") # In "simple mode", we want the * to behave as a wilcard
dgapp.apply_filter("(*)") # In "simple mode", we want the * to behave as a wildcard
call = dgapp.results.apply_filter.calls[3]
eq_(r"\(.*\)", call["filter_str"])
dgapp.options["escape_filter_regexp"] = False
@@ -88,14 +88,14 @@ class TestCaseDupeGuru:
eq_(1, len(calls))
eq_(sourcepath, calls[0]["path"])
def test_Scan_with_objects_evaluating_to_false(self):
def test_scan_with_objects_evaluating_to_false(self):
class FakeFile(fs.File):
def __bool__(self):
return False
# At some point, any() was used in a wrong way that made Scan() wrongly return 1
app = TestApp().app
f1, f2 = [FakeFile("foo") for i in range(2)]
f1, f2 = [FakeFile("foo") for _ in range(2)]
f1.is_ref, f2.is_ref = (False, False)
assert not (bool(f1) and bool(f2))
add_fake_files_to_directories(app.directories, [f1, f2])
@@ -110,7 +110,7 @@ class TestCaseDupeGuru:
os.link(str(tmppath["myfile"]), str(tmppath["hardlink"]))
app = TestApp().app
app.directories.add_path(tmppath)
app.options["scan_type"] = ScanType.Contents
app.options["scan_type"] = ScanType.CONTENTS
app.options["ignore_hardlink_matches"] = True
app.start_scanning()
eq_(len(app.results.groups), 0)
@@ -124,7 +124,7 @@ class TestCaseDupeGuru:
assert not dgapp.result_table.rename_selected("foo") # no crash
class TestCaseDupeGuru_clean_empty_dirs:
class TestCaseDupeGuruCleanEmptyDirs:
@pytest.fixture
def do_setup(self, request):
monkeypatch = request.getfixturevalue("monkeypatch")
@@ -184,7 +184,7 @@ class TestCaseDupeGuruWithResults:
tmppath["bar"].mkdir()
self.app.directories.add_path(tmppath)
def test_GetObjects(self, do_setup):
def test_get_objects(self, do_setup):
objects = self.objects
groups = self.groups
r = self.rtable[0]
@@ -197,7 +197,7 @@ class TestCaseDupeGuruWithResults:
assert r._group is groups[1]
assert r._dupe is objects[4]
def test_GetObjects_after_sort(self, do_setup):
def test_get_objects_after_sort(self, do_setup):
objects = self.objects
groups = self.groups[:] # we need an un-sorted reference
self.rtable.sort("name", False)
@@ -212,7 +212,7 @@ class TestCaseDupeGuruWithResults:
# The first 2 dupes have been removed. The 3rd one is a ref. it stays there, in first pos.
eq_(self.rtable.selected_indexes, [1]) # no exception
def test_selectResultNodePaths(self, do_setup):
def test_select_result_node_paths(self, do_setup):
app = self.app
objects = self.objects
self.rtable.select([1, 2])
@@ -220,7 +220,7 @@ class TestCaseDupeGuruWithResults:
assert app.selected_dupes[0] is objects[1]
assert app.selected_dupes[1] is objects[2]
def test_selectResultNodePaths_with_ref(self, do_setup):
def test_select_result_node_paths_with_ref(self, do_setup):
app = self.app
objects = self.objects
self.rtable.select([1, 2, 3])
@@ -229,7 +229,7 @@ class TestCaseDupeGuruWithResults:
assert app.selected_dupes[1] is objects[2]
assert app.selected_dupes[2] is self.groups[1].ref
def test_selectResultNodePaths_after_sort(self, do_setup):
def test_select_result_node_paths_after_sort(self, do_setup):
app = self.app
objects = self.objects
groups = self.groups[:] # To keep the old order in memory
@@ -256,7 +256,7 @@ class TestCaseDupeGuruWithResults:
app.remove_selected()
eq_(self.rtable.selected_indexes, []) # no exception
def test_selectPowerMarkerRows_after_sort(self, do_setup):
def test_select_powermarker_rows_after_sort(self, do_setup):
app = self.app
objects = self.objects
self.rtable.power_marker = True
@@ -295,7 +295,7 @@ class TestCaseDupeGuruWithResults:
app.toggle_selected_mark_state()
eq_(app.results.mark_count, 0)
def test_refreshDetailsWithSelected(self, do_setup):
def test_refresh_details_with_selected(self, do_setup):
self.rtable.select([1, 4])
eq_(self.dpanel.row(0), ("Filename", "bar bleh", "foo bar"))
self.dpanel.view.check_gui_calls(["refresh"])
@@ -303,7 +303,7 @@ class TestCaseDupeGuruWithResults:
eq_(self.dpanel.row(0), ("Filename", "---", "---"))
self.dpanel.view.check_gui_calls(["refresh"])
def test_makeSelectedReference(self, do_setup):
def test_make_selected_reference(self, do_setup):
app = self.app
objects = self.objects
groups = self.groups
@@ -312,7 +312,7 @@ class TestCaseDupeGuruWithResults:
assert groups[0].ref is objects[1]
assert groups[1].ref is objects[4]
def test_makeSelectedReference_by_selecting_two_dupes_in_the_same_group(self, do_setup):
def test_make_selected_reference_by_selecting_two_dupes_in_the_same_group(self, do_setup):
app = self.app
objects = self.objects
groups = self.groups
@@ -322,7 +322,7 @@ class TestCaseDupeGuruWithResults:
assert groups[0].ref is objects[1]
assert groups[1].ref is objects[4]
def test_removeSelected(self, do_setup):
def test_remove_selected(self, do_setup):
app = self.app
self.rtable.select([1, 4])
app.remove_selected()
@@ -330,7 +330,7 @@ class TestCaseDupeGuruWithResults:
app.remove_selected()
eq_(len(app.results.dupes), 0)
def test_addDirectory_simple(self, do_setup):
def test_add_directory_simple(self, do_setup):
# There's already a directory in self.app, so adding another once makes 2 of em
app = self.app
# any other path that isn't a parent or child of the already added path
@@ -338,7 +338,7 @@ class TestCaseDupeGuruWithResults:
app.add_directory(otherpath)
eq_(len(app.directories), 2)
def test_addDirectory_already_there(self, do_setup):
def test_add_directory_already_there(self, do_setup):
app = self.app
otherpath = Path(op.dirname(__file__))
app.add_directory(otherpath)
@@ -346,7 +346,7 @@ class TestCaseDupeGuruWithResults:
eq_(len(app.view.messages), 1)
assert "already" in app.view.messages[0]
def test_addDirectory_does_not_exist(self, do_setup):
def test_add_directory_does_not_exist(self, do_setup):
app = self.app
app.add_directory("/does_not_exist")
eq_(len(app.view.messages), 1)
@@ -362,30 +362,30 @@ class TestCaseDupeGuruWithResults:
# BOTH the ref and the other dupe should have been added
eq_(len(app.ignore_list), 3)
def test_purgeIgnoreList(self, do_setup, tmpdir):
def test_purge_ignorelist(self, do_setup, tmpdir):
app = self.app
p1 = str(tmpdir.join("file1"))
p2 = str(tmpdir.join("file2"))
open(p1, "w").close()
open(p2, "w").close()
dne = "/does_not_exist"
app.ignore_list.Ignore(dne, p1)
app.ignore_list.Ignore(p2, dne)
app.ignore_list.Ignore(p1, p2)
app.ignore_list.ignore(dne, p1)
app.ignore_list.ignore(p2, dne)
app.ignore_list.ignore(p1, p2)
app.purge_ignore_list()
eq_(1, len(app.ignore_list))
assert app.ignore_list.AreIgnored(p1, p2)
assert not app.ignore_list.AreIgnored(dne, p1)
assert app.ignore_list.are_ignored(p1, p2)
assert not app.ignore_list.are_ignored(dne, p1)
def test_only_unicode_is_added_to_ignore_list(self, do_setup):
def FakeIgnore(first, second):
def fake_ignore(first, second):
if not isinstance(first, str):
self.fail()
if not isinstance(second, str):
self.fail()
app = self.app
app.ignore_list.Ignore = FakeIgnore
app.ignore_list.ignore = fake_ignore
self.rtable.select([4])
app.add_selected_to_ignore_list()
@@ -419,7 +419,7 @@ class TestCaseDupeGuruWithResults:
# don't crash
class TestCaseDupeGuru_renameSelected:
class TestCaseDupeGuruRenameSelected:
@pytest.fixture
def do_setup(self, request):
tmpdir = request.getfixturevalue("tmpdir")
@@ -502,7 +502,6 @@ class TestAppWithDirectoriesInTree:
# refreshed.
node = self.dtree[0]
eq_(len(node), 3) # a len() call is required for subnodes to be loaded
subnode = node[0]
node.state = 1 # the state property is a state index
node = self.dtree[0]
eq_(len(node), 3)

View File

@@ -151,8 +151,8 @@ class TestApp(TestAppBase):
def __init__(self):
def link_gui(gui):
gui.view = self.make_logger()
if hasattr(gui, "columns"): # tables
gui.columns.view = self.make_logger()
if hasattr(gui, "_columns"): # tables
gui._columns.view = self.make_logger()
return gui
TestAppBase.__init__(self)

View File

@@ -73,99 +73,6 @@ class TestCasegetblock:
eq_((meanred, meangreen, meanblue), b)
# class TCdiff(unittest.TestCase):
# def test_diff(self):
# b1 = (10, 20, 30)
# b2 = (1, 2, 3)
# eq_(9 + 18 + 27, diff(b1, b2))
#
# def test_diff_negative(self):
# b1 = (10, 20, 30)
# b2 = (1, 2, 3)
# eq_(9 + 18 + 27, diff(b2, b1))
#
# def test_diff_mixed_positive_and_negative(self):
# b1 = (1, 5, 10)
# b2 = (10, 1, 15)
# eq_(9 + 4 + 5, diff(b1, b2))
#
# class TCgetblocks(unittest.TestCase):
# def test_empty_image(self):
# im = empty()
# blocks = getblocks(im, 1)
# eq_(0, len(blocks))
#
# def test_one_block_image(self):
# im = four_pixels()
# blocks = getblocks2(im, 1)
# eq_(1, len(blocks))
# block = blocks[0]
# meanred = (0xff + 0x80) // 4
# meangreen = (0x80 + 0x40) // 4
# meanblue = (0xff + 0x80) // 4
# eq_((meanred, meangreen, meanblue), block)
#
# def test_not_enough_height_to_fit_a_block(self):
# im = FakeImage((2, 1), [BLACK, BLACK])
# blocks = getblocks(im, 2)
# eq_(0, len(blocks))
#
# def xtest_dont_include_leftovers(self):
# # this test is disabled because getblocks is not used and getblock in cdeffed
# pixels = [
# RED,(0, 0x80, 0xff), BLACK,
# (0x80, 0, 0),(0, 0x40, 0x80), BLACK,
# BLACK, BLACK, BLACK
# ]
# im = FakeImage((3, 3), pixels)
# blocks = getblocks(im, 2)
# block = blocks[0]
# #Because the block is smaller than the image, only blocksize must be considered.
# meanred = (0xff + 0x80) // 4
# meangreen = (0x80 + 0x40) // 4
# meanblue = (0xff + 0x80) // 4
# eq_((meanred, meangreen, meanblue), block)
#
# def xtest_two_blocks(self):
# # this test is disabled because getblocks is not used and getblock in cdeffed
# pixels = [BLACK for i in xrange(4 * 2)]
# pixels[0] = RED
# pixels[1] = (0, 0x80, 0xff)
# pixels[4] = (0x80, 0, 0)
# pixels[5] = (0, 0x40, 0x80)
# im = FakeImage((4, 2), pixels)
# blocks = getblocks(im, 2)
# eq_(2, len(blocks))
# block = blocks[0]
# #Because the block is smaller than the image, only blocksize must be considered.
# meanred = (0xff + 0x80) // 4
# meangreen = (0x80 + 0x40) // 4
# meanblue = (0xff + 0x80) // 4
# eq_((meanred, meangreen, meanblue), block)
# eq_(BLACK, blocks[1])
#
# def test_four_blocks(self):
# pixels = [BLACK for i in xrange(4 * 4)]
# pixels[0] = RED
# pixels[1] = (0, 0x80, 0xff)
# pixels[4] = (0x80, 0, 0)
# pixels[5] = (0, 0x40, 0x80)
# im = FakeImage((4, 4), pixels)
# blocks = getblocks2(im, 2)
# eq_(4, len(blocks))
# block = blocks[0]
# #Because the block is smaller than the image, only blocksize must be considered.
# meanred = (0xff + 0x80) // 4
# meangreen = (0x80 + 0x40) // 4
# meanblue = (0xff + 0x80) // 4
# eq_((meanred, meangreen, meanblue), block)
# eq_(BLACK, blocks[1])
# eq_(BLACK, blocks[2])
# eq_(BLACK, blocks[3])
#
class TestCasegetblocks2:
def test_empty_image(self):
im = empty()
@@ -270,8 +177,8 @@ class TestCaseavgdiff:
def test_return_at_least_1_at_the_slightest_difference(self):
ref = (0, 0, 0)
b1 = (1, 0, 0)
blocks1 = [ref for i in range(250)]
blocks2 = [ref for i in range(250)]
blocks1 = [ref for _ in range(250)]
blocks2 = [ref for _ in range(250)]
blocks2[0] = b1
eq_(1, my_avgdiff(blocks1, blocks2))
@@ -280,41 +187,3 @@ class TestCaseavgdiff:
blocks1 = [ref, ref]
blocks2 = [ref, ref]
eq_(0, my_avgdiff(blocks1, blocks2))
# class TCmaxdiff(unittest.TestCase):
# def test_empty(self):
# self.assertRaises(NoBlocksError, maxdiff,[],[])
#
# def test_two_blocks(self):
# b1 = (5, 10, 15)
# b2 = (255, 250, 245)
# b3 = (0, 0, 0)
# b4 = (255, 0, 255)
# blocks1 = [b1, b2]
# blocks2 = [b3, b4]
# expected1 = 5 + 10 + 15
# expected2 = 0 + 250 + 10
# expected = max(expected1, expected2)
# eq_(expected, maxdiff(blocks1, blocks2))
#
# def test_blocks_not_the_same_size(self):
# b = (0, 0, 0)
# self.assertRaises(DifferentBlockCountError, maxdiff,[b, b],[b])
#
# def test_first_arg_is_empty_but_not_second(self):
# #Don't return 0 (as when the 2 lists are empty), raise!
# b = (0, 0, 0)
# self.assertRaises(DifferentBlockCountError, maxdiff,[],[b])
#
# def test_limit(self):
# b1 = (5, 10, 15)
# b2 = (255, 250, 245)
# b3 = (0, 0, 0)
# b4 = (255, 0, 255)
# blocks1 = [b1, b2]
# blocks2 = [b3, b4]
# expected1 = 5 + 10 + 15
# expected2 = 0 + 250 + 10
# eq_(expected1, maxdiff(blocks1, blocks2, expected1 - 1))
#

View File

@@ -17,7 +17,7 @@ except ImportError:
skip("Can't import the cache module, probably hasn't been compiled.")
class TestCasecolors_to_string:
class TestCaseColorsToString:
def test_no_color(self):
eq_("", colors_to_string([]))
@@ -30,7 +30,7 @@ class TestCasecolors_to_string:
eq_("000102030405", colors_to_string([(0, 1, 2), (3, 4, 5)]))
class TestCasestring_to_colors:
class TestCaseStringToColors:
def test_empty(self):
eq_([], string_to_colors(""))

View File

@@ -92,7 +92,7 @@ def test_add_path():
assert p in d
def test_AddPath_when_path_is_already_there():
def test_add_path_when_path_is_already_there():
d = Directories()
p = testpath["onefile"]
d.add_path(p)
@@ -112,7 +112,7 @@ def test_add_path_containing_paths_already_there():
eq_(d[0], testpath)
def test_AddPath_non_latin(tmpdir):
def test_add_path_non_latin(tmpdir):
p = Path(str(tmpdir))
to_add = p["unicode\u201a"]
os.mkdir(str(to_add))
@@ -140,20 +140,20 @@ def test_states():
d = Directories()
p = testpath["onefile"]
d.add_path(p)
eq_(DirectoryState.Normal, d.get_state(p))
d.set_state(p, DirectoryState.Reference)
eq_(DirectoryState.Reference, d.get_state(p))
eq_(DirectoryState.Reference, d.get_state(p["dir1"]))
eq_(DirectoryState.NORMAL, d.get_state(p))
d.set_state(p, DirectoryState.REFERENCE)
eq_(DirectoryState.REFERENCE, d.get_state(p))
eq_(DirectoryState.REFERENCE, d.get_state(p["dir1"]))
eq_(1, len(d.states))
eq_(p, list(d.states.keys())[0])
eq_(DirectoryState.Reference, d.states[p])
eq_(DirectoryState.REFERENCE, d.states[p])
def test_get_state_with_path_not_there():
# When the path's not there, just return DirectoryState.Normal
d = Directories()
d.add_path(testpath["onefile"])
eq_(d.get_state(testpath), DirectoryState.Normal)
eq_(d.get_state(testpath), DirectoryState.NORMAL)
def test_states_overwritten_when_larger_directory_eat_smaller_ones():
@@ -162,20 +162,20 @@ def test_states_overwritten_when_larger_directory_eat_smaller_ones():
d = Directories()
p = testpath["onefile"]
d.add_path(p)
d.set_state(p, DirectoryState.Excluded)
d.set_state(p, DirectoryState.EXCLUDED)
d.add_path(testpath)
d.set_state(testpath, DirectoryState.Reference)
eq_(d.get_state(p), DirectoryState.Reference)
eq_(d.get_state(p["dir1"]), DirectoryState.Reference)
eq_(d.get_state(testpath), DirectoryState.Reference)
d.set_state(testpath, DirectoryState.REFERENCE)
eq_(d.get_state(p), DirectoryState.REFERENCE)
eq_(d.get_state(p["dir1"]), DirectoryState.REFERENCE)
eq_(d.get_state(testpath), DirectoryState.REFERENCE)
def test_get_files():
d = Directories()
p = testpath["fs"]
d.add_path(p)
d.set_state(p["dir1"], DirectoryState.Reference)
d.set_state(p["dir2"], DirectoryState.Excluded)
d.set_state(p["dir1"], DirectoryState.REFERENCE)
d.set_state(p["dir2"], DirectoryState.EXCLUDED)
files = list(d.get_files())
eq_(5, len(files))
for f in files:
@@ -204,8 +204,8 @@ def test_get_folders():
d = Directories()
p = testpath["fs"]
d.add_path(p)
d.set_state(p["dir1"], DirectoryState.Reference)
d.set_state(p["dir2"], DirectoryState.Excluded)
d.set_state(p["dir1"], DirectoryState.REFERENCE)
d.set_state(p["dir2"], DirectoryState.EXCLUDED)
folders = list(d.get_folders())
eq_(len(folders), 3)
ref = [f for f in folders if f.is_ref]
@@ -220,7 +220,7 @@ def test_get_files_with_inherited_exclusion():
d = Directories()
p = testpath["onefile"]
d.add_path(p)
d.set_state(p, DirectoryState.Excluded)
d.set_state(p, DirectoryState.EXCLUDED)
eq_([], list(d.get_files()))
@@ -233,14 +233,14 @@ def test_save_and_load(tmpdir):
p2.mkdir()
d1.add_path(p1)
d1.add_path(p2)
d1.set_state(p1, DirectoryState.Reference)
d1.set_state(p1["dir1"], DirectoryState.Excluded)
d1.set_state(p1, DirectoryState.REFERENCE)
d1.set_state(p1["dir1"], DirectoryState.EXCLUDED)
tmpxml = str(tmpdir.join("directories_testunit.xml"))
d1.save_to_file(tmpxml)
d2.load_from_file(tmpxml)
eq_(2, len(d2))
eq_(DirectoryState.Reference, d2.get_state(p1))
eq_(DirectoryState.Excluded, d2.get_state(p1["dir1"]))
eq_(DirectoryState.REFERENCE, d2.get_state(p1))
eq_(DirectoryState.EXCLUDED, d2.get_state(p1["dir1"]))
def test_invalid_path():
@@ -258,7 +258,7 @@ def test_set_state_on_invalid_path():
Path(
"foobar",
),
DirectoryState.Normal,
DirectoryState.NORMAL,
)
except LookupError:
assert False
@@ -287,7 +287,7 @@ def test_unicode_save(tmpdir):
p1.mkdir()
p1["foo\xe9"].mkdir()
d.add_path(p1)
d.set_state(p1["foo\xe9"], DirectoryState.Excluded)
d.set_state(p1["foo\xe9"], DirectoryState.EXCLUDED)
tmpxml = str(tmpdir.join("directories_testunit.xml"))
try:
d.save_to_file(tmpxml)
@@ -321,10 +321,10 @@ def test_get_state_returns_excluded_by_default_for_hidden_directories(tmpdir):
hidden_dir_path = p[".foo"]
p[".foo"].mkdir()
d.add_path(p)
eq_(d.get_state(hidden_dir_path), DirectoryState.Excluded)
eq_(d.get_state(hidden_dir_path), DirectoryState.EXCLUDED)
# But it can be overriden
d.set_state(hidden_dir_path, DirectoryState.Normal)
eq_(d.get_state(hidden_dir_path), DirectoryState.Normal)
d.set_state(hidden_dir_path, DirectoryState.NORMAL)
eq_(d.get_state(hidden_dir_path), DirectoryState.NORMAL)
def test_default_path_state_override(tmpdir):
@@ -332,7 +332,7 @@ def test_default_path_state_override(tmpdir):
class MyDirectories(Directories):
def _default_state_for_path(self, path):
if "foobar" in path:
return DirectoryState.Excluded
return DirectoryState.EXCLUDED
d = MyDirectories()
p1 = Path(str(tmpdir))
@@ -341,12 +341,12 @@ def test_default_path_state_override(tmpdir):
p1["foobaz"].mkdir()
p1["foobaz/somefile"].open("w").close()
d.add_path(p1)
eq_(d.get_state(p1["foobaz"]), DirectoryState.Normal)
eq_(d.get_state(p1["foobar"]), DirectoryState.Excluded)
eq_(d.get_state(p1["foobaz"]), DirectoryState.NORMAL)
eq_(d.get_state(p1["foobar"]), DirectoryState.EXCLUDED)
eq_(len(list(d.get_files())), 1) # only the 'foobaz' file is there
# However, the default state can be changed
d.set_state(p1["foobar"], DirectoryState.Normal)
eq_(d.get_state(p1["foobar"]), DirectoryState.Normal)
d.set_state(p1["foobar"], DirectoryState.NORMAL)
eq_(d.get_state(p1["foobar"]), DirectoryState.NORMAL)
eq_(len(list(d.get_files())), 2)
@@ -375,11 +375,11 @@ files: {self.d._exclude_list.compiled_files} all: {self.d._exclude_list.compiled
p1["$Recycle.Bin"].mkdir()
p1["$Recycle.Bin"]["subdir"].mkdir()
self.d.add_path(p1)
eq_(self.d.get_state(p1["$Recycle.Bin"]), DirectoryState.Excluded)
# By default, subdirs should be excluded too, but this can be overriden separately
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.Excluded)
self.d.set_state(p1["$Recycle.Bin"]["subdir"], DirectoryState.Normal)
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.Normal)
eq_(self.d.get_state(p1["$Recycle.Bin"]), DirectoryState.EXCLUDED)
# By default, subdirs should be excluded too, but this can be overridden separately
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.EXCLUDED)
self.d.set_state(p1["$Recycle.Bin"]["subdir"], DirectoryState.NORMAL)
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.NORMAL)
def test_exclude_refined(self, tmpdir):
regex1 = r"^\$Recycle\.Bin$"
@@ -398,16 +398,16 @@ files: {self.d._exclude_list.compiled_files} all: {self.d._exclude_list.compiled
self.d.add_path(p1["$Recycle.Bin"])
# Filter should set the default state to Excluded
eq_(self.d.get_state(p1["$Recycle.Bin"]), DirectoryState.Excluded)
eq_(self.d.get_state(p1["$Recycle.Bin"]), DirectoryState.EXCLUDED)
# The subdir should inherit its parent state
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.Excluded)
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdar"]), DirectoryState.Excluded)
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.EXCLUDED)
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdar"]), DirectoryState.EXCLUDED)
# Override a child path's state
self.d.set_state(p1["$Recycle.Bin"]["subdir"], DirectoryState.Normal)
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.Normal)
self.d.set_state(p1["$Recycle.Bin"]["subdir"], DirectoryState.NORMAL)
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.NORMAL)
# Parent should keep its default state, and the other child too
eq_(self.d.get_state(p1["$Recycle.Bin"]), DirectoryState.Excluded)
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdar"]), DirectoryState.Excluded)
eq_(self.d.get_state(p1["$Recycle.Bin"]), DirectoryState.EXCLUDED)
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdar"]), DirectoryState.EXCLUDED)
# print(f"get_folders(): {[x for x in self.d.get_folders()]}")
# only the 2 files directly under the Normal directory
@@ -419,8 +419,8 @@ files: {self.d._exclude_list.compiled_files} all: {self.d._exclude_list.compiled
assert "somesubdirfile.png" in files
assert "unwanted_subdirfile.gif" in files
# Overriding the parent should enable all children
self.d.set_state(p1["$Recycle.Bin"], DirectoryState.Normal)
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdar"]), DirectoryState.Normal)
self.d.set_state(p1["$Recycle.Bin"], DirectoryState.NORMAL)
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdar"]), DirectoryState.NORMAL)
# all files there
files = self.get_files_and_expect_num_result(6)
assert "somefile.png" in files
@@ -444,7 +444,7 @@ files: {self.d._exclude_list.compiled_files} all: {self.d._exclude_list.compiled
assert self.d._exclude_list.error(regex3) is None
# print(f"get_folders(): {[x for x in self.d.get_folders()]}")
# Directory shouldn't change its state here, unless explicitely done by user
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.Normal)
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.NORMAL)
files = self.get_files_and_expect_num_result(5)
assert "unwanted_subdirfile.gif" not in files
assert "unwanted_subdarfile.png" in files
@@ -454,14 +454,14 @@ files: {self.d._exclude_list.compiled_files} all: {self.d._exclude_list.compiled
self.d._exclude_list.rename(regex3, regex4)
assert self.d._exclude_list.error(regex4) is None
p1["$Recycle.Bin"]["subdar"]["file_ending_with_subdir"].open("w").close()
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.Excluded)
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.EXCLUDED)
files = self.get_files_and_expect_num_result(4)
assert "file_ending_with_subdir" not in files
assert "somesubdarfile.jpeg" in files
assert "somesubdirfile.png" not in files
assert "unwanted_subdirfile.gif" not in files
self.d.set_state(p1["$Recycle.Bin"]["subdir"], DirectoryState.Normal)
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.Normal)
self.d.set_state(p1["$Recycle.Bin"]["subdir"], DirectoryState.NORMAL)
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.NORMAL)
# print(f"get_folders(): {[x for x in self.d.get_folders()]}")
files = self.get_files_and_expect_num_result(6)
assert "file_ending_with_subdir" not in files
@@ -471,7 +471,7 @@ files: {self.d._exclude_list.compiled_files} all: {self.d._exclude_list.compiled
regex5 = r".*subdir.*"
self.d._exclude_list.rename(regex4, regex5)
# Files containing substring should be filtered
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.Normal)
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.NORMAL)
# The path should not match, only the filename, the "subdir" in the directory name shouldn't matter
p1["$Recycle.Bin"]["subdir"]["file_which_shouldnt_match"].open("w").close()
files = self.get_files_and_expect_num_result(5)
@@ -493,7 +493,7 @@ files: {self.d._exclude_list.compiled_files} all: {self.d._exclude_list.compiled
assert self.d._exclude_list.error(regex6) is None
assert regex6 in self.d._exclude_list
# This still should not be affected
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.Normal)
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.NORMAL)
files = self.get_files_and_expect_num_result(5)
# These files are under the "/subdir" directory
assert "somesubdirfile.png" not in files
@@ -518,7 +518,7 @@ files: {self.d._exclude_list.compiled_files} all: {self.d._exclude_list.compiled
self.d._exclude_list.add(regex3)
self.d._exclude_list.mark(regex3)
# print(f"get_folders(): {[x for x in self.d.get_folders()]}")
eq_(self.d.get_state(p1["$Recycle.Bin"]["思叫物語"]), DirectoryState.Excluded)
eq_(self.d.get_state(p1["$Recycle.Bin"]["思叫物語"]), DirectoryState.EXCLUDED)
files = self.get_files_and_expect_num_result(2)
assert "過去白濁物語~]_カラー.jpg" not in files
assert "なししろ会う前" not in files
@@ -527,7 +527,7 @@ files: {self.d._exclude_list.compiled_files} all: {self.d._exclude_list.compiled
regex4 = r".*物語$"
self.d._exclude_list.rename(regex3, regex4)
assert self.d._exclude_list.error(regex4) is None
self.d.set_state(p1["$Recycle.Bin"]["思叫物語"], DirectoryState.Normal)
self.d.set_state(p1["$Recycle.Bin"]["思叫物語"], DirectoryState.NORMAL)
files = self.get_files_and_expect_num_result(5)
assert "過去白濁物語~]_カラー.jpg" in files
assert "なししろ会う前" in files
@@ -546,8 +546,8 @@ files: {self.d._exclude_list.compiled_files} all: {self.d._exclude_list.compiled
p1["foobar"][".hidden_dir"][".hidden_subfile.png"].open("w").close()
self.d.add_path(p1["foobar"])
# It should not inherit its parent's state originally
eq_(self.d.get_state(p1["foobar"][".hidden_dir"]), DirectoryState.Excluded)
self.d.set_state(p1["foobar"][".hidden_dir"], DirectoryState.Normal)
eq_(self.d.get_state(p1["foobar"][".hidden_dir"]), DirectoryState.EXCLUDED)
self.d.set_state(p1["foobar"][".hidden_dir"], DirectoryState.NORMAL)
# The files should still be filtered
files = self.get_files_and_expect_num_result(1)
eq_(len(self.d._exclude_list.compiled_paths), 0)

View File

@@ -103,10 +103,9 @@ class TestCasegetfields:
expected = [["a", "bc", "def"]]
actual = getfields(" - a bc def")
eq_(expected, actual)
expected = [["bc", "def"]]
class TestCaseunpack_fields:
class TestCaseUnpackFields:
def test_with_fields(self):
expected = ["a", "b", "c", "d", "e", "f"]
actual = unpack_fields([["a"], ["b", "c"], ["d", "e", "f"]])
@@ -218,24 +217,24 @@ class TestCaseWordCompareWithFields:
eq_([["c", "d", "f"], ["a", "b"]], second)
class TestCasebuild_word_dict:
class TestCaseBuildWordDict:
def test_with_standard_words(self):
itemList = [NamedObject("foo bar", True)]
itemList.append(NamedObject("bar baz", True))
itemList.append(NamedObject("baz bleh foo", True))
d = build_word_dict(itemList)
item_list = [NamedObject("foo bar", True)]
item_list.append(NamedObject("bar baz", True))
item_list.append(NamedObject("baz bleh foo", True))
d = build_word_dict(item_list)
eq_(4, len(d))
eq_(2, len(d["foo"]))
assert itemList[0] in d["foo"]
assert itemList[2] in d["foo"]
assert item_list[0] in d["foo"]
assert item_list[2] in d["foo"]
eq_(2, len(d["bar"]))
assert itemList[0] in d["bar"]
assert itemList[1] in d["bar"]
assert item_list[0] in d["bar"]
assert item_list[1] in d["bar"]
eq_(2, len(d["baz"]))
assert itemList[1] in d["baz"]
assert itemList[2] in d["baz"]
assert item_list[1] in d["baz"]
assert item_list[2] in d["baz"]
eq_(1, len(d["bleh"]))
assert itemList[2] in d["bleh"]
assert item_list[2] in d["bleh"]
def test_unpack_fields(self):
o = NamedObject("")
@@ -269,7 +268,7 @@ class TestCasebuild_word_dict:
eq_(100, self.log[1])
class TestCasemerge_similar_words:
class TestCaseMergeSimilarWords:
def test_some_similar_words(self):
d = {
"foobar": set([1]),
@@ -281,11 +280,11 @@ class TestCasemerge_similar_words:
eq_(3, len(d["foobar"]))
class TestCasereduce_common_words:
class TestCaseReduceCommonWords:
def test_typical(self):
d = {
"foo": set([NamedObject("foo bar", True) for i in range(50)]),
"bar": set([NamedObject("foo bar", True) for i in range(49)]),
"foo": set([NamedObject("foo bar", True) for _ in range(50)]),
"bar": set([NamedObject("foo bar", True) for _ in range(49)]),
}
reduce_common_words(d, 50)
assert "foo" not in d
@@ -293,7 +292,7 @@ class TestCasereduce_common_words:
def test_dont_remove_objects_with_only_common_words(self):
d = {
"common": set([NamedObject("common uncommon", True) for i in range(50)] + [NamedObject("common", True)]),
"common": set([NamedObject("common uncommon", True) for _ in range(50)] + [NamedObject("common", True)]),
"uncommon": set([NamedObject("common uncommon", True)]),
}
reduce_common_words(d, 50)
@@ -302,20 +301,20 @@ class TestCasereduce_common_words:
def test_values_still_are_set_instances(self):
d = {
"common": set([NamedObject("common uncommon", True) for i in range(50)] + [NamedObject("common", True)]),
"common": set([NamedObject("common uncommon", True) for _ in range(50)] + [NamedObject("common", True)]),
"uncommon": set([NamedObject("common uncommon", True)]),
}
reduce_common_words(d, 50)
assert isinstance(d["common"], set)
assert isinstance(d["uncommon"], set)
def test_dont_raise_KeyError_when_a_word_has_been_removed(self):
def test_dont_raise_keyerror_when_a_word_has_been_removed(self):
# If a word has been removed by the reduce, an object in a subsequent common word that
# contains the word that has been removed would cause a KeyError.
d = {
"foo": set([NamedObject("foo bar baz", True) for i in range(50)]),
"bar": set([NamedObject("foo bar baz", True) for i in range(50)]),
"baz": set([NamedObject("foo bar baz", True) for i in range(49)]),
"foo": set([NamedObject("foo bar baz", True) for _ in range(50)]),
"bar": set([NamedObject("foo bar baz", True) for _ in range(50)]),
"baz": set([NamedObject("foo bar baz", True) for _ in range(49)]),
}
try:
reduce_common_words(d, 50)
@@ -329,7 +328,7 @@ class TestCasereduce_common_words:
o.words = [["foo", "bar"], ["baz"]]
return o
d = {"foo": set([create_it() for i in range(50)])}
d = {"foo": set([create_it() for _ in range(50)])}
try:
reduce_common_words(d, 50)
except TypeError:
@@ -342,9 +341,9 @@ class TestCasereduce_common_words:
# would not stay in 'bar' because 'foo' is not a common word anymore.
only_common = NamedObject("foo bar", True)
d = {
"foo": set([NamedObject("foo bar baz", True) for i in range(49)] + [only_common]),
"bar": set([NamedObject("foo bar baz", True) for i in range(49)] + [only_common]),
"baz": set([NamedObject("foo bar baz", True) for i in range(49)]),
"foo": set([NamedObject("foo bar baz", True) for _ in range(49)] + [only_common]),
"bar": set([NamedObject("foo bar baz", True) for _ in range(49)] + [only_common]),
"baz": set([NamedObject("foo bar baz", True) for _ in range(49)]),
}
reduce_common_words(d, 50)
eq_(1, len(d["foo"]))
@@ -352,7 +351,7 @@ class TestCasereduce_common_words:
eq_(49, len(d["baz"]))
class TestCaseget_match:
class TestCaseGetMatch:
def test_simple(self):
o1 = NamedObject("foo bar", True)
o2 = NamedObject("bar bleh", True)
@@ -381,12 +380,12 @@ class TestCaseGetMatches:
eq_(getmatches([]), [])
def test_simple(self):
itemList = [
item_list = [
NamedObject("foo bar"),
NamedObject("bar bleh"),
NamedObject("a b c foo"),
]
r = getmatches(itemList)
r = getmatches(item_list)
eq_(2, len(r))
m = first(m for m in r if m.percentage == 50) # "foo bar" and "bar bleh"
assert_match(m, "foo bar", "bar bleh")
@@ -394,40 +393,40 @@ class TestCaseGetMatches:
assert_match(m, "foo bar", "a b c foo")
def test_null_and_unrelated_objects(self):
itemList = [
item_list = [
NamedObject("foo bar"),
NamedObject("bar bleh"),
NamedObject(""),
NamedObject("unrelated object"),
]
r = getmatches(itemList)
r = getmatches(item_list)
eq_(len(r), 1)
m = r[0]
eq_(m.percentage, 50)
assert_match(m, "foo bar", "bar bleh")
def test_twice_the_same_word(self):
itemList = [NamedObject("foo foo bar"), NamedObject("bar bleh")]
r = getmatches(itemList)
item_list = [NamedObject("foo foo bar"), NamedObject("bar bleh")]
r = getmatches(item_list)
eq_(1, len(r))
def test_twice_the_same_word_when_preworded(self):
itemList = [NamedObject("foo foo bar", True), NamedObject("bar bleh", True)]
r = getmatches(itemList)
item_list = [NamedObject("foo foo bar", True), NamedObject("bar bleh", True)]
r = getmatches(item_list)
eq_(1, len(r))
def test_two_words_match(self):
itemList = [NamedObject("foo bar"), NamedObject("foo bar bleh")]
r = getmatches(itemList)
item_list = [NamedObject("foo bar"), NamedObject("foo bar bleh")]
r = getmatches(item_list)
eq_(1, len(r))
def test_match_files_with_only_common_words(self):
# If a word occurs more than 50 times, it is excluded from the matching process
# The problem with the common_word_threshold is that the files containing only common
# words will never be matched together. We *should* match them.
# This test assumes that the common word threashold const is 50
itemList = [NamedObject("foo") for i in range(50)]
r = getmatches(itemList)
# This test assumes that the common word threshold const is 50
item_list = [NamedObject("foo") for _ in range(50)]
r = getmatches(item_list)
eq_(1225, len(r))
def test_use_words_already_there_if_there(self):
@@ -450,28 +449,28 @@ class TestCaseGetMatches:
eq_(100, self.log[-1])
def test_weight_words(self):
itemList = [NamedObject("foo bar"), NamedObject("bar bleh")]
m = getmatches(itemList, weight_words=True)[0]
item_list = [NamedObject("foo bar"), NamedObject("bar bleh")]
m = getmatches(item_list, weight_words=True)[0]
eq_(int((6.0 / 13.0) * 100), m.percentage)
def test_similar_word(self):
itemList = [NamedObject("foobar"), NamedObject("foobars")]
eq_(len(getmatches(itemList, match_similar_words=True)), 1)
eq_(getmatches(itemList, match_similar_words=True)[0].percentage, 100)
itemList = [NamedObject("foobar"), NamedObject("foo")]
eq_(len(getmatches(itemList, match_similar_words=True)), 0) # too far
itemList = [NamedObject("bizkit"), NamedObject("bizket")]
eq_(len(getmatches(itemList, match_similar_words=True)), 1)
itemList = [NamedObject("foobar"), NamedObject("foosbar")]
eq_(len(getmatches(itemList, match_similar_words=True)), 1)
item_list = [NamedObject("foobar"), NamedObject("foobars")]
eq_(len(getmatches(item_list, match_similar_words=True)), 1)
eq_(getmatches(item_list, match_similar_words=True)[0].percentage, 100)
item_list = [NamedObject("foobar"), NamedObject("foo")]
eq_(len(getmatches(item_list, match_similar_words=True)), 0) # too far
item_list = [NamedObject("bizkit"), NamedObject("bizket")]
eq_(len(getmatches(item_list, match_similar_words=True)), 1)
item_list = [NamedObject("foobar"), NamedObject("foosbar")]
eq_(len(getmatches(item_list, match_similar_words=True)), 1)
def test_single_object_with_similar_words(self):
itemList = [NamedObject("foo foos")]
eq_(len(getmatches(itemList, match_similar_words=True)), 0)
item_list = [NamedObject("foo foos")]
eq_(len(getmatches(item_list, match_similar_words=True)), 0)
def test_double_words_get_counted_only_once(self):
itemList = [NamedObject("foo bar foo bleh"), NamedObject("foo bar bleh bar")]
m = getmatches(itemList)[0]
item_list = [NamedObject("foo bar foo bleh"), NamedObject("foo bar bleh bar")]
m = getmatches(item_list)[0]
eq_(75, m.percentage)
def test_with_fields(self):
@@ -491,13 +490,13 @@ class TestCaseGetMatches:
eq_(m.percentage, 50)
def test_only_match_similar_when_the_option_is_set(self):
itemList = [NamedObject("foobar"), NamedObject("foobars")]
eq_(len(getmatches(itemList, match_similar_words=False)), 0)
item_list = [NamedObject("foobar"), NamedObject("foobars")]
eq_(len(getmatches(item_list, match_similar_words=False)), 0)
def test_dont_recurse_do_match(self):
# with nosetests, the stack is increased. The number has to be high enough not to be failing falsely
sys.setrecursionlimit(200)
files = [NamedObject("foo bar") for i in range(201)]
files = [NamedObject("foo bar") for _ in range(201)]
try:
getmatches(files)
except RuntimeError:
@@ -506,35 +505,31 @@ class TestCaseGetMatches:
sys.setrecursionlimit(1000)
def test_min_match_percentage(self):
itemList = [
item_list = [
NamedObject("foo bar"),
NamedObject("bar bleh"),
NamedObject("a b c foo"),
]
r = getmatches(itemList, min_match_percentage=50)
r = getmatches(item_list, min_match_percentage=50)
eq_(1, len(r)) # Only "foo bar" / "bar bleh" should match
def test_MemoryError(self, monkeypatch):
def test_memory_error(self, monkeypatch):
@log_calls
def mocked_match(first, second, flags):
if len(mocked_match.calls) > 42:
raise MemoryError()
return Match(first, second, 0)
objects = [NamedObject() for i in range(10)] # results in 45 matches
objects = [NamedObject() for _ in range(10)] # results in 45 matches
monkeypatch.setattr(engine, "get_match", mocked_match)
try:
r = getmatches(objects)
except MemoryError:
self.fail("MemorryError must be handled")
self.fail("MemoryError must be handled")
eq_(42, len(r))
class TestCaseGetMatchesByContents:
def test_dont_compare_empty_files(self):
o1, o2 = no(size=0), no(size=0)
assert not getmatches_by_contents([o1, o2])
def test_big_file_partial_hashes(self):
smallsize = 1
bigsize = 100 * 1024 * 1024 # 100MB
@@ -563,7 +558,7 @@ class TestCaseGetMatchesByContents:
class TestCaseGroup:
def test_empy(self):
def test_empty(self):
g = Group()
eq_(None, g.ref)
eq_([], g.dupes)
@@ -802,14 +797,14 @@ class TestCaseGroup:
eq_(0, len(g.candidates))
class TestCaseget_groups:
class TestCaseGetGroups:
def test_empty(self):
r = get_groups([])
eq_([], r)
def test_simple(self):
itemList = [NamedObject("foo bar"), NamedObject("bar bleh")]
matches = getmatches(itemList)
item_list = [NamedObject("foo bar"), NamedObject("bar bleh")]
matches = getmatches(item_list)
m = matches[0]
r = get_groups(matches)
eq_(1, len(r))
@@ -819,15 +814,15 @@ class TestCaseget_groups:
def test_group_with_multiple_matches(self):
# This results in 3 matches
itemList = [NamedObject("foo"), NamedObject("foo"), NamedObject("foo")]
matches = getmatches(itemList)
item_list = [NamedObject("foo"), NamedObject("foo"), NamedObject("foo")]
matches = getmatches(item_list)
r = get_groups(matches)
eq_(1, len(r))
g = r[0]
eq_(3, len(g))
def test_must_choose_a_group(self):
itemList = [
item_list = [
NamedObject("a b"),
NamedObject("a b"),
NamedObject("b c"),
@@ -836,13 +831,13 @@ class TestCaseget_groups:
]
# There will be 2 groups here: group "a b" and group "c d"
# "b c" can go either of them, but not both.
matches = getmatches(itemList)
matches = getmatches(item_list)
r = get_groups(matches)
eq_(2, len(r))
eq_(5, len(r[0]) + len(r[1]))
def test_should_all_go_in_the_same_group(self):
itemList = [
item_list = [
NamedObject("a b"),
NamedObject("a b"),
NamedObject("a b"),
@@ -850,7 +845,7 @@ class TestCaseget_groups:
]
# There will be 2 groups here: group "a b" and group "c d"
# "b c" can fit in both, but it must be in only one of them
matches = getmatches(itemList)
matches = getmatches(item_list)
r = get_groups(matches)
eq_(1, len(r))
@@ -869,8 +864,8 @@ class TestCaseget_groups:
assert o3 in g
def test_four_sized_group(self):
itemList = [NamedObject("foobar") for i in range(4)]
m = getmatches(itemList)
item_list = [NamedObject("foobar") for _ in range(4)]
m = getmatches(item_list)
r = get_groups(m)
eq_(1, len(r))
eq_(4, len(r[0]))

View File

@@ -5,12 +5,8 @@
# http://www.gnu.org/licenses/gpl-3.0.html
import io
# import os.path as op
from xml.etree import ElementTree as ET
# from pytest import raises
from hscommon.testutil import eq_
from hscommon.plat import ISWINDOWS
@@ -144,11 +140,7 @@ class TestCaseListEmpty:
def test_force_add_not_compilable(self):
"""Used when loading from XML for example"""
regex = r"one))"
try:
self.exclude_list.add(regex, forced=True)
except Exception as e:
# Should not get an exception here unless it's a duplicate regex
raise e
self.exclude_list.add(regex, forced=True)
marked = self.exclude_list.mark(regex)
eq_(marked, False) # can't be marked since not compilable
eq_(len(self.exclude_list), 1)
@@ -232,7 +224,6 @@ class TestCaseListEmpty:
found = True
if not found:
raise (Exception(f"Default RE {re} not found in compiled list."))
continue
eq_(len(default_regexes), len(self.exclude_list.compiled))

View File

@@ -16,54 +16,54 @@ from ..ignore import IgnoreList
def test_empty():
il = IgnoreList()
eq_(0, len(il))
assert not il.AreIgnored("foo", "bar")
assert not il.are_ignored("foo", "bar")
def test_simple():
il = IgnoreList()
il.Ignore("foo", "bar")
assert il.AreIgnored("foo", "bar")
assert il.AreIgnored("bar", "foo")
assert not il.AreIgnored("foo", "bleh")
assert not il.AreIgnored("bleh", "bar")
il.ignore("foo", "bar")
assert il.are_ignored("foo", "bar")
assert il.are_ignored("bar", "foo")
assert not il.are_ignored("foo", "bleh")
assert not il.are_ignored("bleh", "bar")
eq_(1, len(il))
def test_multiple():
il = IgnoreList()
il.Ignore("foo", "bar")
il.Ignore("foo", "bleh")
il.Ignore("bleh", "bar")
il.Ignore("aybabtu", "bleh")
assert il.AreIgnored("foo", "bar")
assert il.AreIgnored("bar", "foo")
assert il.AreIgnored("foo", "bleh")
assert il.AreIgnored("bleh", "bar")
assert not il.AreIgnored("aybabtu", "bar")
il.ignore("foo", "bar")
il.ignore("foo", "bleh")
il.ignore("bleh", "bar")
il.ignore("aybabtu", "bleh")
assert il.are_ignored("foo", "bar")
assert il.are_ignored("bar", "foo")
assert il.are_ignored("foo", "bleh")
assert il.are_ignored("bleh", "bar")
assert not il.are_ignored("aybabtu", "bar")
eq_(4, len(il))
def test_clear():
il = IgnoreList()
il.Ignore("foo", "bar")
il.Clear()
assert not il.AreIgnored("foo", "bar")
assert not il.AreIgnored("bar", "foo")
il.ignore("foo", "bar")
il.clear()
assert not il.are_ignored("foo", "bar")
assert not il.are_ignored("bar", "foo")
eq_(0, len(il))
def test_add_same_twice():
il = IgnoreList()
il.Ignore("foo", "bar")
il.Ignore("bar", "foo")
il.ignore("foo", "bar")
il.ignore("bar", "foo")
eq_(1, len(il))
def test_save_to_xml():
il = IgnoreList()
il.Ignore("foo", "bar")
il.Ignore("foo", "bleh")
il.Ignore("bleh", "bar")
il.ignore("foo", "bar")
il.ignore("foo", "bleh")
il.ignore("bleh", "bar")
f = io.BytesIO()
il.save_to_xml(f)
f.seek(0)
@@ -77,22 +77,22 @@ def test_save_to_xml():
eq_(len(subchildren), 3)
def test_SaveThenLoad():
def test_save_then_load():
il = IgnoreList()
il.Ignore("foo", "bar")
il.Ignore("foo", "bleh")
il.Ignore("bleh", "bar")
il.Ignore("\u00e9", "bar")
il.ignore("foo", "bar")
il.ignore("foo", "bleh")
il.ignore("bleh", "bar")
il.ignore("\u00e9", "bar")
f = io.BytesIO()
il.save_to_xml(f)
f.seek(0)
il = IgnoreList()
il.load_from_xml(f)
eq_(4, len(il))
assert il.AreIgnored("\u00e9", "bar")
assert il.are_ignored("\u00e9", "bar")
def test_LoadXML_with_empty_file_tags():
def test_load_xml_with_empty_file_tags():
f = io.BytesIO()
f.write(b'<?xml version="1.0" encoding="utf-8"?><ignore_list><file><file/></file></ignore_list>')
f.seek(0)
@@ -101,18 +101,18 @@ def test_LoadXML_with_empty_file_tags():
eq_(0, len(il))
def test_AreIgnore_works_when_a_child_is_a_key_somewhere_else():
def test_are_ignore_works_when_a_child_is_a_key_somewhere_else():
il = IgnoreList()
il.Ignore("foo", "bar")
il.Ignore("bar", "baz")
assert il.AreIgnored("bar", "foo")
il.ignore("foo", "bar")
il.ignore("bar", "baz")
assert il.are_ignored("bar", "foo")
def test_no_dupes_when_a_child_is_a_key_somewhere_else():
il = IgnoreList()
il.Ignore("foo", "bar")
il.Ignore("bar", "baz")
il.Ignore("bar", "foo")
il.ignore("foo", "bar")
il.ignore("bar", "baz")
il.ignore("bar", "foo")
eq_(2, len(il))
@@ -121,7 +121,7 @@ def test_iterate():
il = IgnoreList()
expected = [("foo", "bar"), ("bar", "baz"), ("foo", "baz")]
for i in expected:
il.Ignore(i[0], i[1])
il.ignore(i[0], i[1])
for i in il:
expected.remove(i) # No exception should be raised
assert not expected # expected should be empty
@@ -129,18 +129,18 @@ def test_iterate():
def test_filter():
il = IgnoreList()
il.Ignore("foo", "bar")
il.Ignore("bar", "baz")
il.Ignore("foo", "baz")
il.Filter(lambda f, s: f == "bar")
il.ignore("foo", "bar")
il.ignore("bar", "baz")
il.ignore("foo", "baz")
il.filter(lambda f, s: f == "bar")
eq_(1, len(il))
assert not il.AreIgnored("foo", "bar")
assert il.AreIgnored("bar", "baz")
assert not il.are_ignored("foo", "bar")
assert il.are_ignored("bar", "baz")
def test_save_with_non_ascii_items():
il = IgnoreList()
il.Ignore("\xac", "\xbf")
il.ignore("\xac", "\xbf")
f = io.BytesIO()
try:
il.save_to_xml(f)
@@ -151,29 +151,29 @@ def test_save_with_non_ascii_items():
def test_len():
il = IgnoreList()
eq_(0, len(il))
il.Ignore("foo", "bar")
il.ignore("foo", "bar")
eq_(1, len(il))
def test_nonzero():
il = IgnoreList()
assert not il
il.Ignore("foo", "bar")
il.ignore("foo", "bar")
assert il
def test_remove():
il = IgnoreList()
il.Ignore("foo", "bar")
il.Ignore("foo", "baz")
il.ignore("foo", "bar")
il.ignore("foo", "baz")
il.remove("bar", "foo")
eq_(len(il), 1)
assert not il.AreIgnored("foo", "bar")
assert not il.are_ignored("foo", "bar")
def test_remove_non_existant():
il = IgnoreList()
il.Ignore("foo", "bar")
il.Ignore("foo", "baz")
il.ignore("foo", "bar")
il.ignore("foo", "baz")
with raises(ValueError):
il.remove("foo", "bleh")

View File

@@ -402,7 +402,7 @@ class TestCaseResultsMarkings:
self.results.make_ref(d)
eq_("0 / 3 (0.00 B / 3.00 B) duplicates marked.", self.results.stat_line)
def test_SaveXML(self):
def test_save_xml(self):
self.results.mark(self.objects[1])
self.results.mark_invert()
f = io.BytesIO()
@@ -419,7 +419,7 @@ class TestCaseResultsMarkings:
eq_("n", d1.get("marked"))
eq_("y", d2.get("marked"))
def test_LoadXML(self):
def test_load_xml(self):
def get_file(path):
return [f for f in self.objects if str(f.path) == path][0]
@@ -485,7 +485,7 @@ class TestCaseResultsXML:
eq_("ibabtu", d1.get("words"))
eq_("ibabtu", d2.get("words"))
def test_LoadXML(self):
def test_load_xml(self):
def get_file(path):
return [f for f in self.objects if str(f.path) == path][0]
@@ -517,7 +517,7 @@ class TestCaseResultsXML:
eq_(["ibabtu"], g2[0].words)
eq_(["ibabtu"], g2[1].words)
def test_LoadXML_with_filename(self, tmpdir):
def test_load_xml_with_filename(self, tmpdir):
def get_file(path):
return [f for f in self.objects if str(f.path) == path][0]
@@ -529,7 +529,7 @@ class TestCaseResultsXML:
r.load_from_xml(filename, get_file)
eq_(2, len(r.groups))
def test_LoadXML_with_some_files_that_dont_exist_anymore(self):
def test_load_xml_with_some_files_that_dont_exist_anymore(self):
def get_file(path):
if path.endswith("ibabtu 2"):
return None
@@ -545,7 +545,7 @@ class TestCaseResultsXML:
eq_(1, len(r.groups))
eq_(3, len(r.groups[0]))
def test_LoadXML_missing_attributes_and_bogus_elements(self):
def test_load_xml_missing_attributes_and_bogus_elements(self):
def get_file(path):
return [f for f in self.objects if str(f.path) == path][0]

View File

@@ -52,10 +52,12 @@ def test_empty(fake_fileexists):
def test_default_settings(fake_fileexists):
s = Scanner()
eq_(s.min_match_percentage, 80)
eq_(s.scan_type, ScanType.Filename)
eq_(s.scan_type, ScanType.FILENAME)
eq_(s.mix_file_kind, True)
eq_(s.word_weighting, False)
eq_(s.match_similar_words, False)
eq_(s.size_threshold, 0)
eq_(s.large_size_threshold, 0)
eq_(s.big_file_size_threshold, 0)
@@ -98,7 +100,7 @@ def test_trim_all_ref_groups(fake_fileexists):
eq_(s.discarded_file_count, 0)
def test_priorize(fake_fileexists):
def test_prioritize(fake_fileexists):
s = Scanner()
f = [
no("foo", path="p1"),
@@ -119,7 +121,7 @@ def test_priorize(fake_fileexists):
def test_content_scan(fake_fileexists):
s = Scanner()
s.scan_type = ScanType.Contents
s.scan_type = ScanType.CONTENTS
f = [no("foo"), no("bar"), no("bleh")]
f[0].md5 = f[0].md5partial = f[0].md5samples = "foobar"
f[1].md5 = f[1].md5partial = f[1].md5samples = "foobar"
@@ -133,18 +135,62 @@ def test_content_scan(fake_fileexists):
def test_content_scan_compare_sizes_first(fake_fileexists):
class MyFile(no):
@property
def md5(file):
def md5(self):
raise AssertionError()
s = Scanner()
s.scan_type = ScanType.Contents
s.scan_type = ScanType.CONTENTS
f = [MyFile("foo", 1), MyFile("bar", 2)]
eq_(len(s.get_dupe_groups(f)), 0)
def test_ignore_file_size(fake_fileexists):
s = Scanner()
s.scan_type = ScanType.CONTENTS
small_size = 10 # 10KB
s.size_threshold = 0
large_size = 100 * 1024 * 1024 # 100MB
s.large_size_threshold = 0
f = [
no("smallignore1", small_size - 1),
no("smallignore2", small_size - 1),
no("small1", small_size),
no("small2", small_size),
no("large1", large_size),
no("large2", large_size),
no("largeignore1", large_size + 1),
no("largeignore2", large_size + 1),
]
f[0].md5 = f[0].md5partial = f[0].md5samples = "smallignore"
f[1].md5 = f[1].md5partial = f[1].md5samples = "smallignore"
f[2].md5 = f[2].md5partial = f[2].md5samples = "small"
f[3].md5 = f[3].md5partial = f[3].md5samples = "small"
f[4].md5 = f[4].md5partial = f[4].md5samples = "large"
f[5].md5 = f[5].md5partial = f[5].md5samples = "large"
f[6].md5 = f[6].md5partial = f[6].md5samples = "largeignore"
f[7].md5 = f[7].md5partial = f[7].md5samples = "largeignore"
r = s.get_dupe_groups(f)
# No ignores
eq_(len(r), 4)
# Ignore smaller
s.size_threshold = small_size
r = s.get_dupe_groups(f)
eq_(len(r), 3)
# Ignore larger
s.size_threshold = 0
s.large_size_threshold = large_size
r = s.get_dupe_groups(f)
eq_(len(r), 3)
# Ignore both
s.size_threshold = small_size
r = s.get_dupe_groups(f)
eq_(len(r), 2)
def test_big_file_partial_hashes(fake_fileexists):
s = Scanner()
s.scan_type = ScanType.Contents
s.scan_type = ScanType.CONTENTS
smallsize = 1
bigsize = 100 * 1024 * 1024 # 100MB
@@ -173,7 +219,7 @@ def test_big_file_partial_hashes(fake_fileexists):
def test_min_match_perc_doesnt_matter_for_content_scan(fake_fileexists):
s = Scanner()
s.scan_type = ScanType.Contents
s.scan_type = ScanType.CONTENTS
f = [no("foo"), no("bar"), no("bleh")]
f[0].md5 = f[0].md5partial = f[0].md5samples = "foobar"
f[1].md5 = f[1].md5partial = f[1].md5samples = "foobar"
@@ -190,7 +236,7 @@ def test_min_match_perc_doesnt_matter_for_content_scan(fake_fileexists):
def test_content_scan_doesnt_put_md5_in_words_at_the_end(fake_fileexists):
s = Scanner()
s.scan_type = ScanType.Contents
s.scan_type = ScanType.CONTENTS
f = [no("foo"), no("bar")]
f[0].md5 = f[0].md5partial = f[0].md5samples = "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f"
f[1].md5 = f[1].md5partial = f[1].md5samples = "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f"
@@ -256,7 +302,7 @@ def test_similar_words(fake_fileexists):
def test_fields(fake_fileexists):
s = Scanner()
s.scan_type = ScanType.Fields
s.scan_type = ScanType.FIELDS
f = [no("The White Stripes - Little Ghost"), no("The White Stripes - Little Acorn")]
r = s.get_dupe_groups(f)
eq_(len(r), 0)
@@ -264,7 +310,7 @@ def test_fields(fake_fileexists):
def test_fields_no_order(fake_fileexists):
s = Scanner()
s.scan_type = ScanType.FieldsNoOrder
s.scan_type = ScanType.FIELDSNOORDER
f = [no("The White Stripes - Little Ghost"), no("Little Ghost - The White Stripes")]
r = s.get_dupe_groups(f)
eq_(len(r), 1)
@@ -272,7 +318,7 @@ def test_fields_no_order(fake_fileexists):
def test_tag_scan(fake_fileexists):
s = Scanner()
s.scan_type = ScanType.Tag
s.scan_type = ScanType.TAG
o1 = no("foo")
o2 = no("bar")
o1.artist = "The White Stripes"
@@ -285,7 +331,7 @@ def test_tag_scan(fake_fileexists):
def test_tag_with_album_scan(fake_fileexists):
s = Scanner()
s.scan_type = ScanType.Tag
s.scan_type = ScanType.TAG
s.scanned_tags = set(["artist", "album", "title"])
o1 = no("foo")
o2 = no("bar")
@@ -305,7 +351,7 @@ def test_tag_with_album_scan(fake_fileexists):
def test_that_dash_in_tags_dont_create_new_fields(fake_fileexists):
s = Scanner()
s.scan_type = ScanType.Tag
s.scan_type = ScanType.TAG
s.scanned_tags = set(["artist", "album", "title"])
s.min_match_percentage = 50
o1 = no("foo")
@@ -322,7 +368,7 @@ def test_that_dash_in_tags_dont_create_new_fields(fake_fileexists):
def test_tag_scan_with_different_scanned(fake_fileexists):
s = Scanner()
s.scan_type = ScanType.Tag
s.scan_type = ScanType.TAG
s.scanned_tags = set(["track", "year"])
o1 = no("foo")
o2 = no("bar")
@@ -340,7 +386,7 @@ def test_tag_scan_with_different_scanned(fake_fileexists):
def test_tag_scan_only_scans_existing_tags(fake_fileexists):
s = Scanner()
s.scan_type = ScanType.Tag
s.scan_type = ScanType.TAG
s.scanned_tags = set(["artist", "foo"])
o1 = no("foo")
o2 = no("bar")
@@ -354,7 +400,7 @@ def test_tag_scan_only_scans_existing_tags(fake_fileexists):
def test_tag_scan_converts_to_str(fake_fileexists):
s = Scanner()
s.scan_type = ScanType.Tag
s.scan_type = ScanType.TAG
s.scanned_tags = set(["track"])
o1 = no("foo")
o2 = no("bar")
@@ -369,7 +415,7 @@ def test_tag_scan_converts_to_str(fake_fileexists):
def test_tag_scan_non_ascii(fake_fileexists):
s = Scanner()
s.scan_type = ScanType.Tag
s.scan_type = ScanType.TAG
s.scanned_tags = set(["title"])
o1 = no("foo")
o2 = no("bar")
@@ -391,8 +437,8 @@ def test_ignore_list(fake_fileexists):
f2.path = Path("dir2/foobar")
f3.path = Path("dir3/foobar")
ignore_list = IgnoreList()
ignore_list.Ignore(str(f1.path), str(f2.path))
ignore_list.Ignore(str(f1.path), str(f3.path))
ignore_list.ignore(str(f1.path), str(f2.path))
ignore_list.ignore(str(f1.path), str(f3.path))
r = s.get_dupe_groups([f1, f2, f3], ignore_list=ignore_list)
eq_(len(r), 1)
g = r[0]
@@ -415,8 +461,8 @@ def test_ignore_list_checks_for_unicode(fake_fileexists):
f2.path = Path("foo2\u00e9")
f3.path = Path("foo3\u00e9")
ignore_list = IgnoreList()
ignore_list.Ignore(str(f1.path), str(f2.path))
ignore_list.Ignore(str(f1.path), str(f3.path))
ignore_list.ignore(str(f1.path), str(f2.path))
ignore_list.ignore(str(f1.path), str(f3.path))
r = s.get_dupe_groups([f1, f2, f3], ignore_list=ignore_list)
eq_(len(r), 1)
g = r[0]
@@ -520,7 +566,7 @@ def test_dont_group_files_that_dont_exist(tmpdir):
# In this test, we have to delete one of the files between the get_matches() part and the
# get_groups() part.
s = Scanner()
s.scan_type = ScanType.Contents
s.scan_type = ScanType.CONTENTS
p = Path(str(tmpdir))
p["file1"].open("w").write("foo")
p["file2"].open("w").write("foo")
@@ -539,7 +585,7 @@ def test_folder_scan_exclude_subfolder_matches(fake_fileexists):
# when doing a Folders scan type, don't include matches for folders whose parent folder already
# match.
s = Scanner()
s.scan_type = ScanType.Folders
s.scan_type = ScanType.FOLDERS
topf1 = no("top folder 1", size=42)
topf1.md5 = topf1.md5partial = topf1.md5samples = b"some_md5_1"
topf1.path = Path("/topf1")
@@ -574,7 +620,7 @@ def test_dont_count_ref_files_as_discarded(fake_fileexists):
# However, this causes problems in "discarded" counting and we make sure here that we don't
# report discarded matches in exact duplicate scans.
s = Scanner()
s.scan_type = ScanType.Contents
s.scan_type = ScanType.CONTENTS
o1 = no("foo", path="p1")
o2 = no("foo", path="p2")
o3 = no("foo", path="p3")
@@ -587,8 +633,8 @@ def test_dont_count_ref_files_as_discarded(fake_fileexists):
eq_(s.discarded_file_count, 0)
def test_priorize_me(fake_fileexists):
# in ScannerME, bitrate goes first (right after is_ref) in priorization
def test_prioritize_me(fake_fileexists):
# in ScannerME, bitrate goes first (right after is_ref) in prioritization
s = ScannerME()
o1, o2 = no("foo", path="p1"), no("foo", path="p2")
o1.bitrate = 1

View File

@@ -5,6 +5,8 @@
# http://www.gnu.org/licenses/gpl-3.0.html
import time
import sys
import os
from hscommon.util import format_time_decimal
@@ -58,3 +60,7 @@ def fix_surrogate_encoding(s, encoding="utf-8"):
return s.encode(encoding, "replace").decode(encoding)
else:
return s
def executable_folder():
return os.path.dirname(os.path.abspath(sys.argv[0]))

View File

@@ -1,3 +1,42 @@
=== 4.2.1 (2022-03-25)
* Default to English on unsupported system language (#976)
* Fix image viewer zoom datatype issue (#978)
* Fix errors from window change event (#937, #980)
* Fix deprecation warning from SQLite
* Enforce minimum Windows version in installer (#983)
* Fix help path for local files
* Drop python 3.6 support
* VS Code project settings added, yaml validation for GitHub actions
=== 4.2.0 (2021-01-24)
* Add Malay and Turkish
* Add dark style for windows (#900)
* Add caching md5 file hashes (#942)
* Add feature to partially hash large files, with user adjustable preference (#908)
* Add portable mode (store settings next to executable)
* Add file association for .dupeguru files on windows
* Add ability to pass .dupeguru file to load on startup (#902)
* Add ability to reveal in explorer/finder (#895)
* Switch audio tag processing from hsaudiotag to mutagen (#440)
* Add ability to use Qt dialogs instead of native OS dialogs for some file selection operations
* Add OS and Python details to error dialog to assist in troubleshooting
* Add preference to ignore large files with threshold (#430)
* Fix error on close from DetailsPanel (#857, #873)
* Change reference background color (#894, #898)
* Remove stripping of unicode characters when matching names (#879)
* Fix exception when deleting in delta view (#863, #905)
* Fix dupes only view not updating after re-prioritize results (#757, #910, #911)
* Fix ability to drag'n'drop file/folder with certain characters in name (#897)
* Fix window position opening partially offscreen (#653)
* Fix TypeError is photo mode (#551)
* Change message for when files are deleted directly (#904)
* Add more feedback during scan (#700)
* Add Python version check to build.py (#589)
* General code cleanups
* Improvements to using standardized build tooling
* Moved CI/CD to github actions, added codeql, SonarCloud
=== 4.1.1 (2021-03-21)
* Add Japanese

View File

@@ -1,7 +1,7 @@
Häufig gestellte Fragen
==========================
.. topic:: What is |appname|?
.. topic:: What is dupeGuru?
.. only:: edition_se
@@ -25,7 +25,7 @@ Häufig gestellte Fragen
.. topic:: Was sind die Demo-Einschränkungen von dupeGuru?
Keine, |appname| ist `Fairware <http://open.hardcoded.net/about/>`_.
Keine, dupeGuru ist `Fairware <http://open.hardcoded.net/about/>`_.
.. topic:: Die Markierungsbox einer Datei, die ich löschen möchte, ist deaktiviert. Was muss ich tun?

View File

@@ -1,21 +1,13 @@
|appname| Hilfe
dupeGuru Hilfe
===============
.. only:: edition_se
Dieses Dokument ist auch auf `Englisch <http://www.hardcoded.net/dupeguru/help/en/>`__ und `Französisch <http://www.hardcoded.net/dupeguru/help/fr/>`__ verfügbar.
.. only:: edition_me
Dieses Dokument ist auch auf `Englisch <http://www.hardcoded.net/dupeguru/help/en/>`__ und `Französisch <http://www.hardcoded.net/dupeguru_me/help/fr/>`__ verfügbar.
.. only:: edition_pe
Dieses Dokument ist auch auf `Englisch <http://www.hardcoded.net/dupeguru/help/en/>`__ und `Französisch <http://www.hardcoded.net/dupeguru_pe/help/fr/>`__ verfügbar.
Dieses Dokument ist auch auf `Englisch <http://dupeguru.voltaicideas.net/help/en/>`__ und `Französisch <http://dupeguru.voltaicideas.net/help/fr/>`__ verfügbar.
.. only:: edition_se or edition_me
|appname| ist ein Tool zum Auffinden von Duplikaten auf Ihrem Computer. Es kann entweder Dateinamen oder Inhalte scannen. Der Dateiname-Scan stellt einen lockeren Suchalgorithmus zur Verfügung, der sogar Duplikate findet, die nicht den exakten selben Namen haben.
dupeGuru ist ein Tool zum Auffinden von Duplikaten auf Ihrem Computer. Es kann entweder Dateinamen oder Inhalte scannen. Der Dateiname-Scan stellt einen lockeren Suchalgorithmus zur Verfügung, der sogar Duplikate findet, die nicht den exakten selben Namen haben.
.. only:: edition_pe
@@ -23,7 +15,7 @@
Obwohl dupeGuru auch leicht ohne Dokumentation genutzt werden kann, ist es sinnvoll die Hilfe zu lesen. Wenn Sie nach einer Führung für den ersten Duplikatscan suchen, werfen Sie einen Blick auf die :doc:`Schnellstart <quick_start>` Sektion
Es ist eine gute Idee |appname| aktuell zu halten. Sie können die neueste Version auf der `homepage`_ finden.
Es ist eine gute Idee dupeGuru aktuell zu halten. Sie können die neueste Version auf der http://dupeguru.voltaicideas.net finden.
Inhalte:

View File

@@ -12,7 +12,7 @@ a community around this project.
So, whatever your skills, if you're interested in contributing to dupeGuru, please do so. Normally,
this documentation should be enough to get you started, but if it isn't, then **please**,
`let me know`_ because it's a problem that I'm committed to fix. If there's any situation where you'd
open a discussion at https://github.com/arsenetar/dupeguru/discussions. If there's any situation where you'd
wish to contribute but some doubt you're having prevent you from going forward, please contact me.
I'd much prefer to spend the time figuring out with you whether (and how) you can contribute than
taking the chance of missing that opportunity.
@@ -82,10 +82,9 @@ agree on what should be added to the documentation.
dupeGuru. For more information about how to do that, you can refer to the `translator guide`_.
.. _been open source: https://www.hardcoded.net/articles/free-as-in-speech-fair-as-in-trade
.. _let me know: mailto:hsoft@hardcoded.net
.. _Source code repository: https://github.com/hsoft/dupeguru
.. _Issue Tracker: https://github.com/hsoft/dupeguru/issues
.. _Issue labels meaning: https://github.com/hsoft/dupeguru/wiki/issue-labels
.. _Source code repository: https://github.com/arsenetar/dupeguru
.. _Issue Tracker: https://github.com/arsenetar/issues
.. _Issue labels meaning: https://github.com/arsenetar/wiki/issue-labels
.. _Sphinx: http://sphinx-doc.org/
.. _reST: http://en.wikipedia.org/wiki/ReStructuredText
.. _translator guide: https://github.com/hsoft/dupeguru/wiki/Translator-Guide
.. _translator guide: https://github.com/arsenetar/wiki/Translator-Guide

View File

@@ -1,12 +0,0 @@
hscommon.jobprogress.qt
=======================
.. automodule:: hscommon.jobprogress.qt
.. autosummary::
Progress
.. autoclass:: Progress
:members:

View File

@@ -151,8 +151,6 @@ delete files" option that is offered to you when you activate Send to Trash. Thi
files to the Trash, but delete them immediately. In some cases, for example on network storage
(NAS), this has been known to work when normal deletion didn't.
If this fail, `HS forums`_ might be of some help.
Why is Picture mode's contents scan so slow?
--------------------------------------------
@@ -178,7 +176,6 @@ Preferences are stored elsewhere:
* Linux: ``~/.config/Hardcoded Software/dupeGuru.conf``
* Mac OS X: In the built-in ``defaults`` system, as ``com.hardcoded-software.dupeguru``
.. _HS forums: https://forum.hardcoded.net/
.. _Github: https://github.com/hsoft/dupeguru
.. _open an issue: https://github.com/hsoft/dupeguru/wiki/issue-labels
.. _Github: https://github.com/arsenetar/dupeguru
.. _open an issue: https://github.com/arsenetar/dupeguru/wiki/issue-labels

View File

@@ -3,11 +3,11 @@ dupeGuru help
This help document is also available in these languages:
* `French <http://www.hardcoded.net/dupeguru/help/fr>`__
* `German <http://www.hardcoded.net/dupeguru/help/de>`__
* `Armenian <http://www.hardcoded.net/dupeguru/help/hy>`__
* `Russian <http://www.hardcoded.net/dupeguru/help/ru>`__
* `Ukrainian <http://www.hardcoded.net/dupeguru/help/uk>`__
* `French <http://dupeguru.voltaicideas.net/help/fr>`__
* `German <http://dupeguru.voltaicideas.net/help/de>`__
* `Armenian <http://dupeguru.voltaicideas.net/help/hy>`__
* `Russian <http://dupeguru.voltaicideas.net/help/ru>`__
* `Ukrainian <http://dupeguru.voltaicideas.net/help/uk>`__
dupeGuru is a tool to find duplicate files on your computer. It has three
modes, Standard, Music and Picture, with each mode having its own scan types
@@ -42,4 +42,4 @@ Indices and tables
* :ref:`genindex`
* :ref:`search`
.. _homepage: https://www.hardcoded.net/dupeguru
.. _homepage: https://dupeguru.voltaicideas.net/

View File

@@ -3,7 +3,7 @@ Foire aux questions
.. contents::
Qu'est-ce que |appname|?
Qu'est-ce que dupeGuru?
------------------------
.. only:: edition_se

View File

@@ -1,21 +1,13 @@
Aide |appname|
Aide dupeGuru
===============
.. only:: edition_se
Ce document est aussi disponible en `anglais <http://www.hardcoded.net/dupeguru/help/en/>`__, en `allemand <http://www.hardcoded.net/dupeguru/help/de/>`__ et en `arménien <http://www.hardcoded.net/dupeguru/help/hy/>`__.
.. only:: edition_me
Ce document est aussi disponible en `anglais <http://www.hardcoded.net/dupeguru_me/help/en/>`__, en `allemand <http://www.hardcoded.net/dupeguru_me/help/de/>`__ et en `arménien <http://www.hardcoded.net/dupeguru_me/help/hy/>`__.
.. only:: edition_pe
Ce document est aussi disponible en `anglais <http://www.hardcoded.net/dupeguru_pe/help/en/>`__, en `allemand <http://www.hardcoded.net/dupeguru_pe/help/de/>`__ et en `arménien <http://www.hardcoded.net/dupeguru_pe/help/hy/>`__.
Ce document est aussi disponible en `anglais <http://dupeguru.voltaicideas.net/help/en/>`__, en `allemand <http://dupeguru.voltaicideas.net/help/de/>`__ et en `arménien <http://dupeguru.voltaicideas.net/help/hy/>`__.
.. only:: edition_se or edition_me
|appname| est un outil pour trouver des doublons parmi vos fichiers. Il peut comparer soit les noms de fichiers, soit le contenu. Le comparateur de nom de fichier peut trouver des doublons même si les noms ne sont pas exactement pareils.
dupeGuru est un outil pour trouver des doublons parmi vos fichiers. Il peut comparer soit les noms de fichiers, soit le contenu. Le comparateur de nom de fichier peut trouver des doublons même si les noms ne sont pas exactement pareils.
.. only:: edition_pe
@@ -23,7 +15,7 @@ Aide |appname|
Bien que dupeGuru puisse être utilisé sans lire l'aide, une telle lecture vous permettra de bien comprendre comment l'application fonctionne. Pour un guide rapide pour une première utilisation, référez vous à la section :doc:`Démarrage Rapide <quick_start>`.
C'est toujours une bonne idée de garder |appname| à jour. Vous pouvez télécharger la dernière version sur sa `page web`_.
C'est toujours une bonne idée de garder dupeGuru à jour. Vous pouvez télécharger la dernière version sur sa http://dupeguru.voltaicideas.net.
Contents:

View File

@@ -1,7 +1,7 @@
Հաճախ Տրվող Հարցեր
==========================
.. topic:: Ի՞նչ է |appname|-ը:
.. topic:: Ի՞նչ է dupeGuru-ը:
.. only:: edition_se

View File

@@ -1,21 +1,13 @@
|appname| help
dupeGuru help
===============
.. only:: edition_se
Այս փաստաթուղթը հասանելի է նաև՝ `Ֆրանսերեն <http://www.hardcoded.net/dupeguru/help/fr/>`__ և `Գերմաներեն <http://www.hardcoded.net/dupeguru/help/de/>`__.
.. only:: edition_me
Այս փաստաթուղթը հասանելի է նաև՝ `Ֆրանսերեն <http://www.hardcoded.net/dupeguru_me/help/fr/>`__ և `Գերմաներեն <http://www.hardcoded.net/dupeguru_me/help/de/>`__.
.. only:: edition_pe
Այս փաստաթուղթը հասանելի է նաև՝ `Ֆրանսերեն <http://www.hardcoded.net/dupeguru_pe/help/fr/>`__ և `Գերմաներեն <http://www.hardcoded.net/dupeguru_pe/help/de/>`__.
Այս փաստաթուղթը հասանելի է նաև՝ `Ֆրանսերեն <http://dupeguru.voltaicideas.net/help/fr/>`__ և `Գերմաներեն <http://dupeguru.voltaicideas.net/help/de/>`__.
.. only:: edition_se or edition_me
|appname| ծրագիր է՝ գտնելու կրկնօրինակ ունեցող ֆայլեր Ձեր համակարգչում: Այն կարող է անգամ ստուգել ֆայլի անունները կան բովանդակությունը: Ֆայլի անվան ստուգման հնարավորությունները ոչ ճշգրիտ համընկման ալգորիթմով, որը կարող է գտնել ֆայլի անվան կրկնօրինակներ, անգամ եթե դրանք նույնը չեն:
dupeGuru ծրագիր է՝ գտնելու կրկնօրինակ ունեցող ֆայլեր Ձեր համակարգչում: Այն կարող է անգամ ստուգել ֆայլի անունները կան բովանդակությունը: Ֆայլի անվան ստուգման հնարավորությունները ոչ ճշգրիտ համընկման ալգորիթմով, որը կարող է գտնել ֆայլի անվան կրկնօրինակներ, անգամ եթե դրանք նույնը չեն:
.. only:: edition_pe
@@ -23,7 +15,7 @@
Չնայած dupeGuru-ն կարող է հեշտությամբ օգտագործվել առանց օգնության, այնուհանդերձ եթե կարդաք այս ֆայլը, այն մեծապես կօգնի Ձեզ ընկալելու ծրագրի աշխատանքը: Եթե Դուք նայում եք ձեռնարկը կրկնօրինակների առաջին ստուգման համար, ապա կարող եք ընտրել :doc:`Արագ Սկիզբ <quick_start>` հատվածը:
Շատ լավ միտք է պահելու |appname| թարմացված: Կարող եք բեռնել վեբ կայքի համապատասխան էջից `homepage`_:
Շատ լավ միտք է պահելու dupeGuru թարմացված: Կարող եք բեռնել վեբ կայքի համապատասխան էջից http://dupeguru.voltaicideas.net:
Պարունակությունը.

View File

@@ -1,7 +1,7 @@
Часто задаваемые вопросы
==========================
.. topic:: Что такое |appname|?
.. topic:: Что такое dupeGuru?
.. only:: edition_se

View File

@@ -1,21 +1,11 @@
|appname| help
dupeGuru help
===============
.. only:: edition_se
Этот документ также доступна на `французском <http://www.hardcoded.net/dupeguru/help/fr/>`__, `немецком <http://www.hardcoded.net/dupeguru/help/de/>`__ и `армянский <http://www.hardcoded.net/dupeguru/help/hy/>`__.
.. only:: edition_me
Этот документ также доступна на `французском <http://www.hardcoded.net/dupeguru_me/help/fr/>`__, `немецкий <http://www.hardcoded.net/dupeguru_me/help/de/>`__ и `армянский <http://www.hardcoded.net/dupeguru_me/help/hy/>`__.
.. only:: edition_pe
Этот документ также доступна на `французском <http://www.hardcoded.net/dupeguru_pe/help/fr/>`__, `немецкий <http://www.hardcoded.net/dupeguru_pe/help/de/>`__ и `армянский <http://www.hardcoded.net/dupeguru_pe/help/hy/>`__.
Этот документ также доступна на `французском <http://dupeguru.voltaicideas.net/help/fr/>`__, `немецком <http://dupeguru.voltaicideas.net/help/de/>`__ и `армянский <http://dupeguru.voltaicideas.net/help/hy/>`__.
.. only:: edition_se or edition_me
|appname| есть инструмент для поиска дубликатов файлов на вашем компьютере. Он может сканировать либо имен файлов или содержимого.Имя файла функций сканирования нечеткого соответствия алгоритма, который позволяет найти одинаковые имена файлов, даже если они не совсем то же самое.
dupeGuru есть инструмент для поиска дубликатов файлов на вашем компьютере. Он может сканировать либо имен файлов или содержимого.Имя файла функций сканирования нечеткого соответствия алгоритма, который позволяет найти одинаковые имена файлов, даже если они не совсем то же самое.
.. only:: edition_pe
@@ -23,7 +13,7 @@
Хотя dupeGuru может быть легко использована без документации, чтение этого файла поможет вам освоить его. Если вы ищете руководство для вашей первой дублировать сканирования, вы можете взглянуть на раздел :doc:`Быстрый <quick_start>` Начало.
Это хорошая идея, чтобы сохранить |appname| обновлен. Вы можете скачать последнюю версию на своей `homepage`_.
Это хорошая идея, чтобы сохранить dupeGuru обновлен. Вы можете скачать последнюю версию на своей http://dupeguru.voltaicideas.net.
Содержание:
.. toctree::

View File

@@ -1,7 +1,7 @@
Часті питання
==========================
.. topic:: Що таке |appname|?
.. topic:: Що таке dupeGuru?
.. only:: edition_se

View File

@@ -1,21 +1,13 @@
|appname| help
dupeGuru help
===============
.. only:: edition_se
Цей документ також доступна на `французькому <http://www.hardcoded.net/dupeguru/help/fr/>`__, `німецький <http://www.hardcoded.net/dupeguru/help/de/>`__ і `Вірменський <http://www.hardcoded.net/dupeguru/help/hy/>`__.
.. only:: edition_me
Цей документ також доступна на `французькому <http://www.hardcoded.net/dupeguru_me/help/fr/>`__, `німецький <http://www.hardcoded.net/dupeguru_me/help/de/>`__ і `Вірменський <http://www.hardcoded.net/dupeguru_me/help/hy/>`__.
.. only:: edition_pe
Цей документ також доступна на `французькому <http://www.hardcoded.net/dupeguru_pe/help/fr/>`__, `німецький <http://www.hardcoded.net/dupeguru_pe/help/de/>`__ і `Вірменський <http://www.hardcoded.net/dupeguru_pe/help/hy/>`__.
Цей документ також доступна на `французькому <http://dupeguru.voltaicideas.net/help/fr/>`__, `німецький <http://dupeguru.voltaicideas.net/help/de/>`__ і `Вірменський <http://dupeguru.voltaicideas.net/help/hy/>`__.
.. only:: edition_se or edition_me
|appname| це інструмент для пошуку дублікатів файлів на вашому комп'ютері. Він може сканувати або імен файлів або вмісту. Файл функцій сканування нечіткого відповідності алгоритму, який дозволяє знайти однакові імена файлів, навіть якщо вони не зовсім те ж саме.
dupeGuru це інструмент для пошуку дублікатів файлів на вашому комп'ютері. Він може сканувати або імен файлів або вмісту. Файл функцій сканування нечіткого відповідності алгоритму, який дозволяє знайти однакові імена файлів, навіть якщо вони не зовсім те ж саме.
.. only:: edition_pe
@@ -23,7 +15,7 @@
Хоча dupeGuru може бути легко використана без документації, читання цього файлу допоможе вам освоїти його. Якщо ви шукаєте керівництво для вашої першої дублювати сканування, ви можете поглянути на: :doc:`Quick Start <quick_start>`
Це гарна ідея, щоб зберегти |appname| оновлено. Ви можете завантажити останню версію на своєму `homepage`_.
Це гарна ідея, щоб зберегти dupeGuru оновлено. Ви можете завантажити останню версію на своєму http://dupeguru.voltaicideas.net.
Contents:

View File

@@ -336,7 +336,6 @@ def read_changelog_file(filename):
with open(filename, "rt", encoding="utf-8") as fp:
contents = fp.read()
splitted = re_changelog_header.split(contents)[1:] # the first item is empty
# splitted = [version1, date1, desc1, version2, date2, ...]
result = []
for version, date_str, description in iter_by_three(iter(splitted)):
date = datetime.strptime(date_str, "%Y-%m-%d").date()
@@ -399,8 +398,8 @@ def create_osx_app_structure(
# `resources`: A list of paths of files or folders going in the "Resources" folder.
# `frameworks`: Same as above for "Frameworks".
# `symlink_resources`: If True, will symlink resources into the structure instead of copying them.
app = OSXAppStructure(dest, infoplist)
app.create()
app = OSXAppStructure(dest)
app.create(infoplist)
app.copy_executable(executable)
app.copy_resources(*resources, use_symlinks=symlink_resources)
app.copy_frameworks(*frameworks)

View File

@@ -13,8 +13,8 @@ import traceback
# Taken from http://bzimmer.ziclix.com/2008/12/17/python-thread-dumps/
def stacktraces():
code = []
for threadId, stack in sys._current_frames().items():
code.append("\n# ThreadID: %s" % threadId)
for thread_id, stack in sys._current_frames().items():
code.append("\n# ThreadID: %s" % thread_id)
for filename, lineno, name, line in traceback.extract_stack(stack):
code.append('File: "%s", line %d, in %s' % (filename, lineno, name))
if line:

View File

@@ -11,8 +11,8 @@ import logging
class SpecialFolder:
AppData = 1
Cache = 2
APPDATA = 1
CACHE = 2
def open_url(url):
@@ -30,7 +30,7 @@ def reveal_path(path):
_reveal_path(str(path))
def special_folder_path(special_folder, appname=None):
def special_folder_path(special_folder, appname=None, portable=False):
"""Returns the path of ``special_folder``.
``special_folder`` is a SpecialFolder.* const. The result is the special folder for the current
@@ -38,7 +38,7 @@ def special_folder_path(special_folder, appname=None):
You can override the application name with ``appname``. This argument is ingored under Qt.
"""
return _special_folder_path(special_folder, appname)
return _special_folder_path(special_folder, appname, portable=portable)
try:
@@ -54,8 +54,8 @@ try:
_open_path = proxy.openPath_
_reveal_path = proxy.revealPath_
def _special_folder_path(special_folder, appname=None):
if special_folder == SpecialFolder.Cache:
def _special_folder_path(special_folder, appname=None, portable=False):
if special_folder == SpecialFolder.CACHE:
base = proxy.getCachePath()
else:
base = proxy.getAppdataPath()
@@ -63,11 +63,14 @@ try:
appname = proxy.bundleInfo_("CFBundleName")
return op.join(base, appname)
except ImportError:
try:
from PyQt5.QtCore import QUrl, QStandardPaths
from PyQt5.QtGui import QDesktopServices
from qtlib.util import get_appdata
from core.util import executable_folder
from hscommon.plat import ISWINDOWS, ISOSX
import subprocess
def _open_url(url):
QDesktopServices.openUrl(QUrl(url))
@@ -77,14 +80,22 @@ except ImportError:
QDesktopServices.openUrl(url)
def _reveal_path(path):
_open_path(op.dirname(str(path)))
def _special_folder_path(special_folder, appname=None):
if special_folder == SpecialFolder.Cache:
qtfolder = QStandardPaths.CacheLocation
if ISWINDOWS:
subprocess.run(["explorer", "/select,", op.abspath(path)])
elif ISOSX:
subprocess.run(["open", "-R", op.abspath(path)])
else:
qtfolder = QStandardPaths.DataLocation
return QStandardPaths.standardLocations(qtfolder)[0]
_open_path(op.dirname(str(path)))
def _special_folder_path(special_folder, appname=None, portable=False):
if special_folder == SpecialFolder.CACHE:
if ISWINDOWS and portable:
folder = op.join(executable_folder(), "cache")
else:
folder = QStandardPaths.standardLocations(QStandardPaths.CacheLocation)[0]
else:
folder = get_appdata(portable)
return folder
except ImportError:
# We're either running tests, and these functions don't matter much or we're in a really
@@ -92,10 +103,12 @@ except ImportError:
logging.warning("Can't setup desktop functions!")
def _open_path(path):
# Dummy for tests
pass
def _reveal_path(path):
# Dummy for tests
pass
def _special_folder_path(special_folder, appname=None):
def _special_folder_path(special_folder, appname=None, portable=False):
return "/tmp"

View File

@@ -139,31 +139,34 @@ class Job:
self._progress = progress
if self._progress > self._currmax:
self._progress = self._currmax
if self._progress < 0:
self._progress = 0
self._do_update(desc)
class NullJob:
def __init__(self, *args, **kwargs):
# Null job does nothing
pass
def add_progress(self, *args, **kwargs):
# Null job does nothing
pass
def check_if_cancelled(self):
# Null job does nothing
pass
def iter_with_progress(self, sequence, *args, **kwargs):
return iter(sequence)
def start_job(self, *args, **kwargs):
# Null job does nothing
pass
def start_subjob(self, *args, **kwargs):
return NullJob()
def set_progress(self, *args, **kwargs):
# Null job does nothing
pass

View File

@@ -1,52 +0,0 @@
# Created By: Virgil Dupras
# Created On: 2009-09-14
# Copyright 2011 Hardcoded Software (http://www.hardcoded.net)
#
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
from PyQt5.QtCore import pyqtSignal, Qt, QTimer
from PyQt5.QtWidgets import QProgressDialog
from . import performer
class Progress(QProgressDialog, performer.ThreadedJobPerformer):
finished = pyqtSignal(["QString"])
def __init__(self, parent):
flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint
QProgressDialog.__init__(self, "", "Cancel", 0, 100, parent, flags)
self.setModal(True)
self.setAutoReset(False)
self.setAutoClose(False)
self._timer = QTimer()
self._jobid = ""
self._timer.timeout.connect(self.updateProgress)
def updateProgress(self):
# the values might change before setValue happens
last_progress = self.last_progress
last_desc = self.last_desc
if not self._job_running or last_progress is None:
self._timer.stop()
self.close()
if not self.job_cancelled:
self.finished.emit(self._jobid)
return
if self.wasCanceled():
self.job_cancelled = True
return
if last_desc:
self.setLabelText(last_desc)
self.setValue(last_progress)
def run(self, jobid, title, target, args=()):
self._jobid = jobid
self.reset()
self.setLabelText("")
self.run_threaded(target, args)
self.setWindowTitle(title)
self.show()
self._timer.start(500)

View File

@@ -21,6 +21,8 @@ PO2COCOA = {
COCOA2PO = {v: k for k, v in PO2COCOA.items()}
STRING_EXT = ".strings"
def get_langs(folder):
return [name for name in os.listdir(folder) if op.isdir(op.join(folder, name))]
@@ -152,7 +154,7 @@ def strings2pot(target, dest):
def allstrings2pot(lprojpath, dest, excludes=None):
allstrings = files_with_ext(lprojpath, ".strings")
allstrings = files_with_ext(lprojpath, STRING_EXT)
if excludes:
allstrings = [p for p in allstrings if op.splitext(op.basename(p))[0] not in excludes]
for strings_path in allstrings:
@@ -210,7 +212,7 @@ def generate_cocoa_strings_from_code(code_folder, dest_folder):
def generate_cocoa_strings_from_xib(xib_folder):
xibs = [op.join(xib_folder, fn) for fn in os.listdir(xib_folder) if fn.endswith(".xib")]
for xib in xibs:
dest = xib.replace(".xib", ".strings")
dest = xib.replace(".xib", STRING_EXT)
print_and_do("ibtool {} --generate-strings-file {}".format(xib, dest))
print_and_do("iconv -f utf-16 -t utf-8 {0} | tee {0}".format(dest))
@@ -226,6 +228,6 @@ def localize_stringsfile(stringsfile, dest_root_folder):
def localize_all_stringsfiles(src_folder, dest_root_folder):
stringsfiles = [op.join(src_folder, fn) for fn in os.listdir(src_folder) if fn.endswith(".strings")]
stringsfiles = [op.join(src_folder, fn) for fn in os.listdir(src_folder) if fn.endswith(STRING_EXT)]
for path in stringsfiles:
localize_stringsfile(path, dest_root_folder)

View File

@@ -167,10 +167,10 @@ def getFilesForName(name):
# check for glob chars
if containsAny(name, "*?[]"):
files = glob.glob(name)
list = []
file_list = []
for file in files:
list.extend(getFilesForName(file))
return list
file_list.extend(getFilesForName(file))
return file_list
# try to find module or package
name = _get_modpkg_path(name)
@@ -179,9 +179,9 @@ def getFilesForName(name):
if os.path.isdir(name):
# find all python files in directory
list = []
os.walk(name, _visit_pyfiles, list)
return list
file_list = []
os.walk(name, _visit_pyfiles, file_list)
return file_list
elif os.path.exists(name):
# a single file
return [name]

View File

@@ -4,7 +4,7 @@
# which should be included with this package. The terms are also available at
# http://www.gnu.org/licenses/gpl-3.0.html
import os.path as op
from pathlib import Path
import re
from .build import read_changelog_file, filereplace
@@ -48,9 +48,9 @@ def gen(
if confrepl is None:
confrepl = {}
if confpath is None:
confpath = op.join(basepath, "conf.tmpl")
confpath = Path(basepath, "conf.tmpl")
if changelogtmpl is None:
changelogtmpl = op.join(basepath, "changelog.tmpl")
changelogtmpl = Path(basepath, "changelog.tmpl")
changelog = read_changelog_file(changelogpath)
tix = tixgen(tixurl)
rendered_logs = []
@@ -62,13 +62,13 @@ def gen(
rendered = CHANGELOG_FORMAT.format(version=log["version"], date=log["date_str"], description=description)
rendered_logs.append(rendered)
confrepl["version"] = changelog[0]["version"]
changelog_out = op.join(basepath, "changelog.rst")
changelog_out = Path(basepath, "changelog.rst")
filereplace(changelogtmpl, changelog_out, changelog="\n".join(rendered_logs))
if op.exists(confpath):
conf_out = op.join(basepath, "conf.py")
if Path(confpath).exists():
conf_out = Path(basepath, "conf.py")
filereplace(confpath, conf_out, **confrepl)
# Call the sphinx_build function, which is the same as doing sphinx-build from cli
try:
sphinx_build([basepath, destpath])
sphinx_build([str(basepath), str(destpath)])
except SystemExit:
print("Sphinx called sys.exit(), but we're cancelling it because we don't actually want to exit")

View File

@@ -45,7 +45,7 @@ class _ActualThread(threading.Thread):
self._lock = threading.Lock()
self._run = True
self.lastrowid = -1
self.setDaemon(True)
self.daemon = True
self.start()
def _query(self, query):

View File

@@ -19,7 +19,7 @@ from ..path import Path
from ..testutil import eq_
class TestCase_GetConflictedName:
class TestCaseGetConflictedName:
def test_simple(self):
name = get_conflicted_name(["bar"], "bar")
eq_("[000] bar", name)
@@ -46,7 +46,7 @@ class TestCase_GetConflictedName:
eq_("[000] bar", name)
class TestCase_GetUnconflictedName:
class TestCaseGetUnconflictedName:
def test_main(self):
eq_("foobar", get_unconflicted_name("[000] foobar"))
eq_("foobar", get_unconflicted_name("[9999] foobar"))
@@ -56,7 +56,7 @@ class TestCase_GetUnconflictedName:
eq_("foo [000] bar", get_unconflicted_name("foo [000] bar"))
class TestCase_IsConflicted:
class TestCaseIsConflicted:
def test_main(self):
assert is_conflicted("[000] foobar")
assert is_conflicted("[9999] foobar")
@@ -66,7 +66,7 @@ class TestCase_IsConflicted:
assert not is_conflicted("foo [000] bar")
class TestCase_move_copy:
class TestCaseMoveCopy:
@pytest.fixture
def do_setup(self, request):
tmpdir = request.getfixturevalue("tmpdir")

View File

@@ -51,7 +51,7 @@ def test_init_with_tuple_and_list(force_ossep):
def test_init_with_invalid_value(force_ossep):
try:
path = Path(42) # noqa: F841
Path(42)
assert False
except TypeError:
pass
@@ -142,8 +142,6 @@ def test_path_slice(force_ossep):
eq_((), foobar[:foobar])
abcd = Path("a/b/c/d")
a = Path("a")
b = Path("b") # noqa: #F841
c = Path("c") # noqa: #F841
d = Path("d")
z = Path("z")
eq_("b/c", abcd[a:d])
@@ -216,7 +214,7 @@ def test_str_repr_of_mix_between_non_ascii_str_and_unicode(force_ossep):
eq_("foo\u00e9/bar".encode(sys.getfilesystemencoding()), p.tobytes())
def test_Path_of_a_Path_returns_self(force_ossep):
def test_path_of_a_path_returns_self(force_ossep):
# if Path() is called with a path as value, just return value.
p = Path("foo/bar")
assert Path(p) is p

View File

@@ -91,7 +91,7 @@ def test_make_sure_theres_no_messup_between_queries():
threads = []
for i in range(1, 101):
t = threading.Thread(target=run, args=(i,))
t.start
t.start()
threads.append(t)
while threads:
time.sleep(0.1)

View File

@@ -19,6 +19,7 @@ class TestRow(Row):
self._index = index
def load(self):
# Does nothing for test
pass
def save(self):
@@ -75,14 +76,17 @@ def test_allow_edit_when_attr_is_property_with_fset():
class TestRow(Row):
@property
def foo(self):
# property only for existence checks
pass
@property
def bar(self):
# property only for existence checks
pass
@bar.setter
def bar(self, value):
# setter only for existence checks
pass
row = TestRow(Table())
@@ -97,10 +101,12 @@ def test_can_edit_prop_has_priority_over_fset_checks():
class TestRow(Row):
@property
def bar(self):
# property only for existence checks
pass
@bar.setter
def bar(self, value):
# setter only for existence checks
pass
can_edit_bar = False

View File

@@ -236,49 +236,8 @@ def test_multi_replace():
# --- Files
# These test cases needed https://github.com/hsoft/pytest-monkeyplus/ which appears to not be compatible with latest
# pytest, looking at where this is used only appears to be in hscommon.localize_all_stringfiles at top level.
# Right now this repo does not seem to utilize any of that functionality so going to leave these tests out for now.
# TODO decide if fixing these tests is worth it or not.
# class TestCase_modified_after:
# def test_first_is_modified_after(self, monkeyplus):
# monkeyplus.patch_osstat("first", st_mtime=42)
# monkeyplus.patch_osstat("second", st_mtime=41)
# assert modified_after("first", "second")
# def test_second_is_modified_after(self, monkeyplus):
# monkeyplus.patch_osstat("first", st_mtime=42)
# monkeyplus.patch_osstat("second", st_mtime=43)
# assert not modified_after("first", "second")
# def test_same_mtime(self, monkeyplus):
# monkeyplus.patch_osstat("first", st_mtime=42)
# monkeyplus.patch_osstat("second", st_mtime=42)
# assert not modified_after("first", "second")
# def test_first_file_does_not_exist(self, monkeyplus):
# # when the first file doesn't exist, we return False
# monkeyplus.patch_osstat("second", st_mtime=42)
# assert not modified_after("does_not_exist", "second") # no crash
# def test_second_file_does_not_exist(self, monkeyplus):
# # when the second file doesn't exist, we return True
# monkeyplus.patch_osstat("first", st_mtime=42)
# assert modified_after("first", "does_not_exist") # no crash
# def test_first_file_is_none(self, monkeyplus):
# # when the first file is None, we return False
# monkeyplus.patch_osstat("second", st_mtime=42)
# assert not modified_after(None, "second") # no crash
# def test_second_file_is_none(self, monkeyplus):
# # when the second file is None, we return True
# monkeyplus.patch_osstat("first", st_mtime=42)
# assert modified_after("first", None) # no crash
class TestCase_delete_if_empty:
class TestCaseDeleteIfEmpty:
def test_is_empty(self, tmpdir):
testpath = Path(str(tmpdir))
assert delete_if_empty(testpath)
@@ -330,9 +289,11 @@ class TestCase_delete_if_empty:
delete_if_empty(Path(str(tmpdir))) # no crash
class TestCase_open_if_filename:
class TestCaseOpenIfFilename:
FILE_NAME = "test.txt"
def test_file_name(self, tmpdir):
filepath = str(tmpdir.join("test.txt"))
filepath = str(tmpdir.join(self.FILE_NAME))
open(filepath, "wb").write(b"test_data")
file, close = open_if_filename(filepath)
assert close
@@ -348,16 +309,18 @@ class TestCase_open_if_filename:
eq_("test_data", file.read())
def test_mode_is_passed_to_open(self, tmpdir):
filepath = str(tmpdir.join("test.txt"))
filepath = str(tmpdir.join(self.FILE_NAME))
open(filepath, "w").close()
file, close = open_if_filename(filepath, "a")
eq_("a", file.mode)
file.close()
class TestCase_FileOrPath:
class TestCaseFileOrPath:
FILE_NAME = "test.txt"
def test_path(self, tmpdir):
filepath = str(tmpdir.join("test.txt"))
filepath = str(tmpdir.join(self.FILE_NAME))
open(filepath, "wb").write(b"test_data")
with FileOrPath(filepath) as fp:
eq_(b"test_data", fp.read())
@@ -370,7 +333,7 @@ class TestCase_FileOrPath:
eq_("test_data", fp.read())
def test_mode_is_passed_to_open(self, tmpdir):
filepath = str(tmpdir.join("test.txt"))
filepath = str(tmpdir.join(self.FILE_NAME))
open(filepath, "w").close()
with FileOrPath(filepath, "a") as fp:
eq_("a", fp.mode)

View File

@@ -230,8 +230,8 @@ def log_calls(func):
"""
def wrapper(*args, **kwargs):
unifiedArgs = _unify_args(func, args, kwargs)
wrapper.calls.append(unifiedArgs)
unified_args = _unify_args(func, args, kwargs)
wrapper.calls.append(unified_args)
return func(*args, **kwargs)
wrapper.calls = []

View File

@@ -58,6 +58,7 @@ def get_locale_name(lang):
"it": "it_IT",
"ja": "ja_JP",
"ko": "ko_KR",
"ms": "ms_MY",
"nl": "nl_NL",
"pl_PL": "pl_PL",
"pt_BR": "pt_BR",
@@ -131,11 +132,11 @@ def install_gettext_trans(base_folder, lang):
def install_gettext_trans_under_cocoa():
from cocoa import proxy
resFolder = proxy.getResourcePath()
baseFolder = op.join(resFolder, "locale")
currentLang = proxy.systemLang()
install_gettext_trans(baseFolder, currentLang)
localename = get_locale_name(currentLang)
res_folder = proxy.getResourcePath()
base_folder = op.join(res_folder, "locale")
current_lang = proxy.systemLang()
install_gettext_trans(base_folder, current_lang)
localename = get_locale_name(current_lang)
if localename is not None:
locale.setlocale(locale.LC_ALL, localename)
@@ -149,11 +150,13 @@ def install_gettext_trans_under_qt(base_folder, lang=None):
if not lang:
lang = str(QLocale.system().name())[:2]
localename = get_locale_name(lang)
if localename is not None:
try:
locale.setlocale(locale.LC_ALL, localename)
except locale.Error:
logging.warning("Couldn't set locale %s", localename)
if localename is None:
lang = "en"
localename = get_locale_name(lang)
try:
locale.setlocale(locale.LC_ALL, localename)
except locale.Error:
logging.warning("Couldn't set locale %s", localename)
qmname = "qt_%s" % lang
if ISLINUX:
# Under linux, a full Qt installation is already available in the system, we didn't bundle

View File

@@ -177,13 +177,13 @@ def pluralize(number, word, decimals=0, plural_word=None):
``plural_word``: If the plural rule for word is more complex than adding a 's', specify a plural
"""
number = round(number, decimals)
format = "%%1.%df %%s" % decimals
plural_format = "%%1.%df %%s" % decimals
if number > 1:
if plural_word is None:
word += "s"
else:
word = plural_word
return format % (number, word)
return plural_format % (number, word)
def format_time(seconds, with_hours=True):
@@ -226,7 +226,7 @@ def format_time_decimal(seconds):
SIZE_DESC = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
SIZE_VALS = tuple(1024 ** i for i in range(1, 9))
SIZE_VALS = tuple(1024**i for i in range(1, 9))
def format_size(size, decimal=0, forcepower=-1, showdesc=True):
@@ -252,16 +252,16 @@ def format_size(size, decimal=0, forcepower=-1, showdesc=True):
div = SIZE_VALS[i - 1]
else:
div = 1
format = "%%%d.%df" % (decimal, decimal)
size_format = "%%%d.%df" % (decimal, decimal)
negative = size < 0
divided_size = (0.0 + abs(size)) / div
if decimal == 0:
divided_size = ceil(divided_size)
else:
divided_size = ceil(divided_size * (10 ** decimal)) / (10 ** decimal)
divided_size = ceil(divided_size * (10**decimal)) / (10**decimal)
if negative:
divided_size *= -1
result = format % divided_size
result = size_format % divided_size
if showdesc:
result += " " + SIZE_DESC[i]
return result
@@ -292,7 +292,7 @@ def multi_replace(s, replace_from, replace_to=""):
the same length as ``replace_from``, it will be transformed into a list.
"""
if isinstance(replace_to, str) and (len(replace_from) != len(replace_to)):
replace_to = [replace_to for r in replace_from]
replace_to = [replace_to for _ in replace_from]
if len(replace_from) != len(replace_to):
raise ValueError("len(replace_from) must be equal to len(replace_to)")
replace = list(zip(replace_from, replace_to))

View File

@@ -36,95 +36,103 @@ msgstr ""
msgid "Sending to Trash"
msgstr ""
#: core\app.py:308
#: core\app.py:289
msgid "A previous action is still hanging in there. You can't start a new one yet. Wait a few seconds, then try again."
msgstr ""
#: core\app.py:318
#: core\app.py:300
msgid "No duplicates found."
msgstr ""
#: core\app.py:333
#: core\app.py:315
msgid "All marked files were copied successfully."
msgstr ""
#: core\app.py:334
#: core\app.py:317
msgid "All marked files were moved successfully."
msgstr ""
#: core\app.py:335
#: core\app.py:319
msgid "All marked files were deleted successfully."
msgstr ""
#: core\app.py:321
msgid "All marked files were successfully sent to Trash."
msgstr ""
#: core\app.py:343
#: core\app.py:326
msgid "Could not load file: {}"
msgstr ""
#: core\app.py:399
#: core\app.py:382
msgid "'{}' already is in the list."
msgstr ""
#: core\app.py:401
#: core\app.py:384
msgid "'{}' does not exist."
msgstr ""
#: core\app.py:410
#: core\app.py:392
msgid "All selected %d matches are going to be ignored in all subsequent scans. Continue?"
msgstr ""
#: core\app.py:486
#: core\app.py:469
msgid "Select a directory to copy marked files to"
msgstr ""
#: core\app.py:487
#: core\app.py:471
msgid "Select a directory to move marked files to"
msgstr ""
#: core\app.py:527
#: core\app.py:510
msgid "Select a destination for your exported CSV"
msgstr ""
#: core\app.py:534 core\app.py:803 core\app.py:813
#: core\app.py:516 core\app.py:771 core\app.py:781
msgid "Couldn't write to file: {}"
msgstr ""
#: core\app.py:559
#: core\app.py:539
msgid "You have no custom command set up. Set it up in your preferences."
msgstr ""
#: core\app.py:727 core\app.py:740
#: core\app.py:695 core\app.py:707
msgid "You are about to remove %d files from results. Continue?"
msgstr ""
#: core\app.py:776
#: core\app.py:743
msgid "{} duplicate groups were changed by the re-prioritization."
msgstr ""
#: core\app.py:823
#: core\app.py:790
msgid "The selected directories contain no scannable file."
msgstr ""
#: core\app.py:837
#: core\app.py:803
msgid "Collecting files to scan"
msgstr ""
#: core\app.py:893
#: core\app.py:850
msgid "%s (%d discarded)"
msgstr ""
#: core\engine.py:255 core\engine.py:299
msgid "0 matches found"
#: core\directories.py:191
msgid "Collected {} files to scan"
msgstr ""
#: core\engine.py:273 core\engine.py:307
msgid "%d matches found"
#: core\directories.py:207
msgid "Collected {} folders to scan"
msgstr ""
#: core\gui\deletion_options.py:73
#: core\engine.py:27
msgid "%d matches found from %d groups"
msgstr ""
#: core\gui\deletion_options.py:71
msgid "You are sending {} file(s) to the Trash."
msgstr ""
#: core\gui\exclude_list_table.py:15
#: core\gui\exclude_list_table.py:14
msgid "Regular Expressions"
msgstr ""
@@ -156,15 +164,15 @@ msgstr ""
msgid "Analyzed %d/%d pictures"
msgstr ""
#: core\pe\matchblock.py:181
#: core\pe\matchblock.py:177
msgid "Performed %d/%d chunk matches"
msgstr ""
#: core\pe\matchblock.py:191
#: core\pe\matchblock.py:185
msgid "Preparing for matching"
msgstr ""
#: core\pe\matchblock.py:244
#: core\pe\matchblock.py:234
msgid "Verified %d/%d matches"
msgstr ""
@@ -212,23 +220,23 @@ msgstr ""
msgid "Oldest"
msgstr ""
#: core\results.py:144
#: core\results.py:134
msgid "%d / %d (%s / %s) duplicates marked."
msgstr ""
#: core\results.py:151
#: core\results.py:141
msgid " filter: %s"
msgstr ""
#: core\scanner.py:85
#: core\scanner.py:90
msgid "Read size of %d/%d files"
msgstr ""
#: core\scanner.py:109
#: core\scanner.py:116
msgid "Read metadata of %d/%d files"
msgstr ""
#: core\scanner.py:147
#: core\scanner.py:154
msgid "Almost done! Fiddling with results..."
msgstr ""

View File

@@ -47,7 +47,7 @@ msgstr "Kopíruji"
msgid "Sending to Trash"
msgstr "Vyhazuji do koše"
#: core\app.py:308
#: core\app.py:289
msgid ""
"A previous action is still hanging in there. You can't start a new one yet. "
"Wait a few seconds, then try again."
@@ -55,35 +55,39 @@ msgstr ""
"Předchozí akce stále nebyla ukončena. Novou zatím nemůžete spustit. Počkejte"
" pár sekund a zkuste to znovu."
#: core\app.py:318
#: core\app.py:300
msgid "No duplicates found."
msgstr "Nebyli nalezeny žádné duplicity."
#: core\app.py:333
#: core\app.py:315
msgid "All marked files were copied successfully."
msgstr "Všechny označené soubory byly úspěšně zkopírovány."
#: core\app.py:334
#: core\app.py:317
msgid "All marked files were moved successfully."
msgstr "Všechny označené soubory byly úspěšně přesunuty."
#: core\app.py:335
#: core\app.py:319
msgid "All marked files were deleted successfully."
msgstr ""
#: core\app.py:321
msgid "All marked files were successfully sent to Trash."
msgstr "Všechny označené soubory byly úspěšně odeslány do koše."
#: core\app.py:343
#: core\app.py:326
msgid "Could not load file: {}"
msgstr "Soubor se nepodařilo načíst: {}"
#: core\app.py:399
#: core\app.py:382
msgid "'{}' already is in the list."
msgstr "'{}' již je v seznamu."
#: core\app.py:401
#: core\app.py:384
msgid "'{}' does not exist."
msgstr "'{}' neexistuje."
#: core\app.py:410
#: core\app.py:392
msgid ""
"All selected %d matches are going to be ignored in all subsequent scans. "
"Continue?"
@@ -91,60 +95,64 @@ msgstr ""
"Všech %d vybraných shod bude v následujících hledáních ignorováno. "
"Pokračovat?"
#: core\app.py:486
#: core\app.py:469
msgid "Select a directory to copy marked files to"
msgstr "Vyberte adresář, do kterého chcete zkopírovat označené soubory"
#: core\app.py:487
#: core\app.py:471
msgid "Select a directory to move marked files to"
msgstr "Vyberte adresář, kam chcete přesunout označené soubory"
#: core\app.py:527
#: core\app.py:510
msgid "Select a destination for your exported CSV"
msgstr "Vyberte cíl pro exportovaný soubor CSV"
#: core\app.py:534 core\app.py:801 core\app.py:811
#: core\app.py:516 core\app.py:771 core\app.py:781
msgid "Couldn't write to file: {}"
msgstr "Nelze zapisovat do souboru: {}"
#: core\app.py:559
#: core\app.py:539
msgid "You have no custom command set up. Set it up in your preferences."
msgstr ""
"Nedefinoval jste žádný uživatelský příkaz. Nadefinujete ho v předvolbách."
#: core\app.py:727 core\app.py:740
#: core\app.py:695 core\app.py:707
msgid "You are about to remove %d files from results. Continue?"
msgstr "Chystáte se z výsledků odstranit %d souborů. Pokračovat?"
#: core\app.py:774
#: core\app.py:743
msgid "{} duplicate groups were changed by the re-prioritization."
msgstr "{} duplicitní skupiny byly změněny změně priorit."
#: core\app.py:821
#: core\app.py:790
msgid "The selected directories contain no scannable file."
msgstr "Vybrané adresáře neobsahují žádné soubory vhodné k prohledávání."
#: core\app.py:835
#: core\app.py:803
msgid "Collecting files to scan"
msgstr "Shromažďuji prohlížené soubory"
#: core\app.py:891
#: core\app.py:850
msgid "%s (%d discarded)"
msgstr "%s (%d vyřazeno)"
#: core\engine.py:244 core\engine.py:288
msgid "0 matches found"
msgstr "Nalezeno 0 shod"
#: core\directories.py:191
msgid "Collected {} files to scan"
msgstr ""
#: core\engine.py:262 core\engine.py:296
msgid "%d matches found"
msgstr "Nalezeno %d shod"
#: core\directories.py:207
msgid "Collected {} folders to scan"
msgstr ""
#: core\gui\deletion_options.py:73
#: core\engine.py:27
msgid "%d matches found from %d groups"
msgstr ""
#: core\gui\deletion_options.py:71
msgid "You are sending {} file(s) to the Trash."
msgstr "Posíláte-{} soubory do koše."
#: core\gui\exclude_list_table.py:15
#: core\gui\exclude_list_table.py:14
msgid "Regular Expressions"
msgstr "Regulární výrazy"
@@ -176,15 +184,15 @@ msgstr "Obsah"
msgid "Analyzed %d/%d pictures"
msgstr "Analyzováno %d/%d snímků"
#: core\pe\matchblock.py:181
#: core\pe\matchblock.py:177
msgid "Performed %d/%d chunk matches"
msgstr "Provedeno %d/%d porovnání bloků"
#: core\pe\matchblock.py:191
#: core\pe\matchblock.py:185
msgid "Preparing for matching"
msgstr "Připravuji porovnávání"
#: core\pe\matchblock.py:244
#: core\pe\matchblock.py:234
msgid "Verified %d/%d matches"
msgstr "Ověřeno %d/%d shod"
@@ -232,23 +240,23 @@ msgstr "Nejnovější"
msgid "Oldest"
msgstr "Nejstarší"
#: core\results.py:142
#: core\results.py:134
msgid "%d / %d (%s / %s) duplicates marked."
msgstr "%d / %d (%s / %s) duplicit označeno."
#: core\results.py:149
#: core\results.py:141
msgid " filter: %s"
msgstr " filtr: %s"
#: core\scanner.py:85
#: core\scanner.py:90
msgid "Read size of %d/%d files"
msgstr "Read size of %d/%d files"
#: core\scanner.py:109
#: core\scanner.py:116
msgid "Read metadata of %d/%d files"
msgstr "Načtena metadata %d/%d souborů"
#: core\scanner.py:147
#: core\scanner.py:154
msgid "Almost done! Fiddling with results..."
msgstr "Skoro hotovo! Fidlování s výsledky..."

View File

@@ -937,3 +937,43 @@ msgstr "Všeobecné"
#: qt\preferences_dialog.py:286
msgid "Display"
msgstr "Zobrazit"
#: qt\se\preferences_dialog.py:70
msgid "Partially hash files bigger than"
msgstr ""
#: qt\se\preferences_dialog.py:80
msgid "MB"
msgstr ""
#: qt\preferences_dialog.py:163
msgid "Use native OS dialogs"
msgstr ""
#: qt\preferences_dialog.py:166
msgid ""
"For actions such as file/folder selection use the OS native dialogs.\n"
"Some native dialogs have limited functionality."
msgstr ""
#: qt\se\preferences_dialog.py:68
msgid "Ignore files larger than"
msgstr ""
#: qt\app.py:135 qt\app.py:293
msgid "Clear Cache"
msgstr ""
#: qt\app.py:294
msgid ""
"Do you really want to clear the cache? This will remove all cached file "
"hashes and picture analysis."
msgstr ""
#: qt\app.py:299
msgid "Cache cleared."
msgstr ""
#: qt\preferences_dialog.py:173
msgid "Use dark style"
msgstr ""

View File

@@ -1,10 +1,11 @@
# Translators:
# Andrew Senetar <arsenetar@gmail.com>, 2021
# Fuan <jcfrt@posteo.net>, 2021
# Robert M, 2021
#
msgid ""
msgstr ""
"Last-Translator: Fuan <jcfrt@posteo.net>, 2021\n"
"Last-Translator: Robert M, 2021\n"
"Language-Team: German (https://www.transifex.com/voltaicideas/teams/116153/de/)\n"
"Language: de\n"
"Content-Type: text/plain; charset=UTF-8\n"
@@ -47,7 +48,7 @@ msgstr "Kopiere"
msgid "Sending to Trash"
msgstr "Verschiebe in den Papierkorb"
#: core\app.py:308
#: core\app.py:289
msgid ""
"A previous action is still hanging in there. You can't start a new one yet. "
"Wait a few seconds, then try again."
@@ -55,36 +56,40 @@ msgstr ""
"Eine vorherige Aktion ist noch in der Bearbeitung. Sie können noch keine "
"Neue starten. Warten Sie einige Sekunden und versuchen es erneut."
#: core\app.py:318
#: core\app.py:300
msgid "No duplicates found."
msgstr "Keine Duplikate gefunden."
#: core\app.py:333
#: core\app.py:315
msgid "All marked files were copied successfully."
msgstr "Alle markierten Dateien wurden erfolgreich kopiert."
#: core\app.py:334
#: core\app.py:317
msgid "All marked files were moved successfully."
msgstr "Alle markierten Dateien wurden erfolgreich verschoben."
#: core\app.py:335
#: core\app.py:319
msgid "All marked files were deleted successfully."
msgstr "Alle markierten Dateien wurden erfolgreich gelöscht."
#: core\app.py:321
msgid "All marked files were successfully sent to Trash."
msgstr ""
"Alle markierten Dateien wurden erfolgreich in den Papierkorb verschoben."
#: core\app.py:343
#: core\app.py:326
msgid "Could not load file: {}"
msgstr "Konnte Datei {} nicht laden."
#: core\app.py:399
#: core\app.py:382
msgid "'{}' already is in the list."
msgstr "'{}' ist bereits in der Liste."
#: core\app.py:401
#: core\app.py:384
msgid "'{}' does not exist."
msgstr "'{}' existiert nicht."
#: core\app.py:410
#: core\app.py:392
msgid ""
"All selected %d matches are going to be ignored in all subsequent scans. "
"Continue?"
@@ -92,65 +97,69 @@ msgstr ""
"Alle %d ausgewählten Dateien werden in zukünftigen Scans ignoriert. "
"Fortfahren?"
#: core\app.py:486
#: core\app.py:469
msgid "Select a directory to copy marked files to"
msgstr ""
"Wählen Sie ein Verzeichnis aus, in das markierte Dateien kopiert werden "
"sollen"
#: core\app.py:487
#: core\app.py:471
msgid "Select a directory to move marked files to"
msgstr ""
"Wählen Sie ein Verzeichnis aus, in das markierte Dateien verschoben werden "
"sollen"
#: core\app.py:527
#: core\app.py:510
msgid "Select a destination for your exported CSV"
msgstr "Zielverzeichnis für den CSV Export angeben"
#: core\app.py:534 core\app.py:801 core\app.py:811
#: core\app.py:516 core\app.py:771 core\app.py:781
msgid "Couldn't write to file: {}"
msgstr "Konnte Datei {} nicht schreiben."
#: core\app.py:559
#: core\app.py:539
msgid "You have no custom command set up. Set it up in your preferences."
msgstr ""
"Sie haben noch keinen Befehl erstellt. Bitte dies in den Einstellungen vornehmen.\n"
"Bsp.: \"C:\\Program Files\\Diff\\Diff.exe\" \"%d\" \"%r\""
#: core\app.py:727 core\app.py:740
#: core\app.py:695 core\app.py:707
msgid "You are about to remove %d files from results. Continue?"
msgstr "%d Dateien werden aus der Ergebnisliste entfernt. Fortfahren?"
#: core\app.py:774
#: core\app.py:743
msgid "{} duplicate groups were changed by the re-prioritization."
msgstr "{} Duplikat-Gruppen wurden durch die Neu-Priorisierung geändert."
#: core\app.py:821
#: core\app.py:790
msgid "The selected directories contain no scannable file."
msgstr "Ausgewählte Ordner enthalten keine scannbaren Dateien."
#: core\app.py:835
#: core\app.py:803
msgid "Collecting files to scan"
msgstr "Sammle zu scannende Dateien..."
#: core\app.py:891
#: core\app.py:850
msgid "%s (%d discarded)"
msgstr "%s (%d verworfen)"
#: core\engine.py:244 core\engine.py:288
msgid "0 matches found"
msgstr "0 Übereinstimmungen gefunden"
#: core\directories.py:191
msgid "Collected {} files to scan"
msgstr "{} Dateien für Scan gesammelt"
#: core\engine.py:262 core\engine.py:296
msgid "%d matches found"
msgstr "%d Übereinstimmungen gefunden"
#: core\directories.py:207
msgid "Collected {} folders to scan"
msgstr "{} Ordner für Scan gesammelt"
#: core\gui\deletion_options.py:73
#: core\engine.py:27
msgid "%d matches found from %d groups"
msgstr "%d Treffer in %d Gruppen gefunden"
#: core\gui\deletion_options.py:71
msgid "You are sending {} file(s) to the Trash."
msgstr "Verschiebe {} Datei(en) in den Papierkorb."
#: core\gui\exclude_list_table.py:15
#: core\gui\exclude_list_table.py:14
msgid "Regular Expressions"
msgstr "Reguläre Ausdrücke"
@@ -182,15 +191,15 @@ msgstr "Inhalt"
msgid "Analyzed %d/%d pictures"
msgstr "Analysiere Bild %d/%d"
#: core\pe\matchblock.py:181
#: core\pe\matchblock.py:177
msgid "Performed %d/%d chunk matches"
msgstr "%d/%d Chunk-Matches ausgeführt"
#: core\pe\matchblock.py:191
#: core\pe\matchblock.py:185
msgid "Preparing for matching"
msgstr "Bereite Matching vor"
#: core\pe\matchblock.py:244
#: core\pe\matchblock.py:234
msgid "Verified %d/%d matches"
msgstr "%d/%d verifizierte Übereinstimmungen"
@@ -238,23 +247,23 @@ msgstr "Neuste"
msgid "Oldest"
msgstr "Älterste"
#: core\results.py:142
#: core\results.py:134
msgid "%d / %d (%s / %s) duplicates marked."
msgstr "%d / %d (%s / %s) Duplikate markiert."
#: core\results.py:149
#: core\results.py:141
msgid " filter: %s"
msgstr " Filter: %s"
#: core\scanner.py:85
#: core\scanner.py:90
msgid "Read size of %d/%d files"
msgstr "Lese Größe von %d/%d Dateien"
#: core\scanner.py:109
#: core\scanner.py:116
msgid "Read metadata of %d/%d files"
msgstr "Lese Metadaten von %d/%d Dateien"
#: core\scanner.py:147
#: core\scanner.py:154
msgid "Almost done! Fiddling with results..."
msgstr "Fast fertig! Arrangiere Ergebnisse..."

View File

@@ -1,10 +1,11 @@
# Translators:
# Andrew Senetar <arsenetar@gmail.com>, 2021
# Fuan <jcfrt@posteo.net>, 2021
# Robert M, 2021
#
msgid ""
msgstr ""
"Last-Translator: Fuan <jcfrt@posteo.net>, 2021\n"
"Last-Translator: Robert M, 2021\n"
"Language-Team: German (https://www.transifex.com/voltaicideas/teams/116153/de/)\n"
"Language: de\n"
"Content-Type: text/plain; charset=UTF-8\n"
@@ -946,3 +947,45 @@ msgstr "Allgemeines"
#: qt\preferences_dialog.py:286
msgid "Display"
msgstr "Anzeige"
#: qt\se\preferences_dialog.py:70
msgid "Partially hash files bigger than"
msgstr "Dateien partiell hashen die größer sind als"
#: qt\se\preferences_dialog.py:80
msgid "MB"
msgstr "MB"
#: qt\preferences_dialog.py:163
msgid "Use native OS dialogs"
msgstr "Benutzer System-eigene Dialoge"
#: qt\preferences_dialog.py:166
msgid ""
"For actions such as file/folder selection use the OS native dialogs.\n"
"Some native dialogs have limited functionality."
msgstr ""
"Benutzer System-eigene Dialoge für Aktionen wie Datei/Ordern-Auswahl\n"
"Manche System-eigene Dialoge sind in ihren Funktionen limitiert."
#: qt\se\preferences_dialog.py:68
msgid "Ignore files larger than"
msgstr "Ignoriere Dateien größer als"
#: qt\app.py:135 qt\app.py:293
msgid "Clear Cache"
msgstr ""
#: qt\app.py:294
msgid ""
"Do you really want to clear the cache? This will remove all cached file "
"hashes and picture analysis."
msgstr ""
#: qt\app.py:299
msgid "Cache cleared."
msgstr ""
#: qt\preferences_dialog.py:173
msgid "Use dark style"
msgstr ""

View File

@@ -48,7 +48,7 @@ msgstr "Αντιγραφή"
msgid "Sending to Trash"
msgstr "Αποστολή στα σκουπίδια"
#: core\app.py:308
#: core\app.py:289
msgid ""
"A previous action is still hanging in there. You can't start a new one yet. "
"Wait a few seconds, then try again."
@@ -56,94 +56,102 @@ msgstr ""
"Μια προηγούμενη ενέργεια είναι σε εξέλιξη. Δεν μπορείτε να ξεκινήσετε "
"καινούργια ακόμα. Περιμένετε λίγα δευτερόλεπτα, έπειτα προσπαθήστε ξανά."
#: core\app.py:318
#: core\app.py:300
msgid "No duplicates found."
msgstr "Δεν βρέθηκαν διπλότυπα."
#: core\app.py:333
#: core\app.py:315
msgid "All marked files were copied successfully."
msgstr "Όλα τα επιλεγμένα αρχεία αντιγράφηκαν επιτυχώς."
#: core\app.py:334
#: core\app.py:317
msgid "All marked files were moved successfully."
msgstr "Όλα τα επιλεγμένα αρχεία μετακινήθηκαν επιτυχώς."
#: core\app.py:335
#: core\app.py:319
msgid "All marked files were deleted successfully."
msgstr ""
#: core\app.py:321
msgid "All marked files were successfully sent to Trash."
msgstr "Όλα τα επιλεγμένα αρχεία στάλθηκαν με επιτυχία στον κάδο."
#: core\app.py:343
#: core\app.py:326
msgid "Could not load file: {}"
msgstr "Δεν ήταν δυνατή η φόρτωση του αρχείου: {}"
#: core\app.py:399
#: core\app.py:382
msgid "'{}' already is in the list."
msgstr "'{}' υπάρχει ήδη στη λίστα."
#: core\app.py:401
#: core\app.py:384
msgid "'{}' does not exist."
msgstr "'{}' δεν υπάρχει."
#: core\app.py:410
#: core\app.py:392
msgid ""
"All selected %d matches are going to be ignored in all subsequent scans. "
"Continue?"
msgstr ""
"Όλα τα επιλεγμένα %d στοιχεία θα αγνοηθούν σε μελλοντικές σαρώσεις.Συνέχεια;"
#: core\app.py:486
#: core\app.py:469
msgid "Select a directory to copy marked files to"
msgstr "Επιλέξτε έναν κατάλογο για να αντιγράψετε επισημασμένα αρχεία."
#: core\app.py:487
#: core\app.py:471
msgid "Select a directory to move marked files to"
msgstr "Επιλέξτε έναν κατάλογο για να μετακινήσετε τα επισημασμένα αρχεία."
#: core\app.py:527
#: core\app.py:510
msgid "Select a destination for your exported CSV"
msgstr "Επιλέξτε έναν προορισμό για το εξαγόμενο CSV σας"
#: core\app.py:534 core\app.py:801 core\app.py:811
#: core\app.py:516 core\app.py:771 core\app.py:781
msgid "Couldn't write to file: {}"
msgstr "Δεν ήταν δυνατή η εγγραφή στο αρχείο: {}"
#: core\app.py:559
#: core\app.py:539
msgid "You have no custom command set up. Set it up in your preferences."
msgstr "Δεν έχετε ορίσει ειδική εντολή. Ρυθμίστε τη στις προτιμήσεις σας. "
#: core\app.py:727 core\app.py:740
#: core\app.py:695 core\app.py:707
msgid "You are about to remove %d files from results. Continue?"
msgstr "Πρόκειται να αφαιρέσετε %d αρχεία από τα αποτελέσματα. Συνέχεια;"
#: core\app.py:774
#: core\app.py:743
msgid "{} duplicate groups were changed by the re-prioritization."
msgstr "{} ομάδες διπλοτύπων άλλαξαν από το επαναπροσδιορισμό."
#: core\app.py:821
#: core\app.py:790
msgid "The selected directories contain no scannable file."
msgstr "Οι επιλεγμένοι φάκελοι δεν περιέχουν σαρώσιμα αρχεία."
#: core\app.py:835
#: core\app.py:803
msgid "Collecting files to scan"
msgstr "Συλλογή αρχείων για σάρωση"
#: core\app.py:891
#: core\app.py:850
msgid "%s (%d discarded)"
msgstr "%s (%d απορρίφθηκαν)"
#: core\engine.py:244 core\engine.py:288
msgid "0 matches found"
msgstr "0 διπλότυπα βρέθηκαν"
#: core\directories.py:191
msgid "Collected {} files to scan"
msgstr ""
#: core\engine.py:262 core\engine.py:296
msgid "%d matches found"
msgstr "Βρέθηκαν %d διπλότυπα"
#: core\directories.py:207
msgid "Collected {} folders to scan"
msgstr ""
#: core\gui\deletion_options.py:73
#: core\engine.py:27
msgid "%d matches found from %d groups"
msgstr ""
#: core\gui\deletion_options.py:71
msgid "You are sending {} file(s) to the Trash."
msgstr "Στέλνετε {} αρχεία στα σκουπίδια."
#: core\gui\exclude_list_table.py:15
#: core\gui\exclude_list_table.py:14
msgid "Regular Expressions"
msgstr "Κανονικές εκφράσεις"
@@ -175,15 +183,15 @@ msgstr "Περιεχόμενα"
msgid "Analyzed %d/%d pictures"
msgstr "Ανάλυση %d/%d εικόνων"
#: core\pe\matchblock.py:181
#: core\pe\matchblock.py:177
msgid "Performed %d/%d chunk matches"
msgstr "Εκτέλεση %d/%d μερικής ταυτοποίησης"
#: core\pe\matchblock.py:191
#: core\pe\matchblock.py:185
msgid "Preparing for matching"
msgstr "Προετοιμασία για σύγκριση"
#: core\pe\matchblock.py:244
#: core\pe\matchblock.py:234
msgid "Verified %d/%d matches"
msgstr "Πιστοποίηση %d/%d ταυτόσημων"
@@ -231,23 +239,23 @@ msgstr "Νεώτερο"
msgid "Oldest"
msgstr "Παλαιότερο"
#: core\results.py:142
#: core\results.py:134
msgid "%d / %d (%s / %s) duplicates marked."
msgstr "%d / %d (%s / %s) επιλεγμένα διπλότυπα."
#: core\results.py:149
#: core\results.py:141
msgid " filter: %s"
msgstr " φίλτρο: %s"
#: core\scanner.py:85
#: core\scanner.py:90
msgid "Read size of %d/%d files"
msgstr "Ανάγνωση μεγέθους %d/%d αρχείων"
#: core\scanner.py:109
#: core\scanner.py:116
msgid "Read metadata of %d/%d files"
msgstr "Ανάγνωση μεταδεδομένων των %d/%d αρχείων"
#: core\scanner.py:147
#: core\scanner.py:154
msgid "Almost done! Fiddling with results..."
msgstr "Σχεδόν τελείωσα! Παιχνίδι με αποτελέσματα ..."

View File

@@ -954,3 +954,43 @@ msgstr "Γενικός"
#: qt\preferences_dialog.py:286
msgid "Display"
msgstr "Απεικόνιση"
#: qt\se\preferences_dialog.py:70
msgid "Partially hash files bigger than"
msgstr ""
#: qt\se\preferences_dialog.py:80
msgid "MB"
msgstr ""
#: qt\preferences_dialog.py:163
msgid "Use native OS dialogs"
msgstr ""
#: qt\preferences_dialog.py:166
msgid ""
"For actions such as file/folder selection use the OS native dialogs.\n"
"Some native dialogs have limited functionality."
msgstr ""
#: qt\se\preferences_dialog.py:68
msgid "Ignore files larger than"
msgstr ""
#: qt\app.py:135 qt\app.py:293
msgid "Clear Cache"
msgstr ""
#: qt\app.py:294
msgid ""
"Do you really want to clear the cache? This will remove all cached file "
"hashes and picture analysis."
msgstr ""
#: qt\app.py:299
msgid "Cache cleared."
msgstr ""
#: qt\preferences_dialog.py:173
msgid "Use dark style"
msgstr ""

View File

@@ -47,7 +47,7 @@ msgstr "Copiando"
msgid "Sending to Trash"
msgstr "Enviando a la Papelera"
#: core\app.py:308
#: core\app.py:289
msgid ""
"A previous action is still hanging in there. You can't start a new one yet. "
"Wait a few seconds, then try again."
@@ -55,36 +55,40 @@ msgstr ""
"Una acción previa sigue ejecutándose. No puede abrir una nueva todavía. "
"Espere unos segundos y vuelva a intentarlo."
#: core\app.py:318
#: core\app.py:300
msgid "No duplicates found."
msgstr "No se han encontrado duplicados."
#: core\app.py:333
#: core\app.py:315
msgid "All marked files were copied successfully."
msgstr ""
"Todos los ficheros seleccionados han sido copiados satisfactoriamente."
#: core\app.py:334
#: core\app.py:317
msgid "All marked files were moved successfully."
msgstr "Todos los ficheros seleccionados se han movidos satisfactoriamente."
#: core\app.py:335
#: core\app.py:319
msgid "All marked files were deleted successfully."
msgstr ""
#: core\app.py:321
msgid "All marked files were successfully sent to Trash."
msgstr "Todo los ficheros marcados se han enviado a la papelera exitosamente."
#: core\app.py:343
#: core\app.py:326
msgid "Could not load file: {}"
msgstr "No se pudo cargar el archivo: {}"
#: core\app.py:399
#: core\app.py:382
msgid "'{}' already is in the list."
msgstr "'{}' ya está en la lista."
#: core\app.py:401
#: core\app.py:384
msgid "'{}' does not exist."
msgstr "'{}' no existe."
#: core\app.py:410
#: core\app.py:392
msgid ""
"All selected %d matches are going to be ignored in all subsequent scans. "
"Continue?"
@@ -92,59 +96,63 @@ msgstr ""
"Todas las %d coincidencias seleccionadas van a ser ignoradas en las "
"subsiguientes exploraciones. ¿Continuar?"
#: core\app.py:486
#: core\app.py:469
msgid "Select a directory to copy marked files to"
msgstr "Seleccione un directorio donde desee copiar los archivos marcados"
#: core\app.py:487
#: core\app.py:471
msgid "Select a directory to move marked files to"
msgstr "Seleccione un directorio al que desee mover los archivos marcados"
#: core\app.py:527
#: core\app.py:510
msgid "Select a destination for your exported CSV"
msgstr "Seleccionar un destino para el CSV seleccionado"
#: core\app.py:534 core\app.py:801 core\app.py:811
#: core\app.py:516 core\app.py:771 core\app.py:781
msgid "Couldn't write to file: {}"
msgstr "No se pudo escribir en el archivo: {}"
#: core\app.py:559
#: core\app.py:539
msgid "You have no custom command set up. Set it up in your preferences."
msgstr "No hay comandos configurados. Establézcalos en sus preferencias."
#: core\app.py:727 core\app.py:740
#: core\app.py:695 core\app.py:707
msgid "You are about to remove %d files from results. Continue?"
msgstr "Está a punto de eliminar %d ficheros de resultados. ¿Continuar?"
#: core\app.py:774
#: core\app.py:743
msgid "{} duplicate groups were changed by the re-prioritization."
msgstr "{} grupos de duplicados han sido cambiados por la re-priorización"
#: core\app.py:821
#: core\app.py:790
msgid "The selected directories contain no scannable file."
msgstr "Las carpetas seleccionadas no contienen ficheros para explorar."
#: core\app.py:835
#: core\app.py:803
msgid "Collecting files to scan"
msgstr "Recopilando ficheros a explorar"
#: core\app.py:891
#: core\app.py:850
msgid "%s (%d discarded)"
msgstr "%s (%d descartados)"
#: core\engine.py:244 core\engine.py:288
msgid "0 matches found"
msgstr "0 coincidencias"
#: core\directories.py:191
msgid "Collected {} files to scan"
msgstr ""
#: core\engine.py:262 core\engine.py:296
msgid "%d matches found"
msgstr "%d coincidencias encontradas"
#: core\directories.py:207
msgid "Collected {} folders to scan"
msgstr ""
#: core\gui\deletion_options.py:73
#: core\engine.py:27
msgid "%d matches found from %d groups"
msgstr ""
#: core\gui\deletion_options.py:71
msgid "You are sending {} file(s) to the Trash."
msgstr "Enviando {} fichero(s) a la Papelera"
#: core\gui\exclude_list_table.py:15
#: core\gui\exclude_list_table.py:14
msgid "Regular Expressions"
msgstr "Expresiones regulares"
@@ -177,15 +185,15 @@ msgstr "Contenido"
msgid "Analyzed %d/%d pictures"
msgstr "Analizadas %d/%d imágenes"
#: core\pe\matchblock.py:181
#: core\pe\matchblock.py:177
msgid "Performed %d/%d chunk matches"
msgstr "Realizado %d/%d trozos coincidentes"
#: core\pe\matchblock.py:191
#: core\pe\matchblock.py:185
msgid "Preparing for matching"
msgstr "Preparando para coincidencias"
#: core\pe\matchblock.py:244
#: core\pe\matchblock.py:234
msgid "Verified %d/%d matches"
msgstr "Verificadas %d/%d coincidencias"
@@ -233,23 +241,23 @@ msgstr "El más nuevo"
msgid "Oldest"
msgstr "El más antiguo"
#: core\results.py:142
#: core\results.py:134
msgid "%d / %d (%s / %s) duplicates marked."
msgstr "%d / %d (%s / %s) duplicados marcados."
#: core\results.py:149
#: core\results.py:141
msgid " filter: %s"
msgstr "filtro: %s"
#: core\scanner.py:85
#: core\scanner.py:90
msgid "Read size of %d/%d files"
msgstr "Tamaño de lectura de %d/%d ficheros"
#: core\scanner.py:109
#: core\scanner.py:116
msgid "Read metadata of %d/%d files"
msgstr "Leyendo metadatos de %d/%d ficheros"
#: core\scanner.py:147
#: core\scanner.py:154
msgid "Almost done! Fiddling with results..."
msgstr "¡Casi termino! Jugando con los resultados..."

View File

@@ -947,3 +947,43 @@ msgstr "General"
#: qt\preferences_dialog.py:286
msgid "Display"
msgstr "Visualización"
#: qt\se\preferences_dialog.py:70
msgid "Partially hash files bigger than"
msgstr ""
#: qt\se\preferences_dialog.py:80
msgid "MB"
msgstr ""
#: qt\preferences_dialog.py:163
msgid "Use native OS dialogs"
msgstr ""
#: qt\preferences_dialog.py:166
msgid ""
"For actions such as file/folder selection use the OS native dialogs.\n"
"Some native dialogs have limited functionality."
msgstr ""
#: qt\se\preferences_dialog.py:68
msgid "Ignore files larger than"
msgstr ""
#: qt\app.py:135 qt\app.py:293
msgid "Clear Cache"
msgstr ""
#: qt\app.py:294
msgid ""
"Do you really want to clear the cache? This will remove all cached file "
"hashes and picture analysis."
msgstr ""
#: qt\app.py:299
msgid "Cache cleared."
msgstr ""
#: qt\preferences_dialog.py:173
msgid "Use dark style"
msgstr ""

View File

@@ -47,7 +47,7 @@ msgstr "Copie en cours"
msgid "Sending to Trash"
msgstr "Envoi de fichiers à la corbeille"
#: core\app.py:308
#: core\app.py:289
msgid ""
"A previous action is still hanging in there. You can't start a new one yet. "
"Wait a few seconds, then try again."
@@ -55,95 +55,103 @@ msgstr ""
"Une action précédente est encore en cours. Attendez quelques secondes avant "
"d'en repartir une nouvelle."
#: core\app.py:318
#: core\app.py:300
msgid "No duplicates found."
msgstr "Aucun doublon trouvé."
#: core\app.py:333
#: core\app.py:315
msgid "All marked files were copied successfully."
msgstr "Tous les fichiers marqués ont été copiés correctement."
#: core\app.py:334
#: core\app.py:317
msgid "All marked files were moved successfully."
msgstr "Tous les fichiers marqués ont été déplacés correctement."
#: core\app.py:335
#: core\app.py:319
msgid "All marked files were deleted successfully."
msgstr ""
#: core\app.py:321
msgid "All marked files were successfully sent to Trash."
msgstr ""
"Tous les fichiers marqués ont été correctement envoyés à la corbeille."
#: core\app.py:343
#: core\app.py:326
msgid "Could not load file: {}"
msgstr "Impossible d'ouvrir le fichier: {}"
#: core\app.py:399
#: core\app.py:382
msgid "'{}' already is in the list."
msgstr "'{}' est déjà dans la liste."
#: core\app.py:401
#: core\app.py:384
msgid "'{}' does not exist."
msgstr "'{}' n'existe pas."
#: core\app.py:410
#: core\app.py:392
msgid ""
"All selected %d matches are going to be ignored in all subsequent scans. "
"Continue?"
msgstr "%d fichiers seront ignorés des prochains scans. Continuer?"
#: core\app.py:486
#: core\app.py:469
msgid "Select a directory to copy marked files to"
msgstr "Sélectionnez un dossier vers lequel copier les fichiers marqués."
#: core\app.py:487
#: core\app.py:471
msgid "Select a directory to move marked files to"
msgstr "Sélectionnez un dossier vers lequel déplacer les fichiers marqués."
#: core\app.py:527
#: core\app.py:510
msgid "Select a destination for your exported CSV"
msgstr "Choisissez une destination pour votre exportation CSV"
#: core\app.py:534 core\app.py:801 core\app.py:811
#: core\app.py:516 core\app.py:771 core\app.py:781
msgid "Couldn't write to file: {}"
msgstr "Impossible d'écrire le fichier: {}"
#: core\app.py:559
#: core\app.py:539
msgid "You have no custom command set up. Set it up in your preferences."
msgstr ""
"Vous n'avez pas de commande personnalisée. Ajoutez-la dans vos préférences."
#: core\app.py:727 core\app.py:740
#: core\app.py:695 core\app.py:707
msgid "You are about to remove %d files from results. Continue?"
msgstr "%d fichiers seront retirés des résultats. Continuer?"
#: core\app.py:774
#: core\app.py:743
msgid "{} duplicate groups were changed by the re-prioritization."
msgstr "{} groupes de doublons ont été modifiés par la re-prioritisation."
#: core\app.py:821
#: core\app.py:790
msgid "The selected directories contain no scannable file."
msgstr "Les dossiers sélectionnés ne contiennent pas de fichiers valides."
#: core\app.py:835
#: core\app.py:803
msgid "Collecting files to scan"
msgstr "Collecte des fichiers à scanner"
#: core\app.py:891
#: core\app.py:850
msgid "%s (%d discarded)"
msgstr "%s (%d hors-groupe)"
#: core\engine.py:244 core\engine.py:288
msgid "0 matches found"
msgstr "0 paires trouvées"
#: core\directories.py:191
msgid "Collected {} files to scan"
msgstr ""
#: core\engine.py:262 core\engine.py:296
msgid "%d matches found"
msgstr "%d paires trouvées"
#: core\directories.py:207
msgid "Collected {} folders to scan"
msgstr ""
#: core\gui\deletion_options.py:73
#: core\engine.py:27
msgid "%d matches found from %d groups"
msgstr ""
#: core\gui\deletion_options.py:71
msgid "You are sending {} file(s) to the Trash."
msgstr "Vous envoyez {} fichier(s) à la corbeille."
#: core\gui\exclude_list_table.py:15
#: core\gui\exclude_list_table.py:14
msgid "Regular Expressions"
msgstr "Expressions régulières"
@@ -177,15 +185,15 @@ msgstr "Contenu"
msgid "Analyzed %d/%d pictures"
msgstr "Analyzé %d/%d images"
#: core\pe\matchblock.py:181
#: core\pe\matchblock.py:177
msgid "Performed %d/%d chunk matches"
msgstr "%d/%d blocs d'images comparés"
#: core\pe\matchblock.py:191
#: core\pe\matchblock.py:185
msgid "Preparing for matching"
msgstr "Préparation pour la comparaison"
#: core\pe\matchblock.py:244
#: core\pe\matchblock.py:234
msgid "Verified %d/%d matches"
msgstr "Vérifié %d/%d paires"
@@ -233,23 +241,23 @@ msgstr "Plus récent"
msgid "Oldest"
msgstr "Moins récent"
#: core\results.py:142
#: core\results.py:134
msgid "%d / %d (%s / %s) duplicates marked."
msgstr "%d / %d (%s / %s) doublons marqués."
#: core\results.py:149
#: core\results.py:141
msgid " filter: %s"
msgstr " filtre: %s"
#: core\scanner.py:85
#: core\scanner.py:90
msgid "Read size of %d/%d files"
msgstr "Lu la taille de %d/%d fichiers"
#: core\scanner.py:109
#: core\scanner.py:116
msgid "Read metadata of %d/%d files"
msgstr "Lu les métadonnées de %d/%d fichiers"
#: core\scanner.py:147
#: core\scanner.py:154
msgid "Almost done! Fiddling with results..."
msgstr "Bientôt terminé! Bidouille des résultats..."

View File

@@ -942,3 +942,43 @@ msgstr "Général"
#: qt\preferences_dialog.py:286
msgid "Display"
msgstr "Affichage"
#: qt\se\preferences_dialog.py:70
msgid "Partially hash files bigger than"
msgstr ""
#: qt\se\preferences_dialog.py:80
msgid "MB"
msgstr ""
#: qt\preferences_dialog.py:163
msgid "Use native OS dialogs"
msgstr ""
#: qt\preferences_dialog.py:166
msgid ""
"For actions such as file/folder selection use the OS native dialogs.\n"
"Some native dialogs have limited functionality."
msgstr ""
#: qt\se\preferences_dialog.py:68
msgid "Ignore files larger than"
msgstr ""
#: qt\app.py:135 qt\app.py:293
msgid "Clear Cache"
msgstr ""
#: qt\app.py:294
msgid ""
"Do you really want to clear the cache? This will remove all cached file "
"hashes and picture analysis."
msgstr ""
#: qt\app.py:299
msgid "Cache cleared."
msgstr ""
#: qt\preferences_dialog.py:173
msgid "Use dark style"
msgstr ""

View File

@@ -48,7 +48,7 @@ msgstr "Պատճենվում է"
msgid "Sending to Trash"
msgstr "Ուղարկվում է Աղբարկղ"
#: core\app.py:308
#: core\app.py:289
msgid ""
"A previous action is still hanging in there. You can't start a new one yet. "
"Wait a few seconds, then try again."
@@ -56,95 +56,103 @@ msgstr ""
"Նախորդ գործողությունը դեռևս ձեռադրում է այստեղ: Չեք կարող սկսել մեկ ուրիշը: "
"Սպասեք մի քանի վայրկյան և կրկին փորձեք:"
#: core\app.py:318
#: core\app.py:300
msgid "No duplicates found."
msgstr "Կրկնօրինակներ չկան:"
#: core\app.py:333
#: core\app.py:315
msgid "All marked files were copied successfully."
msgstr "Բոլոր նշված ֆայլերը հաջողությամբ պատճենվել են:"
#: core\app.py:334
#: core\app.py:317
msgid "All marked files were moved successfully."
msgstr "Բոլոր նշված ֆայլերը հաջողությամբ տեղափոխվել են:"
#: core\app.py:335
#: core\app.py:319
msgid "All marked files were deleted successfully."
msgstr ""
#: core\app.py:321
msgid "All marked files were successfully sent to Trash."
msgstr "Բոլոր նշված ֆայլերը հաջողությամբ Ջնջվել են:"
#: core\app.py:343
#: core\app.py:326
msgid "Could not load file: {}"
msgstr "Հնարավոր չէ բեռնել ֆայլը: {}"
#: core\app.py:399
#: core\app.py:382
msgid "'{}' already is in the list."
msgstr "'{}'-ը արդեն առկա է ցանկում:"
#: core\app.py:401
#: core\app.py:384
msgid "'{}' does not exist."
msgstr "'{}'-ը գոյություն չունի:"
#: core\app.py:410
#: core\app.py:392
msgid ""
"All selected %d matches are going to be ignored in all subsequent scans. "
"Continue?"
msgstr ""
"Ընտրված %d համընկնումները կանտեսվեն հետագա բոլոր ստուգումներից: Շարունակե՞լ:"
#: core\app.py:486
#: core\app.py:469
msgid "Select a directory to copy marked files to"
msgstr "Ընտրեք գրացուցակ, որտեղ ցանկանում եք պատճենել նշված ֆայլերը"
#: core\app.py:487
#: core\app.py:471
msgid "Select a directory to move marked files to"
msgstr ""
"Խնդրում ենք ընտրել գրացուցակ, որտեղ ցանկանում եք տեղափոխել նշված ֆայլերը"
#: core\app.py:527
#: core\app.py:510
msgid "Select a destination for your exported CSV"
msgstr "Ընտրեք նպատակակետ ձեր արտահանված CSV- ի համար"
#: core\app.py:534 core\app.py:801 core\app.py:811
#: core\app.py:516 core\app.py:771 core\app.py:781
msgid "Couldn't write to file: {}"
msgstr "Չէր կարող գրել է ֆայլը: {}"
#: core\app.py:559
#: core\app.py:539
msgid "You have no custom command set up. Set it up in your preferences."
msgstr "Դուք չեք կատարել Հրամանի ընտրություն: Կատարեք այն կարգավորումներում:"
#: core\app.py:727 core\app.py:740
#: core\app.py:695 core\app.py:707
msgid "You are about to remove %d files from results. Continue?"
msgstr "Դուք պատրաստվում եք ջնջելու %d ֆայլեր: Շարունակե՞լ:"
#: core\app.py:774
#: core\app.py:743
msgid "{} duplicate groups were changed by the re-prioritization."
msgstr "{} կրկնօրինակ խմբերը փոխվել են առաջնահերթության կարգով:"
#: core\app.py:821
#: core\app.py:790
msgid "The selected directories contain no scannable file."
msgstr "Ընտրված թղթապանակները պարունակում են չստուգվող ֆայլ:"
#: core\app.py:835
#: core\app.py:803
msgid "Collecting files to scan"
msgstr "Հավաքվում են ֆայլեր՝ ստուգելու համար"
#: core\app.py:891
#: core\app.py:850
msgid "%s (%d discarded)"
msgstr "%s (%d անպիտան)"
#: core\engine.py:244 core\engine.py:288
msgid "0 matches found"
msgstr "0 համընկնում է գտնվել"
#: core\directories.py:191
msgid "Collected {} files to scan"
msgstr ""
#: core\engine.py:262 core\engine.py:296
msgid "%d matches found"
msgstr "%d համընկնում է գտնվել"
#: core\directories.py:207
msgid "Collected {} folders to scan"
msgstr ""
#: core\gui\deletion_options.py:73
#: core\engine.py:27
msgid "%d matches found from %d groups"
msgstr ""
#: core\gui\deletion_options.py:71
msgid "You are sending {} file(s) to the Trash."
msgstr "Դուք {} ֆայլ եք ուղարկում աղբարկղ:"
#: core\gui\exclude_list_table.py:15
#: core\gui\exclude_list_table.py:14
msgid "Regular Expressions"
msgstr "Կանոնավոր արտահայտություններ"
@@ -176,15 +184,15 @@ msgstr "Բովանդակություն"
msgid "Analyzed %d/%d pictures"
msgstr "Ստուգվում է %d/%d նկարները"
#: core\pe\matchblock.py:181
#: core\pe\matchblock.py:177
msgid "Performed %d/%d chunk matches"
msgstr "Կատարվում է %d/%d տվյալի համընկնում"
#: core\pe\matchblock.py:191
#: core\pe\matchblock.py:185
msgid "Preparing for matching"
msgstr "Նախապատրաստեցվում է համընկնումը"
#: core\pe\matchblock.py:244
#: core\pe\matchblock.py:234
msgid "Verified %d/%d matches"
msgstr "Ստուգում է %d/%d համընկնումները"
@@ -232,23 +240,23 @@ msgstr "Նորագույնը"
msgid "Oldest"
msgstr "Ամենահինը"
#: core\results.py:142
#: core\results.py:134
msgid "%d / %d (%s / %s) duplicates marked."
msgstr "%d / %d (%s / %s) նշված կրկնօրինակներ:"
#: core\results.py:149
#: core\results.py:141
msgid " filter: %s"
msgstr "ֆիլտր. %s"
#: core\scanner.py:85
#: core\scanner.py:90
msgid "Read size of %d/%d files"
msgstr "Կարդալ %d/%d ֆայլերի չափը"
#: core\scanner.py:109
#: core\scanner.py:116
msgid "Read metadata of %d/%d files"
msgstr "Կարդալ %d/%d ֆայլերի մետատվյալները"
#: core\scanner.py:147
#: core\scanner.py:154
msgid "Almost done! Fiddling with results..."
msgstr "Գրեթե արված է! Արդյունքների կազմակերպում..."

View File

@@ -1,10 +1,11 @@
# Translators:
# Andrew Senetar <arsenetar@gmail.com>, 2021
# Fuan <jcfrt@posteo.net>, 2021
# Emanuele, 2021
#
msgid ""
msgstr ""
"Last-Translator: Fuan <jcfrt@posteo.net>, 2021\n"
"Last-Translator: Emanuele, 2021\n"
"Language-Team: Italian (https://www.transifex.com/voltaicideas/teams/116153/it/)\n"
"Language: it\n"
"Content-Type: text/plain; charset=UTF-8\n"
@@ -48,7 +49,7 @@ msgstr "Copia in corso"
msgid "Sending to Trash"
msgstr "Spostamento nel cestino"
#: core\app.py:308
#: core\app.py:289
msgid ""
"A previous action is still hanging in there. You can't start a new one yet. "
"Wait a few seconds, then try again."
@@ -56,35 +57,39 @@ msgstr ""
"Un'azione precedente è ancora in corso. Non puoi cominciarne una nuova. "
"Aspetta qualche secondo e quindi riprova."
#: core\app.py:318
#: core\app.py:300
msgid "No duplicates found."
msgstr "Non sono stati trovati dei duplicati."
#: core\app.py:333
#: core\app.py:315
msgid "All marked files were copied successfully."
msgstr "Tutti i file marcati sono stati copiati correttamente."
#: core\app.py:334
#: core\app.py:317
msgid "All marked files were moved successfully."
msgstr "Tutti i file marcati sono stati spostati correttamente."
#: core\app.py:335
#: core\app.py:319
msgid "All marked files were deleted successfully."
msgstr "Tutti i file marcati sono stati cancellati correttamente."
#: core\app.py:321
msgid "All marked files were successfully sent to Trash."
msgstr "Tutti i file marcati sono stati spostati nel cestino."
#: core\app.py:343
#: core\app.py:326
msgid "Could not load file: {}"
msgstr "Impossibile caricare il file: {}"
#: core\app.py:399
#: core\app.py:382
msgid "'{}' already is in the list."
msgstr "'{}' è già nella lista."
#: core\app.py:401
#: core\app.py:384
msgid "'{}' does not exist."
msgstr "'{}' non esiste."
#: core\app.py:410
#: core\app.py:392
msgid ""
"All selected %d matches are going to be ignored in all subsequent scans. "
"Continue?"
@@ -92,62 +97,66 @@ msgstr ""
"Tutti i %d elementi che coincidono verranno ignorati in tutte le scansioni "
"successive. Continuare?"
#: core\app.py:486
#: core\app.py:469
msgid "Select a directory to copy marked files to"
msgstr "Seleziona una directory in cui desideri copiare i file contrassegnati"
#: core\app.py:487
#: core\app.py:471
msgid "Select a directory to move marked files to"
msgstr ""
"Seleziona una directory in cui desideri spostare i file contrassegnati"
#: core\app.py:527
#: core\app.py:510
msgid "Select a destination for your exported CSV"
msgstr "Seleziona una destinazione per il file CSV"
#: core\app.py:534 core\app.py:801 core\app.py:811
#: core\app.py:516 core\app.py:771 core\app.py:781
msgid "Couldn't write to file: {}"
msgstr "Impossibile modificare il file: {}"
#: core\app.py:559
#: core\app.py:539
msgid "You have no custom command set up. Set it up in your preferences."
msgstr ""
"Non hai impostato nessun comando personalizzato. Impostalo nelle tue "
"preferenze."
#: core\app.py:727 core\app.py:740
#: core\app.py:695 core\app.py:707
msgid "You are about to remove %d files from results. Continue?"
msgstr "Stai per rimuovere %d file dai risultati. Continuare?"
#: core\app.py:774
#: core\app.py:743
msgid "{} duplicate groups were changed by the re-prioritization."
msgstr "{} gruppi duplicati sono stati cambiati dalla nuova priorirità"
#: core\app.py:821
#: core\app.py:790
msgid "The selected directories contain no scannable file."
msgstr "Le cartelle selezionate non contengono file da scansionare."
#: core\app.py:835
#: core\app.py:803
msgid "Collecting files to scan"
msgstr "Raccolta file da scansionare"
#: core\app.py:891
#: core\app.py:850
msgid "%s (%d discarded)"
msgstr "%s (%d scartati)"
#: core\engine.py:244 core\engine.py:288
msgid "0 matches found"
msgstr "Nessun duplicato trovato"
#: core\directories.py:191
msgid "Collected {} files to scan"
msgstr "Raccolti {} file da scansionare"
#: core\engine.py:262 core\engine.py:296
msgid "%d matches found"
msgstr "Trovato/i %d duplicato/i"
#: core\directories.py:207
msgid "Collected {} folders to scan"
msgstr "Raccolte {} cartelle da scansionare"
#: core\gui\deletion_options.py:73
#: core\engine.py:27
msgid "%d matches found from %d groups"
msgstr "%d corrispondeze trovate da %d gruppi"
#: core\gui\deletion_options.py:71
msgid "You are sending {} file(s) to the Trash."
msgstr "Stai spostando {} file al Cestino."
#: core\gui\exclude_list_table.py:15
#: core\gui\exclude_list_table.py:14
msgid "Regular Expressions"
msgstr "Espressioni regolari"
@@ -181,15 +190,15 @@ msgstr "Contenuti"
msgid "Analyzed %d/%d pictures"
msgstr "Analizzate %d/%d immagini"
#: core\pe\matchblock.py:181
#: core\pe\matchblock.py:177
msgid "Performed %d/%d chunk matches"
msgstr "Effettuate %d/%d comparazioni sui sottogruppi di immagini"
#: core\pe\matchblock.py:191
#: core\pe\matchblock.py:185
msgid "Preparing for matching"
msgstr "Preparazione per la comparazione"
#: core\pe\matchblock.py:244
#: core\pe\matchblock.py:234
msgid "Verified %d/%d matches"
msgstr "Verificate %d/%d somiglianze"
@@ -237,23 +246,23 @@ msgstr "Il più nuovo"
msgid "Oldest"
msgstr "Il più vecchio"
#: core\results.py:142
#: core\results.py:134
msgid "%d / %d (%s / %s) duplicates marked."
msgstr "%d / %d (%s / %s) duplicati marcati."
#: core\results.py:149
#: core\results.py:141
msgid " filter: %s"
msgstr " filtro: %s"
#: core\scanner.py:85
#: core\scanner.py:90
msgid "Read size of %d/%d files"
msgstr "Lettura dimensione di %d/%d file"
#: core\scanner.py:109
#: core\scanner.py:116
msgid "Read metadata of %d/%d files"
msgstr "Lettura metadata di %d/%d files"
#: core\scanner.py:147
#: core\scanner.py:154
msgid "Almost done! Fiddling with results..."
msgstr "Quasi finito! Sto organizzando i risultati..."

View File

@@ -1,10 +1,11 @@
# Translators:
# Andrew Senetar <arsenetar@gmail.com>, 2021
# Fuan <jcfrt@posteo.net>, 2021
# Emanuele, 2021
#
msgid ""
msgstr ""
"Last-Translator: Fuan <jcfrt@posteo.net>, 2021\n"
"Last-Translator: Emanuele, 2021\n"
"Language-Team: Italian (https://www.transifex.com/voltaicideas/teams/116153/it/)\n"
"Language: it\n"
"Content-Type: text/plain; charset=UTF-8\n"
@@ -951,3 +952,45 @@ msgstr "Generale"
#: qt\preferences_dialog.py:286
msgid "Display"
msgstr "Schermo"
#: qt\se\preferences_dialog.py:70
msgid "Partially hash files bigger than"
msgstr "Calcola hash parziale di file più grandi di"
#: qt\se\preferences_dialog.py:80
msgid "MB"
msgstr "MB"
#: qt\preferences_dialog.py:163
msgid "Use native OS dialogs"
msgstr "Usa le finestre di dialogo native del Sistema Operativo"
#: qt\preferences_dialog.py:166
msgid ""
"For actions such as file/folder selection use the OS native dialogs.\n"
"Some native dialogs have limited functionality."
msgstr ""
"Per azioni come selezione di file/cartelle usa le finestre di dialogo native del Sistema Operativo.\n"
"Alcune finestre di dialogo native hanno funzionalità limitate."
#: qt\se\preferences_dialog.py:68
msgid "Ignore files larger than"
msgstr "Ignora file più grandi di"
#: qt\app.py:135 qt\app.py:293
msgid "Clear Cache"
msgstr ""
#: qt\app.py:294
msgid ""
"Do you really want to clear the cache? This will remove all cached file "
"hashes and picture analysis."
msgstr ""
#: qt\app.py:299
msgid "Cache cleared."
msgstr ""
#: qt\preferences_dialog.py:173
msgid "Use dark style"
msgstr ""

View File

@@ -44,99 +44,107 @@ msgstr "コピー中"
msgid "Sending to Trash"
msgstr "ごみ箱に送信します"
#: core\app.py:308
#: core\app.py:289
msgid ""
"A previous action is still hanging in there. You can't start a new one yet. "
"Wait a few seconds, then try again."
msgstr "前のアクションはまだそこにぶら下がっています。 まだ新しいものを始めることはできません。 数秒待ってから、再試行してください。"
#: core\app.py:318
#: core\app.py:300
msgid "No duplicates found."
msgstr "重複は見つかりませんでした。"
#: core\app.py:333
#: core\app.py:315
msgid "All marked files were copied successfully."
msgstr "マークされたファイルはすべて正常にコピーされました。"
#: core\app.py:334
#: core\app.py:317
msgid "All marked files were moved successfully."
msgstr "マークされたファイルはすべて正常に移動されました。"
#: core\app.py:335
#: core\app.py:319
msgid "All marked files were deleted successfully."
msgstr ""
#: core\app.py:321
msgid "All marked files were successfully sent to Trash."
msgstr "マークされたファイルはすべてごみ箱に正常に送信されました。"
#: core\app.py:343
#: core\app.py:326
msgid "Could not load file: {}"
msgstr "ファイルを読み込めませんでした:{}"
#: core\app.py:399
#: core\app.py:382
msgid "'{}' already is in the list."
msgstr "「{}」既にリストに含まれています。"
#: core\app.py:401
#: core\app.py:384
msgid "'{}' does not exist."
msgstr "'{}' 存在しません。"
#: core\app.py:410
#: core\app.py:392
msgid ""
"All selected %d matches are going to be ignored in all subsequent scans. "
"Continue?"
msgstr "選択した%d個の一致は、以降のすべてのスキャンで無視されます。 継続する?"
#: core\app.py:486
#: core\app.py:469
msgid "Select a directory to copy marked files to"
msgstr "マークされたファイルをコピーするディレクトリを選択してください"
#: core\app.py:487
#: core\app.py:471
msgid "Select a directory to move marked files to"
msgstr "マークされたファイルを移動するディレクトリを選択してください"
#: core\app.py:527
#: core\app.py:510
msgid "Select a destination for your exported CSV"
msgstr "エクスポートしたCSVの宛先を選択します。"
#: core\app.py:534 core\app.py:801 core\app.py:811
#: core\app.py:516 core\app.py:771 core\app.py:781
msgid "Couldn't write to file: {}"
msgstr "ファイルに書き込めませんでした:{}"
#: core\app.py:559
#: core\app.py:539
msgid "You have no custom command set up. Set it up in your preferences."
msgstr "カスタムコマンドは設定されていません。 お好みで設定してください。"
#: core\app.py:727 core\app.py:740
#: core\app.py:695 core\app.py:707
msgid "You are about to remove %d files from results. Continue?"
msgstr "結果から%d個のファイルを削除しようとしています。 継続する?"
#: core\app.py:774
#: core\app.py:743
msgid "{} duplicate groups were changed by the re-prioritization."
msgstr "{}重複するグループは、再優先順位付けによって変更されました。"
#: core\app.py:821
#: core\app.py:790
msgid "The selected directories contain no scannable file."
msgstr "選択したディレクトリにはスキャン可能なファイルが含まれていません。"
#: core\app.py:835
#: core\app.py:803
msgid "Collecting files to scan"
msgstr "スキャンするファイルを収集しています"
#: core\app.py:891
#: core\app.py:850
msgid "%s (%d discarded)"
msgstr "%s (%d 廃棄)"
#: core\engine.py:244 core\engine.py:288
msgid "0 matches found"
msgstr "一致するものが見つかりません"
#: core\directories.py:191
msgid "Collected {} files to scan"
msgstr ""
#: core\engine.py:262 core\engine.py:296
msgid "%d matches found"
msgstr "%d の一致が見つかりました"
#: core\directories.py:207
msgid "Collected {} folders to scan"
msgstr ""
#: core\gui\deletion_options.py:73
#: core\engine.py:27
msgid "%d matches found from %d groups"
msgstr ""
#: core\gui\deletion_options.py:71
msgid "You are sending {} file(s) to the Trash."
msgstr "{}個のファイルをゴミ箱に送信しています"
#: core\gui\exclude_list_table.py:15
#: core\gui\exclude_list_table.py:14
msgid "Regular Expressions"
msgstr "正規表現"
@@ -168,15 +176,15 @@ msgstr "内容"
msgid "Analyzed %d/%d pictures"
msgstr "%d/%d 枚の写真を分析しました"
#: core\pe\matchblock.py:181
#: core\pe\matchblock.py:177
msgid "Performed %d/%d chunk matches"
msgstr "チャンクマッチを%d/%d回実行しました"
#: core\pe\matchblock.py:191
#: core\pe\matchblock.py:185
msgid "Preparing for matching"
msgstr "マッチングの準備"
#: core\pe\matchblock.py:244
#: core\pe\matchblock.py:234
msgid "Verified %d/%d matches"
msgstr "%d/%d件の一致を確認"
@@ -224,23 +232,23 @@ msgstr "最新"
msgid "Oldest"
msgstr "最古"
#: core\results.py:142
#: core\results.py:134
msgid "%d / %d (%s / %s) duplicates marked."
msgstr "%d / %d (%s / %s)マークされた重複。"
#: core\results.py:149
#: core\results.py:141
msgid " filter: %s"
msgstr "フィルタ: %s"
#: core\scanner.py:85
#: core\scanner.py:90
msgid "Read size of %d/%d files"
msgstr "%d/%dファイルのサイズを読み取った"
#: core\scanner.py:109
#: core\scanner.py:116
msgid "Read metadata of %d/%d files"
msgstr "%d/%dファイルのメタデータを読み取った"
#: core\scanner.py:147
#: core\scanner.py:154
msgid "Almost done! Fiddling with results..."
msgstr "ほぼ完了しました! 結果をいじっています..."

View File

@@ -925,3 +925,43 @@ msgstr "一般"
#: qt\preferences_dialog.py:286
msgid "Display"
msgstr "表示"
#: qt\se\preferences_dialog.py:70
msgid "Partially hash files bigger than"
msgstr ""
#: qt\se\preferences_dialog.py:80
msgid "MB"
msgstr ""
#: qt\preferences_dialog.py:163
msgid "Use native OS dialogs"
msgstr ""
#: qt\preferences_dialog.py:166
msgid ""
"For actions such as file/folder selection use the OS native dialogs.\n"
"Some native dialogs have limited functionality."
msgstr ""
#: qt\se\preferences_dialog.py:68
msgid "Ignore files larger than"
msgstr ""
#: qt\app.py:135 qt\app.py:293
msgid "Clear Cache"
msgstr ""
#: qt\app.py:294
msgid ""
"Do you really want to clear the cache? This will remove all cached file "
"hashes and picture analysis."
msgstr ""
#: qt\app.py:299
msgid "Cache cleared."
msgstr ""
#: qt\preferences_dialog.py:173
msgid "Use dark style"
msgstr ""

View File

@@ -45,99 +45,107 @@ msgstr "복사중"
msgid "Sending to Trash"
msgstr "휴지통으로 보내기"
#: core\app.py:308
#: core\app.py:289
msgid ""
"A previous action is still hanging in there. You can't start a new one yet. "
"Wait a few seconds, then try again."
msgstr "이전 작업이 여전히 거기에 걸려 있습니다. 아직 새로운 것을 시작할 수 없습니다. 몇 초 후에 다시 시도하십시오."
#: core\app.py:318
#: core\app.py:300
msgid "No duplicates found."
msgstr "중복 파일이 없습니다."
#: core\app.py:333
#: core\app.py:315
msgid "All marked files were copied successfully."
msgstr "표시된 모든 파일이 성공적으로 복사되었습니다."
#: core\app.py:334
#: core\app.py:317
msgid "All marked files were moved successfully."
msgstr "표시된 모든 파일이 성공적으로 이동되었습니다."
#: core\app.py:335
#: core\app.py:319
msgid "All marked files were deleted successfully."
msgstr ""
#: core\app.py:321
msgid "All marked files were successfully sent to Trash."
msgstr "표시된 모든 파일이 성공적으로 휴지통으로 전송되었습니다."
#: core\app.py:343
#: core\app.py:326
msgid "Could not load file: {}"
msgstr "파일을로드 할 수 없습니다 : {}"
#: core\app.py:399
#: core\app.py:382
msgid "'{}' already is in the list."
msgstr "'{}' 는 이미 목록에 있습니다."
#: core\app.py:401
#: core\app.py:384
msgid "'{}' does not exist."
msgstr "'{}' 가 존재하지 않습니다."
#: core\app.py:410
#: core\app.py:392
msgid ""
"All selected %d matches are going to be ignored in all subsequent scans. "
"Continue?"
msgstr "선택된 %d개의 일치 항목은 모든 후속 검색에서 무시됩니다. 계속하다?"
#: core\app.py:486
#: core\app.py:469
msgid "Select a directory to copy marked files to"
msgstr "표시된 파일을 복사 할 디렉토리를 선택하십시오"
#: core\app.py:487
#: core\app.py:471
msgid "Select a directory to move marked files to"
msgstr "표시된 파일을 이동할 디렉토리를 선택하십시오"
#: core\app.py:527
#: core\app.py:510
msgid "Select a destination for your exported CSV"
msgstr "내 보낸 CSV의 대상을 선택하십시오"
#: core\app.py:534 core\app.py:801 core\app.py:811
#: core\app.py:516 core\app.py:771 core\app.py:781
msgid "Couldn't write to file: {}"
msgstr "파일에 쓸 수 없습니다 : {}"
#: core\app.py:559
#: core\app.py:539
msgid "You have no custom command set up. Set it up in your preferences."
msgstr "사용자 지정 명령을 설정하지 않았습니다. 기본 설정에서 설정하십시오."
#: core\app.py:727 core\app.py:740
#: core\app.py:695 core\app.py:707
msgid "You are about to remove %d files from results. Continue?"
msgstr "결과에서 %d 개의 파일을 제거하려고합니다. 계속하다?"
#: core\app.py:774
#: core\app.py:743
msgid "{} duplicate groups were changed by the re-prioritization."
msgstr "{} 개의 중복 그룹이 우선 순위 재 지정으로 변경되었습니다."
#: core\app.py:821
#: core\app.py:790
msgid "The selected directories contain no scannable file."
msgstr "선택한 디렉토리에 스캔 가능한 파일이 없습니다."
#: core\app.py:835
#: core\app.py:803
msgid "Collecting files to scan"
msgstr "스캔 할 파일 수집"
#: core\app.py:891
#: core\app.py:850
msgid "%s (%d discarded)"
msgstr "%s (%d 폐기)"
#: core\engine.py:244 core\engine.py:288
msgid "0 matches found"
msgstr "일치하는 항목이 없습니다"
#: core\directories.py:191
msgid "Collected {} files to scan"
msgstr ""
#: core\engine.py:262 core\engine.py:296
msgid "%d matches found"
msgstr "%d개의 일치 항목을 찾았습니다."
#: core\directories.py:207
msgid "Collected {} folders to scan"
msgstr ""
#: core\gui\deletion_options.py:73
#: core\engine.py:27
msgid "%d matches found from %d groups"
msgstr ""
#: core\gui\deletion_options.py:71
msgid "You are sending {} file(s) to the Trash."
msgstr "{}개의 파일을 휴지통으로 보내고 있습니다."
#: core\gui\exclude_list_table.py:15
#: core\gui\exclude_list_table.py:14
msgid "Regular Expressions"
msgstr "정규식"
@@ -169,15 +177,15 @@ msgstr "내용"
msgid "Analyzed %d/%d pictures"
msgstr "%d/%d 사진 분석"
#: core\pe\matchblock.py:181
#: core\pe\matchblock.py:177
msgid "Performed %d/%d chunk matches"
msgstr "%d/%d 청크 매치 수행"
#: core\pe\matchblock.py:191
#: core\pe\matchblock.py:185
msgid "Preparing for matching"
msgstr "매칭 준비"
#: core\pe\matchblock.py:244
#: core\pe\matchblock.py:234
msgid "Verified %d/%d matches"
msgstr "%d/%d 일치 확인"
@@ -225,23 +233,23 @@ msgstr "최신"
msgid "Oldest"
msgstr "가장 오래된"
#: core\results.py:142
#: core\results.py:134
msgid "%d / %d (%s / %s) duplicates marked."
msgstr "%d / %d (%s / %s) 개의 중복이 표시되었습니다."
#: core\results.py:149
#: core\results.py:141
msgid " filter: %s"
msgstr "필터: %s"
#: core\scanner.py:85
#: core\scanner.py:90
msgid "Read size of %d/%d files"
msgstr "%d/%d 개의 파일을 읽을 수 있습니다."
#: core\scanner.py:109
#: core\scanner.py:116
msgid "Read metadata of %d/%d files"
msgstr "%d/%d 개 파일의 메타 데이터를 읽었습니다."
#: core\scanner.py:147
#: core\scanner.py:154
msgid "Almost done! Fiddling with results..."
msgstr "거의 완료되었습니다! 결과를 만지작 거리는 중..."

Some files were not shown because too many files have changed in this diff Show More