Compare commits
202 Commits
c4a6958ef0
...
4.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 421a58a61c | |||
|
|
b5a3313f80 | ||
|
|
116ac18e13 | ||
|
|
32dcd90b50 | ||
|
|
c2fef8d624 | ||
|
fd0adc77b3
|
|||
|
6a03e1e399
|
|||
|
ae51842007
|
|||
| ab6acd9e88 | |||
|
6a2c1eb293
|
|||
| 7b4c31d262 | |||
|
|
5553414205 | ||
|
|
b138dfad33 | ||
| 701e6d4bb2 | |||
|
b44d1652b6
|
|||
|
|
990eaaa797 | ||
|
|
348ce95f83 | ||
|
|
3255bdf0a2 | ||
|
|
1058247b44 | ||
|
|
7414f82e28 | ||
|
|
8105bb709f | ||
|
ec628751af
|
|||
|
|
288023d03e | ||
|
|
7740dfca0e | ||
|
1e12ad8d4c
|
|||
|
|
c1d94d6771 | ||
| 7f691d3c31 | |||
|
|
a93bd3aeee | ||
|
|
39d353d073 | ||
|
|
b76e86686a | ||
|
|
b5f59d27c9 | ||
|
|
f0d3dec517 | ||
|
|
90c7c067b7 | ||
|
c8cfa954d5
|
|||
|
|
e533a396fb | ||
|
|
4b4cc04e87 | ||
|
e822a67b38
|
|||
|
c30c3400d4
|
|||
|
d539517525
|
|||
|
|
07eba09ec2 | ||
|
|
7f19647e4b | ||
| bf7d720126 | |||
|
|
6bc619055e | ||
|
|
452d1604bd | ||
|
|
680cb581c1 | ||
| 1d05f8910d | |||
|
|
bd09b30468 | ||
| 8d9933d035 | |||
|
|
cf5ba038d7 | ||
|
|
59ce740369 | ||
|
|
92feba5f08 | ||
|
|
a265b71d36 | ||
| 8d26c921a0 | |||
|
|
32d66cd19b | ||
|
|
735ba2fd0e | ||
|
|
b16b6ecf4d | ||
|
|
2875448c71 | ||
|
|
51b76385c0 | ||
|
|
b9f8dd6ea0 | ||
|
|
6623b04403 | ||
|
|
424d34a7ed | ||
|
|
2a032d24bc | ||
|
|
b8af2a4eb5 | ||
|
|
a55e02b36d | ||
|
|
18c933b4bf | ||
|
|
ea11a566af | ||
|
|
584e9c92d9 | ||
|
|
4a1641e39d | ||
|
|
26d18945b1 | ||
|
|
3382bd5e5b | ||
|
|
9f223f3964 | ||
|
|
2eaf7e7893 | ||
|
|
a26de27c47 | ||
|
|
21e62b7374 | ||
| 9e6b117327 | |||
|
|
3333d26557 | ||
|
|
6e81042989 | ||
|
|
470307aa3c | ||
|
|
089f00adb8 | ||
|
|
76fbfc2822 | ||
|
|
866bf996cf | ||
|
|
0104d8922c | ||
|
|
fbd7c4fe5f | ||
|
|
de5e61293b | ||
|
|
a3e402a3af | ||
|
|
056fa819cc | ||
|
|
3be1ee87c6 | ||
|
|
628d772766 | ||
|
|
acdeb01206 | ||
| ab402d4024 | |||
|
|
d2cdcc989b | ||
|
|
2620d0080c | ||
|
|
63a9f00552 | ||
|
|
87f9317805 | ||
|
|
a542168a0d | ||
|
|
86e1b55b02 | ||
|
|
1b3b40543b | ||
|
|
dd6ffe08d7 | ||
|
|
11254381a8 | ||
|
|
23642815f6 | ||
|
|
7e4f371841 | ||
|
|
9b8637ffc8 | ||
|
|
79613f9b1e | ||
|
|
fa54e93236 | ||
|
|
8fb82ae3d8 | ||
|
|
eab5003e61 | ||
|
|
da8c493c9f | ||
|
|
9795f14176 | ||
|
|
1937120ad7 | ||
|
|
1823575af4 | ||
|
|
7dc9f25b06 | ||
| 5502b48089 | |||
| f02b66fd54 | |||
| d2235f9bc9 | |||
|
|
5f5f9232c1 | ||
| c36fd84512 | |||
|
|
63b2f95cfa | ||
|
|
d193e1fd12 | ||
|
|
f0adf35db4 | ||
|
|
49a1beb225 | ||
|
|
f19b5d6ea6 | ||
|
|
730fadf63f | ||
|
|
9ae0d7e5cf | ||
| 1167519730 | |||
|
|
cf64565012 | ||
|
|
298f659f6e | ||
|
|
3539263437 | ||
|
|
6213d50670 | ||
|
|
ac941037ff | ||
|
|
733b3b0ed4 | ||
|
|
9168d72f38 | ||
|
|
75621cc816 | ||
|
|
3c816b2f11 | ||
|
|
85d6e05cd4 | ||
|
|
66127d025e | ||
|
|
58c675d1fa | ||
|
|
95b8406c7b | ||
|
|
3eddeb6aeb | ||
|
|
56912a7108 | ||
|
|
7ab299874d | ||
|
|
a4265e7fff | ||
|
|
db228ec8a3 | ||
|
|
61fc4f07ae | ||
|
|
b0a256f0d4 | ||
|
|
4ee9479a5f | ||
|
|
e7b3252534 | ||
|
|
36ab84423a | ||
|
|
370b582c9b | ||
|
|
9f15139d5f | ||
|
|
011939f5ee | ||
|
|
977c20f7c4 | ||
|
|
aa79b31aae | ||
|
|
970bb5e19d | ||
|
|
a706d0ebe5 | ||
|
|
b7abcf2989 | ||
|
|
8103cb3664 | ||
|
|
c3797918d2 | ||
|
|
60ddb9b596 | ||
|
|
a29f3fb407 | ||
|
|
c6162914ed | ||
|
|
02bd822ca0 | ||
|
|
ea6197626b | ||
|
|
468a736bfb | ||
|
|
f42df12a29 | ||
|
|
9b48e1851d | ||
|
|
c973224fa4 | ||
|
092cf1471b
|
|||
|
|
5cbe342d5b | ||
|
4f252480d3
|
|||
|
5cc439d846
|
|||
|
|
c6f5031dd8 | ||
|
|
eb6946343b | ||
|
|
e41a6b878c | ||
|
ee2671a5f3
|
|||
|
e05c72ad8c
|
|||
| 7658cdafbc | |||
|
ecf005fad0
|
|||
| de0542d2a8 | |||
|
|
bcb26507fe | ||
| c35db7f698 | |||
|
d2193328a7
|
|||
|
|
ed64428c80 | ||
|
|
e89156e55c | ||
|
4c9309ea9c
|
|||
| 1c00331bc2 | |||
| 427e32f406 | |||
|
|
b048fa5968 | ||
| d5a6ca7488 | |||
|
|
d15dea7aa0 | ||
|
|
ccb1c75f22 | ||
|
|
dffbed8e22 | ||
|
|
50ce010212 | ||
|
|
0e8cd32a6e | ||
|
|
ea191a8924 | ||
| 6abcedddda | |||
| debf309a9a | |||
|
|
4b1c925ab1 | ||
|
|
1c0990f610 | ||
|
|
89f2dc3b15 | ||
|
|
ffae58040d | ||
| 0cc1cb4cb8 | |||
|
|
dab762f05e |
24
.github/ISSUE_TEMPLATE.md
vendored
@@ -1,24 +0,0 @@
|
|||||||
# Instructions
|
|
||||||
1. Provide a short descriptive title for the issue. A good example is 'Results window appears off screen.', a non-optimal example is 'Problem with App'.
|
|
||||||
2. Please fill out either the 'Bug / Issue' or the 'Feature Request' section. Replace values in ` `.
|
|
||||||
3. Delete these instructions and the unused sections.
|
|
||||||
|
|
||||||
# Bug / issue Report
|
|
||||||
System Information:
|
|
||||||
- DupeGuru Version: `version`
|
|
||||||
- Operating System: `Windows/Linux/OSX` `distribution` `version`
|
|
||||||
|
|
||||||
If using the source distribution and building yourself also provide (otherwise remove):
|
|
||||||
- Python Version: `version ex. 3.6.6` `32/64bit`
|
|
||||||
- Complier: `gcc/llvm/msvc` `version`
|
|
||||||
|
|
||||||
## Description
|
|
||||||
`Provide a detailed description of the issue to help reproduce it. If it happens after a specific sequence of events provide them here.`
|
|
||||||
|
|
||||||
## Debug Log
|
|
||||||
```
|
|
||||||
If reporting an error provide the debug log and/or the error message information. If the debug log is short < 40 lines you can provide it here, otherwise attach the text file to this issue.
|
|
||||||
```
|
|
||||||
|
|
||||||
# Feature Requests
|
|
||||||
`Provide a detailed description of the feature.`
|
|
||||||
31
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Create a report to help us improve
|
||||||
|
title: ''
|
||||||
|
labels: bug
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
**To Reproduce**
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
1. Go to '...'
|
||||||
|
2. Click on '....'
|
||||||
|
3. Scroll down to '....'
|
||||||
|
4. See error
|
||||||
|
|
||||||
|
**Expected behavior**
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
**Screenshots**
|
||||||
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
|
**Desktop (please complete the following information):**
|
||||||
|
- OS: [e.g. Windows 10 / OSX 10.15 / Ubuntu 20.04 / Arch Linux]
|
||||||
|
- Version [e.g. 4.1.0]
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context about the problem here. You may include the debug log although it is normally best to attach it as a file.
|
||||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
name: Feature request
|
||||||
|
about: Suggest an idea for this project
|
||||||
|
title: ''
|
||||||
|
labels: feature
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Is your feature request related to a problem? Please describe.**
|
||||||
|
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
|
||||||
|
**Describe the solution you'd like**
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
|
**Describe alternatives you've considered**
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
|
**Additional context**
|
||||||
|
Add any other context or screenshots about the feature request here.
|
||||||
2
.gitignore
vendored
@@ -23,3 +23,5 @@ cocoa/autogen
|
|||||||
*.pyd
|
*.pyd
|
||||||
*.exe
|
*.exe
|
||||||
*.spec
|
*.spec
|
||||||
|
|
||||||
|
.vscode
|
||||||
|
|||||||
20
.travis.yml
@@ -12,18 +12,16 @@ matrix:
|
|||||||
dist: "xenial"
|
dist: "xenial"
|
||||||
python: "3.7"
|
python: "3.7"
|
||||||
- os: "linux"
|
- os: "linux"
|
||||||
dist: "xenial"
|
dist: "focal"
|
||||||
python: "3.8"
|
python: "3.8"
|
||||||
|
- os: "linux"
|
||||||
|
dist: "focal"
|
||||||
|
python: "3.9"
|
||||||
- os: "windows"
|
- os: "windows"
|
||||||
language: shell
|
language: shell
|
||||||
python: "3.7"
|
python: "3.8"
|
||||||
env: "PATH=/c/python37:/c/python37/Scripts:$PATH"
|
env: "PATH=/c/python38:/c/python38/Scripts:$PATH"
|
||||||
before_install:
|
before_install:
|
||||||
- choco install python --version=3.7.6
|
- choco install python --version=3.8.6
|
||||||
- choco install make
|
- cp /c/python38/python.exe /c/python38/python3.exe
|
||||||
- cp /c/python37/python.exe /c/python37/python3.exe
|
script: tox -e py38
|
||||||
before_script:
|
|
||||||
- pip3 install -r requirements-windows.txt
|
|
||||||
- python3 build.py
|
|
||||||
script:
|
|
||||||
- tox -e WINDOWS
|
|
||||||
|
|||||||
2
CREDITS
@@ -1,6 +1,8 @@
|
|||||||
To know who contributed to dupeGuru, you can look at the commit log, but not all contributions
|
To know who contributed to dupeGuru, you can look at the commit log, but not all contributions
|
||||||
result in a commit. This file lists contributors who don't necessarily appear in the commit log.
|
result in a commit. This file lists contributors who don't necessarily appear in the commit log.
|
||||||
|
|
||||||
|
* Jason Cho, Exchange icon
|
||||||
|
* schollidesign (https://findicons.com/pack/1035/human_o2), Zoom-in, Zoom-out, Zoom-best-fit, Zoom-original icons
|
||||||
* Jérôme Cantin, Main icon
|
* Jérôme Cantin, Main icon
|
||||||
* Gregor Tätzner, German localization
|
* Gregor Tätzner, German localization
|
||||||
* Frank Weber, German localization
|
* Frank Weber, German localization
|
||||||
|
|||||||
46
Makefile
@@ -1,7 +1,7 @@
|
|||||||
PYTHON ?= python3
|
PYTHON ?= python3
|
||||||
PYTHON_VERSION_MINOR := $(shell ${PYTHON} -c "import sys; print(sys.version_info.minor)")
|
PYTHON_VERSION_MINOR := $(shell ${PYTHON} -c "import sys; print(sys.version_info.minor)")
|
||||||
PYRCC5 ?= pyrcc5
|
PYRCC5 ?= pyrcc5
|
||||||
REQ_MINOR_VERSION = 4
|
REQ_MINOR_VERSION = 6
|
||||||
PREFIX ?= /usr/local
|
PREFIX ?= /usr/local
|
||||||
|
|
||||||
# Window compatability via Msys2
|
# Window compatability via Msys2
|
||||||
@@ -15,7 +15,7 @@ ifeq ($(shell ${PYTHON} -c "import platform; print(platform.system())"), Windows
|
|||||||
VENV_OPTIONS =
|
VENV_OPTIONS =
|
||||||
else
|
else
|
||||||
BIN = bin
|
BIN = bin
|
||||||
SO = cpython-3$(PYTHON_VERSION_MINOR)*.so
|
SO = *.so
|
||||||
VENV_OPTIONS = --system-site-packages
|
VENV_OPTIONS = --system-site-packages
|
||||||
endif
|
endif
|
||||||
|
|
||||||
@@ -43,16 +43,16 @@ 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:
|
||||||
$(VENV_PYTHON) run.py
|
$(VENV_PYTHON) run.py
|
||||||
|
|
||||||
pyc:
|
pyc: | env
|
||||||
${PYTHON} -m compileall ${packages}
|
${VENV_PYTHON} -m compileall ${packages}
|
||||||
|
|
||||||
reqs :
|
reqs:
|
||||||
ifneq ($(shell test $(PYTHON_VERSION_MINOR) -gt $(REQ_MINOR_VERSION); echo $$?),0)
|
ifneq ($(shell test $(PYTHON_VERSION_MINOR) -gt $(REQ_MINOR_VERSION); echo $$?),0)
|
||||||
$(error "Python 3.${REQ_MINOR_VERSION}+ required. Aborting.")
|
$(error "Python 3.${REQ_MINOR_VERSION}+ required. Aborting.")
|
||||||
endif
|
endif
|
||||||
@@ -63,7 +63,7 @@ endif
|
|||||||
@${PYTHON} -c 'import PyQt5' >/dev/null 2>&1 || \
|
@${PYTHON} -c 'import PyQt5' >/dev/null 2>&1 || \
|
||||||
{ echo "PyQt 5.4+ required. Install it and try again. Aborting"; exit 1; }
|
{ echo "PyQt 5.4+ required. Install it and try again. Aborting"; exit 1; }
|
||||||
|
|
||||||
env : | reqs
|
env: | reqs
|
||||||
ifndef NO_VENV
|
ifndef NO_VENV
|
||||||
@echo "Creating our virtualenv"
|
@echo "Creating our virtualenv"
|
||||||
${PYTHON} -m venv env
|
${PYTHON} -m venv env
|
||||||
@@ -73,40 +73,26 @@ ifndef NO_VENV
|
|||||||
${PYTHON} -m venv --upgrade ${VENV_OPTIONS} env
|
${PYTHON} -m venv --upgrade ${VENV_OPTIONS} env
|
||||||
endif
|
endif
|
||||||
|
|
||||||
build/help : | env
|
build/help: | env
|
||||||
$(VENV_PYTHON) build.py --doc
|
$(VENV_PYTHON) build.py --doc
|
||||||
|
|
||||||
qt/dg_rc.py : qt/dg.qrc
|
qt/dg_rc.py: qt/dg.qrc
|
||||||
$(PYRCC5) qt/dg.qrc > qt/dg_rc.py
|
$(PYRCC5) qt/dg.qrc > qt/dg_rc.py
|
||||||
|
|
||||||
i18n: $(mofiles)
|
i18n: $(mofiles)
|
||||||
|
|
||||||
%.mo : %.po
|
%.mo: %.po
|
||||||
msgfmt -o $@ $<
|
msgfmt -o $@ $<
|
||||||
|
|
||||||
core/pe/_block.$(SO) : core/pe/modules/block.c core/pe/modules/common.c
|
modules: | env
|
||||||
$(PYTHON) hscommon/build_ext.py $^ _block
|
$(VENV_PYTHON) build.py --modules
|
||||||
mv _block.$(SO) core/pe
|
|
||||||
|
|
||||||
core/pe/_cache.$(SO) : core/pe/modules/cache.c core/pe/modules/common.c
|
mergepot: | env
|
||||||
$(PYTHON) hscommon/build_ext.py $^ _cache
|
|
||||||
mv _cache.$(SO) core/pe
|
|
||||||
|
|
||||||
qt/pe/_block_qt.$(SO) : qt/pe/modules/block.c
|
|
||||||
$(PYTHON) hscommon/build_ext.py $^ _block_qt
|
|
||||||
mv _block_qt.$(SO) qt/pe
|
|
||||||
|
|
||||||
modules : core/pe/_block.$(SO) core/pe/_cache.$(SO) qt/pe/_block_qt.$(SO)
|
|
||||||
|
|
||||||
mergepot :
|
|
||||||
$(VENV_PYTHON) build.py --mergepot
|
$(VENV_PYTHON) build.py --mergepot
|
||||||
|
|
||||||
normpo :
|
normpo: | env
|
||||||
$(VENV_PYTHON) build.py --normpo
|
$(VENV_PYTHON) build.py --normpo
|
||||||
|
|
||||||
srcpkg :
|
|
||||||
./scripts/srcpkg.sh
|
|
||||||
|
|
||||||
install: all pyc
|
install: all pyc
|
||||||
mkdir -p ${DESTDIR}${PREFIX}/share/dupeguru
|
mkdir -p ${DESTDIR}${PREFIX}/share/dupeguru
|
||||||
cp -rf ${packages} locale ${DESTDIR}${PREFIX}/share/dupeguru
|
cp -rf ${packages} locale ${DESTDIR}${PREFIX}/share/dupeguru
|
||||||
@@ -123,7 +109,7 @@ installdocs: build/help
|
|||||||
mkdir -p ${DESTDIR}${PREFIX}/share/dupeguru
|
mkdir -p ${DESTDIR}${PREFIX}/share/dupeguru
|
||||||
cp -rf build/help ${DESTDIR}${PREFIX}/share/dupeguru
|
cp -rf build/help ${DESTDIR}${PREFIX}/share/dupeguru
|
||||||
|
|
||||||
uninstall :
|
uninstall:
|
||||||
rm -rf "${DESTDIR}${PREFIX}/share/dupeguru"
|
rm -rf "${DESTDIR}${PREFIX}/share/dupeguru"
|
||||||
rm -f "${DESTDIR}${PREFIX}/bin/dupeguru"
|
rm -f "${DESTDIR}${PREFIX}/bin/dupeguru"
|
||||||
rm -f "${DESTDIR}${PREFIX}/share/applications/dupeguru.desktop"
|
rm -f "${DESTDIR}${PREFIX}/share/applications/dupeguru.desktop"
|
||||||
@@ -134,4 +120,4 @@ clean:
|
|||||||
-rm locale/*/LC_MESSAGES/*.mo
|
-rm locale/*/LC_MESSAGES/*.mo
|
||||||
-rm core/pe/*.$(SO) qt/pe/*.$(SO)
|
-rm core/pe/*.$(SO) qt/pe/*.$(SO)
|
||||||
|
|
||||||
.PHONY : clean srcpkg normpo mergepot modules i18n reqs run pyc install uninstall all
|
.PHONY: clean normpo mergepot modules i18n reqs run pyc install uninstall all
|
||||||
|
|||||||
65
README.md
@@ -1,19 +1,21 @@
|
|||||||
# dupeGuru
|
# dupeGuru
|
||||||
|
|
||||||
[dupeGuru][dupeguru] is a cross-platform (Linux, OS X, Windows) GUI tool to find duplicate files in
|
[dupeGuru][dupeguru] is a cross-platform (Linux, OS X, Windows) GUI tool to find duplicate files in
|
||||||
a system. It's written mostly in Python 3 and has the peculiarity of using
|
a system. It is written mostly in Python 3 and has the peculiarity of using
|
||||||
[multiple GUI toolkits][cross-toolkit], all using the same core Python code. On OS X, the UI layer
|
[multiple GUI toolkits][cross-toolkit], all using the same core Python code. On OS X, the UI layer
|
||||||
is written in Objective-C and uses Cocoa. On Linux, it's written in Python and uses Qt5.
|
is written in Objective-C and uses Cocoa. On Linux, it is written in Python and uses Qt5.
|
||||||
|
|
||||||
The Cocoa UI of dupeGuru is hosted in a separate repo: https://github.com/hsoft/dupeguru-cocoa
|
The Cocoa UI of dupeGuru is hosted in a separate repo: https://github.com/arsenetar/dupeguru-cocoa
|
||||||
|
|
||||||
## Current status
|
## Current status
|
||||||
|
|
||||||
Development has been slow this past year, however very close to getting all the different 4.0.4 releases posted. Most of the work this past year (2019) has been towards packaging the application and issues related to that.
|
2020: various bug fixes and small UI improvements have been added. Packaging for MacOS is still a problem.
|
||||||
|
|
||||||
Still looking for additional help especially with regards to:
|
Still looking for additional help especially with regards to:
|
||||||
- OSX maintenance (reproducing bugs & cocoa version)
|
* OSX maintenance: reproducing bugs & cocoa version, building package with Cocoa UI.
|
||||||
- Linux maintenance (reproducing bugs)
|
* Linux maintenance: reproducing bugs, maintaining PPA repository, Debian package.
|
||||||
|
* Translations: updating missing strings.
|
||||||
|
* Documentation: keeping it up-to-date.
|
||||||
|
|
||||||
## Contents of this folder
|
## Contents of this folder
|
||||||
|
|
||||||
@@ -31,26 +33,57 @@ This folder contains the source for dupeGuru. Its documentation is in `help`, bu
|
|||||||
|
|
||||||
## How to build dupeGuru from source
|
## How to build dupeGuru from source
|
||||||
|
|
||||||
### Windows
|
### Windows & macOS specific additional instructions
|
||||||
For windows instructions see the [Windows Instructions](Windows.md).
|
For windows instructions see the [Windows Instructions](Windows.md).
|
||||||
|
|
||||||
### Prerequisites
|
For macos instructions (qt version) see the [macOS Instructions](macos.md).
|
||||||
|
|
||||||
* [Python 3.5+][python]
|
### Prerequisites
|
||||||
|
* [Python 3.6+][python]
|
||||||
* PyQt5
|
* PyQt5
|
||||||
|
|
||||||
### make
|
### System Setup
|
||||||
|
When running in a linux based environment the following system packages or equivalents are needed to build:
|
||||||
|
* python3-pyqt5
|
||||||
|
* python3-wheel (for hsaudiotag3k)
|
||||||
|
* python3-venv (only if using a virtual environment)
|
||||||
|
* python3-dev
|
||||||
|
* build-essential
|
||||||
|
|
||||||
dupeGuru is built with "make":
|
To create packages the following are also needed:
|
||||||
|
* python3-setuptools
|
||||||
|
* debhelper
|
||||||
|
|
||||||
$ make
|
### Building with Make
|
||||||
$ make run
|
dupeGuru comes with a makefile that can be used to build and run:
|
||||||
|
|
||||||
### Generate Debian/Ubuntu package
|
$ make && make run
|
||||||
|
|
||||||
$ bash -c "python3 -m venv --system-site-packages env && source env/bin/activate && pip install -r requirements.txt && python3 build.py --clean && python3 package.py"
|
### Building without Make
|
||||||
|
|
||||||
### Running tests
|
$ cd <dupeGuru directory>
|
||||||
|
$ python3 -m venv --system-site-packages ./env
|
||||||
|
$ source ./env/bin/activate
|
||||||
|
$ pip install -r requirements.txt
|
||||||
|
$ python build.py
|
||||||
|
$ python run.py
|
||||||
|
|
||||||
|
### Generating Debian/Ubuntu package
|
||||||
|
To generate packages the extra requirements in requirements-extra.txt must be installed, the
|
||||||
|
steps are as follows:
|
||||||
|
|
||||||
|
$ cd <dupeGuru directory>
|
||||||
|
$ python3 -m venv --system-site-packages ./env
|
||||||
|
$ source ./env/bin/activate
|
||||||
|
$ pip install -r requirements.txt -r requirements-extra.txt
|
||||||
|
$ python build.py --clean
|
||||||
|
$ python package.py
|
||||||
|
|
||||||
|
This can be made a one-liner (once in the directory) as:
|
||||||
|
|
||||||
|
$ bash -c "python3 -m venv --system-site-packages env && source env/bin/activate && pip install -r requirements.txt -r requirements-extra.txt && python build.py --clean && python package.py"
|
||||||
|
|
||||||
|
## Running tests
|
||||||
|
|
||||||
The complete test suite is run with [Tox 1.7+][tox]. If you have it installed system-wide, you
|
The complete test suite is run with [Tox 1.7+][tox]. If you have it installed system-wide, you
|
||||||
don't even need to set up a virtualenv. Just `cd` into the root project folder and run `tox`.
|
don't even need to set up a virtualenv. Just `cd` into the root project folder and run `tox`.
|
||||||
|
|||||||
30
Windows.md
@@ -2,26 +2,26 @@
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- [Python 3.5+][python]
|
- [Python 3.6+][python]
|
||||||
- [Visual Studio 2017][vs] or [Visual Studio Build Tools 2017][vsBuildTools] with the Windows 10 SDK
|
- [Visual Studio 2019][vs] or [Visual Studio Build Tools 2019][vsBuildTools] with the Windows 10 SDK
|
||||||
- [nsis][nsis] (for installer creation)
|
- [nsis][nsis] (for installer creation)
|
||||||
- [msys2][msys2] (for using makefile method)
|
- [msys2][msys2] (for using makefile method)
|
||||||
|
|
||||||
When installing Visual Studio or the Visual Studio Build Tools with the Windows 10 SDK on versions of Windows below 10 be sure to make sure that the Universal CRT is installed before installing Visual studio as noted in the [Windows 10 SDK Notes][win10sdk] and found at [KB2999226][KB2999226].
|
NOTE: When installing Visual Studio or the Visual Studio Build Tools with the Windows 10 SDK on versions of Windows below 10 be sure to make sure that the Universal CRT is installed before installing Visual studio as noted in the [Windows 10 SDK Notes][win10sdk] and found at [KB2999226][KB2999226].
|
||||||
|
|
||||||
After installing python it is recommended to update setuptools before compiling packages. To update run (example is for python launcher and 3.7):
|
After installing python it is recommended to update setuptools before compiling packages. To update run (example is for python launcher and 3.8):
|
||||||
|
|
||||||
$ py -3.7 -m pip install --upgrade setuptools
|
$ py -3.8 -m pip install --upgrade setuptools
|
||||||
|
|
||||||
More details on setting up python for compiling packages on windows can be found on the [python wiki][pythonWindowsCompilers]
|
More details on setting up python for compiling packages on windows can be found on the [python wiki][pythonWindowsCompilers] Take note of the required vc++ versions.
|
||||||
|
|
||||||
### With build.py (preferred)
|
### With build.py (preferred)
|
||||||
To build with a different python version 3.5 vs 3.7 or 32 bit vs 64 bit specify that version instead of -3.7 to the `py` command below. If you want to build additional versions while keeping all virtual environments setup use a different location for each vritual environment.
|
To build with a different python version 3.6 vs 3.8 or 32 bit vs 64 bit specify that version instead of -3.8 to the `py` command below. If you want to build additional versions while keeping all virtual environments setup use a different location for each virtual environment.
|
||||||
|
|
||||||
$ cd <dupeGuru directory>
|
$ cd <dupeGuru directory>
|
||||||
$ py -3.7 -m venv .\env
|
$ py -3.8 -m venv .\env
|
||||||
$ .\env\Scripts\activate
|
$ .\env\Scripts\activate
|
||||||
$ pip install -r requirements.txt -r requirements-windows.txt
|
$ pip install -r requirements.txt
|
||||||
$ python build.py
|
$ python build.py
|
||||||
$ python run.py
|
$ python run.py
|
||||||
|
|
||||||
@@ -34,23 +34,21 @@ It is possible to build dupeGuru with the makefile on windows using a compatable
|
|||||||
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.
|
||||||
|
|
||||||
$ cd <dupeGuru directory>
|
$ cd <dupeGuru directory>
|
||||||
$ make PYTHON='py -3.7'
|
$ make PYTHON='py -3.8'
|
||||||
$ make run
|
$ make run
|
||||||
|
|
||||||
NOTE: Install PyQt5 & cx-Freeze with requirements-windows.txt into the venv before running the packaging scripts in the section below.
|
|
||||||
|
|
||||||
### Generate Windows Installer Packages
|
### Generate Windows Installer Packages
|
||||||
You need to use the respective x86 or x64 version of python to build the 32 bit and 64 bit versions. The build scripts will automatically detect the python architecture for you. When using build.py make sure the resulting python works before continuing to package.py. NOTE: package.py looks for the 'makensis' executable in the default location for a 64 bit windows system. Run the following in the respective virtual environment.
|
You need to use the respective x86 or x64 version of python to build the 32 bit and 64 bit versions. The build scripts will automatically detect the python architecture for you. When using build.py make sure the resulting python works before continuing to package.py. NOTE: package.py looks for the 'makensis' executable in the default location for a 64 bit windows system. The extra requirements need to be installed to run packaging: `pip install -r requirements-extra.txt`. Run the following in the respective virtual environment.
|
||||||
|
|
||||||
$ python package.py
|
$ python package.py
|
||||||
|
|
||||||
### Running tests
|
### Running tests
|
||||||
The complete test suite can be run with tox just like on linux.
|
The complete test suite can be run with tox just like on linux. NOTE: The extra requirements need to be installed to run unit tests: `pip install -r requirements-extra.txt`.
|
||||||
|
|
||||||
[python]: http://www.python.org/
|
[python]: http://www.python.org/
|
||||||
[nsis]: http://nsis.sourceforge.net/Main_Page
|
[nsis]: http://nsis.sourceforge.net/Main_Page
|
||||||
[vs]: https://www.visualstudio.com/downloads/#visual-studio-community-2017
|
[vs]: https://www.visualstudio.com/downloads/#visual-studio-community-2019
|
||||||
[vsBuildTools]: https://www.visualstudio.com/downloads/#build-tools-for-visual-studio-2017
|
[vsBuildTools]: https://www.visualstudio.com/downloads/#build-tools-for-visual-studio-2019
|
||||||
[win10sdk]: https://developer.microsoft.com/en-us/windows/downloads/windows-10-sdk
|
[win10sdk]: https://developer.microsoft.com/en-us/windows/downloads/windows-10-sdk
|
||||||
[KB2999226]: https://support.microsoft.com/en-us/help/2999226/update-for-universal-c-runtime-in-windows
|
[KB2999226]: https://support.microsoft.com/en-us/help/2999226/update-for-universal-c-runtime-in-windows
|
||||||
[pythonWindowsCompilers]: https://wiki.python.org/moin/WindowsCompilers
|
[pythonWindowsCompilers]: https://wiki.python.org/moin/WindowsCompilers
|
||||||
|
|||||||
8
build.py
@@ -54,6 +54,12 @@ def parse_args():
|
|||||||
dest="normpo",
|
dest="normpo",
|
||||||
help="Normalize all PO files (do this before commit).",
|
help="Normalize all PO files (do this before commit).",
|
||||||
)
|
)
|
||||||
|
parser.add_option(
|
||||||
|
"--modules",
|
||||||
|
action="store_true",
|
||||||
|
dest="modules",
|
||||||
|
help="Build the python modules.",
|
||||||
|
)
|
||||||
(options, args) = parser.parse_args()
|
(options, args) = parser.parse_args()
|
||||||
return options
|
return options
|
||||||
|
|
||||||
@@ -182,6 +188,8 @@ def main():
|
|||||||
build_mergepot()
|
build_mergepot()
|
||||||
elif options.normpo:
|
elif options.normpo:
|
||||||
build_normpo()
|
build_normpo()
|
||||||
|
elif options.modules:
|
||||||
|
build_pe_modules()
|
||||||
else:
|
else:
|
||||||
build_normal()
|
build_normal()
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
__version__ = "4.0.4"
|
__version__ = "4.1.0"
|
||||||
__appname__ = "dupeGuru"
|
__appname__ = "dupeGuru"
|
||||||
|
|||||||
33
core/app.py
@@ -26,11 +26,13 @@ from .pe.photo import get_delta_dimensions
|
|||||||
from .util import cmp_value, fix_surrogate_encoding
|
from .util import cmp_value, fix_surrogate_encoding
|
||||||
from . import directories, results, export, fs, prioritize
|
from . import directories, results, export, fs, prioritize
|
||||||
from .ignore import IgnoreList
|
from .ignore import IgnoreList
|
||||||
|
from .exclude import ExcludeDict as ExcludeList
|
||||||
from .scanner import ScanType
|
from .scanner import ScanType
|
||||||
from .gui.deletion_options import DeletionOptions
|
from .gui.deletion_options import DeletionOptions
|
||||||
from .gui.details_panel import DetailsPanel
|
from .gui.details_panel import DetailsPanel
|
||||||
from .gui.directory_tree import DirectoryTree
|
from .gui.directory_tree import DirectoryTree
|
||||||
from .gui.ignore_list_dialog import IgnoreListDialog
|
from .gui.ignore_list_dialog import IgnoreListDialog
|
||||||
|
from .gui.exclude_list_dialog import ExcludeListDialogCore
|
||||||
from .gui.problem_dialog import ProblemDialog
|
from .gui.problem_dialog import ProblemDialog
|
||||||
from .gui.stats_label import StatsLabel
|
from .gui.stats_label import StatsLabel
|
||||||
|
|
||||||
@@ -137,7 +139,8 @@ class DupeGuru(Broadcaster):
|
|||||||
os.makedirs(self.appdata)
|
os.makedirs(self.appdata)
|
||||||
self.app_mode = AppMode.Standard
|
self.app_mode = AppMode.Standard
|
||||||
self.discarded_file_count = 0
|
self.discarded_file_count = 0
|
||||||
self.directories = directories.Directories()
|
self.exclude_list = ExcludeList()
|
||||||
|
self.directories = directories.Directories(self.exclude_list)
|
||||||
self.results = results.Results(self)
|
self.results = results.Results(self)
|
||||||
self.ignore_list = IgnoreList()
|
self.ignore_list = IgnoreList()
|
||||||
# In addition to "app-level" options, this dictionary also holds options that will be
|
# In addition to "app-level" options, this dictionary also holds options that will be
|
||||||
@@ -155,6 +158,7 @@ class DupeGuru(Broadcaster):
|
|||||||
self.directory_tree = DirectoryTree(self)
|
self.directory_tree = DirectoryTree(self)
|
||||||
self.problem_dialog = ProblemDialog(self)
|
self.problem_dialog = ProblemDialog(self)
|
||||||
self.ignore_list_dialog = IgnoreListDialog(self)
|
self.ignore_list_dialog = IgnoreListDialog(self)
|
||||||
|
self.exclude_list_dialog = ExcludeListDialogCore(self)
|
||||||
self.stats_label = StatsLabel(self)
|
self.stats_label = StatsLabel(self)
|
||||||
self.result_table = None
|
self.result_table = None
|
||||||
self.deletion_options = DeletionOptions()
|
self.deletion_options = DeletionOptions()
|
||||||
@@ -259,7 +263,7 @@ class DupeGuru(Broadcaster):
|
|||||||
|
|
||||||
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.
|
||||||
return fs.get_file(path, self.fileclasses + [fs.Folder])
|
return fs.get_file(path, self.fileclasses + [se.fs.Folder])
|
||||||
|
|
||||||
def _get_file(self, str_path):
|
def _get_file(self, str_path):
|
||||||
path = Path(str_path)
|
path = Path(str_path)
|
||||||
@@ -539,8 +543,8 @@ class DupeGuru(Broadcaster):
|
|||||||
return dupe.get_display_info(group, delta)
|
return dupe.get_display_info(group, delta)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(
|
logging.warning(
|
||||||
"Exception on GetDisplayInfo for %s: %s", str(dupe.path), str(e)
|
"Exception (type: %s) on GetDisplayInfo for %s: %s",
|
||||||
)
|
type(e), str(dupe.path), str(e))
|
||||||
return empty_data()
|
return empty_data()
|
||||||
|
|
||||||
def invoke_custom_command(self):
|
def invoke_custom_command(self):
|
||||||
@@ -587,6 +591,15 @@ class DupeGuru(Broadcaster):
|
|||||||
p = op.join(self.appdata, "ignore_list.xml")
|
p = op.join(self.appdata, "ignore_list.xml")
|
||||||
self.ignore_list.load_from_xml(p)
|
self.ignore_list.load_from_xml(p)
|
||||||
self.ignore_list_dialog.refresh()
|
self.ignore_list_dialog.refresh()
|
||||||
|
p = op.join(self.appdata, "exclude_list.xml")
|
||||||
|
self.exclude_list.load_from_xml(p)
|
||||||
|
self.exclude_list_dialog.refresh()
|
||||||
|
|
||||||
|
def load_directories(self, filepath):
|
||||||
|
# Clear out previous entries
|
||||||
|
self.directories.__init__()
|
||||||
|
self.directories.load_from_file(filepath)
|
||||||
|
self.notify("directories_changed")
|
||||||
|
|
||||||
def load_from(self, filename):
|
def load_from(self, filename):
|
||||||
"""Start an async job to load results from ``filename``.
|
"""Start an async job to load results from ``filename``.
|
||||||
@@ -773,6 +786,8 @@ class DupeGuru(Broadcaster):
|
|||||||
self.directories.save_to_file(op.join(self.appdata, "last_directories.xml"))
|
self.directories.save_to_file(op.join(self.appdata, "last_directories.xml"))
|
||||||
p = op.join(self.appdata, "ignore_list.xml")
|
p = op.join(self.appdata, "ignore_list.xml")
|
||||||
self.ignore_list.save_to_xml(p)
|
self.ignore_list.save_to_xml(p)
|
||||||
|
p = op.join(self.appdata, "exclude_list.xml")
|
||||||
|
self.exclude_list.save_to_xml(p)
|
||||||
self.notify("save_session")
|
self.notify("save_session")
|
||||||
|
|
||||||
def save_as(self, filename):
|
def save_as(self, filename):
|
||||||
@@ -785,6 +800,16 @@ 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 save_directories_as(self, filename):
|
||||||
|
"""Save directories in ``filename``.
|
||||||
|
|
||||||
|
:param str filename: path of the file to save directories (as XML) to.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
self.directories.save_to_file(filename)
|
||||||
|
except OSError as e:
|
||||||
|
self.view.show_message(tr("Couldn't write to file: {}").format(str(e)))
|
||||||
|
|
||||||
def start_scanning(self):
|
def start_scanning(self):
|
||||||
"""Starts an async job to scan for duplicates.
|
"""Starts an async job to scan for duplicates.
|
||||||
|
|
||||||
|
|||||||
@@ -54,10 +54,11 @@ class Directories:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# ---Override
|
# ---Override
|
||||||
def __init__(self):
|
def __init__(self, exclude_list=None):
|
||||||
self._dirs = []
|
self._dirs = []
|
||||||
# {path: state}
|
# {path: state}
|
||||||
self.states = {}
|
self.states = {}
|
||||||
|
self._exclude_list = exclude_list
|
||||||
|
|
||||||
def __contains__(self, path):
|
def __contains__(self, path):
|
||||||
for p in self._dirs:
|
for p in self._dirs:
|
||||||
@@ -76,39 +77,62 @@ class Directories:
|
|||||||
|
|
||||||
# ---Private
|
# ---Private
|
||||||
def _default_state_for_path(self, path):
|
def _default_state_for_path(self, path):
|
||||||
|
# New logic with regex filters
|
||||||
|
if self._exclude_list is not None and self._exclude_list.mark_count > 0:
|
||||||
|
# We iterate even if we only have one item here
|
||||||
|
for denied_path_re in self._exclude_list.compiled:
|
||||||
|
if denied_path_re.match(str(path.name)):
|
||||||
|
return DirectoryState.Excluded
|
||||||
|
# return # We still use the old logic to force state on hidden dirs
|
||||||
# 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("."): # hidden
|
if path.name.startswith("."):
|
||||||
return DirectoryState.Excluded
|
return DirectoryState.Excluded
|
||||||
|
|
||||||
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)):
|
for root, dirs, files in os.walk(str(from_path)):
|
||||||
j.check_if_cancelled()
|
j.check_if_cancelled()
|
||||||
root = Path(root)
|
rootPath = Path(root)
|
||||||
state = self.get_state(root)
|
state = self.get_state(rootPath)
|
||||||
if state == DirectoryState.Excluded:
|
if state == DirectoryState.Excluded:
|
||||||
# Recursively get files from folders with lots of subfolder is expensive. However, there
|
# 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
|
# might be a subfolder in this path that is not excluded. What we want to do is to skim
|
||||||
# through self.states and see if we must continue, or we can stop right here to save time
|
# through self.states and see if we must continue, or we can stop right here to save time
|
||||||
if not any(p[: len(root)] == root for p in self.states):
|
if not any(p[: len(rootPath)] == rootPath for p in self.states):
|
||||||
del dirs[:]
|
del dirs[:]
|
||||||
try:
|
try:
|
||||||
if state != DirectoryState.Excluded:
|
if state != DirectoryState.Excluded:
|
||||||
found_files = [
|
# Old logic
|
||||||
fs.get_file(root + f, fileclasses=fileclasses) for f in files
|
if self._exclude_list is None or not self._exclude_list.mark_count:
|
||||||
]
|
found_files = [fs.get_file(rootPath + f, fileclasses=fileclasses) for f in files]
|
||||||
|
else:
|
||||||
|
found_files = []
|
||||||
|
# print(f"len of files: {len(files)} {files}")
|
||||||
|
for f in files:
|
||||||
|
found = False
|
||||||
|
for expr in self._exclude_list.compiled_files:
|
||||||
|
if expr.match(f):
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
if not found:
|
||||||
|
for expr in self._exclude_list.compiled_paths:
|
||||||
|
if expr.match(root + os.sep + f):
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
if not found:
|
||||||
|
found_files.append(fs.get_file(rootPath + f, fileclasses=fileclasses))
|
||||||
found_files = [f for f in found_files if f is not None]
|
found_files = [f for f in found_files if f is not None]
|
||||||
# In some cases, directories can be considered as files by dupeGuru, which is
|
# In some cases, directories can be considered as files by dupeGuru, which is
|
||||||
# why we have this line below. In fact, there only one case: Bundle files under
|
# 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.
|
# OS X... In other situations, this forloop will do nothing.
|
||||||
for d in dirs[:]:
|
for d in dirs[:]:
|
||||||
f = fs.get_file(root + d, fileclasses=fileclasses)
|
f = fs.get_file(rootPath + d, fileclasses=fileclasses)
|
||||||
if f is not None:
|
if f is not None:
|
||||||
found_files.append(f)
|
found_files.append(f)
|
||||||
dirs.remove(d)
|
dirs.remove(d)
|
||||||
logging.debug(
|
logging.debug(
|
||||||
"Collected %d files in folder %s",
|
"Collected %d files in folder %s",
|
||||||
len(found_files),
|
len(found_files),
|
||||||
str(from_path),
|
str(rootPath),
|
||||||
)
|
)
|
||||||
for file in found_files:
|
for file in found_files:
|
||||||
file.is_ref = state == DirectoryState.Reference
|
file.is_ref = state == DirectoryState.Reference
|
||||||
@@ -194,8 +218,14 @@ class Directories:
|
|||||||
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) or DirectoryState.Normal
|
||||||
|
# Save non-default states in cache, necessary for _get_files()
|
||||||
|
if state != DirectoryState.Normal:
|
||||||
|
self.states[path] = state
|
||||||
|
return state
|
||||||
|
|
||||||
prevlen = 0
|
prevlen = 0
|
||||||
# we loop through the states to find the longest matching prefix
|
# we loop through the states to find the longest matching prefix
|
||||||
|
# if the parent has a state in cache, return that state
|
||||||
for p, s in self.states.items():
|
for p, s in self.states.items():
|
||||||
if p.is_parent_of(path) and len(p) > prevlen:
|
if p.is_parent_of(path) and len(p) > prevlen:
|
||||||
prevlen = len(p)
|
prevlen = len(p)
|
||||||
@@ -224,7 +254,7 @@ class Directories:
|
|||||||
root = ET.parse(infile).getroot()
|
root = ET.parse(infile).getroot()
|
||||||
except Exception:
|
except Exception:
|
||||||
return
|
return
|
||||||
for rdn in root.getiterator("root_directory"):
|
for rdn in root.iter("root_directory"):
|
||||||
attrib = rdn.attrib
|
attrib = rdn.attrib
|
||||||
if "path" not in attrib:
|
if "path" not in attrib:
|
||||||
continue
|
continue
|
||||||
@@ -233,7 +263,7 @@ class Directories:
|
|||||||
self.add_path(Path(path))
|
self.add_path(Path(path))
|
||||||
except (AlreadyThereError, InvalidPathError):
|
except (AlreadyThereError, InvalidPathError):
|
||||||
pass
|
pass
|
||||||
for sn in root.getiterator("state"):
|
for sn in root.iter("state"):
|
||||||
attrib = sn.attrib
|
attrib = sn.attrib
|
||||||
if not ("path" in attrib and "value" in attrib):
|
if not ("path" in attrib and "value" in attrib):
|
||||||
continue
|
continue
|
||||||
|
|||||||
499
core/exclude.py
Normal file
@@ -0,0 +1,499 @@
|
|||||||
|
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||||
|
# which should be included with this package. The terms are also available at
|
||||||
|
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||||
|
|
||||||
|
from .markable import Markable
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
# TODO: perhaps use regex module for better Unicode support? https://pypi.org/project/regex/
|
||||||
|
# also https://pypi.org/project/re2/
|
||||||
|
# TODO update the Result list with newly added regexes if possible
|
||||||
|
import re
|
||||||
|
from os import sep
|
||||||
|
import logging
|
||||||
|
import functools
|
||||||
|
from hscommon.util import FileOrPath
|
||||||
|
from hscommon.plat import ISWINDOWS
|
||||||
|
import time
|
||||||
|
|
||||||
|
default_regexes = [r"^thumbs\.db$", # Obsolete after WindowsXP
|
||||||
|
r"^desktop\.ini$", # Windows metadata
|
||||||
|
r"^\.DS_Store$", # MacOS metadata
|
||||||
|
r"^\.Trash\-.*", # Linux trash directories
|
||||||
|
r"^\$Recycle\.Bin$", # Windows
|
||||||
|
r"^\..*", # Hidden files on Unix-like
|
||||||
|
]
|
||||||
|
# These are too broad
|
||||||
|
forbidden_regexes = [r".*", r"\/.*", r".*\/.*", r".*\\\\.*", r".*\..*"]
|
||||||
|
|
||||||
|
|
||||||
|
def timer(func):
|
||||||
|
@functools.wraps(func)
|
||||||
|
def wrapper_timer(*args):
|
||||||
|
start = time.perf_counter_ns()
|
||||||
|
value = func(*args)
|
||||||
|
end = time.perf_counter_ns()
|
||||||
|
print(f"DEBUG: func {func.__name__!r} took {end - start} ns.")
|
||||||
|
return value
|
||||||
|
return wrapper_timer
|
||||||
|
|
||||||
|
|
||||||
|
def memoize(func):
|
||||||
|
func.cache = dict()
|
||||||
|
|
||||||
|
@functools.wraps(func)
|
||||||
|
def _memoize(*args):
|
||||||
|
if args not in func.cache:
|
||||||
|
func.cache[args] = func(*args)
|
||||||
|
return func.cache[args]
|
||||||
|
return _memoize
|
||||||
|
|
||||||
|
|
||||||
|
class AlreadyThereException(Exception):
|
||||||
|
"""Expression already in the list"""
|
||||||
|
def __init__(self, arg="Expression is already in excluded list."):
|
||||||
|
super().__init__(arg)
|
||||||
|
|
||||||
|
|
||||||
|
class ExcludeList(Markable):
|
||||||
|
"""A list of lists holding regular expression strings and the compiled re.Pattern"""
|
||||||
|
|
||||||
|
# Used to filter out directories and files that we would rather avoid scanning.
|
||||||
|
# The list() class allows us to preserve item order without too much hassle.
|
||||||
|
# The downside is we have to compare strings every time we look for an item in the list
|
||||||
|
# since we use regex strings as keys.
|
||||||
|
# If _use_union is True, the compiled regexes will be combined into one single
|
||||||
|
# Pattern instead of separate Patterns which may or may not give better
|
||||||
|
# performance compared to looping through each Pattern individually.
|
||||||
|
|
||||||
|
# ---Override
|
||||||
|
def __init__(self, union_regex=True):
|
||||||
|
Markable.__init__(self)
|
||||||
|
self._use_union = union_regex
|
||||||
|
# list([str regex, bool iscompilable, re.error exception, Pattern compiled], ...)
|
||||||
|
self._excluded = []
|
||||||
|
self._excluded_compiled = set()
|
||||||
|
self._dirty = True
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
"""Iterate in order."""
|
||||||
|
for item in self._excluded:
|
||||||
|
regex = item[0]
|
||||||
|
yield self.is_marked(regex), regex
|
||||||
|
|
||||||
|
def __contains__(self, item):
|
||||||
|
return self.isExcluded(item)
|
||||||
|
|
||||||
|
def __len__(self):
|
||||||
|
"""Returns the total number of regexes regardless of mark status."""
|
||||||
|
return len(self._excluded)
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
"""Returns the list item corresponding to key."""
|
||||||
|
for item in self._excluded:
|
||||||
|
if item[0] == key:
|
||||||
|
return item
|
||||||
|
raise KeyError(f"Key {key} is not in exclusion list.")
|
||||||
|
|
||||||
|
def __setitem__(self, key, value):
|
||||||
|
# TODO if necessary
|
||||||
|
pass
|
||||||
|
|
||||||
|
def __delitem__(self, key):
|
||||||
|
# TODO if necessary
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_compiled(self, key):
|
||||||
|
"""Returns the (precompiled) Pattern for key"""
|
||||||
|
return self.__getitem__(key)[3]
|
||||||
|
|
||||||
|
def is_markable(self, regex):
|
||||||
|
return self._is_markable(regex)
|
||||||
|
|
||||||
|
def _is_markable(self, regex):
|
||||||
|
"""Return the cached result of "compilable" property"""
|
||||||
|
for item in self._excluded:
|
||||||
|
if item[0] == regex:
|
||||||
|
return item[1]
|
||||||
|
return False # should not be necessary, the regex SHOULD be in there
|
||||||
|
|
||||||
|
def _did_mark(self, regex):
|
||||||
|
self._add_compiled(regex)
|
||||||
|
|
||||||
|
def _did_unmark(self, regex):
|
||||||
|
self._remove_compiled(regex)
|
||||||
|
|
||||||
|
def _add_compiled(self, regex):
|
||||||
|
self._dirty = True
|
||||||
|
if self._use_union:
|
||||||
|
return
|
||||||
|
for item in self._excluded:
|
||||||
|
# FIXME probably faster to just rebuild the set from the compiled instead of comparing strings
|
||||||
|
if item[0] == regex:
|
||||||
|
# no need to test if already present since it's a set()
|
||||||
|
self._excluded_compiled.add(item[3])
|
||||||
|
break
|
||||||
|
|
||||||
|
def _remove_compiled(self, regex):
|
||||||
|
self._dirty = True
|
||||||
|
if self._use_union:
|
||||||
|
return
|
||||||
|
for item in self._excluded_compiled:
|
||||||
|
if regex in item.pattern:
|
||||||
|
self._excluded_compiled.remove(item)
|
||||||
|
break
|
||||||
|
|
||||||
|
# @timer
|
||||||
|
@memoize
|
||||||
|
def _do_compile(self, expr):
|
||||||
|
try:
|
||||||
|
return re.compile(expr)
|
||||||
|
except Exception as e:
|
||||||
|
raise(e)
|
||||||
|
|
||||||
|
# @timer
|
||||||
|
# @memoize # probably not worth memoizing this one if we memoize the above
|
||||||
|
def compile_re(self, regex):
|
||||||
|
compiled = None
|
||||||
|
try:
|
||||||
|
compiled = self._do_compile(regex)
|
||||||
|
except Exception as e:
|
||||||
|
return False, e, compiled
|
||||||
|
return True, None, compiled
|
||||||
|
|
||||||
|
def error(self, regex):
|
||||||
|
"""Return the compilation error Exception for regex.
|
||||||
|
It should have a "msg" attr."""
|
||||||
|
for item in self._excluded:
|
||||||
|
if item[0] == regex:
|
||||||
|
return item[2]
|
||||||
|
|
||||||
|
def build_compiled_caches(self, union=False):
|
||||||
|
if not union:
|
||||||
|
self._cached_compiled_files =\
|
||||||
|
[x for x in self._excluded_compiled if not has_sep(x.pattern)]
|
||||||
|
self._cached_compiled_paths =\
|
||||||
|
[x for x in self._excluded_compiled if has_sep(x.pattern)]
|
||||||
|
return
|
||||||
|
marked_count = [x for marked, x in self if marked]
|
||||||
|
# If there is no item, the compiled Pattern will be '' and match everything!
|
||||||
|
if not marked_count:
|
||||||
|
self._cached_compiled_union_all = []
|
||||||
|
self._cached_compiled_union_files = []
|
||||||
|
self._cached_compiled_union_paths = []
|
||||||
|
else:
|
||||||
|
# HACK returned as a tuple to get a free iterator and keep interface
|
||||||
|
# the same regardless of whether the client asked for union or not
|
||||||
|
self._cached_compiled_union_all =\
|
||||||
|
(re.compile('|'.join(marked_count)),)
|
||||||
|
files_marked = [x for x in marked_count if not has_sep(x)]
|
||||||
|
if not files_marked:
|
||||||
|
self._cached_compiled_union_files = tuple()
|
||||||
|
else:
|
||||||
|
self._cached_compiled_union_files =\
|
||||||
|
(re.compile('|'.join(files_marked)),)
|
||||||
|
paths_marked = [x for x in marked_count if has_sep(x)]
|
||||||
|
if not paths_marked:
|
||||||
|
self._cached_compiled_union_paths = tuple()
|
||||||
|
else:
|
||||||
|
self._cached_compiled_union_paths =\
|
||||||
|
(re.compile('|'.join(paths_marked)),)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def compiled(self):
|
||||||
|
"""Should be used by other classes to retrieve the up-to-date list of patterns."""
|
||||||
|
if self._use_union:
|
||||||
|
if self._dirty:
|
||||||
|
self.build_compiled_caches(True)
|
||||||
|
self._dirty = False
|
||||||
|
return self._cached_compiled_union_all
|
||||||
|
return self._excluded_compiled
|
||||||
|
|
||||||
|
@property
|
||||||
|
def compiled_files(self):
|
||||||
|
"""When matching against filenames only, we probably won't be seeing any
|
||||||
|
directory separator, so we filter out regexes with os.sep in them.
|
||||||
|
The interface should be expected to be a generator, even if it returns only
|
||||||
|
one item (one Pattern in the union case)."""
|
||||||
|
if self._dirty:
|
||||||
|
self.build_compiled_caches(True if self._use_union else False)
|
||||||
|
self._dirty = False
|
||||||
|
return self._cached_compiled_union_files if self._use_union\
|
||||||
|
else self._cached_compiled_files
|
||||||
|
|
||||||
|
@property
|
||||||
|
def compiled_paths(self):
|
||||||
|
"""Returns patterns with only separators in them, for more precise filtering."""
|
||||||
|
if self._dirty:
|
||||||
|
self.build_compiled_caches(True if self._use_union else False)
|
||||||
|
self._dirty = False
|
||||||
|
return self._cached_compiled_union_paths if self._use_union\
|
||||||
|
else self._cached_compiled_paths
|
||||||
|
|
||||||
|
# ---Public
|
||||||
|
def add(self, regex, forced=False):
|
||||||
|
"""This interface should throw exceptions if there is an error during
|
||||||
|
regex compilation"""
|
||||||
|
if self.isExcluded(regex):
|
||||||
|
# This exception should never be ignored
|
||||||
|
raise AlreadyThereException()
|
||||||
|
if regex in forbidden_regexes:
|
||||||
|
raise Exception("Forbidden (dangerous) expression.")
|
||||||
|
|
||||||
|
iscompilable, exception, compiled = self.compile_re(regex)
|
||||||
|
if not iscompilable and not forced:
|
||||||
|
# This exception can be ignored, but taken into account
|
||||||
|
# to avoid adding to compiled set
|
||||||
|
raise exception
|
||||||
|
else:
|
||||||
|
self._do_add(regex, iscompilable, exception, compiled)
|
||||||
|
|
||||||
|
def _do_add(self, regex, iscompilable, exception, compiled):
|
||||||
|
# We need to insert at the top
|
||||||
|
self._excluded.insert(0, [regex, iscompilable, exception, compiled])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def marked_count(self):
|
||||||
|
"""Returns the number of marked regexes only."""
|
||||||
|
return len([x for marked, x in self if marked])
|
||||||
|
|
||||||
|
def isExcluded(self, regex):
|
||||||
|
for item in self._excluded:
|
||||||
|
if regex == item[0]:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def remove(self, regex):
|
||||||
|
for item in self._excluded:
|
||||||
|
if item[0] == regex:
|
||||||
|
self._excluded.remove(item)
|
||||||
|
self._remove_compiled(regex)
|
||||||
|
|
||||||
|
def rename(self, regex, newregex):
|
||||||
|
if regex == newregex:
|
||||||
|
return
|
||||||
|
found = False
|
||||||
|
was_marked = False
|
||||||
|
is_compilable = False
|
||||||
|
for item in self._excluded:
|
||||||
|
if item[0] == regex:
|
||||||
|
found = True
|
||||||
|
was_marked = self.is_marked(regex)
|
||||||
|
is_compilable, exception, compiled = self.compile_re(newregex)
|
||||||
|
# We overwrite the found entry
|
||||||
|
self._excluded[self._excluded.index(item)] =\
|
||||||
|
[newregex, is_compilable, exception, compiled]
|
||||||
|
self._remove_compiled(regex)
|
||||||
|
break
|
||||||
|
if not found:
|
||||||
|
return
|
||||||
|
if is_compilable and was_marked:
|
||||||
|
# Not marked by default when added, add it back
|
||||||
|
self.mark(newregex)
|
||||||
|
|
||||||
|
# def change_index(self, regex, new_index):
|
||||||
|
# """Internal list must be a list, not dict."""
|
||||||
|
# item = self._excluded.pop(regex)
|
||||||
|
# self._excluded.insert(new_index, item)
|
||||||
|
|
||||||
|
def restore_defaults(self):
|
||||||
|
for _, regex in self:
|
||||||
|
if regex not in default_regexes:
|
||||||
|
self.unmark(regex)
|
||||||
|
for default_regex in default_regexes:
|
||||||
|
if not self.isExcluded(default_regex):
|
||||||
|
self.add(default_regex)
|
||||||
|
self.mark(default_regex)
|
||||||
|
|
||||||
|
def load_from_xml(self, infile):
|
||||||
|
"""Loads the ignore list from a XML created with save_to_xml.
|
||||||
|
|
||||||
|
infile can be a file object or a filename.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
root = ET.parse(infile).getroot()
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning(f"Error while loading {infile}: {e}")
|
||||||
|
self.restore_defaults()
|
||||||
|
return e
|
||||||
|
|
||||||
|
marked = set()
|
||||||
|
exclude_elems = (e for e in root if e.tag == "exclude")
|
||||||
|
for exclude_item in exclude_elems:
|
||||||
|
regex_string = exclude_item.get("regex")
|
||||||
|
if not regex_string:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
# "forced" avoids compilation exceptions and adds anyway
|
||||||
|
self.add(regex_string, forced=True)
|
||||||
|
except AlreadyThereException:
|
||||||
|
logging.error(f"Regex \"{regex_string}\" \
|
||||||
|
loaded from XML was already present in the list.")
|
||||||
|
continue
|
||||||
|
if exclude_item.get("marked") == "y":
|
||||||
|
marked.add(regex_string)
|
||||||
|
|
||||||
|
for item in marked:
|
||||||
|
self.mark(item)
|
||||||
|
|
||||||
|
def save_to_xml(self, outfile):
|
||||||
|
"""Create a XML file that can be used by load_from_xml.
|
||||||
|
outfile can be a file object or a filename."""
|
||||||
|
root = ET.Element("exclude_list")
|
||||||
|
# reversed in order to keep order of entries when reloading from xml later
|
||||||
|
for item in reversed(self._excluded):
|
||||||
|
exclude_node = ET.SubElement(root, "exclude")
|
||||||
|
exclude_node.set("regex", str(item[0]))
|
||||||
|
exclude_node.set("marked", ("y" if self.is_marked(item[0]) else "n"))
|
||||||
|
tree = ET.ElementTree(root)
|
||||||
|
with FileOrPath(outfile, "wb") as fp:
|
||||||
|
tree.write(fp, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class ExcludeDict(ExcludeList):
|
||||||
|
"""Exclusion list holding a set of regular expressions as keys, the compiled
|
||||||
|
Pattern, compilation error and compilable boolean as values."""
|
||||||
|
# Implemntation around a dictionary instead of a list, which implies
|
||||||
|
# to keep the index of each string-key as its sub-element and keep it updated
|
||||||
|
# whenever insert/remove is done.
|
||||||
|
|
||||||
|
def __init__(self, union_regex=False):
|
||||||
|
Markable.__init__(self)
|
||||||
|
self._use_union = union_regex
|
||||||
|
# { "regex string":
|
||||||
|
# {
|
||||||
|
# "index": int,
|
||||||
|
# "compilable": bool,
|
||||||
|
# "error": str,
|
||||||
|
# "compiled": Pattern or None
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
self._excluded = {}
|
||||||
|
self._excluded_compiled = set()
|
||||||
|
self._dirty = True
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
"""Iterate in order."""
|
||||||
|
for regex in ordered_keys(self._excluded):
|
||||||
|
yield self.is_marked(regex), regex
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
"""Returns the dict item correponding to key"""
|
||||||
|
return self._excluded.__getitem__(key)
|
||||||
|
|
||||||
|
def get_compiled(self, key):
|
||||||
|
"""Returns the compiled item for key"""
|
||||||
|
return self.__getitem__(key).get("compiled")
|
||||||
|
|
||||||
|
def is_markable(self, regex):
|
||||||
|
return self._is_markable(regex)
|
||||||
|
|
||||||
|
def _is_markable(self, regex):
|
||||||
|
"""Return the cached result of "compilable" property"""
|
||||||
|
exists = self._excluded.get(regex)
|
||||||
|
if exists:
|
||||||
|
return exists.get("compilable")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _add_compiled(self, regex):
|
||||||
|
self._dirty = True
|
||||||
|
if self._use_union:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._excluded_compiled.add(self._excluded[regex]["compiled"])
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning(f"Exception while adding regex {regex} to compiled set: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
def is_compilable(self, regex):
|
||||||
|
"""Returns the cached "compilable" value"""
|
||||||
|
return self._excluded[regex]["compilable"]
|
||||||
|
|
||||||
|
def error(self, regex):
|
||||||
|
"""Return the compilation error message for regex string"""
|
||||||
|
return self._excluded.get(regex).get("error")
|
||||||
|
|
||||||
|
# ---Public
|
||||||
|
def _do_add(self, regex, iscompilable, exception, compiled):
|
||||||
|
# We always insert at the top, so index should be 0
|
||||||
|
# and other indices should be pushed by one
|
||||||
|
for value in self._excluded.values():
|
||||||
|
value["index"] += 1
|
||||||
|
self._excluded[regex] = {
|
||||||
|
"index": 0,
|
||||||
|
"compilable": iscompilable,
|
||||||
|
"error": exception,
|
||||||
|
"compiled": compiled
|
||||||
|
}
|
||||||
|
|
||||||
|
def isExcluded(self, regex):
|
||||||
|
if regex in self._excluded.keys():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def remove(self, regex):
|
||||||
|
old_value = self._excluded.pop(regex)
|
||||||
|
# Bring down all indices which where above it
|
||||||
|
index = old_value["index"]
|
||||||
|
if index == len(self._excluded) - 1: # we start at 0...
|
||||||
|
# Old index was at the end, no need to update other indices
|
||||||
|
self._remove_compiled(regex)
|
||||||
|
return
|
||||||
|
|
||||||
|
for value in self._excluded.values():
|
||||||
|
if value.get("index") > old_value["index"]:
|
||||||
|
value["index"] -= 1
|
||||||
|
self._remove_compiled(regex)
|
||||||
|
|
||||||
|
def rename(self, regex, newregex):
|
||||||
|
if regex == newregex or regex not in self._excluded.keys():
|
||||||
|
return
|
||||||
|
was_marked = self.is_marked(regex)
|
||||||
|
previous = self._excluded.pop(regex)
|
||||||
|
iscompilable, error, compiled = self.compile_re(newregex)
|
||||||
|
self._excluded[newregex] = {
|
||||||
|
"index": previous["index"],
|
||||||
|
"compilable": iscompilable,
|
||||||
|
"error": error,
|
||||||
|
"compiled": compiled
|
||||||
|
}
|
||||||
|
self._remove_compiled(regex)
|
||||||
|
if was_marked and iscompilable:
|
||||||
|
self.mark(newregex)
|
||||||
|
|
||||||
|
def save_to_xml(self, outfile):
|
||||||
|
"""Create a XML file that can be used by load_from_xml.
|
||||||
|
|
||||||
|
outfile can be a file object or a filename.
|
||||||
|
"""
|
||||||
|
root = ET.Element("exclude_list")
|
||||||
|
# reversed in order to keep order of entries when reloading from xml later
|
||||||
|
reversed_list = []
|
||||||
|
for key in ordered_keys(self._excluded):
|
||||||
|
reversed_list.append(key)
|
||||||
|
for item in reversed(reversed_list):
|
||||||
|
exclude_node = ET.SubElement(root, "exclude")
|
||||||
|
exclude_node.set("regex", str(item))
|
||||||
|
exclude_node.set("marked", ("y" if self.is_marked(item) else "n"))
|
||||||
|
tree = ET.ElementTree(root)
|
||||||
|
with FileOrPath(outfile, "wb") as fp:
|
||||||
|
tree.write(fp, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def ordered_keys(_dict):
|
||||||
|
"""Returns an iterator over the keys of dictionary sorted by "index" key"""
|
||||||
|
if not len(_dict):
|
||||||
|
return
|
||||||
|
list_of_items = []
|
||||||
|
for item in _dict.items():
|
||||||
|
list_of_items.append(item)
|
||||||
|
list_of_items.sort(key=lambda x: x[1].get("index"))
|
||||||
|
for item in list_of_items:
|
||||||
|
yield item[0]
|
||||||
|
|
||||||
|
|
||||||
|
if ISWINDOWS:
|
||||||
|
def has_sep(x):
|
||||||
|
return '\\' + sep in x
|
||||||
|
else:
|
||||||
|
def has_sep(x):
|
||||||
|
return sep in x
|
||||||
71
core/gui/exclude_list_dialog.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# Created On: 2012/03/13
|
||||||
|
# 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
|
||||||
|
|
||||||
|
# from hscommon.trans import tr
|
||||||
|
from .exclude_list_table import ExcludeListTable
|
||||||
|
import logging
|
||||||
|
|
||||||
|
|
||||||
|
class ExcludeListDialogCore:
|
||||||
|
def __init__(self, app):
|
||||||
|
self.app = app
|
||||||
|
self.exclude_list = self.app.exclude_list # Markable from exclude.py
|
||||||
|
self.exclude_list_table = ExcludeListTable(self, app) # GUITable, this is the "model"
|
||||||
|
|
||||||
|
def restore_defaults(self):
|
||||||
|
self.exclude_list.restore_defaults()
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def refresh(self):
|
||||||
|
self.exclude_list_table.refresh()
|
||||||
|
|
||||||
|
def remove_selected(self):
|
||||||
|
for row in self.exclude_list_table.selected_rows:
|
||||||
|
self.exclude_list_table.remove(row)
|
||||||
|
self.exclude_list.remove(row.regex)
|
||||||
|
self.refresh()
|
||||||
|
|
||||||
|
def rename_selected(self, newregex):
|
||||||
|
"""Renames the selected regex to ``newregex``.
|
||||||
|
If there's more than one selected row, the first one is used.
|
||||||
|
:param str newregex: The regex to rename the row's regex to.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
r = self.exclude_list_table.selected_rows[0]
|
||||||
|
self.exclude_list.rename(r.regex, newregex)
|
||||||
|
self.refresh()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning(f"Error while renaming regex to {newregex}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def add(self, regex):
|
||||||
|
try:
|
||||||
|
self.exclude_list.add(regex)
|
||||||
|
except Exception as e:
|
||||||
|
raise(e)
|
||||||
|
self.exclude_list.mark(regex)
|
||||||
|
self.exclude_list_table.add(regex)
|
||||||
|
|
||||||
|
def test_string(self, test_string):
|
||||||
|
"""Sets property on row to highlight if its regex matches test_string supplied."""
|
||||||
|
matched = False
|
||||||
|
for row in self.exclude_list_table.rows:
|
||||||
|
compiled_regex = self.exclude_list.get_compiled(row.regex)
|
||||||
|
if compiled_regex and compiled_regex.match(test_string):
|
||||||
|
matched = True
|
||||||
|
row.highlight = True
|
||||||
|
else:
|
||||||
|
row.highlight = False
|
||||||
|
return matched
|
||||||
|
|
||||||
|
def reset_rows_highlight(self):
|
||||||
|
for row in self.exclude_list_table.rows:
|
||||||
|
row.highlight = False
|
||||||
|
|
||||||
|
def show(self):
|
||||||
|
self.view.show()
|
||||||
98
core/gui/exclude_list_table.py
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||||
|
# which should be included with this package. The terms are also available at
|
||||||
|
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||||
|
|
||||||
|
from .base import DupeGuruGUIObject
|
||||||
|
from hscommon.gui.table import GUITable, Row
|
||||||
|
from hscommon.gui.column import Column, Columns
|
||||||
|
from hscommon.trans import trget
|
||||||
|
tr = trget("ui")
|
||||||
|
|
||||||
|
|
||||||
|
class ExcludeListTable(GUITable, DupeGuruGUIObject):
|
||||||
|
COLUMNS = [
|
||||||
|
Column("marked", ""),
|
||||||
|
Column("regex", tr("Regular Expressions"))
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, exclude_list_dialog, app):
|
||||||
|
GUITable.__init__(self)
|
||||||
|
DupeGuruGUIObject.__init__(self, app)
|
||||||
|
self.columns = Columns(self)
|
||||||
|
self.dialog = exclude_list_dialog
|
||||||
|
|
||||||
|
def rename_selected(self, newname):
|
||||||
|
row = self.selected_row
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
row._data = None
|
||||||
|
return self.dialog.rename_selected(newname)
|
||||||
|
|
||||||
|
# --- Virtual
|
||||||
|
def _do_add(self, regex):
|
||||||
|
"""(Virtual) Creates a new row, adds it in the table.
|
||||||
|
Returns ``(row, insert_index)``."""
|
||||||
|
# Return index 0 to insert at the top
|
||||||
|
return ExcludeListRow(self, self.dialog.exclude_list.is_marked(regex), regex), 0
|
||||||
|
|
||||||
|
def _do_delete(self):
|
||||||
|
self.dalog.exclude_list.remove(self.selected_row.regex)
|
||||||
|
|
||||||
|
# --- Override
|
||||||
|
def add(self, regex):
|
||||||
|
row, insert_index = self._do_add(regex)
|
||||||
|
self.insert(insert_index, row)
|
||||||
|
self.view.refresh()
|
||||||
|
|
||||||
|
def _fill(self):
|
||||||
|
for enabled, regex in self.dialog.exclude_list:
|
||||||
|
self.append(ExcludeListRow(self, enabled, regex))
|
||||||
|
|
||||||
|
def refresh(self, refresh_view=True):
|
||||||
|
"""Override to avoid keeping previous selection in case of multiple rows
|
||||||
|
selected previously."""
|
||||||
|
self.cancel_edits()
|
||||||
|
del self[:]
|
||||||
|
self._fill()
|
||||||
|
if refresh_view:
|
||||||
|
self.view.refresh()
|
||||||
|
|
||||||
|
|
||||||
|
class ExcludeListRow(Row):
|
||||||
|
def __init__(self, table, enabled, regex):
|
||||||
|
Row.__init__(self, table)
|
||||||
|
self._app = table.app
|
||||||
|
self._data = None
|
||||||
|
self.enabled = str(enabled)
|
||||||
|
self.regex = str(regex)
|
||||||
|
self.highlight = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def data(self):
|
||||||
|
if self._data is None:
|
||||||
|
self._data = {"marked": self.enabled, "regex": self.regex}
|
||||||
|
return self._data
|
||||||
|
|
||||||
|
@property
|
||||||
|
def markable(self):
|
||||||
|
return self._app.exclude_list.is_markable(self.regex)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def marked(self):
|
||||||
|
return self._app.exclude_list.is_marked(self.regex)
|
||||||
|
|
||||||
|
@marked.setter
|
||||||
|
def marked(self, value):
|
||||||
|
if value:
|
||||||
|
self._app.exclude_list.mark(self.regex)
|
||||||
|
else:
|
||||||
|
self._app.exclude_list.unmark(self.regex)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def error(self):
|
||||||
|
# This assumes error() returns an Exception()
|
||||||
|
message = self._app.exclude_list.error(self.regex)
|
||||||
|
if hasattr(message, "msg"):
|
||||||
|
return self._app.exclude_list.error(self.regex).msg
|
||||||
|
else:
|
||||||
|
return message # Exception object
|
||||||
@@ -17,7 +17,7 @@ class IgnoreListDialog:
|
|||||||
def __init__(self, app):
|
def __init__(self, app):
|
||||||
self.app = app
|
self.app = app
|
||||||
self.ignore_list = self.app.ignore_list
|
self.ignore_list = self.app.ignore_list
|
||||||
self.ignore_list_table = IgnoreListTable(self)
|
self.ignore_list_table = IgnoreListTable(self) # GUITable
|
||||||
|
|
||||||
def clear(self):
|
def clear(self):
|
||||||
if not self.ignore_list:
|
if not self.ignore_list:
|
||||||
|
|||||||
@@ -72,13 +72,15 @@ class PrioritizeDialog(GUIObject):
|
|||||||
# Add selected criteria in criteria_list to prioritization_list.
|
# Add selected criteria in criteria_list to prioritization_list.
|
||||||
if self.criteria_list.selected_index is None:
|
if self.criteria_list.selected_index is None:
|
||||||
return
|
return
|
||||||
crit = self.criteria[self.criteria_list.selected_index]
|
for i in self.criteria_list.selected_indexes:
|
||||||
|
crit = self.criteria[i]
|
||||||
self.prioritizations.append(crit)
|
self.prioritizations.append(crit)
|
||||||
del crit
|
del crit
|
||||||
self.prioritization_list[:] = [crit.display for crit in self.prioritizations]
|
self.prioritization_list[:] = [crit.display for crit in self.prioritizations]
|
||||||
|
|
||||||
def remove_selected(self):
|
def remove_selected(self):
|
||||||
self.prioritization_list.remove_selected()
|
self.prioritization_list.remove_selected()
|
||||||
|
self.prioritization_list.select([])
|
||||||
|
|
||||||
def perform_reprioritization(self):
|
def perform_reprioritization(self):
|
||||||
self.app.reprioritize_groups(self._sort_key)
|
self.app.reprioritize_groups(self._sort_key)
|
||||||
|
|||||||
@@ -254,6 +254,7 @@ def getmatches(pictures, cache_path, threshold, match_scaled=False, j=job.nulljo
|
|||||||
ref.dimensions # pre-read dimensions for display in results
|
ref.dimensions # pre-read dimensions for display in results
|
||||||
other.dimensions
|
other.dimensions
|
||||||
result.append(get_match(ref, other, percentage))
|
result.append(get_match(ref, other, percentage))
|
||||||
|
pool.join()
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -241,13 +241,13 @@ class Results(Markable):
|
|||||||
|
|
||||||
self.apply_filter(None)
|
self.apply_filter(None)
|
||||||
root = ET.parse(infile).getroot()
|
root = ET.parse(infile).getroot()
|
||||||
group_elems = list(root.getiterator("group"))
|
group_elems = list(root.iter("group"))
|
||||||
groups = []
|
groups = []
|
||||||
marked = set()
|
marked = set()
|
||||||
for group_elem in j.iter_with_progress(group_elems, every=100):
|
for group_elem in j.iter_with_progress(group_elems, every=100):
|
||||||
group = engine.Group()
|
group = engine.Group()
|
||||||
dupes = []
|
dupes = []
|
||||||
for file_elem in group_elem.getiterator("file"):
|
for file_elem in group_elem.iter("file"):
|
||||||
path = file_elem.get("path")
|
path = file_elem.get("path")
|
||||||
words = file_elem.get("words", "")
|
words = file_elem.get("words", "")
|
||||||
if not path:
|
if not path:
|
||||||
@@ -260,7 +260,7 @@ class Results(Markable):
|
|||||||
dupes.append(file)
|
dupes.append(file)
|
||||||
if file_elem.get("marked") == "y":
|
if file_elem.get("marked") == "y":
|
||||||
marked.add(file)
|
marked.add(file)
|
||||||
for match_elem in group_elem.getiterator("match"):
|
for match_elem in group_elem.iter("match"):
|
||||||
try:
|
try:
|
||||||
attrs = match_elem.attrib
|
attrs = match_elem.attrib
|
||||||
first_file = dupes[int(attrs["first"])]
|
first_file = dupes[int(attrs["first"])]
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import os
|
|||||||
import os.path as op
|
import os.path as op
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from pytest import mark
|
import pytest
|
||||||
from hscommon.path import Path
|
from hscommon.path import Path
|
||||||
import hscommon.conflict
|
import hscommon.conflict
|
||||||
import hscommon.util
|
import hscommon.util
|
||||||
@@ -109,7 +109,7 @@ class TestCaseDupeGuru:
|
|||||||
add_fake_files_to_directories(app.directories, [f1, f2])
|
add_fake_files_to_directories(app.directories, [f1, f2])
|
||||||
app.start_scanning() # no exception
|
app.start_scanning() # no exception
|
||||||
|
|
||||||
@mark.skipif("not hasattr(os, 'link')")
|
@pytest.mark.skipif("not hasattr(os, 'link')")
|
||||||
def test_ignore_hardlink_matches(self, tmpdir):
|
def test_ignore_hardlink_matches(self, tmpdir):
|
||||||
# If the ignore_hardlink_matches option is set, don't match files hardlinking to the same
|
# If the ignore_hardlink_matches option is set, don't match files hardlinking to the same
|
||||||
# inode.
|
# inode.
|
||||||
@@ -133,8 +133,9 @@ class TestCaseDupeGuru:
|
|||||||
|
|
||||||
|
|
||||||
class TestCaseDupeGuru_clean_empty_dirs:
|
class TestCaseDupeGuru_clean_empty_dirs:
|
||||||
def pytest_funcarg__do_setup(self, request):
|
@pytest.fixture
|
||||||
monkeypatch = request.getfuncargvalue("monkeypatch")
|
def do_setup(self, request):
|
||||||
|
monkeypatch = request.getfixturevalue("monkeypatch")
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
hscommon.util,
|
hscommon.util,
|
||||||
"delete_if_empty",
|
"delete_if_empty",
|
||||||
@@ -175,7 +176,8 @@ class TestCaseDupeGuru_clean_empty_dirs:
|
|||||||
|
|
||||||
|
|
||||||
class TestCaseDupeGuruWithResults:
|
class TestCaseDupeGuruWithResults:
|
||||||
def pytest_funcarg__do_setup(self, request):
|
@pytest.fixture
|
||||||
|
def do_setup(self, request):
|
||||||
app = TestApp()
|
app = TestApp()
|
||||||
self.app = app.app
|
self.app = app.app
|
||||||
self.objects, self.matches, self.groups = GetTestGroups()
|
self.objects, self.matches, self.groups = GetTestGroups()
|
||||||
@@ -184,7 +186,7 @@ class TestCaseDupeGuruWithResults:
|
|||||||
self.dtree = app.dtree
|
self.dtree = app.dtree
|
||||||
self.rtable = app.rtable
|
self.rtable = app.rtable
|
||||||
self.rtable.refresh()
|
self.rtable.refresh()
|
||||||
tmpdir = request.getfuncargvalue("tmpdir")
|
tmpdir = request.getfixturevalue("tmpdir")
|
||||||
tmppath = Path(str(tmpdir))
|
tmppath = Path(str(tmpdir))
|
||||||
tmppath["foo"].mkdir()
|
tmppath["foo"].mkdir()
|
||||||
tmppath["bar"].mkdir()
|
tmppath["bar"].mkdir()
|
||||||
@@ -430,8 +432,9 @@ class TestCaseDupeGuruWithResults:
|
|||||||
|
|
||||||
|
|
||||||
class TestCaseDupeGuru_renameSelected:
|
class TestCaseDupeGuru_renameSelected:
|
||||||
def pytest_funcarg__do_setup(self, request):
|
@pytest.fixture
|
||||||
tmpdir = request.getfuncargvalue("tmpdir")
|
def do_setup(self, request):
|
||||||
|
tmpdir = request.getfixturevalue("tmpdir")
|
||||||
p = Path(str(tmpdir))
|
p = Path(str(tmpdir))
|
||||||
fp = open(str(p["foo bar 1"]), mode="w")
|
fp = open(str(p["foo bar 1"]), mode="w")
|
||||||
fp.close()
|
fp.close()
|
||||||
@@ -493,8 +496,9 @@ class TestCaseDupeGuru_renameSelected:
|
|||||||
|
|
||||||
|
|
||||||
class TestAppWithDirectoriesInTree:
|
class TestAppWithDirectoriesInTree:
|
||||||
def pytest_funcarg__do_setup(self, request):
|
@pytest.fixture
|
||||||
tmpdir = request.getfuncargvalue("tmpdir")
|
def do_setup(self, request):
|
||||||
|
tmpdir = request.getfixturevalue("tmpdir")
|
||||||
p = Path(str(tmpdir))
|
p = Path(str(tmpdir))
|
||||||
p["sub1"].mkdir()
|
p["sub1"].mkdir()
|
||||||
p["sub2"].mkdir()
|
p["sub2"].mkdir()
|
||||||
|
|||||||
@@ -147,6 +147,8 @@ def GetTestGroups():
|
|||||||
|
|
||||||
|
|
||||||
class TestApp(TestAppBase):
|
class TestApp(TestAppBase):
|
||||||
|
__test__ = False
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
def link_gui(gui):
|
def link_gui(gui):
|
||||||
gui.view = self.make_logger()
|
gui.view = self.make_logger()
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
from hscommon.testutil import pytest_funcarg__app # noqa
|
from hscommon.testutil import app # noqa
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import shutil
|
|||||||
from pytest import raises
|
from pytest import raises
|
||||||
from hscommon.path import Path
|
from hscommon.path import Path
|
||||||
from hscommon.testutil import eq_
|
from hscommon.testutil import eq_
|
||||||
|
from hscommon.plat import ISWINDOWS
|
||||||
|
|
||||||
from ..fs import File
|
from ..fs import File
|
||||||
from ..directories import (
|
from ..directories import (
|
||||||
@@ -20,6 +21,7 @@ from ..directories import (
|
|||||||
AlreadyThereError,
|
AlreadyThereError,
|
||||||
InvalidPathError,
|
InvalidPathError,
|
||||||
)
|
)
|
||||||
|
from ..exclude import ExcludeList, ExcludeDict
|
||||||
|
|
||||||
|
|
||||||
def create_fake_fs(rootpath):
|
def create_fake_fs(rootpath):
|
||||||
@@ -341,3 +343,200 @@ def test_default_path_state_override(tmpdir):
|
|||||||
d.set_state(p1["foobar"], DirectoryState.Normal)
|
d.set_state(p1["foobar"], DirectoryState.Normal)
|
||||||
eq_(d.get_state(p1["foobar"]), DirectoryState.Normal)
|
eq_(d.get_state(p1["foobar"]), DirectoryState.Normal)
|
||||||
eq_(len(list(d.get_files())), 2)
|
eq_(len(list(d.get_files())), 2)
|
||||||
|
|
||||||
|
|
||||||
|
class TestExcludeList():
|
||||||
|
def setup_method(self, method):
|
||||||
|
self.d = Directories(exclude_list=ExcludeList(union_regex=False))
|
||||||
|
|
||||||
|
def get_files_and_expect_num_result(self, num_result):
|
||||||
|
"""Calls get_files(), get the filenames only, print for debugging.
|
||||||
|
num_result is how many files are expected as a result."""
|
||||||
|
print(f"EXCLUDED REGEX: paths {self.d._exclude_list.compiled_paths} \
|
||||||
|
files: {self.d._exclude_list.compiled_files} all: {self.d._exclude_list.compiled}")
|
||||||
|
files = list(self.d.get_files())
|
||||||
|
files = [file.name for file in files]
|
||||||
|
print(f"FINAL FILES {files}")
|
||||||
|
eq_(len(files), num_result)
|
||||||
|
return files
|
||||||
|
|
||||||
|
def test_exclude_recycle_bin_by_default(self, tmpdir):
|
||||||
|
regex = r"^.*Recycle\.Bin$"
|
||||||
|
self.d._exclude_list.add(regex)
|
||||||
|
self.d._exclude_list.mark(regex)
|
||||||
|
p1 = Path(str(tmpdir))
|
||||||
|
p1["$Recycle.Bin"].mkdir()
|
||||||
|
p1["$Recycle.Bin"]["subdir"].mkdir()
|
||||||
|
self.d.add_path(p1)
|
||||||
|
eq_(self.d.get_state(p1["$Recycle.Bin"]), DirectoryState.Excluded)
|
||||||
|
# By default, subdirs should be excluded too, but this can be overriden separately
|
||||||
|
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.Excluded)
|
||||||
|
self.d.set_state(p1["$Recycle.Bin"]["subdir"], DirectoryState.Normal)
|
||||||
|
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.Normal)
|
||||||
|
|
||||||
|
def test_exclude_refined(self, tmpdir):
|
||||||
|
regex1 = r"^\$Recycle\.Bin$"
|
||||||
|
self.d._exclude_list.add(regex1)
|
||||||
|
self.d._exclude_list.mark(regex1)
|
||||||
|
p1 = Path(str(tmpdir))
|
||||||
|
p1["$Recycle.Bin"].mkdir()
|
||||||
|
p1["$Recycle.Bin"]["somefile.png"].open("w").close()
|
||||||
|
p1["$Recycle.Bin"]["some_unwanted_file.jpg"].open("w").close()
|
||||||
|
p1["$Recycle.Bin"]["subdir"].mkdir()
|
||||||
|
p1["$Recycle.Bin"]["subdir"]["somesubdirfile.png"].open("w").close()
|
||||||
|
p1["$Recycle.Bin"]["subdir"]["unwanted_subdirfile.gif"].open("w").close()
|
||||||
|
p1["$Recycle.Bin"]["subdar"].mkdir()
|
||||||
|
p1["$Recycle.Bin"]["subdar"]["somesubdarfile.jpeg"].open("w").close()
|
||||||
|
p1["$Recycle.Bin"]["subdar"]["unwanted_subdarfile.png"].open("w").close()
|
||||||
|
self.d.add_path(p1["$Recycle.Bin"])
|
||||||
|
|
||||||
|
# Filter should set the default state to Excluded
|
||||||
|
eq_(self.d.get_state(p1["$Recycle.Bin"]), DirectoryState.Excluded)
|
||||||
|
# The subdir should inherit its parent state
|
||||||
|
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.Excluded)
|
||||||
|
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdar"]), DirectoryState.Excluded)
|
||||||
|
# Override a child path's state
|
||||||
|
self.d.set_state(p1["$Recycle.Bin"]["subdir"], DirectoryState.Normal)
|
||||||
|
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.Normal)
|
||||||
|
# Parent should keep its default state, and the other child too
|
||||||
|
eq_(self.d.get_state(p1["$Recycle.Bin"]), DirectoryState.Excluded)
|
||||||
|
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdar"]), DirectoryState.Excluded)
|
||||||
|
# print(f"get_folders(): {[x for x in self.d.get_folders()]}")
|
||||||
|
|
||||||
|
# only the 2 files directly under the Normal directory
|
||||||
|
files = self.get_files_and_expect_num_result(2)
|
||||||
|
assert "somefile.png" not in files
|
||||||
|
assert "some_unwanted_file.jpg" not in files
|
||||||
|
assert "somesubdarfile.jpeg" not in files
|
||||||
|
assert "unwanted_subdarfile.png" not in files
|
||||||
|
assert "somesubdirfile.png" in files
|
||||||
|
assert "unwanted_subdirfile.gif" in files
|
||||||
|
# Overriding the parent should enable all children
|
||||||
|
self.d.set_state(p1["$Recycle.Bin"], DirectoryState.Normal)
|
||||||
|
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdar"]), DirectoryState.Normal)
|
||||||
|
# all files there
|
||||||
|
files = self.get_files_and_expect_num_result(6)
|
||||||
|
assert "somefile.png" in files
|
||||||
|
assert "some_unwanted_file.jpg" in files
|
||||||
|
|
||||||
|
# This should still filter out files under directory, despite the Normal state
|
||||||
|
regex2 = r".*unwanted.*"
|
||||||
|
self.d._exclude_list.add(regex2)
|
||||||
|
self.d._exclude_list.mark(regex2)
|
||||||
|
files = self.get_files_and_expect_num_result(3)
|
||||||
|
assert "somefile.png" in files
|
||||||
|
assert "some_unwanted_file.jpg" not in files
|
||||||
|
assert "unwanted_subdirfile.gif" not in files
|
||||||
|
assert "unwanted_subdarfile.png" not in files
|
||||||
|
|
||||||
|
if ISWINDOWS:
|
||||||
|
regex3 = r".*Recycle\.Bin\\.*unwanted.*subdirfile.*"
|
||||||
|
else:
|
||||||
|
regex3 = r".*Recycle\.Bin\/.*unwanted.*subdirfile.*"
|
||||||
|
self.d._exclude_list.rename(regex2, regex3)
|
||||||
|
assert self.d._exclude_list.error(regex3) is None
|
||||||
|
# print(f"get_folders(): {[x for x in self.d.get_folders()]}")
|
||||||
|
# Directory shouldn't change its state here, unless explicitely done by user
|
||||||
|
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.Normal)
|
||||||
|
files = self.get_files_and_expect_num_result(5)
|
||||||
|
assert "unwanted_subdirfile.gif" not in files
|
||||||
|
assert "unwanted_subdarfile.png" in files
|
||||||
|
|
||||||
|
# using end of line character should only filter the directory, or file ending with subdir
|
||||||
|
regex4 = r".*subdir$"
|
||||||
|
self.d._exclude_list.rename(regex3, regex4)
|
||||||
|
assert self.d._exclude_list.error(regex4) is None
|
||||||
|
p1["$Recycle.Bin"]["subdar"]["file_ending_with_subdir"].open("w").close()
|
||||||
|
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.Excluded)
|
||||||
|
files = self.get_files_and_expect_num_result(4)
|
||||||
|
assert "file_ending_with_subdir" not in files
|
||||||
|
assert "somesubdarfile.jpeg" in files
|
||||||
|
assert "somesubdirfile.png" not in files
|
||||||
|
assert "unwanted_subdirfile.gif" not in files
|
||||||
|
self.d.set_state(p1["$Recycle.Bin"]["subdir"], DirectoryState.Normal)
|
||||||
|
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.Normal)
|
||||||
|
# print(f"get_folders(): {[x for x in self.d.get_folders()]}")
|
||||||
|
files = self.get_files_and_expect_num_result(6)
|
||||||
|
assert "file_ending_with_subdir" not in files
|
||||||
|
assert "somesubdirfile.png" in files
|
||||||
|
assert "unwanted_subdirfile.gif" in files
|
||||||
|
|
||||||
|
regex5 = r".*subdir.*"
|
||||||
|
self.d._exclude_list.rename(regex4, regex5)
|
||||||
|
# Files containing substring should be filtered
|
||||||
|
eq_(self.d.get_state(p1["$Recycle.Bin"]["subdir"]), DirectoryState.Normal)
|
||||||
|
# The path should not match, only the filename, the "subdir" in the directory name shouldn't matter
|
||||||
|
p1["$Recycle.Bin"]["subdir"]["file_which_shouldnt_match"].open("w").close()
|
||||||
|
files = self.get_files_and_expect_num_result(5)
|
||||||
|
assert "somesubdirfile.png" not in files
|
||||||
|
assert "unwanted_subdirfile.gif" not in files
|
||||||
|
assert "file_ending_with_subdir" not in files
|
||||||
|
assert "file_which_shouldnt_match" in files
|
||||||
|
|
||||||
|
def test_japanese_unicode(self, tmpdir):
|
||||||
|
p1 = Path(str(tmpdir))
|
||||||
|
p1["$Recycle.Bin"].mkdir()
|
||||||
|
p1["$Recycle.Bin"]["somerecycledfile.png"].open("w").close()
|
||||||
|
p1["$Recycle.Bin"]["some_unwanted_file.jpg"].open("w").close()
|
||||||
|
p1["$Recycle.Bin"]["subdir"].mkdir()
|
||||||
|
p1["$Recycle.Bin"]["subdir"]["過去白濁物語~]_カラー.jpg"].open("w").close()
|
||||||
|
p1["$Recycle.Bin"]["思叫物語"].mkdir()
|
||||||
|
p1["$Recycle.Bin"]["思叫物語"]["なししろ会う前"].open("w").close()
|
||||||
|
p1["$Recycle.Bin"]["思叫物語"]["堂~ロ"].open("w").close()
|
||||||
|
self.d.add_path(p1["$Recycle.Bin"])
|
||||||
|
regex3 = r".*物語.*"
|
||||||
|
self.d._exclude_list.add(regex3)
|
||||||
|
self.d._exclude_list.mark(regex3)
|
||||||
|
# print(f"get_folders(): {[x for x in self.d.get_folders()]}")
|
||||||
|
eq_(self.d.get_state(p1["$Recycle.Bin"]["思叫物語"]), DirectoryState.Excluded)
|
||||||
|
files = self.get_files_and_expect_num_result(2)
|
||||||
|
assert "過去白濁物語~]_カラー.jpg" not in files
|
||||||
|
assert "なししろ会う前" not in files
|
||||||
|
assert "堂~ロ" not in files
|
||||||
|
# using end of line character should only filter that directory, not affecting its files
|
||||||
|
regex4 = r".*物語$"
|
||||||
|
self.d._exclude_list.rename(regex3, regex4)
|
||||||
|
assert self.d._exclude_list.error(regex4) is None
|
||||||
|
self.d.set_state(p1["$Recycle.Bin"]["思叫物語"], DirectoryState.Normal)
|
||||||
|
files = self.get_files_and_expect_num_result(5)
|
||||||
|
assert "過去白濁物語~]_カラー.jpg" in files
|
||||||
|
assert "なししろ会う前" in files
|
||||||
|
assert "堂~ロ" in files
|
||||||
|
|
||||||
|
def test_get_state_returns_excluded_for_hidden_directories_and_files(self, tmpdir):
|
||||||
|
# This regex only work for files, not paths
|
||||||
|
regex = r"^\..*$"
|
||||||
|
self.d._exclude_list.add(regex)
|
||||||
|
self.d._exclude_list.mark(regex)
|
||||||
|
p1 = Path(str(tmpdir))
|
||||||
|
p1["foobar"].mkdir()
|
||||||
|
p1["foobar"][".hidden_file.txt"].open("w").close()
|
||||||
|
p1["foobar"][".hidden_dir"].mkdir()
|
||||||
|
p1["foobar"][".hidden_dir"]["foobar.jpg"].open("w").close()
|
||||||
|
p1["foobar"][".hidden_dir"][".hidden_subfile.png"].open("w").close()
|
||||||
|
self.d.add_path(p1["foobar"])
|
||||||
|
# It should not inherit its parent's state originally
|
||||||
|
eq_(self.d.get_state(p1["foobar"][".hidden_dir"]), DirectoryState.Excluded)
|
||||||
|
self.d.set_state(p1["foobar"][".hidden_dir"], DirectoryState.Normal)
|
||||||
|
# The files should still be filtered
|
||||||
|
files = self.get_files_and_expect_num_result(1)
|
||||||
|
eq_(len(self.d._exclude_list.compiled_paths), 0)
|
||||||
|
eq_(len(self.d._exclude_list.compiled_files), 1)
|
||||||
|
assert ".hidden_file.txt" not in files
|
||||||
|
assert ".hidden_subfile.png" not in files
|
||||||
|
assert "foobar.jpg" in files
|
||||||
|
|
||||||
|
|
||||||
|
class TestExcludeDict(TestExcludeList):
|
||||||
|
def setup_method(self, method):
|
||||||
|
self.d = Directories(exclude_list=ExcludeDict(union_regex=False))
|
||||||
|
|
||||||
|
|
||||||
|
class TestExcludeListunion(TestExcludeList):
|
||||||
|
def setup_method(self, method):
|
||||||
|
self.d = Directories(exclude_list=ExcludeList(union_regex=True))
|
||||||
|
|
||||||
|
|
||||||
|
class TestExcludeDictunion(TestExcludeList):
|
||||||
|
def setup_method(self, method):
|
||||||
|
self.d = Directories(exclude_list=ExcludeDict(union_regex=True))
|
||||||
|
|||||||
282
core/tests/exclude_test.py
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
# Copyright 2016 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 io
|
||||||
|
# import os.path as op
|
||||||
|
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
# from pytest import raises
|
||||||
|
from hscommon.testutil import eq_
|
||||||
|
from hscommon.plat import ISWINDOWS
|
||||||
|
|
||||||
|
from .base import DupeGuru
|
||||||
|
from ..exclude import ExcludeList, ExcludeDict, default_regexes, AlreadyThereException
|
||||||
|
|
||||||
|
from re import error
|
||||||
|
|
||||||
|
|
||||||
|
# Two slightly different implementations here, one around a list of lists,
|
||||||
|
# and another around a dictionary.
|
||||||
|
|
||||||
|
|
||||||
|
class TestCaseListXMLLoading:
|
||||||
|
def setup_method(self, method):
|
||||||
|
self.exclude_list = ExcludeList()
|
||||||
|
|
||||||
|
def test_load_non_existant_file(self):
|
||||||
|
# Loads the pre-defined regexes
|
||||||
|
self.exclude_list.load_from_xml("non_existant.xml")
|
||||||
|
eq_(len(default_regexes), len(self.exclude_list))
|
||||||
|
# they should also be marked by default
|
||||||
|
eq_(len(default_regexes), self.exclude_list.marked_count)
|
||||||
|
|
||||||
|
def test_save_to_xml(self):
|
||||||
|
f = io.BytesIO()
|
||||||
|
self.exclude_list.save_to_xml(f)
|
||||||
|
f.seek(0)
|
||||||
|
doc = ET.parse(f)
|
||||||
|
root = doc.getroot()
|
||||||
|
eq_("exclude_list", root.tag)
|
||||||
|
|
||||||
|
def test_save_and_load(self, tmpdir):
|
||||||
|
e1 = ExcludeList()
|
||||||
|
e2 = ExcludeList()
|
||||||
|
eq_(len(e1), 0)
|
||||||
|
e1.add(r"one")
|
||||||
|
e1.mark(r"one")
|
||||||
|
e1.add(r"two")
|
||||||
|
tmpxml = str(tmpdir.join("exclude_testunit.xml"))
|
||||||
|
e1.save_to_xml(tmpxml)
|
||||||
|
e2.load_from_xml(tmpxml)
|
||||||
|
# We should have the default regexes
|
||||||
|
assert r"one" in e2
|
||||||
|
assert r"two" in e2
|
||||||
|
eq_(len(e2), 2)
|
||||||
|
eq_(e2.marked_count, 1)
|
||||||
|
|
||||||
|
def test_load_xml_with_garbage_and_missing_elements(self):
|
||||||
|
root = ET.Element("foobar") # The root element shouldn't matter
|
||||||
|
exclude_node = ET.SubElement(root, "bogus")
|
||||||
|
exclude_node.set("regex", "None")
|
||||||
|
exclude_node.set("marked", "y")
|
||||||
|
|
||||||
|
exclude_node = ET.SubElement(root, "exclude")
|
||||||
|
exclude_node.set("regex", "one")
|
||||||
|
# marked field invalid
|
||||||
|
exclude_node.set("markedddd", "y")
|
||||||
|
|
||||||
|
exclude_node = ET.SubElement(root, "exclude")
|
||||||
|
exclude_node.set("regex", "two")
|
||||||
|
# missing marked field
|
||||||
|
|
||||||
|
exclude_node = ET.SubElement(root, "exclude")
|
||||||
|
exclude_node.set("regex", "three")
|
||||||
|
exclude_node.set("markedddd", "pazjbjepo")
|
||||||
|
|
||||||
|
f = io.BytesIO()
|
||||||
|
tree = ET.ElementTree(root)
|
||||||
|
tree.write(f, encoding="utf-8")
|
||||||
|
f.seek(0)
|
||||||
|
self.exclude_list.load_from_xml(f)
|
||||||
|
print(f"{[x for x in self.exclude_list]}")
|
||||||
|
# only the two "exclude" nodes should be added,
|
||||||
|
eq_(3, len(self.exclude_list))
|
||||||
|
# None should be marked
|
||||||
|
eq_(0, self.exclude_list.marked_count)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCaseDictXMLLoading(TestCaseListXMLLoading):
|
||||||
|
def setup_method(self, method):
|
||||||
|
self.exclude_list = ExcludeDict()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCaseListEmpty:
|
||||||
|
def setup_method(self, method):
|
||||||
|
self.app = DupeGuru()
|
||||||
|
self.app.exclude_list = ExcludeList(union_regex=False)
|
||||||
|
self.exclude_list = self.app.exclude_list
|
||||||
|
|
||||||
|
def test_add_mark_and_remove_regex(self):
|
||||||
|
regex1 = r"one"
|
||||||
|
regex2 = r"two"
|
||||||
|
self.exclude_list.add(regex1)
|
||||||
|
assert(regex1 in self.exclude_list)
|
||||||
|
self.exclude_list.add(regex2)
|
||||||
|
self.exclude_list.mark(regex1)
|
||||||
|
self.exclude_list.mark(regex2)
|
||||||
|
eq_(len(self.exclude_list), 2)
|
||||||
|
eq_(len(self.exclude_list.compiled), 2)
|
||||||
|
compiled_files = [x for x in self.exclude_list.compiled_files]
|
||||||
|
eq_(len(compiled_files), 2)
|
||||||
|
self.exclude_list.remove(regex2)
|
||||||
|
assert(regex2 not in self.exclude_list)
|
||||||
|
eq_(len(self.exclude_list), 1)
|
||||||
|
|
||||||
|
def test_add_duplicate(self):
|
||||||
|
self.exclude_list.add(r"one")
|
||||||
|
eq_(1 , len(self.exclude_list))
|
||||||
|
try:
|
||||||
|
self.exclude_list.add(r"one")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
eq_(1 , len(self.exclude_list))
|
||||||
|
|
||||||
|
def test_add_not_compilable(self):
|
||||||
|
# Trying to add a non-valid regex should not work and raise exception
|
||||||
|
regex = r"one))"
|
||||||
|
try:
|
||||||
|
self.exclude_list.add(regex)
|
||||||
|
except Exception as e:
|
||||||
|
# Make sure we raise a re.error so that the interface can process it
|
||||||
|
eq_(type(e), error)
|
||||||
|
added = self.exclude_list.mark(regex)
|
||||||
|
eq_(added, False)
|
||||||
|
eq_(len(self.exclude_list), 0)
|
||||||
|
eq_(len(self.exclude_list.compiled), 0)
|
||||||
|
compiled_files = [x for x in self.exclude_list.compiled_files]
|
||||||
|
eq_(len(compiled_files), 0)
|
||||||
|
|
||||||
|
def test_force_add_not_compilable(self):
|
||||||
|
"""Used when loading from XML for example"""
|
||||||
|
regex = r"one))"
|
||||||
|
try:
|
||||||
|
self.exclude_list.add(regex, forced=True)
|
||||||
|
except Exception as e:
|
||||||
|
# Should not get an exception here unless it's a duplicate regex
|
||||||
|
raise e
|
||||||
|
marked = self.exclude_list.mark(regex)
|
||||||
|
eq_(marked, False) # can't be marked since not compilable
|
||||||
|
eq_(len(self.exclude_list), 1)
|
||||||
|
eq_(len(self.exclude_list.compiled), 0)
|
||||||
|
compiled_files = [x for x in self.exclude_list.compiled_files]
|
||||||
|
eq_(len(compiled_files), 0)
|
||||||
|
# adding a duplicate
|
||||||
|
regex = r"one))"
|
||||||
|
try:
|
||||||
|
self.exclude_list.add(regex, forced=True)
|
||||||
|
except Exception as e:
|
||||||
|
# we should have this exception, and it shouldn't be added
|
||||||
|
assert type(e) is AlreadyThereException
|
||||||
|
eq_(len(self.exclude_list), 1)
|
||||||
|
eq_(len(self.exclude_list.compiled), 0)
|
||||||
|
|
||||||
|
def test_rename_regex(self):
|
||||||
|
regex = r"one"
|
||||||
|
self.exclude_list.add(regex)
|
||||||
|
self.exclude_list.mark(regex)
|
||||||
|
regex_renamed = r"one))"
|
||||||
|
# Not compilable, can't be marked
|
||||||
|
self.exclude_list.rename(regex, regex_renamed)
|
||||||
|
assert regex not in self.exclude_list
|
||||||
|
assert regex_renamed in self.exclude_list
|
||||||
|
eq_(self.exclude_list.is_marked(regex_renamed), False)
|
||||||
|
self.exclude_list.mark(regex_renamed)
|
||||||
|
eq_(self.exclude_list.is_marked(regex_renamed), False)
|
||||||
|
regex_renamed_compilable = r"two"
|
||||||
|
self.exclude_list.rename(regex_renamed, regex_renamed_compilable)
|
||||||
|
assert regex_renamed_compilable in self.exclude_list
|
||||||
|
eq_(self.exclude_list.is_marked(regex_renamed), False)
|
||||||
|
self.exclude_list.mark(regex_renamed_compilable)
|
||||||
|
eq_(self.exclude_list.is_marked(regex_renamed_compilable), True)
|
||||||
|
eq_(len(self.exclude_list), 1)
|
||||||
|
# Should still be marked after rename
|
||||||
|
regex_compilable = r"three"
|
||||||
|
self.exclude_list.rename(regex_renamed_compilable, regex_compilable)
|
||||||
|
eq_(self.exclude_list.is_marked(regex_compilable), True)
|
||||||
|
|
||||||
|
def test_restore_default(self):
|
||||||
|
"""Only unmark previously added regexes and mark the pre-defined ones"""
|
||||||
|
regex = r"one"
|
||||||
|
self.exclude_list.add(regex)
|
||||||
|
self.exclude_list.mark(regex)
|
||||||
|
self.exclude_list.restore_defaults()
|
||||||
|
eq_(len(default_regexes), self.exclude_list.marked_count)
|
||||||
|
# added regex shouldn't be marked
|
||||||
|
eq_(self.exclude_list.is_marked(regex), False)
|
||||||
|
# added regex shouldn't be in compiled list either
|
||||||
|
compiled = [x for x in self.exclude_list.compiled]
|
||||||
|
assert regex not in compiled
|
||||||
|
# Only default regexes marked and in compiled list
|
||||||
|
for re in default_regexes:
|
||||||
|
assert self.exclude_list.is_marked(re)
|
||||||
|
found = False
|
||||||
|
for compiled_re in compiled:
|
||||||
|
if compiled_re.pattern == re:
|
||||||
|
found = True
|
||||||
|
if not found:
|
||||||
|
raise(Exception(f"Default RE {re} not found in compiled list."))
|
||||||
|
continue
|
||||||
|
eq_(len(default_regexes), len(self.exclude_list.compiled))
|
||||||
|
|
||||||
|
|
||||||
|
class TestCaseDictEmpty(TestCaseListEmpty):
|
||||||
|
"""Same, but with dictionary implementation"""
|
||||||
|
def setup_method(self, method):
|
||||||
|
self.app = DupeGuru()
|
||||||
|
self.app.exclude_list = ExcludeDict(union_regex=False)
|
||||||
|
self.exclude_list = self.app.exclude_list
|
||||||
|
|
||||||
|
|
||||||
|
def split_union(pattern_object):
|
||||||
|
"""Returns list of strings for each union pattern"""
|
||||||
|
return [x for x in pattern_object.pattern.split("|")]
|
||||||
|
|
||||||
|
|
||||||
|
class TestCaseCompiledList():
|
||||||
|
"""Test consistency between union or and separate versions."""
|
||||||
|
def setup_method(self, method):
|
||||||
|
self.e_separate = ExcludeList(union_regex=False)
|
||||||
|
self.e_separate.restore_defaults()
|
||||||
|
self.e_union = ExcludeList(union_regex=True)
|
||||||
|
self.e_union.restore_defaults()
|
||||||
|
|
||||||
|
def test_same_number_of_expressions(self):
|
||||||
|
# We only get one union Pattern item in a tuple, which is made of however many parts
|
||||||
|
eq_(len(split_union(self.e_union.compiled[0])), len(default_regexes))
|
||||||
|
# We get as many as there are marked items
|
||||||
|
eq_(len(self.e_separate.compiled), len(default_regexes))
|
||||||
|
exprs = split_union(self.e_union.compiled[0])
|
||||||
|
# We should have the same number and the same expressions
|
||||||
|
eq_(len(exprs), len(self.e_separate.compiled))
|
||||||
|
for expr in self.e_separate.compiled:
|
||||||
|
assert expr.pattern in exprs
|
||||||
|
|
||||||
|
def test_compiled_files(self):
|
||||||
|
# is path separator checked properly to yield the output
|
||||||
|
if ISWINDOWS:
|
||||||
|
regex1 = r"test\\one\\sub"
|
||||||
|
else:
|
||||||
|
regex1 = r"test/one/sub"
|
||||||
|
self.e_separate.add(regex1)
|
||||||
|
self.e_separate.mark(regex1)
|
||||||
|
self.e_union.add(regex1)
|
||||||
|
self.e_union.mark(regex1)
|
||||||
|
separate_compiled_dirs = self.e_separate.compiled
|
||||||
|
separate_compiled_files = [x for x in self.e_separate.compiled_files]
|
||||||
|
# HACK we need to call compiled property FIRST to generate the cache
|
||||||
|
union_compiled_dirs = self.e_union.compiled
|
||||||
|
# print(f"type: {type(self.e_union.compiled_files[0])}")
|
||||||
|
# A generator returning only one item... ugh
|
||||||
|
union_compiled_files = [x for x in self.e_union.compiled_files][0]
|
||||||
|
print(f"compiled files: {union_compiled_files}")
|
||||||
|
# Separate should give several plus the one added
|
||||||
|
eq_(len(separate_compiled_dirs), len(default_regexes) + 1)
|
||||||
|
# regex1 shouldn't be in the "files" version
|
||||||
|
eq_(len(separate_compiled_files), len(default_regexes))
|
||||||
|
# Only one Pattern returned, which when split should be however many + 1
|
||||||
|
eq_(len(split_union(union_compiled_dirs[0])), len(default_regexes) + 1)
|
||||||
|
# regex1 shouldn't be here either
|
||||||
|
eq_(len(split_union(union_compiled_files)), len(default_regexes))
|
||||||
|
|
||||||
|
|
||||||
|
class TestCaseCompiledDict(TestCaseCompiledList):
|
||||||
|
"""Test the dictionary version"""
|
||||||
|
def setup_method(self, method):
|
||||||
|
self.e_separate = ExcludeDict(union_regex=False)
|
||||||
|
self.e_separate.restore_defaults()
|
||||||
|
self.e_union = ExcludeDict(union_regex=True)
|
||||||
|
self.e_union.restore_defaults()
|
||||||
@@ -414,12 +414,12 @@ class TestCaseResultsMarkings:
|
|||||||
f.seek(0)
|
f.seek(0)
|
||||||
doc = ET.parse(f)
|
doc = ET.parse(f)
|
||||||
root = doc.getroot()
|
root = doc.getroot()
|
||||||
g1, g2 = root.getiterator("group")
|
g1, g2 = root.iter("group")
|
||||||
d1, d2, d3 = g1.getiterator("file")
|
d1, d2, d3 = g1.iter("file")
|
||||||
eq_("n", d1.get("marked"))
|
eq_("n", d1.get("marked"))
|
||||||
eq_("n", d2.get("marked"))
|
eq_("n", d2.get("marked"))
|
||||||
eq_("y", d3.get("marked"))
|
eq_("y", d3.get("marked"))
|
||||||
d1, d2 = g2.getiterator("file")
|
d1, d2 = g2.iter("file")
|
||||||
eq_("n", d1.get("marked"))
|
eq_("n", d1.get("marked"))
|
||||||
eq_("y", d2.get("marked"))
|
eq_("y", d2.get("marked"))
|
||||||
|
|
||||||
|
|||||||
@@ -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 pytest
|
||||||
|
|
||||||
from hscommon.jobprogress import job
|
from hscommon.jobprogress import job
|
||||||
from hscommon.path import Path
|
from hscommon.path import Path
|
||||||
from hscommon.testutil import eq_
|
from hscommon.testutil import eq_
|
||||||
@@ -33,10 +35,11 @@ class NamedObject:
|
|||||||
no = NamedObject
|
no = NamedObject
|
||||||
|
|
||||||
|
|
||||||
def pytest_funcarg__fake_fileexists(request):
|
@pytest.fixture
|
||||||
|
def fake_fileexists(request):
|
||||||
# This is a hack to avoid invalidating all previous tests since the scanner started to test
|
# This is a hack to avoid invalidating all previous tests since the scanner started to test
|
||||||
# for file existence before doing the match grouping.
|
# for file existence before doing the match grouping.
|
||||||
monkeypatch = request.getfuncargvalue("monkeypatch")
|
monkeypatch = request.getfixturevalue("monkeypatch")
|
||||||
monkeypatch.setattr(Path, "exists", lambda _: True)
|
monkeypatch.setattr(Path, "exists", lambda _: True)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,29 @@
|
|||||||
|
=== 4.1.0 (2020-12-29)
|
||||||
|
|
||||||
|
* Use tabs instead of separate windows (#688)
|
||||||
|
* Show the shortcut for "mark selected" in results dialog (#656, #641)
|
||||||
|
* Add image comparison features to details dialog (#683)
|
||||||
|
* Add the ability to use regex based exclusion filters (#705)
|
||||||
|
* Change reference row background color, and allow user to adjust the color (#701)
|
||||||
|
* Save / Load directories as XML (#706)
|
||||||
|
* Workaround for EXIF IFD type mismatch in parsing function (#630, #698)
|
||||||
|
* Progress dialog stuck at "Verified X/X matches" (#693, #694)
|
||||||
|
* Fix word wrap in ignore list dialog (#687)
|
||||||
|
* Fix issue with result window action on creation (#685)
|
||||||
|
* Colorize details table differences, allow moving rows (#682)
|
||||||
|
* Fix loading Result of 'Scan Type: Folders' shows only '---' in every table cell (#677, #676)
|
||||||
|
* Fix issue with details and results dialog row trimming (#655, #654)
|
||||||
|
* Add option to enable/disable bold font (#646, #314)
|
||||||
|
* Use relative icon path for themes to override more easily (#746)
|
||||||
|
* Fix issues with Python 3.8 compatibility (#665)
|
||||||
|
* Fix flake8 issues (#672)
|
||||||
|
* Update to use newer pytest and expand flake8 checking, cleanup various Deprecation Warnings
|
||||||
|
* Add warnings to packaging script when files are not built (#691)
|
||||||
|
* Use relative icon path for themes to override more easily (#746)
|
||||||
|
* Update Packaging for Ubuntu (#593)
|
||||||
|
* Minor Build Updates (#627, #575, #628, #614)
|
||||||
|
* Update CI builds and add windows CI (#572, #669)
|
||||||
|
|
||||||
=== 4.0.4 (2019-05-13)
|
=== 4.0.4 (2019-05-13)
|
||||||
|
|
||||||
* Update qt/platform.py to support other Unix style OSes (#444)
|
* Update qt/platform.py to support other Unix style OSes (#444)
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ def build_debian_changelog(
|
|||||||
return [s.strip() for s in result if s.strip()]
|
return [s.strip() for s in result if s.strip()]
|
||||||
|
|
||||||
ENTRY_MODEL = (
|
ENTRY_MODEL = (
|
||||||
"{pkg} ({version}-1) {distribution}; urgency=low\n\n{changes}\n "
|
"{pkg} ({version}) {distribution}; urgency=low\n\n{changes}\n "
|
||||||
"-- Virgil Dupras <hsoft@hardcoded.net> {date}\n\n"
|
"-- Virgil Dupras <hsoft@hardcoded.net> {date}\n\n"
|
||||||
)
|
)
|
||||||
CHANGE_MODEL = " * {description}\n"
|
CHANGE_MODEL = " * {description}\n"
|
||||||
@@ -557,7 +557,7 @@ def fix_qt_resource_file(path):
|
|||||||
with open(path, "rb") as fp:
|
with open(path, "rb") as fp:
|
||||||
contents = fp.read()
|
contents = fp.read()
|
||||||
lines = contents.split(b"\n")
|
lines = contents.split(b"\n")
|
||||||
lines = [l for l in lines if not l.startswith(b"#")]
|
lines = [line for line in lines if not line.startswith(b"#")]
|
||||||
with open(path, "wb") as fp:
|
with open(path, "wb") as fp:
|
||||||
fp.write(b"\n".join(lines))
|
fp.write(b"\n".join(lines))
|
||||||
|
|
||||||
|
|||||||
@@ -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 collections import Sequence, MutableSequence
|
from collections.abc import Sequence, MutableSequence
|
||||||
|
|
||||||
from .base import GUIObject
|
from .base import GUIObject
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,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
|
||||||
|
|
||||||
from collections import MutableSequence, namedtuple
|
from collections.abc import MutableSequence
|
||||||
|
from collections import namedtuple
|
||||||
|
|
||||||
from .base import GUIObject
|
from .base import GUIObject
|
||||||
from .selectable_list import Selectable
|
from .selectable_list import Selectable
|
||||||
|
|||||||
@@ -4,7 +4,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 collections import MutableSequence
|
from collections.abc import MutableSequence
|
||||||
|
|
||||||
from .base import GUIObject
|
from .base import GUIObject
|
||||||
|
|
||||||
|
|||||||
@@ -257,6 +257,6 @@ def log_io_error(func):
|
|||||||
msg = 'Error "{0}" during operation "{1}" on "{2}": "{3}"'
|
msg = 'Error "{0}" during operation "{1}" on "{2}": "{3}"'
|
||||||
classname = e.__class__.__name__
|
classname = e.__class__.__name__
|
||||||
funcname = func.__name__
|
funcname = func.__name__
|
||||||
logging.warn(msg.format(classname, funcname, str(path), str(e)))
|
logging.warning(msg.format(classname, funcname, str(path), str(e)))
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|||||||
@@ -6,7 +6,15 @@
|
|||||||
# 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 ..conflict import *
|
import pytest
|
||||||
|
|
||||||
|
from ..conflict import (
|
||||||
|
get_conflicted_name,
|
||||||
|
get_unconflicted_name,
|
||||||
|
is_conflicted,
|
||||||
|
smart_copy,
|
||||||
|
smart_move,
|
||||||
|
)
|
||||||
from ..path import Path
|
from ..path import Path
|
||||||
from ..testutil import eq_
|
from ..testutil import eq_
|
||||||
|
|
||||||
@@ -59,8 +67,9 @@ class TestCase_IsConflicted:
|
|||||||
|
|
||||||
|
|
||||||
class TestCase_move_copy:
|
class TestCase_move_copy:
|
||||||
def pytest_funcarg__do_setup(self, request):
|
@pytest.fixture
|
||||||
tmpdir = request.getfuncargvalue("tmpdir")
|
def do_setup(self, request):
|
||||||
|
tmpdir = request.getfixturevalue("tmpdir")
|
||||||
self.path = Path(str(tmpdir))
|
self.path = Path(str(tmpdir))
|
||||||
self.path["foo"].open("w").close()
|
self.path["foo"].open("w").close()
|
||||||
self.path["bar"].open("w").close()
|
self.path["bar"].open("w").close()
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ class HelloRepeater(Repeater):
|
|||||||
|
|
||||||
def create_pair():
|
def create_pair():
|
||||||
b = Broadcaster()
|
b = Broadcaster()
|
||||||
l = HelloListener(b)
|
listener = HelloListener(b)
|
||||||
return b, l
|
return b, listener
|
||||||
|
|
||||||
|
|
||||||
def test_disconnect_during_notification():
|
def test_disconnect_during_notification():
|
||||||
@@ -60,53 +60,53 @@ def test_disconnect_during_notification():
|
|||||||
|
|
||||||
def test_disconnect():
|
def test_disconnect():
|
||||||
# After a disconnect, the listener doesn't hear anything.
|
# After a disconnect, the listener doesn't hear anything.
|
||||||
b, l = create_pair()
|
b, listener = create_pair()
|
||||||
l.connect()
|
listener.connect()
|
||||||
l.disconnect()
|
listener.disconnect()
|
||||||
b.notify("hello")
|
b.notify("hello")
|
||||||
eq_(l.hello_count, 0)
|
eq_(listener.hello_count, 0)
|
||||||
|
|
||||||
|
|
||||||
def test_disconnect_when_not_connected():
|
def test_disconnect_when_not_connected():
|
||||||
# When disconnecting an already disconnected listener, nothing happens.
|
# When disconnecting an already disconnected listener, nothing happens.
|
||||||
b, l = create_pair()
|
b, listener = create_pair()
|
||||||
l.disconnect()
|
listener.disconnect()
|
||||||
|
|
||||||
|
|
||||||
def test_not_connected_on_init():
|
def test_not_connected_on_init():
|
||||||
# A listener is not initialized connected.
|
# A listener is not initialized connected.
|
||||||
b, l = create_pair()
|
b, listener = create_pair()
|
||||||
b.notify("hello")
|
b.notify("hello")
|
||||||
eq_(l.hello_count, 0)
|
eq_(listener.hello_count, 0)
|
||||||
|
|
||||||
|
|
||||||
def test_notify():
|
def test_notify():
|
||||||
# The listener listens to the broadcaster.
|
# The listener listens to the broadcaster.
|
||||||
b, l = create_pair()
|
b, listener = create_pair()
|
||||||
l.connect()
|
listener.connect()
|
||||||
b.notify("hello")
|
b.notify("hello")
|
||||||
eq_(l.hello_count, 1)
|
eq_(listener.hello_count, 1)
|
||||||
|
|
||||||
|
|
||||||
def test_reconnect():
|
def test_reconnect():
|
||||||
# It's possible to reconnect a listener after disconnection.
|
# It's possible to reconnect a listener after disconnection.
|
||||||
b, l = create_pair()
|
b, listener = create_pair()
|
||||||
l.connect()
|
listener.connect()
|
||||||
l.disconnect()
|
listener.disconnect()
|
||||||
l.connect()
|
listener.connect()
|
||||||
b.notify("hello")
|
b.notify("hello")
|
||||||
eq_(l.hello_count, 1)
|
eq_(listener.hello_count, 1)
|
||||||
|
|
||||||
|
|
||||||
def test_repeater():
|
def test_repeater():
|
||||||
b = Broadcaster()
|
b = Broadcaster()
|
||||||
r = HelloRepeater(b)
|
r = HelloRepeater(b)
|
||||||
l = HelloListener(r)
|
listener = HelloListener(r)
|
||||||
r.connect()
|
r.connect()
|
||||||
l.connect()
|
listener.connect()
|
||||||
b.notify("hello")
|
b.notify("hello")
|
||||||
eq_(r.hello_count, 1)
|
eq_(r.hello_count, 1)
|
||||||
eq_(l.hello_count, 1)
|
eq_(listener.hello_count, 1)
|
||||||
|
|
||||||
|
|
||||||
def test_repeater_with_repeated_notifications():
|
def test_repeater_with_repeated_notifications():
|
||||||
@@ -124,15 +124,15 @@ def test_repeater_with_repeated_notifications():
|
|||||||
|
|
||||||
b = Broadcaster()
|
b = Broadcaster()
|
||||||
r = MyRepeater(b)
|
r = MyRepeater(b)
|
||||||
l = HelloListener(r)
|
listener = HelloListener(r)
|
||||||
r.connect()
|
r.connect()
|
||||||
l.connect()
|
listener.connect()
|
||||||
b.notify("hello")
|
b.notify("hello")
|
||||||
b.notify(
|
b.notify(
|
||||||
"foo"
|
"foo"
|
||||||
) # if the repeater repeated this notif, we'd get a crash on HelloListener
|
) # if the repeater repeated this notif, we'd get a crash on HelloListener
|
||||||
eq_(r.hello_count, 1)
|
eq_(r.hello_count, 1)
|
||||||
eq_(l.hello_count, 1)
|
eq_(listener.hello_count, 1)
|
||||||
eq_(r.foo_count, 1)
|
eq_(r.foo_count, 1)
|
||||||
|
|
||||||
|
|
||||||
@@ -140,18 +140,18 @@ def test_repeater_doesnt_try_to_dispatch_to_self_if_it_cant():
|
|||||||
# if a repeater doesn't handle a particular message, it doesn't crash and simply repeats it.
|
# if a repeater doesn't handle a particular message, it doesn't crash and simply repeats it.
|
||||||
b = Broadcaster()
|
b = Broadcaster()
|
||||||
r = Repeater(b) # doesnt handle hello
|
r = Repeater(b) # doesnt handle hello
|
||||||
l = HelloListener(r)
|
listener = HelloListener(r)
|
||||||
r.connect()
|
r.connect()
|
||||||
l.connect()
|
listener.connect()
|
||||||
b.notify("hello") # no crash
|
b.notify("hello") # no crash
|
||||||
eq_(l.hello_count, 1)
|
eq_(listener.hello_count, 1)
|
||||||
|
|
||||||
|
|
||||||
def test_bind_messages():
|
def test_bind_messages():
|
||||||
b, l = create_pair()
|
b, listener = create_pair()
|
||||||
l.bind_messages({"foo", "bar"}, l.hello)
|
listener.bind_messages({"foo", "bar"}, listener.hello)
|
||||||
l.connect()
|
listener.connect()
|
||||||
b.notify("foo")
|
b.notify("foo")
|
||||||
b.notify("bar")
|
b.notify("bar")
|
||||||
b.notify("hello") # Normal dispatching still work
|
b.notify("hello") # Normal dispatching still work
|
||||||
eq_(l.hello_count, 3)
|
eq_(listener.hello_count, 3)
|
||||||
|
|||||||
@@ -9,14 +9,15 @@
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from pytest import raises, mark
|
import pytest
|
||||||
|
|
||||||
from ..path import Path, pathify
|
from ..path import Path, pathify
|
||||||
from ..testutil import eq_
|
from ..testutil import eq_
|
||||||
|
|
||||||
|
|
||||||
def pytest_funcarg__force_ossep(request):
|
@pytest.fixture
|
||||||
monkeypatch = request.getfuncargvalue("monkeypatch")
|
def force_ossep(request):
|
||||||
|
monkeypatch = request.getfixturevalue("monkeypatch")
|
||||||
monkeypatch.setattr(os, "sep", "/")
|
monkeypatch.setattr(os, "sep", "/")
|
||||||
|
|
||||||
|
|
||||||
@@ -50,7 +51,7 @@ def test_init_with_tuple_and_list(force_ossep):
|
|||||||
|
|
||||||
def test_init_with_invalid_value(force_ossep):
|
def test_init_with_invalid_value(force_ossep):
|
||||||
try:
|
try:
|
||||||
path = Path(42)
|
path = Path(42) # noqa: F841
|
||||||
assert False
|
assert False
|
||||||
except TypeError:
|
except TypeError:
|
||||||
pass
|
pass
|
||||||
@@ -142,8 +143,8 @@ def test_path_slice(force_ossep):
|
|||||||
eq_((), foobar[:foobar])
|
eq_((), foobar[:foobar])
|
||||||
abcd = Path("a/b/c/d")
|
abcd = Path("a/b/c/d")
|
||||||
a = Path("a")
|
a = Path("a")
|
||||||
b = Path("b")
|
b = Path("b") # noqa: #F841
|
||||||
c = Path("c")
|
c = Path("c") # noqa: #F841
|
||||||
d = Path("d")
|
d = Path("d")
|
||||||
z = Path("z")
|
z = Path("z")
|
||||||
eq_("b/c", abcd[a:d])
|
eq_("b/c", abcd[a:d])
|
||||||
@@ -236,12 +237,12 @@ def test_getitem_path(force_ossep):
|
|||||||
eq_(p[Path("baz/bleh")], Path("/foo/bar/baz/bleh"))
|
eq_(p[Path("baz/bleh")], Path("/foo/bar/baz/bleh"))
|
||||||
|
|
||||||
|
|
||||||
@mark.xfail(reason="pytest's capture mechanism is flaky, I have to investigate")
|
@pytest.mark.xfail(reason="pytest's capture mechanism is flaky, I have to investigate")
|
||||||
def test_log_unicode_errors(force_ossep, monkeypatch, capsys):
|
def test_log_unicode_errors(force_ossep, monkeypatch, capsys):
|
||||||
# When an there's a UnicodeDecodeError on path creation, log it so it can be possible
|
# When an there's a UnicodeDecodeError on path creation, log it so it can be possible
|
||||||
# to debug the cause of it.
|
# to debug the cause of it.
|
||||||
monkeypatch.setattr(sys, "getfilesystemencoding", lambda: "ascii")
|
monkeypatch.setattr(sys, "getfilesystemencoding", lambda: "ascii")
|
||||||
with raises(UnicodeDecodeError):
|
with pytest.raises(UnicodeDecodeError):
|
||||||
Path(["", b"foo\xe9"])
|
Path(["", b"foo\xe9"])
|
||||||
out, err = capsys.readouterr()
|
out, err = capsys.readouterr()
|
||||||
assert repr(b"foo\xe9") in err
|
assert repr(b"foo\xe9") in err
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ def test_make_sure_theres_no_messup_between_queries():
|
|||||||
threads.append(t)
|
threads.append(t)
|
||||||
while threads:
|
while threads:
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
threads = [t for t in threads if t.isAlive()]
|
threads = [t for t in threads if t.is_alive()]
|
||||||
|
|
||||||
|
|
||||||
def test_query_after_close():
|
def test_query_after_close():
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ from ..gui.table import Table, GUITable, Row
|
|||||||
|
|
||||||
|
|
||||||
class TestRow(Row):
|
class TestRow(Row):
|
||||||
|
__test__ = False
|
||||||
|
|
||||||
def __init__(self, table, index, is_new=False):
|
def __init__(self, table, index, is_new=False):
|
||||||
Row.__init__(self, table)
|
Row.__init__(self, table)
|
||||||
self.is_new = is_new
|
self.is_new = is_new
|
||||||
@@ -28,6 +30,8 @@ class TestRow(Row):
|
|||||||
|
|
||||||
|
|
||||||
class TestGUITable(GUITable):
|
class TestGUITable(GUITable):
|
||||||
|
__test__ = False
|
||||||
|
|
||||||
def __init__(self, rowcount, viewclass=CallLogger):
|
def __init__(self, rowcount, viewclass=CallLogger):
|
||||||
GUITable.__init__(self)
|
GUITable.__init__(self)
|
||||||
self.view = viewclass()
|
self.view = viewclass()
|
||||||
|
|||||||
@@ -12,7 +12,31 @@ from pytest import raises
|
|||||||
|
|
||||||
from ..testutil import eq_
|
from ..testutil import eq_
|
||||||
from ..path import Path
|
from ..path import Path
|
||||||
from ..util import *
|
from ..util import (
|
||||||
|
nonone,
|
||||||
|
tryint,
|
||||||
|
minmax,
|
||||||
|
first,
|
||||||
|
flatten,
|
||||||
|
dedupe,
|
||||||
|
stripfalse,
|
||||||
|
extract,
|
||||||
|
allsame,
|
||||||
|
trailiter,
|
||||||
|
format_time,
|
||||||
|
format_time_decimal,
|
||||||
|
format_size,
|
||||||
|
remove_invalid_xml,
|
||||||
|
multi_replace,
|
||||||
|
delete_if_empty,
|
||||||
|
open_if_filename,
|
||||||
|
FileOrPath,
|
||||||
|
iterconsume,
|
||||||
|
escape,
|
||||||
|
get_file_ext,
|
||||||
|
rem_file_ext,
|
||||||
|
pluralize,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_nonone():
|
def test_nonone():
|
||||||
@@ -214,42 +238,46 @@ def test_multi_replace():
|
|||||||
|
|
||||||
# --- Files
|
# --- Files
|
||||||
|
|
||||||
|
# These test cases needed https://github.com/hsoft/pytest-monkeyplus/ which appears to not be compatible with latest
|
||||||
|
# pytest, looking at where this is used only appears to be in hscommon.localize_all_stringfiles at top level.
|
||||||
|
# Right now this repo does not seem to utilize any of that functionality so going to leave these tests out for now.
|
||||||
|
# TODO decide if fixing these tests is worth it or not.
|
||||||
|
|
||||||
class TestCase_modified_after:
|
# class TestCase_modified_after:
|
||||||
def test_first_is_modified_after(self, monkeyplus):
|
# def test_first_is_modified_after(self, monkeyplus):
|
||||||
monkeyplus.patch_osstat("first", st_mtime=42)
|
# monkeyplus.patch_osstat("first", st_mtime=42)
|
||||||
monkeyplus.patch_osstat("second", st_mtime=41)
|
# monkeyplus.patch_osstat("second", st_mtime=41)
|
||||||
assert modified_after("first", "second")
|
# assert modified_after("first", "second")
|
||||||
|
|
||||||
def test_second_is_modified_after(self, monkeyplus):
|
# def test_second_is_modified_after(self, monkeyplus):
|
||||||
monkeyplus.patch_osstat("first", st_mtime=42)
|
# monkeyplus.patch_osstat("first", st_mtime=42)
|
||||||
monkeyplus.patch_osstat("second", st_mtime=43)
|
# monkeyplus.patch_osstat("second", st_mtime=43)
|
||||||
assert not modified_after("first", "second")
|
# assert not modified_after("first", "second")
|
||||||
|
|
||||||
def test_same_mtime(self, monkeyplus):
|
# def test_same_mtime(self, monkeyplus):
|
||||||
monkeyplus.patch_osstat("first", st_mtime=42)
|
# monkeyplus.patch_osstat("first", st_mtime=42)
|
||||||
monkeyplus.patch_osstat("second", st_mtime=42)
|
# monkeyplus.patch_osstat("second", st_mtime=42)
|
||||||
assert not modified_after("first", "second")
|
# assert not modified_after("first", "second")
|
||||||
|
|
||||||
def test_first_file_does_not_exist(self, monkeyplus):
|
# def test_first_file_does_not_exist(self, monkeyplus):
|
||||||
# when the first file doesn't exist, we return False
|
# # when the first file doesn't exist, we return False
|
||||||
monkeyplus.patch_osstat("second", st_mtime=42)
|
# monkeyplus.patch_osstat("second", st_mtime=42)
|
||||||
assert not modified_after("does_not_exist", "second") # no crash
|
# assert not modified_after("does_not_exist", "second") # no crash
|
||||||
|
|
||||||
def test_second_file_does_not_exist(self, monkeyplus):
|
# def test_second_file_does_not_exist(self, monkeyplus):
|
||||||
# when the second file doesn't exist, we return True
|
# # when the second file doesn't exist, we return True
|
||||||
monkeyplus.patch_osstat("first", st_mtime=42)
|
# monkeyplus.patch_osstat("first", st_mtime=42)
|
||||||
assert modified_after("first", "does_not_exist") # no crash
|
# assert modified_after("first", "does_not_exist") # no crash
|
||||||
|
|
||||||
def test_first_file_is_none(self, monkeyplus):
|
# def test_first_file_is_none(self, monkeyplus):
|
||||||
# when the first file is None, we return False
|
# # when the first file is None, we return False
|
||||||
monkeyplus.patch_osstat("second", st_mtime=42)
|
# monkeyplus.patch_osstat("second", st_mtime=42)
|
||||||
assert not modified_after(None, "second") # no crash
|
# assert not modified_after(None, "second") # no crash
|
||||||
|
|
||||||
def test_second_file_is_none(self, monkeyplus):
|
# def test_second_file_is_none(self, monkeyplus):
|
||||||
# when the second file is None, we return True
|
# # when the second file is None, we return True
|
||||||
monkeyplus.patch_osstat("first", st_mtime=42)
|
# monkeyplus.patch_osstat("first", st_mtime=42)
|
||||||
assert modified_after("first", None) # no crash
|
# assert modified_after("first", None) # no crash
|
||||||
|
|
||||||
|
|
||||||
class TestCase_delete_if_empty:
|
class TestCase_delete_if_empty:
|
||||||
|
|||||||
@@ -6,6 +6,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 pytest
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
import py.path
|
import py.path
|
||||||
|
|
||||||
@@ -148,7 +150,7 @@ class TestApp:
|
|||||||
return gui
|
return gui
|
||||||
|
|
||||||
|
|
||||||
# To use @with_app, you have to import pytest_funcarg__app in your conftest.py file.
|
# To use @with_app, you have to import app in your conftest.py file.
|
||||||
def with_app(setupfunc):
|
def with_app(setupfunc):
|
||||||
def decorator(func):
|
def decorator(func):
|
||||||
func.setupfunc = setupfunc
|
func.setupfunc = setupfunc
|
||||||
@@ -157,7 +159,8 @@ def with_app(setupfunc):
|
|||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
def pytest_funcarg__app(request):
|
@pytest.fixture
|
||||||
|
def app(request):
|
||||||
setupfunc = request.function.setupfunc
|
setupfunc = request.function.setupfunc
|
||||||
if hasattr(setupfunc, "__code__"):
|
if hasattr(setupfunc, "__code__"):
|
||||||
argnames = setupfunc.__code__.co_varnames[: setupfunc.__code__.co_argcount]
|
argnames = setupfunc.__code__.co_varnames[: setupfunc.__code__.co_argcount]
|
||||||
|
|||||||
BIN
images/dialog-error.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
images/dupeguru.icns
Executable file
BIN
images/exchange.icns
Normal file
BIN
images/exchange.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
BIN
images/exchange.png
Normal file
|
After Width: | Height: | Size: 797 B |
BIN
images/exchange_purple.png
Normal file
|
After Width: | Height: | Size: 685 B |
BIN
images/exchange_purple_upscaled.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
images/exchange_purple_waifu_s4_tta8.png
Normal file
|
After Width: | Height: | Size: 7.0 KiB |
BIN
images/exchange_purple_waifu_s4_tta8.xcf
Normal file
BIN
images/exchange_waifu_s4_tta8.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
images/old_zoom_best_fit.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
images/old_zoom_in.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
images/old_zoom_original.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
images/old_zoom_out.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
53
macos.md
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
## How to build dupeGuru for macos
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- [Python 3.6+][python]
|
||||||
|
- [Xcode 12.3][xcode] or just Xcode command line tools (older versions can be used if not interested in arm macs)
|
||||||
|
- [Homebrew][homebrew]
|
||||||
|
- [qt5](https://www.qt.io/)
|
||||||
|
|
||||||
|
#### Prerequisite setup
|
||||||
|
1. Install Xcode if desired
|
||||||
|
2. Install [Homebrew][homebrew], if not on the path after install (arm based Macs) create `~/.zshrc`
|
||||||
|
with `export PATH="/opt/homebrew/bin:$PATH"`. Will need to reload terminal or source the file to take
|
||||||
|
affect.
|
||||||
|
3. Install qt5 with `brew`. If you are using a version of macos without system python 3.6+ then you will
|
||||||
|
also need to install that via brew or with pyenv.
|
||||||
|
|
||||||
|
$ brew install qt5
|
||||||
|
|
||||||
|
NOTE: Using `brew` to install qt5 is to allow pyqt5 to build without a native wheel
|
||||||
|
available. If you are using an intel based mac you can probably skip this step.
|
||||||
|
|
||||||
|
4. May need to launch a new terminal to have everything working.
|
||||||
|
|
||||||
|
### With build.py
|
||||||
|
OSX comes with a version of python 3 by default in newer versions of OSX. To produce universal
|
||||||
|
builds either the 3.8 version shipped in macos or 3.9.1 or newer needs to be used. If needing to
|
||||||
|
build pyqt5 from source then the first line below is needed, else it may be omitted. (Path shown is
|
||||||
|
for an arm mac.)
|
||||||
|
|
||||||
|
$ export PATH="/opt/homebrew/opt/qt/bin:$PATH"
|
||||||
|
$ cd <dupeGuru directory>
|
||||||
|
$ python3 -m venv ./env
|
||||||
|
$ source ./env/bin/activate
|
||||||
|
$ pip install -r requirements.txt
|
||||||
|
$ python build.py
|
||||||
|
$ python run.py
|
||||||
|
|
||||||
|
### Generate OSX Packages
|
||||||
|
The extra requirements need to be installed to run packaging: `pip install -r requirements-extra.txt`.
|
||||||
|
Run the following in the respective virtual environment.
|
||||||
|
|
||||||
|
$ python package.py
|
||||||
|
|
||||||
|
This will produce a dupeGuru.app in the dist folder.
|
||||||
|
|
||||||
|
### Running tests
|
||||||
|
The complete test suite can be run with tox just like on linux. NOTE: The extra requirements need to
|
||||||
|
be installed to run unit tests: `pip install -r requirements-extra.txt`.
|
||||||
|
|
||||||
|
[python]: http://www.python.org/
|
||||||
|
[homebrew]: https://brew.sh/
|
||||||
|
[xcode]: https://developer.apple.com/xcode/
|
||||||
47
package.py
@@ -12,6 +12,7 @@ import shutil
|
|||||||
import json
|
import json
|
||||||
from argparse import ArgumentParser
|
from argparse import ArgumentParser
|
||||||
import platform
|
import platform
|
||||||
|
import distro
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from hscommon.build import (
|
from hscommon.build import (
|
||||||
@@ -42,6 +43,15 @@ def copy_files_to_package(destpath, packages, with_so):
|
|||||||
shutil.copy("run.py", op.join(destpath, "run.py"))
|
shutil.copy("run.py", op.join(destpath, "run.py"))
|
||||||
extra_ignores = ["*.so"] if not with_so else None
|
extra_ignores = ["*.so"] if not with_so else None
|
||||||
copy_packages(packages, destpath, extra_ignores=extra_ignores)
|
copy_packages(packages, destpath, extra_ignores=extra_ignores)
|
||||||
|
# include locale files if they are built otherwise exit as it will break
|
||||||
|
# the localization
|
||||||
|
if not op.exists("build/locale"):
|
||||||
|
print('Locale files are missing. Have you run "build.py --loc"? Exiting...')
|
||||||
|
return
|
||||||
|
# include help files if they are built otherwise exit as they should be included?
|
||||||
|
if not op.exists("build/help"):
|
||||||
|
print('Help files are missing. Have you run "build.py --doc"? Exiting...')
|
||||||
|
return
|
||||||
shutil.copytree(op.join("build", "help"), op.join(destpath, "help"))
|
shutil.copytree(op.join("build", "help"), op.join(destpath, "help"))
|
||||||
shutil.copytree(op.join("build", "locale"), op.join(destpath, "locale"))
|
shutil.copytree(op.join("build", "locale"), op.join(destpath, "locale"))
|
||||||
compileall.compile_dir(destpath)
|
compileall.compile_dir(destpath)
|
||||||
@@ -90,7 +100,7 @@ def package_debian_distribution(distribution):
|
|||||||
)
|
)
|
||||||
shutil.copy(op.join("images", "dgse_logo_128.png"), srcpath)
|
shutil.copy(op.join("images", "dgse_logo_128.png"), srcpath)
|
||||||
os.chdir(destpath)
|
os.chdir(destpath)
|
||||||
cmd = "dpkg-buildpackage -S -us -uc"
|
cmd = "dpkg-buildpackage -F -us -uc"
|
||||||
os.system(cmd)
|
os.system(cmd)
|
||||||
os.chdir("../..")
|
os.chdir("../..")
|
||||||
|
|
||||||
@@ -151,11 +161,11 @@ def package_windows():
|
|||||||
# include locale files if they are built otherwise exit as it will break
|
# include locale files if they are built otherwise exit as it will break
|
||||||
# the localization
|
# the localization
|
||||||
if not op.exists("build/locale"):
|
if not op.exists("build/locale"):
|
||||||
print("Locale files not built, exiting...")
|
print('Locale files are missing. Have you run "build.py --loc"? Exiting...')
|
||||||
return
|
return
|
||||||
# include help files if they are built otherwise exit as they should be included?
|
# include help files if they are built otherwise exit as they should be included?
|
||||||
if not op.exists("build/help"):
|
if not op.exists("build/help"):
|
||||||
print("Help files not built, exiting...")
|
print('Help files are missing. Have you run "build.py --doc"? Exiting...')
|
||||||
return
|
return
|
||||||
# create version information file from template
|
# create version information file from template
|
||||||
try:
|
try:
|
||||||
@@ -201,6 +211,33 @@ def package_windows():
|
|||||||
print_and_do(cmd.format(version_array[0], version_array[1], version_array[2], bits))
|
print_and_do(cmd.format(version_array[0], version_array[1], version_array[2], bits))
|
||||||
|
|
||||||
|
|
||||||
|
def package_macos():
|
||||||
|
# include locale files if they are built otherwise exit as it will break
|
||||||
|
# the localization
|
||||||
|
if not op.exists("build/locale"):
|
||||||
|
print('Locale files are missing. Have you run "build.py --loc"? Exiting...')
|
||||||
|
return
|
||||||
|
# include help files if they are built otherwise exit as they should be included?
|
||||||
|
if not op.exists("build/help"):
|
||||||
|
print('Help files are missing. Have you run "build.py --doc"? Exiting...')
|
||||||
|
return
|
||||||
|
# run pyinstaller from here:
|
||||||
|
import PyInstaller.__main__
|
||||||
|
|
||||||
|
PyInstaller.__main__.run(
|
||||||
|
[
|
||||||
|
"--name=dupeguru",
|
||||||
|
"--windowed",
|
||||||
|
"--noconfirm",
|
||||||
|
"--icon=images/dupeguru.icns",
|
||||||
|
"--osx-bundle-identifier=com.hardcoded-software.dupeguru",
|
||||||
|
"--add-data=build/locale:locale",
|
||||||
|
"--add-data=build/help:help",
|
||||||
|
"run.py",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
if args.src_pkg:
|
if args.src_pkg:
|
||||||
@@ -210,9 +247,11 @@ def main():
|
|||||||
print("Packaging dupeGuru with UI qt")
|
print("Packaging dupeGuru with UI qt")
|
||||||
if sys.platform == "win32":
|
if sys.platform == "win32":
|
||||||
package_windows()
|
package_windows()
|
||||||
|
elif sys.platform == "darwin":
|
||||||
|
package_macos()
|
||||||
else:
|
else:
|
||||||
if not args.arch_pkg:
|
if not args.arch_pkg:
|
||||||
distname, _, _ = platform.dist()
|
distname = distro.id()
|
||||||
else:
|
else:
|
||||||
distname = "arch"
|
distname = "arch"
|
||||||
if distname == "arch":
|
if distname == "arch":
|
||||||
|
|||||||
@@ -3,5 +3,5 @@
|
|||||||
"longname": "dupeGuru",
|
"longname": "dupeGuru",
|
||||||
"execname": "dupeguru",
|
"execname": "dupeguru",
|
||||||
"arch": "any",
|
"arch": "any",
|
||||||
"iconpath": "/usr/share/dupeguru/dgse_logo_128.png"
|
"iconpath": "dupeguru"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,4 +8,6 @@ all:
|
|||||||
chmod +x src/run.py
|
chmod +x src/run.py
|
||||||
cp -R src/ "$(CURDIR)/debian/{pkgname}/usr/share/{execname}"
|
cp -R src/ "$(CURDIR)/debian/{pkgname}/usr/share/{execname}"
|
||||||
cp "$(CURDIR)/debian/{execname}.desktop" "$(CURDIR)/debian/{pkgname}/usr/share/applications"
|
cp "$(CURDIR)/debian/{execname}.desktop" "$(CURDIR)/debian/{pkgname}/usr/share/applications"
|
||||||
|
mkdir -p "$(CURDIR)/debian/{pkgname}/usr/share/pixmaps"
|
||||||
|
ln -s "/usr/share/{execname}/dgse_logo_128.png" "$(CURDIR)/debian/{pkgname}/usr/share/pixmaps/{execname}.png"
|
||||||
ln -s "/usr/share/{execname}/run.py" "$(CURDIR)/debian/{pkgname}/usr/bin/{execname}"
|
ln -s "/usr/share/{execname}/run.py" "$(CURDIR)/debian/{pkgname}/usr/bin/{execname}"
|
||||||
|
|||||||
351
pkg/debian/changelog
Normal file
@@ -0,0 +1,351 @@
|
|||||||
|
dupeguru (4.0.4-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Update qt/platform.py to support other Unix style OSes (#444)
|
||||||
|
* Fix font size scaling issue in properties dialog [qt] (#504)
|
||||||
|
* Updates to support Python 3.7
|
||||||
|
* Fix issue with result window appearing partially off-screen [qt] (#521)
|
||||||
|
* Fix translation error for Simplified Chinese
|
||||||
|
* Updates to language files for German (#479)
|
||||||
|
* Fix error with multiple close calls to the progress window [qt] (#460, #449)
|
||||||
|
* Add Travis CI Builds
|
||||||
|
* Un-recurse methods get_files() and get_state() to improve stability (#421)
|
||||||
|
* Updates to language files for Italian (#445, #446, #447, #448)
|
||||||
|
* Fix issue with cache_shelve (#402, #439)
|
||||||
|
* Updated Windows packaging and builds (#438, #456, #461, #491, #474, #490, #565)
|
||||||
|
* Handle OS termination signals (#425)
|
||||||
|
* Make documentation installation optional
|
||||||
|
* Move cocoa UI to dupeguru-cocoa [cocoa]
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Mon, 13 May 2019 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (4.0.3-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Add new picture cache backend: shelve
|
||||||
|
* Make shelve picture cache backend the active one on MacOS to fix #394 more elegantly. [cocoa]
|
||||||
|
* Remove Sparkle (auto-updates) due to technical limitations. [cocoa]
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Thu, 24 Nov 2016 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (4.0.2-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Fix systematic crash in Picture Mode under MacOS Sierra. (#394)
|
||||||
|
* No change for Linux. Just keeping version in sync.
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Sun, 09 Oct 2016 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (4.0.1-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Add Greek localization, by Gabriel Koutilellis. (#382)
|
||||||
|
* Fix localization base path. [qt] (#378)
|
||||||
|
* Fix broken load results dialog. [qt]
|
||||||
|
* Fix crash on load results. [cocoa] (#380)
|
||||||
|
* Save preferences more predictably. [qt] (#379)
|
||||||
|
* Fix picture mode's fuzzy block scanner threshold. (#387)
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Wed, 24 Aug 2016 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (4.0.0-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Merge Standard, Music and Picture editions in the same application!
|
||||||
|
* Improve documentation. (#294)
|
||||||
|
* Add Polish, Korean, Spanish and Dutch localizations.
|
||||||
|
* qt: Fix wrong use_regexp option propagation to core. (#295)
|
||||||
|
* qt: Fix progress window mistakenly showing up on startup. (#357)
|
||||||
|
* Bump Python requirement to v3.4.
|
||||||
|
* Bump OS X requirement to 10.8
|
||||||
|
* Drop Windows support, maybe temporarily. `Details <https://www.hardcoded.net/archive2015#2015-11-01>`_
|
||||||
|
* cocoa: Drop iPhoto, Aperture and iTunes support. Was unmaintained and obsolete.
|
||||||
|
* Drop "Audio Contents" scan type. Was confusing and seldom useful.
|
||||||
|
* Change license to GPLv3
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Fri, 01 Jul 2016 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (3.9.1-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Fixed ``AttributeError: 'ComboboxModel' object has no attribute 'reset'``. [Linux, Windows] (#254)
|
||||||
|
* Fixed ``PermissionError`` on saving results. (#266)
|
||||||
|
* Fixed a build problem introduced by Sphinx 1.2.3.
|
||||||
|
* Updated German localisation, by Frank Weber.
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Fri, 17 Oct 2014 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (3.9.0-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* This is mostly a dependencies upgrade.
|
||||||
|
* Upgraded to Python 3.3.
|
||||||
|
* Upgraded to Qt 5.
|
||||||
|
* Minimum Windows version is now Windows 7 64bit.
|
||||||
|
* Minimum Ubuntu version is now 14.04.
|
||||||
|
* Minimum OS X version is now 10.7 (Lion).
|
||||||
|
* ... But with a couple of little improvements.
|
||||||
|
* Improved documentation.
|
||||||
|
* Overwrite subfolders' state when setting states in folder dialog (#248)
|
||||||
|
* The error report dialog now brings the user to Github issues.
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Sat, 19 Apr 2014 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (3.8.0-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Disable symlink/hardlink deletion option when not relevant. (#247)
|
||||||
|
* Make Cmd+A select all folders in the Folder Selection dialog. [Mac] (#228)
|
||||||
|
* Make non-numeric delta comparison case insensitive. (#239)
|
||||||
|
* Fix surrogate-related UnicodeEncodeError on CSV export. (#210)
|
||||||
|
* Fixed crash on Dupe Count sorting with Delta + Dupes Only. (#238)
|
||||||
|
* Improved documentation.
|
||||||
|
* Important internal refactorings.
|
||||||
|
* Dropped Ubuntu 12.04 and 12.10 support.
|
||||||
|
* Removed the fairware dialog ([More Info](http://www.hardcoded.net/articles/phasing-out-fairware)).
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Sat, 07 Dec 2013 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (3.7.1-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Fixed folder scan type, which was broken in v3.7.0.
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Mon, 19 Aug 2013 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (3.7.0-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Improved delta values to support non-numerical values. (#213)
|
||||||
|
* Improved the Re-Prioritize dialog's UI. (#224)
|
||||||
|
* Added hardlink/symlink support on Windows Vista+. (#220)
|
||||||
|
* Dropped 32bit support on Mac OS X.
|
||||||
|
* Added Vietnamese localization by Phan Anh.
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Sat, 17 Aug 2013 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (3.6.1-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Improved "Make Selection Reference" to make it clearer. (#222)
|
||||||
|
* Improved "Open Selected" to allow opening more than one file at once. (#142)
|
||||||
|
* Fixed a few typos here and there. (#216 #225)
|
||||||
|
* Tweaked the fairware dialog ([More Info](http://www.hardcoded.net/articles/phasing-out-fairware)).
|
||||||
|
* Added Arch Linux packaging
|
||||||
|
* Added a 64-bit build for Windows.
|
||||||
|
* Improved Russian localization by Kyrill Detinov.
|
||||||
|
* Improved Brazilian localization by Victor Figueiredo.
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Sun, 28 Apr 2013 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (3.6.0-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Added "Export to CSV". (#189)
|
||||||
|
* Added "Replace with symlinks" to complement "Replace with hardlinks". [Mac, Linux] (#194)
|
||||||
|
* dupeGuru now tells how many duplicates were affected after each re-prioritization operation. (#204)
|
||||||
|
* Added Longest/Shortest filename criteria in the re-prioritize dialog. (#198)
|
||||||
|
* Fixed result table cells which mistakenly became writable in v3.5.0. [Mac] (#203)
|
||||||
|
* Fixed "Rename Selected" which was broken since v3.5.0. [Mac] (#202)
|
||||||
|
* Fixed a bug where "Reset to Defaults" in the Columns menu wouldn't refresh menu items' marked state.
|
||||||
|
* Added Brazilian localization by Victor Figueiredo.
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Wed, 08 Aug 2012 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (3.5.0-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Added a Deletion Options panel.
|
||||||
|
* Greatly improved memory usage for big scans.
|
||||||
|
* Added a keybinding for the filter field. (#182) [Mac]
|
||||||
|
* Upgraded minimum requirements for Ubuntu to 12.04.
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Fri, 01 Jun 2012 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (3.4.1-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Fixed the "Folders" scan type. [Mac]
|
||||||
|
* Fixed localization issues. [Windows, Linux]
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Sat, 14 Apr 2012 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (3.4.0-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Improved results window UI. [Windows, Linux]
|
||||||
|
* Added a dialog to edit the Ignore List.
|
||||||
|
* Added the ability to sort results by "marked" status.
|
||||||
|
* Fixed "Open with default application". (#190)
|
||||||
|
* Fixed a bug where there would be a false reporting of discarded matches. (#195)
|
||||||
|
* Fixed various localization glitches.
|
||||||
|
* Fixed hard crashes on crash reporting. (#196)
|
||||||
|
* Fixed bug where the details panel would show up at inconvenient places in the screen. [Windows, Linux]
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Thu, 29 Mar 2012 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (3.3.3-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Fixed crash on adding some folders. [Mac OS X]
|
||||||
|
* Added Ukrainian localization by Yuri Petrashko.
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Wed, 01 Feb 2012 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (3.3.2-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Fixed random hard crashes (yeah, again). [Mac OS X]
|
||||||
|
* Fixed crash on Export to HTML. [Windows, Linux]
|
||||||
|
* Added Armenian localization by Hrant Ohanyan.
|
||||||
|
* Added Russian localization by Igor Pavlov.
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Mon, 16 Jan 2012 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (3.3.1-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Fixed a couple of nasty crashes.
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Fri, 02 Dec 2011 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (3.3.0-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Added multiple-selection in folder selection dialog for a more efficient folder removal. (#179)
|
||||||
|
* Fixed a crash in the prioritize dialog. (#178)
|
||||||
|
* Fixed a bug where mass marking with a filter would mark more than filtered duplicates. (#181)
|
||||||
|
* Fixed random hard crashes. [Mac OS X] (#183 #184)
|
||||||
|
* Added Czech localization by Aleš Nehyba.
|
||||||
|
* Added Italian localization by Paolo Rossi.
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Wed, 30 Nov 2011 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (3.2.1-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Fixed a couple of broken action bindings from v3.2.0.
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Sun, 02 Oct 2011 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (3.2.0-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Added duplicate re-prioritization dialog. (#138)
|
||||||
|
* Added font size preference for duplicate table. (#82)
|
||||||
|
* Added Quicklook support. [Mac OS X] (#21)
|
||||||
|
* Improved behavior of Mark Selected. (#139)
|
||||||
|
* Improved filename sorting. (#169)
|
||||||
|
* Added Chinese (Simplified) localization by Eric Dee.
|
||||||
|
* Tweaked the fairware system.
|
||||||
|
* Upgraded minimum requirements to OS X 10.6 and Ubuntu 11.04.
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Tue, 27 Sep 2011 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (3.1.2-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Fixed a bug preventing the Folders scan from working. (#172)
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Thu, 25 Aug 2011 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (3.1.1-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Added German localization by Gregor Tätzner.
|
||||||
|
* Improved OS X Lion compatibility. [Mac OS X]
|
||||||
|
* Made the file collection phase cancellable. (#168)
|
||||||
|
* Fixed glitch in folder window upon selecting a folder state. [Windows, Linux] (#165)
|
||||||
|
* Fixed a text coloring glitch in the results. (#156)
|
||||||
|
* Fixed glitch in the sorting feature of the Folder column. (#161)
|
||||||
|
* Make sure that saved results have the ".dupeguru" extension. [Linux] (#157)
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Wed, 24 Aug 2011 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (3.1.0-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Added the "Folders" scan type. (#89)
|
||||||
|
* Fixed a couple of crashes. (#140 #149)
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Sat, 16 Apr 2011 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (3.0.2-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Fixed crash after removing marked dupes. (#140)
|
||||||
|
* Fixed crash on error handling. [Windows] (#144)
|
||||||
|
* Fixed crash on copy/move. [Windows] (#148)
|
||||||
|
* Fixed crash when launching dupeGuru from a very long folder name. [Mac OS X] (#119)
|
||||||
|
* Fixed a refresh bug in directory panel. (#153)
|
||||||
|
* Improved reliability of the "Send to Trash" operation. [Linux]
|
||||||
|
* Tweaked Fairware reminders.
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Wed, 16 Mar 2011 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (3.0.1-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Restored the context menu which had been broken in 3.0.0. [Mac OS X] (#133)
|
||||||
|
* Fixed a bug where an "unsaved results" warning would be issued on quit even with empty results. (#134)
|
||||||
|
* Removed focus from the cancel button in the progress dialog to avoid accidental cancellations. [Mac OS X] (#135)
|
||||||
|
* Folders added through drag and drop are added to the recent folders list. (#136)
|
||||||
|
* Added a debugging mode. (#132)
|
||||||
|
* Fixed french localization glitches.
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Thu, 27 Jan 2011 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (3.0.0-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Re-designed the UI. (#129)
|
||||||
|
* Internationalized dupeGuru and localized it to french. (#32)
|
||||||
|
* Changed the format of the help file. (#130)
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Mon, 24 Jan 2011 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (2.12.3-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Fixed bug causing results to be corrupted after a scan cancellation. (#120)
|
||||||
|
* Fixed crash when fetching Fairware unpaid hours. (#121)
|
||||||
|
* Fixed crash when replacing files with hardlinks. (#122)
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Sat, 01 Jan 2011 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (2.12.2-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Fixed delta column colors which were broken since 2.12.0.
|
||||||
|
* Fixed column sorting crash. (#108)
|
||||||
|
* Fixed occasional crash during scan. (#106)
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Tue, 05 Oct 2010 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (2.12.1-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Re-licensed dupeGuru to BSD and made it [Fairware](http://open.hardcoded.net/about/).
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Thu, 30 Sep 2010 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (2.12.0-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Improved UI with a little revamp.
|
||||||
|
* Added the possibility to place hardlinks to references after having deleted duplicates. [Mac OS X, Linux] (#91)
|
||||||
|
* Added an option to ignore duplicates hardlinking to the same file. [Mac OS X, Linux] (#92)
|
||||||
|
* Added multiple selection in the "Add Directory" dialog. [Mac OS X] (#105)
|
||||||
|
* Fixed a bug preventing drag & drop from working in the Directories panel. [Windows, Linux]
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Sun, 26 Sep 2010 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (2.11.1-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Fixed HTML exporting which was broken in 2.11.0.
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Thu, 26 Aug 2010 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (2.11.0-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Added the ability to save results (and reload them) at arbitrary locations.
|
||||||
|
* Improved the way reference files in dupe groups are chosen. (#15)
|
||||||
|
* Remember size/position of all windows between launches. (#102)
|
||||||
|
* Fixed a bug sometimes preventing dupeGuru from reloading previous results.
|
||||||
|
* Fixed a bug sometimes causing the progress dialog to be stuck there. [Mac OS X] (#103)
|
||||||
|
* Removed the Creation Date column, which wasn't displaying the correct value anyway. (#101)
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Wed, 18 Aug 2010 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (2.10.1-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Fixed a couple of crashes. (#95, #97, #100)
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Thu, 15 Jul 2010 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (2.10.0-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* Improved error messages when files can't be sent to trash, moved or copied.
|
||||||
|
* Added a custom command invocation action. (#12)
|
||||||
|
* Filters are now applied on whole paths. (#4)
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Tue, 13 Apr 2010 00:00:00 +0000
|
||||||
|
|
||||||
|
dupeguru (2.9.2-1) unstable; urgency=low
|
||||||
|
|
||||||
|
* dupeGuru is now 64-bit on Mac OS X!
|
||||||
|
* Fixed a crash upon quitting when support folder is not present. (#83)
|
||||||
|
* Fixed a crash during sorting. (#85)
|
||||||
|
* Fixed selection glitches, especially while renaming. (#93)
|
||||||
|
|
||||||
|
-- Virgil Dupras <hsoft@hardcoded.net> Wed, 10 Feb 2010 00:00:00 +0000
|
||||||
|
|
||||||
@@ -3,5 +3,5 @@
|
|||||||
"longname": "dupeGuru",
|
"longname": "dupeGuru",
|
||||||
"execname": "dupeguru",
|
"execname": "dupeguru",
|
||||||
"arch": "any",
|
"arch": "any",
|
||||||
"iconpath": "/usr/share/dupeguru/dgse_logo_128.png"
|
"iconpath": "dupeguru"
|
||||||
}
|
}
|
||||||
|
|||||||
159
qt/app.py
@@ -7,7 +7,7 @@
|
|||||||
import sys
|
import sys
|
||||||
import os.path as op
|
import os.path as op
|
||||||
|
|
||||||
from PyQt5.QtCore import QTimer, QObject, QUrl, pyqtSignal
|
from PyQt5.QtCore import QTimer, QObject, QUrl, pyqtSignal, Qt
|
||||||
from PyQt5.QtGui import QDesktopServices
|
from PyQt5.QtGui import QDesktopServices
|
||||||
from PyQt5.QtWidgets import QApplication, QFileDialog, QDialog, QMessageBox
|
from PyQt5.QtWidgets import QApplication, QFileDialog, QDialog, QMessageBox
|
||||||
|
|
||||||
@@ -27,6 +27,7 @@ from .result_window import ResultWindow
|
|||||||
from .directories_dialog import DirectoriesDialog
|
from .directories_dialog import DirectoriesDialog
|
||||||
from .problem_dialog import ProblemDialog
|
from .problem_dialog import ProblemDialog
|
||||||
from .ignore_list_dialog import IgnoreListDialog
|
from .ignore_list_dialog import IgnoreListDialog
|
||||||
|
from .exclude_list_dialog import ExcludeListDialog
|
||||||
from .deletion_options import DeletionOptions
|
from .deletion_options import DeletionOptions
|
||||||
from .se.details_dialog import DetailsDialog as DetailsDialogStandard
|
from .se.details_dialog import DetailsDialog as DetailsDialogStandard
|
||||||
from .me.details_dialog import DetailsDialog as DetailsDialogMusic
|
from .me.details_dialog import DetailsDialog as DetailsDialogMusic
|
||||||
@@ -35,6 +36,7 @@ from .se.preferences_dialog import PreferencesDialog as PreferencesDialogStandar
|
|||||||
from .me.preferences_dialog import PreferencesDialog as PreferencesDialogMusic
|
from .me.preferences_dialog import PreferencesDialog as PreferencesDialogMusic
|
||||||
from .pe.preferences_dialog import PreferencesDialog as PreferencesDialogPicture
|
from .pe.preferences_dialog import PreferencesDialog as PreferencesDialogPicture
|
||||||
from .pe.photo import File as PlatSpecificPhoto
|
from .pe.photo import File as PlatSpecificPhoto
|
||||||
|
from .tabbed_window import TabBarWindow, TabWindow
|
||||||
|
|
||||||
tr = trget("ui")
|
tr = trget("ui")
|
||||||
|
|
||||||
@@ -47,6 +49,9 @@ class DupeGuru(QObject):
|
|||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.prefs = Preferences()
|
self.prefs = Preferences()
|
||||||
self.prefs.load()
|
self.prefs.load()
|
||||||
|
# Enable tabs instead of separate floating windows for each dialog
|
||||||
|
# Could be passed as an argument to this class if we wanted
|
||||||
|
self.use_tabs = True
|
||||||
self.model = DupeGuruModel(view=self)
|
self.model = DupeGuruModel(view=self)
|
||||||
self._setup()
|
self._setup()
|
||||||
|
|
||||||
@@ -54,27 +59,61 @@ class DupeGuru(QObject):
|
|||||||
def _setup(self):
|
def _setup(self):
|
||||||
core.pe.photo.PLAT_SPECIFIC_PHOTO_CLASS = PlatSpecificPhoto
|
core.pe.photo.PLAT_SPECIFIC_PHOTO_CLASS = PlatSpecificPhoto
|
||||||
self._setupActions()
|
self._setupActions()
|
||||||
|
self.details_dialog = None
|
||||||
self._update_options()
|
self._update_options()
|
||||||
self.recentResults = Recent(self, "recentResults")
|
self.recentResults = Recent(self, "recentResults")
|
||||||
self.recentResults.mustOpenItem.connect(self.model.load_from)
|
self.recentResults.mustOpenItem.connect(self.model.load_from)
|
||||||
self.resultWindow = None
|
self.resultWindow = None
|
||||||
self.details_dialog = None
|
if self.use_tabs:
|
||||||
|
self.main_window = (
|
||||||
|
TabBarWindow(self)
|
||||||
|
if not self.prefs.tabs_default_pos
|
||||||
|
else TabWindow(self)
|
||||||
|
)
|
||||||
|
parent_window = self.main_window
|
||||||
|
self.directories_dialog = self.main_window.createPage(
|
||||||
|
"DirectoriesDialog", app=self
|
||||||
|
)
|
||||||
|
self.main_window.addTab(
|
||||||
|
self.directories_dialog, "Directories", switch=False
|
||||||
|
)
|
||||||
|
self.actionDirectoriesWindow.setEnabled(False)
|
||||||
|
else: # floating windows only
|
||||||
|
self.main_window = None
|
||||||
self.directories_dialog = DirectoriesDialog(self)
|
self.directories_dialog = DirectoriesDialog(self)
|
||||||
self.progress_window = ProgressWindow(
|
parent_window = self.directories_dialog
|
||||||
self.directories_dialog, self.model.progress_window
|
|
||||||
)
|
|
||||||
self.problemDialog = ProblemDialog(
|
|
||||||
parent=self.directories_dialog, model=self.model.problem_dialog
|
|
||||||
)
|
|
||||||
self.ignoreListDialog = IgnoreListDialog(
|
|
||||||
parent=self.directories_dialog, model=self.model.ignore_list_dialog
|
|
||||||
)
|
|
||||||
self.deletionOptions = DeletionOptions(
|
|
||||||
parent=self.directories_dialog, model=self.model.deletion_options
|
|
||||||
)
|
|
||||||
self.about_box = AboutBox(self.directories_dialog, self)
|
|
||||||
|
|
||||||
self.directories_dialog.show()
|
self.progress_window = ProgressWindow(parent_window, self.model.progress_window)
|
||||||
|
self.problemDialog = ProblemDialog(
|
||||||
|
parent=parent_window, model=self.model.problem_dialog
|
||||||
|
)
|
||||||
|
if self.use_tabs:
|
||||||
|
self.ignoreListDialog = self.main_window.createPage(
|
||||||
|
"IgnoreListDialog",
|
||||||
|
parent=self.main_window,
|
||||||
|
model=self.model.ignore_list_dialog,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.excludeListDialog = self.main_window.createPage(
|
||||||
|
"ExcludeListDialog",
|
||||||
|
app=self,
|
||||||
|
parent=self.main_window,
|
||||||
|
model=self.model.exclude_list_dialog,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.ignoreListDialog = IgnoreListDialog(
|
||||||
|
parent=parent_window, model=self.model.ignore_list_dialog
|
||||||
|
)
|
||||||
|
self.excludeDialog = ExcludeListDialog(
|
||||||
|
app=self, parent=parent_window, model=self.model.exclude_list_dialog
|
||||||
|
)
|
||||||
|
|
||||||
|
self.deletionOptions = DeletionOptions(
|
||||||
|
parent=parent_window, model=self.model.deletion_options
|
||||||
|
)
|
||||||
|
self.about_box = AboutBox(parent_window, self)
|
||||||
|
|
||||||
|
parent_window.show()
|
||||||
self.model.load()
|
self.model.load()
|
||||||
|
|
||||||
self.SIGTERM.connect(self.handleSIGTERM)
|
self.SIGTERM.connect(self.handleSIGTERM)
|
||||||
@@ -98,6 +137,13 @@ class DupeGuru(QObject):
|
|||||||
self.preferencesTriggered,
|
self.preferencesTriggered,
|
||||||
),
|
),
|
||||||
("actionIgnoreList", "", "", tr("Ignore List"), self.ignoreListTriggered),
|
("actionIgnoreList", "", "", tr("Ignore List"), self.ignoreListTriggered),
|
||||||
|
(
|
||||||
|
"actionDirectoriesWindow",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
tr("Directories"),
|
||||||
|
self.showDirectoriesWindow,
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"actionClearPictureCache",
|
"actionClearPictureCache",
|
||||||
"Ctrl+Shift+P",
|
"Ctrl+Shift+P",
|
||||||
@@ -105,6 +151,13 @@ class DupeGuru(QObject):
|
|||||||
tr("Clear Picture Cache"),
|
tr("Clear Picture Cache"),
|
||||||
self.clearPictureCacheTriggered,
|
self.clearPictureCacheTriggered,
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"actionExcludeList",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
tr("Exclusion Filters"),
|
||||||
|
self.excludeListTriggered,
|
||||||
|
),
|
||||||
("actionShowHelp", "F1", "", tr("dupeGuru Help"), self.showHelpTriggered),
|
("actionShowHelp", "F1", "", tr("dupeGuru Help"), self.showHelpTriggered),
|
||||||
("actionAbout", "", "", tr("About dupeGuru"), self.showAboutBoxTriggered),
|
("actionAbout", "", "", tr("About dupeGuru"), self.showAboutBoxTriggered),
|
||||||
(
|
(
|
||||||
@@ -152,6 +205,9 @@ class DupeGuru(QObject):
|
|||||||
self.model.options["match_scaled"] = self.prefs.match_scaled
|
self.model.options["match_scaled"] = self.prefs.match_scaled
|
||||||
self.model.options["picture_cache_type"] = self.prefs.picture_cache_type
|
self.model.options["picture_cache_type"] = self.prefs.picture_cache_type
|
||||||
|
|
||||||
|
if self.details_dialog:
|
||||||
|
self.details_dialog.update_options()
|
||||||
|
|
||||||
# --- Private
|
# --- Private
|
||||||
def _get_details_dialog_class(self):
|
def _get_details_dialog_class(self):
|
||||||
if self.model.app_mode == AppMode.Picture:
|
if self.model.app_mode == AppMode.Picture:
|
||||||
@@ -187,12 +243,28 @@ class DupeGuru(QObject):
|
|||||||
|
|
||||||
def show_details(self):
|
def show_details(self):
|
||||||
if self.details_dialog is not None:
|
if self.details_dialog is not None:
|
||||||
|
if not self.details_dialog.isVisible():
|
||||||
self.details_dialog.show()
|
self.details_dialog.show()
|
||||||
|
else:
|
||||||
|
self.details_dialog.hide()
|
||||||
|
|
||||||
def showResultsWindow(self):
|
def showResultsWindow(self):
|
||||||
if self.resultWindow is not None:
|
if self.resultWindow is not None:
|
||||||
|
if self.use_tabs:
|
||||||
|
if self.main_window.indexOfWidget(self.resultWindow) < 0:
|
||||||
|
self.main_window.addTab(self.resultWindow, "Results", switch=True)
|
||||||
|
return
|
||||||
|
self.main_window.showTab(self.resultWindow)
|
||||||
|
else:
|
||||||
self.resultWindow.show()
|
self.resultWindow.show()
|
||||||
|
|
||||||
|
def showDirectoriesWindow(self):
|
||||||
|
if self.directories_dialog is not None:
|
||||||
|
if self.use_tabs:
|
||||||
|
self.main_window.showTab(self.directories_dialog)
|
||||||
|
else:
|
||||||
|
self.directories_dialog.show()
|
||||||
|
|
||||||
def shutdown(self):
|
def shutdown(self):
|
||||||
self.willSavePrefs.emit()
|
self.willSavePrefs.emit()
|
||||||
self.prefs.save()
|
self.prefs.save()
|
||||||
@@ -212,7 +284,11 @@ class DupeGuru(QObject):
|
|||||||
"scanning have accented letters, you'll probably get a crash. It is advised that "
|
"scanning have accented letters, you'll probably get a crash. It is advised that "
|
||||||
"you set your system locale properly."
|
"you set your system locale properly."
|
||||||
)
|
)
|
||||||
QMessageBox.warning(self.directories_dialog, "Wrong Locale", msg)
|
QMessageBox.warning(
|
||||||
|
self.main_window if self.main_window else self.directories_dialog,
|
||||||
|
"Wrong Locale",
|
||||||
|
msg,
|
||||||
|
)
|
||||||
|
|
||||||
def clearPictureCacheTriggered(self):
|
def clearPictureCacheTriggered(self):
|
||||||
title = tr("Clear Picture Cache")
|
title = tr("Clear Picture Cache")
|
||||||
@@ -223,15 +299,35 @@ class DupeGuru(QObject):
|
|||||||
QMessageBox.information(active, title, tr("Picture cache cleared."))
|
QMessageBox.information(active, title, tr("Picture cache cleared."))
|
||||||
|
|
||||||
def ignoreListTriggered(self):
|
def ignoreListTriggered(self):
|
||||||
|
if self.use_tabs:
|
||||||
|
self.showTriggeredTabbedDialog(self.ignoreListDialog, "Ignore List")
|
||||||
|
else: # floating windows
|
||||||
self.model.ignore_list_dialog.show()
|
self.model.ignore_list_dialog.show()
|
||||||
|
|
||||||
|
def excludeListTriggered(self):
|
||||||
|
if self.use_tabs:
|
||||||
|
self.showTriggeredTabbedDialog(self.excludeListDialog, "Exclusion Filters")
|
||||||
|
else: # floating windows
|
||||||
|
self.model.exclude_list_dialog.show()
|
||||||
|
|
||||||
|
def showTriggeredTabbedDialog(self, dialog, desc_string):
|
||||||
|
"""Add tab for dialog, name the tab with desc_string, then show it."""
|
||||||
|
index = self.main_window.indexOfWidget(dialog)
|
||||||
|
# Create the tab if it doesn't exist already
|
||||||
|
if (
|
||||||
|
index < 0
|
||||||
|
): # or (not dialog.isVisible() and not self.main_window.isTabVisible(index)):
|
||||||
|
index = self.main_window.addTab(dialog, desc_string, switch=True)
|
||||||
|
# Show the tab for that widget
|
||||||
|
self.main_window.setCurrentIndex(index)
|
||||||
|
|
||||||
def openDebugLogTriggered(self):
|
def openDebugLogTriggered(self):
|
||||||
debugLogPath = op.join(self.model.appdata, "debug.log")
|
debugLogPath = op.join(self.model.appdata, "debug.log")
|
||||||
desktop.open_path(debugLogPath)
|
desktop.open_path(debugLogPath)
|
||||||
|
|
||||||
def preferencesTriggered(self):
|
def preferencesTriggered(self):
|
||||||
preferences_dialog = self._get_preferences_dialog_class()(
|
preferences_dialog = self._get_preferences_dialog_class()(
|
||||||
self.directories_dialog, self
|
self.main_window if self.main_window else self.directories_dialog, self
|
||||||
)
|
)
|
||||||
preferences_dialog.load()
|
preferences_dialog.load()
|
||||||
result = preferences_dialog.exec()
|
result = preferences_dialog.exec()
|
||||||
@@ -242,6 +338,12 @@ class DupeGuru(QObject):
|
|||||||
preferences_dialog.setParent(None)
|
preferences_dialog.setParent(None)
|
||||||
|
|
||||||
def quitTriggered(self):
|
def quitTriggered(self):
|
||||||
|
if self.details_dialog is not None:
|
||||||
|
self.details_dialog.close()
|
||||||
|
|
||||||
|
if self.main_window:
|
||||||
|
self.main_window.close()
|
||||||
|
else:
|
||||||
self.directories_dialog.close()
|
self.directories_dialog.close()
|
||||||
|
|
||||||
def showAboutBoxTriggered(self):
|
def showAboutBoxTriggered(self):
|
||||||
@@ -253,7 +355,7 @@ class DupeGuru(QObject):
|
|||||||
if op.exists(help_path):
|
if op.exists(help_path):
|
||||||
url = QUrl.fromLocalFile(help_path)
|
url = QUrl.fromLocalFile(help_path)
|
||||||
else:
|
else:
|
||||||
url = QUrl("https://www.hardcoded.net/dupeguru/help/en/")
|
url = QUrl("https://dupeguru.voltaicideas.net/help/en/")
|
||||||
QDesktopServices.openUrl(url)
|
QDesktopServices.openUrl(url)
|
||||||
|
|
||||||
def handleSIGTERM(self):
|
def handleSIGTERM(self):
|
||||||
@@ -274,15 +376,28 @@ class DupeGuru(QObject):
|
|||||||
return self.confirm("", prompt)
|
return self.confirm("", prompt)
|
||||||
|
|
||||||
def create_results_window(self):
|
def create_results_window(self):
|
||||||
"""Creates resultWindow and details_dialog depending on the selected ``app_mode``.
|
"""Creates resultWindow and details_dialog depending on the selected ``app_mode``."""
|
||||||
"""
|
|
||||||
if self.details_dialog is not None:
|
if self.details_dialog is not None:
|
||||||
|
# The object is not deleted entirely, avoid saving its geometry in the future
|
||||||
|
# self.willSavePrefs.disconnect(self.details_dialog.appWillSavePrefs)
|
||||||
|
# or simply delete it on close which is probably cleaner:
|
||||||
|
self.details_dialog.setAttribute(Qt.WA_DeleteOnClose)
|
||||||
self.details_dialog.close()
|
self.details_dialog.close()
|
||||||
|
# if we don't do the following, Qt will crash when we recreate the Results dialog
|
||||||
self.details_dialog.setParent(None)
|
self.details_dialog.setParent(None)
|
||||||
if self.resultWindow is not None:
|
if self.resultWindow is not None:
|
||||||
self.resultWindow.close()
|
self.resultWindow.close()
|
||||||
self.resultWindow.setParent(None)
|
# This is better for tabs, as it takes care of duplicate items in menu bar
|
||||||
|
self.resultWindow.deleteLater() if self.use_tabs else self.resultWindow.setParent(
|
||||||
|
None
|
||||||
|
)
|
||||||
|
if self.use_tabs:
|
||||||
|
self.resultWindow = self.main_window.createPage(
|
||||||
|
"ResultWindow", parent=self.main_window, app=self
|
||||||
|
)
|
||||||
|
else: # We don't use a tab widget, regular floating QMainWindow
|
||||||
self.resultWindow = ResultWindow(self.directories_dialog, self)
|
self.resultWindow = ResultWindow(self.directories_dialog, self)
|
||||||
|
self.directories_dialog._updateActionsState()
|
||||||
self.details_dialog = self._get_details_dialog_class()(self.resultWindow, self)
|
self.details_dialog = self._get_details_dialog_class()(self.resultWindow, self)
|
||||||
|
|
||||||
def show_results_window(self):
|
def show_results_window(self):
|
||||||
|
|||||||
@@ -7,34 +7,63 @@
|
|||||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||||
|
|
||||||
from PyQt5.QtCore import Qt
|
from PyQt5.QtCore import Qt
|
||||||
from PyQt5.QtWidgets import QDialog
|
from PyQt5.QtWidgets import QDockWidget, QWidget
|
||||||
|
|
||||||
from .details_table import DetailsModel
|
from .details_table import DetailsModel
|
||||||
|
from hscommon.plat import ISLINUX
|
||||||
|
|
||||||
|
|
||||||
class DetailsDialog(QDialog):
|
class DetailsDialog(QDockWidget):
|
||||||
def __init__(self, parent, app, **kwargs):
|
def __init__(self, parent, app, **kwargs):
|
||||||
super().__init__(parent, Qt.Tool, **kwargs)
|
super().__init__(parent, Qt.Tool, **kwargs)
|
||||||
|
self.parent = parent
|
||||||
self.app = app
|
self.app = app
|
||||||
self.model = app.model.details_panel
|
self.model = app.model.details_panel
|
||||||
|
self.setAllowedAreas(Qt.AllDockWidgetAreas)
|
||||||
self._setupUi()
|
self._setupUi()
|
||||||
# To avoid saving uninitialized geometry on appWillSavePrefs, we track whether our dialog
|
# To avoid saving uninitialized geometry on appWillSavePrefs, we track whether our dialog
|
||||||
# has been shown. If it has, we know that our geometry should be saved.
|
# has been shown. If it has, we know that our geometry should be saved.
|
||||||
self._shown_once = False
|
self._shown_once = False
|
||||||
self.app.prefs.restoreGeometry("DetailsWindowRect", self)
|
self._wasDocked, area = self.app.prefs.restoreGeometry("DetailsWindowRect", self)
|
||||||
self.tableModel = DetailsModel(self.model)
|
self.tableModel = DetailsModel(self.model, app)
|
||||||
# tableView is defined in subclasses
|
# tableView is defined in subclasses
|
||||||
self.tableView.setModel(self.tableModel)
|
self.tableView.setModel(self.tableModel)
|
||||||
self.model.view = self
|
self.model.view = self
|
||||||
|
|
||||||
self.app.willSavePrefs.connect(self.appWillSavePrefs)
|
self.app.willSavePrefs.connect(self.appWillSavePrefs)
|
||||||
|
# self.setAttribute(Qt.WA_DeleteOnClose)
|
||||||
|
parent.addDockWidget(
|
||||||
|
area if self._wasDocked else Qt.BottomDockWidgetArea, self)
|
||||||
|
|
||||||
def _setupUi(self): # Virtual
|
def _setupUi(self): # Virtual
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def show(self):
|
def show(self):
|
||||||
|
if not self._shown_once and self._wasDocked:
|
||||||
|
self.setFloating(False)
|
||||||
self._shown_once = True
|
self._shown_once = True
|
||||||
super().show()
|
super().show()
|
||||||
|
self.update_options()
|
||||||
|
|
||||||
|
def update_options(self):
|
||||||
|
# This disables the title bar (if we had not set one before already)
|
||||||
|
# essentially making it a simple floating window, not dockable anymore
|
||||||
|
if not self.app.prefs.details_dialog_titlebar_enabled:
|
||||||
|
if not self.titleBarWidget(): # default title bar
|
||||||
|
self.setTitleBarWidget(QWidget()) # disables title bar
|
||||||
|
# Windows (and MacOS?) users cannot move a floating window which
|
||||||
|
# has not native decoration so we force it to dock for now
|
||||||
|
if not ISLINUX:
|
||||||
|
self.setFloating(False)
|
||||||
|
elif self.titleBarWidget() is not None: # title bar is disabled
|
||||||
|
self.setTitleBarWidget(None) # resets to the default title bar
|
||||||
|
elif not self.titleBarWidget() and not self.app.prefs.details_dialog_titlebar_enabled:
|
||||||
|
self.setTitleBarWidget(QWidget())
|
||||||
|
|
||||||
|
features = self.features()
|
||||||
|
if self.app.prefs.details_dialog_vertical_titlebar:
|
||||||
|
self.setFeatures(features | QDockWidget.DockWidgetVerticalTitleBar)
|
||||||
|
elif features & QDockWidget.DockWidgetVerticalTitleBar:
|
||||||
|
self.setFeatures(features ^ QDockWidget.DockWidgetVerticalTitleBar)
|
||||||
|
|
||||||
# --- Events
|
# --- Events
|
||||||
def appWillSavePrefs(self):
|
def appWillSavePrefs(self):
|
||||||
|
|||||||
@@ -8,18 +8,20 @@
|
|||||||
|
|
||||||
from PyQt5.QtCore import Qt, QAbstractTableModel
|
from PyQt5.QtCore import Qt, QAbstractTableModel
|
||||||
from PyQt5.QtWidgets import QHeaderView, QTableView
|
from PyQt5.QtWidgets import QHeaderView, QTableView
|
||||||
|
from PyQt5.QtGui import QFont, QBrush
|
||||||
|
|
||||||
from hscommon.trans import trget
|
from hscommon.trans import trget
|
||||||
|
|
||||||
tr = trget("ui")
|
tr = trget("ui")
|
||||||
|
|
||||||
HEADER = [tr("Attribute"), tr("Selected"), tr("Reference")]
|
HEADER = [tr("Selected"), tr("Reference")]
|
||||||
|
|
||||||
|
|
||||||
class DetailsModel(QAbstractTableModel):
|
class DetailsModel(QAbstractTableModel):
|
||||||
def __init__(self, model, **kwargs):
|
def __init__(self, model, app, **kwargs):
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.model = model
|
self.model = model
|
||||||
|
self.prefs = app.prefs
|
||||||
|
|
||||||
def columnCount(self, parent):
|
def columnCount(self, parent):
|
||||||
return len(HEADER)
|
return len(HEADER)
|
||||||
@@ -27,12 +29,28 @@ class DetailsModel(QAbstractTableModel):
|
|||||||
def data(self, index, role):
|
def data(self, index, role):
|
||||||
if not index.isValid():
|
if not index.isValid():
|
||||||
return None
|
return None
|
||||||
|
# Skip first value "Attribute"
|
||||||
|
column = index.column() + 1
|
||||||
|
row = index.row()
|
||||||
|
|
||||||
|
ignored_fields = ["Dupe Count"]
|
||||||
|
if (self.model.row(row)[0] in ignored_fields
|
||||||
|
or self.model.row(row)[1] == "---"
|
||||||
|
or self.model.row(row)[2] == "---"):
|
||||||
if role != Qt.DisplayRole:
|
if role != Qt.DisplayRole:
|
||||||
return None
|
return None
|
||||||
column = index.column()
|
|
||||||
row = index.row()
|
|
||||||
return self.model.row(row)[column]
|
return self.model.row(row)[column]
|
||||||
|
|
||||||
|
if role == Qt.DisplayRole:
|
||||||
|
return self.model.row(row)[column]
|
||||||
|
if role == Qt.ForegroundRole and self.model.row(row)[1] != self.model.row(row)[2]:
|
||||||
|
return QBrush(self.prefs.details_table_delta_foreground_color)
|
||||||
|
if role == Qt.FontRole and self.model.row(row)[1] != self.model.row(row)[2]:
|
||||||
|
font = QFont(self.model.view.font()) # or simply QFont()
|
||||||
|
font.setBold(True)
|
||||||
|
return font
|
||||||
|
return None # QVariant()
|
||||||
|
|
||||||
def headerData(self, section, orientation, role):
|
def headerData(self, section, orientation, role):
|
||||||
if (
|
if (
|
||||||
orientation == Qt.Horizontal
|
orientation == Qt.Horizontal
|
||||||
@@ -40,6 +58,13 @@ class DetailsModel(QAbstractTableModel):
|
|||||||
and section < len(HEADER)
|
and section < len(HEADER)
|
||||||
):
|
):
|
||||||
return HEADER[section]
|
return HEADER[section]
|
||||||
|
elif (
|
||||||
|
orientation == Qt.Vertical
|
||||||
|
and role == Qt.DisplayRole
|
||||||
|
and section < self.model.row_count()
|
||||||
|
):
|
||||||
|
# Read "Attribute" cell for horizontal header
|
||||||
|
return self.model.row(section)[0]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def rowCount(self, parent):
|
def rowCount(self, parent):
|
||||||
@@ -51,18 +76,22 @@ class DetailsTable(QTableView):
|
|||||||
QTableView.__init__(self, *args)
|
QTableView.__init__(self, *args)
|
||||||
self.setAlternatingRowColors(True)
|
self.setAlternatingRowColors(True)
|
||||||
self.setSelectionBehavior(QTableView.SelectRows)
|
self.setSelectionBehavior(QTableView.SelectRows)
|
||||||
|
self.setSelectionMode(QTableView.NoSelection)
|
||||||
self.setShowGrid(False)
|
self.setShowGrid(False)
|
||||||
|
self.setWordWrap(False)
|
||||||
|
self.setCornerButtonEnabled(False)
|
||||||
|
|
||||||
def setModel(self, model):
|
def setModel(self, model):
|
||||||
QTableView.setModel(self, model)
|
QTableView.setModel(self, model)
|
||||||
# The model needs to be set to set header stuff
|
# The model needs to be set to set header stuff
|
||||||
hheader = self.horizontalHeader()
|
hheader = self.horizontalHeader()
|
||||||
hheader.setHighlightSections(False)
|
hheader.setHighlightSections(False)
|
||||||
hheader.setStretchLastSection(False)
|
hheader.setSectionResizeMode(0, QHeaderView.Stretch)
|
||||||
hheader.resizeSection(0, 100)
|
|
||||||
hheader.setSectionResizeMode(0, QHeaderView.Fixed)
|
|
||||||
hheader.setSectionResizeMode(1, QHeaderView.Stretch)
|
hheader.setSectionResizeMode(1, QHeaderView.Stretch)
|
||||||
hheader.setSectionResizeMode(2, QHeaderView.Stretch)
|
|
||||||
vheader = self.verticalHeader()
|
vheader = self.verticalHeader()
|
||||||
vheader.setVisible(False)
|
vheader.setVisible(True)
|
||||||
vheader.setDefaultSectionSize(18)
|
vheader.setDefaultSectionSize(18)
|
||||||
|
# hardcoded value above is not ideal, perhaps resize to contents first?
|
||||||
|
# vheader.setSectionResizeMode(QHeaderView.ResizeToContents)
|
||||||
|
vheader.setSectionResizeMode(QHeaderView.Fixed)
|
||||||
|
vheader.setSectionsMovable(True)
|
||||||
|
|||||||
@@ -5,5 +5,11 @@
|
|||||||
<file alias="plus">../images/plus_8.png</file>
|
<file alias="plus">../images/plus_8.png</file>
|
||||||
<file alias="minus">../images/minus_8.png</file>
|
<file alias="minus">../images/minus_8.png</file>
|
||||||
<file alias="search_clear_13">../qtlib/images/search_clear_13.png</file>
|
<file alias="search_clear_13">../qtlib/images/search_clear_13.png</file>
|
||||||
|
<file alias="exchange">../images/exchange_purple_upscaled.png</file>
|
||||||
|
<file alias="zoom_in">../images/old_zoom_in.png</file>
|
||||||
|
<file alias="zoom_out">../images/old_zoom_out.png</file>
|
||||||
|
<file alias="zoom_original">../images/old_zoom_original.png</file>
|
||||||
|
<file alias="zoom_best_fit">../images/old_zoom_best_fit.png</file>
|
||||||
|
<file alias="error">../images/dialog-error.png</file>
|
||||||
</qresource>
|
</qresource>
|
||||||
</RCC>
|
</RCC>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ class DirectoriesDialog(QMainWindow):
|
|||||||
def __init__(self, app, **kwargs):
|
def __init__(self, app, **kwargs):
|
||||||
super().__init__(None, **kwargs)
|
super().__init__(None, **kwargs)
|
||||||
self.app = app
|
self.app = app
|
||||||
|
self.specific_actions = set()
|
||||||
self.lastAddedFolder = platform.INITIAL_FOLDER_IN_DIALOGS
|
self.lastAddedFolder = platform.INITIAL_FOLDER_IN_DIALOGS
|
||||||
self.recentFolders = Recent(self.app, "recentFolders")
|
self.recentFolders = Recent(self.app, "recentFolders")
|
||||||
self._setupUi()
|
self._setupUi()
|
||||||
@@ -87,14 +88,22 @@ class DirectoriesDialog(QMainWindow):
|
|||||||
"actionShowResultsWindow",
|
"actionShowResultsWindow",
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
tr("Results Window"),
|
tr("Scan Results"),
|
||||||
self.app.showResultsWindow,
|
self.app.showResultsWindow,
|
||||||
),
|
),
|
||||||
("actionAddFolder", "", "", tr("Add Folder..."), self.addFolderTriggered),
|
("actionAddFolder", "", "", tr("Add Folder..."), self.addFolderTriggered),
|
||||||
|
("actionLoadDirectories", "", "", tr("Load Directories..."), self.loadDirectoriesTriggered),
|
||||||
|
("actionSaveDirectories", "", "", tr("Save Directories..."), self.saveDirectoriesTriggered),
|
||||||
]
|
]
|
||||||
createActions(ACTIONS, self)
|
createActions(ACTIONS, self)
|
||||||
|
if self.app.use_tabs:
|
||||||
|
# Keep track of actions which should only be accessible from this window
|
||||||
|
self.specific_actions.add(self.actionLoadDirectories)
|
||||||
|
self.specific_actions.add(self.actionSaveDirectories)
|
||||||
|
|
||||||
def _setupMenu(self):
|
def _setupMenu(self):
|
||||||
|
if not self.app.use_tabs:
|
||||||
|
# we are our own QMainWindow, we need our own menu bar
|
||||||
self.menubar = QMenuBar(self)
|
self.menubar = QMenuBar(self)
|
||||||
self.menubar.setGeometry(QRect(0, 0, 42, 22))
|
self.menubar.setGeometry(QRect(0, 0, 42, 22))
|
||||||
self.menuFile = QMenu(self.menubar)
|
self.menuFile = QMenu(self.menubar)
|
||||||
@@ -103,26 +112,42 @@ class DirectoriesDialog(QMainWindow):
|
|||||||
self.menuView.setTitle(tr("View"))
|
self.menuView.setTitle(tr("View"))
|
||||||
self.menuHelp = QMenu(self.menubar)
|
self.menuHelp = QMenu(self.menubar)
|
||||||
self.menuHelp.setTitle(tr("Help"))
|
self.menuHelp.setTitle(tr("Help"))
|
||||||
|
self.setMenuBar(self.menubar)
|
||||||
|
menubar = self.menubar
|
||||||
|
else:
|
||||||
|
# we are part of a tab widget, we populate its window's menubar instead
|
||||||
|
self.menuFile = self.app.main_window.menuFile
|
||||||
|
self.menuView = self.app.main_window.menuView
|
||||||
|
self.menuHelp = self.app.main_window.menuHelp
|
||||||
|
menubar = self.app.main_window.menubar
|
||||||
|
|
||||||
self.menuLoadRecent = QMenu(self.menuFile)
|
self.menuLoadRecent = QMenu(self.menuFile)
|
||||||
self.menuLoadRecent.setTitle(tr("Load Recent Results"))
|
self.menuLoadRecent.setTitle(tr("Load Recent Results"))
|
||||||
self.setMenuBar(self.menubar)
|
|
||||||
|
|
||||||
self.menuFile.addAction(self.actionLoadResults)
|
self.menuFile.addAction(self.actionLoadResults)
|
||||||
self.menuFile.addAction(self.menuLoadRecent.menuAction())
|
self.menuFile.addAction(self.menuLoadRecent.menuAction())
|
||||||
self.menuFile.addSeparator()
|
self.menuFile.addSeparator()
|
||||||
self.menuFile.addAction(self.app.actionClearPictureCache)
|
self.menuFile.addAction(self.app.actionClearPictureCache)
|
||||||
self.menuFile.addSeparator()
|
self.menuFile.addSeparator()
|
||||||
|
self.menuFile.addAction(self.actionLoadDirectories)
|
||||||
|
self.menuFile.addAction(self.actionSaveDirectories)
|
||||||
|
self.menuFile.addSeparator()
|
||||||
self.menuFile.addAction(self.app.actionQuit)
|
self.menuFile.addAction(self.app.actionQuit)
|
||||||
self.menuView.addAction(self.app.actionPreferences)
|
|
||||||
|
self.menuView.addAction(self.app.actionDirectoriesWindow)
|
||||||
self.menuView.addAction(self.actionShowResultsWindow)
|
self.menuView.addAction(self.actionShowResultsWindow)
|
||||||
self.menuView.addAction(self.app.actionIgnoreList)
|
self.menuView.addAction(self.app.actionIgnoreList)
|
||||||
|
self.menuView.addAction(self.app.actionExcludeList)
|
||||||
|
self.menuView.addSeparator()
|
||||||
|
self.menuView.addAction(self.app.actionPreferences)
|
||||||
|
|
||||||
self.menuHelp.addAction(self.app.actionShowHelp)
|
self.menuHelp.addAction(self.app.actionShowHelp)
|
||||||
self.menuHelp.addAction(self.app.actionOpenDebugLog)
|
self.menuHelp.addAction(self.app.actionOpenDebugLog)
|
||||||
self.menuHelp.addAction(self.app.actionAbout)
|
self.menuHelp.addAction(self.app.actionAbout)
|
||||||
|
|
||||||
self.menubar.addAction(self.menuFile.menuAction())
|
menubar.addAction(self.menuFile.menuAction())
|
||||||
self.menubar.addAction(self.menuView.menuAction())
|
menubar.addAction(self.menuView.menuAction())
|
||||||
self.menubar.addAction(self.menuHelp.menuAction())
|
menubar.addAction(self.menuHelp.menuAction())
|
||||||
|
|
||||||
# Recent folders menu
|
# Recent folders menu
|
||||||
self.menuRecentFolders = QMenu()
|
self.menuRecentFolders = QMenu()
|
||||||
@@ -139,6 +164,8 @@ class DirectoriesDialog(QMainWindow):
|
|||||||
self.resize(420, 338)
|
self.resize(420, 338)
|
||||||
self.centralwidget = QWidget(self)
|
self.centralwidget = QWidget(self)
|
||||||
self.verticalLayout = QVBoxLayout(self.centralwidget)
|
self.verticalLayout = QVBoxLayout(self.centralwidget)
|
||||||
|
self.verticalLayout.setContentsMargins(4, 0, 4, 0)
|
||||||
|
self.verticalLayout.setSpacing(0)
|
||||||
hl = QHBoxLayout()
|
hl = QHBoxLayout()
|
||||||
label = QLabel(tr("Application Mode:"), self)
|
label = QLabel(tr("Application Mode:"), self)
|
||||||
label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
label.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||||
@@ -306,9 +333,25 @@ class DirectoriesDialog(QMainWindow):
|
|||||||
self.app.model.load_from(destination)
|
self.app.model.load_from(destination)
|
||||||
self.app.recentResults.insertItem(destination)
|
self.app.recentResults.insertItem(destination)
|
||||||
|
|
||||||
|
def loadDirectoriesTriggered(self):
|
||||||
|
title = tr("Select a directories file to load")
|
||||||
|
files = ";;".join([tr("dupeGuru Results (*.dupegurudirs)"), tr("All Files (*.*)")])
|
||||||
|
destination = QFileDialog.getOpenFileName(self, title, "", files)[0]
|
||||||
|
if destination:
|
||||||
|
self.app.model.load_directories(destination)
|
||||||
|
|
||||||
def removeFolderButtonClicked(self):
|
def removeFolderButtonClicked(self):
|
||||||
self.directoriesModel.model.remove_selected()
|
self.directoriesModel.model.remove_selected()
|
||||||
|
|
||||||
|
def saveDirectoriesTriggered(self):
|
||||||
|
title = tr("Select a file to save your directories to")
|
||||||
|
files = tr("dupeGuru Directories (*.dupegurudirs)")
|
||||||
|
destination, chosen_filter = QFileDialog.getSaveFileName(self, title, "", files)
|
||||||
|
if destination:
|
||||||
|
if not destination.endswith(".dupegurudirs"):
|
||||||
|
destination = "{}.dupegurudirs".format(destination)
|
||||||
|
self.app.model.save_directories_as(destination)
|
||||||
|
|
||||||
def scanButtonClicked(self):
|
def scanButtonClicked(self):
|
||||||
if self.app.model.results.is_modified:
|
if self.app.model.results.is_modified:
|
||||||
title = tr("Start a new scan")
|
title = tr("Start a new scan")
|
||||||
|
|||||||
165
qt/exclude_list_dialog.py
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
# 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 re
|
||||||
|
from PyQt5.QtCore import Qt, pyqtSlot
|
||||||
|
from PyQt5.QtWidgets import (
|
||||||
|
QPushButton, QLineEdit, QVBoxLayout, QGridLayout, QDialog,
|
||||||
|
QTableView, QAbstractItemView, QSpacerItem, QSizePolicy, QHeaderView
|
||||||
|
)
|
||||||
|
from .exclude_list_table import ExcludeListTable
|
||||||
|
|
||||||
|
from core.exclude import AlreadyThereException
|
||||||
|
from hscommon.trans import trget
|
||||||
|
tr = trget("ui")
|
||||||
|
|
||||||
|
|
||||||
|
class ExcludeListDialog(QDialog):
|
||||||
|
def __init__(self, app, parent, model, **kwargs):
|
||||||
|
flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint
|
||||||
|
super().__init__(parent, flags, **kwargs)
|
||||||
|
self.app = app
|
||||||
|
self.specific_actions = frozenset()
|
||||||
|
self._setupUI()
|
||||||
|
self.model = model # ExcludeListDialogCore
|
||||||
|
self.model.view = self
|
||||||
|
self.table = ExcludeListTable(app, view=self.tableView) # Qt ExcludeListTable
|
||||||
|
self._row_matched = False # test if at least one row matched our test string
|
||||||
|
self._input_styled = False
|
||||||
|
|
||||||
|
self.buttonAdd.clicked.connect(self.addStringFromLineEdit)
|
||||||
|
self.buttonRemove.clicked.connect(self.removeSelected)
|
||||||
|
self.buttonRestore.clicked.connect(self.restoreDefaults)
|
||||||
|
self.buttonClose.clicked.connect(self.accept)
|
||||||
|
self.buttonHelp.clicked.connect(self.display_help_message)
|
||||||
|
self.buttonTestString.clicked.connect(self.onTestStringButtonClicked)
|
||||||
|
self.inputLine.textEdited.connect(self.reset_input_style)
|
||||||
|
self.testLine.textEdited.connect(self.reset_input_style)
|
||||||
|
self.testLine.textEdited.connect(self.reset_table_style)
|
||||||
|
|
||||||
|
def _setupUI(self):
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
gridlayout = QGridLayout()
|
||||||
|
self.buttonAdd = QPushButton(tr("Add"))
|
||||||
|
self.buttonRemove = QPushButton(tr("Remove Selected"))
|
||||||
|
self.buttonRestore = QPushButton(tr("Restore defaults"))
|
||||||
|
self.buttonTestString = QPushButton(tr("Test string"))
|
||||||
|
self.buttonClose = QPushButton(tr("Close"))
|
||||||
|
self.buttonHelp = QPushButton(tr("Help"))
|
||||||
|
self.inputLine = QLineEdit()
|
||||||
|
self.testLine = QLineEdit()
|
||||||
|
self.tableView = QTableView()
|
||||||
|
triggers = (
|
||||||
|
QAbstractItemView.DoubleClicked
|
||||||
|
| QAbstractItemView.EditKeyPressed
|
||||||
|
| QAbstractItemView.SelectedClicked
|
||||||
|
)
|
||||||
|
self.tableView.setEditTriggers(triggers)
|
||||||
|
self.tableView.setSelectionMode(QTableView.ExtendedSelection)
|
||||||
|
self.tableView.setSelectionBehavior(QTableView.SelectRows)
|
||||||
|
self.tableView.setShowGrid(False)
|
||||||
|
vheader = self.tableView.verticalHeader()
|
||||||
|
vheader.setSectionsMovable(True)
|
||||||
|
vheader.setVisible(False)
|
||||||
|
hheader = self.tableView.horizontalHeader()
|
||||||
|
hheader.setSectionsMovable(False)
|
||||||
|
hheader.setSectionResizeMode(QHeaderView.Fixed)
|
||||||
|
hheader.setStretchLastSection(True)
|
||||||
|
hheader.setHighlightSections(False)
|
||||||
|
hheader.setVisible(True)
|
||||||
|
gridlayout.addWidget(self.inputLine, 0, 0)
|
||||||
|
gridlayout.addWidget(self.buttonAdd, 0, 1, Qt.AlignLeft)
|
||||||
|
gridlayout.addWidget(self.buttonRemove, 1, 1, Qt.AlignLeft)
|
||||||
|
gridlayout.addWidget(self.buttonRestore, 2, 1, Qt.AlignLeft)
|
||||||
|
gridlayout.addWidget(self.buttonHelp, 3, 1, Qt.AlignLeft)
|
||||||
|
gridlayout.addWidget(self.buttonClose, 4, 1)
|
||||||
|
gridlayout.addWidget(self.tableView, 1, 0, 6, 1)
|
||||||
|
gridlayout.addItem(QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.Expanding), 4, 1)
|
||||||
|
gridlayout.addWidget(self.buttonTestString, 6, 1)
|
||||||
|
gridlayout.addWidget(self.testLine, 6, 0)
|
||||||
|
|
||||||
|
layout.addLayout(gridlayout)
|
||||||
|
self.inputLine.setPlaceholderText(tr("Type a python regular expression here..."))
|
||||||
|
self.inputLine.setFocus()
|
||||||
|
self.testLine.setPlaceholderText(tr("Type a file system path or filename here..."))
|
||||||
|
self.testLine.setClearButtonEnabled(True)
|
||||||
|
|
||||||
|
# --- model --> view
|
||||||
|
def show(self):
|
||||||
|
super().show()
|
||||||
|
self.inputLine.setFocus()
|
||||||
|
|
||||||
|
@pyqtSlot()
|
||||||
|
def addStringFromLineEdit(self):
|
||||||
|
text = self.inputLine.text()
|
||||||
|
if not text:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.model.add(text)
|
||||||
|
except AlreadyThereException:
|
||||||
|
self.app.show_message("Expression already in the list.")
|
||||||
|
return
|
||||||
|
except Exception as e:
|
||||||
|
self.app.show_message(f"Expression is invalid: {e}")
|
||||||
|
return
|
||||||
|
self.inputLine.clear()
|
||||||
|
|
||||||
|
def removeSelected(self):
|
||||||
|
self.model.remove_selected()
|
||||||
|
|
||||||
|
def restoreDefaults(self):
|
||||||
|
self.model.restore_defaults()
|
||||||
|
|
||||||
|
def onTestStringButtonClicked(self):
|
||||||
|
input_text = self.testLine.text()
|
||||||
|
if not input_text:
|
||||||
|
self.reset_input_style()
|
||||||
|
return
|
||||||
|
# if at least one row matched, we know whether table is highlighted or not
|
||||||
|
self._row_matched = self.model.test_string(input_text)
|
||||||
|
self.table.refresh()
|
||||||
|
|
||||||
|
input_regex = self.inputLine.text()
|
||||||
|
if not input_regex:
|
||||||
|
self.reset_input_style()
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
compiled = re.compile(input_regex)
|
||||||
|
except re.error:
|
||||||
|
self.reset_input_style()
|
||||||
|
return
|
||||||
|
match = compiled.match(input_text)
|
||||||
|
if match:
|
||||||
|
self._input_styled = True
|
||||||
|
self.inputLine.setStyleSheet("background-color: rgb(10, 200, 10);")
|
||||||
|
else:
|
||||||
|
self.reset_input_style()
|
||||||
|
|
||||||
|
def reset_input_style(self):
|
||||||
|
"""Reset regex input line background"""
|
||||||
|
if self._input_styled:
|
||||||
|
self._input_styled = False
|
||||||
|
self.inputLine.setStyleSheet(self.styleSheet())
|
||||||
|
|
||||||
|
def reset_table_style(self):
|
||||||
|
if self._row_matched:
|
||||||
|
self._row_matched = False
|
||||||
|
self.model.reset_rows_highlight()
|
||||||
|
self.table.refresh()
|
||||||
|
|
||||||
|
def display_help_message(self):
|
||||||
|
self.app.show_message(tr("""\
|
||||||
|
These (case sensitive) python regular expressions will filter out files during scans.<br>\
|
||||||
|
Directores will also have their <strong>default state</strong> set to Excluded \
|
||||||
|
in the Directories tab if their name happen to match one of the regular expressions.<br>\
|
||||||
|
For each file collected two tests are perfomed on each of them to determine whether or not to filter them out:<br>\
|
||||||
|
<li>1. Regular expressions with no path separator in them will be compared to the file name only.</li>
|
||||||
|
<li>2. Regular expressions with no path separator in them will be compared to the full path to the file.</li><br>
|
||||||
|
Example: if you want to filter out .PNG files from the "My Pictures" directory only:<br>\
|
||||||
|
<code>.*My\\sPictures\\\\.*\\.png</code><br><br>\
|
||||||
|
You can test the regular expression with the test string feature by pasting a fake path in it:<br>\
|
||||||
|
<code>C:\\\\User\\My Pictures\\test.png</code><br><br>
|
||||||
|
Matching regular expressions will be highlighted.<br>\
|
||||||
|
If there is at least one highlight, the path tested will be ignored during scans.<br><br>\
|
||||||
|
Directories and files starting with a period '.' are filtered out by default.<br><br>"""))
|
||||||
77
qt/exclude_list_table.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||||
|
# which should be included with this package. The terms are also available at
|
||||||
|
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||||
|
|
||||||
|
from PyQt5.QtCore import Qt
|
||||||
|
from PyQt5.QtGui import QFont, QFontMetrics, QIcon, QColor
|
||||||
|
|
||||||
|
from qtlib.column import Column
|
||||||
|
from qtlib.table import Table
|
||||||
|
from hscommon.trans import trget
|
||||||
|
tr = trget("ui")
|
||||||
|
|
||||||
|
|
||||||
|
class ExcludeListTable(Table):
|
||||||
|
"""Model for exclude list"""
|
||||||
|
COLUMNS = [
|
||||||
|
Column("marked", defaultWidth=15),
|
||||||
|
Column("regex", defaultWidth=230)
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, app, view, **kwargs):
|
||||||
|
model = app.model.exclude_list_dialog.exclude_list_table # pointer to GUITable
|
||||||
|
super().__init__(model, view, **kwargs)
|
||||||
|
font = view.font()
|
||||||
|
font.setPointSize(app.prefs.tableFontSize)
|
||||||
|
view.setFont(font)
|
||||||
|
fm = QFontMetrics(font)
|
||||||
|
view.verticalHeader().setDefaultSectionSize(fm.height() + 2)
|
||||||
|
# app.willSavePrefs.connect(self.appWillSavePrefs)
|
||||||
|
|
||||||
|
def _getData(self, row, column, role):
|
||||||
|
if column.name == "marked":
|
||||||
|
if role == Qt.CheckStateRole and row.markable:
|
||||||
|
return Qt.Checked if row.marked else Qt.Unchecked
|
||||||
|
if role == Qt.ToolTipRole and not row.markable:
|
||||||
|
return tr("Compilation error: ") + row.get_cell_value("error")
|
||||||
|
if role == Qt.DecorationRole and not row.markable:
|
||||||
|
return QIcon.fromTheme("dialog-error", QIcon(":/error"))
|
||||||
|
return None
|
||||||
|
if role == Qt.DisplayRole:
|
||||||
|
return row.data[column.name]
|
||||||
|
elif role == Qt.FontRole:
|
||||||
|
return QFont(self.view.font())
|
||||||
|
elif role == Qt.BackgroundRole and column.name == "regex":
|
||||||
|
if row.highlight:
|
||||||
|
return QColor(10, 200, 10) # green
|
||||||
|
elif role == Qt.EditRole:
|
||||||
|
if column.name == "regex":
|
||||||
|
return row.data[column.name]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _getFlags(self, row, column):
|
||||||
|
flags = Qt.ItemIsEnabled
|
||||||
|
if column.name == "marked":
|
||||||
|
if row.markable:
|
||||||
|
flags |= Qt.ItemIsUserCheckable
|
||||||
|
elif column.name == "regex":
|
||||||
|
flags |= Qt.ItemIsEditable | Qt.ItemIsSelectable | Qt.ItemIsDragEnabled | Qt.ItemIsDropEnabled
|
||||||
|
return flags
|
||||||
|
|
||||||
|
def _setData(self, row, column, value, role):
|
||||||
|
if role == Qt.CheckStateRole:
|
||||||
|
if column.name == "marked":
|
||||||
|
row.marked = bool(value)
|
||||||
|
return True
|
||||||
|
elif role == Qt.EditRole:
|
||||||
|
if column.name == "regex":
|
||||||
|
return self.model.rename_selected(value)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# def sort(self, column, order):
|
||||||
|
# column = self.model.COLUMNS[column]
|
||||||
|
# self.model.sort(column.name, order == Qt.AscendingOrder)
|
||||||
|
|
||||||
|
# # --- Events
|
||||||
|
# def appWillSavePrefs(self):
|
||||||
|
# self.model.columns.save_columns()
|
||||||
@@ -26,6 +26,7 @@ class IgnoreListDialog(QDialog):
|
|||||||
def __init__(self, parent, model, **kwargs):
|
def __init__(self, parent, model, **kwargs):
|
||||||
flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint
|
flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint
|
||||||
super().__init__(parent, flags, **kwargs)
|
super().__init__(parent, flags, **kwargs)
|
||||||
|
self.specific_actions = frozenset()
|
||||||
self._setupUi()
|
self._setupUi()
|
||||||
self.model = model
|
self.model = model
|
||||||
self.model.view = self
|
self.model.view = self
|
||||||
@@ -39,6 +40,7 @@ class IgnoreListDialog(QDialog):
|
|||||||
self.setWindowTitle(tr("Ignore List"))
|
self.setWindowTitle(tr("Ignore List"))
|
||||||
self.resize(540, 330)
|
self.resize(540, 330)
|
||||||
self.verticalLayout = QVBoxLayout(self)
|
self.verticalLayout = QVBoxLayout(self)
|
||||||
|
self.verticalLayout.setContentsMargins(0, 0, 0, 0)
|
||||||
self.tableView = QTableView()
|
self.tableView = QTableView()
|
||||||
self.tableView.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
self.tableView.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||||
self.tableView.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
self.tableView.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
||||||
@@ -48,6 +50,7 @@ class IgnoreListDialog(QDialog):
|
|||||||
self.tableView.verticalHeader().setDefaultSectionSize(18)
|
self.tableView.verticalHeader().setDefaultSectionSize(18)
|
||||||
self.tableView.verticalHeader().setHighlightSections(False)
|
self.tableView.verticalHeader().setHighlightSections(False)
|
||||||
self.tableView.verticalHeader().setVisible(False)
|
self.tableView.verticalHeader().setVisible(False)
|
||||||
|
self.tableView.setWordWrap(False)
|
||||||
self.verticalLayout.addWidget(self.tableView)
|
self.verticalLayout.addWidget(self.tableView)
|
||||||
self.removeSelectedButton = QPushButton(tr("Remove Selected"))
|
self.removeSelectedButton = QPushButton(tr("Remove Selected"))
|
||||||
self.clearButton = QPushButton(tr("Clear"))
|
self.clearButton = QPushButton(tr("Clear"))
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ from qtlib.table import Table
|
|||||||
|
|
||||||
|
|
||||||
class IgnoreListTable(Table):
|
class IgnoreListTable(Table):
|
||||||
|
""" Ignore list model"""
|
||||||
|
|
||||||
COLUMNS = [
|
COLUMNS = [
|
||||||
Column("path1", defaultWidth=230),
|
Column("path1", defaultWidth=230),
|
||||||
Column("path2", defaultWidth=230),
|
Column("path2", defaultWidth=230),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||||
|
|
||||||
from PyQt5.QtCore import QSize
|
from PyQt5.QtCore import QSize
|
||||||
from PyQt5.QtWidgets import QVBoxLayout, QAbstractItemView
|
from PyQt5.QtWidgets import QAbstractItemView
|
||||||
|
|
||||||
from hscommon.trans import trget
|
from hscommon.trans import trget
|
||||||
from ..details_dialog import DetailsDialog as DetailsDialogBase
|
from ..details_dialog import DetailsDialog as DetailsDialogBase
|
||||||
@@ -19,11 +19,8 @@ class DetailsDialog(DetailsDialogBase):
|
|||||||
self.setWindowTitle(tr("Details"))
|
self.setWindowTitle(tr("Details"))
|
||||||
self.resize(502, 295)
|
self.resize(502, 295)
|
||||||
self.setMinimumSize(QSize(250, 250))
|
self.setMinimumSize(QSize(250, 250))
|
||||||
self.verticalLayout = QVBoxLayout(self)
|
|
||||||
self.verticalLayout.setSpacing(0)
|
|
||||||
self.verticalLayout.setContentsMargins(0, 0, 0, 0)
|
|
||||||
self.tableView = DetailsTable(self)
|
self.tableView = DetailsTable(self)
|
||||||
self.tableView.setAlternatingRowColors(True)
|
self.tableView.setAlternatingRowColors(True)
|
||||||
self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows)
|
self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||||
self.tableView.setShowGrid(False)
|
self.tableView.setShowGrid(False)
|
||||||
self.verticalLayout.addWidget(self.tableView)
|
self.setWidget(self.tableView)
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class PreferencesDialog(PreferencesDialogBase):
|
|||||||
self.widgetsVLayout.addWidget(self.debugModeBox)
|
self.widgetsVLayout.addWidget(self.debugModeBox)
|
||||||
self._setupBottomPart()
|
self._setupBottomPart()
|
||||||
|
|
||||||
def _load(self, prefs, setchecked):
|
def _load(self, prefs, setchecked, section):
|
||||||
setchecked(self.tagTrackBox, prefs.scan_tag_track)
|
setchecked(self.tagTrackBox, prefs.scan_tag_track)
|
||||||
setchecked(self.tagArtistBox, prefs.scan_tag_artist)
|
setchecked(self.tagArtistBox, prefs.scan_tag_artist)
|
||||||
setchecked(self.tagAlbumBox, prefs.scan_tag_album)
|
setchecked(self.tagAlbumBox, prefs.scan_tag_album)
|
||||||
|
|||||||
@@ -4,115 +4,142 @@
|
|||||||
# 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 PyQt5.QtCore import Qt, QSize
|
from PyQt5.QtCore import Qt, QSize, pyqtSignal, pyqtSlot
|
||||||
from PyQt5.QtGui import QPixmap
|
|
||||||
from PyQt5.QtWidgets import (
|
from PyQt5.QtWidgets import (
|
||||||
QVBoxLayout,
|
QAbstractItemView, QSizePolicy, QGridLayout, QSplitter, QFrame)
|
||||||
QAbstractItemView,
|
from PyQt5.QtGui import QResizeEvent
|
||||||
QHBoxLayout,
|
|
||||||
QLabel,
|
|
||||||
QSizePolicy,
|
|
||||||
)
|
|
||||||
|
|
||||||
from hscommon.trans import trget
|
from hscommon.trans import trget
|
||||||
from ..details_dialog import DetailsDialog as DetailsDialogBase
|
from ..details_dialog import DetailsDialog as DetailsDialogBase
|
||||||
from ..details_table import DetailsTable
|
from ..details_table import DetailsTable
|
||||||
|
from .image_viewer import (
|
||||||
|
ViewerToolBar, ScrollAreaImageViewer, ScrollAreaController)
|
||||||
tr = trget("ui")
|
tr = trget("ui")
|
||||||
|
|
||||||
|
|
||||||
class DetailsDialog(DetailsDialogBase):
|
class DetailsDialog(DetailsDialogBase):
|
||||||
def __init__(self, parent, app):
|
def __init__(self, parent, app):
|
||||||
DetailsDialogBase.__init__(self, parent, app)
|
self.vController = None
|
||||||
self.selectedPixmap = None
|
self.app = app
|
||||||
self.referencePixmap = None
|
super().__init__(parent, app)
|
||||||
|
|
||||||
def _setupUi(self):
|
def _setupUi(self):
|
||||||
self.setWindowTitle(tr("Details"))
|
self.setWindowTitle(tr("Details"))
|
||||||
self.resize(502, 295)
|
self.resize(502, 502)
|
||||||
self.setMinimumSize(QSize(250, 250))
|
self.setMinimumSize(QSize(250, 250))
|
||||||
self.verticalLayout = QVBoxLayout(self)
|
self.splitter = QSplitter(Qt.Vertical)
|
||||||
self.verticalLayout.setSpacing(0)
|
self.topFrame = EmittingFrame()
|
||||||
self.verticalLayout.setContentsMargins(0, 0, 0, 0)
|
self.topFrame.setFrameShape(QFrame.StyledPanel)
|
||||||
self.horizontalLayout = QHBoxLayout()
|
self.horizontalLayout = QGridLayout()
|
||||||
self.horizontalLayout.setSpacing(4)
|
# Minimum width for the toolbar in the middle:
|
||||||
self.selectedImage = QLabel(self)
|
self.horizontalLayout.setColumnMinimumWidth(1, 10)
|
||||||
sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored)
|
self.horizontalLayout.setContentsMargins(0, 0, 0, 0)
|
||||||
sizePolicy.setHorizontalStretch(0)
|
self.horizontalLayout.setColumnStretch(0, 32)
|
||||||
sizePolicy.setVerticalStretch(0)
|
# Smaller value for the toolbar in the middle to avoid excessive resize
|
||||||
sizePolicy.setHeightForWidth(
|
self.horizontalLayout.setColumnStretch(1, 2)
|
||||||
self.selectedImage.sizePolicy().hasHeightForWidth()
|
self.horizontalLayout.setColumnStretch(2, 32)
|
||||||
)
|
# This avoids toolbar getting incorrectly partially hidden when window resizes
|
||||||
self.selectedImage.setSizePolicy(sizePolicy)
|
self.horizontalLayout.setRowStretch(0, 1)
|
||||||
self.selectedImage.setScaledContents(False)
|
self.horizontalLayout.setRowStretch(1, 24)
|
||||||
self.selectedImage.setAlignment(Qt.AlignCenter)
|
self.horizontalLayout.setRowStretch(2, 1)
|
||||||
self.horizontalLayout.addWidget(self.selectedImage)
|
self.horizontalLayout.setSpacing(1) # probably not important
|
||||||
self.referenceImage = QLabel(self)
|
|
||||||
sizePolicy = QSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored)
|
self.selectedImageViewer = ScrollAreaImageViewer(self, "selectedImage")
|
||||||
sizePolicy.setHorizontalStretch(0)
|
self.horizontalLayout.addWidget(self.selectedImageViewer, 0, 0, 3, 1)
|
||||||
sizePolicy.setVerticalStretch(0)
|
# Use a specific type of controller depending on the underlying viewer type
|
||||||
sizePolicy.setHeightForWidth(
|
self.vController = ScrollAreaController(self)
|
||||||
self.referenceImage.sizePolicy().hasHeightForWidth()
|
|
||||||
)
|
self.verticalToolBar = ViewerToolBar(self, self.vController)
|
||||||
self.referenceImage.setSizePolicy(sizePolicy)
|
self.verticalToolBar.setOrientation(Qt.Orientation(Qt.Vertical))
|
||||||
self.referenceImage.setAlignment(Qt.AlignCenter)
|
self.horizontalLayout.addWidget(self.verticalToolBar, 1, 1, 1, 1, Qt.AlignCenter)
|
||||||
self.horizontalLayout.addWidget(self.referenceImage)
|
|
||||||
self.verticalLayout.addLayout(self.horizontalLayout)
|
self.referenceImageViewer = ScrollAreaImageViewer(self, "referenceImage")
|
||||||
|
self.horizontalLayout.addWidget(self.referenceImageViewer, 0, 2, 3, 1)
|
||||||
|
self.topFrame.setLayout(self.horizontalLayout)
|
||||||
|
self.splitter.addWidget(self.topFrame)
|
||||||
|
self.splitter.setStretchFactor(0, 8)
|
||||||
|
|
||||||
self.tableView = DetailsTable(self)
|
self.tableView = DetailsTable(self)
|
||||||
sizePolicy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
sizePolicy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Maximum)
|
||||||
sizePolicy.setHorizontalStretch(0)
|
sizePolicy.setHorizontalStretch(0)
|
||||||
sizePolicy.setVerticalStretch(0)
|
sizePolicy.setVerticalStretch(0)
|
||||||
sizePolicy.setHeightForWidth(self.tableView.sizePolicy().hasHeightForWidth())
|
|
||||||
self.tableView.setSizePolicy(sizePolicy)
|
self.tableView.setSizePolicy(sizePolicy)
|
||||||
self.tableView.setMinimumSize(QSize(0, 188))
|
# self.tableView.setMinimumSize(QSize(0, 190))
|
||||||
self.tableView.setMaximumSize(QSize(16777215, 190))
|
# self.tableView.setMaximumSize(QSize(16777215, 190))
|
||||||
self.tableView.setAlternatingRowColors(True)
|
self.tableView.setAlternatingRowColors(True)
|
||||||
self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows)
|
self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||||
self.tableView.setShowGrid(False)
|
self.tableView.setShowGrid(False)
|
||||||
self.verticalLayout.addWidget(self.tableView)
|
self.splitter.addWidget(self.tableView)
|
||||||
|
self.splitter.setStretchFactor(1, 1)
|
||||||
|
# Late population needed here for connections to the toolbar
|
||||||
|
self.vController.setupViewers(
|
||||||
|
self.selectedImageViewer, self.referenceImageViewer)
|
||||||
|
# self.setCentralWidget(self.splitter) # only as QMainWindow
|
||||||
|
self.setWidget(self.splitter) # only as QDockWidget
|
||||||
|
|
||||||
|
self.topFrame.resized.connect(self.resizeEvent)
|
||||||
|
|
||||||
def _update(self):
|
def _update(self):
|
||||||
|
if self.vController is None: # Not yet constructed!
|
||||||
|
return
|
||||||
if not self.app.model.selected_dupes:
|
if not self.app.model.selected_dupes:
|
||||||
|
# No item from the model, disable and clear everything.
|
||||||
|
self.vController.resetViewersState()
|
||||||
return
|
return
|
||||||
dupe = self.app.model.selected_dupes[0]
|
dupe = self.app.model.selected_dupes[0]
|
||||||
group = self.app.model.results.get_group_of_duplicate(dupe)
|
group = self.app.model.results.get_group_of_duplicate(dupe)
|
||||||
ref = group.ref
|
ref = group.ref
|
||||||
|
|
||||||
self.selectedPixmap = QPixmap(str(dupe.path))
|
self.vController.updateView(ref, dupe, group)
|
||||||
if ref is dupe:
|
|
||||||
self.referencePixmap = None
|
|
||||||
else:
|
|
||||||
self.referencePixmap = QPixmap(str(ref.path))
|
|
||||||
self._updateImages()
|
|
||||||
|
|
||||||
def _updateImages(self):
|
|
||||||
if self.selectedPixmap is not None:
|
|
||||||
target_size = self.selectedImage.size()
|
|
||||||
scaledPixmap = self.selectedPixmap.scaled(
|
|
||||||
target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation
|
|
||||||
)
|
|
||||||
self.selectedImage.setPixmap(scaledPixmap)
|
|
||||||
else:
|
|
||||||
self.selectedImage.setPixmap(QPixmap())
|
|
||||||
if self.referencePixmap is not None:
|
|
||||||
target_size = self.referenceImage.size()
|
|
||||||
scaledPixmap = self.referencePixmap.scaled(
|
|
||||||
target_size, Qt.KeepAspectRatio, Qt.SmoothTransformation
|
|
||||||
)
|
|
||||||
self.referenceImage.setPixmap(scaledPixmap)
|
|
||||||
else:
|
|
||||||
self.referenceImage.setPixmap(QPixmap())
|
|
||||||
|
|
||||||
# --- Override
|
# --- Override
|
||||||
|
@pyqtSlot(QResizeEvent)
|
||||||
def resizeEvent(self, event):
|
def resizeEvent(self, event):
|
||||||
self._updateImages()
|
self.ensure_same_sizes()
|
||||||
|
if self.vController is None or not self.vController.bestFit:
|
||||||
|
return
|
||||||
|
# Only update the scaled down pixmaps
|
||||||
|
self.vController.updateBothImages()
|
||||||
|
|
||||||
def show(self):
|
def show(self):
|
||||||
|
# Give the splitter a maximum height to reach. This is assuming that
|
||||||
|
# all rows below their headers have the same height
|
||||||
|
self.tableView.setMaximumHeight(
|
||||||
|
self.tableView.rowHeight(1)
|
||||||
|
* self.tableModel.model.row_count()
|
||||||
|
+ self.tableView.verticalHeader().sectionSize(0)
|
||||||
|
# looks like the handle is taken into account by the splitter
|
||||||
|
+ self.splitter.handle(1).size().height())
|
||||||
DetailsDialogBase.show(self)
|
DetailsDialogBase.show(self)
|
||||||
|
self.ensure_same_sizes()
|
||||||
self._update()
|
self._update()
|
||||||
|
|
||||||
|
def ensure_same_sizes(self):
|
||||||
|
# HACK This ensures same size while shrinking.
|
||||||
|
# ReferenceViewer might be 1 pixel shorter in width
|
||||||
|
# due to the toolbar in the middle keeping the same width,
|
||||||
|
# so resizing in the GridLayout's engine leads to not enough space
|
||||||
|
# left for the panel on the right.
|
||||||
|
# This work as a QMainWindow, but doesn't work as a QDockWidget:
|
||||||
|
# resize can only grow. Might need some custom sizeHint somewhere...
|
||||||
|
# self.horizontalLayout.setColumnMinimumWidth(
|
||||||
|
# 0, self.selectedImageViewer.size().width())
|
||||||
|
# self.horizontalLayout.setColumnMinimumWidth(
|
||||||
|
# 2, self.selectedImageViewer.size().width())
|
||||||
|
|
||||||
|
# This works when expanding but it's ugly:
|
||||||
|
if self.selectedImageViewer.size().width() > self.referenceImageViewer.size().width():
|
||||||
|
self.selectedImageViewer.resize(self.referenceImageViewer.size())
|
||||||
|
|
||||||
# model --> view
|
# model --> view
|
||||||
def refresh(self):
|
def refresh(self):
|
||||||
DetailsDialogBase.refresh(self)
|
DetailsDialogBase.refresh(self)
|
||||||
if self.isVisible():
|
if self.isVisible():
|
||||||
self._update()
|
self._update()
|
||||||
|
|
||||||
|
|
||||||
|
class EmittingFrame(QFrame):
|
||||||
|
"""Emits a signal whenever is resized"""
|
||||||
|
resized = pyqtSignal(QResizeEvent)
|
||||||
|
|
||||||
|
def resizeEvent(self, event):
|
||||||
|
self.resized.emit(event)
|
||||||
|
|||||||
1370
qt/pe/image_viewer.py
Normal file
@@ -29,6 +29,15 @@ class File(PhotoBase):
|
|||||||
def _plat_get_blocks(self, block_count_per_side, orientation):
|
def _plat_get_blocks(self, block_count_per_side, orientation):
|
||||||
image = QImage(str(self.path))
|
image = QImage(str(self.path))
|
||||||
image = image.convertToFormat(QImage.Format_RGB888)
|
image = image.convertToFormat(QImage.Format_RGB888)
|
||||||
|
if type(orientation) == str:
|
||||||
|
logging.warning("Orientation for file '%s' was a str '%s', not an int.",
|
||||||
|
str(self.path), orientation)
|
||||||
|
try:
|
||||||
|
orientation = int(orientation)
|
||||||
|
except Exception as e:
|
||||||
|
logging.exception("Skipping transformation because could not \
|
||||||
|
convert str to int. %s", e)
|
||||||
|
return getblocks(image, block_count_per_side)
|
||||||
# MYSTERY TO SOLVE: For reasons I cannot explain, orientations 5 and 7 don't work for
|
# MYSTERY TO SOLVE: For reasons I cannot explain, orientations 5 and 7 don't work for
|
||||||
# duplicate scanning. The transforms seems to work fine (if I try to save the image after
|
# duplicate scanning. The transforms seems to work fine (if I try to save the image after
|
||||||
# the transform, we see that the image has been correctly flipped and rotated), but the
|
# the transform, we see that the image has been correctly flipped and rotated), but the
|
||||||
|
|||||||
@@ -4,8 +4,10 @@
|
|||||||
# 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 PyQt5.QtWidgets import QLabel
|
from PyQt5.QtWidgets import QFormLayout
|
||||||
|
from PyQt5.QtCore import Qt
|
||||||
from hscommon.trans import trget
|
from hscommon.trans import trget
|
||||||
|
from hscommon.plat import ISLINUX
|
||||||
from qtlib.radio_box import RadioBox
|
from qtlib.radio_box import RadioBox
|
||||||
from core.scanner import ScanType
|
from core.scanner import ScanType
|
||||||
from core.app import AppMode
|
from core.app import AppMode
|
||||||
@@ -40,12 +42,35 @@ class PreferencesDialog(PreferencesDialogBase):
|
|||||||
self.widgetsVLayout.addWidget(self.ignoreHardlinkMatches)
|
self.widgetsVLayout.addWidget(self.ignoreHardlinkMatches)
|
||||||
self._setupAddCheckbox("debugModeBox", tr("Debug mode (restart required)"))
|
self._setupAddCheckbox("debugModeBox", tr("Debug mode (restart required)"))
|
||||||
self.widgetsVLayout.addWidget(self.debugModeBox)
|
self.widgetsVLayout.addWidget(self.debugModeBox)
|
||||||
self.widgetsVLayout.addWidget(QLabel(tr("Picture cache mode:")))
|
|
||||||
self.cacheTypeRadio = RadioBox(self, items=["Sqlite", "Shelve"], spread=False)
|
self.cacheTypeRadio = RadioBox(self, items=["Sqlite", "Shelve"], spread=False)
|
||||||
self.widgetsVLayout.addWidget(self.cacheTypeRadio)
|
cache_form = QFormLayout()
|
||||||
|
cache_form.setLabelAlignment(Qt.AlignLeft)
|
||||||
|
cache_form.addRow(tr("Picture cache mode:"), self.cacheTypeRadio)
|
||||||
|
self.widgetsVLayout.addLayout(cache_form)
|
||||||
self._setupBottomPart()
|
self._setupBottomPart()
|
||||||
|
|
||||||
def _load(self, prefs, setchecked):
|
def _setupDisplayPage(self):
|
||||||
|
super()._setupDisplayPage()
|
||||||
|
self._setupAddCheckbox("details_dialog_override_theme_icons",
|
||||||
|
tr("Override theme icons in viewer toolbar"))
|
||||||
|
self.details_dialog_override_theme_icons.setToolTip(
|
||||||
|
tr("Use our own internal icons instead of those provided by the theme engine"))
|
||||||
|
# Prevent changing this on platforms where themes are unpredictable
|
||||||
|
self.details_dialog_override_theme_icons.setEnabled(False if not ISLINUX else True)
|
||||||
|
# Insert this right after the vertical title bar option
|
||||||
|
index = self.details_groupbox_layout.indexOf(self.details_dialog_vertical_titlebar)
|
||||||
|
self.details_groupbox_layout.insertWidget(
|
||||||
|
index + 1, self.details_dialog_override_theme_icons)
|
||||||
|
self._setupAddCheckbox("details_dialog_viewers_show_scrollbars",
|
||||||
|
tr("Show scrollbars in image viewers"))
|
||||||
|
self.details_dialog_viewers_show_scrollbars.setToolTip(
|
||||||
|
tr("When the image displayed doesn't fit the viewport, \
|
||||||
|
show scrollbars to span the view around"))
|
||||||
|
self.details_groupbox_layout.insertWidget(
|
||||||
|
index + 2, self.details_dialog_viewers_show_scrollbars)
|
||||||
|
|
||||||
|
def _load(self, prefs, setchecked, section):
|
||||||
setchecked(self.matchScaledBox, prefs.match_scaled)
|
setchecked(self.matchScaledBox, prefs.match_scaled)
|
||||||
self.cacheTypeRadio.selected_index = (
|
self.cacheTypeRadio.selected_index = (
|
||||||
1 if prefs.picture_cache_type == "shelve" else 0
|
1 if prefs.picture_cache_type == "shelve" else 0
|
||||||
@@ -55,9 +80,17 @@ class PreferencesDialog(PreferencesDialogBase):
|
|||||||
scan_type = prefs.get_scan_type(AppMode.Picture)
|
scan_type = prefs.get_scan_type(AppMode.Picture)
|
||||||
fuzzy_scan = scan_type == ScanType.FuzzyBlock
|
fuzzy_scan = scan_type == ScanType.FuzzyBlock
|
||||||
self.filterHardnessSlider.setEnabled(fuzzy_scan)
|
self.filterHardnessSlider.setEnabled(fuzzy_scan)
|
||||||
|
setchecked(self.details_dialog_override_theme_icons,
|
||||||
|
prefs.details_dialog_override_theme_icons)
|
||||||
|
setchecked(self.details_dialog_viewers_show_scrollbars,
|
||||||
|
prefs.details_dialog_viewers_show_scrollbars)
|
||||||
|
|
||||||
def _save(self, prefs, ischecked):
|
def _save(self, prefs, ischecked):
|
||||||
prefs.match_scaled = ischecked(self.matchScaledBox)
|
prefs.match_scaled = ischecked(self.matchScaledBox)
|
||||||
prefs.picture_cache_type = (
|
prefs.picture_cache_type = (
|
||||||
"shelve" if self.cacheTypeRadio.selected_index == 1 else "sqlite"
|
"shelve" if self.cacheTypeRadio.selected_index == 1 else "sqlite"
|
||||||
)
|
)
|
||||||
|
prefs.details_dialog_override_theme_icons =\
|
||||||
|
ischecked(self.details_dialog_override_theme_icons)
|
||||||
|
prefs.details_dialog_viewers_show_scrollbars =\
|
||||||
|
ischecked(self.details_dialog_viewers_show_scrollbars)
|
||||||
|
|||||||
@@ -5,8 +5,11 @@
|
|||||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||||
|
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PyQt5.QtWidgets import QApplication
|
||||||
|
from PyQt5.QtCore import Qt
|
||||||
|
from PyQt5.QtGui import QColor
|
||||||
|
|
||||||
from hscommon import trans
|
from hscommon import trans
|
||||||
|
from hscommon.plat import ISLINUX
|
||||||
from core.app import AppMode
|
from core.app import AppMode
|
||||||
from core.scanner import ScanType
|
from core.scanner import ScanType
|
||||||
from qtlib.preferences import Preferences as PreferencesBase
|
from qtlib.preferences import Preferences as PreferencesBase
|
||||||
@@ -30,16 +33,42 @@ class Preferences(PreferencesBase):
|
|||||||
self.language = trans.installed_lang
|
self.language = trans.installed_lang
|
||||||
|
|
||||||
self.tableFontSize = get("TableFontSize", self.tableFontSize)
|
self.tableFontSize = get("TableFontSize", self.tableFontSize)
|
||||||
|
self.reference_bold_font = get("ReferenceBoldFont", self.reference_bold_font)
|
||||||
|
self.details_dialog_titlebar_enabled = get("DetailsDialogTitleBarEnabled",
|
||||||
|
self.details_dialog_titlebar_enabled)
|
||||||
|
self.details_dialog_vertical_titlebar = get("DetailsDialogVerticalTitleBar",
|
||||||
|
self.details_dialog_vertical_titlebar)
|
||||||
|
# On Windows and MacOS, use internal icons by default
|
||||||
|
self.details_dialog_override_theme_icons =\
|
||||||
|
get("DetailsDialogOverrideThemeIcons",
|
||||||
|
self.details_dialog_override_theme_icons) if ISLINUX else True
|
||||||
|
self.details_table_delta_foreground_color =\
|
||||||
|
get("DetailsTableDeltaForegroundColor", self.details_table_delta_foreground_color)
|
||||||
|
self.details_dialog_viewers_show_scrollbars =\
|
||||||
|
get("DetailsDialogViewersShowScrollbars", self.details_dialog_viewers_show_scrollbars)
|
||||||
|
|
||||||
|
self.result_table_ref_foreground_color =\
|
||||||
|
get("ResultTableRefForegroundColor", self.result_table_ref_foreground_color)
|
||||||
|
self.result_table_ref_background_color =\
|
||||||
|
get("ResultTableRefBackgroundColor", self.result_table_ref_background_color)
|
||||||
|
self.result_table_delta_foreground_color =\
|
||||||
|
get("ResultTableDeltaForegroundColor", self.result_table_delta_foreground_color)
|
||||||
|
|
||||||
self.resultWindowIsMaximized = get(
|
self.resultWindowIsMaximized = get(
|
||||||
"ResultWindowIsMaximized", self.resultWindowIsMaximized
|
"ResultWindowIsMaximized", self.resultWindowIsMaximized
|
||||||
)
|
)
|
||||||
self.resultWindowRect = self.get_rect("ResultWindowRect", self.resultWindowRect)
|
self.resultWindowRect = self.get_rect("ResultWindowRect", self.resultWindowRect)
|
||||||
|
self.mainWindowIsMaximized = get(
|
||||||
|
"MainWindowIsMaximized", self.mainWindowIsMaximized
|
||||||
|
)
|
||||||
|
self.mainWindowRect = self.get_rect("MainWindowRect", self.mainWindowRect)
|
||||||
self.directoriesWindowRect = self.get_rect(
|
self.directoriesWindowRect = self.get_rect(
|
||||||
"DirectoriesWindowRect", self.directoriesWindowRect
|
"DirectoriesWindowRect", self.directoriesWindowRect
|
||||||
)
|
)
|
||||||
|
|
||||||
self.recentResults = get("RecentResults", self.recentResults)
|
self.recentResults = get("RecentResults", self.recentResults)
|
||||||
self.recentFolders = get("RecentFolders", self.recentFolders)
|
self.recentFolders = get("RecentFolders", self.recentFolders)
|
||||||
|
self.tabs_default_pos = get("TabsDefaultPosition", self.tabs_default_pos)
|
||||||
self.word_weighting = get("WordWeighting", self.word_weighting)
|
self.word_weighting = get("WordWeighting", self.word_weighting)
|
||||||
self.match_similar = get("MatchSimilar", self.match_similar)
|
self.match_similar = get("MatchSimilar", self.match_similar)
|
||||||
self.ignore_small_files = get("IgnoreSmallFiles", self.ignore_small_files)
|
self.ignore_small_files = get("IgnoreSmallFiles", self.ignore_small_files)
|
||||||
@@ -65,12 +94,25 @@ class Preferences(PreferencesBase):
|
|||||||
self.language = trans.installed_lang if trans.installed_lang else ""
|
self.language = trans.installed_lang if trans.installed_lang else ""
|
||||||
|
|
||||||
self.tableFontSize = QApplication.font().pointSize()
|
self.tableFontSize = QApplication.font().pointSize()
|
||||||
|
self.reference_bold_font = True
|
||||||
|
self.details_dialog_titlebar_enabled = True
|
||||||
|
self.details_dialog_vertical_titlebar = True
|
||||||
|
self.details_table_delta_foreground_color = QColor(250, 20, 20) # red
|
||||||
|
# By default use internal icons on platforms other than Linux for now
|
||||||
|
self.details_dialog_override_theme_icons = False if not ISLINUX else True
|
||||||
|
self.details_dialog_viewers_show_scrollbars = True
|
||||||
|
self.result_table_ref_foreground_color = QColor(Qt.blue)
|
||||||
|
self.result_table_ref_background_color = QColor(Qt.darkGray)
|
||||||
|
self.result_table_delta_foreground_color = QColor(255, 142, 40) # orange
|
||||||
self.resultWindowIsMaximized = False
|
self.resultWindowIsMaximized = False
|
||||||
self.resultWindowRect = None
|
self.resultWindowRect = None
|
||||||
self.directoriesWindowRect = None
|
self.directoriesWindowRect = None
|
||||||
|
self.mainWindowRect = None
|
||||||
|
self.mainWindowIsMaximized = False
|
||||||
self.recentResults = []
|
self.recentResults = []
|
||||||
self.recentFolders = []
|
self.recentFolders = []
|
||||||
|
|
||||||
|
self.tabs_default_pos = True
|
||||||
self.word_weighting = True
|
self.word_weighting = True
|
||||||
self.match_similar = False
|
self.match_similar = False
|
||||||
self.ignore_small_files = True
|
self.ignore_small_files = True
|
||||||
@@ -97,12 +139,24 @@ class Preferences(PreferencesBase):
|
|||||||
set_("Language", self.language)
|
set_("Language", self.language)
|
||||||
|
|
||||||
set_("TableFontSize", self.tableFontSize)
|
set_("TableFontSize", self.tableFontSize)
|
||||||
|
set_("ReferenceBoldFont", self.reference_bold_font)
|
||||||
|
set_("DetailsDialogTitleBarEnabled", self.details_dialog_titlebar_enabled)
|
||||||
|
set_("DetailsDialogVerticalTitleBar", self.details_dialog_vertical_titlebar)
|
||||||
|
set_("DetailsDialogOverrideThemeIcons", self.details_dialog_override_theme_icons)
|
||||||
|
set_("DetailsDialogViewersShowScrollbars", self.details_dialog_viewers_show_scrollbars)
|
||||||
|
set_("DetailsTableDeltaForegroundColor", self.details_table_delta_foreground_color)
|
||||||
|
set_("ResultTableRefForegroundColor", self.result_table_ref_foreground_color)
|
||||||
|
set_("ResultTableRefBackgroundColor", self.result_table_ref_background_color)
|
||||||
|
set_("ResultTableDeltaForegroundColor", self.result_table_delta_foreground_color)
|
||||||
set_("ResultWindowIsMaximized", self.resultWindowIsMaximized)
|
set_("ResultWindowIsMaximized", self.resultWindowIsMaximized)
|
||||||
|
set_("MainWindowIsMaximized", self.mainWindowIsMaximized)
|
||||||
self.set_rect("ResultWindowRect", self.resultWindowRect)
|
self.set_rect("ResultWindowRect", self.resultWindowRect)
|
||||||
|
self.set_rect("MainWindowRect", self.mainWindowRect)
|
||||||
self.set_rect("DirectoriesWindowRect", self.directoriesWindowRect)
|
self.set_rect("DirectoriesWindowRect", self.directoriesWindowRect)
|
||||||
set_("RecentResults", self.recentResults)
|
set_("RecentResults", self.recentResults)
|
||||||
set_("RecentFolders", self.recentFolders)
|
set_("RecentFolders", self.recentFolders)
|
||||||
|
|
||||||
|
set_("TabsDefaultPosition", self.tabs_default_pos)
|
||||||
set_("WordWeighting", self.word_weighting)
|
set_("WordWeighting", self.word_weighting)
|
||||||
set_("MatchSimilar", self.match_similar)
|
set_("MatchSimilar", self.match_similar)
|
||||||
set_("IgnoreSmallFiles", self.ignore_small_files)
|
set_("IgnoreSmallFiles", self.ignore_small_files)
|
||||||
|
|||||||
@@ -4,12 +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 PyQt5.QtCore import Qt, QSize
|
from PyQt5.QtCore import Qt, QSize, pyqtSlot
|
||||||
from PyQt5.QtWidgets import (
|
from PyQt5.QtWidgets import (
|
||||||
QDialog,
|
QDialog,
|
||||||
QDialogButtonBox,
|
QDialogButtonBox,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
|
QGridLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QSlider,
|
QSlider,
|
||||||
@@ -20,11 +21,20 @@ from PyQt5.QtWidgets import (
|
|||||||
QMessageBox,
|
QMessageBox,
|
||||||
QSpinBox,
|
QSpinBox,
|
||||||
QLayout,
|
QLayout,
|
||||||
|
QTabWidget,
|
||||||
|
QWidget,
|
||||||
|
QColorDialog,
|
||||||
|
QPushButton,
|
||||||
|
QGroupBox,
|
||||||
|
QFormLayout,
|
||||||
)
|
)
|
||||||
|
from PyQt5.QtGui import QPixmap, QIcon
|
||||||
|
|
||||||
from hscommon.trans import trget
|
from hscommon.trans import trget
|
||||||
|
from hscommon.plat import ISLINUX
|
||||||
from qtlib.util import horizontalWrap
|
from qtlib.util import horizontalWrap
|
||||||
from qtlib.preferences import get_langnames
|
from qtlib.preferences import get_langnames
|
||||||
|
from enum import Flag, auto
|
||||||
|
|
||||||
from .preferences import Preferences
|
from .preferences import Preferences
|
||||||
|
|
||||||
@@ -50,6 +60,13 @@ SUPPORTED_LANGUAGES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Sections(Flag):
|
||||||
|
"""Filter blocks of preferences when reset or loaded"""
|
||||||
|
GENERAL = auto()
|
||||||
|
DISPLAY = auto()
|
||||||
|
ALL = GENERAL | DISPLAY
|
||||||
|
|
||||||
|
|
||||||
class PreferencesDialogBase(QDialog):
|
class PreferencesDialogBase(QDialog):
|
||||||
def __init__(self, parent, app, **kwargs):
|
def __init__(self, parent, app, **kwargs):
|
||||||
flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint
|
flags = Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowSystemMenuHint
|
||||||
@@ -111,19 +128,6 @@ class PreferencesDialogBase(QDialog):
|
|||||||
|
|
||||||
def _setupBottomPart(self):
|
def _setupBottomPart(self):
|
||||||
# The bottom part of the pref panel is always the same in all editions.
|
# The bottom part of the pref panel is always the same in all editions.
|
||||||
self.fontSizeLabel = QLabel(tr("Font size:"))
|
|
||||||
self.fontSizeSpinBox = QSpinBox()
|
|
||||||
self.fontSizeSpinBox.setMinimum(5)
|
|
||||||
self.widgetsVLayout.addLayout(
|
|
||||||
horizontalWrap([self.fontSizeLabel, self.fontSizeSpinBox, None])
|
|
||||||
)
|
|
||||||
self.languageLabel = QLabel(tr("Language:"), self)
|
|
||||||
self.languageComboBox = QComboBox(self)
|
|
||||||
for lang in self.supportedLanguages:
|
|
||||||
self.languageComboBox.addItem(get_langnames()[lang])
|
|
||||||
self.widgetsVLayout.addLayout(
|
|
||||||
horizontalWrap([self.languageLabel, self.languageComboBox, None])
|
|
||||||
)
|
|
||||||
self.copyMoveLabel = QLabel(self)
|
self.copyMoveLabel = QLabel(self)
|
||||||
self.copyMoveLabel.setText(tr("Copy and Move:"))
|
self.copyMoveLabel.setText(tr("Copy and Move:"))
|
||||||
self.widgetsVLayout.addWidget(self.copyMoveLabel)
|
self.widgetsVLayout.addWidget(self.copyMoveLabel)
|
||||||
@@ -140,6 +144,81 @@ class PreferencesDialogBase(QDialog):
|
|||||||
self.customCommandEdit = QLineEdit(self)
|
self.customCommandEdit = QLineEdit(self)
|
||||||
self.widgetsVLayout.addWidget(self.customCommandEdit)
|
self.widgetsVLayout.addWidget(self.customCommandEdit)
|
||||||
|
|
||||||
|
def _setupDisplayPage(self):
|
||||||
|
self.ui_groupbox = QGroupBox("&General Interface")
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
self.languageLabel = QLabel(tr("Language:"), self)
|
||||||
|
self.languageComboBox = QComboBox(self)
|
||||||
|
for lang in self.supportedLanguages:
|
||||||
|
self.languageComboBox.addItem(get_langnames()[lang])
|
||||||
|
layout.addLayout(horizontalWrap([self.languageLabel, self.languageComboBox, None]))
|
||||||
|
self._setupAddCheckbox("tabs_default_pos",
|
||||||
|
tr("Use default position for tab bar (requires restart)"))
|
||||||
|
self.tabs_default_pos.setToolTip(
|
||||||
|
tr("Place the tab bar below the main menu instead of next to it\n\
|
||||||
|
On MacOS, the tab bar will fill up the window's width instead."))
|
||||||
|
layout.addWidget(self.tabs_default_pos)
|
||||||
|
self.ui_groupbox.setLayout(layout)
|
||||||
|
self.displayVLayout.addWidget(self.ui_groupbox)
|
||||||
|
|
||||||
|
gridlayout = QGridLayout()
|
||||||
|
gridlayout.setColumnStretch(2, 2)
|
||||||
|
formlayout = QFormLayout()
|
||||||
|
result_groupbox = QGroupBox("&Result Table")
|
||||||
|
self.fontSizeSpinBox = QSpinBox()
|
||||||
|
self.fontSizeSpinBox.setMinimum(5)
|
||||||
|
formlayout.addRow(tr("Font size:"), self.fontSizeSpinBox)
|
||||||
|
self._setupAddCheckbox("reference_bold_font",
|
||||||
|
tr("Use bold font for references"))
|
||||||
|
formlayout.addRow(self.reference_bold_font)
|
||||||
|
|
||||||
|
self.result_table_ref_foreground_color = ColorPickerButton(self)
|
||||||
|
formlayout.addRow(tr("Reference foreground color:"),
|
||||||
|
self.result_table_ref_foreground_color)
|
||||||
|
self.result_table_ref_background_color = ColorPickerButton(self)
|
||||||
|
formlayout.addRow(tr("Reference background color:"),
|
||||||
|
self.result_table_ref_background_color)
|
||||||
|
self.result_table_delta_foreground_color = ColorPickerButton(self)
|
||||||
|
formlayout.addRow(tr("Delta foreground color:"),
|
||||||
|
self.result_table_delta_foreground_color)
|
||||||
|
formlayout.setLabelAlignment(Qt.AlignLeft)
|
||||||
|
|
||||||
|
# Keep same vertical spacing as parent layout for consistency
|
||||||
|
formlayout.setVerticalSpacing(self.displayVLayout.spacing())
|
||||||
|
gridlayout.addLayout(formlayout, 0, 0)
|
||||||
|
result_groupbox.setLayout(gridlayout)
|
||||||
|
self.displayVLayout.addWidget(result_groupbox)
|
||||||
|
|
||||||
|
details_groupbox = QGroupBox("&Details Window")
|
||||||
|
self.details_groupbox_layout = QVBoxLayout()
|
||||||
|
self._setupAddCheckbox("details_dialog_titlebar_enabled",
|
||||||
|
tr("Show the title bar and can be docked"))
|
||||||
|
self.details_dialog_titlebar_enabled.setToolTip(
|
||||||
|
tr("While the title bar is hidden, \
|
||||||
|
use the modifier key to drag the floating window around") if ISLINUX else
|
||||||
|
tr("The title bar can only be disabled while the window is docked"))
|
||||||
|
self.details_groupbox_layout.addWidget(self.details_dialog_titlebar_enabled)
|
||||||
|
self._setupAddCheckbox("details_dialog_vertical_titlebar",
|
||||||
|
tr("Vertical title bar"))
|
||||||
|
self.details_dialog_vertical_titlebar.setToolTip(
|
||||||
|
tr("Change the title bar from horizontal on top, to vertical on the left side"))
|
||||||
|
self.details_groupbox_layout.addWidget(self.details_dialog_vertical_titlebar)
|
||||||
|
self.details_dialog_vertical_titlebar.setEnabled(
|
||||||
|
self.details_dialog_titlebar_enabled.isChecked())
|
||||||
|
self.details_dialog_titlebar_enabled.stateChanged.connect(
|
||||||
|
self.details_dialog_vertical_titlebar.setEnabled)
|
||||||
|
gridlayout = QGridLayout()
|
||||||
|
formlayout = QFormLayout()
|
||||||
|
self.details_table_delta_foreground_color = ColorPickerButton(self)
|
||||||
|
# Padding on the right side and space between label and widget to keep it somewhat consistent across themes
|
||||||
|
gridlayout.setColumnStretch(1, 1)
|
||||||
|
formlayout.setHorizontalSpacing(50)
|
||||||
|
formlayout.addRow(tr("Delta foreground color:"), self.details_table_delta_foreground_color)
|
||||||
|
gridlayout.addLayout(formlayout, 0, 0)
|
||||||
|
self.details_groupbox_layout.addLayout(gridlayout)
|
||||||
|
details_groupbox.setLayout(self.details_groupbox_layout)
|
||||||
|
self.displayVLayout.addWidget(details_groupbox)
|
||||||
|
|
||||||
def _setupAddCheckbox(self, name, label, parent=None):
|
def _setupAddCheckbox(self, name, label, parent=None):
|
||||||
if parent is None:
|
if parent is None:
|
||||||
parent = self
|
parent = self
|
||||||
@@ -156,19 +235,32 @@ class PreferencesDialogBase(QDialog):
|
|||||||
self.setSizeGripEnabled(False)
|
self.setSizeGripEnabled(False)
|
||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
self.mainVLayout = QVBoxLayout(self)
|
self.mainVLayout = QVBoxLayout(self)
|
||||||
|
self.tabwidget = QTabWidget()
|
||||||
|
self.page_general = QWidget()
|
||||||
|
self.page_display = QWidget()
|
||||||
self.widgetsVLayout = QVBoxLayout()
|
self.widgetsVLayout = QVBoxLayout()
|
||||||
|
self.page_general.setLayout(self.widgetsVLayout)
|
||||||
|
self.displayVLayout = QVBoxLayout()
|
||||||
|
self.displayVLayout.setSpacing(5) # arbitrary value, might conflict with style
|
||||||
|
self.page_display.setLayout(self.displayVLayout)
|
||||||
self._setupPreferenceWidgets()
|
self._setupPreferenceWidgets()
|
||||||
self.mainVLayout.addLayout(self.widgetsVLayout)
|
self._setupDisplayPage()
|
||||||
|
# self.mainVLayout.addLayout(self.widgetsVLayout)
|
||||||
self.buttonBox = QDialogButtonBox(self)
|
self.buttonBox = QDialogButtonBox(self)
|
||||||
self.buttonBox.setStandardButtons(
|
self.buttonBox.setStandardButtons(
|
||||||
QDialogButtonBox.Cancel
|
QDialogButtonBox.Cancel
|
||||||
| QDialogButtonBox.Ok
|
| QDialogButtonBox.Ok
|
||||||
| QDialogButtonBox.RestoreDefaults
|
| QDialogButtonBox.RestoreDefaults
|
||||||
)
|
)
|
||||||
|
self.mainVLayout.addWidget(self.tabwidget)
|
||||||
self.mainVLayout.addWidget(self.buttonBox)
|
self.mainVLayout.addWidget(self.buttonBox)
|
||||||
self.layout().setSizeConstraint(QLayout.SetFixedSize)
|
self.layout().setSizeConstraint(QLayout.SetFixedSize)
|
||||||
|
self.tabwidget.addTab(self.page_general, "General")
|
||||||
|
self.tabwidget.addTab(self.page_display, "Display")
|
||||||
|
self.displayVLayout.addStretch(0)
|
||||||
|
self.widgetsVLayout.addStretch(0)
|
||||||
|
|
||||||
def _load(self, prefs, setchecked):
|
def _load(self, prefs, setchecked, section):
|
||||||
# Edition-specific
|
# Edition-specific
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -176,12 +268,13 @@ class PreferencesDialogBase(QDialog):
|
|||||||
# Edition-specific
|
# Edition-specific
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def load(self, prefs=None):
|
def load(self, prefs=None, section=Sections.ALL):
|
||||||
if prefs is None:
|
if prefs is None:
|
||||||
prefs = self.app.prefs
|
prefs = self.app.prefs
|
||||||
|
setchecked = lambda cb, b: cb.setCheckState(Qt.Checked if b else Qt.Unchecked)
|
||||||
|
if section & Sections.GENERAL:
|
||||||
self.filterHardnessSlider.setValue(prefs.filter_hardness)
|
self.filterHardnessSlider.setValue(prefs.filter_hardness)
|
||||||
self.filterHardnessLabel.setNum(prefs.filter_hardness)
|
self.filterHardnessLabel.setNum(prefs.filter_hardness)
|
||||||
setchecked = lambda cb, b: cb.setCheckState(Qt.Checked if b else Qt.Unchecked)
|
|
||||||
setchecked(self.mixFileKindBox, prefs.mix_file_kind)
|
setchecked(self.mixFileKindBox, prefs.mix_file_kind)
|
||||||
setchecked(self.useRegexpBox, prefs.use_regexp)
|
setchecked(self.useRegexpBox, prefs.use_regexp)
|
||||||
setchecked(self.removeEmptyFoldersBox, prefs.remove_empty_folders)
|
setchecked(self.removeEmptyFoldersBox, prefs.remove_empty_folders)
|
||||||
@@ -189,13 +282,28 @@ class PreferencesDialogBase(QDialog):
|
|||||||
setchecked(self.debugModeBox, prefs.debug_mode)
|
setchecked(self.debugModeBox, prefs.debug_mode)
|
||||||
self.copyMoveDestinationComboBox.setCurrentIndex(prefs.destination_type)
|
self.copyMoveDestinationComboBox.setCurrentIndex(prefs.destination_type)
|
||||||
self.customCommandEdit.setText(prefs.custom_command)
|
self.customCommandEdit.setText(prefs.custom_command)
|
||||||
|
if section & Sections.DISPLAY:
|
||||||
|
setchecked(self.reference_bold_font, prefs.reference_bold_font)
|
||||||
|
setchecked(self.tabs_default_pos, prefs.tabs_default_pos)
|
||||||
|
setchecked(self.details_dialog_titlebar_enabled,
|
||||||
|
prefs.details_dialog_titlebar_enabled)
|
||||||
|
setchecked(self.details_dialog_vertical_titlebar,
|
||||||
|
prefs.details_dialog_vertical_titlebar)
|
||||||
self.fontSizeSpinBox.setValue(prefs.tableFontSize)
|
self.fontSizeSpinBox.setValue(prefs.tableFontSize)
|
||||||
|
self.details_table_delta_foreground_color.setColor(
|
||||||
|
prefs.details_table_delta_foreground_color)
|
||||||
|
self.result_table_ref_foreground_color.setColor(
|
||||||
|
prefs.result_table_ref_foreground_color)
|
||||||
|
self.result_table_ref_background_color.setColor(
|
||||||
|
prefs.result_table_ref_background_color)
|
||||||
|
self.result_table_delta_foreground_color.setColor(
|
||||||
|
prefs.result_table_delta_foreground_color)
|
||||||
try:
|
try:
|
||||||
langindex = self.supportedLanguages.index(self.app.prefs.language)
|
langindex = self.supportedLanguages.index(self.app.prefs.language)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
langindex = 0
|
langindex = 0
|
||||||
self.languageComboBox.setCurrentIndex(langindex)
|
self.languageComboBox.setCurrentIndex(langindex)
|
||||||
self._load(prefs, setchecked)
|
self._load(prefs, setchecked, section)
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
prefs = self.app.prefs
|
prefs = self.app.prefs
|
||||||
@@ -206,9 +314,17 @@ class PreferencesDialogBase(QDialog):
|
|||||||
prefs.remove_empty_folders = ischecked(self.removeEmptyFoldersBox)
|
prefs.remove_empty_folders = ischecked(self.removeEmptyFoldersBox)
|
||||||
prefs.ignore_hardlink_matches = ischecked(self.ignoreHardlinkMatches)
|
prefs.ignore_hardlink_matches = ischecked(self.ignoreHardlinkMatches)
|
||||||
prefs.debug_mode = ischecked(self.debugModeBox)
|
prefs.debug_mode = ischecked(self.debugModeBox)
|
||||||
|
prefs.reference_bold_font = ischecked(self.reference_bold_font)
|
||||||
|
prefs.details_dialog_titlebar_enabled = ischecked(self.details_dialog_titlebar_enabled)
|
||||||
|
prefs.details_dialog_vertical_titlebar = ischecked(self.details_dialog_vertical_titlebar)
|
||||||
|
prefs.details_table_delta_foreground_color = self.details_table_delta_foreground_color.color
|
||||||
|
prefs.result_table_ref_foreground_color = self.result_table_ref_foreground_color.color
|
||||||
|
prefs.result_table_ref_background_color = self.result_table_ref_background_color.color
|
||||||
|
prefs.result_table_delta_foreground_color = self.result_table_delta_foreground_color.color
|
||||||
prefs.destination_type = self.copyMoveDestinationComboBox.currentIndex()
|
prefs.destination_type = self.copyMoveDestinationComboBox.currentIndex()
|
||||||
prefs.custom_command = str(self.customCommandEdit.text())
|
prefs.custom_command = str(self.customCommandEdit.text())
|
||||||
prefs.tableFontSize = self.fontSizeSpinBox.value()
|
prefs.tableFontSize = self.fontSizeSpinBox.value()
|
||||||
|
prefs.tabs_default_pos = ischecked(self.tabs_default_pos)
|
||||||
lang = self.supportedLanguages[self.languageComboBox.currentIndex()]
|
lang = self.supportedLanguages[self.languageComboBox.currentIndex()]
|
||||||
oldlang = self.app.prefs.language
|
oldlang = self.app.prefs.language
|
||||||
if oldlang not in self.supportedLanguages:
|
if oldlang not in self.supportedLanguages:
|
||||||
@@ -222,11 +338,45 @@ class PreferencesDialogBase(QDialog):
|
|||||||
self.app.prefs.language = lang
|
self.app.prefs.language = lang
|
||||||
self._save(prefs, ischecked)
|
self._save(prefs, ischecked)
|
||||||
|
|
||||||
def resetToDefaults(self):
|
def resetToDefaults(self, section_to_update):
|
||||||
self.load(Preferences())
|
self.load(Preferences(), section_to_update)
|
||||||
|
|
||||||
# --- Events
|
# --- Events
|
||||||
def buttonClicked(self, button):
|
def buttonClicked(self, button):
|
||||||
role = self.buttonBox.buttonRole(button)
|
role = self.buttonBox.buttonRole(button)
|
||||||
if role == QDialogButtonBox.ResetRole:
|
if role == QDialogButtonBox.ResetRole:
|
||||||
self.resetToDefaults()
|
current_tab = self.tabwidget.currentWidget()
|
||||||
|
section_to_update = Sections.ALL
|
||||||
|
if current_tab is self.page_general:
|
||||||
|
section_to_update = Sections.GENERAL
|
||||||
|
if current_tab is self.page_display:
|
||||||
|
section_to_update = Sections.DISPLAY
|
||||||
|
self.resetToDefaults(section_to_update)
|
||||||
|
|
||||||
|
|
||||||
|
class ColorPickerButton(QPushButton):
|
||||||
|
def __init__(self, parent):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.parent = parent
|
||||||
|
self.color = None
|
||||||
|
self.clicked.connect(self.onClicked)
|
||||||
|
|
||||||
|
@pyqtSlot()
|
||||||
|
def onClicked(self):
|
||||||
|
color = QColorDialog.getColor(
|
||||||
|
self.color if self.color is not None else Qt.white,
|
||||||
|
self.parent)
|
||||||
|
self.setColor(color)
|
||||||
|
|
||||||
|
def setColor(self, color):
|
||||||
|
size = QSize(16, 16)
|
||||||
|
px = QPixmap(size)
|
||||||
|
if color is None:
|
||||||
|
size.width = 0
|
||||||
|
size.height = 0
|
||||||
|
elif not color.isValid():
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
self.color = color
|
||||||
|
px.fill(color)
|
||||||
|
self.setIcon(QIcon(px))
|
||||||
|
|||||||
@@ -47,9 +47,16 @@ class PrioritizationList(ListviewModel):
|
|||||||
# to know where the drop took place.
|
# to know where the drop took place.
|
||||||
if parentIndex.isValid():
|
if parentIndex.isValid():
|
||||||
return False
|
return False
|
||||||
|
# "When row and column are -1 it means that the dropped data should be considered as
|
||||||
|
# dropped directly on parent."
|
||||||
|
# Moving items to row -1 would put them before the last item. Fix the row to drop the
|
||||||
|
# dragged items after the last item.
|
||||||
|
if row < 0:
|
||||||
|
row = len(self.model) - 1
|
||||||
strMimeData = bytes(mimeData.data(MIME_INDEXES)).decode()
|
strMimeData = bytes(mimeData.data(MIME_INDEXES)).decode()
|
||||||
indexes = list(map(int, strMimeData.split(",")))
|
indexes = list(map(int, strMimeData.split(",")))
|
||||||
self.model.move_indexes(indexes, row)
|
self.model.move_indexes(indexes, row)
|
||||||
|
self.view.selectionModel().clearSelection()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def mimeData(self, indexes):
|
def mimeData(self, indexes):
|
||||||
@@ -84,7 +91,9 @@ class PrioritizeDialog(QDialog):
|
|||||||
self.model.view = self
|
self.model.view = self
|
||||||
|
|
||||||
self.addCriteriaButton.clicked.connect(self.model.add_selected)
|
self.addCriteriaButton.clicked.connect(self.model.add_selected)
|
||||||
|
self.criteriaListView.doubleClicked.connect(self.model.add_selected)
|
||||||
self.removeCriteriaButton.clicked.connect(self.model.remove_selected)
|
self.removeCriteriaButton.clicked.connect(self.model.remove_selected)
|
||||||
|
self.prioritizationListView.doubleClicked.connect(self.model.remove_selected)
|
||||||
self.buttonBox.accepted.connect(self.accept)
|
self.buttonBox.accepted.connect(self.accept)
|
||||||
self.buttonBox.rejected.connect(self.reject)
|
self.buttonBox.rejected.connect(self.reject)
|
||||||
|
|
||||||
@@ -102,6 +111,7 @@ class PrioritizeDialog(QDialog):
|
|||||||
self.promptLabel.setWordWrap(True)
|
self.promptLabel.setWordWrap(True)
|
||||||
self.categoryCombobox = QComboBox()
|
self.categoryCombobox = QComboBox()
|
||||||
self.criteriaListView = QListView()
|
self.criteriaListView = QListView()
|
||||||
|
self.criteriaListView.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
||||||
self.addCriteriaButton = QPushButton(
|
self.addCriteriaButton = QPushButton(
|
||||||
self.style().standardIcon(QStyle.SP_ArrowRight), ""
|
self.style().standardIcon(QStyle.SP_ArrowRight), ""
|
||||||
)
|
)
|
||||||
@@ -113,6 +123,7 @@ class PrioritizeDialog(QDialog):
|
|||||||
self.prioritizationListView.setDragEnabled(True)
|
self.prioritizationListView.setDragEnabled(True)
|
||||||
self.prioritizationListView.setDragDropMode(QAbstractItemView.InternalMove)
|
self.prioritizationListView.setDragDropMode(QAbstractItemView.InternalMove)
|
||||||
self.prioritizationListView.setSelectionBehavior(QAbstractItemView.SelectRows)
|
self.prioritizationListView.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||||
|
self.prioritizationListView.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
||||||
self.buttonBox = QDialogButtonBox()
|
self.buttonBox = QDialogButtonBox()
|
||||||
self.buttonBox.setStandardButtons(QDialogButtonBox.Cancel | QDialogButtonBox.Ok)
|
self.buttonBox.setStandardButtons(QDialogButtonBox.Cancel | QDialogButtonBox.Ok)
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class ResultWindow(QMainWindow):
|
|||||||
def __init__(self, parent, app, **kwargs):
|
def __init__(self, parent, app, **kwargs):
|
||||||
super().__init__(parent, **kwargs)
|
super().__init__(parent, **kwargs)
|
||||||
self.app = app
|
self.app = app
|
||||||
|
self.specific_actions = set()
|
||||||
self._setupUi()
|
self._setupUi()
|
||||||
if app.model.app_mode == AppMode.Picture:
|
if app.model.app_mode == AppMode.Picture:
|
||||||
MODEL_CLASS = ResultsModelPicture
|
MODEL_CLASS = ResultsModelPicture
|
||||||
@@ -169,7 +170,7 @@ class ResultWindow(QMainWindow):
|
|||||||
),
|
),
|
||||||
(
|
(
|
||||||
"actionMarkSelected",
|
"actionMarkSelected",
|
||||||
"",
|
Qt.Key_Space,
|
||||||
"",
|
"",
|
||||||
tr("Mark Selected"),
|
tr("Mark Selected"),
|
||||||
self.markSelectedTriggered,
|
self.markSelectedTriggered,
|
||||||
@@ -207,8 +208,15 @@ class ResultWindow(QMainWindow):
|
|||||||
self.actionDelta.setCheckable(True)
|
self.actionDelta.setCheckable(True)
|
||||||
self.actionPowerMarker.setCheckable(True)
|
self.actionPowerMarker.setCheckable(True)
|
||||||
|
|
||||||
|
if self.app.main_window: # We use tab widgets in this case
|
||||||
|
# Keep track of actions which should only be accessible from this class
|
||||||
|
for action, _, _, _, _ in ACTIONS:
|
||||||
|
self.specific_actions.add(getattr(self, action))
|
||||||
|
|
||||||
def _setupMenu(self):
|
def _setupMenu(self):
|
||||||
self.menubar = QMenuBar()
|
if not self.app.use_tabs:
|
||||||
|
# we are our own QMainWindow, we need our own menu bar
|
||||||
|
self.menubar = QMenuBar() # self.menuBar() works as well here
|
||||||
self.menubar.setGeometry(QRect(0, 0, 630, 22))
|
self.menubar.setGeometry(QRect(0, 0, 630, 22))
|
||||||
self.menuFile = QMenu(self.menubar)
|
self.menuFile = QMenu(self.menubar)
|
||||||
self.menuFile.setTitle(tr("File"))
|
self.menuFile.setTitle(tr("File"))
|
||||||
@@ -223,6 +231,16 @@ class ResultWindow(QMainWindow):
|
|||||||
self.menuHelp = QMenu(self.menubar)
|
self.menuHelp = QMenu(self.menubar)
|
||||||
self.menuHelp.setTitle(tr("Help"))
|
self.menuHelp.setTitle(tr("Help"))
|
||||||
self.setMenuBar(self.menubar)
|
self.setMenuBar(self.menubar)
|
||||||
|
menubar = self.menubar
|
||||||
|
else:
|
||||||
|
# we are part of a tab widget, we populate its window's menubar instead
|
||||||
|
self.menuFile = self.app.main_window.menuFile
|
||||||
|
self.menuMark = self.app.main_window.menuMark
|
||||||
|
self.menuActions = self.app.main_window.menuActions
|
||||||
|
self.menuColumns = self.app.main_window.menuColumns
|
||||||
|
self.menuView = self.app.main_window.menuView
|
||||||
|
self.menuHelp = self.app.main_window.menuHelp
|
||||||
|
menubar = self.app.main_window.menubar
|
||||||
|
|
||||||
self.menuActions.addAction(self.actionDeleteMarked)
|
self.menuActions.addAction(self.actionDeleteMarked)
|
||||||
self.menuActions.addAction(self.actionMoveMarked)
|
self.menuActions.addAction(self.actionMoveMarked)
|
||||||
@@ -242,12 +260,18 @@ class ResultWindow(QMainWindow):
|
|||||||
self.menuMark.addAction(self.actionMarkNone)
|
self.menuMark.addAction(self.actionMarkNone)
|
||||||
self.menuMark.addAction(self.actionInvertMarking)
|
self.menuMark.addAction(self.actionInvertMarking)
|
||||||
self.menuMark.addAction(self.actionMarkSelected)
|
self.menuMark.addAction(self.actionMarkSelected)
|
||||||
|
|
||||||
|
self.menuView.addAction(self.actionDetails)
|
||||||
|
self.menuView.addSeparator()
|
||||||
self.menuView.addAction(self.actionPowerMarker)
|
self.menuView.addAction(self.actionPowerMarker)
|
||||||
self.menuView.addAction(self.actionDelta)
|
self.menuView.addAction(self.actionDelta)
|
||||||
self.menuView.addSeparator()
|
self.menuView.addSeparator()
|
||||||
self.menuView.addAction(self.actionDetails)
|
if not self.app.use_tabs:
|
||||||
self.menuView.addAction(self.app.actionIgnoreList)
|
self.menuView.addAction(self.app.actionIgnoreList)
|
||||||
|
# This also pushes back the options entry to the bottom of the menu
|
||||||
|
self.menuView.addSeparator()
|
||||||
self.menuView.addAction(self.app.actionPreferences)
|
self.menuView.addAction(self.app.actionPreferences)
|
||||||
|
|
||||||
self.menuHelp.addAction(self.app.actionShowHelp)
|
self.menuHelp.addAction(self.app.actionShowHelp)
|
||||||
self.menuHelp.addAction(self.app.actionOpenDebugLog)
|
self.menuHelp.addAction(self.app.actionOpenDebugLog)
|
||||||
self.menuHelp.addAction(self.app.actionAbout)
|
self.menuHelp.addAction(self.app.actionAbout)
|
||||||
@@ -257,15 +281,19 @@ class ResultWindow(QMainWindow):
|
|||||||
self.menuFile.addSeparator()
|
self.menuFile.addSeparator()
|
||||||
self.menuFile.addAction(self.app.actionQuit)
|
self.menuFile.addAction(self.app.actionQuit)
|
||||||
|
|
||||||
self.menubar.addAction(self.menuFile.menuAction())
|
menubar.addAction(self.menuFile.menuAction())
|
||||||
self.menubar.addAction(self.menuMark.menuAction())
|
menubar.addAction(self.menuMark.menuAction())
|
||||||
self.menubar.addAction(self.menuActions.menuAction())
|
menubar.addAction(self.menuActions.menuAction())
|
||||||
self.menubar.addAction(self.menuColumns.menuAction())
|
menubar.addAction(self.menuColumns.menuAction())
|
||||||
self.menubar.addAction(self.menuView.menuAction())
|
menubar.addAction(self.menuView.menuAction())
|
||||||
self.menubar.addAction(self.menuHelp.menuAction())
|
menubar.addAction(self.menuHelp.menuAction())
|
||||||
|
|
||||||
# Columns menu
|
# Columns menu
|
||||||
menu = self.menuColumns
|
menu = self.menuColumns
|
||||||
|
# Avoid adding duplicate actions in tab widget menu in case we recreated
|
||||||
|
# the Result Window instance.
|
||||||
|
if menu.actions():
|
||||||
|
menu.clear()
|
||||||
self._column_actions = []
|
self._column_actions = []
|
||||||
for index, (display, visible) in enumerate(
|
for index, (display, visible) in enumerate(
|
||||||
self.app.model.result_table.columns.menu_items()
|
self.app.model.result_table.columns.menu_items()
|
||||||
@@ -280,7 +308,7 @@ class ResultWindow(QMainWindow):
|
|||||||
action.item_index = -1
|
action.item_index = -1
|
||||||
|
|
||||||
# Action menu
|
# Action menu
|
||||||
actionMenu = QMenu(tr("Actions"), self.menubar)
|
actionMenu = QMenu(tr("Actions"), menubar)
|
||||||
actionMenu.addAction(self.actionDeleteMarked)
|
actionMenu.addAction(self.actionDeleteMarked)
|
||||||
actionMenu.addAction(self.actionMoveMarked)
|
actionMenu.addAction(self.actionMoveMarked)
|
||||||
actionMenu.addAction(self.actionCopyMarked)
|
actionMenu.addAction(self.actionCopyMarked)
|
||||||
@@ -327,6 +355,7 @@ class ResultWindow(QMainWindow):
|
|||||||
self.resultsView.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
self.resultsView.setSelectionMode(QAbstractItemView.ExtendedSelection)
|
||||||
self.resultsView.setSelectionBehavior(QAbstractItemView.SelectRows)
|
self.resultsView.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||||
self.resultsView.setSortingEnabled(True)
|
self.resultsView.setSortingEnabled(True)
|
||||||
|
self.resultsView.setWordWrap(False)
|
||||||
self.resultsView.verticalHeader().setVisible(False)
|
self.resultsView.verticalHeader().setVisible(False)
|
||||||
h = self.resultsView.horizontalHeader()
|
h = self.resultsView.horizontalHeader()
|
||||||
h.setHighlightSections(False)
|
h.setHighlightSections(False)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||||
|
|
||||||
from PyQt5.QtCore import Qt, pyqtSignal, QModelIndex
|
from PyQt5.QtCore import Qt, pyqtSignal, QModelIndex
|
||||||
from PyQt5.QtGui import QBrush, QFont, QFontMetrics, QColor
|
from PyQt5.QtGui import QBrush, QFont, QFontMetrics
|
||||||
from PyQt5.QtWidgets import QTableView
|
from PyQt5.QtWidgets import QTableView
|
||||||
|
|
||||||
from qtlib.table import Table
|
from qtlib.table import Table
|
||||||
@@ -20,14 +20,17 @@ class ResultsModel(Table):
|
|||||||
view.horizontalHeader().setSortIndicator(1, Qt.AscendingOrder)
|
view.horizontalHeader().setSortIndicator(1, Qt.AscendingOrder)
|
||||||
font = view.font()
|
font = view.font()
|
||||||
font.setPointSize(app.prefs.tableFontSize)
|
font.setPointSize(app.prefs.tableFontSize)
|
||||||
self.view.setFont(font)
|
view.setFont(font)
|
||||||
fm = QFontMetrics(font)
|
fm = QFontMetrics(font)
|
||||||
view.verticalHeader().setDefaultSectionSize(fm.height() + 2)
|
view.verticalHeader().setDefaultSectionSize(fm.height() + 2)
|
||||||
|
|
||||||
app.willSavePrefs.connect(self.appWillSavePrefs)
|
app.willSavePrefs.connect(self.appWillSavePrefs)
|
||||||
|
self.prefs = app.prefs
|
||||||
|
|
||||||
def _getData(self, row, column, role):
|
def _getData(self, row, column, role):
|
||||||
if column.name == "marked":
|
if column.name == "marked":
|
||||||
|
if role == Qt.BackgroundRole and row.isref:
|
||||||
|
return QBrush(self.prefs.result_table_ref_background_color)
|
||||||
if role == Qt.CheckStateRole and row.markable:
|
if role == Qt.CheckStateRole and row.markable:
|
||||||
return Qt.Checked if row.marked else Qt.Unchecked
|
return Qt.Checked if row.marked else Qt.Unchecked
|
||||||
return None
|
return None
|
||||||
@@ -36,13 +39,16 @@ class ResultsModel(Table):
|
|||||||
return data[column.name]
|
return data[column.name]
|
||||||
elif role == Qt.ForegroundRole:
|
elif role == Qt.ForegroundRole:
|
||||||
if row.isref:
|
if row.isref:
|
||||||
return QBrush(Qt.blue)
|
return QBrush(self.prefs.result_table_ref_foreground_color)
|
||||||
elif row.is_cell_delta(column.name):
|
elif row.is_cell_delta(column.name):
|
||||||
return QBrush(QColor(255, 142, 40)) # orange
|
return QBrush(self.prefs.result_table_delta_foreground_color)
|
||||||
|
elif role == Qt.BackgroundRole:
|
||||||
|
if row.isref:
|
||||||
|
return QBrush(self.prefs.result_table_ref_background_color)
|
||||||
elif role == Qt.FontRole:
|
elif role == Qt.FontRole:
|
||||||
isBold = row.isref
|
|
||||||
font = QFont(self.view.font())
|
font = QFont(self.view.font())
|
||||||
font.setBold(isBold)
|
if self.prefs.reference_bold_font:
|
||||||
|
font.setBold(row.isref)
|
||||||
return font
|
return font
|
||||||
elif role == Qt.EditRole:
|
elif role == Qt.EditRole:
|
||||||
if column.name == "name":
|
if column.name == "name":
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
# http://www.gnu.org/licenses/gpl-3.0.html
|
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||||
|
|
||||||
from PyQt5.QtCore import QSize
|
from PyQt5.QtCore import QSize
|
||||||
from PyQt5.QtWidgets import QVBoxLayout, QAbstractItemView
|
from PyQt5.QtWidgets import QAbstractItemView
|
||||||
|
|
||||||
from hscommon.trans import trget
|
from hscommon.trans import trget
|
||||||
from ..details_dialog import DetailsDialog as DetailsDialogBase
|
from ..details_dialog import DetailsDialog as DetailsDialogBase
|
||||||
@@ -19,11 +19,8 @@ class DetailsDialog(DetailsDialogBase):
|
|||||||
self.setWindowTitle(tr("Details"))
|
self.setWindowTitle(tr("Details"))
|
||||||
self.resize(502, 186)
|
self.resize(502, 186)
|
||||||
self.setMinimumSize(QSize(200, 0))
|
self.setMinimumSize(QSize(200, 0))
|
||||||
self.verticalLayout = QVBoxLayout(self)
|
|
||||||
self.verticalLayout.setSpacing(0)
|
|
||||||
self.verticalLayout.setContentsMargins(0, 0, 0, 0)
|
|
||||||
self.tableView = DetailsTable(self)
|
self.tableView = DetailsTable(self)
|
||||||
self.tableView.setAlternatingRowColors(True)
|
self.tableView.setAlternatingRowColors(True)
|
||||||
self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows)
|
self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||||
self.tableView.setShowGrid(False)
|
self.tableView.setShowGrid(False)
|
||||||
self.verticalLayout.addWidget(self.tableView)
|
self.setWidget(self.tableView)
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ class PreferencesDialog(PreferencesDialogBase):
|
|||||||
self.widgetsVLayout.addWidget(self.widget)
|
self.widgetsVLayout.addWidget(self.widget)
|
||||||
self._setupBottomPart()
|
self._setupBottomPart()
|
||||||
|
|
||||||
def _load(self, prefs, setchecked):
|
def _load(self, prefs, setchecked, section):
|
||||||
setchecked(self.matchSimilarBox, prefs.match_similar)
|
setchecked(self.matchSimilarBox, prefs.match_similar)
|
||||||
setchecked(self.wordWeightingBox, prefs.word_weighting)
|
setchecked(self.wordWeightingBox, prefs.word_weighting)
|
||||||
setchecked(self.ignoreSmallFilesBox, prefs.ignore_small_files)
|
setchecked(self.ignoreSmallFilesBox, prefs.ignore_small_files)
|
||||||
|
|||||||
367
qt/tabbed_window.py
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
# This software is licensed under the "GPLv3" License as described in the "LICENSE" file,
|
||||||
|
# which should be included with this package. The terms are also available at
|
||||||
|
# http://www.gnu.org/licenses/gpl-3.0.html
|
||||||
|
|
||||||
|
from PyQt5.QtCore import QRect, pyqtSlot, Qt
|
||||||
|
from PyQt5.QtWidgets import (
|
||||||
|
QWidget,
|
||||||
|
QVBoxLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QMainWindow,
|
||||||
|
QTabWidget,
|
||||||
|
QMenu,
|
||||||
|
QTabBar,
|
||||||
|
QStackedWidget,
|
||||||
|
)
|
||||||
|
from hscommon.trans import trget
|
||||||
|
from qtlib.util import moveToScreenCenter, createActions
|
||||||
|
from .directories_dialog import DirectoriesDialog
|
||||||
|
from .result_window import ResultWindow
|
||||||
|
from .ignore_list_dialog import IgnoreListDialog
|
||||||
|
from .exclude_list_dialog import ExcludeListDialog
|
||||||
|
tr = trget("ui")
|
||||||
|
|
||||||
|
|
||||||
|
class TabWindow(QMainWindow):
|
||||||
|
def __init__(self, app, **kwargs):
|
||||||
|
super().__init__(None, **kwargs)
|
||||||
|
self.app = app
|
||||||
|
self.pages = {} # This is currently not used anywhere
|
||||||
|
self.menubar = None
|
||||||
|
self.menuList = set()
|
||||||
|
self.last_index = -1
|
||||||
|
self.previous_widget_actions = set()
|
||||||
|
self._setupUi()
|
||||||
|
self.app.willSavePrefs.connect(self.appWillSavePrefs)
|
||||||
|
|
||||||
|
def _setupActions(self):
|
||||||
|
# (name, shortcut, icon, desc, func)
|
||||||
|
ACTIONS = [
|
||||||
|
(
|
||||||
|
"actionToggleTabs",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
tr("Show tab bar"),
|
||||||
|
self.toggleTabBar,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
createActions(ACTIONS, self)
|
||||||
|
self.actionToggleTabs.setCheckable(True)
|
||||||
|
self.actionToggleTabs.setChecked(True)
|
||||||
|
|
||||||
|
def _setupUi(self):
|
||||||
|
self.setWindowTitle(self.app.NAME)
|
||||||
|
self.resize(640, 480)
|
||||||
|
self.tabWidget = QTabWidget()
|
||||||
|
# self.tabWidget.setTabPosition(QTabWidget.South)
|
||||||
|
self.tabWidget.setContentsMargins(0, 0, 0, 0)
|
||||||
|
# self.tabWidget.setTabBarAutoHide(True)
|
||||||
|
# This gets rid of the annoying margin around the TabWidget:
|
||||||
|
self.tabWidget.setDocumentMode(True)
|
||||||
|
|
||||||
|
self._setupActions()
|
||||||
|
self._setupMenu()
|
||||||
|
# This should be the same as self.centralWidget.setLayout(self.verticalLayout)
|
||||||
|
self.verticalLayout = QVBoxLayout(self.tabWidget)
|
||||||
|
# self.verticalLayout.addWidget(self.tabWidget)
|
||||||
|
self.verticalLayout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self.tabWidget.setTabsClosable(True)
|
||||||
|
self.setCentralWidget(self.tabWidget) # only for QMainWindow
|
||||||
|
|
||||||
|
self.tabWidget.currentChanged.connect(self.updateMenuBar)
|
||||||
|
self.tabWidget.tabCloseRequested.connect(self.onTabCloseRequested)
|
||||||
|
self.updateMenuBar(self.tabWidget.currentIndex())
|
||||||
|
self.restoreGeometry()
|
||||||
|
|
||||||
|
def restoreGeometry(self):
|
||||||
|
if self.app.prefs.mainWindowRect is not None:
|
||||||
|
self.setGeometry(self.app.prefs.mainWindowRect)
|
||||||
|
else:
|
||||||
|
moveToScreenCenter(self)
|
||||||
|
|
||||||
|
def _setupMenu(self):
|
||||||
|
"""Setup the menubar boiler plates which will be filled by the underlying
|
||||||
|
tab's widgets whenever they are instantiated."""
|
||||||
|
self.menubar = self.menuBar() # QMainWindow, similar to just QMenuBar() here
|
||||||
|
# self.setMenuBar(self.menubar) # already set if QMainWindow class
|
||||||
|
self.menubar.setGeometry(QRect(0, 0, 100, 22))
|
||||||
|
self.menuFile = QMenu(self.menubar)
|
||||||
|
self.menuFile.setTitle(tr("File"))
|
||||||
|
self.menuMark = QMenu(self.menubar)
|
||||||
|
self.menuMark.setTitle(tr("Mark"))
|
||||||
|
self.menuActions = QMenu(self.menubar)
|
||||||
|
self.menuActions.setTitle(tr("Actions"))
|
||||||
|
self.menuColumns = QMenu(self.menubar)
|
||||||
|
self.menuColumns.setTitle(tr("Columns"))
|
||||||
|
self.menuView = QMenu(self.menubar)
|
||||||
|
self.menuView.setTitle(tr("View"))
|
||||||
|
self.menuHelp = QMenu(self.menubar)
|
||||||
|
self.menuHelp.setTitle(tr("Help"))
|
||||||
|
|
||||||
|
self.menuView.addAction(self.actionToggleTabs)
|
||||||
|
self.menuView.addSeparator()
|
||||||
|
|
||||||
|
self.menuList.add(self.menuFile)
|
||||||
|
self.menuList.add(self.menuMark)
|
||||||
|
self.menuList.add(self.menuActions)
|
||||||
|
self.menuList.add(self.menuColumns)
|
||||||
|
self.menuList.add(self.menuView)
|
||||||
|
self.menuList.add(self.menuHelp)
|
||||||
|
|
||||||
|
@pyqtSlot(int)
|
||||||
|
def updateMenuBar(self, page_index=-1):
|
||||||
|
if page_index < 0:
|
||||||
|
return
|
||||||
|
current_index = self.getCurrentIndex()
|
||||||
|
active_widget = self.getWidgetAtIndex(current_index)
|
||||||
|
if self.last_index < 0:
|
||||||
|
self.last_index = current_index
|
||||||
|
self.previous_widget_actions = active_widget.specific_actions
|
||||||
|
return
|
||||||
|
|
||||||
|
page_type = type(active_widget).__name__
|
||||||
|
for menu in self.menuList:
|
||||||
|
if menu is self.menuColumns or menu is self.menuActions or menu is self.menuMark:
|
||||||
|
if not isinstance(active_widget, ResultWindow):
|
||||||
|
menu.setEnabled(False)
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
menu.setEnabled(True)
|
||||||
|
for action in menu.actions():
|
||||||
|
if action not in active_widget.specific_actions:
|
||||||
|
if action in self.previous_widget_actions:
|
||||||
|
action.setEnabled(False)
|
||||||
|
continue
|
||||||
|
action.setEnabled(True)
|
||||||
|
|
||||||
|
self.app.directories_dialog.actionShowResultsWindow.setEnabled(
|
||||||
|
False if page_type == "ResultWindow"
|
||||||
|
else self.app.resultWindow is not None)
|
||||||
|
self.app.actionIgnoreList.setEnabled(
|
||||||
|
True if self.app.ignoreListDialog is not None
|
||||||
|
and not page_type == "IgnoreListDialog" else False)
|
||||||
|
self.app.actionDirectoriesWindow.setEnabled(
|
||||||
|
False if page_type == "DirectoriesDialog" else True)
|
||||||
|
self.app.actionExcludeList.setEnabled(
|
||||||
|
True if self.app.excludeListDialog is not None
|
||||||
|
and not page_type == "ExcludeListDialog" else False)
|
||||||
|
|
||||||
|
self.previous_widget_actions = active_widget.specific_actions
|
||||||
|
self.last_index = current_index
|
||||||
|
|
||||||
|
def createPage(self, cls, **kwargs):
|
||||||
|
app = kwargs.get("app", self.app)
|
||||||
|
page = None
|
||||||
|
if cls == "DirectoriesDialog":
|
||||||
|
page = DirectoriesDialog(app)
|
||||||
|
elif cls == "ResultWindow":
|
||||||
|
parent = kwargs.get("parent", self)
|
||||||
|
page = ResultWindow(parent, app)
|
||||||
|
elif cls == "IgnoreListDialog":
|
||||||
|
parent = kwargs.get("parent", self)
|
||||||
|
model = kwargs.get("model")
|
||||||
|
page = IgnoreListDialog(parent, model)
|
||||||
|
page.accepted.connect(self.onDialogAccepted)
|
||||||
|
elif cls == "ExcludeListDialog":
|
||||||
|
app = kwargs.get("app", app)
|
||||||
|
parent = kwargs.get("parent", self)
|
||||||
|
model = kwargs.get("model")
|
||||||
|
page = ExcludeListDialog(app, parent, model)
|
||||||
|
page.accepted.connect(self.onDialogAccepted)
|
||||||
|
self.pages[cls] = page # Not used, might remove
|
||||||
|
return page
|
||||||
|
|
||||||
|
def addTab(self, page, title, switch=False):
|
||||||
|
# Warning: this supposedly takes ownership of the page
|
||||||
|
index = self.tabWidget.addTab(page, title)
|
||||||
|
# index = self.tabWidget.insertTab(-1, page, title)
|
||||||
|
if isinstance(page, DirectoriesDialog):
|
||||||
|
self.tabWidget.tabBar().setTabButton(
|
||||||
|
index, QTabBar.RightSide, None)
|
||||||
|
if switch:
|
||||||
|
self.setCurrentIndex(index)
|
||||||
|
return index
|
||||||
|
|
||||||
|
def showTab(self, page):
|
||||||
|
index = self.indexOfWidget(page)
|
||||||
|
self.setCurrentIndex(index)
|
||||||
|
|
||||||
|
def indexOfWidget(self, widget):
|
||||||
|
return self.tabWidget.indexOf(widget)
|
||||||
|
|
||||||
|
def setCurrentIndex(self, index):
|
||||||
|
return self.tabWidget.setCurrentIndex(index)
|
||||||
|
|
||||||
|
def removeTab(self, index):
|
||||||
|
return self.tabWidget.removeTab(index)
|
||||||
|
|
||||||
|
def isTabVisible(self, index):
|
||||||
|
return self.tabWidget.isTabVisible(index)
|
||||||
|
|
||||||
|
def getCurrentIndex(self):
|
||||||
|
return self.tabWidget.currentIndex()
|
||||||
|
|
||||||
|
def getWidgetAtIndex(self, index):
|
||||||
|
return self.tabWidget.widget(index)
|
||||||
|
|
||||||
|
def getCount(self):
|
||||||
|
return self.tabWidget.count()
|
||||||
|
|
||||||
|
# --- Events
|
||||||
|
def appWillSavePrefs(self):
|
||||||
|
# Right now this is useless since the first spawned dialog inside the
|
||||||
|
# QTabWidget will assign its geometry after restoring it
|
||||||
|
prefs = self.app.prefs
|
||||||
|
prefs.mainWindowIsMaximized = self.isMaximized()
|
||||||
|
prefs.mainWindowRect = self.geometry()
|
||||||
|
|
||||||
|
def closeEvent(self, close_event):
|
||||||
|
# Force closing of our tabbed widgets in reverse order so that the
|
||||||
|
# directories dialog (which usually is at index 0) will be called last
|
||||||
|
for index in range(self.getCount() - 1, -1, -1):
|
||||||
|
self.getWidgetAtIndex(index).closeEvent(close_event)
|
||||||
|
self.appWillSavePrefs()
|
||||||
|
|
||||||
|
@pyqtSlot(int)
|
||||||
|
def onTabCloseRequested(self, index):
|
||||||
|
current_widget = self.getWidgetAtIndex(index)
|
||||||
|
if isinstance(current_widget, DirectoriesDialog):
|
||||||
|
# if we close this one, the application quits. Force user to use the
|
||||||
|
# menu or shortcut. But this is useless if we don't have a button
|
||||||
|
# set up to make a close request anyway. This check could be removed.
|
||||||
|
return
|
||||||
|
self.removeTab(index)
|
||||||
|
|
||||||
|
@pyqtSlot()
|
||||||
|
def onDialogAccepted(self):
|
||||||
|
"""Remove tabbed dialog when Accepted/Done (close button clicked)."""
|
||||||
|
widget = self.sender()
|
||||||
|
index = self.indexOfWidget(widget)
|
||||||
|
if index > -1:
|
||||||
|
self.removeTab(index)
|
||||||
|
|
||||||
|
@pyqtSlot()
|
||||||
|
def toggleTabBar(self):
|
||||||
|
value = self.sender().isChecked()
|
||||||
|
self.actionToggleTabs.setChecked(value)
|
||||||
|
self.tabWidget.tabBar().setVisible(value)
|
||||||
|
|
||||||
|
|
||||||
|
class TabBarWindow(TabWindow):
|
||||||
|
"""Implementation which uses a separate QTabBar and QStackedWidget.
|
||||||
|
The Tab bar is placed next to the menu bar to save real estate."""
|
||||||
|
def __init__(self, app, **kwargs):
|
||||||
|
super().__init__(app, **kwargs)
|
||||||
|
|
||||||
|
def _setupUi(self):
|
||||||
|
self.setWindowTitle(self.app.NAME)
|
||||||
|
self.resize(640, 480)
|
||||||
|
self.tabBar = QTabBar()
|
||||||
|
self.verticalLayout = QVBoxLayout()
|
||||||
|
self.verticalLayout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
self._setupActions()
|
||||||
|
self._setupMenu()
|
||||||
|
|
||||||
|
self.centralWidget = QWidget(self)
|
||||||
|
self.setCentralWidget(self.centralWidget)
|
||||||
|
self.stackedWidget = QStackedWidget()
|
||||||
|
self.centralWidget.setLayout(self.verticalLayout)
|
||||||
|
self.horizontalLayout = QHBoxLayout()
|
||||||
|
self.horizontalLayout.addWidget(self.menubar, 0, Qt.AlignTop)
|
||||||
|
self.horizontalLayout.addWidget(self.tabBar, 0, Qt.AlignTop)
|
||||||
|
self.verticalLayout.addLayout(self.horizontalLayout)
|
||||||
|
self.verticalLayout.addWidget(self.stackedWidget)
|
||||||
|
|
||||||
|
self.tabBar.currentChanged.connect(self.showTabIndex)
|
||||||
|
self.tabBar.tabCloseRequested.connect(self.onTabCloseRequested)
|
||||||
|
|
||||||
|
self.stackedWidget.currentChanged.connect(self.updateMenuBar)
|
||||||
|
self.stackedWidget.widgetRemoved.connect(self.onRemovedWidget)
|
||||||
|
|
||||||
|
self.tabBar.setTabsClosable(True)
|
||||||
|
self.restoreGeometry()
|
||||||
|
|
||||||
|
def addTab(self, page, title, switch=True):
|
||||||
|
stack_index = self.stackedWidget.addWidget(page)
|
||||||
|
self.tabBar.insertTab(stack_index, title)
|
||||||
|
|
||||||
|
if isinstance(page, DirectoriesDialog):
|
||||||
|
self.tabBar.setTabButton(
|
||||||
|
stack_index, QTabBar.RightSide, None)
|
||||||
|
if switch: # switch to the added tab immediately upon creation
|
||||||
|
self.setTabIndex(stack_index)
|
||||||
|
return stack_index
|
||||||
|
|
||||||
|
@pyqtSlot(int)
|
||||||
|
def showTabIndex(self, index):
|
||||||
|
# The tab bar's indices should be aligned with the stackwidget's
|
||||||
|
if index >= 0 and index <= self.stackedWidget.count():
|
||||||
|
self.stackedWidget.setCurrentIndex(index)
|
||||||
|
|
||||||
|
def indexOfWidget(self, widget):
|
||||||
|
# Warning: this may return -1 if widget is not a child of stackedwidget
|
||||||
|
return self.stackedWidget.indexOf(widget)
|
||||||
|
|
||||||
|
def setCurrentIndex(self, tab_index):
|
||||||
|
self.setTabIndex(tab_index)
|
||||||
|
# The signal will handle switching the stackwidget's widget
|
||||||
|
# self.stackedWidget.setCurrentWidget(self.stackedWidget.widget(tab_index))
|
||||||
|
|
||||||
|
def setCurrentWidget(self, widget):
|
||||||
|
"""Sets the current Tab on TabBar for this widget."""
|
||||||
|
self.tabBar.setCurrentIndex(self.indexOfWidget(widget))
|
||||||
|
|
||||||
|
@pyqtSlot(int)
|
||||||
|
def setTabIndex(self, index):
|
||||||
|
if index is None:
|
||||||
|
return
|
||||||
|
self.tabBar.setCurrentIndex(index)
|
||||||
|
|
||||||
|
@pyqtSlot(int)
|
||||||
|
def onRemovedWidget(self, index):
|
||||||
|
self.removeTab(index)
|
||||||
|
|
||||||
|
@pyqtSlot(int)
|
||||||
|
def removeTab(self, index):
|
||||||
|
"""Remove the tab, but not the widget (it should already be removed)"""
|
||||||
|
return self.tabBar.removeTab(index)
|
||||||
|
|
||||||
|
@pyqtSlot(int)
|
||||||
|
def removeWidget(self, widget):
|
||||||
|
return self.stackedWidget.removeWidget(widget)
|
||||||
|
|
||||||
|
def isTabVisible(self, index):
|
||||||
|
return self.tabBar.isTabVisible(index)
|
||||||
|
|
||||||
|
def getCurrentIndex(self):
|
||||||
|
return self.stackedWidget.currentIndex()
|
||||||
|
|
||||||
|
def getWidgetAtIndex(self, index):
|
||||||
|
return self.stackedWidget.widget(index)
|
||||||
|
|
||||||
|
def getCount(self):
|
||||||
|
return self.stackedWidget.count()
|
||||||
|
|
||||||
|
@pyqtSlot()
|
||||||
|
def toggleTabBar(self):
|
||||||
|
value = self.sender().isChecked()
|
||||||
|
self.actionToggleTabs.setChecked(value)
|
||||||
|
self.tabBar.setVisible(value)
|
||||||
|
|
||||||
|
@pyqtSlot(int)
|
||||||
|
def onTabCloseRequested(self, index):
|
||||||
|
target_widget = self.getWidgetAtIndex(index)
|
||||||
|
if isinstance(target_widget, DirectoriesDialog):
|
||||||
|
# On MacOS, the tab has a close button even though we explicitely
|
||||||
|
# set it to None in order to hide it. This should prevent
|
||||||
|
# the "Directories" tab from closing by mistake.
|
||||||
|
return
|
||||||
|
# target_widget.close() # seems unnecessary
|
||||||
|
# Removing the widget should trigger tab removal via the signal
|
||||||
|
self.removeWidget(self.getWidgetAtIndex(index))
|
||||||
|
|
||||||
|
@pyqtSlot()
|
||||||
|
def onDialogAccepted(self):
|
||||||
|
"""Remove tabbed dialog when Accepted/Done (close button clicked)."""
|
||||||
|
widget = self.sender()
|
||||||
|
self.removeWidget(widget)
|
||||||
@@ -42,7 +42,7 @@ class AboutBox(QDialog):
|
|||||||
self.setWindowTitle(
|
self.setWindowTitle(
|
||||||
tr("About {}").format(QCoreApplication.instance().applicationName())
|
tr("About {}").format(QCoreApplication.instance().applicationName())
|
||||||
)
|
)
|
||||||
self.resize(400, 190)
|
self.resize(400, 290)
|
||||||
sizePolicy = QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
sizePolicy = QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||||
sizePolicy.setHorizontalStretch(0)
|
sizePolicy.setHorizontalStretch(0)
|
||||||
sizePolicy.setVerticalStretch(0)
|
sizePolicy.setVerticalStretch(0)
|
||||||
|
|||||||
@@ -58,9 +58,10 @@ class ErrorReportDialog(QDialog):
|
|||||||
self.verticalLayout.addWidget(self.errorTextEdit)
|
self.verticalLayout.addWidget(self.errorTextEdit)
|
||||||
msg = tr(
|
msg = tr(
|
||||||
"Error reports should be reported as Github issues. You can copy the error traceback "
|
"Error reports should be reported as Github issues. You can copy the error traceback "
|
||||||
"above and paste it in a new issue (bonus point if you run a search to make sure the "
|
"above and paste it in a new issue.\n\nPlease make sure to run a search for any already "
|
||||||
"issue doesn't already exist). What usually really helps is if you add a description "
|
"existing issues beforehand. Also make sure to test the very latest version available from the repository, "
|
||||||
"of how you got the error. Thanks!"
|
"since the bug you are experiencing might have already been patched.\n\n"
|
||||||
|
"What usually really helps is if you add a description of how you got the error. Thanks!"
|
||||||
"\n\n"
|
"\n\n"
|
||||||
"Although the application should continue to run after this error, it may be in an "
|
"Although the application should continue to run after this error, it may be in an "
|
||||||
"unstable state, so it is recommended that you restart the application."
|
"unstable state, so it is recommended that you restart the application."
|
||||||
|
|||||||
@@ -6,10 +6,14 @@
|
|||||||
# 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 PyQt5.QtCore import Qt, QSettings, QRect, QObject, pyqtSignal
|
from PyQt5.QtCore import Qt, QSettings, QRect, QObject, pyqtSignal, QStandardPaths
|
||||||
|
from PyQt5.QtWidgets import QDockWidget
|
||||||
|
|
||||||
from hscommon.trans import trget
|
from hscommon.trans import trget
|
||||||
from hscommon.util import tryint
|
from hscommon.util import tryint
|
||||||
|
from hscommon.plat import ISWINDOWS
|
||||||
|
|
||||||
|
from os import path as op
|
||||||
|
|
||||||
tr = trget("qtlib")
|
tr = trget("qtlib")
|
||||||
|
|
||||||
@@ -73,6 +77,17 @@ class Preferences(QObject):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
QObject.__init__(self)
|
QObject.__init__(self)
|
||||||
self.reset()
|
self.reset()
|
||||||
|
# On windows use an ini file in the AppDataLocation instead of registry if possible as it
|
||||||
|
# makes it easier for a user to clear it out when there are issues.
|
||||||
|
if ISWINDOWS:
|
||||||
|
Locations = QStandardPaths.standardLocations(QStandardPaths.AppDataLocation)
|
||||||
|
if Locations:
|
||||||
|
self._settings = QSettings(
|
||||||
|
op.join(Locations[0], "settings.ini"), QSettings.IniFormat
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._settings = QSettings()
|
||||||
|
else:
|
||||||
self._settings = QSettings()
|
self._settings = QSettings()
|
||||||
|
|
||||||
def _load_values(self, settings, get):
|
def _load_values(self, settings, get):
|
||||||
@@ -120,19 +135,27 @@ class Preferences(QObject):
|
|||||||
self._settings.setValue(name, normalize_for_serialization(value))
|
self._settings.setValue(name, normalize_for_serialization(value))
|
||||||
|
|
||||||
def saveGeometry(self, name, widget):
|
def saveGeometry(self, name, widget):
|
||||||
# We save geometry under a 5-sized int array: first item is a flag for whether the widget
|
# We save geometry under a 7-sized int array: first item is a flag
|
||||||
# is maximized and the other 4 are (x, y, w, h).
|
# for whether the widget is maximized, second item is a flag for whether
|
||||||
|
# the widget is docked, third item is a Qt::DockWidgetArea enum value,
|
||||||
|
# and the other 4 are (x, y, w, h).
|
||||||
m = 1 if widget.isMaximized() else 0
|
m = 1 if widget.isMaximized() else 0
|
||||||
|
d = 1 if isinstance(widget, QDockWidget) and not widget.isFloating() else 0
|
||||||
|
area = widget.parent.dockWidgetArea(widget) if d else 0
|
||||||
r = widget.geometry()
|
r = widget.geometry()
|
||||||
rectAsList = [r.x(), r.y(), r.width(), r.height()]
|
rectAsList = [r.x(), r.y(), r.width(), r.height()]
|
||||||
self.set_value(name, [m] + rectAsList)
|
self.set_value(name, [m, d, area] + rectAsList)
|
||||||
|
|
||||||
def restoreGeometry(self, name, widget):
|
def restoreGeometry(self, name, widget):
|
||||||
geometry = self.get_value(name)
|
geometry = self.get_value(name)
|
||||||
if geometry and len(geometry) == 5:
|
if geometry and len(geometry) == 7:
|
||||||
m, x, y, w, h = geometry
|
m, d, area, x, y, w, h = geometry
|
||||||
if m:
|
if m:
|
||||||
widget.setWindowState(Qt.WindowMaximized)
|
widget.setWindowState(Qt.WindowMaximized)
|
||||||
else:
|
else:
|
||||||
r = QRect(x, y, w, h)
|
r = QRect(x, y, w, h)
|
||||||
widget.setGeometry(r)
|
widget.setGeometry(r)
|
||||||
|
if isinstance(widget, QDockWidget):
|
||||||
|
# Inform of the previous dock state and the area used
|
||||||
|
return bool(d), area
|
||||||
|
return False, 0
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
pytest>=2.0.0,<3.0
|
pytest>=5,<6
|
||||||
pytest-monkeyplus>=1.0.0
|
|
||||||
flake8
|
flake8
|
||||||
tox-travis
|
tox-travis
|
||||||
black
|
black
|
||||||
|
pyinstaller>=4.0,<5.0; sys_platform != 'linux'
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
PyQt5 >=5.4,<6.0
|
|
||||||
pypiwin32>=200
|
|
||||||
pyinstaller>=3.4,<4.0
|
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
Send2Trash>=1.3.0
|
Send2Trash>=1.3.0
|
||||||
sphinx>=1.2.2
|
sphinx>=1.2.2
|
||||||
polib>=1.0.4
|
polib>=1.0.4
|
||||||
hsaudiotag3k>=1.1.3
|
hsaudiotag3k>=1.1.3*
|
||||||
|
distro>=1.5.0
|
||||||
|
PyQt5 >=5.4,<6.0; sys_platform != 'linux'
|
||||||
|
pywin32>=200; sys_platform == 'win32'
|
||||||
2
run.py
@@ -16,7 +16,7 @@ from PyQt5.QtWidgets import QApplication
|
|||||||
from hscommon.trans import install_gettext_trans_under_qt
|
from hscommon.trans import install_gettext_trans_under_qt
|
||||||
from qtlib.error_report_dialog import install_excepthook
|
from qtlib.error_report_dialog import install_excepthook
|
||||||
from qtlib.util import setupQtLogging
|
from qtlib.util import setupQtLogging
|
||||||
from qt import dg_rc
|
from qt import dg_rc # noqa: F401
|
||||||
from qt.platform import BASE_PATH
|
from qt.platform import BASE_PATH
|
||||||
from core import __version__, __appname__
|
from core import __version__, __appname__
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
echo "Creating git archive"
|
|
||||||
version=`python -c "from hscommon.build import get_module_version; print(get_module_version('core'))"`
|
|
||||||
dest="dupeguru-src-${version}.tar"
|
|
||||||
|
|
||||||
git archive -o ${dest} HEAD
|
|
||||||
|
|
||||||
# Now, we need to include submodules
|
|
||||||
submodules="cocoalib"
|
|
||||||
|
|
||||||
for submodule in $submodules; do
|
|
||||||
echo "Adding submodule ${submodule} to archive"
|
|
||||||
archive_name="${submodule}.tar"
|
|
||||||
git -C ${submodule} archive -o ../${archive_name} --prefix ${submodule}/ HEAD
|
|
||||||
tar -A ${archive_name} -f ${dest}
|
|
||||||
rm ${archive_name}
|
|
||||||
done
|
|
||||||
|
|
||||||
xz ${dest}
|
|
||||||
echo "Built source package ${dest}.xz"
|
|
||||||
@@ -48,9 +48,9 @@ SetCompressor /SOLID lzma
|
|||||||
!define APPLICENSE "LICENSE" ; License is not in build directory
|
!define APPLICENSE "LICENSE" ; License is not in build directory
|
||||||
!define APPICON "images\dgse_logo.ico" ; nor is the icon
|
!define APPICON "images\dgse_logo.ico" ; nor is the icon
|
||||||
!define DISTDIR "dist"
|
!define DISTDIR "dist"
|
||||||
!define HELPURL "http://www.hardcoded.net/support/"
|
!define HELPURL "https://github.com/arsenetar/dupeguru/issues"
|
||||||
!define UPDATEURL "http://www.hardcoded.net/dupeguru/"
|
!define UPDATEURL "https://dupeguru.voltaicideas.net/"
|
||||||
!define ABOUTURL "http://www.hardcoded.net/dupeguru/"
|
!define ABOUTURL "https://dupeguru.voltaicideas.net/"
|
||||||
|
|
||||||
; Static Defines
|
; Static Defines
|
||||||
!define UNINSTALLREGBASE "Software\Microsoft\Windows\CurrentVersion\Uninstall"
|
!define UNINSTALLREGBASE "Software\Microsoft\Windows\CurrentVersion\Uninstall"
|
||||||
|
|||||||
15
tox.ini
@@ -1,28 +1,21 @@
|
|||||||
[tox]
|
[tox]
|
||||||
envlist = py36,py37,py38
|
envlist = py36,py37,py38,py39
|
||||||
skipsdist = True
|
skipsdist = True
|
||||||
skip_missing_interpreters = True
|
skip_missing_interpreters = True
|
||||||
|
|
||||||
[testenv]
|
[testenv]
|
||||||
whitelist_externals =
|
|
||||||
make
|
|
||||||
setenv =
|
setenv =
|
||||||
PYTHON="{envpython}"
|
PYTHON="{envpython}"
|
||||||
commands =
|
commands =
|
||||||
make modules
|
python build.py --modules
|
||||||
flake8
|
flake8
|
||||||
py.test core hscommon
|
{posargs:py.test core hscommon}
|
||||||
deps =
|
deps =
|
||||||
-r{toxinidir}/requirements.txt
|
-r{toxinidir}/requirements.txt
|
||||||
-r{toxinidir}/requirements-extra.txt
|
-r{toxinidir}/requirements-extra.txt
|
||||||
|
|
||||||
[testenv:WINDOWS]
|
|
||||||
deps =
|
|
||||||
{[testenv]deps}
|
|
||||||
-r{toxinidir}/requirements-windows.txt
|
|
||||||
|
|
||||||
[flake8]
|
[flake8]
|
||||||
exclude = .tox,env,build,hscommon/tests,cocoalib,cocoa,help,./qt/dg_rc.py,qt/run_template.py,cocoa/run_template.py,./run.py,./pkg
|
exclude = .tox,env,build,cocoalib,cocoa,help,./qt/dg_rc.py,cocoa/run_template.py,./pkg
|
||||||
max-line-length = 120
|
max-line-length = 120
|
||||||
ignore = E731,E203,E501,W503
|
ignore = E731,E203,E501,W503
|
||||||
|
|
||||||
|
|||||||