mirror of
https://github.com/arsenetar/dupeguru.git
synced 2026-02-01 19:11:38 +00:00
Compare commits
98 Commits
143147cb8e
...
as/pyproje
| Author | SHA1 | Date | |
|---|---|---|---|
|
f26b515286
|
|||
|
9f83018a1a
|
|||
|
|
8f197ea7e1 | ||
|
3a97ba941a
|
|||
|
e3bcf9d686
|
|||
|
a81069be61
|
|||
|
08154815d0
|
|||
|
a95a9db08b
|
|||
|
3d866cec9a
|
|||
|
253dfd897c
|
|||
|
6e87f53f91
|
|||
|
95e04c4d82
|
|||
|
e3a612a704
|
|||
|
53d5ac06bf
|
|||
|
13dd00c798
|
|||
|
|
9f22835f73 | ||
|
|
85a4557525 | ||
| 70d956b4f8 | |||
|
|
007404f46a | ||
| 4385b50825 | |||
| 4ef1d24351 | |||
| 03be82c0b0 | |||
|
|
332b814c00 | ||
|
|
f56bef67e1 | ||
|
|
8160fe4fcc | ||
| 9ad84ade29 | |||
|
18f32fda19
|
|||
|
99ec4e0f27
|
|||
|
|
fe0e4bef91 | ||
| 322d29a996 | |||
|
c5a71f61b8
|
|||
|
10405ad063
|
|||
|
a257dbf0d5
|
|||
|
|
7a4506ece3 | ||
|
aade6593ac
|
|||
|
6d8b86b7eb
|
|||
| e41c91623c | |||
|
46521c8af1
|
|||
|
549eb7f153
|
|||
|
8125e3ec97
|
|||
|
8c5e18b980
|
|||
|
d81759f77f
|
|||
|
c57042fdd2
|
|||
|
057be0294a
|
|||
|
81daddd072
|
|||
| 1e651a1603 | |||
|
78f4145910
|
|||
|
46d1afb566
|
|||
| a5e31f15f0 | |||
|
0cf6c9a1a2
|
|||
|
6db2fa2be6
|
|||
|
2dd2a801cc
|
|||
|
83f5e80427
|
|||
|
091cae0cc6
|
|||
|
e30a135451
|
|||
| 1db93fd142 | |||
| 48862b6414 | |||
|
|
c920412856 | ||
|
4448b999ab
|
|||
| af1ae33598 | |||
| 265d10b261 | |||
|
|
f1153c85c0 | ||
|
|
1eee3fd7e4 | ||
|
|
1827827fdf | ||
|
|
db174d4e63 | ||
|
1f1dfa88dc
|
|||
|
916c5204cf
|
|||
|
71af825b37
|
|||
|
97f490b8b7
|
|||
|
d369bcddd7
|
|||
|
360dceca7b
|
|||
|
92b27801c3
|
|||
|
|
b9aabb8545 | ||
|
d5eeab4a17
|
|||
|
7865e4aeac
|
|||
|
58863b1728
|
|||
|
e382683f66
|
|||
|
f7ed1c801c
|
|||
|
f587c7b5d8
|
|||
|
40ff40bea8
|
|||
|
7a44c72a0a
|
|||
|
66aff9f74e
|
|||
|
5451f55219
|
|||
|
36280b01e6
|
|||
|
18359c3ea6
|
|||
|
0a4e61edf5
|
|||
|
d73a85b82e
|
|||
|
81c593399e
|
|||
|
6a732a79a8
|
|||
|
63dd4d4561
|
|||
|
e0061d7bc1
|
|||
|
c5818b1d1f
|
|||
|
a470a8de25
|
|||
|
a37b5b0eeb
|
|||
|
efd500ecc1
|
|||
|
43fcc52291
|
|||
|
50f5db1543
|
|||
|
a5b0ccdd02
|
75
.github/workflows/default.yml
vendored
75
.github/workflows/default.yml
vendored
@@ -4,71 +4,42 @@ name: Default CI/CD
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [master]
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [master]
|
branches: [master]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
pre-commit:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Python 3.10
|
- name: Set up Python 3.12
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.10"
|
python-version: "3.12"
|
||||||
- name: Install dependencies
|
- uses: pre-commit/action@v3.0.1
|
||||||
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.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:
|
test:
|
||||||
needs: [lint, format]
|
needs: [pre-commit]
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
os: [ubuntu-latest]
|
||||||
python-version: [3.7, 3.8, 3.9, "3.10"]
|
python-version: [3.7, 3.8, 3.9, "3.10", "3.11", "3.12"]
|
||||||
exclude:
|
include:
|
||||||
- os: macos-latest
|
|
||||||
python-version: 3.7
|
|
||||||
- os: macos-latest
|
|
||||||
python-version: 3.8
|
|
||||||
- os: macos-latest
|
|
||||||
python-version: 3.9
|
|
||||||
- os: windows-latest
|
- os: windows-latest
|
||||||
python-version: 3.7
|
python-version: "3.12"
|
||||||
- os: windows-latest
|
- os: macos-latest
|
||||||
python-version: 3.8
|
python-version: "3.12"
|
||||||
- os: windows-latest
|
|
||||||
python-version: 3.9
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
|
pip install setuptools
|
||||||
pip install -r requirements.txt -r requirements-extra.txt
|
pip install -r requirements.txt -r requirements-extra.txt
|
||||||
- name: Build python modules
|
- name: Build python modules
|
||||||
run: |
|
run: |
|
||||||
@@ -78,7 +49,17 @@ jobs:
|
|||||||
pytest core hscommon
|
pytest core hscommon
|
||||||
- name: Upload Artifacts
|
- name: Upload Artifacts
|
||||||
if: matrix.os == 'ubuntu-latest'
|
if: matrix.os == 'ubuntu-latest'
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: modules ${{ matrix.python-version }}
|
name: modules ${{ matrix.python-version }}
|
||||||
path: ${{ github.workspace }}/**/*.so
|
path: build/**/*.so
|
||||||
|
merge-artifacts:
|
||||||
|
needs: [test]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Merge Artifacts
|
||||||
|
uses: actions/upload-artifact/merge@v4
|
||||||
|
with:
|
||||||
|
name: modules
|
||||||
|
pattern: modules*
|
||||||
|
delete-merged: true
|
||||||
|
|||||||
26
.github/workflows/tx-push.yml
vendored
Normal file
26
.github/workflows/tx-push.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Push translation source to Transifex
|
||||||
|
name: Transifex Sync
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
paths:
|
||||||
|
- locale/*.pot
|
||||||
|
|
||||||
|
env:
|
||||||
|
TX_VERSION: "v1.6.10"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
push-source:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Get Transifex Client
|
||||||
|
run: |
|
||||||
|
curl -o- https://raw.githubusercontent.com/transifex/cli/master/install.sh | bash -s -- $TX_VERSION
|
||||||
|
- name: Update & Push Translation Sources
|
||||||
|
env:
|
||||||
|
TX_TOKEN: ${{ secrets.TX_TOKEN }}
|
||||||
|
run: |
|
||||||
|
./tx push -s --use-git-timestamps
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -87,8 +87,8 @@ cython_debug/
|
|||||||
# Visual Studio Code
|
# Visual Studio Code
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/settings.json
|
!.vscode/settings.json
|
||||||
#!.vscode/tasks.json
|
!.vscode/tasks.json
|
||||||
#!.vscode/launch.json
|
!.vscode/launch.json
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
!.vscode/*.code-snippets
|
!.vscode/*.code-snippets
|
||||||
|
|
||||||
@@ -108,4 +108,4 @@ cocoa/autogen
|
|||||||
|
|
||||||
*.waf*
|
*.waf*
|
||||||
.lock-waf*
|
.lock-waf*
|
||||||
/tags
|
/tags
|
||||||
|
|||||||
24
.pre-commit-config.yaml
Normal file
24
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v4.5.0
|
||||||
|
hooks:
|
||||||
|
- id: check-yaml
|
||||||
|
- id: check-toml
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
exclude: ".*.json"
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- repo: https://github.com/psf/black
|
||||||
|
rev: 24.2.0
|
||||||
|
hooks:
|
||||||
|
- id: black
|
||||||
|
- repo: https://github.com/PyCQA/flake8
|
||||||
|
rev: 7.0.0
|
||||||
|
hooks:
|
||||||
|
- id: flake8
|
||||||
|
exclude: ^(.tox|env|build|dist|help|qt/dg_rc.py|pkg).*
|
||||||
|
- repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook
|
||||||
|
rev: v9.11.0
|
||||||
|
hooks:
|
||||||
|
- id: commitlint
|
||||||
|
stages: [commit-msg]
|
||||||
|
additional_dependencies: ["@commitlint/config-conventional"]
|
||||||
@@ -1 +1 @@
|
|||||||
sonar.python.version=3.7, 3.8, 3.9, 3.10
|
sonar.python.version=3.7, 3.8, 3.9, 3.10, 3.11
|
||||||
|
|||||||
@@ -13,15 +13,8 @@ source_file = locale/core.pot
|
|||||||
source_lang = en
|
source_lang = en
|
||||||
type = PO
|
type = PO
|
||||||
|
|
||||||
[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
|
|
||||||
|
|
||||||
[o:voltaicideas:p:dupeguru-1:r:ui]
|
[o:voltaicideas:p:dupeguru-1:r:ui]
|
||||||
file_filter = locale/<lang>/LC_MESSAGES/ui.po
|
file_filter = locale/<lang>/LC_MESSAGES/ui.po
|
||||||
source_file = locale/ui.pot
|
source_file = locale/ui.pot
|
||||||
source_lang = en
|
source_lang = en
|
||||||
type = PO
|
type = PO
|
||||||
|
|
||||||
|
|||||||
6
.vscode/extensions.json
vendored
6
.vscode/extensions.json
vendored
@@ -3,8 +3,10 @@
|
|||||||
"recommendations": [
|
"recommendations": [
|
||||||
"redhat.vscode-yaml",
|
"redhat.vscode-yaml",
|
||||||
"ms-python.vscode-pylance",
|
"ms-python.vscode-pylance",
|
||||||
"ms-python.python"
|
"ms-python.python",
|
||||||
|
"ms-python.black-formatter",
|
||||||
],
|
],
|
||||||
// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
|
// List of extensions recommended by VS Code that should not be recommended for
|
||||||
|
// users of this workspace.
|
||||||
"unwantedRecommendations": []
|
"unwantedRecommendations": []
|
||||||
}
|
}
|
||||||
17
.vscode/launch.json
vendored
Normal file
17
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "DupuGuru",
|
||||||
|
"type": "debugpy",
|
||||||
|
"request": "launch",
|
||||||
|
"program": "run.py",
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"subProcess": true,
|
||||||
|
"justMyCode": false
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
13
.vscode/settings.json
vendored
13
.vscode/settings.json
vendored
@@ -1,12 +1,17 @@
|
|||||||
{
|
{
|
||||||
"python.formatting.provider": "black",
|
|
||||||
"cSpell.words": [
|
"cSpell.words": [
|
||||||
"Dupras",
|
"Dupras",
|
||||||
"hscommon"
|
"hscommon"
|
||||||
],
|
],
|
||||||
|
"editor.rulers": [
|
||||||
|
88,
|
||||||
|
120
|
||||||
|
],
|
||||||
"python.languageServer": "Pylance",
|
"python.languageServer": "Pylance",
|
||||||
"yaml.schemaStore.enable": true,
|
"yaml.schemaStore.enable": true,
|
||||||
"yaml.schemas": {
|
"[python]": {
|
||||||
"https://json.schemastore.org/github-workflow.json": ".github/workflows/*.yml"
|
"editor.formatOnSave": true,
|
||||||
}
|
"editor.defaultFormatter": "ms-python.black-formatter"
|
||||||
|
},
|
||||||
|
"python.testing.pytestEnabled": true
|
||||||
}
|
}
|
||||||
1
LICENSE
1
LICENSE
@@ -619,4 +619,3 @@ Program, unless a warranty or assumption of liability accompanies a
|
|||||||
copy of the Program in return for a fee.
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
|||||||
@@ -3,4 +3,3 @@ recursive-include core *.m
|
|||||||
include run.py
|
include run.py
|
||||||
graft locale
|
graft locale
|
||||||
graft help
|
graft help
|
||||||
graft qtlib/locale
|
|
||||||
10
Makefile
10
Makefile
@@ -4,7 +4,7 @@ PYRCC5 ?= pyrcc5
|
|||||||
REQ_MINOR_VERSION = 7
|
REQ_MINOR_VERSION = 7
|
||||||
PREFIX ?= /usr/local
|
PREFIX ?= /usr/local
|
||||||
|
|
||||||
# Window compatability via Msys2
|
# Window compatability via Msys2
|
||||||
# - venv creates Scripts instead of bin
|
# - venv creates Scripts instead of bin
|
||||||
# - compile generates .pyd instead of .so
|
# - compile generates .pyd instead of .so
|
||||||
# - venv with --sytem-site-packages has issues on windows as well...
|
# - venv with --sytem-site-packages has issues on windows as well...
|
||||||
@@ -12,7 +12,7 @@ PREFIX ?= /usr/local
|
|||||||
ifeq ($(shell ${PYTHON} -c "import platform; print(platform.system())"), Windows)
|
ifeq ($(shell ${PYTHON} -c "import platform; print(platform.system())"), Windows)
|
||||||
BIN = Scripts
|
BIN = Scripts
|
||||||
SO = *.pyd
|
SO = *.pyd
|
||||||
VENV_OPTIONS =
|
VENV_OPTIONS =
|
||||||
else
|
else
|
||||||
BIN = bin
|
BIN = bin
|
||||||
SO = *.so
|
SO = *.so
|
||||||
@@ -35,7 +35,7 @@ endif
|
|||||||
# Our build scripts are not very "make like" yet and perform their task in a bundle. For now, we
|
# Our build scripts are not very "make like" yet and perform their task in a bundle. For now, we
|
||||||
# use one of each file to act as a representative, a target, of these groups.
|
# use one of each file to act as a representative, a target, of these groups.
|
||||||
|
|
||||||
packages = hscommon qtlib core qt
|
packages = hscommon core qt
|
||||||
localedirs = $(wildcard locale/*/LC_MESSAGES)
|
localedirs = $(wildcard locale/*/LC_MESSAGES)
|
||||||
pofiles = $(wildcard locale/*/LC_MESSAGES/*.po)
|
pofiles = $(wildcard locale/*/LC_MESSAGES/*.po)
|
||||||
mofiles = $(patsubst %.po,%.mo,$(pofiles))
|
mofiles = $(patsubst %.po,%.mo,$(pofiles))
|
||||||
@@ -43,7 +43,7 @@ mofiles = $(patsubst %.po,%.mo,$(pofiles))
|
|||||||
vpath %.po $(localedirs)
|
vpath %.po $(localedirs)
|
||||||
vpath %.mo $(localedirs)
|
vpath %.mo $(localedirs)
|
||||||
|
|
||||||
all: | env i18n modules qt/dg_rc.py
|
all: | env i18n modules qt/dg_rc.py
|
||||||
@echo "Build complete! You can run dupeGuru with 'make run'"
|
@echo "Build complete! You can run dupeGuru with 'make run'"
|
||||||
|
|
||||||
run:
|
run:
|
||||||
@@ -82,7 +82,7 @@ qt/dg_rc.py: qt/dg.qrc
|
|||||||
i18n: $(mofiles)
|
i18n: $(mofiles)
|
||||||
|
|
||||||
%.mo: %.po
|
%.mo: %.po
|
||||||
msgfmt -o $@ $<
|
msgfmt -o $@ $<
|
||||||
|
|
||||||
modules: | env
|
modules: | env
|
||||||
$(VENV_PYTHON) build.py --modules
|
$(VENV_PYTHON) build.py --modules
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ This folder contains the source for dupeGuru. Its documentation is in `help`, bu
|
|||||||
* help: Help document, written for Sphinx.
|
* help: Help document, written for Sphinx.
|
||||||
* locale: .po files for localization.
|
* locale: .po files for localization.
|
||||||
* hscommon: A collection of helpers used across HS applications.
|
* hscommon: A collection of helpers used across HS applications.
|
||||||
* qtlib: A collection of helpers used across Qt UI codebases of HS applications.
|
|
||||||
|
|
||||||
## How to build dupeGuru from source
|
## How to build dupeGuru from source
|
||||||
|
|
||||||
@@ -64,7 +63,7 @@ dupeGuru comes with a makefile that can be used to build and run:
|
|||||||
$ python run.py
|
$ python run.py
|
||||||
|
|
||||||
### Generating Debian/Ubuntu package
|
### Generating Debian/Ubuntu package
|
||||||
To generate packages the extra requirements in requirements-extra.txt must be installed, the
|
To generate packages the extra requirements in requirements-extra.txt must be installed, the
|
||||||
steps are as follows:
|
steps are as follows:
|
||||||
|
|
||||||
$ cd <dupeGuru directory>
|
$ cd <dupeGuru directory>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ To build with a different python version 3.7 vs 3.8 or 32 bit vs 64 bit specify
|
|||||||
It is possible to build dupeGuru with the makefile on windows using a compatable POSIX environment. The following steps have been tested using [msys2][msys2]. Before running make:
|
It is possible to build dupeGuru with the makefile on windows using a compatable POSIX environment. The following steps have been tested using [msys2][msys2]. Before running make:
|
||||||
1. Install msys2 or other POSIX environment
|
1. Install msys2 or other POSIX environment
|
||||||
2. Install PyQt5 globally via pip
|
2. Install PyQt5 globally via pip
|
||||||
3. Use the respective console for msys2 it is `msys2 msys`
|
3. Use the respective console for msys2 it is `msys2 msys`
|
||||||
|
|
||||||
Then the following execution of the makefile should work. Pass the correct value for PYTHON to the makefile if not on the path as python3.
|
Then the following execution of the makefile should work. Pass the correct value for PYTHON to the makefile if not on the path as python3.
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ def parse_args():
|
|||||||
|
|
||||||
|
|
||||||
def build_one_help(language):
|
def build_one_help(language):
|
||||||
print("Generating Help in {}".format(language))
|
print(f"Generating Help in {language}")
|
||||||
current_path = Path(".").absolute()
|
current_path = Path(".").absolute()
|
||||||
changelog_path = current_path.joinpath("help", "changelog")
|
changelog_path = current_path.joinpath("help", "changelog")
|
||||||
tixurl = "https://github.com/arsenetar/dupeguru/issues/{}"
|
tixurl = "https://github.com/arsenetar/dupeguru/issues/{}"
|
||||||
@@ -88,14 +88,8 @@ def build_help():
|
|||||||
p.map(build_one_help, languages)
|
p.map(build_one_help, languages)
|
||||||
|
|
||||||
|
|
||||||
def build_qt_localizations():
|
|
||||||
loc.compile_all_po(Path("qtlib", "locale"))
|
|
||||||
loc.merge_locale_dir(Path("qtlib", "locale"), "locale")
|
|
||||||
|
|
||||||
|
|
||||||
def build_localizations():
|
def build_localizations():
|
||||||
loc.compile_all_po("locale")
|
loc.compile_all_po("locale")
|
||||||
build_qt_localizations()
|
|
||||||
locale_dest = Path("build", "locale")
|
locale_dest = Path("build", "locale")
|
||||||
if locale_dest.exists():
|
if locale_dest.exists():
|
||||||
shutil.rmtree(locale_dest)
|
shutil.rmtree(locale_dest)
|
||||||
@@ -110,19 +104,15 @@ def build_updatepot():
|
|||||||
loc.generate_pot(["core"], Path("locale", "columns.pot"), ["coltr"])
|
loc.generate_pot(["core"], Path("locale", "columns.pot"), ["coltr"])
|
||||||
print("Building ui.pot")
|
print("Building ui.pot")
|
||||||
loc.generate_pot(["qt"], Path("locale", "ui.pot"), ["tr"], merge=True)
|
loc.generate_pot(["qt"], Path("locale", "ui.pot"), ["tr"], merge=True)
|
||||||
print("Building qtlib.pot")
|
|
||||||
loc.generate_pot(["qtlib"], Path("qtlib", "locale", "qtlib.pot"), ["tr"])
|
|
||||||
|
|
||||||
|
|
||||||
def build_mergepot():
|
def build_mergepot():
|
||||||
print("Updating .po files using .pot files")
|
print("Updating .po files using .pot files")
|
||||||
loc.merge_pots_into_pos("locale")
|
loc.merge_pots_into_pos("locale")
|
||||||
loc.merge_pots_into_pos(Path("qtlib", "locale"))
|
|
||||||
|
|
||||||
|
|
||||||
def build_normpo():
|
def build_normpo():
|
||||||
loc.normalize_all_pos("locale")
|
loc.normalize_all_pos("locale")
|
||||||
loc.normalize_all_pos(Path("qtlib", "locale"))
|
|
||||||
|
|
||||||
|
|
||||||
def build_pe_modules():
|
def build_pe_modules():
|
||||||
@@ -139,7 +129,8 @@ def build_normal():
|
|||||||
print("Building localizations")
|
print("Building localizations")
|
||||||
build_localizations()
|
build_localizations()
|
||||||
print("Building Qt stuff")
|
print("Building Qt stuff")
|
||||||
print_and_do("pyrcc5 {0} > {1}".format(Path("qt", "dg.qrc"), Path("qt", "dg_rc.py")))
|
Path("qt", "dg_rc.py").unlink(missing_ok=True)
|
||||||
|
print_and_do("pyrcc5 {} > {}".format(Path("qt", "dg.qrc"), Path("qt", "dg_rc.py")))
|
||||||
fix_qt_resource_file(Path("qt", "dg_rc.py"))
|
fix_qt_resource_file(Path("qt", "dg_rc.py"))
|
||||||
build_help()
|
build_help()
|
||||||
|
|
||||||
17
commitlint.config.js
Normal file
17
commitlint.config.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
const Configuration = {
|
||||||
|
/*
|
||||||
|
* Resolve and load @commitlint/config-conventional from node_modules.
|
||||||
|
* Referenced packages must be installed
|
||||||
|
*/
|
||||||
|
extends: ['@commitlint/config-conventional'],
|
||||||
|
/*
|
||||||
|
* Any rules defined here will override rules from @commitlint/config-conventional
|
||||||
|
*/
|
||||||
|
rules: {
|
||||||
|
'header-max-length': [2, 'always', 72],
|
||||||
|
'subject-case': [2, 'always', 'sentence-case'],
|
||||||
|
'scope-enum': [2, 'always'],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = Configuration;
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
__version__ = "4.2.1"
|
__version__ = "4.3.1"
|
||||||
__appname__ = "dupeGuru"
|
__appname__ = "dupeGuru"
|
||||||
|
|||||||
63
core/app.py
63
core/app.py
@@ -4,6 +4,8 @@
|
|||||||
# which should be included with this package. The terms are also available at
|
# which should be included with this package. The terms are also available at
|
||||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||||
|
|
||||||
|
import cProfile
|
||||||
|
import datetime
|
||||||
import os
|
import os
|
||||||
import os.path as op
|
import os.path as op
|
||||||
import logging
|
import logging
|
||||||
@@ -21,20 +23,20 @@ from hscommon.util import delete_if_empty, first, escape, nonone, allsame
|
|||||||
from hscommon.trans import tr
|
from hscommon.trans import tr
|
||||||
from hscommon import desktop
|
from hscommon import desktop
|
||||||
|
|
||||||
from . import se, me, pe
|
from core import se, me, pe
|
||||||
from .pe.photo import get_delta_dimensions
|
from core.pe.photo import get_delta_dimensions
|
||||||
from .util import cmp_value, fix_surrogate_encoding
|
from core.util import cmp_value, fix_surrogate_encoding
|
||||||
from . import directories, results, export, fs, prioritize
|
from core import directories, results, export, fs, prioritize
|
||||||
from .ignore import IgnoreList
|
from core.ignore import IgnoreList
|
||||||
from .exclude import ExcludeDict as ExcludeList
|
from core.exclude import ExcludeDict as ExcludeList
|
||||||
from .scanner import ScanType
|
from core.scanner import ScanType
|
||||||
from .gui.deletion_options import DeletionOptions
|
from core.gui.deletion_options import DeletionOptions
|
||||||
from .gui.details_panel import DetailsPanel
|
from core.gui.details_panel import DetailsPanel
|
||||||
from .gui.directory_tree import DirectoryTree
|
from core.gui.directory_tree import DirectoryTree
|
||||||
from .gui.ignore_list_dialog import IgnoreListDialog
|
from core.gui.ignore_list_dialog import IgnoreListDialog
|
||||||
from .gui.exclude_list_dialog import ExcludeListDialogCore
|
from core.gui.exclude_list_dialog import ExcludeListDialogCore
|
||||||
from .gui.problem_dialog import ProblemDialog
|
from core.gui.problem_dialog import ProblemDialog
|
||||||
from .gui.stats_label import StatsLabel
|
from core.gui.stats_label import StatsLabel
|
||||||
|
|
||||||
HAD_FIRST_LAUNCH_PREFERENCE = "HadFirstLaunch"
|
HAD_FIRST_LAUNCH_PREFERENCE = "HadFirstLaunch"
|
||||||
DEBUG_MODE_PREFERENCE = "DebugMode"
|
DEBUG_MODE_PREFERENCE = "DebugMode"
|
||||||
@@ -124,15 +126,13 @@ class DupeGuru(Broadcaster):
|
|||||||
|
|
||||||
NAME = PROMPT_NAME = "dupeGuru"
|
NAME = PROMPT_NAME = "dupeGuru"
|
||||||
|
|
||||||
PICTURE_CACHE_TYPE = "sqlite" # set to 'shelve' for a ShelveCache
|
|
||||||
|
|
||||||
def __init__(self, view, portable=False):
|
def __init__(self, view, portable=False):
|
||||||
if view.get_default(DEBUG_MODE_PREFERENCE):
|
if view.get_default(DEBUG_MODE_PREFERENCE):
|
||||||
logging.getLogger().setLevel(logging.DEBUG)
|
logging.getLogger().setLevel(logging.DEBUG)
|
||||||
logging.debug("Debug mode enabled")
|
logging.debug("Debug mode enabled")
|
||||||
Broadcaster.__init__(self)
|
Broadcaster.__init__(self)
|
||||||
self.view = view
|
self.view = view
|
||||||
self.appdata = desktop.special_folder_path(desktop.SpecialFolder.APPDATA, appname=self.NAME, portable=portable)
|
self.appdata = desktop.special_folder_path(desktop.SpecialFolder.APPDATA, portable=portable)
|
||||||
if not op.exists(self.appdata):
|
if not op.exists(self.appdata):
|
||||||
os.makedirs(self.appdata)
|
os.makedirs(self.appdata)
|
||||||
self.app_mode = AppMode.STANDARD
|
self.app_mode = AppMode.STANDARD
|
||||||
@@ -151,7 +151,8 @@ class DupeGuru(Broadcaster):
|
|||||||
"clean_empty_dirs": False,
|
"clean_empty_dirs": False,
|
||||||
"ignore_hardlink_matches": False,
|
"ignore_hardlink_matches": False,
|
||||||
"copymove_dest_type": DestType.RELATIVE,
|
"copymove_dest_type": DestType.RELATIVE,
|
||||||
"picture_cache_type": self.PICTURE_CACHE_TYPE,
|
"include_exists_check": True,
|
||||||
|
"rehash_ignore_mtime": False,
|
||||||
}
|
}
|
||||||
self.selected_dupes = []
|
self.selected_dupes = []
|
||||||
self.details_panel = DetailsPanel(self)
|
self.details_panel = DetailsPanel(self)
|
||||||
@@ -181,8 +182,7 @@ class DupeGuru(Broadcaster):
|
|||||||
self.view.create_results_window()
|
self.view.create_results_window()
|
||||||
|
|
||||||
def _get_picture_cache_path(self):
|
def _get_picture_cache_path(self):
|
||||||
cache_type = self.options["picture_cache_type"]
|
cache_name = "cached_pictures.db"
|
||||||
cache_name = "cached_pictures.shelve" if cache_type == "shelve" else "cached_pictures.db"
|
|
||||||
return op.join(self.appdata, cache_name)
|
return op.join(self.appdata, cache_name)
|
||||||
|
|
||||||
def _get_dupe_sort_key(self, dupe, get_group, key, delta):
|
def _get_dupe_sort_key(self, dupe, get_group, key, delta):
|
||||||
@@ -248,7 +248,7 @@ class DupeGuru(Broadcaster):
|
|||||||
ref = group.ref
|
ref = group.ref
|
||||||
linkfunc = os.link if use_hardlinks else os.symlink
|
linkfunc = os.link if use_hardlinks else os.symlink
|
||||||
linkfunc(str(ref.path), str_path)
|
linkfunc(str(ref.path), str_path)
|
||||||
self.clean_empty_dirs(dupe.path.parent())
|
self.clean_empty_dirs(dupe.path.parent)
|
||||||
|
|
||||||
def _create_file(self, path):
|
def _create_file(self, path):
|
||||||
# We add fs.Folder to fileclasses in case the file we're loading contains folder paths.
|
# We add fs.Folder to fileclasses in case the file we're loading contains folder paths.
|
||||||
@@ -262,7 +262,7 @@ class DupeGuru(Broadcaster):
|
|||||||
try:
|
try:
|
||||||
f._read_all_info(attrnames=self.METADATA_TO_READ)
|
f._read_all_info(attrnames=self.METADATA_TO_READ)
|
||||||
return f
|
return f
|
||||||
except EnvironmentError:
|
except OSError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _get_export_data(self):
|
def _get_export_data(self):
|
||||||
@@ -553,9 +553,15 @@ class DupeGuru(Broadcaster):
|
|||||||
# a workaround to make the damn thing work.
|
# a workaround to make the damn thing work.
|
||||||
exepath, args = match.groups()
|
exepath, args = match.groups()
|
||||||
path, exename = op.split(exepath)
|
path, exename = op.split(exepath)
|
||||||
subprocess.Popen(exename + args, shell=True, cwd=path)
|
p = subprocess.Popen(
|
||||||
|
exename + args, shell=True, cwd=path, stdout=subprocess.PIPE, stderr=subprocess.STDOUT
|
||||||
|
)
|
||||||
|
output = p.stdout.read()
|
||||||
|
logging.info("Custom command %s %s: %s", exename, args, output)
|
||||||
else:
|
else:
|
||||||
subprocess.Popen(dupe_cmd, shell=True)
|
p = subprocess.Popen(dupe_cmd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
||||||
|
output = p.stdout.read()
|
||||||
|
logging.info("Custom command %s: %s", dupe_cmd, output)
|
||||||
|
|
||||||
def load(self):
|
def load(self):
|
||||||
"""Load directory selection and ignore list from files in appdata.
|
"""Load directory selection and ignore list from files in appdata.
|
||||||
@@ -780,12 +786,13 @@ class DupeGuru(Broadcaster):
|
|||||||
except OSError as e:
|
except OSError as e:
|
||||||
self.view.show_message(tr("Couldn't write to file: {}").format(str(e)))
|
self.view.show_message(tr("Couldn't write to file: {}").format(str(e)))
|
||||||
|
|
||||||
def start_scanning(self):
|
def start_scanning(self, profile_scan=False):
|
||||||
"""Starts an async job to scan for duplicates.
|
"""Starts an async job to scan for duplicates.
|
||||||
|
|
||||||
Scans folders selected in :attr:`directories` and put the results in :attr:`results`
|
Scans folders selected in :attr:`directories` and put the results in :attr:`results`
|
||||||
"""
|
"""
|
||||||
scanner = self.SCANNER_CLASS()
|
scanner = self.SCANNER_CLASS()
|
||||||
|
fs.filesdb.ignore_mtime = self.options["rehash_ignore_mtime"] is True
|
||||||
if not self.directories.has_any_file():
|
if not self.directories.has_any_file():
|
||||||
self.view.show_message(tr("The selected directories contain no scannable file."))
|
self.view.show_message(tr("The selected directories contain no scannable file."))
|
||||||
return
|
return
|
||||||
@@ -800,6 +807,9 @@ class DupeGuru(Broadcaster):
|
|||||||
self._results_changed()
|
self._results_changed()
|
||||||
|
|
||||||
def do(j):
|
def do(j):
|
||||||
|
if profile_scan:
|
||||||
|
pr = cProfile.Profile()
|
||||||
|
pr.enable()
|
||||||
j.set_progress(0, tr("Collecting files to scan"))
|
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))
|
files = list(self.directories.get_folders(folderclass=se.fs.Folder, j=j))
|
||||||
@@ -810,6 +820,9 @@ class DupeGuru(Broadcaster):
|
|||||||
logging.info("Scanning %d files" % len(files))
|
logging.info("Scanning %d files" % len(files))
|
||||||
self.results.groups = scanner.get_dupe_groups(files, self.ignore_list, j)
|
self.results.groups = scanner.get_dupe_groups(files, self.ignore_list, j)
|
||||||
self.discarded_file_count = scanner.discarded_file_count
|
self.discarded_file_count = scanner.discarded_file_count
|
||||||
|
if profile_scan:
|
||||||
|
pr.disable()
|
||||||
|
pr.dump_stats(op.join(self.appdata, f"{datetime.datetime.now():%Y-%m-%d_%H-%M-%S}.profile"))
|
||||||
|
|
||||||
self._start_job(JobType.SCAN, do)
|
self._start_job(JobType.SCAN, do)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from hscommon.jobprogress import job
|
|||||||
from hscommon.util import FileOrPath
|
from hscommon.util import FileOrPath
|
||||||
from hscommon.trans import tr
|
from hscommon.trans import tr
|
||||||
|
|
||||||
from . import fs
|
from core import fs
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Directories",
|
"Directories",
|
||||||
@@ -84,66 +84,64 @@ class Directories:
|
|||||||
for denied_path_re in self._exclude_list.compiled:
|
for denied_path_re in self._exclude_list.compiled:
|
||||||
if denied_path_re.match(str(path.name)):
|
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
|
return DirectoryState.NORMAL
|
||||||
# Override this in subclasses to specify the state of some special folders.
|
# Override this in subclasses to specify the state of some special folders.
|
||||||
if path.name.startswith("."):
|
if path.name.startswith("."):
|
||||||
return DirectoryState.EXCLUDED
|
return DirectoryState.EXCLUDED
|
||||||
|
return DirectoryState.NORMAL
|
||||||
|
|
||||||
def _get_files(self, from_path, fileclasses, j):
|
def _get_files(self, from_path, fileclasses, j):
|
||||||
for root, dirs, files in os.walk(str(from_path)):
|
try:
|
||||||
j.check_if_cancelled()
|
with os.scandir(from_path) as iter:
|
||||||
root_path = Path(root)
|
root_path = Path(from_path)
|
||||||
state = self.get_state(root_path)
|
state = self.get_state(root_path)
|
||||||
if state == DirectoryState.EXCLUDED and not any(
|
# if we have no un-excluded dirs under this directory skip going deeper
|
||||||
p.parts[: len(root_path.parts)] == root_path.parts for p in self.states
|
skip_dirs = state == DirectoryState.EXCLUDED and not any(
|
||||||
):
|
p.parts[: len(root_path.parts)] == root_path.parts 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
|
count = 0
|
||||||
# through self.states and see if we must continue, or we can stop right here to save time
|
for item in iter:
|
||||||
del dirs[:]
|
j.check_if_cancelled()
|
||||||
try:
|
try:
|
||||||
if state != DirectoryState.EXCLUDED:
|
if item.is_dir():
|
||||||
# Old logic
|
if skip_dirs:
|
||||||
if self._exclude_list is None or not self._exclude_list.mark_count:
|
continue
|
||||||
found_files = [fs.get_file(root_path.joinpath(f), fileclasses=fileclasses) for f in files]
|
yield from self._get_files(item.path, fileclasses, j)
|
||||||
else:
|
continue
|
||||||
found_files = []
|
elif state == DirectoryState.EXCLUDED:
|
||||||
# print(f"len of files: {len(files)} {files}")
|
continue
|
||||||
for f in files:
|
# File excluding or not
|
||||||
if not self._exclude_list.is_excluded(root, f):
|
if (
|
||||||
found_files.append(fs.get_file(root_path.joinpath(f), fileclasses=fileclasses))
|
self._exclude_list is None
|
||||||
found_files = [f for f in found_files if f is not None]
|
or not self._exclude_list.mark_count
|
||||||
# In some cases, directories can be considered as files by dupeGuru, which is
|
or not self._exclude_list.is_excluded(str(from_path), item.name)
|
||||||
# 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.
|
file = fs.get_file(item, fileclasses=fileclasses)
|
||||||
for d in dirs[:]:
|
if file:
|
||||||
f = fs.get_file(root_path.joinpath(d), fileclasses=fileclasses)
|
file.is_ref = state == DirectoryState.REFERENCE
|
||||||
if f is not None:
|
count += 1
|
||||||
found_files.append(f)
|
yield file
|
||||||
dirs.remove(d)
|
except (OSError, fs.InvalidPath):
|
||||||
logging.debug(
|
pass
|
||||||
"Collected %d files in folder %s",
|
logging.debug(
|
||||||
len(found_files),
|
"Collected %d files in folder %s",
|
||||||
str(root_path),
|
count,
|
||||||
)
|
str(root_path),
|
||||||
for file in found_files:
|
)
|
||||||
file.is_ref = state == DirectoryState.REFERENCE
|
except OSError:
|
||||||
yield file
|
pass
|
||||||
except (EnvironmentError, fs.InvalidPath):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _get_folders(self, from_folder, j):
|
def _get_folders(self, from_folder, j):
|
||||||
j.check_if_cancelled()
|
j.check_if_cancelled()
|
||||||
try:
|
try:
|
||||||
for subfolder in from_folder.subfolders:
|
for subfolder in from_folder.subfolders:
|
||||||
for folder in self._get_folders(subfolder, j):
|
yield from self._get_folders(subfolder, j)
|
||||||
yield folder
|
|
||||||
state = self.get_state(from_folder.path)
|
state = self.get_state(from_folder.path)
|
||||||
if state != DirectoryState.EXCLUDED:
|
if state != DirectoryState.EXCLUDED:
|
||||||
from_folder.is_ref = state == DirectoryState.REFERENCE
|
from_folder.is_ref = state == DirectoryState.REFERENCE
|
||||||
logging.debug("Yielding Folder %r state: %d", from_folder, state)
|
logging.debug("Yielding Folder %r state: %d", from_folder, state)
|
||||||
yield from_folder
|
yield from_folder
|
||||||
except (EnvironmentError, fs.InvalidPath):
|
except (OSError, fs.InvalidPath):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# ---Public
|
# ---Public
|
||||||
@@ -175,7 +173,7 @@ class Directories:
|
|||||||
subpaths = [p for p in path.glob("*") if p.is_dir()]
|
subpaths = [p for p in path.glob("*") if p.is_dir()]
|
||||||
subpaths.sort(key=lambda x: x.name.lower())
|
subpaths.sort(key=lambda x: x.name.lower())
|
||||||
return subpaths
|
return subpaths
|
||||||
except EnvironmentError:
|
except OSError:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def get_files(self, fileclasses=None, j=job.nulljob):
|
def get_files(self, fileclasses=None, j=job.nulljob):
|
||||||
@@ -189,7 +187,7 @@ class Directories:
|
|||||||
for path in self._dirs:
|
for path in self._dirs:
|
||||||
for file in self._get_files(path, fileclasses=fileclasses, j=j):
|
for file in self._get_files(path, fileclasses=fileclasses, j=j):
|
||||||
file_count += 1
|
file_count += 1
|
||||||
if type(j) != job.NullJob:
|
if not isinstance(j, job.NullJob):
|
||||||
j.set_progress(-1, tr("Collected {} files to scan").format(file_count))
|
j.set_progress(-1, tr("Collected {} files to scan").format(file_count))
|
||||||
yield file
|
yield file
|
||||||
|
|
||||||
@@ -205,7 +203,7 @@ class Directories:
|
|||||||
from_folder = folderclass(path)
|
from_folder = folderclass(path)
|
||||||
for folder in self._get_folders(from_folder, j):
|
for folder in self._get_folders(from_folder, j):
|
||||||
folder_count += 1
|
folder_count += 1
|
||||||
if type(j) != job.NullJob:
|
if not isinstance(j, job.NullJob):
|
||||||
j.set_progress(-1, tr("Collected {} folders to scan").format(folder_count))
|
j.set_progress(-1, tr("Collected {} folders to scan").format(folder_count))
|
||||||
yield folder
|
yield folder
|
||||||
|
|
||||||
@@ -217,19 +215,16 @@ class Directories:
|
|||||||
# direct match? easy result.
|
# direct match? easy result.
|
||||||
if path in self.states:
|
if path in self.states:
|
||||||
return self.states[path]
|
return self.states[path]
|
||||||
state = self._default_state_for_path(path) or DirectoryState.NORMAL
|
state = self._default_state_for_path(path)
|
||||||
# Save non-default states in cache, necessary for _get_files()
|
# Save non-default states in cache, necessary for _get_files()
|
||||||
if state != DirectoryState.NORMAL:
|
if state != DirectoryState.NORMAL:
|
||||||
self.states[path] = state
|
self.states[path] = state
|
||||||
return state
|
return state
|
||||||
|
# find the longest parent path that is in states and return that state if found
|
||||||
prevlen = 0
|
# NOTE: path.parents is ordered longest to shortest
|
||||||
# we loop through the states to find the longest matching prefix
|
for parent_path in path.parents:
|
||||||
# if the parent has a state in cache, return that state
|
if parent_path in self.states:
|
||||||
for p, s in self.states.items():
|
return self.states[parent_path]
|
||||||
if p in path.parents and len(p.parts) > prevlen:
|
|
||||||
prevlen = len(p.parts)
|
|
||||||
state = s
|
|
||||||
return state
|
return state
|
||||||
|
|
||||||
def has_any_file(self):
|
def has_any_file(self):
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ def reduce_common_words(word_dict, threshold):
|
|||||||
The exception to this removal are the objects where all the words of the object are common.
|
The exception to this removal are the objects where all the words of the object are common.
|
||||||
Because if we remove them, we will miss some duplicates!
|
Because if we remove them, we will miss some duplicates!
|
||||||
"""
|
"""
|
||||||
uncommon_words = set(word for word, objects in word_dict.items() if len(objects) < threshold)
|
uncommon_words = {word for word, objects in word_dict.items() if len(objects) < threshold}
|
||||||
for word, objects in list(word_dict.items()):
|
for word, objects in list(word_dict.items()):
|
||||||
if len(objects) < threshold:
|
if len(objects) < threshold:
|
||||||
continue
|
continue
|
||||||
@@ -303,12 +303,13 @@ def getmatches_by_contents(files, bigsize=0, j=job.nulljob):
|
|||||||
# skip hashing for zero length files
|
# skip hashing for zero length files
|
||||||
result.append(Match(first, second, 100))
|
result.append(Match(first, second, 100))
|
||||||
continue
|
continue
|
||||||
if first.digest_partial == second.digest_partial:
|
# if digests are the same (and not None) then files match
|
||||||
|
if first.digest_partial is not None and first.digest_partial == second.digest_partial:
|
||||||
if bigsize > 0 and first.size > bigsize:
|
if bigsize > 0 and first.size > bigsize:
|
||||||
if first.digest_samples == second.digest_samples:
|
if first.digest_samples is not None and first.digest_samples == second.digest_samples:
|
||||||
result.append(Match(first, second, 100))
|
result.append(Match(first, second, 100))
|
||||||
else:
|
else:
|
||||||
if first.digest == second.digest:
|
if first.digest is not None and first.digest == second.digest:
|
||||||
result.append(Match(first, second, 100))
|
result.append(Match(first, second, 100))
|
||||||
group_count += 1
|
group_count += 1
|
||||||
j.add_progress(desc=PROGRESS_MESSAGE % (len(result), group_count))
|
j.add_progress(desc=PROGRESS_MESSAGE % (len(result), group_count))
|
||||||
@@ -409,7 +410,7 @@ class Group:
|
|||||||
|
|
||||||
You can call this after the duplicate scanning process to free a bit of memory.
|
You can call this after the duplicate scanning process to free a bit of memory.
|
||||||
"""
|
"""
|
||||||
discarded = set(m for m in self.matches if not all(obj in self.unordered for obj in [m.first, m.second]))
|
discarded = {m for m in self.matches if not all(obj in self.unordered for obj in [m.first, m.second])}
|
||||||
self.matches -= discarded
|
self.matches -= discarded
|
||||||
self.candidates = defaultdict(set)
|
self.candidates = defaultdict(set)
|
||||||
return discarded
|
return discarded
|
||||||
@@ -456,7 +457,7 @@ class Group:
|
|||||||
self._matches_for_ref = None
|
self._matches_for_ref = None
|
||||||
if (len(self) > 1) and any(not getattr(item, "is_ref", False) for item in self):
|
if (len(self) > 1) and any(not getattr(item, "is_ref", False) for item in self):
|
||||||
if discard_matches:
|
if discard_matches:
|
||||||
self.matches = set(m for m in self.matches if item not in m)
|
self.matches = {m for m in self.matches if item not in m}
|
||||||
else:
|
else:
|
||||||
self._clear()
|
self._clear()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -529,7 +530,7 @@ def get_groups(matches):
|
|||||||
del dupe2group
|
del dupe2group
|
||||||
del matches
|
del matches
|
||||||
# should free enough memory to continue
|
# should free enough memory to continue
|
||||||
logging.warning("Memory Overflow. Groups: {0}".format(len(groups)))
|
logging.warning(f"Memory Overflow. Groups: {len(groups)}")
|
||||||
# Now that we have a group, we have to discard groups' matches and see if there're any "orphan"
|
# Now that we have a group, we have to discard groups' matches and see if there're any "orphan"
|
||||||
# matches, that is, matches that were candidate in a group but that none of their 2 files were
|
# matches, that is, matches that were candidate in a group but that none of their 2 files were
|
||||||
# accepted in the group. With these orphan groups, it's safe to build additional groups
|
# accepted in the group. With these orphan groups, it's safe to build additional groups
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# which should be included with this package. The terms are also available at
|
# which should be included with this package. The terms are also available at
|
||||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||||
|
|
||||||
from .markable import Markable
|
from core.markable import Markable
|
||||||
from xml.etree import ElementTree as ET
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
# TODO: perhaps use regex module for better Unicode support? https://pypi.org/project/regex/
|
# TODO: perhaps use regex module for better Unicode support? https://pypi.org/project/regex/
|
||||||
|
|||||||
182
core/fs.py
182
core/fs.py
@@ -13,6 +13,17 @@
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from math import floor
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
from sys import platform
|
||||||
|
from threading import Lock
|
||||||
|
from typing import Any, AnyStr, Union, Callable
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from hscommon.util import nonone, get_file_ext
|
||||||
|
|
||||||
|
hasher: Callable
|
||||||
try:
|
try:
|
||||||
import xxhash
|
import xxhash
|
||||||
|
|
||||||
@@ -22,15 +33,6 @@ except ImportError:
|
|||||||
|
|
||||||
hasher = hashlib.md5
|
hasher = hashlib.md5
|
||||||
|
|
||||||
from math import floor
|
|
||||||
import logging
|
|
||||||
import sqlite3
|
|
||||||
from threading import Lock
|
|
||||||
from typing import Any, AnyStr, Union
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from hscommon.util import nonone, get_file_ext
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"File",
|
"File",
|
||||||
"Folder",
|
"Folder",
|
||||||
@@ -53,6 +55,9 @@ CHUNK_SIZE = 1024 * 1024 # 1 MiB
|
|||||||
# Minimum size below which partial hashing is not used
|
# Minimum size below which partial hashing is not used
|
||||||
MIN_FILE_SIZE = 3 * CHUNK_SIZE # 3MiB, because we take 3 samples
|
MIN_FILE_SIZE = 3 * CHUNK_SIZE # 3MiB, because we take 3 samples
|
||||||
|
|
||||||
|
# Partial hashing offset and size
|
||||||
|
PARTIAL_OFFSET_SIZE = (0x4000, 0x4000)
|
||||||
|
|
||||||
|
|
||||||
class FSError(Exception):
|
class FSError(Exception):
|
||||||
cls_message = "An error has occured on '{name}' in '{parent}'"
|
cls_message = "An error has occured on '{name}' in '{parent}'"
|
||||||
@@ -96,60 +101,76 @@ class FilesDB:
|
|||||||
schema_version = 1
|
schema_version = 1
|
||||||
schema_version_description = "Changed from md5 to xxhash if available."
|
schema_version_description = "Changed from md5 to xxhash if available."
|
||||||
|
|
||||||
create_table_query = "CREATE TABLE IF NOT EXISTS files (path TEXT PRIMARY KEY, size INTEGER, mtime_ns INTEGER, entry_dt DATETIME, digest BLOB, digest_partial BLOB, digest_samples BLOB)"
|
create_table_query = """CREATE TABLE IF NOT EXISTS files (path TEXT PRIMARY KEY, size INTEGER, mtime_ns INTEGER,
|
||||||
|
entry_dt DATETIME, digest BLOB, digest_partial BLOB, digest_samples BLOB)"""
|
||||||
drop_table_query = "DROP TABLE IF EXISTS files;"
|
drop_table_query = "DROP TABLE IF EXISTS files;"
|
||||||
select_query = "SELECT {key} FROM files WHERE path=:path AND size=:size and mtime_ns=:mtime_ns"
|
select_query = "SELECT {key} FROM files WHERE path=:path AND size=:size and mtime_ns=:mtime_ns"
|
||||||
|
select_query_ignore_mtime = "SELECT {key} FROM files WHERE path=:path AND size=:size"
|
||||||
insert_query = """
|
insert_query = """
|
||||||
INSERT INTO files (path, size, mtime_ns, entry_dt, {key}) VALUES (:path, :size, :mtime_ns, datetime('now'), :value)
|
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;
|
ON CONFLICT(path) DO UPDATE SET size=:size, mtime_ns=:mtime_ns, entry_dt=datetime('now'), {key}=:value;
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
ignore_mtime = False
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.conn = None
|
self.conn = None
|
||||||
self.cur = None
|
|
||||||
self.lock = None
|
self.lock = None
|
||||||
|
|
||||||
def connect(self, path: Union[AnyStr, os.PathLike]) -> None:
|
def connect(self, path: Union[AnyStr, os.PathLike]) -> None:
|
||||||
self.conn = sqlite3.connect(path, check_same_thread=False)
|
if platform.startswith("gnu0"):
|
||||||
self.cur = self.conn.cursor()
|
self.conn = sqlite3.connect(path, check_same_thread=False, isolation_level=None)
|
||||||
|
else:
|
||||||
|
self.conn = sqlite3.connect(path, check_same_thread=False)
|
||||||
self.lock = Lock()
|
self.lock = Lock()
|
||||||
self._check_upgrade()
|
self._check_upgrade()
|
||||||
|
|
||||||
def _check_upgrade(self) -> None:
|
def _check_upgrade(self) -> None:
|
||||||
with self.lock:
|
with self.lock, self.conn as conn:
|
||||||
has_schema = self.cur.execute(
|
has_schema = conn.execute(
|
||||||
"SELECT NAME FROM sqlite_master WHERE type='table' AND name='schema_version'"
|
"SELECT NAME FROM sqlite_master WHERE type='table' AND name='schema_version'"
|
||||||
).fetchall()
|
).fetchall()
|
||||||
version = None
|
version = None
|
||||||
if has_schema:
|
if has_schema:
|
||||||
version = self.cur.execute("SELECT version FROM schema_version ORDER BY version DESC").fetchone()[0]
|
version = conn.execute("SELECT version FROM schema_version ORDER BY version DESC").fetchone()[0]
|
||||||
else:
|
else:
|
||||||
self.cur.execute("CREATE TABLE schema_version (version int PRIMARY KEY, description TEXT)")
|
conn.execute("CREATE TABLE schema_version (version int PRIMARY KEY, description TEXT)")
|
||||||
if version != self.schema_version:
|
if version != self.schema_version:
|
||||||
self.cur.execute(self.drop_table_query)
|
conn.execute(self.drop_table_query)
|
||||||
self.cur.execute(
|
conn.execute(
|
||||||
"INSERT OR REPLACE INTO schema_version VALUES (:version, :description)",
|
"INSERT OR REPLACE INTO schema_version VALUES (:version, :description)",
|
||||||
{"version": self.schema_version, "description": self.schema_version_description},
|
{"version": self.schema_version, "description": self.schema_version_description},
|
||||||
)
|
)
|
||||||
self.cur.execute(self.create_table_query)
|
conn.execute(self.create_table_query)
|
||||||
self.conn.commit()
|
|
||||||
|
|
||||||
def clear(self) -> None:
|
def clear(self) -> None:
|
||||||
with self.lock:
|
with self.lock, self.conn as conn:
|
||||||
self.cur.execute(self.drop_table_query)
|
conn.execute(self.drop_table_query)
|
||||||
self.cur.execute(self.create_table_query)
|
conn.execute(self.create_table_query)
|
||||||
|
|
||||||
def get(self, path: Path, key: str) -> Union[bytes, None]:
|
def get(self, path: Path, key: str) -> Union[bytes, None]:
|
||||||
stat = path.stat()
|
stat = path.stat()
|
||||||
size = stat.st_size
|
size = stat.st_size
|
||||||
mtime_ns = stat.st_mtime_ns
|
mtime_ns = stat.st_mtime_ns
|
||||||
|
try:
|
||||||
|
with self.conn as conn:
|
||||||
|
if self.ignore_mtime:
|
||||||
|
cursor = conn.execute(
|
||||||
|
self.select_query_ignore_mtime.format(key=key), {"path": str(path), "size": size}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor = conn.execute(
|
||||||
|
self.select_query.format(key=key),
|
||||||
|
{"path": str(path), "size": size, "mtime_ns": mtime_ns},
|
||||||
|
)
|
||||||
|
result = cursor.fetchone()
|
||||||
|
cursor.close()
|
||||||
|
|
||||||
with self.lock:
|
if result:
|
||||||
self.cur.execute(self.select_query.format(key=key), {"path": str(path), "size": size, "mtime_ns": mtime_ns})
|
return result[0]
|
||||||
result = self.cur.fetchone()
|
except Exception as ex:
|
||||||
|
logging.warning(f"Couldn't get {key} for {path} w/{size}, {mtime_ns}: {ex}")
|
||||||
if result:
|
|
||||||
return result[0]
|
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -157,12 +178,14 @@ class FilesDB:
|
|||||||
stat = path.stat()
|
stat = path.stat()
|
||||||
size = stat.st_size
|
size = stat.st_size
|
||||||
mtime_ns = stat.st_mtime_ns
|
mtime_ns = stat.st_mtime_ns
|
||||||
|
try:
|
||||||
with self.lock:
|
with self.lock, self.conn as conn:
|
||||||
self.cur.execute(
|
conn.execute(
|
||||||
self.insert_query.format(key=key),
|
self.insert_query.format(key=key),
|
||||||
{"path": str(path), "size": size, "mtime_ns": mtime_ns, "value": value},
|
{"path": str(path), "size": size, "mtime_ns": mtime_ns, "value": value},
|
||||||
)
|
)
|
||||||
|
except Exception as ex:
|
||||||
|
logging.warning(f"Couldn't put {key} for {path} w/{size}, {mtime_ns}: {ex}")
|
||||||
|
|
||||||
def commit(self) -> None:
|
def commit(self) -> None:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
@@ -170,7 +193,6 @@ class FilesDB:
|
|||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.cur.close()
|
|
||||||
self.conn.close()
|
self.conn.close()
|
||||||
|
|
||||||
|
|
||||||
@@ -184,15 +206,22 @@ class File:
|
|||||||
# Slots for File make us save quite a bit of memory. In a memory test I've made with a lot of
|
# Slots for File make us save quite a bit of memory. In a memory test I've made with a lot of
|
||||||
# files, I saved 35% memory usage with "unread" files (no _read_info() call) and gains become
|
# files, I saved 35% memory usage with "unread" files (no _read_info() call) and gains become
|
||||||
# even greater when we take into account read attributes (70%!). Yeah, it's worth it.
|
# even greater when we take into account read attributes (70%!). Yeah, it's worth it.
|
||||||
__slots__ = ("path", "is_ref", "words") + tuple(INITIAL_INFO.keys())
|
__slots__ = ("path", "unicode_path", "is_ref", "words") + tuple(INITIAL_INFO.keys())
|
||||||
|
|
||||||
def __init__(self, path):
|
def __init__(self, path):
|
||||||
self.path = path
|
|
||||||
for attrname in self.INITIAL_INFO:
|
for attrname in self.INITIAL_INFO:
|
||||||
setattr(self, attrname, NOT_SET)
|
setattr(self, attrname, NOT_SET)
|
||||||
|
if type(path) is os.DirEntry:
|
||||||
|
self.path = Path(path.path)
|
||||||
|
self.size = nonone(path.stat().st_size, 0)
|
||||||
|
self.mtime = nonone(path.stat().st_mtime, 0)
|
||||||
|
else:
|
||||||
|
self.path = path
|
||||||
|
if self.path:
|
||||||
|
self.unicode_path = str(self.path)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "<{} {}>".format(self.__class__.__name__, str(self.path))
|
return f"<{self.__class__.__name__} {str(self.path)}>"
|
||||||
|
|
||||||
def __getattribute__(self, attrname):
|
def __getattribute__(self, attrname):
|
||||||
result = object.__getattribute__(self, attrname)
|
result = object.__getattribute__(self, attrname)
|
||||||
@@ -223,14 +252,9 @@ class File:
|
|||||||
|
|
||||||
def _calc_digest_partial(self):
|
def _calc_digest_partial(self):
|
||||||
# type: () -> bytes
|
# type: () -> bytes
|
||||||
|
|
||||||
# This offset is where we should start reading the file to get a partial hash
|
|
||||||
# For audio file, it should be where audio data starts
|
|
||||||
offset, size = (0x4000, 0x4000)
|
|
||||||
|
|
||||||
with self.path.open("rb") as fp:
|
with self.path.open("rb") as fp:
|
||||||
fp.seek(offset)
|
fp.seek(PARTIAL_OFFSET_SIZE[0])
|
||||||
partial_data = fp.read(size)
|
partial_data = fp.read(PARTIAL_OFFSET_SIZE[1])
|
||||||
return hasher(partial_data).digest()
|
return hasher(partial_data).digest()
|
||||||
|
|
||||||
def _calc_digest_samples(self) -> bytes:
|
def _calc_digest_samples(self) -> bytes:
|
||||||
@@ -259,34 +283,29 @@ class File:
|
|||||||
self.size = nonone(stats.st_size, 0)
|
self.size = nonone(stats.st_size, 0)
|
||||||
self.mtime = nonone(stats.st_mtime, 0)
|
self.mtime = nonone(stats.st_mtime, 0)
|
||||||
elif field == "digest_partial":
|
elif field == "digest_partial":
|
||||||
try:
|
self.digest_partial = filesdb.get(self.path, "digest_partial")
|
||||||
self.digest_partial = filesdb.get(self.path, "digest_partial")
|
if self.digest_partial is None:
|
||||||
if self.digest_partial is None:
|
# If file is smaller than partial requirements just use the full digest
|
||||||
|
if self.size < PARTIAL_OFFSET_SIZE[0] + PARTIAL_OFFSET_SIZE[1]:
|
||||||
|
self.digest_partial = self.digest
|
||||||
|
else:
|
||||||
self.digest_partial = self._calc_digest_partial()
|
self.digest_partial = self._calc_digest_partial()
|
||||||
filesdb.put(self.path, "digest_partial", self.digest_partial)
|
filesdb.put(self.path, "digest_partial", self.digest_partial)
|
||||||
except Exception as e:
|
|
||||||
logging.warning("Couldn't get digest_partial for %s: %s", self.path, e)
|
|
||||||
elif field == "digest":
|
elif field == "digest":
|
||||||
try:
|
self.digest = filesdb.get(self.path, "digest")
|
||||||
self.digest = filesdb.get(self.path, "digest")
|
if self.digest is None:
|
||||||
if self.digest is None:
|
self.digest = self._calc_digest()
|
||||||
self.digest = self._calc_digest()
|
filesdb.put(self.path, "digest", self.digest)
|
||||||
filesdb.put(self.path, "digest", self.digest)
|
|
||||||
except Exception as e:
|
|
||||||
logging.warning("Couldn't get digest for %s: %s", self.path, e)
|
|
||||||
elif field == "digest_samples":
|
elif field == "digest_samples":
|
||||||
size = self.size
|
size = self.size
|
||||||
# Might as well hash such small files entirely.
|
# Might as well hash such small files entirely.
|
||||||
if size <= MIN_FILE_SIZE:
|
if size <= MIN_FILE_SIZE:
|
||||||
setattr(self, field, self.digest)
|
self.digest_samples = self.digest
|
||||||
return
|
return
|
||||||
try:
|
self.digest_samples = filesdb.get(self.path, "digest_samples")
|
||||||
self.digest_samples = filesdb.get(self.path, "digest_samples")
|
if self.digest_samples is None:
|
||||||
if self.digest_samples is None:
|
self.digest_samples = self._calc_digest_samples()
|
||||||
self.digest_samples = self._calc_digest_samples()
|
filesdb.put(self.path, "digest_samples", self.digest_samples)
|
||||||
filesdb.put(self.path, "digest_samples", self.digest_samples)
|
|
||||||
except Exception as e:
|
|
||||||
logging.warning(f"Couldn't get digest_samples for {self.path}: {e}")
|
|
||||||
|
|
||||||
def _read_all_info(self, attrnames=None):
|
def _read_all_info(self, attrnames=None):
|
||||||
"""Cache all possible info.
|
"""Cache all possible info.
|
||||||
@@ -304,6 +323,14 @@ class File:
|
|||||||
"""Returns whether this file wrapper class can handle ``path``."""
|
"""Returns whether this file wrapper class can handle ``path``."""
|
||||||
return not path.is_symlink() and path.is_file()
|
return not path.is_symlink() and path.is_file()
|
||||||
|
|
||||||
|
def exists(self) -> bool:
|
||||||
|
"""Safely check if the underlying file exists, treat error as non-existent"""
|
||||||
|
try:
|
||||||
|
return self.path.exists()
|
||||||
|
except OSError as ex:
|
||||||
|
logging.warning(f"Checking {self.path} raised: {ex}")
|
||||||
|
return False
|
||||||
|
|
||||||
def rename(self, newname):
|
def rename(self, newname):
|
||||||
if newname == self.name:
|
if newname == self.name:
|
||||||
return
|
return
|
||||||
@@ -312,7 +339,7 @@ class File:
|
|||||||
raise AlreadyExistsError(newname, self.path.parent)
|
raise AlreadyExistsError(newname, self.path.parent)
|
||||||
try:
|
try:
|
||||||
self.path.rename(destpath)
|
self.path.rename(destpath)
|
||||||
except EnvironmentError:
|
except OSError:
|
||||||
raise OperationError(self)
|
raise OperationError(self)
|
||||||
if not destpath.exists():
|
if not destpath.exists():
|
||||||
raise OperationError(self)
|
raise OperationError(self)
|
||||||
@@ -346,6 +373,7 @@ class Folder(File):
|
|||||||
|
|
||||||
def __init__(self, path):
|
def __init__(self, path):
|
||||||
File.__init__(self, path)
|
File.__init__(self, path)
|
||||||
|
self.size = NOT_SET
|
||||||
self._subfolders = None
|
self._subfolders = None
|
||||||
|
|
||||||
def _all_items(self):
|
def _all_items(self):
|
||||||
@@ -377,7 +405,8 @@ class Folder(File):
|
|||||||
@property
|
@property
|
||||||
def subfolders(self):
|
def subfolders(self):
|
||||||
if self._subfolders is None:
|
if self._subfolders is None:
|
||||||
subfolders = [p for p in self.path.glob("*") if not p.is_symlink() and p.is_dir()]
|
with os.scandir(self.path) as iter:
|
||||||
|
subfolders = [p for p in iter if not p.is_symlink() and p.is_dir()]
|
||||||
self._subfolders = [self.__class__(p) for p in subfolders]
|
self._subfolders = [self.__class__(p) for p in subfolders]
|
||||||
return self._subfolders
|
return self._subfolders
|
||||||
|
|
||||||
@@ -408,10 +437,11 @@ def get_files(path, fileclasses=[File]):
|
|||||||
assert all(issubclass(fileclass, File) for fileclass in fileclasses)
|
assert all(issubclass(fileclass, File) for fileclass in fileclasses)
|
||||||
try:
|
try:
|
||||||
result = []
|
result = []
|
||||||
for path in path.glob("*"):
|
with os.scandir(path) as iter:
|
||||||
file = get_file(path, fileclasses=fileclasses)
|
for item in iter:
|
||||||
if file is not None:
|
file = get_file(item, fileclasses=fileclasses)
|
||||||
result.append(file)
|
if file is not None:
|
||||||
|
result.append(file)
|
||||||
return result
|
return result
|
||||||
except EnvironmentError:
|
except OSError:
|
||||||
raise InvalidPath(path)
|
raise InvalidPath(path)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||||
|
|
||||||
from hscommon.gui.base import GUIObject
|
from hscommon.gui.base import GUIObject
|
||||||
from .base import DupeGuruGUIObject
|
from core.gui.base import DupeGuruGUIObject
|
||||||
|
|
||||||
|
|
||||||
class DetailsPanel(GUIObject, DupeGuruGUIObject):
|
class DetailsPanel(GUIObject, DupeGuruGUIObject):
|
||||||
|
|||||||
@@ -8,8 +8,8 @@
|
|||||||
|
|
||||||
from hscommon.gui.tree import Tree, Node
|
from hscommon.gui.tree import Tree, Node
|
||||||
|
|
||||||
from ..directories import DirectoryState
|
from core.directories import DirectoryState
|
||||||
from .base import DupeGuruGUIObject
|
from core.gui.base import DupeGuruGUIObject
|
||||||
|
|
||||||
STATE_ORDER = [DirectoryState.NORMAL, DirectoryState.REFERENCE, DirectoryState.EXCLUDED]
|
STATE_ORDER = [DirectoryState.NORMAL, DirectoryState.REFERENCE, DirectoryState.EXCLUDED]
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
# which should be included with this package. The terms are also available at
|
# which should be included with this package. The terms are also available at
|
||||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||||
|
|
||||||
from .exclude_list_table import ExcludeListTable
|
from core.gui.exclude_list_table import ExcludeListTable
|
||||||
from core.exclude import has_sep
|
from core.exclude import has_sep
|
||||||
from os import sep
|
from os import sep
|
||||||
import logging
|
import logging
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# which should be included with this package. The terms are also available at
|
# which should be included with this package. The terms are also available at
|
||||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||||
|
|
||||||
from .base import DupeGuruGUIObject
|
from core.gui.base import DupeGuruGUIObject
|
||||||
from hscommon.gui.table import GUITable, Row
|
from hscommon.gui.table import GUITable, Row
|
||||||
from hscommon.gui.column import Column, Columns
|
from hscommon.gui.column import Column, Columns
|
||||||
from hscommon.trans import trget
|
from hscommon.trans import trget
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||||
|
|
||||||
from hscommon.trans import tr
|
from hscommon.trans import tr
|
||||||
from .ignore_list_table import IgnoreListTable
|
from core.gui.ignore_list_table import IgnoreListTable
|
||||||
|
|
||||||
|
|
||||||
class IgnoreListDialog:
|
class IgnoreListDialog:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
from hscommon import desktop
|
from hscommon import desktop
|
||||||
|
|
||||||
from .problem_table import ProblemTable
|
from core.gui.problem_table import ProblemTable
|
||||||
|
|
||||||
|
|
||||||
class ProblemDialog:
|
class ProblemDialog:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from operator import attrgetter
|
|||||||
from hscommon.gui.table import GUITable, Row
|
from hscommon.gui.table import GUITable, Row
|
||||||
from hscommon.gui.column import Columns
|
from hscommon.gui.column import Columns
|
||||||
|
|
||||||
from .base import DupeGuruGUIObject
|
from core.gui.base import DupeGuruGUIObject
|
||||||
|
|
||||||
|
|
||||||
class DupeRow(Row):
|
class DupeRow(Row):
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
# which should be included with this package. The terms are also available at
|
# which should be included with this package. The terms are also available at
|
||||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||||
|
|
||||||
from .base import DupeGuruGUIObject
|
from core.gui.base import DupeGuruGUIObject
|
||||||
|
|
||||||
|
|
||||||
class StatsLabel(DupeGuruGUIObject):
|
class StatsLabel(DupeGuruGUIObject):
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
from . import fs, prioritize, result_table, scanner # noqa
|
from core.me import fs, prioritize, result_table, scanner # noqa
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
from . import ( # noqa
|
from core.pe import ( # noqa
|
||||||
block,
|
block,
|
||||||
cache,
|
cache,
|
||||||
exif,
|
exif,
|
||||||
iphoto_plist,
|
|
||||||
matchblock,
|
matchblock,
|
||||||
matchexif,
|
matchexif,
|
||||||
photo,
|
photo,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
# which should be included with this package. The terms are also available at
|
# which should be included with this package. The terms are also available at
|
||||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||||
|
|
||||||
from ._block import NoBlocksError, DifferentBlockCountError, avgdiff, getblocks2 # NOQA
|
from core.pe._block import NoBlocksError, DifferentBlockCountError, avgdiff, getblocks2 # NOQA
|
||||||
|
|
||||||
# Converted to C
|
# Converted to C
|
||||||
# def getblock(image):
|
# def getblock(image):
|
||||||
|
|||||||
13
core/pe/block.pyi
Normal file
13
core/pe/block.pyi
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from typing import Tuple, List, Union, Sequence
|
||||||
|
|
||||||
|
_block = Tuple[int, int, int]
|
||||||
|
|
||||||
|
class NoBlocksError(Exception): ... # noqa: E302, E701
|
||||||
|
class DifferentBlockCountError(Exception): ... # noqa E701
|
||||||
|
|
||||||
|
def getblock(image: object) -> Union[_block, None]: ... # noqa: E302
|
||||||
|
def getblocks2(image: object, block_count_per_side: int) -> Union[List[_block], None]: ...
|
||||||
|
def diff(first: _block, second: _block) -> int: ...
|
||||||
|
def avgdiff( # noqa: E302
|
||||||
|
first: Sequence[_block], second: Sequence[_block], limit: int = 768, min_iterations: int = 1
|
||||||
|
) -> Union[int, None]: ...
|
||||||
@@ -4,24 +4,13 @@
|
|||||||
# which should be included with this package. The terms are also available at
|
# which should be included with this package. The terms are also available at
|
||||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||||
|
|
||||||
from ._cache import string_to_colors # noqa
|
from core.pe._cache import bytes_to_colors # noqa
|
||||||
|
|
||||||
|
|
||||||
def colors_to_string(colors):
|
def colors_to_bytes(colors):
|
||||||
"""Transform the 3 sized tuples 'colors' into a hex string.
|
"""Transform the 3 sized tuples 'colors' into a bytes string.
|
||||||
|
|
||||||
[(0,100,255)] --> 0064ff
|
[(0,100,255)] --> b'\x00d\xff'
|
||||||
[(1,2,3),(4,5,6)] --> 010203040506
|
[(1,2,3),(4,5,6)] --> b'\x01\x02\x03\x04\x05\x06'
|
||||||
"""
|
"""
|
||||||
return "".join("%02x%02x%02x" % (r, g, b) for r, g, b in colors)
|
return b"".join(map(bytes, colors))
|
||||||
|
|
||||||
|
|
||||||
# This function is an important bottleneck of dupeGuru PE. It has been converted to C.
|
|
||||||
# def string_to_colors(s):
|
|
||||||
# """Transform the string 's' in a list of 3 sized tuples.
|
|
||||||
# """
|
|
||||||
# result = []
|
|
||||||
# for i in xrange(0, len(s), 6):
|
|
||||||
# number = int(s[i:i+6], 16)
|
|
||||||
# result.append((number >> 16, (number >> 8) & 0xff, number & 0xff))
|
|
||||||
# return result
|
|
||||||
|
|||||||
6
core/pe/cache.pyi
Normal file
6
core/pe/cache.pyi
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from typing import Union, Tuple, List
|
||||||
|
|
||||||
|
_block = Tuple[int, int, int]
|
||||||
|
|
||||||
|
def colors_to_bytes(colors: List[_block]) -> bytes: ... # noqa: E302
|
||||||
|
def bytes_to_colors(s: bytes) -> Union[List[_block], None]: ...
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
# Copyright 2016 Virgil Dupras
|
|
||||||
#
|
|
||||||
# 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
|
|
||||||
|
|
||||||
import os
|
|
||||||
import os.path as op
|
|
||||||
import shelve
|
|
||||||
import tempfile
|
|
||||||
from collections import namedtuple
|
|
||||||
|
|
||||||
from .cache import string_to_colors, colors_to_string
|
|
||||||
|
|
||||||
|
|
||||||
def wrap_path(path):
|
|
||||||
return "path:{}".format(path)
|
|
||||||
|
|
||||||
|
|
||||||
def unwrap_path(key):
|
|
||||||
return key[5:]
|
|
||||||
|
|
||||||
|
|
||||||
def wrap_id(path):
|
|
||||||
return "id:{}".format(path)
|
|
||||||
|
|
||||||
|
|
||||||
def unwrap_id(key):
|
|
||||||
return int(key[3:])
|
|
||||||
|
|
||||||
|
|
||||||
CacheRow = namedtuple("CacheRow", "id path blocks mtime")
|
|
||||||
|
|
||||||
|
|
||||||
class ShelveCache:
|
|
||||||
"""A class to cache picture blocks in a shelve backend."""
|
|
||||||
|
|
||||||
def __init__(self, db=None, readonly=False):
|
|
||||||
self.istmp = db is None
|
|
||||||
if self.istmp:
|
|
||||||
self.dtmp = tempfile.mkdtemp()
|
|
||||||
self.ftmp = db = op.join(self.dtmp, "tmpdb")
|
|
||||||
flag = "r" if readonly else "c"
|
|
||||||
self.shelve = shelve.open(db, flag)
|
|
||||||
self.maxid = self._compute_maxid()
|
|
||||||
|
|
||||||
def __contains__(self, key):
|
|
||||||
return wrap_path(key) in self.shelve
|
|
||||||
|
|
||||||
def __delitem__(self, key):
|
|
||||||
row = self.shelve[wrap_path(key)]
|
|
||||||
del self.shelve[wrap_path(key)]
|
|
||||||
del self.shelve[wrap_id(row.id)]
|
|
||||||
|
|
||||||
def __getitem__(self, key):
|
|
||||||
if isinstance(key, int):
|
|
||||||
skey = self.shelve[wrap_id(key)]
|
|
||||||
else:
|
|
||||||
skey = wrap_path(key)
|
|
||||||
return string_to_colors(self.shelve[skey].blocks)
|
|
||||||
|
|
||||||
def __iter__(self):
|
|
||||||
return (unwrap_path(k) for k in self.shelve if k.startswith("path:"))
|
|
||||||
|
|
||||||
def __len__(self):
|
|
||||||
return sum(1 for k in self.shelve if k.startswith("path:"))
|
|
||||||
|
|
||||||
def __setitem__(self, path_str, blocks):
|
|
||||||
blocks = colors_to_string(blocks)
|
|
||||||
if op.exists(path_str):
|
|
||||||
mtime = int(os.stat(path_str).st_mtime)
|
|
||||||
else:
|
|
||||||
mtime = 0
|
|
||||||
if path_str in self:
|
|
||||||
rowid = self.shelve[wrap_path(path_str)].id
|
|
||||||
else:
|
|
||||||
rowid = self._get_new_id()
|
|
||||||
row = CacheRow(rowid, path_str, blocks, mtime)
|
|
||||||
self.shelve[wrap_path(path_str)] = row
|
|
||||||
self.shelve[wrap_id(rowid)] = wrap_path(path_str)
|
|
||||||
|
|
||||||
def _compute_maxid(self):
|
|
||||||
return max((unwrap_id(k) for k in self.shelve if k.startswith("id:")), default=1)
|
|
||||||
|
|
||||||
def _get_new_id(self):
|
|
||||||
self.maxid += 1
|
|
||||||
return self.maxid
|
|
||||||
|
|
||||||
def clear(self):
|
|
||||||
self.shelve.clear()
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
if self.shelve is not None:
|
|
||||||
self.shelve.close()
|
|
||||||
if self.istmp:
|
|
||||||
os.remove(self.ftmp)
|
|
||||||
os.rmdir(self.dtmp)
|
|
||||||
self.shelve = None
|
|
||||||
|
|
||||||
def filter(self, func):
|
|
||||||
to_delete = [key for key in self if not func(key)]
|
|
||||||
for key in to_delete:
|
|
||||||
del self[key]
|
|
||||||
|
|
||||||
def get_id(self, path):
|
|
||||||
if path in self:
|
|
||||||
return self.shelve[wrap_path(path)].id
|
|
||||||
else:
|
|
||||||
raise ValueError(path)
|
|
||||||
|
|
||||||
def get_multiple(self, rowids):
|
|
||||||
for rowid in rowids:
|
|
||||||
try:
|
|
||||||
skey = self.shelve[wrap_id(rowid)]
|
|
||||||
except KeyError:
|
|
||||||
continue
|
|
||||||
yield (rowid, string_to_colors(self.shelve[skey].blocks))
|
|
||||||
|
|
||||||
def purge_outdated(self):
|
|
||||||
"""Go through the cache and purge outdated records.
|
|
||||||
|
|
||||||
A record is outdated if the picture doesn't exist or if its mtime is greater than the one in
|
|
||||||
the db.
|
|
||||||
"""
|
|
||||||
todelete = []
|
|
||||||
for path in self:
|
|
||||||
row = self.shelve[wrap_path(path)]
|
|
||||||
if row.mtime and op.exists(path):
|
|
||||||
picture_mtime = os.stat(path).st_mtime
|
|
||||||
if int(picture_mtime) <= row.mtime:
|
|
||||||
# not outdated
|
|
||||||
continue
|
|
||||||
todelete.append(path)
|
|
||||||
for path in todelete:
|
|
||||||
try:
|
|
||||||
del self[path]
|
|
||||||
except KeyError:
|
|
||||||
# I have no idea why a KeyError sometimes happen, but it does, as we can see in
|
|
||||||
# #402 and #439. I don't think it hurts to silently ignore the error, so that's
|
|
||||||
# what we do
|
|
||||||
pass
|
|
||||||
@@ -9,12 +9,24 @@ import os.path as op
|
|||||||
import logging
|
import logging
|
||||||
import sqlite3 as sqlite
|
import sqlite3 as sqlite
|
||||||
|
|
||||||
from .cache import string_to_colors, colors_to_string
|
from core.pe.cache import bytes_to_colors, colors_to_bytes
|
||||||
|
|
||||||
|
|
||||||
class SqliteCache:
|
class SqliteCache:
|
||||||
"""A class to cache picture blocks in a sqlite backend."""
|
"""A class to cache picture blocks in a sqlite backend."""
|
||||||
|
|
||||||
|
schema_version = 2
|
||||||
|
schema_version_description = "Added blocks for all 8 orientations."
|
||||||
|
|
||||||
|
create_table_query = (
|
||||||
|
"CREATE TABLE IF NOT EXISTS "
|
||||||
|
"pictures(path TEXT, mtime_ns INTEGER, blocks BLOB, blocks2 BLOB, blocks3 BLOB, "
|
||||||
|
"blocks4 BLOB, blocks5 BLOB, blocks6 BLOB, blocks7 BLOB, blocks8 BLOB)"
|
||||||
|
)
|
||||||
|
create_index_query = "CREATE INDEX IF NOT EXISTS idx_path on pictures (path)"
|
||||||
|
drop_table_query = "DROP TABLE IF EXISTS pictures"
|
||||||
|
drop_index_query = "DROP INDEX IF EXISTS idx_path"
|
||||||
|
|
||||||
def __init__(self, db=":memory:", readonly=False):
|
def __init__(self, db=":memory:", readonly=False):
|
||||||
# readonly is not used in the sqlite version of the cache
|
# readonly is not used in the sqlite version of the cache
|
||||||
self.dbname = db
|
self.dbname = db
|
||||||
@@ -35,12 +47,20 @@ class SqliteCache:
|
|||||||
# Optimized
|
# Optimized
|
||||||
def __getitem__(self, key):
|
def __getitem__(self, key):
|
||||||
if isinstance(key, int):
|
if isinstance(key, int):
|
||||||
sql = "select blocks from pictures where rowid = ?"
|
sql = (
|
||||||
|
"select blocks, blocks2, blocks3, blocks4, blocks5, blocks6, blocks7, blocks8 "
|
||||||
|
"from pictures "
|
||||||
|
"where rowid = ?"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
sql = "select blocks from pictures where path = ?"
|
sql = (
|
||||||
result = self.con.execute(sql, [key]).fetchone()
|
"select blocks, blocks2, blocks3, blocks4, blocks5, blocks6, blocks7, blocks8 "
|
||||||
if result:
|
"from pictures "
|
||||||
result = string_to_colors(result[0])
|
"where path = ?"
|
||||||
|
)
|
||||||
|
blocks = self.con.execute(sql, [key]).fetchone()
|
||||||
|
if blocks:
|
||||||
|
result = [bytes_to_colors(block) for block in blocks]
|
||||||
return result
|
return result
|
||||||
else:
|
else:
|
||||||
raise KeyError(key)
|
raise KeyError(key)
|
||||||
@@ -56,35 +76,33 @@ class SqliteCache:
|
|||||||
return result[0][0]
|
return result[0][0]
|
||||||
|
|
||||||
def __setitem__(self, path_str, blocks):
|
def __setitem__(self, path_str, blocks):
|
||||||
blocks = colors_to_string(blocks)
|
blocks = [colors_to_bytes(block) for block in blocks]
|
||||||
if op.exists(path_str):
|
if op.exists(path_str):
|
||||||
mtime = int(os.stat(path_str).st_mtime)
|
mtime = int(os.stat(path_str).st_mtime)
|
||||||
else:
|
else:
|
||||||
mtime = 0
|
mtime = 0
|
||||||
if path_str in self:
|
if path_str in self:
|
||||||
sql = "update pictures set blocks = ?, mtime = ? where path = ?"
|
sql = (
|
||||||
|
"update pictures set blocks = ?, blocks2 = ?, blocks3 = ?, blocks4 = ?, blocks5 = ?, blocks6 = ?, "
|
||||||
|
"blocks7 = ?, blocks8 = ?, mtime_ns = ?"
|
||||||
|
"where path = ?"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
sql = "insert into pictures(blocks,mtime,path) values(?,?,?)"
|
sql = (
|
||||||
|
"insert into pictures(blocks,blocks2,blocks3,blocks4,blocks5,blocks6,blocks7,blocks8,mtime_ns,path) "
|
||||||
|
"values(?,?,?,?,?,?,?,?,?,?)"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
self.con.execute(sql, [blocks, mtime, path_str])
|
self.con.execute(sql, blocks + [mtime, path_str])
|
||||||
except sqlite.OperationalError:
|
except sqlite.OperationalError:
|
||||||
logging.warning("Picture cache could not set value for key %r", path_str)
|
logging.warning("Picture cache could not set value for key %r", path_str)
|
||||||
except sqlite.DatabaseError as e:
|
except sqlite.DatabaseError as e:
|
||||||
logging.warning("DatabaseError while setting value for key %r: %s", path_str, str(e))
|
logging.warning("DatabaseError while setting value for key %r: %s", path_str, str(e))
|
||||||
|
|
||||||
def _create_con(self, second_try=False):
|
def _create_con(self, second_try=False):
|
||||||
def create_tables():
|
|
||||||
logging.debug("Creating picture cache tables.")
|
|
||||||
self.con.execute("drop table if exists pictures")
|
|
||||||
self.con.execute("drop index if exists idx_path")
|
|
||||||
self.con.execute("create table pictures(path TEXT, mtime INTEGER, blocks TEXT)")
|
|
||||||
self.con.execute("create index idx_path on pictures (path)")
|
|
||||||
|
|
||||||
self.con = sqlite.connect(self.dbname, isolation_level=None)
|
|
||||||
try:
|
try:
|
||||||
self.con.execute("select path, mtime, blocks from pictures where 1=2")
|
self.con = sqlite.connect(self.dbname, isolation_level=None)
|
||||||
except sqlite.OperationalError: # new db
|
self._check_upgrade()
|
||||||
create_tables()
|
|
||||||
except sqlite.DatabaseError as e: # corrupted db
|
except sqlite.DatabaseError as e: # corrupted db
|
||||||
if second_try:
|
if second_try:
|
||||||
raise # Something really strange is happening
|
raise # Something really strange is happening
|
||||||
@@ -93,6 +111,25 @@ class SqliteCache:
|
|||||||
os.remove(self.dbname)
|
os.remove(self.dbname)
|
||||||
self._create_con(second_try=True)
|
self._create_con(second_try=True)
|
||||||
|
|
||||||
|
def _check_upgrade(self) -> None:
|
||||||
|
with self.con as conn:
|
||||||
|
has_schema = conn.execute(
|
||||||
|
"SELECT NAME FROM sqlite_master WHERE type='table' AND name='schema_version'"
|
||||||
|
).fetchall()
|
||||||
|
version = None
|
||||||
|
if has_schema:
|
||||||
|
version = conn.execute("SELECT version FROM schema_version ORDER BY version DESC").fetchone()[0]
|
||||||
|
else:
|
||||||
|
conn.execute("CREATE TABLE schema_version (version int PRIMARY KEY, description TEXT)")
|
||||||
|
if version != self.schema_version:
|
||||||
|
conn.execute(self.drop_table_query)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO schema_version VALUES (:version, :description)",
|
||||||
|
{"version": self.schema_version, "description": self.schema_version_description},
|
||||||
|
)
|
||||||
|
conn.execute(self.create_table_query)
|
||||||
|
conn.execute(self.create_index_query)
|
||||||
|
|
||||||
def clear(self):
|
def clear(self):
|
||||||
self.close()
|
self.close()
|
||||||
if self.dbname != ":memory:":
|
if self.dbname != ":memory:":
|
||||||
@@ -118,9 +155,28 @@ class SqliteCache:
|
|||||||
raise ValueError(path)
|
raise ValueError(path)
|
||||||
|
|
||||||
def get_multiple(self, rowids):
|
def get_multiple(self, rowids):
|
||||||
sql = "select rowid, blocks from pictures where rowid in (%s)" % ",".join(map(str, rowids))
|
ids = ",".join(map(str, rowids))
|
||||||
|
sql = (
|
||||||
|
"select rowid, blocks, blocks2, blocks3, blocks4, blocks5, blocks6, blocks7, blocks8 "
|
||||||
|
f"from pictures where rowid in ({ids})"
|
||||||
|
)
|
||||||
cur = self.con.execute(sql)
|
cur = self.con.execute(sql)
|
||||||
return ((rowid, string_to_colors(blocks)) for rowid, blocks in cur)
|
return (
|
||||||
|
(
|
||||||
|
rowid,
|
||||||
|
[
|
||||||
|
bytes_to_colors(blocks),
|
||||||
|
bytes_to_colors(blocks2),
|
||||||
|
bytes_to_colors(blocks3),
|
||||||
|
bytes_to_colors(blocks4),
|
||||||
|
bytes_to_colors(blocks5),
|
||||||
|
bytes_to_colors(blocks6),
|
||||||
|
bytes_to_colors(blocks7),
|
||||||
|
bytes_to_colors(blocks8),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
for rowid, blocks, blocks2, blocks3, blocks4, blocks5, blocks6, blocks7, blocks8 in cur
|
||||||
|
)
|
||||||
|
|
||||||
def purge_outdated(self):
|
def purge_outdated(self):
|
||||||
"""Go through the cache and purge outdated records.
|
"""Go through the cache and purge outdated records.
|
||||||
@@ -129,12 +185,12 @@ class SqliteCache:
|
|||||||
the db.
|
the db.
|
||||||
"""
|
"""
|
||||||
todelete = []
|
todelete = []
|
||||||
sql = "select rowid, path, mtime from pictures"
|
sql = "select rowid, path, mtime_ns from pictures"
|
||||||
cur = self.con.execute(sql)
|
cur = self.con.execute(sql)
|
||||||
for rowid, path_str, mtime in cur:
|
for rowid, path_str, mtime_ns in cur:
|
||||||
if mtime and op.exists(path_str):
|
if mtime_ns and op.exists(path_str):
|
||||||
picture_mtime = os.stat(path_str).st_mtime
|
picture_mtime = os.stat(path_str).st_mtime
|
||||||
if int(picture_mtime) <= mtime:
|
if int(picture_mtime) <= mtime_ns:
|
||||||
# not outdated
|
# not outdated
|
||||||
continue
|
continue
|
||||||
todelete.append(rowid)
|
todelete.append(rowid)
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
# Created By: Virgil Dupras
|
|
||||||
# Created On: 2014-03-15
|
|
||||||
# Copyright 2015 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
|
|
||||||
|
|
||||||
import plistlib
|
|
||||||
|
|
||||||
|
|
||||||
class IPhotoPlistParser(plistlib._PlistParser):
|
|
||||||
"""A parser for iPhoto plists.
|
|
||||||
|
|
||||||
iPhoto plists tend to be malformed, so we have to subclass the built-in parser to be a bit more
|
|
||||||
lenient.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
plistlib._PlistParser.__init__(self, use_builtin_types=True, dict_type=dict)
|
|
||||||
# For debugging purposes, we remember the last bit of data to be analyzed so that we can
|
|
||||||
# log it in case of an exception
|
|
||||||
self.lastdata = ""
|
|
||||||
|
|
||||||
def get_data(self):
|
|
||||||
self.lastdata = plistlib._PlistParser.get_data(self)
|
|
||||||
return self.lastdata
|
|
||||||
|
|
||||||
def end_integer(self):
|
|
||||||
try:
|
|
||||||
self.add_object(int(self.get_data()))
|
|
||||||
except ValueError:
|
|
||||||
self.add_object(0)
|
|
||||||
@@ -15,7 +15,8 @@ from hscommon.trans import tr
|
|||||||
from hscommon.jobprogress import job
|
from hscommon.jobprogress import job
|
||||||
|
|
||||||
from core.engine import Match
|
from core.engine import Match
|
||||||
from .block import avgdiff, DifferentBlockCountError, NoBlocksError
|
from core.pe.block import avgdiff, DifferentBlockCountError, NoBlocksError
|
||||||
|
from core.pe.cache_sqlite import SqliteCache
|
||||||
|
|
||||||
# OPTIMIZATION NOTES:
|
# OPTIMIZATION NOTES:
|
||||||
# The bottleneck of the matching phase is CPU, which is why we use multiprocessing. However, another
|
# The bottleneck of the matching phase is CPU, which is why we use multiprocessing. However, another
|
||||||
@@ -27,7 +28,7 @@ from .block import avgdiff, DifferentBlockCountError, NoBlocksError
|
|||||||
# to files in other chunks. So chunkifying doesn't save us any actual comparison, but the advantage
|
# to files in other chunks. So chunkifying doesn't save us any actual comparison, but the advantage
|
||||||
# is that instead of reading blocks from disk number_of_files**2 times, we read it
|
# is that instead of reading blocks from disk number_of_files**2 times, we read it
|
||||||
# number_of_files*number_of_chunks times.
|
# number_of_files*number_of_chunks times.
|
||||||
# Determining the right chunk size is tricky, bceause if it's too big, too many blocks will be in
|
# Determining the right chunk size is tricky, because if it's too big, too many blocks will be in
|
||||||
# memory at the same time and we might end up with memory trashing, which is awfully slow. So,
|
# memory at the same time and we might end up with memory trashing, which is awfully slow. So,
|
||||||
# because our *real* bottleneck is CPU, the chunk size must simply be enough so that the CPU isn't
|
# because our *real* bottleneck is CPU, the chunk size must simply be enough so that the CPU isn't
|
||||||
# starved by Disk IOs.
|
# starved by Disk IOs.
|
||||||
@@ -50,17 +51,10 @@ except Exception:
|
|||||||
|
|
||||||
|
|
||||||
def get_cache(cache_path, readonly=False):
|
def get_cache(cache_path, readonly=False):
|
||||||
if cache_path.endswith("shelve"):
|
return SqliteCache(cache_path, readonly=readonly)
|
||||||
from .cache_shelve import ShelveCache
|
|
||||||
|
|
||||||
return ShelveCache(cache_path, readonly=readonly)
|
|
||||||
else:
|
|
||||||
from .cache_sqlite import SqliteCache
|
|
||||||
|
|
||||||
return SqliteCache(cache_path, readonly=readonly)
|
|
||||||
|
|
||||||
|
|
||||||
def prepare_pictures(pictures, cache_path, with_dimensions, j=job.nulljob):
|
def prepare_pictures(pictures, cache_path, with_dimensions, match_rotated, j=job.nulljob):
|
||||||
# The MemoryError handlers in there use logging without first caring about whether or not
|
# The MemoryError handlers in there use logging without first caring about whether or not
|
||||||
# there is enough memory left to carry on the operation because it is assumed that the
|
# there is enough memory left to carry on the operation because it is assumed that the
|
||||||
# MemoryError happens when trying to read an image file, which is freed from memory by the
|
# MemoryError happens when trying to read an image file, which is freed from memory by the
|
||||||
@@ -78,16 +72,21 @@ def prepare_pictures(pictures, cache_path, with_dimensions, j=job.nulljob):
|
|||||||
# entry in iPhoto library.
|
# entry in iPhoto library.
|
||||||
logging.warning("We have a picture with a null path here")
|
logging.warning("We have a picture with a null path here")
|
||||||
continue
|
continue
|
||||||
picture.unicode_path = str(picture.path)
|
|
||||||
logging.debug("Analyzing picture at %s", picture.unicode_path)
|
logging.debug("Analyzing picture at %s", picture.unicode_path)
|
||||||
if with_dimensions:
|
if with_dimensions:
|
||||||
picture.dimensions # pre-read dimensions
|
picture.dimensions # pre-read dimensions
|
||||||
try:
|
try:
|
||||||
if picture.unicode_path not in cache:
|
if picture.unicode_path not in cache or (
|
||||||
blocks = picture.get_blocks(BLOCK_COUNT_PER_SIDE)
|
match_rotated and any(block == [] for block in cache[picture.unicode_path])
|
||||||
|
):
|
||||||
|
if match_rotated:
|
||||||
|
blocks = [picture.get_blocks(BLOCK_COUNT_PER_SIDE, orientation) for orientation in range(1, 9)]
|
||||||
|
else:
|
||||||
|
blocks = [[]] * 8
|
||||||
|
blocks[max(picture.get_orientation() - 1, 0)] = picture.get_blocks(BLOCK_COUNT_PER_SIDE)
|
||||||
cache[picture.unicode_path] = blocks
|
cache[picture.unicode_path] = blocks
|
||||||
prepared.append(picture)
|
prepared.append(picture)
|
||||||
except (IOError, ValueError) as e:
|
except (OSError, ValueError) as e:
|
||||||
logging.warning(str(e))
|
logging.warning(str(e))
|
||||||
except MemoryError:
|
except MemoryError:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
@@ -125,13 +124,13 @@ def get_match(first, second, percentage):
|
|||||||
return Match(first, second, percentage)
|
return Match(first, second, percentage)
|
||||||
|
|
||||||
|
|
||||||
def async_compare(ref_ids, other_ids, dbname, threshold, picinfo):
|
def async_compare(ref_ids, other_ids, dbname, threshold, picinfo, match_rotated=False):
|
||||||
# The list of ids in ref_ids have to be compared to the list of ids in other_ids. other_ids
|
# The list of ids in ref_ids have to be compared to the list of ids in other_ids. other_ids
|
||||||
# can be None. In this case, ref_ids has to be compared with itself
|
# can be None. In this case, ref_ids has to be compared with itself
|
||||||
# picinfo is a dictionary {pic_id: (dimensions, is_ref)}
|
# picinfo is a dictionary {pic_id: (dimensions, is_ref)}
|
||||||
cache = get_cache(dbname, readonly=True)
|
cache = get_cache(dbname, readonly=True)
|
||||||
limit = 100 - threshold
|
limit = 100 - threshold
|
||||||
ref_pairs = list(cache.get_multiple(ref_ids))
|
ref_pairs = list(cache.get_multiple(ref_ids)) # (rowid, [b, b2, ..., b8])
|
||||||
if other_ids is not None:
|
if other_ids is not None:
|
||||||
other_pairs = list(cache.get_multiple(other_ids))
|
other_pairs = list(cache.get_multiple(other_ids))
|
||||||
comparisons_to_do = [(r, o) for r in ref_pairs for o in other_pairs]
|
comparisons_to_do = [(r, o) for r in ref_pairs for o in other_pairs]
|
||||||
@@ -144,22 +143,35 @@ def async_compare(ref_ids, other_ids, dbname, threshold, picinfo):
|
|||||||
if ref_is_ref and other_is_ref:
|
if ref_is_ref and other_is_ref:
|
||||||
continue
|
continue
|
||||||
if ref_dimensions != other_dimensions:
|
if ref_dimensions != other_dimensions:
|
||||||
continue
|
if match_rotated:
|
||||||
try:
|
rotated_ref_dimensions = (ref_dimensions[1], ref_dimensions[0])
|
||||||
diff = avgdiff(ref_blocks, other_blocks, limit, MIN_ITERATIONS)
|
if rotated_ref_dimensions != other_dimensions:
|
||||||
percentage = 100 - diff
|
continue
|
||||||
except (DifferentBlockCountError, NoBlocksError):
|
else:
|
||||||
percentage = 0
|
continue
|
||||||
if percentage >= threshold:
|
|
||||||
results.append((ref_id, other_id, percentage))
|
orientation_range = 1
|
||||||
|
if match_rotated:
|
||||||
|
orientation_range = 8
|
||||||
|
|
||||||
|
for orientation_ref in range(orientation_range):
|
||||||
|
try:
|
||||||
|
diff = avgdiff(ref_blocks[orientation_ref], other_blocks[0], limit, MIN_ITERATIONS)
|
||||||
|
percentage = 100 - diff
|
||||||
|
except (DifferentBlockCountError, NoBlocksError):
|
||||||
|
percentage = 0
|
||||||
|
if percentage >= threshold:
|
||||||
|
results.append((ref_id, other_id, percentage))
|
||||||
|
break
|
||||||
|
|
||||||
cache.close()
|
cache.close()
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def getmatches(pictures, cache_path, threshold, match_scaled=False, j=job.nulljob):
|
def getmatches(pictures, cache_path, threshold, match_scaled=False, match_rotated=False, j=job.nulljob):
|
||||||
def get_picinfo(p):
|
def get_picinfo(p):
|
||||||
if match_scaled:
|
if match_scaled:
|
||||||
return (None, p.is_ref)
|
return ((None, None), p.is_ref)
|
||||||
else:
|
else:
|
||||||
return (p.dimensions, p.is_ref)
|
return (p.dimensions, p.is_ref)
|
||||||
|
|
||||||
@@ -181,7 +193,7 @@ def getmatches(pictures, cache_path, threshold, match_scaled=False, j=job.nulljo
|
|||||||
j.set_progress(comparison_count, progress_msg)
|
j.set_progress(comparison_count, progress_msg)
|
||||||
|
|
||||||
j = j.start_subjob([3, 7])
|
j = j.start_subjob([3, 7])
|
||||||
pictures = prepare_pictures(pictures, cache_path, with_dimensions=not match_scaled, j=j)
|
pictures = prepare_pictures(pictures, cache_path, not match_scaled, match_rotated, j=j)
|
||||||
j = j.start_subjob([9, 1], tr("Preparing for matching"))
|
j = j.start_subjob([9, 1], tr("Preparing for matching"))
|
||||||
cache = get_cache(cache_path)
|
cache = get_cache(cache_path)
|
||||||
id2picture = {}
|
id2picture = {}
|
||||||
@@ -211,7 +223,7 @@ def getmatches(pictures, cache_path, threshold, match_scaled=False, j=job.nulljo
|
|||||||
picinfo.update({p.cache_id: get_picinfo(p) for p in other_chunk})
|
picinfo.update({p.cache_id: get_picinfo(p) for p in other_chunk})
|
||||||
else:
|
else:
|
||||||
other_ids = None
|
other_ids = None
|
||||||
args = (ref_ids, other_ids, cache_path, threshold, picinfo)
|
args = (ref_ids, other_ids, cache_path, threshold, picinfo, match_rotated)
|
||||||
async_results.append(pool.apply_async(async_compare, args))
|
async_results.append(pool.apply_async(async_compare, args))
|
||||||
collect_results()
|
collect_results()
|
||||||
collect_results(collect_all=True)
|
collect_results(collect_all=True)
|
||||||
|
|||||||
@@ -245,4 +245,4 @@ PyObject *PyInit__block(void) {
|
|||||||
PyModule_AddObject(m, "DifferentBlockCountError", DifferentBlockCountError);
|
PyModule_AddObject(m, "DifferentBlockCountError", DifferentBlockCountError);
|
||||||
|
|
||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
* Created On: 2010-02-04
|
* Created On: 2010-02-04
|
||||||
* Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
* Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||||
*
|
*
|
||||||
* This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
* 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
|
* which should be included with this package. The terms are also available at
|
||||||
* http://www.gnu.org/licenses/gpl-3.0.html
|
* http://www.gnu.org/licenses/gpl-3.0.html
|
||||||
**/
|
**/
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ pystring2cfstring(PyObject *pystring)
|
|||||||
UInt8 *s;
|
UInt8 *s;
|
||||||
CFIndex size;
|
CFIndex size;
|
||||||
CFStringRef result;
|
CFStringRef result;
|
||||||
|
|
||||||
if (PyUnicode_Check(pystring)) {
|
if (PyUnicode_Check(pystring)) {
|
||||||
encoded = PyUnicode_AsUTF8String(pystring);
|
encoded = PyUnicode_AsUTF8String(pystring);
|
||||||
if (encoded == NULL) {
|
if (encoded == NULL) {
|
||||||
@@ -32,7 +32,7 @@ pystring2cfstring(PyObject *pystring)
|
|||||||
encoded = pystring;
|
encoded = pystring;
|
||||||
Py_INCREF(encoded);
|
Py_INCREF(encoded);
|
||||||
}
|
}
|
||||||
|
|
||||||
s = (UInt8*)PyBytes_AS_STRING(encoded);
|
s = (UInt8*)PyBytes_AS_STRING(encoded);
|
||||||
size = PyBytes_GET_SIZE(encoded);
|
size = PyBytes_GET_SIZE(encoded);
|
||||||
result = CFStringCreateWithBytes(NULL, s, size, kCFStringEncodingUTF8, FALSE);
|
result = CFStringCreateWithBytes(NULL, s, size, kCFStringEncodingUTF8, FALSE);
|
||||||
@@ -50,20 +50,20 @@ static PyObject* block_osx_get_image_size(PyObject *self, PyObject *args)
|
|||||||
long width, height;
|
long width, height;
|
||||||
PyObject *pwidth, *pheight;
|
PyObject *pwidth, *pheight;
|
||||||
PyObject *result;
|
PyObject *result;
|
||||||
|
|
||||||
width = 0;
|
width = 0;
|
||||||
height = 0;
|
height = 0;
|
||||||
if (!PyArg_ParseTuple(args, "O", &path)) {
|
if (!PyArg_ParseTuple(args, "O", &path)) {
|
||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
image_path = pystring2cfstring(path);
|
image_path = pystring2cfstring(path);
|
||||||
if (image_path == NULL) {
|
if (image_path == NULL) {
|
||||||
return PyErr_NoMemory();
|
return PyErr_NoMemory();
|
||||||
}
|
}
|
||||||
image_url = CFURLCreateWithFileSystemPath(NULL, image_path, kCFURLPOSIXPathStyle, FALSE);
|
image_url = CFURLCreateWithFileSystemPath(NULL, image_path, kCFURLPOSIXPathStyle, FALSE);
|
||||||
CFRelease(image_path);
|
CFRelease(image_path);
|
||||||
|
|
||||||
source = CGImageSourceCreateWithURL(image_url, NULL);
|
source = CGImageSourceCreateWithURL(image_url, NULL);
|
||||||
CFRelease(image_url);
|
CFRelease(image_url);
|
||||||
if (source != NULL) {
|
if (source != NULL) {
|
||||||
@@ -75,7 +75,7 @@ static PyObject* block_osx_get_image_size(PyObject *self, PyObject *args)
|
|||||||
}
|
}
|
||||||
CFRelease(source);
|
CFRelease(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
pwidth = PyLong_FromLong(width);
|
pwidth = PyLong_FromLong(width);
|
||||||
if (pwidth == NULL) {
|
if (pwidth == NULL) {
|
||||||
return NULL;
|
return NULL;
|
||||||
@@ -91,19 +91,19 @@ static PyObject* block_osx_get_image_size(PyObject *self, PyObject *args)
|
|||||||
}
|
}
|
||||||
|
|
||||||
static CGContextRef
|
static CGContextRef
|
||||||
MyCreateBitmapContext(int width, int height)
|
MyCreateBitmapContext(int width, int height)
|
||||||
{
|
{
|
||||||
CGContextRef context = NULL;
|
CGContextRef context = NULL;
|
||||||
CGColorSpaceRef colorSpace;
|
CGColorSpaceRef colorSpace;
|
||||||
void *bitmapData;
|
void *bitmapData;
|
||||||
int bitmapByteCount;
|
int bitmapByteCount;
|
||||||
int bitmapBytesPerRow;
|
int bitmapBytesPerRow;
|
||||||
|
|
||||||
bitmapBytesPerRow = (width * 4);
|
bitmapBytesPerRow = (width * 4);
|
||||||
bitmapByteCount = (bitmapBytesPerRow * height);
|
bitmapByteCount = (bitmapBytesPerRow * height);
|
||||||
|
|
||||||
colorSpace = CGColorSpaceCreateWithName(kCGColorSpaceGenericRGB);
|
colorSpace = CGColorSpaceCreateWithName(kCGColorSpaceGenericRGB);
|
||||||
|
|
||||||
// calloc() must be used to allocate bitmapData here because the buffer has to be zeroed.
|
// calloc() must be used to allocate bitmapData here because the buffer has to be zeroed.
|
||||||
// If it's not zeroes, when images with transparency are drawn in the context, this buffer
|
// If it's not zeroes, when images with transparency are drawn in the context, this buffer
|
||||||
// will stay with undefined pixels, which means that two pictures with the same pixels will
|
// will stay with undefined pixels, which means that two pictures with the same pixels will
|
||||||
@@ -113,7 +113,7 @@ MyCreateBitmapContext(int width, int height)
|
|||||||
fprintf(stderr, "Memory not allocated!");
|
fprintf(stderr, "Memory not allocated!");
|
||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
context = CGBitmapContextCreate(bitmapData, width, height, 8, bitmapBytesPerRow, colorSpace,
|
context = CGBitmapContextCreate(bitmapData, width, height, 8, bitmapBytesPerRow, colorSpace,
|
||||||
(CGBitmapInfo)kCGImageAlphaNoneSkipLast);
|
(CGBitmapInfo)kCGImageAlphaNoneSkipLast);
|
||||||
if (context== NULL) {
|
if (context== NULL) {
|
||||||
@@ -128,7 +128,7 @@ MyCreateBitmapContext(int width, int height)
|
|||||||
static PyObject* getblock(unsigned char *imageData, int imageWidth, int imageHeight, int boxX, int boxY, int boxW, int boxH)
|
static PyObject* getblock(unsigned char *imageData, int imageWidth, int imageHeight, int boxX, int boxY, int boxW, int boxH)
|
||||||
{
|
{
|
||||||
int i,j, totalR, totalG, totalB;
|
int i,j, totalR, totalG, totalB;
|
||||||
|
|
||||||
totalR = totalG = totalB = 0;
|
totalR = totalG = totalB = 0;
|
||||||
for(i=boxY; i<boxY+boxH; i++) {
|
for(i=boxY; i<boxY+boxH; i++) {
|
||||||
for(j=boxX; j<boxX+boxW; j++) {
|
for(j=boxX; j<boxX+boxW; j++) {
|
||||||
@@ -142,7 +142,7 @@ static PyObject* getblock(unsigned char *imageData, int imageWidth, int imageHei
|
|||||||
totalR /= pixelCount;
|
totalR /= pixelCount;
|
||||||
totalG /= pixelCount;
|
totalG /= pixelCount;
|
||||||
totalB /= pixelCount;
|
totalB /= pixelCount;
|
||||||
|
|
||||||
return inttuple(3, totalR, totalG, totalB);
|
return inttuple(3, totalR, totalG, totalB);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,27 +155,27 @@ static PyObject* block_osx_getblocks(PyObject *self, PyObject *args)
|
|||||||
CGImageRef image;
|
CGImageRef image;
|
||||||
size_t width, height, image_width, image_height;
|
size_t width, height, image_width, image_height;
|
||||||
int block_count, block_width, block_height, orientation, i;
|
int block_count, block_width, block_height, orientation, i;
|
||||||
|
|
||||||
if (!PyArg_ParseTuple(args, "Oii", &path, &block_count, &orientation)) {
|
if (!PyArg_ParseTuple(args, "Oii", &path, &block_count, &orientation)) {
|
||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (PySequence_Length(path) == 0) {
|
if (PySequence_Length(path) == 0) {
|
||||||
PyErr_SetString(PyExc_ValueError, "empty path");
|
PyErr_SetString(PyExc_ValueError, "empty path");
|
||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((orientation > 8) || (orientation < 0)) {
|
if ((orientation > 8) || (orientation < 0)) {
|
||||||
orientation = 0; // simplifies checks later since we can only have values in 0-8
|
orientation = 0; // simplifies checks later since we can only have values in 0-8
|
||||||
}
|
}
|
||||||
|
|
||||||
image_path = pystring2cfstring(path);
|
image_path = pystring2cfstring(path);
|
||||||
if (image_path == NULL) {
|
if (image_path == NULL) {
|
||||||
return PyErr_NoMemory();
|
return PyErr_NoMemory();
|
||||||
}
|
}
|
||||||
image_url = CFURLCreateWithFileSystemPath(NULL, image_path, kCFURLPOSIXPathStyle, FALSE);
|
image_url = CFURLCreateWithFileSystemPath(NULL, image_path, kCFURLPOSIXPathStyle, FALSE);
|
||||||
CFRelease(image_path);
|
CFRelease(image_path);
|
||||||
|
|
||||||
source = CGImageSourceCreateWithURL(image_url, NULL);
|
source = CGImageSourceCreateWithURL(image_url, NULL);
|
||||||
CFRelease(image_url);
|
CFRelease(image_url);
|
||||||
if (source == NULL) {
|
if (source == NULL) {
|
||||||
@@ -187,8 +187,8 @@ static PyObject* block_osx_getblocks(PyObject *self, PyObject *args)
|
|||||||
CFRelease(source);
|
CFRelease(source);
|
||||||
return PyErr_NoMemory();
|
return PyErr_NoMemory();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
width = image_width = CGImageGetWidth(image);
|
width = image_width = CGImageGetWidth(image);
|
||||||
height = image_height = CGImageGetHeight(image);
|
height = image_height = CGImageGetHeight(image);
|
||||||
if (orientation >= 5) {
|
if (orientation >= 5) {
|
||||||
@@ -196,9 +196,9 @@ static PyObject* block_osx_getblocks(PyObject *self, PyObject *args)
|
|||||||
width = image_height;
|
width = image_height;
|
||||||
height = image_width;
|
height = image_width;
|
||||||
}
|
}
|
||||||
|
|
||||||
CGContextRef context = MyCreateBitmapContext(width, height);
|
CGContextRef context = MyCreateBitmapContext(width, height);
|
||||||
|
|
||||||
if (orientation == 2) {
|
if (orientation == 2) {
|
||||||
// Flip X
|
// Flip X
|
||||||
CGContextTranslateCTM(context, width, 0);
|
CGContextTranslateCTM(context, width, 0);
|
||||||
@@ -207,7 +207,7 @@ static PyObject* block_osx_getblocks(PyObject *self, PyObject *args)
|
|||||||
else if (orientation == 3) {
|
else if (orientation == 3) {
|
||||||
// Rot 180
|
// Rot 180
|
||||||
CGContextTranslateCTM(context, width, height);
|
CGContextTranslateCTM(context, width, height);
|
||||||
CGContextRotateCTM(context, RADIANS(180));
|
CGContextRotateCTM(context, RADIANS(180));
|
||||||
}
|
}
|
||||||
else if (orientation == 4) {
|
else if (orientation == 4) {
|
||||||
// Flip Y
|
// Flip Y
|
||||||
@@ -242,21 +242,21 @@ static PyObject* block_osx_getblocks(PyObject *self, PyObject *args)
|
|||||||
CGContextDrawImage(context, myBoundingBox, image);
|
CGContextDrawImage(context, myBoundingBox, image);
|
||||||
unsigned char *bitmapData = CGBitmapContextGetData(context);
|
unsigned char *bitmapData = CGBitmapContextGetData(context);
|
||||||
CGContextRelease(context);
|
CGContextRelease(context);
|
||||||
|
|
||||||
CGImageRelease(image);
|
CGImageRelease(image);
|
||||||
CFRelease(source);
|
CFRelease(source);
|
||||||
if (bitmapData == NULL) {
|
if (bitmapData == NULL) {
|
||||||
return PyErr_NoMemory();
|
return PyErr_NoMemory();
|
||||||
}
|
}
|
||||||
|
|
||||||
block_width = max(width/block_count, 1);
|
block_width = max(width/block_count, 1);
|
||||||
block_height = max(height/block_count, 1);
|
block_height = max(height/block_count, 1);
|
||||||
|
|
||||||
result = PyList_New(block_count * block_count);
|
result = PyList_New(block_count * block_count);
|
||||||
if (result == NULL) {
|
if (result == NULL) {
|
||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
for(i=0; i<block_count; i++) {
|
for(i=0; i<block_count; i++) {
|
||||||
int j, top;
|
int j, top;
|
||||||
top = min(i*block_height, height-block_height);
|
top = min(i*block_height, height-block_height);
|
||||||
@@ -271,8 +271,8 @@ static PyObject* block_osx_getblocks(PyObject *self, PyObject *args)
|
|||||||
PyList_SET_ITEM(result, i*block_count+j, block);
|
PyList_SET_ITEM(result, i*block_count+j, block);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
free(bitmapData);
|
free(bitmapData);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,4 +302,4 @@ PyInit__block_osx(void)
|
|||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
return m;
|
return m;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,94 +2,68 @@
|
|||||||
* Created On: 2010-01-30
|
* Created On: 2010-01-30
|
||||||
* Copyright 2014 Hardcoded Software (http://www.hardcoded.net)
|
* Copyright 2014 Hardcoded Software (http://www.hardcoded.net)
|
||||||
*
|
*
|
||||||
* This software is licensed under the "BSD" License as described in the "LICENSE" file,
|
* This software is licensed under the "BSD" License as described in the
|
||||||
* which should be included with this package. The terms are also available at
|
* "LICENSE" file, which should be included with this package. The terms are
|
||||||
* http://www.hardcoded.net/licenses/bsd_license
|
* also available at http://www.hardcoded.net/licenses/bsd_license
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include "common.h"
|
#include "common.h"
|
||||||
|
|
||||||
/* I know that there strtol out there, but it requires a pointer to
|
static PyObject *cache_bytes_to_colors(PyObject *self, PyObject *args) {
|
||||||
* a char, which would in turn require me to buffer my chars around,
|
char *y;
|
||||||
* making the whole process slower.
|
Py_ssize_t char_count, i, color_count;
|
||||||
*/
|
PyObject *result;
|
||||||
static long
|
unsigned long r, g, b;
|
||||||
xchar_to_long(char c)
|
Py_ssize_t ci;
|
||||||
{
|
PyObject *color_tuple;
|
||||||
if ((c >= 48) && (c <= 57)) { /* 0-9 */
|
|
||||||
return c - 48;
|
|
||||||
}
|
|
||||||
else if ((c >= 65) && (c <= 70)) { /* A-F */
|
|
||||||
return c - 55;
|
|
||||||
}
|
|
||||||
else if ((c >= 97) && (c <= 102)) { /* a-f */
|
|
||||||
return c - 87;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
static PyObject*
|
if (!PyArg_ParseTuple(args, "y#", &y, &char_count)) {
|
||||||
cache_string_to_colors(PyObject *self, PyObject *args)
|
return NULL;
|
||||||
{
|
}
|
||||||
char *s;
|
|
||||||
Py_ssize_t char_count, color_count, i;
|
color_count = char_count / 3;
|
||||||
PyObject *result;
|
result = PyList_New(color_count);
|
||||||
|
if (result == NULL) {
|
||||||
if (!PyArg_ParseTuple(args, "s#", &s, &char_count)) {
|
return NULL;
|
||||||
return NULL;
|
}
|
||||||
|
|
||||||
|
for (i = 0; i < color_count; i++) {
|
||||||
|
ci = i * 3;
|
||||||
|
r = (unsigned char)y[ci];
|
||||||
|
g = (unsigned char)y[ci + 1];
|
||||||
|
b = (unsigned char)y[ci + 2];
|
||||||
|
|
||||||
|
color_tuple = inttuple(3, r, g, b);
|
||||||
|
if (color_tuple == NULL) {
|
||||||
|
Py_DECREF(result);
|
||||||
|
return NULL;
|
||||||
}
|
}
|
||||||
|
PyList_SET_ITEM(result, i, color_tuple);
|
||||||
color_count = (char_count / 6);
|
}
|
||||||
result = PyList_New(color_count);
|
|
||||||
if (result == NULL) {
|
return result;
|
||||||
return NULL;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (i=0; i<color_count; i++) {
|
|
||||||
long r, g, b;
|
|
||||||
Py_ssize_t ci;
|
|
||||||
PyObject *color_tuple;
|
|
||||||
|
|
||||||
ci = i * 6;
|
|
||||||
r = (xchar_to_long(s[ci]) << 4) + xchar_to_long(s[ci+1]);
|
|
||||||
g = (xchar_to_long(s[ci+2]) << 4) + xchar_to_long(s[ci+3]);
|
|
||||||
b = (xchar_to_long(s[ci+4]) << 4) + xchar_to_long(s[ci+5]);
|
|
||||||
|
|
||||||
color_tuple = inttuple(3, r, g, b);
|
|
||||||
if (color_tuple == NULL) {
|
|
||||||
Py_DECREF(result);
|
|
||||||
return NULL;
|
|
||||||
}
|
|
||||||
PyList_SET_ITEM(result, i, color_tuple);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static PyMethodDef CacheMethods[] = {
|
static PyMethodDef CacheMethods[] = {
|
||||||
{"string_to_colors", cache_string_to_colors, METH_VARARGS,
|
{"bytes_to_colors", cache_bytes_to_colors, METH_VARARGS,
|
||||||
"Transform the string 's' in a list of 3 sized tuples."},
|
"Transform the bytes 's' into a list of 3 sized tuples."},
|
||||||
{NULL, NULL, 0, NULL} /* Sentinel */
|
{NULL, NULL, 0, NULL} /* Sentinel */
|
||||||
};
|
};
|
||||||
|
|
||||||
static struct PyModuleDef CacheDef = {
|
static struct PyModuleDef CacheDef = {PyModuleDef_HEAD_INIT,
|
||||||
PyModuleDef_HEAD_INIT,
|
"_cache",
|
||||||
"_cache",
|
NULL,
|
||||||
NULL,
|
-1,
|
||||||
-1,
|
CacheMethods,
|
||||||
CacheMethods,
|
NULL,
|
||||||
NULL,
|
NULL,
|
||||||
NULL,
|
NULL,
|
||||||
NULL,
|
NULL};
|
||||||
NULL
|
|
||||||
};
|
|
||||||
|
|
||||||
PyObject *
|
PyObject *PyInit__cache(void) {
|
||||||
PyInit__cache(void)
|
PyObject *m = PyModule_Create(&CacheDef);
|
||||||
{
|
if (m == NULL) {
|
||||||
PyObject *m = PyModule_Create(&CacheDef);
|
return NULL;
|
||||||
if (m == NULL) {
|
}
|
||||||
return NULL;
|
return m;
|
||||||
}
|
}
|
||||||
return m;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
* Created On: 2010-02-04
|
* Created On: 2010-02-04
|
||||||
* Copyright 2014 Hardcoded Software (http://www.hardcoded.net)
|
* Copyright 2014 Hardcoded Software (http://www.hardcoded.net)
|
||||||
*
|
*
|
||||||
* This software is licensed under the "BSD" License as described in the "LICENSE" file,
|
* 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
|
* which should be included with this package. The terms are also available at
|
||||||
* http://www.hardcoded.net/licenses/bsd_license
|
* http://www.hardcoded.net/licenses/bsd_license
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -27,19 +27,19 @@ PyObject* inttuple(int n, ...)
|
|||||||
PyObject *pnumber;
|
PyObject *pnumber;
|
||||||
PyObject *result;
|
PyObject *result;
|
||||||
va_list numbers;
|
va_list numbers;
|
||||||
|
|
||||||
va_start(numbers, n);
|
va_start(numbers, n);
|
||||||
result = PyTuple_New(n);
|
result = PyTuple_New(n);
|
||||||
|
|
||||||
for (i=0; i<n; i++) {
|
for (i=0; i<n; i++) {
|
||||||
pnumber = PyLong_FromLong(va_arg(numbers, long));
|
pnumber = PyLong_FromUnsignedLong(va_arg(numbers, long));
|
||||||
if (pnumber == NULL) {
|
if (pnumber == NULL) {
|
||||||
Py_DECREF(result);
|
Py_DECREF(result);
|
||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
PyTuple_SET_ITEM(result, i, pnumber);
|
PyTuple_SET_ITEM(result, i, pnumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
va_end(numbers);
|
va_end(numbers);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
* Created On: 2010-02-04
|
* Created On: 2010-02-04
|
||||||
* Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
* Copyright 2015 Hardcoded Software (http://www.hardcoded.net)
|
||||||
*
|
*
|
||||||
* This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
* 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
|
* which should be included with this package. The terms are also available at
|
||||||
* http://www.gnu.org/licenses/gpl-3.0.html
|
* http://www.gnu.org/licenses/gpl-3.0.html
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -17,4 +17,4 @@ int min(int a, int b);
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
/* Create a tuple out of an array of integers. */
|
/* Create a tuple out of an array of integers. */
|
||||||
PyObject* inttuple(int n, ...);
|
PyObject* inttuple(int n, ...);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from hscommon.util import get_file_ext, format_size
|
|||||||
|
|
||||||
from core.util import format_timestamp, format_perc, format_dupe_count
|
from core.util import format_timestamp, format_perc, format_dupe_count
|
||||||
from core import fs
|
from core import fs
|
||||||
from . import exif
|
from core.pe import exif
|
||||||
|
|
||||||
# This global value is set by the platform-specific subclasser of the Photo base class
|
# This global value is set by the platform-specific subclasser of the Photo base class
|
||||||
PLAT_SPECIFIC_PHOTO_CLASS = None
|
PLAT_SPECIFIC_PHOTO_CLASS = None
|
||||||
@@ -29,7 +29,7 @@ class Photo(fs.File):
|
|||||||
__slots__ = fs.File.__slots__ + tuple(INITIAL_INFO.keys())
|
__slots__ = fs.File.__slots__ + tuple(INITIAL_INFO.keys())
|
||||||
|
|
||||||
# These extensions are supported on all platforms
|
# These extensions are supported on all platforms
|
||||||
HANDLED_EXTS = {"png", "jpg", "jpeg", "gif", "bmp", "tiff", "tif"}
|
HANDLED_EXTS = {"png", "jpg", "jpeg", "gif", "bmp", "tiff", "tif", "webp"}
|
||||||
|
|
||||||
def _plat_get_dimensions(self):
|
def _plat_get_dimensions(self):
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
@@ -37,7 +37,7 @@ class Photo(fs.File):
|
|||||||
def _plat_get_blocks(self, block_count_per_side, orientation):
|
def _plat_get_blocks(self, block_count_per_side, orientation):
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
def _get_orientation(self):
|
def get_orientation(self):
|
||||||
if not hasattr(self, "_cached_orientation"):
|
if not hasattr(self, "_cached_orientation"):
|
||||||
try:
|
try:
|
||||||
with self.path.open("rb") as fp:
|
with self.path.open("rb") as fp:
|
||||||
@@ -95,10 +95,13 @@ class Photo(fs.File):
|
|||||||
fs.File._read_info(self, field)
|
fs.File._read_info(self, field)
|
||||||
if field == "dimensions":
|
if field == "dimensions":
|
||||||
self.dimensions = self._plat_get_dimensions()
|
self.dimensions = self._plat_get_dimensions()
|
||||||
if self._get_orientation() in {5, 6, 7, 8}:
|
if self.get_orientation() in {5, 6, 7, 8}:
|
||||||
self.dimensions = (self.dimensions[1], self.dimensions[0])
|
self.dimensions = (self.dimensions[1], self.dimensions[0])
|
||||||
elif field == "exif_timestamp":
|
elif field == "exif_timestamp":
|
||||||
self.exif_timestamp = self._get_exif_timestamp()
|
self.exif_timestamp = self._get_exif_timestamp()
|
||||||
|
|
||||||
def get_blocks(self, block_count_per_side):
|
def get_blocks(self, block_count_per_side, orientation: int = None):
|
||||||
return self._plat_get_blocks(block_count_per_side, self._get_orientation())
|
if orientation is None:
|
||||||
|
return self._plat_get_blocks(block_count_per_side, self.get_orientation())
|
||||||
|
else:
|
||||||
|
return self._plat_get_blocks(block_count_per_side, orientation)
|
||||||
|
|||||||
@@ -8,12 +8,13 @@ from hscommon.trans import tr
|
|||||||
|
|
||||||
from core.scanner import Scanner, ScanType, ScanOption
|
from core.scanner import Scanner, ScanType, ScanOption
|
||||||
|
|
||||||
from . import matchblock, matchexif
|
from core.pe import matchblock, matchexif
|
||||||
|
|
||||||
|
|
||||||
class ScannerPE(Scanner):
|
class ScannerPE(Scanner):
|
||||||
cache_path = None
|
cache_path = None
|
||||||
match_scaled = False
|
match_scaled = False
|
||||||
|
match_rotated = False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_scan_options():
|
def get_scan_options():
|
||||||
@@ -29,6 +30,7 @@ class ScannerPE(Scanner):
|
|||||||
cache_path=self.cache_path,
|
cache_path=self.cache_path,
|
||||||
threshold=self.min_match_percentage,
|
threshold=self.min_match_percentage,
|
||||||
match_scaled=self.match_scaled,
|
match_scaled=self.match_scaled,
|
||||||
|
match_rotated=self.match_rotated,
|
||||||
j=j,
|
j=j,
|
||||||
)
|
)
|
||||||
elif self.scan_type == ScanType.EXIFTIMESTAMP:
|
elif self.scan_type == ScanType.EXIFTIMESTAMP:
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class Criterion:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def display(self):
|
def display(self):
|
||||||
return "{} ({})".format(self.category.NAME, self.display_value)
|
return f"{self.category.NAME} ({self.display_value})"
|
||||||
|
|
||||||
|
|
||||||
class ValueListCategory(CriterionCategory):
|
class ValueListCategory(CriterionCategory):
|
||||||
@@ -96,6 +96,8 @@ class FilenameCategory(CriterionCategory):
|
|||||||
DOESNT_END_WITH_NUMBER = 1
|
DOESNT_END_WITH_NUMBER = 1
|
||||||
LONGEST = 2
|
LONGEST = 2
|
||||||
SHORTEST = 3
|
SHORTEST = 3
|
||||||
|
LONGEST_PATH = 4
|
||||||
|
SHORTEST_PATH = 5
|
||||||
|
|
||||||
def format_criterion_value(self, value):
|
def format_criterion_value(self, value):
|
||||||
return {
|
return {
|
||||||
@@ -103,6 +105,8 @@ class FilenameCategory(CriterionCategory):
|
|||||||
self.DOESNT_END_WITH_NUMBER: tr("Doesn't end with number"),
|
self.DOESNT_END_WITH_NUMBER: tr("Doesn't end with number"),
|
||||||
self.LONGEST: tr("Longest"),
|
self.LONGEST: tr("Longest"),
|
||||||
self.SHORTEST: tr("Shortest"),
|
self.SHORTEST: tr("Shortest"),
|
||||||
|
self.LONGEST_PATH: tr("Longest Path"),
|
||||||
|
self.SHORTEST_PATH: tr("Shortest Path"),
|
||||||
}[value]
|
}[value]
|
||||||
|
|
||||||
def extract_value(self, dupe):
|
def extract_value(self, dupe):
|
||||||
@@ -116,6 +120,10 @@ class FilenameCategory(CriterionCategory):
|
|||||||
return 0 if ends_with_digit else 1
|
return 0 if ends_with_digit else 1
|
||||||
else:
|
else:
|
||||||
return 1 if ends_with_digit else 0
|
return 1 if ends_with_digit else 0
|
||||||
|
elif crit_value == self.LONGEST_PATH:
|
||||||
|
return len(str(dupe.folder_path)) * -1
|
||||||
|
elif crit_value == self.SHORTEST_PATH:
|
||||||
|
return len(str(dupe.folder_path))
|
||||||
else:
|
else:
|
||||||
value = len(value)
|
value = len(value)
|
||||||
if crit_value == self.LONGEST:
|
if crit_value == self.LONGEST:
|
||||||
@@ -130,6 +138,8 @@ class FilenameCategory(CriterionCategory):
|
|||||||
self.DOESNT_END_WITH_NUMBER,
|
self.DOESNT_END_WITH_NUMBER,
|
||||||
self.LONGEST,
|
self.LONGEST,
|
||||||
self.SHORTEST,
|
self.SHORTEST,
|
||||||
|
self.LONGEST_PATH,
|
||||||
|
self.SHORTEST_PATH,
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import logging
|
|||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
import os.path as op
|
import os.path as op
|
||||||
|
from errno import EISDIR, EACCES
|
||||||
from xml.etree import ElementTree as ET
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
from hscommon.jobprogress.job import nulljob
|
from hscommon.jobprogress.job import nulljob
|
||||||
@@ -17,8 +18,8 @@ from hscommon.conflict import get_conflicted_name
|
|||||||
from hscommon.util import flatten, nonone, FileOrPath, format_size
|
from hscommon.util import flatten, nonone, FileOrPath, format_size
|
||||||
from hscommon.trans import tr
|
from hscommon.trans import tr
|
||||||
|
|
||||||
from . import engine
|
from core import engine
|
||||||
from .markable import Markable
|
from core.markable import Markable
|
||||||
|
|
||||||
|
|
||||||
class Results(Markable):
|
class Results(Markable):
|
||||||
@@ -191,7 +192,7 @@ class Results(Markable):
|
|||||||
self.__filters.append(filter_str)
|
self.__filters.append(filter_str)
|
||||||
if self.__filtered_dupes is None:
|
if self.__filtered_dupes is None:
|
||||||
self.__filtered_dupes = flatten(g[:] for g in self.groups)
|
self.__filtered_dupes = flatten(g[:] for g in self.groups)
|
||||||
self.__filtered_dupes = set(dupe for dupe in self.__filtered_dupes if filter_re.search(str(dupe.path)))
|
self.__filtered_dupes = {dupe for dupe in self.__filtered_dupes if filter_re.search(str(dupe.path))}
|
||||||
filtered_groups = set()
|
filtered_groups = set()
|
||||||
for dupe in self.__filtered_dupes:
|
for dupe in self.__filtered_dupes:
|
||||||
filtered_groups.add(self.get_group_of_duplicate(dupe))
|
filtered_groups.add(self.get_group_of_duplicate(dupe))
|
||||||
@@ -301,7 +302,7 @@ class Results(Markable):
|
|||||||
try:
|
try:
|
||||||
func(dupe)
|
func(dupe)
|
||||||
to_remove.append(dupe)
|
to_remove.append(dupe)
|
||||||
except (EnvironmentError, UnicodeEncodeError) as e:
|
except (OSError, UnicodeEncodeError) as e:
|
||||||
self.problems.append((dupe, str(e)))
|
self.problems.append((dupe, str(e)))
|
||||||
if remove_from_results:
|
if remove_from_results:
|
||||||
self.remove_duplicates(to_remove)
|
self.remove_duplicates(to_remove)
|
||||||
@@ -374,10 +375,10 @@ class Results(Markable):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
do_write(outfile)
|
do_write(outfile)
|
||||||
except IOError as e:
|
except OSError as e:
|
||||||
# If our IOError is because dest is already a directory, we want to handle that. 21 is
|
# If our OSError is because dest is already a directory, we want to handle that. 21 is
|
||||||
# the code we get on OS X and Linux, 13 is what we get on Windows.
|
# the code we get on OS X and Linux (EISDIR), 13 is what we get on Windows (EACCES).
|
||||||
if e.errno in {21, 13}:
|
if e.errno in (EISDIR, EACCES):
|
||||||
p = str(outfile)
|
p = str(outfile)
|
||||||
dirname, basename = op.split(p)
|
dirname, basename = op.split(p)
|
||||||
otherfiles = os.listdir(dirname)
|
otherfiles = os.listdir(dirname)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from hscommon.jobprogress import job
|
|||||||
from hscommon.util import dedupe, rem_file_ext, get_file_ext
|
from hscommon.util import dedupe, rem_file_ext, get_file_ext
|
||||||
from hscommon.trans import tr
|
from hscommon.trans import tr
|
||||||
|
|
||||||
from . import engine
|
from core import engine
|
||||||
|
|
||||||
# It's quite ugly to have scan types from all editions all put in the same class, but because there's
|
# It's quite ugly to have scan types from all editions all put in the same class, but because there's
|
||||||
# there will be some nasty bugs popping up (ScanType is used in core when in should exclusively be
|
# there will be some nasty bugs popping up (ScanType is used in core when in should exclusively be
|
||||||
@@ -87,8 +87,6 @@ class Scanner:
|
|||||||
}
|
}
|
||||||
):
|
):
|
||||||
j = j.start_subjob([2, 8])
|
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:
|
if self.size_threshold:
|
||||||
files = [f for f in files if f.size >= self.size_threshold]
|
files = [f for f in files if f.size >= self.size_threshold]
|
||||||
if self.large_size_threshold:
|
if self.large_size_threshold:
|
||||||
@@ -171,8 +169,11 @@ class Scanner:
|
|||||||
matches = [m for m in matches if m.first.path not in toremove or m.second.path not in toremove]
|
matches = [m for m in matches if m.first.path not in toremove or m.second.path not in toremove]
|
||||||
if not self.mix_file_kind:
|
if not self.mix_file_kind:
|
||||||
matches = [m for m in matches if get_file_ext(m.first.name) == get_file_ext(m.second.name)]
|
matches = [m for m in matches if get_file_ext(m.first.name) == get_file_ext(m.second.name)]
|
||||||
matches = [m for m in matches if m.first.path.exists() and m.second.path.exists()]
|
if self.include_exists_check:
|
||||||
matches = [m for m in matches if not (m.first.is_ref and m.second.is_ref)]
|
matches = [m for m in matches if m.first.exists() and m.second.exists()]
|
||||||
|
# Contents already handles ref checks, other scan types might not catch during scan
|
||||||
|
if self.scan_type != ScanType.CONTENTS:
|
||||||
|
matches = [m for m in matches if not (m.first.is_ref and m.second.is_ref)]
|
||||||
if ignore_list:
|
if ignore_list:
|
||||||
matches = [m for m in matches if not ignore_list.are_ignored(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")
|
logging.info("Grouping matches")
|
||||||
@@ -212,3 +213,4 @@ class Scanner:
|
|||||||
large_size_threshold = 0
|
large_size_threshold = 0
|
||||||
big_file_size_threshold = 0
|
big_file_size_threshold = 0
|
||||||
word_weighting = False
|
word_weighting = False
|
||||||
|
include_exists_check = True
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
from . import fs, result_table, scanner # noqa
|
from core.se import fs, result_table, scanner # noqa
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import os
|
import os
|
||||||
import os.path as op
|
import os.path as op
|
||||||
import logging
|
import logging
|
||||||
|
import tempfile
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -15,10 +16,10 @@ import hscommon.util
|
|||||||
from hscommon.testutil import eq_, log_calls
|
from hscommon.testutil import eq_, log_calls
|
||||||
from hscommon.jobprogress.job import Job
|
from hscommon.jobprogress.job import Job
|
||||||
|
|
||||||
from .base import TestApp
|
from core.tests.base import TestApp
|
||||||
from .results_test import GetTestGroups
|
from core.tests.results_test import GetTestGroups
|
||||||
from .. import app, fs, engine
|
from core import app, fs, engine
|
||||||
from ..scanner import ScanType
|
from core.scanner import ScanType
|
||||||
|
|
||||||
|
|
||||||
def add_fake_files_to_directories(directories, files):
|
def add_fake_files_to_directories(directories, files):
|
||||||
@@ -68,11 +69,12 @@ class TestCaseDupeGuru:
|
|||||||
dgapp = TestApp().app
|
dgapp = TestApp().app
|
||||||
dgapp.directories.add_path(p)
|
dgapp.directories.add_path(p)
|
||||||
[f] = dgapp.directories.get_files()
|
[f] = dgapp.directories.get_files()
|
||||||
dgapp.copy_or_move(f, True, "some_destination", 0)
|
with tempfile.TemporaryDirectory() as tmp_dir:
|
||||||
eq_(1, len(hscommon.conflict.smart_copy.calls))
|
dgapp.copy_or_move(f, True, tmp_dir, 0)
|
||||||
call = hscommon.conflict.smart_copy.calls[0]
|
eq_(1, len(hscommon.conflict.smart_copy.calls))
|
||||||
eq_(call["dest_path"], Path("some_destination", "foo"))
|
call = hscommon.conflict.smart_copy.calls[0]
|
||||||
eq_(call["source_path"], f.path)
|
eq_(call["dest_path"], Path(tmp_dir, "foo"))
|
||||||
|
eq_(call["source_path"], f.path)
|
||||||
|
|
||||||
def test_copy_or_move_clean_empty_dirs(self, tmpdir, monkeypatch):
|
def test_copy_or_move_clean_empty_dirs(self, tmpdir, monkeypatch):
|
||||||
tmppath = Path(str(tmpdir))
|
tmppath = Path(str(tmpdir))
|
||||||
@@ -95,7 +97,7 @@ class TestCaseDupeGuru:
|
|||||||
|
|
||||||
# At some point, any() was used in a wrong way that made Scan() wrongly return 1
|
# At some point, any() was used in a wrong way that made Scan() wrongly return 1
|
||||||
app = TestApp().app
|
app = TestApp().app
|
||||||
f1, f2 = [FakeFile("foo") for _ in range(2)]
|
f1, f2 = (FakeFile("foo") for _ in range(2))
|
||||||
f1.is_ref, f2.is_ref = (False, False)
|
f1.is_ref, f2.is_ref = (False, False)
|
||||||
assert not (bool(f1) and bool(f2))
|
assert not (bool(f1) and bool(f2))
|
||||||
add_fake_files_to_directories(app.directories, [f1, f2])
|
add_fake_files_to_directories(app.directories, [f1, f2])
|
||||||
|
|||||||
@@ -10,12 +10,11 @@ from hscommon.util import get_file_ext, format_size
|
|||||||
from hscommon.gui.column import Column
|
from hscommon.gui.column import Column
|
||||||
from hscommon.jobprogress.job import nulljob, JobCancelled
|
from hscommon.jobprogress.job import nulljob, JobCancelled
|
||||||
|
|
||||||
from .. import engine
|
from core import engine, prioritize
|
||||||
from .. import prioritize
|
from core.engine import getwords
|
||||||
from ..engine import getwords
|
from core.app import DupeGuru as DupeGuruBase
|
||||||
from ..app import DupeGuru as DupeGuruBase
|
from core.gui.result_table import ResultTable as ResultTableBase
|
||||||
from ..gui.result_table import ResultTable as ResultTableBase
|
from core.gui.prioritize_dialog import PrioritizeDialog
|
||||||
from ..gui.prioritize_dialog import PrioritizeDialog
|
|
||||||
|
|
||||||
|
|
||||||
class DupeGuruView:
|
class DupeGuruView:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from pytest import raises, skip
|
|||||||
from hscommon.testutil import eq_
|
from hscommon.testutil import eq_
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from ..pe.block import avgdiff, getblocks2, NoBlocksError, DifferentBlockCountError
|
from core.pe.block import avgdiff, getblocks2, NoBlocksError, DifferentBlockCountError
|
||||||
except ImportError:
|
except ImportError:
|
||||||
skip("Can't import the block module, probably hasn't been compiled.")
|
skip("Can't import the block module, probably hasn't been compiled.")
|
||||||
|
|
||||||
|
|||||||
@@ -10,41 +10,41 @@ from pytest import raises, skip
|
|||||||
from hscommon.testutil import eq_
|
from hscommon.testutil import eq_
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from ..pe.cache import colors_to_string, string_to_colors
|
from core.pe.cache import colors_to_bytes, bytes_to_colors
|
||||||
from ..pe.cache_sqlite import SqliteCache
|
from core.pe.cache_sqlite import SqliteCache
|
||||||
from ..pe.cache_shelve import ShelveCache
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
skip("Can't import the cache module, probably hasn't been compiled.")
|
skip("Can't import the cache module, probably hasn't been compiled.")
|
||||||
|
|
||||||
|
|
||||||
class TestCaseColorsToString:
|
class TestCaseColorsToString:
|
||||||
def test_no_color(self):
|
def test_no_color(self):
|
||||||
eq_("", colors_to_string([]))
|
eq_(b"", colors_to_bytes([]))
|
||||||
|
|
||||||
def test_single_color(self):
|
def test_single_color(self):
|
||||||
eq_("000000", colors_to_string([(0, 0, 0)]))
|
eq_(b"\x00\x00\x00", colors_to_bytes([(0, 0, 0)]))
|
||||||
eq_("010101", colors_to_string([(1, 1, 1)]))
|
eq_(b"\x01\x01\x01", colors_to_bytes([(1, 1, 1)]))
|
||||||
eq_("0a141e", colors_to_string([(10, 20, 30)]))
|
eq_(b"\x0a\x14\x1e", colors_to_bytes([(10, 20, 30)]))
|
||||||
|
|
||||||
def test_two_colors(self):
|
def test_two_colors(self):
|
||||||
eq_("000102030405", colors_to_string([(0, 1, 2), (3, 4, 5)]))
|
eq_(b"\x00\x01\x02\x03\x04\x05", colors_to_bytes([(0, 1, 2), (3, 4, 5)]))
|
||||||
|
|
||||||
|
|
||||||
class TestCaseStringToColors:
|
class TestCaseStringToColors:
|
||||||
def test_empty(self):
|
def test_empty(self):
|
||||||
eq_([], string_to_colors(""))
|
eq_([], bytes_to_colors(b""))
|
||||||
|
|
||||||
def test_single_color(self):
|
def test_single_color(self):
|
||||||
eq_([(0, 0, 0)], string_to_colors("000000"))
|
eq_([(0, 0, 0)], bytes_to_colors(b"\x00\x00\x00"))
|
||||||
eq_([(2, 3, 4)], string_to_colors("020304"))
|
eq_([(2, 3, 4)], bytes_to_colors(b"\x02\x03\x04"))
|
||||||
eq_([(10, 20, 30)], string_to_colors("0a141e"))
|
eq_([(10, 20, 30)], bytes_to_colors(b"\x0a\x14\x1e"))
|
||||||
|
|
||||||
def test_two_colors(self):
|
def test_two_colors(self):
|
||||||
eq_([(10, 20, 30), (40, 50, 60)], string_to_colors("0a141e28323c"))
|
eq_([(10, 20, 30), (40, 50, 60)], bytes_to_colors(b"\x0a\x14\x1e\x28\x32\x3c"))
|
||||||
|
|
||||||
def test_incomplete_color(self):
|
def test_incomplete_color(self):
|
||||||
# don't return anything if it's not a complete color
|
# don't return anything if it's not a complete color
|
||||||
eq_([], string_to_colors("102"))
|
eq_([], bytes_to_colors(b"\x01"))
|
||||||
|
eq_([(1, 2, 3)], bytes_to_colors(b"\x01\x02\x03\x04"))
|
||||||
|
|
||||||
|
|
||||||
class BaseTestCaseCache:
|
class BaseTestCaseCache:
|
||||||
@@ -59,13 +59,13 @@ class BaseTestCaseCache:
|
|||||||
|
|
||||||
def test_set_then_retrieve_blocks(self):
|
def test_set_then_retrieve_blocks(self):
|
||||||
c = self.get_cache()
|
c = self.get_cache()
|
||||||
b = [(0, 0, 0), (1, 2, 3)]
|
b = [[(0, 0, 0), (1, 2, 3)]] * 8
|
||||||
c["foo"] = b
|
c["foo"] = b
|
||||||
eq_(b, c["foo"])
|
eq_(b, c["foo"])
|
||||||
|
|
||||||
def test_delitem(self):
|
def test_delitem(self):
|
||||||
c = self.get_cache()
|
c = self.get_cache()
|
||||||
c["foo"] = ""
|
c["foo"] = [[]] * 8
|
||||||
del c["foo"]
|
del c["foo"]
|
||||||
assert "foo" not in c
|
assert "foo" not in c
|
||||||
with raises(KeyError):
|
with raises(KeyError):
|
||||||
@@ -74,16 +74,16 @@ class BaseTestCaseCache:
|
|||||||
def test_persistance(self, tmpdir):
|
def test_persistance(self, tmpdir):
|
||||||
DBNAME = tmpdir.join("hstest.db")
|
DBNAME = tmpdir.join("hstest.db")
|
||||||
c = self.get_cache(str(DBNAME))
|
c = self.get_cache(str(DBNAME))
|
||||||
c["foo"] = [(1, 2, 3)]
|
c["foo"] = [[(1, 2, 3)]] * 8
|
||||||
del c
|
del c
|
||||||
c = self.get_cache(str(DBNAME))
|
c = self.get_cache(str(DBNAME))
|
||||||
eq_([(1, 2, 3)], c["foo"])
|
eq_([[(1, 2, 3)]] * 8, c["foo"])
|
||||||
|
|
||||||
def test_filter(self):
|
def test_filter(self):
|
||||||
c = self.get_cache()
|
c = self.get_cache()
|
||||||
c["foo"] = ""
|
c["foo"] = [[]] * 8
|
||||||
c["bar"] = ""
|
c["bar"] = [[]] * 8
|
||||||
c["baz"] = ""
|
c["baz"] = [[]] * 8
|
||||||
c.filter(lambda p: p != "bar") # only 'bar' is removed
|
c.filter(lambda p: p != "bar") # only 'bar' is removed
|
||||||
eq_(2, len(c))
|
eq_(2, len(c))
|
||||||
assert "foo" in c
|
assert "foo" in c
|
||||||
@@ -92,9 +92,9 @@ class BaseTestCaseCache:
|
|||||||
|
|
||||||
def test_clear(self):
|
def test_clear(self):
|
||||||
c = self.get_cache()
|
c = self.get_cache()
|
||||||
c["foo"] = ""
|
c["foo"] = [[]] * 8
|
||||||
c["bar"] = ""
|
c["bar"] = [[]] * 8
|
||||||
c["baz"] = ""
|
c["baz"] = [[]] * 8
|
||||||
c.clear()
|
c.clear()
|
||||||
eq_(0, len(c))
|
eq_(0, len(c))
|
||||||
assert "foo" not in c
|
assert "foo" not in c
|
||||||
@@ -104,7 +104,7 @@ class BaseTestCaseCache:
|
|||||||
def test_by_id(self):
|
def test_by_id(self):
|
||||||
# it's possible to use the cache by referring to the files by their row_id
|
# it's possible to use the cache by referring to the files by their row_id
|
||||||
c = self.get_cache()
|
c = self.get_cache()
|
||||||
b = [(0, 0, 0), (1, 2, 3)]
|
b = [[(0, 0, 0), (1, 2, 3)]] * 8
|
||||||
c["foo"] = b
|
c["foo"] = b
|
||||||
foo_id = c.get_id("foo")
|
foo_id = c.get_id("foo")
|
||||||
eq_(c[foo_id], b)
|
eq_(c[foo_id], b)
|
||||||
@@ -127,15 +127,10 @@ class TestCaseSqliteCache(BaseTestCaseCache):
|
|||||||
fp.write("invalid sqlite content")
|
fp.write("invalid sqlite content")
|
||||||
fp.close()
|
fp.close()
|
||||||
c = self.get_cache(dbname) # should not raise a DatabaseError
|
c = self.get_cache(dbname) # should not raise a DatabaseError
|
||||||
c["foo"] = [(1, 2, 3)]
|
c["foo"] = [[(1, 2, 3)]] * 8
|
||||||
del c
|
del c
|
||||||
c = self.get_cache(dbname)
|
c = self.get_cache(dbname)
|
||||||
eq_(c["foo"], [(1, 2, 3)])
|
eq_(c["foo"], [[(1, 2, 3)]] * 8)
|
||||||
|
|
||||||
|
|
||||||
class TestCaseShelveCache(BaseTestCaseCache):
|
|
||||||
def get_cache(self, dbname=None):
|
|
||||||
return ShelveCache(dbname)
|
|
||||||
|
|
||||||
|
|
||||||
class TestCaseCacheSQLEscape:
|
class TestCaseCacheSQLEscape:
|
||||||
@@ -157,7 +152,7 @@ class TestCaseCacheSQLEscape:
|
|||||||
|
|
||||||
def test_delitem(self):
|
def test_delitem(self):
|
||||||
c = self.get_cache()
|
c = self.get_cache()
|
||||||
c["foo'bar"] = []
|
c["foo'bar"] = [[]] * 8
|
||||||
try:
|
try:
|
||||||
del c["foo'bar"]
|
del c["foo'bar"]
|
||||||
except KeyError:
|
except KeyError:
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ from pathlib import Path
|
|||||||
from hscommon.testutil import eq_
|
from hscommon.testutil import eq_
|
||||||
from hscommon.plat import ISWINDOWS
|
from hscommon.plat import ISWINDOWS
|
||||||
|
|
||||||
from ..fs import File
|
from core.fs import File
|
||||||
from ..directories import (
|
from core.directories import (
|
||||||
Directories,
|
Directories,
|
||||||
DirectoryState,
|
DirectoryState,
|
||||||
AlreadyThereError,
|
AlreadyThereError,
|
||||||
InvalidPathError,
|
InvalidPathError,
|
||||||
)
|
)
|
||||||
from ..exclude import ExcludeList, ExcludeDict
|
from core.exclude import ExcludeList, ExcludeDict
|
||||||
|
|
||||||
|
|
||||||
def create_fake_fs(rootpath):
|
def create_fake_fs(rootpath):
|
||||||
@@ -326,6 +326,7 @@ def test_default_path_state_override(tmpdir):
|
|||||||
def _default_state_for_path(self, path):
|
def _default_state_for_path(self, path):
|
||||||
if "foobar" in path.parts:
|
if "foobar" in path.parts:
|
||||||
return DirectoryState.EXCLUDED
|
return DirectoryState.EXCLUDED
|
||||||
|
return DirectoryState.NORMAL
|
||||||
|
|
||||||
d = MyDirectories()
|
d = MyDirectories()
|
||||||
p1 = Path(str(tmpdir))
|
p1 = Path(str(tmpdir))
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ from hscommon.jobprogress import job
|
|||||||
from hscommon.util import first
|
from hscommon.util import first
|
||||||
from hscommon.testutil import eq_, log_calls
|
from hscommon.testutil import eq_, log_calls
|
||||||
|
|
||||||
from .base import NamedObject
|
from core.tests.base import NamedObject
|
||||||
from .. import engine
|
from core import engine
|
||||||
from ..engine import (
|
from core.engine import (
|
||||||
get_match,
|
get_match,
|
||||||
getwords,
|
getwords,
|
||||||
Group,
|
Group,
|
||||||
@@ -71,7 +71,10 @@ class TestCasegetwords:
|
|||||||
|
|
||||||
def test_unicode(self):
|
def test_unicode(self):
|
||||||
eq_(["e", "c", "0", "a", "o", "u", "e", "u"], getwords("é ç 0 à ö û è ¤ ù"))
|
eq_(["e", "c", "0", "a", "o", "u", "e", "u"], getwords("é ç 0 à ö û è ¤ ù"))
|
||||||
eq_(["02", "君のこころは輝いてるかい?", "国木田花丸", "solo", "ver"], getwords("02 君のこころは輝いてるかい? 国木田花丸 Solo Ver"))
|
eq_(
|
||||||
|
["02", "君のこころは輝いてるかい?", "国木田花丸", "solo", "ver"],
|
||||||
|
getwords("02 君のこころは輝いてるかい? 国木田花丸 Solo Ver"),
|
||||||
|
)
|
||||||
|
|
||||||
def test_splitter_chars(self):
|
def test_splitter_chars(self):
|
||||||
eq_(
|
eq_(
|
||||||
@@ -271,9 +274,9 @@ class TestCaseBuildWordDict:
|
|||||||
class TestCaseMergeSimilarWords:
|
class TestCaseMergeSimilarWords:
|
||||||
def test_some_similar_words(self):
|
def test_some_similar_words(self):
|
||||||
d = {
|
d = {
|
||||||
"foobar": set([1]),
|
"foobar": {1},
|
||||||
"foobar1": set([2]),
|
"foobar1": {2},
|
||||||
"foobar2": set([3]),
|
"foobar2": {3},
|
||||||
}
|
}
|
||||||
merge_similar_words(d)
|
merge_similar_words(d)
|
||||||
eq_(1, len(d))
|
eq_(1, len(d))
|
||||||
@@ -283,8 +286,8 @@ class TestCaseMergeSimilarWords:
|
|||||||
class TestCaseReduceCommonWords:
|
class TestCaseReduceCommonWords:
|
||||||
def test_typical(self):
|
def test_typical(self):
|
||||||
d = {
|
d = {
|
||||||
"foo": set([NamedObject("foo bar", True) for _ in range(50)]),
|
"foo": {NamedObject("foo bar", True) for _ in range(50)},
|
||||||
"bar": set([NamedObject("foo bar", True) for _ in range(49)]),
|
"bar": {NamedObject("foo bar", True) for _ in range(49)},
|
||||||
}
|
}
|
||||||
reduce_common_words(d, 50)
|
reduce_common_words(d, 50)
|
||||||
assert "foo" not in d
|
assert "foo" not in d
|
||||||
@@ -293,7 +296,7 @@ class TestCaseReduceCommonWords:
|
|||||||
def test_dont_remove_objects_with_only_common_words(self):
|
def test_dont_remove_objects_with_only_common_words(self):
|
||||||
d = {
|
d = {
|
||||||
"common": set([NamedObject("common uncommon", True) for _ 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)]),
|
"uncommon": {NamedObject("common uncommon", True)},
|
||||||
}
|
}
|
||||||
reduce_common_words(d, 50)
|
reduce_common_words(d, 50)
|
||||||
eq_(1, len(d["common"]))
|
eq_(1, len(d["common"]))
|
||||||
@@ -302,7 +305,7 @@ class TestCaseReduceCommonWords:
|
|||||||
def test_values_still_are_set_instances(self):
|
def test_values_still_are_set_instances(self):
|
||||||
d = {
|
d = {
|
||||||
"common": set([NamedObject("common uncommon", True) for _ 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)]),
|
"uncommon": {NamedObject("common uncommon", True)},
|
||||||
}
|
}
|
||||||
reduce_common_words(d, 50)
|
reduce_common_words(d, 50)
|
||||||
assert isinstance(d["common"], set)
|
assert isinstance(d["common"], set)
|
||||||
@@ -312,9 +315,9 @@ class TestCaseReduceCommonWords:
|
|||||||
# If a word has been removed by the reduce, an object in a subsequent common word that
|
# 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.
|
# contains the word that has been removed would cause a KeyError.
|
||||||
d = {
|
d = {
|
||||||
"foo": set([NamedObject("foo bar baz", True) for _ in range(50)]),
|
"foo": {NamedObject("foo bar baz", True) for _ in range(50)},
|
||||||
"bar": set([NamedObject("foo bar baz", True) for _ in range(50)]),
|
"bar": {NamedObject("foo bar baz", True) for _ in range(50)},
|
||||||
"baz": set([NamedObject("foo bar baz", True) for _ in range(49)]),
|
"baz": {NamedObject("foo bar baz", True) for _ in range(49)},
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
reduce_common_words(d, 50)
|
reduce_common_words(d, 50)
|
||||||
@@ -328,7 +331,7 @@ class TestCaseReduceCommonWords:
|
|||||||
o.words = [["foo", "bar"], ["baz"]]
|
o.words = [["foo", "bar"], ["baz"]]
|
||||||
return o
|
return o
|
||||||
|
|
||||||
d = {"foo": set([create_it() for _ in range(50)])}
|
d = {"foo": {create_it() for _ in range(50)}}
|
||||||
try:
|
try:
|
||||||
reduce_common_words(d, 50)
|
reduce_common_words(d, 50)
|
||||||
except TypeError:
|
except TypeError:
|
||||||
@@ -343,7 +346,7 @@ class TestCaseReduceCommonWords:
|
|||||||
d = {
|
d = {
|
||||||
"foo": set([NamedObject("foo bar baz", True) for _ in range(49)] + [only_common]),
|
"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]),
|
"bar": set([NamedObject("foo bar baz", True) for _ in range(49)] + [only_common]),
|
||||||
"baz": set([NamedObject("foo bar baz", True) for _ in range(49)]),
|
"baz": {NamedObject("foo bar baz", True) for _ in range(49)},
|
||||||
}
|
}
|
||||||
reduce_common_words(d, 50)
|
reduce_common_words(d, 50)
|
||||||
eq_(1, len(d["foo"]))
|
eq_(1, len(d["foo"]))
|
||||||
@@ -884,7 +887,7 @@ class TestCaseGetGroups:
|
|||||||
# If, with a (A, B, C, D) set, all match with A, but C and D don't match with B and that the
|
# If, with a (A, B, C, D) set, all match with A, but C and D don't match with B and that the
|
||||||
# (A, B) match is the highest (thus resulting in an (A, B) group), still match C and D
|
# (A, B) match is the highest (thus resulting in an (A, B) group), still match C and D
|
||||||
# in a separate group instead of discarding them.
|
# in a separate group instead of discarding them.
|
||||||
A, B, C, D = [NamedObject() for _ in range(4)]
|
A, B, C, D = (NamedObject() for _ in range(4))
|
||||||
m1 = Match(A, B, 90) # This is the strongest "A" match
|
m1 = Match(A, B, 90) # This is the strongest "A" match
|
||||||
m2 = Match(A, C, 80) # Because C doesn't match with B, it won't be in the group
|
m2 = Match(A, C, 80) # Because C doesn't match with B, it won't be in the group
|
||||||
m3 = Match(A, D, 80) # Same thing for D
|
m3 = Match(A, D, 80) # Same thing for D
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ from xml.etree import ElementTree as ET
|
|||||||
from hscommon.testutil import eq_
|
from hscommon.testutil import eq_
|
||||||
from hscommon.plat import ISWINDOWS
|
from hscommon.plat import ISWINDOWS
|
||||||
|
|
||||||
from .base import DupeGuru
|
from core.tests.base import DupeGuru
|
||||||
from ..exclude import ExcludeList, ExcludeDict, default_regexes, AlreadyThereException
|
from core.exclude import ExcludeList, ExcludeDict, default_regexes, AlreadyThereException
|
||||||
|
|
||||||
from re import error
|
from re import error
|
||||||
|
|
||||||
@@ -289,8 +289,8 @@ class TestCaseListEmptyUnion(TestCaseListEmpty):
|
|||||||
compiled = [x for x in self.exclude_list.compiled]
|
compiled = [x for x in self.exclude_list.compiled]
|
||||||
assert regex not in compiled
|
assert regex not in compiled
|
||||||
# Need to escape both to get the same strings after compilation
|
# Need to escape both to get the same strings after compilation
|
||||||
compiled_escaped = set([x.encode("unicode-escape").decode() for x in compiled[0].pattern.split("|")])
|
compiled_escaped = {x.encode("unicode-escape").decode() for x in compiled[0].pattern.split("|")}
|
||||||
default_escaped = set([x.encode("unicode-escape").decode() for x in default_regexes])
|
default_escaped = {x.encode("unicode-escape").decode() for x in default_regexes}
|
||||||
assert compiled_escaped == default_escaped
|
assert compiled_escaped == default_escaped
|
||||||
eq_(len(default_regexes), len(compiled[0].pattern.split("|")))
|
eq_(len(default_regexes), len(compiled[0].pattern.split("|")))
|
||||||
|
|
||||||
@@ -366,8 +366,8 @@ class TestCaseDictEmptyUnion(TestCaseDictEmpty):
|
|||||||
compiled = [x for x in self.exclude_list.compiled]
|
compiled = [x for x in self.exclude_list.compiled]
|
||||||
assert regex not in compiled
|
assert regex not in compiled
|
||||||
# Need to escape both to get the same strings after compilation
|
# Need to escape both to get the same strings after compilation
|
||||||
compiled_escaped = set([x.encode("unicode-escape").decode() for x in compiled[0].pattern.split("|")])
|
compiled_escaped = {x.encode("unicode-escape").decode() for x in compiled[0].pattern.split("|")}
|
||||||
default_escaped = set([x.encode("unicode-escape").decode() for x in default_regexes])
|
default_escaped = {x.encode("unicode-escape").decode() for x in default_regexes}
|
||||||
assert compiled_escaped == default_escaped
|
assert compiled_escaped == default_escaped
|
||||||
eq_(len(default_regexes), len(compiled[0].pattern.split("|")))
|
eq_(len(default_regexes), len(compiled[0].pattern.split("|")))
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,16 @@
|
|||||||
# which should be included with this package. The terms are also available at
|
# which should be included with this package. The terms are also available at
|
||||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||||
|
|
||||||
|
import typing
|
||||||
|
from os import urandom
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from hscommon.testutil import eq_
|
||||||
|
from core.tests.directories_test import create_fake_fs
|
||||||
|
|
||||||
|
from core import fs
|
||||||
|
|
||||||
|
hasher: typing.Callable
|
||||||
try:
|
try:
|
||||||
import xxhash
|
import xxhash
|
||||||
|
|
||||||
@@ -15,14 +25,6 @@ except ImportError:
|
|||||||
|
|
||||||
hasher = hashlib.md5
|
hasher = hashlib.md5
|
||||||
|
|
||||||
from os import urandom
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from hscommon.testutil import eq_
|
|
||||||
from core.tests.directories_test import create_fake_fs
|
|
||||||
|
|
||||||
from .. import fs
|
|
||||||
|
|
||||||
|
|
||||||
def create_fake_fs_with_random_data(rootpath):
|
def create_fake_fs_with_random_data(rootpath):
|
||||||
rootpath = rootpath.joinpath("fs")
|
rootpath = rootpath.joinpath("fs")
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from xml.etree import ElementTree as ET
|
|||||||
from pytest import raises
|
from pytest import raises
|
||||||
from hscommon.testutil import eq_
|
from hscommon.testutil import eq_
|
||||||
|
|
||||||
from ..ignore import IgnoreList
|
from core.ignore import IgnoreList
|
||||||
|
|
||||||
|
|
||||||
def test_empty():
|
def test_empty():
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
from hscommon.testutil import eq_
|
from hscommon.testutil import eq_
|
||||||
|
|
||||||
from ..markable import MarkableList, Markable
|
from core.markable import MarkableList, Markable
|
||||||
|
|
||||||
|
|
||||||
def gen():
|
def gen():
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
import os.path as op
|
import os.path as op
|
||||||
from itertools import combinations
|
from itertools import combinations
|
||||||
|
|
||||||
from .base import TestApp, NamedObject, with_app, eq_
|
from core.tests.base import TestApp, NamedObject, with_app, eq_
|
||||||
from ..engine import Group, Match
|
from core.engine import Group, Match
|
||||||
|
|
||||||
no = NamedObject
|
no = NamedObject
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
# which should be included with this package. The terms are also available at
|
# which should be included with this package. The terms are also available at
|
||||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||||
|
|
||||||
from .base import TestApp, GetTestGroups
|
from core.tests.base import TestApp, GetTestGroups
|
||||||
|
|
||||||
|
|
||||||
def app_with_results():
|
def app_with_results():
|
||||||
|
|||||||
@@ -12,10 +12,9 @@ from xml.etree import ElementTree as ET
|
|||||||
from pytest import raises
|
from pytest import raises
|
||||||
from hscommon.testutil import eq_
|
from hscommon.testutil import eq_
|
||||||
from hscommon.util import first
|
from hscommon.util import first
|
||||||
|
from core import engine
|
||||||
from .. import engine
|
from core.tests.base import NamedObject, GetTestGroups, DupeGuru
|
||||||
from .base import NamedObject, GetTestGroups, DupeGuru
|
from core.results import Results
|
||||||
from ..results import Results
|
|
||||||
|
|
||||||
|
|
||||||
class TestCaseResultsEmpty:
|
class TestCaseResultsEmpty:
|
||||||
@@ -337,7 +336,7 @@ class TestCaseResultsMarkings:
|
|||||||
def log_object(o):
|
def log_object(o):
|
||||||
log.append(o)
|
log.append(o)
|
||||||
if o is self.objects[1]:
|
if o is self.objects[1]:
|
||||||
raise EnvironmentError("foobar")
|
raise OSError("foobar")
|
||||||
|
|
||||||
log = []
|
log = []
|
||||||
self.results.mark_all()
|
self.results.mark_all()
|
||||||
@@ -464,7 +463,7 @@ class TestCaseResultsXML:
|
|||||||
eq_(6, len(g1))
|
eq_(6, len(g1))
|
||||||
eq_(3, len([c for c in g1 if c.tag == "file"]))
|
eq_(3, len([c for c in g1 if c.tag == "file"]))
|
||||||
eq_(3, len([c for c in g1 if c.tag == "match"]))
|
eq_(3, len([c for c in g1 if c.tag == "match"]))
|
||||||
d1, d2, d3 = [c for c in g1 if c.tag == "file"]
|
d1, d2, d3 = (c for c in g1 if c.tag == "file")
|
||||||
eq_(op.join("basepath", "foo bar"), d1.get("path"))
|
eq_(op.join("basepath", "foo bar"), d1.get("path"))
|
||||||
eq_(op.join("basepath", "bar bleh"), d2.get("path"))
|
eq_(op.join("basepath", "bar bleh"), d2.get("path"))
|
||||||
eq_(op.join("basepath", "foo bleh"), d3.get("path"))
|
eq_(op.join("basepath", "foo bleh"), d3.get("path"))
|
||||||
@@ -477,7 +476,7 @@ class TestCaseResultsXML:
|
|||||||
eq_(3, len(g2))
|
eq_(3, len(g2))
|
||||||
eq_(2, len([c for c in g2 if c.tag == "file"]))
|
eq_(2, len([c for c in g2 if c.tag == "file"]))
|
||||||
eq_(1, len([c for c in g2 if c.tag == "match"]))
|
eq_(1, len([c for c in g2 if c.tag == "match"]))
|
||||||
d1, d2 = [c for c in g2 if c.tag == "file"]
|
d1, d2 = (c for c in g2 if c.tag == "file")
|
||||||
eq_(op.join("basepath", "ibabtu"), d1.get("path"))
|
eq_(op.join("basepath", "ibabtu"), d1.get("path"))
|
||||||
eq_(op.join("basepath", "ibabtu"), d2.get("path"))
|
eq_(op.join("basepath", "ibabtu"), d2.get("path"))
|
||||||
eq_("n", d1.get("is_ref"))
|
eq_("n", d1.get("is_ref"))
|
||||||
|
|||||||
@@ -10,13 +10,14 @@ from hscommon.jobprogress import job
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from hscommon.testutil import eq_
|
from hscommon.testutil import eq_
|
||||||
|
|
||||||
from .. import fs
|
from core import fs
|
||||||
from ..engine import getwords, Match
|
from core.engine import getwords, Match
|
||||||
from ..ignore import IgnoreList
|
from core.ignore import IgnoreList
|
||||||
from ..scanner import Scanner, ScanType
|
from core.scanner import Scanner, ScanType
|
||||||
from ..me.scanner import ScannerME
|
from core.me.scanner import ScannerME
|
||||||
|
|
||||||
|
|
||||||
|
# TODO update this to be able to inherit from fs.File
|
||||||
class NamedObject:
|
class NamedObject:
|
||||||
def __init__(self, name="foobar", size=1, path=None):
|
def __init__(self, name="foobar", size=1, path=None):
|
||||||
if path is None:
|
if path is None:
|
||||||
@@ -29,7 +30,10 @@ class NamedObject:
|
|||||||
self.words = getwords(name)
|
self.words = getwords(name)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "<NamedObject %r %r>" % (self.name, self.path)
|
return "<NamedObject {!r} {!r}>".format(self.name, self.path)
|
||||||
|
|
||||||
|
def exists(self):
|
||||||
|
return self.path.exists()
|
||||||
|
|
||||||
|
|
||||||
no = NamedObject
|
no = NamedObject
|
||||||
@@ -238,12 +242,12 @@ def test_content_scan_doesnt_put_digest_in_words_at_the_end(fake_fileexists):
|
|||||||
s = Scanner()
|
s = Scanner()
|
||||||
s.scan_type = ScanType.CONTENTS
|
s.scan_type = ScanType.CONTENTS
|
||||||
f = [no("foo"), no("bar")]
|
f = [no("foo"), no("bar")]
|
||||||
f[0].digest = f[0].digest_partial = f[
|
f[0].digest = f[0].digest_partial = f[0].digest_samples = (
|
||||||
0
|
"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f"
|
||||||
].digest_samples = "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f"
|
)
|
||||||
f[1].digest = f[1].digest_partial = f[
|
f[1].digest = f[1].digest_partial = f[1].digest_samples = (
|
||||||
1
|
"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f"
|
||||||
].digest_samples = "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f"
|
)
|
||||||
r = s.get_dupe_groups(f)
|
r = s.get_dupe_groups(f)
|
||||||
# FIXME looks like we are missing something here?
|
# FIXME looks like we are missing something here?
|
||||||
r[0]
|
r[0]
|
||||||
@@ -336,7 +340,7 @@ def test_tag_scan(fake_fileexists):
|
|||||||
def test_tag_with_album_scan(fake_fileexists):
|
def test_tag_with_album_scan(fake_fileexists):
|
||||||
s = Scanner()
|
s = Scanner()
|
||||||
s.scan_type = ScanType.TAG
|
s.scan_type = ScanType.TAG
|
||||||
s.scanned_tags = set(["artist", "album", "title"])
|
s.scanned_tags = {"artist", "album", "title"}
|
||||||
o1 = no("foo")
|
o1 = no("foo")
|
||||||
o2 = no("bar")
|
o2 = no("bar")
|
||||||
o3 = no("bleh")
|
o3 = no("bleh")
|
||||||
@@ -356,7 +360,7 @@ def test_tag_with_album_scan(fake_fileexists):
|
|||||||
def test_that_dash_in_tags_dont_create_new_fields(fake_fileexists):
|
def test_that_dash_in_tags_dont_create_new_fields(fake_fileexists):
|
||||||
s = Scanner()
|
s = Scanner()
|
||||||
s.scan_type = ScanType.TAG
|
s.scan_type = ScanType.TAG
|
||||||
s.scanned_tags = set(["artist", "album", "title"])
|
s.scanned_tags = {"artist", "album", "title"}
|
||||||
s.min_match_percentage = 50
|
s.min_match_percentage = 50
|
||||||
o1 = no("foo")
|
o1 = no("foo")
|
||||||
o2 = no("bar")
|
o2 = no("bar")
|
||||||
@@ -373,7 +377,7 @@ def test_that_dash_in_tags_dont_create_new_fields(fake_fileexists):
|
|||||||
def test_tag_scan_with_different_scanned(fake_fileexists):
|
def test_tag_scan_with_different_scanned(fake_fileexists):
|
||||||
s = Scanner()
|
s = Scanner()
|
||||||
s.scan_type = ScanType.TAG
|
s.scan_type = ScanType.TAG
|
||||||
s.scanned_tags = set(["track", "year"])
|
s.scanned_tags = {"track", "year"}
|
||||||
o1 = no("foo")
|
o1 = no("foo")
|
||||||
o2 = no("bar")
|
o2 = no("bar")
|
||||||
o1.artist = "The White Stripes"
|
o1.artist = "The White Stripes"
|
||||||
@@ -391,7 +395,7 @@ def test_tag_scan_with_different_scanned(fake_fileexists):
|
|||||||
def test_tag_scan_only_scans_existing_tags(fake_fileexists):
|
def test_tag_scan_only_scans_existing_tags(fake_fileexists):
|
||||||
s = Scanner()
|
s = Scanner()
|
||||||
s.scan_type = ScanType.TAG
|
s.scan_type = ScanType.TAG
|
||||||
s.scanned_tags = set(["artist", "foo"])
|
s.scanned_tags = {"artist", "foo"}
|
||||||
o1 = no("foo")
|
o1 = no("foo")
|
||||||
o2 = no("bar")
|
o2 = no("bar")
|
||||||
o1.artist = "The White Stripes"
|
o1.artist = "The White Stripes"
|
||||||
@@ -405,7 +409,7 @@ def test_tag_scan_only_scans_existing_tags(fake_fileexists):
|
|||||||
def test_tag_scan_converts_to_str(fake_fileexists):
|
def test_tag_scan_converts_to_str(fake_fileexists):
|
||||||
s = Scanner()
|
s = Scanner()
|
||||||
s.scan_type = ScanType.TAG
|
s.scan_type = ScanType.TAG
|
||||||
s.scanned_tags = set(["track"])
|
s.scanned_tags = {"track"}
|
||||||
o1 = no("foo")
|
o1 = no("foo")
|
||||||
o2 = no("bar")
|
o2 = no("bar")
|
||||||
o1.track = 42
|
o1.track = 42
|
||||||
@@ -420,7 +424,7 @@ def test_tag_scan_converts_to_str(fake_fileexists):
|
|||||||
def test_tag_scan_non_ascii(fake_fileexists):
|
def test_tag_scan_non_ascii(fake_fileexists):
|
||||||
s = Scanner()
|
s = Scanner()
|
||||||
s.scan_type = ScanType.TAG
|
s.scan_type = ScanType.TAG
|
||||||
s.scanned_tags = set(["title"])
|
s.scanned_tags = {"title"}
|
||||||
o1 = no("foo")
|
o1 = no("foo")
|
||||||
o2 = no("bar")
|
o2 = no("bar")
|
||||||
o1.title = "foobar\u00e9"
|
o1.title = "foobar\u00e9"
|
||||||
|
|||||||
@@ -1,3 +1,21 @@
|
|||||||
|
=== 4.3.1 (2022-07-08)
|
||||||
|
* Fix issue where cache db exceptions could prevent files being hashed (#1015)
|
||||||
|
* Add extra guard for non-zero length files without digests to prevent false duplicates
|
||||||
|
* Update Italian translations
|
||||||
|
|
||||||
|
=== 4.3.0 (2022-07-01)
|
||||||
|
* Redirect stdout from custom command to the log files (#1008)
|
||||||
|
* Update translations
|
||||||
|
* Fix typo in debian control file (#989)
|
||||||
|
* Add option to profile scans
|
||||||
|
* Update fs.py to optimize stat() calls
|
||||||
|
* Fix Error when delete after scan (#988)
|
||||||
|
* Update directory scanning to use os.scandir() and DirEntry objects
|
||||||
|
* Improve performance of Directories.get_state()
|
||||||
|
* Migrate from hscommon.path to pathlib
|
||||||
|
* Switch file hashing to xxhash with fallback to md5
|
||||||
|
* Add update check feature to about box
|
||||||
|
|
||||||
=== 4.2.1 (2022-03-25)
|
=== 4.2.1 (2022-03-25)
|
||||||
* Default to English on unsupported system language (#976)
|
* Default to English on unsupported system language (#976)
|
||||||
* Fix image viewer zoom datatype issue (#978)
|
* Fix image viewer zoom datatype issue (#978)
|
||||||
@@ -39,7 +57,7 @@
|
|||||||
|
|
||||||
=== 4.1.1 (2021-03-21)
|
=== 4.1.1 (2021-03-21)
|
||||||
|
|
||||||
* Add Japanese
|
* Add Japanese
|
||||||
* Update internationalization and translations to be up to date with current UI.
|
* Update internationalization and translations to be up to date with current UI.
|
||||||
* Minor translation and UI language updates
|
* Minor translation and UI language updates
|
||||||
* Fix language selection issues on Windows (#760)
|
* Fix language selection issues on Windows (#760)
|
||||||
@@ -401,7 +419,7 @@
|
|||||||
|
|
||||||
=== 2.6.1 (2009-03-27)
|
=== 2.6.1 (2009-03-27)
|
||||||
* **Fixed** an occasional crash caused by permission issues.
|
* **Fixed** an occasional crash caused by permission issues.
|
||||||
* **Fixed** a bug where the "X discarded" notice would show a too large number of discarded
|
* **Fixed** a bug where the "X discarded" notice would show a too large number of discarded
|
||||||
duplicates.
|
duplicates.
|
||||||
|
|
||||||
=== 2.6.0 (2008-09-10)
|
=== 2.6.0 (2008-09-10)
|
||||||
@@ -435,14 +453,14 @@
|
|||||||
* **Added** the "Remove empty folders" option.
|
* **Added** the "Remove empty folders" option.
|
||||||
* **Fixed** results load/save issues.
|
* **Fixed** results load/save issues.
|
||||||
* **Fixed** occasional status bar inaccuracies when the results are filtered.
|
* **Fixed** occasional status bar inaccuracies when the results are filtered.
|
||||||
|
|
||||||
|
|
||||||
=== 2.5.0 (2007-09-15)
|
=== 2.5.0 (2007-09-15)
|
||||||
|
|
||||||
* **Added** post scan filtering.
|
* **Added** post scan filtering.
|
||||||
* **Fixed** issues with the rename feature under Windows
|
* **Fixed** issues with the rename feature under Windows
|
||||||
* **Fixed** some user interface annoyances under Windows
|
* **Fixed** some user interface annoyances under Windows
|
||||||
|
|
||||||
|
|
||||||
=== 2.4.8 (2007-04-14)
|
=== 2.4.8 (2007-04-14)
|
||||||
|
|
||||||
@@ -458,7 +476,7 @@
|
|||||||
|
|
||||||
* **Added** Re-orderable columns. In fact, I re-added the feature which was lost in the C# conversion in 2.4.0 (Windows).
|
* **Added** Re-orderable columns. In fact, I re-added the feature which was lost in the C# conversion in 2.4.0 (Windows).
|
||||||
* **Changed** the behavior of the scanning engine when setting the hardness to 100. It will now only match files that have their words in the same order.
|
* **Changed** the behavior of the scanning engine when setting the hardness to 100. It will now only match files that have their words in the same order.
|
||||||
* **Fixed** a bug with all the Delete/Move/Copy actions with certain kinds of files.
|
* **Fixed** a bug with all the Delete/Move/Copy actions with certain kinds of files.
|
||||||
|
|
||||||
=== 2.4.5 (2007-01-11)
|
=== 2.4.5 (2007-01-11)
|
||||||
|
|
||||||
@@ -496,7 +514,7 @@
|
|||||||
|
|
||||||
=== 2.3.4 (2006-11-07)
|
=== 2.3.4 (2006-11-07)
|
||||||
|
|
||||||
* **Improved** speed and memory usage of the scanning engine, again. Does it mean there was a lot of improvements to be made? Nah...
|
* **Improved** speed and memory usage of the scanning engine, again. Does it mean there was a lot of improvements to be made? Nah...
|
||||||
|
|
||||||
=== 2.3.3 (2006-11-02)
|
=== 2.3.3 (2006-11-02)
|
||||||
|
|
||||||
@@ -554,7 +572,7 @@
|
|||||||
=== 2.2.3 (2006-06-15)
|
=== 2.2.3 (2006-06-15)
|
||||||
|
|
||||||
* **Improved** duplicate scanning speed.
|
* **Improved** duplicate scanning speed.
|
||||||
* **Added** a warning that a file couldn't be renamed if a file with the same name already exists.
|
* **Added** a warning that a file couldn't be renamed if a file with the same name already exists.
|
||||||
|
|
||||||
=== 2.2.2 (2006-06-07)
|
=== 2.2.2 (2006-06-07)
|
||||||
|
|
||||||
@@ -598,9 +616,9 @@
|
|||||||
|
|
||||||
=== 2.0.0 (2006-03-17)
|
=== 2.0.0 (2006-03-17)
|
||||||
|
|
||||||
* Complete rewrite.
|
* Complete rewrite.
|
||||||
* Now runs on Mac OS X.
|
* Now runs on Mac OS X.
|
||||||
|
|
||||||
=== 1.0.0 (2004-09-24)
|
=== 1.0.0 (2004-09-24)
|
||||||
|
|
||||||
* Initial release.
|
* Initial release.
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ Häufig gestellte Fragen
|
|||||||
* Klicken Sie **Markieren --> Alle Markieren**.
|
* Klicken Sie **Markieren --> Alle Markieren**.
|
||||||
|
|
||||||
.. only:: edition_me
|
.. only:: edition_me
|
||||||
|
|
||||||
.. topic:: Ich möchte alle Stücke markieren, die mehr als 3 Sekunden von ihrer Referenz verschieden sind. Was kann ich tun?
|
.. topic:: Ich möchte alle Stücke markieren, die mehr als 3 Sekunden von ihrer Referenz verschieden sind. Was kann ich tun?
|
||||||
|
|
||||||
* Aktivieren Sie den :doc:`Nur Duplikate <results>` Modus.
|
* Aktivieren Sie den :doc:`Nur Duplikate <results>` Modus.
|
||||||
@@ -83,7 +83,7 @@ Häufig gestellte Fragen
|
|||||||
* Klicken Sie auf **Entferne Ausgewählte von den Ergebnissen**.
|
* Klicken Sie auf **Entferne Ausgewählte von den Ergebnissen**.
|
||||||
|
|
||||||
.. topic:: Ich möchte meine Stücke mit der höchsten Bitrate zur Referenz machen. Was kann ich tun?
|
.. topic:: Ich möchte meine Stücke mit der höchsten Bitrate zur Referenz machen. Was kann ich tun?
|
||||||
|
|
||||||
* Aktivieren Sie den :doc:`Nur Duplikate <results>` Modus.
|
* Aktivieren Sie den :doc:`Nur Duplikate <results>` Modus.
|
||||||
* Aktivieren Sie den **Deltawerte** Modus.
|
* Aktivieren Sie den **Deltawerte** Modus.
|
||||||
* Klicken Sie auf die "Bitrate" Spalte, um nach Bitrate zu sortieren.
|
* Klicken Sie auf die "Bitrate" Spalte, um nach Bitrate zu sortieren.
|
||||||
@@ -92,9 +92,9 @@ Häufig gestellte Fragen
|
|||||||
* Klicken Sie auf **Mache Ausgewählte zur Referenz**.
|
* Klicken Sie auf **Mache Ausgewählte zur Referenz**.
|
||||||
|
|
||||||
.. topic:: Ich möchte nicht das [live] und [remix] Versionen meiner Stücke als Duplikate erkannt werden. Was kann ich tun?
|
.. topic:: Ich möchte nicht das [live] und [remix] Versionen meiner Stücke als Duplikate erkannt werden. Was kann ich tun?
|
||||||
|
|
||||||
Ist Ihre Vergleichsschwelle niedrig genug, werden möglicherweise die live und remix Versionen in der Ergebnisliste landen. Das kann nicht verhindert werden, aber es gibt die Möglichkeit die Ergebnisse nach dem Scan zu entfernen, mittels dem Filter. Möchten Sie jedes Stück mit irgendetwas in eckigen Klammern [] im Dateinamen entfernen, so:
|
Ist Ihre Vergleichsschwelle niedrig genug, werden möglicherweise die live und remix Versionen in der Ergebnisliste landen. Das kann nicht verhindert werden, aber es gibt die Möglichkeit die Ergebnisse nach dem Scan zu entfernen, mittels dem Filter. Möchten Sie jedes Stück mit irgendetwas in eckigen Klammern [] im Dateinamen entfernen, so:
|
||||||
|
|
||||||
* **Windows**: Klicken Sie auf **Aktionen --> Filter anwenden**, geben "[*]" ein und klicken OK.
|
* **Windows**: Klicken Sie auf **Aktionen --> Filter anwenden**, geben "[*]" ein und klicken OK.
|
||||||
* **Mac OS X**: Geben Sie "[*]" in das "Filter" Feld der Werkzeugleiste ein.
|
* **Mac OS X**: Geben Sie "[*]" in das "Filter" Feld der Werkzeugleiste ein.
|
||||||
* Klicken Sie auf **Markieren --> Alle Markieren**.
|
* Klicken Sie auf **Markieren --> Alle Markieren**.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Jeder Ordner kann in einem von 3 Zuständen sein:
|
|||||||
* **Referenz:** Duplikate in diesem Ordner können **nicht** gelöscht werden. Dateien dieses Ordners können sich nur in der **Referenz** Position einer Duplikatgruppe befinden. Ist mehr als eine Datei des Referenzordners in derselben Duplikatgruppe, so wird nur Eine behalten. Die Anderen werden aus der Gruppe entfernt.
|
* **Referenz:** Duplikate in diesem Ordner können **nicht** gelöscht werden. Dateien dieses Ordners können sich nur in der **Referenz** Position einer Duplikatgruppe befinden. Ist mehr als eine Datei des Referenzordners in derselben Duplikatgruppe, so wird nur Eine behalten. Die Anderen werden aus der Gruppe entfernt.
|
||||||
* **Ausgeschlossen:** Dateien in diesem Verzeichnis sind nicht im Scan eingeschlossen.
|
* **Ausgeschlossen:** Dateien in diesem Verzeichnis sind nicht im Scan eingeschlossen.
|
||||||
|
|
||||||
Der Standardzustand eines Ordners ist natürlich **Normal**. Sie können den **Referenz** Zustand für Ordner nutzen, in denen auf keinen Fall eine Datei gelöscht werden soll.
|
Der Standardzustand eines Ordners ist natürlich **Normal**. Sie können den **Referenz** Zustand für Ordner nutzen, in denen auf keinen Fall eine Datei gelöscht werden soll.
|
||||||
|
|
||||||
Wenn sie einen Zustand für ein Verzeichnis setzen, erben alle Unterordner automatisch diesen Zustand, es sei denn Sie ändern den Zustand der Unterordner explizit.
|
Wenn sie einen Zustand für ein Verzeichnis setzen, erben alle Unterordner automatisch diesen Zustand, es sei denn Sie ändern den Zustand der Unterordner explizit.
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ Inhalte:
|
|||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 2
|
:maxdepth: 2
|
||||||
|
|
||||||
quick_start
|
quick_start
|
||||||
folders
|
folders
|
||||||
preferences
|
preferences
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ Einstellungen
|
|||||||
.. only:: edition_se
|
.. only:: edition_se
|
||||||
|
|
||||||
**Scan Typ:** Diese Option bestimmt nach welcher Eigenschaft die Dateien in einem Duplikate Scan verglichen werden. Wenn Sie **Dateiname** auswählen, wird dupeGuru jeden Dateinamen Wort für Wort vergleichen und, abhängig von den unteren Einstellungen, feststellen ob genügend Wörter übereinstimmen, um 2 Dateien als Duplikate zu betrachten. Wenn Sie **Inhalt** wählen, werden nur Dateien mit dem exakt gleichen Inhalt zusammenpassen.
|
**Scan Typ:** Diese Option bestimmt nach welcher Eigenschaft die Dateien in einem Duplikate Scan verglichen werden. Wenn Sie **Dateiname** auswählen, wird dupeGuru jeden Dateinamen Wort für Wort vergleichen und, abhängig von den unteren Einstellungen, feststellen ob genügend Wörter übereinstimmen, um 2 Dateien als Duplikate zu betrachten. Wenn Sie **Inhalt** wählen, werden nur Dateien mit dem exakt gleichen Inhalt zusammenpassen.
|
||||||
|
|
||||||
Der **Ordner** Scan Typ ist etwas speziell. Wird er ausgewählt, scannt dupeGuru nach doppelten Ordnern anstelle von Dateien. Um festzustellen ob 2 Ordner identisch sind, werden alle Datein im Ordner gescannt und wenn die Inhalte aller Dateien der Ordner übereinstimmen, werden die Ordner als Duplikate erkannt.
|
Der **Ordner** Scan Typ ist etwas speziell. Wird er ausgewählt, scannt dupeGuru nach doppelten Ordnern anstelle von Dateien. Um festzustellen ob 2 Ordner identisch sind, werden alle Datein im Ordner gescannt und wenn die Inhalte aller Dateien der Ordner übereinstimmen, werden die Ordner als Duplikate erkannt.
|
||||||
|
|
||||||
**Filterempfindlichkeit:** Wenn Sie den **Dateiname** Scan Typ wählen, bestimmt diese Option wie ähnlich 2 Dateinamen für dupeGuru sein müssen, um Duplikate zu sein. Ist die Empfindlichkeit zum Beispiel 80, müssen 80% der Worte der 2 Dateinamen übereinstimmen. Um den Übereinstimmungsanteil herauszufinden, zählt dupeGuru zuerst die Gesamtzahl der Wörter **beider** Dateinamen, dann werden die gleichen Wörter gezählt (jedes Wort zählt als 2) und durch die Gesamtzahl der Wörter dividiert. Ist das Resultat größer oder gleich der Filterempfindlichkeit, haben wir ein Duplikat. Zum Beispiel, "a b c d" und "c d e" haben einen Übereinstimmungsanteil von 57 (4 gleiche Wörter, insgesamt 7 Wörter).
|
**Filterempfindlichkeit:** Wenn Sie den **Dateiname** Scan Typ wählen, bestimmt diese Option wie ähnlich 2 Dateinamen für dupeGuru sein müssen, um Duplikate zu sein. Ist die Empfindlichkeit zum Beispiel 80, müssen 80% der Worte der 2 Dateinamen übereinstimmen. Um den Übereinstimmungsanteil herauszufinden, zählt dupeGuru zuerst die Gesamtzahl der Wörter **beider** Dateinamen, dann werden die gleichen Wörter gezählt (jedes Wort zählt als 2) und durch die Gesamtzahl der Wörter dividiert. Ist das Resultat größer oder gleich der Filterempfindlichkeit, haben wir ein Duplikat. Zum Beispiel, "a b c d" und "c d e" haben einen Übereinstimmungsanteil von 57 (4 gleiche Wörter, insgesamt 7 Wörter).
|
||||||
|
|
||||||
.. only:: edition_me
|
.. only:: edition_me
|
||||||
@@ -33,7 +33,7 @@ Einstellungen
|
|||||||
.. only:: edition_pe
|
.. only:: edition_pe
|
||||||
|
|
||||||
**Scan Typ:** Diese option bestimmt, welcher Scan Typ bei Ihren Bildern angewendet wird. Der **Inhalte** Scan Typ vergleicht den Inhalt der Bilder auf eine ungenaue Art und Weise (so werden nicht nur exakte Duplikate gefunden, sondern auch Ähnliche). Der **EXIF Zeitstempel** Scan Typ schaut auf die EXIF Metadaten der Bilder (wenn vorhanden) und erkennt Bilder die den Selben haben. Er ist viel schneller als der Inhalte Scan. **Warnung:** Veränderte Bilder behalten oft den selben EXIF Zeitstempel, also achten Sie auf Falschpositive bei der Nutzung dieses Scans.
|
**Scan Typ:** Diese option bestimmt, welcher Scan Typ bei Ihren Bildern angewendet wird. Der **Inhalte** Scan Typ vergleicht den Inhalt der Bilder auf eine ungenaue Art und Weise (so werden nicht nur exakte Duplikate gefunden, sondern auch Ähnliche). Der **EXIF Zeitstempel** Scan Typ schaut auf die EXIF Metadaten der Bilder (wenn vorhanden) und erkennt Bilder die den Selben haben. Er ist viel schneller als der Inhalte Scan. **Warnung:** Veränderte Bilder behalten oft den selben EXIF Zeitstempel, also achten Sie auf Falschpositive bei der Nutzung dieses Scans.
|
||||||
|
|
||||||
**Filterempfindlichkeit:** *Nur Inhalte Scan.* Je höher diese Einstellung, desto strenger ist der Filter (Mit anderen Worten, desto weniger Ergebnisse erhalten Sie). Die meisten Bilder der selben Qualität stimmen zu 100% überein, selbst wenn das Format anders ist (PNG und JPG zum Beispiel). Wie auch immer, wenn ein PNG mit einem JPG niederiger Qualität übereinstimmen soll, muss die Filterempfindlichkeit kleiner als 100 sein. Die Voreinstellung, 95, ist eine gute Wahl.
|
**Filterempfindlichkeit:** *Nur Inhalte Scan.* Je höher diese Einstellung, desto strenger ist der Filter (Mit anderen Worten, desto weniger Ergebnisse erhalten Sie). Die meisten Bilder der selben Qualität stimmen zu 100% überein, selbst wenn das Format anders ist (PNG und JPG zum Beispiel). Wie auch immer, wenn ein PNG mit einem JPG niederiger Qualität übereinstimmen soll, muss die Filterempfindlichkeit kleiner als 100 sein. Die Voreinstellung, 95, ist eine gute Wahl.
|
||||||
|
|
||||||
**Bilder unterschiedlicher Abmessung gleich:** Wird diese Box gewählt, dürfen Bilder unterschiedlicher Abmessung in einer Duplikategruppe sein..
|
**Bilder unterschiedlicher Abmessung gleich:** Wird diese Box gewählt, dürfen Bilder unterschiedlicher Abmessung in einer Duplikategruppe sein..
|
||||||
@@ -57,7 +57,7 @@ Auf jeden Fall behandelt dupeGuru Namenskonflikte indem es dem Ziel-Dateinamen e
|
|||||||
**Eigener Befehl:** Diese Einstellung bestimmt den Befehl der durch "Führe eigenen Befehl aus" ausgeführt wird. Sie können jede externe Anwendung durch diese Aktion aufrufen. Dies ist zum Beispiel hilfreich, wenn Sie eine gute diff-Anwendung installiert haben.
|
**Eigener Befehl:** Diese Einstellung bestimmt den Befehl der durch "Führe eigenen Befehl aus" ausgeführt wird. Sie können jede externe Anwendung durch diese Aktion aufrufen. Dies ist zum Beispiel hilfreich, wenn Sie eine gute diff-Anwendung installiert haben.
|
||||||
|
|
||||||
Das Format des Befehls ist das Selbe wie in einer Befehlszeile, außer das 2 Platzhalter vorhanden sind: **%d** und **%r**. Diese Platzhalter werden durch den Pfad des markierten Duplikates (%d) und dem Pfad der Duplikatereferenz ersetzt (%r).
|
Das Format des Befehls ist das Selbe wie in einer Befehlszeile, außer das 2 Platzhalter vorhanden sind: **%d** und **%r**. Diese Platzhalter werden durch den Pfad des markierten Duplikates (%d) und dem Pfad der Duplikatereferenz ersetzt (%r).
|
||||||
|
|
||||||
Wenn der Pfad Ihrer ausführbaren Datei Leerzeichen enthält, so schließen sie ihn bitte mit "" Zeichen ein. Sie sollten auch Platzhalter mit den Zitatzeichen einschließen, denn es ist möglich, das die Pfade der Duplikate und Referenzen ebenfalls Leerzeichen enthalten. Hier ist ein Beispiel eines eigenen Befehls::
|
Wenn der Pfad Ihrer ausführbaren Datei Leerzeichen enthält, so schließen sie ihn bitte mit "" Zeichen ein. Sie sollten auch Platzhalter mit den Zitatzeichen einschließen, denn es ist möglich, das die Pfade der Duplikate und Referenzen ebenfalls Leerzeichen enthalten. Hier ist ein Beispiel eines eigenen Befehls::
|
||||||
|
|
||||||
"C:\Program Files\SuperDiffProg\SuperDiffProg.exe" "%d" "%r"
|
"C:\Program Files\SuperDiffProg\SuperDiffProg.exe" "%d" "%r"
|
||||||
|
|||||||
@@ -22,4 +22,4 @@ criterion is used and so on and so on. For example, if your arguments are "Size
|
|||||||
"Filename (Doesn't end with a number)", the reference file that will be picked in a group will be
|
"Filename (Doesn't end with a number)", the reference file that will be picked in a group will be
|
||||||
the biggest file, and if two or more files have the same size, the one that has a filename that
|
the biggest file, and if two or more files have the same size, the one that has a filename that
|
||||||
doesn't end with a number will be used. When all criteria result in ties, the order in which dupes
|
doesn't end with a number will be used. When all criteria result in ties, the order in which dupes
|
||||||
previously were in the group will be used.
|
previously were in the group will be used.
|
||||||
|
|||||||
@@ -98,4 +98,4 @@ Aktionen Menü
|
|||||||
* **Ausgewählte umbenennen:** Fragt nach einem neuen Namen und benennt die ausgewählte Datei um.
|
* **Ausgewählte umbenennen:** Fragt nach einem neuen Namen und benennt die ausgewählte Datei um.
|
||||||
|
|
||||||
.. todo:: Add Move and iPhoto/iTunes warning
|
.. todo:: Add Move and iPhoto/iTunes warning
|
||||||
.. todo:: Add "Deletion Options" section.
|
.. todo:: Add "Deletion Options" section.
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ Development process
|
|||||||
* `Issue Tracker`_
|
* `Issue Tracker`_
|
||||||
* `Issue labels meaning`_
|
* `Issue labels meaning`_
|
||||||
|
|
||||||
dupeGuru's source code is on Github and thus managed in a Git repository. At all times, you should
|
dupeGuru's source code is on GitHub and thus managed in a Git repository. At all times, you should
|
||||||
be able to build from source a fresh checkout of the ``master`` branch using instructions from the
|
be able to build from source a fresh checkout of the ``master`` branch using instructions from the
|
||||||
``README.md`` file at the root of this project. If you can't, it's a bug. Please report it.
|
``README.md`` file at the root of this project. If you can't, it's a bug. Please report it.
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ It's the same thing with feature requests. Description of a feature request, whe
|
|||||||
already been given to how such a feature would fit in the current design, are precious to developers
|
already been given to how such a feature would fit in the current design, are precious to developers
|
||||||
and help them figure out a clear roadmap for the project.
|
and help them figure out a clear roadmap for the project.
|
||||||
|
|
||||||
So, even if you're not a developer, you can always open a Github account and create/comment issues.
|
So, even if you're not a developer, you can always open a GitHub account and create/comment issues.
|
||||||
Your contribution will be much appreciated.
|
Your contribution will be much appreciated.
|
||||||
|
|
||||||
**Documentation**. This is a bit trickier because dupeGuru's documentation is written with a rather
|
**Documentation**. This is a bit trickier because dupeGuru's documentation is written with a rather
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ core.engine
|
|||||||
===========
|
===========
|
||||||
|
|
||||||
.. automodule:: core.engine
|
.. automodule:: core.engine
|
||||||
|
|
||||||
.. autoclass:: Match
|
.. autoclass:: Match
|
||||||
|
|
||||||
.. autoclass:: Group
|
.. autoclass:: Group
|
||||||
:members:
|
:members:
|
||||||
|
|
||||||
.. autofunction:: build_word_dict
|
.. autofunction:: build_word_dict
|
||||||
.. autofunction:: compare
|
.. autofunction:: compare
|
||||||
.. autofunction:: compare_fields
|
.. autofunction:: compare_fields
|
||||||
@@ -16,7 +16,7 @@ core.engine
|
|||||||
.. autofunction:: get_groups
|
.. autofunction:: get_groups
|
||||||
.. autofunction:: merge_similar_words
|
.. autofunction:: merge_similar_words
|
||||||
.. autofunction:: reduce_common_words
|
.. autofunction:: reduce_common_words
|
||||||
|
|
||||||
.. _fields:
|
.. _fields:
|
||||||
|
|
||||||
Fields
|
Fields
|
||||||
|
|||||||
@@ -6,5 +6,5 @@ core.gui
|
|||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 2
|
:maxdepth: 2
|
||||||
|
|
||||||
deletion_options
|
deletion_options
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ core
|
|||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 2
|
:maxdepth: 2
|
||||||
|
|
||||||
app
|
app
|
||||||
fs
|
fs
|
||||||
engine
|
engine
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ hscommon.gui.base
|
|||||||
.. automodule:: hscommon.gui.base
|
.. automodule:: hscommon.gui.base
|
||||||
|
|
||||||
.. autosummary::
|
.. autosummary::
|
||||||
|
|
||||||
GUIObject
|
GUIObject
|
||||||
|
|
||||||
.. autoclass:: GUIObject
|
.. autoclass:: GUIObject
|
||||||
:members:
|
:members:
|
||||||
:private-members:
|
:private-members:
|
||||||
|
|||||||
@@ -4,22 +4,22 @@ hscommon.gui.column
|
|||||||
.. automodule:: hscommon.gui.column
|
.. automodule:: hscommon.gui.column
|
||||||
|
|
||||||
.. autosummary::
|
.. autosummary::
|
||||||
|
|
||||||
Columns
|
Columns
|
||||||
Column
|
Column
|
||||||
ColumnsView
|
ColumnsView
|
||||||
PrefAccessInterface
|
PrefAccessInterface
|
||||||
|
|
||||||
.. autoclass:: Columns
|
.. autoclass:: Columns
|
||||||
:members:
|
:members:
|
||||||
:private-members:
|
:private-members:
|
||||||
|
|
||||||
.. autoclass:: Column
|
.. autoclass:: Column
|
||||||
:members:
|
:members:
|
||||||
:private-members:
|
:private-members:
|
||||||
|
|
||||||
.. autoclass:: ColumnsView
|
.. autoclass:: ColumnsView
|
||||||
:members:
|
:members:
|
||||||
|
|
||||||
.. autoclass:: PrefAccessInterface
|
.. autoclass:: PrefAccessInterface
|
||||||
:members:
|
:members:
|
||||||
|
|||||||
@@ -4,15 +4,14 @@ hscommon.gui.progress_window
|
|||||||
.. automodule:: hscommon.gui.progress_window
|
.. automodule:: hscommon.gui.progress_window
|
||||||
|
|
||||||
.. autosummary::
|
.. autosummary::
|
||||||
|
|
||||||
ProgressWindow
|
ProgressWindow
|
||||||
ProgressWindowView
|
ProgressWindowView
|
||||||
|
|
||||||
.. autoclass:: ProgressWindow
|
.. autoclass:: ProgressWindow
|
||||||
:members:
|
:members:
|
||||||
:private-members:
|
:private-members:
|
||||||
|
|
||||||
.. autoclass:: ProgressWindowView
|
.. autoclass:: ProgressWindowView
|
||||||
:members:
|
:members:
|
||||||
:private-members:
|
:private-members:
|
||||||
|
|
||||||
|
|||||||
@@ -4,23 +4,23 @@ hscommon.gui.selectable_list
|
|||||||
.. automodule:: hscommon.gui.selectable_list
|
.. automodule:: hscommon.gui.selectable_list
|
||||||
|
|
||||||
.. autosummary::
|
.. autosummary::
|
||||||
|
|
||||||
Selectable
|
Selectable
|
||||||
SelectableList
|
SelectableList
|
||||||
GUISelectableList
|
GUISelectableList
|
||||||
GUISelectableListView
|
GUISelectableListView
|
||||||
|
|
||||||
.. autoclass:: Selectable
|
.. autoclass:: Selectable
|
||||||
:members:
|
:members:
|
||||||
:private-members:
|
:private-members:
|
||||||
|
|
||||||
.. autoclass:: SelectableList
|
.. autoclass:: SelectableList
|
||||||
:members:
|
:members:
|
||||||
:private-members:
|
:private-members:
|
||||||
|
|
||||||
.. autoclass:: GUISelectableList
|
.. autoclass:: GUISelectableList
|
||||||
:members:
|
:members:
|
||||||
:private-members:
|
:private-members:
|
||||||
|
|
||||||
.. autoclass:: GUISelectableListView
|
.. autoclass:: GUISelectableListView
|
||||||
:members:
|
:members:
|
||||||
|
|||||||
@@ -2,18 +2,18 @@ hscommon.gui.table
|
|||||||
==================
|
==================
|
||||||
|
|
||||||
.. automodule:: hscommon.gui.table
|
.. automodule:: hscommon.gui.table
|
||||||
|
|
||||||
.. autosummary::
|
.. autosummary::
|
||||||
|
|
||||||
Table
|
Table
|
||||||
Row
|
Row
|
||||||
GUITable
|
GUITable
|
||||||
GUITableView
|
GUITableView
|
||||||
|
|
||||||
.. autoclass:: Table
|
.. autoclass:: Table
|
||||||
:members:
|
:members:
|
||||||
:private-members:
|
:private-members:
|
||||||
|
|
||||||
.. autoclass:: Row
|
.. autoclass:: Row
|
||||||
:members:
|
:members:
|
||||||
:private-members:
|
:private-members:
|
||||||
@@ -21,6 +21,6 @@ hscommon.gui.table
|
|||||||
.. autoclass:: GUITable
|
.. autoclass:: GUITable
|
||||||
:members:
|
:members:
|
||||||
:private-members:
|
:private-members:
|
||||||
|
|
||||||
.. autoclass:: GUITableView
|
.. autoclass:: GUITableView
|
||||||
:members:
|
:members:
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ hscommon.gui.text_field
|
|||||||
.. automodule:: hscommon.gui.text_field
|
.. automodule:: hscommon.gui.text_field
|
||||||
|
|
||||||
.. autosummary::
|
.. autosummary::
|
||||||
|
|
||||||
TextField
|
TextField
|
||||||
TextFieldView
|
TextFieldView
|
||||||
|
|
||||||
.. autoclass:: TextField
|
.. autoclass:: TextField
|
||||||
:members:
|
:members:
|
||||||
:private-members:
|
:private-members:
|
||||||
|
|||||||
@@ -2,17 +2,16 @@ hscommon.gui.tree
|
|||||||
=================
|
=================
|
||||||
|
|
||||||
.. automodule:: hscommon.gui.tree
|
.. automodule:: hscommon.gui.tree
|
||||||
|
|
||||||
.. autosummary::
|
.. autosummary::
|
||||||
|
|
||||||
Tree
|
Tree
|
||||||
Node
|
Node
|
||||||
|
|
||||||
.. autoclass:: Tree
|
.. autoclass:: Tree
|
||||||
:members:
|
:members:
|
||||||
:private-members:
|
:private-members:
|
||||||
|
|
||||||
.. autoclass:: Node
|
.. autoclass:: Node
|
||||||
:members:
|
:members:
|
||||||
:private-members:
|
:private-members:
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ hscommon
|
|||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 2
|
:maxdepth: 2
|
||||||
:glob:
|
:glob:
|
||||||
|
|
||||||
build
|
build
|
||||||
conflict
|
conflict
|
||||||
desktop
|
desktop
|
||||||
@@ -13,4 +13,3 @@ hscommon
|
|||||||
util
|
util
|
||||||
jobprogress/*
|
jobprogress/*
|
||||||
gui/*
|
gui/*
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,13 @@ hscommon.jobprogress.job
|
|||||||
.. automodule:: hscommon.jobprogress.job
|
.. automodule:: hscommon.jobprogress.job
|
||||||
|
|
||||||
.. autosummary::
|
.. autosummary::
|
||||||
|
|
||||||
Job
|
Job
|
||||||
NullJob
|
NullJob
|
||||||
|
|
||||||
.. autoclass:: Job
|
.. autoclass:: Job
|
||||||
:members:
|
:members:
|
||||||
:private-members:
|
:private-members:
|
||||||
|
|
||||||
.. autoclass:: NullJob
|
.. autoclass:: NullJob
|
||||||
:members:
|
:members:
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ hscommon.jobprogress.performer
|
|||||||
.. automodule:: hscommon.jobprogress.performer
|
.. automodule:: hscommon.jobprogress.performer
|
||||||
|
|
||||||
.. autosummary::
|
.. autosummary::
|
||||||
|
|
||||||
ThreadedJobPerformer
|
ThreadedJobPerformer
|
||||||
|
|
||||||
.. autoclass:: ThreadedJobPerformer
|
.. autoclass:: ThreadedJobPerformer
|
||||||
:members:
|
:members:
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,6 @@ API
|
|||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 2
|
:maxdepth: 2
|
||||||
|
|
||||||
core/index
|
core/index
|
||||||
hscommon/index
|
hscommon/index
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ that makes sure that you will **always** keep at least one member of the duplica
|
|||||||
How can I report a bug a suggest a feature?
|
How can I report a bug a suggest a feature?
|
||||||
-------------------------------------------
|
-------------------------------------------
|
||||||
|
|
||||||
dupeGuru is hosted on `Github`_ and it's also where issues are tracked. The best way to report a
|
dupeGuru is hosted on `GitHub`_ and it's also where issues are tracked. The best way to report a
|
||||||
bug or suggest a feature is to sign up on Github and `open an issue`_.
|
bug or suggest a feature is to sign up on GitHub and `open an issue`_.
|
||||||
|
|
||||||
The mark box of a file I want to delete is disabled. What must I do?
|
The mark box of a file I want to delete is disabled. What must I do?
|
||||||
--------------------------------------------------------------------
|
--------------------------------------------------------------------
|
||||||
@@ -176,6 +176,5 @@ Preferences are stored elsewhere:
|
|||||||
* Linux: ``~/.config/Hardcoded Software/dupeGuru.conf``
|
* Linux: ``~/.config/Hardcoded Software/dupeGuru.conf``
|
||||||
* Mac OS X: In the built-in ``defaults`` system, as ``com.hardcoded-software.dupeguru``
|
* Mac OS X: In the built-in ``defaults`` system, as ``com.hardcoded-software.dupeguru``
|
||||||
|
|
||||||
.. _Github: https://github.com/arsenetar/dupeguru
|
.. _GitHub: https://github.com/arsenetar/dupeguru
|
||||||
.. _open an issue: https://github.com/arsenetar/dupeguru/wiki/issue-labels
|
.. _open an issue: https://github.com/arsenetar/dupeguru/wiki/issue-labels
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ Contents:
|
|||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 2
|
:maxdepth: 2
|
||||||
|
|
||||||
contribute
|
contribute
|
||||||
quick_start
|
quick_start
|
||||||
folders
|
folders
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ Preferences
|
|||||||
If you check this box, pictures of different dimensions will be allowed in the same
|
If you check this box, pictures of different dimensions will be allowed in the same
|
||||||
duplicate group.
|
duplicate group.
|
||||||
|
|
||||||
|
**Match pictures of different rotations:**
|
||||||
|
If you check this box, pictures of different rotations will be allowed in the same
|
||||||
|
duplicate group.
|
||||||
|
|
||||||
.. _filter-hardness:
|
.. _filter-hardness:
|
||||||
|
|
||||||
**Filter Hardness:**
|
**Filter Hardness:**
|
||||||
@@ -67,11 +71,11 @@ filename if the filename already exists in the destination.
|
|||||||
The format of the command is the same as what you would write in the command line, except that there
|
The format of the command is the same as what you would write in the command line, except that there
|
||||||
are 2 placeholders: **%d** and **%r**. These placeholders will be replaced by the path of the
|
are 2 placeholders: **%d** and **%r**. These placeholders will be replaced by the path of the
|
||||||
selected dupe (%d) and the path of the selected dupe's reference file (%r).
|
selected dupe (%d) and the path of the selected dupe's reference file (%r).
|
||||||
|
|
||||||
If the path to your executable contains space characters, you should enclose it in "" quotes. You
|
If the path to your executable contains space characters, you should enclose it in "" quotes. You
|
||||||
should also enclose placeholders in quotes because it's very possible that paths to dupes and refs
|
should also enclose placeholders in quotes because it's very possible that paths to dupes and refs
|
||||||
will contain spaces. Here's an example custom command::
|
will contain spaces. Here's an example custom command::
|
||||||
|
|
||||||
"C:\Program Files\SuperDiffProg\SuperDiffProg.exe" "%d" "%r"
|
"C:\Program Files\SuperDiffProg\SuperDiffProg.exe" "%d" "%r"
|
||||||
|
|
||||||
.. _inode: http://en.wikipedia.org/wiki/Inode
|
.. _inode: http://en.wikipedia.org/wiki/Inode
|
||||||
|
|||||||
@@ -22,4 +22,4 @@ criterion is used and so on and so on. For example, if your arguments are "Size
|
|||||||
"Filename (Doesn't end with a number)", the reference file that will be picked in a group will be
|
"Filename (Doesn't end with a number)", the reference file that will be picked in a group will be
|
||||||
the biggest file, and if two or more files have the same size, the one that has a filename that
|
the biggest file, and if two or more files have the same size, the one that has a filename that
|
||||||
doesn't end with a number will be used. When all criteria result in ties, the order in which dupes
|
doesn't end with a number will be used. When all criteria result in ties, the order in which dupes
|
||||||
previously were in the group will be used.
|
previously were in the group will be used.
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ any of them.
|
|||||||
the file's path. If the original file is deleted or moved, the link is broken. A hardlink is a
|
the file's path. If the original file is deleted or moved, the link is broken. A hardlink is a
|
||||||
link to the file *itself*. That link is as good as a "real" file. Only when *all* hardlinks to a
|
link to the file *itself*. That link is as good as a "real" file. Only when *all* hardlinks to a
|
||||||
file are deleted is the file itself deleted.
|
file are deleted is the file itself deleted.
|
||||||
|
|
||||||
On OSX and Linux, this feature is supported fully, but under Windows, it's a bit complicated.
|
On OSX and Linux, this feature is supported fully, but under Windows, it's a bit complicated.
|
||||||
Windows XP doesn't support it, but Vista and up support it. However, for the feature to work,
|
Windows XP doesn't support it, but Vista and up support it. However, for the feature to work,
|
||||||
dupeGuru has to run with administrative privileges.
|
dupeGuru has to run with administrative privileges.
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ Tour groupe de doublons contient au moins un fichier dit "référence" et ce fic
|
|||||||
effacé. Par contre, ce que vous pouvez faire c'est de le remplacer par un autre fichier du groupe.
|
effacé. Par contre, ce que vous pouvez faire c'est de le remplacer par un autre fichier du groupe.
|
||||||
Pour ce faire, sélectionnez un fichier du groupe et cliquez sur l'action **Transformer sélectionnés
|
Pour ce faire, sélectionnez un fichier du groupe et cliquez sur l'action **Transformer sélectionnés
|
||||||
en références**.
|
en références**.
|
||||||
|
|
||||||
Notez que si le fichier référence du groupe vient d'un dossier qui a été défini comme dossier
|
Notez que si le fichier référence du groupe vient d'un dossier qui a été défini comme dossier
|
||||||
référence, ce fichier ne peut pas être déplacé de sa position de référence du groupe.
|
référence, ce fichier ne peut pas être déplacé de sa position de référence du groupe.
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ doublons. Example: Nous avons 3 fichiers, A, B et C. Nous les comparons en utili
|
|||||||
de filtre. La comparaison détermine que A est un double de B, A est un double C, mais que B n'est
|
de filtre. La comparaison détermine que A est un double de B, A est un double C, mais que B n'est
|
||||||
**pas** un double de C. dupeGuru a ici un problème. Il ne peut pas créer un groupe avec A, B et C.
|
**pas** un double de C. dupeGuru a ici un problème. Il ne peut pas créer un groupe avec A, B et C.
|
||||||
Il décide donc de jeter C hors du groupe. C'est de là que vient la notice '(X hors-groupe)'.
|
Il décide donc de jeter C hors du groupe. C'est de là que vient la notice '(X hors-groupe)'.
|
||||||
|
|
||||||
Cette notice veut dire que si jamais vous effacez tout les doubles contenus dans vos résultats et
|
Cette notice veut dire que si jamais vous effacez tout les doubles contenus dans vos résultats et
|
||||||
que vous faites un nouveau scan, vous pourriez avoir de nouveaux résultats.
|
que vous faites un nouveau scan, vous pourriez avoir de nouveaux résultats.
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ Sélection de dossiers
|
|||||||
|
|
||||||
La première fenêtre qui apparaît lorsque dupeGuru démarre est la fenêtre de sélection de dossiers à scanner. Elle détermine la liste des dossiers qui seront scannés lorsque vous cliquerez sur **Scan**.
|
La première fenêtre qui apparaît lorsque dupeGuru démarre est la fenêtre de sélection de dossiers à scanner. Elle détermine la liste des dossiers qui seront scannés lorsque vous cliquerez sur **Scan**.
|
||||||
|
|
||||||
Pour ajouter un dossier, cliquez sur le bouton **+**. Si vous avez ajouté des dossiers dans le passé, un menu vous permettra de rapidement choisir un de ceux ci. Autrement, il vous sera demandé d'indiquer le dossier à ajouter.
|
Pour ajouter un dossier, cliquez sur le bouton **+**. Si vous avez ajouté des dossiers dans le passé, un menu vous permettra de rapidement choisir un de ceux ci. Autrement, il vous sera demandé d'indiquer le dossier à ajouter.
|
||||||
|
|
||||||
Vous pouvez aussi utiliser le drag & drop pour ajouter des dossiers à la liste.
|
Vous pouvez aussi utiliser le drag & drop pour ajouter des dossiers à la liste.
|
||||||
|
|
||||||
@@ -26,14 +26,14 @@ Le type d'un dossier s'applique à ses sous-dossiers, excepté si un sous-dossie
|
|||||||
|
|
||||||
Bibliothèques iPhoto et Aperture
|
Bibliothèques iPhoto et Aperture
|
||||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
dupeGuru PE supporte iPhoto et Aperture, ce qui veut dire qu'il sait comment lire le contenu de
|
dupeGuru PE supporte iPhoto et Aperture, ce qui veut dire qu'il sait comment lire le contenu de
|
||||||
ces bibliothèques et comment communiquer avec ces applications pour correctement supprimer des
|
ces bibliothèques et comment communiquer avec ces applications pour correctement supprimer des
|
||||||
photos de celles-ci. Pour utiliser cette fonctionnalité, vous devez ajouter iPhoto et/ou
|
photos de celles-ci. Pour utiliser cette fonctionnalité, vous devez ajouter iPhoto et/ou
|
||||||
Aperture avec les boutons spéciaux "Ajouter librairie iPhoto" et "Ajouter librairie Aperture",
|
Aperture avec les boutons spéciaux "Ajouter librairie iPhoto" et "Ajouter librairie Aperture",
|
||||||
qui apparaissent quand on clique sur le petit "+". Les dossiers ajoutés seront alors
|
qui apparaissent quand on clique sur le petit "+". Les dossiers ajoutés seront alors
|
||||||
correctement interprétés par dupeGuru.
|
correctement interprétés par dupeGuru.
|
||||||
|
|
||||||
Quand une photo est supprimée d'iPhoto, elle est envoyée dans la corbeille d'iPhoto.
|
Quand une photo est supprimée d'iPhoto, elle est envoyée dans la corbeille d'iPhoto.
|
||||||
|
|
||||||
Quand une photo est supprimée d'Aperture, il n'est malheureusement pas possible de l'envoyer
|
Quand une photo est supprimée d'Aperture, il n'est malheureusement pas possible de l'envoyer
|
||||||
@@ -45,13 +45,13 @@ Le type d'un dossier s'applique à ses sous-dossiers, excepté si un sous-dossie
|
|||||||
|
|
||||||
Bibliothèques iTunes
|
Bibliothèques iTunes
|
||||||
^^^^^^^^^^^^^^^^^^^^
|
^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
dupeGuru ME supporte iTunes, ce qui veut dire qu'il sait comment lire le contenu de sa
|
dupeGuru ME supporte iTunes, ce qui veut dire qu'il sait comment lire le contenu de sa
|
||||||
bibliothèque et comment communiquer avec iTunes pour correctement supprimer des chansons de sa
|
bibliothèque et comment communiquer avec iTunes pour correctement supprimer des chansons de sa
|
||||||
bibliothèque. Pour utiliser cette fonctionnalité, vous devez ajouter iTunes avec le bouton
|
bibliothèque. Pour utiliser cette fonctionnalité, vous devez ajouter iTunes avec le bouton
|
||||||
spécial "Ajouter librairie iTunes", qui apparait quand on clique sur le petit "+". Le dossier
|
spécial "Ajouter librairie iTunes", qui apparait quand on clique sur le petit "+". Le dossier
|
||||||
ajouté sera alors correctement interprété par dupeGuru.
|
ajouté sera alors correctement interprété par dupeGuru.
|
||||||
|
|
||||||
Quand une chanson est supprimée d'iTunes, elle est envoyée à la corebeille du système, comme un
|
Quand une chanson est supprimée d'iTunes, elle est envoyée à la corebeille du système, comme un
|
||||||
fichier normal. La différence ici, c'est qu'après la suppression, iTunes est correctement mis au
|
fichier normal. La différence ici, c'est qu'après la suppression, iTunes est correctement mis au
|
||||||
fait de cette suppression et retire sa référence à cette chanson de sa bibliothèque.
|
fait de cette suppression et retire sa référence à cette chanson de sa bibliothèque.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ Contents:
|
|||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 2
|
:maxdepth: 2
|
||||||
|
|
||||||
quick_start
|
quick_start
|
||||||
folders
|
folders
|
||||||
preferences
|
preferences
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ Préférences
|
|||||||
.. only:: edition_se
|
.. only:: edition_se
|
||||||
|
|
||||||
**Type de scan:** Cette option détermine quels aspects du fichier doit être comparé. Un scan par **Nom de fichier** compare les noms de fichiers mot-à-mot et, dépendant des autres préférences ci-dessous, déterminera si les noms se ressemblent assez pour être considérés comme doublons. Un scan par **Contenu** trouvera les doublons qui ont exactement le même contenu.
|
**Type de scan:** Cette option détermine quels aspects du fichier doit être comparé. Un scan par **Nom de fichier** compare les noms de fichiers mot-à-mot et, dépendant des autres préférences ci-dessous, déterminera si les noms se ressemblent assez pour être considérés comme doublons. Un scan par **Contenu** trouvera les doublons qui ont exactement le même contenu.
|
||||||
|
|
||||||
Le scan **Dossiers** est spécial. Si vous le sélectionnez, dupeGuru cherchera des doublons de *dossiers* plutôt que des doublons de fichiers. Pour déterminer si deux dossiers sont des doublons, dupeGuru regarde le contenu de tous les fichiers dans les dossiers, et si **tous** sont les mêmes, les dossiers sont considérés comme des doublons.
|
Le scan **Dossiers** est spécial. Si vous le sélectionnez, dupeGuru cherchera des doublons de *dossiers* plutôt que des doublons de fichiers. Pour déterminer si deux dossiers sont des doublons, dupeGuru regarde le contenu de tous les fichiers dans les dossiers, et si **tous** sont les mêmes, les dossiers sont considérés comme des doublons.
|
||||||
|
|
||||||
**Seuil du filtre:** Pour les scan de type **Nom de fichier**, cette option détermine le degré de similtude nécessaire afin de considérer deux noms comme doublons. Avec un seuil de 80, 80% des mots doivent être égaux. Pour déterminer ce pourcentage, dupeGuru compte le nombre de mots total des deux noms, puis compte le nombre de mots égaux, puis fait la division des deux. Un résultat égalisant ou dépassant le seuil sera considéré comme un doublon. Exemple: "a b c d" et "c d e" ont un pourcentage de 57 (4 mots égaux, 7 au total).
|
**Seuil du filtre:** Pour les scan de type **Nom de fichier**, cette option détermine le degré de similtude nécessaire afin de considérer deux noms comme doublons. Avec un seuil de 80, 80% des mots doivent être égaux. Pour déterminer ce pourcentage, dupeGuru compte le nombre de mots total des deux noms, puis compte le nombre de mots égaux, puis fait la division des deux. Un résultat égalisant ou dépassant le seuil sera considéré comme un doublon. Exemple: "a b c d" et "c d e" ont un pourcentage de 57 (4 mots égaux, 7 au total).
|
||||||
|
|
||||||
.. only:: edition_me
|
.. only:: edition_me
|
||||||
@@ -33,7 +33,7 @@ Préférences
|
|||||||
.. only:: edition_pe
|
.. only:: edition_pe
|
||||||
|
|
||||||
**Type de scan:** Détermine le type de scan qui sera fait sur vos images. Le type **Contenu** compare le contenu des images de façon "fuzzy", rendant possible de trouver non seulement les doublons exactes, mais aussi les similaires. Le type **EXIF Timestamp** compare les métadonnées EXIF des images (si existantes) et détermine si le "timestamp" (moment de prise de la photo) est pareille. C'est beaucoup plus rapide que le scan par Contenu. **Attention:** Les photos modifiées gardent souvent le même timestamp, donc faites attention aux faux doublons si vous utilisez cette méthode.
|
**Type de scan:** Détermine le type de scan qui sera fait sur vos images. Le type **Contenu** compare le contenu des images de façon "fuzzy", rendant possible de trouver non seulement les doublons exactes, mais aussi les similaires. Le type **EXIF Timestamp** compare les métadonnées EXIF des images (si existantes) et détermine si le "timestamp" (moment de prise de la photo) est pareille. C'est beaucoup plus rapide que le scan par Contenu. **Attention:** Les photos modifiées gardent souvent le même timestamp, donc faites attention aux faux doublons si vous utilisez cette méthode.
|
||||||
|
|
||||||
**Seuil du filtre:** *Scan par Contenu seulement.* Plus il est élevé, plus les images doivent être similaires pour être considérées comme des doublons. Le défaut de 95% permet quelques petites différence, comme par exemple une différence de qualité ou bien une légère modification des couleurs.
|
**Seuil du filtre:** *Scan par Contenu seulement.* Plus il est élevé, plus les images doivent être similaires pour être considérées comme des doublons. Le défaut de 95% permet quelques petites différence, comme par exemple une différence de qualité ou bien une légère modification des couleurs.
|
||||||
|
|
||||||
**Comparer les images de tailles différentes:** Le nom dit tout. Sans cette option, les images de tailles différentes ne sont pas comparées.
|
**Comparer les images de tailles différentes:** Le nom dit tout. Sans cette option, les images de tailles différentes ne sont pas comparées.
|
||||||
@@ -58,6 +58,6 @@ Dans tous les cas, dupeGuru résout les conflits de noms de fichier en ajoutant
|
|||||||
|
|
||||||
Le format de la ligne de commande est la même que celle que vous écrireriez manuellement, excepté pour les arguments, **%d** et **%r**. L'endroit où vous placez ces deux arguments sera remplacé par le chemin du fichier sélectionné (%d) et le chemin de son fichier référence dans le groupe (%r).
|
Le format de la ligne de commande est la même que celle que vous écrireriez manuellement, excepté pour les arguments, **%d** et **%r**. L'endroit où vous placez ces deux arguments sera remplacé par le chemin du fichier sélectionné (%d) et le chemin de son fichier référence dans le groupe (%r).
|
||||||
|
|
||||||
Si le chemin de votre executable contient un espace, vous devez le placer entre guillemets "". Vous devriez aussi placer vos arguments %d et %r entre guillemets parce qu'il est très possible d'avoir des chemins de fichier contenant des espaces. Voici un exemple de commande personnelle::
|
Si le chemin de votre executable contient un espace, vous devez le placer entre guillemets "". Vous devriez aussi placer vos arguments %d et %r entre guillemets parce qu'il est très possible d'avoir des chemins de fichier contenant des espaces. Voici un exemple de commande personnelle::
|
||||||
|
|
||||||
"C:\Program Files\SuperDiffProg\SuperDiffProg.exe" "%d" "%r"
|
"C:\Program Files\SuperDiffProg\SuperDiffProg.exe" "%d" "%r"
|
||||||
|
|||||||
@@ -22,4 +22,4 @@ criterion is used and so on and so on. For example, if your arguments are "Size
|
|||||||
"Filename (Doesn't end with a number)", the reference file that will be picked in a group will be
|
"Filename (Doesn't end with a number)", the reference file that will be picked in a group will be
|
||||||
the biggest file, and if two or more files have the same size, the one that has a filename that
|
the biggest file, and if two or more files have the same size, the one that has a filename that
|
||||||
doesn't end with a number will be used. When all criteria result in ties, the order in which dupes
|
doesn't end with a number will be used. When all criteria result in ties, the order in which dupes
|
||||||
previously were in the group will be used.
|
previously were in the group will be used.
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ s'exécute. La plupart du temps, ces options n'ont pas a être activées.
|
|||||||
liens (`symlink`_ ou `hardlink`_) vers leur fichiers de référence respectifs. Un symlink est un
|
liens (`symlink`_ ou `hardlink`_) vers leur fichiers de référence respectifs. Un symlink est un
|
||||||
lien symbolique (qui devient caduque si l'original est supprimé) et un hardlink est un lien direct
|
lien symbolique (qui devient caduque si l'original est supprimé) et un hardlink est un lien direct
|
||||||
au contenu du fichier (même si l'original est supprimé, le lien reste valide).
|
au contenu du fichier (même si l'original est supprimé, le lien reste valide).
|
||||||
|
|
||||||
Sur OS X et Linux, cette fonction est supportée pleinement, mais sur Windows, c'est un peu
|
Sur OS X et Linux, cette fonction est supportée pleinement, mais sur Windows, c'est un peu
|
||||||
compliqué. Windows XP ne le supporte pas, mais Vista oui. De plus, cette fonction ne peut être
|
compliqué. Windows XP ne le supporte pas, mais Vista oui. De plus, cette fonction ne peut être
|
||||||
utilisée que si dupeGuru roule avec les privilèges administratifs. Ouaip, Windows c'est la joie.
|
utilisée que si dupeGuru roule avec les privilèges administratifs. Ouaip, Windows c'est la joie.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
.. topic:: Ո՞րոն եք dupeGuru-ի լիցենզիայի սահմանափակումները:
|
.. topic:: Ո՞րոն եք dupeGuru-ի լիցենզիայի սահմանափակումները:
|
||||||
|
|
||||||
Փորձնական եղանակում, Դուք կարող եք միայն կատարել գործողություններ 10 կրկնօրինակների հետ միաժամանակ: Ծրագրի
|
Փորձնական եղանակում, Դուք կարող եք միայն կատարել գործողություններ 10 կրկնօրինակների հետ միաժամանակ: Ծրագրի
|
||||||
`Անվճար տարբերակում <http://open.hardcoded.net/about/>`_ mode, այնուհանդերձ չկան էական սահմանափակումներ:
|
`Անվճար տարբերակում <http://open.hardcoded.net/about/>`_ mode, այնուհանդերձ չկան էական սահմանափակումներ:
|
||||||
|
|
||||||
.. topic::Ջնջելու համար նշելու դաշտի պատուհանը ակտիվ չէ: Ի՞նչ անել:
|
.. topic::Ջնջելու համար նշելու դաշտի պատուհանը ակտիվ չէ: Ի՞նչ անել:
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
* Սեղմեք **Նշել --> Նշել բոլորը**:
|
* Սեղմեք **Նշել --> Նշել բոլորը**:
|
||||||
|
|
||||||
.. only:: edition_me
|
.. only:: edition_me
|
||||||
|
|
||||||
.. topic:: Ես ցանկանում եմ հեռացնել բոլոր երգերը, որոնք 3 վայրկյանից հեռու են իրենց հղման ֆայլից: Ի՞նչ կարող եմ ես անել:
|
.. topic:: Ես ցանկանում եմ հեռացնել բոլոր երգերը, որոնք 3 վայրկյանից հեռու են իրենց հղման ֆայլից: Ի՞նչ կարող եմ ես անել:
|
||||||
|
|
||||||
* Միացնել :doc:`Միայն Սխալները <results>` եղանակում:
|
* Միացնել :doc:`Միայն Սխալները <results>` եղանակում:
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
* Սեղմեք **Ջնջել ընտրվածը արդյունքներից**:
|
* Սեղմեք **Ջնջել ընտրվածը արդյունքներից**:
|
||||||
|
|
||||||
.. topic:: Ես ցանկանում եմ դարձնել իմ բարձրագույն բիթրեյթ ունեցող երգերը հղման ֆայլեր: Ի՞նչ կարող եմ ես անել:
|
.. topic:: Ես ցանկանում եմ դարձնել իմ բարձրագույն բիթրեյթ ունեցող երգերը հղման ֆայլեր: Ի՞նչ կարող եմ ես անել:
|
||||||
|
|
||||||
* Միացնել :doc:`Միայն Սխալները <results>` եղանակում:
|
* Միացնել :doc:`Միայն Սխալները <results>` եղանակում:
|
||||||
* Միացնել **Դելտա նշանակությունները** եղանակը:
|
* Միացնել **Դելտա նշանակությունները** եղանակը:
|
||||||
* Սեղմեք "Բիթրեյթը" սյանը՝ դասավորելու համար արդյունքները ըստ բիթրեյթի:
|
* Սեղմեք "Բիթրեյթը" սյանը՝ դասավորելու համար արդյունքները ըստ բիթրեյթի:
|
||||||
@@ -93,12 +93,12 @@
|
|||||||
* Սեղմեք **Դարձնել ընտրվածը հղում**:
|
* Սեղմեք **Դարձնել ընտրվածը հղում**:
|
||||||
|
|
||||||
.. topic:: Ես չեմ ցանկանում [live] և [remix] տարբերակները իմ երգերի՝ հաշված որպես կրկնօրինակ: Ինչպե՞ս դա անել:
|
.. topic:: Ես չեմ ցանկանում [live] և [remix] տարբերակները իմ երգերի՝ հաշված որպես կրկնօրինակ: Ինչպե՞ս դա անել:
|
||||||
|
|
||||||
Եթե Ձեր համեմատության սահմանը բավականին ցածր է, հնարավոր է Դուք ավարտվեք կենդանի և ռեմիքս տարբերակներով Ձեր երգերի արդյունեքներում: Դուք ոչինչ չեք կարող անել դրա համար, բայց կա ինչ-որ եղանակ՝ դրանք ստուգման արդյունքներից ջնջելու համար: Եթե օրինակի համար, Դուք ցանկանում եք ջնջել ամեն մի երգ, որը գտնվում է գծիկների միջև []:.
|
Եթե Ձեր համեմատության սահմանը բավականին ցածր է, հնարավոր է Դուք ավարտվեք կենդանի և ռեմիքս տարբերակներով Ձեր երգերի արդյունեքներում: Դուք ոչինչ չեք կարող անել դրա համար, բայց կա ինչ-որ եղանակ՝ դրանք ստուգման արդյունքներից ջնջելու համար: Եթե օրինակի համար, Դուք ցանկանում եք ջնջել ամեն մի երգ, որը գտնվում է գծիկների միջև []:.
|
||||||
* **Windows**. Սեղմեք **Գործողություններ --> Կիրառել ֆիլտրը**, ապա տեսակը "[*]", ապա սեղմեք ԼԱՎ:
|
* **Windows**. Սեղմեք **Գործողություններ --> Կիրառել ֆիլտրը**, ապա տեսակը "[*]", ապա սեղմեք ԼԱՎ:
|
||||||
* **Mac OS X**. Տեսակը "[*]" "Ֆիլտր" դաշտում՝ գործիքաշերտի:
|
* **Mac OS X**. Տեսակը "[*]" "Ֆիլտր" դաշտում՝ գործիքաշերտի:
|
||||||
* Սեղմեք **Նշել --> Նշել բոլորը**:
|
* Սեղմեք **Նշել --> Նշել բոլորը**:
|
||||||
* Սեղմեք **Գործողություններ --> Ջնջել ընտրվածը արդյունքներից**.
|
* Սեղմեք **Գործողություններ --> Ջնջել ընտրվածը արդյունքներից**.
|
||||||
|
|
||||||
.. topic:: Ես փորձում եմ կրկնօրինակները ուղարկել Աղբարկղ, բայց dupeGuru-ն ինձ ասում է, որ չես կարող: Ինչու՞: Ի՞նչ կարող եմ ես անել:
|
.. topic:: Ես փորձում եմ կրկնօրինակները ուղարկել Աղբարկղ, բայց dupeGuru-ն ինձ ասում է, որ չես կարող: Ինչու՞: Ի՞նչ կարող եմ ես անել:
|
||||||
|
|
||||||
@@ -112,4 +112,4 @@
|
|||||||
|
|
||||||
Եթե այս ամենը ձախողվի, `կապնվեք HS աջակցության թիմի հետ <http://www.hardcoded.net/support>`_, մենք կփորձեք օգնել Ձեզ:
|
Եթե այս ամենը ձախողվի, `կապնվեք HS աջակցության թիմի հետ <http://www.hardcoded.net/support>`_, մենք կփորձեք օգնել Ձեզ:
|
||||||
|
|
||||||
.. todo:: This FAQ qestion is outdated, see english version.
|
.. todo:: This FAQ qestion is outdated, see english version.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
|
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 2
|
:maxdepth: 2
|
||||||
|
|
||||||
quick_start
|
quick_start
|
||||||
folders
|
folders
|
||||||
preferences
|
preferences
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user