mirror of
https://github.com/arsenetar/dupeguru.git
synced 2026-03-12 03:31:37 +00:00
Compare commits
58 Commits
c8cfa954d5
...
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 | ||
|
|
e533a396fb | ||
|
|
4b4cc04e87 | ||
|
|
07eba09ec2 | ||
|
|
7f19647e4b | ||
|
|
6bc619055e | ||
|
|
680cb581c1 | ||
|
|
32d66cd19b | ||
|
|
735ba2fd0e | ||
|
|
b16b6ecf4d | ||
|
|
2875448c71 | ||
|
|
51b76385c0 | ||
|
|
b9f8dd6ea0 | ||
|
|
6623b04403 | ||
|
|
424d34a7ed | ||
|
|
a55e02b36d | ||
|
|
18c933b4bf | ||
|
|
ea11a566af | ||
|
|
584e9c92d9 | ||
|
|
4a1641e39d | ||
|
|
26d18945b1 | ||
|
|
3382bd5e5b | ||
|
|
9f223f3964 | ||
|
|
2eaf7e7893 | ||
|
|
a26de27c47 | ||
|
|
470307aa3c |
4
.github/ISSUE_TEMPLATE/bug_report.md
vendored
4
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -24,8 +24,8 @@ A clear and concise description of what you expected to happen.
|
|||||||
If applicable, add screenshots to help explain your problem.
|
If applicable, add screenshots to help explain your problem.
|
||||||
|
|
||||||
**Desktop (please complete the following information):**
|
**Desktop (please complete the following information):**
|
||||||
- OS: [e.g. Windows 10 / OSX 10.15 / Ubuntu 20.04]
|
- OS: [e.g. Windows 10 / OSX 10.15 / Ubuntu 20.04 / Arch Linux]
|
||||||
- Version [e.g. 4.0.4]
|
- Version [e.g. 4.1.0]
|
||||||
|
|
||||||
**Additional context**
|
**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.
|
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.
|
||||||
|
|||||||
46
Makefile
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
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`.
|
||||||
|
|||||||
26
Windows.md
26
Windows.md
@@ -2,24 +2,24 @@
|
|||||||
|
|
||||||
### 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
|
$ pip install -r requirements.txt
|
||||||
$ python build.py
|
$ python build.py
|
||||||
@@ -34,21 +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
|
||||||
|
|
||||||
### 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
|
||||||
|
|||||||
@@ -1,2 +1,2 @@
|
|||||||
__version__ = "4.0.4"
|
__version__ = "4.1.0"
|
||||||
__appname__ = "dupeGuru"
|
__appname__ = "dupeGuru"
|
||||||
|
|||||||
11
core/app.py
11
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()
|
||||||
@@ -587,6 +591,9 @@ 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):
|
def load_directories(self, filepath):
|
||||||
# Clear out previous entries
|
# Clear out previous entries
|
||||||
@@ -779,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):
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
499
core/exclude.py
Normal file
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
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
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:
|
||||||
self.prioritizations.append(crit)
|
crit = self.criteria[i]
|
||||||
del crit
|
self.prioritizations.append(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)
|
||||||
|
|||||||
@@ -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
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()
|
||||||
@@ -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"
|
||||||
|
|||||||
BIN
images/dialog-error.png
Normal file
BIN
images/dialog-error.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
images/dupeguru.icns
Executable file
BIN
images/dupeguru.icns
Executable file
Binary file not shown.
53
macos.md
Normal file
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/
|
||||||
37
package.py
37
package.py
@@ -46,11 +46,11 @@ def copy_files_to_package(destpath, packages, with_so):
|
|||||||
# 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 are missing. Have you run \"build.py --loc\"? 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 are missing. Have you run \"build.py --doc\"? Exiting...")
|
print('Help files are missing. Have you run "build.py --doc"? Exiting...')
|
||||||
return
|
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"))
|
||||||
@@ -161,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 are missing. Have you run \"build.py --loc\"? 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 are missing. Have you run \"build.py --doc\"? 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:
|
||||||
@@ -211,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:
|
||||||
@@ -220,6 +247,8 @@ 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 = distro.id()
|
distname = distro.id()
|
||||||
|
|||||||
@@ -8,5 +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"
|
||||||
ln -s "/usr/share/{execname}/dgse_logo_128.png" "$(CURDIR)/debian/{pkgname}/usr/pixmaps/{execname}.png"
|
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}"
|
||||||
|
|||||||
112
qt/app.py
112
qt/app.py
@@ -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
|
||||||
@@ -64,20 +65,25 @@ class DupeGuru(QObject):
|
|||||||
self.recentResults.mustOpenItem.connect(self.model.load_from)
|
self.recentResults.mustOpenItem.connect(self.model.load_from)
|
||||||
self.resultWindow = None
|
self.resultWindow = None
|
||||||
if self.use_tabs:
|
if self.use_tabs:
|
||||||
self.main_window = TabBarWindow(self) if not self.prefs.tabs_default_pos else TabWindow(self)
|
self.main_window = (
|
||||||
|
TabBarWindow(self)
|
||||||
|
if not self.prefs.tabs_default_pos
|
||||||
|
else TabWindow(self)
|
||||||
|
)
|
||||||
parent_window = self.main_window
|
parent_window = self.main_window
|
||||||
self.directories_dialog = self.main_window.createPage("DirectoriesDialog", app=self)
|
self.directories_dialog = self.main_window.createPage(
|
||||||
|
"DirectoriesDialog", app=self
|
||||||
|
)
|
||||||
self.main_window.addTab(
|
self.main_window.addTab(
|
||||||
self.directories_dialog, "Directories", switch=False)
|
self.directories_dialog, "Directories", switch=False
|
||||||
|
)
|
||||||
self.actionDirectoriesWindow.setEnabled(False)
|
self.actionDirectoriesWindow.setEnabled(False)
|
||||||
else: # floating windows only
|
else: # floating windows only
|
||||||
self.main_window = None
|
self.main_window = None
|
||||||
self.directories_dialog = DirectoriesDialog(self)
|
self.directories_dialog = DirectoriesDialog(self)
|
||||||
parent_window = self.directories_dialog
|
parent_window = self.directories_dialog
|
||||||
|
|
||||||
self.progress_window = ProgressWindow(
|
self.progress_window = ProgressWindow(parent_window, self.model.progress_window)
|
||||||
parent_window, self.model.progress_window
|
|
||||||
)
|
|
||||||
self.problemDialog = ProblemDialog(
|
self.problemDialog = ProblemDialog(
|
||||||
parent=parent_window, model=self.model.problem_dialog
|
parent=parent_window, model=self.model.problem_dialog
|
||||||
)
|
)
|
||||||
@@ -85,16 +91,25 @@ class DupeGuru(QObject):
|
|||||||
self.ignoreListDialog = self.main_window.createPage(
|
self.ignoreListDialog = self.main_window.createPage(
|
||||||
"IgnoreListDialog",
|
"IgnoreListDialog",
|
||||||
parent=self.main_window,
|
parent=self.main_window,
|
||||||
model=self.model.ignore_list_dialog)
|
model=self.model.ignore_list_dialog,
|
||||||
self.ignoreListDialog.accepted.connect(self.main_window.onDialogAccepted)
|
)
|
||||||
|
|
||||||
|
self.excludeListDialog = self.main_window.createPage(
|
||||||
|
"ExcludeListDialog",
|
||||||
|
app=self,
|
||||||
|
parent=self.main_window,
|
||||||
|
model=self.model.exclude_list_dialog,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
self.ignoreListDialog = IgnoreListDialog(
|
self.ignoreListDialog = IgnoreListDialog(
|
||||||
parent=parent_window, model=self.model.ignore_list_dialog
|
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(
|
self.deletionOptions = DeletionOptions(
|
||||||
parent=parent_window,
|
parent=parent_window, model=self.model.deletion_options
|
||||||
model=self.model.deletion_options
|
|
||||||
)
|
)
|
||||||
self.about_box = AboutBox(parent_window, self)
|
self.about_box = AboutBox(parent_window, self)
|
||||||
|
|
||||||
@@ -122,7 +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),
|
(
|
||||||
|
"actionDirectoriesWindow",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
tr("Directories"),
|
||||||
|
self.showDirectoriesWindow,
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"actionClearPictureCache",
|
"actionClearPictureCache",
|
||||||
"Ctrl+Shift+P",
|
"Ctrl+Shift+P",
|
||||||
@@ -130,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),
|
||||||
(
|
(
|
||||||
@@ -223,6 +251,9 @@ class DupeGuru(QObject):
|
|||||||
def showResultsWindow(self):
|
def showResultsWindow(self):
|
||||||
if self.resultWindow is not None:
|
if self.resultWindow is not None:
|
||||||
if self.use_tabs:
|
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)
|
self.main_window.showTab(self.resultWindow)
|
||||||
else:
|
else:
|
||||||
self.resultWindow.show()
|
self.resultWindow.show()
|
||||||
@@ -253,9 +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.main_window if self.main_window
|
QMessageBox.warning(
|
||||||
else self.directories_dialog,
|
self.main_window if self.main_window else self.directories_dialog,
|
||||||
"Wrong Locale", msg)
|
"Wrong Locale",
|
||||||
|
msg,
|
||||||
|
)
|
||||||
|
|
||||||
def clearPictureCacheTriggered(self):
|
def clearPictureCacheTriggered(self):
|
||||||
title = tr("Clear Picture Cache")
|
title = tr("Clear Picture Cache")
|
||||||
@@ -267,27 +300,34 @@ class DupeGuru(QObject):
|
|||||||
|
|
||||||
def ignoreListTriggered(self):
|
def ignoreListTriggered(self):
|
||||||
if self.use_tabs:
|
if self.use_tabs:
|
||||||
# Fetch the index in the TabWidget or the StackWidget (depends on class):
|
self.showTriggeredTabbedDialog(self.ignoreListDialog, "Ignore List")
|
||||||
index = self.main_window.indexOfWidget(self.ignoreListDialog)
|
else: # floating windows
|
||||||
if index < 0:
|
|
||||||
# we have not instantiated and populated it in their internal list yet
|
|
||||||
index = self.main_window.addTab(
|
|
||||||
self.ignoreListDialog, "Ignore List", switch=True)
|
|
||||||
# if not self.main_window.tabWidget.isTabVisible(index):
|
|
||||||
self.main_window.setTabVisible(index, True)
|
|
||||||
self.main_window.setCurrentIndex(index)
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
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.main_window if self.main_window else self.directories_dialog,
|
self.main_window if self.main_window else self.directories_dialog, self
|
||||||
self
|
|
||||||
)
|
)
|
||||||
preferences_dialog.load()
|
preferences_dialog.load()
|
||||||
result = preferences_dialog.exec()
|
result = preferences_dialog.exec()
|
||||||
@@ -315,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):
|
||||||
@@ -336,23 +376,25 @@ 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
|
# The object is not deleted entirely, avoid saving its geometry in the future
|
||||||
# self.willSavePrefs.disconnect(self.details_dialog.appWillSavePrefs)
|
# self.willSavePrefs.disconnect(self.details_dialog.appWillSavePrefs)
|
||||||
# or simply delete it on close which is probably cleaner:
|
# or simply delete it on close which is probably cleaner:
|
||||||
self.details_dialog.setAttribute(Qt.WA_DeleteOnClose)
|
self.details_dialog.setAttribute(Qt.WA_DeleteOnClose)
|
||||||
self.details_dialog.close()
|
self.details_dialog.close()
|
||||||
# self.details_dialog.setParent(None) # seems unnecessary
|
# if we don't do the following, Qt will crash when we recreate the Results dialog
|
||||||
|
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:
|
if self.use_tabs:
|
||||||
self.resultWindow = self.main_window.createPage(
|
self.resultWindow = self.main_window.createPage(
|
||||||
"ResultWindow", parent=self.main_window, app=self)
|
"ResultWindow", parent=self.main_window, app=self
|
||||||
self.main_window.addTab(
|
)
|
||||||
self.resultWindow, "Results", switch=False)
|
|
||||||
else: # We don't use a tab widget, regular floating QMainWindow
|
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.directories_dialog._updateActionsState()
|
||||||
|
|||||||
@@ -10,5 +10,6 @@
|
|||||||
<file alias="zoom_out">../images/old_zoom_out.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_original">../images/old_zoom_original.png</file>
|
||||||
<file alias="zoom_best_fit">../images/old_zoom_best_fit.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>
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ class DirectoriesDialog(QMainWindow):
|
|||||||
self.menuView.addAction(self.app.actionDirectoriesWindow)
|
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.addSeparator()
|
||||||
self.menuView.addAction(self.app.actionPreferences)
|
self.menuView.addAction(self.app.actionPreferences)
|
||||||
|
|
||||||
|
|||||||
165
qt/exclude_list_dialog.py
Normal file
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
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()
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from PyQt5.QtWidgets import (
|
|||||||
QAbstractItemView, QSizePolicy, QGridLayout, QSplitter, QFrame)
|
QAbstractItemView, QSizePolicy, QGridLayout, QSplitter, QFrame)
|
||||||
from PyQt5.QtGui import QResizeEvent
|
from PyQt5.QtGui import QResizeEvent
|
||||||
from hscommon.trans import trget
|
from hscommon.trans import trget
|
||||||
from hscommon.plat import ISWINDOWS
|
|
||||||
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 (
|
from .image_viewer import (
|
||||||
@@ -102,14 +101,14 @@ class DetailsDialog(DetailsDialogBase):
|
|||||||
self.vController.updateBothImages()
|
self.vController.updateBothImages()
|
||||||
|
|
||||||
def show(self):
|
def show(self):
|
||||||
# Compute the maximum size the table view can reach
|
# Give the splitter a maximum height to reach. This is assuming that
|
||||||
# Assuming all rows below headers have the same height
|
# all rows below their headers have the same height
|
||||||
self.tableView.setMaximumHeight(
|
self.tableView.setMaximumHeight(
|
||||||
self.tableView.rowHeight(1)
|
self.tableView.rowHeight(1)
|
||||||
* self.tableModel.model.row_count()
|
* self.tableModel.model.row_count()
|
||||||
+ self.tableView.verticalHeader().sectionSize(0)
|
+ self.tableView.verticalHeader().sectionSize(0)
|
||||||
# Windows seems to add a few pixels more to the table somehow
|
# looks like the handle is taken into account by the splitter
|
||||||
+ (5 if ISWINDOWS else 0))
|
+ self.splitter.handle(1).size().height())
|
||||||
DetailsDialogBase.show(self)
|
DetailsDialogBase.show(self)
|
||||||
self.ensure_same_sizes()
|
self.ensure_same_sizes()
|
||||||
self._update()
|
self._update()
|
||||||
|
|||||||
@@ -161,28 +161,31 @@ On MacOS, the tab bar will fill up the window's width instead."))
|
|||||||
self.ui_groupbox.setLayout(layout)
|
self.ui_groupbox.setLayout(layout)
|
||||||
self.displayVLayout.addWidget(self.ui_groupbox)
|
self.displayVLayout.addWidget(self.ui_groupbox)
|
||||||
|
|
||||||
gridlayout = QFormLayout()
|
gridlayout = QGridLayout()
|
||||||
|
gridlayout.setColumnStretch(2, 2)
|
||||||
|
formlayout = QFormLayout()
|
||||||
result_groupbox = QGroupBox("&Result Table")
|
result_groupbox = QGroupBox("&Result Table")
|
||||||
self.fontSizeSpinBox = QSpinBox()
|
self.fontSizeSpinBox = QSpinBox()
|
||||||
self.fontSizeSpinBox.setMinimum(5)
|
self.fontSizeSpinBox.setMinimum(5)
|
||||||
gridlayout.addRow(tr("Font size:"), self.fontSizeSpinBox)
|
formlayout.addRow(tr("Font size:"), self.fontSizeSpinBox)
|
||||||
self._setupAddCheckbox("reference_bold_font",
|
self._setupAddCheckbox("reference_bold_font",
|
||||||
tr("Use bold font for references"))
|
tr("Use bold font for references"))
|
||||||
gridlayout.addRow(self.reference_bold_font)
|
formlayout.addRow(self.reference_bold_font)
|
||||||
|
|
||||||
self.result_table_ref_foreground_color = ColorPickerButton(self)
|
self.result_table_ref_foreground_color = ColorPickerButton(self)
|
||||||
gridlayout.addRow(tr("Reference foreground color:"),
|
formlayout.addRow(tr("Reference foreground color:"),
|
||||||
self.result_table_ref_foreground_color)
|
self.result_table_ref_foreground_color)
|
||||||
self.result_table_ref_background_color = ColorPickerButton(self)
|
self.result_table_ref_background_color = ColorPickerButton(self)
|
||||||
gridlayout.addRow(tr("Reference background color:"),
|
formlayout.addRow(tr("Reference background color:"),
|
||||||
self.result_table_ref_background_color)
|
self.result_table_ref_background_color)
|
||||||
self.result_table_delta_foreground_color = ColorPickerButton(self)
|
self.result_table_delta_foreground_color = ColorPickerButton(self)
|
||||||
gridlayout.addRow(tr("Delta foreground color:"),
|
formlayout.addRow(tr("Delta foreground color:"),
|
||||||
self.result_table_delta_foreground_color)
|
self.result_table_delta_foreground_color)
|
||||||
gridlayout.setLabelAlignment(Qt.AlignLeft)
|
formlayout.setLabelAlignment(Qt.AlignLeft)
|
||||||
|
|
||||||
# Keep same vertical spacing as parent layout for consistency
|
# Keep same vertical spacing as parent layout for consistency
|
||||||
gridlayout.setVerticalSpacing(self.displayVLayout.spacing())
|
formlayout.setVerticalSpacing(self.displayVLayout.spacing())
|
||||||
|
gridlayout.addLayout(formlayout, 0, 0)
|
||||||
result_groupbox.setLayout(gridlayout)
|
result_groupbox.setLayout(gridlayout)
|
||||||
self.displayVLayout.addWidget(result_groupbox)
|
self.displayVLayout.addWidget(result_groupbox)
|
||||||
|
|
||||||
@@ -205,12 +208,13 @@ use the modifier key to drag the floating window around") if ISLINUX else
|
|||||||
self.details_dialog_titlebar_enabled.stateChanged.connect(
|
self.details_dialog_titlebar_enabled.stateChanged.connect(
|
||||||
self.details_dialog_vertical_titlebar.setEnabled)
|
self.details_dialog_vertical_titlebar.setEnabled)
|
||||||
gridlayout = QGridLayout()
|
gridlayout = QGridLayout()
|
||||||
self.details_table_delta_foreground_color_label = QLabel(tr("Delta foreground color:"))
|
formlayout = QFormLayout()
|
||||||
gridlayout.addWidget(self.details_table_delta_foreground_color_label, 4, 0)
|
|
||||||
self.details_table_delta_foreground_color = ColorPickerButton(self)
|
self.details_table_delta_foreground_color = ColorPickerButton(self)
|
||||||
gridlayout.addWidget(self.details_table_delta_foreground_color, 4, 2, 1, 1, Qt.AlignLeft)
|
# Padding on the right side and space between label and widget to keep it somewhat consistent across themes
|
||||||
gridlayout.setColumnStretch(1, 1)
|
gridlayout.setColumnStretch(1, 1)
|
||||||
gridlayout.setColumnStretch(3, 4)
|
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)
|
self.details_groupbox_layout.addLayout(gridlayout)
|
||||||
details_groupbox.setLayout(self.details_groupbox_layout)
|
details_groupbox.setLayout(self.details_groupbox_layout)
|
||||||
self.displayVLayout.addWidget(details_groupbox)
|
self.displayVLayout.addWidget(details_groupbox)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from qtlib.util import moveToScreenCenter, createActions
|
|||||||
from .directories_dialog import DirectoriesDialog
|
from .directories_dialog import DirectoriesDialog
|
||||||
from .result_window import ResultWindow
|
from .result_window import ResultWindow
|
||||||
from .ignore_list_dialog import IgnoreListDialog
|
from .ignore_list_dialog import IgnoreListDialog
|
||||||
|
from .exclude_list_dialog import ExcludeListDialog
|
||||||
tr = trget("ui")
|
tr = trget("ui")
|
||||||
|
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ class TabWindow(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.pages = {}
|
self.pages = {} # This is currently not used anywhere
|
||||||
self.menubar = None
|
self.menubar = None
|
||||||
self.menuList = set()
|
self.menuList = set()
|
||||||
self.last_index = -1
|
self.last_index = -1
|
||||||
@@ -108,7 +109,7 @@ class TabWindow(QMainWindow):
|
|||||||
self.menuList.add(self.menuHelp)
|
self.menuList.add(self.menuHelp)
|
||||||
|
|
||||||
@pyqtSlot(int)
|
@pyqtSlot(int)
|
||||||
def updateMenuBar(self, page_index=None):
|
def updateMenuBar(self, page_index=-1):
|
||||||
if page_index < 0:
|
if page_index < 0:
|
||||||
return
|
return
|
||||||
current_index = self.getCurrentIndex()
|
current_index = self.getCurrentIndex()
|
||||||
@@ -141,6 +142,9 @@ class TabWindow(QMainWindow):
|
|||||||
and not page_type == "IgnoreListDialog" else False)
|
and not page_type == "IgnoreListDialog" else False)
|
||||||
self.app.actionDirectoriesWindow.setEnabled(
|
self.app.actionDirectoriesWindow.setEnabled(
|
||||||
False if page_type == "DirectoriesDialog" else True)
|
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.previous_widget_actions = active_widget.specific_actions
|
||||||
self.last_index = current_index
|
self.last_index = current_index
|
||||||
@@ -157,7 +161,14 @@ class TabWindow(QMainWindow):
|
|||||||
parent = kwargs.get("parent", self)
|
parent = kwargs.get("parent", self)
|
||||||
model = kwargs.get("model")
|
model = kwargs.get("model")
|
||||||
page = IgnoreListDialog(parent, model)
|
page = IgnoreListDialog(parent, model)
|
||||||
self.pages[cls] = page
|
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
|
return page
|
||||||
|
|
||||||
def addTab(self, page, title, switch=False):
|
def addTab(self, page, title, switch=False):
|
||||||
@@ -173,7 +184,6 @@ class TabWindow(QMainWindow):
|
|||||||
|
|
||||||
def showTab(self, page):
|
def showTab(self, page):
|
||||||
index = self.indexOfWidget(page)
|
index = self.indexOfWidget(page)
|
||||||
self.setTabVisible(index, True)
|
|
||||||
self.setCurrentIndex(index)
|
self.setCurrentIndex(index)
|
||||||
|
|
||||||
def indexOfWidget(self, widget):
|
def indexOfWidget(self, widget):
|
||||||
@@ -182,9 +192,6 @@ class TabWindow(QMainWindow):
|
|||||||
def setCurrentIndex(self, index):
|
def setCurrentIndex(self, index):
|
||||||
return self.tabWidget.setCurrentIndex(index)
|
return self.tabWidget.setCurrentIndex(index)
|
||||||
|
|
||||||
def setTabVisible(self, index, value):
|
|
||||||
return self.tabWidget.setTabVisible(index, value)
|
|
||||||
|
|
||||||
def removeTab(self, index):
|
def removeTab(self, index):
|
||||||
return self.tabWidget.removeTab(index)
|
return self.tabWidget.removeTab(index)
|
||||||
|
|
||||||
@@ -202,7 +209,7 @@ class TabWindow(QMainWindow):
|
|||||||
|
|
||||||
# --- Events
|
# --- Events
|
||||||
def appWillSavePrefs(self):
|
def appWillSavePrefs(self):
|
||||||
# Right now this is useless since the first spawn dialog inside the
|
# Right now this is useless since the first spawned dialog inside the
|
||||||
# QTabWidget will assign its geometry after restoring it
|
# QTabWidget will assign its geometry after restoring it
|
||||||
prefs = self.app.prefs
|
prefs = self.app.prefs
|
||||||
prefs.mainWindowIsMaximized = self.isMaximized()
|
prefs.mainWindowIsMaximized = self.isMaximized()
|
||||||
@@ -223,14 +230,11 @@ class TabWindow(QMainWindow):
|
|||||||
# menu or shortcut. But this is useless if we don't have a button
|
# 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.
|
# set up to make a close request anyway. This check could be removed.
|
||||||
return
|
return
|
||||||
current_widget.close()
|
|
||||||
self.setTabVisible(index, False)
|
|
||||||
# self.tabWidget.widget(index).hide()
|
|
||||||
self.removeTab(index)
|
self.removeTab(index)
|
||||||
|
|
||||||
@pyqtSlot()
|
@pyqtSlot()
|
||||||
def onDialogAccepted(self):
|
def onDialogAccepted(self):
|
||||||
"""Remove tabbed dialog when Accepted/Done."""
|
"""Remove tabbed dialog when Accepted/Done (close button clicked)."""
|
||||||
widget = self.sender()
|
widget = self.sender()
|
||||||
index = self.indexOfWidget(widget)
|
index = self.indexOfWidget(widget)
|
||||||
if index > -1:
|
if index > -1:
|
||||||
@@ -268,7 +272,7 @@ class TabBarWindow(TabWindow):
|
|||||||
self.verticalLayout.addLayout(self.horizontalLayout)
|
self.verticalLayout.addLayout(self.horizontalLayout)
|
||||||
self.verticalLayout.addWidget(self.stackedWidget)
|
self.verticalLayout.addWidget(self.stackedWidget)
|
||||||
|
|
||||||
self.tabBar.currentChanged.connect(self.showWidget)
|
self.tabBar.currentChanged.connect(self.showTabIndex)
|
||||||
self.tabBar.tabCloseRequested.connect(self.onTabCloseRequested)
|
self.tabBar.tabCloseRequested.connect(self.onTabCloseRequested)
|
||||||
|
|
||||||
self.stackedWidget.currentChanged.connect(self.updateMenuBar)
|
self.stackedWidget.currentChanged.connect(self.updateMenuBar)
|
||||||
@@ -278,50 +282,48 @@ class TabBarWindow(TabWindow):
|
|||||||
self.restoreGeometry()
|
self.restoreGeometry()
|
||||||
|
|
||||||
def addTab(self, page, title, switch=True):
|
def addTab(self, page, title, switch=True):
|
||||||
stack_index = self.stackedWidget.insertWidget(-1, page)
|
stack_index = self.stackedWidget.addWidget(page)
|
||||||
tab_index = self.tabBar.addTab(title)
|
self.tabBar.insertTab(stack_index, title)
|
||||||
|
|
||||||
if isinstance(page, DirectoriesDialog):
|
if isinstance(page, DirectoriesDialog):
|
||||||
self.tabBar.setTabButton(
|
self.tabBar.setTabButton(
|
||||||
tab_index, QTabBar.RightSide, None)
|
stack_index, QTabBar.RightSide, None)
|
||||||
if switch: # switch to the added tab immediately upon creation
|
if switch: # switch to the added tab immediately upon creation
|
||||||
self.setTabIndex(tab_index)
|
self.setTabIndex(stack_index)
|
||||||
self.stackedWidget.setCurrentWidget(page)
|
|
||||||
return stack_index
|
return stack_index
|
||||||
|
|
||||||
@pyqtSlot(int)
|
@pyqtSlot(int)
|
||||||
def showWidget(self, index):
|
def showTabIndex(self, index):
|
||||||
if index >= 0 and index <= self.stackedWidget.count() - 1:
|
# The tab bar's indices should be aligned with the stackwidget's
|
||||||
|
if index >= 0 and index <= self.stackedWidget.count():
|
||||||
self.stackedWidget.setCurrentIndex(index)
|
self.stackedWidget.setCurrentIndex(index)
|
||||||
# if not self.tabBar.isTabVisible(index):
|
|
||||||
self.setTabVisible(index, True)
|
|
||||||
|
|
||||||
def indexOfWidget(self, widget):
|
def indexOfWidget(self, widget):
|
||||||
# Warning: this may return -1 if widget is not a child of stackedwidget
|
# Warning: this may return -1 if widget is not a child of stackedwidget
|
||||||
return self.stackedWidget.indexOf(widget)
|
return self.stackedWidget.indexOf(widget)
|
||||||
|
|
||||||
def setCurrentIndex(self, tab_index):
|
def setCurrentIndex(self, tab_index):
|
||||||
# The signal will handle switching the stackwidget's widget
|
|
||||||
self.setTabIndex(tab_index)
|
self.setTabIndex(tab_index)
|
||||||
|
# The signal will handle switching the stackwidget's widget
|
||||||
# self.stackedWidget.setCurrentWidget(self.stackedWidget.widget(tab_index))
|
# 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)
|
@pyqtSlot(int)
|
||||||
def setTabIndex(self, index):
|
def setTabIndex(self, index):
|
||||||
if index is None:
|
if index is None:
|
||||||
return
|
return
|
||||||
self.tabBar.setCurrentIndex(index)
|
self.tabBar.setCurrentIndex(index)
|
||||||
|
|
||||||
def setTabVisible(self, index, value):
|
|
||||||
return self.tabBar.setTabVisible(index, value)
|
|
||||||
|
|
||||||
@pyqtSlot(int)
|
@pyqtSlot(int)
|
||||||
def onRemovedWidget(self, index):
|
def onRemovedWidget(self, index):
|
||||||
self.removeTab(index)
|
self.removeTab(index)
|
||||||
|
|
||||||
@pyqtSlot(int)
|
@pyqtSlot(int)
|
||||||
def removeTab(self, index):
|
def removeTab(self, index):
|
||||||
# No need to remove the widget here:
|
"""Remove the tab, but not the widget (it should already be removed)"""
|
||||||
# self.stackedWidget.removeWidget(self.stackedWidget.widget(index))
|
|
||||||
return self.tabBar.removeTab(index)
|
return self.tabBar.removeTab(index)
|
||||||
|
|
||||||
@pyqtSlot(int)
|
@pyqtSlot(int)
|
||||||
@@ -348,13 +350,18 @@ class TabBarWindow(TabWindow):
|
|||||||
|
|
||||||
@pyqtSlot(int)
|
@pyqtSlot(int)
|
||||||
def onTabCloseRequested(self, index):
|
def onTabCloseRequested(self, index):
|
||||||
current_widget = self.getWidgetAtIndex(index)
|
target_widget = self.getWidgetAtIndex(index)
|
||||||
if isinstance(current_widget, DirectoriesDialog):
|
if isinstance(target_widget, DirectoriesDialog):
|
||||||
# On MacOS, the tab has a close button even though we explicitely
|
# On MacOS, the tab has a close button even though we explicitely
|
||||||
# set it to None in order to hide it. This should prevent
|
# set it to None in order to hide it. This should prevent
|
||||||
# the "Directories" tab from closing by mistake.
|
# the "Directories" tab from closing by mistake.
|
||||||
return
|
return
|
||||||
current_widget.close()
|
# target_widget.close() # seems unnecessary
|
||||||
self.stackedWidget.removeWidget(current_widget)
|
# Removing the widget should trigger tab removal via the signal
|
||||||
# In this case the signal will take care of the tab itself after removing the widget
|
self.removeWidget(self.getWidgetAtIndex(index))
|
||||||
# self.removeTab(index)
|
|
||||||
|
@pyqtSlot()
|
||||||
|
def onDialogAccepted(self):
|
||||||
|
"""Remove tabbed dialog when Accepted/Done (close button clicked)."""
|
||||||
|
widget = self.sender()
|
||||||
|
self.removeWidget(widget)
|
||||||
|
|||||||
@@ -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,11 +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 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")
|
||||||
|
|
||||||
@@ -74,7 +77,18 @@ class Preferences(QObject):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
QObject.__init__(self)
|
QObject.__init__(self)
|
||||||
self.reset()
|
self.reset()
|
||||||
self._settings = QSettings()
|
# 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()
|
||||||
|
|
||||||
def _load_values(self, settings, get):
|
def _load_values(self, settings, get):
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ pytest>=5,<6
|
|||||||
flake8
|
flake8
|
||||||
tox-travis
|
tox-travis
|
||||||
black
|
black
|
||||||
pyinstaller>=4.0,<5.0; sys_platform == 'win32'
|
pyinstaller>=4.0,<5.0; sys_platform != 'linux'
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
PyQt5 >=5.4,<6.0
|
|
||||||
pywin32>=200
|
|
||||||
pyinstaller>=3.4,<4.0
|
|
||||||
@@ -1,7 +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
|
distro>=1.5.0
|
||||||
PyQt5 >=5.4,<6.0; sys_platform == 'win32'
|
PyQt5 >=5.4,<6.0; sys_platform != 'linux'
|
||||||
pywin32>=200; sys_platform == 'win32'
|
pywin32>=200; sys_platform == 'win32'
|
||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user